Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
32 changes: 32 additions & 0 deletions openhands_cli/tui/core/conversation_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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()
Expand Down
23 changes: 22 additions & 1 deletion 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 @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
47 changes: 45 additions & 2 deletions openhands_cli/tui/core/user_message_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
<PLANNING_MODE>
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.
</PLANNING_MODE>

User's request:
"""


class UserMessageController:
def __init__(
self,
Expand Down Expand Up @@ -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.
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 @@ -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":
Expand Down Expand Up @@ -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."""

Expand Down
34 changes: 30 additions & 4 deletions openhands_cli/tui/widgets/status_line.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = """
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions openhands_cli/tui/widgets/user_input/input_field.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading