glaip-sdk 0.1.2__py3-none-any.whl → 0.7.17__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 (217) 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 +1413 -0
  5. glaip_sdk/branding.py +126 -2
  6. glaip_sdk/cli/account_store.py +555 -0
  7. glaip_sdk/cli/auth.py +260 -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/__init__.py +116 -0
  11. glaip_sdk/cli/commands/agents/_common.py +562 -0
  12. glaip_sdk/cli/commands/agents/create.py +155 -0
  13. glaip_sdk/cli/commands/agents/delete.py +64 -0
  14. glaip_sdk/cli/commands/agents/get.py +89 -0
  15. glaip_sdk/cli/commands/agents/list.py +129 -0
  16. glaip_sdk/cli/commands/agents/run.py +264 -0
  17. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  18. glaip_sdk/cli/commands/agents/update.py +112 -0
  19. glaip_sdk/cli/commands/common_config.py +104 -0
  20. glaip_sdk/cli/commands/configure.py +728 -113
  21. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  22. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  23. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  24. glaip_sdk/cli/commands/mcps/create.py +152 -0
  25. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  26. glaip_sdk/cli/commands/mcps/get.py +212 -0
  27. glaip_sdk/cli/commands/mcps/list.py +69 -0
  28. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  29. glaip_sdk/cli/commands/mcps/update.py +190 -0
  30. glaip_sdk/cli/commands/models.py +12 -8
  31. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  32. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  33. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  34. glaip_sdk/cli/commands/tools/_common.py +80 -0
  35. glaip_sdk/cli/commands/tools/create.py +228 -0
  36. glaip_sdk/cli/commands/tools/delete.py +61 -0
  37. glaip_sdk/cli/commands/tools/get.py +103 -0
  38. glaip_sdk/cli/commands/tools/list.py +69 -0
  39. glaip_sdk/cli/commands/tools/script.py +49 -0
  40. glaip_sdk/cli/commands/tools/update.py +102 -0
  41. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  42. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  43. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  44. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  45. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  46. glaip_sdk/cli/commands/update.py +163 -17
  47. glaip_sdk/cli/config.py +49 -4
  48. glaip_sdk/cli/constants.py +38 -0
  49. glaip_sdk/cli/context.py +8 -0
  50. glaip_sdk/cli/core/__init__.py +79 -0
  51. glaip_sdk/cli/core/context.py +124 -0
  52. glaip_sdk/cli/core/output.py +851 -0
  53. glaip_sdk/cli/core/prompting.py +649 -0
  54. glaip_sdk/cli/core/rendering.py +187 -0
  55. glaip_sdk/cli/display.py +41 -20
  56. glaip_sdk/cli/entrypoint.py +20 -0
  57. glaip_sdk/cli/hints.py +57 -0
  58. glaip_sdk/cli/io.py +6 -3
  59. glaip_sdk/cli/main.py +340 -143
  60. glaip_sdk/cli/masking.py +21 -33
  61. glaip_sdk/cli/pager.py +12 -13
  62. glaip_sdk/cli/parsers/__init__.py +1 -3
  63. glaip_sdk/cli/resolution.py +2 -1
  64. glaip_sdk/cli/slash/__init__.py +0 -9
  65. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  66. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  67. glaip_sdk/cli/slash/agent_session.py +62 -21
  68. glaip_sdk/cli/slash/prompt.py +21 -0
  69. glaip_sdk/cli/slash/remote_runs_controller.py +568 -0
  70. glaip_sdk/cli/slash/session.py +1105 -153
  71. glaip_sdk/cli/slash/tui/__init__.py +36 -0
  72. glaip_sdk/cli/slash/tui/accounts.tcss +177 -0
  73. glaip_sdk/cli/slash/tui/accounts_app.py +1853 -0
  74. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  75. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  76. glaip_sdk/cli/slash/tui/context.py +92 -0
  77. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  78. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  79. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  80. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  81. glaip_sdk/cli/slash/tui/loading.py +80 -0
  82. glaip_sdk/cli/slash/tui/remote_runs_app.py +760 -0
  83. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  84. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  85. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  86. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  87. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  88. glaip_sdk/cli/slash/tui/toast.py +388 -0
  89. glaip_sdk/cli/transcript/__init__.py +12 -52
  90. glaip_sdk/cli/transcript/cache.py +255 -44
  91. glaip_sdk/cli/transcript/capture.py +66 -1
  92. glaip_sdk/cli/transcript/history.py +815 -0
  93. glaip_sdk/cli/transcript/viewer.py +72 -463
  94. glaip_sdk/cli/tui_settings.py +125 -0
  95. glaip_sdk/cli/update_notifier.py +227 -10
  96. glaip_sdk/cli/validators.py +5 -6
  97. glaip_sdk/client/__init__.py +3 -1
  98. glaip_sdk/client/_schedule_payloads.py +89 -0
  99. glaip_sdk/client/agent_runs.py +147 -0
  100. glaip_sdk/client/agents.py +576 -44
  101. glaip_sdk/client/base.py +26 -0
  102. glaip_sdk/client/hitl.py +136 -0
  103. glaip_sdk/client/main.py +25 -14
  104. glaip_sdk/client/mcps.py +165 -24
  105. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  106. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +63 -47
  107. glaip_sdk/client/payloads/agent/responses.py +43 -0
  108. glaip_sdk/client/run_rendering.py +546 -92
  109. glaip_sdk/client/schedules.py +439 -0
  110. glaip_sdk/client/shared.py +21 -0
  111. glaip_sdk/client/tools.py +206 -32
  112. glaip_sdk/config/constants.py +33 -2
  113. glaip_sdk/guardrails/__init__.py +80 -0
  114. glaip_sdk/guardrails/serializer.py +89 -0
  115. glaip_sdk/hitl/__init__.py +48 -0
  116. glaip_sdk/hitl/base.py +64 -0
  117. glaip_sdk/hitl/callback.py +43 -0
  118. glaip_sdk/hitl/local.py +121 -0
  119. glaip_sdk/hitl/remote.py +523 -0
  120. glaip_sdk/mcps/__init__.py +21 -0
  121. glaip_sdk/mcps/base.py +345 -0
  122. glaip_sdk/models/__init__.py +136 -0
  123. glaip_sdk/models/_provider_mappings.py +101 -0
  124. glaip_sdk/models/_validation.py +97 -0
  125. glaip_sdk/models/agent.py +48 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/constants.py +141 -0
  129. glaip_sdk/models/mcp.py +33 -0
  130. glaip_sdk/models/model.py +170 -0
  131. glaip_sdk/models/schedule.py +224 -0
  132. glaip_sdk/models/tool.py +33 -0
  133. glaip_sdk/payload_schemas/__init__.py +1 -13
  134. glaip_sdk/payload_schemas/agent.py +1 -0
  135. glaip_sdk/payload_schemas/guardrails.py +34 -0
  136. glaip_sdk/registry/__init__.py +55 -0
  137. glaip_sdk/registry/agent.py +164 -0
  138. glaip_sdk/registry/base.py +139 -0
  139. glaip_sdk/registry/mcp.py +253 -0
  140. glaip_sdk/registry/tool.py +445 -0
  141. glaip_sdk/rich_components.py +58 -2
  142. glaip_sdk/runner/__init__.py +76 -0
  143. glaip_sdk/runner/base.py +84 -0
  144. glaip_sdk/runner/deps.py +115 -0
  145. glaip_sdk/runner/langgraph.py +1055 -0
  146. glaip_sdk/runner/logging_config.py +77 -0
  147. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  148. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  149. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  150. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +116 -0
  151. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  152. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  153. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  154. glaip_sdk/schedules/__init__.py +22 -0
  155. glaip_sdk/schedules/base.py +291 -0
  156. glaip_sdk/tools/__init__.py +22 -0
  157. glaip_sdk/tools/base.py +488 -0
  158. glaip_sdk/utils/__init__.py +59 -12
  159. glaip_sdk/utils/a2a/__init__.py +34 -0
  160. glaip_sdk/utils/a2a/event_processor.py +188 -0
  161. glaip_sdk/utils/agent_config.py +8 -2
  162. glaip_sdk/utils/bundler.py +403 -0
  163. glaip_sdk/utils/client.py +111 -0
  164. glaip_sdk/utils/client_utils.py +39 -7
  165. glaip_sdk/utils/datetime_helpers.py +58 -0
  166. glaip_sdk/utils/discovery.py +78 -0
  167. glaip_sdk/utils/display.py +23 -15
  168. glaip_sdk/utils/export.py +143 -0
  169. glaip_sdk/utils/general.py +0 -33
  170. glaip_sdk/utils/import_export.py +12 -7
  171. glaip_sdk/utils/import_resolver.py +524 -0
  172. glaip_sdk/utils/instructions.py +101 -0
  173. glaip_sdk/utils/rendering/__init__.py +115 -1
  174. glaip_sdk/utils/rendering/formatting.py +5 -30
  175. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  176. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  177. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  178. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  179. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  180. glaip_sdk/utils/rendering/models.py +1 -0
  181. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  182. glaip_sdk/utils/rendering/renderer/base.py +299 -1434
  183. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  184. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  185. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  186. glaip_sdk/utils/rendering/renderer/stream.py +4 -33
  187. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  188. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  189. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  190. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  191. glaip_sdk/utils/rendering/state.py +204 -0
  192. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  193. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  194. glaip_sdk/utils/rendering/steps/format.py +176 -0
  195. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  196. glaip_sdk/utils/rendering/timing.py +36 -0
  197. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  198. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  199. glaip_sdk/utils/resource_refs.py +25 -13
  200. glaip_sdk/utils/runtime_config.py +426 -0
  201. glaip_sdk/utils/serialization.py +18 -0
  202. glaip_sdk/utils/sync.py +162 -0
  203. glaip_sdk/utils/tool_detection.py +301 -0
  204. glaip_sdk/utils/tool_storage_provider.py +140 -0
  205. glaip_sdk/utils/validation.py +16 -24
  206. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/METADATA +69 -23
  207. glaip_sdk-0.7.17.dist-info/RECORD +224 -0
  208. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/WHEEL +2 -1
  209. glaip_sdk-0.7.17.dist-info/entry_points.txt +2 -0
  210. glaip_sdk-0.7.17.dist-info/top_level.txt +1 -0
  211. glaip_sdk/cli/commands/agents.py +0 -1369
  212. glaip_sdk/cli/commands/mcps.py +0 -1187
  213. glaip_sdk/cli/commands/tools.py +0 -584
  214. glaip_sdk/cli/utils.py +0 -1278
  215. glaip_sdk/models.py +0 -240
  216. glaip_sdk-0.1.2.dist-info/RECORD +0 -82
  217. glaip_sdk-0.1.2.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,154 +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.steps import 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
66
+
67
+ _NO_STEPS_TEXT = Text("No steps yet", style="dim")
54
68
 
55
69
  # Configure logger
56
70
  logger = logging.getLogger("glaip_sdk.run_renderer")
57
71
 
58
72
  # 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
73
  RUNNING_STATUS_HINTS = {"running", "started", "pending", "working"}
70
74
  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
75
 
131
- @dataclass
132
- class ThinkingScopeState:
133
- """Runtime bookkeeping for deterministic thinking spans."""
134
76
 
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
-
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:
77
+ class RichStreamRenderer(TranscriptModeMixin):
169
78
  """Live, modern terminal renderer for agent execution with rich visual output."""
170
79
 
171
80
  def __init__(
@@ -174,6 +83,8 @@ class RichStreamRenderer:
174
83
  *,
175
84
  cfg: RendererConfig | None = None,
176
85
  verbose: bool = False,
86
+ transcript_buffer: TranscriptBuffer | None = None,
87
+ callbacks: dict[str, Any] | None = None,
177
88
  ) -> None:
178
89
  """Initialize the renderer.
179
90
 
@@ -181,7 +92,10 @@ class RichStreamRenderer:
181
92
  console: Rich console instance
182
93
  cfg: Renderer configuration
183
94
  verbose: Whether to enable verbose mode
95
+ transcript_buffer: Optional transcript buffer for capturing output
96
+ callbacks: Optional dictionary of callback functions
184
97
  """
98
+ super().__init__()
185
99
  self.console = console or RichConsole()
186
100
  self.cfg = cfg or RendererConfig()
187
101
  self.verbose = verbose
@@ -189,16 +103,32 @@ class RichStreamRenderer:
189
103
  # Initialize components
190
104
  self.stream_processor = StreamProcessor()
191
105
  self.state = RendererState()
106
+ if transcript_buffer is not None:
107
+ self.state.buffer = transcript_buffer
108
+
109
+ self._callbacks = callbacks or {}
192
110
 
193
111
  # Initialize step manager and other state
194
112
  self.steps = StepManager(max_steps=self.cfg.summary_max_steps)
195
113
  # Live display instance (single source of truth)
196
114
  self.live: Live | None = None
197
115
  self._step_spinners: dict[str, Spinner] = {}
116
+ self._last_steps_panel_template: Any | None = None
198
117
 
199
118
  # Tool tracking and thinking scopes
200
- self.tool_panels: dict[str, dict[str, Any]] = {}
201
- 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
+ )
202
132
  self._root_agent_friendly: str | None = None
203
133
  self._root_agent_step_id: str | None = None
204
134
  self._root_query: str | None = None
@@ -210,21 +140,11 @@ class RichStreamRenderer:
210
140
  # Header/text
211
141
  self.header_text: str = ""
212
142
  # Track per-step server start times for accurate elapsed labels
213
- self._step_server_start_times: dict[str, float] = {}
214
-
215
143
  # Output formatting constants
216
144
  self.OUTPUT_PREFIX: str = "**Output:**\n"
217
145
 
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 = "[dim]Transcript view · Press Ctrl+T to return to the summary.[/dim]"
223
- self._summary_hint_message = "[dim]Press Ctrl+T to inspect raw transcript events.[/dim]"
224
- self._summary_hint_printed_once: bool = False
225
- self._transcript_hint_printed_once: bool = False
226
- self._transcript_header_printed: bool = False
227
- 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
228
148
 
229
149
  def on_start(self, meta: dict[str, Any]) -> None:
230
150
  """Handle renderer start event."""
@@ -242,7 +162,7 @@ class RichStreamRenderer:
242
162
  meta_payload = meta or {}
243
163
  self.steps.set_root_agent(meta_payload.get("agent_id"))
244
164
  self._root_agent_friendly = self._humanize_agent_slug(meta_payload.get("agent_name"))
245
- self._root_query = _truncate_display(
165
+ self._root_query = truncate_display(
246
166
  meta_payload.get("input_message")
247
167
  or meta_payload.get("query")
248
168
  or meta_payload.get("message")
@@ -302,20 +222,6 @@ class RichStreamRenderer:
302
222
  except Exception:
303
223
  logger.exception("Failed to print header fallback")
304
224
 
305
- def _extract_query_from_meta(self, meta: dict[str, Any] | None) -> str | None:
306
- """Extract the primary query string from a metadata payload."""
307
- if not meta:
308
- return None
309
- query = (
310
- meta.get("input_message")
311
- or meta.get("query")
312
- or meta.get("message")
313
- or (meta.get("meta") or {}).get("input_message")
314
- )
315
- if isinstance(query, str) and query.strip():
316
- return query
317
- return None
318
-
319
225
  def _build_user_query_panel(self, query: str) -> AIPPanel:
320
226
  """Create the panel used to display the user request."""
321
227
  return AIPPanel(
@@ -327,7 +233,7 @@ class RichStreamRenderer:
327
233
 
328
234
  def _render_user_query(self, meta: dict[str, Any]) -> None:
329
235
  """Render the user query panel."""
330
- query = self._extract_query_from_meta(meta)
236
+ query = extract_query_from_meta(meta)
331
237
  if not query:
332
238
  return
333
239
  self.console.print(self._build_user_query_panel(query))
@@ -340,13 +246,42 @@ class RichStreamRenderer:
340
246
  elif self.header_text and not self._render_header_rule():
341
247
  self._render_header_fallback()
342
248
 
343
- query = self._extract_query_from_meta(meta) or self._root_query
249
+ query = extract_query_from_meta(meta) or self._root_query
344
250
  if query:
345
251
  self.console.print(self._build_user_query_panel(query))
346
252
 
253
+ def _render_summary_after_transcript_toggle(self) -> None:
254
+ """Render the summary panel after leaving transcript mode."""
255
+ if self.state.finalizing_ui:
256
+ self._render_final_summary_panels()
257
+ elif self.live:
258
+ self._refresh_live_panels()
259
+ else:
260
+ self._render_static_summary_panels()
261
+
262
+ def _render_final_summary_panels(self) -> None:
263
+ """Render a static summary and disable live mode for final output."""
264
+ self.cfg.live = False
265
+ self.live = None
266
+ self._render_static_summary_panels()
267
+
268
+ def _render_static_summary_panels(self) -> None:
269
+ """Render the steps and main panels in a static (non-live) layout."""
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)
281
+
347
282
  def _ensure_streaming_started_baseline(self, timestamp: float) -> None:
348
283
  """Synchronize streaming start state across renderer components."""
349
- self.state.streaming_started_at = timestamp
284
+ self.state.start_stream_timer(timestamp)
350
285
  self.stream_processor.streaming_started_at = timestamp
351
286
  self._started_at = timestamp
352
287
 
@@ -368,7 +303,7 @@ class RichStreamRenderer:
368
303
 
369
304
  def _resolve_received_timestamp(self, ev: dict[str, Any]) -> datetime:
370
305
  """Return the timestamp an event was received, normalising inputs."""
371
- received_at = _coerce_received_at(ev.get("received_at"))
306
+ received_at = coerce_received_at(ev.get("received_at"))
372
307
  if received_at is None:
373
308
  received_at = datetime.now(timezone.utc)
374
309
 
@@ -415,6 +350,9 @@ class RichStreamRenderer:
415
350
  self._handle_status_event(ev)
416
351
  elif kind == "content":
417
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)
418
356
  elif kind == "final_response":
419
357
  self._handle_final_response_event(content, metadata)
420
358
  elif kind in {"agent_step", "agent_thinking_step"}:
@@ -431,43 +369,62 @@ class RichStreamRenderer:
431
369
  def _handle_content_event(self, content: str) -> None:
432
370
  """Handle content streaming events."""
433
371
  if content:
434
- self.state.buffer.append(content)
372
+ self.state.append_transcript_text(content)
435
373
  self._ensure_live()
436
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
+
437
400
  def _handle_final_response_event(self, content: str, metadata: dict[str, Any]) -> None:
438
401
  """Handle final response events."""
439
402
  if content:
440
- self.state.buffer.append(content)
441
- self.state.final_text = content
403
+ self.state.append_transcript_text(content)
404
+ self.state.set_final_output(content)
442
405
 
443
406
  meta_payload = metadata.get("metadata") or {}
444
- final_time = self._coerce_server_time(meta_payload.get("time"))
407
+ final_time = coerce_server_time(meta_payload.get("time"))
445
408
  self._update_final_duration(final_time)
446
- self._close_active_thinking_scopes(final_time)
409
+ self.thinking_controller.close_active_scopes(final_time)
447
410
  self._finish_running_steps()
448
- self._finish_tool_panels()
411
+ self.tool_controller.finish_all_panels()
449
412
  self._normalise_finished_icons()
450
413
 
451
414
  self._ensure_live()
452
415
  self._print_final_panel_if_needed()
453
416
 
454
417
  def _normalise_finished_icons(self) -> None:
455
- """Ensure finished steps do not keep spinner icons."""
418
+ """Ensure finished steps release any running spinners."""
456
419
  for step in self.steps.by_id.values():
457
- if getattr(step, "status", None) == "finished" and getattr(step, "status_icon", None) == "spinner":
458
- step.status_icon = "success"
459
420
  if getattr(step, "status", None) != "running":
460
421
  self._step_spinners.pop(step.step_id, None)
461
422
 
462
423
  def _handle_agent_step_event(self, ev: dict[str, Any], metadata: dict[str, Any]) -> None:
463
424
  """Handle agent step events."""
464
- # Extract tool information
465
- (
466
- tool_name,
467
- tool_args,
468
- tool_out,
469
- tool_calls_info,
470
- ) = 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
471
428
 
472
429
  payload = metadata.get("metadata") or {}
473
430
 
@@ -478,7 +435,11 @@ class RichStreamRenderer:
478
435
  logger.debug("Malformed step event skipped", exc_info=True)
479
436
  else:
480
437
  self._record_step_server_start(tracked_step, payload)
481
- 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
+ )
482
443
  self._maybe_override_root_agent_label(tracked_step, payload)
483
444
  self._maybe_attach_root_query(tracked_step)
484
445
 
@@ -486,7 +447,7 @@ class RichStreamRenderer:
486
447
  self.stream_processor.track_tools_and_agents(tool_name, tool_calls_info, is_delegation_tool)
487
448
 
488
449
  # Handle tool execution
489
- self._handle_agent_step(
450
+ self.tool_controller.handle_agent_step(
490
451
  ev,
491
452
  tool_name,
492
453
  tool_args,
@@ -531,246 +492,7 @@ class RichStreamRenderer:
531
492
  if not self._root_agent_step_id:
532
493
  self._root_agent_step_id = step.step_id
533
494
 
534
- def _update_thinking_timeline(self, step: Step | None, payload: dict[str, Any]) -> None:
535
- """Maintain deterministic thinking spans for each agent/delegate scope."""
536
- if not self.cfg.render_thinking or not step:
537
- return
538
-
539
- now_monotonic = monotonic()
540
- server_time = self._coerce_server_time(payload.get("time"))
541
- status_hint = (payload.get("status") or "").lower()
542
-
543
- if self._is_scope_anchor(step):
544
- self._update_anchor_thinking(
545
- step=step,
546
- server_time=server_time,
547
- status_hint=status_hint,
548
- now_monotonic=now_monotonic,
549
- )
550
- return
551
-
552
- self._update_child_thinking(
553
- step=step,
554
- server_time=server_time,
555
- status_hint=status_hint,
556
- now_monotonic=now_monotonic,
557
- )
558
-
559
- def _update_anchor_thinking(
560
- self,
561
- *,
562
- step: Step,
563
- server_time: float | None,
564
- status_hint: str,
565
- now_monotonic: float,
566
- ) -> None:
567
- """Handle deterministic thinking bookkeeping for agent/delegate anchors."""
568
- scope = self._get_or_create_scope(step)
569
- if scope.anchor_started_at is None and server_time is not None:
570
- scope.anchor_started_at = server_time
571
-
572
- if not scope.closed and scope.active_thinking_id is None:
573
- self._start_scope_thinking(
574
- scope,
575
- start_server_time=scope.anchor_started_at or server_time,
576
- start_monotonic=now_monotonic,
577
- )
578
-
579
- is_anchor_finished = status_hint in FINISHED_STATUS_HINTS or (not status_hint and is_step_finished(step))
580
- if is_anchor_finished:
581
- scope.anchor_finished_at = server_time or scope.anchor_finished_at
582
- self._finish_scope_thinking(scope, server_time, now_monotonic)
583
- scope.closed = True
584
-
585
- parent_anchor_id = self._resolve_anchor_id(step)
586
- if parent_anchor_id:
587
- self._cascade_anchor_update(
588
- parent_anchor_id=parent_anchor_id,
589
- child_step=step,
590
- server_time=server_time,
591
- now_monotonic=now_monotonic,
592
- is_finished=is_anchor_finished,
593
- )
594
-
595
- def _cascade_anchor_update(
596
- self,
597
- *,
598
- parent_anchor_id: str,
599
- child_step: Step,
600
- server_time: float | None,
601
- now_monotonic: float,
602
- is_finished: bool,
603
- ) -> None:
604
- """Propagate anchor state changes to the parent scope."""
605
- parent_scope = self._thinking_scopes.get(parent_anchor_id)
606
- if not parent_scope or parent_scope.closed:
607
- return
608
- if is_finished:
609
- self._mark_child_finished(parent_scope, child_step.step_id, server_time, now_monotonic)
610
- else:
611
- self._mark_child_running(parent_scope, child_step, server_time, now_monotonic)
612
-
613
- def _update_child_thinking(
614
- self,
615
- *,
616
- step: Step,
617
- server_time: float | None,
618
- status_hint: str,
619
- now_monotonic: float,
620
- ) -> None:
621
- """Update deterministic thinking state for non-anchor steps."""
622
- anchor_id = self._resolve_anchor_id(step)
623
- if not anchor_id:
624
- return
625
-
626
- scope = self._thinking_scopes.get(anchor_id)
627
- if not scope or scope.closed or step.kind == "thinking":
628
- return
629
-
630
- is_finish_event = status_hint in FINISHED_STATUS_HINTS or (not status_hint and is_step_finished(step))
631
- if is_finish_event:
632
- self._mark_child_finished(scope, step.step_id, server_time, now_monotonic)
633
- else:
634
- self._mark_child_running(scope, step, server_time, now_monotonic)
635
-
636
- def _resolve_anchor_id(self, step: Step) -> str | None:
637
- """Return the nearest agent/delegate ancestor for a step."""
638
- parent_id = step.parent_id
639
- while parent_id:
640
- parent = self.steps.by_id.get(parent_id)
641
- if not parent:
642
- return None
643
- if self._is_scope_anchor(parent):
644
- return parent.step_id
645
- parent_id = parent.parent_id
646
- return None
647
-
648
- def _get_or_create_scope(self, step: Step) -> ThinkingScopeState:
649
- """Fetch (or create) thinking state for the given anchor step."""
650
- scope = self._thinking_scopes.get(step.step_id)
651
- if scope:
652
- if scope.task_id is None:
653
- scope.task_id = step.task_id
654
- if scope.context_id is None:
655
- scope.context_id = step.context_id
656
- return scope
657
- scope = ThinkingScopeState(
658
- anchor_id=step.step_id,
659
- task_id=step.task_id,
660
- context_id=step.context_id,
661
- )
662
- self._thinking_scopes[step.step_id] = scope
663
- return scope
664
-
665
- def _is_scope_anchor(self, step: Step) -> bool:
666
- """Return True when a step should host its own thinking timeline."""
667
- if step.kind in {"agent", "delegate"}:
668
- return True
669
- name = (step.name or "").lower()
670
- return name.startswith(("delegate_to_", "delegate_", "delegate "))
671
-
672
- def _start_scope_thinking(
673
- self,
674
- scope: ThinkingScopeState,
675
- *,
676
- start_server_time: float | None,
677
- start_monotonic: float,
678
- ) -> None:
679
- """Open a deterministic thinking node beneath the scope anchor."""
680
- if scope.closed or scope.active_thinking_id or not scope.anchor_id:
681
- return
682
- step = self.steps.start_or_get(
683
- task_id=scope.task_id,
684
- context_id=scope.context_id,
685
- kind="thinking",
686
- name=f"agent_thinking_step::{scope.anchor_id}",
687
- parent_id=scope.anchor_id,
688
- args={"reason": "deterministic_timeline"},
689
- )
690
- step.display_label = "💭 Thinking…"
691
- step.status_icon = "spinner"
692
- scope.active_thinking_id = step.step_id
693
- scope.idle_started_at = start_server_time
694
- scope.idle_started_monotonic = start_monotonic
695
-
696
- def _finish_scope_thinking(
697
- self,
698
- scope: ThinkingScopeState,
699
- end_server_time: float | None,
700
- end_monotonic: float,
701
- ) -> None:
702
- """Close the currently running thinking node if one exists."""
703
- if not scope.active_thinking_id:
704
- return
705
- thinking_step = self.steps.by_id.get(scope.active_thinking_id)
706
- if not thinking_step:
707
- scope.active_thinking_id = None
708
- scope.idle_started_at = None
709
- scope.idle_started_monotonic = None
710
- return
711
-
712
- duration = self._calculate_timeline_duration(
713
- scope.idle_started_at,
714
- end_server_time,
715
- scope.idle_started_monotonic,
716
- end_monotonic,
717
- )
718
- thinking_step.display_label = thinking_step.display_label or "💭 Thinking…"
719
- if duration is not None:
720
- thinking_step.finish(duration, source="timeline")
721
- else:
722
- thinking_step.finish(None, source="timeline")
723
- thinking_step.status_icon = "success"
724
- scope.active_thinking_id = None
725
- scope.idle_started_at = None
726
- scope.idle_started_monotonic = None
727
-
728
- def _mark_child_running(
729
- self,
730
- scope: ThinkingScopeState,
731
- step: Step,
732
- server_time: float | None,
733
- now_monotonic: float,
734
- ) -> None:
735
- """Mark a direct child as running and close any open thinking node."""
736
- if step.step_id in scope.running_children:
737
- return
738
- scope.running_children.add(step.step_id)
739
- if not scope.active_thinking_id:
740
- return
741
-
742
- start_server = self._step_server_start_times.get(step.step_id)
743
- if start_server is None:
744
- start_server = server_time
745
- self._finish_scope_thinking(scope, start_server, now_monotonic)
746
-
747
- def _mark_child_finished(
748
- self,
749
- scope: ThinkingScopeState,
750
- step_id: str,
751
- server_time: float | None,
752
- now_monotonic: float,
753
- ) -> None:
754
- """Handle completion for a scope child and resume thinking if idle."""
755
- if step_id in scope.running_children:
756
- scope.running_children.discard(step_id)
757
- if scope.running_children or scope.closed:
758
- return
759
- self._start_scope_thinking(
760
- scope,
761
- start_server_time=server_time,
762
- start_monotonic=now_monotonic,
763
- )
764
-
765
- def _close_active_thinking_scopes(self, server_time: float | None) -> None:
766
- """Finish any in-flight thinking nodes during finalization."""
767
- now = monotonic()
768
- for scope in self._thinking_scopes.values():
769
- if not scope.active_thinking_id:
770
- continue
771
- self._finish_scope_thinking(scope, server_time, now)
772
- scope.closed = True
773
- # Parent scopes resume thinking via _cascade_anchor_update
495
+ # Thinking scope management is handled by ThinkingScopeController.
774
496
 
775
497
  def _apply_root_duration(self, duration_seconds: float | None) -> None:
776
498
  """Propagate the final run duration to the root agent step."""
@@ -787,33 +509,6 @@ class RichStreamRenderer:
787
509
  root_step.duration_source = root_step.duration_source or "run"
788
510
  root_step.status = "finished"
789
511
 
790
- @staticmethod
791
- def _coerce_server_time(value: Any) -> float | None:
792
- """Convert a raw SSE time payload into a float if possible."""
793
- if isinstance(value, (int, float)):
794
- return float(value)
795
- try:
796
- return float(value)
797
- except (TypeError, ValueError):
798
- return None
799
-
800
- @staticmethod
801
- def _calculate_timeline_duration(
802
- start_server: float | None,
803
- end_server: float | None,
804
- start_monotonic: float | None,
805
- end_monotonic: float,
806
- ) -> float | None:
807
- """Pick the most reliable pair of timestamps to derive duration seconds."""
808
- if start_server is not None and end_server is not None:
809
- return max(0.0, float(end_server) - float(start_server))
810
- if start_monotonic is not None:
811
- try:
812
- return max(0.0, float(end_monotonic) - float(start_monotonic))
813
- except Exception:
814
- return None
815
- return None
816
-
817
512
  @staticmethod
818
513
  def _humanize_agent_slug(value: Any) -> str | None:
819
514
  """Convert a slugified agent name into Title Case."""
@@ -838,19 +533,6 @@ class RichStreamRenderer:
838
533
  if step.duration_ms is None:
839
534
  step.duration_ms = 0
840
535
  step.duration_source = step.duration_source or "unknown"
841
- step.status_icon = "warning"
842
-
843
- def _finish_tool_panels(self) -> None:
844
- """Mark unfinished tool panels as finished."""
845
- try:
846
- items = list(self.tool_panels.items())
847
- except Exception: # pragma: no cover - defensive guard
848
- logger.exception("Failed to iterate tool panels during cleanup")
849
- return
850
-
851
- for _sid, meta in items:
852
- if meta.get("status") != "finished":
853
- meta["status"] = "finished"
854
536
 
855
537
  def _stop_live_display(self) -> None:
856
538
  """Stop live display and clean up."""
@@ -861,28 +543,72 @@ class RichStreamRenderer:
861
543
  if self.state.printed_final_output:
862
544
  return
863
545
 
864
- 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()
865
547
  if not body:
866
548
  return
867
549
 
868
550
  if getattr(self, "_transcript_mode_enabled", False):
869
551
  return
870
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
+
871
565
  if self.verbose:
872
- final_panel = create_final_panel(
873
- body,
566
+ panel = build_final_panel(
567
+ self.state,
874
568
  title=self._final_panel_title(),
875
- theme=self.cfg.theme,
876
569
  )
877
- self.console.print(final_panel)
570
+ if panel is None:
571
+ return
572
+ self.console.print(panel)
878
573
  self.state.printed_final_output = True
879
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
+
880
606
  def on_complete(self, stats: RunStats) -> None:
881
607
  """Handle completion event."""
882
608
  self.state.finalizing_ui = True
883
609
 
884
610
  self._handle_stats_duration(stats)
885
- self._close_active_thinking_scopes(self.state.final_duration_seconds)
611
+ self.thinking_controller.close_active_scopes(self.state.final_duration_seconds)
886
612
  self._cleanup_ui_elements()
887
613
  self._finalize_display()
888
614
  self._print_completion_message()
@@ -908,18 +634,36 @@ class RichStreamRenderer:
908
634
  self._finish_running_steps()
909
635
 
910
636
  # Mark unfinished tool panels as finished
911
- self._finish_tool_panels()
637
+ self.tool_controller.finish_all_panels()
912
638
 
913
639
  def _finalize_display(self) -> None:
914
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
+
915
654
  # Final refresh
916
655
  self._ensure_live()
917
656
 
657
+ header, body = self.finalize()
658
+
918
659
  # Stop live display
919
660
  self._stop_live_display()
920
661
 
921
662
  # Render final output based on configuration
922
- 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)
923
667
 
924
668
  def _print_completion_message(self) -> None:
925
669
  """Print completion message based on current mode."""
@@ -939,6 +683,10 @@ class RichStreamRenderer:
939
683
  """Ensure live display is updated."""
940
684
  if getattr(self, "_transcript_mode_enabled", False):
941
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
942
690
  if not self._ensure_live_stack():
943
691
  return
944
692
 
@@ -991,13 +739,18 @@ class RichStreamRenderer:
991
739
  if not self.live:
992
740
  return
993
741
 
994
- main_panel = self._render_main_panel()
995
- 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()
996
746
  steps_panel = AIPPanel(
997
- steps_renderable,
998
- title="Steps",
999
- 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)),
1000
751
  )
752
+
753
+ main_panel = self._render_main_panel()
1001
754
  panels = self._build_live_panels(main_panel, steps_panel)
1002
755
 
1003
756
  self.live.update(Group(*panels))
@@ -1015,26 +768,19 @@ class RichStreamRenderer:
1015
768
 
1016
769
  def _render_main_panel(self) -> Any:
1017
770
  """Render the main content panel."""
1018
- body = "".join(self.state.buffer).strip()
771
+ body = self.state.buffer.render().strip()
772
+ theme = DEFAULT_TRANSCRIPT_THEME
1019
773
  if not self.verbose:
1020
- final_content = (self.state.final_text or "").strip()
1021
- if final_content:
1022
- title = self._final_panel_title()
1023
- return create_final_panel(
1024
- final_content,
1025
- title=title,
1026
- theme=self.cfg.theme,
1027
- )
774
+ panel = build_final_panel(self.state, theme=theme)
775
+ if panel is not None:
776
+ return panel
1028
777
  # Dynamic title with spinner + elapsed/hints
1029
778
  title = self._format_enhanced_main_title()
1030
- return create_main_panel(body, title, self.cfg.theme)
779
+ return create_main_panel(body, title, theme)
1031
780
 
1032
781
  def _final_panel_title(self) -> str:
1033
782
  """Compose title for the final result panel including duration."""
1034
- title = "Final Result"
1035
- if self.state.final_duration_text:
1036
- title = f"{title} · {self.state.final_duration_text}"
1037
- return title
783
+ return format_final_panel_title(self.state)
1038
784
 
1039
785
  def apply_verbosity(self, verbose: bool) -> None:
1040
786
  """Update verbose behaviour at runtime."""
@@ -1042,8 +788,6 @@ class RichStreamRenderer:
1042
788
  return
1043
789
 
1044
790
  self.verbose = verbose
1045
- self.cfg.style = "debug" if verbose else "pretty"
1046
-
1047
791
  desired_live = not verbose
1048
792
  if desired_live != self.cfg.live:
1049
793
  self.cfg.live = desired_live
@@ -1055,560 +799,16 @@ class RichStreamRenderer:
1055
799
  if self.cfg.live:
1056
800
  self._ensure_live()
1057
801
 
1058
- # ------------------------------------------------------------------
1059
- # Transcript helpers
1060
- # ------------------------------------------------------------------
1061
- @property
1062
- def transcript_mode_enabled(self) -> bool:
1063
- """Return True when transcript mode is currently active."""
1064
- return self._transcript_mode_enabled
1065
-
1066
- def toggle_transcript_mode(self) -> None:
1067
- """Flip transcript mode on/off."""
1068
- self.set_transcript_mode(not self._transcript_mode_enabled)
1069
-
1070
- def set_transcript_mode(self, enabled: bool) -> None:
1071
- """Set transcript mode explicitly."""
1072
- if enabled == self._transcript_mode_enabled:
1073
- return
1074
-
1075
- self._transcript_mode_enabled = enabled
1076
- self.apply_verbosity(enabled)
1077
-
1078
- if enabled:
1079
- self._summary_hint_printed_once = False
1080
- self._transcript_hint_printed_once = False
1081
- self._transcript_header_printed = False
1082
- self._transcript_enabled_message_printed = False
1083
- self._stop_live_display()
1084
- self._clear_console_safe()
1085
- self._print_transcript_enabled_message()
1086
- self._render_transcript_backfill()
1087
- else:
1088
- self._transcript_hint_printed_once = False
1089
- self._transcript_header_printed = False
1090
- self._transcript_enabled_message_printed = False
1091
- self._clear_console_safe()
1092
- self._render_summary_static_sections()
1093
- summary_notice = (
1094
- "[dim]Returning to the summary view. Streaming will continue here.[/dim]"
1095
- if not self.state.finalizing_ui
1096
- else "[dim]Returning to the summary view.[/dim]"
1097
- )
1098
- self.console.print(summary_notice)
1099
- if self.live:
1100
- self._refresh_live_panels()
1101
- else:
1102
- steps_renderable = self._render_steps_text()
1103
- steps_panel = AIPPanel(
1104
- steps_renderable,
1105
- title="Steps",
1106
- border_style="blue",
1107
- )
1108
- self.console.print(steps_panel)
1109
- self.console.print(self._render_main_panel())
1110
- if not self.state.finalizing_ui:
1111
- self._print_summary_hint(force=True)
1112
-
1113
- def _clear_console_safe(self) -> None:
1114
- """Best-effort console clear that ignores platform quirks."""
1115
- try:
1116
- self.console.clear()
1117
- except Exception:
1118
- pass
1119
-
1120
- def _print_transcript_hint(self) -> None:
1121
- """Render the transcript toggle hint, keeping it near the bottom."""
1122
- if not self._transcript_mode_enabled:
1123
- return
1124
- try:
1125
- self.console.print(self._transcript_hint_message)
1126
- except Exception:
1127
- pass
1128
- else:
1129
- self._transcript_hint_printed_once = True
1130
-
1131
- def _print_transcript_enabled_message(self) -> None:
1132
- if self._transcript_enabled_message_printed:
1133
- return
1134
- try:
1135
- self.console.print("[dim]Transcript mode enabled — streaming raw transcript events.[/dim]")
1136
- except Exception:
1137
- pass
1138
- else:
1139
- self._transcript_enabled_message_printed = True
1140
-
1141
- def _ensure_transcript_header(self) -> None:
1142
- if self._transcript_header_printed:
1143
- return
1144
- try:
1145
- self.console.rule("Transcript Events")
1146
- except Exception:
1147
- self._transcript_header_printed = True
1148
- return
1149
- self._transcript_header_printed = True
1150
-
1151
- def _print_summary_hint(self, force: bool = False) -> None:
1152
- """Show the summary-mode toggle hint."""
1153
- controller = getattr(self, "transcript_controller", None)
1154
- if controller and not getattr(controller, "enabled", False):
1155
- if not force:
1156
- self._summary_hint_printed_once = True
1157
- return
1158
- if not force and self._summary_hint_printed_once:
1159
- return
1160
- try:
1161
- self.console.print(self._summary_hint_message)
1162
- except Exception:
1163
- return
1164
- self._summary_hint_printed_once = True
1165
-
1166
- def _render_transcript_backfill(self) -> None:
1167
- """Render any captured events that haven't been shown in transcript mode."""
1168
- pending = self.state.events[self._transcript_render_cursor :]
1169
- self._ensure_transcript_header()
1170
- if not pending:
1171
- self._print_transcript_hint()
1172
- return
1173
-
1174
- baseline = self.state.streaming_started_event_ts
1175
- for ev in pending:
1176
- received_ts = _coerce_received_at(ev.get("received_at"))
1177
- render_debug_event(
1178
- ev,
1179
- self.console,
1180
- received_ts=received_ts,
1181
- baseline_ts=baseline,
1182
- )
1183
-
1184
- self._transcript_render_cursor = len(self.state.events)
1185
- self._print_transcript_hint()
1186
-
1187
- def _capture_event(self, ev: dict[str, Any], received_at: datetime | None = None) -> None:
1188
- """Capture a deep copy of SSE events for transcript replay."""
1189
- try:
1190
- captured = json.loads(json.dumps(ev))
1191
- except Exception:
1192
- captured = ev
1193
-
1194
- if received_at is not None:
1195
- try:
1196
- captured["received_at"] = received_at.isoformat()
1197
- except Exception:
1198
- try:
1199
- captured["received_at"] = str(received_at)
1200
- except Exception:
1201
- captured["received_at"] = repr(received_at)
1202
-
1203
- self.state.events.append(captured)
1204
- if self._transcript_mode_enabled:
1205
- self._transcript_render_cursor = len(self.state.events)
802
+ # Transcript helper implementations live in TranscriptModeMixin.
1206
803
 
1207
804
  def get_aggregated_output(self) -> str:
1208
805
  """Return the concatenated assistant output collected so far."""
1209
- return ("".join(self.state.buffer or [])).strip()
806
+ return self.state.buffer.render().strip()
1210
807
 
1211
808
  def get_transcript_events(self) -> list[dict[str, Any]]:
1212
809
  """Return captured SSE events."""
1213
810
  return list(self.state.events)
1214
811
 
1215
- def _ensure_tool_panel(self, name: str, args: Any, task_id: str, context_id: str) -> str:
1216
- """Ensure a tool panel exists and return its ID."""
1217
- formatted_title = format_tool_title(name)
1218
- is_delegation = is_delegation_tool(name)
1219
- tool_sid = f"tool_{name}_{task_id}_{context_id}"
1220
-
1221
- if tool_sid not in self.tool_panels:
1222
- self.tool_panels[tool_sid] = {
1223
- "title": formatted_title,
1224
- "status": "running",
1225
- "started_at": monotonic(),
1226
- "server_started_at": self.stream_processor.server_elapsed_time,
1227
- "chunks": [],
1228
- "args": args or {},
1229
- "output": None,
1230
- "is_delegation": is_delegation,
1231
- }
1232
- # Add Args section once
1233
- if args:
1234
- try:
1235
- args_content = "**Args:**\n```json\n" + json.dumps(args, indent=2) + "\n```\n\n"
1236
- except Exception:
1237
- args_content = f"**Args:**\n{args}\n\n"
1238
- self.tool_panels[tool_sid]["chunks"].append(args_content)
1239
-
1240
- return tool_sid
1241
-
1242
- def _start_tool_step(
1243
- self,
1244
- task_id: str,
1245
- context_id: str,
1246
- tool_name: str,
1247
- tool_args: Any,
1248
- _tool_sid: str,
1249
- *,
1250
- tracked_step: Step | None = None,
1251
- ) -> Step | None:
1252
- """Start or get a step for a tool."""
1253
- if tracked_step is not None:
1254
- return tracked_step
1255
-
1256
- if is_delegation_tool(tool_name):
1257
- st = self.steps.start_or_get(
1258
- task_id=task_id,
1259
- context_id=context_id,
1260
- kind="delegate",
1261
- name=tool_name,
1262
- args=tool_args,
1263
- )
1264
- else:
1265
- st = self.steps.start_or_get(
1266
- task_id=task_id,
1267
- context_id=context_id,
1268
- kind="tool",
1269
- name=tool_name,
1270
- args=tool_args,
1271
- )
1272
-
1273
- # Record server start time for this step if available
1274
- if st and self.stream_processor.server_elapsed_time is not None:
1275
- self._step_server_start_times[st.step_id] = self.stream_processor.server_elapsed_time
1276
-
1277
- return st
1278
-
1279
- def _process_additional_tool_calls(
1280
- self,
1281
- tool_calls_info: list[tuple[str, Any, Any]],
1282
- tool_name: str,
1283
- task_id: str,
1284
- context_id: str,
1285
- ) -> None:
1286
- """Process additional tool calls to avoid duplicates."""
1287
- for call_name, call_args, _ in tool_calls_info or []:
1288
- if call_name and call_name != tool_name:
1289
- self._process_single_tool_call(call_name, call_args, task_id, context_id)
1290
-
1291
- def _process_single_tool_call(self, call_name: str, call_args: Any, task_id: str, context_id: str) -> None:
1292
- """Process a single additional tool call."""
1293
- self._ensure_tool_panel(call_name, call_args, task_id, context_id)
1294
-
1295
- st2 = self._create_step_for_tool_call(call_name, call_args, task_id, context_id)
1296
-
1297
- if self.stream_processor.server_elapsed_time is not None and st2:
1298
- self._step_server_start_times[st2.step_id] = self.stream_processor.server_elapsed_time
1299
-
1300
- def _create_step_for_tool_call(self, call_name: str, call_args: Any, task_id: str, context_id: str) -> Any:
1301
- """Create appropriate step for tool call."""
1302
- if is_delegation_tool(call_name):
1303
- return self.steps.start_or_get(
1304
- task_id=task_id,
1305
- context_id=context_id,
1306
- kind="delegate",
1307
- name=call_name,
1308
- args=call_args,
1309
- )
1310
- else:
1311
- return self.steps.start_or_get(
1312
- task_id=task_id,
1313
- context_id=context_id,
1314
- kind="tool",
1315
- name=call_name,
1316
- args=call_args,
1317
- )
1318
-
1319
- def _detect_tool_completion(self, metadata: dict, content: str) -> tuple[bool, str | None, Any]:
1320
- """Detect if a tool has completed and return completion info."""
1321
- tool_info = metadata.get("tool_info", {}) if isinstance(metadata, dict) else {}
1322
-
1323
- if tool_info.get("status") == "finished" and tool_info.get("name"):
1324
- return True, tool_info.get("name"), tool_info.get("output")
1325
- elif content and isinstance(content, str) and content.startswith("Completed "):
1326
- # content like "Completed google_serper"
1327
- tname = content.replace("Completed ", "").strip()
1328
- if tname:
1329
- output = tool_info.get("output") if tool_info.get("name") == tname else None
1330
- return True, tname, output
1331
- elif metadata.get("status") == "finished" and tool_info.get("name"):
1332
- return True, tool_info.get("name"), tool_info.get("output")
1333
-
1334
- return False, None, None
1335
-
1336
- def _get_tool_session_id(self, finished_tool_name: str, task_id: str, context_id: str) -> str:
1337
- """Generate tool session ID."""
1338
- return f"tool_{finished_tool_name}_{task_id}_{context_id}"
1339
-
1340
- def _calculate_tool_duration(self, meta: dict[str, Any]) -> float | None:
1341
- """Calculate tool duration from metadata."""
1342
- server_now = self.stream_processor.server_elapsed_time
1343
- server_start = meta.get("server_started_at")
1344
- dur = None
1345
-
1346
- try:
1347
- if isinstance(server_now, (int, float)) and server_start is not None:
1348
- dur = max(0.0, float(server_now) - float(server_start))
1349
- else:
1350
- started_at = meta.get("started_at")
1351
- if started_at is not None:
1352
- started_at_float = float(started_at)
1353
- dur = max(0.0, float(monotonic()) - started_at_float)
1354
- except (TypeError, ValueError):
1355
- logger.exception("Failed to calculate tool duration")
1356
- return None
1357
-
1358
- return dur
1359
-
1360
- def _update_tool_metadata(self, meta: dict[str, Any], dur: float | None) -> None:
1361
- """Update tool metadata with duration information."""
1362
- if dur is not None:
1363
- meta["duration_seconds"] = dur
1364
- meta["server_finished_at"] = (
1365
- self.stream_processor.server_elapsed_time
1366
- if isinstance(self.stream_processor.server_elapsed_time, (int, float))
1367
- else None
1368
- )
1369
- meta["finished_at"] = monotonic()
1370
-
1371
- def _add_tool_output_to_panel(
1372
- self, meta: dict[str, Any], finished_tool_output: Any, finished_tool_name: str
1373
- ) -> None:
1374
- """Add tool output to panel metadata."""
1375
- if finished_tool_output is not None:
1376
- meta["chunks"].append(self._format_output_block(finished_tool_output, finished_tool_name))
1377
- meta["output"] = finished_tool_output
1378
-
1379
- def _mark_panel_as_finished(self, meta: dict[str, Any], tool_sid: str) -> None:
1380
- """Mark panel as finished and ensure visibility."""
1381
- if meta.get("status") != "finished":
1382
- meta["status"] = "finished"
1383
-
1384
- dur = self._calculate_tool_duration(meta)
1385
- self._update_tool_metadata(meta, dur)
1386
-
1387
- # Ensure this finished panel is visible in this frame
1388
- self.stream_processor.current_event_finished_panels.add(tool_sid)
1389
-
1390
- def _finish_tool_panel(
1391
- self,
1392
- finished_tool_name: str,
1393
- finished_tool_output: Any,
1394
- task_id: str,
1395
- context_id: str,
1396
- ) -> None:
1397
- """Finish a tool panel and update its status."""
1398
- tool_sid = self._get_tool_session_id(finished_tool_name, task_id, context_id)
1399
- if tool_sid not in self.tool_panels:
1400
- return
1401
-
1402
- meta = self.tool_panels[tool_sid]
1403
- self._mark_panel_as_finished(meta, tool_sid)
1404
- self._add_tool_output_to_panel(meta, finished_tool_output, finished_tool_name)
1405
-
1406
- def _get_step_duration(self, finished_tool_name: str, task_id: str, context_id: str) -> float | None:
1407
- """Get step duration from tool panels."""
1408
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
1409
- return self.tool_panels.get(tool_sid, {}).get("duration_seconds")
1410
-
1411
- def _finish_delegation_step(
1412
- self,
1413
- finished_tool_name: str,
1414
- finished_tool_output: Any,
1415
- task_id: str,
1416
- context_id: str,
1417
- step_duration: float | None,
1418
- ) -> None:
1419
- """Finish a delegation step."""
1420
- self.steps.finish(
1421
- task_id=task_id,
1422
- context_id=context_id,
1423
- kind="delegate",
1424
- name=finished_tool_name,
1425
- output=finished_tool_output,
1426
- duration_raw=step_duration,
1427
- )
1428
-
1429
- def _finish_tool_step_type(
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 regular tool step."""
1438
- self.steps.finish(
1439
- task_id=task_id,
1440
- context_id=context_id,
1441
- kind="tool",
1442
- name=finished_tool_name,
1443
- output=finished_tool_output,
1444
- duration_raw=step_duration,
1445
- )
1446
-
1447
- def _finish_tool_step(
1448
- self,
1449
- finished_tool_name: str,
1450
- finished_tool_output: Any,
1451
- task_id: str,
1452
- context_id: str,
1453
- *,
1454
- tracked_step: Step | None = None,
1455
- ) -> None:
1456
- """Finish the corresponding step for a completed tool."""
1457
- if tracked_step is not None:
1458
- return
1459
-
1460
- step_duration = self._get_step_duration(finished_tool_name, task_id, context_id)
1461
-
1462
- if is_delegation_tool(finished_tool_name):
1463
- self._finish_delegation_step(
1464
- finished_tool_name,
1465
- finished_tool_output,
1466
- task_id,
1467
- context_id,
1468
- step_duration,
1469
- )
1470
- else:
1471
- self._finish_tool_step_type(
1472
- finished_tool_name,
1473
- finished_tool_output,
1474
- task_id,
1475
- context_id,
1476
- step_duration,
1477
- )
1478
-
1479
- def _should_create_snapshot(self, tool_sid: str) -> bool:
1480
- """Check if a snapshot should be created."""
1481
- return self.cfg.append_finished_snapshots and not self.tool_panels.get(tool_sid, {}).get("snapshot_printed")
1482
-
1483
- def _get_snapshot_title(self, meta: dict[str, Any], finished_tool_name: str) -> str:
1484
- """Get the title for the snapshot."""
1485
- adjusted_title = meta.get("title") or finished_tool_name
1486
-
1487
- # Add elapsed time to title
1488
- dur = meta.get("duration_seconds")
1489
- if isinstance(dur, (int, float)):
1490
- elapsed_str = self._format_snapshot_duration(dur)
1491
- adjusted_title = f"{adjusted_title} · {elapsed_str}"
1492
-
1493
- return adjusted_title
1494
-
1495
- def _format_snapshot_duration(self, dur: int | float) -> str:
1496
- """Format duration for snapshot title."""
1497
- try:
1498
- # Handle invalid types
1499
- if not isinstance(dur, (int, float)):
1500
- return "<1ms"
1501
-
1502
- if dur >= 1:
1503
- return f"{dur:.2f}s"
1504
- elif int(dur * 1000) > 0:
1505
- return f"{int(dur * 1000)}ms"
1506
- else:
1507
- return "<1ms"
1508
- except (TypeError, ValueError, OverflowError):
1509
- return "<1ms"
1510
-
1511
- def _clamp_snapshot_body(self, body_text: str) -> str:
1512
- """Clamp snapshot body to configured limits."""
1513
- max_lines = int(self.cfg.snapshot_max_lines or 0)
1514
- lines = body_text.splitlines()
1515
- if max_lines > 0 and len(lines) > max_lines:
1516
- lines = lines[:max_lines] + ["… (truncated)"]
1517
- body_text = "\n".join(lines)
1518
-
1519
- max_chars = int(self.cfg.snapshot_max_chars or 0)
1520
- if max_chars > 0 and len(body_text) > max_chars:
1521
- suffix = "\n… (truncated)"
1522
- body_text = body_text[: max_chars - len(suffix)] + suffix
1523
-
1524
- return body_text
1525
-
1526
- def _create_snapshot_panel(self, adjusted_title: str, body_text: str, finished_tool_name: str) -> Any:
1527
- """Create the snapshot panel."""
1528
- return create_tool_panel(
1529
- title=adjusted_title,
1530
- content=body_text or "(no output)",
1531
- status="finished",
1532
- theme=self.cfg.theme,
1533
- is_delegation=is_delegation_tool(finished_tool_name),
1534
- )
1535
-
1536
- def _print_and_mark_snapshot(self, tool_sid: str, snapshot_panel: Any) -> None:
1537
- """Print snapshot and mark as printed."""
1538
- self.console.print(snapshot_panel)
1539
- self.tool_panels[tool_sid]["snapshot_printed"] = True
1540
-
1541
- def _create_tool_snapshot(self, finished_tool_name: str, task_id: str, context_id: str) -> None:
1542
- """Create and print a snapshot for a finished tool."""
1543
- tool_sid = f"tool_{finished_tool_name}_{task_id}_{context_id}"
1544
-
1545
- if not self._should_create_snapshot(tool_sid):
1546
- return
1547
-
1548
- meta = self.tool_panels[tool_sid]
1549
- adjusted_title = self._get_snapshot_title(meta, finished_tool_name)
1550
-
1551
- # Compose body from chunks and clamp
1552
- body_text = "".join(meta.get("chunks") or [])
1553
- body_text = self._clamp_snapshot_body(body_text)
1554
-
1555
- snapshot_panel = self._create_snapshot_panel(adjusted_title, body_text, finished_tool_name)
1556
-
1557
- self._print_and_mark_snapshot(tool_sid, snapshot_panel)
1558
-
1559
- def _handle_agent_step(
1560
- self,
1561
- event: dict[str, Any],
1562
- tool_name: str | None,
1563
- tool_args: Any,
1564
- _tool_out: Any,
1565
- tool_calls_info: list[tuple[str, Any, Any]],
1566
- *,
1567
- tracked_step: Step | None = None,
1568
- ) -> None:
1569
- """Handle agent step event."""
1570
- metadata = event.get("metadata", {})
1571
- task_id = event.get("task_id") or metadata.get("task_id")
1572
- context_id = event.get("context_id") or metadata.get("context_id")
1573
- content = event.get("content", "")
1574
-
1575
- # Create steps and panels for the primary tool
1576
- if tool_name:
1577
- tool_sid = self._ensure_tool_panel(tool_name, tool_args, task_id, context_id)
1578
- self._start_tool_step(
1579
- task_id,
1580
- context_id,
1581
- tool_name,
1582
- tool_args,
1583
- tool_sid,
1584
- tracked_step=tracked_step,
1585
- )
1586
-
1587
- # Handle additional tool calls
1588
- self._process_additional_tool_calls(tool_calls_info, tool_name, task_id, context_id)
1589
-
1590
- # Check for tool completion
1591
- (
1592
- is_tool_finished,
1593
- finished_tool_name,
1594
- finished_tool_output,
1595
- ) = self._detect_tool_completion(metadata, content)
1596
-
1597
- if is_tool_finished and finished_tool_name:
1598
- self._finish_tool_panel(finished_tool_name, finished_tool_output, task_id, context_id)
1599
- self._finish_tool_step(
1600
- finished_tool_name,
1601
- finished_tool_output,
1602
- task_id,
1603
- context_id,
1604
- tracked_step=tracked_step,
1605
- )
1606
- self._create_tool_snapshot(finished_tool_name, task_id, context_id)
1607
-
1608
- def _spinner(self) -> str:
1609
- """Return spinner character."""
1610
- return get_spinner()
1611
-
1612
812
  def _format_working_indicator(self, started_at: float | None) -> str:
1613
813
  """Format working indicator."""
1614
814
  return format_working_indicator(
@@ -1768,18 +968,7 @@ class RichStreamRenderer:
1768
968
 
1769
969
  def _resolve_step_label(self, step: Step) -> str:
1770
970
  """Return the display label for a step with sensible fallbacks."""
1771
- raw_label = getattr(step, "display_label", None)
1772
- label = raw_label.strip() if isinstance(raw_label, str) else ""
1773
- if label:
1774
- return normalise_display_label(label)
1775
-
1776
- if not (step.name or "").strip():
1777
- return "Unknown step detail"
1778
-
1779
- icon = self._get_step_icon(step.kind)
1780
- base_name = self._get_step_display_name(step)
1781
- fallback = " ".join(part for part in (icon, base_name) if part).strip()
1782
- return normalise_display_label(fallback)
971
+ return format_step_label(step)
1783
972
 
1784
973
  def _check_parallel_tools(self) -> dict[tuple[str | None, str | None], list]:
1785
974
  """Check for parallel running tools."""
@@ -1800,332 +989,75 @@ class RichStreamRenderer:
1800
989
  key = (step.task_id, step.context_id)
1801
990
  return len(running_by_ctx.get(key, [])) > 1
1802
991
 
1803
- def _compose_step_renderable(
1804
- self,
1805
- step: Step,
1806
- branch_state: tuple[bool, ...],
1807
- ) -> Any:
1808
- """Compose a single renderable for the hierarchical steps panel."""
1809
- prefix = build_connector_prefix(branch_state)
1810
- text_line = self._build_step_text_line(step, prefix)
1811
- renderables = self._wrap_step_text(step, text_line)
1812
-
1813
- args_renderable = self._build_args_renderable(step, prefix)
1814
- if args_renderable is not None:
1815
- renderables.append(args_renderable)
1816
-
1817
- return self._collapse_renderables(renderables)
1818
-
1819
- def _build_step_text_line(
1820
- self,
1821
- step: Step,
1822
- prefix: str,
1823
- ) -> Text:
1824
- """Create the textual portion of a step renderable."""
1825
- text_line = Text()
1826
- text_line.append(prefix, style="dim")
1827
- text_line.append(self._resolve_step_label(step))
1828
-
1829
- status_badge = self._format_step_status(step)
1830
- self._append_status_badge(text_line, step, status_badge)
1831
- self._append_state_glyph(text_line, step)
1832
- return text_line
1833
-
1834
- def _append_status_badge(self, text_line: Text, step: Step, status_badge: str) -> None:
1835
- """Append the formatted status badge when available."""
1836
- glyph_key = getattr(step, "status_icon", None)
1837
- glyph = glyph_for_status(glyph_key)
1838
-
1839
- if status_badge:
1840
- text_line.append(" ")
1841
- text_line.append(status_badge, style="cyan")
1842
-
1843
- if glyph:
1844
- text_line.append(" ")
1845
- style = self._status_icon_style(glyph_key)
1846
- if style:
1847
- text_line.append(glyph, style=style)
1848
- else:
1849
- text_line.append(glyph)
1850
-
1851
- def _append_state_glyph(self, text_line: Text, step: Step) -> None:
1852
- """Append glyph/failure markers in a single place."""
1853
- failure_reason = (step.failure_reason or "").strip()
1854
- if failure_reason:
1855
- text_line.append(f" {failure_reason}")
1856
-
1857
- @staticmethod
1858
- def _status_icon_style(icon_key: str | None) -> str | None:
1859
- """Return style for a given status icon."""
1860
- if not icon_key:
1861
- return None
1862
- return STATUS_ICON_STYLES.get(icon_key)
1863
-
1864
- def _wrap_step_text(self, step: Step, text_line: Text) -> list[Any]:
1865
- """Return the base text, optionally decorated with a trailing spinner."""
1866
- if getattr(step, "status", None) == "running":
1867
- spinner = self._step_spinners.get(step.step_id)
1868
- if spinner is None:
1869
- spinner = Spinner("dots", style="dim")
1870
- self._step_spinners[step.step_id] = spinner
1871
- return [TrailingSpinnerLine(text_line, spinner)]
1872
-
1873
- self._step_spinners.pop(step.step_id, None)
1874
- return [text_line]
1875
-
1876
- def _collapse_renderables(self, renderables: list[Any]) -> Any:
1877
- """Collapse a list of renderables into a single object."""
1878
- if not renderables:
1879
- return None
1880
-
1881
- if len(renderables) == 1:
1882
- return renderables[0]
1883
-
1884
- return Group(*renderables)
1885
-
1886
- def _build_args_renderable(self, step: Step, prefix: str) -> Text | Group | None:
1887
- """Build a dimmed argument line for tool or agent steps."""
1888
- if step.kind not in {"tool", "delegate", "agent"}:
1889
- return None
1890
- if step.kind == "agent" and step.parent_id:
1891
- return None
1892
- formatted_args = self._format_step_args(step)
1893
- if not formatted_args:
1894
- return None
1895
- if isinstance(formatted_args, list):
1896
- return self._build_arg_list(prefix, formatted_args)
1897
-
1898
- args_text = Text()
1899
- args_text.append(prefix, style="dim")
1900
- args_text.append(" " * 5)
1901
- args_text.append(formatted_args, style="dim")
1902
- return args_text
1903
-
1904
- def _build_arg_list(self, prefix: str, formatted_args: list[str | tuple[int, str]]) -> Group | None:
1905
- """Render multi-line argument entries preserving indentation."""
1906
- arg_lines: list[Text] = []
1907
- for indent_level, text_value in self._iter_arg_entries(formatted_args):
1908
- arg_text = Text()
1909
- arg_text.append(prefix, style="dim")
1910
- arg_text.append(" " * 5)
1911
- arg_text.append(" " * (indent_level * 2))
1912
- arg_text.append(text_value, style="dim")
1913
- arg_lines.append(arg_text)
1914
- if not arg_lines:
1915
- return None
1916
- return Group(*arg_lines)
1917
-
1918
- @staticmethod
1919
- def _iter_arg_entries(
1920
- formatted_args: list[str | tuple[int, str]],
1921
- ) -> Iterable[tuple[int, str]]:
1922
- """Yield normalized indentation/value pairs for argument entries."""
1923
- for value in formatted_args:
1924
- if isinstance(value, tuple) and len(value) == 2:
1925
- indent_level, text_value = value
1926
- yield indent_level, str(text_value)
1927
- else:
1928
- yield 0, str(value)
1929
-
1930
- def _format_step_args(self, step: Step) -> str | list[str] | list[tuple[int, str]] | None:
1931
- """Return a printable representation of tool arguments."""
1932
- args = getattr(step, "args", None)
1933
- if args is None:
1934
- return None
1935
-
1936
- if isinstance(args, dict):
1937
- return self._format_dict_args(args, step=step)
1938
-
1939
- if isinstance(args, (list, tuple)):
1940
- return self._safe_pretty_args(list(args))
1941
-
1942
- if isinstance(args, (str, int, float)):
1943
- return self._stringify_args(args)
1944
-
1945
- return None
1946
-
1947
- def _format_dict_args(self, args: dict[str, Any], *, step: Step) -> str | list[str] | list[tuple[int, str]] | None:
1948
- """Format dictionary arguments with guardrails."""
1949
- if not args:
1950
- return None
1951
-
1952
- masked_args = self._redact_arg_payload(args)
1953
-
1954
- if self._should_collapse_single_query(step):
1955
- single_query = self._extract_single_query_arg(masked_args)
1956
- if single_query:
1957
- return single_query
1958
-
1959
- return self._format_dict_arg_lines(masked_args)
1960
-
1961
- @staticmethod
1962
- def _extract_single_query_arg(args: dict[str, Any]) -> str | None:
1963
- """Return a trimmed query argument when it is the only entry."""
1964
- if len(args) != 1:
1965
- return None
1966
- key, value = next(iter(args.items()))
1967
- if key != "query" or not isinstance(value, str):
1968
- return None
1969
- stripped = value.strip()
1970
- return stripped or None
1971
-
1972
- @staticmethod
1973
- def _redact_arg_payload(args: dict[str, Any]) -> dict[str, Any]:
1974
- """Apply best-effort masking before rendering arguments."""
1975
- try:
1976
- cleaned = redact_sensitive(args)
1977
- return cleaned if isinstance(cleaned, dict) else args
1978
- except Exception:
1979
- return args
1980
-
1981
- @staticmethod
1982
- def _should_collapse_single_query(step: Step) -> bool:
1983
- """Return True when we should display raw query text."""
1984
- if step.kind == "agent":
1985
- return True
1986
- if step.kind == "delegate":
1987
- return True
1988
- return False
1989
-
1990
- def _format_dict_arg_lines(self, args: dict[str, Any]) -> list[tuple[int, str]] | None:
1991
- """Render dictionary arguments as nested YAML-style lines."""
1992
- lines: list[tuple[int, str]] = []
1993
- for raw_key, value in args.items():
1994
- key = str(raw_key)
1995
- lines.extend(self._format_nested_entry(key, value, indent=0))
1996
- return lines or None
1997
-
1998
- def _format_nested_entry(self, key: str, value: Any, indent: int) -> list[tuple[int, str]]:
1999
- """Format a mapping entry recursively."""
2000
- lines: list[tuple[int, str]] = []
2001
-
2002
- if isinstance(value, dict):
2003
- if value:
2004
- lines.append((indent, f"{key}:"))
2005
- lines.extend(self._format_nested_mapping(value, indent + 1))
2006
- else:
2007
- lines.append((indent, f"{key}: {{}}"))
2008
- return lines
2009
-
2010
- if isinstance(value, (list, tuple, set)):
2011
- seq_lines = self._format_sequence_entries(list(value), indent + 1)
2012
- if seq_lines:
2013
- lines.append((indent, f"{key}:"))
2014
- lines.extend(seq_lines)
2015
- else:
2016
- lines.append((indent, f"{key}: []"))
2017
- return lines
2018
-
2019
- formatted_value = self._format_arg_value(value)
2020
- if formatted_value is not None:
2021
- lines.append((indent, f"{key}: {formatted_value}"))
2022
- return lines
2023
-
2024
- def _format_nested_mapping(self, mapping: dict[str, Any], indent: int) -> list[tuple[int, str]]:
2025
- """Format nested dictionary values."""
2026
- nested_lines: list[tuple[int, str]] = []
2027
- for raw_key, value in mapping.items():
2028
- key = str(raw_key)
2029
- nested_lines.extend(self._format_nested_entry(key, value, indent))
2030
- return nested_lines
2031
-
2032
- def _format_sequence_entries(self, sequence: list[Any], indent: int) -> list[tuple[int, str]]:
2033
- """Format list/tuple/set values with YAML-style bullets."""
2034
- if not sequence:
2035
- return []
2036
-
2037
- lines: list[tuple[int, str]] = []
2038
- for item in sequence:
2039
- lines.extend(self._format_sequence_item(item, indent))
2040
- return lines
2041
-
2042
- def _format_sequence_item(self, item: Any, indent: int) -> list[tuple[int, str]]:
2043
- """Format a single list entry."""
2044
- if isinstance(item, dict):
2045
- return self._format_dict_sequence_item(item, indent)
2046
-
2047
- if isinstance(item, (list, tuple, set)):
2048
- return self._format_nested_sequence_item(list(item), indent)
2049
-
2050
- formatted = self._format_arg_value(item)
2051
- if formatted is not None:
2052
- return [(indent, f"- {formatted}")]
2053
- return []
2054
-
2055
- def _format_dict_sequence_item(self, mapping: dict[str, Any], indent: int) -> list[tuple[int, str]]:
2056
- """Format a dictionary entry within a list."""
2057
- child_lines = self._format_nested_mapping(mapping, indent + 1)
2058
- if child_lines:
2059
- return self._prepend_sequence_prefix(child_lines, indent)
2060
- return [(indent, "- {}")]
2061
-
2062
- def _format_nested_sequence_item(self, sequence: list[Any], indent: int) -> list[tuple[int, str]]:
2063
- """Format a nested sequence entry within a list."""
2064
- child_lines = self._format_sequence_entries(sequence, indent + 1)
2065
- if child_lines:
2066
- return self._prepend_sequence_prefix(child_lines, indent)
2067
- return [(indent, "- []")]
2068
-
2069
- @staticmethod
2070
- def _prepend_sequence_prefix(child_lines: list[tuple[int, str]], indent: int) -> list[tuple[int, str]]:
2071
- """Attach a sequence bullet to the first child line."""
2072
- _, first_text = child_lines[0]
2073
- prefixed: list[tuple[int, str]] = [(indent, f"- {first_text}")]
2074
- prefixed.extend(child_lines[1:])
2075
- return prefixed
2076
-
2077
- def _format_arg_value(self, value: Any) -> str | None:
2078
- """Format a single argument value with per-value truncation."""
2079
- if value is None:
2080
- return "null"
2081
- if isinstance(value, (bool, int, float)):
2082
- return json.dumps(value, ensure_ascii=False)
2083
- if isinstance(value, str):
2084
- return self._format_string_arg_value(value)
2085
- return _truncate_display(str(value), limit=ARGS_VALUE_MAX_LEN)
2086
-
2087
- @staticmethod
2088
- def _format_string_arg_value(value: str) -> str:
2089
- """Return a trimmed, quoted representation of a string argument."""
2090
- sanitised = value.replace("\n", " ").strip()
2091
- sanitised = sanitised.replace('"', '\\"')
2092
- trimmed = _truncate_display(sanitised, limit=ARGS_VALUE_MAX_LEN)
2093
- return f'"{trimmed}"'
2094
-
2095
- @staticmethod
2096
- def _safe_pretty_args(args: dict[str, Any]) -> str | None:
2097
- """Defensively format argument dictionaries."""
2098
- try:
2099
- return pretty_args(args, max_len=160)
2100
- except Exception:
2101
- return str(args)
2102
-
2103
- @staticmethod
2104
- def _stringify_args(args: Any) -> str | None:
2105
- """Format non-dictionary argument payloads."""
2106
- text = str(args).strip()
2107
- if not text:
2108
- return None
2109
- return _truncate_display(text)
2110
-
2111
- def _render_steps_text(self) -> Any:
2112
- """Render the steps panel content."""
2113
- if not (self.steps.order or self.steps.children):
2114
- return Text("No steps yet", style="dim")
2115
-
2116
- renderables: list[Any] = []
2117
- for step_id, branch_state in self.steps.iter_tree():
2118
- step = self.steps.by_id.get(step_id)
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)
2119
997
  if not step:
2120
998
  continue
2121
- renderable = self._compose_step_renderable(step, branch_state)
2122
- if renderable is not None:
2123
- renderables.append(renderable)
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(),
1018
+ )
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)
2124
1051
 
2125
- if not renderables:
2126
- return Text("No steps yet", style="dim")
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)
2127
1055
 
2128
- return Group(*renderables)
1056
+ def _summary_window_size(self) -> int:
1057
+ """Return the active window size for step display."""
1058
+ if self.state.finalizing_ui:
1059
+ return 0
1060
+ return int(self.cfg.summary_display_window or 0)
2129
1061
 
2130
1062
  def _update_final_duration(self, duration: float | None, *, overwrite: bool = False) -> None:
2131
1063
  """Store formatted duration for eventual final panels."""
@@ -2145,73 +1077,6 @@ class RichStreamRenderer:
2145
1077
  if overwrite and existing is not None:
2146
1078
  duration_val = max(existing, duration_val)
2147
1079
 
2148
- self.state.final_duration_seconds = duration_val
2149
- 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)
2150
1082
  self._apply_root_duration(duration_val)
2151
-
2152
- def _format_elapsed_time(self, elapsed: float) -> str:
2153
- """Format elapsed time as a readable string."""
2154
- if elapsed >= 1:
2155
- return f"{elapsed:.2f}s"
2156
- elif int(elapsed * 1000) > 0:
2157
- return f"{int(elapsed * 1000)}ms"
2158
- else:
2159
- return "<1ms"
2160
-
2161
- def _format_dict_or_list_output(self, output_value: dict | list) -> str:
2162
- """Format dict/list output as pretty JSON."""
2163
- try:
2164
- return self.OUTPUT_PREFIX + "```json\n" + json.dumps(output_value, indent=2) + "\n```\n"
2165
- except Exception:
2166
- return self.OUTPUT_PREFIX + str(output_value) + "\n"
2167
-
2168
- def _clean_sub_agent_prefix(self, output: str, tool_name: str | None) -> str:
2169
- """Clean sub-agent name prefix from output."""
2170
- if not (tool_name and is_delegation_tool(tool_name)):
2171
- return output
2172
-
2173
- sub = tool_name
2174
- if tool_name.startswith("delegate_to_"):
2175
- sub = tool_name.replace("delegate_to_", "")
2176
- elif tool_name.startswith("delegate_"):
2177
- sub = tool_name.replace("delegate_", "")
2178
- prefix = f"[{sub}]"
2179
- if output.startswith(prefix):
2180
- return output[len(prefix) :].lstrip()
2181
-
2182
- return output
2183
-
2184
- def _format_json_string_output(self, output: str) -> str:
2185
- """Format string that looks like JSON."""
2186
- try:
2187
- parsed = json.loads(output)
2188
- return self.OUTPUT_PREFIX + "```json\n" + json.dumps(parsed, indent=2) + "\n```\n"
2189
- except Exception:
2190
- return self.OUTPUT_PREFIX + output + "\n"
2191
-
2192
- def _format_string_output(self, output: str, tool_name: str | None) -> str:
2193
- """Format string output with optional prefix cleaning."""
2194
- s = output.strip()
2195
- s = self._clean_sub_agent_prefix(s, tool_name)
2196
-
2197
- # If looks like JSON, pretty print it
2198
- if (s.startswith("{") and s.endswith("}")) or (s.startswith("[") and s.endswith("]")):
2199
- return self._format_json_string_output(s)
2200
-
2201
- return self.OUTPUT_PREFIX + s + "\n"
2202
-
2203
- def _format_other_output(self, output_value: Any) -> str:
2204
- """Format other types of output."""
2205
- try:
2206
- return self.OUTPUT_PREFIX + json.dumps(output_value, indent=2) + "\n"
2207
- except Exception:
2208
- return self.OUTPUT_PREFIX + str(output_value) + "\n"
2209
-
2210
- def _format_output_block(self, output_value: Any, tool_name: str | None) -> str:
2211
- """Format an output value for panel display."""
2212
- if isinstance(output_value, (dict, list)):
2213
- return self._format_dict_or_list_output(output_value)
2214
- elif isinstance(output_value, str):
2215
- return self._format_string_output(output_value, tool_name)
2216
- else:
2217
- return self._format_other_output(output_value)