glaip-sdk 0.0.19__py3-none-any.whl → 0.1.0__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 (56) hide show
  1. glaip_sdk/_version.py +2 -2
  2. glaip_sdk/branding.py +27 -2
  3. glaip_sdk/cli/auth.py +93 -28
  4. glaip_sdk/cli/commands/__init__.py +2 -2
  5. glaip_sdk/cli/commands/agents.py +127 -21
  6. glaip_sdk/cli/commands/configure.py +141 -90
  7. glaip_sdk/cli/commands/mcps.py +82 -31
  8. glaip_sdk/cli/commands/models.py +4 -3
  9. glaip_sdk/cli/commands/tools.py +27 -14
  10. glaip_sdk/cli/commands/update.py +66 -0
  11. glaip_sdk/cli/config.py +13 -2
  12. glaip_sdk/cli/display.py +35 -26
  13. glaip_sdk/cli/io.py +14 -5
  14. glaip_sdk/cli/main.py +185 -73
  15. glaip_sdk/cli/pager.py +2 -1
  16. glaip_sdk/cli/resolution.py +4 -1
  17. glaip_sdk/cli/slash/__init__.py +3 -4
  18. glaip_sdk/cli/slash/agent_session.py +88 -36
  19. glaip_sdk/cli/slash/prompt.py +20 -48
  20. glaip_sdk/cli/slash/session.py +437 -189
  21. glaip_sdk/cli/transcript/__init__.py +71 -0
  22. glaip_sdk/cli/transcript/cache.py +338 -0
  23. glaip_sdk/cli/transcript/capture.py +278 -0
  24. glaip_sdk/cli/transcript/export.py +38 -0
  25. glaip_sdk/cli/transcript/launcher.py +79 -0
  26. glaip_sdk/cli/transcript/viewer.py +794 -0
  27. glaip_sdk/cli/update_notifier.py +29 -5
  28. glaip_sdk/cli/utils.py +255 -74
  29. glaip_sdk/client/agents.py +3 -1
  30. glaip_sdk/client/run_rendering.py +126 -21
  31. glaip_sdk/icons.py +25 -0
  32. glaip_sdk/models.py +6 -0
  33. glaip_sdk/rich_components.py +29 -1
  34. glaip_sdk/utils/__init__.py +1 -1
  35. glaip_sdk/utils/client_utils.py +6 -4
  36. glaip_sdk/utils/display.py +61 -32
  37. glaip_sdk/utils/rendering/formatting.py +55 -11
  38. glaip_sdk/utils/rendering/models.py +15 -2
  39. glaip_sdk/utils/rendering/renderer/__init__.py +0 -2
  40. glaip_sdk/utils/rendering/renderer/base.py +1287 -227
  41. glaip_sdk/utils/rendering/renderer/config.py +3 -5
  42. glaip_sdk/utils/rendering/renderer/debug.py +73 -16
  43. glaip_sdk/utils/rendering/renderer/panels.py +27 -15
  44. glaip_sdk/utils/rendering/renderer/progress.py +61 -38
  45. glaip_sdk/utils/rendering/renderer/stream.py +3 -3
  46. glaip_sdk/utils/rendering/renderer/toggle.py +184 -0
  47. glaip_sdk/utils/rendering/step_tree_state.py +102 -0
  48. glaip_sdk/utils/rendering/steps.py +944 -16
  49. glaip_sdk/utils/serialization.py +5 -2
  50. glaip_sdk/utils/validation.py +1 -2
  51. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.1.0.dist-info}/METADATA +12 -1
  52. glaip_sdk-0.1.0.dist-info/RECORD +82 -0
  53. glaip_sdk/utils/rich_utils.py +0 -29
  54. glaip_sdk-0.0.19.dist-info/RECORD +0 -73
  55. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.1.0.dist-info}/WHEEL +0 -0
  56. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.1.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,794 @@
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, Iterable
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import click
16
+ from rich.console import Console
17
+ from rich.markdown import Markdown
18
+ from rich.text import Text
19
+
20
+ try: # pragma: no cover - optional dependency
21
+ import questionary
22
+ from questionary import Choice
23
+ except Exception: # pragma: no cover - optional dependency
24
+ questionary = None # type: ignore[assignment]
25
+ Choice = None # type: ignore[assignment]
26
+
27
+ 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,
34
+ )
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
+
43
+ EXPORT_CANCELLED_MESSAGE = "[dim]Export cancelled.[/dim]"
44
+
45
+
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]
56
+
57
+
58
+ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
59
+ """Simple interactive session for inspecting agent run transcripts."""
60
+
61
+ def __init__(
62
+ self,
63
+ console: Console,
64
+ ctx: ViewerContext,
65
+ export_callback: Callable[[Path], Path],
66
+ ) -> None:
67
+ """Initialize viewer state for a captured transcript."""
68
+ self.console = console
69
+ self.ctx = ctx
70
+ self._export_callback = export_callback
71
+ self._view_mode = "default"
72
+
73
+ def run(self) -> None:
74
+ """Enter the interactive loop."""
75
+ if not self.ctx.events and not (
76
+ self.ctx.default_output or self.ctx.final_output
77
+ ):
78
+ return
79
+ if self._view_mode == "transcript":
80
+ self._render()
81
+ self._print_command_hint()
82
+ self._fallback_loop()
83
+
84
+ # ------------------------------------------------------------------
85
+ # Rendering helpers
86
+ # ------------------------------------------------------------------
87
+ def _render(self) -> None:
88
+ try:
89
+ if self.console.is_terminal:
90
+ self.console.clear()
91
+ except Exception: # pragma: no cover - platform quirks
92
+ pass
93
+
94
+ header = (
95
+ f"Agent transcript viewer · run {self.ctx.manifest_entry.get('run_id')}"
96
+ )
97
+ agent_label = self.ctx.manifest_entry.get("agent_name") or "unknown agent"
98
+ model = self.ctx.manifest_entry.get("model") or self.ctx.meta.get("model")
99
+ agent_id = self.ctx.manifest_entry.get("agent_id")
100
+ subtitle_parts = [agent_label]
101
+ if model:
102
+ subtitle_parts.append(str(model))
103
+ if agent_id:
104
+ subtitle_parts.append(agent_id)
105
+
106
+ if self._view_mode == "transcript":
107
+ self.console.rule(header)
108
+ if subtitle_parts:
109
+ self.console.print(f"[dim]{' · '.join(subtitle_parts)}[/]")
110
+ self.console.print()
111
+
112
+ query = self._get_user_query()
113
+
114
+ if self._view_mode == "default":
115
+ self._render_default_view(query)
116
+ 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()
167
+
168
+ # ------------------------------------------------------------------
169
+ # Interaction loops
170
+ # ------------------------------------------------------------------
171
+ def _fallback_loop(self) -> None:
172
+ while True:
173
+ try:
174
+ ch = click.getchar()
175
+ except (EOFError, KeyboardInterrupt):
176
+ break
177
+
178
+ if ch in {"\r", "\n"}:
179
+ break
180
+
181
+ if ch == "\x14" or ch.lower() == "t": # Ctrl+T or t
182
+ self.toggle_view()
183
+ continue
184
+
185
+ if ch.lower() == "e":
186
+ self.export_transcript()
187
+ self._print_command_hint()
188
+ else:
189
+ continue
190
+
191
+ def _handle_command(self, raw: str) -> bool:
192
+ lowered = raw.lower()
193
+ if lowered in {"exit", "quit", "q"}:
194
+ return True
195
+ if lowered in {"export", "e"}:
196
+ self.export_transcript()
197
+ self._print_command_hint()
198
+ return False
199
+ self.console.print("[dim]Commands: export, exit.[/dim]")
200
+ return False
201
+
202
+ # ------------------------------------------------------------------
203
+ # Actions
204
+ # ------------------------------------------------------------------
205
+ def toggle_view(self) -> None:
206
+ """Switch between default result view and verbose transcript."""
207
+ self._view_mode = "transcript" if self._view_mode == "default" else "default"
208
+ self._render()
209
+ self._print_command_hint()
210
+
211
+ def export_transcript(self) -> None:
212
+ """Prompt user for a destination and export the cached transcript."""
213
+ entry = self.ctx.manifest_entry
214
+ default_name = suggest_filename(entry)
215
+ default_path = Path.cwd() / default_name
216
+
217
+ def _display_path(path: Path) -> str:
218
+ raw = str(path)
219
+ return raw if len(raw) <= 80 else f"…{raw[-77:]}"
220
+
221
+ selection = self._prompt_export_choice(
222
+ default_path, _display_path(default_path)
223
+ )
224
+ if selection is None:
225
+ self._legacy_export_prompt(default_path, _display_path)
226
+ return
227
+
228
+ action, _ = selection
229
+ if action == "cancel":
230
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
231
+ return
232
+
233
+ if action == "default":
234
+ destination = default_path
235
+ else:
236
+ destination = self._prompt_custom_destination()
237
+ if destination is None:
238
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
239
+ return
240
+
241
+ try:
242
+ target = self._export_callback(destination)
243
+ self.console.print(f"[green]Transcript exported to {target}[/green]")
244
+ except FileNotFoundError as exc:
245
+ self.console.print(f"[red]{exc}[/red]")
246
+ except Exception as exc: # pragma: no cover - unexpected IO failures
247
+ self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
248
+
249
+ def _prompt_export_choice(
250
+ self, default_path: Path, default_display: str
251
+ ) -> tuple[str, Any] | None:
252
+ """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:
280
+ return None
281
+
282
+ if answer is None:
283
+ return ("cancel", None)
284
+ return answer
285
+
286
+ def _prompt_custom_destination(self) -> Path | None:
287
+ """Prompt for custom export path with filesystem completion."""
288
+ if not self.console.is_terminal:
289
+ return None
290
+
291
+ try:
292
+ response = questionary.path(
293
+ "Destination path (Tab to autocomplete):",
294
+ default="",
295
+ only_directories=False,
296
+ ).ask()
297
+ except Exception:
298
+ return None
299
+
300
+ if not response:
301
+ return None
302
+
303
+ candidate = Path(response.strip()).expanduser()
304
+ if not candidate.is_absolute():
305
+ candidate = Path.cwd() / candidate
306
+ return candidate
307
+
308
+ def _legacy_export_prompt(
309
+ self, default_path: Path, formatter: Callable[[Path], str]
310
+ ) -> None:
311
+ """Fallback export workflow when interactive UI is unavailable."""
312
+ self.console.print("[dim]Export options (fallback mode)[/dim]")
313
+ self.console.print(f" 1. Save to default ({formatter(default_path)})")
314
+ self.console.print(" 2. Choose a different path")
315
+ self.console.print(" 3. Cancel")
316
+
317
+ try:
318
+ choice = click.prompt(
319
+ "Select option",
320
+ type=click.Choice(["1", "2", "3"], case_sensitive=False),
321
+ default="1",
322
+ show_choices=False,
323
+ )
324
+ except (EOFError, KeyboardInterrupt):
325
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
326
+ return
327
+
328
+ if choice == "3":
329
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
330
+ return
331
+
332
+ if choice == "1":
333
+ destination = default_path
334
+ else:
335
+ try:
336
+ destination_str = click.prompt("Enter destination path", default="")
337
+ except (EOFError, KeyboardInterrupt):
338
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
339
+ return
340
+ if not destination_str.strip():
341
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
342
+ return
343
+ destination = Path(destination_str.strip()).expanduser()
344
+ if not destination.is_absolute():
345
+ destination = Path.cwd() / destination
346
+
347
+ try:
348
+ target = self._export_callback(destination)
349
+ self.console.print(f"[green]Transcript exported to {target}[/green]")
350
+ except FileNotFoundError as exc:
351
+ self.console.print(f"[red]{exc}[/red]")
352
+ except Exception as exc: # pragma: no cover - unexpected IO failures
353
+ self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
354
+
355
+ 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)
389
+ self.console.print()
390
+
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
+ @staticmethod
687
+ def _extract_direct_tool(
688
+ tool_info: dict[str, Any],
689
+ ) -> tuple[str, dict[str, Any]] | None:
690
+ if isinstance(tool_info, dict):
691
+ name = tool_info.get("name")
692
+ if name:
693
+ return name, tool_info
694
+ return None
695
+
696
+ @staticmethod
697
+ def _extract_completed_name(event: dict[str, Any]) -> str | None:
698
+ content = event.get("content") or ""
699
+ if isinstance(content, str) and content.startswith("Completed "):
700
+ name = content.replace("Completed ", "").strip()
701
+ if name:
702
+ return name
703
+ return None
704
+
705
+ def _ensure_step_entry(
706
+ self,
707
+ steps: dict[str, dict[str, Any]],
708
+ order: list[str],
709
+ name: str,
710
+ ) -> dict[str, Any]:
711
+ if name not in steps:
712
+ steps[name] = {
713
+ "name": name,
714
+ "title": name,
715
+ "is_delegate": is_delegation_tool(name),
716
+ "duration": None,
717
+ "started_at": None,
718
+ "finished": False,
719
+ }
720
+ order.append(name)
721
+ return steps[name]
722
+
723
+ def _apply_step_update(
724
+ self,
725
+ step: dict[str, Any],
726
+ metadata: dict[str, Any],
727
+ info: dict[str, Any],
728
+ event: dict[str, Any],
729
+ ) -> None:
730
+ status = metadata.get("status")
731
+ event_time = metadata.get("time")
732
+
733
+ if (
734
+ status == "running"
735
+ and step.get("started_at") is None
736
+ and isinstance(event_time, (int, float))
737
+ ):
738
+ try:
739
+ step["started_at"] = float(event_time)
740
+ except Exception:
741
+ step["started_at"] = None
742
+
743
+ if self._is_step_finished(metadata, event):
744
+ step["finished"] = True
745
+
746
+ duration = self._compute_step_duration(step, info, metadata)
747
+ if duration is not None:
748
+ step["duration"] = duration
749
+
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
+
787
+ def run_viewer_session(
788
+ console: Console,
789
+ ctx: ViewerContext,
790
+ export_callback: Callable[[Path], Path],
791
+ ) -> None:
792
+ """Entry point for creating and running the post-run viewer."""
793
+ viewer = PostRunViewer(console, ctx, export_callback)
794
+ viewer.run()