Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,8 @@ jobs:
uv sync --dev

- name: Build and test binary executable
env:
LITELLM_API_KEY: dummy-ci-key
LITELLM_MODEL: dummy-ci-model
run: |
./build.sh --install-pyinstaller
41 changes: 41 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Workflow that runs Python unit tests with pytest
name: Unit Tests

on:
push:
branches:
- main
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true

jobs:
test-python:
name: Run unit tests (pytest)
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.12

- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "latest"

- name: Install dependencies
run: |
uv sync --extra dev

- name: Run tests
run: |
uv run pytest -v
3 changes: 2 additions & 1 deletion build.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ def main() -> int:
# Test the executable
if not args.no_test:
if not test_executable():
print("⚠️ Executable test failed, but build completed")
print("❌ Executable test failed, build process failed")
return 1

print("\n🎉 Build process completed!")
print("📁 Check the 'dist/' directory for your executable")
Expand Down
36 changes: 26 additions & 10 deletions openhands-cli.spec
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,19 @@ This spec file configures PyInstaller to create a standalone executable
for the OpenHands CLI application.
"""

from pathlib import Path
import os
import sys
from pathlib import Path
from PyInstaller.utils.hooks import collect_submodules, collect_data_files

# Ensure build-time import resolution prefers the packaged SDK over the monorepo path
# Remove any OpenHands monorepo paths if present (prevents importing /openhands/code/openhands)
_sys_paths_to_remove = [p for p in list(sys.path) if p.startswith('/openhands/code')]
for _p in _sys_paths_to_remove:
try:
sys.path.remove(_p)
except ValueError:
pass

# Get the project root directory (current working directory when running PyInstaller)
project_root = Path.cwd()
Expand All @@ -20,19 +30,24 @@ a = Analysis(
datas=[
# Include any data files that might be needed
# Add more data files here if needed in the future
*collect_data_files('tiktoken'),
*collect_data_files('tiktoken_ext'),
*collect_data_files('litellm'),
# Include Jinja prompt templates required by the agent SDK
*collect_data_files('openhands.core.agent.codeact_agent', includes=['prompts/*.j2']),
],
hiddenimports=[
# Explicitly include modules that might not be detected automatically
'openhands_cli.tui',
'openhands_cli.pt_style',
'prompt_toolkit',
'prompt_toolkit.formatted_text',
'prompt_toolkit.shortcuts',
'prompt_toolkit.styles',
'prompt_toolkit.application',
'prompt_toolkit.key_binding',
'prompt_toolkit.layout',
'prompt_toolkit.widgets',
*collect_submodules('prompt_toolkit'),
# Include OpenHands SDK submodules explicitly to avoid resolution issues
*collect_submodules('openhands.core'),
*collect_submodules('openhands.tools'),

*collect_submodules('tiktoken'),
*collect_submodules('tiktoken_ext'),
*collect_submodules('litellm'),
],
hookspath=[],
hooksconfig={},
Expand All @@ -50,7 +65,8 @@ a = Analysis(
'notebook',
],
noarchive=False,
optimize=2, # Enable Python optimization
# IMPORTANT: do not use optimize=2 (-OO) because it strips docstrings used by PLY/bashlex grammar
optimize=0,
)
pyz = PYZ(a.pure)

Expand Down
54 changes: 34 additions & 20 deletions openhands_cli/agent_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from prompt_toolkit.shortcuts import clear
from pydantic import SecretStr

from openhands_cli.tui import CommandCompleter, display_banner, display_help

try:
from openhands.core.agent.codeact_agent import CodeActAgent
from openhands.core.config import LLMConfig
Expand Down Expand Up @@ -98,19 +100,15 @@ def conversation_callback(event: EventType) -> None:
return None, None, None


def display_welcome() -> None:
def display_welcome(session_id: str = "chat") -> None:
"""Display welcome message."""
clear()
print_formatted_text(HTML("<gold>🤖 OpenHands Agent Chat</gold>"))
print_formatted_text(HTML("<grey>AI Agent Conversation Interface</grey>"))
print()
print_formatted_text(HTML("<skyblue>Commands:</skyblue>"))
print_formatted_text(HTML(" <white>/exit</white> - Exit the chat"))
print_formatted_text(HTML(" <white>/clear</white> - Clear the screen"))
print_formatted_text(HTML(" <white>/help</white> - Show this help"))
print()
display_banner(session_id)
print_formatted_text(HTML("<gold>Let's start building!</gold>"))
print_formatted_text(
HTML("<green>Type your message and press Enter to chat with the agent.</green>")
HTML(
"<green>What do you want to build? <grey>Type /help for help</grey></green>"
)
)
print()

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

display_welcome()
# Generate session ID
import uuid

session_id = str(uuid.uuid4())[:8]

# Create prompt session
session = PromptSession()
display_welcome(session_id)

# Create prompt session with command completer
session = PromptSession(completer=CommandCompleter())

# Main chat loop
while True:
try:
# Get user input
user_input = session.prompt(
HTML("<blue>You: </blue>"),
HTML("<gold>> </gold>"),
multiline=False,
)

if not user_input.strip():
continue

# Handle commands
if user_input.strip().lower() == "/exit":
command = user_input.strip().lower()
if command == "/exit":
print_formatted_text(HTML("<yellow>Goodbye! 👋</yellow>"))
break
elif user_input.strip().lower() == "/clear":
clear()
display_welcome()
elif command == "/clear":
display_welcome(session_id)
continue
elif command == "/help":
display_help()
continue
elif command == "/status":
print_formatted_text(HTML(f"<grey>Session ID: {session_id}</grey>"))
print_formatted_text(HTML("<grey>Status: Active</grey>"))
continue
elif user_input.strip().lower() == "/help":
display_welcome()
elif command == "/new":
print_formatted_text(
HTML("<yellow>Starting new conversation...</yellow>")
)
session_id = str(uuid.uuid4())[:8]
display_welcome(session_id)
continue

# Send message to agent
Expand Down
76 changes: 26 additions & 50 deletions openhands_cli/simple_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import sys
import traceback

from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.formatted_text import HTML
Expand Down Expand Up @@ -59,56 +60,31 @@ def show_tui_demo() -> None:

def main() -> int:
"""Main entry point for the OpenHands CLI."""
while True:
try:
choice = show_menu()

if choice == "1":
# Start agent chat
try:
from openhands_cli.agent_chat import main as run_agent_chat

run_agent_chat()
except ImportError as e:
print_formatted_text(
HTML(
f"<red>Error: Agent chat requires additional dependencies: {e}</red>"
)
)
print_formatted_text(
HTML(
"<yellow>Please ensure the agent SDK is properly installed.</yellow>"
)
)
except Exception as e:
print_formatted_text(
HTML(f"<red>Error starting agent chat: {e}</red>")
)

elif choice == "2":
# Show TUI demo
show_tui_demo()

elif choice == "3":
# Exit
print_formatted_text(HTML("<yellow>Goodbye! 👋</yellow>"))
break

else:
print_formatted_text(
HTML("<red>Invalid choice. Please select 1, 2, or 3.</red>")
)

print() # Add spacing between menu iterations

except KeyboardInterrupt:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
break
except EOFError:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
break

return 0
try:
# Start agent chat directly by default
from openhands_cli.agent_chat import main as run_agent_chat

run_agent_chat()
return 0

except ImportError as e:
print_formatted_text(
HTML(f"<red>Error: Agent chat requires additional dependencies: {e}</red>")
)
print_formatted_text(
HTML("<yellow>Please ensure the agent SDK is properly installed.</yellow>")
)
return 1
except KeyboardInterrupt:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
return 0
except EOFError:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
return 0
except Exception as e:
print_formatted_text(HTML(f"<red>Error starting agent chat: {e}</red>"))
traceback.print_exc()
return 1


if __name__ == "__main__":
Expand Down
49 changes: 49 additions & 0 deletions openhands_cli/tui.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,42 @@
from collections.abc import Generator

from prompt_toolkit import print_formatted_text
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.formatted_text import HTML

from openhands_cli import __version__
from openhands_cli.pt_style import get_cli_style

DEFAULT_STYLE = get_cli_style()

# Available commands with descriptions
COMMANDS = {
"/exit": "Exit the application",
"/help": "Display available commands",
"/clear": "Clear the screen",
"/status": "Display conversation details",
"/new": "Create a new conversation",
}


class CommandCompleter(Completer):
"""Custom completer for commands with interactive dropdown."""

def get_completions(
self, document: Document, complete_event: CompleteEvent
) -> Generator[Completion, None, None]:
text = document.text_before_cursor.lstrip()
if text.startswith("/"):
for command, description in COMMANDS.items():
if command.startswith(text):
yield Completion(
command,
start_position=-len(text),
display_meta=description,
style="bg:ansidarkgray fg:gold",
)


def display_banner(session_id: str) -> None:
print_formatted_text(
Expand All @@ -25,3 +56,21 @@ def display_banner(session_id: str) -> None:
print_formatted_text("")
print_formatted_text(HTML(f"<grey>Initialized conversation {session_id}</grey>"))
print_formatted_text("")


def display_help() -> None:
"""Display help information about available commands."""
print_formatted_text("")
print_formatted_text(HTML("<gold>🤖 OpenHands CLI Help</gold>"))
print_formatted_text(HTML("<grey>Available commands:</grey>"))
print_formatted_text("")

for command, description in COMMANDS.items():
print_formatted_text(HTML(f" <white>{command}</white> - {description}"))

print_formatted_text("")
print_formatted_text(HTML("<grey>Tips:</grey>"))
print_formatted_text(" • Type / and press Tab to see command suggestions")
print_formatted_text(" • Use arrow keys to navigate through suggestions")
print_formatted_text(" • Press Enter to select a command")
print_formatted_text("")
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for OpenHands CLI."""
Loading