glaip-sdk 0.0.7__py3-none-any.whl → 0.6.5b6__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 (161) hide show
  1. glaip_sdk/__init__.py +6 -3
  2. glaip_sdk/_version.py +12 -5
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1126 -0
  5. glaip_sdk/branding.py +79 -15
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +699 -0
  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 +503 -183
  12. glaip_sdk/cli/commands/common_config.py +101 -0
  13. glaip_sdk/cli/commands/configure.py +774 -137
  14. glaip_sdk/cli/commands/mcps.py +1124 -181
  15. glaip_sdk/cli/commands/models.py +25 -10
  16. glaip_sdk/cli/commands/tools.py +144 -92
  17. glaip_sdk/cli/commands/transcripts.py +755 -0
  18. glaip_sdk/cli/commands/update.py +61 -0
  19. glaip_sdk/cli/config.py +95 -0
  20. glaip_sdk/cli/constants.py +38 -0
  21. glaip_sdk/cli/context.py +150 -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 +143 -53
  28. glaip_sdk/cli/hints.py +57 -0
  29. glaip_sdk/cli/io.py +24 -18
  30. glaip_sdk/cli/main.py +420 -145
  31. glaip_sdk/cli/masking.py +136 -0
  32. glaip_sdk/cli/mcp_validators.py +287 -0
  33. glaip_sdk/cli/pager.py +266 -0
  34. glaip_sdk/cli/parsers/__init__.py +7 -0
  35. glaip_sdk/cli/parsers/json_input.py +177 -0
  36. glaip_sdk/cli/resolution.py +28 -21
  37. glaip_sdk/cli/rich_helpers.py +27 -0
  38. glaip_sdk/cli/slash/__init__.py +15 -0
  39. glaip_sdk/cli/slash/accounts_controller.py +500 -0
  40. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  41. glaip_sdk/cli/slash/agent_session.py +282 -0
  42. glaip_sdk/cli/slash/prompt.py +245 -0
  43. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  44. glaip_sdk/cli/slash/session.py +1679 -0
  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 +872 -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 +31 -0
  52. glaip_sdk/cli/transcript/cache.py +536 -0
  53. glaip_sdk/cli/transcript/capture.py +329 -0
  54. glaip_sdk/cli/transcript/export.py +38 -0
  55. glaip_sdk/cli/transcript/history.py +815 -0
  56. glaip_sdk/cli/transcript/launcher.py +77 -0
  57. glaip_sdk/cli/transcript/viewer.py +372 -0
  58. glaip_sdk/cli/update_notifier.py +290 -0
  59. glaip_sdk/cli/utils.py +247 -1238
  60. glaip_sdk/cli/validators.py +16 -18
  61. glaip_sdk/client/__init__.py +2 -1
  62. glaip_sdk/client/_agent_payloads.py +520 -0
  63. glaip_sdk/client/agent_runs.py +147 -0
  64. glaip_sdk/client/agents.py +940 -574
  65. glaip_sdk/client/base.py +163 -48
  66. glaip_sdk/client/main.py +35 -12
  67. glaip_sdk/client/mcps.py +126 -18
  68. glaip_sdk/client/run_rendering.py +415 -0
  69. glaip_sdk/client/shared.py +21 -0
  70. glaip_sdk/client/tools.py +195 -37
  71. glaip_sdk/client/validators.py +20 -48
  72. glaip_sdk/config/constants.py +15 -5
  73. glaip_sdk/exceptions.py +16 -9
  74. glaip_sdk/icons.py +25 -0
  75. glaip_sdk/mcps/__init__.py +21 -0
  76. glaip_sdk/mcps/base.py +345 -0
  77. glaip_sdk/models/__init__.py +90 -0
  78. glaip_sdk/models/agent.py +47 -0
  79. glaip_sdk/models/agent_runs.py +116 -0
  80. glaip_sdk/models/common.py +42 -0
  81. glaip_sdk/models/mcp.py +33 -0
  82. glaip_sdk/models/tool.py +33 -0
  83. glaip_sdk/payload_schemas/__init__.py +7 -0
  84. glaip_sdk/payload_schemas/agent.py +85 -0
  85. glaip_sdk/registry/__init__.py +55 -0
  86. glaip_sdk/registry/agent.py +164 -0
  87. glaip_sdk/registry/base.py +139 -0
  88. glaip_sdk/registry/mcp.py +253 -0
  89. glaip_sdk/registry/tool.py +231 -0
  90. glaip_sdk/rich_components.py +98 -2
  91. glaip_sdk/runner/__init__.py +59 -0
  92. glaip_sdk/runner/base.py +84 -0
  93. glaip_sdk/runner/deps.py +115 -0
  94. glaip_sdk/runner/langgraph.py +597 -0
  95. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  96. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  97. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +158 -0
  98. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  99. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  100. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  101. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +177 -0
  102. glaip_sdk/tools/__init__.py +22 -0
  103. glaip_sdk/tools/base.py +435 -0
  104. glaip_sdk/utils/__init__.py +59 -13
  105. glaip_sdk/utils/a2a/__init__.py +34 -0
  106. glaip_sdk/utils/a2a/event_processor.py +188 -0
  107. glaip_sdk/utils/agent_config.py +53 -40
  108. glaip_sdk/utils/bundler.py +267 -0
  109. glaip_sdk/utils/client.py +111 -0
  110. glaip_sdk/utils/client_utils.py +58 -26
  111. glaip_sdk/utils/datetime_helpers.py +58 -0
  112. glaip_sdk/utils/discovery.py +78 -0
  113. glaip_sdk/utils/display.py +65 -32
  114. glaip_sdk/utils/export.py +143 -0
  115. glaip_sdk/utils/general.py +1 -36
  116. glaip_sdk/utils/import_export.py +20 -25
  117. glaip_sdk/utils/import_resolver.py +492 -0
  118. glaip_sdk/utils/instructions.py +101 -0
  119. glaip_sdk/utils/rendering/__init__.py +115 -1
  120. glaip_sdk/utils/rendering/formatting.py +85 -43
  121. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  122. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +51 -19
  123. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  124. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  125. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  126. glaip_sdk/utils/rendering/models.py +39 -7
  127. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  128. glaip_sdk/utils/rendering/renderer/base.py +672 -759
  129. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  130. glaip_sdk/utils/rendering/renderer/debug.py +75 -22
  131. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  132. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  133. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  134. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  135. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  136. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  137. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  138. glaip_sdk/utils/rendering/state.py +204 -0
  139. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  140. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  141. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  142. glaip_sdk/utils/rendering/steps/format.py +176 -0
  143. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  144. glaip_sdk/utils/rendering/timing.py +36 -0
  145. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  146. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  147. glaip_sdk/utils/resource_refs.py +29 -26
  148. glaip_sdk/utils/runtime_config.py +422 -0
  149. glaip_sdk/utils/serialization.py +184 -51
  150. glaip_sdk/utils/sync.py +142 -0
  151. glaip_sdk/utils/tool_detection.py +33 -0
  152. glaip_sdk/utils/validation.py +21 -30
  153. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/METADATA +58 -12
  154. glaip_sdk-0.6.5b6.dist-info/RECORD +159 -0
  155. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/WHEEL +1 -1
  156. glaip_sdk/models.py +0 -250
  157. glaip_sdk/utils/rendering/renderer/progress.py +0 -118
  158. glaip_sdk/utils/rendering/steps.py +0 -232
  159. glaip_sdk/utils/rich_utils.py +0 -29
  160. glaip_sdk-0.0.7.dist-info/RECORD +0 -55
  161. {glaip_sdk-0.0.7.dist-info → glaip_sdk-0.6.5b6.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,77 @@
1
+ """Utilities for launching the post-run transcript viewer.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from rich.console import Console
14
+
15
+ from glaip_sdk.cli.context import get_ctx_value
16
+ from glaip_sdk.cli.transcript.cache import (
17
+ export_transcript as export_cached_transcript,
18
+ )
19
+ from glaip_sdk.cli.transcript.capture import StoredTranscriptContext
20
+ from glaip_sdk.cli.transcript.viewer import ViewerContext, run_viewer_session
21
+
22
+
23
+ def should_launch_post_run_viewer(ctx: Any, console: Console, *, slash_mode: bool) -> bool:
24
+ """Return True if the viewer should open automatically."""
25
+ if slash_mode:
26
+ return False
27
+ ctx_obj = getattr(ctx, "obj", None)
28
+ if isinstance(ctx_obj, dict) and ctx_obj.get("_slash_session"):
29
+ return False
30
+ if get_ctx_value(ctx, "view", "rich") != "rich":
31
+ return False
32
+ if not bool(get_ctx_value(ctx, "tty", True)):
33
+ return False
34
+ if not console.is_terminal:
35
+ return False
36
+ try:
37
+ if not sys.stdin.isatty():
38
+ return False
39
+ except Exception:
40
+ return False
41
+ return True
42
+
43
+
44
+ def maybe_launch_post_run_viewer(
45
+ ctx: Any,
46
+ transcript_context: StoredTranscriptContext | None,
47
+ *,
48
+ console: Console,
49
+ slash_mode: bool,
50
+ ) -> None:
51
+ """Launch the post-run viewer when context and settings allow it."""
52
+ if transcript_context is None:
53
+ return
54
+ if not should_launch_post_run_viewer(ctx, console, slash_mode=slash_mode):
55
+ return
56
+
57
+ manifest_entry = transcript_context.store_result.manifest_entry
58
+ run_id = manifest_entry.get("run_id")
59
+ if not run_id:
60
+ return
61
+
62
+ viewer_ctx = ViewerContext(
63
+ manifest_entry=manifest_entry,
64
+ events=transcript_context.payload.events,
65
+ default_output=transcript_context.payload.default_output,
66
+ final_output=transcript_context.payload.final_output,
67
+ stream_started_at=None,
68
+ meta=transcript_context.payload.meta,
69
+ )
70
+
71
+ def _export(destination: Path) -> Path:
72
+ return export_cached_transcript(destination=destination, run_id=run_id)
73
+
74
+ run_viewer_session(console, viewer_ctx, _export)
75
+
76
+
77
+ __all__ = ["should_launch_post_run_viewer", "maybe_launch_post_run_viewer"]
@@ -0,0 +1,372 @@
1
+ """Interactive viewer for post-run transcript exploration.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import click
14
+ from rich.console import Console
15
+
16
+ try: # pragma: no cover - optional dependency
17
+ import questionary
18
+ from questionary import Choice
19
+ except Exception: # pragma: no cover - optional dependency
20
+ questionary = None # type: ignore[assignment]
21
+ Choice = None # type: ignore[assignment]
22
+
23
+ from glaip_sdk.cli.transcript.cache import suggest_filename
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.viewer import (
27
+ ViewerContext as PresenterViewerContext,
28
+ prepare_viewer_snapshot as presenter_prepare_viewer_snapshot,
29
+ render_post_run_view as presenter_render_post_run_view,
30
+ render_transcript_events as presenter_render_transcript_events,
31
+ render_transcript_view as presenter_render_transcript_view,
32
+ )
33
+
34
+ EXPORT_CANCELLED_MESSAGE = "[dim]Export cancelled.[/dim]"
35
+
36
+
37
+ ViewerContext = PresenterViewerContext
38
+
39
+
40
+ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
41
+ """Simple interactive session for inspecting agent run transcripts."""
42
+
43
+ def __init__(
44
+ self,
45
+ console: Console,
46
+ ctx: ViewerContext,
47
+ export_callback: Callable[[Path], Path],
48
+ *,
49
+ initial_view: str = "default",
50
+ ) -> None:
51
+ """Initialize viewer state for a captured transcript."""
52
+ self.console = console
53
+ self.ctx = ctx
54
+ self._export_callback = export_callback
55
+ self._view_mode = initial_view if initial_view in {"default", "transcript"} else "default"
56
+
57
+ def run(self) -> None:
58
+ """Enter the interactive loop."""
59
+ if not self.ctx.events and not (self.ctx.default_output or self.ctx.final_output):
60
+ return
61
+ if self._view_mode == "transcript":
62
+ self._render()
63
+ self._print_command_hint()
64
+ self._fallback_loop()
65
+
66
+ # ------------------------------------------------------------------
67
+ # Rendering helpers
68
+ # ------------------------------------------------------------------
69
+ def _render(self) -> None:
70
+ """Render the transcript viewer interface."""
71
+ try:
72
+ if self.console.is_terminal:
73
+ self.console.clear()
74
+ except Exception: # pragma: no cover - platform quirks
75
+ pass
76
+
77
+ header = f"Agent transcript viewer · run {self.ctx.manifest_entry.get('run_id')}"
78
+ agent_label = self.ctx.manifest_entry.get("agent_name") or "unknown agent"
79
+ model = self.ctx.manifest_entry.get("model") or self.ctx.meta.get("model")
80
+ agent_id = self.ctx.manifest_entry.get("agent_id")
81
+ subtitle_parts = [agent_label]
82
+ if model:
83
+ subtitle_parts.append(str(model))
84
+ if agent_id:
85
+ subtitle_parts.append(agent_id)
86
+
87
+ if self._view_mode == "transcript":
88
+ self.console.rule(header)
89
+ if subtitle_parts:
90
+ self.console.print(f"[dim]{' · '.join(subtitle_parts)}[/]")
91
+ self.console.print()
92
+
93
+ if self._view_mode == "default":
94
+ presenter_render_post_run_view(self.console, self.ctx)
95
+ else:
96
+ snapshot, state = presenter_prepare_viewer_snapshot(self.ctx, glyphs=None)
97
+ presenter_render_transcript_view(self.console, snapshot)
98
+ presenter_render_transcript_events(self.console, state.events)
99
+
100
+ # ------------------------------------------------------------------
101
+ # Interaction loops
102
+ # ------------------------------------------------------------------
103
+ def _fallback_loop(self) -> None:
104
+ """Fallback interaction loop for non-interactive terminals."""
105
+ while True:
106
+ try:
107
+ ch = click.getchar()
108
+ except (EOFError, KeyboardInterrupt):
109
+ break
110
+
111
+ if ch in {"\r", "\n"}:
112
+ break
113
+
114
+ if ch == "\x14" or ch.lower() == "t": # Ctrl+T or t
115
+ self.toggle_view()
116
+ continue
117
+
118
+ if ch.lower() == "e":
119
+ self.export_transcript()
120
+ self._print_command_hint()
121
+ else:
122
+ continue
123
+
124
+ def _handle_command(self, raw: str) -> bool:
125
+ """Handle a command input.
126
+
127
+ Args:
128
+ raw: Raw command string.
129
+
130
+ Returns:
131
+ True to continue, False to exit.
132
+ """
133
+ lowered = raw.lower()
134
+ if lowered in {"exit", "quit", "q"}:
135
+ return True
136
+ if lowered in {"export", "e"}:
137
+ self.export_transcript()
138
+ self._print_command_hint()
139
+ return False
140
+ self.console.print("[dim]Commands: export, exit.[/dim]")
141
+ return False
142
+
143
+ # ------------------------------------------------------------------
144
+ # Actions
145
+ # ------------------------------------------------------------------
146
+ def toggle_view(self) -> None:
147
+ """Switch between default result view and verbose transcript."""
148
+ self._view_mode = "transcript" if self._view_mode == "default" else "default"
149
+ self._render()
150
+ self._print_command_hint()
151
+
152
+ def export_transcript(self) -> None:
153
+ """Prompt user for a destination and export the cached transcript."""
154
+ entry = self.ctx.manifest_entry
155
+ default_name = suggest_filename(entry)
156
+ default_path = Path.cwd() / default_name
157
+
158
+ def _display_path(path: Path) -> str:
159
+ raw = str(path)
160
+ return raw if len(raw) <= 80 else f"…{raw[-77:]}"
161
+
162
+ selection = self._prompt_export_choice(default_path, _display_path(default_path))
163
+ if selection is None:
164
+ self._legacy_export_prompt(default_path, _display_path)
165
+ return
166
+
167
+ action, _ = selection
168
+ if action == "cancel":
169
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
170
+ return
171
+
172
+ if action == "default":
173
+ destination = default_path
174
+ else:
175
+ destination = self._prompt_custom_destination()
176
+ if destination is None:
177
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
178
+ return
179
+
180
+ try:
181
+ target = self._export_callback(destination)
182
+ self.console.print(f"[green]Transcript exported to {target}[/green]")
183
+ except FileNotFoundError as exc:
184
+ self.console.print(f"[red]{exc}[/red]")
185
+ except Exception as exc: # pragma: no cover - unexpected IO failures
186
+ self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
187
+
188
+ def _prompt_export_choice(self, default_path: Path, default_display: str) -> tuple[str, Any] | None:
189
+ """Render interactive export menu with numeric shortcuts."""
190
+ if not self.console.is_terminal:
191
+ return None
192
+
193
+ return prompt_export_choice_questionary(default_path, default_display)
194
+
195
+ def _prompt_custom_destination(self) -> Path | None:
196
+ """Prompt for custom export path with filesystem completion."""
197
+ if not self.console.is_terminal:
198
+ return None
199
+
200
+ try:
201
+ question = questionary.path(
202
+ "Destination path (Tab to autocomplete):",
203
+ default="",
204
+ only_directories=False,
205
+ )
206
+ response = questionary_safe_ask(question)
207
+ except Exception:
208
+ return None
209
+
210
+ if not response:
211
+ return None
212
+
213
+ candidate = Path(response.strip()).expanduser()
214
+ if not candidate.is_absolute():
215
+ candidate = Path.cwd() / candidate
216
+ return candidate
217
+
218
+ def _legacy_export_prompt(self, default_path: Path, formatter: Callable[[Path], str]) -> None:
219
+ """Fallback export workflow when interactive UI is unavailable."""
220
+ self.console.print("[dim]Export options (fallback mode)[/dim]")
221
+ self.console.print(f" 1. Save to default ({formatter(default_path)})")
222
+ self.console.print(" 2. Choose a different path")
223
+ self.console.print(" 3. Cancel")
224
+
225
+ try:
226
+ choice = click.prompt(
227
+ "Select option",
228
+ type=click.Choice(["1", "2", "3"], case_sensitive=False),
229
+ default="1",
230
+ show_choices=False,
231
+ )
232
+ except (EOFError, KeyboardInterrupt):
233
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
234
+ return
235
+
236
+ if choice == "3":
237
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
238
+ return
239
+
240
+ if choice == "1":
241
+ destination = default_path
242
+ else:
243
+ try:
244
+ destination_str = click.prompt("Enter destination path", default="")
245
+ except (EOFError, KeyboardInterrupt):
246
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
247
+ return
248
+ if not destination_str.strip():
249
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
250
+ return
251
+ destination = Path(destination_str.strip()).expanduser()
252
+ if not destination.is_absolute():
253
+ destination = Path.cwd() / destination
254
+
255
+ try:
256
+ target = self._export_callback(destination)
257
+ self.console.print(f"[green]Transcript exported to {target}[/green]")
258
+ except FileNotFoundError as exc:
259
+ self.console.print(f"[red]{exc}[/red]")
260
+ except Exception as exc: # pragma: no cover - unexpected IO failures
261
+ self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
262
+
263
+ def _print_command_hint(self) -> None:
264
+ """Print command hint for user interaction."""
265
+ self.console.print("[dim]Ctrl+T to toggle transcript · type `e` to export · press Enter to exit[/dim]")
266
+ self.console.print()
267
+
268
+ @staticmethod
269
+ def _extract_direct_tool(
270
+ tool_info: dict[str, Any],
271
+ ) -> tuple[str, dict[str, Any]] | None:
272
+ """Extract direct tool from tool_info.
273
+
274
+ Args:
275
+ tool_info: Tool info dictionary.
276
+
277
+ Returns:
278
+ Tuple of (tool_name, tool_info) or None.
279
+ """
280
+ if isinstance(tool_info, dict):
281
+ name = tool_info.get("name")
282
+ if name:
283
+ return name, tool_info
284
+ return None
285
+
286
+ @staticmethod
287
+ def _extract_completed_name(event: dict[str, Any]) -> str | None:
288
+ """Extract completed tool name from event content.
289
+
290
+ Args:
291
+ event: Event dictionary.
292
+
293
+ Returns:
294
+ Tool name or None.
295
+ """
296
+ content = event.get("content") or ""
297
+ if isinstance(content, str) and content.startswith("Completed "):
298
+ name = content.replace("Completed ", "").strip()
299
+ if name:
300
+ return name
301
+ return None
302
+
303
+ def _ensure_step_entry(
304
+ self,
305
+ steps: dict[str, dict[str, Any]],
306
+ order: list[str],
307
+ name: str,
308
+ ) -> dict[str, Any]:
309
+ """Ensure step entry exists, creating if needed.
310
+
311
+ Args:
312
+ steps: Steps dictionary.
313
+ order: Order list.
314
+ name: Step name.
315
+
316
+ Returns:
317
+ Step dictionary.
318
+ """
319
+ if name not in steps:
320
+ steps[name] = {
321
+ "name": name,
322
+ "title": name,
323
+ "is_delegate": is_delegation_tool(name),
324
+ "duration": None,
325
+ "started_at": None,
326
+ "finished": False,
327
+ }
328
+ order.append(name)
329
+ return steps[name]
330
+
331
+ def _apply_step_update(
332
+ self,
333
+ step: dict[str, Any],
334
+ metadata: dict[str, Any],
335
+ info: dict[str, Any],
336
+ event: dict[str, Any],
337
+ ) -> None:
338
+ """Apply update to step from event metadata.
339
+
340
+ Args:
341
+ step: Step dictionary to update.
342
+ metadata: Event metadata.
343
+ info: Step info dictionary.
344
+ event: Event dictionary.
345
+ """
346
+ status = metadata.get("status")
347
+ event_time = metadata.get("time")
348
+
349
+ if status == "running" and step.get("started_at") is None and isinstance(event_time, (int, float)):
350
+ try:
351
+ step["started_at"] = float(event_time)
352
+ except Exception:
353
+ step["started_at"] = None
354
+
355
+ if self._is_step_finished(metadata, event):
356
+ step["finished"] = True
357
+
358
+ duration = self._compute_step_duration(step, info, metadata)
359
+ if duration is not None:
360
+ step["duration"] = duration
361
+
362
+
363
+ def run_viewer_session(
364
+ console: Console,
365
+ ctx: ViewerContext,
366
+ export_callback: Callable[[Path], Path],
367
+ *,
368
+ initial_view: str = "default",
369
+ ) -> None:
370
+ """Entry point for creating and running the post-run viewer."""
371
+ viewer = PostRunViewer(console, ctx, export_callback, initial_view=initial_view)
372
+ viewer.run()