Skip to content
Open
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
5 changes: 5 additions & 0 deletions openhands_cli/tui/core/conversation_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,11 @@ def pause_conversation(self) -> None:
"""Pause the current conversation."""
self.post_message(PauseConversation())

def replay_history(self, conversation_id: uuid.UUID) -> None:
"""Create (or retrieve) a runner for the conversation and replay its events."""
runner = self._runners.get_or_create(conversation_id)
runner.replay_events()

def reload_visualizer_configuration(self) -> None:
"""Reload the visualizer configuration for the current conversation."""
runner = self._runners.current
Expand Down
29 changes: 29 additions & 0 deletions openhands_cli/tui/core/conversation_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,35 @@ def __init__(
def is_confirmation_mode_active(self) -> bool:
return self._state.is_confirmation_active

# Maximum number of events to replay on resume/switch to avoid
# O(n) memory and rendering cost for long conversations.
MAX_REPLAY_EVENTS = 50

def replay_events(self) -> None:
"""Replay the tail of persisted events through the visualizer.

Only the last MAX_REPLAY_EVENTS are replayed to avoid loading
unbounded history into memory. User messages are rendered via
render_user_message since on_event skips them.
"""
events = self.conversation.state.events
start = max(0, len(events) - self.MAX_REPLAY_EVENTS)

# Render summary banner if events were truncated
if start > 0:
self.visualizer.render_replay_summary(start)

for event in events[start:]:
if ConversationVisualizer.is_user_initiated_message(event):
text = str(event.visualize)
if text.strip():
self.visualizer.render_user_message(text)
continue
self.visualizer.on_event(event)

# Scroll to the most recent content so it's visible on load
self.visualizer.scroll_to_bottom()

async def queue_message(self, user_input: str) -> None:
"""Queue a message for a running conversation"""
assert self.conversation is not None, "Conversation should be running"
Expand Down
5 changes: 4 additions & 1 deletion openhands_cli/tui/core/conversation_switch_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ def _prepare_switch(self, target_id: uuid.UUID) -> None:
self._state.reset_conversation_state()

self._runners.clear_current()
self._runners.get_or_create(target_id)
runner = self._runners.get_or_create(target_id)

# Replay persisted events so history is visible in the UI
runner.replay_events()

self._state.finish_switching(target_id)
self._state.set_switch_confirmation_target(None)
Expand Down
22 changes: 21 additions & 1 deletion openhands_cli/tui/textual_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ def __init__(
get_conversations_dir(), initial_conversation_id
)

# Track whether this is a resumed conversation
self._is_resuming = resume_conversation_id is not None

# Store queued inputs (copy to prevent mutating caller's list)
self.pending_inputs = list(queued_inputs) if queued_inputs else []

Expand Down Expand Up @@ -426,7 +429,8 @@ def _initialize_main_ui(self) -> None:
1. Checking if the agent has a critic configured
2. Collecting and displaying loaded resources (skills, hooks, MCPs)
3. Initializing the splash content (one-time setup)
4. Processing any queued inputs
4. Replaying conversation history when resuming
5. Processing any queued inputs

UI lifecycle is owned by OpenHandsApp, not ConversationContainer. The splash
content initialization is a direct method call, not a reactive
Expand Down Expand Up @@ -463,9 +467,25 @@ def _initialize_main_ui(self) -> None:
# in SplashContent via data_bind
self.conversation_state.set_loaded_resources(loaded_resources)

# When resuming, eagerly create the runner and replay persisted events.
# Skip when agent is unavailable (first-time user with no config yet).
if self._is_resuming and agent is not None:
self._replay_conversation_history()

# Process any queued inputs
self._process_queued_inputs()

def _replay_conversation_history(self) -> None:
"""Create the runner and replay persisted events for a resumed conversation."""
conversation_id = self.conversation_state.conversation_id
if conversation_id is None:
return

try:
self.conversation_manager.replay_history(conversation_id)
except Exception as e:
self.notify(f"Resume error: {e}", severity="error")

def _process_queued_inputs(self) -> None:
"""Process any queued inputs from --task or --file arguments.

Expand Down
7 changes: 7 additions & 0 deletions openhands_cli/tui/textual_app.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ Screen {
color: $primary;
}

.replay-summary {
padding: 0 1;
margin-top: 1;
color: $primary;
text-style: underline;
}

.help-message, .error-message, .status-message {
padding: 0 1;
background: $background;
Expand Down
Loading
Loading