glaip-sdk 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. glaip_sdk/cli/auth.py +2 -1
  2. glaip_sdk/cli/commands/agents.py +1 -1
  3. glaip_sdk/cli/commands/configure.py +2 -1
  4. glaip_sdk/cli/commands/mcps.py +191 -44
  5. glaip_sdk/cli/commands/transcripts.py +1 -1
  6. glaip_sdk/cli/display.py +1 -1
  7. glaip_sdk/cli/hints.py +58 -0
  8. glaip_sdk/cli/io.py +6 -3
  9. glaip_sdk/cli/main.py +2 -1
  10. glaip_sdk/cli/slash/agent_session.py +2 -1
  11. glaip_sdk/cli/slash/session.py +1 -1
  12. glaip_sdk/cli/transcript/capture.py +1 -1
  13. glaip_sdk/cli/transcript/viewer.py +13 -646
  14. glaip_sdk/cli/update_notifier.py +2 -1
  15. glaip_sdk/cli/utils.py +63 -110
  16. glaip_sdk/client/agents.py +2 -4
  17. glaip_sdk/client/main.py +2 -18
  18. glaip_sdk/client/mcps.py +11 -1
  19. glaip_sdk/client/run_rendering.py +90 -111
  20. glaip_sdk/client/shared.py +21 -0
  21. glaip_sdk/models.py +8 -7
  22. glaip_sdk/utils/display.py +23 -15
  23. glaip_sdk/utils/rendering/__init__.py +6 -13
  24. glaip_sdk/utils/rendering/formatting.py +5 -30
  25. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  26. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  27. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  28. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  29. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  30. glaip_sdk/utils/rendering/models.py +1 -0
  31. glaip_sdk/utils/rendering/renderer/__init__.py +10 -28
  32. glaip_sdk/utils/rendering/renderer/base.py +214 -1469
  33. glaip_sdk/utils/rendering/renderer/debug.py +24 -0
  34. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  35. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  36. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  37. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  38. glaip_sdk/utils/rendering/state.py +204 -0
  39. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  40. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  41. glaip_sdk/utils/rendering/steps/format.py +176 -0
  42. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  43. glaip_sdk/utils/rendering/timing.py +36 -0
  44. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  45. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  46. glaip_sdk/utils/validation.py +13 -21
  47. {glaip_sdk-0.3.0.dist-info → glaip_sdk-0.4.0.dist-info}/METADATA +1 -1
  48. {glaip_sdk-0.3.0.dist-info → glaip_sdk-0.4.0.dist-info}/RECORD +50 -34
  49. {glaip_sdk-0.3.0.dist-info → glaip_sdk-0.4.0.dist-info}/WHEEL +0 -0
  50. {glaip_sdk-0.3.0.dist-info → glaip_sdk-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -8,8 +8,6 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import logging
11
- from collections.abc import Iterable
12
- from dataclasses import dataclass, field
13
11
  from datetime import datetime, timezone
14
12
  from time import monotonic
15
13
  from typing import Any
@@ -18,158 +16,64 @@ from rich.console import Console as RichConsole
18
16
  from rich.console import Group
19
17
  from rich.live import Live
20
18
  from rich.markdown import Markdown
21
- from rich.measure import Measurement
22
19
  from rich.spinner import Spinner
23
20
  from rich.text import Text
24
21
 
25
22
  from glaip_sdk.icons import ICON_AGENT, ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
26
23
  from glaip_sdk.rich_components import AIPPanel
27
24
  from glaip_sdk.utils.rendering.formatting import (
28
- build_connector_prefix,
29
25
  format_main_title,
30
- get_spinner_char,
31
- glyph_for_status,
32
26
  is_step_finished,
33
27
  normalise_display_label,
34
- pretty_args,
35
- redact_sensitive,
36
28
  )
37
29
  from glaip_sdk.utils.rendering.models import RunStats, Step
38
- from glaip_sdk.utils.rendering.renderer.config import RendererConfig
39
- from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
40
- from glaip_sdk.utils.rendering.renderer.panels import (
41
- create_final_panel,
42
- create_main_panel,
43
- create_tool_panel,
44
- )
45
- from glaip_sdk.utils.rendering.renderer.progress import (
30
+ from glaip_sdk.utils.rendering.layout.panels import create_main_panel
31
+ from glaip_sdk.utils.rendering.layout.progress import (
32
+ build_progress_footer,
46
33
  format_elapsed_time,
47
- format_tool_title,
48
34
  format_working_indicator,
49
- get_spinner,
35
+ get_spinner_char,
50
36
  is_delegation_tool,
51
37
  )
38
+ from glaip_sdk.utils.rendering.layout.summary import render_summary_panels
39
+ from glaip_sdk.utils.rendering.layout.transcript import (
40
+ DEFAULT_TRANSCRIPT_THEME,
41
+ TranscriptSnapshot,
42
+ build_final_panel,
43
+ build_transcript_snapshot,
44
+ build_transcript_view,
45
+ extract_query_from_meta,
46
+ format_final_panel_title,
47
+ )
48
+ from glaip_sdk.utils.rendering.renderer.config import RendererConfig
49
+ from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
52
50
  from glaip_sdk.utils.rendering.renderer.stream import StreamProcessor
53
- from glaip_sdk.utils.rendering.renderer.summary_window import clamp_step_nodes
54
- from glaip_sdk.utils.rendering.steps import UNKNOWN_STEP_DETAIL, StepManager
51
+ from glaip_sdk.utils.rendering.renderer.thinking import ThinkingScopeController
52
+ from glaip_sdk.utils.rendering.renderer.tool_panels import ToolPanelController
53
+ from glaip_sdk.utils.rendering.renderer.transcript_mode import TranscriptModeMixin
54
+ from glaip_sdk.utils.rendering.state import (
55
+ RendererState,
56
+ TranscriptBuffer,
57
+ coerce_received_at,
58
+ truncate_display,
59
+ )
60
+ from glaip_sdk.utils.rendering.steps import (
61
+ StepManager,
62
+ format_step_label,
63
+ )
64
+ from glaip_sdk.utils.rendering.timing import coerce_server_time
55
65
 
56
- DEFAULT_RENDERER_THEME = "dark"
57
66
  _NO_STEPS_TEXT = Text("No steps yet", style="dim")
58
67
 
59
68
  # Configure logger
60
69
  logger = logging.getLogger("glaip_sdk.run_renderer")
61
70
 
62
71
  # Constants
63
- LESS_THAN_1MS = "[<1ms]"
64
- FINISHED_STATUS_HINTS = {
65
- "finished",
66
- "success",
67
- "succeeded",
68
- "completed",
69
- "failed",
70
- "stopped",
71
- "error",
72
- }
73
72
  RUNNING_STATUS_HINTS = {"running", "started", "pending", "working"}
74
73
  ARGS_VALUE_MAX_LEN = 160
75
- STATUS_ICON_STYLES = {
76
- "success": "green",
77
- "failed": "red",
78
- "warning": "yellow",
79
- }
80
-
81
-
82
- def _coerce_received_at(value: Any) -> datetime | None:
83
- """Coerce a received_at value to an aware datetime if possible."""
84
- if value is None:
85
- return None
86
-
87
- if isinstance(value, datetime):
88
- return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
89
-
90
- if isinstance(value, str):
91
- try:
92
- normalised = value.replace("Z", "+00:00")
93
- dt = datetime.fromisoformat(normalised)
94
- except ValueError:
95
- return None
96
- return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
97
-
98
- return None
99
-
100
-
101
- def _truncate_display(text: str | None, limit: int = 160) -> str:
102
- """Return text capped at the given character limit with ellipsis."""
103
- if not text:
104
- return ""
105
- stripped = str(text).strip()
106
- if len(stripped) <= limit:
107
- return stripped
108
- return stripped[: limit - 1] + "…"
109
-
110
-
111
- @dataclass
112
- class RendererState:
113
- """Internal state for the renderer."""
114
-
115
- buffer: list[str] | None = None
116
- final_text: str = ""
117
- streaming_started_at: float | None = None
118
- printed_final_output: bool = False
119
- finalizing_ui: bool = False
120
- final_duration_seconds: float | None = None
121
- final_duration_text: str | None = None
122
- events: list[dict[str, Any]] = field(default_factory=list)
123
- meta: dict[str, Any] = field(default_factory=dict)
124
- streaming_started_event_ts: datetime | None = None
125
-
126
- def __post_init__(self) -> None:
127
- """Initialize renderer state after dataclass creation.
128
-
129
- Ensures buffer is initialized as an empty list if not provided.
130
- """
131
- if self.buffer is None:
132
- self.buffer = []
133
74
 
134
75
 
135
- @dataclass
136
- class ThinkingScopeState:
137
- """Runtime bookkeeping for deterministic thinking spans."""
138
-
139
- anchor_id: str
140
- task_id: str | None
141
- context_id: str | None
142
- anchor_started_at: float | None = None
143
- anchor_finished_at: float | None = None
144
- idle_started_at: float | None = None
145
- idle_started_monotonic: float | None = None
146
- active_thinking_id: str | None = None
147
- running_children: set[str] = field(default_factory=set)
148
- closed: bool = False
149
-
150
-
151
- class TrailingSpinnerLine:
152
- """Render a text line with a trailing animated Rich spinner."""
153
-
154
- def __init__(self, base_text: Text, spinner: Spinner) -> None:
155
- """Initialize spinner line with base text and spinner component."""
156
- self._base_text = base_text
157
- self._spinner = spinner
158
-
159
- def __rich_console__(self, console: RichConsole, options: Any) -> Any:
160
- """Render the text with trailing animated spinner."""
161
- spinner_render = self._spinner.render(console.get_time())
162
- combined = Text.assemble(self._base_text.copy(), " ", spinner_render)
163
- yield combined
164
-
165
- def __rich_measure__(self, console: RichConsole, options: Any) -> Measurement:
166
- """Measure the combined text and spinner dimensions."""
167
- snapshot = self._spinner.render(0)
168
- combined = Text.assemble(self._base_text.copy(), " ", snapshot)
169
- return Measurement.get(console, options, combined)
170
-
171
-
172
- class RichStreamRenderer:
76
+ class RichStreamRenderer(TranscriptModeMixin):
173
77
  """Live, modern terminal renderer for agent execution with rich visual output."""
174
78
 
175
79
  def __init__(
@@ -178,6 +82,8 @@ class RichStreamRenderer:
178
82
  *,
179
83
  cfg: RendererConfig | None = None,
180
84
  verbose: bool = False,
85
+ transcript_buffer: TranscriptBuffer | None = None,
86
+ callbacks: dict[str, Any] | None = None,
181
87
  ) -> None:
182
88
  """Initialize the renderer.
183
89
 
@@ -185,7 +91,10 @@ class RichStreamRenderer:
185
91
  console: Rich console instance
186
92
  cfg: Renderer configuration
187
93
  verbose: Whether to enable verbose mode
94
+ transcript_buffer: Optional transcript buffer for capturing output
95
+ callbacks: Optional dictionary of callback functions
188
96
  """
97
+ super().__init__()
189
98
  self.console = console or RichConsole()
190
99
  self.cfg = cfg or RendererConfig()
191
100
  self.verbose = verbose
@@ -193,16 +102,32 @@ class RichStreamRenderer:
193
102
  # Initialize components
194
103
  self.stream_processor = StreamProcessor()
195
104
  self.state = RendererState()
105
+ if transcript_buffer is not None:
106
+ self.state.buffer = transcript_buffer
107
+
108
+ self._callbacks = callbacks or {}
196
109
 
197
110
  # Initialize step manager and other state
198
111
  self.steps = StepManager(max_steps=self.cfg.summary_max_steps)
199
112
  # Live display instance (single source of truth)
200
113
  self.live: Live | None = None
201
114
  self._step_spinners: dict[str, Spinner] = {}
115
+ self._last_steps_panel_template: Any | None = None
202
116
 
203
117
  # Tool tracking and thinking scopes
204
- self.tool_panels: dict[str, dict[str, Any]] = {}
205
- self._thinking_scopes: dict[str, ThinkingScopeState] = {}
118
+ self._step_server_start_times: dict[str, float] = {}
119
+ self.tool_controller = ToolPanelController(
120
+ steps=self.steps,
121
+ stream_processor=self.stream_processor,
122
+ console=self.console,
123
+ cfg=self.cfg,
124
+ step_server_start_times=self._step_server_start_times,
125
+ output_prefix="**Output:**\n",
126
+ )
127
+ self.thinking_controller = ThinkingScopeController(
128
+ self.steps,
129
+ step_server_start_times=self._step_server_start_times,
130
+ )
206
131
  self._root_agent_friendly: str | None = None
207
132
  self._root_agent_step_id: str | None = None
208
133
  self._root_query: str | None = None
@@ -214,21 +139,11 @@ class RichStreamRenderer:
214
139
  # Header/text
215
140
  self.header_text: str = ""
216
141
  # Track per-step server start times for accurate elapsed labels
217
- self._step_server_start_times: dict[str, float] = {}
218
-
219
142
  # Output formatting constants
220
143
  self.OUTPUT_PREFIX: str = "**Output:**\n"
221
144
 
222
- # Transcript toggling
223
- self._transcript_mode_enabled: bool = False
224
- self._transcript_render_cursor: int = 0
225
- self.transcript_controller: Any | None = None
226
- self._transcript_hint_message = "[dim]Transcript view · Press Ctrl+T to return to the summary.[/dim]"
227
- self._summary_hint_message = "[dim]Press Ctrl+T to inspect raw transcript events.[/dim]"
228
- self._summary_hint_printed_once: bool = False
229
- self._transcript_hint_printed_once: bool = False
230
- self._transcript_header_printed: bool = False
231
- self._transcript_enabled_message_printed: bool = False
145
+ self._final_transcript_snapshot: TranscriptSnapshot | None = None
146
+ self._final_transcript_renderables: tuple[list[Any], list[Any]] | None = None
232
147
 
233
148
  def on_start(self, meta: dict[str, Any]) -> None:
234
149
  """Handle renderer start event."""
@@ -246,7 +161,7 @@ class RichStreamRenderer:
246
161
  meta_payload = meta or {}
247
162
  self.steps.set_root_agent(meta_payload.get("agent_id"))
248
163
  self._root_agent_friendly = self._humanize_agent_slug(meta_payload.get("agent_name"))
249
- self._root_query = _truncate_display(
164
+ self._root_query = truncate_display(
250
165
  meta_payload.get("input_message")
251
166
  or meta_payload.get("query")
252
167
  or meta_payload.get("message")
@@ -306,20 +221,6 @@ class RichStreamRenderer:
306
221
  except Exception:
307
222
  logger.exception("Failed to print header fallback")
308
223
 
309
- def _extract_query_from_meta(self, meta: dict[str, Any] | None) -> str | None:
310
- """Extract the primary query string from a metadata payload."""
311
- if not meta:
312
- return None
313
- query = (
314
- meta.get("input_message")
315
- or meta.get("query")
316
- or meta.get("message")
317
- or (meta.get("meta") or {}).get("input_message")
318
- )
319
- if isinstance(query, str) and query.strip():
320
- return query
321
- return None
322
-
323
224
  def _build_user_query_panel(self, query: str) -> AIPPanel:
324
225
  """Create the panel used to display the user request."""
325
226
  return AIPPanel(
@@ -331,7 +232,7 @@ class RichStreamRenderer:
331
232
 
332
233
  def _render_user_query(self, meta: dict[str, Any]) -> None:
333
234
  """Render the user query panel."""
334
- query = self._extract_query_from_meta(meta)
235
+ query = extract_query_from_meta(meta)
335
236
  if not query:
336
237
  return
337
238
  self.console.print(self._build_user_query_panel(query))
@@ -344,7 +245,7 @@ class RichStreamRenderer:
344
245
  elif self.header_text and not self._render_header_rule():
345
246
  self._render_header_fallback()
346
247
 
347
- query = self._extract_query_from_meta(meta) or self._root_query
248
+ query = extract_query_from_meta(meta) or self._root_query
348
249
  if query:
349
250
  self.console.print(self._build_user_query_panel(query))
350
251
 
@@ -365,18 +266,21 @@ class RichStreamRenderer:
365
266
 
366
267
  def _render_static_summary_panels(self) -> None:
367
268
  """Render the steps and main panels in a static (non-live) layout."""
368
- steps_renderable = self._render_steps_text()
369
- steps_panel = AIPPanel(
370
- steps_renderable,
371
- title="Steps",
372
- border_style="blue",
373
- )
374
- self.console.print(steps_panel)
375
- self.console.print(self._render_main_panel())
269
+ summary_window = self._summary_window_size()
270
+ window_arg = summary_window if summary_window > 0 else None
271
+ status_overrides = self._build_step_status_overrides()
272
+ for renderable in render_summary_panels(
273
+ self.state,
274
+ self.steps,
275
+ summary_window=window_arg,
276
+ include_query_panel=False,
277
+ step_status_overrides=status_overrides,
278
+ ):
279
+ self.console.print(renderable)
376
280
 
377
281
  def _ensure_streaming_started_baseline(self, timestamp: float) -> None:
378
282
  """Synchronize streaming start state across renderer components."""
379
- self.state.streaming_started_at = timestamp
283
+ self.state.start_stream_timer(timestamp)
380
284
  self.stream_processor.streaming_started_at = timestamp
381
285
  self._started_at = timestamp
382
286
 
@@ -398,7 +302,7 @@ class RichStreamRenderer:
398
302
 
399
303
  def _resolve_received_timestamp(self, ev: dict[str, Any]) -> datetime:
400
304
  """Return the timestamp an event was received, normalising inputs."""
401
- received_at = _coerce_received_at(ev.get("received_at"))
305
+ received_at = coerce_received_at(ev.get("received_at"))
402
306
  if received_at is None:
403
307
  received_at = datetime.now(timezone.utc)
404
308
 
@@ -461,31 +365,29 @@ class RichStreamRenderer:
461
365
  def _handle_content_event(self, content: str) -> None:
462
366
  """Handle content streaming events."""
463
367
  if content:
464
- self.state.buffer.append(content)
368
+ self.state.append_transcript_text(content)
465
369
  self._ensure_live()
466
370
 
467
371
  def _handle_final_response_event(self, content: str, metadata: dict[str, Any]) -> None:
468
372
  """Handle final response events."""
469
373
  if content:
470
- self.state.buffer.append(content)
471
- self.state.final_text = content
374
+ self.state.append_transcript_text(content)
375
+ self.state.set_final_output(content)
472
376
 
473
377
  meta_payload = metadata.get("metadata") or {}
474
- final_time = self._coerce_server_time(meta_payload.get("time"))
378
+ final_time = coerce_server_time(meta_payload.get("time"))
475
379
  self._update_final_duration(final_time)
476
- self._close_active_thinking_scopes(final_time)
380
+ self.thinking_controller.close_active_scopes(final_time)
477
381
  self._finish_running_steps()
478
- self._finish_tool_panels()
382
+ self.tool_controller.finish_all_panels()
479
383
  self._normalise_finished_icons()
480
384
 
481
385
  self._ensure_live()
482
386
  self._print_final_panel_if_needed()
483
387
 
484
388
  def _normalise_finished_icons(self) -> None:
485
- """Ensure finished steps do not keep spinner icons."""
389
+ """Ensure finished steps release any running spinners."""
486
390
  for step in self.steps.by_id.values():
487
- if getattr(step, "status", None) == "finished" and getattr(step, "status_icon", None) == "spinner":
488
- step.status_icon = "success"
489
391
  if getattr(step, "status", None) != "running":
490
392
  self._step_spinners.pop(step.step_id, None)
491
393
 
@@ -504,7 +406,11 @@ class RichStreamRenderer:
504
406
  logger.debug("Malformed step event skipped", exc_info=True)
505
407
  else:
506
408
  self._record_step_server_start(tracked_step, payload)
507
- self._update_thinking_timeline(tracked_step, payload)
409
+ self.thinking_controller.update_timeline(
410
+ tracked_step,
411
+ payload,
412
+ enabled=self.cfg.render_thinking,
413
+ )
508
414
  self._maybe_override_root_agent_label(tracked_step, payload)
509
415
  self._maybe_attach_root_query(tracked_step)
510
416
 
@@ -512,7 +418,7 @@ class RichStreamRenderer:
512
418
  self.stream_processor.track_tools_and_agents(tool_name, tool_calls_info, is_delegation_tool)
513
419
 
514
420
  # Handle tool execution
515
- self._handle_agent_step(
421
+ self.tool_controller.handle_agent_step(
516
422
  ev,
517
423
  tool_name,
518
424
  tool_args,
@@ -557,246 +463,7 @@ class RichStreamRenderer:
557
463
  if not self._root_agent_step_id:
558
464
  self._root_agent_step_id = step.step_id
559
465
 
560
- def _update_thinking_timeline(self, step: Step | None, payload: dict[str, Any]) -> None:
561
- """Maintain deterministic thinking spans for each agent/delegate scope."""
562
- if not self.cfg.render_thinking or not step:
563
- return
564
-
565
- now_monotonic = monotonic()
566
- server_time = self._coerce_server_time(payload.get("time"))
567
- status_hint = (payload.get("status") or "").lower()
568
-
569
- if self._is_scope_anchor(step):
570
- self._update_anchor_thinking(
571
- step=step,
572
- server_time=server_time,
573
- status_hint=status_hint,
574
- now_monotonic=now_monotonic,
575
- )
576
- return
577
-
578
- self._update_child_thinking(
579
- step=step,
580
- server_time=server_time,
581
- status_hint=status_hint,
582
- now_monotonic=now_monotonic,
583
- )
584
-
585
- def _update_anchor_thinking(
586
- self,
587
- *,
588
- step: Step,
589
- server_time: float | None,
590
- status_hint: str,
591
- now_monotonic: float,
592
- ) -> None:
593
- """Handle deterministic thinking bookkeeping for agent/delegate anchors."""
594
- scope = self._get_or_create_scope(step)
595
- if scope.anchor_started_at is None and server_time is not None:
596
- scope.anchor_started_at = server_time
597
-
598
- if not scope.closed and scope.active_thinking_id is None:
599
- self._start_scope_thinking(
600
- scope,
601
- start_server_time=scope.anchor_started_at or server_time,
602
- start_monotonic=now_monotonic,
603
- )
604
-
605
- is_anchor_finished = status_hint in FINISHED_STATUS_HINTS or (not status_hint and is_step_finished(step))
606
- if is_anchor_finished:
607
- scope.anchor_finished_at = server_time or scope.anchor_finished_at
608
- self._finish_scope_thinking(scope, server_time, now_monotonic)
609
- scope.closed = True
610
-
611
- parent_anchor_id = self._resolve_anchor_id(step)
612
- if parent_anchor_id:
613
- self._cascade_anchor_update(
614
- parent_anchor_id=parent_anchor_id,
615
- child_step=step,
616
- server_time=server_time,
617
- now_monotonic=now_monotonic,
618
- is_finished=is_anchor_finished,
619
- )
620
-
621
- def _cascade_anchor_update(
622
- self,
623
- *,
624
- parent_anchor_id: str,
625
- child_step: Step,
626
- server_time: float | None,
627
- now_monotonic: float,
628
- is_finished: bool,
629
- ) -> None:
630
- """Propagate anchor state changes to the parent scope."""
631
- parent_scope = self._thinking_scopes.get(parent_anchor_id)
632
- if not parent_scope or parent_scope.closed:
633
- return
634
- if is_finished:
635
- self._mark_child_finished(parent_scope, child_step.step_id, server_time, now_monotonic)
636
- else:
637
- self._mark_child_running(parent_scope, child_step, server_time, now_monotonic)
638
-
639
- def _update_child_thinking(
640
- self,
641
- *,
642
- step: Step,
643
- server_time: float | None,
644
- status_hint: str,
645
- now_monotonic: float,
646
- ) -> None:
647
- """Update deterministic thinking state for non-anchor steps."""
648
- anchor_id = self._resolve_anchor_id(step)
649
- if not anchor_id:
650
- return
651
-
652
- scope = self._thinking_scopes.get(anchor_id)
653
- if not scope or scope.closed or step.kind == "thinking":
654
- return
655
-
656
- is_finish_event = status_hint in FINISHED_STATUS_HINTS or (not status_hint and is_step_finished(step))
657
- if is_finish_event:
658
- self._mark_child_finished(scope, step.step_id, server_time, now_monotonic)
659
- else:
660
- self._mark_child_running(scope, step, server_time, now_monotonic)
661
-
662
- def _resolve_anchor_id(self, step: Step) -> str | None:
663
- """Return the nearest agent/delegate ancestor for a step."""
664
- parent_id = step.parent_id
665
- while parent_id:
666
- parent = self.steps.by_id.get(parent_id)
667
- if not parent:
668
- return None
669
- if self._is_scope_anchor(parent):
670
- return parent.step_id
671
- parent_id = parent.parent_id
672
- return None
673
-
674
- def _get_or_create_scope(self, step: Step) -> ThinkingScopeState:
675
- """Fetch (or create) thinking state for the given anchor step."""
676
- scope = self._thinking_scopes.get(step.step_id)
677
- if scope:
678
- if scope.task_id is None:
679
- scope.task_id = step.task_id
680
- if scope.context_id is None:
681
- scope.context_id = step.context_id
682
- return scope
683
- scope = ThinkingScopeState(
684
- anchor_id=step.step_id,
685
- task_id=step.task_id,
686
- context_id=step.context_id,
687
- )
688
- self._thinking_scopes[step.step_id] = scope
689
- return scope
690
-
691
- def _is_scope_anchor(self, step: Step) -> bool:
692
- """Return True when a step should host its own thinking timeline."""
693
- if step.kind in {"agent", "delegate"}:
694
- return True
695
- name = (step.name or "").lower()
696
- return name.startswith(("delegate_to_", "delegate_", "delegate "))
697
-
698
- def _start_scope_thinking(
699
- self,
700
- scope: ThinkingScopeState,
701
- *,
702
- start_server_time: float | None,
703
- start_monotonic: float,
704
- ) -> None:
705
- """Open a deterministic thinking node beneath the scope anchor."""
706
- if scope.closed or scope.active_thinking_id or not scope.anchor_id:
707
- return
708
- step = self.steps.start_or_get(
709
- task_id=scope.task_id,
710
- context_id=scope.context_id,
711
- kind="thinking",
712
- name=f"agent_thinking_step::{scope.anchor_id}",
713
- parent_id=scope.anchor_id,
714
- args={"reason": "deterministic_timeline"},
715
- )
716
- step.display_label = "💭 Thinking…"
717
- step.status_icon = "spinner"
718
- scope.active_thinking_id = step.step_id
719
- scope.idle_started_at = start_server_time
720
- scope.idle_started_monotonic = start_monotonic
721
-
722
- def _finish_scope_thinking(
723
- self,
724
- scope: ThinkingScopeState,
725
- end_server_time: float | None,
726
- end_monotonic: float,
727
- ) -> None:
728
- """Close the currently running thinking node if one exists."""
729
- if not scope.active_thinking_id:
730
- return
731
- thinking_step = self.steps.by_id.get(scope.active_thinking_id)
732
- if not thinking_step:
733
- scope.active_thinking_id = None
734
- scope.idle_started_at = None
735
- scope.idle_started_monotonic = None
736
- return
737
-
738
- duration = self._calculate_timeline_duration(
739
- scope.idle_started_at,
740
- end_server_time,
741
- scope.idle_started_monotonic,
742
- end_monotonic,
743
- )
744
- thinking_step.display_label = thinking_step.display_label or "💭 Thinking…"
745
- if duration is not None:
746
- thinking_step.finish(duration, source="timeline")
747
- else:
748
- thinking_step.finish(None, source="timeline")
749
- thinking_step.status_icon = "success"
750
- scope.active_thinking_id = None
751
- scope.idle_started_at = None
752
- scope.idle_started_monotonic = None
753
-
754
- def _mark_child_running(
755
- self,
756
- scope: ThinkingScopeState,
757
- step: Step,
758
- server_time: float | None,
759
- now_monotonic: float,
760
- ) -> None:
761
- """Mark a direct child as running and close any open thinking node."""
762
- if step.step_id in scope.running_children:
763
- return
764
- scope.running_children.add(step.step_id)
765
- if not scope.active_thinking_id:
766
- return
767
-
768
- start_server = self._step_server_start_times.get(step.step_id)
769
- if start_server is None:
770
- start_server = server_time
771
- self._finish_scope_thinking(scope, start_server, now_monotonic)
772
-
773
- def _mark_child_finished(
774
- self,
775
- scope: ThinkingScopeState,
776
- step_id: str,
777
- server_time: float | None,
778
- now_monotonic: float,
779
- ) -> None:
780
- """Handle completion for a scope child and resume thinking if idle."""
781
- if step_id in scope.running_children:
782
- scope.running_children.discard(step_id)
783
- if scope.running_children or scope.closed:
784
- return
785
- self._start_scope_thinking(
786
- scope,
787
- start_server_time=server_time,
788
- start_monotonic=now_monotonic,
789
- )
790
-
791
- def _close_active_thinking_scopes(self, server_time: float | None) -> None:
792
- """Finish any in-flight thinking nodes during finalization."""
793
- now = monotonic()
794
- for scope in self._thinking_scopes.values():
795
- if not scope.active_thinking_id:
796
- continue
797
- self._finish_scope_thinking(scope, server_time, now)
798
- scope.closed = True
799
- # Parent scopes resume thinking via _cascade_anchor_update
466
+ # Thinking scope management is handled by ThinkingScopeController.
800
467
 
801
468
  def _apply_root_duration(self, duration_seconds: float | None) -> None:
802
469
  """Propagate the final run duration to the root agent step."""
@@ -813,33 +480,6 @@ class RichStreamRenderer:
813
480
  root_step.duration_source = root_step.duration_source or "run"
814
481
  root_step.status = "finished"
815
482
 
816
- @staticmethod
817
- def _coerce_server_time(value: Any) -> float | None:
818
- """Convert a raw SSE time payload into a float if possible."""
819
- if isinstance(value, (int, float)):
820
- return float(value)
821
- try:
822
- return float(value)
823
- except (TypeError, ValueError):
824
- return None
825
-
826
- @staticmethod
827
- def _calculate_timeline_duration(
828
- start_server: float | None,
829
- end_server: float | None,
830
- start_monotonic: float | None,
831
- end_monotonic: float,
832
- ) -> float | None:
833
- """Pick the most reliable pair of timestamps to derive duration seconds."""
834
- if start_server is not None and end_server is not None:
835
- return max(0.0, float(end_server) - float(start_server))
836
- if start_monotonic is not None:
837
- try:
838
- return max(0.0, float(end_monotonic) - float(start_monotonic))
839
- except Exception:
840
- return None
841
- return None
842
-
843
483
  @staticmethod
844
484
  def _humanize_agent_slug(value: Any) -> str | None:
845
485
  """Convert a slugified agent name into Title Case."""
@@ -864,19 +504,6 @@ class RichStreamRenderer:
864
504
  if step.duration_ms is None:
865
505
  step.duration_ms = 0
866
506
  step.duration_source = step.duration_source or "unknown"
867
- step.status_icon = "warning"
868
-
869
- def _finish_tool_panels(self) -> None:
870
- """Mark unfinished tool panels as finished."""
871
- try:
872
- items = list(self.tool_panels.items())
873
- except Exception: # pragma: no cover - defensive guard
874
- logger.exception("Failed to iterate tool panels during cleanup")
875
- return
876
-
877
- for _sid, meta in items:
878
- if meta.get("status") != "finished":
879
- meta["status"] = "finished"
880
507
 
881
508
  def _stop_live_display(self) -> None:
882
509
  """Stop live display and clean up."""
@@ -887,7 +514,7 @@ class RichStreamRenderer:
887
514
  if self.state.printed_final_output:
888
515
  return
889
516
 
890
- body = (self.state.final_text or "".join(self.state.buffer) or "").strip()
517
+ body = (self.state.final_text or self.state.buffer.render() or "").strip()
891
518
  if not body:
892
519
  return
893
520
 
@@ -895,20 +522,52 @@ class RichStreamRenderer:
895
522
  return
896
523
 
897
524
  if self.verbose:
898
- final_panel = create_final_panel(
899
- body,
525
+ panel = build_final_panel(
526
+ self.state,
900
527
  title=self._final_panel_title(),
901
- theme=DEFAULT_RENDERER_THEME,
902
528
  )
903
- self.console.print(final_panel)
529
+ if panel is None:
530
+ return
531
+ self.console.print(panel)
904
532
  self.state.printed_final_output = True
905
533
 
534
+ def finalize(self) -> tuple[list[Any], list[Any]]:
535
+ """Compose the final transcript renderables."""
536
+ return self._compose_final_transcript()
537
+
538
+ def _compose_final_transcript(self) -> tuple[list[Any], list[Any]]:
539
+ """Build the transcript snapshot used for final summaries."""
540
+ summary_window = self._summary_window_size()
541
+ summary_window = summary_window if summary_window > 0 else None
542
+ snapshot = build_transcript_snapshot(
543
+ self.state,
544
+ self.steps,
545
+ query_text=extract_query_from_meta(self.state.meta),
546
+ meta=self.state.meta,
547
+ summary_window=summary_window,
548
+ step_status_overrides=self._build_step_status_overrides(),
549
+ )
550
+ header, body = build_transcript_view(snapshot)
551
+ self._final_transcript_snapshot = snapshot
552
+ self._final_transcript_renderables = (header, body)
553
+ return header, body
554
+
555
+ def _render_final_summary(self, header: list[Any], body: list[Any]) -> None:
556
+ """Print the composed transcript summary for non-live renders."""
557
+ renderables = list(header) + list(body)
558
+ for renderable in renderables:
559
+ try:
560
+ self.console.print(renderable)
561
+ self.console.print()
562
+ except Exception:
563
+ pass
564
+
906
565
  def on_complete(self, stats: RunStats) -> None:
907
566
  """Handle completion event."""
908
567
  self.state.finalizing_ui = True
909
568
 
910
569
  self._handle_stats_duration(stats)
911
- self._close_active_thinking_scopes(self.state.final_duration_seconds)
570
+ self.thinking_controller.close_active_scopes(self.state.final_duration_seconds)
912
571
  self._cleanup_ui_elements()
913
572
  self._finalize_display()
914
573
  self._print_completion_message()
@@ -934,18 +593,23 @@ class RichStreamRenderer:
934
593
  self._finish_running_steps()
935
594
 
936
595
  # Mark unfinished tool panels as finished
937
- self._finish_tool_panels()
596
+ self.tool_controller.finish_all_panels()
938
597
 
939
598
  def _finalize_display(self) -> None:
940
599
  """Finalize live display and render final output."""
941
600
  # Final refresh
942
601
  self._ensure_live()
943
602
 
603
+ header, body = self.finalize()
604
+
944
605
  # Stop live display
945
606
  self._stop_live_display()
946
607
 
947
608
  # Render final output based on configuration
948
- self._print_final_panel_if_needed()
609
+ if self.cfg.live:
610
+ self._print_final_panel_if_needed()
611
+ else:
612
+ self._render_final_summary(header, body)
949
613
 
950
614
  def _print_completion_message(self) -> None:
951
615
  """Print completion message based on current mode."""
@@ -1017,13 +681,18 @@ class RichStreamRenderer:
1017
681
  if not self.live:
1018
682
  return
1019
683
 
1020
- main_panel = self._render_main_panel()
1021
- steps_renderable = self._render_steps_text()
684
+ steps_body = self._render_steps_text()
685
+ template_panel = getattr(self, "_last_steps_panel_template", None)
686
+ if template_panel is None:
687
+ template_panel = self._resolve_steps_panel()
1022
688
  steps_panel = AIPPanel(
1023
- steps_renderable,
1024
- title="Steps",
1025
- border_style="blue",
689
+ steps_body,
690
+ title=getattr(template_panel, "title", "Steps"),
691
+ border_style=getattr(template_panel, "border_style", "blue"),
692
+ padding=getattr(template_panel, "padding", (0, 1)),
1026
693
  )
694
+
695
+ main_panel = self._render_main_panel()
1027
696
  panels = self._build_live_panels(main_panel, steps_panel)
1028
697
 
1029
698
  self.live.update(Group(*panels))
@@ -1041,26 +710,19 @@ class RichStreamRenderer:
1041
710
 
1042
711
  def _render_main_panel(self) -> Any:
1043
712
  """Render the main content panel."""
1044
- body = "".join(self.state.buffer).strip()
713
+ body = self.state.buffer.render().strip()
714
+ theme = DEFAULT_TRANSCRIPT_THEME
1045
715
  if not self.verbose:
1046
- final_content = (self.state.final_text or "").strip()
1047
- if final_content:
1048
- title = self._final_panel_title()
1049
- return create_final_panel(
1050
- final_content,
1051
- title=title,
1052
- theme=DEFAULT_RENDERER_THEME,
1053
- )
716
+ panel = build_final_panel(self.state, theme=theme)
717
+ if panel is not None:
718
+ return panel
1054
719
  # Dynamic title with spinner + elapsed/hints
1055
720
  title = self._format_enhanced_main_title()
1056
- return create_main_panel(body, title, DEFAULT_RENDERER_THEME)
721
+ return create_main_panel(body, title, theme)
1057
722
 
1058
723
  def _final_panel_title(self) -> str:
1059
724
  """Compose title for the final result panel including duration."""
1060
- title = "Final Result"
1061
- if self.state.final_duration_text:
1062
- title = f"{title} · {self.state.final_duration_text}"
1063
- return title
725
+ return format_final_panel_title(self.state)
1064
726
 
1065
727
  def apply_verbosity(self, verbose: bool) -> None:
1066
728
  """Update verbose behaviour at runtime."""
@@ -1079,550 +741,16 @@ class RichStreamRenderer:
1079
741
  if self.cfg.live:
1080
742
  self._ensure_live()
1081
743
 
1082
- # ------------------------------------------------------------------
1083
- # Transcript helpers
1084
- # ------------------------------------------------------------------
1085
- @property
1086
- def transcript_mode_enabled(self) -> bool:
1087
- """Return True when transcript mode is currently active."""
1088
- return self._transcript_mode_enabled
1089
-
1090
- def toggle_transcript_mode(self) -> None:
1091
- """Flip transcript mode on/off."""
1092
- self.set_transcript_mode(not self._transcript_mode_enabled)
1093
-
1094
- def set_transcript_mode(self, enabled: bool) -> None:
1095
- """Set transcript mode explicitly."""
1096
- if enabled == self._transcript_mode_enabled:
1097
- return
1098
-
1099
- self._transcript_mode_enabled = enabled
1100
- self.apply_verbosity(enabled)
1101
-
1102
- if enabled:
1103
- self._summary_hint_printed_once = False
1104
- self._transcript_hint_printed_once = False
1105
- self._transcript_header_printed = False
1106
- self._transcript_enabled_message_printed = False
1107
- self._stop_live_display()
1108
- self._clear_console_safe()
1109
- self._print_transcript_enabled_message()
1110
- self._render_transcript_backfill()
1111
- else:
1112
- self._transcript_hint_printed_once = False
1113
- self._transcript_header_printed = False
1114
- self._transcript_enabled_message_printed = False
1115
- self._clear_console_safe()
1116
- self._render_summary_static_sections()
1117
- summary_notice = (
1118
- "[dim]Returning to the summary view. Streaming will continue here.[/dim]"
1119
- if not self.state.finalizing_ui
1120
- else "[dim]Returning to the summary view.[/dim]"
1121
- )
1122
- self.console.print(summary_notice)
1123
- self._render_summary_after_transcript_toggle()
1124
- if not self.state.finalizing_ui:
1125
- self._print_summary_hint(force=True)
1126
-
1127
- def _clear_console_safe(self) -> None:
1128
- """Best-effort console clear that ignores platform quirks."""
1129
- try:
1130
- self.console.clear()
1131
- except Exception:
1132
- pass
1133
-
1134
- def _print_transcript_hint(self) -> None:
1135
- """Render the transcript toggle hint, keeping it near the bottom."""
1136
- if not self._transcript_mode_enabled:
1137
- return
1138
- try:
1139
- self.console.print(self._transcript_hint_message)
1140
- except Exception:
1141
- pass
1142
- else:
1143
- self._transcript_hint_printed_once = True
1144
-
1145
- def _print_transcript_enabled_message(self) -> None:
1146
- if self._transcript_enabled_message_printed:
1147
- return
1148
- try:
1149
- self.console.print("[dim]Transcript mode enabled — streaming raw transcript events.[/dim]")
1150
- except Exception:
1151
- pass
1152
- else:
1153
- self._transcript_enabled_message_printed = True
1154
-
1155
- def _ensure_transcript_header(self) -> None:
1156
- if self._transcript_header_printed:
1157
- return
1158
- try:
1159
- self.console.rule("Transcript Events")
1160
- except Exception:
1161
- self._transcript_header_printed = True
1162
- return
1163
- self._transcript_header_printed = True
1164
-
1165
- def _print_summary_hint(self, force: bool = False) -> None:
1166
- """Show the summary-mode toggle hint."""
1167
- controller = getattr(self, "transcript_controller", None)
1168
- if controller and not getattr(controller, "enabled", False):
1169
- if not force:
1170
- self._summary_hint_printed_once = True
1171
- return
1172
- if not force and self._summary_hint_printed_once:
1173
- return
1174
- try:
1175
- self.console.print(self._summary_hint_message)
1176
- except Exception:
1177
- return
1178
- self._summary_hint_printed_once = True
1179
-
1180
- def _render_transcript_backfill(self) -> None:
1181
- """Render any captured events that haven't been shown in transcript mode."""
1182
- pending = self.state.events[self._transcript_render_cursor :]
1183
- self._ensure_transcript_header()
1184
- if not pending:
1185
- self._print_transcript_hint()
1186
- return
1187
-
1188
- baseline = self.state.streaming_started_event_ts
1189
- for ev in pending:
1190
- received_ts = _coerce_received_at(ev.get("received_at"))
1191
- render_debug_event(
1192
- ev,
1193
- self.console,
1194
- received_ts=received_ts,
1195
- baseline_ts=baseline,
1196
- )
1197
-
1198
- self._transcript_render_cursor = len(self.state.events)
1199
- self._print_transcript_hint()
1200
-
1201
- def _capture_event(self, ev: dict[str, Any], received_at: datetime | None = None) -> None:
1202
- """Capture a deep copy of SSE events for transcript replay."""
1203
- try:
1204
- captured = json.loads(json.dumps(ev))
1205
- except Exception:
1206
- captured = ev
1207
-
1208
- if received_at is not None:
1209
- try:
1210
- captured["received_at"] = received_at.isoformat()
1211
- except Exception:
1212
- try:
1213
- captured["received_at"] = str(received_at)
1214
- except Exception:
1215
- captured["received_at"] = repr(received_at)
1216
-
1217
- self.state.events.append(captured)
1218
- if self._transcript_mode_enabled:
1219
- self._transcript_render_cursor = len(self.state.events)
744
+ # Transcript helper implementations live in TranscriptModeMixin.
1220
745
 
1221
746
  def get_aggregated_output(self) -> str:
1222
747
  """Return the concatenated assistant output collected so far."""
1223
- return ("".join(self.state.buffer or [])).strip()
748
+ return self.state.buffer.render().strip()
1224
749
 
1225
750
  def get_transcript_events(self) -> list[dict[str, Any]]:
1226
751
  """Return captured SSE events."""
1227
752
  return list(self.state.events)
1228
753
 
1229
- def _ensure_tool_panel(self, name: str, args: Any, task_id: str, context_id: str) -> str:
1230
- """Ensure a tool panel exists and return its ID."""
1231
- formatted_title = format_tool_title(name)
1232
- is_delegation = is_delegation_tool(name)
1233
- tool_sid = f"tool_{name}_{task_id}_{context_id}"
1234
-
1235
- if tool_sid not in self.tool_panels:
1236
- self.tool_panels[tool_sid] = {
1237
- "title": formatted_title,
1238
- "status": "running",
1239
- "started_at": monotonic(),
1240
- "server_started_at": self.stream_processor.server_elapsed_time,
1241
- "chunks": [],
1242
- "args": args or {},
1243
- "output": None,
1244
- "is_delegation": is_delegation,
1245
- }
1246
- # Add Args section once
1247
- if args:
1248
- try:
1249
- args_content = "**Args:**\n```json\n" + json.dumps(args, indent=2) + "\n```\n\n"
1250
- except Exception:
1251
- args_content = f"**Args:**\n{args}\n\n"
1252
- self.tool_panels[tool_sid]["chunks"].append(args_content)
1253
-
1254
- return tool_sid
1255
-
1256
- def _start_tool_step(
1257
- self,
1258
- task_id: str,
1259
- context_id: str,
1260
- tool_name: str,
1261
- tool_args: Any,
1262
- _tool_sid: str,
1263
- *,
1264
- tracked_step: Step | None = None,
1265
- ) -> Step | None:
1266
- """Start or get a step for a tool."""
1267
- if tracked_step is not None:
1268
- return tracked_step
1269
-
1270
- if is_delegation_tool(tool_name):
1271
- st = self.steps.start_or_get(
1272
- task_id=task_id,
1273
- context_id=context_id,
1274
- kind="delegate",
1275
- name=tool_name,
1276
- args=tool_args,
1277
- )
1278
- else:
1279
- st = self.steps.start_or_get(
1280
- task_id=task_id,
1281
- context_id=context_id,
1282
- kind="tool",
1283
- name=tool_name,
1284
- args=tool_args,
1285
- )
1286
-
1287
- # Record server start time for this step if available
1288
- if st and self.stream_processor.server_elapsed_time is not None:
1289
- self._step_server_start_times[st.step_id] = self.stream_processor.server_elapsed_time
1290
-
1291
- return st
1292
-
1293
- def _process_additional_tool_calls(
1294
- self,
1295
- tool_calls_info: list[tuple[str, Any, Any]],
1296
- tool_name: str,
1297
- task_id: str,
1298
- context_id: str,
1299
- ) -> None:
1300
- """Process additional tool calls to avoid duplicates."""
1301
- for call_name, call_args, _ in tool_calls_info or []:
1302
- if call_name and call_name != tool_name:
1303
- self._process_single_tool_call(call_name, call_args, task_id, context_id)
1304
-
1305
- def _process_single_tool_call(self, call_name: str, call_args: Any, task_id: str, context_id: str) -> None:
1306
- """Process a single additional tool call."""
1307
- self._ensure_tool_panel(call_name, call_args, task_id, context_id)
1308
-
1309
- st2 = self._create_step_for_tool_call(call_name, call_args, task_id, context_id)
1310
-
1311
- if self.stream_processor.server_elapsed_time is not None and st2:
1312
- self._step_server_start_times[st2.step_id] = self.stream_processor.server_elapsed_time
1313
-
1314
- def _create_step_for_tool_call(self, call_name: str, call_args: Any, task_id: str, context_id: str) -> Any:
1315
- """Create appropriate step for tool call."""
1316
- if is_delegation_tool(call_name):
1317
- return self.steps.start_or_get(
1318
- task_id=task_id,
1319
- context_id=context_id,
1320
- kind="delegate",
1321
- name=call_name,
1322
- args=call_args,
1323
- )
1324
- else:
1325
- return self.steps.start_or_get(
1326
- task_id=task_id,
1327
- context_id=context_id,
1328
- kind="tool",
1329
- name=call_name,
1330
- args=call_args,
1331
- )
1332
-
1333
- def _detect_tool_completion(self, metadata: dict, content: str) -> tuple[bool, str | None, Any]:
1334
- """Detect if a tool has completed and return completion info."""
1335
- tool_info = metadata.get("tool_info", {}) if isinstance(metadata, dict) else {}
1336
-
1337
- if tool_info.get("status") == "finished" and tool_info.get("name"):
1338
- return True, tool_info.get("name"), tool_info.get("output")
1339
- elif content and isinstance(content, str) and content.startswith("Completed "):
1340
- # content like "Completed google_serper"
1341
- tname = content.replace("Completed ", "").strip()
1342
- if tname:
1343
- output = tool_info.get("output") if tool_info.get("name") == tname else None
1344
- return True, tname, output
1345
- elif metadata.get("status") == "finished" and tool_info.get("name"):
1346
- return True, tool_info.get("name"), tool_info.get("output")
1347
-
1348
- return False, None, None
1349
-
1350
- def _get_tool_session_id(self, finished_tool_name: str, task_id: str, context_id: str) -> str:
1351
- """Generate tool session ID."""
1352
- return f"tool_{finished_tool_name}_{task_id}_{context_id}"
1353
-
1354
- def _calculate_tool_duration(self, meta: dict[str, Any]) -> float | None:
1355
- """Calculate tool duration from metadata."""
1356
- server_now = self.stream_processor.server_elapsed_time
1357
- server_start = meta.get("server_started_at")
1358
- dur = None
1359
-
1360
- try:
1361
- if isinstance(server_now, (int, float)) and server_start is not None:
1362
- dur = max(0.0, float(server_now) - float(server_start))
1363
- else:
1364
- started_at = meta.get("started_at")
1365
- if started_at is not None:
1366
- started_at_float = float(started_at)
1367
- dur = max(0.0, float(monotonic()) - started_at_float)
1368
- except (TypeError, ValueError):
1369
- logger.exception("Failed to calculate tool duration")
1370
- return None
1371
-
1372
- return dur
1373
-
1374
- def _update_tool_metadata(self, meta: dict[str, Any], dur: float | None) -> None:
1375
- """Update tool metadata with duration information."""
1376
- if dur is not None:
1377
- meta["duration_seconds"] = dur
1378
- meta["server_finished_at"] = (
1379
- self.stream_processor.server_elapsed_time
1380
- if isinstance(self.stream_processor.server_elapsed_time, (int, float))
1381
- else None
1382
- )
1383
- meta["finished_at"] = monotonic()
1384
-
1385
- def _add_tool_output_to_panel(
1386
- self, meta: dict[str, Any], finished_tool_output: Any, finished_tool_name: str
1387
- ) -> None:
1388
- """Add tool output to panel metadata."""
1389
- if finished_tool_output is not None:
1390
- meta["chunks"].append(self._format_output_block(finished_tool_output, finished_tool_name))
1391
- meta["output"] = finished_tool_output
1392
-
1393
- def _mark_panel_as_finished(self, meta: dict[str, Any], tool_sid: str) -> None:
1394
- """Mark panel as finished and ensure visibility."""
1395
- if meta.get("status") != "finished":
1396
- meta["status"] = "finished"
1397
-
1398
- dur = self._calculate_tool_duration(meta)
1399
- self._update_tool_metadata(meta, dur)
1400
-
1401
- # Ensure this finished panel is visible in this frame
1402
- self.stream_processor.current_event_finished_panels.add(tool_sid)
1403
-
1404
- def _finish_tool_panel(
1405
- self,
1406
- finished_tool_name: str,
1407
- finished_tool_output: Any,
1408
- task_id: str,
1409
- context_id: str,
1410
- ) -> None:
1411
- """Finish a tool panel and update its status."""
1412
- tool_sid = self._get_tool_session_id(finished_tool_name, task_id, context_id)
1413
- if tool_sid not in self.tool_panels:
1414
- return
1415
-
1416
- meta = self.tool_panels[tool_sid]
1417
- self._mark_panel_as_finished(meta, tool_sid)
1418
- self._add_tool_output_to_panel(meta, finished_tool_output, finished_tool_name)
1419
-
1420
- def _get_step_duration(self, finished_tool_name: str, task_id: str, context_id: str) -> float | None:
1421
- """Get step duration from tool panels."""
1422
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
1423
- return self.tool_panels.get(tool_sid, {}).get("duration_seconds")
1424
-
1425
- def _finish_delegation_step(
1426
- self,
1427
- finished_tool_name: str,
1428
- finished_tool_output: Any,
1429
- task_id: str,
1430
- context_id: str,
1431
- step_duration: float | None,
1432
- ) -> None:
1433
- """Finish a delegation step."""
1434
- self.steps.finish(
1435
- task_id=task_id,
1436
- context_id=context_id,
1437
- kind="delegate",
1438
- name=finished_tool_name,
1439
- output=finished_tool_output,
1440
- duration_raw=step_duration,
1441
- )
1442
-
1443
- def _finish_tool_step_type(
1444
- self,
1445
- finished_tool_name: str,
1446
- finished_tool_output: Any,
1447
- task_id: str,
1448
- context_id: str,
1449
- step_duration: float | None,
1450
- ) -> None:
1451
- """Finish a regular tool step."""
1452
- self.steps.finish(
1453
- task_id=task_id,
1454
- context_id=context_id,
1455
- kind="tool",
1456
- name=finished_tool_name,
1457
- output=finished_tool_output,
1458
- duration_raw=step_duration,
1459
- )
1460
-
1461
- def _finish_tool_step(
1462
- self,
1463
- finished_tool_name: str,
1464
- finished_tool_output: Any,
1465
- task_id: str,
1466
- context_id: str,
1467
- *,
1468
- tracked_step: Step | None = None,
1469
- ) -> None:
1470
- """Finish the corresponding step for a completed tool."""
1471
- if tracked_step is not None:
1472
- return
1473
-
1474
- step_duration = self._get_step_duration(finished_tool_name, task_id, context_id)
1475
-
1476
- if is_delegation_tool(finished_tool_name):
1477
- self._finish_delegation_step(
1478
- finished_tool_name,
1479
- finished_tool_output,
1480
- task_id,
1481
- context_id,
1482
- step_duration,
1483
- )
1484
- else:
1485
- self._finish_tool_step_type(
1486
- finished_tool_name,
1487
- finished_tool_output,
1488
- task_id,
1489
- context_id,
1490
- step_duration,
1491
- )
1492
-
1493
- def _should_create_snapshot(self, tool_sid: str) -> bool:
1494
- """Check if a snapshot should be created."""
1495
- return self.cfg.append_finished_snapshots and not self.tool_panels.get(tool_sid, {}).get("snapshot_printed")
1496
-
1497
- def _get_snapshot_title(self, meta: dict[str, Any], finished_tool_name: str) -> str:
1498
- """Get the title for the snapshot."""
1499
- adjusted_title = meta.get("title") or finished_tool_name
1500
-
1501
- # Add elapsed time to title
1502
- dur = meta.get("duration_seconds")
1503
- if isinstance(dur, (int, float)):
1504
- elapsed_str = self._format_snapshot_duration(dur)
1505
- adjusted_title = f"{adjusted_title} · {elapsed_str}"
1506
-
1507
- return adjusted_title
1508
-
1509
- def _format_snapshot_duration(self, dur: int | float) -> str:
1510
- """Format duration for snapshot title."""
1511
- try:
1512
- # Handle invalid types
1513
- if not isinstance(dur, (int, float)):
1514
- return "<1ms"
1515
-
1516
- if dur >= 1:
1517
- return f"{dur:.2f}s"
1518
- elif int(dur * 1000) > 0:
1519
- return f"{int(dur * 1000)}ms"
1520
- else:
1521
- return "<1ms"
1522
- except (TypeError, ValueError, OverflowError):
1523
- return "<1ms"
1524
-
1525
- def _clamp_snapshot_body(self, body_text: str) -> str:
1526
- """Clamp snapshot body to configured limits."""
1527
- max_lines = int(self.cfg.snapshot_max_lines or 0)
1528
- lines = body_text.splitlines()
1529
- if max_lines > 0 and len(lines) > max_lines:
1530
- lines = lines[:max_lines] + ["… (truncated)"]
1531
- body_text = "\n".join(lines)
1532
-
1533
- max_chars = int(self.cfg.snapshot_max_chars or 0)
1534
- if max_chars > 0 and len(body_text) > max_chars:
1535
- suffix = "\n… (truncated)"
1536
- body_text = body_text[: max_chars - len(suffix)] + suffix
1537
-
1538
- return body_text
1539
-
1540
- def _create_snapshot_panel(self, adjusted_title: str, body_text: str, finished_tool_name: str) -> Any:
1541
- """Create the snapshot panel."""
1542
- return create_tool_panel(
1543
- title=adjusted_title,
1544
- content=body_text or "(no output)",
1545
- status="finished",
1546
- theme=DEFAULT_RENDERER_THEME,
1547
- is_delegation=is_delegation_tool(finished_tool_name),
1548
- )
1549
-
1550
- def _print_and_mark_snapshot(self, tool_sid: str, snapshot_panel: Any) -> None:
1551
- """Print snapshot and mark as printed."""
1552
- self.console.print(snapshot_panel)
1553
- self.tool_panels[tool_sid]["snapshot_printed"] = True
1554
-
1555
- def _create_tool_snapshot(self, finished_tool_name: str, task_id: str, context_id: str) -> None:
1556
- """Create and print a snapshot for a finished tool."""
1557
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
1558
-
1559
- if not self._should_create_snapshot(tool_sid):
1560
- return
1561
-
1562
- meta = self.tool_panels[tool_sid]
1563
- adjusted_title = self._get_snapshot_title(meta, finished_tool_name)
1564
-
1565
- # Compose body from chunks and clamp
1566
- body_text = "".join(meta.get("chunks") or [])
1567
- body_text = self._clamp_snapshot_body(body_text)
1568
-
1569
- snapshot_panel = self._create_snapshot_panel(adjusted_title, body_text, finished_tool_name)
1570
-
1571
- self._print_and_mark_snapshot(tool_sid, snapshot_panel)
1572
-
1573
- def _handle_agent_step(
1574
- self,
1575
- event: dict[str, Any],
1576
- tool_name: str | None,
1577
- tool_args: Any,
1578
- _tool_out: Any,
1579
- tool_calls_info: list[tuple[str, Any, Any]],
1580
- *,
1581
- tracked_step: Step | None = None,
1582
- ) -> None:
1583
- """Handle agent step event."""
1584
- metadata = event.get("metadata", {})
1585
- task_id = event.get("task_id") or metadata.get("task_id")
1586
- context_id = event.get("context_id") or metadata.get("context_id")
1587
- content = event.get("content", "")
1588
-
1589
- # Create steps and panels for the primary tool
1590
- if tool_name:
1591
- tool_sid = self._ensure_tool_panel(tool_name, tool_args, task_id, context_id)
1592
- self._start_tool_step(
1593
- task_id,
1594
- context_id,
1595
- tool_name,
1596
- tool_args,
1597
- tool_sid,
1598
- tracked_step=tracked_step,
1599
- )
1600
-
1601
- # Handle additional tool calls
1602
- self._process_additional_tool_calls(tool_calls_info, tool_name, task_id, context_id)
1603
-
1604
- # Check for tool completion
1605
- (
1606
- is_tool_finished,
1607
- finished_tool_name,
1608
- finished_tool_output,
1609
- ) = self._detect_tool_completion(metadata, content)
1610
-
1611
- if is_tool_finished and finished_tool_name:
1612
- self._finish_tool_panel(finished_tool_name, finished_tool_output, task_id, context_id)
1613
- self._finish_tool_step(
1614
- finished_tool_name,
1615
- finished_tool_output,
1616
- task_id,
1617
- context_id,
1618
- tracked_step=tracked_step,
1619
- )
1620
- self._create_tool_snapshot(finished_tool_name, task_id, context_id)
1621
-
1622
- def _spinner(self) -> str:
1623
- """Return spinner character."""
1624
- return get_spinner()
1625
-
1626
754
  def _format_working_indicator(self, started_at: float | None) -> str:
1627
755
  """Format working indicator."""
1628
756
  return format_working_indicator(
@@ -1782,18 +910,7 @@ class RichStreamRenderer:
1782
910
 
1783
911
  def _resolve_step_label(self, step: Step) -> str:
1784
912
  """Return the display label for a step with sensible fallbacks."""
1785
- raw_label = getattr(step, "display_label", None)
1786
- label = raw_label.strip() if isinstance(raw_label, str) else ""
1787
- if label:
1788
- return normalise_display_label(label)
1789
-
1790
- if not (step.name or "").strip():
1791
- return UNKNOWN_STEP_DETAIL
1792
-
1793
- icon = self._get_step_icon(step.kind)
1794
- base_name = self._get_step_display_name(step)
1795
- fallback = " ".join(part for part in (icon, base_name) if part).strip()
1796
- return normalise_display_label(fallback)
913
+ return format_step_label(step)
1797
914
 
1798
915
  def _check_parallel_tools(self) -> dict[tuple[str | None, str | None], list]:
1799
916
  """Check for parallel running tools."""
@@ -1814,348 +931,69 @@ class RichStreamRenderer:
1814
931
  key = (step.task_id, step.context_id)
1815
932
  return len(running_by_ctx.get(key, [])) > 1
1816
933
 
1817
- def _compose_step_renderable(
1818
- self,
1819
- step: Step,
1820
- branch_state: tuple[bool, ...],
1821
- ) -> Any:
1822
- """Compose a single renderable for the hierarchical steps panel."""
1823
- prefix = build_connector_prefix(branch_state)
1824
- text_line = self._build_step_text_line(step, prefix)
1825
- renderables = self._wrap_step_text(step, text_line)
1826
-
1827
- args_renderable = self._build_args_renderable(step, prefix)
1828
- if args_renderable is not None:
1829
- renderables.append(args_renderable)
1830
-
1831
- return self._collapse_renderables(renderables)
1832
-
1833
- def _build_step_text_line(
1834
- self,
1835
- step: Step,
1836
- prefix: str,
1837
- ) -> Text:
1838
- """Create the textual portion of a step renderable."""
1839
- text_line = Text()
1840
- text_line.append(prefix, style="dim")
1841
- text_line.append(self._resolve_step_label(step))
1842
-
1843
- status_badge = self._format_step_status(step)
1844
- self._append_status_badge(text_line, step, status_badge)
1845
- self._append_state_glyph(text_line, step)
1846
- return text_line
1847
-
1848
- def _append_status_badge(self, text_line: Text, step: Step, status_badge: str) -> None:
1849
- """Append the formatted status badge when available."""
1850
- glyph_key = getattr(step, "status_icon", None)
1851
- glyph = glyph_for_status(glyph_key)
1852
-
1853
- if status_badge:
1854
- text_line.append(" ")
1855
- text_line.append(status_badge, style="cyan")
1856
-
1857
- if glyph:
1858
- text_line.append(" ")
1859
- style = self._status_icon_style(glyph_key)
1860
- if style:
1861
- text_line.append(glyph, style=style)
1862
- else:
1863
- text_line.append(glyph)
1864
-
1865
- def _append_state_glyph(self, text_line: Text, step: Step) -> None:
1866
- """Append glyph/failure markers in a single place."""
1867
- failure_reason = (step.failure_reason or "").strip()
1868
- if failure_reason:
1869
- text_line.append(f" {failure_reason}")
1870
-
1871
- @staticmethod
1872
- def _status_icon_style(icon_key: str | None) -> str | None:
1873
- """Return style for a given status icon."""
1874
- if not icon_key:
1875
- return None
1876
- return STATUS_ICON_STYLES.get(icon_key)
1877
-
1878
- def _wrap_step_text(self, step: Step, text_line: Text) -> list[Any]:
1879
- """Return the base text, optionally decorated with a trailing spinner."""
1880
- if getattr(step, "status", None) == "running":
1881
- spinner = self._step_spinners.get(step.step_id)
1882
- if spinner is None:
1883
- spinner = Spinner("dots", style="dim")
1884
- self._step_spinners[step.step_id] = spinner
1885
- return [TrailingSpinnerLine(text_line, spinner)]
1886
-
1887
- self._step_spinners.pop(step.step_id, None)
1888
- return [text_line]
1889
-
1890
- def _collapse_renderables(self, renderables: list[Any]) -> Any:
1891
- """Collapse a list of renderables into a single object."""
1892
- if not renderables:
1893
- return None
1894
-
1895
- if len(renderables) == 1:
1896
- return renderables[0]
1897
-
1898
- return Group(*renderables)
1899
-
1900
- def _build_args_renderable(self, step: Step, prefix: str) -> Text | Group | None:
1901
- """Build a dimmed argument line for tool or agent steps."""
1902
- if step.kind not in {"tool", "delegate", "agent"}:
1903
- return None
1904
- if step.kind == "agent" and step.parent_id:
1905
- return None
1906
- formatted_args = self._format_step_args(step)
1907
- if not formatted_args:
1908
- return None
1909
- if isinstance(formatted_args, list):
1910
- return self._build_arg_list(prefix, formatted_args)
1911
-
1912
- args_text = Text()
1913
- args_text.append(prefix, style="dim")
1914
- args_text.append(" " * 5)
1915
- args_text.append(formatted_args, style="dim")
1916
- return args_text
1917
-
1918
- def _build_arg_list(self, prefix: str, formatted_args: list[str | tuple[int, str]]) -> Group | None:
1919
- """Render multi-line argument entries preserving indentation."""
1920
- arg_lines: list[Text] = []
1921
- for indent_level, text_value in self._iter_arg_entries(formatted_args):
1922
- arg_text = Text()
1923
- arg_text.append(prefix, style="dim")
1924
- arg_text.append(" " * 5)
1925
- arg_text.append(" " * (indent_level * 2))
1926
- arg_text.append(text_value, style="dim")
1927
- arg_lines.append(arg_text)
1928
- if not arg_lines:
1929
- return None
1930
- return Group(*arg_lines)
1931
-
1932
- @staticmethod
1933
- def _iter_arg_entries(
1934
- formatted_args: list[str | tuple[int, str]],
1935
- ) -> Iterable[tuple[int, str]]:
1936
- """Yield normalized indentation/value pairs for argument entries."""
1937
- for value in formatted_args:
1938
- if isinstance(value, tuple) and len(value) == 2:
1939
- indent_level, text_value = value
1940
- yield indent_level, str(text_value)
1941
- else:
1942
- yield 0, str(value)
1943
-
1944
- def _format_step_args(self, step: Step) -> str | list[str] | list[tuple[int, str]] | None:
1945
- """Return a printable representation of tool arguments."""
1946
- args = getattr(step, "args", None)
1947
- if args is None:
1948
- return None
1949
-
1950
- if isinstance(args, dict):
1951
- return self._format_dict_args(args, step=step)
1952
-
1953
- if isinstance(args, (list, tuple)):
1954
- return self._safe_pretty_args(list(args))
1955
-
1956
- if isinstance(args, (str, int, float)):
1957
- return self._stringify_args(args)
1958
-
1959
- return None
1960
-
1961
- def _format_dict_args(self, args: dict[str, Any], *, step: Step) -> str | list[str] | list[tuple[int, str]] | None:
1962
- """Format dictionary arguments with guardrails."""
1963
- if not args:
1964
- return None
1965
-
1966
- masked_args = self._redact_arg_payload(args)
1967
-
1968
- if self._should_collapse_single_query(step):
1969
- single_query = self._extract_single_query_arg(masked_args)
1970
- if single_query:
1971
- return single_query
1972
-
1973
- return self._format_dict_arg_lines(masked_args)
1974
-
1975
- @staticmethod
1976
- def _extract_single_query_arg(args: dict[str, Any]) -> str | None:
1977
- """Return a trimmed query argument when it is the only entry."""
1978
- if len(args) != 1:
1979
- return None
1980
- key, value = next(iter(args.items()))
1981
- if key != "query" or not isinstance(value, str):
1982
- return None
1983
- stripped = value.strip()
1984
- return stripped or None
1985
-
1986
- @staticmethod
1987
- def _redact_arg_payload(args: dict[str, Any]) -> dict[str, Any]:
1988
- """Apply best-effort masking before rendering arguments."""
1989
- try:
1990
- cleaned = redact_sensitive(args)
1991
- return cleaned if isinstance(cleaned, dict) else args
1992
- except Exception:
1993
- return args
1994
-
1995
- @staticmethod
1996
- def _should_collapse_single_query(step: Step) -> bool:
1997
- """Return True when we should display raw query text."""
1998
- if step.kind == "agent":
1999
- return True
2000
- if step.kind == "delegate":
2001
- return True
2002
- return False
2003
-
2004
- def _format_dict_arg_lines(self, args: dict[str, Any]) -> list[tuple[int, str]] | None:
2005
- """Render dictionary arguments as nested YAML-style lines."""
2006
- lines: list[tuple[int, str]] = []
2007
- for raw_key, value in args.items():
2008
- key = str(raw_key)
2009
- lines.extend(self._format_nested_entry(key, value, indent=0))
2010
- return lines or None
2011
-
2012
- def _format_nested_entry(self, key: str, value: Any, indent: int) -> list[tuple[int, str]]:
2013
- """Format a mapping entry recursively."""
2014
- lines: list[tuple[int, str]] = []
2015
-
2016
- if isinstance(value, dict):
2017
- if value:
2018
- lines.append((indent, f"{key}:"))
2019
- lines.extend(self._format_nested_mapping(value, indent + 1))
2020
- else:
2021
- lines.append((indent, f"{key}: {{}}"))
2022
- return lines
2023
-
2024
- if isinstance(value, (list, tuple, set)):
2025
- seq_lines = self._format_sequence_entries(list(value), indent + 1)
2026
- if seq_lines:
2027
- lines.append((indent, f"{key}:"))
2028
- lines.extend(seq_lines)
2029
- else:
2030
- lines.append((indent, f"{key}: []"))
2031
- return lines
2032
-
2033
- formatted_value = self._format_arg_value(value)
2034
- if formatted_value is not None:
2035
- lines.append((indent, f"{key}: {formatted_value}"))
2036
- return lines
2037
-
2038
- def _format_nested_mapping(self, mapping: dict[str, Any], indent: int) -> list[tuple[int, str]]:
2039
- """Format nested dictionary values."""
2040
- nested_lines: list[tuple[int, str]] = []
2041
- for raw_key, value in mapping.items():
2042
- key = str(raw_key)
2043
- nested_lines.extend(self._format_nested_entry(key, value, indent))
2044
- return nested_lines
2045
-
2046
- def _format_sequence_entries(self, sequence: list[Any], indent: int) -> list[tuple[int, str]]:
2047
- """Format list/tuple/set values with YAML-style bullets."""
2048
- if not sequence:
2049
- return []
2050
-
2051
- lines: list[tuple[int, str]] = []
2052
- for item in sequence:
2053
- lines.extend(self._format_sequence_item(item, indent))
2054
- return lines
2055
-
2056
- def _format_sequence_item(self, item: Any, indent: int) -> list[tuple[int, str]]:
2057
- """Format a single list entry."""
2058
- if isinstance(item, dict):
2059
- return self._format_dict_sequence_item(item, indent)
2060
-
2061
- if isinstance(item, (list, tuple, set)):
2062
- return self._format_nested_sequence_item(list(item), indent)
2063
-
2064
- formatted = self._format_arg_value(item)
2065
- if formatted is not None:
2066
- return [(indent, f"- {formatted}")]
2067
- return []
2068
-
2069
- def _format_dict_sequence_item(self, mapping: dict[str, Any], indent: int) -> list[tuple[int, str]]:
2070
- """Format a dictionary entry within a list."""
2071
- child_lines = self._format_nested_mapping(mapping, indent + 1)
2072
- if child_lines:
2073
- return self._prepend_sequence_prefix(child_lines, indent)
2074
- return [(indent, "- {}")]
2075
-
2076
- def _format_nested_sequence_item(self, sequence: list[Any], indent: int) -> list[tuple[int, str]]:
2077
- """Format a nested sequence entry within a list."""
2078
- child_lines = self._format_sequence_entries(sequence, indent + 1)
2079
- if child_lines:
2080
- return self._prepend_sequence_prefix(child_lines, indent)
2081
- return [(indent, "- []")]
2082
-
2083
- @staticmethod
2084
- def _prepend_sequence_prefix(child_lines: list[tuple[int, str]], indent: int) -> list[tuple[int, str]]:
2085
- """Attach a sequence bullet to the first child line."""
2086
- _, first_text = child_lines[0]
2087
- prefixed: list[tuple[int, str]] = [(indent, f"- {first_text}")]
2088
- prefixed.extend(child_lines[1:])
2089
- return prefixed
2090
-
2091
- def _format_arg_value(self, value: Any) -> str | None:
2092
- """Format a single argument value with per-value truncation."""
2093
- if value is None:
2094
- return "null"
2095
- if isinstance(value, (bool, int, float)):
2096
- return json.dumps(value, ensure_ascii=False)
2097
- if isinstance(value, str):
2098
- return self._format_string_arg_value(value)
2099
- return _truncate_display(str(value), limit=ARGS_VALUE_MAX_LEN)
2100
-
2101
- @staticmethod
2102
- def _format_string_arg_value(value: str) -> str:
2103
- """Return a trimmed, quoted representation of a string argument."""
2104
- sanitised = value.replace("\n", " ").strip()
2105
- sanitised = sanitised.replace('"', '\\"')
2106
- trimmed = _truncate_display(sanitised, limit=ARGS_VALUE_MAX_LEN)
2107
- return f'"{trimmed}"'
2108
-
2109
- @staticmethod
2110
- def _safe_pretty_args(args: dict[str, Any]) -> str | None:
2111
- """Defensively format argument dictionaries."""
2112
- try:
2113
- return pretty_args(args, max_len=160)
2114
- except Exception:
2115
- return str(args)
2116
-
2117
- @staticmethod
2118
- def _stringify_args(args: Any) -> str | None:
2119
- """Format non-dictionary argument payloads."""
2120
- text = str(args).strip()
2121
- if not text:
2122
- return None
2123
- return _truncate_display(text)
2124
-
2125
- def _render_steps_text(self) -> Any:
2126
- """Render the steps panel content."""
2127
- if not (self.steps.order or self.steps.children):
2128
- return _NO_STEPS_TEXT.copy()
2129
-
2130
- nodes = list(self.steps.iter_tree())
2131
- if not nodes:
2132
- return _NO_STEPS_TEXT.copy()
2133
-
2134
- window = self._summary_window_size()
2135
- display_nodes, header_notice, footer_notice = clamp_step_nodes(
2136
- nodes,
2137
- window=window,
2138
- get_label=self._get_step_label,
2139
- get_parent=self._get_step_parent,
934
+ def _build_step_status_overrides(self) -> dict[str, str]:
935
+ """Return status text overrides for steps (running duration badges)."""
936
+ overrides: dict[str, str] = {}
937
+ for sid in self.steps.order:
938
+ step = self.steps.by_id.get(sid)
939
+ if not step:
940
+ continue
941
+ try:
942
+ status_text = self._format_step_status(step)
943
+ except Exception:
944
+ status_text = ""
945
+ if status_text:
946
+ overrides[sid] = status_text
947
+ return overrides
948
+
949
+ def _resolve_steps_panel(self) -> AIPPanel:
950
+ """Return the shared steps panel renderable generated by layout helpers."""
951
+ window_arg = self._summary_window_size()
952
+ window_arg = window_arg if window_arg > 0 else None
953
+ panels = render_summary_panels(
954
+ self.state,
955
+ self.steps,
956
+ summary_window=window_arg,
957
+ include_query_panel=False,
958
+ include_final_panel=False,
959
+ step_status_overrides=self._build_step_status_overrides(),
2140
960
  )
2141
- step_renderables = self._build_step_renderables(display_nodes)
2142
-
2143
- if not step_renderables and not header_notice and not footer_notice:
2144
- return _NO_STEPS_TEXT.copy()
2145
-
2146
- return self._assemble_step_renderables(step_renderables, header_notice, footer_notice)
2147
-
2148
- def _get_step_label(self, step_id: str) -> str:
2149
- """Get label for a step by ID."""
2150
- step = self.steps.by_id.get(step_id)
2151
- if step:
2152
- return self._resolve_step_label(step)
2153
- return UNKNOWN_STEP_DETAIL
961
+ steps_panel = next((panel for panel in panels if getattr(panel, "title", "").lower() == "steps"), None)
962
+ panel_cls = AIPPanel if isinstance(AIPPanel, type) else None
963
+ if steps_panel is not None and (panel_cls is None or isinstance(steps_panel, panel_cls)):
964
+ return steps_panel
965
+ return AIPPanel(_NO_STEPS_TEXT.copy(), title="Steps", border_style="blue")
966
+
967
+ def _prepare_steps_renderable(self, *, include_progress: bool) -> tuple[AIPPanel, Any]:
968
+ """Return the template panel and content renderable for steps."""
969
+ panel = self._resolve_steps_panel()
970
+ self._last_steps_panel_template = panel
971
+ base_renderable: Any = getattr(panel, "renderable", panel)
972
+
973
+ if include_progress and not self.state.finalizing_ui:
974
+ footer = build_progress_footer(
975
+ state=self.state,
976
+ steps=self.steps,
977
+ started_at=self._started_at,
978
+ server_elapsed_time=self.stream_processor.server_elapsed_time,
979
+ )
980
+ if footer is not None:
981
+ if isinstance(base_renderable, Group):
982
+ base_renderable = Group(*base_renderable.renderables, footer)
983
+ else:
984
+ base_renderable = Group(base_renderable, footer)
985
+ return panel, base_renderable
986
+
987
+ def _build_steps_body(self, *, include_progress: bool) -> Any:
988
+ """Return the rendered steps body with optional progress footer."""
989
+ _, renderable = self._prepare_steps_renderable(include_progress=include_progress)
990
+ if isinstance(renderable, Group):
991
+ return renderable
992
+ return Group(renderable)
2154
993
 
2155
- def _get_step_parent(self, step_id: str) -> str | None:
2156
- """Get parent ID for a step by ID."""
2157
- step = self.steps.by_id.get(step_id)
2158
- return step.parent_id if step else None
994
+ def _render_steps_text(self) -> Any:
995
+ """Return the rendered steps body used by transcript capture."""
996
+ return self._build_steps_body(include_progress=True)
2159
997
 
2160
998
  def _summary_window_size(self) -> int:
2161
999
  """Return the active window size for step display."""
@@ -2163,32 +1001,6 @@ class RichStreamRenderer:
2163
1001
  return 0
2164
1002
  return int(self.cfg.summary_display_window or 0)
2165
1003
 
2166
- def _assemble_step_renderables(self, step_renderables: list[Any], header_notice: Any, footer_notice: Any) -> Any:
2167
- """Assemble step renderables with header and footer into final output."""
2168
- renderables: list[Any] = []
2169
- if header_notice is not None:
2170
- renderables.append(header_notice)
2171
- renderables.extend(step_renderables)
2172
- if footer_notice is not None:
2173
- renderables.append(footer_notice)
2174
-
2175
- if len(renderables) == 1:
2176
- return renderables[0]
2177
-
2178
- return Group(*renderables)
2179
-
2180
- def _build_step_renderables(self, display_nodes: list[tuple[str, tuple[bool, ...]]]) -> list[Any]:
2181
- """Convert step nodes to renderables for the steps panel."""
2182
- renderables: list[Any] = []
2183
- for step_id, branch_state in display_nodes:
2184
- step = self.steps.by_id.get(step_id)
2185
- if not step:
2186
- continue
2187
- renderable = self._compose_step_renderable(step, branch_state)
2188
- if renderable is not None:
2189
- renderables.append(renderable)
2190
- return renderables
2191
-
2192
1004
  def _update_final_duration(self, duration: float | None, *, overwrite: bool = False) -> None:
2193
1005
  """Store formatted duration for eventual final panels."""
2194
1006
  if duration is None:
@@ -2207,73 +1019,6 @@ class RichStreamRenderer:
2207
1019
  if overwrite and existing is not None:
2208
1020
  duration_val = max(existing, duration_val)
2209
1021
 
2210
- self.state.final_duration_seconds = duration_val
2211
- self.state.final_duration_text = self._format_elapsed_time(duration_val)
1022
+ formatted = format_elapsed_time(duration_val)
1023
+ self.state.mark_final_duration(duration_val, formatted=formatted)
2212
1024
  self._apply_root_duration(duration_val)
2213
-
2214
- def _format_elapsed_time(self, elapsed: float) -> str:
2215
- """Format elapsed time as a readable string."""
2216
- if elapsed >= 1:
2217
- return f"{elapsed:.2f}s"
2218
- elif int(elapsed * 1000) > 0:
2219
- return f"{int(elapsed * 1000)}ms"
2220
- else:
2221
- return "<1ms"
2222
-
2223
- def _format_dict_or_list_output(self, output_value: dict | list) -> str:
2224
- """Format dict/list output as pretty JSON."""
2225
- try:
2226
- return self.OUTPUT_PREFIX + "```json\n" + json.dumps(output_value, indent=2) + "\n```\n"
2227
- except Exception:
2228
- return self.OUTPUT_PREFIX + str(output_value) + "\n"
2229
-
2230
- def _clean_sub_agent_prefix(self, output: str, tool_name: str | None) -> str:
2231
- """Clean sub-agent name prefix from output."""
2232
- if not (tool_name and is_delegation_tool(tool_name)):
2233
- return output
2234
-
2235
- sub = tool_name
2236
- if tool_name.startswith("delegate_to_"):
2237
- sub = tool_name.replace("delegate_to_", "")
2238
- elif tool_name.startswith("delegate_"):
2239
- sub = tool_name.replace("delegate_", "")
2240
- prefix = f"[{sub}]"
2241
- if output.startswith(prefix):
2242
- return output[len(prefix) :].lstrip()
2243
-
2244
- return output
2245
-
2246
- def _format_json_string_output(self, output: str) -> str:
2247
- """Format string that looks like JSON."""
2248
- try:
2249
- parsed = json.loads(output)
2250
- return self.OUTPUT_PREFIX + "```json\n" + json.dumps(parsed, indent=2) + "\n```\n"
2251
- except Exception:
2252
- return self.OUTPUT_PREFIX + output + "\n"
2253
-
2254
- def _format_string_output(self, output: str, tool_name: str | None) -> str:
2255
- """Format string output with optional prefix cleaning."""
2256
- s = output.strip()
2257
- s = self._clean_sub_agent_prefix(s, tool_name)
2258
-
2259
- # If looks like JSON, pretty print it
2260
- if (s.startswith("{") and s.endswith("}")) or (s.startswith("[") and s.endswith("]")):
2261
- return self._format_json_string_output(s)
2262
-
2263
- return self.OUTPUT_PREFIX + s + "\n"
2264
-
2265
- def _format_other_output(self, output_value: Any) -> str:
2266
- """Format other types of output."""
2267
- try:
2268
- return self.OUTPUT_PREFIX + json.dumps(output_value, indent=2) + "\n"
2269
- except Exception:
2270
- return self.OUTPUT_PREFIX + str(output_value) + "\n"
2271
-
2272
- def _format_output_block(self, output_value: Any, tool_name: str | None) -> str:
2273
- """Format an output value for panel display."""
2274
- if isinstance(output_value, (dict, list)):
2275
- return self._format_dict_or_list_output(output_value)
2276
- elif isinstance(output_value, str):
2277
- return self._format_string_output(output_value, tool_name)
2278
- else:
2279
- return self._format_other_output(output_value)