glaip-sdk 0.1.3__py3-none-any.whl → 0.6.10__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 (141) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1191 -0
  5. glaip_sdk/branding.py +13 -0
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/auth.py +254 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents.py +213 -73
  11. glaip_sdk/cli/commands/common_config.py +101 -0
  12. glaip_sdk/cli/commands/configure.py +729 -113
  13. glaip_sdk/cli/commands/mcps.py +241 -72
  14. glaip_sdk/cli/commands/models.py +11 -5
  15. glaip_sdk/cli/commands/tools.py +49 -57
  16. glaip_sdk/cli/commands/transcripts.py +755 -0
  17. glaip_sdk/cli/config.py +48 -4
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +8 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +846 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +35 -19
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +6 -3
  28. glaip_sdk/cli/main.py +228 -119
  29. glaip_sdk/cli/masking.py +21 -33
  30. glaip_sdk/cli/pager.py +9 -10
  31. glaip_sdk/cli/parsers/__init__.py +1 -3
  32. glaip_sdk/cli/slash/__init__.py +0 -9
  33. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  34. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  35. glaip_sdk/cli/slash/agent_session.py +62 -21
  36. glaip_sdk/cli/slash/prompt.py +21 -0
  37. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  38. glaip_sdk/cli/slash/session.py +771 -140
  39. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  40. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  41. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  42. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  43. glaip_sdk/cli/slash/tui/loading.py +58 -0
  44. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  45. glaip_sdk/cli/transcript/__init__.py +12 -52
  46. glaip_sdk/cli/transcript/cache.py +255 -44
  47. glaip_sdk/cli/transcript/capture.py +27 -1
  48. glaip_sdk/cli/transcript/history.py +815 -0
  49. glaip_sdk/cli/transcript/viewer.py +72 -499
  50. glaip_sdk/cli/update_notifier.py +14 -5
  51. glaip_sdk/cli/utils.py +243 -1252
  52. glaip_sdk/cli/validators.py +5 -6
  53. glaip_sdk/client/__init__.py +2 -1
  54. glaip_sdk/client/_agent_payloads.py +45 -9
  55. glaip_sdk/client/agent_runs.py +147 -0
  56. glaip_sdk/client/agents.py +287 -29
  57. glaip_sdk/client/base.py +1 -0
  58. glaip_sdk/client/main.py +19 -10
  59. glaip_sdk/client/mcps.py +122 -12
  60. glaip_sdk/client/run_rendering.py +133 -88
  61. glaip_sdk/client/shared.py +21 -0
  62. glaip_sdk/client/tools.py +155 -10
  63. glaip_sdk/config/constants.py +11 -0
  64. glaip_sdk/mcps/__init__.py +21 -0
  65. glaip_sdk/mcps/base.py +345 -0
  66. glaip_sdk/models/__init__.py +90 -0
  67. glaip_sdk/models/agent.py +47 -0
  68. glaip_sdk/models/agent_runs.py +116 -0
  69. glaip_sdk/models/common.py +42 -0
  70. glaip_sdk/models/mcp.py +33 -0
  71. glaip_sdk/models/tool.py +33 -0
  72. glaip_sdk/payload_schemas/__init__.py +1 -13
  73. glaip_sdk/registry/__init__.py +55 -0
  74. glaip_sdk/registry/agent.py +164 -0
  75. glaip_sdk/registry/base.py +139 -0
  76. glaip_sdk/registry/mcp.py +253 -0
  77. glaip_sdk/registry/tool.py +232 -0
  78. glaip_sdk/rich_components.py +58 -2
  79. glaip_sdk/runner/__init__.py +59 -0
  80. glaip_sdk/runner/base.py +84 -0
  81. glaip_sdk/runner/deps.py +115 -0
  82. glaip_sdk/runner/langgraph.py +706 -0
  83. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  84. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  85. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  86. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  87. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  88. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  89. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  90. glaip_sdk/tools/__init__.py +22 -0
  91. glaip_sdk/tools/base.py +435 -0
  92. glaip_sdk/utils/__init__.py +58 -12
  93. glaip_sdk/utils/a2a/__init__.py +34 -0
  94. glaip_sdk/utils/a2a/event_processor.py +188 -0
  95. glaip_sdk/utils/bundler.py +267 -0
  96. glaip_sdk/utils/client.py +111 -0
  97. glaip_sdk/utils/client_utils.py +39 -7
  98. glaip_sdk/utils/datetime_helpers.py +58 -0
  99. glaip_sdk/utils/discovery.py +78 -0
  100. glaip_sdk/utils/display.py +23 -15
  101. glaip_sdk/utils/export.py +143 -0
  102. glaip_sdk/utils/general.py +0 -33
  103. glaip_sdk/utils/import_export.py +12 -7
  104. glaip_sdk/utils/import_resolver.py +492 -0
  105. glaip_sdk/utils/instructions.py +101 -0
  106. glaip_sdk/utils/rendering/__init__.py +115 -1
  107. glaip_sdk/utils/rendering/formatting.py +5 -30
  108. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  109. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  110. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  111. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  112. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  113. glaip_sdk/utils/rendering/models.py +1 -0
  114. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  115. glaip_sdk/utils/rendering/renderer/base.py +217 -1476
  116. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  117. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  118. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  119. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  120. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  121. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  122. glaip_sdk/utils/rendering/state.py +204 -0
  123. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  124. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  125. glaip_sdk/utils/rendering/steps/format.py +176 -0
  126. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  127. glaip_sdk/utils/rendering/timing.py +36 -0
  128. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  129. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  130. glaip_sdk/utils/resource_refs.py +25 -13
  131. glaip_sdk/utils/runtime_config.py +425 -0
  132. glaip_sdk/utils/serialization.py +18 -0
  133. glaip_sdk/utils/sync.py +142 -0
  134. glaip_sdk/utils/tool_detection.py +33 -0
  135. glaip_sdk/utils/validation.py +16 -24
  136. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.10.dist-info}/METADATA +42 -4
  137. glaip_sdk-0.6.10.dist-info/RECORD +159 -0
  138. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.10.dist-info}/WHEEL +1 -1
  139. glaip_sdk/models.py +0 -240
  140. glaip_sdk-0.1.3.dist-info/RECORD +0 -83
  141. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.10.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,43 +365,37 @@ 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
 
492
394
  def _handle_agent_step_event(self, ev: dict[str, Any], metadata: dict[str, Any]) -> None:
493
395
  """Handle agent step events."""
494
- # Extract tool information
495
- (
496
- tool_name,
497
- tool_args,
498
- tool_out,
499
- tool_calls_info,
500
- ) = self.stream_processor.parse_tool_calls(ev)
396
+ # Extract tool information using stream processor
397
+ tool_calls_result = self.stream_processor.parse_tool_calls(ev)
398
+ tool_name, tool_args, tool_out, tool_calls_info = tool_calls_result
501
399
 
502
400
  payload = metadata.get("metadata") or {}
503
401
 
@@ -508,7 +406,11 @@ class RichStreamRenderer:
508
406
  logger.debug("Malformed step event skipped", exc_info=True)
509
407
  else:
510
408
  self._record_step_server_start(tracked_step, payload)
511
- 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
+ )
512
414
  self._maybe_override_root_agent_label(tracked_step, payload)
513
415
  self._maybe_attach_root_query(tracked_step)
514
416
 
@@ -516,7 +418,7 @@ class RichStreamRenderer:
516
418
  self.stream_processor.track_tools_and_agents(tool_name, tool_calls_info, is_delegation_tool)
517
419
 
518
420
  # Handle tool execution
519
- self._handle_agent_step(
421
+ self.tool_controller.handle_agent_step(
520
422
  ev,
521
423
  tool_name,
522
424
  tool_args,
@@ -561,246 +463,7 @@ class RichStreamRenderer:
561
463
  if not self._root_agent_step_id:
562
464
  self._root_agent_step_id = step.step_id
563
465
 
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
466
+ # Thinking scope management is handled by ThinkingScopeController.
804
467
 
805
468
  def _apply_root_duration(self, duration_seconds: float | None) -> None:
806
469
  """Propagate the final run duration to the root agent step."""
@@ -817,33 +480,6 @@ class RichStreamRenderer:
817
480
  root_step.duration_source = root_step.duration_source or "run"
818
481
  root_step.status = "finished"
819
482
 
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
483
  @staticmethod
848
484
  def _humanize_agent_slug(value: Any) -> str | None:
849
485
  """Convert a slugified agent name into Title Case."""
@@ -868,19 +504,6 @@ class RichStreamRenderer:
868
504
  if step.duration_ms is None:
869
505
  step.duration_ms = 0
870
506
  step.duration_source = step.duration_source or "unknown"
871
- step.status_icon = "warning"
872
-
873
- def _finish_tool_panels(self) -> None:
874
- """Mark unfinished tool panels as finished."""
875
- try:
876
- items = list(self.tool_panels.items())
877
- except Exception: # pragma: no cover - defensive guard
878
- logger.exception("Failed to iterate tool panels during cleanup")
879
- return
880
-
881
- for _sid, meta in items:
882
- if meta.get("status") != "finished":
883
- meta["status"] = "finished"
884
507
 
885
508
  def _stop_live_display(self) -> None:
886
509
  """Stop live display and clean up."""
@@ -891,7 +514,7 @@ class RichStreamRenderer:
891
514
  if self.state.printed_final_output:
892
515
  return
893
516
 
894
- 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()
895
518
  if not body:
896
519
  return
897
520
 
@@ -899,20 +522,52 @@ class RichStreamRenderer:
899
522
  return
900
523
 
901
524
  if self.verbose:
902
- final_panel = create_final_panel(
903
- body,
525
+ panel = build_final_panel(
526
+ self.state,
904
527
  title=self._final_panel_title(),
905
- theme=DEFAULT_RENDERER_THEME,
906
528
  )
907
- self.console.print(final_panel)
529
+ if panel is None:
530
+ return
531
+ self.console.print(panel)
908
532
  self.state.printed_final_output = True
909
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
+
910
565
  def on_complete(self, stats: RunStats) -> None:
911
566
  """Handle completion event."""
912
567
  self.state.finalizing_ui = True
913
568
 
914
569
  self._handle_stats_duration(stats)
915
- self._close_active_thinking_scopes(self.state.final_duration_seconds)
570
+ self.thinking_controller.close_active_scopes(self.state.final_duration_seconds)
916
571
  self._cleanup_ui_elements()
917
572
  self._finalize_display()
918
573
  self._print_completion_message()
@@ -938,18 +593,23 @@ class RichStreamRenderer:
938
593
  self._finish_running_steps()
939
594
 
940
595
  # Mark unfinished tool panels as finished
941
- self._finish_tool_panels()
596
+ self.tool_controller.finish_all_panels()
942
597
 
943
598
  def _finalize_display(self) -> None:
944
599
  """Finalize live display and render final output."""
945
600
  # Final refresh
946
601
  self._ensure_live()
947
602
 
603
+ header, body = self.finalize()
604
+
948
605
  # Stop live display
949
606
  self._stop_live_display()
950
607
 
951
608
  # Render final output based on configuration
952
- 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)
953
613
 
954
614
  def _print_completion_message(self) -> None:
955
615
  """Print completion message based on current mode."""
@@ -1021,13 +681,18 @@ class RichStreamRenderer:
1021
681
  if not self.live:
1022
682
  return
1023
683
 
1024
- main_panel = self._render_main_panel()
1025
- 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()
1026
688
  steps_panel = AIPPanel(
1027
- steps_renderable,
1028
- title="Steps",
1029
- 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)),
1030
693
  )
694
+
695
+ main_panel = self._render_main_panel()
1031
696
  panels = self._build_live_panels(main_panel, steps_panel)
1032
697
 
1033
698
  self.live.update(Group(*panels))
@@ -1045,26 +710,19 @@ class RichStreamRenderer:
1045
710
 
1046
711
  def _render_main_panel(self) -> Any:
1047
712
  """Render the main content panel."""
1048
- body = "".join(self.state.buffer).strip()
713
+ body = self.state.buffer.render().strip()
714
+ theme = DEFAULT_TRANSCRIPT_THEME
1049
715
  if not self.verbose:
1050
- final_content = (self.state.final_text or "").strip()
1051
- if final_content:
1052
- title = self._final_panel_title()
1053
- return create_final_panel(
1054
- final_content,
1055
- title=title,
1056
- theme=DEFAULT_RENDERER_THEME,
1057
- )
716
+ panel = build_final_panel(self.state, theme=theme)
717
+ if panel is not None:
718
+ return panel
1058
719
  # Dynamic title with spinner + elapsed/hints
1059
720
  title = self._format_enhanced_main_title()
1060
- return create_main_panel(body, title, DEFAULT_RENDERER_THEME)
721
+ return create_main_panel(body, title, theme)
1061
722
 
1062
723
  def _final_panel_title(self) -> str:
1063
724
  """Compose title for the final result panel including duration."""
1064
- title = "Final Result"
1065
- if self.state.final_duration_text:
1066
- title = f"{title} · {self.state.final_duration_text}"
1067
- return title
725
+ return format_final_panel_title(self.state)
1068
726
 
1069
727
  def apply_verbosity(self, verbose: bool) -> None:
1070
728
  """Update verbose behaviour at runtime."""
@@ -1083,550 +741,16 @@ class RichStreamRenderer:
1083
741
  if self.cfg.live:
1084
742
  self._ensure_live()
1085
743
 
1086
- # ------------------------------------------------------------------
1087
- # Transcript helpers
1088
- # ------------------------------------------------------------------
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:
1206
- """Capture a deep copy of SSE events for transcript replay."""
1207
- try:
1208
- captured = json.loads(json.dumps(ev))
1209
- except Exception:
1210
- captured = ev
1211
-
1212
- if received_at is not None:
1213
- try:
1214
- captured["received_at"] = received_at.isoformat()
1215
- except Exception:
1216
- try:
1217
- captured["received_at"] = str(received_at)
1218
- except Exception:
1219
- captured["received_at"] = repr(received_at)
1220
-
1221
- self.state.events.append(captured)
1222
- if self._transcript_mode_enabled:
1223
- self._transcript_render_cursor = len(self.state.events)
744
+ # Transcript helper implementations live in TranscriptModeMixin.
1224
745
 
1225
746
  def get_aggregated_output(self) -> str:
1226
747
  """Return the concatenated assistant output collected so far."""
1227
- return ("".join(self.state.buffer or [])).strip()
748
+ return self.state.buffer.render().strip()
1228
749
 
1229
750
  def get_transcript_events(self) -> list[dict[str, Any]]:
1230
751
  """Return captured SSE events."""
1231
752
  return list(self.state.events)
1232
753
 
1233
- def _ensure_tool_panel(self, name: str, args: Any, task_id: str, context_id: str) -> str:
1234
- """Ensure a tool panel exists and return its ID."""
1235
- formatted_title = format_tool_title(name)
1236
- is_delegation = is_delegation_tool(name)
1237
- tool_sid = f"tool_{name}_{task_id}_{context_id}"
1238
-
1239
- if tool_sid not in self.tool_panels:
1240
- self.tool_panels[tool_sid] = {
1241
- "title": formatted_title,
1242
- "status": "running",
1243
- "started_at": monotonic(),
1244
- "server_started_at": self.stream_processor.server_elapsed_time,
1245
- "chunks": [],
1246
- "args": args or {},
1247
- "output": None,
1248
- "is_delegation": is_delegation,
1249
- }
1250
- # Add Args section once
1251
- if args:
1252
- try:
1253
- args_content = "**Args:**\n```json\n" + json.dumps(args, indent=2) + "\n```\n\n"
1254
- except Exception:
1255
- args_content = f"**Args:**\n{args}\n\n"
1256
- self.tool_panels[tool_sid]["chunks"].append(args_content)
1257
-
1258
- return tool_sid
1259
-
1260
- def _start_tool_step(
1261
- self,
1262
- task_id: str,
1263
- context_id: str,
1264
- tool_name: str,
1265
- tool_args: Any,
1266
- _tool_sid: str,
1267
- *,
1268
- tracked_step: Step | None = None,
1269
- ) -> Step | None:
1270
- """Start or get a step for a tool."""
1271
- if tracked_step is not None:
1272
- return tracked_step
1273
-
1274
- if is_delegation_tool(tool_name):
1275
- st = self.steps.start_or_get(
1276
- task_id=task_id,
1277
- context_id=context_id,
1278
- kind="delegate",
1279
- name=tool_name,
1280
- args=tool_args,
1281
- )
1282
- else:
1283
- st = self.steps.start_or_get(
1284
- task_id=task_id,
1285
- context_id=context_id,
1286
- kind="tool",
1287
- name=tool_name,
1288
- args=tool_args,
1289
- )
1290
-
1291
- # Record server start time for this step if available
1292
- if st and self.stream_processor.server_elapsed_time is not None:
1293
- self._step_server_start_times[st.step_id] = self.stream_processor.server_elapsed_time
1294
-
1295
- return st
1296
-
1297
- def _process_additional_tool_calls(
1298
- self,
1299
- tool_calls_info: list[tuple[str, Any, Any]],
1300
- tool_name: str,
1301
- task_id: str,
1302
- context_id: str,
1303
- ) -> None:
1304
- """Process additional tool calls to avoid duplicates."""
1305
- for call_name, call_args, _ in tool_calls_info or []:
1306
- if call_name and call_name != tool_name:
1307
- self._process_single_tool_call(call_name, call_args, task_id, context_id)
1308
-
1309
- def _process_single_tool_call(self, call_name: str, call_args: Any, task_id: str, context_id: str) -> None:
1310
- """Process a single additional tool call."""
1311
- self._ensure_tool_panel(call_name, call_args, task_id, context_id)
1312
-
1313
- st2 = self._create_step_for_tool_call(call_name, call_args, task_id, context_id)
1314
-
1315
- if self.stream_processor.server_elapsed_time is not None and st2:
1316
- self._step_server_start_times[st2.step_id] = self.stream_processor.server_elapsed_time
1317
-
1318
- def _create_step_for_tool_call(self, call_name: str, call_args: Any, task_id: str, context_id: str) -> Any:
1319
- """Create appropriate step for tool call."""
1320
- if is_delegation_tool(call_name):
1321
- return self.steps.start_or_get(
1322
- task_id=task_id,
1323
- context_id=context_id,
1324
- kind="delegate",
1325
- name=call_name,
1326
- args=call_args,
1327
- )
1328
- else:
1329
- return self.steps.start_or_get(
1330
- task_id=task_id,
1331
- context_id=context_id,
1332
- kind="tool",
1333
- name=call_name,
1334
- args=call_args,
1335
- )
1336
-
1337
- def _detect_tool_completion(self, metadata: dict, content: str) -> tuple[bool, str | None, Any]:
1338
- """Detect if a tool has completed and return completion info."""
1339
- tool_info = metadata.get("tool_info", {}) if isinstance(metadata, dict) else {}
1340
-
1341
- if tool_info.get("status") == "finished" and tool_info.get("name"):
1342
- return True, tool_info.get("name"), tool_info.get("output")
1343
- elif content and isinstance(content, str) and content.startswith("Completed "):
1344
- # content like "Completed google_serper"
1345
- tname = content.replace("Completed ", "").strip()
1346
- if tname:
1347
- output = tool_info.get("output") if tool_info.get("name") == tname else None
1348
- return True, tname, output
1349
- elif metadata.get("status") == "finished" and tool_info.get("name"):
1350
- return True, tool_info.get("name"), tool_info.get("output")
1351
-
1352
- return False, None, None
1353
-
1354
- def _get_tool_session_id(self, finished_tool_name: str, task_id: str, context_id: str) -> str:
1355
- """Generate tool session ID."""
1356
- return f"tool_{finished_tool_name}_{task_id}_{context_id}"
1357
-
1358
- def _calculate_tool_duration(self, meta: dict[str, Any]) -> float | None:
1359
- """Calculate tool duration from metadata."""
1360
- server_now = self.stream_processor.server_elapsed_time
1361
- server_start = meta.get("server_started_at")
1362
- dur = None
1363
-
1364
- try:
1365
- if isinstance(server_now, (int, float)) and server_start is not None:
1366
- dur = max(0.0, float(server_now) - float(server_start))
1367
- else:
1368
- started_at = meta.get("started_at")
1369
- if started_at is not None:
1370
- started_at_float = float(started_at)
1371
- dur = max(0.0, float(monotonic()) - started_at_float)
1372
- except (TypeError, ValueError):
1373
- logger.exception("Failed to calculate tool duration")
1374
- return None
1375
-
1376
- return dur
1377
-
1378
- def _update_tool_metadata(self, meta: dict[str, Any], dur: float | None) -> None:
1379
- """Update tool metadata with duration information."""
1380
- if dur is not None:
1381
- meta["duration_seconds"] = dur
1382
- meta["server_finished_at"] = (
1383
- self.stream_processor.server_elapsed_time
1384
- if isinstance(self.stream_processor.server_elapsed_time, (int, float))
1385
- else None
1386
- )
1387
- meta["finished_at"] = monotonic()
1388
-
1389
- def _add_tool_output_to_panel(
1390
- self, meta: dict[str, Any], finished_tool_output: Any, finished_tool_name: str
1391
- ) -> None:
1392
- """Add tool output to panel metadata."""
1393
- if finished_tool_output is not None:
1394
- meta["chunks"].append(self._format_output_block(finished_tool_output, finished_tool_name))
1395
- meta["output"] = finished_tool_output
1396
-
1397
- def _mark_panel_as_finished(self, meta: dict[str, Any], tool_sid: str) -> None:
1398
- """Mark panel as finished and ensure visibility."""
1399
- if meta.get("status") != "finished":
1400
- meta["status"] = "finished"
1401
-
1402
- dur = self._calculate_tool_duration(meta)
1403
- self._update_tool_metadata(meta, dur)
1404
-
1405
- # Ensure this finished panel is visible in this frame
1406
- self.stream_processor.current_event_finished_panels.add(tool_sid)
1407
-
1408
- def _finish_tool_panel(
1409
- self,
1410
- finished_tool_name: str,
1411
- finished_tool_output: Any,
1412
- task_id: str,
1413
- context_id: str,
1414
- ) -> None:
1415
- """Finish a tool panel and update its status."""
1416
- tool_sid = self._get_tool_session_id(finished_tool_name, task_id, context_id)
1417
- if tool_sid not in self.tool_panels:
1418
- return
1419
-
1420
- meta = self.tool_panels[tool_sid]
1421
- self._mark_panel_as_finished(meta, tool_sid)
1422
- self._add_tool_output_to_panel(meta, finished_tool_output, finished_tool_name)
1423
-
1424
- def _get_step_duration(self, finished_tool_name: str, task_id: str, context_id: str) -> float | None:
1425
- """Get step duration from tool panels."""
1426
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
1427
- return self.tool_panels.get(tool_sid, {}).get("duration_seconds")
1428
-
1429
- def _finish_delegation_step(
1430
- self,
1431
- finished_tool_name: str,
1432
- finished_tool_output: Any,
1433
- task_id: str,
1434
- context_id: str,
1435
- step_duration: float | None,
1436
- ) -> None:
1437
- """Finish a delegation step."""
1438
- self.steps.finish(
1439
- task_id=task_id,
1440
- context_id=context_id,
1441
- kind="delegate",
1442
- name=finished_tool_name,
1443
- output=finished_tool_output,
1444
- duration_raw=step_duration,
1445
- )
1446
-
1447
- def _finish_tool_step_type(
1448
- self,
1449
- finished_tool_name: str,
1450
- finished_tool_output: Any,
1451
- task_id: str,
1452
- context_id: str,
1453
- step_duration: float | None,
1454
- ) -> None:
1455
- """Finish a regular tool step."""
1456
- self.steps.finish(
1457
- task_id=task_id,
1458
- context_id=context_id,
1459
- kind="tool",
1460
- name=finished_tool_name,
1461
- output=finished_tool_output,
1462
- duration_raw=step_duration,
1463
- )
1464
-
1465
- def _finish_tool_step(
1466
- self,
1467
- finished_tool_name: str,
1468
- finished_tool_output: Any,
1469
- task_id: str,
1470
- context_id: str,
1471
- *,
1472
- tracked_step: Step | None = None,
1473
- ) -> None:
1474
- """Finish the corresponding step for a completed tool."""
1475
- if tracked_step is not None:
1476
- return
1477
-
1478
- step_duration = self._get_step_duration(finished_tool_name, task_id, context_id)
1479
-
1480
- if is_delegation_tool(finished_tool_name):
1481
- self._finish_delegation_step(
1482
- finished_tool_name,
1483
- finished_tool_output,
1484
- task_id,
1485
- context_id,
1486
- step_duration,
1487
- )
1488
- else:
1489
- self._finish_tool_step_type(
1490
- finished_tool_name,
1491
- finished_tool_output,
1492
- task_id,
1493
- context_id,
1494
- step_duration,
1495
- )
1496
-
1497
- def _should_create_snapshot(self, tool_sid: str) -> bool:
1498
- """Check if a snapshot should be created."""
1499
- return self.cfg.append_finished_snapshots and not self.tool_panels.get(tool_sid, {}).get("snapshot_printed")
1500
-
1501
- def _get_snapshot_title(self, meta: dict[str, Any], finished_tool_name: str) -> str:
1502
- """Get the title for the snapshot."""
1503
- adjusted_title = meta.get("title") or finished_tool_name
1504
-
1505
- # Add elapsed time to title
1506
- dur = meta.get("duration_seconds")
1507
- if isinstance(dur, (int, float)):
1508
- elapsed_str = self._format_snapshot_duration(dur)
1509
- adjusted_title = f"{adjusted_title} · {elapsed_str}"
1510
-
1511
- return adjusted_title
1512
-
1513
- def _format_snapshot_duration(self, dur: int | float) -> str:
1514
- """Format duration for snapshot title."""
1515
- try:
1516
- # Handle invalid types
1517
- if not isinstance(dur, (int, float)):
1518
- return "<1ms"
1519
-
1520
- if dur >= 1:
1521
- return f"{dur:.2f}s"
1522
- elif int(dur * 1000) > 0:
1523
- return f"{int(dur * 1000)}ms"
1524
- else:
1525
- return "<1ms"
1526
- except (TypeError, ValueError, OverflowError):
1527
- return "<1ms"
1528
-
1529
- def _clamp_snapshot_body(self, body_text: str) -> str:
1530
- """Clamp snapshot body to configured limits."""
1531
- max_lines = int(self.cfg.snapshot_max_lines or 0)
1532
- lines = body_text.splitlines()
1533
- if max_lines > 0 and len(lines) > max_lines:
1534
- lines = lines[:max_lines] + ["… (truncated)"]
1535
- body_text = "\n".join(lines)
1536
-
1537
- max_chars = int(self.cfg.snapshot_max_chars or 0)
1538
- if max_chars > 0 and len(body_text) > max_chars:
1539
- suffix = "\n… (truncated)"
1540
- body_text = body_text[: max_chars - len(suffix)] + suffix
1541
-
1542
- return body_text
1543
-
1544
- def _create_snapshot_panel(self, adjusted_title: str, body_text: str, finished_tool_name: str) -> Any:
1545
- """Create the snapshot panel."""
1546
- return create_tool_panel(
1547
- title=adjusted_title,
1548
- content=body_text or "(no output)",
1549
- status="finished",
1550
- theme=DEFAULT_RENDERER_THEME,
1551
- is_delegation=is_delegation_tool(finished_tool_name),
1552
- )
1553
-
1554
- def _print_and_mark_snapshot(self, tool_sid: str, snapshot_panel: Any) -> None:
1555
- """Print snapshot and mark as printed."""
1556
- self.console.print(snapshot_panel)
1557
- self.tool_panels[tool_sid]["snapshot_printed"] = True
1558
-
1559
- def _create_tool_snapshot(self, finished_tool_name: str, task_id: str, context_id: str) -> None:
1560
- """Create and print a snapshot for a finished tool."""
1561
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
1562
-
1563
- if not self._should_create_snapshot(tool_sid):
1564
- return
1565
-
1566
- meta = self.tool_panels[tool_sid]
1567
- adjusted_title = self._get_snapshot_title(meta, finished_tool_name)
1568
-
1569
- # Compose body from chunks and clamp
1570
- body_text = "".join(meta.get("chunks") or [])
1571
- body_text = self._clamp_snapshot_body(body_text)
1572
-
1573
- snapshot_panel = self._create_snapshot_panel(adjusted_title, body_text, finished_tool_name)
1574
-
1575
- self._print_and_mark_snapshot(tool_sid, snapshot_panel)
1576
-
1577
- def _handle_agent_step(
1578
- self,
1579
- event: dict[str, Any],
1580
- tool_name: str | None,
1581
- tool_args: Any,
1582
- _tool_out: Any,
1583
- tool_calls_info: list[tuple[str, Any, Any]],
1584
- *,
1585
- tracked_step: Step | None = None,
1586
- ) -> None:
1587
- """Handle agent step event."""
1588
- metadata = event.get("metadata", {})
1589
- task_id = event.get("task_id") or metadata.get("task_id")
1590
- context_id = event.get("context_id") or metadata.get("context_id")
1591
- content = event.get("content", "")
1592
-
1593
- # Create steps and panels for the primary tool
1594
- if tool_name:
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,
1603
- )
1604
-
1605
- # Handle additional tool calls
1606
- self._process_additional_tool_calls(tool_calls_info, tool_name, task_id, context_id)
1607
-
1608
- # Check for tool completion
1609
- (
1610
- is_tool_finished,
1611
- finished_tool_name,
1612
- finished_tool_output,
1613
- ) = self._detect_tool_completion(metadata, content)
1614
-
1615
- if is_tool_finished and finished_tool_name:
1616
- self._finish_tool_panel(finished_tool_name, finished_tool_output, task_id, context_id)
1617
- self._finish_tool_step(
1618
- finished_tool_name,
1619
- finished_tool_output,
1620
- task_id,
1621
- context_id,
1622
- tracked_step=tracked_step,
1623
- )
1624
- self._create_tool_snapshot(finished_tool_name, task_id, context_id)
1625
-
1626
- def _spinner(self) -> str:
1627
- """Return spinner character."""
1628
- return get_spinner()
1629
-
1630
754
  def _format_working_indicator(self, started_at: float | None) -> str:
1631
755
  """Format working indicator."""
1632
756
  return format_working_indicator(
@@ -1786,18 +910,7 @@ class RichStreamRenderer:
1786
910
 
1787
911
  def _resolve_step_label(self, step: Step) -> str:
1788
912
  """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)
913
+ return format_step_label(step)
1801
914
 
1802
915
  def _check_parallel_tools(self) -> dict[tuple[str | None, str | None], list]:
1803
916
  """Check for parallel running tools."""
@@ -1818,348 +931,69 @@ class RichStreamRenderer:
1818
931
  key = (step.task_id, step.context_id)
1819
932
  return len(running_by_ctx.get(key, [])) > 1
1820
933
 
1821
- def _compose_step_renderable(
1822
- self,
1823
- step: Step,
1824
- branch_state: tuple[bool, ...],
1825
- ) -> Any:
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)
1830
-
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:
1862
- 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
1898
-
1899
- if len(renderables) == 1:
1900
- return renderables[0]
1901
-
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)
2128
-
2129
- def _render_steps_text(self) -> Any:
2130
- """Render the steps panel content."""
2131
- if not (self.steps.order or self.steps.children):
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,
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(),
2144
960
  )
2145
- step_renderables = self._build_step_renderables(display_nodes)
2146
-
2147
- if not step_renderables and not header_notice and not footer_notice:
2148
- return _NO_STEPS_TEXT.copy()
2149
-
2150
- return self._assemble_step_renderables(step_renderables, header_notice, footer_notice)
2151
-
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
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)
2158
993
 
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
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)
2163
997
 
2164
998
  def _summary_window_size(self) -> int:
2165
999
  """Return the active window size for step display."""
@@ -2167,32 +1001,6 @@ class RichStreamRenderer:
2167
1001
  return 0
2168
1002
  return int(self.cfg.summary_display_window or 0)
2169
1003
 
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)
2178
-
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
1004
  def _update_final_duration(self, duration: float | None, *, overwrite: bool = False) -> None:
2197
1005
  """Store formatted duration for eventual final panels."""
2198
1006
  if duration is None:
@@ -2211,73 +1019,6 @@ class RichStreamRenderer:
2211
1019
  if overwrite and existing is not None:
2212
1020
  duration_val = max(existing, duration_val)
2213
1021
 
2214
- self.state.final_duration_seconds = duration_val
2215
- 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)
2216
1024
  self._apply_root_duration(duration_val)
2217
-
2218
- def _format_elapsed_time(self, elapsed: float) -> str:
2219
- """Format elapsed time as a readable string."""
2220
- if elapsed >= 1:
2221
- return f"{elapsed:.2f}s"
2222
- elif int(elapsed * 1000) > 0:
2223
- return f"{int(elapsed * 1000)}ms"
2224
- else:
2225
- return "<1ms"
2226
-
2227
- def _format_dict_or_list_output(self, output_value: dict | list) -> str:
2228
- """Format dict/list output as pretty JSON."""
2229
- try:
2230
- return self.OUTPUT_PREFIX + "```json\n" + json.dumps(output_value, indent=2) + "\n```\n"
2231
- except Exception:
2232
- return self.OUTPUT_PREFIX + str(output_value) + "\n"
2233
-
2234
- def _clean_sub_agent_prefix(self, output: str, tool_name: str | None) -> str:
2235
- """Clean sub-agent name prefix from output."""
2236
- if not (tool_name and is_delegation_tool(tool_name)):
2237
- return output
2238
-
2239
- sub = tool_name
2240
- if tool_name.startswith("delegate_to_"):
2241
- sub = tool_name.replace("delegate_to_", "")
2242
- elif tool_name.startswith("delegate_"):
2243
- sub = tool_name.replace("delegate_", "")
2244
- prefix = f"[{sub}]"
2245
- if output.startswith(prefix):
2246
- return output[len(prefix) :].lstrip()
2247
-
2248
- return output
2249
-
2250
- def _format_json_string_output(self, output: str) -> str:
2251
- """Format string that looks like JSON."""
2252
- try:
2253
- parsed = json.loads(output)
2254
- return self.OUTPUT_PREFIX + "```json\n" + json.dumps(parsed, indent=2) + "\n```\n"
2255
- except Exception:
2256
- return self.OUTPUT_PREFIX + output + "\n"
2257
-
2258
- def _format_string_output(self, output: str, tool_name: str | None) -> str:
2259
- """Format string output with optional prefix cleaning."""
2260
- s = output.strip()
2261
- s = self._clean_sub_agent_prefix(s, tool_name)
2262
-
2263
- # If looks like JSON, pretty print it
2264
- if (s.startswith("{") and s.endswith("}")) or (s.startswith("[") and s.endswith("]")):
2265
- return self._format_json_string_output(s)
2266
-
2267
- return self.OUTPUT_PREFIX + s + "\n"
2268
-
2269
- def _format_other_output(self, output_value: Any) -> str:
2270
- """Format other types of output."""
2271
- try:
2272
- return self.OUTPUT_PREFIX + json.dumps(output_value, indent=2) + "\n"
2273
- except Exception:
2274
- return self.OUTPUT_PREFIX + str(output_value) + "\n"
2275
-
2276
- def _format_output_block(self, output_value: Any, tool_name: str | None) -> str:
2277
- """Format an output value for panel display."""
2278
- if isinstance(output_value, (dict, list)):
2279
- return self._format_dict_or_list_output(output_value)
2280
- elif isinstance(output_value, str):
2281
- return self._format_string_output(output_value, tool_name)
2282
- else:
2283
- return self._format_other_output(output_value)