Skip to content
21 changes: 17 additions & 4 deletions openhands-cli/openhands_cli/agent_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import sys

from openhands.sdk import (
Message,
TextContent,
Expand Down Expand Up @@ -37,10 +38,20 @@ def _restore_tty() -> None:
except Exception:
pass


def run_cli_entry() -> None:
def _print_exit_hint(conversation_id: str) -> None:
"""Print a resume hint with the current conversation ID."""
print_formatted_text(HTML(f"<grey>Conversation ID:</grey> <yellow>{conversation_id}</yellow>"))
print_formatted_text(
HTML(
f"<grey>Hint:</grey> run <gold>openhands-cli --resume {conversation_id}</gold> "
"to resume this conversation."
)
)

def run_cli_entry(resume_conversation_id: str | None = None) -> None:
"""Run the agent chat session using the agent SDK.


Raises:
AgentSetupError: If agent setup fails
KeyboardInterrupt: If user interrupts the session
Expand All @@ -52,11 +63,11 @@ def run_cli_entry() -> None:

while not conversation:
try:
conversation = setup_conversation()
conversation = setup_conversation(resume_conversation_id)
except MissingAgentSpec:
settings_screen.handle_basic_settings(escapable=False)

display_welcome(conversation.id)
display_welcome(conversation.id, bool(resume_conversation_id))

# Create conversation runner to handle state machine logic
runner = ConversationRunner(conversation)
Expand Down Expand Up @@ -86,6 +97,7 @@ def run_cli_entry() -> None:
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
_print_exit_hint(conversation.id)
break

elif command == "/settings":
Expand Down Expand Up @@ -147,6 +159,7 @@ def run_cli_entry() -> None:
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
_print_exit_hint(conversation.id)
break


Expand Down
17 changes: 15 additions & 2 deletions openhands-cli/openhands_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,28 @@ class MissingAgentSpec(Exception):
"""Raised when agent specification is not found or invalid."""
pass

def setup_conversation() -> BaseConversation:
def setup_conversation(conversation_id: str | None = None) -> BaseConversation:
"""
Setup the conversation with agent.

Args:
conversation_id: conversation ID to use. If not provided, a random UUID will be generated.

Raises:
MissingAgentSpec: If agent specification is not found or invalid.
"""

conversation_id = uuid.uuid4()
# Use provided conversation_id or generate a random one
if conversation_id is None:
conversation_id = uuid.uuid4()
elif isinstance(conversation_id, str):
try:
conversation_id = uuid.UUID(conversation_id)
except ValueError as e:
print_formatted_text(
HTML(f"<yellow>Warning: '{conversation_id}' is not a valid UUID.</yellow>")
)
raise e

with LoadingContext("Initializing OpenHands agent..."):
agent_store = AgentStore()
Expand Down
16 changes: 13 additions & 3 deletions openhands-cli/openhands_cli/simple_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
This is a simplified version that demonstrates the TUI functionality.
"""

import argparse
import logging
import os

Expand All @@ -16,18 +17,27 @@
from openhands_cli.agent_chat import run_cli_entry



def main() -> None:
"""Main entry point for the OpenHands CLI.

Raises:
ImportError: If agent chat dependencies are missing
Exception: On other error conditions
"""
parser = argparse.ArgumentParser(
description="OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
)
parser.add_argument(
"--resume",
type=str,
help="Conversation ID to use for the session. If not provided, a random UUID will be generated."
)

args = parser.parse_args()

try:
# Start agent chat directly by default
run_cli_entry()
# Start agent chat
run_cli_entry(resume_conversation_id=args.resume)

except ImportError as e:
print_formatted_text(
Expand Down
11 changes: 7 additions & 4 deletions openhands-cli/openhands_cli/tui/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def get_completions(
)


def display_banner(conversation_id: str) -> None:
def display_banner(conversation_id: str, resume: bool = False) -> None:
print_formatted_text(
HTML(r"""<gold>
___ _ _ _
Expand All @@ -59,7 +59,10 @@ def display_banner(conversation_id: str) -> None:
print_formatted_text(HTML(f"<grey>OpenHands CLI v{__version__}</grey>"))

print_formatted_text("")
print_formatted_text(HTML(f"<grey>Initialized conversation {conversation_id}</grey>"))
if not resume:
print_formatted_text(HTML(f"<grey>Initialized conversation {conversation_id}</grey>"))
else:
print_formatted_text(HTML(f"<grey>Resumed conversation {conversation_id}</grey>"))
print_formatted_text("")


Expand All @@ -81,10 +84,10 @@ def display_help() -> None:
print_formatted_text("")


def display_welcome(conversation_id: UUID) -> None:
def display_welcome(conversation_id: UUID, resume: bool = False) -> None:
"""Display welcome message."""
clear()
display_banner(str(conversation_id)[0:8])
display_banner(str(conversation_id), resume)
print_formatted_text(HTML("<gold>Let's start building!</gold>"))
print_formatted_text(
HTML(
Expand Down
75 changes: 34 additions & 41 deletions openhands-cli/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,23 @@
class TestMainEntryPoint:
"""Test the main entry point behavior."""

@patch('openhands_cli.agent_chat.setup_conversation')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.get_session_prompter')
@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli'])
def test_main_starts_agent_chat_directly(
self, mock_get_session_prompter: MagicMock, mock_runner: MagicMock, mock_setup_conversation: MagicMock
self, mock_run_agent_chat: MagicMock
) -> None:
"""Test that main() starts agent chat directly when setup succeeds."""
# Mock setup_conversation to return a valid conversation
mock_conversation = MagicMock()
mock_conversation.id = str(uuid.uuid4())
mock_setup_conversation.return_value = mock_conversation

# Mock prompt session to raise KeyboardInterrupt to exit the loop
mock_session = MagicMock()
mock_session.prompt.side_effect = KeyboardInterrupt()
mock_get_session_prompter.return_value = mock_session
# Mock run_cli_entry to raise KeyboardInterrupt to exit gracefully
mock_run_agent_chat.side_effect = KeyboardInterrupt()

# Should complete without raising an exception (graceful exit)
simple_main.main()

# Should call setup_conversation
mock_setup_conversation.assert_called_once()
# Should call run_cli_entry with no resume conversation ID
mock_run_agent_chat.assert_called_once_with(resume_conversation_id=None)

@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli'])
def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None:
"""Test that main() handles ImportError gracefully."""
mock_run_agent_chat.side_effect = ImportError('Missing dependency')
Expand All @@ -45,47 +38,32 @@ def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None

assert str(exc_info.value) == 'Missing dependency'

@patch('openhands_cli.agent_chat.setup_conversation')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.get_session_prompter')
@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli'])
def test_main_handles_keyboard_interrupt(
self, mock_get_session_prompter: MagicMock, mock_runner: MagicMock, mock_setup_conversation: MagicMock
self, mock_run_agent_chat: MagicMock
) -> None:
"""Test that main() handles KeyboardInterrupt gracefully."""
# Mock setup_conversation to return a valid conversation
mock_conversation = MagicMock()
mock_conversation.id = str(uuid.uuid4())
mock_setup_conversation.return_value = mock_conversation

# Mock prompt session to raise KeyboardInterrupt
mock_session = MagicMock()
mock_session.prompt.side_effect = KeyboardInterrupt()
mock_get_session_prompter.return_value = mock_session
# Mock run_cli_entry to raise KeyboardInterrupt
mock_run_agent_chat.side_effect = KeyboardInterrupt()

# Should complete without raising an exception (graceful exit)
simple_main.main()

@patch('openhands_cli.agent_chat.setup_conversation')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.get_session_prompter')
@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli'])
def test_main_handles_eof_error(
self, mock_get_session_prompter: MagicMock, mock_runner: MagicMock, mock_setup_conversation: MagicMock
self, mock_run_agent_chat: MagicMock
) -> None:
"""Test that main() handles EOFError gracefully."""
# Mock setup_conversation to return a valid conversation
mock_conversation = MagicMock()
mock_conversation.id = str(uuid.uuid4())
mock_setup_conversation.return_value = mock_conversation

# Mock prompt session to raise EOFError
mock_session = MagicMock()
mock_session.prompt.side_effect = EOFError()
mock_get_session_prompter.return_value = mock_session
# Mock run_cli_entry to raise EOFError
mock_run_agent_chat.side_effect = EOFError()

# Should complete without raising an exception (graceful exit)
simple_main.main()

@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli'])
def test_main_handles_general_exception(
self, mock_run_agent_chat: MagicMock
) -> None:
Expand All @@ -97,3 +75,18 @@ def test_main_handles_general_exception(
simple_main.main()

assert str(exc_info.value) == 'Unexpected error'

@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli', '--resume', 'test-conversation-id'])
def test_main_with_resume_argument(
self, mock_run_agent_chat: MagicMock
) -> None:
"""Test that main() passes resume conversation ID when provided."""
# Mock run_cli_entry to raise KeyboardInterrupt to exit gracefully
mock_run_agent_chat.side_effect = KeyboardInterrupt()

# Should complete without raising an exception (graceful exit)
simple_main.main()

# Should call run_cli_entry with the provided resume conversation ID
mock_run_agent_chat.assert_called_once_with(resume_conversation_id='test-conversation-id')
Loading