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.
- openhands-1.3.0.dist-info/METADATA +56 -0
- openhands-1.3.0.dist-info/RECORD +43 -0
- openhands-1.3.0.dist-info/WHEEL +4 -0
- openhands-1.3.0.dist-info/entry_points.txt +3 -0
- openhands-1.3.0.dist-info/licenses/LICENSE +21 -0
- openhands_cli/__init__.py +9 -0
- openhands_cli/acp_impl/README.md +68 -0
- openhands_cli/acp_impl/__init__.py +1 -0
- openhands_cli/acp_impl/agent.py +483 -0
- openhands_cli/acp_impl/event.py +512 -0
- openhands_cli/acp_impl/main.py +21 -0
- openhands_cli/acp_impl/test_utils.py +174 -0
- openhands_cli/acp_impl/utils/__init__.py +14 -0
- openhands_cli/acp_impl/utils/convert.py +103 -0
- openhands_cli/acp_impl/utils/mcp.py +66 -0
- openhands_cli/acp_impl/utils/resources.py +189 -0
- openhands_cli/agent_chat.py +236 -0
- openhands_cli/argparsers/main_parser.py +78 -0
- openhands_cli/argparsers/serve_parser.py +31 -0
- openhands_cli/gui_launcher.py +224 -0
- openhands_cli/listeners/__init__.py +4 -0
- openhands_cli/listeners/pause_listener.py +83 -0
- openhands_cli/locations.py +14 -0
- openhands_cli/pt_style.py +33 -0
- openhands_cli/runner.py +190 -0
- openhands_cli/setup.py +136 -0
- openhands_cli/simple_main.py +71 -0
- openhands_cli/tui/__init__.py +6 -0
- openhands_cli/tui/settings/mcp_screen.py +225 -0
- openhands_cli/tui/settings/settings_screen.py +226 -0
- openhands_cli/tui/settings/store.py +132 -0
- openhands_cli/tui/status.py +110 -0
- openhands_cli/tui/tui.py +120 -0
- openhands_cli/tui/utils.py +14 -0
- openhands_cli/tui/visualizer.py +22 -0
- openhands_cli/user_actions/__init__.py +18 -0
- openhands_cli/user_actions/agent_action.py +82 -0
- openhands_cli/user_actions/exit_session.py +18 -0
- openhands_cli/user_actions/settings_action.py +176 -0
- openhands_cli/user_actions/types.py +17 -0
- openhands_cli/user_actions/utils.py +199 -0
- openhands_cli/utils.py +122 -0
- 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)
|
openhands_cli/tui/tui.py
ADDED
|
@@ -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)
|