diff --git a/openhands_cli/tui/core/__init__.py b/openhands_cli/tui/core/__init__.py index 9e7e23f45..f0d3e09c9 100644 --- a/openhands_cli/tui/core/__init__.py +++ b/openhands_cli/tui/core/__init__.py @@ -5,6 +5,7 @@ ConversationManager, CreateConversation, PauseConversation, + SetAgentMode, SetConfirmationPolicy, SwitchConfirmed, SwitchConversation, @@ -15,6 +16,7 @@ ShowConfirmationPanel, ) from openhands_cli.tui.core.state import ( + AgentMode, ConfirmationRequired, ConversationContainer, ConversationFinished, @@ -27,6 +29,8 @@ "ConversationContainer", "ConversationFinished", "ConfirmationRequired", + # Types + "AgentMode", # Manager "ConversationManager", # Operation Messages (input to ConversationManager) @@ -36,6 +40,7 @@ "PauseConversation", "CondenseConversation", "SetConfirmationPolicy", + "SetAgentMode", "SwitchConfirmed", # Events (App ↔ ConversationManager) "RequestSwitchConfirmation", diff --git a/openhands_cli/tui/core/commands.py b/openhands_cli/tui/core/commands.py index 41aa2bf2a..76407b73c 100644 --- a/openhands_cli/tui/core/commands.py +++ b/openhands_cli/tui/core/commands.py @@ -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"), @@ -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 diff --git a/openhands_cli/tui/core/conversation_manager.py b/openhands_cli/tui/core/conversation_manager.py index 421553562..d1378c4fc 100644 --- a/openhands_cli/tui/core/conversation_manager.py +++ b/openhands_cli/tui/core/conversation_manager.py @@ -81,6 +81,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.""" @@ -277,6 +289,26 @@ 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.""" + 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 + + self._state.set_agent_mode(mode) # type: ignore[arg-type] + mode_display = "Planning" if mode == "plan" else "Code" + self.notify( + f"Switched to {mode_display} Mode", + severity="information", + ) + @on(ShowConfirmationPanel) def _on_show_confirmation_panel(self, event: ShowConfirmationPanel) -> None: event.stop() diff --git a/openhands_cli/tui/core/state.py b/openhands_cli/tui/core/state.py index 9b8e49cf7..769f8784a 100644 --- a/openhands_cli/tui/core/state.py +++ b/openhands_cli/tui/core/state.py @@ -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. @@ -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 @@ -43,6 +44,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 @@ -136,6 +142,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, @@ -198,12 +208,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, @@ -395,6 +407,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( diff --git a/openhands_cli/tui/core/user_message_controller.py b/openhands_cli/tui/core/user_message_controller.py index 1484ff757..98d54ce1a 100644 --- a/openhands_cli/tui/core/user_message_controller.py +++ b/openhands_cli/tui/core/user_message_controller.py @@ -13,6 +13,29 @@ from openhands_cli.tui.core.state import ConversationContainer +# Planning mode instructions prepended to user messages when in plan mode +PLANNING_MODE_INSTRUCTIONS = """ + +You are currently in PLANNING MODE. In this mode: + +1. **DO NOT execute any code** - Do not use terminal, file_editor, or tools +2. **Focus on understanding** - Ask clarifying questions about requirements +3. **Create a PLAN.md file** - Generate a structured plan document with: + - Problem statement and requirements + - Proposed approach and architecture + - Step-by-step implementation plan + - Potential challenges and mitigations + - Success criteria and testing approach +4. **Be thorough** - Identify edge cases, dependencies, and constraints +5. **Seek confirmation** - Ask user to confirm before they switch to Code Mode + +When you've gathered enough information, create PLAN.md in the workspace root. + + +User's request: +""" + + class UserMessageController: def __init__( self, @@ -43,13 +66,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. diff --git a/openhands_cli/tui/widgets/input_area.py b/openhands_cli/tui/widgets/input_area.py index 1a38bd614..b802bc379 100644 --- a/openhands_cli/tui/widgets/input_area.py +++ b/openhands_cli/tui/widgets/input_area.py @@ -78,6 +78,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": @@ -112,6 +116,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.""" diff --git a/openhands_cli/tui/widgets/status_line.py b/openhands_cli/tui/widgets/status_line.py index 1487e87a2..7c9d75591 100644 --- a/openhands_cli/tui/widgets/status_line.py +++ b/openhands_cli/tui/widgets/status_line.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from typing import TYPE_CHECKING from textual.reactive import var from textual.timer import Timer @@ -12,12 +13,16 @@ from openhands_cli.utils import abbreviate_number, format_cost +if TYPE_CHECKING: + from openhands_cli.tui.core.state import AgentMode + + class WorkingStatusLine(Static): - """Status line showing conversation timer and working indicator (above input). + """Status line showing timer, working indicator, and mode (above input). This widget uses data_bind() to bind to ConversationContainer reactive properties. - When ConversationContainer.running, elapsed_seconds, or critic_settings change, - this widget's corresponding properties are automatically updated. + When ConversationContainer.running, elapsed_seconds, critic_settings, or agent_mode + change, this widget's corresponding properties are automatically updated. """ DEFAULT_CSS = """ @@ -33,6 +38,7 @@ class WorkingStatusLine(Static): running: var[bool] = var(False) elapsed_seconds: var[int] = var(0) critic_settings: var[CriticSettings] = var(CriticSettings()) + agent_mode: var[AgentMode] = var("code") def __init__(self, **kwargs) -> None: super().__init__("", id="working_status_line", markup=True, **kwargs) @@ -61,6 +67,10 @@ def watch_critic_settings(self, _settings: CriticSettings) -> None: """React to critic settings changes from ConversationContainer.""" self._update_text() + def watch_agent_mode(self, _mode: AgentMode) -> None: + """React to agent mode changes from ConversationContainer.""" + self._update_text() + # ----- Internal helpers ----- def _on_tick(self) -> None: @@ -90,10 +100,26 @@ def _get_refinement_indicator(self) -> str: ) return "" + def _get_mode_indicator(self) -> str: + """Return the mode indicator based on current agent mode. + + In planning mode, shows a blue-colored indicator. + In code mode (default), returns empty string (no indicator needed). + """ + if self.agent_mode == "plan": + # Use accent color (#277dff) for planning mode - matches GUI blue + return "[bold #277dff]📋 Planning Mode[/bold #277dff]" + return "" + def _update_text(self) -> None: - """Rebuild the working status text with refinement indicator.""" + """Rebuild the working status text with mode and refinement indicators.""" parts = [] + # Add mode indicator (shows for planning mode) + mode_indicator = self._get_mode_indicator() + if mode_indicator: + parts.append(mode_indicator) + # Add refinement indicator (always show when enabled in settings) refinement_indicator = self._get_refinement_indicator() if refinement_indicator: diff --git a/openhands_cli/tui/widgets/user_input/input_field.py b/openhands_cli/tui/widgets/user_input/input_field.py index 465f1d5a3..493621cd6 100644 --- a/openhands_cli/tui/widgets/user_input/input_field.py +++ b/openhands_cli/tui/widgets/user_input/input_field.py @@ -98,6 +98,12 @@ class InputField(Container): conversation_id: reactive[uuid.UUID | None] = reactive(None) # >0 = waiting for user confirmation (input disabled) pending_action_count: reactive[int] = reactive(0) + # Agent operating mode ("plan" or "code") + agent_mode: reactive[str] = reactive("code") + + # Color constants for mode indication + CODE_MODE_BORDER = "#ffe165" # Primary/logo color (yellow) + PLAN_MODE_BORDER = "#277dff" # Accent color (blue) DEFAULT_CSS = """ InputField { @@ -189,6 +195,25 @@ def watch_pending_action_count(self, count: int) -> None: # Re-enable and focus when confirmation is complete self.focus_input() + def watch_agent_mode(self, _mode: str) -> None: + """React to agent_mode changes - update border color.""" + self._update_border_color() + + def _update_border_color(self) -> None: + """Update the input border color based on the current agent mode. + + Uses blue (#277dff) for planning mode, yellow (#ffe165) for code mode. + """ + from textual.color import Color + + is_plan_mode = self.agent_mode == "plan" + border_color = self.PLAN_MODE_BORDER if is_plan_mode else self.CODE_MODE_BORDER + color = Color.parse(border_color) + + # Update both single-line and multiline input borders + self.single_line_widget.styles.border = ("round", color) + self.multiline_widget.styles.border = ("round", color) + def _update_disabled_state(self) -> None: """Update disabled state based on conversation_id and pending actions.""" is_switching = self.conversation_id is None diff --git a/tests/tui/core/test_planning_mode.py b/tests/tui/core/test_planning_mode.py new file mode 100644 index 000000000..b0ef24f05 --- /dev/null +++ b/tests/tui/core/test_planning_mode.py @@ -0,0 +1,111 @@ +"""Tests for Planning Mode functionality.""" + +from openhands_cli.tui.core.state import AgentMode, ConversationContainer +from openhands_cli.tui.core.user_message_controller import ( + PLANNING_MODE_INSTRUCTIONS, + UserMessageController, +) + + +class TestAgentMode: + """Tests for AgentMode type and ConversationContainer.agent_mode.""" + + def test_agent_mode_default_is_code(self): + """Test that the default agent mode is 'code'.""" + container = ConversationContainer() + assert container.agent_mode == "code" + + def test_agent_mode_can_be_set_to_plan(self): + """Test that agent mode can be set to 'plan'.""" + container = ConversationContainer() + container.set_agent_mode("plan") + assert container.agent_mode == "plan" + + def test_agent_mode_can_be_set_back_to_code(self): + """Test that agent mode can be switched back to 'code'.""" + container = ConversationContainer() + container.set_agent_mode("plan") + container.set_agent_mode("code") + assert container.agent_mode == "code" + + def test_agent_mode_type_literal_values(self): + """Test that AgentMode is a Literal type with 'plan' and 'code' values.""" + # These should be valid AgentMode values + plan_mode: AgentMode = "plan" + code_mode: AgentMode = "code" + assert plan_mode == "plan" + assert code_mode == "code" + + +class TestPlanningModeInstructions: + """Tests for PLANNING_MODE_INSTRUCTIONS constant.""" + + def test_planning_mode_instructions_exist(self): + """Test that PLANNING_MODE_INSTRUCTIONS constant exists and is not empty.""" + assert PLANNING_MODE_INSTRUCTIONS + assert len(PLANNING_MODE_INSTRUCTIONS) > 0 + + def test_planning_mode_instructions_contain_key_phrases(self): + """Test that instructions contain key planning-related phrases.""" + # Should mention not executing code + assert "DO NOT execute" in PLANNING_MODE_INSTRUCTIONS + + # Should mention PLAN.md + assert "PLAN.md" in PLANNING_MODE_INSTRUCTIONS + + # Should mention understanding/questions + assert "understanding" in PLANNING_MODE_INSTRUCTIONS.lower() + + # Should mention confirmation + assert "confirm" in PLANNING_MODE_INSTRUCTIONS.lower() + + +class TestUserMessageControllerPlanningMode: + """Tests for UserMessageController planning mode behavior.""" + + def test_apply_mode_instructions_code_mode_returns_original(self): + """Test that code mode returns the original content unchanged.""" + # Create a minimal mock state + from unittest.mock import MagicMock + + mock_state = MagicMock() + mock_state.agent_mode = "code" + mock_state.conversation_id = None # Not used in _apply_mode_instructions + + controller = UserMessageController( + state=mock_state, + runners=MagicMock(), + run_worker=MagicMock(), + headless_mode=False, + ) + + original_content = "Hello, please help me with something" + result = controller._apply_mode_instructions(original_content) + + assert result == original_content + + def test_apply_mode_instructions_plan_mode_prepends_instructions(self): + """Test that plan mode prepends instructions to the content.""" + from unittest.mock import MagicMock + + mock_state = MagicMock() + mock_state.agent_mode = "plan" + + controller = UserMessageController( + state=mock_state, + runners=MagicMock(), + run_worker=MagicMock(), + headless_mode=False, + ) + + original_content = "Hello, please help me with something" + result = controller._apply_mode_instructions(original_content) + + # Result should contain the planning instructions + assert PLANNING_MODE_INSTRUCTIONS in result + + # Result should also contain the original content + assert original_content in result + + # Instructions should come before the content + assert result.index(PLANNING_MODE_INSTRUCTIONS) < result.index(original_content) diff --git a/tests/tui/test_commands.py b/tests/tui/test_commands.py index d89fc50ff..2c1f6cf9e 100644 --- a/tests/tui/test_commands.py +++ b/tests/tui/test_commands.py @@ -29,7 +29,7 @@ class TestCommands: def test_commands_list_structure(self): """Test that COMMANDS list has correct structure.""" assert isinstance(COMMANDS, list) - assert len(COMMANDS) == 9 + assert len(COMMANDS) == 11 # Check that all items are DropdownItems for command in COMMANDS: @@ -43,6 +43,8 @@ def test_commands_list_structure(self): [ ("/help", "Display available commands"), ("/new", "Start a new conversation"), + ("/plan", "Switch to Planning Mode (generate PLAN.md)"), + ("/code", "Switch to Code Mode (normal execution)"), ("/history", "Toggle conversation history"), ("/settings", "Open settings"), ("/confirm", "Configure confirmation settings"), @@ -185,9 +187,11 @@ def test_commands_contains_history(self): assert "/history" in command_names assert "/help" in command_names assert "/new" in command_names + assert "/plan" in command_names + assert "/code" in command_names assert "/settings" in command_names assert "/skills" in command_names - assert len(COMMANDS) == 9 + assert len(COMMANDS) == 11 def test_all_commands_included_in_help(self): """Test that all commands from COMMANDS list are included in help text.