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,14 @@
|
|
|
1
|
+
from openhands_cli.acp_impl.utils.convert import convert_acp_prompt_to_message_content
|
|
2
|
+
from openhands_cli.acp_impl.utils.mcp import (
|
|
3
|
+
ACPMCPServerType,
|
|
4
|
+
convert_acp_mcp_servers_to_agent_format,
|
|
5
|
+
)
|
|
6
|
+
from openhands_cli.acp_impl.utils.resources import RESOURCE_SKILL
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"convert_acp_mcp_servers_to_agent_format",
|
|
11
|
+
"ACPMCPServerType",
|
|
12
|
+
"convert_acp_prompt_to_message_content",
|
|
13
|
+
"RESOURCE_SKILL",
|
|
14
|
+
]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Utility functions for ACP implementation."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from acp.schema import (
|
|
7
|
+
AudioContentBlock as ACPAudioContentBlock,
|
|
8
|
+
EmbeddedResourceContentBlock as ACPEmbeddedResourceContentBlock,
|
|
9
|
+
ImageContentBlock as ACPImageContentBlock,
|
|
10
|
+
ResourceContentBlock as ACPResourceContentBlock,
|
|
11
|
+
TextContentBlock as ACPTextContentBlock,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from openhands.sdk import ImageContent, TextContent
|
|
15
|
+
from openhands_cli.acp_impl.utils.resources import (
|
|
16
|
+
ACP_CACHE_DIR,
|
|
17
|
+
SUPPORTED_IMAGE_MIME_TYPES,
|
|
18
|
+
_convert_image_to_supported_format,
|
|
19
|
+
convert_resources_to_content,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _convert_image_block(block: ACPImageContentBlock) -> TextContent | ImageContent:
|
|
24
|
+
"""
|
|
25
|
+
Convert an ACP image content block to SDK format.
|
|
26
|
+
|
|
27
|
+
Handles:
|
|
28
|
+
1. Supported image formats -> ImageContent
|
|
29
|
+
2. Unsupported but convertible formats -> ImageContent with converted data
|
|
30
|
+
3. Unsupported and non-convertible formats -> TextContent with file path
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
block: ACP image content block
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
ImageContent if format is supported or convertible, TextContent otherwise
|
|
37
|
+
"""
|
|
38
|
+
# Handle supported formats directly
|
|
39
|
+
if block.mimeType in SUPPORTED_IMAGE_MIME_TYPES:
|
|
40
|
+
return ImageContent(image_urls=[f"data:{block.mimeType};base64,{block.data}"])
|
|
41
|
+
|
|
42
|
+
# Try to convert unsupported formats
|
|
43
|
+
data = base64.b64decode(block.data)
|
|
44
|
+
converted = _convert_image_to_supported_format(data, block.mimeType)
|
|
45
|
+
|
|
46
|
+
if converted is not None:
|
|
47
|
+
target_mime, converted_data = converted
|
|
48
|
+
return ImageContent(image_urls=[f"data:{target_mime};base64,{converted_data}"])
|
|
49
|
+
|
|
50
|
+
# Conversion failed - save to disk and return explanatory text
|
|
51
|
+
filename = f"image_{uuid4().hex}"
|
|
52
|
+
target = ACP_CACHE_DIR / filename
|
|
53
|
+
target.write_bytes(data)
|
|
54
|
+
supported = ", ".join(sorted(SUPPORTED_IMAGE_MIME_TYPES))
|
|
55
|
+
|
|
56
|
+
return TextContent(
|
|
57
|
+
text=(
|
|
58
|
+
"\n[BEGIN USER PROVIDED ADDITIONAL CONTEXT]\n"
|
|
59
|
+
f"User provided image with unsupported format ({block.mimeType}).\n"
|
|
60
|
+
"Attempted automatic conversion failed.\n"
|
|
61
|
+
f"Supported formats: {supported}\n"
|
|
62
|
+
f"Saved to file: {str(target)}\n"
|
|
63
|
+
"[END USER PROVIDED ADDITIONAL CONTEXT]\n"
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def convert_acp_prompt_to_message_content(
|
|
69
|
+
acp_prompt: list[
|
|
70
|
+
ACPTextContentBlock
|
|
71
|
+
| ACPImageContentBlock
|
|
72
|
+
| ACPAudioContentBlock
|
|
73
|
+
| ACPResourceContentBlock
|
|
74
|
+
| ACPEmbeddedResourceContentBlock,
|
|
75
|
+
],
|
|
76
|
+
) -> list[TextContent | ImageContent]:
|
|
77
|
+
"""
|
|
78
|
+
Convert ACP prompt to OpenHands message content format.
|
|
79
|
+
|
|
80
|
+
Handles various ACP prompt formats:
|
|
81
|
+
- Simple string
|
|
82
|
+
- List of content blocks (text/image)
|
|
83
|
+
- Single ContentBlock object
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
prompt: ACP prompt in various formats (string, list, or ContentBlock)
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
List of TextContent and ImageContent objects supported by SDK
|
|
90
|
+
"""
|
|
91
|
+
message_content: list[TextContent | ImageContent] = []
|
|
92
|
+
for block in acp_prompt:
|
|
93
|
+
if isinstance(block, ACPTextContentBlock):
|
|
94
|
+
message_content.append(TextContent(text=block.text))
|
|
95
|
+
elif isinstance(block, ACPImageContentBlock):
|
|
96
|
+
message_content.append(_convert_image_block(block))
|
|
97
|
+
elif isinstance(
|
|
98
|
+
block, ACPResourceContentBlock | ACPEmbeddedResourceContentBlock
|
|
99
|
+
):
|
|
100
|
+
# https://agentclientprotocol.com/protocol/content#resource-link
|
|
101
|
+
# https://agentclientprotocol.com/protocol/content#embedded-resource
|
|
102
|
+
message_content.append(convert_resources_to_content(block))
|
|
103
|
+
return message_content
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Utility functions for MCP in ACP implementation."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from acp.schema import (
|
|
7
|
+
HttpMcpServer,
|
|
8
|
+
SseMcpServer,
|
|
9
|
+
StdioMcpServer,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
ACPMCPServerType = StdioMcpServer | HttpMcpServer | SseMcpServer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _convert_env_to_dict(env: Sequence[dict[str, str]]) -> dict[str, str]:
|
|
17
|
+
"""
|
|
18
|
+
Convert environment variables from serialized EnvVariable format to a dictionary.
|
|
19
|
+
|
|
20
|
+
When Pydantic models are dumped to dict, EnvVariable objects become dicts
|
|
21
|
+
with 'name' and 'value' keys.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
env: List of dicts with 'name' and 'value' keys (serialized EnvVariable objects)
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Dictionary mapping environment variable names to values
|
|
28
|
+
"""
|
|
29
|
+
env_dict: dict[str, str] = {}
|
|
30
|
+
for env_var in env:
|
|
31
|
+
env_dict[env_var["name"]] = env_var["value"]
|
|
32
|
+
return env_dict
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def convert_acp_mcp_servers_to_agent_format(
|
|
36
|
+
mcp_servers: Sequence[ACPMCPServerType],
|
|
37
|
+
) -> dict[str, dict[str, Any]]:
|
|
38
|
+
"""
|
|
39
|
+
Convert MCP servers from ACP format to Agent format.
|
|
40
|
+
|
|
41
|
+
ACP and Agent use different formats for MCP server configurations:
|
|
42
|
+
- ACP: List of Pydantic server models with 'name' field, env as array of EnvVariable
|
|
43
|
+
- Agent: Dict keyed by server name, env as dict
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
mcp_servers: List of MCP server Pydantic models from ACP
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Dictionary of MCP servers in Agent format (keyed by name)
|
|
50
|
+
"""
|
|
51
|
+
converted_servers: dict[str, dict[str, Any]] = {}
|
|
52
|
+
|
|
53
|
+
for server in mcp_servers:
|
|
54
|
+
server_dict = server.model_dump()
|
|
55
|
+
server_name: str = server_dict["name"]
|
|
56
|
+
server_config: dict[str, Any] = {
|
|
57
|
+
k: v for k, v in server_dict.items() if k != "name"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Convert env from array to dict format if present
|
|
61
|
+
# ACP sends env as array of EnvVariable objects, but Agent expects dict
|
|
62
|
+
if "env" in server_config:
|
|
63
|
+
server_config["env"] = _convert_env_to_dict(server_config["env"])
|
|
64
|
+
converted_servers[server_name] = server_config
|
|
65
|
+
|
|
66
|
+
return converted_servers
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Utility functions for ACP implementation."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
import mimetypes
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from acp.schema import (
|
|
10
|
+
BlobResourceContents as ACPBlobResourceContents,
|
|
11
|
+
EmbeddedResourceContentBlock as ACPEmbeddedResourceContentBlock,
|
|
12
|
+
ResourceContentBlock as ACPResourceContentBlock,
|
|
13
|
+
TextResourceContents as ACPTextResourceContents,
|
|
14
|
+
)
|
|
15
|
+
from PIL import Image
|
|
16
|
+
|
|
17
|
+
from openhands.sdk import ImageContent, TextContent
|
|
18
|
+
from openhands.sdk.context import Skill
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
RESOURCE_SKILL = Skill(
|
|
22
|
+
name="user_provided_resources",
|
|
23
|
+
content=(
|
|
24
|
+
"You may encounter sections labeled as user-provided additional "
|
|
25
|
+
"context or resources. "
|
|
26
|
+
"These blocks contain files or data that the user referenced in their message. "
|
|
27
|
+
"They may include plain text, images, code snippets, or binary "
|
|
28
|
+
"content saved to a temporary file. "
|
|
29
|
+
"Treat these blocks as part of the user’s input. "
|
|
30
|
+
"Read them carefully and use their contents when forming your "
|
|
31
|
+
"reasoning or answering the query. "
|
|
32
|
+
"If a block points to a saved file, assume it contains relevant "
|
|
33
|
+
"binary data that could not be displayed directly."
|
|
34
|
+
),
|
|
35
|
+
trigger=None,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
ACP_CACHE_DIR = Path.home() / ".openhands" / "cache" / "acp"
|
|
39
|
+
ACP_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
# LLM API supported image MIME types (Anthropic/Claude compatible)
|
|
42
|
+
SUPPORTED_IMAGE_MIME_TYPES = {
|
|
43
|
+
"image/jpeg",
|
|
44
|
+
"image/png",
|
|
45
|
+
"image/gif",
|
|
46
|
+
"image/webp",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _convert_image_to_supported_format(
|
|
51
|
+
image_data: bytes,
|
|
52
|
+
source_mime_type: str, # noqa: ARG001
|
|
53
|
+
) -> tuple[str, str] | None:
|
|
54
|
+
"""
|
|
55
|
+
Try to convert an unsupported image format to PNG.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
image_data: The raw image bytes
|
|
59
|
+
source_mime_type: The original MIME type (currently unused but kept for API)
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A tuple of (mime_type, base64_data) if conversion succeeds, None otherwise
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
# Open the image with Pillow
|
|
66
|
+
img = Image.open(io.BytesIO(image_data))
|
|
67
|
+
|
|
68
|
+
# Convert to RGB if necessary (some formats like RGBA need this for JPEG)
|
|
69
|
+
# PNG supports transparency, so we'll use PNG as target format
|
|
70
|
+
if img.mode in ("RGBA", "LA", "P"):
|
|
71
|
+
# Keep transparency by converting to PNG
|
|
72
|
+
output_format = "PNG"
|
|
73
|
+
target_mime = "image/png"
|
|
74
|
+
else:
|
|
75
|
+
# For non-transparent images, we can use PNG or JPEG
|
|
76
|
+
# PNG is lossless, so prefer it
|
|
77
|
+
output_format = "PNG"
|
|
78
|
+
target_mime = "image/png"
|
|
79
|
+
|
|
80
|
+
# Convert the image to the target format
|
|
81
|
+
output_buffer = io.BytesIO()
|
|
82
|
+
img.save(output_buffer, format=output_format)
|
|
83
|
+
output_buffer.seek(0)
|
|
84
|
+
|
|
85
|
+
# Encode to base64
|
|
86
|
+
converted_data = base64.b64encode(output_buffer.read()).decode("utf-8")
|
|
87
|
+
|
|
88
|
+
return target_mime, converted_data
|
|
89
|
+
|
|
90
|
+
except Exception:
|
|
91
|
+
# If conversion fails for any reason, return None
|
|
92
|
+
# This could happen for corrupted images, unsupported formats, etc.
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _materialize_embedded_resource(
|
|
97
|
+
block: ACPEmbeddedResourceContentBlock,
|
|
98
|
+
) -> TextContent | ImageContent:
|
|
99
|
+
"""
|
|
100
|
+
For:
|
|
101
|
+
- text resources: return TextContent containing the text.
|
|
102
|
+
- image blobs: return ImageContent directly (no disk write).
|
|
103
|
+
- other binary blobs: write to disk and return TextContent explaining the path.
|
|
104
|
+
"""
|
|
105
|
+
res: ACPTextResourceContents | ACPBlobResourceContents = block.resource
|
|
106
|
+
|
|
107
|
+
if isinstance(res, ACPTextResourceContents):
|
|
108
|
+
return TextContent(
|
|
109
|
+
text=(
|
|
110
|
+
"\n[BEGIN USER PROVIDED ADDITIONAL CONTEXT]\n"
|
|
111
|
+
f"URI: {res.uri}\n"
|
|
112
|
+
f"mimeType: {res.mimeType}\n"
|
|
113
|
+
"Content:\n"
|
|
114
|
+
f"{res.text}\n"
|
|
115
|
+
"[END USER PROVIDED ADDITIONAL CONTEXT]\n"
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
elif isinstance(res, ACPBlobResourceContents):
|
|
120
|
+
mime_type = res.mimeType or ""
|
|
121
|
+
|
|
122
|
+
# 1. If it's a supported image type, directly return ImageContent
|
|
123
|
+
if mime_type in SUPPORTED_IMAGE_MIME_TYPES:
|
|
124
|
+
data_uri = f"data:{mime_type};base64,{res.blob}"
|
|
125
|
+
return ImageContent(image_urls=[data_uri])
|
|
126
|
+
|
|
127
|
+
# 2. If it's an unsupported image type, try to convert it
|
|
128
|
+
if mime_type.startswith("image/"):
|
|
129
|
+
data = base64.b64decode(res.blob)
|
|
130
|
+
converted = _convert_image_to_supported_format(data, mime_type)
|
|
131
|
+
|
|
132
|
+
if converted is not None:
|
|
133
|
+
# Conversion succeeded, return as ImageContent
|
|
134
|
+
target_mime, converted_data = converted
|
|
135
|
+
data_uri = f"data:{target_mime};base64,{converted_data}"
|
|
136
|
+
return ImageContent(image_urls=[data_uri])
|
|
137
|
+
|
|
138
|
+
# Conversion failed, fall through to disk storage
|
|
139
|
+
|
|
140
|
+
# 3. For non-images or failed conversions, save to disk
|
|
141
|
+
data = base64.b64decode(res.blob)
|
|
142
|
+
|
|
143
|
+
ext = ""
|
|
144
|
+
if mime_type:
|
|
145
|
+
ext = mimetypes.guess_extension(mime_type) or ""
|
|
146
|
+
|
|
147
|
+
filename = f"embedded_resource_{uuid4().hex}{ext}"
|
|
148
|
+
target = ACP_CACHE_DIR / filename
|
|
149
|
+
target.write_bytes(data)
|
|
150
|
+
|
|
151
|
+
# Provide appropriate message based on content type
|
|
152
|
+
if mime_type.startswith("image/"):
|
|
153
|
+
description = (
|
|
154
|
+
f"User provided image with unsupported format ({mime_type}).\n"
|
|
155
|
+
"Attempted automatic conversion failed.\n"
|
|
156
|
+
f"Supported formats: {', '.join(sorted(SUPPORTED_IMAGE_MIME_TYPES))}\n"
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
description = "User provided binary context (non-image).\n"
|
|
160
|
+
|
|
161
|
+
return TextContent(
|
|
162
|
+
text=(
|
|
163
|
+
"\n[BEGIN USER PROVIDED ADDITIONAL CONTEXT]\n"
|
|
164
|
+
f"{description}"
|
|
165
|
+
f"Saved to file: {str(target)}\n"
|
|
166
|
+
"[END USER PROVIDED ADDITIONAL CONTEXT]\n"
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def convert_resources_to_content(
|
|
172
|
+
resource: ACPResourceContentBlock | ACPEmbeddedResourceContentBlock,
|
|
173
|
+
) -> TextContent | ImageContent:
|
|
174
|
+
if isinstance(resource, ACPResourceContentBlock):
|
|
175
|
+
return TextContent(
|
|
176
|
+
text=(
|
|
177
|
+
"\n[BEGIN USER PROVIDED ADDITIONAL RESOURCE]\n"
|
|
178
|
+
f"Type: {resource.type}\n"
|
|
179
|
+
f"URI: {resource.uri}\n"
|
|
180
|
+
f"name: {resource.name}\n"
|
|
181
|
+
f"mimeType: {resource.mimeType}\n"
|
|
182
|
+
f"size: {resource.size}\n"
|
|
183
|
+
"[END USER PROVIDED ADDITIONAL RESOURCE]\n"
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
elif isinstance(resource, ACPEmbeddedResourceContentBlock):
|
|
187
|
+
return _materialize_embedded_resource(resource)
|
|
188
|
+
|
|
189
|
+
raise ValueError(f"Unexpected resource type: {type(resource)}")
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Agent chat functionality for OpenHands CLI.
|
|
4
|
+
Provides a conversation interface with an AI agent using OpenHands patterns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from prompt_toolkit import print_formatted_text
|
|
12
|
+
from prompt_toolkit.formatted_text import HTML
|
|
13
|
+
|
|
14
|
+
from openhands.sdk import (
|
|
15
|
+
Message,
|
|
16
|
+
TextContent,
|
|
17
|
+
)
|
|
18
|
+
from openhands.sdk.conversation.state import ConversationExecutionStatus
|
|
19
|
+
from openhands_cli.runner import ConversationRunner
|
|
20
|
+
from openhands_cli.setup import (
|
|
21
|
+
MissingAgentSpec,
|
|
22
|
+
setup_conversation,
|
|
23
|
+
verify_agent_exists_or_setup_agent,
|
|
24
|
+
)
|
|
25
|
+
from openhands_cli.tui.settings.mcp_screen import MCPScreen
|
|
26
|
+
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
|
27
|
+
from openhands_cli.tui.status import display_status
|
|
28
|
+
from openhands_cli.tui.tui import (
|
|
29
|
+
display_help,
|
|
30
|
+
display_welcome,
|
|
31
|
+
)
|
|
32
|
+
from openhands_cli.user_actions import UserConfirmation, exit_session_confirmation
|
|
33
|
+
from openhands_cli.user_actions.utils import get_session_prompter
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _restore_tty() -> None:
|
|
37
|
+
"""
|
|
38
|
+
Ensure terminal modes are reset in case prompt_toolkit cleanup didn't run.
|
|
39
|
+
- Turn off application cursor keys (DECCKM): ESC[?1l
|
|
40
|
+
- Turn off bracketed paste: ESC[?2004l
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
sys.stdout.write("\x1b[?1l\x1b[?2004l")
|
|
44
|
+
sys.stdout.flush()
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _print_exit_hint(conversation_id: str) -> None:
|
|
50
|
+
"""Print a resume hint with the current conversation ID."""
|
|
51
|
+
print_formatted_text(
|
|
52
|
+
HTML(f"<grey>Conversation ID:</grey> <yellow>{conversation_id}</yellow>")
|
|
53
|
+
)
|
|
54
|
+
print_formatted_text(
|
|
55
|
+
HTML(
|
|
56
|
+
f"<grey>Hint:</grey> run <gold>openhands --resume {conversation_id}</gold> "
|
|
57
|
+
"to resume this conversation."
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run_cli_entry(
|
|
63
|
+
resume_conversation_id: str | None = None,
|
|
64
|
+
queued_inputs: list[str] | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Run the agent chat session using the agent SDK.
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
AgentSetupError: If agent setup fails
|
|
71
|
+
KeyboardInterrupt: If user interrupts the session
|
|
72
|
+
EOFError: If EOF is encountered
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# Normalize queued_inputs to a local copy to prevent mutating the caller's list
|
|
76
|
+
pending_inputs = list(queued_inputs) if queued_inputs else []
|
|
77
|
+
|
|
78
|
+
conversation_id = uuid.uuid4()
|
|
79
|
+
if resume_conversation_id:
|
|
80
|
+
try:
|
|
81
|
+
conversation_id = uuid.UUID(resume_conversation_id)
|
|
82
|
+
except ValueError:
|
|
83
|
+
print_formatted_text(
|
|
84
|
+
HTML(
|
|
85
|
+
f"<yellow>Warning: '{resume_conversation_id}' is not a valid "
|
|
86
|
+
f"UUID.</yellow>"
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
initialized_agent = verify_agent_exists_or_setup_agent()
|
|
93
|
+
except MissingAgentSpec:
|
|
94
|
+
print_formatted_text(
|
|
95
|
+
HTML("\n<yellow>Setup is required to use OpenHands CLI.</yellow>")
|
|
96
|
+
)
|
|
97
|
+
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
display_welcome(conversation_id, bool(resume_conversation_id))
|
|
101
|
+
|
|
102
|
+
# Track session start time for uptime calculation
|
|
103
|
+
session_start_time = datetime.now()
|
|
104
|
+
|
|
105
|
+
# Create conversation runner to handle state machine logic
|
|
106
|
+
runner = None
|
|
107
|
+
conversation = None
|
|
108
|
+
session = get_session_prompter()
|
|
109
|
+
|
|
110
|
+
# Main chat loop
|
|
111
|
+
while True:
|
|
112
|
+
try:
|
|
113
|
+
# Get user input
|
|
114
|
+
if pending_inputs:
|
|
115
|
+
user_input = pending_inputs.pop(0)
|
|
116
|
+
else:
|
|
117
|
+
user_input = session.prompt(
|
|
118
|
+
HTML("<gold>> </gold>"),
|
|
119
|
+
multiline=False,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if not user_input.strip():
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
# Handle commands
|
|
126
|
+
command = user_input.strip().lower()
|
|
127
|
+
|
|
128
|
+
message = Message(
|
|
129
|
+
role="user",
|
|
130
|
+
content=[TextContent(text=user_input)],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if command == "/exit":
|
|
134
|
+
exit_confirmation = exit_session_confirmation()
|
|
135
|
+
if exit_confirmation == UserConfirmation.ACCEPT:
|
|
136
|
+
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
|
|
137
|
+
_print_exit_hint(str(conversation_id))
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
elif command == "/settings":
|
|
141
|
+
settings_screen = SettingsScreen(
|
|
142
|
+
runner.conversation if runner else None
|
|
143
|
+
)
|
|
144
|
+
settings_screen.display_settings()
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
elif command == "/mcp":
|
|
148
|
+
mcp_screen = MCPScreen()
|
|
149
|
+
mcp_screen.display_mcp_info(initialized_agent)
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
elif command == "/clear":
|
|
153
|
+
display_welcome(conversation_id)
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
elif command == "/new":
|
|
157
|
+
try:
|
|
158
|
+
# Start a fresh conversation (no resume ID = new conversation)
|
|
159
|
+
conversation_id = uuid.uuid4()
|
|
160
|
+
runner = None
|
|
161
|
+
conversation = None
|
|
162
|
+
display_welcome(conversation_id, resume=False)
|
|
163
|
+
print_formatted_text(
|
|
164
|
+
HTML("<green>✓ Started fresh conversation</green>")
|
|
165
|
+
)
|
|
166
|
+
continue
|
|
167
|
+
except Exception as e:
|
|
168
|
+
print_formatted_text(
|
|
169
|
+
HTML(f"<red>Error starting fresh conversation: {e}</red>")
|
|
170
|
+
)
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
elif command == "/help":
|
|
174
|
+
display_help()
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
elif command == "/status":
|
|
178
|
+
if conversation is not None:
|
|
179
|
+
display_status(conversation, session_start_time=session_start_time)
|
|
180
|
+
else:
|
|
181
|
+
print_formatted_text(
|
|
182
|
+
HTML("<yellow>No active conversation</yellow>")
|
|
183
|
+
)
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
elif command == "/confirm":
|
|
187
|
+
if runner is not None:
|
|
188
|
+
runner.toggle_confirmation_mode()
|
|
189
|
+
new_status = (
|
|
190
|
+
"enabled" if runner.is_confirmation_mode_active else "disabled"
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
new_status = "disabled (no active conversation)"
|
|
194
|
+
print_formatted_text(
|
|
195
|
+
HTML(f"<yellow>Confirmation mode {new_status}</yellow>")
|
|
196
|
+
)
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
elif command == "/resume":
|
|
200
|
+
if not runner:
|
|
201
|
+
print_formatted_text(
|
|
202
|
+
HTML("<yellow>No active conversation running...</yellow>")
|
|
203
|
+
)
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
conversation = runner.conversation
|
|
207
|
+
if not (
|
|
208
|
+
conversation.state.execution_status
|
|
209
|
+
== ConversationExecutionStatus.PAUSED
|
|
210
|
+
or conversation.state.execution_status
|
|
211
|
+
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
|
|
212
|
+
):
|
|
213
|
+
print_formatted_text(
|
|
214
|
+
HTML("<red>No paused conversation to resume...</red>")
|
|
215
|
+
)
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
# Resume without new message
|
|
219
|
+
message = None
|
|
220
|
+
|
|
221
|
+
if not runner or not conversation:
|
|
222
|
+
conversation = setup_conversation(conversation_id)
|
|
223
|
+
runner = ConversationRunner(conversation)
|
|
224
|
+
runner.process_message(message)
|
|
225
|
+
|
|
226
|
+
print() # Add spacing
|
|
227
|
+
|
|
228
|
+
except KeyboardInterrupt:
|
|
229
|
+
exit_confirmation = exit_session_confirmation()
|
|
230
|
+
if exit_confirmation == UserConfirmation.ACCEPT:
|
|
231
|
+
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
|
|
232
|
+
_print_exit_hint(str(conversation_id))
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
# Clean up terminal state
|
|
236
|
+
_restore_tty()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Main argument parser for OpenHands CLI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from openhands_cli import __version__
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_main_parser() -> argparse.ArgumentParser:
|
|
9
|
+
"""Create the main argument parser with CLI as default and serve as subcommand.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
The configured argument parser
|
|
13
|
+
"""
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
description="OpenHands CLI - Terminal User Interface for OpenHands AI Agent",
|
|
16
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
17
|
+
epilog="""
|
|
18
|
+
By default, OpenHands runs in CLI mode (terminal interface).
|
|
19
|
+
Use 'serve' subcommand to launch the GUI server instead.
|
|
20
|
+
|
|
21
|
+
Examples:
|
|
22
|
+
openhands # Start CLI mode
|
|
23
|
+
openhands --resume conversation-id # Resume a conversation in CLI mode
|
|
24
|
+
openhands serve # Launch GUI server
|
|
25
|
+
openhands serve --gpu # Launch GUI server with GPU support
|
|
26
|
+
openhands acp # Start as Agent-Client Protocol
|
|
27
|
+
server for clients like Zed IDE
|
|
28
|
+
""",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Version argument
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--version",
|
|
34
|
+
"-v",
|
|
35
|
+
action="version",
|
|
36
|
+
version=f"OpenHands CLI {__version__}",
|
|
37
|
+
help="Show the version number and exit",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"-t",
|
|
42
|
+
"--task",
|
|
43
|
+
type=str,
|
|
44
|
+
help="Initial task text to seed the conversation with",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"-f",
|
|
49
|
+
"--file",
|
|
50
|
+
type=str,
|
|
51
|
+
help="Path to a file whose contents will seed the initial conversation",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# CLI arguments at top level (default mode)
|
|
55
|
+
parser.add_argument("--resume", type=str, help="Conversation ID to resume")
|
|
56
|
+
|
|
57
|
+
# Subcommands
|
|
58
|
+
subparsers = parser.add_subparsers(dest="command", help="Additional commands")
|
|
59
|
+
|
|
60
|
+
# Add serve subcommand
|
|
61
|
+
serve_parser = subparsers.add_parser(
|
|
62
|
+
"serve", help="Launch the OpenHands GUI server using Docker (web interface)"
|
|
63
|
+
)
|
|
64
|
+
serve_parser.add_argument(
|
|
65
|
+
"--mount-cwd",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Mount the current working directory in the Docker container",
|
|
68
|
+
)
|
|
69
|
+
serve_parser.add_argument(
|
|
70
|
+
"--gpu", action="store_true", help="Enable GPU support in the Docker container"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Add ACP subcommand
|
|
74
|
+
subparsers.add_parser(
|
|
75
|
+
"acp", help="Start OpenHands as an Agent Client Protocol (ACP) agent"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return parser
|