diff --git a/openhands_cli/tui/core/conversation_manager.py b/openhands_cli/tui/core/conversation_manager.py
index 421553562..8944cd911 100644
--- a/openhands_cli/tui/core/conversation_manager.py
+++ b/openhands_cli/tui/core/conversation_manager.py
@@ -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
diff --git a/openhands_cli/tui/core/conversation_runner.py b/openhands_cli/tui/core/conversation_runner.py
index c643b583e..a0fcc5a19 100644
--- a/openhands_cli/tui/core/conversation_runner.py
+++ b/openhands_cli/tui/core/conversation_runner.py
@@ -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"
diff --git a/openhands_cli/tui/core/conversation_switch_controller.py b/openhands_cli/tui/core/conversation_switch_controller.py
index edf2deadc..370795e23 100644
--- a/openhands_cli/tui/core/conversation_switch_controller.py
+++ b/openhands_cli/tui/core/conversation_switch_controller.py
@@ -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)
diff --git a/openhands_cli/tui/textual_app.py b/openhands_cli/tui/textual_app.py
index 37ba07c5b..240ec0e87 100644
--- a/openhands_cli/tui/textual_app.py
+++ b/openhands_cli/tui/textual_app.py
@@ -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 []
@@ -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
@@ -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.
diff --git a/openhands_cli/tui/textual_app.tcss b/openhands_cli/tui/textual_app.tcss
index 5fc7bbad5..e9688efc6 100644
--- a/openhands_cli/tui/textual_app.tcss
+++ b/openhands_cli/tui/textual_app.tcss
@@ -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;
diff --git a/openhands_cli/tui/widgets/richlog_visualizer.py b/openhands_cli/tui/widgets/richlog_visualizer.py
index f24adde1b..563a18b61 100644
--- a/openhands_cli/tui/widgets/richlog_visualizer.py
+++ b/openhands_cli/tui/widgets/richlog_visualizer.py
@@ -8,7 +8,8 @@
from typing import TYPE_CHECKING
from rich.text import Text
-from textual.widgets import Markdown
+from textual import events
+from textual.widgets import Markdown, Static
from openhands.sdk.conversation.visualizer.base import ConversationVisualizerBase
from openhands.sdk.event import (
@@ -94,6 +95,19 @@ class ConversationVisualizer(ConversationVisualizerBase):
container. Supports delegate visualization by tracking agent identity.
"""
+ # Maximum number of events to load per "load more" click
+ MAX_REPLAY_EVENTS = 50
+
+ @staticmethod
+ def is_user_initiated_message(event: Event) -> bool:
+ """Return True if *event* is a user-typed message (not a delegation message)."""
+ return (
+ isinstance(event, MessageEvent)
+ and event.llm_message is not None
+ and event.llm_message.role == "user"
+ and not event.sender
+ )
+
def __init__(
self,
container: "VerticalScroll",
@@ -118,6 +132,9 @@ def __init__(
self._cli_settings: CliSettings | None = None
# Track pending actions by tool_call_id for action-observation pairing
self._pending_actions: dict[str, tuple[ActionEvent, Collapsible]] = {}
+ # Track replay state for load-more functionality
+ self._replay_offset = 0
+ self._load_more_widget: Static | None = None
@property
def cli_settings(self) -> CliSettings:
@@ -298,12 +315,24 @@ def on_event(self, event: Event) -> None:
if critic_result is not None:
self._handle_critic_result(critic_result)
+ def scroll_to_bottom(self) -> None:
+ """Schedule a scroll-to-bottom on the main thread."""
+ self._run_on_main_thread(self._do_scroll_to_bottom)
+
+ def _do_scroll_to_bottom(self) -> None:
+ """Scroll the container to the bottom (must be called from main thread)."""
+ self._container.scroll_end(animate=False)
+
def _add_widget_to_ui(self, widget: "Widget") -> None:
"""Add a widget to the UI (must be called from main thread)."""
self._container.mount(widget)
if self._container.is_vertical_scroll_end:
self._container.scroll_end(animate=False)
+ def _add_widget_after(self, anchor: "Widget", widget: "Widget") -> None:
+ """Add a widget after an existing widget in the UI."""
+ self._container.mount(widget, after=anchor)
+
def _handle_critic_result(self, critic_result: "CriticResult") -> None:
"""Handle a critic result by displaying widgets and notifying controller.
@@ -318,47 +347,13 @@ def _handle_critic_result(self, critic_result: "CriticResult") -> None:
Args:
critic_result: The critic evaluation result to handle.
"""
- from openhands_cli.tui.messages import CriticResultReceived
- from openhands_cli.tui.utils.critic import (
- create_critic_collapsible,
- send_critic_inference_event,
- )
- from openhands_cli.tui.utils.critic.feedback import CriticFeedbackWidget
-
- critic_settings = self.cli_settings.critic
-
- # Skip display if critic is disabled
- if not critic_settings.enable_critic:
+ critic_widgets = self._build_critic_widgets(critic_result)
+ if not critic_widgets:
return
- # Get agent model for tracking
- agent_model = self._get_agent_model()
- conversation_id = str(self._app.conversation_id)
-
- # Send critic inference event to PostHog
- send_critic_inference_event(
- critic_result=critic_result,
- conversation_id=conversation_id,
- agent_model=agent_model,
- )
-
- # Display critic score collapsible
- critic_widget = create_critic_collapsible(critic_result)
- self._run_on_main_thread(self._add_widget_to_ui, critic_widget)
-
- # Add feedback widget after critic collapsible
- feedback_widget = CriticFeedbackWidget(
- critic_result=critic_result,
- conversation_id=conversation_id,
- agent_model=agent_model,
- )
- self._run_on_main_thread(self._add_widget_to_ui, feedback_widget)
-
- # Notify RefinementController to evaluate and potentially trigger refinement
- self._app.call_from_thread(
- self._app.conversation_manager.post_message,
- CriticResultReceived(critic_result),
- )
+ self._emit_critic_result_side_effects(critic_result)
+ for widget in critic_widgets:
+ self._run_on_main_thread(self._add_widget_to_ui, widget)
def _dismiss_pending_feedback_widgets(self) -> None:
"""Dismiss any pending feedback widgets.
@@ -377,13 +372,13 @@ def _render_message_widget(self, content: str) -> None:
Args:
content: The message text to display.
"""
- from textual.widgets import Static
- user_message_widget = Static(
- f"> {content}", classes="user-message", markup=False
- )
+ user_message_widget = self._create_user_message_widget(content)
self._run_on_main_thread(self._add_widget_to_ui, user_message_widget)
+ def _create_user_message_widget(self, content: str) -> Static:
+ return Static(f"> {content}", classes="user-message", markup=False)
+
def render_user_message(self, content: str) -> None:
"""Render a user message to the UI.
@@ -415,6 +410,174 @@ def render_refinement_message(self, content: str) -> None:
self._dismiss_pending_feedback_widgets()
self._render_message_widget(content)
+ def _replay_banner_text(self, remaining_hidden_count: int) -> str:
+ batch_count = min(self.MAX_REPLAY_EVENTS, remaining_hidden_count)
+ return f"[u]Load {batch_count:,} earlier events[/]"
+
+ def render_replay_summary(self, hidden_count: int) -> None:
+ """Render a replay summary banner when history is truncated.
+
+ Displays a clickable banner to load more events.
+ This is called during replay when the event history exceeds
+ MAX_REPLAY_EVENTS.
+
+ Args:
+ hidden_count: Start index of the shown events (i.e. how many
+ events precede the displayed tail).
+ """
+ if hidden_count <= 0:
+ return
+
+ # Track replay state
+ self._replay_offset = hidden_count
+
+ # Capture visualizer reference for the click handler
+ visualizer = self
+
+ class ClickableBanner(Static):
+ """A clickable banner to load more history."""
+
+ async def _on_click(self, _event: events.Click) -> None: # type: ignore[override]
+ """Handle click to load more history."""
+ visualizer.load_more_events()
+
+ banner = ClickableBanner(
+ self._replay_banner_text(hidden_count),
+ id="replay-summary-banner",
+ classes="replay-summary",
+ markup=True,
+ )
+
+ self._load_more_widget = banner
+ self._run_on_main_thread(self._add_widget_to_ui, banner)
+
+ def _build_critic_widgets(self, critic_result: "CriticResult") -> list["Widget"]:
+ from openhands_cli.tui.utils.critic import create_critic_collapsible
+ from openhands_cli.tui.utils.critic.feedback import CriticFeedbackWidget
+
+ critic_settings = self.cli_settings.critic
+ if not critic_settings.enable_critic:
+ return []
+
+ conversation_id = str(self._app.conversation_id)
+ agent_model = self._get_agent_model()
+ return [
+ create_critic_collapsible(critic_result),
+ CriticFeedbackWidget(
+ critic_result=critic_result,
+ conversation_id=conversation_id,
+ agent_model=agent_model,
+ ),
+ ]
+
+ def _emit_critic_result_side_effects(self, critic_result: "CriticResult") -> None:
+ from openhands_cli.tui.messages import CriticResultReceived
+ from openhands_cli.tui.utils.critic import send_critic_inference_event
+
+ critic_settings = self.cli_settings.critic
+ if not critic_settings.enable_critic:
+ return
+
+ agent_model = self._get_agent_model()
+ conversation_id = str(self._app.conversation_id)
+
+ send_critic_inference_event(
+ critic_result=critic_result,
+ conversation_id=conversation_id,
+ agent_model=agent_model,
+ )
+
+ self._app.call_from_thread(
+ self._app.conversation_manager.post_message,
+ CriticResultReceived(critic_result),
+ )
+
+ def _render_history_event(
+ self,
+ event: Event,
+ anchor: "Widget",
+ ) -> "Widget":
+ # Note: unlike on_event(), this intentionally skips TaskTrackerObservation
+ # plan-panel refresh since replayed history should not trigger side-effects.
+ if isinstance(
+ event,
+ ObservationEvent | UserRejectObservation | AgentErrorEvent,
+ ):
+ if self._handle_observation_event(event):
+ return anchor
+
+ if self.is_user_initiated_message(event):
+ text = str(event.visualize)
+ if not text.strip():
+ return anchor
+ widget: Widget | None = self._create_user_message_widget(text)
+ else:
+ widget = self._create_event_widget(event)
+
+ if widget is None:
+ return anchor
+
+ self._add_widget_after(anchor, widget)
+ current_anchor: Widget = widget
+
+ critic_result = getattr(event, "critic_result", None)
+ if critic_result is None:
+ return current_anchor
+
+ critic_widgets = self._build_critic_widgets(critic_result)
+ if not critic_widgets:
+ return current_anchor
+
+ # Skip side effects (PostHog, CriticResultReceived) during replay
+ for critic_widget in critic_widgets:
+ self._add_widget_after(current_anchor, critic_widget)
+ current_anchor = critic_widget
+ return current_anchor
+
+ def load_more_events(self) -> None:
+ """Load the next batch of events from history.
+
+ Called when user clicks the 'load more' banner.
+ Loads exactly MAX_REPLAY_EVENTS events at a time.
+ """
+ if self._load_more_widget is None:
+ return
+
+ # Calculate how many more events to load
+ start = max(0, self._replay_offset - self.MAX_REPLAY_EVENTS)
+ end = self._replay_offset
+
+ if start >= end:
+ return
+
+ # Get conversation via current_runner (like other code does)
+ runner = self._app.conversation_manager.current_runner # type: ignore[union-attr]
+ if runner is None:
+ return
+ event_log = runner.conversation.state.events # type: ignore[union-attr]
+
+ current_anchor: Widget = self._load_more_widget
+ for event in event_log[start:end]:
+ current_anchor = self._render_history_event(event, current_anchor)
+
+ # Update replay state
+ self._replay_offset = start
+
+ # Update or remove the banner
+ if start > 0:
+ # There are 'start' events remaining to load
+ remaining = start
+ # Update existing banner text instead of creating new one
+ if self._load_more_widget:
+ self._load_more_widget.update(self._replay_banner_text(remaining))
+ else:
+ # No more to load - remove banner
+ if self._load_more_widget:
+ self._run_on_main_thread(self._load_more_widget.remove)
+ self._load_more_widget = None
+
+ self._app.notify(f"Loaded {end - start} more events")
+
def _update_widget_in_ui(
self, collapsible: Collapsible, new_title: str, new_content: str
) -> None:
diff --git a/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_after_load_all.svg b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_after_load_all.svg
new file mode 100644
index 000000000..1e45a3b8e
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_after_load_all.svg
@@ -0,0 +1,111 @@
+
diff --git a/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_with_messages.svg b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_with_messages.svg
new file mode 100644
index 000000000..d4926fe03
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestReplaySummaryBannerSnapshots.test_replay_banner_with_messages.svg
@@ -0,0 +1,111 @@
+
diff --git a/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase5_click_previous_conversation.svg b/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase5_click_previous_conversation.svg
index 92c0e6c35..98dee0863 100644
--- a/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase5_click_previous_conversation.svg
+++ b/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase5_click_previous_conversation.svg
@@ -42,11 +42,13 @@
.terminal-r8 { fill: #dfd9c0 }
.terminal-r9 { fill: #277dff }
.terminal-r10 { fill: #fee165 }
-.terminal-r11 { fill: #ffeb99;font-weight: bold }
-.terminal-r12 { fill: #222222 }
-.terminal-r13 { fill: #7a7a7a }
-.terminal-r14 { fill: #277dff;font-weight: bold }
-.terminal-r15 { fill: #4e4e4e }
+.terminal-r11 { fill: #ffffff;font-weight: bold }
+.terminal-r12 { fill: #6bff6b }
+.terminal-r13 { fill: #ffeb99;font-weight: bold }
+.terminal-r14 { fill: #222222 }
+.terminal-r15 { fill: #7a7a7a }
+.terminal-r16 { fill: #277dff;font-weight: bold }
+.terminal-r17 { fill: #4e4e4e }
@@ -180,7 +182,7 @@
-
+
▏Conversations ✕
___ _ _ _ ▏
@@ -200,28 +202,28 @@
3. Type /help for help, /feedback to leave anonymous feedback, or / to scroll▏
through available commands▏
▏
-▏
+▶ Loaded: 6 tools, system prompt▏
▏
-▏
+> echo hello world▏
▏
-▏
+▶Print "hello world" to terminal: $ echo hello world✓▏
▏
▏
-▏
+hello world▏
▏
▏
▏
▏
▌
-▌New Conversation
+▌New Conversation
╭─────────────────────────────────────────────────────────▌Started a new conversation
-│Type your message, @mention a file, or / for commands▌
+│Type your message, @mention a file, or / for commands▌
╰─────────────────────────────────────────────────────────────────────────────╯▏
[Ctrl+L for multi-line • Ctrl+X for custom editor] •▌
-/tmp/openhands-e2e-test-workspace ctx N/A • $ 0.00 (↑ 0 ↓ ▌Switched
+/tmp/openhands-e2e-test-workspace ctx N/A • $ 0.00 (↑ 0 ↓ ▌Switched
▌Resumed conversation 00000000
▌
- ^a Select all ^x Open external editor ^l Toggle single/multi-line input ^j Submit multi-line input ^r R▏^p palette
+ ^a Select all ^x Open external editor ^l Toggle single/multi-line input ^j Submit multi-line input ^r R▏^p palette
diff --git a/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase6_conversation_loaded.svg b/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase6_conversation_loaded.svg
index 92c0e6c35..98dee0863 100644
--- a/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase6_conversation_loaded.svg
+++ b/tests/snapshots/e2e/__snapshots__/test_history_panel/TestHistoryPanelFlow.test_phase6_conversation_loaded.svg
@@ -42,11 +42,13 @@
.terminal-r8 { fill: #dfd9c0 }
.terminal-r9 { fill: #277dff }
.terminal-r10 { fill: #fee165 }
-.terminal-r11 { fill: #ffeb99;font-weight: bold }
-.terminal-r12 { fill: #222222 }
-.terminal-r13 { fill: #7a7a7a }
-.terminal-r14 { fill: #277dff;font-weight: bold }
-.terminal-r15 { fill: #4e4e4e }
+.terminal-r11 { fill: #ffffff;font-weight: bold }
+.terminal-r12 { fill: #6bff6b }
+.terminal-r13 { fill: #ffeb99;font-weight: bold }
+.terminal-r14 { fill: #222222 }
+.terminal-r15 { fill: #7a7a7a }
+.terminal-r16 { fill: #277dff;font-weight: bold }
+.terminal-r17 { fill: #4e4e4e }
@@ -180,7 +182,7 @@
-
+
▏Conversations ✕
___ _ _ _ ▏
@@ -200,28 +202,28 @@
3. Type /help for help, /feedback to leave anonymous feedback, or / to scroll▏
through available commands▏
▏
-▏
+▶ Loaded: 6 tools, system prompt▏
▏
-▏
+> echo hello world▏
▏
-▏
+▶Print "hello world" to terminal: $ echo hello world✓▏
▏
▏
-▏
+hello world▏
▏
▏
▏
▏
▌
-▌New Conversation
+▌New Conversation
╭─────────────────────────────────────────────────────────▌Started a new conversation
-│Type your message, @mention a file, or / for commands▌
+│Type your message, @mention a file, or / for commands▌
╰─────────────────────────────────────────────────────────────────────────────╯▏
[Ctrl+L for multi-line • Ctrl+X for custom editor] •▌
-/tmp/openhands-e2e-test-workspace ctx N/A • $ 0.00 (↑ 0 ↓ ▌Switched
+/tmp/openhands-e2e-test-workspace ctx N/A • $ 0.00 (↑ 0 ↓ ▌Switched
▌Resumed conversation 00000000
▌
- ^a Select all ^x Open external editor ^l Toggle single/multi-line input ^j Submit multi-line input ^r R▏^p palette
+ ^a Select all ^x Open external editor ^l Toggle single/multi-line input ^j Submit multi-line input ^r R▏^p palette
diff --git a/tests/snapshots/test_visualizer_snapshots.py b/tests/snapshots/test_visualizer_snapshots.py
index 6f469267a..771c2eef7 100644
--- a/tests/snapshots/test_visualizer_snapshots.py
+++ b/tests/snapshots/test_visualizer_snapshots.py
@@ -5,7 +5,7 @@
"""
from typing import TYPE_CHECKING, Any, cast
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
@@ -15,6 +15,7 @@
from openhands.sdk.llm import MessageToolCall
from openhands.sdk.tool.builtins.finish import FinishAction
from openhands.sdk.tool.builtins.think import ThinkAction
+from openhands_cli.stores import CliSettings
from openhands_cli.theme import OPENHANDS_THEME
from openhands_cli.tui.panels.plan_side_panel import PlanSidePanel
from openhands_cli.tui.widgets.richlog_visualizer import ConversationVisualizer
@@ -49,6 +50,12 @@ class VisualizerTestApp(App):
background: $background;
color: $primary;
}
+ .replay-summary {
+ padding: 0 1;
+ margin-top: 1;
+ color: $primary;
+ text-style: underline;
+ }
"""
def __init__(self, events: list[Any]) -> None:
@@ -121,6 +128,63 @@ def _create_think_action_event(thought: str) -> ActionEvent:
)
+class TestReplaySummaryBannerSnapshots:
+ """Snapshot tests for the replay summary banner."""
+
+ def test_replay_banner_with_messages(self, snap_compare):
+ """Verify replay banner appears above replayed user messages."""
+
+ class ReplayBannerApp(VisualizerTestApp):
+ def __init__(self):
+ super().__init__(events=[])
+
+ def compose(self) -> ComposeResult:
+ yield VerticalScroll(id="scroll_view")
+
+ def on_mount(self) -> None:
+ container = self.query_one("#scroll_view", VerticalScroll)
+ self.visualizer = ConversationVisualizer(
+ container, cast("OpenHandsApp", self)
+ )
+ settings = CliSettings(default_cells_expanded=True)
+ self.visualizer._cli_settings = settings
+ self.visualizer.render_replay_summary(100)
+ self.visualizer.render_user_message("What is the status?")
+ self.visualizer.render_user_message("Please continue.")
+
+ assert snap_compare(ReplayBannerApp(), terminal_size=(80, 14))
+
+ def test_replay_banner_after_load_all(self, snap_compare):
+ """Verify banner is removed after all events are loaded."""
+
+ class LoadAllApp(VisualizerTestApp):
+ def __init__(self):
+ super().__init__(events=[])
+
+ def compose(self) -> ComposeResult:
+ yield VerticalScroll(id="scroll_view")
+
+ def on_mount(self) -> None:
+ container = self.query_one("#scroll_view", VerticalScroll)
+ self.visualizer = ConversationVisualizer(
+ container, cast("OpenHandsApp", self)
+ )
+ settings = CliSettings(default_cells_expanded=True)
+ self.visualizer._cli_settings = settings
+ with patch.object(ConversationVisualizer, "MAX_REPLAY_EVENTS", 2):
+ self.visualizer.render_replay_summary(2)
+ self.visualizer.render_user_message("Hello")
+ self.visualizer.render_user_message("World")
+
+ async def load_all(pilot):
+ app = pilot.app
+ with patch.object(ConversationVisualizer, "MAX_REPLAY_EVENTS", 2):
+ app.visualizer.load_more_events()
+ await pilot.pause()
+
+ assert snap_compare(LoadAllApp(), terminal_size=(80, 14), run_before=load_all)
+
+
class TestVisualizerSnapshots:
"""Snapshot tests for the ConversationVisualizer."""
diff --git a/tests/tui/widgets/test_richlog_visualizer.py b/tests/tui/widgets/test_richlog_visualizer.py
index acf5c455c..5004a4b46 100644
--- a/tests/tui/widgets/test_richlog_visualizer.py
+++ b/tests/tui/widgets/test_richlog_visualizer.py
@@ -1,7 +1,9 @@
"""Tests for ConversationVisualizer and Chinese character markup handling."""
+from collections.abc import Sequence
+from types import SimpleNamespace
from typing import TYPE_CHECKING, cast
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
import pytest
from rich.errors import MarkupError
@@ -10,16 +12,22 @@
from textual.containers import VerticalScroll
from textual.widgets import Static
-from openhands.sdk import Action, MessageEvent, TextContent
-from openhands.sdk.event import ActionEvent, AgentErrorEvent, UserRejectObservation
+from openhands.sdk import Action, Message, MessageEvent, TextContent
+from openhands.sdk.event import (
+ ActionEvent,
+ AgentErrorEvent,
+ ObservationEvent,
+ UserRejectObservation,
+)
from openhands.sdk.event.conversation_error import ConversationErrorEvent
from openhands.sdk.llm import MessageToolCall
-from openhands.tools.terminal.definition import TerminalAction
+from openhands.tools.terminal.definition import TerminalAction, TerminalObservation
from openhands_cli.stores import CliSettings
from openhands_cli.tui.textual_app import OpenHandsApp
from openhands_cli.tui.widgets.richlog_visualizer import (
ELLIPSIS,
MAX_LINE_LENGTH,
+ SUCCESS_ICON,
ConversationVisualizer,
)
@@ -110,6 +118,69 @@ def create_terminal_action_event(
)
+def create_user_message_event(content: str) -> MessageEvent:
+ return MessageEvent(
+ llm_message=Message(role="user", content=[TextContent(text=content)]),
+ source="user",
+ )
+
+
+def create_terminal_observation_event(
+ action_event: ActionEvent, content: str = "done"
+) -> ObservationEvent:
+ observation = TerminalObservation(
+ command=cast(TerminalAction, action_event.action).command,
+ exit_code=0,
+ timeout=False,
+ content=[TextContent(text=content)],
+ )
+ return ObservationEvent(
+ tool_name=action_event.tool_name,
+ tool_call_id=action_event.tool_call_id,
+ observation=observation,
+ action_id=action_event.id,
+ )
+
+
+class ReplayHistoryTestApp(App):
+ def __init__(self, events: Sequence[object]) -> None:
+ super().__init__()
+ self.plan_panel = MagicMock()
+ self.plan_panel.is_on_screen = False
+ self.plan_panel.user_dismissed = True
+ self.conversation_id = "test-conversation"
+ self.conversation_manager = SimpleNamespace(
+ current_runner=SimpleNamespace(
+ conversation=SimpleNamespace(state=SimpleNamespace(events=events))
+ ),
+ post_message=MagicMock(),
+ )
+ self.notifications: list[str] = []
+
+ def compose(self):
+ yield VerticalScroll(id="scroll_view")
+
+ def notify(self, message: str, *args, **kwargs) -> None:
+ self.notifications.append(message)
+
+
+def history_widget_labels(container: VerticalScroll) -> list[str]:
+ labels: list[str] = []
+ for child in container.children:
+ if child.id == "replay-summary-banner":
+ labels.append(str(child.render()).replace("\n", " ").strip())
+ continue
+
+ if isinstance(child, Static):
+ rendered = str(child.render())
+ labels.append(rendered.replace("\n", " ").strip())
+ continue
+
+ title = getattr(child, "title", None)
+ labels.append(str(title) if title is not None else child.__class__.__name__)
+ return labels
+
+
class TestChineseCharacterMarkupHandling:
"""Tests for handling Chinese characters with special markup symbols."""
@@ -227,6 +298,136 @@ def test_various_chinese_patterns_are_escaped(self, visualizer, test_content):
assert rendered is not None
+class TestReplayHistoryLoading:
+ @pytest.mark.asyncio
+ async def test_load_more_events_preserves_chronological_order(
+ self, mock_cli_settings
+ ):
+ events = [create_user_message_event(f"msg{i}") for i in range(6)]
+ app = ReplayHistoryTestApp(events)
+
+ async with app.run_test() as pilot:
+ container = app.query_one("#scroll_view", VerticalScroll)
+ visualizer = ConversationVisualizer(container, cast(OpenHandsApp, app))
+
+ with patch.object(ConversationVisualizer, "MAX_REPLAY_EVENTS", 2):
+ with mock_cli_settings(
+ visualizer=visualizer, default_cells_expanded=True
+ ):
+ visualizer.render_replay_summary(4)
+ visualizer.render_user_message("msg4")
+ visualizer.render_user_message("msg5")
+ await pilot.pause()
+
+ assert history_widget_labels(container) == [
+ "Load 2 earlier events",
+ "> msg4",
+ "> msg5",
+ ]
+
+ visualizer.load_more_events()
+ await pilot.pause()
+
+ assert history_widget_labels(container) == [
+ "Load 2 earlier events",
+ "> msg2",
+ "> msg3",
+ "> msg4",
+ "> msg5",
+ ]
+
+ visualizer.load_more_events()
+ await pilot.pause()
+
+ assert history_widget_labels(container) == [
+ "> msg0",
+ "> msg1",
+ "> msg2",
+ "> msg3",
+ "> msg4",
+ "> msg5",
+ ]
+
+ @pytest.mark.asyncio
+ async def test_load_more_events_pairs_action_and_observation(
+ self, mock_cli_settings
+ ):
+ from openhands_cli.tui.widgets.collapsible import Collapsible
+
+ action_event = create_terminal_action_event("echo older", "Run older command")
+ observation_event = create_terminal_observation_event(
+ action_event, "older output"
+ )
+ later_events = [
+ create_user_message_event("msg2"),
+ create_user_message_event("msg3"),
+ ]
+ events = [action_event, observation_event, *later_events]
+ app = ReplayHistoryTestApp(events)
+
+ async with app.run_test() as pilot:
+ container = app.query_one("#scroll_view", VerticalScroll)
+ visualizer = ConversationVisualizer(container, cast(OpenHandsApp, app))
+
+ with patch.object(ConversationVisualizer, "MAX_REPLAY_EVENTS", 2):
+ with mock_cli_settings(
+ visualizer=visualizer, default_cells_expanded=True
+ ):
+ visualizer.render_replay_summary(2)
+ visualizer.render_user_message("msg2")
+ visualizer.render_user_message("msg3")
+ await pilot.pause()
+
+ visualizer.load_more_events()
+ await pilot.pause()
+
+ collapsibles = list(container.query(Collapsible))
+ assert len(collapsibles) == 1
+ collapsible = cast(Collapsible, collapsibles[0])
+ assert len(container.children) == 3
+ assert "Run older command" in str(collapsible.title)
+ assert SUCCESS_ICON in str(collapsible.title)
+ assert history_widget_labels(container)[1:] == ["> msg2", "> msg3"]
+
+ @pytest.mark.asyncio
+ async def test_replay_does_not_emit_critic_side_effects(self, mock_cli_settings):
+ """Replaying history should render critic widgets but NOT emit side effects.
+
+ Side effects include PostHog analytics events and CriticResultReceived
+ messages. These must only fire on the live path, not during replay.
+ """
+ from openhands.sdk.critic.result import CriticResult
+
+ # Create a MessageEvent with a critic_result attached
+ message = Message(role="assistant", content=[TextContent(text="analysis")])
+ event = MessageEvent(llm_message=message, source="agent")
+ event = event.model_copy(
+ update={"critic_result": CriticResult(score=0.85, message="Good: 0.85")}
+ )
+
+ events = [create_user_message_event("hi"), event]
+ app = ReplayHistoryTestApp(events)
+
+ async with app.run_test() as pilot:
+ container = app.query_one("#scroll_view", VerticalScroll)
+ visualizer = ConversationVisualizer(container, cast(OpenHandsApp, app))
+
+ with mock_cli_settings(visualizer=visualizer, default_cells_expanded=True):
+ with patch.object(
+ visualizer, "_emit_critic_result_side_effects"
+ ) as mock_emit:
+ # Replay all events via _render_history_event
+ anchor = container
+ for ev in events:
+ if ConversationVisualizer.is_user_initiated_message(ev):
+ visualizer.render_user_message(str(ev.visualize).strip())
+ else:
+ anchor = visualizer._render_history_event(ev, anchor)
+ await pilot.pause()
+
+ mock_emit.assert_not_called()
+
+
class TestVisualizerWithoutEscaping:
"""Tests that demonstrate what happens WITHOUT the escaping fix.