glaip-sdk 0.1.3__py3-none-any.whl → 0.6.19__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 (146) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1196 -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 +104 -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 +851 -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 +241 -121
  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 +291 -35
  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 +466 -89
  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/hitl/__init__.py +15 -0
  65. glaip_sdk/hitl/local.py +151 -0
  66. glaip_sdk/mcps/__init__.py +21 -0
  67. glaip_sdk/mcps/base.py +345 -0
  68. glaip_sdk/models/__init__.py +90 -0
  69. glaip_sdk/models/agent.py +47 -0
  70. glaip_sdk/models/agent_runs.py +116 -0
  71. glaip_sdk/models/common.py +42 -0
  72. glaip_sdk/models/mcp.py +33 -0
  73. glaip_sdk/models/tool.py +33 -0
  74. glaip_sdk/payload_schemas/__init__.py +1 -13
  75. glaip_sdk/registry/__init__.py +55 -0
  76. glaip_sdk/registry/agent.py +164 -0
  77. glaip_sdk/registry/base.py +139 -0
  78. glaip_sdk/registry/mcp.py +253 -0
  79. glaip_sdk/registry/tool.py +232 -0
  80. glaip_sdk/rich_components.py +58 -2
  81. glaip_sdk/runner/__init__.py +59 -0
  82. glaip_sdk/runner/base.py +84 -0
  83. glaip_sdk/runner/deps.py +112 -0
  84. glaip_sdk/runner/langgraph.py +870 -0
  85. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  86. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  87. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  88. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  89. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  90. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  91. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  92. glaip_sdk/tools/__init__.py +22 -0
  93. glaip_sdk/tools/base.py +435 -0
  94. glaip_sdk/utils/__init__.py +58 -12
  95. glaip_sdk/utils/a2a/__init__.py +34 -0
  96. glaip_sdk/utils/a2a/event_processor.py +188 -0
  97. glaip_sdk/utils/bundler.py +267 -0
  98. glaip_sdk/utils/client.py +111 -0
  99. glaip_sdk/utils/client_utils.py +39 -7
  100. glaip_sdk/utils/datetime_helpers.py +58 -0
  101. glaip_sdk/utils/discovery.py +78 -0
  102. glaip_sdk/utils/display.py +23 -15
  103. glaip_sdk/utils/export.py +143 -0
  104. glaip_sdk/utils/general.py +0 -33
  105. glaip_sdk/utils/import_export.py +12 -7
  106. glaip_sdk/utils/import_resolver.py +492 -0
  107. glaip_sdk/utils/instructions.py +101 -0
  108. glaip_sdk/utils/rendering/__init__.py +115 -1
  109. glaip_sdk/utils/rendering/formatting.py +5 -30
  110. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  111. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  112. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  113. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  114. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  115. glaip_sdk/utils/rendering/models.py +1 -0
  116. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  117. glaip_sdk/utils/rendering/renderer/base.py +275 -1476
  118. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  119. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  120. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  121. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  122. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  123. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  124. glaip_sdk/utils/rendering/state.py +204 -0
  125. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  126. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  127. glaip_sdk/utils/rendering/steps/format.py +176 -0
  128. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  129. glaip_sdk/utils/rendering/timing.py +36 -0
  130. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  131. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  132. glaip_sdk/utils/resource_refs.py +25 -13
  133. glaip_sdk/utils/runtime_config.py +425 -0
  134. glaip_sdk/utils/serialization.py +18 -0
  135. glaip_sdk/utils/sync.py +142 -0
  136. glaip_sdk/utils/tool_detection.py +33 -0
  137. glaip_sdk/utils/tool_storage_provider.py +140 -0
  138. glaip_sdk/utils/validation.py +16 -24
  139. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/METADATA +56 -21
  140. glaip_sdk-0.6.19.dist-info/RECORD +163 -0
  141. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.19.dist-info}/WHEEL +2 -1
  142. glaip_sdk-0.6.19.dist-info/entry_points.txt +2 -0
  143. glaip_sdk-0.6.19.dist-info/top_level.txt +1 -0
  144. glaip_sdk/models.py +0 -240
  145. glaip_sdk-0.1.3.dist-info/RECORD +0 -83
  146. glaip_sdk-0.1.3.dist-info/entry_points.txt +0 -3
@@ -8,8 +8,7 @@ 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
11
+ import sys
13
12
  from datetime import datetime, timezone
14
13
  from time import monotonic
15
14
  from typing import Any
@@ -18,158 +17,64 @@ from rich.console import Console as RichConsole
18
17
  from rich.console import Group
19
18
  from rich.live import Live
20
19
  from rich.markdown import Markdown
21
- from rich.measure import Measurement
22
20
  from rich.spinner import Spinner
23
21
  from rich.text import Text
24
22
 
25
23
  from glaip_sdk.icons import ICON_AGENT, ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
26
24
  from glaip_sdk.rich_components import AIPPanel
27
25
  from glaip_sdk.utils.rendering.formatting import (
28
- build_connector_prefix,
29
26
  format_main_title,
30
- get_spinner_char,
31
- glyph_for_status,
32
27
  is_step_finished,
33
28
  normalise_display_label,
34
- pretty_args,
35
- redact_sensitive,
36
29
  )
37
30
  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 (
31
+ from glaip_sdk.utils.rendering.layout.panels import create_main_panel
32
+ from glaip_sdk.utils.rendering.layout.progress import (
33
+ build_progress_footer,
46
34
  format_elapsed_time,
47
- format_tool_title,
48
35
  format_working_indicator,
49
- get_spinner,
36
+ get_spinner_char,
50
37
  is_delegation_tool,
51
38
  )
39
+ from glaip_sdk.utils.rendering.layout.summary import render_summary_panels
40
+ from glaip_sdk.utils.rendering.layout.transcript import (
41
+ DEFAULT_TRANSCRIPT_THEME,
42
+ TranscriptSnapshot,
43
+ build_final_panel,
44
+ build_transcript_snapshot,
45
+ build_transcript_view,
46
+ extract_query_from_meta,
47
+ format_final_panel_title,
48
+ )
49
+ from glaip_sdk.utils.rendering.renderer.config import RendererConfig
50
+ from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
52
51
  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
52
+ from glaip_sdk.utils.rendering.renderer.thinking import ThinkingScopeController
53
+ from glaip_sdk.utils.rendering.renderer.tool_panels import ToolPanelController
54
+ from glaip_sdk.utils.rendering.renderer.transcript_mode import TranscriptModeMixin
55
+ from glaip_sdk.utils.rendering.state import (
56
+ RendererState,
57
+ TranscriptBuffer,
58
+ coerce_received_at,
59
+ truncate_display,
60
+ )
61
+ from glaip_sdk.utils.rendering.steps import (
62
+ StepManager,
63
+ format_step_label,
64
+ )
65
+ from glaip_sdk.utils.rendering.timing import coerce_server_time
55
66
 
56
- DEFAULT_RENDERER_THEME = "dark"
57
67
  _NO_STEPS_TEXT = Text("No steps yet", style="dim")
58
68
 
59
69
  # Configure logger
60
70
  logger = logging.getLogger("glaip_sdk.run_renderer")
61
71
 
62
72
  # 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
73
  RUNNING_STATUS_HINTS = {"running", "started", "pending", "working"}
74
74
  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
-
134
-
135
- @dataclass
136
- class ThinkingScopeState:
137
- """Runtime bookkeeping for deterministic thinking spans."""
138
75
 
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
76
 
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:
77
+ class RichStreamRenderer(TranscriptModeMixin):
173
78
  """Live, modern terminal renderer for agent execution with rich visual output."""
174
79
 
175
80
  def __init__(
@@ -178,6 +83,8 @@ class RichStreamRenderer:
178
83
  *,
179
84
  cfg: RendererConfig | None = None,
180
85
  verbose: bool = False,
86
+ transcript_buffer: TranscriptBuffer | None = None,
87
+ callbacks: dict[str, Any] | None = None,
181
88
  ) -> None:
182
89
  """Initialize the renderer.
183
90
 
@@ -185,7 +92,10 @@ class RichStreamRenderer:
185
92
  console: Rich console instance
186
93
  cfg: Renderer configuration
187
94
  verbose: Whether to enable verbose mode
95
+ transcript_buffer: Optional transcript buffer for capturing output
96
+ callbacks: Optional dictionary of callback functions
188
97
  """
98
+ super().__init__()
189
99
  self.console = console or RichConsole()
190
100
  self.cfg = cfg or RendererConfig()
191
101
  self.verbose = verbose
@@ -193,16 +103,32 @@ class RichStreamRenderer:
193
103
  # Initialize components
194
104
  self.stream_processor = StreamProcessor()
195
105
  self.state = RendererState()
106
+ if transcript_buffer is not None:
107
+ self.state.buffer = transcript_buffer
108
+
109
+ self._callbacks = callbacks or {}
196
110
 
197
111
  # Initialize step manager and other state
198
112
  self.steps = StepManager(max_steps=self.cfg.summary_max_steps)
199
113
  # Live display instance (single source of truth)
200
114
  self.live: Live | None = None
201
115
  self._step_spinners: dict[str, Spinner] = {}
116
+ self._last_steps_panel_template: Any | None = None
202
117
 
203
118
  # Tool tracking and thinking scopes
204
- self.tool_panels: dict[str, dict[str, Any]] = {}
205
- self._thinking_scopes: dict[str, ThinkingScopeState] = {}
119
+ self._step_server_start_times: dict[str, float] = {}
120
+ self.tool_controller = ToolPanelController(
121
+ steps=self.steps,
122
+ stream_processor=self.stream_processor,
123
+ console=self.console,
124
+ cfg=self.cfg,
125
+ step_server_start_times=self._step_server_start_times,
126
+ output_prefix="**Output:**\n",
127
+ )
128
+ self.thinking_controller = ThinkingScopeController(
129
+ self.steps,
130
+ step_server_start_times=self._step_server_start_times,
131
+ )
206
132
  self._root_agent_friendly: str | None = None
207
133
  self._root_agent_step_id: str | None = None
208
134
  self._root_query: str | None = None
@@ -214,21 +140,11 @@ class RichStreamRenderer:
214
140
  # Header/text
215
141
  self.header_text: str = ""
216
142
  # Track per-step server start times for accurate elapsed labels
217
- self._step_server_start_times: dict[str, float] = {}
218
-
219
143
  # Output formatting constants
220
144
  self.OUTPUT_PREFIX: str = "**Output:**\n"
221
145
 
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
146
+ self._final_transcript_snapshot: TranscriptSnapshot | None = None
147
+ self._final_transcript_renderables: tuple[list[Any], list[Any]] | None = None
232
148
 
233
149
  def on_start(self, meta: dict[str, Any]) -> None:
234
150
  """Handle renderer start event."""
@@ -246,7 +162,7 @@ class RichStreamRenderer:
246
162
  meta_payload = meta or {}
247
163
  self.steps.set_root_agent(meta_payload.get("agent_id"))
248
164
  self._root_agent_friendly = self._humanize_agent_slug(meta_payload.get("agent_name"))
249
- self._root_query = _truncate_display(
165
+ self._root_query = truncate_display(
250
166
  meta_payload.get("input_message")
251
167
  or meta_payload.get("query")
252
168
  or meta_payload.get("message")
@@ -306,20 +222,6 @@ class RichStreamRenderer:
306
222
  except Exception:
307
223
  logger.exception("Failed to print header fallback")
308
224
 
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
225
  def _build_user_query_panel(self, query: str) -> AIPPanel:
324
226
  """Create the panel used to display the user request."""
325
227
  return AIPPanel(
@@ -331,7 +233,7 @@ class RichStreamRenderer:
331
233
 
332
234
  def _render_user_query(self, meta: dict[str, Any]) -> None:
333
235
  """Render the user query panel."""
334
- query = self._extract_query_from_meta(meta)
236
+ query = extract_query_from_meta(meta)
335
237
  if not query:
336
238
  return
337
239
  self.console.print(self._build_user_query_panel(query))
@@ -344,7 +246,7 @@ class RichStreamRenderer:
344
246
  elif self.header_text and not self._render_header_rule():
345
247
  self._render_header_fallback()
346
248
 
347
- query = self._extract_query_from_meta(meta) or self._root_query
249
+ query = extract_query_from_meta(meta) or self._root_query
348
250
  if query:
349
251
  self.console.print(self._build_user_query_panel(query))
350
252
 
@@ -365,18 +267,21 @@ class RichStreamRenderer:
365
267
 
366
268
  def _render_static_summary_panels(self) -> None:
367
269
  """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())
270
+ summary_window = self._summary_window_size()
271
+ window_arg = summary_window if summary_window > 0 else None
272
+ status_overrides = self._build_step_status_overrides()
273
+ for renderable in render_summary_panels(
274
+ self.state,
275
+ self.steps,
276
+ summary_window=window_arg,
277
+ include_query_panel=False,
278
+ step_status_overrides=status_overrides,
279
+ ):
280
+ self.console.print(renderable)
376
281
 
377
282
  def _ensure_streaming_started_baseline(self, timestamp: float) -> None:
378
283
  """Synchronize streaming start state across renderer components."""
379
- self.state.streaming_started_at = timestamp
284
+ self.state.start_stream_timer(timestamp)
380
285
  self.stream_processor.streaming_started_at = timestamp
381
286
  self._started_at = timestamp
382
287
 
@@ -398,7 +303,7 @@ class RichStreamRenderer:
398
303
 
399
304
  def _resolve_received_timestamp(self, ev: dict[str, Any]) -> datetime:
400
305
  """Return the timestamp an event was received, normalising inputs."""
401
- received_at = _coerce_received_at(ev.get("received_at"))
306
+ received_at = coerce_received_at(ev.get("received_at"))
402
307
  if received_at is None:
403
308
  received_at = datetime.now(timezone.utc)
404
309
 
@@ -445,6 +350,9 @@ class RichStreamRenderer:
445
350
  self._handle_status_event(ev)
446
351
  elif kind == "content":
447
352
  self._handle_content_event(content)
353
+ elif kind == "token":
354
+ # Token events should stream content incrementally with immediate console output
355
+ self._handle_token_event(content)
448
356
  elif kind == "final_response":
449
357
  self._handle_final_response_event(content, metadata)
450
358
  elif kind in {"agent_step", "agent_thinking_step"}:
@@ -461,43 +369,62 @@ class RichStreamRenderer:
461
369
  def _handle_content_event(self, content: str) -> None:
462
370
  """Handle content streaming events."""
463
371
  if content:
464
- self.state.buffer.append(content)
372
+ self.state.append_transcript_text(content)
465
373
  self._ensure_live()
466
374
 
375
+ def _handle_token_event(self, content: str) -> None:
376
+ """Handle token streaming events - print immediately for real-time streaming."""
377
+ if content:
378
+ self.state.append_transcript_text(content)
379
+ # Print token content directly to stdout for immediate visibility when not verbose
380
+ # This bypasses Rich's Live display which has refresh rate limitations
381
+ if not self.verbose:
382
+ try:
383
+ # Mark that we're streaming tokens directly to prevent Live display from starting
384
+ self._streaming_tokens_directly = True
385
+ # Stop Live display if active to prevent it from intercepting stdout
386
+ # and causing each token to appear on a new line
387
+ if self.live is not None:
388
+ self._stop_live_display()
389
+ # Write directly to stdout - tokens will stream on the same line
390
+ # since we're bypassing Rich's console which adds newlines
391
+ sys.stdout.write(content)
392
+ sys.stdout.flush()
393
+ except Exception:
394
+ # Fallback to live display if direct write fails
395
+ self._ensure_live()
396
+ else:
397
+ # In verbose mode, use normal live display (debug panels handle the output)
398
+ self._ensure_live()
399
+
467
400
  def _handle_final_response_event(self, content: str, metadata: dict[str, Any]) -> None:
468
401
  """Handle final response events."""
469
402
  if content:
470
- self.state.buffer.append(content)
471
- self.state.final_text = content
403
+ self.state.append_transcript_text(content)
404
+ self.state.set_final_output(content)
472
405
 
473
406
  meta_payload = metadata.get("metadata") or {}
474
- final_time = self._coerce_server_time(meta_payload.get("time"))
407
+ final_time = coerce_server_time(meta_payload.get("time"))
475
408
  self._update_final_duration(final_time)
476
- self._close_active_thinking_scopes(final_time)
409
+ self.thinking_controller.close_active_scopes(final_time)
477
410
  self._finish_running_steps()
478
- self._finish_tool_panels()
411
+ self.tool_controller.finish_all_panels()
479
412
  self._normalise_finished_icons()
480
413
 
481
414
  self._ensure_live()
482
415
  self._print_final_panel_if_needed()
483
416
 
484
417
  def _normalise_finished_icons(self) -> None:
485
- """Ensure finished steps do not keep spinner icons."""
418
+ """Ensure finished steps release any running spinners."""
486
419
  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
420
  if getattr(step, "status", None) != "running":
490
421
  self._step_spinners.pop(step.step_id, None)
491
422
 
492
423
  def _handle_agent_step_event(self, ev: dict[str, Any], metadata: dict[str, Any]) -> None:
493
424
  """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)
425
+ # Extract tool information using stream processor
426
+ tool_calls_result = self.stream_processor.parse_tool_calls(ev)
427
+ tool_name, tool_args, tool_out, tool_calls_info = tool_calls_result
501
428
 
502
429
  payload = metadata.get("metadata") or {}
503
430
 
@@ -508,7 +435,11 @@ class RichStreamRenderer:
508
435
  logger.debug("Malformed step event skipped", exc_info=True)
509
436
  else:
510
437
  self._record_step_server_start(tracked_step, payload)
511
- self._update_thinking_timeline(tracked_step, payload)
438
+ self.thinking_controller.update_timeline(
439
+ tracked_step,
440
+ payload,
441
+ enabled=self.cfg.render_thinking,
442
+ )
512
443
  self._maybe_override_root_agent_label(tracked_step, payload)
513
444
  self._maybe_attach_root_query(tracked_step)
514
445
 
@@ -516,7 +447,7 @@ class RichStreamRenderer:
516
447
  self.stream_processor.track_tools_and_agents(tool_name, tool_calls_info, is_delegation_tool)
517
448
 
518
449
  # Handle tool execution
519
- self._handle_agent_step(
450
+ self.tool_controller.handle_agent_step(
520
451
  ev,
521
452
  tool_name,
522
453
  tool_args,
@@ -561,246 +492,7 @@ class RichStreamRenderer:
561
492
  if not self._root_agent_step_id:
562
493
  self._root_agent_step_id = step.step_id
563
494
 
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
495
+ # Thinking scope management is handled by ThinkingScopeController.
804
496
 
805
497
  def _apply_root_duration(self, duration_seconds: float | None) -> None:
806
498
  """Propagate the final run duration to the root agent step."""
@@ -817,33 +509,6 @@ class RichStreamRenderer:
817
509
  root_step.duration_source = root_step.duration_source or "run"
818
510
  root_step.status = "finished"
819
511
 
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
512
  @staticmethod
848
513
  def _humanize_agent_slug(value: Any) -> str | None:
849
514
  """Convert a slugified agent name into Title Case."""
@@ -868,19 +533,6 @@ class RichStreamRenderer:
868
533
  if step.duration_ms is None:
869
534
  step.duration_ms = 0
870
535
  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
536
 
885
537
  def _stop_live_display(self) -> None:
886
538
  """Stop live display and clean up."""
@@ -891,28 +543,72 @@ class RichStreamRenderer:
891
543
  if self.state.printed_final_output:
892
544
  return
893
545
 
894
- body = (self.state.final_text or "".join(self.state.buffer) or "").strip()
546
+ body = (self.state.final_text or self.state.buffer.render() or "").strip()
895
547
  if not body:
896
548
  return
897
549
 
898
550
  if getattr(self, "_transcript_mode_enabled", False):
899
551
  return
900
552
 
553
+ # When verbose=False and tokens were streamed directly, skip final panel
554
+ # The user's script will print the final result, avoiding duplication
555
+ if not self.verbose and getattr(self, "_streaming_tokens_directly", False):
556
+ # Add a newline after streaming tokens for clean separation
557
+ try:
558
+ sys.stdout.write("\n")
559
+ sys.stdout.flush()
560
+ except Exception:
561
+ pass
562
+ self.state.printed_final_output = True
563
+ return
564
+
901
565
  if self.verbose:
902
- final_panel = create_final_panel(
903
- body,
566
+ panel = build_final_panel(
567
+ self.state,
904
568
  title=self._final_panel_title(),
905
- theme=DEFAULT_RENDERER_THEME,
906
569
  )
907
- self.console.print(final_panel)
570
+ if panel is None:
571
+ return
572
+ self.console.print(panel)
908
573
  self.state.printed_final_output = True
909
574
 
575
+ def finalize(self) -> tuple[list[Any], list[Any]]:
576
+ """Compose the final transcript renderables."""
577
+ return self._compose_final_transcript()
578
+
579
+ def _compose_final_transcript(self) -> tuple[list[Any], list[Any]]:
580
+ """Build the transcript snapshot used for final summaries."""
581
+ summary_window = self._summary_window_size()
582
+ summary_window = summary_window if summary_window > 0 else None
583
+ snapshot = build_transcript_snapshot(
584
+ self.state,
585
+ self.steps,
586
+ query_text=extract_query_from_meta(self.state.meta),
587
+ meta=self.state.meta,
588
+ summary_window=summary_window,
589
+ step_status_overrides=self._build_step_status_overrides(),
590
+ )
591
+ header, body = build_transcript_view(snapshot)
592
+ self._final_transcript_snapshot = snapshot
593
+ self._final_transcript_renderables = (header, body)
594
+ return header, body
595
+
596
+ def _render_final_summary(self, header: list[Any], body: list[Any]) -> None:
597
+ """Print the composed transcript summary for non-live renders."""
598
+ renderables = list(header) + list(body)
599
+ for renderable in renderables:
600
+ try:
601
+ self.console.print(renderable)
602
+ self.console.print()
603
+ except Exception:
604
+ pass
605
+
910
606
  def on_complete(self, stats: RunStats) -> None:
911
607
  """Handle completion event."""
912
608
  self.state.finalizing_ui = True
913
609
 
914
610
  self._handle_stats_duration(stats)
915
- self._close_active_thinking_scopes(self.state.final_duration_seconds)
611
+ self.thinking_controller.close_active_scopes(self.state.final_duration_seconds)
916
612
  self._cleanup_ui_elements()
917
613
  self._finalize_display()
918
614
  self._print_completion_message()
@@ -938,18 +634,36 @@ class RichStreamRenderer:
938
634
  self._finish_running_steps()
939
635
 
940
636
  # Mark unfinished tool panels as finished
941
- self._finish_tool_panels()
637
+ self.tool_controller.finish_all_panels()
942
638
 
943
639
  def _finalize_display(self) -> None:
944
640
  """Finalize live display and render final output."""
641
+ # When verbose=False and tokens were streamed directly, skip live display updates
642
+ # to avoid showing duplicate final result
643
+ if not self.verbose and getattr(self, "_streaming_tokens_directly", False):
644
+ # Just add a newline after streaming tokens for clean separation
645
+ try:
646
+ sys.stdout.write("\n")
647
+ sys.stdout.flush()
648
+ except Exception:
649
+ pass
650
+ self._stop_live_display()
651
+ self.state.printed_final_output = True
652
+ return
653
+
945
654
  # Final refresh
946
655
  self._ensure_live()
947
656
 
657
+ header, body = self.finalize()
658
+
948
659
  # Stop live display
949
660
  self._stop_live_display()
950
661
 
951
662
  # Render final output based on configuration
952
- self._print_final_panel_if_needed()
663
+ if self.cfg.live:
664
+ self._print_final_panel_if_needed()
665
+ else:
666
+ self._render_final_summary(header, body)
953
667
 
954
668
  def _print_completion_message(self) -> None:
955
669
  """Print completion message based on current mode."""
@@ -969,6 +683,10 @@ class RichStreamRenderer:
969
683
  """Ensure live display is updated."""
970
684
  if getattr(self, "_transcript_mode_enabled", False):
971
685
  return
686
+ # When verbose=False, don't start Live display if we're streaming tokens directly
687
+ # This prevents Live from intercepting stdout and causing tokens to appear on separate lines
688
+ if not self.verbose and getattr(self, "_streaming_tokens_directly", False):
689
+ return
972
690
  if not self._ensure_live_stack():
973
691
  return
974
692
 
@@ -1021,13 +739,18 @@ class RichStreamRenderer:
1021
739
  if not self.live:
1022
740
  return
1023
741
 
1024
- main_panel = self._render_main_panel()
1025
- steps_renderable = self._render_steps_text()
742
+ steps_body = self._render_steps_text()
743
+ template_panel = getattr(self, "_last_steps_panel_template", None)
744
+ if template_panel is None:
745
+ template_panel = self._resolve_steps_panel()
1026
746
  steps_panel = AIPPanel(
1027
- steps_renderable,
1028
- title="Steps",
1029
- border_style="blue",
747
+ steps_body,
748
+ title=getattr(template_panel, "title", "Steps"),
749
+ border_style=getattr(template_panel, "border_style", "blue"),
750
+ padding=getattr(template_panel, "padding", (0, 1)),
1030
751
  )
752
+
753
+ main_panel = self._render_main_panel()
1031
754
  panels = self._build_live_panels(main_panel, steps_panel)
1032
755
 
1033
756
  self.live.update(Group(*panels))
@@ -1045,26 +768,19 @@ class RichStreamRenderer:
1045
768
 
1046
769
  def _render_main_panel(self) -> Any:
1047
770
  """Render the main content panel."""
1048
- body = "".join(self.state.buffer).strip()
771
+ body = self.state.buffer.render().strip()
772
+ theme = DEFAULT_TRANSCRIPT_THEME
1049
773
  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
- )
774
+ panel = build_final_panel(self.state, theme=theme)
775
+ if panel is not None:
776
+ return panel
1058
777
  # Dynamic title with spinner + elapsed/hints
1059
778
  title = self._format_enhanced_main_title()
1060
- return create_main_panel(body, title, DEFAULT_RENDERER_THEME)
779
+ return create_main_panel(body, title, theme)
1061
780
 
1062
781
  def _final_panel_title(self) -> str:
1063
782
  """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
783
+ return format_final_panel_title(self.state)
1068
784
 
1069
785
  def apply_verbosity(self, verbose: bool) -> None:
1070
786
  """Update verbose behaviour at runtime."""
@@ -1083,550 +799,16 @@ class RichStreamRenderer:
1083
799
  if self.cfg.live:
1084
800
  self._ensure_live()
1085
801
 
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)
802
+ # Transcript helper implementations live in TranscriptModeMixin.
1224
803
 
1225
804
  def get_aggregated_output(self) -> str:
1226
805
  """Return the concatenated assistant output collected so far."""
1227
- return ("".join(self.state.buffer or [])).strip()
806
+ return self.state.buffer.render().strip()
1228
807
 
1229
808
  def get_transcript_events(self) -> list[dict[str, Any]]:
1230
809
  """Return captured SSE events."""
1231
810
  return list(self.state.events)
1232
811
 
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
812
  def _format_working_indicator(self, started_at: float | None) -> str:
1631
813
  """Format working indicator."""
1632
814
  return format_working_indicator(
@@ -1786,18 +968,7 @@ class RichStreamRenderer:
1786
968
 
1787
969
  def _resolve_step_label(self, step: Step) -> str:
1788
970
  """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)
971
+ return format_step_label(step)
1801
972
 
1802
973
  def _check_parallel_tools(self) -> dict[tuple[str | None, str | None], list]:
1803
974
  """Check for parallel running tools."""
@@ -1818,348 +989,69 @@ class RichStreamRenderer:
1818
989
  key = (step.task_id, step.context_id)
1819
990
  return len(running_by_ctx.get(key, [])) > 1
1820
991
 
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,
992
+ def _build_step_status_overrides(self) -> dict[str, str]:
993
+ """Return status text overrides for steps (running duration badges)."""
994
+ overrides: dict[str, str] = {}
995
+ for sid in self.steps.order:
996
+ step = self.steps.by_id.get(sid)
997
+ if not step:
998
+ continue
999
+ try:
1000
+ status_text = self._format_step_status(step)
1001
+ except Exception:
1002
+ status_text = ""
1003
+ if status_text:
1004
+ overrides[sid] = status_text
1005
+ return overrides
1006
+
1007
+ def _resolve_steps_panel(self) -> AIPPanel:
1008
+ """Return the shared steps panel renderable generated by layout helpers."""
1009
+ window_arg = self._summary_window_size()
1010
+ window_arg = window_arg if window_arg > 0 else None
1011
+ panels = render_summary_panels(
1012
+ self.state,
1013
+ self.steps,
1014
+ summary_window=window_arg,
1015
+ include_query_panel=False,
1016
+ include_final_panel=False,
1017
+ step_status_overrides=self._build_step_status_overrides(),
2144
1018
  )
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
1019
+ steps_panel = next((panel for panel in panels if getattr(panel, "title", "").lower() == "steps"), None)
1020
+ panel_cls = AIPPanel if isinstance(AIPPanel, type) else None
1021
+ if steps_panel is not None and (panel_cls is None or isinstance(steps_panel, panel_cls)):
1022
+ return steps_panel
1023
+ return AIPPanel(_NO_STEPS_TEXT.copy(), title="Steps", border_style="blue")
1024
+
1025
+ def _prepare_steps_renderable(self, *, include_progress: bool) -> tuple[AIPPanel, Any]:
1026
+ """Return the template panel and content renderable for steps."""
1027
+ panel = self._resolve_steps_panel()
1028
+ self._last_steps_panel_template = panel
1029
+ base_renderable: Any = getattr(panel, "renderable", panel)
1030
+
1031
+ if include_progress and not self.state.finalizing_ui:
1032
+ footer = build_progress_footer(
1033
+ state=self.state,
1034
+ steps=self.steps,
1035
+ started_at=self._started_at,
1036
+ server_elapsed_time=self.stream_processor.server_elapsed_time,
1037
+ )
1038
+ if footer is not None:
1039
+ if isinstance(base_renderable, Group):
1040
+ base_renderable = Group(*base_renderable.renderables, footer)
1041
+ else:
1042
+ base_renderable = Group(base_renderable, footer)
1043
+ return panel, base_renderable
1044
+
1045
+ def _build_steps_body(self, *, include_progress: bool) -> Any:
1046
+ """Return the rendered steps body with optional progress footer."""
1047
+ _, renderable = self._prepare_steps_renderable(include_progress=include_progress)
1048
+ if isinstance(renderable, Group):
1049
+ return renderable
1050
+ return Group(renderable)
2158
1051
 
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
1052
+ def _render_steps_text(self) -> Any:
1053
+ """Return the rendered steps body used by transcript capture."""
1054
+ return self._build_steps_body(include_progress=True)
2163
1055
 
2164
1056
  def _summary_window_size(self) -> int:
2165
1057
  """Return the active window size for step display."""
@@ -2167,32 +1059,6 @@ class RichStreamRenderer:
2167
1059
  return 0
2168
1060
  return int(self.cfg.summary_display_window or 0)
2169
1061
 
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
1062
  def _update_final_duration(self, duration: float | None, *, overwrite: bool = False) -> None:
2197
1063
  """Store formatted duration for eventual final panels."""
2198
1064
  if duration is None:
@@ -2211,73 +1077,6 @@ class RichStreamRenderer:
2211
1077
  if overwrite and existing is not None:
2212
1078
  duration_val = max(existing, duration_val)
2213
1079
 
2214
- self.state.final_duration_seconds = duration_val
2215
- self.state.final_duration_text = self._format_elapsed_time(duration_val)
1080
+ formatted = format_elapsed_time(duration_val)
1081
+ self.state.mark_final_duration(duration_val, formatted=formatted)
2216
1082
  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)