glaip-sdk 0.1.0__py3-none-any.whl → 0.6.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1191 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +265 -45
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents.py +251 -173
  12. glaip_sdk/cli/commands/common_config.py +101 -0
  13. glaip_sdk/cli/commands/configure.py +735 -143
  14. glaip_sdk/cli/commands/mcps.py +266 -134
  15. glaip_sdk/cli/commands/models.py +13 -9
  16. glaip_sdk/cli/commands/tools.py +67 -88
  17. glaip_sdk/cli/commands/transcripts.py +755 -0
  18. glaip_sdk/cli/commands/update.py +3 -8
  19. glaip_sdk/cli/config.py +49 -7
  20. glaip_sdk/cli/constants.py +38 -0
  21. glaip_sdk/cli/context.py +8 -0
  22. glaip_sdk/cli/core/__init__.py +79 -0
  23. glaip_sdk/cli/core/context.py +124 -0
  24. glaip_sdk/cli/core/output.py +846 -0
  25. glaip_sdk/cli/core/prompting.py +649 -0
  26. glaip_sdk/cli/core/rendering.py +187 -0
  27. glaip_sdk/cli/display.py +45 -32
  28. glaip_sdk/cli/hints.py +57 -0
  29. glaip_sdk/cli/io.py +14 -17
  30. glaip_sdk/cli/main.py +232 -143
  31. glaip_sdk/cli/masking.py +21 -33
  32. glaip_sdk/cli/mcp_validators.py +5 -15
  33. glaip_sdk/cli/pager.py +12 -19
  34. glaip_sdk/cli/parsers/__init__.py +1 -3
  35. glaip_sdk/cli/parsers/json_input.py +11 -22
  36. glaip_sdk/cli/resolution.py +3 -9
  37. glaip_sdk/cli/rich_helpers.py +1 -3
  38. glaip_sdk/cli/slash/__init__.py +0 -9
  39. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  40. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  41. glaip_sdk/cli/slash/agent_session.py +65 -29
  42. glaip_sdk/cli/slash/prompt.py +24 -10
  43. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  44. glaip_sdk/cli/slash/session.py +807 -225
  45. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  46. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  47. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  48. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  49. glaip_sdk/cli/slash/tui/loading.py +58 -0
  50. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  51. glaip_sdk/cli/transcript/__init__.py +12 -52
  52. glaip_sdk/cli/transcript/cache.py +258 -60
  53. glaip_sdk/cli/transcript/capture.py +72 -21
  54. glaip_sdk/cli/transcript/history.py +815 -0
  55. glaip_sdk/cli/transcript/launcher.py +1 -3
  56. glaip_sdk/cli/transcript/viewer.py +79 -499
  57. glaip_sdk/cli/update_notifier.py +177 -24
  58. glaip_sdk/cli/utils.py +242 -1308
  59. glaip_sdk/cli/validators.py +16 -18
  60. glaip_sdk/client/__init__.py +2 -1
  61. glaip_sdk/client/_agent_payloads.py +53 -37
  62. glaip_sdk/client/agent_runs.py +147 -0
  63. glaip_sdk/client/agents.py +320 -92
  64. glaip_sdk/client/base.py +78 -35
  65. glaip_sdk/client/main.py +19 -10
  66. glaip_sdk/client/mcps.py +123 -15
  67. glaip_sdk/client/run_rendering.py +136 -101
  68. glaip_sdk/client/shared.py +21 -0
  69. glaip_sdk/client/tools.py +163 -34
  70. glaip_sdk/client/validators.py +20 -48
  71. glaip_sdk/config/constants.py +11 -0
  72. glaip_sdk/exceptions.py +1 -3
  73. glaip_sdk/mcps/__init__.py +21 -0
  74. glaip_sdk/mcps/base.py +345 -0
  75. glaip_sdk/models/__init__.py +90 -0
  76. glaip_sdk/models/agent.py +47 -0
  77. glaip_sdk/models/agent_runs.py +116 -0
  78. glaip_sdk/models/common.py +42 -0
  79. glaip_sdk/models/mcp.py +33 -0
  80. glaip_sdk/models/tool.py +33 -0
  81. glaip_sdk/payload_schemas/__init__.py +1 -13
  82. glaip_sdk/payload_schemas/agent.py +1 -3
  83. glaip_sdk/registry/__init__.py +55 -0
  84. glaip_sdk/registry/agent.py +164 -0
  85. glaip_sdk/registry/base.py +139 -0
  86. glaip_sdk/registry/mcp.py +253 -0
  87. glaip_sdk/registry/tool.py +232 -0
  88. glaip_sdk/rich_components.py +58 -2
  89. glaip_sdk/runner/__init__.py +59 -0
  90. glaip_sdk/runner/base.py +84 -0
  91. glaip_sdk/runner/deps.py +115 -0
  92. glaip_sdk/runner/langgraph.py +706 -0
  93. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  94. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  95. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  96. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  97. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  98. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  99. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  100. glaip_sdk/tools/__init__.py +22 -0
  101. glaip_sdk/tools/base.py +435 -0
  102. glaip_sdk/utils/__init__.py +58 -12
  103. glaip_sdk/utils/a2a/__init__.py +34 -0
  104. glaip_sdk/utils/a2a/event_processor.py +188 -0
  105. glaip_sdk/utils/agent_config.py +4 -14
  106. glaip_sdk/utils/bundler.py +267 -0
  107. glaip_sdk/utils/client.py +111 -0
  108. glaip_sdk/utils/client_utils.py +46 -28
  109. glaip_sdk/utils/datetime_helpers.py +58 -0
  110. glaip_sdk/utils/discovery.py +78 -0
  111. glaip_sdk/utils/display.py +25 -21
  112. glaip_sdk/utils/export.py +143 -0
  113. glaip_sdk/utils/general.py +1 -36
  114. glaip_sdk/utils/import_export.py +15 -16
  115. glaip_sdk/utils/import_resolver.py +492 -0
  116. glaip_sdk/utils/instructions.py +101 -0
  117. glaip_sdk/utils/rendering/__init__.py +115 -1
  118. glaip_sdk/utils/rendering/formatting.py +7 -35
  119. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  120. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  121. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  122. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  123. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  124. glaip_sdk/utils/rendering/models.py +3 -6
  125. glaip_sdk/utils/rendering/renderer/__init__.py +9 -49
  126. glaip_sdk/utils/rendering/renderer/base.py +258 -1577
  127. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  128. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  129. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  130. glaip_sdk/utils/rendering/renderer/stream.py +10 -51
  131. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  132. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  133. glaip_sdk/utils/rendering/renderer/toggle.py +1 -3
  134. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  135. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  136. glaip_sdk/utils/rendering/state.py +204 -0
  137. glaip_sdk/utils/rendering/step_tree_state.py +1 -3
  138. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  139. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +76 -517
  140. glaip_sdk/utils/rendering/steps/format.py +176 -0
  141. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  142. glaip_sdk/utils/rendering/timing.py +36 -0
  143. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  144. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  145. glaip_sdk/utils/resource_refs.py +29 -26
  146. glaip_sdk/utils/runtime_config.py +425 -0
  147. glaip_sdk/utils/serialization.py +32 -46
  148. glaip_sdk/utils/sync.py +142 -0
  149. glaip_sdk/utils/tool_detection.py +33 -0
  150. glaip_sdk/utils/validation.py +20 -28
  151. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/METADATA +42 -4
  152. glaip_sdk-0.6.10.dist-info/RECORD +159 -0
  153. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/WHEEL +1 -1
  154. glaip_sdk/models.py +0 -259
  155. glaip_sdk-0.1.0.dist-info/RECORD +0 -82
  156. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/entry_points.txt +0 -0
@@ -6,16 +6,12 @@ Authors:
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from collections.abc import Callable, Iterable
10
- from dataclasses import dataclass
11
- from datetime import datetime, timezone
9
+ from collections.abc import Callable
12
10
  from pathlib import Path
13
11
  from typing import Any
14
12
 
15
13
  import click
16
14
  from rich.console import Console
17
- from rich.markdown import Markdown
18
- from rich.text import Text
19
15
 
20
16
  try: # pragma: no cover - optional dependency
21
17
  import questionary
@@ -25,34 +21,21 @@ except Exception: # pragma: no cover - optional dependency
25
21
  Choice = None # type: ignore[assignment]
26
22
 
27
23
  from glaip_sdk.cli.transcript.cache import suggest_filename
28
- from glaip_sdk.icons import ICON_DELEGATE, ICON_TOOL_STEP
29
- from glaip_sdk.rich_components import AIPPanel
30
- from glaip_sdk.utils.rendering.formatting import (
31
- build_connector_prefix,
32
- glyph_for_status,
33
- normalise_display_label,
24
+ from glaip_sdk.cli.utils import prompt_export_choice_questionary, questionary_safe_ask
25
+ from glaip_sdk.utils.rendering.layout.progress import is_delegation_tool
26
+ from glaip_sdk.utils.rendering.layout.transcript import DEFAULT_TRANSCRIPT_THEME
27
+ from glaip_sdk.utils.rendering.viewer import (
28
+ ViewerContext as PresenterViewerContext,
29
+ prepare_viewer_snapshot as presenter_prepare_viewer_snapshot,
30
+ render_post_run_view as presenter_render_post_run_view,
31
+ render_transcript_events as presenter_render_transcript_events,
32
+ render_transcript_view as presenter_render_transcript_view,
34
33
  )
35
- from glaip_sdk.utils.rendering.renderer.debug import render_debug_event
36
- from glaip_sdk.utils.rendering.renderer.panels import create_final_panel
37
- from glaip_sdk.utils.rendering.renderer.progress import (
38
- format_elapsed_time,
39
- is_delegation_tool,
40
- )
41
- from glaip_sdk.utils.rendering.steps import StepManager
42
34
 
43
35
  EXPORT_CANCELLED_MESSAGE = "[dim]Export cancelled.[/dim]"
44
36
 
45
37
 
46
- @dataclass(slots=True)
47
- class ViewerContext:
48
- """Runtime context for the viewer session."""
49
-
50
- manifest_entry: dict[str, Any]
51
- events: list[dict[str, Any]]
52
- default_output: str
53
- final_output: str
54
- stream_started_at: float | None
55
- meta: dict[str, Any]
38
+ ViewerContext = PresenterViewerContext
56
39
 
57
40
 
58
41
  class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
@@ -63,18 +46,18 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
63
46
  console: Console,
64
47
  ctx: ViewerContext,
65
48
  export_callback: Callable[[Path], Path],
49
+ *,
50
+ initial_view: str = "default",
66
51
  ) -> None:
67
52
  """Initialize viewer state for a captured transcript."""
68
53
  self.console = console
69
54
  self.ctx = ctx
70
55
  self._export_callback = export_callback
71
- self._view_mode = "default"
56
+ self._view_mode = initial_view if initial_view in {"default", "transcript"} else "default"
72
57
 
73
58
  def run(self) -> None:
74
59
  """Enter the interactive loop."""
75
- if not self.ctx.events and not (
76
- self.ctx.default_output or self.ctx.final_output
77
- ):
60
+ if not self.ctx.events and not (self.ctx.default_output or self.ctx.final_output):
78
61
  return
79
62
  if self._view_mode == "transcript":
80
63
  self._render()
@@ -85,15 +68,14 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
85
68
  # Rendering helpers
86
69
  # ------------------------------------------------------------------
87
70
  def _render(self) -> None:
71
+ """Render the transcript viewer interface."""
88
72
  try:
89
73
  if self.console.is_terminal:
90
74
  self.console.clear()
91
75
  except Exception: # pragma: no cover - platform quirks
92
76
  pass
93
77
 
94
- header = (
95
- f"Agent transcript viewer · run {self.ctx.manifest_entry.get('run_id')}"
96
- )
78
+ header = f"Agent transcript viewer · run {self.ctx.manifest_entry.get('run_id')}"
97
79
  agent_label = self.ctx.manifest_entry.get("agent_name") or "unknown agent"
98
80
  model = self.ctx.manifest_entry.get("model") or self.ctx.meta.get("model")
99
81
  agent_id = self.ctx.manifest_entry.get("agent_id")
@@ -109,66 +91,19 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
109
91
  self.console.print(f"[dim]{' · '.join(subtitle_parts)}[/]")
110
92
  self.console.print()
111
93
 
112
- query = self._get_user_query()
113
-
114
94
  if self._view_mode == "default":
115
- self._render_default_view(query)
95
+ presenter_render_post_run_view(self.console, self.ctx)
116
96
  else:
117
- self._render_transcript_view(query)
118
-
119
- def _render_default_view(self, query: str | None) -> None:
120
- if query:
121
- self._render_user_query(query)
122
- self._render_steps_summary()
123
- self._render_final_panel()
124
-
125
- def _render_transcript_view(self, query: str | None) -> None:
126
- if not self.ctx.events:
127
- self.console.print("[dim]No SSE events were captured for this run.[/dim]")
128
- return
129
-
130
- if query:
131
- self._render_user_query(query)
132
-
133
- self._render_steps_summary()
134
- self._render_final_panel()
135
-
136
- self.console.print("[bold]Transcript Events[/bold]")
137
- self.console.print(
138
- "[dim]────────────────────────────────────────────────────────[/dim]"
139
- )
140
-
141
- base_received_ts: datetime | None = None
142
- for event in self.ctx.events:
143
- received_ts = self._parse_received_timestamp(event)
144
- if base_received_ts is None and received_ts is not None:
145
- base_received_ts = received_ts
146
- render_debug_event(
147
- event,
148
- self.console,
149
- received_ts=received_ts,
150
- baseline_ts=base_received_ts,
151
- )
152
- self.console.print()
153
-
154
- def _render_final_panel(self) -> None:
155
- content = (
156
- self.ctx.final_output
157
- or self.ctx.default_output
158
- or "No response content captured."
159
- )
160
- title = "Final Result"
161
- duration_text = self._extract_final_duration()
162
- if duration_text:
163
- title += f" · {duration_text}"
164
- panel = create_final_panel(content, title=title, theme="dark")
165
- self.console.print(panel)
166
- self.console.print()
97
+ theme = DEFAULT_TRANSCRIPT_THEME
98
+ snapshot, state = presenter_prepare_viewer_snapshot(self.ctx, glyphs=None, theme=theme)
99
+ presenter_render_transcript_view(self.console, snapshot, theme=theme)
100
+ presenter_render_transcript_events(self.console, state.events)
167
101
 
168
102
  # ------------------------------------------------------------------
169
103
  # Interaction loops
170
104
  # ------------------------------------------------------------------
171
105
  def _fallback_loop(self) -> None:
106
+ """Fallback interaction loop for non-interactive terminals."""
172
107
  while True:
173
108
  try:
174
109
  ch = click.getchar()
@@ -189,6 +124,14 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
189
124
  continue
190
125
 
191
126
  def _handle_command(self, raw: str) -> bool:
127
+ """Handle a command input.
128
+
129
+ Args:
130
+ raw: Raw command string.
131
+
132
+ Returns:
133
+ True to continue, False to exit.
134
+ """
192
135
  lowered = raw.lower()
193
136
  if lowered in {"exit", "quit", "q"}:
194
137
  return True
@@ -218,9 +161,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
218
161
  raw = str(path)
219
162
  return raw if len(raw) <= 80 else f"…{raw[-77:]}"
220
163
 
221
- selection = self._prompt_export_choice(
222
- default_path, _display_path(default_path)
223
- )
164
+ selection = self._prompt_export_choice(default_path, _display_path(default_path))
224
165
  if selection is None:
225
166
  self._legacy_export_prompt(default_path, _display_path)
226
167
  return
@@ -246,42 +187,12 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
246
187
  except Exception as exc: # pragma: no cover - unexpected IO failures
247
188
  self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
248
189
 
249
- def _prompt_export_choice(
250
- self, default_path: Path, default_display: str
251
- ) -> tuple[str, Any] | None:
190
+ def _prompt_export_choice(self, default_path: Path, default_display: str) -> tuple[str, Any] | None:
252
191
  """Render interactive export menu with numeric shortcuts."""
253
- if not self.console.is_terminal or questionary is None or Choice is None:
254
- return None
255
-
256
- try:
257
- answer = questionary.select(
258
- "Export transcript",
259
- choices=[
260
- Choice(
261
- title=f"Save to default ({default_display})",
262
- value=("default", default_path),
263
- shortcut_key="1",
264
- ),
265
- Choice(
266
- title="Choose a different path",
267
- value=("custom", None),
268
- shortcut_key="2",
269
- ),
270
- Choice(
271
- title="Cancel",
272
- value=("cancel", None),
273
- shortcut_key="3",
274
- ),
275
- ],
276
- use_shortcuts=True,
277
- instruction="Press 1-3 (or arrows) then Enter.",
278
- ).ask()
279
- except Exception:
192
+ if not self.console.is_terminal:
280
193
  return None
281
194
 
282
- if answer is None:
283
- return ("cancel", None)
284
- return answer
195
+ return prompt_export_choice_questionary(default_path, default_display)
285
196
 
286
197
  def _prompt_custom_destination(self) -> Path | None:
287
198
  """Prompt for custom export path with filesystem completion."""
@@ -289,11 +200,12 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
289
200
  return None
290
201
 
291
202
  try:
292
- response = questionary.path(
203
+ question = questionary.path(
293
204
  "Destination path (Tab to autocomplete):",
294
205
  default="",
295
206
  only_directories=False,
296
- ).ask()
207
+ )
208
+ response = questionary_safe_ask(question)
297
209
  except Exception:
298
210
  return None
299
211
 
@@ -305,9 +217,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
305
217
  candidate = Path.cwd() / candidate
306
218
  return candidate
307
219
 
308
- def _legacy_export_prompt(
309
- self, default_path: Path, formatter: Callable[[Path], str]
310
- ) -> None:
220
+ def _legacy_export_prompt(self, default_path: Path, formatter: Callable[[Path], str]) -> None:
311
221
  """Fallback export workflow when interactive UI is unavailable."""
312
222
  self.console.print("[dim]Export options (fallback mode)[/dim]")
313
223
  self.console.print(f" 1. Save to default ({formatter(default_path)})")
@@ -353,340 +263,22 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
353
263
  self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
354
264
 
355
265
  def _print_command_hint(self) -> None:
356
- self.console.print(
357
- "[dim]Ctrl+T to toggle transcript · type `e` to export · press Enter to exit[/dim]"
358
- )
359
- self.console.print()
360
-
361
- def _get_user_query(self) -> str | None:
362
- meta = self.ctx.meta or {}
363
- manifest = self.ctx.manifest_entry or {}
364
- return (
365
- meta.get("input_message")
366
- or meta.get("query")
367
- or meta.get("message")
368
- or manifest.get("input_message")
369
- )
370
-
371
- def _render_user_query(self, query: str) -> None:
372
- panel = AIPPanel(
373
- Markdown(f"Query: {query}"),
374
- title="User Request",
375
- border_style="#d97706",
376
- )
377
- self.console.print(panel)
378
- self.console.print()
379
-
380
- def _render_steps_summary(self) -> None:
381
- tree_text = self._build_tree_summary_text()
382
- if tree_text is not None:
383
- body = tree_text
384
- else:
385
- panel_content = self._format_steps_summary(self._build_step_summary())
386
- body = Text(panel_content, style="dim")
387
- panel = AIPPanel(body, title="Steps", border_style="blue")
388
- self.console.print(panel)
266
+ """Print command hint for user interaction."""
267
+ self.console.print("[dim]Ctrl+T to toggle transcript · type `e` to export · press Enter to exit[/dim]")
389
268
  self.console.print()
390
269
 
391
- @staticmethod
392
- def _format_steps_summary(steps: list[dict[str, Any]]) -> str:
393
- if not steps:
394
- return " No steps yet"
395
-
396
- lines = []
397
- for step in steps:
398
- icon = ICON_DELEGATE if step.get("is_delegate") else ICON_TOOL_STEP
399
- duration = step.get("duration")
400
- duration_str = f" [{duration}]" if duration else ""
401
- status = " ✓" if step.get("finished") else ""
402
- title = step.get("title") or step.get("name") or "Step"
403
- lines.append(f" {icon} {title}{duration_str}{status}")
404
- return "\n".join(lines)
405
-
406
- @staticmethod
407
- def _extract_event_time(event: dict[str, Any]) -> float | None:
408
- metadata = event.get("metadata") or {}
409
- time_value = metadata.get("time")
410
- try:
411
- if isinstance(time_value, (int, float)):
412
- return float(time_value)
413
- except Exception:
414
- return None
415
- return None
416
-
417
- @staticmethod
418
- def _parse_received_timestamp(event: dict[str, Any]) -> datetime | None:
419
- value = event.get("received_at")
420
- if not value:
421
- return None
422
- if isinstance(value, str):
423
- try:
424
- normalised = value.replace("Z", "+00:00")
425
- parsed = datetime.fromisoformat(normalised)
426
- except ValueError:
427
- return None
428
- return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
429
- return None
430
-
431
- def _extract_final_duration(self) -> str | None:
432
- for event in self.ctx.events:
433
- metadata = event.get("metadata") or {}
434
- if metadata.get("kind") == "final_response":
435
- time_value = metadata.get("time")
436
- try:
437
- if isinstance(time_value, (int, float)):
438
- return f"{float(time_value):.2f}s"
439
- except Exception:
440
- return None
441
- return None
442
-
443
- def _build_step_summary(self) -> list[dict[str, Any]]:
444
- stored = self.ctx.meta.get("transcript_steps")
445
- if isinstance(stored, list) and stored:
446
- return [
447
- {
448
- "title": entry.get("display_name") or entry.get("name") or "Step",
449
- "is_delegate": entry.get("kind") == "delegate",
450
- "finished": entry.get("status") == "finished",
451
- "duration": self._format_duration_from_ms(entry.get("duration_ms")),
452
- }
453
- for entry in stored
454
- ]
455
-
456
- steps: dict[str, dict[str, Any]] = {}
457
- order: list[str] = []
458
-
459
- for event in self.ctx.events:
460
- metadata = event.get("metadata") or {}
461
- if not self._is_step_event(metadata):
462
- continue
463
-
464
- for name, info in self._iter_step_candidates(event, metadata):
465
- step = self._ensure_step_entry(steps, order, name)
466
- self._apply_step_update(step, metadata, info, event)
467
-
468
- return [steps[name] for name in order]
469
-
470
- def _build_tree_summary_text(self) -> Text | None:
471
- """Render hierarchical tree from captured SSE events when available."""
472
- manager = StepManager()
473
- processed = False
474
-
475
- for event in self.ctx.events:
476
- payload = self._coerce_step_event(event)
477
- if not payload:
478
- continue
479
- try:
480
- manager.apply_event(payload)
481
- processed = True
482
- except ValueError:
483
- continue
484
-
485
- if not processed or not manager.order:
486
- return None
487
-
488
- lines: list[str] = []
489
- roots = manager.order
490
- total_roots = len(roots)
491
- for index, root_id in enumerate(roots):
492
- self._render_tree_branch(
493
- manager=manager,
494
- step_id=root_id,
495
- ancestor_state=(),
496
- is_last=index == total_roots - 1,
497
- lines=lines,
498
- )
499
-
500
- if not lines:
501
- return None
502
-
503
- return Text("\n".join(lines), style="dim")
504
-
505
- def _render_tree_branch(
506
- self,
507
- *,
508
- manager: StepManager,
509
- step_id: str,
510
- ancestor_state: tuple[bool, ...],
511
- is_last: bool,
512
- lines: list[str],
513
- ) -> None:
514
- step = manager.by_id.get(step_id)
515
- if not step:
516
- return
517
-
518
- suppress = self._should_hide_step(step)
519
- children = manager.children.get(step_id, [])
520
-
521
- if not suppress:
522
- branch_state = ancestor_state
523
- if branch_state:
524
- branch_state = branch_state + (is_last,)
525
- lines.append(self._format_tree_line(step, branch_state))
526
- next_ancestor_state = ancestor_state + (is_last,)
527
- else:
528
- next_ancestor_state = ancestor_state
529
-
530
- if not children:
531
- return
532
-
533
- total_children = len(children)
534
- for idx, child_id in enumerate(children):
535
- self._render_tree_branch(
536
- manager=manager,
537
- step_id=child_id,
538
- ancestor_state=next_ancestor_state if not suppress else ancestor_state,
539
- is_last=idx == total_children - 1,
540
- lines=lines,
541
- )
542
-
543
- def _should_hide_step(self, step: Any) -> bool:
544
- if getattr(step, "parent_id", None) is not None:
545
- return False
546
- if getattr(step, "kind", None) == "thinking":
547
- return True
548
- if getattr(step, "kind", None) == "agent":
549
- return True
550
- name = getattr(step, "name", "") or ""
551
- return self._looks_like_uuid(name)
552
-
553
- def _coerce_step_event(self, event: dict[str, Any]) -> dict[str, Any] | None:
554
- metadata = event.get("metadata")
555
- if not isinstance(metadata, dict):
556
- return None
557
- if not isinstance(metadata.get("step_id"), str):
558
- return None
559
- return {
560
- "metadata": metadata,
561
- "status": event.get("status"),
562
- "task_state": event.get("task_state"),
563
- "content": event.get("content"),
564
- "task_id": event.get("task_id"),
565
- "context_id": event.get("context_id"),
566
- }
567
-
568
- def _format_tree_line(self, step: Any, branch_state: tuple[bool, ...]) -> str:
569
- prefix = build_connector_prefix(branch_state)
570
- raw_label = normalise_display_label(getattr(step, "display_label", None))
571
- title, summary = self._split_label(raw_label)
572
- line = f"{prefix}{title}"
573
-
574
- if summary:
575
- line += f" — {self._truncate_summary(summary)}"
576
-
577
- badge = self._format_duration_badge(step)
578
- if badge:
579
- line += f" {badge}"
580
-
581
- glyph = glyph_for_status(getattr(step, "status_icon", None))
582
- failure_reason = getattr(step, "failure_reason", None)
583
- if glyph and glyph != "spinner":
584
- if failure_reason and glyph == "✗":
585
- line += f" {glyph} {failure_reason}"
586
- else:
587
- line += f" {glyph}"
588
- elif failure_reason:
589
- line += f" ✗ {failure_reason}"
590
-
591
- return line
592
-
593
- @staticmethod
594
- def _format_duration_badge(step: Any) -> str | None:
595
- duration_ms = getattr(step, "duration_ms", None)
596
- if duration_ms is None:
597
- return None
598
- try:
599
- duration_ms = int(duration_ms)
600
- except Exception:
601
- return None
602
-
603
- if duration_ms <= 0:
604
- payload = "<1ms"
605
- elif duration_ms >= 1000:
606
- payload = f"{duration_ms / 1000:.2f}s"
607
- else:
608
- payload = f"{duration_ms}ms"
609
-
610
- return f"[{payload}]"
611
-
612
- @staticmethod
613
- def _split_label(label: str) -> tuple[str, str | None]:
614
- if " — " in label:
615
- title, summary = label.split(" — ", 1)
616
- return title.strip(), summary.strip()
617
- return label.strip(), None
618
-
619
- @staticmethod
620
- def _truncate_summary(summary: str, limit: int = 48) -> str:
621
- summary = summary.strip()
622
- if len(summary) <= limit:
623
- return summary
624
- return summary[: limit - 1].rstrip() + "…"
625
-
626
- @staticmethod
627
- def _looks_like_uuid(value: str) -> bool:
628
- stripped = value.replace("-", "")
629
- if len(stripped) not in {32, 36}:
630
- return False
631
- return all(ch in "0123456789abcdefABCDEF" for ch in stripped)
632
-
633
- @staticmethod
634
- def _format_duration_from_ms(value: Any) -> str | None:
635
- try:
636
- if value is None:
637
- return None
638
- duration_ms = float(value)
639
- except Exception:
640
- return None
641
-
642
- if duration_ms <= 0:
643
- return "<1ms"
644
- if duration_ms < 1000:
645
- return f"{int(duration_ms)}ms"
646
- return f"{duration_ms / 1000:.2f}s"
647
-
648
- @staticmethod
649
- def _is_step_event(metadata: dict[str, Any]) -> bool:
650
- kind = metadata.get("kind")
651
- return kind in {"agent_step", "agent_thinking_step"}
652
-
653
- def _iter_step_candidates(
654
- self, event: dict[str, Any], metadata: dict[str, Any]
655
- ) -> Iterable[tuple[str, dict[str, Any]]]:
656
- tool_info = metadata.get("tool_info") or {}
657
-
658
- yielded = False
659
- for candidate in self._iter_tool_call_candidates(tool_info):
660
- yielded = True
661
- yield candidate
662
-
663
- if yielded:
664
- return
665
-
666
- direct_tool = self._extract_direct_tool(tool_info)
667
- if direct_tool is not None:
668
- yield direct_tool
669
- return
670
-
671
- completed = self._extract_completed_name(event)
672
- if completed is not None:
673
- yield completed, {}
674
-
675
- @staticmethod
676
- def _iter_tool_call_candidates(
677
- tool_info: dict[str, Any],
678
- ) -> Iterable[tuple[str, dict[str, Any]]]:
679
- tool_calls = tool_info.get("tool_calls")
680
- if isinstance(tool_calls, list):
681
- for call in tool_calls:
682
- name = call.get("name")
683
- if name:
684
- yield name, call
685
-
686
270
  @staticmethod
687
271
  def _extract_direct_tool(
688
272
  tool_info: dict[str, Any],
689
273
  ) -> tuple[str, dict[str, Any]] | None:
274
+ """Extract direct tool from tool_info.
275
+
276
+ Args:
277
+ tool_info: Tool info dictionary.
278
+
279
+ Returns:
280
+ Tuple of (tool_name, tool_info) or None.
281
+ """
690
282
  if isinstance(tool_info, dict):
691
283
  name = tool_info.get("name")
692
284
  if name:
@@ -695,6 +287,14 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
695
287
 
696
288
  @staticmethod
697
289
  def _extract_completed_name(event: dict[str, Any]) -> str | None:
290
+ """Extract completed tool name from event content.
291
+
292
+ Args:
293
+ event: Event dictionary.
294
+
295
+ Returns:
296
+ Tool name or None.
297
+ """
698
298
  content = event.get("content") or ""
699
299
  if isinstance(content, str) and content.startswith("Completed "):
700
300
  name = content.replace("Completed ", "").strip()
@@ -708,6 +308,16 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
708
308
  order: list[str],
709
309
  name: str,
710
310
  ) -> dict[str, Any]:
311
+ """Ensure step entry exists, creating if needed.
312
+
313
+ Args:
314
+ steps: Steps dictionary.
315
+ order: Order list.
316
+ name: Step name.
317
+
318
+ Returns:
319
+ Step dictionary.
320
+ """
711
321
  if name not in steps:
712
322
  steps[name] = {
713
323
  "name": name,
@@ -727,14 +337,18 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
727
337
  info: dict[str, Any],
728
338
  event: dict[str, Any],
729
339
  ) -> None:
340
+ """Apply update to step from event metadata.
341
+
342
+ Args:
343
+ step: Step dictionary to update.
344
+ metadata: Event metadata.
345
+ info: Step info dictionary.
346
+ event: Event dictionary.
347
+ """
730
348
  status = metadata.get("status")
731
349
  event_time = metadata.get("time")
732
350
 
733
- if (
734
- status == "running"
735
- and step.get("started_at") is None
736
- and isinstance(event_time, (int, float))
737
- ):
351
+ if status == "running" and step.get("started_at") is None and isinstance(event_time, (int, float)):
738
352
  try:
739
353
  step["started_at"] = float(event_time)
740
354
  except Exception:
@@ -747,48 +361,14 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
747
361
  if duration is not None:
748
362
  step["duration"] = duration
749
363
 
750
- @staticmethod
751
- def _is_step_finished(metadata: dict[str, Any], event: dict[str, Any]) -> bool:
752
- status = metadata.get("status")
753
- return status == "finished" or bool(event.get("final"))
754
-
755
- def _compute_step_duration(
756
- self, step: dict[str, Any], info: dict[str, Any], metadata: dict[str, Any]
757
- ) -> str | None:
758
- """Calculate a formatted duration string for a step if possible."""
759
- event_time = metadata.get("time")
760
- started_at = step.get("started_at")
761
- duration_value: float | None = None
762
-
763
- if isinstance(event_time, (int, float)) and isinstance(
764
- started_at, (int, float)
765
- ):
766
- try:
767
- delta = float(event_time) - float(started_at)
768
- if delta >= 0:
769
- duration_value = delta
770
- except Exception:
771
- duration_value = None
772
-
773
- if duration_value is None:
774
- exec_time = info.get("execution_time")
775
- if isinstance(exec_time, (int, float)):
776
- duration_value = float(exec_time)
777
-
778
- if duration_value is None:
779
- return None
780
-
781
- try:
782
- return format_elapsed_time(duration_value)
783
- except Exception:
784
- return None
785
-
786
364
 
787
365
  def run_viewer_session(
788
366
  console: Console,
789
367
  ctx: ViewerContext,
790
368
  export_callback: Callable[[Path], Path],
369
+ *,
370
+ initial_view: str = "default",
791
371
  ) -> None:
792
372
  """Entry point for creating and running the post-run viewer."""
793
- viewer = PostRunViewer(console, ctx, export_callback)
373
+ viewer = PostRunViewer(console, ctx, export_callback, initial_view=initial_view)
794
374
  viewer.run()