Skip to content
Open
5 changes: 5 additions & 0 deletions openhands_cli/tui/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
ConversationManager,
CreateConversation,
PauseConversation,
SetAgentMode,
SetConfirmationPolicy,
SwitchConfirmed,
SwitchConversation,
Expand All @@ -15,6 +16,7 @@
ShowConfirmationPanel,
)
from openhands_cli.tui.core.state import (
AgentMode,
ConfirmationRequired,
ConversationContainer,
ConversationFinished,
Expand All @@ -27,6 +29,8 @@
"ConversationContainer",
"ConversationFinished",
"ConfirmationRequired",
# Types
"AgentMode",
# Manager
"ConversationManager",
# Operation Messages (input to ConversationManager)
Expand All @@ -36,6 +40,7 @@
"PauseConversation",
"CondenseConversation",
"SetConfirmationPolicy",
"SetAgentMode",
"SwitchConfirmed",
# Events (App ↔ ConversationManager)
"RequestSwitchConfirmation",
Expand Down
4 changes: 4 additions & 0 deletions openhands_cli/tui/core/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
COMMANDS = [
DropdownItem(main="/help - Display available commands"),
DropdownItem(main="/new - Start a new conversation"),
DropdownItem(main="/plan - Switch to Planning Mode (generate PLAN.md)"),
DropdownItem(main="/code - Switch to Code Mode (normal execution)"),
DropdownItem(main="/history - Toggle conversation history"),
DropdownItem(main="/settings - Open settings"),
DropdownItem(main="/confirm - Configure confirmation settings"),
Expand Down Expand Up @@ -73,6 +75,8 @@ def show_help(scroll_view: VerticalScroll) -> None:

[{secondary}]/help[/{secondary}] - Display available commands
[{secondary}]/new[/{secondary}] - Start a new conversation
[{secondary}]/plan[/{secondary}] - Switch to Planning Mode (generate PLAN.md)
[{secondary}]/code[/{secondary}] - Switch to Code Mode (normal execution)
[{secondary}]/history[/{secondary}] - Toggle conversation history
[{secondary}]/settings[/{secondary}] - Open settings
[{secondary}]/confirm[/{secondary}] - Configure confirmation settings
Expand Down
65 changes: 64 additions & 1 deletion openhands_cli/tui/core/conversation_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
from textual.containers import Container
from textual.message import Message

from openhands.sdk.security.confirmation_policy import ConfirmationPolicyBase
from openhands.sdk.security.confirmation_policy import (
AlwaysConfirm,
ConfirmationPolicyBase,
)
from openhands_cli.conversations.protocols import ConversationStore
from openhands_cli.tui.core.confirmation_flow_controller import (
ConfirmationFlowController,
Expand Down Expand Up @@ -81,6 +84,18 @@ def __init__(self, policy: ConfirmationPolicyBase) -> None:
self.policy = policy


class SetAgentMode(Message):
"""Request to change the agent operating mode.

Args:
mode: The agent mode to set ('plan' or 'code')
"""

def __init__(self, mode: str) -> None:
super().__init__()
self.mode = mode


class SwitchConfirmed(Message):
"""Internal message: User confirmed switch in modal."""

Expand Down Expand Up @@ -277,6 +292,54 @@ def _on_set_confirmation_policy(self, event: SetConfirmationPolicy) -> None:
event.stop()
self._policy_service.set_policy(event.policy)

@on(SetAgentMode)
def _on_set_agent_mode(self, event: SetAgentMode) -> None:
"""Handle request to change agent operating mode.

When entering plan mode:
1. Save the current confirmation policy
2. Switch to AlwaysConfirm so the user must approve every action
3. Update the UI mode indicator

When returning to code mode:
1. Restore the previously saved confirmation policy
2. Clear the mode indicator
"""
event.stop()
mode = event.mode
if mode not in ("plan", "code"):
self.notify(
f"Invalid mode: {mode}. Use 'plan' or 'code'.",
title="Mode Error",
severity="error",
)
return

if mode == "plan":
# Save current policy and enforce AlwaysConfirm as a safety net.
# Even if the agent ignores prompt instructions, the user still
# gets a confirmation dialog before any action executes.
if not self._state.has_pre_plan_policy:
self._state.save_pre_plan_policy(
self._state.confirmation_policy
)
self._policy_service.set_policy(AlwaysConfirm())
self._state.set_agent_mode("plan")
self.notify(
"Planning Mode β€” all actions require your approval",
severity="information",
)
else:
# Restore the user's previous confirmation policy
saved_policy = self._state.restore_pre_plan_policy()
if saved_policy is not None:
self._policy_service.set_policy(saved_policy)
self._state.set_agent_mode("code")
self.notify(
"Code Mode β€” confirmation policy restored",
severity="information",
)

@on(ShowConfirmationPanel)
def _on_show_confirmation_panel(self, event: ShowConfirmationPanel) -> None:
event.stop()
Expand Down
54 changes: 52 additions & 2 deletions openhands_cli/tui/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
This module provides:
- ConversationContainer: UI container that owns and exposes reactive state
- ConversationFinished: Message emitted when conversation finishes
- AgentMode: Literal type for agent operating modes ("plan" or "code")

Architecture:
ConversationContainer holds reactive properties that UI components bind to.
Expand All @@ -27,7 +28,7 @@
import threading
import time
import uuid
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Literal

from textual.app import ComposeResult
from textual.containers import Container
Expand All @@ -44,6 +45,11 @@
from openhands_cli.stores import CriticSettings


# Agent operating mode: "plan" focuses on generating PLAN.md without code execution
# "code" is the default mode for normal code-writing and execution
AgentMode = Literal["plan", "code"]


if TYPE_CHECKING:
from rich.text import Text

Expand Down Expand Up @@ -137,6 +143,10 @@ class ConversationContainer(Container):
refinement_iteration: var[int] = var(0)
"""Current refinement iteration within a user turn. Resets on new user message."""

# ---- Agent Mode ----
agent_mode: var[AgentMode] = var("code")
"""Agent operating mode: 'plan' for planning-only or 'code' for normal execution."""

def __init__(
self,
initial_confirmation_policy: ConfirmationPolicyBase | None = None,
Expand All @@ -149,6 +159,10 @@ def __init__(
self._conversation_state: ConversationStateProtocol | None = None
self._timer = None

# Store the user's confirmation policy before plan mode overrides it.
# None means plan mode hasn't overridden the policy.
self._pre_plan_confirmation_policy: ConfirmationPolicyBase | None = None

super().__init__(id="conversation_state", **kwargs)

if initial_confirmation_policy is not None:
Expand Down Expand Up @@ -199,12 +213,14 @@ def compose(self) -> ComposeResult:
running=ConversationContainer.running,
elapsed_seconds=ConversationContainer.elapsed_seconds,
critic_settings=ConversationContainer.critic_settings,
agent_mode=ConversationContainer.agent_mode,
)
yield InputField(
placeholder="Type your message, @mention a file, or / for commands"
).data_bind(
conversation_id=ConversationContainer.conversation_id,
pending_action_count=ConversationContainer.pending_action_count,
agent_mode=ConversationContainer.agent_mode,
)
yield InfoStatusLine().data_bind(
running=ConversationContainer.running,
Expand Down Expand Up @@ -386,6 +402,15 @@ def set_refinement_iteration(self, iteration: int) -> None:
"""
self._schedule_update("refinement_iteration", iteration)

def set_agent_mode(self, mode: AgentMode) -> None:
"""Set the agent operating mode. Thread-safe.

Args:
mode: 'plan' for planning-only mode (generates PLAN.md),
'code' for normal code execution mode.
"""
self._schedule_update("agent_mode", mode)

# ---- Conversation Attachment (for metrics) ----

def attach_conversation_state(
Expand All @@ -410,11 +435,34 @@ def _update_metrics(self) -> None:
combined_metrics = stats.get_combined_metrics()
self.metrics = combined_metrics

def save_pre_plan_policy(self, policy: ConfirmationPolicyBase) -> None:
"""Save the user's confirmation policy before plan mode overrides it.

Called by ConversationManager when entering plan mode.
"""
self._pre_plan_confirmation_policy = policy

def restore_pre_plan_policy(self) -> ConfirmationPolicyBase | None:
"""Restore and clear the saved confirmation policy from before plan mode.

Returns:
The saved policy, or None if not in plan mode / no policy was saved.
"""
policy = self._pre_plan_confirmation_policy
self._pre_plan_confirmation_policy = None
return policy

@property
def has_pre_plan_policy(self) -> bool:
"""Check if a pre-plan confirmation policy is saved."""
return self._pre_plan_confirmation_policy is not None

def reset_conversation_state(self) -> None:
"""Reset state for a new conversation.

Resets: running, elapsed_seconds, metrics, conversation_title,
pending_action_count, refinement_iteration, internal state.
pending_action_count, refinement_iteration, agent_mode,
internal state.
Preserves: confirmation_policy (persists across conversations),
conversation_id (set explicitly when switching).

Expand All @@ -426,6 +474,8 @@ def reset_conversation_state(self) -> None:
self.conversation_title = None
self.pending_action_count = 0
self.refinement_iteration = 0
self.agent_mode = "code"
self.switch_confirmation_target = None
self._conversation_start_time = None
self._conversation_state = None
self._pre_plan_confirmation_policy = None
65 changes: 62 additions & 3 deletions openhands_cli/tui/core/user_message_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,41 @@
from openhands_cli.tui.core.state import ConversationContainer


# Planning mode instructions prepended to user messages when in plan mode.
# These work in tandem with AlwaysConfirm policy β€” the prompt guides the agent
# toward planning behavior, while AlwaysConfirm provides a hard safety net
# requiring user approval before any action executes.
PLANNING_MODE_INSTRUCTIONS = """
<PLANNING_MODE>
You are currently in PLANNING MODE. This is a read-only mode.

STRICTLY FORBIDDEN actions:
- DO NOT use CmdRunAction (terminal/shell commands)
- DO NOT use FileWriteAction or FileEditAction (file modifications)
- DO NOT use BrowseInteractiveAction
- DO NOT execute, compile, or run any code
- The ONLY file you may create or edit is PLAN.md in the workspace root

Your role in this mode:
1. Ask clarifying questions to understand requirements fully
2. Analyze the existing codebase using read-only tools (file reading, search)
3. Create a structured PLAN.md with:
- Problem statement and requirements
- Proposed approach and architecture
- Step-by-step implementation plan
- Potential challenges and mitigations
- Success criteria and testing approach
4. Identify edge cases, dependencies, and constraints
5. Present the plan and ask the user to switch to /code mode when ready

IMPORTANT: Even if the user asks you to "just do it" or "go ahead," stay in
planning mode. They must explicitly use /code to enable execution.
</PLANNING_MODE>

User's request:
"""


class UserMessageController:
def __init__(
self,
Expand Down Expand Up @@ -43,13 +78,33 @@ async def handle_user_message(self, content: str) -> None:

runner = self._runners.get_or_create(self._state.conversation_id)

# Render user message to UI
# Render user message to UI (show original content without instructions)
runner.visualizer.render_user_message(content)

# Update conversation title (for history panel)
self._state.set_conversation_title(content)

await self._process_message(runner, content)
# Apply planning mode instructions if in plan mode
message_content = self._apply_mode_instructions(content)

await self._process_message(runner, message_content)

def _apply_mode_instructions(self, content: str) -> str:
"""Apply mode-specific instructions to the message content.

In planning mode, prepends instructions that guide the agent to focus
on understanding requirements and generating a PLAN.md file instead
of executing code.

Args:
content: The original user message content.

Returns:
The message content with mode-specific instructions applied.
"""
if self._state.agent_mode == "plan":
return f"{PLANNING_MODE_INSTRUCTIONS}{content}"
return content

async def handle_refinement_message(self, content: str) -> None:
"""Handle a system-generated refinement message.
Expand All @@ -72,7 +127,11 @@ async def handle_refinement_message(self, content: str) -> None:

# Note: Don't update conversation title for refinement messages

await self._process_message(runner, content)
# Apply planning mode instructions to refinements too β€” the agent
# must stay in planning mode even through system-generated follow-ups.
message_content = self._apply_mode_instructions(content)

await self._process_message(runner, message_content)

async def _process_message(self, runner: ConversationRunner, content: str) -> None:
"""Process a message by queuing or starting a new run.
Expand Down
18 changes: 18 additions & 0 deletions openhands_cli/tui/widgets/input_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ def _on_slash_command_submitted(self, event: SlashCommandSubmitted) -> None:
self._command_help()
case "new":
self._command_new()
case "plan":
self._command_plan()
case "code":
self._command_code()
case "history":
self._command_history()
case "settings":
Expand Down Expand Up @@ -113,6 +117,20 @@ def _command_new(self) -> None:
# Message bubbles up to ConversationManager (ancestor)
self.post_message(CreateConversation())

def _command_plan(self) -> None:
"""Handle the /plan command to switch to Planning Mode."""
from openhands_cli.tui.core import SetAgentMode

# Message bubbles up to ConversationManager (ancestor)
self.post_message(SetAgentMode("plan"))

def _command_code(self) -> None:
"""Handle the /code command to switch to Code Mode."""
from openhands_cli.tui.core import SetAgentMode

# Message bubbles up to ConversationManager (ancestor)
self.post_message(SetAgentMode("code"))

def _command_history(self) -> None:
"""Handle the /history command to show conversation history panel."""

Expand Down
Loading
Loading