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,176 @@
1
+ from enum import Enum
2
+
3
+ from prompt_toolkit.completion import FuzzyWordCompleter
4
+ from pydantic import SecretStr
5
+
6
+ from openhands.sdk.llm import UNVERIFIED_MODELS_EXCLUDING_BEDROCK, VERIFIED_MODELS
7
+ from openhands_cli.tui.utils import StepCounter
8
+ from openhands_cli.user_actions.utils import (
9
+ NonEmptyValueValidator,
10
+ cli_confirm,
11
+ cli_text_input,
12
+ )
13
+
14
+
15
+ class SettingsType(Enum):
16
+ BASIC = "basic"
17
+ ADVANCED = "advanced"
18
+
19
+
20
+ def settings_type_confirmation(first_time: bool = False) -> SettingsType:
21
+ question = (
22
+ "\nWelcome to OpenHands! Let's configure your LLM settings.\n"
23
+ "Choose your preferred setup method:"
24
+ )
25
+ choices = ["LLM (Basic)", "LLM (Advanced)"]
26
+ if not first_time:
27
+ question = "Which settings would you like to modify?"
28
+ choices.append("Go back")
29
+
30
+ index = cli_confirm(question, choices, escapable=True)
31
+
32
+ if choices[index] == "Go back":
33
+ raise KeyboardInterrupt
34
+
35
+ options_map = {0: SettingsType.BASIC, 1: SettingsType.ADVANCED}
36
+
37
+ return options_map.get(index, SettingsType.BASIC)
38
+
39
+
40
+ def choose_llm_provider(step_counter: StepCounter, escapable=True) -> str:
41
+ question = step_counter.next_step(
42
+ "Select LLM Provider (TAB for options, CTRL-c to cancel): "
43
+ )
44
+ options = (
45
+ list(VERIFIED_MODELS.keys()).copy()
46
+ + list(UNVERIFIED_MODELS_EXCLUDING_BEDROCK.keys()).copy()
47
+ )
48
+ alternate_option = "Select another provider"
49
+
50
+ display_options = options[:4] + [alternate_option]
51
+
52
+ index = cli_confirm(question, display_options, escapable=escapable)
53
+ chosen_option = display_options[index]
54
+ if display_options[index] != alternate_option:
55
+ return chosen_option
56
+
57
+ question = step_counter.existing_step(
58
+ "Type LLM Provider (TAB to complete, CTRL-c to cancel): "
59
+ )
60
+ return cli_text_input(
61
+ question, escapable=True, completer=FuzzyWordCompleter(options, WORD=True)
62
+ )
63
+
64
+
65
+ def choose_llm_model(step_counter: StepCounter, provider: str, escapable=True) -> str:
66
+ """Choose LLM model using spec-driven approach. Return (model, deferred)."""
67
+
68
+ models = VERIFIED_MODELS.get(
69
+ provider, []
70
+ ) + UNVERIFIED_MODELS_EXCLUDING_BEDROCK.get(provider, [])
71
+
72
+ if provider == "openhands":
73
+ question = (
74
+ step_counter.next_step("Select Available OpenHands Model:\n")
75
+ + "LLM usage is billed at the providers’ rates with no markup. Details: https://docs.all-hands.dev/usage/llms/openhands-llms"
76
+ )
77
+ else:
78
+ question = step_counter.next_step(
79
+ "Select LLM Model (TAB for options, CTRL-c to cancel): "
80
+ )
81
+ alternate_option = "Select another model"
82
+ display_options = models[:10] + [alternate_option]
83
+ index = cli_confirm(question, display_options, escapable=escapable)
84
+ chosen_option = display_options[index]
85
+
86
+ if chosen_option != alternate_option:
87
+ return chosen_option
88
+
89
+ question = step_counter.existing_step(
90
+ "Type model id (TAB to complete, CTRL-c to cancel): "
91
+ )
92
+
93
+ return cli_text_input(
94
+ question, escapable=True, completer=FuzzyWordCompleter(models, WORD=True)
95
+ )
96
+
97
+
98
+ def prompt_api_key(
99
+ step_counter: StepCounter,
100
+ provider: str,
101
+ existing_api_key: str | SecretStr | None = None,
102
+ escapable=True,
103
+ ) -> str:
104
+ api_key: str | None = (
105
+ existing_api_key.get_secret_value()
106
+ if isinstance(existing_api_key, SecretStr)
107
+ else existing_api_key
108
+ )
109
+ helper_text = (
110
+ "\nYou can find your OpenHands LLM API Key in the API Keys tab of "
111
+ "OpenHands Cloud: "
112
+ "https://app.all-hands.dev/settings/api-keys\n"
113
+ if provider == "openhands"
114
+ else ""
115
+ )
116
+
117
+ if api_key:
118
+ masked_key = api_key[:3] + "***"
119
+ question = (
120
+ f"Enter API Key [{masked_key}] (CTRL-c to cancel, ENTER to keep "
121
+ f"current, type new to change): "
122
+ )
123
+ # For existing keys, allow empty input to keep current key
124
+ validator = None
125
+ else:
126
+ question = "Enter API Key (CTRL-c to cancel): "
127
+ # For new keys, require non-empty input
128
+ validator = NonEmptyValueValidator()
129
+
130
+ question = helper_text + step_counter.next_step(question)
131
+ user_input = cli_text_input(
132
+ question, escapable=escapable, validator=validator, is_password=True
133
+ )
134
+
135
+ # If user pressed ENTER with existing key (empty input), return the existing key
136
+ if api_key and not user_input.strip():
137
+ return api_key
138
+
139
+ return user_input
140
+
141
+
142
+ # Advanced settings functions
143
+ def prompt_custom_model(step_counter: StepCounter, escapable=True) -> str:
144
+ """Prompt for custom model name."""
145
+ question = step_counter.next_step("Custom Model (CTRL-c to cancel): ")
146
+ return cli_text_input(question, escapable=escapable)
147
+
148
+
149
+ def prompt_base_url(step_counter: StepCounter, escapable=True) -> str:
150
+ """Prompt for base URL."""
151
+ question = step_counter.next_step("Base URL (CTRL-c to cancel): ")
152
+ return cli_text_input(
153
+ question, escapable=escapable, validator=NonEmptyValueValidator()
154
+ )
155
+
156
+
157
+ def choose_memory_condensation(step_counter: StepCounter, escapable=True) -> bool:
158
+ """Choose memory condensation setting."""
159
+ question = step_counter.next_step("Memory Condensation (CTRL-c to cancel): ")
160
+ choices = ["Enable", "Disable"]
161
+
162
+ index = cli_confirm(question, choices, escapable=escapable)
163
+ return index == 0 # True for Enable, False for Disable
164
+
165
+
166
+ def save_settings_confirmation() -> bool:
167
+ """Prompt user to confirm saving settings."""
168
+ question = "Save new settings? (They will take effect after restart)"
169
+ discard = "No, discard"
170
+ options = ["Yes, save", discard]
171
+
172
+ index = cli_confirm(question, options, escapable=True)
173
+ if options[index] == discard:
174
+ raise KeyboardInterrupt
175
+
176
+ return True
@@ -0,0 +1,17 @@
1
+ from enum import Enum
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from openhands.sdk.security.confirmation_policy import ConfirmationPolicyBase
6
+
7
+
8
+ class UserConfirmation(Enum):
9
+ ACCEPT = "accept"
10
+ REJECT = "reject"
11
+ DEFER = "defer"
12
+
13
+
14
+ class ConfirmationResult(BaseModel):
15
+ decision: UserConfirmation
16
+ policy_change: ConfirmationPolicyBase | None = None
17
+ reason: str = ""
@@ -0,0 +1,199 @@
1
+ from prompt_toolkit import HTML, PromptSession
2
+ from prompt_toolkit.application import Application
3
+ from prompt_toolkit.completion import Completer
4
+ from prompt_toolkit.input.base import Input
5
+ from prompt_toolkit.key_binding import KeyBindings
6
+ from prompt_toolkit.key_binding.key_processor import KeyPressEvent
7
+ from prompt_toolkit.layout.containers import HSplit, Window
8
+ from prompt_toolkit.layout.controls import FormattedTextControl
9
+ from prompt_toolkit.layout.dimension import Dimension
10
+ from prompt_toolkit.layout.layout import Layout
11
+ from prompt_toolkit.output.base import Output
12
+ from prompt_toolkit.shortcuts import prompt
13
+ from prompt_toolkit.validation import ValidationError, Validator
14
+
15
+ from openhands_cli.tui import DEFAULT_STYLE
16
+ from openhands_cli.tui.tui import CommandCompleter
17
+
18
+
19
+ def build_keybindings(
20
+ choices: list[str], selected: list[int], escapable: bool
21
+ ) -> KeyBindings:
22
+ """Create keybindings for the confirm UI. Split for testability."""
23
+ kb = KeyBindings()
24
+
25
+ @kb.add("up")
26
+ def _handle_up(event: KeyPressEvent) -> None: # noqa: ARG001
27
+ selected[0] = (selected[0] - 1) % len(choices)
28
+
29
+ @kb.add("down")
30
+ def _handle_down(event: KeyPressEvent) -> None: # noqa: ARG001
31
+ selected[0] = (selected[0] + 1) % len(choices)
32
+
33
+ @kb.add("enter")
34
+ def _handle_enter(event: KeyPressEvent) -> None:
35
+ event.app.exit(result=selected[0])
36
+
37
+ if escapable:
38
+
39
+ @kb.add("c-c") # Ctrl+C
40
+ def _handle_hard_interrupt(event: KeyPressEvent) -> None:
41
+ event.app.exit(exception=KeyboardInterrupt())
42
+
43
+ @kb.add("c-p") # Ctrl+P
44
+ def _handle_pause_interrupt(event: KeyPressEvent) -> None:
45
+ event.app.exit(exception=KeyboardInterrupt())
46
+
47
+ @kb.add("escape") # Escape key
48
+ def _handle_escape(event: KeyPressEvent) -> None:
49
+ event.app.exit(exception=KeyboardInterrupt())
50
+
51
+ return kb
52
+
53
+
54
+ def build_layout(question: str, choices: list[str], selected_ref: list[int]) -> Layout:
55
+ """Create the layout for the confirm UI. Split for testability."""
56
+
57
+ def get_choice_text() -> list[tuple[str, str]]:
58
+ lines: list[tuple[str, str]] = []
59
+ lines.append(("class:question", f"{question}\n\n"))
60
+ for i, choice in enumerate(choices):
61
+ is_selected = i == selected_ref[0]
62
+ prefix = "> " if is_selected else " "
63
+ style = "class:selected" if is_selected else "class:unselected"
64
+ lines.append((style, f"{prefix}{choice}\n"))
65
+ return lines
66
+
67
+ content_window = Window(
68
+ FormattedTextControl(get_choice_text),
69
+ always_hide_cursor=True,
70
+ height=Dimension(max=16),
71
+ )
72
+ return Layout(HSplit([content_window]))
73
+
74
+
75
+ def cli_confirm(
76
+ question: str = "Are you sure?",
77
+ choices: list[str] | None = None,
78
+ initial_selection: int = 0,
79
+ escapable: bool = False,
80
+ input: Input | None = None, # strictly for unit testing
81
+ output: Output | None = None, # strictly for unit testing
82
+ ) -> int:
83
+ """Display a confirmation prompt with the given question and choices.
84
+
85
+ Returns the index of the selected choice.
86
+ """
87
+ if choices is None:
88
+ choices = ["Yes", "No"]
89
+ selected = [initial_selection] # Using list to allow modification in closure
90
+
91
+ kb = build_keybindings(choices, selected, escapable)
92
+ layout = build_layout(question, choices, selected)
93
+
94
+ app = Application(
95
+ layout=layout,
96
+ key_bindings=kb,
97
+ style=DEFAULT_STYLE,
98
+ full_screen=False,
99
+ input=input,
100
+ output=output,
101
+ )
102
+
103
+ return int(app.run(in_thread=True))
104
+
105
+
106
+ def cli_text_input(
107
+ question: str,
108
+ escapable: bool = True,
109
+ completer: Completer | None = None,
110
+ validator: Validator | None = None,
111
+ is_password: bool = False,
112
+ ) -> str:
113
+ """Prompt user to enter text input with optional validation.
114
+
115
+ Args:
116
+ question: The prompt question to display
117
+ escapable: Whether the user can escape with Ctrl+C or Ctrl+P
118
+ completer: Optional completer for tab completion
119
+ validator: Optional callable that takes a string and returns True if valid.
120
+ If validation fails, the callable should display error messages
121
+ and the user will be reprompted.
122
+
123
+ Returns:
124
+ The validated user input string (stripped of whitespace)
125
+ """
126
+
127
+ kb = KeyBindings()
128
+
129
+ if escapable:
130
+
131
+ @kb.add("c-c")
132
+ def _(event: KeyPressEvent) -> None:
133
+ event.app.exit(exception=KeyboardInterrupt())
134
+
135
+ @kb.add("c-p")
136
+ def _(event: KeyPressEvent) -> None:
137
+ event.app.exit(exception=KeyboardInterrupt())
138
+
139
+ @kb.add("enter")
140
+ def _handle_enter(event: KeyPressEvent):
141
+ event.app.exit(result=event.current_buffer.text)
142
+
143
+ reason = str(
144
+ prompt(
145
+ question,
146
+ style=DEFAULT_STYLE,
147
+ key_bindings=kb,
148
+ completer=completer,
149
+ is_password=is_password,
150
+ validator=validator,
151
+ )
152
+ )
153
+ return reason.strip()
154
+
155
+
156
+ def get_session_prompter(
157
+ input: Input | None = None, # strictly for unit testing
158
+ output: Output | None = None, # strictly for unit testing
159
+ ) -> PromptSession:
160
+ bindings = KeyBindings()
161
+
162
+ @bindings.add("\\", "enter")
163
+ def _(event: KeyPressEvent) -> None:
164
+ # Typing '\' + Enter forces a newline regardless
165
+ event.current_buffer.insert_text("\n")
166
+
167
+ @bindings.add("enter")
168
+ def _handle_enter(event: KeyPressEvent):
169
+ event.app.exit(result=event.current_buffer.text)
170
+
171
+ @bindings.add("c-c")
172
+ def _keyboard_interrupt(event: KeyPressEvent):
173
+ event.app.exit(exception=KeyboardInterrupt())
174
+
175
+ session = PromptSession(
176
+ completer=CommandCompleter(),
177
+ key_bindings=bindings,
178
+ multiline=True,
179
+ input=input,
180
+ output=output,
181
+ style=DEFAULT_STYLE,
182
+ placeholder=HTML(
183
+ "<placeholder>"
184
+ "Type your message… (tip: press <b>\\</b> + <b>Enter</b> to insert "
185
+ "a newline)"
186
+ "</placeholder>"
187
+ ),
188
+ )
189
+
190
+ return session
191
+
192
+
193
+ class NonEmptyValueValidator(Validator):
194
+ def validate(self, document):
195
+ text = document.text
196
+ if not text:
197
+ raise ValidationError(
198
+ message="API key cannot be empty. Please enter a valid API key."
199
+ )
openhands_cli/utils.py ADDED
@@ -0,0 +1,122 @@
1
+ """Utility functions for LLM configuration in OpenHands CLI."""
2
+
3
+ import os
4
+ from argparse import Namespace
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from prompt_toolkit import print_formatted_text
9
+ from prompt_toolkit.formatted_text import HTML
10
+
11
+ from openhands.sdk import LLM
12
+ from openhands.tools.preset import get_default_agent
13
+
14
+
15
+ def should_set_litellm_extra_body(model_name: str) -> bool:
16
+ """
17
+ Determine if litellm_extra_body should be set based on the model name.
18
+
19
+ Only set litellm_extra_body for openhands models to avoid issues
20
+ with providers that don't support extra_body parameters.
21
+
22
+ The SDK internally translates "openhands/" prefix to "litellm_proxy/"
23
+ when making API calls.
24
+
25
+ Args:
26
+ model_name: Name of the LLM model
27
+
28
+ Returns:
29
+ True if litellm_extra_body should be set, False otherwise
30
+ """
31
+ return "openhands/" in model_name
32
+
33
+
34
+ def get_llm_metadata(
35
+ model_name: str,
36
+ llm_type: str,
37
+ session_id: str | None = None,
38
+ user_id: str | None = None,
39
+ ) -> dict[str, Any]:
40
+ """
41
+ Generate LLM metadata for OpenHands CLI.
42
+
43
+ Args:
44
+ model_name: Name of the LLM model
45
+ agent_name: Name of the agent (defaults to "openhands")
46
+ session_id: Optional session identifier
47
+ user_id: Optional user identifier
48
+
49
+ Returns:
50
+ Dictionary containing metadata for LLM initialization
51
+ """
52
+ # Import here to avoid circular imports
53
+ openhands_sdk_version: str = "n/a"
54
+ try:
55
+ import openhands.sdk
56
+
57
+ openhands_sdk_version = openhands.sdk.__version__
58
+ except (ModuleNotFoundError, AttributeError):
59
+ pass
60
+
61
+ openhands_tools_version: str = "n/a"
62
+ try:
63
+ import openhands.tools
64
+
65
+ openhands_tools_version = openhands.tools.__version__
66
+ except (ModuleNotFoundError, AttributeError):
67
+ pass
68
+
69
+ metadata = {
70
+ "trace_version": openhands_sdk_version,
71
+ "tags": [
72
+ "app:openhands",
73
+ f"model:{model_name}",
74
+ f"type:{llm_type}",
75
+ f"web_host:{os.environ.get('WEB_HOST', 'unspecified')}",
76
+ f"openhands_sdk_version:{openhands_sdk_version}",
77
+ f"openhands_tools_version:{openhands_tools_version}",
78
+ ],
79
+ }
80
+ if session_id is not None:
81
+ metadata["session_id"] = session_id
82
+ if user_id is not None:
83
+ metadata["trace_user_id"] = user_id
84
+ return metadata
85
+
86
+
87
+ def get_default_cli_agent(llm: LLM):
88
+ agent = get_default_agent(llm=llm, cli_mode=True)
89
+
90
+ return agent
91
+
92
+
93
+ def create_seeded_instructions_from_args(args: Namespace) -> list[str] | None:
94
+ """
95
+ Build initial CLI input(s) from parsed arguments.
96
+ """
97
+ if getattr(args, "command", None) == "serve":
98
+ return None
99
+
100
+ # --file takes precedence over --task
101
+ if getattr(args, "file", None):
102
+ path = Path(args.file).expanduser()
103
+ try:
104
+ content = path.read_text(encoding="utf-8")
105
+ except OSError as exc:
106
+ print_formatted_text(HTML(f"<red>Failed to read file {path}: {exc}</red>"))
107
+ raise SystemExit(1)
108
+
109
+ initial_message = (
110
+ "Starting this session with file context.\n\n"
111
+ f"File path: {path}\n\n"
112
+ "File contents:\n"
113
+ "--------------------\n"
114
+ f"{content}\n"
115
+ "--------------------\n"
116
+ )
117
+ return [initial_message]
118
+
119
+ if getattr(args, "task", None):
120
+ return [args.task]
121
+
122
+ return None
@@ -0,0 +1,83 @@
1
+ """Version checking utilities for OpenHands CLI."""
2
+
3
+ import json
4
+ import urllib.request
5
+ from typing import NamedTuple
6
+
7
+ from openhands_cli import __version__
8
+
9
+
10
+ class VersionInfo(NamedTuple):
11
+ """Version information for display."""
12
+
13
+ current_version: str
14
+ latest_version: str | None
15
+ needs_update: bool
16
+ error: str | None
17
+
18
+
19
+ def parse_version(version_str: str) -> tuple[int, ...]:
20
+ """Parse a version string into a tuple of integers for comparison.
21
+
22
+ Args:
23
+ version_str: Version string like "1.2.1"
24
+
25
+ Returns:
26
+ Tuple of integers like (1, 2, 1)
27
+ """
28
+ return tuple(int(x) for x in version_str.split("."))
29
+
30
+
31
+ def check_for_updates(timeout: float = 2.0) -> VersionInfo:
32
+ """Check if a newer version is available on PyPI.
33
+
34
+ Args:
35
+ timeout: Timeout for PyPI request in seconds
36
+
37
+ Returns:
38
+ VersionInfo with update information
39
+ """
40
+ current = __version__
41
+
42
+ # Handle dev versions or special cases
43
+ if current == "0.0.0" or "dev" in current:
44
+ return VersionInfo(
45
+ current_version=current,
46
+ latest_version=None,
47
+ needs_update=False,
48
+ error=None,
49
+ )
50
+
51
+ try:
52
+ # Fetch latest version from PyPI
53
+ url = "https://pypi.org/pypi/openhands/json"
54
+ req = urllib.request.Request(url)
55
+ req.add_header("User-Agent", f"openhands-cli/{current}")
56
+
57
+ with urllib.request.urlopen(req, timeout=timeout) as response:
58
+ data = json.loads(response.read().decode("utf-8"))
59
+ latest = data["info"]["version"]
60
+
61
+ # Compare versions
62
+ try:
63
+ current_tuple = parse_version(current)
64
+ latest_tuple = parse_version(latest)
65
+ needs_update = latest_tuple > current_tuple
66
+ except (ValueError, AttributeError):
67
+ # If we can't parse versions, assume no update needed
68
+ needs_update = False
69
+
70
+ return VersionInfo(
71
+ current_version=current,
72
+ latest_version=latest,
73
+ needs_update=needs_update,
74
+ error=None,
75
+ )
76
+ except Exception as e:
77
+ # Don't block on network errors - just return current version
78
+ return VersionInfo(
79
+ current_version=current,
80
+ latest_version=None,
81
+ needs_update=False,
82
+ error=str(e),
83
+ )