-
Notifications
You must be signed in to change notification settings - Fork 73
Expand file tree
/
Copy pathrichlog_visualizer.py
More file actions
847 lines (705 loc) · 33.9 KB
/
richlog_visualizer.py
File metadata and controls
847 lines (705 loc) · 33.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
"""
Textual-compatible visualizer for OpenHands conversation events.
This replaces the Rich-based CLIVisualizer with a Textual-compatible version.
"""
import re
import threading
from typing import TYPE_CHECKING
from rich.text import Text
from textual.widgets import Markdown
from openhands.sdk.conversation.visualizer.base import ConversationVisualizerBase
from openhands.sdk.event import (
ActionEvent,
AgentErrorEvent,
MessageEvent,
ObservationEvent,
PauseEvent,
SystemPromptEvent,
UserRejectObservation,
)
from openhands.sdk.event.base import Event
from openhands.sdk.event.condenser import Condensation, CondensationRequest
from openhands.sdk.event.conversation_error import ConversationErrorEvent
from openhands.sdk.tool.builtins.finish import FinishAction
from openhands.sdk.tool.builtins.think import ThinkAction
from openhands.tools.delegate.definition import DelegateAction
from openhands.tools.file_editor.definition import FileEditorAction
from openhands.tools.task_tracker.definition import TaskTrackerObservation
from openhands.tools.terminal.definition import TerminalAction
from openhands_cli.shared.delegate_formatter import format_delegate_title
from openhands_cli.stores import CliSettings
from openhands_cli.theme import OPENHANDS_THEME
from openhands_cli.tui.widgets.collapsible import (
Collapsible,
)
# Icons for different event types
SUCCESS_ICON = "✓"
ERROR_ICON = "✗"
AGENT_MESSAGE_PADDING = (1, 0, 1, 1) # top, right, bottom, left
# Maximum line length for truncating titles/commands in collapsed view
MAX_LINE_LENGTH = 70
ELLIPSIS = "..."
# Default agent name - don't show prefix for this agent
DEFAULT_AGENT_NAME = "OpenHands Agent"
if TYPE_CHECKING:
from textual.containers import VerticalScroll
from textual.widget import Widget
from openhands.sdk.critic.result import CriticResult
from openhands_cli.tui.textual_app import OpenHandsApp
def _get_event_symbol_color(event: Event) -> str:
"""Get the color for the collapse/expand symbol based on event type."""
DEFAULT_COLOR = "#ffffff"
if isinstance(event, ActionEvent):
return DEFAULT_COLOR
elif isinstance(event, ObservationEvent):
return DEFAULT_COLOR
elif isinstance(event, UserRejectObservation):
return OPENHANDS_THEME.error or DEFAULT_COLOR
elif isinstance(event, MessageEvent):
if event.llm_message and event.llm_message.role == "user":
return OPENHANDS_THEME.primary
else:
return OPENHANDS_THEME.accent or DEFAULT_COLOR
elif isinstance(event, AgentErrorEvent):
return OPENHANDS_THEME.error or DEFAULT_COLOR
elif isinstance(event, ConversationErrorEvent):
return OPENHANDS_THEME.error or DEFAULT_COLOR
elif isinstance(event, PauseEvent):
return OPENHANDS_THEME.primary
elif isinstance(event, Condensation):
return "#727987"
else:
return DEFAULT_COLOR
class ConversationVisualizer(ConversationVisualizerBase):
"""Handles visualization of conversation events for Textual apps.
This visualizer creates Collapsible widgets and adds them to a VerticalScroll
container. Supports delegate visualization by tracking agent identity.
"""
def __init__(
self,
container: "VerticalScroll",
app: "OpenHandsApp",
name: str | None = None,
):
"""Initialize the visualizer.
Args:
container: The Textual VerticalScroll container to add widgets to
app: The Textual app instance for thread-safe UI updates
name: Agent name to display in panel titles for delegation context.
When set, titles will be prefixed with the agent name.
"""
super().__init__()
self._container = container
self._app = app
self._name = name
# Store the main thread ID for thread safety checks
self._main_thread_id = threading.get_ident()
# Cache CLI settings to avoid repeated file system reads
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]] = {}
@property
def cli_settings(self) -> CliSettings:
if self._cli_settings is None:
self._cli_settings = CliSettings.load()
return self._cli_settings
def reload_configuration(self) -> None:
"""Reload CLI settings from disk."""
self._cli_settings = CliSettings.load()
def create_sub_visualizer(self, agent_id: str) -> "ConversationVisualizer":
"""Create a visualizer for a sub-agent during delegation.
Creates a new ConversationVisualizer instance for the sub-agent that
shares the same container and app, allowing delegate events to be
rendered in the same TUI with agent-specific context.
Args:
agent_id: The identifier of the sub-agent being spawned
Returns:
A new ConversationVisualizer configured for the sub-agent
"""
return ConversationVisualizer(
container=self._container,
app=self._app,
name=agent_id,
)
@staticmethod
def _format_agent_name(name: str) -> str:
"""Convert snake_case or camelCase agent name to Title Case for display.
Args:
name: Agent name in snake_case (e.g., "lodging_expert") or
camelCase (e.g., "MainAgent") or already formatted
(e.g., "Main Agent")
Returns:
Formatted name in Title Case (e.g., "Lodging Expert" or "Main Agent")
Examples:
>>> ConversationVisualizer._format_agent_name("lodging_expert")
'Lodging Expert'
>>> ConversationVisualizer._format_agent_name("MainAgent")
'Main Agent'
>>> ConversationVisualizer._format_agent_name("main_delegator")
'Main Delegator'
>>> ConversationVisualizer._format_agent_name("Main Agent")
'Main Agent'
"""
# If already has spaces, assume it's already formatted
if " " in name:
return name
# Handle snake_case by replacing underscores with spaces
if "_" in name:
return name.replace("_", " ").title()
# Handle camelCase/PascalCase by inserting spaces before capitals
spaced = re.sub(r"(?<!^)(?=[A-Z])", " ", name)
return spaced.title()
def _get_formatted_agent_name(self) -> str:
"""Get the formatted agent name with 'Agent' suffix if needed.
Returns:
Formatted agent name with " Agent" suffix if name is set
and doesn't already contain "agent", or just the formatted name.
Returns empty string if no name is set.
"""
if self._name:
return self._format_agent_name_with_suffix(self._name)
return ""
def _format_agent_name_with_suffix(self, name: str) -> str:
"""Format an agent name and add 'Agent' suffix if needed.
Args:
name: The raw agent name to format.
Returns:
Formatted agent name with " Agent" suffix if name doesn't
already contain "agent", or just the formatted name.
"""
formatted_name = self._format_agent_name(name)
# Don't add "Agent" suffix if name already contains "agent"
if "agent" in formatted_name.lower():
return formatted_name
return f"{formatted_name} Agent"
def _is_non_default_agent(self) -> bool:
"""Check if the current agent is NOT the default OpenHands Agent.
Returns:
True if name is set and is different from the default agent name.
"""
if not self._name:
return False
return self._name.strip() != DEFAULT_AGENT_NAME
def _get_agent_prefix(self) -> str:
"""Get the agent name prefix for titles when in delegation context.
Returns:
Formatted agent name in parentheses like "(Agent Name) " if name is set
and is NOT the default agent, empty string otherwise.
"""
if self._is_non_default_agent():
agent_name = self._get_formatted_agent_name()
return f"({agent_name}) "
return ""
def _run_on_main_thread(self, func, *args) -> None:
"""Run a function on the main thread via call_from_thread if needed."""
if threading.get_ident() == self._main_thread_id:
func(*args)
else:
self._app.call_from_thread(func, *args)
def _do_refresh_plan_panel(self) -> None:
"""Refresh the plan panel (must be called from main thread)."""
plan_panel = self._app.plan_panel
auto_open = self.cli_settings.auto_open_plan_panel
# Panel is already open, refresh contents
if plan_panel.is_on_screen:
plan_panel.refresh_from_disk()
return
# Not mounted: only open if user opted in
# and hasn't dismissed it once already
if not auto_open or plan_panel.user_dismissed:
return
# Open the plan panel
plan_panel.toggle()
def _get_agent_model(self) -> str | None:
"""Get the agent's model name from the conversation state.
Returns:
The agent model name or None if not available.
"""
return self._app.conversation_state.agent_model
def on_event(self, event: Event) -> None:
"""Main event handler that creates widgets for events."""
# Check for TaskTrackerObservation to update/open the plan panel
if isinstance(event, ObservationEvent) and isinstance(
event.observation, TaskTrackerObservation
):
self._run_on_main_thread(self._do_refresh_plan_panel)
# Handle observation events by updating existing action collapsibles
if isinstance(
event, ObservationEvent | UserRejectObservation | AgentErrorEvent
):
if self._handle_observation_event(event):
return # Successfully paired with action, no new widget needed
widget = self._create_event_widget(event)
if widget:
self._run_on_main_thread(self._add_widget_to_ui, widget)
# Add critic collapsible if present (for MessageEvent and ActionEvent)
critic_result = getattr(event, "critic_result", None)
if critic_result is not None:
self._handle_critic_result(critic_result)
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 _handle_critic_result(self, critic_result: "CriticResult") -> None:
"""Handle a critic result by displaying widgets and notifying controller.
This method is responsible for presentation only:
1. Displaying the critic score collapsible (if enabled)
2. Displaying the feedback widget
3. Sending telemetry
4. Posting CriticResultReceived for RefinementController to handle
Business logic (refinement triggering) is handled by RefinementController.
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:
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),
)
def _dismiss_pending_feedback_widgets(self) -> None:
"""Dismiss any pending feedback widgets.
Called when a new user turn starts - user chose to continue
instead of rating the critic feedback.
"""
from openhands_cli.tui.utils.critic.feedback import CriticFeedbackWidget
for widget in self._container.query(CriticFeedbackWidget):
widget.remove()
def _render_message_widget(self, content: str) -> None:
"""Render a message widget to the UI (shared logic).
Args:
content: The message text to display.
"""
from textual.widgets import Static
user_message_widget = Static(
f"> {content}", classes="user-message", markup=False
)
self._run_on_main_thread(self._add_widget_to_ui, user_message_widget)
def render_user_message(self, content: str) -> None:
"""Render a user message to the UI.
This is the entry point for user-initiated messages. It:
1. Dismisses any pending feedback widgets
2. Renders the message to the UI
Note: The refinement iteration counter is reset by UserMessageController,
not here. This keeps the visualizer focused on presentation.
Use render_refinement_message() for system-generated refinement messages.
Args:
content: The user's message text to display.
"""
self._dismiss_pending_feedback_widgets()
self._render_message_widget(content)
def render_refinement_message(self, content: str) -> None:
"""Render a system-generated refinement message to the UI.
This is used for refinement messages that are part of the current
refinement loop. Unlike render_user_message(), this is only for display
purposes - iteration tracking is managed by RefinementController.
Args:
content: The refinement message text to display.
"""
self._dismiss_pending_feedback_widgets()
self._render_message_widget(content)
def _update_widget_in_ui(
self, collapsible: Collapsible, new_title: str, new_content: str
) -> None:
"""Update an existing widget in the UI (must be called from main thread)."""
collapsible.update_title(new_title)
collapsible.update_content(new_content)
if self._container.is_vertical_scroll_end:
self._container.scroll_end(animate=False)
def _handle_observation_event(
self, event: ObservationEvent | UserRejectObservation | AgentErrorEvent
) -> bool:
"""Handle observation event by updating the corresponding action collapsible.
Returns True if the observation was paired with an action, False otherwise.
"""
tool_call_id = event.tool_call_id
if tool_call_id not in self._pending_actions:
return False
action_event, collapsible = self._pending_actions.pop(tool_call_id)
# Determine success/error status
is_error = isinstance(event, UserRejectObservation | AgentErrorEvent)
status_icon = ERROR_ICON if is_error else SUCCESS_ICON
# Build the new title with status icon
new_title = self._build_action_title(action_event)
new_title = f"{new_title} {status_icon}"
# Build the new content (observation result only)
new_content = self._build_observation_content(event)
self._run_on_main_thread(
self._update_widget_in_ui, collapsible, new_title, new_content
)
return True
def _build_action_title(self, event: ActionEvent) -> str:
"""Build a title for an action event.
Format:
"[Agent Prefix][bold]{summary}[/bold]" for most actions
"[Agent Prefix][bold]{summary}[/bold][dim]: $ {command}[/dim]" for terminal
"[Agent Prefix][bold]{summary}[/bold][dim]: {op} {path}[/dim]" for files
The detail portion (after the colon) is rendered in dim style to
visually distinguish it from the main summary text.
When in delegation context (self._name is set), titles are prefixed
with the agent name (e.g., "Lodging Expert Agent ").
"""
agent_prefix = self._get_agent_prefix()
summary = (
self._escape_rich_markup(str(event.summary).strip().replace("\n", " "))
if event.summary
else ""
)
action = event.action
# Terminal actions: show summary + command (truncated for display)
if isinstance(action, TerminalAction) and action.command:
cmd = self._escape_rich_markup(action.command.strip().replace("\n", " "))
cmd = self._truncate_for_display(cmd)
if summary:
return f"{agent_prefix}[bold]{summary}[/bold][dim]: $ {cmd}[/dim]"
return f"{agent_prefix}[dim]$ {cmd}[/dim]"
# File operations: include path with Reading/Editing
elif isinstance(action, FileEditorAction) and action.path:
op = "Reading" if action.command == "view" else "Editing"
path = self._escape_rich_markup(action.path)
if summary:
return f"{agent_prefix}[bold]{summary}[/bold][dim]: {op} {path}[/dim]"
return f"{agent_prefix}[bold]{op}[/bold][dim] {path}[/dim]"
# Delegate actions: show command and details
if isinstance(action, DelegateAction):
title = format_delegate_title(
action.command,
ids=action.ids,
tasks=action.tasks,
agent_types=action.agent_types,
include_agent_types=True,
)
if summary:
lower_title = title.lower()
return f"{agent_prefix}[bold]{summary}[/bold][dim]: {lower_title}[/dim]"
return f"{agent_prefix}[bold]{title}[/bold]"
# All other actions: just use summary
if summary:
return f"{agent_prefix}[bold]{summary}[/bold]"
return f"{agent_prefix}{event.tool_name}"
def _build_observation_content(
self, event: ObservationEvent | UserRejectObservation | AgentErrorEvent
) -> str:
"""Build content string from an observation event.
Returns the Rich-formatted content to preserve colors and styling.
"""
# Return the visualize content directly (Rich Text object)
# The Collapsible widget can handle Rich renderables
return str(event.visualize)
def _escape_rich_markup(self, text: str) -> str:
"""Escape Rich markup characters in text to prevent markup errors.
This is needed to handle content with special characters (e.g., Chinese text
with brackets) that would otherwise cause MarkupError when rendered in
Collapsible widgets with markup=True.
"""
# Escape square brackets which are used for Rich markup
return text.replace("[", r"\[").replace("]", r"\]")
def _truncate_for_display(
self, text: str, max_length: int = MAX_LINE_LENGTH, *, from_start: bool = True
) -> str:
"""Truncate text with ellipsis if it exceeds max_length.
Args:
text: The text to truncate.
max_length: Maximum length before truncation.
from_start: If True, keep the start and add ellipsis at end.
If False, keep the end and add ellipsis at start (for paths).
"""
if len(text) > max_length:
if from_start:
return text[: max_length - len(ELLIPSIS)] + ELLIPSIS
else:
return ELLIPSIS + text[-(max_length - len(ELLIPSIS)) :]
return text
def _clean_and_truncate(self, text: str, *, from_start: bool = True) -> str:
"""Strip, collapse newlines, truncate, and escape Rich markup for display."""
text = str(text).strip().replace("\n", " ")
text = self._truncate_for_display(text, from_start=from_start)
return self._escape_rich_markup(text)
def _extract_meaningful_title(self, event, fallback_title: str) -> str:
"""Extract a meaningful title from an event, with fallback to truncated
content."""
# For ActionEvents, prefer the LLM-generated summary if available
if hasattr(event, "summary") and event.summary:
return self._clean_and_truncate(event.summary)
# Try to extract meaningful information from the event
if hasattr(event, "action") and event.action is not None:
# For ActionEvents, try to get action type and details
action = event.action
action_type = action.__class__.__name__.replace("Action", "")
# Try to get specific details based on action type
if hasattr(action, "command") and action.command:
return f"{action_type}: {self._clean_and_truncate(action.command)}"
elif hasattr(action, "path") and action.path:
# For file actions, truncate from start to show filename
return f"{action_type}: {
self._clean_and_truncate(
action.path,
from_start=False,
)
}"
elif hasattr(action, "content") and action.content:
return f"{action_type}: {self._clean_and_truncate(action.content)}"
elif hasattr(action, "message") and action.message:
return f"{action_type}: {self._clean_and_truncate(action.message)}"
else:
return f"{action_type} Action"
elif hasattr(event, "observation") and event.observation is not None:
obs = event.observation
obs_type = obs.__class__.__name__.replace("Observation", "")
if hasattr(obs, "content") and obs.content:
return f"{obs_type}: {self._clean_and_truncate(obs.content)}"
else:
return f"{obs_type} Observation"
elif hasattr(event, "llm_message") and event.llm_message is not None:
msg = event.llm_message
if hasattr(msg, "content") and msg.content:
# Extract text from content list (content is a list of TextContent
# objects)
content_text = ""
if isinstance(msg.content, list):
for content_item in msg.content:
if hasattr(content_item, "text"):
content_text += content_item.text + " "
elif hasattr(content_item, "content"):
content_text += str(content_item.content) + " "
else:
content_text = str(msg.content)
role = "User" if msg.role == "user" else "Agent"
return f"{role}: {self._clean_and_truncate(content_text)}"
elif hasattr(event, "message") and event.message:
return f"{fallback_title}: {self._clean_and_truncate(event.message)}"
# If we can't extract meaningful info, try to truncate the visualized content
if hasattr(event, "visualize"):
try:
# Convert Rich content to plain text for title
content_str = str(event.visualize).strip().replace("\n", " ")
# Remove ANSI codes and Rich markup
content_str = re.sub(
r"\[/?[^\]]*\]", "", content_str
) # Remove Rich markup
content_str = re.sub(
r"\x1b\[[0-9;]*m", "", content_str
) # Remove ANSI codes
content_str = self._truncate_for_display(content_str)
if content_str.strip():
return f"{fallback_title}: {self._escape_rich_markup(content_str)}"
except Exception:
pass
# Final fallback
return fallback_title
@property
def _default_collapsed(self) -> bool:
"""Get the default collapsed state for new cells based on settings.
Returns True if cells should start collapsed, False if expanded.
"""
return not self.cli_settings.default_cells_expanded
def _make_collapsible(
self,
content: str | Text,
title: str,
event: Event | None = None,
collapsed: bool | None = None,
) -> Collapsible:
"""Create a Collapsible widget with standard settings.
Args:
content: The content to display (string or Rich Text object).
title: The title for the collapsible header.
event: The event used to determine symbol color (None for default).
collapsed: Override the default collapsed state. If None, uses default.
Returns:
A configured Collapsible widget.
"""
if collapsed is None:
collapsed = self._default_collapsed
symbol_color = _get_event_symbol_color(event) if event else "#888888"
return Collapsible(
content,
title=title,
collapsed=collapsed,
symbol_color=symbol_color,
)
def _create_system_prompt_collapsible(
self, event: SystemPromptEvent
) -> Collapsible:
"""Create a collapsible widget showing the system prompt from SystemPromptEvent.
This displays the full system prompt content in a collapsible widget,
matching ACP's display format. The title shows the number of tools loaded.
Args:
event: The SystemPromptEvent containing tools and system prompt
Returns:
A Collapsible widget showing the system prompt
"""
# Build the collapsible content - show system prompt like ACP does
content = str(event.visualize.plain)
# Get tool count for title
tool_count = len(event.tools) if event.tools else 0
title = (
f"Loaded: {tool_count} tool{'s' if tool_count != 1 else ''}, system prompt"
)
return self._make_collapsible(content, title, event)
def _create_event_widget(self, event: Event) -> "Widget | None":
"""Create a widget for the event - either plain text or collapsible."""
content = event.visualize
# Handle SystemPromptEvent - create a collapsible showing the system prompt
# Note: Loaded resources (skills, hooks, tools, MCPs) are displayed at startup
# in _initialize_main_ui(). This collapsible shows the full system prompt.
if isinstance(event, SystemPromptEvent):
return self._create_system_prompt_collapsible(event)
# Don't emit condensation request events (internal events)
elif isinstance(event, CondensationRequest):
return None
# Check if this is a plain text event (finish, think, or message)
if isinstance(event, ActionEvent):
action = event.action
if isinstance(action, FinishAction):
# For finish action, render as markdown with padding to align
# User message has "padding: 0 1" and starts with "> ", so text
# starts at position 3 (1 padding + 2 for "> ")
# In delegation context (non-default agent), add agent header
message = str(action.message)
if self._is_non_default_agent():
agent_name = self._get_formatted_agent_name()
message = f"**{agent_name}:**\n\n{message}"
widget = Markdown(message)
widget.styles.padding = AGENT_MESSAGE_PADDING
return widget
elif isinstance(action, ThinkAction):
# For think action, render as markdown with padding
widget = Markdown(str(action.visualize))
widget.styles.padding = AGENT_MESSAGE_PADDING
return widget
if isinstance(event, MessageEvent):
if not event.llm_message:
return None
# Skip direct user messages (they are displayed separately in the UI)
# This applies for user messages
# without a sender in delegation context
if event.llm_message.role == "user" and not event.sender:
return None
# Case 1: Delegation message (both sender and name are set)
# Format with arrow notation showing sender → receiver
# Only show prefix if this is NOT the default main agent
if event.sender and self._is_non_default_agent():
message_content = str(content)
agent_name = self._get_formatted_agent_name()
event_sender = self._format_agent_name_with_suffix(event.sender)
if event.llm_message.role == "user":
# Message from another agent (via delegation)
prefix = f"**{event_sender} → {agent_name}:**\n\n"
else:
# Agent message - derive recipient from sender context
prefix = f"**{agent_name} → {event_sender}:**\n\n"
message_content = prefix + message_content
widget = Markdown(message_content)
widget.styles.padding = AGENT_MESSAGE_PADDING
return widget
# Case 2: Regular agent message (name set, no sender, assistant role)
# This is the normal case for agent responses in the main conversation
# Fixes GitHub issue #399: Agent MessageEvents were being silently dropped
if self._name and event.llm_message.role == "assistant":
widget = Markdown(str(content))
widget.styles.padding = AGENT_MESSAGE_PADDING
return widget
# Case 3: No name context - skip MessageEvents
# (visualizer without name is typically not used in CLI)
if not self._name:
return None
# For other events, use collapsible
return self._create_event_collapsible(event)
def _create_event_collapsible(self, event: Event) -> Collapsible | None:
"""Create a Collapsible widget for the event with appropriate styling.
When in delegation context (self._name is set), titles are prefixed
with the agent name (e.g., "Lodging Expert Agent Observation").
"""
# Use the event's visualize property for content
content = event.visualize
if not content.plain.strip():
return None
agent_prefix = self._get_agent_prefix()
# Don't emit condensation request events (internal events)
if isinstance(event, CondensationRequest):
return None
elif isinstance(event, ActionEvent):
# Build title using new format with agent prefix
title = self._build_action_title(event)
content_string = self._escape_rich_markup(str(content))
# Action events default to collapsed since we have summary in title
collapsible = self._make_collapsible(content_string, title, event)
# Store for pairing with observation
self._pending_actions[event.tool_call_id] = (event, collapsible)
return collapsible
elif isinstance(event, ObservationEvent):
# If we get here, the observation wasn't paired with an action
# (shouldn't happen normally, but handle gracefully)
title = self._extract_meaningful_title(event, "Observation")
return self._make_collapsible(
self._escape_rich_markup(str(content)), f"{agent_prefix}{title}", event
)
elif isinstance(event, UserRejectObservation):
title = self._extract_meaningful_title(event, "User Rejected Action")
return self._make_collapsible(
self._escape_rich_markup(str(content)), f"{agent_prefix}{title}", event
)
elif isinstance(event, AgentErrorEvent):
title = self._extract_meaningful_title(event, "Agent Error")
content_string = self._escape_rich_markup(str(content))
return self._make_collapsible(
content_string, f"{agent_prefix}{title}", event
)
elif isinstance(event, ConversationErrorEvent):
title = self._extract_meaningful_title(event, "Conversation Error")
content_string = self._escape_rich_markup(str(content))
return self._make_collapsible(
content_string, f"{agent_prefix}{title}", event
)
elif isinstance(event, PauseEvent):
title = self._extract_meaningful_title(event, "User Paused")
return self._make_collapsible(
self._escape_rich_markup(str(content)), f"{agent_prefix}{title}", event
)
elif isinstance(event, Condensation):
title = self._extract_meaningful_title(event, "Condensation")
content_string = self._escape_rich_markup(str(content))
return self._make_collapsible(
content_string, f"{agent_prefix}{title}", event
)
else:
# Fallback for unknown event types
title = self._extract_meaningful_title(
event, f"UNKNOWN Event: {event.__class__.__name__}"
)
content_string = (
f"{self._escape_rich_markup(str(content))}\n\nSource: {event.source}"
)
return self._make_collapsible(
content_string, f"{agent_prefix}{title}", event
)