openhands 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. openhands-1.3.0.dist-info/METADATA +56 -0
  2. openhands-1.3.0.dist-info/RECORD +43 -0
  3. openhands-1.3.0.dist-info/WHEEL +4 -0
  4. openhands-1.3.0.dist-info/entry_points.txt +3 -0
  5. openhands-1.3.0.dist-info/licenses/LICENSE +21 -0
  6. openhands_cli/__init__.py +9 -0
  7. openhands_cli/acp_impl/README.md +68 -0
  8. openhands_cli/acp_impl/__init__.py +1 -0
  9. openhands_cli/acp_impl/agent.py +483 -0
  10. openhands_cli/acp_impl/event.py +512 -0
  11. openhands_cli/acp_impl/main.py +21 -0
  12. openhands_cli/acp_impl/test_utils.py +174 -0
  13. openhands_cli/acp_impl/utils/__init__.py +14 -0
  14. openhands_cli/acp_impl/utils/convert.py +103 -0
  15. openhands_cli/acp_impl/utils/mcp.py +66 -0
  16. openhands_cli/acp_impl/utils/resources.py +189 -0
  17. openhands_cli/agent_chat.py +236 -0
  18. openhands_cli/argparsers/main_parser.py +78 -0
  19. openhands_cli/argparsers/serve_parser.py +31 -0
  20. openhands_cli/gui_launcher.py +224 -0
  21. openhands_cli/listeners/__init__.py +4 -0
  22. openhands_cli/listeners/pause_listener.py +83 -0
  23. openhands_cli/locations.py +14 -0
  24. openhands_cli/pt_style.py +33 -0
  25. openhands_cli/runner.py +190 -0
  26. openhands_cli/setup.py +136 -0
  27. openhands_cli/simple_main.py +71 -0
  28. openhands_cli/tui/__init__.py +6 -0
  29. openhands_cli/tui/settings/mcp_screen.py +225 -0
  30. openhands_cli/tui/settings/settings_screen.py +226 -0
  31. openhands_cli/tui/settings/store.py +132 -0
  32. openhands_cli/tui/status.py +110 -0
  33. openhands_cli/tui/tui.py +120 -0
  34. openhands_cli/tui/utils.py +14 -0
  35. openhands_cli/tui/visualizer.py +22 -0
  36. openhands_cli/user_actions/__init__.py +18 -0
  37. openhands_cli/user_actions/agent_action.py +82 -0
  38. openhands_cli/user_actions/exit_session.py +18 -0
  39. openhands_cli/user_actions/settings_action.py +176 -0
  40. openhands_cli/user_actions/types.py +17 -0
  41. openhands_cli/user_actions/utils.py +199 -0
  42. openhands_cli/utils.py +122 -0
  43. openhands_cli/version_check.py +83 -0
@@ -0,0 +1,132 @@
1
+ # openhands_cli/settings/store.py
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from fastmcp.mcp_config import MCPConfig
8
+ from prompt_toolkit import HTML, print_formatted_text
9
+
10
+ from openhands.sdk import Agent, AgentContext, LocalFileStore
11
+ from openhands.sdk.context import load_skills_from_dir
12
+ from openhands.sdk.context.condenser import LLMSummarizingCondenser
13
+ from openhands.tools.preset.default import get_default_tools
14
+ from openhands_cli.locations import (
15
+ AGENT_SETTINGS_PATH,
16
+ MCP_CONFIG_FILE,
17
+ PERSISTENCE_DIR,
18
+ WORK_DIR,
19
+ )
20
+ from openhands_cli.utils import get_llm_metadata, should_set_litellm_extra_body
21
+
22
+
23
+ class AgentStore:
24
+ """Single source of truth for persisting/retrieving AgentSpec."""
25
+
26
+ def __init__(self) -> None:
27
+ self.file_store = LocalFileStore(root=PERSISTENCE_DIR)
28
+
29
+ def load_mcp_configuration(self) -> dict[str, Any]:
30
+ try:
31
+ mcp_config_path = Path(self.file_store.root) / MCP_CONFIG_FILE
32
+ mcp_config = MCPConfig.from_file(mcp_config_path)
33
+ return mcp_config.to_dict()["mcpServers"]
34
+ except Exception:
35
+ return {}
36
+
37
+ def load_project_skills(self) -> list:
38
+ """Load skills project-specific directories."""
39
+ all_skills = []
40
+
41
+ # Load project-specific skills from .openhands/skills and legacy microagents
42
+ project_skills_dirs = [
43
+ Path(WORK_DIR) / ".openhands" / "skills",
44
+ Path(WORK_DIR) / ".openhands" / "microagents", # Legacy support
45
+ ]
46
+
47
+ for project_skills_dir in project_skills_dirs:
48
+ if project_skills_dir.exists():
49
+ try:
50
+ repo_skills, knowledge_skills = load_skills_from_dir(
51
+ project_skills_dir
52
+ )
53
+ project_skills = list(repo_skills.values()) + list(
54
+ knowledge_skills.values()
55
+ )
56
+ all_skills.extend(project_skills)
57
+ except Exception:
58
+ pass
59
+
60
+ return all_skills
61
+
62
+ def load(self, session_id: str | None = None) -> Agent | None:
63
+ try:
64
+ str_spec = self.file_store.read(AGENT_SETTINGS_PATH)
65
+ agent = Agent.model_validate_json(str_spec)
66
+
67
+ # Update tools with most recent working directory
68
+ updated_tools = get_default_tools(enable_browser=False)
69
+
70
+ # Load skills from user directories and project-specific directories
71
+ skills = self.load_project_skills()
72
+
73
+ agent_context = AgentContext(
74
+ skills=skills,
75
+ system_message_suffix=f"You current working directory is: {WORK_DIR}",
76
+ load_user_skills=True,
77
+ )
78
+
79
+ mcp_config: dict = self.load_mcp_configuration()
80
+
81
+ # Update LLM metadata with current information
82
+ llm_update = {}
83
+ if should_set_litellm_extra_body(agent.llm.model):
84
+ llm_update["litellm_extra_body"] = {
85
+ "metadata": get_llm_metadata(
86
+ model_name=agent.llm.model,
87
+ llm_type="agent",
88
+ session_id=session_id,
89
+ )
90
+ }
91
+ updated_llm = agent.llm.model_copy(update=llm_update)
92
+
93
+ condenser_updates = {}
94
+ if agent.condenser and isinstance(agent.condenser, LLMSummarizingCondenser):
95
+ condenser_llm_update = {}
96
+ if should_set_litellm_extra_body(agent.condenser.llm.model):
97
+ condenser_llm_update["litellm_extra_body"] = {
98
+ "metadata": get_llm_metadata(
99
+ model_name=agent.condenser.llm.model,
100
+ llm_type="condenser",
101
+ session_id=session_id,
102
+ )
103
+ }
104
+ condenser_updates["llm"] = agent.condenser.llm.model_copy(
105
+ update=condenser_llm_update
106
+ )
107
+
108
+ # Update tools and context
109
+ agent = agent.model_copy(
110
+ update={
111
+ "llm": updated_llm,
112
+ "tools": updated_tools,
113
+ "mcp_config": {"mcpServers": mcp_config} if mcp_config else {},
114
+ "agent_context": agent_context,
115
+ "condenser": agent.condenser.model_copy(update=condenser_updates)
116
+ if agent.condenser
117
+ else None,
118
+ }
119
+ )
120
+
121
+ return agent
122
+ except FileNotFoundError:
123
+ return None
124
+ except Exception:
125
+ print_formatted_text(
126
+ HTML("\n<red>Agent configuration file is corrupted!</red>")
127
+ )
128
+ return None
129
+
130
+ def save(self, agent: Agent) -> None:
131
+ serialized_spec = agent.model_dump_json(context={"expose_secrets": True})
132
+ self.file_store.write(AGENT_SETTINGS_PATH, serialized_spec)
@@ -0,0 +1,110 @@
1
+ """Status display components for OpenHands CLI TUI."""
2
+
3
+ from datetime import datetime
4
+
5
+ from prompt_toolkit import print_formatted_text
6
+ from prompt_toolkit.formatted_text import HTML
7
+ from prompt_toolkit.shortcuts import print_container
8
+ from prompt_toolkit.widgets import Frame, TextArea
9
+
10
+ from openhands.sdk import BaseConversation
11
+
12
+
13
+ def display_status(
14
+ conversation: BaseConversation,
15
+ session_start_time: datetime,
16
+ ) -> None:
17
+ """Display detailed conversation status including metrics and uptime.
18
+
19
+ Args:
20
+ conversation: The conversation to display status for
21
+ session_start_time: The session start time for uptime calculation
22
+ """
23
+ # Get conversation stats
24
+ stats = conversation.conversation_stats.get_combined_metrics()
25
+
26
+ # Calculate uptime from session start time
27
+ now = datetime.now()
28
+ diff = now - session_start_time
29
+
30
+ # Format as hours, minutes, seconds
31
+ total_seconds = int(diff.total_seconds())
32
+ hours = total_seconds // 3600
33
+ minutes = (total_seconds % 3600) // 60
34
+ seconds = total_seconds % 60
35
+ uptime_str = f"{hours}h {minutes}m {seconds}s"
36
+
37
+ # Display conversation ID and uptime
38
+ print_formatted_text(HTML(f"<grey>Conversation ID: {conversation.id}</grey>"))
39
+ print_formatted_text(HTML(f"<grey>Uptime: {uptime_str}</grey>"))
40
+ print_formatted_text("")
41
+
42
+ # Calculate token metrics
43
+ token_usage = stats.accumulated_token_usage
44
+ total_input_tokens = token_usage.prompt_tokens if token_usage else 0
45
+ total_output_tokens = token_usage.completion_tokens if token_usage else 0
46
+ cache_hits = token_usage.cache_read_tokens if token_usage else 0
47
+ cache_writes = token_usage.cache_write_tokens if token_usage else 0
48
+ total_tokens = total_input_tokens + total_output_tokens
49
+ total_cost = stats.accumulated_cost
50
+
51
+ # Use prompt_toolkit containers for formatted display
52
+ _display_usage_metrics_container(
53
+ total_cost,
54
+ total_input_tokens,
55
+ total_output_tokens,
56
+ cache_hits,
57
+ cache_writes,
58
+ total_tokens,
59
+ )
60
+
61
+
62
+ def _display_usage_metrics_container(
63
+ total_cost: float,
64
+ total_input_tokens: int,
65
+ total_output_tokens: int,
66
+ cache_hits: int,
67
+ cache_writes: int,
68
+ total_tokens: int,
69
+ ) -> None:
70
+ """Display usage metrics using prompt_toolkit containers."""
71
+ # Format values with proper formatting
72
+ cost_str = f"${total_cost:.6f}"
73
+ input_tokens_str = f"{total_input_tokens:,}"
74
+ cache_read_str = f"{cache_hits:,}"
75
+ cache_write_str = f"{cache_writes:,}"
76
+ output_tokens_str = f"{total_output_tokens:,}"
77
+ total_tokens_str = f"{total_tokens:,}"
78
+
79
+ labels_and_values = [
80
+ (" Total Cost (USD):", cost_str),
81
+ ("", ""),
82
+ (" Total Input Tokens:", input_tokens_str),
83
+ (" Cache Hits:", cache_read_str),
84
+ (" Cache Writes:", cache_write_str),
85
+ (" Total Output Tokens:", output_tokens_str),
86
+ ("", ""),
87
+ (" Total Tokens:", total_tokens_str),
88
+ ]
89
+
90
+ # Calculate max widths for alignment
91
+ max_label_width = max(len(label) for label, _ in labels_and_values)
92
+ max_value_width = max(len(value) for _, value in labels_and_values)
93
+
94
+ # Construct the summary text with aligned columns
95
+ summary_lines = [
96
+ f"{label:<{max_label_width}} {value:<{max_value_width}}"
97
+ for label, value in labels_and_values
98
+ ]
99
+ summary_text = "\n".join(summary_lines)
100
+
101
+ container = Frame(
102
+ TextArea(
103
+ text=summary_text,
104
+ read_only=True,
105
+ wrap_lines=True,
106
+ ),
107
+ title="Usage Metrics",
108
+ )
109
+
110
+ print_container(container)
@@ -0,0 +1,120 @@
1
+ from collections.abc import Generator
2
+ from uuid import UUID
3
+
4
+ from prompt_toolkit import print_formatted_text
5
+ from prompt_toolkit.completion import CompleteEvent, Completer, Completion
6
+ from prompt_toolkit.document import Document
7
+ from prompt_toolkit.formatted_text import HTML
8
+ from prompt_toolkit.shortcuts import clear
9
+
10
+ from openhands_cli.pt_style import get_cli_style
11
+ from openhands_cli.version_check import check_for_updates
12
+
13
+
14
+ DEFAULT_STYLE = get_cli_style()
15
+
16
+ # Available commands with descriptions
17
+ COMMANDS = {
18
+ "/exit": "Exit the application",
19
+ "/help": "Display available commands",
20
+ "/clear": "Clear the screen",
21
+ "/new": "Start a fresh conversation",
22
+ "/status": "Display conversation details",
23
+ "/confirm": "Toggle confirmation mode on/off",
24
+ "/resume": "Resume a paused conversation",
25
+ "/settings": "Display and modify current settings",
26
+ "/mcp": "View MCP (Model Context Protocol) server configuration",
27
+ }
28
+
29
+
30
+ class CommandCompleter(Completer):
31
+ """Custom completer for commands with interactive dropdown."""
32
+
33
+ def get_completions(
34
+ self,
35
+ document: Document,
36
+ complete_event: CompleteEvent, # noqa: ARG002
37
+ ) -> Generator[Completion, None, None]:
38
+ text = document.text_before_cursor.lstrip()
39
+ if text.startswith("/"):
40
+ for command, description in COMMANDS.items():
41
+ if command.startswith(text):
42
+ yield Completion(
43
+ command,
44
+ start_position=-len(text),
45
+ display_meta=description,
46
+ style="bg:ansidarkgray fg:gold",
47
+ )
48
+
49
+
50
+ def display_banner(conversation_id: str, resume: bool = False) -> None:
51
+ print_formatted_text(
52
+ HTML(r"""<gold>
53
+ ___ _ _ _
54
+ / _ \ _ __ ___ _ __ | | | | __ _ _ __ __| |___
55
+ | | | | '_ \ / _ \ '_ \| |_| |/ _` | '_ \ / _` / __|
56
+ | |_| | |_) | __/ | | | _ | (_| | | | | (_| \__ \
57
+ \___ /| .__/ \___|_| |_|_| |_|\__,_|_| |_|\__,_|___/
58
+ |_|
59
+ </gold>"""),
60
+ style=DEFAULT_STYLE,
61
+ )
62
+
63
+ print_formatted_text("")
64
+ if not resume:
65
+ print_formatted_text(
66
+ HTML(f"<grey>Initialized conversation {conversation_id}</grey>")
67
+ )
68
+ else:
69
+ print_formatted_text(
70
+ HTML(f"<grey>Resumed conversation {conversation_id}</grey>")
71
+ )
72
+ print_formatted_text("")
73
+
74
+
75
+ def display_help() -> None:
76
+ """Display help information about available commands."""
77
+ print_formatted_text("")
78
+ print_formatted_text(HTML("<gold>🤖 OpenHands CLI Help</gold>"))
79
+ print_formatted_text(HTML("<grey>Available commands:</grey>"))
80
+ print_formatted_text("")
81
+
82
+ for command, description in COMMANDS.items():
83
+ print_formatted_text(HTML(f" <white>{command}</white> - {description}"))
84
+
85
+ print_formatted_text("")
86
+ print_formatted_text(HTML("<grey>Tips:</grey>"))
87
+ print_formatted_text(" • Type / and press Tab to see command suggestions")
88
+ print_formatted_text(" • Use arrow keys to navigate through suggestions")
89
+ print_formatted_text(" • Press Enter to select a command")
90
+ print_formatted_text("")
91
+
92
+
93
+ def display_welcome(conversation_id: UUID, resume: bool = False) -> None:
94
+ """Display welcome message."""
95
+ clear()
96
+ display_banner(str(conversation_id), resume)
97
+
98
+ # Check for updates and display version info
99
+ version_info = check_for_updates()
100
+ print_formatted_text(HTML(f"<grey>Version: {version_info.current_version}</grey>"))
101
+
102
+ if version_info.needs_update and version_info.latest_version:
103
+ print_formatted_text(
104
+ HTML(f"<yellow>⚠ Update available: {version_info.latest_version}</yellow>")
105
+ )
106
+ print_formatted_text(
107
+ HTML(
108
+ "<grey>Run</grey> <gold>uv tool upgrade openhands</gold> "
109
+ "<grey>to update</grey>"
110
+ )
111
+ )
112
+
113
+ print_formatted_text("")
114
+ print_formatted_text(HTML("<gold>Let's start building!</gold>"))
115
+ print_formatted_text(
116
+ HTML(
117
+ "<green>What do you want to build? <grey>Type /help for help</grey></green>"
118
+ )
119
+ )
120
+ print()
@@ -0,0 +1,14 @@
1
+ class StepCounter:
2
+ """Automatically manages step numbering for settings flows."""
3
+
4
+ def __init__(self, total_steps: int):
5
+ self.current_step = 0
6
+ self.total_steps = total_steps
7
+
8
+ def next_step(self, prompt: str) -> str:
9
+ """Get the next step prompt with automatic numbering."""
10
+ self.current_step += 1
11
+ return f"(Step {self.current_step}/{self.total_steps}) {prompt}"
12
+
13
+ def existing_step(self, prompt: str) -> str:
14
+ return f"(Step {self.current_step}/{self.total_steps}) {prompt}"
@@ -0,0 +1,22 @@
1
+ """CLI-specific visualization configuration.
2
+
3
+ This module customizes the SDK's default visualizer for CLI usage by:
4
+ - Skipping SystemPromptEvent (only relevant for SDK internals)
5
+ - Re-exporting DefaultConversationVisualizer for use in CLI
6
+ """
7
+
8
+ from openhands.sdk.conversation.visualizer.default import (
9
+ EVENT_VISUALIZATION_CONFIG,
10
+ DefaultConversationVisualizer as CLIVisualizer,
11
+ EventVisualizationConfig,
12
+ )
13
+ from openhands.sdk.event import SystemPromptEvent
14
+
15
+
16
+ # CLI-specific customization: skip SystemPromptEvent
17
+ # (not needed in CLI output, only relevant for SDK internals)
18
+ EVENT_VISUALIZATION_CONFIG[SystemPromptEvent] = EventVisualizationConfig(
19
+ **{**EVENT_VISUALIZATION_CONFIG[SystemPromptEvent].model_dump(), "skip": True}
20
+ )
21
+
22
+ __all__ = ["CLIVisualizer"]
@@ -0,0 +1,18 @@
1
+ from openhands_cli.user_actions.agent_action import ask_user_confirmation
2
+ from openhands_cli.user_actions.exit_session import (
3
+ exit_session_confirmation,
4
+ )
5
+ from openhands_cli.user_actions.settings_action import (
6
+ choose_llm_provider,
7
+ settings_type_confirmation,
8
+ )
9
+ from openhands_cli.user_actions.types import UserConfirmation
10
+
11
+
12
+ __all__ = [
13
+ "ask_user_confirmation",
14
+ "exit_session_confirmation",
15
+ "UserConfirmation",
16
+ "settings_type_confirmation",
17
+ "choose_llm_provider",
18
+ ]
@@ -0,0 +1,82 @@
1
+ import html
2
+
3
+ from prompt_toolkit import HTML, print_formatted_text
4
+
5
+ from openhands.sdk.security.confirmation_policy import (
6
+ ConfirmRisky,
7
+ NeverConfirm,
8
+ )
9
+ from openhands.sdk.security.risk import SecurityRisk
10
+ from openhands_cli.user_actions.types import ConfirmationResult, UserConfirmation
11
+ from openhands_cli.user_actions.utils import cli_confirm, cli_text_input
12
+
13
+
14
+ def ask_user_confirmation(
15
+ pending_actions: list, using_risk_based_policy: bool = False
16
+ ) -> ConfirmationResult:
17
+ """Ask user to confirm pending actions.
18
+
19
+ Args:
20
+ pending_actions: List of pending actions from the agent
21
+
22
+ Returns:
23
+ ConfirmationResult with decision, optional policy_change, and reason
24
+ """
25
+
26
+ if not pending_actions:
27
+ return ConfirmationResult(decision=UserConfirmation.ACCEPT)
28
+
29
+ print_formatted_text(
30
+ HTML(
31
+ f"<yellow>🔍 Agent created {len(pending_actions)} action(s) and is "
32
+ f"waiting for confirmation:</yellow>"
33
+ )
34
+ )
35
+
36
+ for i, action in enumerate(pending_actions, 1):
37
+ tool_name = getattr(action, "tool_name", "[unknown tool]")
38
+ action_content = (
39
+ str(getattr(action, "action", ""))[:100].replace("\n", " ")
40
+ or "[unknown action]"
41
+ )
42
+ print_formatted_text(
43
+ HTML(f"<grey> {i}. {tool_name}: {html.escape(action_content)}...</grey>")
44
+ )
45
+
46
+ question = "Choose an option:"
47
+ options = [
48
+ "Yes, proceed",
49
+ "Reject",
50
+ "Always proceed (don't ask again)",
51
+ ]
52
+
53
+ if not using_risk_based_policy:
54
+ options.append("Auto-confirm LOW/MEDIUM risk, ask for HIGH risk")
55
+
56
+ try:
57
+ index = cli_confirm(question, options, escapable=True)
58
+ except (EOFError, KeyboardInterrupt):
59
+ print_formatted_text(HTML("\n<red>No input received; pausing agent.</red>"))
60
+ return ConfirmationResult(decision=UserConfirmation.DEFER)
61
+
62
+ if index == 0:
63
+ return ConfirmationResult(decision=UserConfirmation.ACCEPT)
64
+ elif index == 1:
65
+ # Handle "Reject" option with optional reason
66
+ try:
67
+ reason = cli_text_input("Reason (and let OpenHands know why): ").strip()
68
+ except (EOFError, KeyboardInterrupt):
69
+ return ConfirmationResult(decision=UserConfirmation.DEFER)
70
+
71
+ return ConfirmationResult(decision=UserConfirmation.REJECT, reason=reason)
72
+ elif index == 2:
73
+ return ConfirmationResult(
74
+ decision=UserConfirmation.ACCEPT, policy_change=NeverConfirm()
75
+ )
76
+ elif index == 3:
77
+ return ConfirmationResult(
78
+ decision=UserConfirmation.ACCEPT,
79
+ policy_change=ConfirmRisky(threshold=SecurityRisk.HIGH),
80
+ )
81
+
82
+ return ConfirmationResult(decision=UserConfirmation.REJECT)
@@ -0,0 +1,18 @@
1
+ from openhands_cli.user_actions.types import UserConfirmation
2
+ from openhands_cli.user_actions.utils import cli_confirm
3
+
4
+
5
+ def exit_session_confirmation() -> UserConfirmation:
6
+ """
7
+ Ask user to confirm exiting session.
8
+ """
9
+
10
+ question = "Terminate session?"
11
+ options = ["Yes, proceed", "No, dismiss"]
12
+ index = cli_confirm(question, options) # Blocking UI, not escapable
13
+
14
+ options_mapping = {
15
+ 0: UserConfirmation.ACCEPT, # User accepts termination session
16
+ 1: UserConfirmation.REJECT, # User does not terminate session
17
+ }
18
+ return options_mapping.get(index, UserConfirmation.REJECT)