OpenHands CLI is a standalone terminal interface (Textual TUI) for interacting with the OpenHands agent.
This repo contains the current CLI UX, including the Textual TUI and a browser-served view via openhands web.
- Agent-sdk example: https://github.com/All-Hands-AI/agent-sdk/blob/main/examples/hello_world.py
- If you need to compare with upstream OpenHands code, use
$GITHUB_TOKENfor access.
openhands_cli/: Core CLI/TUI code (openhands_cli/entrypoint.py,openhands_cli/tui/,openhands_cli/auth/,openhands_cli/mcp/,openhands_cli/cloud/,openhands_cli/user_actions/,openhands_cli/conversations/,openhands_cli/theme.py, helpers inopenhands_cli/utils.py). Keep new modules snake_case and colocate tests.tests/: Pytest suite covering units, integration, and snapshot tests; mirrors source layout.tui_e2e/: tests for the PyInstaller-built executable.scripts/acp/: JSON-RPC and debug helpers for ACP development;hooks/: PyInstaller/runtime hooks.- Tooling & packaging:
Makefilefor common tasks,build.sh/build.pyfor PyInstaller artifacts,openhands-cli.specfor the frozen binary,uv.lockfor resolved deps. .agents/skills/: agent guidance for this repo.
This repository uses uv for dependency management and running tooling (such as in Makefile, CI workflows, and uv.lock). Use uv 0.11.6 or newer for local development; older versions can serialize uv.lock differently around relative exclude-newer. Avoid using pip install ... directly if possible.
-
minimum supported
uvversion:0.11.6 -
install dependencies:
make install(runsuv sync) -
install dev dependencies:
make install-dev(runsuv sync --group dev) -
install pre-commit hooks:
uv run pre-commit install(included inmake build) -
build (sync + install hooks):
make build -
lint (all pre-commit hooks):
make lint -
format:
make format -
run the Textual TUI (interactive; prefer running inside tmux so you can detach with
Ctrl+b d):make run(oruv run openhands) -
run the Textual TUI (automation-friendly; use for agent-driven runs):
uv run openhands --exit-without-confirmation(quit withCtrl+Q;Ctrl+Cdoes not work once the TUI is running) -
fast TUI development (see Fast TUI Development Workflow below):
make run-watch- Auto-restart on file changes (recommended for most development)
-
run the browser-served web app (Textual
textual-serve):openhands web -
run the Docker-based OpenHands GUI server:
openhands serve -
run the ACP entrypoint:
uv run openhands-acp -
run unit/integration tests:
make test(for faster runs:uv run pytest -m "not integration" --ignore=tests/snapshots) -
run snapshot tests (Textual UI):
make test-snapshots(oruv run pytest tests/snapshots -v; use--snapshot-updatewhen updating snapshots) -
run binary tests:
make test-binary(oruv run pytest tui_e2e) -
run unit/integration + snapshot tests together:
make test-all -
build PyInstaller binaries:
./build.sh --install-pyinstaller
The fastest way to iterate on TUI changes:
make run-watchThis watches openhands_cli/ and automatically restarts the app when you save any .py or .tcss file. Just edit, save, and see your changes.
Before any commit, run make lint and only commit after it passes. Use make lint to run all pre-commit hooks on all files, and do it before every commit (not after) to avoid CI failures.
Prefer modern typing syntax (X | None over Optional[X]) in new code.
- Don’t add new root-level
.mdfiles or “summary updates” toREADME.mdunless explicitly requested (use thisAGENTS.mdfor repo guidance).
- Python 3.12, ruff formatting (88-char line limit, double quotes).
- Ruff enforced rules: pycodestyle, pyflakes, isort, pyupgrade, unused-arg checks (tests allow fixture-style args), and guards against mutable defaults.
- Keep modules/dirs snake_case; classes in CapWords; user-facing commands/flags kebab-case as in existing entrypoints.
- Type checking via
pyright(uv run pyright); prefer type hints on new functions and public interfaces.
- Unit/integration tests live under
tests/(excludingtests/snapshots) and run viamake test. - Snapshot tests live under
tests/snapshots/and run viamake test-snapshots. - Binary tests live under
tui_e2e/and run viamake test-binary. - Pytest discovery: files
test_*.py, classesTest*, functionstest_*. Use@pytest.mark.integrationfor costly flows. - Match test locations to implementation (
tests/mirrorsopenhands_cli/); add fixtures intests/conftest.pywhen shared. - Run
make testbefore PRs; run snapshot/binary tests when relevant to the change.
- Binary tests in
tui_e2e/can usemock_llm_server.pyfor deterministic testing without real LLM calls. - The mock LLM server provides OpenAI-compatible endpoints with proper tool call format.
- Use
openai/gpt-4o-mockas the model name (litellm requires a provider prefix).
The CLI uses pytest-textual-snapshot for visual regression testing of Textual UI components. Snapshots are SVG screenshots that capture the exact visual state of the application.
# Run all snapshot tests
make test-snapshots
# or: uv run pytest tests/snapshots/ -v
# Update snapshots when intentional UI changes are made
uv run pytest tests/snapshots/ --snapshot-update- Test files:
tests/snapshots/test_app_snapshots.py,tests/snapshots/test_visualizer_snapshots.py - Generated snapshots:
tests/snapshots/__snapshots__/test_app_snapshots/*.svg,tests/snapshots/__snapshots__/test_visualizer_snapshots/*.svg tests/tui/widgets/test_richlog_visualizer.pyis the primary unit test suite forrichlog_visualizer.py; for maintainability refactors there, prefer targeted unit coverage in that file plus snapshot tests only when rendered output changes visually.
Snapshot tests must be synchronous (not async). The snap_compare fixture handles async internally:
from textual.app import App, ComposeResult
from textual.widgets import Static, Footer
def test_my_widget(snap_compare):
"""Snapshot test for my widget."""
class MyTestApp(App):
def compose(self) -> ComposeResult:
yield Static("Content")
yield Footer()
assert snap_compare(MyTestApp(), terminal_size=(80, 24))To interact with the app before taking a screenshot:
def test_with_interaction(snap_compare):
class MyApp(App):
def compose(self) -> ComposeResult:
yield InputField(id="input")
async def setup(pilot):
input_field = pilot.app.query_one(InputField)
input_field.input_widget.value = "Hello!"
await pilot.pause()
assert snap_compare(MyApp(), terminal_size=(80, 24), run_before=setup)def test_with_focus(snap_compare):
assert snap_compare(
MyApp(),
terminal_size=(80, 24),
press=["tab", "tab"], # Press tab twice to move focus
)To view the generated SVG snapshots in a browser:
-
Start a local HTTP server in the snapshots directory:
cd tests/snapshots/__snapshots__/test_app_snapshots python -m http.server 12000 -
Open in browser using the work host URL:
https://work-1-<id>.prod-runtime.all-hands.dev/<snapshot-name>.svgExample snapshot names:
TestExitModalSnapshots.test_exit_modal_initial_state.svgTestVisualizerSnapshots.test_multiple_actions_alignment.svg
-
Stop the server when done:
pkill -f "python -m http.server 12000"
- Mock external dependencies so snapshots are deterministic.
- Always pass a fixed
terminal_size=(width, height). - Commit SVG snapshots.
- Review snapshot diffs carefully.
- Follow the repo’s pattern:
<scope>: <concise message> (#NNN)(seegit log), where scope is the touched area (e.g.,auth,tui,fix). - Keep commits focused; include tests and formatting in the same change when practical.
- PRs should describe behavior changes, list key commands run (e.g., tests/build), link related issues, and include before/after notes or screenshots for UI/TUI updates.
- Check in
uv.lockchanges when dependency versions move; avoid committing secrets or local config.
- Keep PRs minimally scoped; prefer multiple PRs over one large PR when it reduces risk and review load.
- Include tests for behavior changes (unit/integration/e2e as appropriate). If you can’t add tests, explain why and what manual verification you performed.
- For UI/TUI changes, snapshot tests are the preferred evidence. If snapshots aren't available/appropriate, include screenshots (and note the terminal size used).
- Before opening a PR, run this verification flow (and include the exact commands run in the PR description):
make lintmake test- If you touched ACP / binary executable code (e.g.,
tui_e2e/,openhands_cli/acp_impl/,openhands_cli/mcp/, auth/connection flow):make test-binary - If you touched TUI code (e.g.,
openhands_cli/tui/, widgets, styles, layout):make test-snapshots(use--snapshot-updateonly for intentional UI changes)
- Scope is minimal and focused on one change
- Tests added/updated for behavior changes (or PR explains why not)
-
make lint -
make test - (If ACP/binary executable touched)
make test-binary - (If TUI touched)
make test-snapshotsrun and snapshots updated/reviewed - PR description includes: what changed, why, commands run, and UI evidence (snapshots/screenshots)
- Do not embed API keys or endpoints in code; rely on runtime configuration/env vars when integrating new services.
- When packaging, verify no sensitive files are included in
dist/; adjustopenhands-cli.specif new assets are added.
The TUI uses a reactive state management pattern with clear separation of concerns. Key files are in openhands_cli/tui/core/.
ConversationContainer (state.py) - Reactive state holder
- A Textual
Containerwidget that owns all conversation-related reactive properties - Properties include:
running,conversation_id,conversation_title,confirmation_policy,pending_action_count,elapsed_seconds,metrics - UI widgets bind to these properties via
data_bind()and auto-update when state changes - Provides thread-safe state update methods (e.g.,
set_running(),set_conversation_id()) - Composes the main UI hierarchy:
ScrollableContent+InputAreaContainer
ConversationManager (conversation_manager.py) - Message router
- A thin Textual
Containerthat listens to messages and delegates to controllers - Owns:
RunnerRegistry,ConfirmationPolicyService, and all controllers - Message handlers (
@on(MessageType)) route to appropriate controllers - Provides public API methods that post messages internally
Controllers - Single-responsibility business logic
UserMessageController- Handles user input, renders messages, queues/processes with runnerConversationCrudController- Creates new conversations, resets stateConversationSwitchController- Orchestrates switching (pause current, prepare new)ConfirmationFlowController- Shows confirmation panel, handles user decisions
RunnerFactory + RunnerRegistry - Runner lifecycle
RunnerFactory- CreatesConversationRunnerinstances with dependenciesRunnerRegistry- Caches runners by conversation_id, tracks current runner
OpenHandsApp
└── ConversationManager(Container) ← message router
└── ConversationContainer(#conversation_state) ← reactive state
├── ScrollableContent(#scroll_view) ← binds to conversation_id, pending_action_count
│ ├── SplashContent(#splash_content) ← binds to conversation_id
│ └── ... dynamically added conversation widgets
└── InputAreaContainer(#input_area) ← handles slash commands
├── WorkingStatusLine ← binds to running, elapsed_seconds
├── InputField ← binds to conversation_id, pending_action_count
└── InfoStatusLine ← binds to running, metrics
- User input →
InputFieldpostsUserInputSubmitted→ bubbles toConversationManager→UserMessageController.handle_user_message() - Slash commands →
InputFieldpostsSlashCommandSubmitted→InputAreaContainerroutes to command handlers → posts operation messages (e.g.,CreateConversation) - State changes → Controllers call
ConversationContainer.set_*()methods → reactive properties update → bound widgets auto-refresh - Cross-thread updates →
ConversationContainer._schedule_update()usescall_from_thread()for thread safety
- Reactive state: UI components bind to
ConversationContainerproperties viadata_bind(), auto-update on changes - Single source of truth:
ConversationContainerowns all conversation state - Thread safety: State updates use
call_from_thread()when called from background threads - Message-based communication: Components communicate via Textual messages that bubble up the widget tree
- Controller pattern: Business logic split into focused controllers,
ConversationManageris just a router