glaip-sdk 0.1.0__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 (156) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1191 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +265 -45
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents.py +251 -173
  12. glaip_sdk/cli/commands/common_config.py +101 -0
  13. glaip_sdk/cli/commands/configure.py +735 -143
  14. glaip_sdk/cli/commands/mcps.py +266 -134
  15. glaip_sdk/cli/commands/models.py +13 -9
  16. glaip_sdk/cli/commands/tools.py +67 -88
  17. glaip_sdk/cli/commands/transcripts.py +755 -0
  18. glaip_sdk/cli/commands/update.py +3 -8
  19. glaip_sdk/cli/config.py +49 -7
  20. glaip_sdk/cli/constants.py +38 -0
  21. glaip_sdk/cli/context.py +8 -0
  22. glaip_sdk/cli/core/__init__.py +79 -0
  23. glaip_sdk/cli/core/context.py +124 -0
  24. glaip_sdk/cli/core/output.py +846 -0
  25. glaip_sdk/cli/core/prompting.py +649 -0
  26. glaip_sdk/cli/core/rendering.py +187 -0
  27. glaip_sdk/cli/display.py +45 -32
  28. glaip_sdk/cli/hints.py +57 -0
  29. glaip_sdk/cli/io.py +14 -17
  30. glaip_sdk/cli/main.py +232 -143
  31. glaip_sdk/cli/masking.py +21 -33
  32. glaip_sdk/cli/mcp_validators.py +5 -15
  33. glaip_sdk/cli/pager.py +12 -19
  34. glaip_sdk/cli/parsers/__init__.py +1 -3
  35. glaip_sdk/cli/parsers/json_input.py +11 -22
  36. glaip_sdk/cli/resolution.py +3 -9
  37. glaip_sdk/cli/rich_helpers.py +1 -3
  38. glaip_sdk/cli/slash/__init__.py +0 -9
  39. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  40. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  41. glaip_sdk/cli/slash/agent_session.py +65 -29
  42. glaip_sdk/cli/slash/prompt.py +24 -10
  43. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  44. glaip_sdk/cli/slash/session.py +807 -225
  45. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  46. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  47. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  48. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  49. glaip_sdk/cli/slash/tui/loading.py +58 -0
  50. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  51. glaip_sdk/cli/transcript/__init__.py +12 -52
  52. glaip_sdk/cli/transcript/cache.py +258 -60
  53. glaip_sdk/cli/transcript/capture.py +72 -21
  54. glaip_sdk/cli/transcript/history.py +815 -0
  55. glaip_sdk/cli/transcript/launcher.py +1 -3
  56. glaip_sdk/cli/transcript/viewer.py +79 -499
  57. glaip_sdk/cli/update_notifier.py +177 -24
  58. glaip_sdk/cli/utils.py +242 -1308
  59. glaip_sdk/cli/validators.py +16 -18
  60. glaip_sdk/client/__init__.py +2 -1
  61. glaip_sdk/client/_agent_payloads.py +53 -37
  62. glaip_sdk/client/agent_runs.py +147 -0
  63. glaip_sdk/client/agents.py +320 -92
  64. glaip_sdk/client/base.py +78 -35
  65. glaip_sdk/client/main.py +19 -10
  66. glaip_sdk/client/mcps.py +123 -15
  67. glaip_sdk/client/run_rendering.py +136 -101
  68. glaip_sdk/client/shared.py +21 -0
  69. glaip_sdk/client/tools.py +163 -34
  70. glaip_sdk/client/validators.py +20 -48
  71. glaip_sdk/config/constants.py +11 -0
  72. glaip_sdk/exceptions.py +1 -3
  73. glaip_sdk/mcps/__init__.py +21 -0
  74. glaip_sdk/mcps/base.py +345 -0
  75. glaip_sdk/models/__init__.py +90 -0
  76. glaip_sdk/models/agent.py +47 -0
  77. glaip_sdk/models/agent_runs.py +116 -0
  78. glaip_sdk/models/common.py +42 -0
  79. glaip_sdk/models/mcp.py +33 -0
  80. glaip_sdk/models/tool.py +33 -0
  81. glaip_sdk/payload_schemas/__init__.py +1 -13
  82. glaip_sdk/payload_schemas/agent.py +1 -3
  83. glaip_sdk/registry/__init__.py +55 -0
  84. glaip_sdk/registry/agent.py +164 -0
  85. glaip_sdk/registry/base.py +139 -0
  86. glaip_sdk/registry/mcp.py +253 -0
  87. glaip_sdk/registry/tool.py +232 -0
  88. glaip_sdk/rich_components.py +58 -2
  89. glaip_sdk/runner/__init__.py +59 -0
  90. glaip_sdk/runner/base.py +84 -0
  91. glaip_sdk/runner/deps.py +115 -0
  92. glaip_sdk/runner/langgraph.py +706 -0
  93. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  94. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  95. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  96. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  97. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  98. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  99. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  100. glaip_sdk/tools/__init__.py +22 -0
  101. glaip_sdk/tools/base.py +435 -0
  102. glaip_sdk/utils/__init__.py +58 -12
  103. glaip_sdk/utils/a2a/__init__.py +34 -0
  104. glaip_sdk/utils/a2a/event_processor.py +188 -0
  105. glaip_sdk/utils/agent_config.py +4 -14
  106. glaip_sdk/utils/bundler.py +267 -0
  107. glaip_sdk/utils/client.py +111 -0
  108. glaip_sdk/utils/client_utils.py +46 -28
  109. glaip_sdk/utils/datetime_helpers.py +58 -0
  110. glaip_sdk/utils/discovery.py +78 -0
  111. glaip_sdk/utils/display.py +25 -21
  112. glaip_sdk/utils/export.py +143 -0
  113. glaip_sdk/utils/general.py +1 -36
  114. glaip_sdk/utils/import_export.py +15 -16
  115. glaip_sdk/utils/import_resolver.py +492 -0
  116. glaip_sdk/utils/instructions.py +101 -0
  117. glaip_sdk/utils/rendering/__init__.py +115 -1
  118. glaip_sdk/utils/rendering/formatting.py +7 -35
  119. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  120. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  121. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  122. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  123. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  124. glaip_sdk/utils/rendering/models.py +3 -6
  125. glaip_sdk/utils/rendering/renderer/__init__.py +9 -49
  126. glaip_sdk/utils/rendering/renderer/base.py +258 -1577
  127. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  128. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  129. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  130. glaip_sdk/utils/rendering/renderer/stream.py +10 -51
  131. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  132. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  133. glaip_sdk/utils/rendering/renderer/toggle.py +1 -3
  134. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  135. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  136. glaip_sdk/utils/rendering/state.py +204 -0
  137. glaip_sdk/utils/rendering/step_tree_state.py +1 -3
  138. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  139. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +76 -517
  140. glaip_sdk/utils/rendering/steps/format.py +176 -0
  141. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  142. glaip_sdk/utils/rendering/timing.py +36 -0
  143. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  144. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  145. glaip_sdk/utils/resource_refs.py +29 -26
  146. glaip_sdk/utils/runtime_config.py +425 -0
  147. glaip_sdk/utils/serialization.py +32 -46
  148. glaip_sdk/utils/sync.py +142 -0
  149. glaip_sdk/utils/tool_detection.py +33 -0
  150. glaip_sdk/utils/validation.py +20 -28
  151. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/METADATA +42 -4
  152. glaip_sdk-0.6.10.dist-info/RECORD +159 -0
  153. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/WHEEL +1 -1
  154. glaip_sdk/models.py +0 -259
  155. glaip_sdk-0.1.0.dist-info/RECORD +0 -82
  156. {glaip_sdk-0.1.0.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,154 +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.steps import 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
65
+
66
+ _NO_STEPS_TEXT = Text("No steps yet", style="dim")
54
67
 
55
68
  # Configure logger
56
69
  logger = logging.getLogger("glaip_sdk.run_renderer")
57
70
 
58
71
  # Constants
59
- LESS_THAN_1MS = "[<1ms]"
60
- FINISHED_STATUS_HINTS = {
61
- "finished",
62
- "success",
63
- "succeeded",
64
- "completed",
65
- "failed",
66
- "stopped",
67
- "error",
68
- }
69
72
  RUNNING_STATUS_HINTS = {"running", "started", "pending", "working"}
70
73
  ARGS_VALUE_MAX_LEN = 160
71
- STATUS_ICON_STYLES = {
72
- "success": "green",
73
- "failed": "red",
74
- "warning": "yellow",
75
- }
76
-
77
-
78
- def _coerce_received_at(value: Any) -> datetime | None:
79
- """Coerce a received_at value to an aware datetime if possible."""
80
- if value is None:
81
- return None
82
-
83
- if isinstance(value, datetime):
84
- return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
85
-
86
- if isinstance(value, str):
87
- try:
88
- normalised = value.replace("Z", "+00:00")
89
- dt = datetime.fromisoformat(normalised)
90
- except ValueError:
91
- return None
92
- return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
93
-
94
- return None
95
-
96
-
97
- def _truncate_display(text: str | None, limit: int = 160) -> str:
98
- """Return text capped at the given character limit with ellipsis."""
99
- if not text:
100
- return ""
101
- stripped = str(text).strip()
102
- if len(stripped) <= limit:
103
- return stripped
104
- return stripped[: limit - 1] + "…"
105
-
106
-
107
- @dataclass
108
- class RendererState:
109
- """Internal state for the renderer."""
110
-
111
- buffer: list[str] | None = None
112
- final_text: str = ""
113
- streaming_started_at: float | None = None
114
- printed_final_output: bool = False
115
- finalizing_ui: bool = False
116
- final_duration_seconds: float | None = None
117
- final_duration_text: str | None = None
118
- events: list[dict[str, Any]] = field(default_factory=list)
119
- meta: dict[str, Any] = field(default_factory=dict)
120
- streaming_started_event_ts: datetime | None = None
121
-
122
- def __post_init__(self) -> None:
123
- """Initialize renderer state after dataclass creation.
124
-
125
- Ensures buffer is initialized as an empty list if not provided.
126
- """
127
- if self.buffer is None:
128
- self.buffer = []
129
-
130
-
131
- @dataclass
132
- class ThinkingScopeState:
133
- """Runtime bookkeeping for deterministic thinking spans."""
134
74
 
135
- anchor_id: str
136
- task_id: str | None
137
- context_id: str | None
138
- anchor_started_at: float | None = None
139
- anchor_finished_at: float | None = None
140
- idle_started_at: float | None = None
141
- idle_started_monotonic: float | None = None
142
- active_thinking_id: str | None = None
143
- running_children: set[str] = field(default_factory=set)
144
- closed: bool = False
145
75
 
146
-
147
- class TrailingSpinnerLine:
148
- """Render a text line with a trailing animated Rich spinner."""
149
-
150
- def __init__(self, base_text: Text, spinner: Spinner) -> None:
151
- """Initialize spinner line with base text and spinner component."""
152
- self._base_text = base_text
153
- self._spinner = spinner
154
-
155
- def __rich_console__(self, console: RichConsole, options: Any) -> Any:
156
- """Render the text with trailing animated spinner."""
157
- spinner_render = self._spinner.render(console.get_time())
158
- combined = Text.assemble(self._base_text.copy(), " ", spinner_render)
159
- yield combined
160
-
161
- def __rich_measure__(self, console: RichConsole, options: Any) -> Measurement:
162
- """Measure the combined text and spinner dimensions."""
163
- snapshot = self._spinner.render(0)
164
- combined = Text.assemble(self._base_text.copy(), " ", snapshot)
165
- return Measurement.get(console, options, combined)
166
-
167
-
168
- class RichStreamRenderer:
76
+ class RichStreamRenderer(TranscriptModeMixin):
169
77
  """Live, modern terminal renderer for agent execution with rich visual output."""
170
78
 
171
79
  def __init__(
@@ -174,6 +82,8 @@ class RichStreamRenderer:
174
82
  *,
175
83
  cfg: RendererConfig | None = None,
176
84
  verbose: bool = False,
85
+ transcript_buffer: TranscriptBuffer | None = None,
86
+ callbacks: dict[str, Any] | None = None,
177
87
  ) -> None:
178
88
  """Initialize the renderer.
179
89
 
@@ -181,7 +91,10 @@ class RichStreamRenderer:
181
91
  console: Rich console instance
182
92
  cfg: Renderer configuration
183
93
  verbose: Whether to enable verbose mode
94
+ transcript_buffer: Optional transcript buffer for capturing output
95
+ callbacks: Optional dictionary of callback functions
184
96
  """
97
+ super().__init__()
185
98
  self.console = console or RichConsole()
186
99
  self.cfg = cfg or RendererConfig()
187
100
  self.verbose = verbose
@@ -189,16 +102,32 @@ class RichStreamRenderer:
189
102
  # Initialize components
190
103
  self.stream_processor = StreamProcessor()
191
104
  self.state = RendererState()
105
+ if transcript_buffer is not None:
106
+ self.state.buffer = transcript_buffer
107
+
108
+ self._callbacks = callbacks or {}
192
109
 
193
110
  # Initialize step manager and other state
194
111
  self.steps = StepManager(max_steps=self.cfg.summary_max_steps)
195
112
  # Live display instance (single source of truth)
196
113
  self.live: Live | None = None
197
114
  self._step_spinners: dict[str, Spinner] = {}
115
+ self._last_steps_panel_template: Any | None = None
198
116
 
199
117
  # Tool tracking and thinking scopes
200
- self.tool_panels: dict[str, dict[str, Any]] = {}
201
- 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
+ )
202
131
  self._root_agent_friendly: str | None = None
203
132
  self._root_agent_step_id: str | None = None
204
133
  self._root_query: str | None = None
@@ -210,25 +139,11 @@ class RichStreamRenderer:
210
139
  # Header/text
211
140
  self.header_text: str = ""
212
141
  # Track per-step server start times for accurate elapsed labels
213
- self._step_server_start_times: dict[str, float] = {}
214
-
215
142
  # Output formatting constants
216
143
  self.OUTPUT_PREFIX: str = "**Output:**\n"
217
144
 
218
- # Transcript toggling
219
- self._transcript_mode_enabled: bool = False
220
- self._transcript_render_cursor: int = 0
221
- self.transcript_controller: Any | None = None
222
- self._transcript_hint_message = (
223
- "[dim]Transcript view · Press Ctrl+T to return to the summary.[/dim]"
224
- )
225
- self._summary_hint_message = (
226
- "[dim]Press Ctrl+T to inspect raw transcript events.[/dim]"
227
- )
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."""
@@ -245,10 +160,8 @@ class RichStreamRenderer:
245
160
 
246
161
  meta_payload = meta or {}
247
162
  self.steps.set_root_agent(meta_payload.get("agent_id"))
248
- self._root_agent_friendly = self._humanize_agent_slug(
249
- meta_payload.get("agent_name")
250
- )
251
- self._root_query = _truncate_display(
163
+ self._root_agent_friendly = self._humanize_agent_slug(meta_payload.get("agent_name"))
164
+ self._root_query = truncate_display(
252
165
  meta_payload.get("input_message")
253
166
  or meta_payload.get("query")
254
167
  or meta_payload.get("message")
@@ -308,20 +221,6 @@ class RichStreamRenderer:
308
221
  except Exception:
309
222
  logger.exception("Failed to print header fallback")
310
223
 
311
- def _extract_query_from_meta(self, meta: dict[str, Any] | None) -> str | None:
312
- """Extract the primary query string from a metadata payload."""
313
- if not meta:
314
- return None
315
- query = (
316
- meta.get("input_message")
317
- or meta.get("query")
318
- or meta.get("message")
319
- or (meta.get("meta") or {}).get("input_message")
320
- )
321
- if isinstance(query, str) and query.strip():
322
- return query
323
- return None
324
-
325
224
  def _build_user_query_panel(self, query: str) -> AIPPanel:
326
225
  """Create the panel used to display the user request."""
327
226
  return AIPPanel(
@@ -333,7 +232,7 @@ class RichStreamRenderer:
333
232
 
334
233
  def _render_user_query(self, meta: dict[str, Any]) -> None:
335
234
  """Render the user query panel."""
336
- query = self._extract_query_from_meta(meta)
235
+ query = extract_query_from_meta(meta)
337
236
  if not query:
338
237
  return
339
238
  self.console.print(self._build_user_query_panel(query))
@@ -346,13 +245,42 @@ class RichStreamRenderer:
346
245
  elif self.header_text and not self._render_header_rule():
347
246
  self._render_header_fallback()
348
247
 
349
- query = self._extract_query_from_meta(meta) or self._root_query
248
+ query = extract_query_from_meta(meta) or self._root_query
350
249
  if query:
351
250
  self.console.print(self._build_user_query_panel(query))
352
251
 
252
+ def _render_summary_after_transcript_toggle(self) -> None:
253
+ """Render the summary panel after leaving transcript mode."""
254
+ if self.state.finalizing_ui:
255
+ self._render_final_summary_panels()
256
+ elif self.live:
257
+ self._refresh_live_panels()
258
+ else:
259
+ self._render_static_summary_panels()
260
+
261
+ def _render_final_summary_panels(self) -> None:
262
+ """Render a static summary and disable live mode for final output."""
263
+ self.cfg.live = False
264
+ self.live = None
265
+ self._render_static_summary_panels()
266
+
267
+ def _render_static_summary_panels(self) -> None:
268
+ """Render the steps and main panels in a static (non-live) layout."""
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)
280
+
353
281
  def _ensure_streaming_started_baseline(self, timestamp: float) -> None:
354
282
  """Synchronize streaming start state across renderer components."""
355
- self.state.streaming_started_at = timestamp
283
+ self.state.start_stream_timer(timestamp)
356
284
  self.stream_processor.streaming_started_at = timestamp
357
285
  self._started_at = timestamp
358
286
 
@@ -374,7 +302,7 @@ class RichStreamRenderer:
374
302
 
375
303
  def _resolve_received_timestamp(self, ev: dict[str, Any]) -> datetime:
376
304
  """Return the timestamp an event was received, normalising inputs."""
377
- received_at = _coerce_received_at(ev.get("received_at"))
305
+ received_at = coerce_received_at(ev.get("received_at"))
378
306
  if received_at is None:
379
307
  received_at = datetime.now(timezone.utc)
380
308
 
@@ -383,9 +311,7 @@ class RichStreamRenderer:
383
311
 
384
312
  return received_at
385
313
 
386
- def _sync_stream_start(
387
- self, ev: dict[str, Any], received_at: datetime | None
388
- ) -> None:
314
+ def _sync_stream_start(self, ev: dict[str, Any], received_at: datetime | None) -> None:
389
315
  """Ensure renderer and stream processor share a streaming baseline."""
390
316
  baseline = self.state.streaming_started_at
391
317
  if baseline is None:
@@ -439,50 +365,37 @@ class RichStreamRenderer:
439
365
  def _handle_content_event(self, content: str) -> None:
440
366
  """Handle content streaming events."""
441
367
  if content:
442
- self.state.buffer.append(content)
368
+ self.state.append_transcript_text(content)
443
369
  self._ensure_live()
444
370
 
445
- def _handle_final_response_event(
446
- self, content: str, metadata: dict[str, Any]
447
- ) -> None:
371
+ def _handle_final_response_event(self, content: str, metadata: dict[str, Any]) -> None:
448
372
  """Handle final response events."""
449
373
  if content:
450
- self.state.buffer.append(content)
451
- self.state.final_text = content
374
+ self.state.append_transcript_text(content)
375
+ self.state.set_final_output(content)
452
376
 
453
377
  meta_payload = metadata.get("metadata") or {}
454
- final_time = self._coerce_server_time(meta_payload.get("time"))
378
+ final_time = coerce_server_time(meta_payload.get("time"))
455
379
  self._update_final_duration(final_time)
456
- self._close_active_thinking_scopes(final_time)
380
+ self.thinking_controller.close_active_scopes(final_time)
457
381
  self._finish_running_steps()
458
- self._finish_tool_panels()
382
+ self.tool_controller.finish_all_panels()
459
383
  self._normalise_finished_icons()
460
384
 
461
385
  self._ensure_live()
462
386
  self._print_final_panel_if_needed()
463
387
 
464
388
  def _normalise_finished_icons(self) -> None:
465
- """Ensure finished steps do not keep spinner icons."""
389
+ """Ensure finished steps release any running spinners."""
466
390
  for step in self.steps.by_id.values():
467
- if (
468
- getattr(step, "status", None) == "finished"
469
- and getattr(step, "status_icon", None) == "spinner"
470
- ):
471
- step.status_icon = "success"
472
391
  if getattr(step, "status", None) != "running":
473
392
  self._step_spinners.pop(step.step_id, None)
474
393
 
475
- def _handle_agent_step_event(
476
- self, ev: dict[str, Any], metadata: dict[str, Any]
477
- ) -> None:
394
+ def _handle_agent_step_event(self, ev: dict[str, Any], metadata: dict[str, Any]) -> None:
478
395
  """Handle agent step events."""
479
- # Extract tool information
480
- (
481
- tool_name,
482
- tool_args,
483
- tool_out,
484
- tool_calls_info,
485
- ) = 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
486
399
 
487
400
  payload = metadata.get("metadata") or {}
488
401
 
@@ -493,17 +406,19 @@ class RichStreamRenderer:
493
406
  logger.debug("Malformed step event skipped", exc_info=True)
494
407
  else:
495
408
  self._record_step_server_start(tracked_step, payload)
496
- 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
+ )
497
414
  self._maybe_override_root_agent_label(tracked_step, payload)
498
415
  self._maybe_attach_root_query(tracked_step)
499
416
 
500
417
  # Track tools and sub-agents for transcript/debug context
501
- self.stream_processor.track_tools_and_agents(
502
- tool_name, tool_calls_info, is_delegation_tool
503
- )
418
+ self.stream_processor.track_tools_and_agents(tool_name, tool_calls_info, is_delegation_tool)
504
419
 
505
420
  # Handle tool execution
506
- self._handle_agent_step(
421
+ self.tool_controller.handle_agent_step(
507
422
  ev,
508
423
  tool_name,
509
424
  tool_args,
@@ -517,13 +432,7 @@ class RichStreamRenderer:
517
432
 
518
433
  def _maybe_attach_root_query(self, step: Step | None) -> None:
519
434
  """Attach the user query to the root agent step for display."""
520
- if (
521
- not step
522
- or self._root_query_attached
523
- or not self._root_query
524
- or step.kind != "agent"
525
- or step.parent_id
526
- ):
435
+ if not step or self._root_query_attached or not self._root_query or step.kind != "agent" or step.parent_id:
527
436
  return
528
437
 
529
438
  args = dict(getattr(step, "args", {}) or {})
@@ -531,9 +440,7 @@ class RichStreamRenderer:
531
440
  step.args = args
532
441
  self._root_query_attached = True
533
442
 
534
- def _record_step_server_start(
535
- self, step: Step | None, payload: dict[str, Any]
536
- ) -> None:
443
+ def _record_step_server_start(self, step: Step | None, payload: dict[str, Any]) -> None:
537
444
  """Store server-provided start times for elapsed calculations."""
538
445
  if not step:
539
446
  return
@@ -542,276 +449,21 @@ class RichStreamRenderer:
542
449
  return
543
450
  self._step_server_start_times.setdefault(step.step_id, float(server_time))
544
451
 
545
- def _maybe_override_root_agent_label(
546
- self, step: Step | None, payload: dict[str, Any]
547
- ) -> None:
452
+ def _maybe_override_root_agent_label(self, step: Step | None, payload: dict[str, Any]) -> None:
548
453
  """Ensure the root agent row uses the human-friendly name and shows the ID."""
549
454
  if not step or step.kind != "agent" or step.parent_id:
550
455
  return
551
- friendly = self._root_agent_friendly or self._humanize_agent_slug(
552
- (payload or {}).get("agent_name")
553
- )
456
+ friendly = self._root_agent_friendly or self._humanize_agent_slug((payload or {}).get("agent_name"))
554
457
  if not friendly:
555
458
  return
556
459
  agent_identifier = step.name or step.step_id
557
460
  if not agent_identifier:
558
461
  return
559
- step.display_label = normalise_display_label(
560
- f"{ICON_AGENT} {friendly} ({agent_identifier})"
561
- )
462
+ step.display_label = normalise_display_label(f"{ICON_AGENT} {friendly} ({agent_identifier})")
562
463
  if not self._root_agent_step_id:
563
464
  self._root_agent_step_id = step.step_id
564
465
 
565
- def _update_thinking_timeline(
566
- self, step: Step | None, payload: dict[str, Any]
567
- ) -> None:
568
- """Maintain deterministic thinking spans for each agent/delegate scope."""
569
- if not self.cfg.render_thinking or not step:
570
- return
571
-
572
- now_monotonic = monotonic()
573
- server_time = self._coerce_server_time(payload.get("time"))
574
- status_hint = (payload.get("status") or "").lower()
575
-
576
- if self._is_scope_anchor(step):
577
- self._update_anchor_thinking(
578
- step=step,
579
- server_time=server_time,
580
- status_hint=status_hint,
581
- now_monotonic=now_monotonic,
582
- )
583
- return
584
-
585
- self._update_child_thinking(
586
- step=step,
587
- server_time=server_time,
588
- status_hint=status_hint,
589
- now_monotonic=now_monotonic,
590
- )
591
-
592
- def _update_anchor_thinking(
593
- self,
594
- *,
595
- step: Step,
596
- server_time: float | None,
597
- status_hint: str,
598
- now_monotonic: float,
599
- ) -> None:
600
- """Handle deterministic thinking bookkeeping for agent/delegate anchors."""
601
- scope = self._get_or_create_scope(step)
602
- if scope.anchor_started_at is None and server_time is not None:
603
- scope.anchor_started_at = server_time
604
-
605
- if not scope.closed and scope.active_thinking_id is None:
606
- self._start_scope_thinking(
607
- scope,
608
- start_server_time=scope.anchor_started_at or server_time,
609
- start_monotonic=now_monotonic,
610
- )
611
-
612
- is_anchor_finished = status_hint in FINISHED_STATUS_HINTS or (
613
- not status_hint and is_step_finished(step)
614
- )
615
- if is_anchor_finished:
616
- scope.anchor_finished_at = server_time or scope.anchor_finished_at
617
- self._finish_scope_thinking(scope, server_time, now_monotonic)
618
- scope.closed = True
619
-
620
- parent_anchor_id = self._resolve_anchor_id(step)
621
- if parent_anchor_id:
622
- self._cascade_anchor_update(
623
- parent_anchor_id=parent_anchor_id,
624
- child_step=step,
625
- server_time=server_time,
626
- now_monotonic=now_monotonic,
627
- is_finished=is_anchor_finished,
628
- )
629
-
630
- def _cascade_anchor_update(
631
- self,
632
- *,
633
- parent_anchor_id: str,
634
- child_step: Step,
635
- server_time: float | None,
636
- now_monotonic: float,
637
- is_finished: bool,
638
- ) -> None:
639
- """Propagate anchor state changes to the parent scope."""
640
- parent_scope = self._thinking_scopes.get(parent_anchor_id)
641
- if not parent_scope or parent_scope.closed:
642
- return
643
- if is_finished:
644
- self._mark_child_finished(
645
- parent_scope, child_step.step_id, server_time, now_monotonic
646
- )
647
- else:
648
- self._mark_child_running(
649
- parent_scope, child_step, server_time, now_monotonic
650
- )
651
-
652
- def _update_child_thinking(
653
- self,
654
- *,
655
- step: Step,
656
- server_time: float | None,
657
- status_hint: str,
658
- now_monotonic: float,
659
- ) -> None:
660
- """Update deterministic thinking state for non-anchor steps."""
661
- anchor_id = self._resolve_anchor_id(step)
662
- if not anchor_id:
663
- return
664
-
665
- scope = self._thinking_scopes.get(anchor_id)
666
- if not scope or scope.closed or step.kind == "thinking":
667
- return
668
-
669
- is_finish_event = status_hint in FINISHED_STATUS_HINTS or (
670
- not status_hint and is_step_finished(step)
671
- )
672
- if is_finish_event:
673
- self._mark_child_finished(scope, step.step_id, server_time, now_monotonic)
674
- else:
675
- self._mark_child_running(scope, step, server_time, now_monotonic)
676
-
677
- def _resolve_anchor_id(self, step: Step) -> str | None:
678
- """Return the nearest agent/delegate ancestor for a step."""
679
- parent_id = step.parent_id
680
- while parent_id:
681
- parent = self.steps.by_id.get(parent_id)
682
- if not parent:
683
- return None
684
- if self._is_scope_anchor(parent):
685
- return parent.step_id
686
- parent_id = parent.parent_id
687
- return None
688
-
689
- def _get_or_create_scope(self, step: Step) -> ThinkingScopeState:
690
- """Fetch (or create) thinking state for the given anchor step."""
691
- scope = self._thinking_scopes.get(step.step_id)
692
- if scope:
693
- if scope.task_id is None:
694
- scope.task_id = step.task_id
695
- if scope.context_id is None:
696
- scope.context_id = step.context_id
697
- return scope
698
- scope = ThinkingScopeState(
699
- anchor_id=step.step_id,
700
- task_id=step.task_id,
701
- context_id=step.context_id,
702
- )
703
- self._thinking_scopes[step.step_id] = scope
704
- return scope
705
-
706
- def _is_scope_anchor(self, step: Step) -> bool:
707
- """Return True when a step should host its own thinking timeline."""
708
- if step.kind in {"agent", "delegate"}:
709
- return True
710
- name = (step.name or "").lower()
711
- return name.startswith(("delegate_to_", "delegate_", "delegate "))
712
-
713
- def _start_scope_thinking(
714
- self,
715
- scope: ThinkingScopeState,
716
- *,
717
- start_server_time: float | None,
718
- start_monotonic: float,
719
- ) -> None:
720
- """Open a deterministic thinking node beneath the scope anchor."""
721
- if scope.closed or scope.active_thinking_id or not scope.anchor_id:
722
- return
723
- step = self.steps.start_or_get(
724
- task_id=scope.task_id,
725
- context_id=scope.context_id,
726
- kind="thinking",
727
- name=f"agent_thinking_step::{scope.anchor_id}",
728
- parent_id=scope.anchor_id,
729
- args={"reason": "deterministic_timeline"},
730
- )
731
- step.display_label = "💭 Thinking…"
732
- step.status_icon = "spinner"
733
- scope.active_thinking_id = step.step_id
734
- scope.idle_started_at = start_server_time
735
- scope.idle_started_monotonic = start_monotonic
736
-
737
- def _finish_scope_thinking(
738
- self,
739
- scope: ThinkingScopeState,
740
- end_server_time: float | None,
741
- end_monotonic: float,
742
- ) -> None:
743
- """Close the currently running thinking node if one exists."""
744
- if not scope.active_thinking_id:
745
- return
746
- thinking_step = self.steps.by_id.get(scope.active_thinking_id)
747
- if not thinking_step:
748
- scope.active_thinking_id = None
749
- scope.idle_started_at = None
750
- scope.idle_started_monotonic = None
751
- return
752
-
753
- duration = self._calculate_timeline_duration(
754
- scope.idle_started_at,
755
- end_server_time,
756
- scope.idle_started_monotonic,
757
- end_monotonic,
758
- )
759
- thinking_step.display_label = thinking_step.display_label or "💭 Thinking…"
760
- if duration is not None:
761
- thinking_step.finish(duration, source="timeline")
762
- else:
763
- thinking_step.finish(None, source="timeline")
764
- thinking_step.status_icon = "success"
765
- scope.active_thinking_id = None
766
- scope.idle_started_at = None
767
- scope.idle_started_monotonic = None
768
-
769
- def _mark_child_running(
770
- self,
771
- scope: ThinkingScopeState,
772
- step: Step,
773
- server_time: float | None,
774
- now_monotonic: float,
775
- ) -> None:
776
- """Mark a direct child as running and close any open thinking node."""
777
- if step.step_id in scope.running_children:
778
- return
779
- scope.running_children.add(step.step_id)
780
- if not scope.active_thinking_id:
781
- return
782
-
783
- start_server = self._step_server_start_times.get(step.step_id)
784
- if start_server is None:
785
- start_server = server_time
786
- self._finish_scope_thinking(scope, start_server, now_monotonic)
787
-
788
- def _mark_child_finished(
789
- self,
790
- scope: ThinkingScopeState,
791
- step_id: str,
792
- server_time: float | None,
793
- now_monotonic: float,
794
- ) -> None:
795
- """Handle completion for a scope child and resume thinking if idle."""
796
- if step_id in scope.running_children:
797
- scope.running_children.discard(step_id)
798
- if scope.running_children or scope.closed:
799
- return
800
- self._start_scope_thinking(
801
- scope,
802
- start_server_time=server_time,
803
- start_monotonic=now_monotonic,
804
- )
805
-
806
- def _close_active_thinking_scopes(self, server_time: float | None) -> None:
807
- """Finish any in-flight thinking nodes during finalization."""
808
- now = monotonic()
809
- for scope in self._thinking_scopes.values():
810
- if not scope.active_thinking_id:
811
- continue
812
- self._finish_scope_thinking(scope, server_time, now)
813
- scope.closed = True
814
- # Parent scopes resume thinking via _cascade_anchor_update
466
+ # Thinking scope management is handled by ThinkingScopeController.
815
467
 
816
468
  def _apply_root_duration(self, duration_seconds: float | None) -> None:
817
469
  """Propagate the final run duration to the root agent step."""
@@ -828,33 +480,6 @@ class RichStreamRenderer:
828
480
  root_step.duration_source = root_step.duration_source or "run"
829
481
  root_step.status = "finished"
830
482
 
831
- @staticmethod
832
- def _coerce_server_time(value: Any) -> float | None:
833
- """Convert a raw SSE time payload into a float if possible."""
834
- if isinstance(value, (int, float)):
835
- return float(value)
836
- try:
837
- return float(value)
838
- except (TypeError, ValueError):
839
- return None
840
-
841
- @staticmethod
842
- def _calculate_timeline_duration(
843
- start_server: float | None,
844
- end_server: float | None,
845
- start_monotonic: float | None,
846
- end_monotonic: float,
847
- ) -> float | None:
848
- """Pick the most reliable pair of timestamps to derive duration seconds."""
849
- if start_server is not None and end_server is not None:
850
- return max(0.0, float(end_server) - float(start_server))
851
- if start_monotonic is not None:
852
- try:
853
- return max(0.0, float(end_monotonic) - float(start_monotonic))
854
- except Exception:
855
- return None
856
- return None
857
-
858
483
  @staticmethod
859
484
  def _humanize_agent_slug(value: Any) -> str | None:
860
485
  """Convert a slugified agent name into Title Case."""
@@ -879,19 +504,6 @@ class RichStreamRenderer:
879
504
  if step.duration_ms is None:
880
505
  step.duration_ms = 0
881
506
  step.duration_source = step.duration_source or "unknown"
882
- step.status_icon = "warning"
883
-
884
- def _finish_tool_panels(self) -> None:
885
- """Mark unfinished tool panels as finished."""
886
- try:
887
- items = list(self.tool_panels.items())
888
- except Exception: # pragma: no cover - defensive guard
889
- logger.exception("Failed to iterate tool panels during cleanup")
890
- return
891
-
892
- for _sid, meta in items:
893
- if meta.get("status") != "finished":
894
- meta["status"] = "finished"
895
507
 
896
508
  def _stop_live_display(self) -> None:
897
509
  """Stop live display and clean up."""
@@ -902,7 +514,7 @@ class RichStreamRenderer:
902
514
  if self.state.printed_final_output:
903
515
  return
904
516
 
905
- 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()
906
518
  if not body:
907
519
  return
908
520
 
@@ -910,20 +522,52 @@ class RichStreamRenderer:
910
522
  return
911
523
 
912
524
  if self.verbose:
913
- final_panel = create_final_panel(
914
- body,
525
+ panel = build_final_panel(
526
+ self.state,
915
527
  title=self._final_panel_title(),
916
- theme=self.cfg.theme,
917
528
  )
918
- self.console.print(final_panel)
529
+ if panel is None:
530
+ return
531
+ self.console.print(panel)
919
532
  self.state.printed_final_output = True
920
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
+
921
565
  def on_complete(self, stats: RunStats) -> None:
922
566
  """Handle completion event."""
923
567
  self.state.finalizing_ui = True
924
568
 
925
569
  self._handle_stats_duration(stats)
926
- self._close_active_thinking_scopes(self.state.final_duration_seconds)
570
+ self.thinking_controller.close_active_scopes(self.state.final_duration_seconds)
927
571
  self._cleanup_ui_elements()
928
572
  self._finalize_display()
929
573
  self._print_completion_message()
@@ -949,25 +593,31 @@ class RichStreamRenderer:
949
593
  self._finish_running_steps()
950
594
 
951
595
  # Mark unfinished tool panels as finished
952
- self._finish_tool_panels()
596
+ self.tool_controller.finish_all_panels()
953
597
 
954
598
  def _finalize_display(self) -> None:
955
599
  """Finalize live display and render final output."""
956
600
  # Final refresh
957
601
  self._ensure_live()
958
602
 
603
+ header, body = self.finalize()
604
+
959
605
  # Stop live display
960
606
  self._stop_live_display()
961
607
 
962
608
  # Render final output based on configuration
963
- 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)
964
613
 
965
614
  def _print_completion_message(self) -> None:
966
615
  """Print completion message based on current mode."""
967
616
  if self._transcript_mode_enabled:
968
617
  try:
969
618
  self.console.print(
970
- "[dim]Run finished. Press Ctrl+T to return to the summary view or stay here to inspect events. Use the post-run viewer for export.[/dim]"
619
+ "[dim]Run finished. Press Ctrl+T to return to the summary view or stay here to inspect events. "
620
+ "Use the post-run viewer for export.[/dim]"
971
621
  )
972
622
  except Exception:
973
623
  pass
@@ -1031,13 +681,18 @@ class RichStreamRenderer:
1031
681
  if not self.live:
1032
682
  return
1033
683
 
1034
- main_panel = self._render_main_panel()
1035
- 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()
1036
688
  steps_panel = AIPPanel(
1037
- steps_renderable,
1038
- title="Steps",
1039
- 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)),
1040
693
  )
694
+
695
+ main_panel = self._render_main_panel()
1041
696
  panels = self._build_live_panels(main_panel, steps_panel)
1042
697
 
1043
698
  self.live.update(Group(*panels))
@@ -1055,26 +710,19 @@ class RichStreamRenderer:
1055
710
 
1056
711
  def _render_main_panel(self) -> Any:
1057
712
  """Render the main content panel."""
1058
- body = "".join(self.state.buffer).strip()
713
+ body = self.state.buffer.render().strip()
714
+ theme = DEFAULT_TRANSCRIPT_THEME
1059
715
  if not self.verbose:
1060
- final_content = (self.state.final_text or "").strip()
1061
- if final_content:
1062
- title = self._final_panel_title()
1063
- return create_final_panel(
1064
- final_content,
1065
- title=title,
1066
- theme=self.cfg.theme,
1067
- )
716
+ panel = build_final_panel(self.state, theme=theme)
717
+ if panel is not None:
718
+ return panel
1068
719
  # Dynamic title with spinner + elapsed/hints
1069
720
  title = self._format_enhanced_main_title()
1070
- return create_main_panel(body, title, self.cfg.theme)
721
+ return create_main_panel(body, title, theme)
1071
722
 
1072
723
  def _final_panel_title(self) -> str:
1073
724
  """Compose title for the final result panel including duration."""
1074
- title = "Final Result"
1075
- if self.state.final_duration_text:
1076
- title = f"{title} · {self.state.final_duration_text}"
1077
- return title
725
+ return format_final_panel_title(self.state)
1078
726
 
1079
727
  def apply_verbosity(self, verbose: bool) -> None:
1080
728
  """Update verbose behaviour at runtime."""
@@ -1082,8 +730,6 @@ class RichStreamRenderer:
1082
730
  return
1083
731
 
1084
732
  self.verbose = verbose
1085
- self.cfg.style = "debug" if verbose else "pretty"
1086
-
1087
733
  desired_live = not verbose
1088
734
  if desired_live != self.cfg.live:
1089
735
  self.cfg.live = desired_live
@@ -1095,604 +741,16 @@ class RichStreamRenderer:
1095
741
  if self.cfg.live:
1096
742
  self._ensure_live()
1097
743
 
1098
- # ------------------------------------------------------------------
1099
- # Transcript helpers
1100
- # ------------------------------------------------------------------
1101
- @property
1102
- def transcript_mode_enabled(self) -> bool:
1103
- """Return True when transcript mode is currently active."""
1104
- return self._transcript_mode_enabled
1105
-
1106
- def toggle_transcript_mode(self) -> None:
1107
- """Flip transcript mode on/off."""
1108
- self.set_transcript_mode(not self._transcript_mode_enabled)
1109
-
1110
- def set_transcript_mode(self, enabled: bool) -> None:
1111
- """Set transcript mode explicitly."""
1112
- if enabled == self._transcript_mode_enabled:
1113
- return
1114
-
1115
- self._transcript_mode_enabled = enabled
1116
- self.apply_verbosity(enabled)
1117
-
1118
- if enabled:
1119
- self._summary_hint_printed_once = False
1120
- self._transcript_hint_printed_once = False
1121
- self._transcript_header_printed = False
1122
- self._transcript_enabled_message_printed = False
1123
- self._stop_live_display()
1124
- self._clear_console_safe()
1125
- self._print_transcript_enabled_message()
1126
- self._render_transcript_backfill()
1127
- else:
1128
- self._transcript_hint_printed_once = False
1129
- self._transcript_header_printed = False
1130
- self._transcript_enabled_message_printed = False
1131
- self._clear_console_safe()
1132
- self._render_summary_static_sections()
1133
- summary_notice = (
1134
- "[dim]Returning to the summary view. Streaming will continue here.[/dim]"
1135
- if not self.state.finalizing_ui
1136
- else "[dim]Returning to the summary view.[/dim]"
1137
- )
1138
- self.console.print(summary_notice)
1139
- if self.live:
1140
- self._refresh_live_panels()
1141
- else:
1142
- steps_renderable = self._render_steps_text()
1143
- steps_panel = AIPPanel(
1144
- steps_renderable,
1145
- title="Steps",
1146
- border_style="blue",
1147
- )
1148
- self.console.print(steps_panel)
1149
- self.console.print(self._render_main_panel())
1150
- if not self.state.finalizing_ui:
1151
- self._print_summary_hint(force=True)
1152
-
1153
- def _clear_console_safe(self) -> None:
1154
- """Best-effort console clear that ignores platform quirks."""
1155
- try:
1156
- self.console.clear()
1157
- except Exception:
1158
- pass
1159
-
1160
- def _print_transcript_hint(self) -> None:
1161
- """Render the transcript toggle hint, keeping it near the bottom."""
1162
- if not self._transcript_mode_enabled:
1163
- return
1164
- try:
1165
- self.console.print(self._transcript_hint_message)
1166
- except Exception:
1167
- pass
1168
- else:
1169
- self._transcript_hint_printed_once = True
1170
-
1171
- def _print_transcript_enabled_message(self) -> None:
1172
- if self._transcript_enabled_message_printed:
1173
- return
1174
- try:
1175
- self.console.print(
1176
- "[dim]Transcript mode enabled — streaming raw transcript events.[/dim]"
1177
- )
1178
- except Exception:
1179
- pass
1180
- else:
1181
- self._transcript_enabled_message_printed = True
1182
-
1183
- def _ensure_transcript_header(self) -> None:
1184
- if self._transcript_header_printed:
1185
- return
1186
- try:
1187
- self.console.rule("Transcript Events")
1188
- except Exception:
1189
- self._transcript_header_printed = True
1190
- return
1191
- self._transcript_header_printed = True
1192
-
1193
- def _print_summary_hint(self, force: bool = False) -> None:
1194
- """Show the summary-mode toggle hint."""
1195
- controller = getattr(self, "transcript_controller", None)
1196
- if controller and not getattr(controller, "enabled", False):
1197
- if not force:
1198
- self._summary_hint_printed_once = True
1199
- return
1200
- if not force and self._summary_hint_printed_once:
1201
- return
1202
- try:
1203
- self.console.print(self._summary_hint_message)
1204
- except Exception:
1205
- return
1206
- self._summary_hint_printed_once = True
1207
-
1208
- def _render_transcript_backfill(self) -> None:
1209
- """Render any captured events that haven't been shown in transcript mode."""
1210
- pending = self.state.events[self._transcript_render_cursor :]
1211
- self._ensure_transcript_header()
1212
- if not pending:
1213
- self._print_transcript_hint()
1214
- return
1215
-
1216
- baseline = self.state.streaming_started_event_ts
1217
- for ev in pending:
1218
- received_ts = _coerce_received_at(ev.get("received_at"))
1219
- render_debug_event(
1220
- ev,
1221
- self.console,
1222
- received_ts=received_ts,
1223
- baseline_ts=baseline,
1224
- )
1225
-
1226
- self._transcript_render_cursor = len(self.state.events)
1227
- self._print_transcript_hint()
1228
-
1229
- def _capture_event(
1230
- self, ev: dict[str, Any], received_at: datetime | None = None
1231
- ) -> None:
1232
- """Capture a deep copy of SSE events for transcript replay."""
1233
- try:
1234
- captured = json.loads(json.dumps(ev))
1235
- except Exception:
1236
- captured = ev
1237
-
1238
- if received_at is not None:
1239
- try:
1240
- captured["received_at"] = received_at.isoformat()
1241
- except Exception:
1242
- try:
1243
- captured["received_at"] = str(received_at)
1244
- except Exception:
1245
- captured["received_at"] = repr(received_at)
1246
-
1247
- self.state.events.append(captured)
1248
- if self._transcript_mode_enabled:
1249
- self._transcript_render_cursor = len(self.state.events)
744
+ # Transcript helper implementations live in TranscriptModeMixin.
1250
745
 
1251
746
  def get_aggregated_output(self) -> str:
1252
747
  """Return the concatenated assistant output collected so far."""
1253
- return ("".join(self.state.buffer or [])).strip()
748
+ return self.state.buffer.render().strip()
1254
749
 
1255
750
  def get_transcript_events(self) -> list[dict[str, Any]]:
1256
751
  """Return captured SSE events."""
1257
752
  return list(self.state.events)
1258
753
 
1259
- def _ensure_tool_panel(
1260
- self, name: str, args: Any, task_id: str, context_id: str
1261
- ) -> str:
1262
- """Ensure a tool panel exists and return its ID."""
1263
- formatted_title = format_tool_title(name)
1264
- is_delegation = is_delegation_tool(name)
1265
- tool_sid = f"tool_{name}_{task_id}_{context_id}"
1266
-
1267
- if tool_sid not in self.tool_panels:
1268
- self.tool_panels[tool_sid] = {
1269
- "title": formatted_title,
1270
- "status": "running",
1271
- "started_at": monotonic(),
1272
- "server_started_at": self.stream_processor.server_elapsed_time,
1273
- "chunks": [],
1274
- "args": args or {},
1275
- "output": None,
1276
- "is_delegation": is_delegation,
1277
- }
1278
- # Add Args section once
1279
- if args:
1280
- try:
1281
- args_content = (
1282
- "**Args:**\n```json\n"
1283
- + json.dumps(args, indent=2)
1284
- + "\n```\n\n"
1285
- )
1286
- except Exception:
1287
- args_content = f"**Args:**\n{args}\n\n"
1288
- self.tool_panels[tool_sid]["chunks"].append(args_content)
1289
-
1290
- return tool_sid
1291
-
1292
- def _start_tool_step(
1293
- self,
1294
- task_id: str,
1295
- context_id: str,
1296
- tool_name: str,
1297
- tool_args: Any,
1298
- _tool_sid: str,
1299
- *,
1300
- tracked_step: Step | None = None,
1301
- ) -> Step | None:
1302
- """Start or get a step for a tool."""
1303
- if tracked_step is not None:
1304
- return tracked_step
1305
-
1306
- if is_delegation_tool(tool_name):
1307
- st = self.steps.start_or_get(
1308
- task_id=task_id,
1309
- context_id=context_id,
1310
- kind="delegate",
1311
- name=tool_name,
1312
- args=tool_args,
1313
- )
1314
- else:
1315
- st = self.steps.start_or_get(
1316
- task_id=task_id,
1317
- context_id=context_id,
1318
- kind="tool",
1319
- name=tool_name,
1320
- args=tool_args,
1321
- )
1322
-
1323
- # Record server start time for this step if available
1324
- if st and self.stream_processor.server_elapsed_time is not None:
1325
- self._step_server_start_times[st.step_id] = (
1326
- self.stream_processor.server_elapsed_time
1327
- )
1328
-
1329
- return st
1330
-
1331
- def _process_additional_tool_calls(
1332
- self,
1333
- tool_calls_info: list[tuple[str, Any, Any]],
1334
- tool_name: str,
1335
- task_id: str,
1336
- context_id: str,
1337
- ) -> None:
1338
- """Process additional tool calls to avoid duplicates."""
1339
- for call_name, call_args, _ in tool_calls_info or []:
1340
- if call_name and call_name != tool_name:
1341
- self._process_single_tool_call(
1342
- call_name, call_args, task_id, context_id
1343
- )
1344
-
1345
- def _process_single_tool_call(
1346
- self, call_name: str, call_args: Any, task_id: str, context_id: str
1347
- ) -> None:
1348
- """Process a single additional tool call."""
1349
- self._ensure_tool_panel(call_name, call_args, task_id, context_id)
1350
-
1351
- st2 = self._create_step_for_tool_call(call_name, call_args, task_id, context_id)
1352
-
1353
- if self.stream_processor.server_elapsed_time is not None and st2:
1354
- self._step_server_start_times[st2.step_id] = (
1355
- self.stream_processor.server_elapsed_time
1356
- )
1357
-
1358
- def _create_step_for_tool_call(
1359
- self, call_name: str, call_args: Any, task_id: str, context_id: str
1360
- ) -> Any:
1361
- """Create appropriate step for tool call."""
1362
- if is_delegation_tool(call_name):
1363
- return self.steps.start_or_get(
1364
- task_id=task_id,
1365
- context_id=context_id,
1366
- kind="delegate",
1367
- name=call_name,
1368
- args=call_args,
1369
- )
1370
- else:
1371
- return self.steps.start_or_get(
1372
- task_id=task_id,
1373
- context_id=context_id,
1374
- kind="tool",
1375
- name=call_name,
1376
- args=call_args,
1377
- )
1378
-
1379
- def _detect_tool_completion(
1380
- self, metadata: dict, content: str
1381
- ) -> tuple[bool, str | None, Any]:
1382
- """Detect if a tool has completed and return completion info."""
1383
- tool_info = metadata.get("tool_info", {}) if isinstance(metadata, dict) else {}
1384
-
1385
- if tool_info.get("status") == "finished" and tool_info.get("name"):
1386
- return True, tool_info.get("name"), tool_info.get("output")
1387
- elif content and isinstance(content, str) and content.startswith("Completed "):
1388
- # content like "Completed google_serper"
1389
- tname = content.replace("Completed ", "").strip()
1390
- if tname:
1391
- output = (
1392
- tool_info.get("output") if tool_info.get("name") == tname else None
1393
- )
1394
- return True, tname, output
1395
- elif metadata.get("status") == "finished" and tool_info.get("name"):
1396
- return True, tool_info.get("name"), tool_info.get("output")
1397
-
1398
- return False, None, None
1399
-
1400
- def _get_tool_session_id(
1401
- self, finished_tool_name: str, task_id: str, context_id: str
1402
- ) -> str:
1403
- """Generate tool session ID."""
1404
- return f"tool_{finished_tool_name}_{task_id}_{context_id}"
1405
-
1406
- def _calculate_tool_duration(self, meta: dict[str, Any]) -> float | None:
1407
- """Calculate tool duration from metadata."""
1408
- server_now = self.stream_processor.server_elapsed_time
1409
- server_start = meta.get("server_started_at")
1410
- dur = None
1411
-
1412
- try:
1413
- if isinstance(server_now, (int, float)) and server_start is not None:
1414
- dur = max(0.0, float(server_now) - float(server_start))
1415
- else:
1416
- started_at = meta.get("started_at")
1417
- if started_at is not None:
1418
- started_at_float = float(started_at)
1419
- dur = max(0.0, float(monotonic()) - started_at_float)
1420
- except (TypeError, ValueError):
1421
- logger.exception("Failed to calculate tool duration")
1422
- return None
1423
-
1424
- return dur
1425
-
1426
- def _update_tool_metadata(self, meta: dict[str, Any], dur: float | None) -> None:
1427
- """Update tool metadata with duration information."""
1428
- if dur is not None:
1429
- meta["duration_seconds"] = dur
1430
- meta["server_finished_at"] = (
1431
- self.stream_processor.server_elapsed_time
1432
- if isinstance(self.stream_processor.server_elapsed_time, int | float)
1433
- else None
1434
- )
1435
- meta["finished_at"] = monotonic()
1436
-
1437
- def _add_tool_output_to_panel(
1438
- self, meta: dict[str, Any], finished_tool_output: Any, finished_tool_name: str
1439
- ) -> None:
1440
- """Add tool output to panel metadata."""
1441
- if finished_tool_output is not None:
1442
- meta["chunks"].append(
1443
- self._format_output_block(finished_tool_output, finished_tool_name)
1444
- )
1445
- meta["output"] = finished_tool_output
1446
-
1447
- def _mark_panel_as_finished(self, meta: dict[str, Any], tool_sid: str) -> None:
1448
- """Mark panel as finished and ensure visibility."""
1449
- if meta.get("status") != "finished":
1450
- meta["status"] = "finished"
1451
-
1452
- dur = self._calculate_tool_duration(meta)
1453
- self._update_tool_metadata(meta, dur)
1454
-
1455
- # Ensure this finished panel is visible in this frame
1456
- self.stream_processor.current_event_finished_panels.add(tool_sid)
1457
-
1458
- def _finish_tool_panel(
1459
- self,
1460
- finished_tool_name: str,
1461
- finished_tool_output: Any,
1462
- task_id: str,
1463
- context_id: str,
1464
- ) -> None:
1465
- """Finish a tool panel and update its status."""
1466
- tool_sid = self._get_tool_session_id(finished_tool_name, task_id, context_id)
1467
- if tool_sid not in self.tool_panels:
1468
- return
1469
-
1470
- meta = self.tool_panels[tool_sid]
1471
- self._mark_panel_as_finished(meta, tool_sid)
1472
- self._add_tool_output_to_panel(meta, finished_tool_output, finished_tool_name)
1473
-
1474
- def _get_step_duration(
1475
- self, finished_tool_name: str, task_id: str, context_id: str
1476
- ) -> float | None:
1477
- """Get step duration from tool panels."""
1478
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
1479
- return self.tool_panels.get(tool_sid, {}).get("duration_seconds")
1480
-
1481
- def _finish_delegation_step(
1482
- self,
1483
- finished_tool_name: str,
1484
- finished_tool_output: Any,
1485
- task_id: str,
1486
- context_id: str,
1487
- step_duration: float | None,
1488
- ) -> None:
1489
- """Finish a delegation step."""
1490
- self.steps.finish(
1491
- task_id=task_id,
1492
- context_id=context_id,
1493
- kind="delegate",
1494
- name=finished_tool_name,
1495
- output=finished_tool_output,
1496
- duration_raw=step_duration,
1497
- )
1498
-
1499
- def _finish_tool_step_type(
1500
- self,
1501
- finished_tool_name: str,
1502
- finished_tool_output: Any,
1503
- task_id: str,
1504
- context_id: str,
1505
- step_duration: float | None,
1506
- ) -> None:
1507
- """Finish a regular tool step."""
1508
- self.steps.finish(
1509
- task_id=task_id,
1510
- context_id=context_id,
1511
- kind="tool",
1512
- name=finished_tool_name,
1513
- output=finished_tool_output,
1514
- duration_raw=step_duration,
1515
- )
1516
-
1517
- def _finish_tool_step(
1518
- self,
1519
- finished_tool_name: str,
1520
- finished_tool_output: Any,
1521
- task_id: str,
1522
- context_id: str,
1523
- *,
1524
- tracked_step: Step | None = None,
1525
- ) -> None:
1526
- """Finish the corresponding step for a completed tool."""
1527
- if tracked_step is not None:
1528
- return
1529
-
1530
- step_duration = self._get_step_duration(finished_tool_name, task_id, context_id)
1531
-
1532
- if is_delegation_tool(finished_tool_name):
1533
- self._finish_delegation_step(
1534
- finished_tool_name,
1535
- finished_tool_output,
1536
- task_id,
1537
- context_id,
1538
- step_duration,
1539
- )
1540
- else:
1541
- self._finish_tool_step_type(
1542
- finished_tool_name,
1543
- finished_tool_output,
1544
- task_id,
1545
- context_id,
1546
- step_duration,
1547
- )
1548
-
1549
- def _should_create_snapshot(self, tool_sid: str) -> bool:
1550
- """Check if a snapshot should be created."""
1551
- return self.cfg.append_finished_snapshots and not self.tool_panels.get(
1552
- tool_sid, {}
1553
- ).get("snapshot_printed")
1554
-
1555
- def _get_snapshot_title(self, meta: dict[str, Any], finished_tool_name: str) -> str:
1556
- """Get the title for the snapshot."""
1557
- adjusted_title = meta.get("title") or finished_tool_name
1558
-
1559
- # Add elapsed time to title
1560
- dur = meta.get("duration_seconds")
1561
- if isinstance(dur, int | float):
1562
- elapsed_str = self._format_snapshot_duration(dur)
1563
- adjusted_title = f"{adjusted_title} · {elapsed_str}"
1564
-
1565
- return adjusted_title
1566
-
1567
- def _format_snapshot_duration(self, dur: int | float) -> str:
1568
- """Format duration for snapshot title."""
1569
- try:
1570
- # Handle invalid types
1571
- if not isinstance(dur, (int, float)):
1572
- return "<1ms"
1573
-
1574
- if dur >= 1:
1575
- return f"{dur:.2f}s"
1576
- elif int(dur * 1000) > 0:
1577
- return f"{int(dur * 1000)}ms"
1578
- else:
1579
- return "<1ms"
1580
- except (TypeError, ValueError, OverflowError):
1581
- return "<1ms"
1582
-
1583
- def _clamp_snapshot_body(self, body_text: str) -> str:
1584
- """Clamp snapshot body to configured limits."""
1585
- max_lines = int(self.cfg.snapshot_max_lines or 0)
1586
- lines = body_text.splitlines()
1587
- if max_lines > 0 and len(lines) > max_lines:
1588
- lines = lines[:max_lines] + ["… (truncated)"]
1589
- body_text = "\n".join(lines)
1590
-
1591
- max_chars = int(self.cfg.snapshot_max_chars or 0)
1592
- if max_chars > 0 and len(body_text) > max_chars:
1593
- suffix = "\n… (truncated)"
1594
- body_text = body_text[: max_chars - len(suffix)] + suffix
1595
-
1596
- return body_text
1597
-
1598
- def _create_snapshot_panel(
1599
- self, adjusted_title: str, body_text: str, finished_tool_name: str
1600
- ) -> Any:
1601
- """Create the snapshot panel."""
1602
- return create_tool_panel(
1603
- title=adjusted_title,
1604
- content=body_text or "(no output)",
1605
- status="finished",
1606
- theme=self.cfg.theme,
1607
- is_delegation=is_delegation_tool(finished_tool_name),
1608
- )
1609
-
1610
- def _print_and_mark_snapshot(self, tool_sid: str, snapshot_panel: Any) -> None:
1611
- """Print snapshot and mark as printed."""
1612
- self.console.print(snapshot_panel)
1613
- self.tool_panels[tool_sid]["snapshot_printed"] = True
1614
-
1615
- def _create_tool_snapshot(
1616
- self, finished_tool_name: str, task_id: str, context_id: str
1617
- ) -> None:
1618
- """Create and print a snapshot for a finished tool."""
1619
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
1620
-
1621
- if not self._should_create_snapshot(tool_sid):
1622
- return
1623
-
1624
- meta = self.tool_panels[tool_sid]
1625
- adjusted_title = self._get_snapshot_title(meta, finished_tool_name)
1626
-
1627
- # Compose body from chunks and clamp
1628
- body_text = "".join(meta.get("chunks") or [])
1629
- body_text = self._clamp_snapshot_body(body_text)
1630
-
1631
- snapshot_panel = self._create_snapshot_panel(
1632
- adjusted_title, body_text, finished_tool_name
1633
- )
1634
-
1635
- self._print_and_mark_snapshot(tool_sid, snapshot_panel)
1636
-
1637
- def _handle_agent_step(
1638
- self,
1639
- event: dict[str, Any],
1640
- tool_name: str | None,
1641
- tool_args: Any,
1642
- _tool_out: Any,
1643
- tool_calls_info: list[tuple[str, Any, Any]],
1644
- *,
1645
- tracked_step: Step | None = None,
1646
- ) -> None:
1647
- """Handle agent step event."""
1648
- metadata = event.get("metadata", {})
1649
- task_id = event.get("task_id") or metadata.get("task_id")
1650
- context_id = event.get("context_id") or metadata.get("context_id")
1651
- content = event.get("content", "")
1652
-
1653
- # Create steps and panels for the primary tool
1654
- if tool_name:
1655
- tool_sid = self._ensure_tool_panel(
1656
- tool_name, tool_args, task_id, context_id
1657
- )
1658
- self._start_tool_step(
1659
- task_id,
1660
- context_id,
1661
- tool_name,
1662
- tool_args,
1663
- tool_sid,
1664
- tracked_step=tracked_step,
1665
- )
1666
-
1667
- # Handle additional tool calls
1668
- self._process_additional_tool_calls(
1669
- tool_calls_info, tool_name, task_id, context_id
1670
- )
1671
-
1672
- # Check for tool completion
1673
- (
1674
- is_tool_finished,
1675
- finished_tool_name,
1676
- finished_tool_output,
1677
- ) = self._detect_tool_completion(metadata, content)
1678
-
1679
- if is_tool_finished and finished_tool_name:
1680
- self._finish_tool_panel(
1681
- finished_tool_name, finished_tool_output, task_id, context_id
1682
- )
1683
- self._finish_tool_step(
1684
- finished_tool_name,
1685
- finished_tool_output,
1686
- task_id,
1687
- context_id,
1688
- tracked_step=tracked_step,
1689
- )
1690
- self._create_tool_snapshot(finished_tool_name, task_id, context_id)
1691
-
1692
- def _spinner(self) -> str:
1693
- """Return spinner character."""
1694
- return get_spinner()
1695
-
1696
754
  def _format_working_indicator(self, started_at: float | None) -> str:
1697
755
  """Format working indicator."""
1698
756
  return format_working_indicator(
@@ -1735,9 +793,7 @@ class RichStreamRenderer:
1735
793
 
1736
794
  def _get_analysis_progress_info(self) -> dict[str, Any]:
1737
795
  total_steps = len(self.steps.order)
1738
- completed_steps = sum(
1739
- 1 for sid in self.steps.order if is_step_finished(self.steps.by_id[sid])
1740
- )
796
+ completed_steps = sum(1 for sid in self.steps.order if is_step_finished(self.steps.by_id[sid]))
1741
797
  current_step = None
1742
798
  for sid in self.steps.order:
1743
799
  if not is_step_finished(self.steps.by_id[sid]):
@@ -1745,13 +801,11 @@ class RichStreamRenderer:
1745
801
  break
1746
802
  # Prefer server elapsed time when available
1747
803
  elapsed = 0.0
1748
- if isinstance(self.stream_processor.server_elapsed_time, int | float):
804
+ if isinstance(self.stream_processor.server_elapsed_time, (int, float)):
1749
805
  elapsed = float(self.stream_processor.server_elapsed_time)
1750
806
  elif self._started_at is not None:
1751
807
  elapsed = monotonic() - self._started_at
1752
- progress_percent = (
1753
- int((completed_steps / total_steps) * 100) if total_steps else 0
1754
- )
808
+ progress_percent = int((completed_steps / total_steps) * 100) if total_steps else 0
1755
809
  return {
1756
810
  "total_steps": total_steps,
1757
811
  "completed_steps": completed_steps,
@@ -1840,9 +894,7 @@ class RichStreamRenderer:
1840
894
  server_elapsed = self.stream_processor.server_elapsed_time
1841
895
  server_start = self._step_server_start_times.get(step.step_id)
1842
896
 
1843
- if isinstance(server_elapsed, int | float) and isinstance(
1844
- server_start, int | float
1845
- ):
897
+ if isinstance(server_elapsed, (int, float)) and isinstance(server_start, (int, float)):
1846
898
  return max(0.0, float(server_elapsed) - float(server_start))
1847
899
 
1848
900
  try:
@@ -1858,18 +910,7 @@ class RichStreamRenderer:
1858
910
 
1859
911
  def _resolve_step_label(self, step: Step) -> str:
1860
912
  """Return the display label for a step with sensible fallbacks."""
1861
- raw_label = getattr(step, "display_label", None)
1862
- label = raw_label.strip() if isinstance(raw_label, str) else ""
1863
- if label:
1864
- return normalise_display_label(label)
1865
-
1866
- if not (step.name or "").strip():
1867
- return "Unknown step detail"
1868
-
1869
- icon = self._get_step_icon(step.kind)
1870
- base_name = self._get_step_display_name(step)
1871
- fallback = " ".join(part for part in (icon, base_name) if part).strip()
1872
- return normalise_display_label(fallback)
913
+ return format_step_label(step)
1873
914
 
1874
915
  def _check_parallel_tools(self) -> dict[tuple[str | None, str | None], list]:
1875
916
  """Check for parallel running tools."""
@@ -1890,358 +931,77 @@ class RichStreamRenderer:
1890
931
  key = (step.task_id, step.context_id)
1891
932
  return len(running_by_ctx.get(key, [])) > 1
1892
933
 
1893
- def _compose_step_renderable(
1894
- self,
1895
- step: Step,
1896
- branch_state: tuple[bool, ...],
1897
- ) -> Any:
1898
- """Compose a single renderable for the hierarchical steps panel."""
1899
- prefix = build_connector_prefix(branch_state)
1900
- text_line = self._build_step_text_line(step, prefix)
1901
- renderables = self._wrap_step_text(step, text_line)
1902
-
1903
- args_renderable = self._build_args_renderable(step, prefix)
1904
- if args_renderable is not None:
1905
- renderables.append(args_renderable)
1906
-
1907
- return self._collapse_renderables(renderables)
1908
-
1909
- def _build_step_text_line(
1910
- self,
1911
- step: Step,
1912
- prefix: str,
1913
- ) -> Text:
1914
- """Create the textual portion of a step renderable."""
1915
- text_line = Text()
1916
- text_line.append(prefix, style="dim")
1917
- text_line.append(self._resolve_step_label(step))
1918
-
1919
- status_badge = self._format_step_status(step)
1920
- self._append_status_badge(text_line, step, status_badge)
1921
- self._append_state_glyph(text_line, step)
1922
- return text_line
1923
-
1924
- def _append_status_badge(
1925
- self, text_line: Text, step: Step, status_badge: str
1926
- ) -> None:
1927
- """Append the formatted status badge when available."""
1928
- glyph_key = getattr(step, "status_icon", None)
1929
- glyph = glyph_for_status(glyph_key)
1930
-
1931
- if status_badge:
1932
- text_line.append(" ")
1933
- text_line.append(status_badge, style="cyan")
1934
-
1935
- if glyph:
1936
- text_line.append(" ")
1937
- style = self._status_icon_style(glyph_key)
1938
- if style:
1939
- text_line.append(glyph, style=style)
1940
- else:
1941
- text_line.append(glyph)
1942
-
1943
- def _append_state_glyph(self, text_line: Text, step: Step) -> None:
1944
- """Append glyph/failure markers in a single place."""
1945
- failure_reason = (step.failure_reason or "").strip()
1946
- if failure_reason:
1947
- text_line.append(f" {failure_reason}")
1948
-
1949
- @staticmethod
1950
- def _status_icon_style(icon_key: str | None) -> str | None:
1951
- """Return style for a given status icon."""
1952
- if not icon_key:
1953
- return None
1954
- return STATUS_ICON_STYLES.get(icon_key)
1955
-
1956
- def _wrap_step_text(self, step: Step, text_line: Text) -> list[Any]:
1957
- """Return the base text, optionally decorated with a trailing spinner."""
1958
- if getattr(step, "status", None) == "running":
1959
- spinner = self._step_spinners.get(step.step_id)
1960
- if spinner is None:
1961
- spinner = Spinner("dots", style="dim")
1962
- self._step_spinners[step.step_id] = spinner
1963
- return [TrailingSpinnerLine(text_line, spinner)]
1964
-
1965
- self._step_spinners.pop(step.step_id, None)
1966
- return [text_line]
1967
-
1968
- def _collapse_renderables(self, renderables: list[Any]) -> Any:
1969
- """Collapse a list of renderables into a single object."""
1970
- if not renderables:
1971
- return None
1972
-
1973
- if len(renderables) == 1:
1974
- return renderables[0]
1975
-
1976
- return Group(*renderables)
1977
-
1978
- def _build_args_renderable(self, step: Step, prefix: str) -> Text | Group | None:
1979
- """Build a dimmed argument line for tool or agent steps."""
1980
- if step.kind not in {"tool", "delegate", "agent"}:
1981
- return None
1982
- if step.kind == "agent" and step.parent_id:
1983
- return None
1984
- formatted_args = self._format_step_args(step)
1985
- if not formatted_args:
1986
- return None
1987
- if isinstance(formatted_args, list):
1988
- return self._build_arg_list(prefix, formatted_args)
1989
-
1990
- args_text = Text()
1991
- args_text.append(prefix, style="dim")
1992
- args_text.append(" " * 5)
1993
- args_text.append(formatted_args, style="dim")
1994
- return args_text
1995
-
1996
- def _build_arg_list(
1997
- self, prefix: str, formatted_args: list[str | tuple[int, str]]
1998
- ) -> Group | None:
1999
- """Render multi-line argument entries preserving indentation."""
2000
- arg_lines: list[Text] = []
2001
- for indent_level, text_value in self._iter_arg_entries(formatted_args):
2002
- arg_text = Text()
2003
- arg_text.append(prefix, style="dim")
2004
- arg_text.append(" " * 5)
2005
- arg_text.append(" " * (indent_level * 2))
2006
- arg_text.append(text_value, style="dim")
2007
- arg_lines.append(arg_text)
2008
- if not arg_lines:
2009
- return None
2010
- return Group(*arg_lines)
2011
-
2012
- @staticmethod
2013
- def _iter_arg_entries(
2014
- formatted_args: list[str | tuple[int, str]],
2015
- ) -> Iterable[tuple[int, str]]:
2016
- """Yield normalized indentation/value pairs for argument entries."""
2017
- for value in formatted_args:
2018
- if isinstance(value, tuple) and len(value) == 2:
2019
- indent_level, text_value = value
2020
- yield indent_level, str(text_value)
2021
- else:
2022
- yield 0, str(value)
2023
-
2024
- def _format_step_args(
2025
- self, step: Step
2026
- ) -> str | list[str] | list[tuple[int, str]] | None:
2027
- """Return a printable representation of tool arguments."""
2028
- args = getattr(step, "args", None)
2029
- if args is None:
2030
- return None
2031
-
2032
- if isinstance(args, dict):
2033
- return self._format_dict_args(args, step=step)
2034
-
2035
- if isinstance(args, (list, tuple)):
2036
- return self._safe_pretty_args(list(args))
2037
-
2038
- if isinstance(args, (str, int, float)):
2039
- return self._stringify_args(args)
2040
-
2041
- return None
2042
-
2043
- def _format_dict_args(
2044
- self, args: dict[str, Any], *, step: Step
2045
- ) -> str | list[str] | list[tuple[int, str]] | None:
2046
- """Format dictionary arguments with guardrails."""
2047
- if not args:
2048
- return None
2049
-
2050
- masked_args = self._redact_arg_payload(args)
2051
-
2052
- if self._should_collapse_single_query(step):
2053
- single_query = self._extract_single_query_arg(masked_args)
2054
- if single_query:
2055
- return single_query
2056
-
2057
- return self._format_dict_arg_lines(masked_args)
2058
-
2059
- @staticmethod
2060
- def _extract_single_query_arg(args: dict[str, Any]) -> str | None:
2061
- """Return a trimmed query argument when it is the only entry."""
2062
- if len(args) != 1:
2063
- return None
2064
- key, value = next(iter(args.items()))
2065
- if key != "query" or not isinstance(value, str):
2066
- return None
2067
- stripped = value.strip()
2068
- return stripped or None
2069
-
2070
- @staticmethod
2071
- def _redact_arg_payload(args: dict[str, Any]) -> dict[str, Any]:
2072
- """Apply best-effort masking before rendering arguments."""
2073
- try:
2074
- cleaned = redact_sensitive(args)
2075
- return cleaned if isinstance(cleaned, dict) else args
2076
- except Exception:
2077
- return args
2078
-
2079
- @staticmethod
2080
- def _should_collapse_single_query(step: Step) -> bool:
2081
- """Return True when we should display raw query text."""
2082
- if step.kind == "agent":
2083
- return True
2084
- if step.kind == "delegate":
2085
- return True
2086
- return False
2087
-
2088
- def _format_dict_arg_lines(
2089
- self, args: dict[str, Any]
2090
- ) -> list[tuple[int, str]] | None:
2091
- """Render dictionary arguments as nested YAML-style lines."""
2092
- lines: list[tuple[int, str]] = []
2093
- for raw_key, value in args.items():
2094
- key = str(raw_key)
2095
- lines.extend(self._format_nested_entry(key, value, indent=0))
2096
- return lines or None
2097
-
2098
- def _format_nested_entry(
2099
- self, key: str, value: Any, indent: int
2100
- ) -> list[tuple[int, str]]:
2101
- """Format a mapping entry recursively."""
2102
- lines: list[tuple[int, str]] = []
2103
-
2104
- if isinstance(value, dict):
2105
- if value:
2106
- lines.append((indent, f"{key}:"))
2107
- lines.extend(self._format_nested_mapping(value, indent + 1))
2108
- else:
2109
- lines.append((indent, f"{key}: {{}}"))
2110
- return lines
2111
-
2112
- if isinstance(value, (list, tuple, set)):
2113
- seq_lines = self._format_sequence_entries(list(value), indent + 1)
2114
- if seq_lines:
2115
- lines.append((indent, f"{key}:"))
2116
- lines.extend(seq_lines)
2117
- else:
2118
- lines.append((indent, f"{key}: []"))
2119
- return lines
2120
-
2121
- formatted_value = self._format_arg_value(value)
2122
- if formatted_value is not None:
2123
- lines.append((indent, f"{key}: {formatted_value}"))
2124
- return lines
2125
-
2126
- def _format_nested_mapping(
2127
- self, mapping: dict[str, Any], indent: int
2128
- ) -> list[tuple[int, str]]:
2129
- """Format nested dictionary values."""
2130
- nested_lines: list[tuple[int, str]] = []
2131
- for raw_key, value in mapping.items():
2132
- key = str(raw_key)
2133
- nested_lines.extend(self._format_nested_entry(key, value, indent))
2134
- return nested_lines
2135
-
2136
- def _format_sequence_entries(
2137
- self, sequence: list[Any], indent: int
2138
- ) -> list[tuple[int, str]]:
2139
- """Format list/tuple/set values with YAML-style bullets."""
2140
- if not sequence:
2141
- return []
2142
-
2143
- lines: list[tuple[int, str]] = []
2144
- for item in sequence:
2145
- lines.extend(self._format_sequence_item(item, indent))
2146
- return lines
2147
-
2148
- def _format_sequence_item(self, item: Any, indent: int) -> list[tuple[int, str]]:
2149
- """Format a single list entry."""
2150
- if isinstance(item, dict):
2151
- return self._format_dict_sequence_item(item, indent)
2152
-
2153
- if isinstance(item, (list, tuple, set)):
2154
- return self._format_nested_sequence_item(list(item), indent)
2155
-
2156
- formatted = self._format_arg_value(item)
2157
- if formatted is not None:
2158
- return [(indent, f"- {formatted}")]
2159
- return []
2160
-
2161
- def _format_dict_sequence_item(
2162
- self, mapping: dict[str, Any], indent: int
2163
- ) -> list[tuple[int, str]]:
2164
- """Format a dictionary entry within a list."""
2165
- child_lines = self._format_nested_mapping(mapping, indent + 1)
2166
- if child_lines:
2167
- return self._prepend_sequence_prefix(child_lines, indent)
2168
- return [(indent, "- {}")]
2169
-
2170
- def _format_nested_sequence_item(
2171
- self, sequence: list[Any], indent: int
2172
- ) -> list[tuple[int, str]]:
2173
- """Format a nested sequence entry within a list."""
2174
- child_lines = self._format_sequence_entries(sequence, indent + 1)
2175
- if child_lines:
2176
- return self._prepend_sequence_prefix(child_lines, indent)
2177
- return [(indent, "- []")]
2178
-
2179
- @staticmethod
2180
- def _prepend_sequence_prefix(
2181
- child_lines: list[tuple[int, str]], indent: int
2182
- ) -> list[tuple[int, str]]:
2183
- """Attach a sequence bullet to the first child line."""
2184
- _, first_text = child_lines[0]
2185
- prefixed: list[tuple[int, str]] = [(indent, f"- {first_text}")]
2186
- prefixed.extend(child_lines[1:])
2187
- return prefixed
2188
-
2189
- def _format_arg_value(self, value: Any) -> str | None:
2190
- """Format a single argument value with per-value truncation."""
2191
- if value is None:
2192
- return "null"
2193
- if isinstance(value, (bool, int, float)):
2194
- return json.dumps(value, ensure_ascii=False)
2195
- if isinstance(value, str):
2196
- return self._format_string_arg_value(value)
2197
- return _truncate_display(str(value), limit=ARGS_VALUE_MAX_LEN)
2198
-
2199
- @staticmethod
2200
- def _format_string_arg_value(value: str) -> str:
2201
- """Return a trimmed, quoted representation of a string argument."""
2202
- sanitised = value.replace("\n", " ").strip()
2203
- sanitised = sanitised.replace('"', '\\"')
2204
- trimmed = _truncate_display(sanitised, limit=ARGS_VALUE_MAX_LEN)
2205
- return f'"{trimmed}"'
2206
-
2207
- @staticmethod
2208
- def _safe_pretty_args(args: dict[str, Any]) -> str | None:
2209
- """Defensively format argument dictionaries."""
2210
- try:
2211
- return pretty_args(args, max_len=160)
2212
- except Exception:
2213
- return str(args)
2214
-
2215
- @staticmethod
2216
- def _stringify_args(args: Any) -> str | None:
2217
- """Format non-dictionary argument payloads."""
2218
- text = str(args).strip()
2219
- if not text:
2220
- return None
2221
- return _truncate_display(text)
2222
-
2223
- def _render_steps_text(self) -> Any:
2224
- """Render the steps panel content."""
2225
- if not (self.steps.order or self.steps.children):
2226
- return Text("No steps yet", style="dim")
2227
-
2228
- renderables: list[Any] = []
2229
- for step_id, branch_state in self.steps.iter_tree():
2230
- step = self.steps.by_id.get(step_id)
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)
2231
939
  if not step:
2232
940
  continue
2233
- renderable = self._compose_step_renderable(step, branch_state)
2234
- if renderable is not None:
2235
- renderables.append(renderable)
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(),
960
+ )
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)
2236
993
 
2237
- if not renderables:
2238
- return Text("No steps yet", style="dim")
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)
2239
997
 
2240
- return Group(*renderables)
998
+ def _summary_window_size(self) -> int:
999
+ """Return the active window size for step display."""
1000
+ if self.state.finalizing_ui:
1001
+ return 0
1002
+ return int(self.cfg.summary_display_window or 0)
2241
1003
 
2242
- def _update_final_duration(
2243
- self, duration: float | None, *, overwrite: bool = False
2244
- ) -> None:
1004
+ def _update_final_duration(self, duration: float | None, *, overwrite: bool = False) -> None:
2245
1005
  """Store formatted duration for eventual final panels."""
2246
1006
  if duration is None:
2247
1007
  return
@@ -2259,85 +1019,6 @@ class RichStreamRenderer:
2259
1019
  if overwrite and existing is not None:
2260
1020
  duration_val = max(existing, duration_val)
2261
1021
 
2262
- self.state.final_duration_seconds = duration_val
2263
- 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)
2264
1024
  self._apply_root_duration(duration_val)
2265
-
2266
- def _format_elapsed_time(self, elapsed: float) -> str:
2267
- """Format elapsed time as a readable string."""
2268
- if elapsed >= 1:
2269
- return f"{elapsed:.2f}s"
2270
- elif int(elapsed * 1000) > 0:
2271
- return f"{int(elapsed * 1000)}ms"
2272
- else:
2273
- return "<1ms"
2274
-
2275
- def _format_dict_or_list_output(self, output_value: dict | list) -> str:
2276
- """Format dict/list output as pretty JSON."""
2277
- try:
2278
- return (
2279
- self.OUTPUT_PREFIX
2280
- + "```json\n"
2281
- + json.dumps(output_value, indent=2)
2282
- + "\n```\n"
2283
- )
2284
- except Exception:
2285
- return self.OUTPUT_PREFIX + str(output_value) + "\n"
2286
-
2287
- def _clean_sub_agent_prefix(self, output: str, tool_name: str | None) -> str:
2288
- """Clean sub-agent name prefix from output."""
2289
- if not (tool_name and is_delegation_tool(tool_name)):
2290
- return output
2291
-
2292
- sub = tool_name
2293
- if tool_name.startswith("delegate_to_"):
2294
- sub = tool_name.replace("delegate_to_", "")
2295
- elif tool_name.startswith("delegate_"):
2296
- sub = tool_name.replace("delegate_", "")
2297
- prefix = f"[{sub}]"
2298
- if output.startswith(prefix):
2299
- return output[len(prefix) :].lstrip()
2300
-
2301
- return output
2302
-
2303
- def _format_json_string_output(self, output: str) -> str:
2304
- """Format string that looks like JSON."""
2305
- try:
2306
- parsed = json.loads(output)
2307
- return (
2308
- self.OUTPUT_PREFIX
2309
- + "```json\n"
2310
- + json.dumps(parsed, indent=2)
2311
- + "\n```\n"
2312
- )
2313
- except Exception:
2314
- return self.OUTPUT_PREFIX + output + "\n"
2315
-
2316
- def _format_string_output(self, output: str, tool_name: str | None) -> str:
2317
- """Format string output with optional prefix cleaning."""
2318
- s = output.strip()
2319
- s = self._clean_sub_agent_prefix(s, tool_name)
2320
-
2321
- # If looks like JSON, pretty print it
2322
- if (s.startswith("{") and s.endswith("}")) or (
2323
- s.startswith("[") and s.endswith("]")
2324
- ):
2325
- return self._format_json_string_output(s)
2326
-
2327
- return self.OUTPUT_PREFIX + s + "\n"
2328
-
2329
- def _format_other_output(self, output_value: Any) -> str:
2330
- """Format other types of output."""
2331
- try:
2332
- return self.OUTPUT_PREFIX + json.dumps(output_value, indent=2) + "\n"
2333
- except Exception:
2334
- return self.OUTPUT_PREFIX + str(output_value) + "\n"
2335
-
2336
- def _format_output_block(self, output_value: Any, tool_name: str | None) -> str:
2337
- """Format an output value for panel display."""
2338
- if isinstance(output_value, dict | list):
2339
- return self._format_dict_or_list_output(output_value)
2340
- elif isinstance(output_value, str):
2341
- return self._format_string_output(output_value, tool_name)
2342
- else:
2343
- return self._format_other_output(output_value)