Skip to content

Commit b67f06b

Browse files
Add macro style commands with dropdown window (#17)
* Add macro style commands with dropdown window - Implement CommandCompleter class for interactive dropdown with autocomplete - Add support for arrow key navigation and Tab completion for commands - Start agent session by default instead of showing 1-3 menu - Add enhanced command handling with /exit, /help, /clear, /status, /new - Improve UI with better welcome message and session management - Add comprehensive tests for TUI functionality Fixes #16 Co-authored-by: openhands <openhands@all-hands.dev> * ci: add pytest workflow similar to lint to run unit tests using uv + pytest\n\nCo-authored-by: openhands <openhands@all-hands.dev> * ci: add pytest workflow similar to lint (uses uv sync --extra dev and uv run pytest -v)\n\nCo-authored-by: openhands <openhands@all-hands.dev> * Fix build script to fail CI when executable test fails - Changed build.py to return exit code 1 when executable test fails - This ensures GitHub workflow 'Build and Test Binary' fails appropriately - Previously the workflow would pass even when executable couldn't run Co-authored-by: openhands <openhands@all-hands.dev> * collect data files * add trace to err * fix(build): ensure PyInstaller uses packaged SDK and keep docstrings - Remove /openhands/code from sys.path in spec so analysis freezes agent-sdk's openhands package, not monorepo - Set optimize=0 to retain docstrings required by PLY/bashlex grammar This resolves runtime errors: - No module named 'openhands.core.agent' (due to wrong package on path) - bashlex IndexError from stripped docstrings when optimize=2 Co-authored-by: openhands <openhands@all-hands.dev> * build(pyinstaller): include agent prompt templates in binary - Add collect_data_files for openhands.core.agent.codeact_agent/prompts/*.j2 so runtime can load Jinja templates Co-authored-by: openhands <openhands@all-hands.dev> * ci(build): export dummy LITELLM env vars during binary build/test - Set LITELLM_API_KEY and LITELLM_MODEL in Build and Test Binary workflow to exercise prompt/template and agent init paths - Prevents regressions like missing Jinja templates from slipping by Co-authored-by: openhands <openhands@all-hands.dev> * Fix lint issues: add type annotations and format code - Add missing return type annotations to test functions - Fix import ordering and formatting issues - Remove trailing whitespace and fix end-of-file issues - Update typing imports to use collections.abc.Generator - All pre-commit hooks now pass successfully Co-authored-by: openhands <openhands@all-hands.dev> --------- Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 5c7243f commit b67f06b

File tree

10 files changed

+333
-81
lines changed

10 files changed

+333
-81
lines changed

.github/workflows/build-test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,8 @@ jobs:
3939
uv sync --dev
4040
4141
- name: Build and test binary executable
42+
env:
43+
LITELLM_API_KEY: dummy-ci-key
44+
LITELLM_MODEL: dummy-ci-model
4245
run: |
4346
./build.sh --install-pyinstaller

.github/workflows/tests.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Workflow that runs Python unit tests with pytest
2+
name: Unit Tests
3+
4+
on:
5+
push:
6+
branches:
7+
- main
8+
pull_request:
9+
10+
concurrency:
11+
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
12+
cancel-in-progress: true
13+
14+
jobs:
15+
test-python:
16+
name: Run unit tests (pytest)
17+
runs-on: ubuntu-latest
18+
19+
steps:
20+
- name: Checkout repository
21+
uses: actions/checkout@v4
22+
with:
23+
fetch-depth: 0
24+
25+
- name: Set up Python
26+
uses: actions/setup-python@v5
27+
with:
28+
python-version: 3.12
29+
30+
- name: Install uv
31+
uses: astral-sh/setup-uv@v3
32+
with:
33+
version: "latest"
34+
35+
- name: Install dependencies
36+
run: |
37+
uv sync --extra dev
38+
39+
- name: Run tests
40+
run: |
41+
uv run pytest -v

build.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,8 @@ def main() -> int:
178178
# Test the executable
179179
if not args.no_test:
180180
if not test_executable():
181-
print("⚠️ Executable test failed, but build completed")
181+
print("❌ Executable test failed, build process failed")
182+
return 1
182183

183184
print("\n🎉 Build process completed!")
184185
print("📁 Check the 'dist/' directory for your executable")

openhands-cli.spec

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,19 @@ This spec file configures PyInstaller to create a standalone executable
66
for the OpenHands CLI application.
77
"""
88

9+
from pathlib import Path
910
import os
1011
import sys
11-
from pathlib import Path
12+
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
13+
14+
# Ensure build-time import resolution prefers the packaged SDK over the monorepo path
15+
# Remove any OpenHands monorepo paths if present (prevents importing /openhands/code/openhands)
16+
_sys_paths_to_remove = [p for p in list(sys.path) if p.startswith('/openhands/code')]
17+
for _p in _sys_paths_to_remove:
18+
try:
19+
sys.path.remove(_p)
20+
except ValueError:
21+
pass
1222

1323
# Get the project root directory (current working directory when running PyInstaller)
1424
project_root = Path.cwd()
@@ -20,19 +30,24 @@ a = Analysis(
2030
datas=[
2131
# Include any data files that might be needed
2232
# Add more data files here if needed in the future
33+
*collect_data_files('tiktoken'),
34+
*collect_data_files('tiktoken_ext'),
35+
*collect_data_files('litellm'),
36+
# Include Jinja prompt templates required by the agent SDK
37+
*collect_data_files('openhands.core.agent.codeact_agent', includes=['prompts/*.j2']),
2338
],
2439
hiddenimports=[
2540
# Explicitly include modules that might not be detected automatically
2641
'openhands_cli.tui',
2742
'openhands_cli.pt_style',
28-
'prompt_toolkit',
29-
'prompt_toolkit.formatted_text',
30-
'prompt_toolkit.shortcuts',
31-
'prompt_toolkit.styles',
32-
'prompt_toolkit.application',
33-
'prompt_toolkit.key_binding',
34-
'prompt_toolkit.layout',
35-
'prompt_toolkit.widgets',
43+
*collect_submodules('prompt_toolkit'),
44+
# Include OpenHands SDK submodules explicitly to avoid resolution issues
45+
*collect_submodules('openhands.core'),
46+
*collect_submodules('openhands.tools'),
47+
48+
*collect_submodules('tiktoken'),
49+
*collect_submodules('tiktoken_ext'),
50+
*collect_submodules('litellm'),
3651
],
3752
hookspath=[],
3853
hooksconfig={},
@@ -50,7 +65,8 @@ a = Analysis(
5065
'notebook',
5166
],
5267
noarchive=False,
53-
optimize=2, # Enable Python optimization
68+
# IMPORTANT: do not use optimize=2 (-OO) because it strips docstrings used by PLY/bashlex grammar
69+
optimize=0,
5470
)
5571
pyz = PYZ(a.pure)
5672

openhands_cli/agent_chat.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from prompt_toolkit.shortcuts import clear
2020
from pydantic import SecretStr
2121

22+
from openhands_cli.tui import CommandCompleter, display_banner, display_help
23+
2224
try:
2325
from openhands.core.agent.codeact_agent import CodeActAgent
2426
from openhands.core.config import LLMConfig
@@ -98,19 +100,15 @@ def conversation_callback(event: EventType) -> None:
98100
return None, None, None
99101

100102

101-
def display_welcome() -> None:
103+
def display_welcome(session_id: str = "chat") -> None:
102104
"""Display welcome message."""
103105
clear()
104-
print_formatted_text(HTML("<gold>🤖 OpenHands Agent Chat</gold>"))
105-
print_formatted_text(HTML("<grey>AI Agent Conversation Interface</grey>"))
106-
print()
107-
print_formatted_text(HTML("<skyblue>Commands:</skyblue>"))
108-
print_formatted_text(HTML(" <white>/exit</white> - Exit the chat"))
109-
print_formatted_text(HTML(" <white>/clear</white> - Clear the screen"))
110-
print_formatted_text(HTML(" <white>/help</white> - Show this help"))
111-
print()
106+
display_banner(session_id)
107+
print_formatted_text(HTML("<gold>Let's start building!</gold>"))
112108
print_formatted_text(
113-
HTML("<green>Type your message and press Enter to chat with the agent.</green>")
109+
HTML(
110+
"<green>What do you want to build? <grey>Type /help for help</grey></green>"
111+
)
114112
)
115113
print()
116114

@@ -122,33 +120,49 @@ def run_agent_chat() -> None:
122120
if not agent or not conversation:
123121
return
124122

125-
display_welcome()
123+
# Generate session ID
124+
import uuid
125+
126+
session_id = str(uuid.uuid4())[:8]
126127

127-
# Create prompt session
128-
session = PromptSession()
128+
display_welcome(session_id)
129+
130+
# Create prompt session with command completer
131+
session = PromptSession(completer=CommandCompleter())
129132

130133
# Main chat loop
131134
while True:
132135
try:
133136
# Get user input
134137
user_input = session.prompt(
135-
HTML("<blue>You: </blue>"),
138+
HTML("<gold>> </gold>"),
136139
multiline=False,
137140
)
138141

139142
if not user_input.strip():
140143
continue
141144

142145
# Handle commands
143-
if user_input.strip().lower() == "/exit":
146+
command = user_input.strip().lower()
147+
if command == "/exit":
144148
print_formatted_text(HTML("<yellow>Goodbye! 👋</yellow>"))
145149
break
146-
elif user_input.strip().lower() == "/clear":
147-
clear()
148-
display_welcome()
150+
elif command == "/clear":
151+
display_welcome(session_id)
152+
continue
153+
elif command == "/help":
154+
display_help()
155+
continue
156+
elif command == "/status":
157+
print_formatted_text(HTML(f"<grey>Session ID: {session_id}</grey>"))
158+
print_formatted_text(HTML("<grey>Status: Active</grey>"))
149159
continue
150-
elif user_input.strip().lower() == "/help":
151-
display_welcome()
160+
elif command == "/new":
161+
print_formatted_text(
162+
HTML("<yellow>Starting new conversation...</yellow>")
163+
)
164+
session_id = str(uuid.uuid4())[:8]
165+
display_welcome(session_id)
152166
continue
153167

154168
# Send message to agent

openhands_cli/simple_main.py

Lines changed: 26 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import sys
8+
import traceback
89

910
from prompt_toolkit import PromptSession, print_formatted_text
1011
from prompt_toolkit.formatted_text import HTML
@@ -59,56 +60,31 @@ def show_tui_demo() -> None:
5960

6061
def main() -> int:
6162
"""Main entry point for the OpenHands CLI."""
62-
while True:
63-
try:
64-
choice = show_menu()
65-
66-
if choice == "1":
67-
# Start agent chat
68-
try:
69-
from openhands_cli.agent_chat import main as run_agent_chat
70-
71-
run_agent_chat()
72-
except ImportError as e:
73-
print_formatted_text(
74-
HTML(
75-
f"<red>Error: Agent chat requires additional dependencies: {e}</red>"
76-
)
77-
)
78-
print_formatted_text(
79-
HTML(
80-
"<yellow>Please ensure the agent SDK is properly installed.</yellow>"
81-
)
82-
)
83-
except Exception as e:
84-
print_formatted_text(
85-
HTML(f"<red>Error starting agent chat: {e}</red>")
86-
)
87-
88-
elif choice == "2":
89-
# Show TUI demo
90-
show_tui_demo()
91-
92-
elif choice == "3":
93-
# Exit
94-
print_formatted_text(HTML("<yellow>Goodbye! 👋</yellow>"))
95-
break
96-
97-
else:
98-
print_formatted_text(
99-
HTML("<red>Invalid choice. Please select 1, 2, or 3.</red>")
100-
)
101-
102-
print() # Add spacing between menu iterations
103-
104-
except KeyboardInterrupt:
105-
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
106-
break
107-
except EOFError:
108-
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
109-
break
110-
111-
return 0
63+
try:
64+
# Start agent chat directly by default
65+
from openhands_cli.agent_chat import main as run_agent_chat
66+
67+
run_agent_chat()
68+
return 0
69+
70+
except ImportError as e:
71+
print_formatted_text(
72+
HTML(f"<red>Error: Agent chat requires additional dependencies: {e}</red>")
73+
)
74+
print_formatted_text(
75+
HTML("<yellow>Please ensure the agent SDK is properly installed.</yellow>")
76+
)
77+
return 1
78+
except KeyboardInterrupt:
79+
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
80+
return 0
81+
except EOFError:
82+
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
83+
return 0
84+
except Exception as e:
85+
print_formatted_text(HTML(f"<red>Error starting agent chat: {e}</red>"))
86+
traceback.print_exc()
87+
return 1
11288

11389

11490
if __name__ == "__main__":

openhands_cli/tui.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,42 @@
1+
from collections.abc import Generator
2+
13
from prompt_toolkit import print_formatted_text
4+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
5+
from prompt_toolkit.document import Document
26
from prompt_toolkit.formatted_text import HTML
37

48
from openhands_cli import __version__
59
from openhands_cli.pt_style import get_cli_style
610

711
DEFAULT_STYLE = get_cli_style()
812

13+
# Available commands with descriptions
14+
COMMANDS = {
15+
"/exit": "Exit the application",
16+
"/help": "Display available commands",
17+
"/clear": "Clear the screen",
18+
"/status": "Display conversation details",
19+
"/new": "Create a new conversation",
20+
}
21+
22+
23+
class CommandCompleter(Completer):
24+
"""Custom completer for commands with interactive dropdown."""
25+
26+
def get_completions(
27+
self, document: Document, complete_event: CompleteEvent
28+
) -> Generator[Completion, None, None]:
29+
text = document.text_before_cursor.lstrip()
30+
if text.startswith("/"):
31+
for command, description in COMMANDS.items():
32+
if command.startswith(text):
33+
yield Completion(
34+
command,
35+
start_position=-len(text),
36+
display_meta=description,
37+
style="bg:ansidarkgray fg:gold",
38+
)
39+
940

1041
def display_banner(session_id: str) -> None:
1142
print_formatted_text(
@@ -25,3 +56,21 @@ def display_banner(session_id: str) -> None:
2556
print_formatted_text("")
2657
print_formatted_text(HTML(f"<grey>Initialized conversation {session_id}</grey>"))
2758
print_formatted_text("")
59+
60+
61+
def display_help() -> None:
62+
"""Display help information about available commands."""
63+
print_formatted_text("")
64+
print_formatted_text(HTML("<gold>🤖 OpenHands CLI Help</gold>"))
65+
print_formatted_text(HTML("<grey>Available commands:</grey>"))
66+
print_formatted_text("")
67+
68+
for command, description in COMMANDS.items():
69+
print_formatted_text(HTML(f" <white>{command}</white> - {description}"))
70+
71+
print_formatted_text("")
72+
print_formatted_text(HTML("<grey>Tips:</grey>"))
73+
print_formatted_text(" • Type / and press Tab to see command suggestions")
74+
print_formatted_text(" • Use arrow keys to navigate through suggestions")
75+
print_formatted_text(" • Press Enter to select a command")
76+
print_formatted_text("")

tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for OpenHands CLI."""

0 commit comments

Comments
 (0)