glaip-sdk 0.0.0b99__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 (207) hide show
  1. glaip_sdk/__init__.py +52 -0
  2. glaip_sdk/_version.py +81 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1227 -0
  5. glaip_sdk/branding.py +211 -0
  6. glaip_sdk/cli/__init__.py +9 -0
  7. glaip_sdk/cli/account_store.py +540 -0
  8. glaip_sdk/cli/agent_config.py +78 -0
  9. glaip_sdk/cli/auth.py +705 -0
  10. glaip_sdk/cli/commands/__init__.py +5 -0
  11. glaip_sdk/cli/commands/accounts.py +746 -0
  12. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  13. glaip_sdk/cli/commands/agents/_common.py +561 -0
  14. glaip_sdk/cli/commands/agents/create.py +151 -0
  15. glaip_sdk/cli/commands/agents/delete.py +64 -0
  16. glaip_sdk/cli/commands/agents/get.py +89 -0
  17. glaip_sdk/cli/commands/agents/list.py +129 -0
  18. glaip_sdk/cli/commands/agents/run.py +264 -0
  19. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  20. glaip_sdk/cli/commands/agents/update.py +112 -0
  21. glaip_sdk/cli/commands/common_config.py +104 -0
  22. glaip_sdk/cli/commands/configure.py +895 -0
  23. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  24. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  25. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  26. glaip_sdk/cli/commands/mcps/create.py +152 -0
  27. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  28. glaip_sdk/cli/commands/mcps/get.py +212 -0
  29. glaip_sdk/cli/commands/mcps/list.py +69 -0
  30. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  31. glaip_sdk/cli/commands/mcps/update.py +190 -0
  32. glaip_sdk/cli/commands/models.py +67 -0
  33. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  34. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  35. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  36. glaip_sdk/cli/commands/tools/_common.py +80 -0
  37. glaip_sdk/cli/commands/tools/create.py +228 -0
  38. glaip_sdk/cli/commands/tools/delete.py +61 -0
  39. glaip_sdk/cli/commands/tools/get.py +103 -0
  40. glaip_sdk/cli/commands/tools/list.py +69 -0
  41. glaip_sdk/cli/commands/tools/script.py +49 -0
  42. glaip_sdk/cli/commands/tools/update.py +102 -0
  43. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  44. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  45. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  46. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  47. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  48. glaip_sdk/cli/commands/update.py +192 -0
  49. glaip_sdk/cli/config.py +95 -0
  50. glaip_sdk/cli/constants.py +38 -0
  51. glaip_sdk/cli/context.py +150 -0
  52. glaip_sdk/cli/core/__init__.py +79 -0
  53. glaip_sdk/cli/core/context.py +124 -0
  54. glaip_sdk/cli/core/output.py +851 -0
  55. glaip_sdk/cli/core/prompting.py +649 -0
  56. glaip_sdk/cli/core/rendering.py +187 -0
  57. glaip_sdk/cli/display.py +355 -0
  58. glaip_sdk/cli/hints.py +57 -0
  59. glaip_sdk/cli/io.py +112 -0
  60. glaip_sdk/cli/main.py +686 -0
  61. glaip_sdk/cli/masking.py +136 -0
  62. glaip_sdk/cli/mcp_validators.py +287 -0
  63. glaip_sdk/cli/pager.py +266 -0
  64. glaip_sdk/cli/parsers/__init__.py +7 -0
  65. glaip_sdk/cli/parsers/json_input.py +177 -0
  66. glaip_sdk/cli/resolution.py +68 -0
  67. glaip_sdk/cli/rich_helpers.py +27 -0
  68. glaip_sdk/cli/slash/__init__.py +15 -0
  69. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  70. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  71. glaip_sdk/cli/slash/agent_session.py +285 -0
  72. glaip_sdk/cli/slash/prompt.py +256 -0
  73. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  74. glaip_sdk/cli/slash/session.py +1724 -0
  75. glaip_sdk/cli/slash/tui/__init__.py +34 -0
  76. glaip_sdk/cli/slash/tui/accounts.tcss +88 -0
  77. glaip_sdk/cli/slash/tui/accounts_app.py +933 -0
  78. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  79. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  80. glaip_sdk/cli/slash/tui/context.py +59 -0
  81. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  82. glaip_sdk/cli/slash/tui/loading.py +58 -0
  83. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  84. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  85. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  86. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  87. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  88. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  89. glaip_sdk/cli/slash/tui/toast.py +123 -0
  90. glaip_sdk/cli/transcript/__init__.py +31 -0
  91. glaip_sdk/cli/transcript/cache.py +536 -0
  92. glaip_sdk/cli/transcript/capture.py +329 -0
  93. glaip_sdk/cli/transcript/export.py +38 -0
  94. glaip_sdk/cli/transcript/history.py +815 -0
  95. glaip_sdk/cli/transcript/launcher.py +77 -0
  96. glaip_sdk/cli/transcript/viewer.py +374 -0
  97. glaip_sdk/cli/update_notifier.py +369 -0
  98. glaip_sdk/cli/validators.py +238 -0
  99. glaip_sdk/client/__init__.py +12 -0
  100. glaip_sdk/client/_schedule_payloads.py +89 -0
  101. glaip_sdk/client/agent_runs.py +147 -0
  102. glaip_sdk/client/agents.py +1353 -0
  103. glaip_sdk/client/base.py +502 -0
  104. glaip_sdk/client/main.py +253 -0
  105. glaip_sdk/client/mcps.py +401 -0
  106. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  107. glaip_sdk/client/payloads/agent/requests.py +495 -0
  108. glaip_sdk/client/payloads/agent/responses.py +43 -0
  109. glaip_sdk/client/run_rendering.py +747 -0
  110. glaip_sdk/client/schedules.py +439 -0
  111. glaip_sdk/client/shared.py +21 -0
  112. glaip_sdk/client/tools.py +690 -0
  113. glaip_sdk/client/validators.py +198 -0
  114. glaip_sdk/config/constants.py +52 -0
  115. glaip_sdk/exceptions.py +113 -0
  116. glaip_sdk/hitl/__init__.py +15 -0
  117. glaip_sdk/hitl/local.py +151 -0
  118. glaip_sdk/icons.py +25 -0
  119. glaip_sdk/mcps/__init__.py +21 -0
  120. glaip_sdk/mcps/base.py +345 -0
  121. glaip_sdk/models/__init__.py +107 -0
  122. glaip_sdk/models/agent.py +47 -0
  123. glaip_sdk/models/agent_runs.py +117 -0
  124. glaip_sdk/models/common.py +42 -0
  125. glaip_sdk/models/mcp.py +33 -0
  126. glaip_sdk/models/schedule.py +224 -0
  127. glaip_sdk/models/tool.py +33 -0
  128. glaip_sdk/payload_schemas/__init__.py +7 -0
  129. glaip_sdk/payload_schemas/agent.py +85 -0
  130. glaip_sdk/registry/__init__.py +55 -0
  131. glaip_sdk/registry/agent.py +164 -0
  132. glaip_sdk/registry/base.py +139 -0
  133. glaip_sdk/registry/mcp.py +253 -0
  134. glaip_sdk/registry/tool.py +393 -0
  135. glaip_sdk/rich_components.py +125 -0
  136. glaip_sdk/runner/__init__.py +59 -0
  137. glaip_sdk/runner/base.py +84 -0
  138. glaip_sdk/runner/deps.py +112 -0
  139. glaip_sdk/runner/langgraph.py +870 -0
  140. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  141. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  142. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  143. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  144. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  145. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  146. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  147. glaip_sdk/schedules/__init__.py +22 -0
  148. glaip_sdk/schedules/base.py +291 -0
  149. glaip_sdk/tools/__init__.py +22 -0
  150. glaip_sdk/tools/base.py +466 -0
  151. glaip_sdk/utils/__init__.py +86 -0
  152. glaip_sdk/utils/a2a/__init__.py +34 -0
  153. glaip_sdk/utils/a2a/event_processor.py +188 -0
  154. glaip_sdk/utils/agent_config.py +194 -0
  155. glaip_sdk/utils/bundler.py +267 -0
  156. glaip_sdk/utils/client.py +111 -0
  157. glaip_sdk/utils/client_utils.py +486 -0
  158. glaip_sdk/utils/datetime_helpers.py +58 -0
  159. glaip_sdk/utils/discovery.py +78 -0
  160. glaip_sdk/utils/display.py +135 -0
  161. glaip_sdk/utils/export.py +143 -0
  162. glaip_sdk/utils/general.py +61 -0
  163. glaip_sdk/utils/import_export.py +168 -0
  164. glaip_sdk/utils/import_resolver.py +530 -0
  165. glaip_sdk/utils/instructions.py +101 -0
  166. glaip_sdk/utils/rendering/__init__.py +115 -0
  167. glaip_sdk/utils/rendering/formatting.py +264 -0
  168. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  169. glaip_sdk/utils/rendering/layout/panels.py +156 -0
  170. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  171. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  172. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  173. glaip_sdk/utils/rendering/models.py +85 -0
  174. glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
  175. glaip_sdk/utils/rendering/renderer/base.py +1082 -0
  176. glaip_sdk/utils/rendering/renderer/config.py +27 -0
  177. glaip_sdk/utils/rendering/renderer/console.py +55 -0
  178. glaip_sdk/utils/rendering/renderer/debug.py +178 -0
  179. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  180. glaip_sdk/utils/rendering/renderer/stream.py +202 -0
  181. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  182. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  183. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  184. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  185. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  186. glaip_sdk/utils/rendering/state.py +204 -0
  187. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  188. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  189. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  190. glaip_sdk/utils/rendering/steps/format.py +176 -0
  191. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  192. glaip_sdk/utils/rendering/timing.py +36 -0
  193. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  194. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  195. glaip_sdk/utils/resource_refs.py +195 -0
  196. glaip_sdk/utils/run_renderer.py +41 -0
  197. glaip_sdk/utils/runtime_config.py +425 -0
  198. glaip_sdk/utils/serialization.py +424 -0
  199. glaip_sdk/utils/sync.py +142 -0
  200. glaip_sdk/utils/tool_detection.py +33 -0
  201. glaip_sdk/utils/tool_storage_provider.py +140 -0
  202. glaip_sdk/utils/validation.py +264 -0
  203. glaip_sdk-0.0.0b99.dist-info/METADATA +239 -0
  204. glaip_sdk-0.0.0b99.dist-info/RECORD +207 -0
  205. glaip_sdk-0.0.0b99.dist-info/WHEEL +5 -0
  206. glaip_sdk-0.0.0b99.dist-info/entry_points.txt +2 -0
  207. glaip_sdk-0.0.0b99.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1082 @@
1
+ """Base renderer class that orchestrates all rendering components.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import sys
12
+ from datetime import datetime, timezone
13
+ from time import monotonic
14
+ from typing import Any
15
+
16
+ from rich.console import Console as RichConsole
17
+ from rich.console import Group
18
+ from rich.live import Live
19
+ from rich.markdown import Markdown
20
+ from rich.spinner import Spinner
21
+ from rich.text import Text
22
+
23
+ from glaip_sdk.icons import ICON_AGENT, ICON_AGENT_STEP, ICON_DELEGATE, ICON_TOOL_STEP
24
+ from glaip_sdk.rich_components import AIPPanel
25
+ from glaip_sdk.utils.rendering.formatting import (
26
+ format_main_title,
27
+ is_step_finished,
28
+ normalise_display_label,
29
+ )
30
+ from glaip_sdk.utils.rendering.models import RunStats, Step
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,
34
+ format_elapsed_time,
35
+ format_working_indicator,
36
+ get_spinner_char,
37
+ is_delegation_tool,
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
51
+ from glaip_sdk.utils.rendering.renderer.stream import StreamProcessor
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")
68
+
69
+ # Configure logger
70
+ logger = logging.getLogger("glaip_sdk.run_renderer")
71
+
72
+ # Constants
73
+ RUNNING_STATUS_HINTS = {"running", "started", "pending", "working"}
74
+ ARGS_VALUE_MAX_LEN = 160
75
+
76
+
77
+ class RichStreamRenderer(TranscriptModeMixin):
78
+ """Live, modern terminal renderer for agent execution with rich visual output."""
79
+
80
+ def __init__(
81
+ self,
82
+ console: RichConsole | None = None,
83
+ *,
84
+ cfg: RendererConfig | None = None,
85
+ verbose: bool = False,
86
+ transcript_buffer: TranscriptBuffer | None = None,
87
+ callbacks: dict[str, Any] | None = None,
88
+ ) -> None:
89
+ """Initialize the renderer.
90
+
91
+ Args:
92
+ console: Rich console instance
93
+ cfg: Renderer configuration
94
+ verbose: Whether to enable verbose mode
95
+ transcript_buffer: Optional transcript buffer for capturing output
96
+ callbacks: Optional dictionary of callback functions
97
+ """
98
+ super().__init__()
99
+ self.console = console or RichConsole()
100
+ self.cfg = cfg or RendererConfig()
101
+ self.verbose = verbose
102
+
103
+ # Initialize components
104
+ self.stream_processor = StreamProcessor()
105
+ self.state = RendererState()
106
+ if transcript_buffer is not None:
107
+ self.state.buffer = transcript_buffer
108
+
109
+ self._callbacks = callbacks or {}
110
+
111
+ # Initialize step manager and other state
112
+ self.steps = StepManager(max_steps=self.cfg.summary_max_steps)
113
+ # Live display instance (single source of truth)
114
+ self.live: Live | None = None
115
+ self._step_spinners: dict[str, Spinner] = {}
116
+ self._last_steps_panel_template: Any | None = None
117
+
118
+ # Tool tracking and thinking scopes
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
+ )
132
+ self._root_agent_friendly: str | None = None
133
+ self._root_agent_step_id: str | None = None
134
+ self._root_query: str | None = None
135
+ self._root_query_attached: bool = False
136
+
137
+ # Timing
138
+ self._started_at: float | None = None
139
+
140
+ # Header/text
141
+ self.header_text: str = ""
142
+ # Track per-step server start times for accurate elapsed labels
143
+ # Output formatting constants
144
+ self.OUTPUT_PREFIX: str = "**Output:**\n"
145
+
146
+ self._final_transcript_snapshot: TranscriptSnapshot | None = None
147
+ self._final_transcript_renderables: tuple[list[Any], list[Any]] | None = None
148
+
149
+ def on_start(self, meta: dict[str, Any]) -> None:
150
+ """Handle renderer start event."""
151
+ if self.cfg.live:
152
+ # Defer creating Live to _ensure_live so tests and prod both work
153
+ pass
154
+
155
+ # Set up initial state
156
+ self._started_at = monotonic()
157
+ try:
158
+ self.state.meta = json.loads(json.dumps(meta))
159
+ except Exception:
160
+ self.state.meta = dict(meta)
161
+
162
+ meta_payload = meta or {}
163
+ self.steps.set_root_agent(meta_payload.get("agent_id"))
164
+ self._root_agent_friendly = self._humanize_agent_slug(meta_payload.get("agent_name"))
165
+ self._root_query = truncate_display(
166
+ meta_payload.get("input_message")
167
+ or meta_payload.get("query")
168
+ or meta_payload.get("message")
169
+ or (meta_payload.get("meta") or {}).get("input_message")
170
+ or ""
171
+ )
172
+ if not self._root_query:
173
+ self._root_query = None
174
+ self._root_query_attached = False
175
+
176
+ # Print compact header and user request (parity with old renderer)
177
+ self._render_header(meta)
178
+ self._render_user_query(meta)
179
+
180
+ def _render_header(self, meta: dict[str, Any]) -> None:
181
+ """Render the agent header with metadata."""
182
+ parts = self._build_header_parts(meta)
183
+ self.header_text = " ".join(parts)
184
+
185
+ if not self.header_text:
186
+ return
187
+
188
+ # Use a rule-like header for readability with fallback
189
+ if not self._render_header_rule():
190
+ self._render_header_fallback()
191
+
192
+ def _build_header_parts(self, meta: dict[str, Any]) -> list[str]:
193
+ """Build header text parts from metadata."""
194
+ parts: list[str] = [ICON_AGENT]
195
+ agent_name = meta.get("agent_name", "agent")
196
+ if agent_name:
197
+ parts.append(agent_name)
198
+
199
+ model = meta.get("model", "")
200
+ if model:
201
+ parts.extend(["•", model])
202
+
203
+ run_id = meta.get("run_id", "")
204
+ if run_id:
205
+ parts.extend(["•", run_id])
206
+
207
+ return parts
208
+
209
+ def _render_header_rule(self) -> bool:
210
+ """Render header as a rule. Returns True if successful."""
211
+ try:
212
+ self.console.rule(self.header_text)
213
+ return True
214
+ except Exception: # pragma: no cover - defensive fallback
215
+ logger.exception("Failed to render header rule")
216
+ return False
217
+
218
+ def _render_header_fallback(self) -> None:
219
+ """Fallback header rendering."""
220
+ try:
221
+ self.console.print(self.header_text)
222
+ except Exception:
223
+ logger.exception("Failed to print header fallback")
224
+
225
+ def _build_user_query_panel(self, query: str) -> AIPPanel:
226
+ """Create the panel used to display the user request."""
227
+ return AIPPanel(
228
+ Markdown(f"**Query:** {query}"),
229
+ title="User Request",
230
+ border_style="#d97706",
231
+ padding=(0, 1),
232
+ )
233
+
234
+ def _render_user_query(self, meta: dict[str, Any]) -> None:
235
+ """Render the user query panel."""
236
+ query = extract_query_from_meta(meta)
237
+ if not query:
238
+ return
239
+ self.console.print(self._build_user_query_panel(query))
240
+
241
+ def _render_summary_static_sections(self) -> None:
242
+ """Re-render header and user query when returning to summary mode."""
243
+ meta = getattr(self.state, "meta", None)
244
+ if meta:
245
+ self._render_header(meta)
246
+ elif self.header_text and not self._render_header_rule():
247
+ self._render_header_fallback()
248
+
249
+ query = extract_query_from_meta(meta) or self._root_query
250
+ if query:
251
+ self.console.print(self._build_user_query_panel(query))
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
+
282
+ def _ensure_streaming_started_baseline(self, timestamp: float) -> None:
283
+ """Synchronize streaming start state across renderer components."""
284
+ self.state.start_stream_timer(timestamp)
285
+ self.stream_processor.streaming_started_at = timestamp
286
+ self._started_at = timestamp
287
+
288
+ def on_event(self, ev: dict[str, Any]) -> None:
289
+ """Handle streaming events from the backend."""
290
+ received_at = self._resolve_received_timestamp(ev)
291
+ self._capture_event(ev, received_at)
292
+ self.stream_processor.reset_event_tracking()
293
+
294
+ self._sync_stream_start(ev, received_at)
295
+
296
+ metadata = self.stream_processor.extract_event_metadata(ev)
297
+
298
+ self._maybe_render_debug(ev, received_at)
299
+ try:
300
+ self._dispatch_event(ev, metadata)
301
+ finally:
302
+ self.stream_processor.update_timing(metadata.get("context_id"))
303
+
304
+ def _resolve_received_timestamp(self, ev: dict[str, Any]) -> datetime:
305
+ """Return the timestamp an event was received, normalising inputs."""
306
+ received_at = coerce_received_at(ev.get("received_at"))
307
+ if received_at is None:
308
+ received_at = datetime.now(timezone.utc)
309
+
310
+ if self.state.streaming_started_event_ts is None:
311
+ self.state.streaming_started_event_ts = received_at
312
+
313
+ return received_at
314
+
315
+ def _sync_stream_start(self, ev: dict[str, Any], received_at: datetime | None) -> None:
316
+ """Ensure renderer and stream processor share a streaming baseline."""
317
+ baseline = self.state.streaming_started_at
318
+ if baseline is None:
319
+ baseline = monotonic()
320
+ self._ensure_streaming_started_baseline(baseline)
321
+ elif getattr(self.stream_processor, "streaming_started_at", None) is None:
322
+ self._ensure_streaming_started_baseline(baseline)
323
+
324
+ if ev.get("status") == "streaming_started":
325
+ self.state.streaming_started_event_ts = received_at
326
+ self._ensure_streaming_started_baseline(monotonic())
327
+
328
+ def _maybe_render_debug(
329
+ self, ev: dict[str, Any], received_at: datetime
330
+ ) -> None: # pragma: no cover - guard rails for verbose mode
331
+ """Render debug view when verbose mode is enabled."""
332
+ if not self.verbose:
333
+ return
334
+
335
+ self._ensure_transcript_header()
336
+ render_debug_event(
337
+ ev,
338
+ self.console,
339
+ received_ts=received_at,
340
+ baseline_ts=self.state.streaming_started_event_ts,
341
+ )
342
+ self._print_transcript_hint()
343
+
344
+ def _dispatch_event(self, ev: dict[str, Any], metadata: dict[str, Any]) -> None:
345
+ """Route events to the appropriate renderer handlers."""
346
+ kind = metadata["kind"]
347
+ content = metadata["content"]
348
+
349
+ if kind == "status":
350
+ self._handle_status_event(ev)
351
+ elif kind == "content":
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)
356
+ elif kind == "final_response":
357
+ self._handle_final_response_event(content, metadata)
358
+ elif kind in {"agent_step", "agent_thinking_step"}:
359
+ self._handle_agent_step_event(ev, metadata)
360
+ else:
361
+ self._ensure_live()
362
+
363
+ def _handle_status_event(self, ev: dict[str, Any]) -> None:
364
+ """Handle status events."""
365
+ status = ev.get("status")
366
+ if status == "streaming_started":
367
+ return
368
+
369
+ def _handle_content_event(self, content: str) -> None:
370
+ """Handle content streaming events."""
371
+ if content:
372
+ self.state.append_transcript_text(content)
373
+ self._ensure_live()
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
+
400
+ def _handle_final_response_event(self, content: str, metadata: dict[str, Any]) -> None:
401
+ """Handle final response events."""
402
+ if content:
403
+ self.state.append_transcript_text(content)
404
+ self.state.set_final_output(content)
405
+
406
+ meta_payload = metadata.get("metadata") or {}
407
+ final_time = coerce_server_time(meta_payload.get("time"))
408
+ self._update_final_duration(final_time)
409
+ self.thinking_controller.close_active_scopes(final_time)
410
+ self._finish_running_steps()
411
+ self.tool_controller.finish_all_panels()
412
+ self._normalise_finished_icons()
413
+
414
+ self._ensure_live()
415
+ self._print_final_panel_if_needed()
416
+
417
+ def _normalise_finished_icons(self) -> None:
418
+ """Ensure finished steps release any running spinners."""
419
+ for step in self.steps.by_id.values():
420
+ if getattr(step, "status", None) != "running":
421
+ self._step_spinners.pop(step.step_id, None)
422
+
423
+ def _handle_agent_step_event(self, ev: dict[str, Any], metadata: dict[str, Any]) -> None:
424
+ """Handle agent step events."""
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
428
+
429
+ payload = metadata.get("metadata") or {}
430
+
431
+ tracked_step: Step | None = None
432
+ try:
433
+ tracked_step = self.steps.apply_event(ev)
434
+ except ValueError:
435
+ logger.debug("Malformed step event skipped", exc_info=True)
436
+ else:
437
+ self._record_step_server_start(tracked_step, payload)
438
+ self.thinking_controller.update_timeline(
439
+ tracked_step,
440
+ payload,
441
+ enabled=self.cfg.render_thinking,
442
+ )
443
+ self._maybe_override_root_agent_label(tracked_step, payload)
444
+ self._maybe_attach_root_query(tracked_step)
445
+
446
+ # Track tools and sub-agents for transcript/debug context
447
+ self.stream_processor.track_tools_and_agents(tool_name, tool_calls_info, is_delegation_tool)
448
+
449
+ # Handle tool execution
450
+ self.tool_controller.handle_agent_step(
451
+ ev,
452
+ tool_name,
453
+ tool_args,
454
+ tool_out,
455
+ tool_calls_info,
456
+ tracked_step=tracked_step,
457
+ )
458
+
459
+ # Update live display
460
+ self._ensure_live()
461
+
462
+ def _maybe_attach_root_query(self, step: Step | None) -> None:
463
+ """Attach the user query to the root agent step for display."""
464
+ if not step or self._root_query_attached or not self._root_query or step.kind != "agent" or step.parent_id:
465
+ return
466
+
467
+ args = dict(getattr(step, "args", {}) or {})
468
+ args.setdefault("query", self._root_query)
469
+ step.args = args
470
+ self._root_query_attached = True
471
+
472
+ def _record_step_server_start(self, step: Step | None, payload: dict[str, Any]) -> None:
473
+ """Store server-provided start times for elapsed calculations."""
474
+ if not step:
475
+ return
476
+ server_time = payload.get("time")
477
+ if not isinstance(server_time, (int, float)):
478
+ return
479
+ self._step_server_start_times.setdefault(step.step_id, float(server_time))
480
+
481
+ def _maybe_override_root_agent_label(self, step: Step | None, payload: dict[str, Any]) -> None:
482
+ """Ensure the root agent row uses the human-friendly name and shows the ID."""
483
+ if not step or step.kind != "agent" or step.parent_id:
484
+ return
485
+ friendly = self._root_agent_friendly or self._humanize_agent_slug((payload or {}).get("agent_name"))
486
+ if not friendly:
487
+ return
488
+ agent_identifier = step.name or step.step_id
489
+ if not agent_identifier:
490
+ return
491
+ step.display_label = normalise_display_label(f"{ICON_AGENT} {friendly} ({agent_identifier})")
492
+ if not self._root_agent_step_id:
493
+ self._root_agent_step_id = step.step_id
494
+
495
+ # Thinking scope management is handled by ThinkingScopeController.
496
+
497
+ def _apply_root_duration(self, duration_seconds: float | None) -> None:
498
+ """Propagate the final run duration to the root agent step."""
499
+ if duration_seconds is None or not self._root_agent_step_id:
500
+ return
501
+ root_step = self.steps.by_id.get(self._root_agent_step_id)
502
+ if not root_step:
503
+ return
504
+ try:
505
+ duration_ms = max(0, int(round(float(duration_seconds) * 1000)))
506
+ except Exception:
507
+ return
508
+ root_step.duration_ms = duration_ms
509
+ root_step.duration_source = root_step.duration_source or "run"
510
+ root_step.status = "finished"
511
+
512
+ @staticmethod
513
+ def _humanize_agent_slug(value: Any) -> str | None:
514
+ """Convert a slugified agent name into Title Case."""
515
+ if not isinstance(value, str):
516
+ return None
517
+ cleaned = value.replace("_", " ").replace("-", " ").strip()
518
+ if not cleaned:
519
+ return None
520
+ parts = [part for part in cleaned.split() if part]
521
+ return " ".join(part[:1].upper() + part[1:] for part in parts)
522
+
523
+ def _finish_running_steps(self) -> None:
524
+ """Mark any running steps as finished to avoid lingering spinners."""
525
+ for st in self.steps.by_id.values():
526
+ if not is_step_finished(st):
527
+ self._mark_incomplete_step(st)
528
+
529
+ def _mark_incomplete_step(self, step: Step) -> None:
530
+ """Mark a lingering step as incomplete/warning with unknown duration."""
531
+ step.status = "finished"
532
+ step.duration_unknown = True
533
+ if step.duration_ms is None:
534
+ step.duration_ms = 0
535
+ step.duration_source = step.duration_source or "unknown"
536
+
537
+ def _stop_live_display(self) -> None:
538
+ """Stop live display and clean up."""
539
+ self._shutdown_live()
540
+
541
+ def _print_final_panel_if_needed(self) -> None:
542
+ """Print final result when configuration requires it."""
543
+ if self.state.printed_final_output:
544
+ return
545
+
546
+ body = (self.state.final_text or self.state.buffer.render() or "").strip()
547
+ if not body:
548
+ return
549
+
550
+ if getattr(self, "_transcript_mode_enabled", False):
551
+ return
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
+
565
+ if self.verbose:
566
+ panel = build_final_panel(
567
+ self.state,
568
+ title=self._final_panel_title(),
569
+ )
570
+ if panel is None:
571
+ return
572
+ self.console.print(panel)
573
+ self.state.printed_final_output = True
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
+
606
+ def on_complete(self, stats: RunStats) -> None:
607
+ """Handle completion event."""
608
+ self.state.finalizing_ui = True
609
+
610
+ self._handle_stats_duration(stats)
611
+ self.thinking_controller.close_active_scopes(self.state.final_duration_seconds)
612
+ self._cleanup_ui_elements()
613
+ self._finalize_display()
614
+ self._print_completion_message()
615
+
616
+ def _handle_stats_duration(self, stats: RunStats) -> None:
617
+ """Handle stats processing and duration calculation."""
618
+ if not isinstance(stats, RunStats):
619
+ return
620
+
621
+ duration = None
622
+ try:
623
+ if stats.finished_at is not None and stats.started_at is not None:
624
+ duration = max(0.0, float(stats.finished_at) - float(stats.started_at))
625
+ except Exception:
626
+ duration = None
627
+
628
+ if duration is not None:
629
+ self._update_final_duration(duration, overwrite=True)
630
+
631
+ def _cleanup_ui_elements(self) -> None:
632
+ """Clean up running UI elements."""
633
+ # Mark any running steps as finished to avoid lingering spinners
634
+ self._finish_running_steps()
635
+
636
+ # Mark unfinished tool panels as finished
637
+ self.tool_controller.finish_all_panels()
638
+
639
+ def _finalize_display(self) -> None:
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
+
654
+ # Final refresh
655
+ self._ensure_live()
656
+
657
+ header, body = self.finalize()
658
+
659
+ # Stop live display
660
+ self._stop_live_display()
661
+
662
+ # Render final output based on configuration
663
+ if self.cfg.live:
664
+ self._print_final_panel_if_needed()
665
+ else:
666
+ self._render_final_summary(header, body)
667
+
668
+ def _print_completion_message(self) -> None:
669
+ """Print completion message based on current mode."""
670
+ if self._transcript_mode_enabled:
671
+ try:
672
+ self.console.print(
673
+ "[dim]Run finished. Press Ctrl+T to return to the summary view or stay here to inspect events. "
674
+ "Use the post-run viewer for export.[/dim]"
675
+ )
676
+ except Exception:
677
+ pass
678
+ else:
679
+ # No transcript toggle in summary mode; nothing to print here.
680
+ return
681
+
682
+ def _ensure_live(self) -> None:
683
+ """Ensure live display is updated."""
684
+ if getattr(self, "_transcript_mode_enabled", False):
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
690
+ if not self._ensure_live_stack():
691
+ return
692
+
693
+ self._start_live_if_needed()
694
+
695
+ if self.live:
696
+ self._refresh_live_panels()
697
+ if (
698
+ not self._transcript_mode_enabled
699
+ and not self.state.finalizing_ui
700
+ and not self._summary_hint_printed_once
701
+ ):
702
+ self._print_summary_hint(force=True)
703
+
704
+ def _ensure_live_stack(self) -> bool:
705
+ """Guarantee the console exposes the internal live stack Rich expects."""
706
+ live_stack = getattr(self.console, "_live_stack", None)
707
+ if isinstance(live_stack, list):
708
+ return True
709
+
710
+ try:
711
+ self.console._live_stack = [] # type: ignore[attr-defined]
712
+ return True
713
+ except Exception:
714
+ # If the console forbids attribute assignment we simply skip the live
715
+ # update for this cycle and fall back to buffered printing.
716
+ logger.debug(
717
+ "Console missing _live_stack; skipping live UI initialisation",
718
+ exc_info=True,
719
+ )
720
+ return False
721
+
722
+ def _start_live_if_needed(self) -> None:
723
+ """Create and start a Live instance when configuration allows."""
724
+ if self.live is not None or not self.cfg.live:
725
+ return
726
+
727
+ try:
728
+ self.live = Live(
729
+ console=self.console,
730
+ refresh_per_second=1 / self.cfg.refresh_debounce,
731
+ transient=not self.cfg.persist_live,
732
+ )
733
+ self.live.start()
734
+ except Exception:
735
+ self.live = None
736
+
737
+ def _refresh_live_panels(self) -> None:
738
+ """Render panels and push them to the active Live display."""
739
+ if not self.live:
740
+ return
741
+
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()
746
+ steps_panel = AIPPanel(
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)),
751
+ )
752
+
753
+ main_panel = self._render_main_panel()
754
+ panels = self._build_live_panels(main_panel, steps_panel)
755
+
756
+ self.live.update(Group(*panels))
757
+
758
+ def _build_live_panels(
759
+ self,
760
+ main_panel: Any,
761
+ steps_panel: Any,
762
+ ) -> list[Any]:
763
+ """Assemble the panel order for the live display."""
764
+ if self.verbose:
765
+ return [main_panel, steps_panel]
766
+
767
+ return [steps_panel, main_panel]
768
+
769
+ def _render_main_panel(self) -> Any:
770
+ """Render the main content panel."""
771
+ body = self.state.buffer.render().strip()
772
+ theme = DEFAULT_TRANSCRIPT_THEME
773
+ if not self.verbose:
774
+ panel = build_final_panel(self.state, theme=theme)
775
+ if panel is not None:
776
+ return panel
777
+ # Dynamic title with spinner + elapsed/hints
778
+ title = self._format_enhanced_main_title()
779
+ return create_main_panel(body, title, theme)
780
+
781
+ def _final_panel_title(self) -> str:
782
+ """Compose title for the final result panel including duration."""
783
+ return format_final_panel_title(self.state)
784
+
785
+ def apply_verbosity(self, verbose: bool) -> None:
786
+ """Update verbose behaviour at runtime."""
787
+ if self.verbose == verbose:
788
+ return
789
+
790
+ self.verbose = verbose
791
+ desired_live = not verbose
792
+ if desired_live != self.cfg.live:
793
+ self.cfg.live = desired_live
794
+ if not desired_live:
795
+ self._shutdown_live()
796
+ else:
797
+ self._ensure_live()
798
+
799
+ if self.cfg.live:
800
+ self._ensure_live()
801
+
802
+ # Transcript helper implementations live in TranscriptModeMixin.
803
+
804
+ def get_aggregated_output(self) -> str:
805
+ """Return the concatenated assistant output collected so far."""
806
+ return self.state.buffer.render().strip()
807
+
808
+ def get_transcript_events(self) -> list[dict[str, Any]]:
809
+ """Return captured SSE events."""
810
+ return list(self.state.events)
811
+
812
+ def _format_working_indicator(self, started_at: float | None) -> str:
813
+ """Format working indicator."""
814
+ return format_working_indicator(
815
+ started_at,
816
+ self.stream_processor.server_elapsed_time,
817
+ self.state.streaming_started_at,
818
+ )
819
+
820
+ def close(self) -> None:
821
+ """Gracefully stop any live rendering and release resources."""
822
+ self._shutdown_live()
823
+
824
+ def __del__(self) -> None:
825
+ """Destructor that ensures live rendering is properly shut down.
826
+
827
+ This is a safety net to prevent resource leaks if the renderer
828
+ is not explicitly stopped.
829
+ """
830
+ # Destructors must never raise
831
+ try:
832
+ self._shutdown_live(reset_attr=False)
833
+ except Exception: # pragma: no cover - destructor safety net
834
+ pass
835
+
836
+ def _shutdown_live(self, reset_attr: bool = True) -> None:
837
+ """Stop the live renderer without letting exceptions escape."""
838
+ live = getattr(self, "live", None)
839
+ if not live:
840
+ if reset_attr and not hasattr(self, "live"):
841
+ self.live = None
842
+ return
843
+
844
+ try:
845
+ live.stop()
846
+ except Exception:
847
+ logger.exception("Failed to stop live display")
848
+ finally:
849
+ if reset_attr:
850
+ self.live = None
851
+
852
+ def _get_analysis_progress_info(self) -> dict[str, Any]:
853
+ total_steps = len(self.steps.order)
854
+ completed_steps = sum(1 for sid in self.steps.order if is_step_finished(self.steps.by_id[sid]))
855
+ current_step = None
856
+ for sid in self.steps.order:
857
+ if not is_step_finished(self.steps.by_id[sid]):
858
+ current_step = sid
859
+ break
860
+ # Prefer server elapsed time when available
861
+ elapsed = 0.0
862
+ if isinstance(self.stream_processor.server_elapsed_time, (int, float)):
863
+ elapsed = float(self.stream_processor.server_elapsed_time)
864
+ elif self._started_at is not None:
865
+ elapsed = monotonic() - self._started_at
866
+ progress_percent = int((completed_steps / total_steps) * 100) if total_steps else 0
867
+ return {
868
+ "total_steps": total_steps,
869
+ "completed_steps": completed_steps,
870
+ "current_step": current_step,
871
+ "progress_percent": progress_percent,
872
+ "elapsed_time": elapsed,
873
+ "has_running_steps": self._has_running_steps(),
874
+ }
875
+
876
+ def _format_enhanced_main_title(self) -> str:
877
+ base = format_main_title(
878
+ header_text=self.header_text,
879
+ has_running_steps=self._has_running_steps(),
880
+ get_spinner_char=get_spinner_char,
881
+ )
882
+ # Add elapsed time and subtle progress hints for long operations
883
+ info = self._get_analysis_progress_info()
884
+ elapsed = info.get("elapsed_time", 0.0)
885
+ if elapsed and elapsed > 0:
886
+ base += f" · {format_elapsed_time(elapsed)}"
887
+ if info.get("total_steps", 0) > 1 and info.get("has_running_steps"):
888
+ if elapsed > 60:
889
+ base += " 🐌"
890
+ elif elapsed > 30:
891
+ base += " ⚠️"
892
+ return base
893
+
894
+ # Modern interface only — no legacy helper shims below
895
+
896
+ def _refresh(self, _force: bool | None = None) -> None:
897
+ # In the modular renderer, refreshing simply updates the live group
898
+ self._ensure_live()
899
+
900
+ def _has_running_steps(self) -> bool:
901
+ """Check if any steps are still running."""
902
+ for _sid, st in self.steps.by_id.items():
903
+ if not is_step_finished(st):
904
+ return True
905
+ return False
906
+
907
+ def _get_step_icon(self, step_kind: str) -> str:
908
+ """Get icon for step kind."""
909
+ if step_kind == "tool":
910
+ return ICON_TOOL_STEP
911
+ elif step_kind == "delegate":
912
+ return ICON_DELEGATE
913
+ elif step_kind == "agent":
914
+ return ICON_AGENT_STEP
915
+ return ""
916
+
917
+ def _format_step_status(self, step: Step) -> str:
918
+ """Format step status with elapsed time or duration."""
919
+ if is_step_finished(step):
920
+ return self._format_finished_badge(step)
921
+ else:
922
+ # Calculate elapsed time for running steps
923
+ elapsed = self._calculate_step_elapsed_time(step)
924
+ if elapsed >= 0.1:
925
+ return f"[{elapsed:.2f}s]"
926
+ ms = int(round(elapsed * 1000))
927
+ if ms <= 0:
928
+ return ""
929
+ return f"[{ms}ms]"
930
+
931
+ def _format_finished_badge(self, step: Step) -> str:
932
+ """Compose duration badge for finished steps including source tagging."""
933
+ if getattr(step, "duration_unknown", False) is True:
934
+ payload = "??s"
935
+ else:
936
+ duration_ms = step.duration_ms
937
+ if duration_ms is None:
938
+ payload = "<1ms"
939
+ elif duration_ms < 0:
940
+ payload = "<1ms"
941
+ elif duration_ms >= 100:
942
+ payload = f"{duration_ms / 1000:.2f}s"
943
+ elif duration_ms > 0:
944
+ payload = f"{duration_ms}ms"
945
+ else:
946
+ payload = "<1ms"
947
+
948
+ return f"[{payload}]"
949
+
950
+ def _calculate_step_elapsed_time(self, step: Step) -> float:
951
+ """Calculate elapsed time for a running step."""
952
+ server_elapsed = self.stream_processor.server_elapsed_time
953
+ server_start = self._step_server_start_times.get(step.step_id)
954
+
955
+ if isinstance(server_elapsed, (int, float)) and isinstance(server_start, (int, float)):
956
+ return max(0.0, float(server_elapsed) - float(server_start))
957
+
958
+ try:
959
+ return max(0.0, float(monotonic() - step.started_at))
960
+ except Exception:
961
+ return 0.0
962
+
963
+ def _get_step_display_name(self, step: Step) -> str:
964
+ """Get display name for a step."""
965
+ if step.name and step.name != "step":
966
+ return step.name
967
+ return "thinking..." if step.kind == "agent" else f"{step.kind} step"
968
+
969
+ def _resolve_step_label(self, step: Step) -> str:
970
+ """Return the display label for a step with sensible fallbacks."""
971
+ return format_step_label(step)
972
+
973
+ def _check_parallel_tools(self) -> dict[tuple[str | None, str | None], list]:
974
+ """Check for parallel running tools."""
975
+ running_by_ctx: dict[tuple[str | None, str | None], list] = {}
976
+ for sid in self.steps.order:
977
+ st = self.steps.by_id[sid]
978
+ if st.kind == "tool" and not is_step_finished(st):
979
+ key = (st.task_id, st.context_id)
980
+ running_by_ctx.setdefault(key, []).append(st)
981
+ return running_by_ctx
982
+
983
+ def _is_parallel_tool(
984
+ self,
985
+ step: Step,
986
+ running_by_ctx: dict[tuple[str | None, str | None], list],
987
+ ) -> bool:
988
+ """Return True if multiple tools are running in the same context."""
989
+ key = (step.task_id, step.context_id)
990
+ return len(running_by_ctx.get(key, [])) > 1
991
+
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(),
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)
1051
+
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)
1055
+
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)
1061
+
1062
+ def _update_final_duration(self, duration: float | None, *, overwrite: bool = False) -> None:
1063
+ """Store formatted duration for eventual final panels."""
1064
+ if duration is None:
1065
+ return
1066
+
1067
+ try:
1068
+ duration_val = max(0.0, float(duration))
1069
+ except Exception:
1070
+ return
1071
+
1072
+ existing = self.state.final_duration_seconds
1073
+
1074
+ if not overwrite and existing is not None:
1075
+ return
1076
+
1077
+ if overwrite and existing is not None:
1078
+ duration_val = max(existing, duration_val)
1079
+
1080
+ formatted = format_elapsed_time(duration_val)
1081
+ self.state.mark_final_duration(duration_val, formatted=formatted)
1082
+ self._apply_root_duration(duration_val)