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,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