glaip-sdk 0.0.20__py3-none-any.whl → 0.1.3__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 (66) hide show
  1. glaip_sdk/_version.py +1 -3
  2. glaip_sdk/branding.py +2 -6
  3. glaip_sdk/cli/agent_config.py +2 -6
  4. glaip_sdk/cli/auth.py +11 -30
  5. glaip_sdk/cli/commands/agents.py +64 -107
  6. glaip_sdk/cli/commands/configure.py +12 -36
  7. glaip_sdk/cli/commands/mcps.py +25 -63
  8. glaip_sdk/cli/commands/models.py +2 -4
  9. glaip_sdk/cli/commands/tools.py +22 -35
  10. glaip_sdk/cli/commands/update.py +3 -8
  11. glaip_sdk/cli/config.py +1 -3
  12. glaip_sdk/cli/display.py +10 -13
  13. glaip_sdk/cli/io.py +8 -14
  14. glaip_sdk/cli/main.py +10 -30
  15. glaip_sdk/cli/mcp_validators.py +5 -15
  16. glaip_sdk/cli/pager.py +3 -9
  17. glaip_sdk/cli/parsers/json_input.py +11 -22
  18. glaip_sdk/cli/resolution.py +3 -9
  19. glaip_sdk/cli/rich_helpers.py +1 -3
  20. glaip_sdk/cli/slash/agent_session.py +5 -10
  21. glaip_sdk/cli/slash/prompt.py +3 -10
  22. glaip_sdk/cli/slash/session.py +46 -98
  23. glaip_sdk/cli/transcript/cache.py +6 -19
  24. glaip_sdk/cli/transcript/capture.py +45 -20
  25. glaip_sdk/cli/transcript/launcher.py +1 -3
  26. glaip_sdk/cli/transcript/viewer.py +224 -47
  27. glaip_sdk/cli/update_notifier.py +165 -21
  28. glaip_sdk/cli/utils.py +33 -91
  29. glaip_sdk/cli/validators.py +11 -12
  30. glaip_sdk/client/_agent_payloads.py +10 -30
  31. glaip_sdk/client/agents.py +33 -63
  32. glaip_sdk/client/base.py +77 -35
  33. glaip_sdk/client/mcps.py +1 -3
  34. glaip_sdk/client/run_rendering.py +121 -26
  35. glaip_sdk/client/tools.py +8 -24
  36. glaip_sdk/client/validators.py +20 -48
  37. glaip_sdk/exceptions.py +1 -3
  38. glaip_sdk/icons.py +9 -3
  39. glaip_sdk/models.py +14 -33
  40. glaip_sdk/payload_schemas/agent.py +1 -3
  41. glaip_sdk/utils/agent_config.py +4 -14
  42. glaip_sdk/utils/client_utils.py +7 -21
  43. glaip_sdk/utils/display.py +2 -6
  44. glaip_sdk/utils/general.py +1 -3
  45. glaip_sdk/utils/import_export.py +3 -9
  46. glaip_sdk/utils/rendering/formatting.py +52 -12
  47. glaip_sdk/utils/rendering/models.py +17 -8
  48. glaip_sdk/utils/rendering/renderer/__init__.py +1 -5
  49. glaip_sdk/utils/rendering/renderer/base.py +1181 -328
  50. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  51. glaip_sdk/utils/rendering/renderer/debug.py +4 -14
  52. glaip_sdk/utils/rendering/renderer/panels.py +1 -3
  53. glaip_sdk/utils/rendering/renderer/progress.py +3 -11
  54. glaip_sdk/utils/rendering/renderer/stream.py +9 -42
  55. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  56. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  57. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  58. glaip_sdk/utils/rendering/steps.py +899 -25
  59. glaip_sdk/utils/resource_refs.py +4 -13
  60. glaip_sdk/utils/serialization.py +14 -46
  61. glaip_sdk/utils/validation.py +4 -4
  62. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/METADATA +12 -1
  63. glaip_sdk-0.1.3.dist-info/RECORD +83 -0
  64. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  65. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/WHEEL +0 -0
  66. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.1.3.dist-info}/entry_points.txt +0 -0
@@ -8,25 +8,31 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import logging
11
+ from collections.abc import Iterable
11
12
  from dataclasses import dataclass, field
12
13
  from datetime import datetime, timezone
13
14
  from time import monotonic
14
15
  from typing import Any
15
16
 
16
- from rich.align import Align
17
17
  from rich.console import Console as RichConsole
18
18
  from rich.console import Group
19
19
  from rich.live import Live
20
20
  from rich.markdown import Markdown
21
+ from rich.measure import Measurement
21
22
  from rich.spinner import Spinner
22
23
  from rich.text import Text
23
24
 
24
25
  from glaip_sdk.icons import ICON_AGENT, ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
25
26
  from glaip_sdk.rich_components import AIPPanel
26
27
  from glaip_sdk.utils.rendering.formatting import (
28
+ build_connector_prefix,
27
29
  format_main_title,
28
30
  get_spinner_char,
31
+ glyph_for_status,
29
32
  is_step_finished,
33
+ normalise_display_label,
34
+ pretty_args,
35
+ redact_sensitive,
30
36
  )
31
37
  from glaip_sdk.utils.rendering.models import RunStats, Step
32
38
  from glaip_sdk.utils.rendering.renderer.config import RendererConfig
@@ -44,13 +50,33 @@ from glaip_sdk.utils.rendering.renderer.progress import (
44
50
  is_delegation_tool,
45
51
  )
46
52
  from glaip_sdk.utils.rendering.renderer.stream import StreamProcessor
47
- from glaip_sdk.utils.rendering.steps import StepManager
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
55
+
56
+ DEFAULT_RENDERER_THEME = "dark"
57
+ _NO_STEPS_TEXT = Text("No steps yet", style="dim")
48
58
 
49
59
  # Configure logger
50
60
  logger = logging.getLogger("glaip_sdk.run_renderer")
51
61
 
52
62
  # Constants
53
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
+ RUNNING_STATUS_HINTS = {"running", "started", "pending", "working"}
74
+ ARGS_VALUE_MAX_LEN = 160
75
+ STATUS_ICON_STYLES = {
76
+ "success": "green",
77
+ "failed": "red",
78
+ "warning": "yellow",
79
+ }
54
80
 
55
81
 
56
82
  def _coerce_received_at(value: Any) -> datetime | None:
@@ -72,6 +98,16 @@ def _coerce_received_at(value: Any) -> datetime | None:
72
98
  return None
73
99
 
74
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
+
75
111
  @dataclass
76
112
  class RendererState:
77
113
  """Internal state for the renderer."""
@@ -96,6 +132,43 @@ class RendererState:
96
132
  self.buffer = []
97
133
 
98
134
 
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
+
99
172
  class RichStreamRenderer:
100
173
  """Live, modern terminal renderer for agent execution with rich visual output."""
101
174
 
@@ -122,17 +195,18 @@ class RichStreamRenderer:
122
195
  self.state = RendererState()
123
196
 
124
197
  # Initialize step manager and other state
125
- self.steps = StepManager()
198
+ self.steps = StepManager(max_steps=self.cfg.summary_max_steps)
126
199
  # Live display instance (single source of truth)
127
200
  self.live: Live | None = None
201
+ self._step_spinners: dict[str, Spinner] = {}
128
202
 
129
- # Context and tool tracking
130
- self.context_order: list[str] = []
131
- self.context_parent: dict[str, str] = {}
132
- self.tool_order: list[str] = []
133
- self.context_panels: dict[str, list[str]] = {}
134
- self.context_meta: dict[str, dict[str, Any]] = {}
203
+ # Tool tracking and thinking scopes
135
204
  self.tool_panels: dict[str, dict[str, Any]] = {}
205
+ self._thinking_scopes: dict[str, ThinkingScopeState] = {}
206
+ self._root_agent_friendly: str | None = None
207
+ self._root_agent_step_id: str | None = None
208
+ self._root_query: str | None = None
209
+ self._root_query_attached: bool = False
136
210
 
137
211
  # Timing
138
212
  self._started_at: float | None = None
@@ -145,6 +219,17 @@ class RichStreamRenderer:
145
219
  # Output formatting constants
146
220
  self.OUTPUT_PREFIX: str = "**Output:**\n"
147
221
 
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
232
+
148
233
  def on_start(self, meta: dict[str, Any]) -> None:
149
234
  """Handle renderer start event."""
150
235
  if self.cfg.live:
@@ -158,6 +243,20 @@ class RichStreamRenderer:
158
243
  except Exception:
159
244
  self.state.meta = dict(meta)
160
245
 
246
+ meta_payload = meta or {}
247
+ self.steps.set_root_agent(meta_payload.get("agent_id"))
248
+ self._root_agent_friendly = self._humanize_agent_slug(meta_payload.get("agent_name"))
249
+ self._root_query = _truncate_display(
250
+ meta_payload.get("input_message")
251
+ or meta_payload.get("query")
252
+ or meta_payload.get("message")
253
+ or (meta_payload.get("meta") or {}).get("input_message")
254
+ or ""
255
+ )
256
+ if not self._root_query:
257
+ self._root_query = None
258
+ self._root_query_attached = False
259
+
161
260
  # Print compact header and user request (parity with old renderer)
162
261
  self._render_header(meta)
163
262
  self._render_user_query(meta)
@@ -207,20 +306,73 @@ class RichStreamRenderer:
207
306
  except Exception:
208
307
  logger.exception("Failed to print header fallback")
209
308
 
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
+ def _build_user_query_panel(self, query: str) -> AIPPanel:
324
+ """Create the panel used to display the user request."""
325
+ return AIPPanel(
326
+ Markdown(f"**Query:** {query}"),
327
+ title="User Request",
328
+ border_style="#d97706",
329
+ padding=(0, 1),
330
+ )
331
+
210
332
  def _render_user_query(self, meta: dict[str, Any]) -> None:
211
333
  """Render the user query panel."""
212
- query = meta.get("input_message") or meta.get("query") or meta.get("message")
334
+ query = self._extract_query_from_meta(meta)
213
335
  if not query:
214
336
  return
337
+ self.console.print(self._build_user_query_panel(query))
338
+
339
+ def _render_summary_static_sections(self) -> None:
340
+ """Re-render header and user query when returning to summary mode."""
341
+ meta = getattr(self.state, "meta", None)
342
+ if meta:
343
+ self._render_header(meta)
344
+ elif self.header_text and not self._render_header_rule():
345
+ self._render_header_fallback()
215
346
 
216
- self.console.print(
217
- AIPPanel(
218
- Markdown(f"**Query:** {query}"),
219
- title="User Request",
220
- border_style="#d97706",
221
- padding=(0, 1),
222
- )
347
+ query = self._extract_query_from_meta(meta) or self._root_query
348
+ if query:
349
+ self.console.print(self._build_user_query_panel(query))
350
+
351
+ def _render_summary_after_transcript_toggle(self) -> None:
352
+ """Render the summary panel after leaving transcript mode."""
353
+ if self.state.finalizing_ui:
354
+ self._render_final_summary_panels()
355
+ elif self.live:
356
+ self._refresh_live_panels()
357
+ else:
358
+ self._render_static_summary_panels()
359
+
360
+ def _render_final_summary_panels(self) -> None:
361
+ """Render a static summary and disable live mode for final output."""
362
+ self.cfg.live = False
363
+ self.live = None
364
+ self._render_static_summary_panels()
365
+
366
+ def _render_static_summary_panels(self) -> None:
367
+ """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",
223
373
  )
374
+ self.console.print(steps_panel)
375
+ self.console.print(self._render_main_panel())
224
376
 
225
377
  def _ensure_streaming_started_baseline(self, timestamp: float) -> None:
226
378
  """Synchronize streaming start state across renderer components."""
@@ -237,10 +389,12 @@ class RichStreamRenderer:
237
389
  self._sync_stream_start(ev, received_at)
238
390
 
239
391
  metadata = self.stream_processor.extract_event_metadata(ev)
240
- self.stream_processor.update_timing(metadata["context_id"])
241
392
 
242
393
  self._maybe_render_debug(ev, received_at)
243
- self._dispatch_event(ev, metadata)
394
+ try:
395
+ self._dispatch_event(ev, metadata)
396
+ finally:
397
+ self.stream_processor.update_timing(metadata.get("context_id"))
244
398
 
245
399
  def _resolve_received_timestamp(self, ev: dict[str, Any]) -> datetime:
246
400
  """Return the timestamp an event was received, normalising inputs."""
@@ -253,9 +407,7 @@ class RichStreamRenderer:
253
407
 
254
408
  return received_at
255
409
 
256
- def _sync_stream_start(
257
- self, ev: dict[str, Any], received_at: datetime | None
258
- ) -> None:
410
+ def _sync_stream_start(self, ev: dict[str, Any], received_at: datetime | None) -> None:
259
411
  """Ensure renderer and stream processor share a streaming baseline."""
260
412
  baseline = self.state.streaming_started_at
261
413
  if baseline is None:
@@ -275,12 +427,14 @@ class RichStreamRenderer:
275
427
  if not self.verbose:
276
428
  return
277
429
 
430
+ self._ensure_transcript_header()
278
431
  render_debug_event(
279
432
  ev,
280
433
  self.console,
281
434
  received_ts=received_at,
282
435
  baseline_ts=self.state.streaming_started_event_ts,
283
436
  )
437
+ self._print_transcript_hint()
284
438
 
285
439
  def _dispatch_event(self, ev: dict[str, Any], metadata: dict[str, Any]) -> None:
286
440
  """Route events to the appropriate renderer handlers."""
@@ -294,7 +448,7 @@ class RichStreamRenderer:
294
448
  elif kind == "final_response":
295
449
  self._handle_final_response_event(content, metadata)
296
450
  elif kind in {"agent_step", "agent_thinking_step"}:
297
- self._handle_agent_step_event(ev)
451
+ self._handle_agent_step_event(ev, metadata)
298
452
  else:
299
453
  self._ensure_live()
300
454
 
@@ -310,21 +464,32 @@ class RichStreamRenderer:
310
464
  self.state.buffer.append(content)
311
465
  self._ensure_live()
312
466
 
313
- def _handle_final_response_event(
314
- self, content: str, metadata: dict[str, Any]
315
- ) -> None:
467
+ def _handle_final_response_event(self, content: str, metadata: dict[str, Any]) -> None:
316
468
  """Handle final response events."""
317
469
  if content:
318
470
  self.state.buffer.append(content)
319
471
  self.state.final_text = content
320
472
 
321
473
  meta_payload = metadata.get("metadata") or {}
322
- self._update_final_duration(meta_payload.get("time"))
474
+ final_time = self._coerce_server_time(meta_payload.get("time"))
475
+ self._update_final_duration(final_time)
476
+ self._close_active_thinking_scopes(final_time)
477
+ self._finish_running_steps()
478
+ self._finish_tool_panels()
479
+ self._normalise_finished_icons()
323
480
 
324
- self._ensure_live()
325
- self._print_final_panel_if_needed()
481
+ self._ensure_live()
482
+ self._print_final_panel_if_needed()
483
+
484
+ def _normalise_finished_icons(self) -> None:
485
+ """Ensure finished steps do not keep spinner icons."""
486
+ 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
+ if getattr(step, "status", None) != "running":
490
+ self._step_spinners.pop(step.step_id, None)
326
491
 
327
- def _handle_agent_step_event(self, ev: dict[str, Any]) -> None:
492
+ def _handle_agent_step_event(self, ev: dict[str, Any], metadata: dict[str, Any]) -> None:
328
493
  """Handle agent step events."""
329
494
  # Extract tool information
330
495
  (
@@ -334,22 +499,376 @@ class RichStreamRenderer:
334
499
  tool_calls_info,
335
500
  ) = self.stream_processor.parse_tool_calls(ev)
336
501
 
337
- # Track tools and sub-agents
338
- self.stream_processor.track_tools_and_agents(
339
- tool_name, tool_calls_info, is_delegation_tool
340
- )
502
+ payload = metadata.get("metadata") or {}
503
+
504
+ tracked_step: Step | None = None
505
+ try:
506
+ tracked_step = self.steps.apply_event(ev)
507
+ except ValueError:
508
+ logger.debug("Malformed step event skipped", exc_info=True)
509
+ else:
510
+ self._record_step_server_start(tracked_step, payload)
511
+ self._update_thinking_timeline(tracked_step, payload)
512
+ self._maybe_override_root_agent_label(tracked_step, payload)
513
+ self._maybe_attach_root_query(tracked_step)
514
+
515
+ # Track tools and sub-agents for transcript/debug context
516
+ self.stream_processor.track_tools_and_agents(tool_name, tool_calls_info, is_delegation_tool)
341
517
 
342
518
  # Handle tool execution
343
- self._handle_agent_step(ev, tool_name, tool_args, tool_out, tool_calls_info)
519
+ self._handle_agent_step(
520
+ ev,
521
+ tool_name,
522
+ tool_args,
523
+ tool_out,
524
+ tool_calls_info,
525
+ tracked_step=tracked_step,
526
+ )
344
527
 
345
528
  # Update live display
346
529
  self._ensure_live()
347
530
 
531
+ def _maybe_attach_root_query(self, step: Step | None) -> None:
532
+ """Attach the user query to the root agent step for display."""
533
+ if not step or self._root_query_attached or not self._root_query or step.kind != "agent" or step.parent_id:
534
+ return
535
+
536
+ args = dict(getattr(step, "args", {}) or {})
537
+ args.setdefault("query", self._root_query)
538
+ step.args = args
539
+ self._root_query_attached = True
540
+
541
+ def _record_step_server_start(self, step: Step | None, payload: dict[str, Any]) -> None:
542
+ """Store server-provided start times for elapsed calculations."""
543
+ if not step:
544
+ return
545
+ server_time = payload.get("time")
546
+ if not isinstance(server_time, (int, float)):
547
+ return
548
+ self._step_server_start_times.setdefault(step.step_id, float(server_time))
549
+
550
+ def _maybe_override_root_agent_label(self, step: Step | None, payload: dict[str, Any]) -> None:
551
+ """Ensure the root agent row uses the human-friendly name and shows the ID."""
552
+ if not step or step.kind != "agent" or step.parent_id:
553
+ return
554
+ friendly = self._root_agent_friendly or self._humanize_agent_slug((payload or {}).get("agent_name"))
555
+ if not friendly:
556
+ return
557
+ agent_identifier = step.name or step.step_id
558
+ if not agent_identifier:
559
+ return
560
+ step.display_label = normalise_display_label(f"{ICON_AGENT} {friendly} ({agent_identifier})")
561
+ if not self._root_agent_step_id:
562
+ self._root_agent_step_id = step.step_id
563
+
564
+ def _update_thinking_timeline(self, step: Step | None, payload: dict[str, Any]) -> None:
565
+ """Maintain deterministic thinking spans for each agent/delegate scope."""
566
+ if not self.cfg.render_thinking or not step:
567
+ return
568
+
569
+ now_monotonic = monotonic()
570
+ server_time = self._coerce_server_time(payload.get("time"))
571
+ status_hint = (payload.get("status") or "").lower()
572
+
573
+ if self._is_scope_anchor(step):
574
+ self._update_anchor_thinking(
575
+ step=step,
576
+ server_time=server_time,
577
+ status_hint=status_hint,
578
+ now_monotonic=now_monotonic,
579
+ )
580
+ return
581
+
582
+ self._update_child_thinking(
583
+ step=step,
584
+ server_time=server_time,
585
+ status_hint=status_hint,
586
+ now_monotonic=now_monotonic,
587
+ )
588
+
589
+ def _update_anchor_thinking(
590
+ self,
591
+ *,
592
+ step: Step,
593
+ server_time: float | None,
594
+ status_hint: str,
595
+ now_monotonic: float,
596
+ ) -> None:
597
+ """Handle deterministic thinking bookkeeping for agent/delegate anchors."""
598
+ scope = self._get_or_create_scope(step)
599
+ if scope.anchor_started_at is None and server_time is not None:
600
+ scope.anchor_started_at = server_time
601
+
602
+ if not scope.closed and scope.active_thinking_id is None:
603
+ self._start_scope_thinking(
604
+ scope,
605
+ start_server_time=scope.anchor_started_at or server_time,
606
+ start_monotonic=now_monotonic,
607
+ )
608
+
609
+ is_anchor_finished = status_hint in FINISHED_STATUS_HINTS or (not status_hint and is_step_finished(step))
610
+ if is_anchor_finished:
611
+ scope.anchor_finished_at = server_time or scope.anchor_finished_at
612
+ self._finish_scope_thinking(scope, server_time, now_monotonic)
613
+ scope.closed = True
614
+
615
+ parent_anchor_id = self._resolve_anchor_id(step)
616
+ if parent_anchor_id:
617
+ self._cascade_anchor_update(
618
+ parent_anchor_id=parent_anchor_id,
619
+ child_step=step,
620
+ server_time=server_time,
621
+ now_monotonic=now_monotonic,
622
+ is_finished=is_anchor_finished,
623
+ )
624
+
625
+ def _cascade_anchor_update(
626
+ self,
627
+ *,
628
+ parent_anchor_id: str,
629
+ child_step: Step,
630
+ server_time: float | None,
631
+ now_monotonic: float,
632
+ is_finished: bool,
633
+ ) -> None:
634
+ """Propagate anchor state changes to the parent scope."""
635
+ parent_scope = self._thinking_scopes.get(parent_anchor_id)
636
+ if not parent_scope or parent_scope.closed:
637
+ return
638
+ if is_finished:
639
+ self._mark_child_finished(parent_scope, child_step.step_id, server_time, now_monotonic)
640
+ else:
641
+ self._mark_child_running(parent_scope, child_step, server_time, now_monotonic)
642
+
643
+ def _update_child_thinking(
644
+ self,
645
+ *,
646
+ step: Step,
647
+ server_time: float | None,
648
+ status_hint: str,
649
+ now_monotonic: float,
650
+ ) -> None:
651
+ """Update deterministic thinking state for non-anchor steps."""
652
+ anchor_id = self._resolve_anchor_id(step)
653
+ if not anchor_id:
654
+ return
655
+
656
+ scope = self._thinking_scopes.get(anchor_id)
657
+ if not scope or scope.closed or step.kind == "thinking":
658
+ return
659
+
660
+ is_finish_event = status_hint in FINISHED_STATUS_HINTS or (not status_hint and is_step_finished(step))
661
+ if is_finish_event:
662
+ self._mark_child_finished(scope, step.step_id, server_time, now_monotonic)
663
+ else:
664
+ self._mark_child_running(scope, step, server_time, now_monotonic)
665
+
666
+ def _resolve_anchor_id(self, step: Step) -> str | None:
667
+ """Return the nearest agent/delegate ancestor for a step."""
668
+ parent_id = step.parent_id
669
+ while parent_id:
670
+ parent = self.steps.by_id.get(parent_id)
671
+ if not parent:
672
+ return None
673
+ if self._is_scope_anchor(parent):
674
+ return parent.step_id
675
+ parent_id = parent.parent_id
676
+ return None
677
+
678
+ def _get_or_create_scope(self, step: Step) -> ThinkingScopeState:
679
+ """Fetch (or create) thinking state for the given anchor step."""
680
+ scope = self._thinking_scopes.get(step.step_id)
681
+ if scope:
682
+ if scope.task_id is None:
683
+ scope.task_id = step.task_id
684
+ if scope.context_id is None:
685
+ scope.context_id = step.context_id
686
+ return scope
687
+ scope = ThinkingScopeState(
688
+ anchor_id=step.step_id,
689
+ task_id=step.task_id,
690
+ context_id=step.context_id,
691
+ )
692
+ self._thinking_scopes[step.step_id] = scope
693
+ return scope
694
+
695
+ def _is_scope_anchor(self, step: Step) -> bool:
696
+ """Return True when a step should host its own thinking timeline."""
697
+ if step.kind in {"agent", "delegate"}:
698
+ return True
699
+ name = (step.name or "").lower()
700
+ return name.startswith(("delegate_to_", "delegate_", "delegate "))
701
+
702
+ def _start_scope_thinking(
703
+ self,
704
+ scope: ThinkingScopeState,
705
+ *,
706
+ start_server_time: float | None,
707
+ start_monotonic: float,
708
+ ) -> None:
709
+ """Open a deterministic thinking node beneath the scope anchor."""
710
+ if scope.closed or scope.active_thinking_id or not scope.anchor_id:
711
+ return
712
+ step = self.steps.start_or_get(
713
+ task_id=scope.task_id,
714
+ context_id=scope.context_id,
715
+ kind="thinking",
716
+ name=f"agent_thinking_step::{scope.anchor_id}",
717
+ parent_id=scope.anchor_id,
718
+ args={"reason": "deterministic_timeline"},
719
+ )
720
+ step.display_label = "💭 Thinking…"
721
+ step.status_icon = "spinner"
722
+ scope.active_thinking_id = step.step_id
723
+ scope.idle_started_at = start_server_time
724
+ scope.idle_started_monotonic = start_monotonic
725
+
726
+ def _finish_scope_thinking(
727
+ self,
728
+ scope: ThinkingScopeState,
729
+ end_server_time: float | None,
730
+ end_monotonic: float,
731
+ ) -> None:
732
+ """Close the currently running thinking node if one exists."""
733
+ if not scope.active_thinking_id:
734
+ return
735
+ thinking_step = self.steps.by_id.get(scope.active_thinking_id)
736
+ if not thinking_step:
737
+ scope.active_thinking_id = None
738
+ scope.idle_started_at = None
739
+ scope.idle_started_monotonic = None
740
+ return
741
+
742
+ duration = self._calculate_timeline_duration(
743
+ scope.idle_started_at,
744
+ end_server_time,
745
+ scope.idle_started_monotonic,
746
+ end_monotonic,
747
+ )
748
+ thinking_step.display_label = thinking_step.display_label or "💭 Thinking…"
749
+ if duration is not None:
750
+ thinking_step.finish(duration, source="timeline")
751
+ else:
752
+ thinking_step.finish(None, source="timeline")
753
+ thinking_step.status_icon = "success"
754
+ scope.active_thinking_id = None
755
+ scope.idle_started_at = None
756
+ scope.idle_started_monotonic = None
757
+
758
+ def _mark_child_running(
759
+ self,
760
+ scope: ThinkingScopeState,
761
+ step: Step,
762
+ server_time: float | None,
763
+ now_monotonic: float,
764
+ ) -> None:
765
+ """Mark a direct child as running and close any open thinking node."""
766
+ if step.step_id in scope.running_children:
767
+ return
768
+ scope.running_children.add(step.step_id)
769
+ if not scope.active_thinking_id:
770
+ return
771
+
772
+ start_server = self._step_server_start_times.get(step.step_id)
773
+ if start_server is None:
774
+ start_server = server_time
775
+ self._finish_scope_thinking(scope, start_server, now_monotonic)
776
+
777
+ def _mark_child_finished(
778
+ self,
779
+ scope: ThinkingScopeState,
780
+ step_id: str,
781
+ server_time: float | None,
782
+ now_monotonic: float,
783
+ ) -> None:
784
+ """Handle completion for a scope child and resume thinking if idle."""
785
+ if step_id in scope.running_children:
786
+ scope.running_children.discard(step_id)
787
+ if scope.running_children or scope.closed:
788
+ return
789
+ self._start_scope_thinking(
790
+ scope,
791
+ start_server_time=server_time,
792
+ start_monotonic=now_monotonic,
793
+ )
794
+
795
+ def _close_active_thinking_scopes(self, server_time: float | None) -> None:
796
+ """Finish any in-flight thinking nodes during finalization."""
797
+ now = monotonic()
798
+ for scope in self._thinking_scopes.values():
799
+ if not scope.active_thinking_id:
800
+ continue
801
+ self._finish_scope_thinking(scope, server_time, now)
802
+ scope.closed = True
803
+ # Parent scopes resume thinking via _cascade_anchor_update
804
+
805
+ def _apply_root_duration(self, duration_seconds: float | None) -> None:
806
+ """Propagate the final run duration to the root agent step."""
807
+ if duration_seconds is None or not self._root_agent_step_id:
808
+ return
809
+ root_step = self.steps.by_id.get(self._root_agent_step_id)
810
+ if not root_step:
811
+ return
812
+ try:
813
+ duration_ms = max(0, int(round(float(duration_seconds) * 1000)))
814
+ except Exception:
815
+ return
816
+ root_step.duration_ms = duration_ms
817
+ root_step.duration_source = root_step.duration_source or "run"
818
+ root_step.status = "finished"
819
+
820
+ @staticmethod
821
+ def _coerce_server_time(value: Any) -> float | None:
822
+ """Convert a raw SSE time payload into a float if possible."""
823
+ if isinstance(value, (int, float)):
824
+ return float(value)
825
+ try:
826
+ return float(value)
827
+ except (TypeError, ValueError):
828
+ return None
829
+
830
+ @staticmethod
831
+ def _calculate_timeline_duration(
832
+ start_server: float | None,
833
+ end_server: float | None,
834
+ start_monotonic: float | None,
835
+ end_monotonic: float,
836
+ ) -> float | None:
837
+ """Pick the most reliable pair of timestamps to derive duration seconds."""
838
+ if start_server is not None and end_server is not None:
839
+ return max(0.0, float(end_server) - float(start_server))
840
+ if start_monotonic is not None:
841
+ try:
842
+ return max(0.0, float(end_monotonic) - float(start_monotonic))
843
+ except Exception:
844
+ return None
845
+ return None
846
+
847
+ @staticmethod
848
+ def _humanize_agent_slug(value: Any) -> str | None:
849
+ """Convert a slugified agent name into Title Case."""
850
+ if not isinstance(value, str):
851
+ return None
852
+ cleaned = value.replace("_", " ").replace("-", " ").strip()
853
+ if not cleaned:
854
+ return None
855
+ parts = [part for part in cleaned.split() if part]
856
+ return " ".join(part[:1].upper() + part[1:] for part in parts)
857
+
348
858
  def _finish_running_steps(self) -> None:
349
859
  """Mark any running steps as finished to avoid lingering spinners."""
350
860
  for st in self.steps.by_id.values():
351
861
  if not is_step_finished(st):
352
- st.finish(None)
862
+ self._mark_incomplete_step(st)
863
+
864
+ def _mark_incomplete_step(self, step: Step) -> None:
865
+ """Mark a lingering step as incomplete/warning with unknown duration."""
866
+ step.status = "finished"
867
+ step.duration_unknown = True
868
+ if step.duration_ms is None:
869
+ step.duration_ms = 0
870
+ step.duration_source = step.duration_source or "unknown"
871
+ step.status_icon = "warning"
353
872
 
354
873
  def _finish_tool_panels(self) -> None:
355
874
  """Mark unfinished tool panels as finished."""
@@ -376,11 +895,14 @@ class RichStreamRenderer:
376
895
  if not body:
377
896
  return
378
897
 
898
+ if getattr(self, "_transcript_mode_enabled", False):
899
+ return
900
+
379
901
  if self.verbose:
380
902
  final_panel = create_final_panel(
381
903
  body,
382
904
  title=self._final_panel_title(),
383
- theme=self.cfg.theme,
905
+ theme=DEFAULT_RENDERER_THEME,
384
906
  )
385
907
  self.console.print(final_panel)
386
908
  self.state.printed_final_output = True
@@ -389,25 +911,37 @@ class RichStreamRenderer:
389
911
  """Handle completion event."""
390
912
  self.state.finalizing_ui = True
391
913
 
392
- if isinstance(stats, RunStats):
914
+ self._handle_stats_duration(stats)
915
+ self._close_active_thinking_scopes(self.state.final_duration_seconds)
916
+ self._cleanup_ui_elements()
917
+ self._finalize_display()
918
+ self._print_completion_message()
919
+
920
+ def _handle_stats_duration(self, stats: RunStats) -> None:
921
+ """Handle stats processing and duration calculation."""
922
+ if not isinstance(stats, RunStats):
923
+ return
924
+
925
+ duration = None
926
+ try:
927
+ if stats.finished_at is not None and stats.started_at is not None:
928
+ duration = max(0.0, float(stats.finished_at) - float(stats.started_at))
929
+ except Exception:
393
930
  duration = None
394
- try:
395
- if stats.finished_at is not None and stats.started_at is not None:
396
- duration = max(
397
- 0.0, float(stats.finished_at) - float(stats.started_at)
398
- )
399
- except Exception:
400
- duration = None
401
931
 
402
- if duration is not None:
403
- self._update_final_duration(duration, overwrite=True)
932
+ if duration is not None:
933
+ self._update_final_duration(duration, overwrite=True)
404
934
 
935
+ def _cleanup_ui_elements(self) -> None:
936
+ """Clean up running UI elements."""
405
937
  # Mark any running steps as finished to avoid lingering spinners
406
938
  self._finish_running_steps()
407
939
 
408
940
  # Mark unfinished tool panels as finished
409
941
  self._finish_tool_panels()
410
942
 
943
+ def _finalize_display(self) -> None:
944
+ """Finalize live display and render final output."""
411
945
  # Final refresh
412
946
  self._ensure_live()
413
947
 
@@ -417,8 +951,24 @@ class RichStreamRenderer:
417
951
  # Render final output based on configuration
418
952
  self._print_final_panel_if_needed()
419
953
 
954
+ def _print_completion_message(self) -> None:
955
+ """Print completion message based on current mode."""
956
+ if self._transcript_mode_enabled:
957
+ try:
958
+ self.console.print(
959
+ "[dim]Run finished. Press Ctrl+T to return to the summary view or stay here to inspect events. "
960
+ "Use the post-run viewer for export.[/dim]"
961
+ )
962
+ except Exception:
963
+ pass
964
+ else:
965
+ # No transcript toggle in summary mode; nothing to print here.
966
+ return
967
+
420
968
  def _ensure_live(self) -> None:
421
969
  """Ensure live display is updated."""
970
+ if getattr(self, "_transcript_mode_enabled", False):
971
+ return
422
972
  if not self._ensure_live_stack():
423
973
  return
424
974
 
@@ -426,6 +976,12 @@ class RichStreamRenderer:
426
976
 
427
977
  if self.live:
428
978
  self._refresh_live_panels()
979
+ if (
980
+ not self._transcript_mode_enabled
981
+ and not self.state.finalizing_ui
982
+ and not self._summary_hint_printed_once
983
+ ):
984
+ self._print_summary_hint(force=True)
429
985
 
430
986
  def _ensure_live_stack(self) -> bool:
431
987
  """Guarantee the console exposes the internal live stack Rich expects."""
@@ -472,8 +1028,7 @@ class RichStreamRenderer:
472
1028
  title="Steps",
473
1029
  border_style="blue",
474
1030
  )
475
- tool_panels = self._render_tool_panels()
476
- panels = self._build_live_panels(main_panel, steps_panel, tool_panels)
1031
+ panels = self._build_live_panels(main_panel, steps_panel)
477
1032
 
478
1033
  self.live.update(Group(*panels))
479
1034
 
@@ -481,17 +1036,12 @@ class RichStreamRenderer:
481
1036
  self,
482
1037
  main_panel: Any,
483
1038
  steps_panel: Any,
484
- tool_panels: list[Any],
485
1039
  ) -> list[Any]:
486
1040
  """Assemble the panel order for the live display."""
487
1041
  if self.verbose:
488
- return [main_panel, steps_panel, *tool_panels]
1042
+ return [main_panel, steps_panel]
489
1043
 
490
- panels: list[Any] = [steps_panel]
491
- if tool_panels:
492
- panels.extend(tool_panels)
493
- panels.append(main_panel)
494
- return panels
1044
+ return [steps_panel, main_panel]
495
1045
 
496
1046
  def _render_main_panel(self) -> Any:
497
1047
  """Render the main content panel."""
@@ -503,11 +1053,11 @@ class RichStreamRenderer:
503
1053
  return create_final_panel(
504
1054
  final_content,
505
1055
  title=title,
506
- theme=self.cfg.theme,
1056
+ theme=DEFAULT_RENDERER_THEME,
507
1057
  )
508
1058
  # Dynamic title with spinner + elapsed/hints
509
1059
  title = self._format_enhanced_main_title()
510
- return create_main_panel(body, title, self.cfg.theme)
1060
+ return create_main_panel(body, title, DEFAULT_RENDERER_THEME)
511
1061
 
512
1062
  def _final_panel_title(self) -> str:
513
1063
  """Compose title for the final result panel including duration."""
@@ -522,8 +1072,6 @@ class RichStreamRenderer:
522
1072
  return
523
1073
 
524
1074
  self.verbose = verbose
525
- self.cfg.style = "debug" if verbose else "pretty"
526
-
527
1075
  desired_live = not verbose
528
1076
  if desired_live != self.cfg.live:
529
1077
  self.cfg.live = desired_live
@@ -538,9 +1086,123 @@ class RichStreamRenderer:
538
1086
  # ------------------------------------------------------------------
539
1087
  # Transcript helpers
540
1088
  # ------------------------------------------------------------------
541
- def _capture_event(
542
- self, ev: dict[str, Any], received_at: datetime | None = None
543
- ) -> None:
1089
+ @property
1090
+ def transcript_mode_enabled(self) -> bool:
1091
+ """Return True when transcript mode is currently active."""
1092
+ return self._transcript_mode_enabled
1093
+
1094
+ def toggle_transcript_mode(self) -> None:
1095
+ """Flip transcript mode on/off."""
1096
+ self.set_transcript_mode(not self._transcript_mode_enabled)
1097
+
1098
+ def set_transcript_mode(self, enabled: bool) -> None:
1099
+ """Set transcript mode explicitly."""
1100
+ if enabled == self._transcript_mode_enabled:
1101
+ return
1102
+
1103
+ self._transcript_mode_enabled = enabled
1104
+ self.apply_verbosity(enabled)
1105
+
1106
+ if enabled:
1107
+ self._summary_hint_printed_once = False
1108
+ self._transcript_hint_printed_once = False
1109
+ self._transcript_header_printed = False
1110
+ self._transcript_enabled_message_printed = False
1111
+ self._stop_live_display()
1112
+ self._clear_console_safe()
1113
+ self._print_transcript_enabled_message()
1114
+ self._render_transcript_backfill()
1115
+ else:
1116
+ self._transcript_hint_printed_once = False
1117
+ self._transcript_header_printed = False
1118
+ self._transcript_enabled_message_printed = False
1119
+ self._clear_console_safe()
1120
+ self._render_summary_static_sections()
1121
+ summary_notice = (
1122
+ "[dim]Returning to the summary view. Streaming will continue here.[/dim]"
1123
+ if not self.state.finalizing_ui
1124
+ else "[dim]Returning to the summary view.[/dim]"
1125
+ )
1126
+ self.console.print(summary_notice)
1127
+ self._render_summary_after_transcript_toggle()
1128
+ if not self.state.finalizing_ui:
1129
+ self._print_summary_hint(force=True)
1130
+
1131
+ def _clear_console_safe(self) -> None:
1132
+ """Best-effort console clear that ignores platform quirks."""
1133
+ try:
1134
+ self.console.clear()
1135
+ except Exception:
1136
+ pass
1137
+
1138
+ def _print_transcript_hint(self) -> None:
1139
+ """Render the transcript toggle hint, keeping it near the bottom."""
1140
+ if not self._transcript_mode_enabled:
1141
+ return
1142
+ try:
1143
+ self.console.print(self._transcript_hint_message)
1144
+ except Exception:
1145
+ pass
1146
+ else:
1147
+ self._transcript_hint_printed_once = True
1148
+
1149
+ def _print_transcript_enabled_message(self) -> None:
1150
+ if self._transcript_enabled_message_printed:
1151
+ return
1152
+ try:
1153
+ self.console.print("[dim]Transcript mode enabled — streaming raw transcript events.[/dim]")
1154
+ except Exception:
1155
+ pass
1156
+ else:
1157
+ self._transcript_enabled_message_printed = True
1158
+
1159
+ def _ensure_transcript_header(self) -> None:
1160
+ if self._transcript_header_printed:
1161
+ return
1162
+ try:
1163
+ self.console.rule("Transcript Events")
1164
+ except Exception:
1165
+ self._transcript_header_printed = True
1166
+ return
1167
+ self._transcript_header_printed = True
1168
+
1169
+ def _print_summary_hint(self, force: bool = False) -> None:
1170
+ """Show the summary-mode toggle hint."""
1171
+ controller = getattr(self, "transcript_controller", None)
1172
+ if controller and not getattr(controller, "enabled", False):
1173
+ if not force:
1174
+ self._summary_hint_printed_once = True
1175
+ return
1176
+ if not force and self._summary_hint_printed_once:
1177
+ return
1178
+ try:
1179
+ self.console.print(self._summary_hint_message)
1180
+ except Exception:
1181
+ return
1182
+ self._summary_hint_printed_once = True
1183
+
1184
+ def _render_transcript_backfill(self) -> None:
1185
+ """Render any captured events that haven't been shown in transcript mode."""
1186
+ pending = self.state.events[self._transcript_render_cursor :]
1187
+ self._ensure_transcript_header()
1188
+ if not pending:
1189
+ self._print_transcript_hint()
1190
+ return
1191
+
1192
+ baseline = self.state.streaming_started_event_ts
1193
+ for ev in pending:
1194
+ received_ts = _coerce_received_at(ev.get("received_at"))
1195
+ render_debug_event(
1196
+ ev,
1197
+ self.console,
1198
+ received_ts=received_ts,
1199
+ baseline_ts=baseline,
1200
+ )
1201
+
1202
+ self._transcript_render_cursor = len(self.state.events)
1203
+ self._print_transcript_hint()
1204
+
1205
+ def _capture_event(self, ev: dict[str, Any], received_at: datetime | None = None) -> None:
544
1206
  """Capture a deep copy of SSE events for transcript replay."""
545
1207
  try:
546
1208
  captured = json.loads(json.dumps(ev))
@@ -557,6 +1219,8 @@ class RichStreamRenderer:
557
1219
  captured["received_at"] = repr(received_at)
558
1220
 
559
1221
  self.state.events.append(captured)
1222
+ if self._transcript_mode_enabled:
1223
+ self._transcript_render_cursor = len(self.state.events)
560
1224
 
561
1225
  def get_aggregated_output(self) -> str:
562
1226
  """Return the concatenated assistant output collected so far."""
@@ -566,16 +1230,7 @@ class RichStreamRenderer:
566
1230
  """Return captured SSE events."""
567
1231
  return list(self.state.events)
568
1232
 
569
- def _maybe_insert_thinking_gap(
570
- self, task_id: str | None, context_id: str | None
571
- ) -> None:
572
- """Insert thinking gap if needed."""
573
- # Implementation would track thinking states
574
- pass
575
-
576
- def _ensure_tool_panel(
577
- self, name: str, args: Any, task_id: str, context_id: str
578
- ) -> str:
1233
+ def _ensure_tool_panel(self, name: str, args: Any, task_id: str, context_id: str) -> str:
579
1234
  """Ensure a tool panel exists and return its ID."""
580
1235
  formatted_title = format_tool_title(name)
581
1236
  is_delegation = is_delegation_tool(name)
@@ -595,15 +1250,10 @@ class RichStreamRenderer:
595
1250
  # Add Args section once
596
1251
  if args:
597
1252
  try:
598
- args_content = (
599
- "**Args:**\n```json\n"
600
- + json.dumps(args, indent=2)
601
- + "\n```\n\n"
602
- )
1253
+ args_content = "**Args:**\n```json\n" + json.dumps(args, indent=2) + "\n```\n\n"
603
1254
  except Exception:
604
1255
  args_content = f"**Args:**\n{args}\n\n"
605
1256
  self.tool_panels[tool_sid]["chunks"].append(args_content)
606
- self.tool_order.append(tool_sid)
607
1257
 
608
1258
  return tool_sid
609
1259
 
@@ -614,8 +1264,13 @@ class RichStreamRenderer:
614
1264
  tool_name: str,
615
1265
  tool_args: Any,
616
1266
  _tool_sid: str,
1267
+ *,
1268
+ tracked_step: Step | None = None,
617
1269
  ) -> Step | None:
618
1270
  """Start or get a step for a tool."""
1271
+ if tracked_step is not None:
1272
+ return tracked_step
1273
+
619
1274
  if is_delegation_tool(tool_name):
620
1275
  st = self.steps.start_or_get(
621
1276
  task_id=task_id,
@@ -635,9 +1290,7 @@ class RichStreamRenderer:
635
1290
 
636
1291
  # Record server start time for this step if available
637
1292
  if st and self.stream_processor.server_elapsed_time is not None:
638
- self._step_server_start_times[st.step_id] = (
639
- self.stream_processor.server_elapsed_time
640
- )
1293
+ self._step_server_start_times[st.step_id] = self.stream_processor.server_elapsed_time
641
1294
 
642
1295
  return st
643
1296
 
@@ -651,26 +1304,18 @@ class RichStreamRenderer:
651
1304
  """Process additional tool calls to avoid duplicates."""
652
1305
  for call_name, call_args, _ in tool_calls_info or []:
653
1306
  if call_name and call_name != tool_name:
654
- self._process_single_tool_call(
655
- call_name, call_args, task_id, context_id
656
- )
1307
+ self._process_single_tool_call(call_name, call_args, task_id, context_id)
657
1308
 
658
- def _process_single_tool_call(
659
- self, call_name: str, call_args: Any, task_id: str, context_id: str
660
- ) -> None:
1309
+ def _process_single_tool_call(self, call_name: str, call_args: Any, task_id: str, context_id: str) -> None:
661
1310
  """Process a single additional tool call."""
662
1311
  self._ensure_tool_panel(call_name, call_args, task_id, context_id)
663
1312
 
664
1313
  st2 = self._create_step_for_tool_call(call_name, call_args, task_id, context_id)
665
1314
 
666
1315
  if self.stream_processor.server_elapsed_time is not None and st2:
667
- self._step_server_start_times[st2.step_id] = (
668
- self.stream_processor.server_elapsed_time
669
- )
1316
+ self._step_server_start_times[st2.step_id] = self.stream_processor.server_elapsed_time
670
1317
 
671
- def _create_step_for_tool_call(
672
- self, call_name: str, call_args: Any, task_id: str, context_id: str
673
- ) -> Any:
1318
+ def _create_step_for_tool_call(self, call_name: str, call_args: Any, task_id: str, context_id: str) -> Any:
674
1319
  """Create appropriate step for tool call."""
675
1320
  if is_delegation_tool(call_name):
676
1321
  return self.steps.start_or_get(
@@ -689,9 +1334,7 @@ class RichStreamRenderer:
689
1334
  args=call_args,
690
1335
  )
691
1336
 
692
- def _detect_tool_completion(
693
- self, metadata: dict, content: str
694
- ) -> tuple[bool, str | None, Any]:
1337
+ def _detect_tool_completion(self, metadata: dict, content: str) -> tuple[bool, str | None, Any]:
695
1338
  """Detect if a tool has completed and return completion info."""
696
1339
  tool_info = metadata.get("tool_info", {}) if isinstance(metadata, dict) else {}
697
1340
 
@@ -701,18 +1344,14 @@ class RichStreamRenderer:
701
1344
  # content like "Completed google_serper"
702
1345
  tname = content.replace("Completed ", "").strip()
703
1346
  if tname:
704
- output = (
705
- tool_info.get("output") if tool_info.get("name") == tname else None
706
- )
1347
+ output = tool_info.get("output") if tool_info.get("name") == tname else None
707
1348
  return True, tname, output
708
1349
  elif metadata.get("status") == "finished" and tool_info.get("name"):
709
1350
  return True, tool_info.get("name"), tool_info.get("output")
710
1351
 
711
1352
  return False, None, None
712
1353
 
713
- def _get_tool_session_id(
714
- self, finished_tool_name: str, task_id: str, context_id: str
715
- ) -> str:
1354
+ def _get_tool_session_id(self, finished_tool_name: str, task_id: str, context_id: str) -> str:
716
1355
  """Generate tool session ID."""
717
1356
  return f"tool_{finished_tool_name}_{task_id}_{context_id}"
718
1357
 
@@ -742,7 +1381,7 @@ class RichStreamRenderer:
742
1381
  meta["duration_seconds"] = dur
743
1382
  meta["server_finished_at"] = (
744
1383
  self.stream_processor.server_elapsed_time
745
- if isinstance(self.stream_processor.server_elapsed_time, int | float)
1384
+ if isinstance(self.stream_processor.server_elapsed_time, (int, float))
746
1385
  else None
747
1386
  )
748
1387
  meta["finished_at"] = monotonic()
@@ -752,9 +1391,7 @@ class RichStreamRenderer:
752
1391
  ) -> None:
753
1392
  """Add tool output to panel metadata."""
754
1393
  if finished_tool_output is not None:
755
- meta["chunks"].append(
756
- self._format_output_block(finished_tool_output, finished_tool_name)
757
- )
1394
+ meta["chunks"].append(self._format_output_block(finished_tool_output, finished_tool_name))
758
1395
  meta["output"] = finished_tool_output
759
1396
 
760
1397
  def _mark_panel_as_finished(self, meta: dict[str, Any], tool_sid: str) -> None:
@@ -784,9 +1421,7 @@ class RichStreamRenderer:
784
1421
  self._mark_panel_as_finished(meta, tool_sid)
785
1422
  self._add_tool_output_to_panel(meta, finished_tool_output, finished_tool_name)
786
1423
 
787
- def _get_step_duration(
788
- self, finished_tool_name: str, task_id: str, context_id: str
789
- ) -> float | None:
1424
+ def _get_step_duration(self, finished_tool_name: str, task_id: str, context_id: str) -> float | None:
790
1425
  """Get step duration from tool panels."""
791
1426
  tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
792
1427
  return self.tool_panels.get(tool_sid, {}).get("duration_seconds")
@@ -833,8 +1468,13 @@ class RichStreamRenderer:
833
1468
  finished_tool_output: Any,
834
1469
  task_id: str,
835
1470
  context_id: str,
1471
+ *,
1472
+ tracked_step: Step | None = None,
836
1473
  ) -> None:
837
1474
  """Finish the corresponding step for a completed tool."""
1475
+ if tracked_step is not None:
1476
+ return
1477
+
838
1478
  step_duration = self._get_step_duration(finished_tool_name, task_id, context_id)
839
1479
 
840
1480
  if is_delegation_tool(finished_tool_name):
@@ -856,9 +1496,7 @@ class RichStreamRenderer:
856
1496
 
857
1497
  def _should_create_snapshot(self, tool_sid: str) -> bool:
858
1498
  """Check if a snapshot should be created."""
859
- return self.cfg.append_finished_snapshots and not self.tool_panels.get(
860
- tool_sid, {}
861
- ).get("snapshot_printed")
1499
+ return self.cfg.append_finished_snapshots and not self.tool_panels.get(tool_sid, {}).get("snapshot_printed")
862
1500
 
863
1501
  def _get_snapshot_title(self, meta: dict[str, Any], finished_tool_name: str) -> str:
864
1502
  """Get the title for the snapshot."""
@@ -866,7 +1504,7 @@ class RichStreamRenderer:
866
1504
 
867
1505
  # Add elapsed time to title
868
1506
  dur = meta.get("duration_seconds")
869
- if isinstance(dur, int | float):
1507
+ if isinstance(dur, (int, float)):
870
1508
  elapsed_str = self._format_snapshot_duration(dur)
871
1509
  adjusted_title = f"{adjusted_title} · {elapsed_str}"
872
1510
 
@@ -903,15 +1541,13 @@ class RichStreamRenderer:
903
1541
 
904
1542
  return body_text
905
1543
 
906
- def _create_snapshot_panel(
907
- self, adjusted_title: str, body_text: str, finished_tool_name: str
908
- ) -> Any:
1544
+ def _create_snapshot_panel(self, adjusted_title: str, body_text: str, finished_tool_name: str) -> Any:
909
1545
  """Create the snapshot panel."""
910
1546
  return create_tool_panel(
911
1547
  title=adjusted_title,
912
1548
  content=body_text or "(no output)",
913
1549
  status="finished",
914
- theme=self.cfg.theme,
1550
+ theme=DEFAULT_RENDERER_THEME,
915
1551
  is_delegation=is_delegation_tool(finished_tool_name),
916
1552
  )
917
1553
 
@@ -920,9 +1556,7 @@ class RichStreamRenderer:
920
1556
  self.console.print(snapshot_panel)
921
1557
  self.tool_panels[tool_sid]["snapshot_printed"] = True
922
1558
 
923
- def _create_tool_snapshot(
924
- self, finished_tool_name: str, task_id: str, context_id: str
925
- ) -> None:
1559
+ def _create_tool_snapshot(self, finished_tool_name: str, task_id: str, context_id: str) -> None:
926
1560
  """Create and print a snapshot for a finished tool."""
927
1561
  tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
928
1562
 
@@ -936,9 +1570,7 @@ class RichStreamRenderer:
936
1570
  body_text = "".join(meta.get("chunks") or [])
937
1571
  body_text = self._clamp_snapshot_body(body_text)
938
1572
 
939
- snapshot_panel = self._create_snapshot_panel(
940
- adjusted_title, body_text, finished_tool_name
941
- )
1573
+ snapshot_panel = self._create_snapshot_panel(adjusted_title, body_text, finished_tool_name)
942
1574
 
943
1575
  self._print_and_mark_snapshot(tool_sid, snapshot_panel)
944
1576
 
@@ -949,24 +1581,29 @@ class RichStreamRenderer:
949
1581
  tool_args: Any,
950
1582
  _tool_out: Any,
951
1583
  tool_calls_info: list[tuple[str, Any, Any]],
1584
+ *,
1585
+ tracked_step: Step | None = None,
952
1586
  ) -> None:
953
1587
  """Handle agent step event."""
954
1588
  metadata = event.get("metadata", {})
955
- task_id = event.get("task_id")
956
- context_id = event.get("context_id")
1589
+ task_id = event.get("task_id") or metadata.get("task_id")
1590
+ context_id = event.get("context_id") or metadata.get("context_id")
957
1591
  content = event.get("content", "")
958
1592
 
959
1593
  # Create steps and panels for the primary tool
960
1594
  if tool_name:
961
- tool_sid = self._ensure_tool_panel(
962
- tool_name, tool_args, task_id, context_id
1595
+ tool_sid = self._ensure_tool_panel(tool_name, tool_args, task_id, context_id)
1596
+ self._start_tool_step(
1597
+ task_id,
1598
+ context_id,
1599
+ tool_name,
1600
+ tool_args,
1601
+ tool_sid,
1602
+ tracked_step=tracked_step,
963
1603
  )
964
- self._start_tool_step(task_id, context_id, tool_name, tool_args, tool_sid)
965
1604
 
966
1605
  # Handle additional tool calls
967
- self._process_additional_tool_calls(
968
- tool_calls_info, tool_name, task_id, context_id
969
- )
1606
+ self._process_additional_tool_calls(tool_calls_info, tool_name, task_id, context_id)
970
1607
 
971
1608
  # Check for tool completion
972
1609
  (
@@ -976,11 +1613,13 @@ class RichStreamRenderer:
976
1613
  ) = self._detect_tool_completion(metadata, content)
977
1614
 
978
1615
  if is_tool_finished and finished_tool_name:
979
- self._finish_tool_panel(
980
- finished_tool_name, finished_tool_output, task_id, context_id
981
- )
1616
+ self._finish_tool_panel(finished_tool_name, finished_tool_output, task_id, context_id)
982
1617
  self._finish_tool_step(
983
- finished_tool_name, finished_tool_output, task_id, context_id
1618
+ finished_tool_name,
1619
+ finished_tool_output,
1620
+ task_id,
1621
+ context_id,
1622
+ tracked_step=tracked_step,
984
1623
  )
985
1624
  self._create_tool_snapshot(finished_tool_name, task_id, context_id)
986
1625
 
@@ -1030,9 +1669,7 @@ class RichStreamRenderer:
1030
1669
 
1031
1670
  def _get_analysis_progress_info(self) -> dict[str, Any]:
1032
1671
  total_steps = len(self.steps.order)
1033
- completed_steps = sum(
1034
- 1 for sid in self.steps.order if is_step_finished(self.steps.by_id[sid])
1035
- )
1672
+ completed_steps = sum(1 for sid in self.steps.order if is_step_finished(self.steps.by_id[sid]))
1036
1673
  current_step = None
1037
1674
  for sid in self.steps.order:
1038
1675
  if not is_step_finished(self.steps.by_id[sid]):
@@ -1040,13 +1677,11 @@ class RichStreamRenderer:
1040
1677
  break
1041
1678
  # Prefer server elapsed time when available
1042
1679
  elapsed = 0.0
1043
- if isinstance(self.stream_processor.server_elapsed_time, int | float):
1680
+ if isinstance(self.stream_processor.server_elapsed_time, (int, float)):
1044
1681
  elapsed = float(self.stream_processor.server_elapsed_time)
1045
1682
  elif self._started_at is not None:
1046
1683
  elapsed = monotonic() - self._started_at
1047
- progress_percent = (
1048
- int((completed_steps / total_steps) * 100) if total_steps else 0
1049
- )
1684
+ progress_percent = int((completed_steps / total_steps) * 100) if total_steps else 0
1050
1685
  return {
1051
1686
  "total_steps": total_steps,
1052
1687
  "completed_steps": completed_steps,
@@ -1100,29 +1735,42 @@ class RichStreamRenderer:
1100
1735
  def _format_step_status(self, step: Step) -> str:
1101
1736
  """Format step status with elapsed time or duration."""
1102
1737
  if is_step_finished(step):
1103
- if step.duration_ms is None:
1104
- return LESS_THAN_1MS
1105
- elif step.duration_ms >= 1000:
1106
- return f"[{step.duration_ms / 1000:.2f}s]"
1107
- elif step.duration_ms > 0:
1108
- return f"[{step.duration_ms}ms]"
1109
- return LESS_THAN_1MS
1738
+ return self._format_finished_badge(step)
1110
1739
  else:
1111
1740
  # Calculate elapsed time for running steps
1112
1741
  elapsed = self._calculate_step_elapsed_time(step)
1113
- if elapsed >= 1:
1742
+ if elapsed >= 0.1:
1114
1743
  return f"[{elapsed:.2f}s]"
1115
- ms = int(elapsed * 1000)
1116
- return f"[{ms}ms]" if ms > 0 else LESS_THAN_1MS
1744
+ ms = int(round(elapsed * 1000))
1745
+ if ms <= 0:
1746
+ return ""
1747
+ return f"[{ms}ms]"
1748
+
1749
+ def _format_finished_badge(self, step: Step) -> str:
1750
+ """Compose duration badge for finished steps including source tagging."""
1751
+ if getattr(step, "duration_unknown", False) is True:
1752
+ payload = "??s"
1753
+ else:
1754
+ duration_ms = step.duration_ms
1755
+ if duration_ms is None:
1756
+ payload = "<1ms"
1757
+ elif duration_ms < 0:
1758
+ payload = "<1ms"
1759
+ elif duration_ms >= 100:
1760
+ payload = f"{duration_ms / 1000:.2f}s"
1761
+ elif duration_ms > 0:
1762
+ payload = f"{duration_ms}ms"
1763
+ else:
1764
+ payload = "<1ms"
1765
+
1766
+ return f"[{payload}]"
1117
1767
 
1118
1768
  def _calculate_step_elapsed_time(self, step: Step) -> float:
1119
1769
  """Calculate elapsed time for a running step."""
1120
1770
  server_elapsed = self.stream_processor.server_elapsed_time
1121
1771
  server_start = self._step_server_start_times.get(step.step_id)
1122
1772
 
1123
- if isinstance(server_elapsed, int | float) and isinstance(
1124
- server_start, int | float
1125
- ):
1773
+ if isinstance(server_elapsed, (int, float)) and isinstance(server_start, (int, float)):
1126
1774
  return max(0.0, float(server_elapsed) - float(server_start))
1127
1775
 
1128
1776
  try:
@@ -1136,6 +1784,21 @@ class RichStreamRenderer:
1136
1784
  return step.name
1137
1785
  return "thinking..." if step.kind == "agent" else f"{step.kind} step"
1138
1786
 
1787
+ def _resolve_step_label(self, step: Step) -> str:
1788
+ """Return the display label for a step with sensible fallbacks."""
1789
+ raw_label = getattr(step, "display_label", None)
1790
+ label = raw_label.strip() if isinstance(raw_label, str) else ""
1791
+ if label:
1792
+ return normalise_display_label(label)
1793
+
1794
+ if not (step.name or "").strip():
1795
+ return UNKNOWN_STEP_DETAIL
1796
+
1797
+ icon = self._get_step_icon(step.kind)
1798
+ base_name = self._get_step_display_name(step)
1799
+ fallback = " ".join(part for part in (icon, base_name) if part).strip()
1800
+ return normalise_display_label(fallback)
1801
+
1139
1802
  def _check_parallel_tools(self) -> dict[tuple[str | None, str | None], list]:
1140
1803
  """Check for parallel running tools."""
1141
1804
  running_by_ctx: dict[tuple[str | None, str | None], list] = {}
@@ -1158,69 +1821,379 @@ class RichStreamRenderer:
1158
1821
  def _compose_step_renderable(
1159
1822
  self,
1160
1823
  step: Step,
1161
- running_by_ctx: dict[tuple[str | None, str | None], list],
1824
+ branch_state: tuple[bool, ...],
1162
1825
  ) -> Any:
1163
- """Compose a single renderable for the steps panel."""
1164
- finished = is_step_finished(step)
1165
- status_br = self._format_step_status(step)
1166
- display_name = self._get_step_display_name(step)
1167
-
1168
- if (
1169
- not finished
1170
- and step.kind == "tool"
1171
- and self._is_parallel_tool(step, running_by_ctx)
1172
- ):
1173
- status_br = status_br.replace("]", " 🔄]")
1826
+ """Compose a single renderable for the hierarchical steps panel."""
1827
+ prefix = build_connector_prefix(branch_state)
1828
+ text_line = self._build_step_text_line(step, prefix)
1829
+ renderables = self._wrap_step_text(step, text_line)
1174
1830
 
1175
- icon = self._get_step_icon(step.kind)
1176
- text_line = Text(style="dim")
1177
- text_line.append(icon)
1178
- text_line.append(" ")
1179
- text_line.append(display_name)
1180
- if status_br:
1831
+ args_renderable = self._build_args_renderable(step, prefix)
1832
+ if args_renderable is not None:
1833
+ renderables.append(args_renderable)
1834
+
1835
+ return self._collapse_renderables(renderables)
1836
+
1837
+ def _build_step_text_line(
1838
+ self,
1839
+ step: Step,
1840
+ prefix: str,
1841
+ ) -> Text:
1842
+ """Create the textual portion of a step renderable."""
1843
+ text_line = Text()
1844
+ text_line.append(prefix, style="dim")
1845
+ text_line.append(self._resolve_step_label(step))
1846
+
1847
+ status_badge = self._format_step_status(step)
1848
+ self._append_status_badge(text_line, step, status_badge)
1849
+ self._append_state_glyph(text_line, step)
1850
+ return text_line
1851
+
1852
+ def _append_status_badge(self, text_line: Text, step: Step, status_badge: str) -> None:
1853
+ """Append the formatted status badge when available."""
1854
+ glyph_key = getattr(step, "status_icon", None)
1855
+ glyph = glyph_for_status(glyph_key)
1856
+
1857
+ if status_badge:
1858
+ text_line.append(" ")
1859
+ text_line.append(status_badge, style="cyan")
1860
+
1861
+ if glyph:
1181
1862
  text_line.append(" ")
1182
- text_line.append(status_br)
1183
- if finished:
1184
- text_line.append(" ✓")
1863
+ style = self._status_icon_style(glyph_key)
1864
+ if style:
1865
+ text_line.append(glyph, style=style)
1866
+ else:
1867
+ text_line.append(glyph)
1868
+
1869
+ def _append_state_glyph(self, text_line: Text, step: Step) -> None:
1870
+ """Append glyph/failure markers in a single place."""
1871
+ failure_reason = (step.failure_reason or "").strip()
1872
+ if failure_reason:
1873
+ text_line.append(f" {failure_reason}")
1874
+
1875
+ @staticmethod
1876
+ def _status_icon_style(icon_key: str | None) -> str | None:
1877
+ """Return style for a given status icon."""
1878
+ if not icon_key:
1879
+ return None
1880
+ return STATUS_ICON_STYLES.get(icon_key)
1881
+
1882
+ def _wrap_step_text(self, step: Step, text_line: Text) -> list[Any]:
1883
+ """Return the base text, optionally decorated with a trailing spinner."""
1884
+ if getattr(step, "status", None) == "running":
1885
+ spinner = self._step_spinners.get(step.step_id)
1886
+ if spinner is None:
1887
+ spinner = Spinner("dots", style="dim")
1888
+ self._step_spinners[step.step_id] = spinner
1889
+ return [TrailingSpinnerLine(text_line, spinner)]
1890
+
1891
+ self._step_spinners.pop(step.step_id, None)
1892
+ return [text_line]
1893
+
1894
+ def _collapse_renderables(self, renderables: list[Any]) -> Any:
1895
+ """Collapse a list of renderables into a single object."""
1896
+ if not renderables:
1897
+ return None
1185
1898
 
1186
- if finished:
1187
- return text_line
1899
+ if len(renderables) == 1:
1900
+ return renderables[0]
1188
1901
 
1189
- spinner = Spinner("dots", text=text_line, style="dim")
1190
- return Align.left(spinner)
1902
+ return Group(*renderables)
1903
+
1904
+ def _build_args_renderable(self, step: Step, prefix: str) -> Text | Group | None:
1905
+ """Build a dimmed argument line for tool or agent steps."""
1906
+ if step.kind not in {"tool", "delegate", "agent"}:
1907
+ return None
1908
+ if step.kind == "agent" and step.parent_id:
1909
+ return None
1910
+ formatted_args = self._format_step_args(step)
1911
+ if not formatted_args:
1912
+ return None
1913
+ if isinstance(formatted_args, list):
1914
+ return self._build_arg_list(prefix, formatted_args)
1915
+
1916
+ args_text = Text()
1917
+ args_text.append(prefix, style="dim")
1918
+ args_text.append(" " * 5)
1919
+ args_text.append(formatted_args, style="dim")
1920
+ return args_text
1921
+
1922
+ def _build_arg_list(self, prefix: str, formatted_args: list[str | tuple[int, str]]) -> Group | None:
1923
+ """Render multi-line argument entries preserving indentation."""
1924
+ arg_lines: list[Text] = []
1925
+ for indent_level, text_value in self._iter_arg_entries(formatted_args):
1926
+ arg_text = Text()
1927
+ arg_text.append(prefix, style="dim")
1928
+ arg_text.append(" " * 5)
1929
+ arg_text.append(" " * (indent_level * 2))
1930
+ arg_text.append(text_value, style="dim")
1931
+ arg_lines.append(arg_text)
1932
+ if not arg_lines:
1933
+ return None
1934
+ return Group(*arg_lines)
1935
+
1936
+ @staticmethod
1937
+ def _iter_arg_entries(
1938
+ formatted_args: list[str | tuple[int, str]],
1939
+ ) -> Iterable[tuple[int, str]]:
1940
+ """Yield normalized indentation/value pairs for argument entries."""
1941
+ for value in formatted_args:
1942
+ if isinstance(value, tuple) and len(value) == 2:
1943
+ indent_level, text_value = value
1944
+ yield indent_level, str(text_value)
1945
+ else:
1946
+ yield 0, str(value)
1947
+
1948
+ def _format_step_args(self, step: Step) -> str | list[str] | list[tuple[int, str]] | None:
1949
+ """Return a printable representation of tool arguments."""
1950
+ args = getattr(step, "args", None)
1951
+ if args is None:
1952
+ return None
1953
+
1954
+ if isinstance(args, dict):
1955
+ return self._format_dict_args(args, step=step)
1956
+
1957
+ if isinstance(args, (list, tuple)):
1958
+ return self._safe_pretty_args(list(args))
1959
+
1960
+ if isinstance(args, (str, int, float)):
1961
+ return self._stringify_args(args)
1962
+
1963
+ return None
1964
+
1965
+ def _format_dict_args(self, args: dict[str, Any], *, step: Step) -> str | list[str] | list[tuple[int, str]] | None:
1966
+ """Format dictionary arguments with guardrails."""
1967
+ if not args:
1968
+ return None
1969
+
1970
+ masked_args = self._redact_arg_payload(args)
1971
+
1972
+ if self._should_collapse_single_query(step):
1973
+ single_query = self._extract_single_query_arg(masked_args)
1974
+ if single_query:
1975
+ return single_query
1976
+
1977
+ return self._format_dict_arg_lines(masked_args)
1978
+
1979
+ @staticmethod
1980
+ def _extract_single_query_arg(args: dict[str, Any]) -> str | None:
1981
+ """Return a trimmed query argument when it is the only entry."""
1982
+ if len(args) != 1:
1983
+ return None
1984
+ key, value = next(iter(args.items()))
1985
+ if key != "query" or not isinstance(value, str):
1986
+ return None
1987
+ stripped = value.strip()
1988
+ return stripped or None
1989
+
1990
+ @staticmethod
1991
+ def _redact_arg_payload(args: dict[str, Any]) -> dict[str, Any]:
1992
+ """Apply best-effort masking before rendering arguments."""
1993
+ try:
1994
+ cleaned = redact_sensitive(args)
1995
+ return cleaned if isinstance(cleaned, dict) else args
1996
+ except Exception:
1997
+ return args
1998
+
1999
+ @staticmethod
2000
+ def _should_collapse_single_query(step: Step) -> bool:
2001
+ """Return True when we should display raw query text."""
2002
+ if step.kind == "agent":
2003
+ return True
2004
+ if step.kind == "delegate":
2005
+ return True
2006
+ return False
2007
+
2008
+ def _format_dict_arg_lines(self, args: dict[str, Any]) -> list[tuple[int, str]] | None:
2009
+ """Render dictionary arguments as nested YAML-style lines."""
2010
+ lines: list[tuple[int, str]] = []
2011
+ for raw_key, value in args.items():
2012
+ key = str(raw_key)
2013
+ lines.extend(self._format_nested_entry(key, value, indent=0))
2014
+ return lines or None
2015
+
2016
+ def _format_nested_entry(self, key: str, value: Any, indent: int) -> list[tuple[int, str]]:
2017
+ """Format a mapping entry recursively."""
2018
+ lines: list[tuple[int, str]] = []
2019
+
2020
+ if isinstance(value, dict):
2021
+ if value:
2022
+ lines.append((indent, f"{key}:"))
2023
+ lines.extend(self._format_nested_mapping(value, indent + 1))
2024
+ else:
2025
+ lines.append((indent, f"{key}: {{}}"))
2026
+ return lines
2027
+
2028
+ if isinstance(value, (list, tuple, set)):
2029
+ seq_lines = self._format_sequence_entries(list(value), indent + 1)
2030
+ if seq_lines:
2031
+ lines.append((indent, f"{key}:"))
2032
+ lines.extend(seq_lines)
2033
+ else:
2034
+ lines.append((indent, f"{key}: []"))
2035
+ return lines
2036
+
2037
+ formatted_value = self._format_arg_value(value)
2038
+ if formatted_value is not None:
2039
+ lines.append((indent, f"{key}: {formatted_value}"))
2040
+ return lines
2041
+
2042
+ def _format_nested_mapping(self, mapping: dict[str, Any], indent: int) -> list[tuple[int, str]]:
2043
+ """Format nested dictionary values."""
2044
+ nested_lines: list[tuple[int, str]] = []
2045
+ for raw_key, value in mapping.items():
2046
+ key = str(raw_key)
2047
+ nested_lines.extend(self._format_nested_entry(key, value, indent))
2048
+ return nested_lines
2049
+
2050
+ def _format_sequence_entries(self, sequence: list[Any], indent: int) -> list[tuple[int, str]]:
2051
+ """Format list/tuple/set values with YAML-style bullets."""
2052
+ if not sequence:
2053
+ return []
2054
+
2055
+ lines: list[tuple[int, str]] = []
2056
+ for item in sequence:
2057
+ lines.extend(self._format_sequence_item(item, indent))
2058
+ return lines
2059
+
2060
+ def _format_sequence_item(self, item: Any, indent: int) -> list[tuple[int, str]]:
2061
+ """Format a single list entry."""
2062
+ if isinstance(item, dict):
2063
+ return self._format_dict_sequence_item(item, indent)
2064
+
2065
+ if isinstance(item, (list, tuple, set)):
2066
+ return self._format_nested_sequence_item(list(item), indent)
2067
+
2068
+ formatted = self._format_arg_value(item)
2069
+ if formatted is not None:
2070
+ return [(indent, f"- {formatted}")]
2071
+ return []
2072
+
2073
+ def _format_dict_sequence_item(self, mapping: dict[str, Any], indent: int) -> list[tuple[int, str]]:
2074
+ """Format a dictionary entry within a list."""
2075
+ child_lines = self._format_nested_mapping(mapping, indent + 1)
2076
+ if child_lines:
2077
+ return self._prepend_sequence_prefix(child_lines, indent)
2078
+ return [(indent, "- {}")]
2079
+
2080
+ def _format_nested_sequence_item(self, sequence: list[Any], indent: int) -> list[tuple[int, str]]:
2081
+ """Format a nested sequence entry within a list."""
2082
+ child_lines = self._format_sequence_entries(sequence, indent + 1)
2083
+ if child_lines:
2084
+ return self._prepend_sequence_prefix(child_lines, indent)
2085
+ return [(indent, "- []")]
2086
+
2087
+ @staticmethod
2088
+ def _prepend_sequence_prefix(child_lines: list[tuple[int, str]], indent: int) -> list[tuple[int, str]]:
2089
+ """Attach a sequence bullet to the first child line."""
2090
+ _, first_text = child_lines[0]
2091
+ prefixed: list[tuple[int, str]] = [(indent, f"- {first_text}")]
2092
+ prefixed.extend(child_lines[1:])
2093
+ return prefixed
2094
+
2095
+ def _format_arg_value(self, value: Any) -> str | None:
2096
+ """Format a single argument value with per-value truncation."""
2097
+ if value is None:
2098
+ return "null"
2099
+ if isinstance(value, (bool, int, float)):
2100
+ return json.dumps(value, ensure_ascii=False)
2101
+ if isinstance(value, str):
2102
+ return self._format_string_arg_value(value)
2103
+ return _truncate_display(str(value), limit=ARGS_VALUE_MAX_LEN)
2104
+
2105
+ @staticmethod
2106
+ def _format_string_arg_value(value: str) -> str:
2107
+ """Return a trimmed, quoted representation of a string argument."""
2108
+ sanitised = value.replace("\n", " ").strip()
2109
+ sanitised = sanitised.replace('"', '\\"')
2110
+ trimmed = _truncate_display(sanitised, limit=ARGS_VALUE_MAX_LEN)
2111
+ return f'"{trimmed}"'
2112
+
2113
+ @staticmethod
2114
+ def _safe_pretty_args(args: dict[str, Any]) -> str | None:
2115
+ """Defensively format argument dictionaries."""
2116
+ try:
2117
+ return pretty_args(args, max_len=160)
2118
+ except Exception:
2119
+ return str(args)
2120
+
2121
+ @staticmethod
2122
+ def _stringify_args(args: Any) -> str | None:
2123
+ """Format non-dictionary argument payloads."""
2124
+ text = str(args).strip()
2125
+ if not text:
2126
+ return None
2127
+ return _truncate_display(text)
1191
2128
 
1192
2129
  def _render_steps_text(self) -> Any:
1193
2130
  """Render the steps panel content."""
1194
2131
  if not (self.steps.order or self.steps.children):
1195
- return Text("No steps yet", style="dim")
2132
+ return _NO_STEPS_TEXT.copy()
2133
+
2134
+ nodes = list(self.steps.iter_tree())
2135
+ if not nodes:
2136
+ return _NO_STEPS_TEXT.copy()
2137
+
2138
+ window = self._summary_window_size()
2139
+ display_nodes, header_notice, footer_notice = clamp_step_nodes(
2140
+ nodes,
2141
+ window=window,
2142
+ get_label=self._get_step_label,
2143
+ get_parent=self._get_step_parent,
2144
+ )
2145
+ step_renderables = self._build_step_renderables(display_nodes)
1196
2146
 
1197
- running_by_ctx = self._check_parallel_tools()
1198
- renderables: list[Any] = []
1199
- for sid in self.steps.order:
1200
- line = self._compose_step_renderable(self.steps.by_id[sid], running_by_ctx)
1201
- renderables.append(line)
2147
+ if not step_renderables and not header_notice and not footer_notice:
2148
+ return _NO_STEPS_TEXT.copy()
1202
2149
 
1203
- if not renderables:
1204
- return Text("No steps yet", style="dim")
2150
+ return self._assemble_step_renderables(step_renderables, header_notice, footer_notice)
1205
2151
 
1206
- return Group(*renderables)
2152
+ def _get_step_label(self, step_id: str) -> str:
2153
+ """Get label for a step by ID."""
2154
+ step = self.steps.by_id.get(step_id)
2155
+ if step:
2156
+ return self._resolve_step_label(step)
2157
+ return UNKNOWN_STEP_DETAIL
1207
2158
 
1208
- def _should_skip_finished_panel(self, sid: str, status: str) -> bool:
1209
- """Check if a finished panel should be skipped."""
1210
- if status != "finished":
1211
- return False
2159
+ def _get_step_parent(self, step_id: str) -> str | None:
2160
+ """Get parent ID for a step by ID."""
2161
+ step = self.steps.by_id.get(step_id)
2162
+ return step.parent_id if step else None
1212
2163
 
1213
- if getattr(self.cfg, "append_finished_snapshots", False):
1214
- return True
2164
+ def _summary_window_size(self) -> int:
2165
+ """Return the active window size for step display."""
2166
+ if self.state.finalizing_ui:
2167
+ return 0
2168
+ return int(self.cfg.summary_display_window or 0)
1215
2169
 
1216
- return (
1217
- not self.state.finalizing_ui
1218
- and sid not in self.stream_processor.current_event_finished_panels
1219
- )
2170
+ def _assemble_step_renderables(self, step_renderables: list[Any], header_notice: Any, footer_notice: Any) -> Any:
2171
+ """Assemble step renderables with header and footer into final output."""
2172
+ renderables: list[Any] = []
2173
+ if header_notice is not None:
2174
+ renderables.append(header_notice)
2175
+ renderables.extend(step_renderables)
2176
+ if footer_notice is not None:
2177
+ renderables.append(footer_notice)
1220
2178
 
1221
- def _update_final_duration(
1222
- self, duration: float | None, *, overwrite: bool = False
1223
- ) -> None:
2179
+ if len(renderables) == 1:
2180
+ return renderables[0]
2181
+
2182
+ return Group(*renderables)
2183
+
2184
+ def _build_step_renderables(self, display_nodes: list[tuple[str, tuple[bool, ...]]]) -> list[Any]:
2185
+ """Convert step nodes to renderables for the steps panel."""
2186
+ renderables: list[Any] = []
2187
+ for step_id, branch_state in display_nodes:
2188
+ step = self.steps.by_id.get(step_id)
2189
+ if not step:
2190
+ continue
2191
+ renderable = self._compose_step_renderable(step, branch_state)
2192
+ if renderable is not None:
2193
+ renderables.append(renderable)
2194
+ return renderables
2195
+
2196
+ def _update_final_duration(self, duration: float | None, *, overwrite: bool = False) -> None:
1224
2197
  """Store formatted duration for eventual final panels."""
1225
2198
  if duration is None:
1226
2199
  return
@@ -1240,20 +2213,7 @@ class RichStreamRenderer:
1240
2213
 
1241
2214
  self.state.final_duration_seconds = duration_val
1242
2215
  self.state.final_duration_text = self._format_elapsed_time(duration_val)
1243
-
1244
- def _calculate_elapsed_time(self, meta: dict[str, Any]) -> str:
1245
- """Calculate elapsed time string for running tools."""
1246
- server_elapsed = self.stream_processor.server_elapsed_time
1247
- server_start = meta.get("server_started_at")
1248
-
1249
- if isinstance(server_elapsed, int | float) and isinstance(
1250
- server_start, int | float
1251
- ):
1252
- elapsed = max(0.0, float(server_elapsed) - float(server_start))
1253
- else:
1254
- elapsed = max(0.0, monotonic() - (meta.get("started_at") or 0.0))
1255
-
1256
- return self._format_elapsed_time(elapsed)
2216
+ self._apply_root_duration(duration_val)
1257
2217
 
1258
2218
  def _format_elapsed_time(self, elapsed: float) -> str:
1259
2219
  """Format elapsed time as a readable string."""
@@ -1264,110 +2224,10 @@ class RichStreamRenderer:
1264
2224
  else:
1265
2225
  return "<1ms"
1266
2226
 
1267
- def _calculate_finished_duration(self, meta: dict[str, Any]) -> str | None:
1268
- """Calculate duration string for finished tools."""
1269
- dur = meta.get("duration_seconds")
1270
- if isinstance(dur, int | float):
1271
- return self._format_elapsed_time(dur)
1272
-
1273
- try:
1274
- server_now = self.stream_processor.server_elapsed_time
1275
- server_start = meta.get("server_started_at")
1276
- if isinstance(server_now, int | float) and isinstance(
1277
- server_start, int | float
1278
- ):
1279
- dur = max(0.0, float(server_now) - float(server_start))
1280
- elif meta.get("started_at") is not None:
1281
- dur = max(0.0, float(monotonic() - meta.get("started_at")))
1282
- except Exception:
1283
- dur = None
1284
-
1285
- return self._format_elapsed_time(dur) if isinstance(dur, int | float) else None
1286
-
1287
- def _process_running_tool_panel(
1288
- self,
1289
- title: str,
1290
- meta: dict[str, Any],
1291
- body: str,
1292
- *,
1293
- include_spinner: bool = False,
1294
- ) -> tuple[str, str] | tuple[str, str, str | None]:
1295
- """Process a running tool panel."""
1296
- elapsed_str = self._calculate_elapsed_time(meta)
1297
- adjusted_title = f"{title} · {elapsed_str}"
1298
- chip = f"⏱ {elapsed_str}"
1299
- spinner_message: str | None = None
1300
-
1301
- if not body.strip():
1302
- body = ""
1303
- spinner_message = f"{title} running... {elapsed_str}"
1304
- else:
1305
- body = f"{body}\n\n{chip}"
1306
-
1307
- if include_spinner:
1308
- return adjusted_title, body, spinner_message
1309
- return adjusted_title, body
1310
-
1311
- def _process_finished_tool_panel(self, title: str, meta: dict[str, Any]) -> str:
1312
- """Process a finished tool panel."""
1313
- duration_str = self._calculate_finished_duration(meta)
1314
- return f"{title} · {duration_str}" if duration_str else title
1315
-
1316
- def _create_tool_panel_for_session(
1317
- self, sid: str, meta: dict[str, Any]
1318
- ) -> AIPPanel | None:
1319
- """Create a single tool panel for the session."""
1320
- title = meta.get("title") or "Tool"
1321
- status = meta.get("status") or "running"
1322
- chunks = meta.get("chunks") or []
1323
- is_delegation = bool(meta.get("is_delegation"))
1324
-
1325
- if self._should_skip_finished_panel(sid, status):
1326
- return None
1327
-
1328
- body = "".join(chunks)
1329
- adjusted_title = title
1330
-
1331
- spinner_message: str | None = None
1332
-
1333
- if status == "running":
1334
- adjusted_title, body, spinner_message = self._process_running_tool_panel(
1335
- title, meta, body, include_spinner=True
1336
- )
1337
- elif status == "finished":
1338
- adjusted_title = self._process_finished_tool_panel(title, meta)
1339
-
1340
- return create_tool_panel(
1341
- title=adjusted_title,
1342
- content=body,
1343
- status=status,
1344
- theme=self.cfg.theme,
1345
- is_delegation=is_delegation,
1346
- spinner_message=spinner_message,
1347
- )
1348
-
1349
- def _render_tool_panels(self) -> list[AIPPanel]:
1350
- """Render tool execution output panels."""
1351
- if not getattr(self.cfg, "show_delegate_tool_panels", False):
1352
- return []
1353
- panels: list[AIPPanel] = []
1354
- for sid in self.tool_order:
1355
- meta = self.tool_panels.get(sid) or {}
1356
- panel = self._create_tool_panel_for_session(sid, meta)
1357
- if panel:
1358
- panels.append(panel)
1359
-
1360
- return panels
1361
-
1362
2227
  def _format_dict_or_list_output(self, output_value: dict | list) -> str:
1363
2228
  """Format dict/list output as pretty JSON."""
1364
2229
  try:
1365
- return (
1366
- self.OUTPUT_PREFIX
1367
- + "```json\n"
1368
- + json.dumps(output_value, indent=2)
1369
- + "\n```\n"
1370
- )
2230
+ return self.OUTPUT_PREFIX + "```json\n" + json.dumps(output_value, indent=2) + "\n```\n"
1371
2231
  except Exception:
1372
2232
  return self.OUTPUT_PREFIX + str(output_value) + "\n"
1373
2233
 
@@ -1391,12 +2251,7 @@ class RichStreamRenderer:
1391
2251
  """Format string that looks like JSON."""
1392
2252
  try:
1393
2253
  parsed = json.loads(output)
1394
- return (
1395
- self.OUTPUT_PREFIX
1396
- + "```json\n"
1397
- + json.dumps(parsed, indent=2)
1398
- + "\n```\n"
1399
- )
2254
+ return self.OUTPUT_PREFIX + "```json\n" + json.dumps(parsed, indent=2) + "\n```\n"
1400
2255
  except Exception:
1401
2256
  return self.OUTPUT_PREFIX + output + "\n"
1402
2257
 
@@ -1406,9 +2261,7 @@ class RichStreamRenderer:
1406
2261
  s = self._clean_sub_agent_prefix(s, tool_name)
1407
2262
 
1408
2263
  # If looks like JSON, pretty print it
1409
- if (s.startswith("{") and s.endswith("}")) or (
1410
- s.startswith("[") and s.endswith("]")
1411
- ):
2264
+ if (s.startswith("{") and s.endswith("}")) or (s.startswith("[") and s.endswith("]")):
1412
2265
  return self._format_json_string_output(s)
1413
2266
 
1414
2267
  return self.OUTPUT_PREFIX + s + "\n"
@@ -1422,7 +2275,7 @@ class RichStreamRenderer:
1422
2275
 
1423
2276
  def _format_output_block(self, output_value: Any, tool_name: str | None) -> str:
1424
2277
  """Format an output value for panel display."""
1425
- if isinstance(output_value, dict | list):
2278
+ if isinstance(output_value, (dict, list)):
1426
2279
  return self._format_dict_or_list_output(output_value)
1427
2280
  elif isinstance(output_value, str):
1428
2281
  return self._format_string_output(output_value, tool_name)