glaip-sdk 0.0.19__py3-none-any.whl → 0.0.20__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 (49) 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 +108 -21
  6. glaip_sdk/cli/commands/configure.py +141 -90
  7. glaip_sdk/cli/commands/mcps.py +81 -29
  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 +440 -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 +624 -0
  27. glaip_sdk/cli/update_notifier.py +29 -5
  28. glaip_sdk/cli/utils.py +256 -74
  29. glaip_sdk/client/agents.py +3 -1
  30. glaip_sdk/client/run_rendering.py +2 -2
  31. glaip_sdk/icons.py +19 -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 +6 -5
  38. glaip_sdk/utils/rendering/renderer/base.py +213 -66
  39. glaip_sdk/utils/rendering/renderer/debug.py +73 -16
  40. glaip_sdk/utils/rendering/renderer/panels.py +27 -15
  41. glaip_sdk/utils/rendering/renderer/progress.py +61 -38
  42. glaip_sdk/utils/serialization.py +5 -2
  43. glaip_sdk/utils/validation.py +1 -2
  44. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.0.20.dist-info}/METADATA +1 -1
  45. glaip_sdk-0.0.20.dist-info/RECORD +80 -0
  46. glaip_sdk/utils/rich_utils.py +0 -29
  47. glaip_sdk-0.0.19.dist-info/RECORD +0 -73
  48. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.0.20.dist-info}/WHEEL +0 -0
  49. {glaip_sdk-0.0.19.dist-info → glaip_sdk-0.0.20.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,624 @@
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.renderer.debug import render_debug_event
31
+ from glaip_sdk.utils.rendering.renderer.panels import create_final_panel
32
+ from glaip_sdk.utils.rendering.renderer.progress import (
33
+ format_elapsed_time,
34
+ is_delegation_tool,
35
+ )
36
+
37
+ EXPORT_CANCELLED_MESSAGE = "[dim]Export cancelled.[/dim]"
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class ViewerContext:
42
+ """Runtime context for the viewer session."""
43
+
44
+ manifest_entry: dict[str, Any]
45
+ events: list[dict[str, Any]]
46
+ default_output: str
47
+ final_output: str
48
+ stream_started_at: float | None
49
+ meta: dict[str, Any]
50
+
51
+
52
+ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
53
+ """Simple interactive session for inspecting agent run transcripts."""
54
+
55
+ def __init__(
56
+ self,
57
+ console: Console,
58
+ ctx: ViewerContext,
59
+ export_callback: Callable[[Path], Path],
60
+ ) -> None:
61
+ """Initialize viewer state for a captured transcript."""
62
+ self.console = console
63
+ self.ctx = ctx
64
+ self._export_callback = export_callback
65
+ self._view_mode = "default"
66
+
67
+ def run(self) -> None:
68
+ """Enter the interactive loop."""
69
+ if not self.ctx.events and not (
70
+ self.ctx.default_output or self.ctx.final_output
71
+ ):
72
+ return
73
+ if self._view_mode == "transcript":
74
+ self._render()
75
+ self._print_command_hint()
76
+ self._fallback_loop()
77
+
78
+ # ------------------------------------------------------------------
79
+ # Rendering helpers
80
+ # ------------------------------------------------------------------
81
+ def _render(self) -> None:
82
+ try:
83
+ if self.console.is_terminal:
84
+ self.console.clear()
85
+ except Exception: # pragma: no cover - platform quirks
86
+ pass
87
+
88
+ header = (
89
+ f"Agent transcript viewer · run {self.ctx.manifest_entry.get('run_id')}"
90
+ )
91
+ agent_label = self.ctx.manifest_entry.get("agent_name") or "unknown agent"
92
+ model = self.ctx.manifest_entry.get("model") or self.ctx.meta.get("model")
93
+ agent_id = self.ctx.manifest_entry.get("agent_id")
94
+ subtitle_parts = [agent_label]
95
+ if model:
96
+ subtitle_parts.append(str(model))
97
+ if agent_id:
98
+ subtitle_parts.append(agent_id)
99
+
100
+ if self._view_mode == "transcript":
101
+ self.console.rule(header)
102
+ if subtitle_parts:
103
+ self.console.print(f"[dim]{' · '.join(subtitle_parts)}[/]")
104
+ self.console.print()
105
+
106
+ query = self._get_user_query()
107
+
108
+ if self._view_mode == "default":
109
+ self._render_default_view(query)
110
+ else:
111
+ self._render_transcript_view(query)
112
+
113
+ def _render_default_view(self, query: str | None) -> None:
114
+ if query:
115
+ self._render_user_query(query)
116
+ self._render_steps_summary()
117
+ self._render_final_panel()
118
+
119
+ def _render_transcript_view(self, query: str | None) -> None:
120
+ if not self.ctx.events:
121
+ self.console.print("[dim]No SSE events were captured for this run.[/dim]")
122
+ return
123
+
124
+ if query:
125
+ self._render_user_query(query)
126
+
127
+ self._render_steps_summary()
128
+ self._render_final_panel()
129
+
130
+ self.console.print("[bold]Transcript Events[/bold]")
131
+ self.console.print(
132
+ "[dim]────────────────────────────────────────────────────────[/dim]"
133
+ )
134
+
135
+ base_received_ts: datetime | None = None
136
+ for event in self.ctx.events:
137
+ received_ts = self._parse_received_timestamp(event)
138
+ if base_received_ts is None and received_ts is not None:
139
+ base_received_ts = received_ts
140
+ render_debug_event(
141
+ event,
142
+ self.console,
143
+ received_ts=received_ts,
144
+ baseline_ts=base_received_ts,
145
+ )
146
+ self.console.print()
147
+
148
+ def _render_final_panel(self) -> None:
149
+ content = (
150
+ self.ctx.final_output
151
+ or self.ctx.default_output
152
+ or "No response content captured."
153
+ )
154
+ title = "Final Result"
155
+ duration_text = self._extract_final_duration()
156
+ if duration_text:
157
+ title += f" · {duration_text}"
158
+ panel = create_final_panel(content, title=title, theme="dark")
159
+ self.console.print(panel)
160
+ self.console.print()
161
+
162
+ # ------------------------------------------------------------------
163
+ # Interaction loops
164
+ # ------------------------------------------------------------------
165
+ def _fallback_loop(self) -> None:
166
+ while True:
167
+ try:
168
+ ch = click.getchar()
169
+ except (EOFError, KeyboardInterrupt):
170
+ break
171
+
172
+ if ch in {"\r", "\n"}:
173
+ break
174
+
175
+ if ch == "\x14" or ch.lower() == "t": # Ctrl+T or t
176
+ self.toggle_view()
177
+ continue
178
+
179
+ if ch.lower() == "e":
180
+ self.export_transcript()
181
+ self._print_command_hint()
182
+ else:
183
+ continue
184
+
185
+ def _handle_command(self, raw: str) -> bool:
186
+ lowered = raw.lower()
187
+ if lowered in {"exit", "quit", "q"}:
188
+ return True
189
+ if lowered in {"export", "e"}:
190
+ self.export_transcript()
191
+ self._print_command_hint()
192
+ return False
193
+ self.console.print("[dim]Commands: export, exit.[/dim]")
194
+ return False
195
+
196
+ # ------------------------------------------------------------------
197
+ # Actions
198
+ # ------------------------------------------------------------------
199
+ def toggle_view(self) -> None:
200
+ """Switch between default result view and verbose transcript."""
201
+ self._view_mode = "transcript" if self._view_mode == "default" else "default"
202
+ self._render()
203
+ self._print_command_hint()
204
+
205
+ def export_transcript(self) -> None:
206
+ """Prompt user for a destination and export the cached transcript."""
207
+ entry = self.ctx.manifest_entry
208
+ default_name = suggest_filename(entry)
209
+ default_path = Path.cwd() / default_name
210
+
211
+ def _display_path(path: Path) -> str:
212
+ raw = str(path)
213
+ return raw if len(raw) <= 80 else f"…{raw[-77:]}"
214
+
215
+ selection = self._prompt_export_choice(
216
+ default_path, _display_path(default_path)
217
+ )
218
+ if selection is None:
219
+ self._legacy_export_prompt(default_path, _display_path)
220
+ return
221
+
222
+ action, _ = selection
223
+ if action == "cancel":
224
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
225
+ return
226
+
227
+ if action == "default":
228
+ destination = default_path
229
+ else:
230
+ destination = self._prompt_custom_destination()
231
+ if destination is None:
232
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
233
+ return
234
+
235
+ try:
236
+ target = self._export_callback(destination)
237
+ self.console.print(f"[green]Transcript exported to {target}[/green]")
238
+ except FileNotFoundError as exc:
239
+ self.console.print(f"[red]{exc}[/red]")
240
+ except Exception as exc: # pragma: no cover - unexpected IO failures
241
+ self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
242
+
243
+ def _prompt_export_choice(
244
+ self, default_path: Path, default_display: str
245
+ ) -> tuple[str, Any] | None:
246
+ """Render interactive export menu with numeric shortcuts."""
247
+ if not self.console.is_terminal or questionary is None or Choice is None:
248
+ return None
249
+
250
+ try:
251
+ answer = questionary.select(
252
+ "Export transcript",
253
+ choices=[
254
+ Choice(
255
+ title=f"Save to default ({default_display})",
256
+ value=("default", default_path),
257
+ shortcut_key="1",
258
+ ),
259
+ Choice(
260
+ title="Choose a different path",
261
+ value=("custom", None),
262
+ shortcut_key="2",
263
+ ),
264
+ Choice(
265
+ title="Cancel",
266
+ value=("cancel", None),
267
+ shortcut_key="3",
268
+ ),
269
+ ],
270
+ use_shortcuts=True,
271
+ instruction="Press 1-3 (or arrows) then Enter.",
272
+ ).ask()
273
+ except Exception:
274
+ return None
275
+
276
+ if answer is None:
277
+ return ("cancel", None)
278
+ return answer
279
+
280
+ def _prompt_custom_destination(self) -> Path | None:
281
+ """Prompt for custom export path with filesystem completion."""
282
+ if not self.console.is_terminal:
283
+ return None
284
+
285
+ try:
286
+ response = questionary.path(
287
+ "Destination path (Tab to autocomplete):",
288
+ default="",
289
+ only_directories=False,
290
+ ).ask()
291
+ except Exception:
292
+ return None
293
+
294
+ if not response:
295
+ return None
296
+
297
+ candidate = Path(response.strip()).expanduser()
298
+ if not candidate.is_absolute():
299
+ candidate = Path.cwd() / candidate
300
+ return candidate
301
+
302
+ def _legacy_export_prompt(
303
+ self, default_path: Path, formatter: Callable[[Path], str]
304
+ ) -> None:
305
+ """Fallback export workflow when interactive UI is unavailable."""
306
+ self.console.print("[dim]Export options (fallback mode)[/dim]")
307
+ self.console.print(f" 1. Save to default ({formatter(default_path)})")
308
+ self.console.print(" 2. Choose a different path")
309
+ self.console.print(" 3. Cancel")
310
+
311
+ try:
312
+ choice = click.prompt(
313
+ "Select option",
314
+ type=click.Choice(["1", "2", "3"], case_sensitive=False),
315
+ default="1",
316
+ show_choices=False,
317
+ )
318
+ except (EOFError, KeyboardInterrupt):
319
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
320
+ return
321
+
322
+ if choice == "3":
323
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
324
+ return
325
+
326
+ if choice == "1":
327
+ destination = default_path
328
+ else:
329
+ try:
330
+ destination_str = click.prompt("Enter destination path", default="")
331
+ except (EOFError, KeyboardInterrupt):
332
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
333
+ return
334
+ if not destination_str.strip():
335
+ self.console.print(EXPORT_CANCELLED_MESSAGE)
336
+ return
337
+ destination = Path(destination_str.strip()).expanduser()
338
+ if not destination.is_absolute():
339
+ destination = Path.cwd() / destination
340
+
341
+ try:
342
+ target = self._export_callback(destination)
343
+ self.console.print(f"[green]Transcript exported to {target}[/green]")
344
+ except FileNotFoundError as exc:
345
+ self.console.print(f"[red]{exc}[/red]")
346
+ except Exception as exc: # pragma: no cover - unexpected IO failures
347
+ self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
348
+
349
+ def _print_command_hint(self) -> None:
350
+ self.console.print(
351
+ "[dim]Ctrl+T to toggle transcript · type `e` to export · press Enter to exit[/dim]"
352
+ )
353
+ self.console.print()
354
+
355
+ def _get_user_query(self) -> str | None:
356
+ meta = self.ctx.meta or {}
357
+ manifest = self.ctx.manifest_entry or {}
358
+ return (
359
+ meta.get("input_message")
360
+ or meta.get("query")
361
+ or meta.get("message")
362
+ or manifest.get("input_message")
363
+ )
364
+
365
+ def _render_user_query(self, query: str) -> None:
366
+ panel = AIPPanel(
367
+ Markdown(f"Query: {query}"),
368
+ title="User Request",
369
+ border_style="#d97706",
370
+ )
371
+ self.console.print(panel)
372
+ self.console.print()
373
+
374
+ def _render_steps_summary(self) -> None:
375
+ panel_content = self._format_steps_summary(self._build_step_summary())
376
+ panel = AIPPanel(
377
+ Text(panel_content, style="dim"),
378
+ title="Steps",
379
+ border_style="blue",
380
+ )
381
+ self.console.print(panel)
382
+ self.console.print()
383
+
384
+ @staticmethod
385
+ def _format_steps_summary(steps: list[dict[str, Any]]) -> str:
386
+ if not steps:
387
+ return " No steps yet"
388
+
389
+ lines = []
390
+ for step in steps:
391
+ icon = ICON_DELEGATE if step.get("is_delegate") else ICON_TOOL_STEP
392
+ duration = step.get("duration")
393
+ duration_str = f" [{duration}]" if duration else ""
394
+ status = " ✓" if step.get("finished") else ""
395
+ title = step.get("title") or step.get("name") or "Step"
396
+ lines.append(f" {icon} {title}{duration_str}{status}")
397
+ return "\n".join(lines)
398
+
399
+ @staticmethod
400
+ def _extract_event_time(event: dict[str, Any]) -> float | None:
401
+ metadata = event.get("metadata") or {}
402
+ time_value = metadata.get("time")
403
+ try:
404
+ if isinstance(time_value, (int, float)):
405
+ return float(time_value)
406
+ except Exception:
407
+ return None
408
+ return None
409
+
410
+ @staticmethod
411
+ def _parse_received_timestamp(event: dict[str, Any]) -> datetime | None:
412
+ value = event.get("received_at")
413
+ if not value:
414
+ return None
415
+ if isinstance(value, str):
416
+ try:
417
+ normalised = value.replace("Z", "+00:00")
418
+ parsed = datetime.fromisoformat(normalised)
419
+ except ValueError:
420
+ return None
421
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
422
+ return None
423
+
424
+ def _extract_final_duration(self) -> str | None:
425
+ for event in self.ctx.events:
426
+ metadata = event.get("metadata") or {}
427
+ if metadata.get("kind") == "final_response":
428
+ time_value = metadata.get("time")
429
+ try:
430
+ if isinstance(time_value, (int, float)):
431
+ return f"{float(time_value):.2f}s"
432
+ except Exception:
433
+ return None
434
+ return None
435
+
436
+ def _build_step_summary(self) -> list[dict[str, Any]]:
437
+ stored = self.ctx.meta.get("transcript_steps")
438
+ if isinstance(stored, list) and stored:
439
+ return [
440
+ {
441
+ "title": entry.get("display_name") or entry.get("name") or "Step",
442
+ "is_delegate": entry.get("kind") == "delegate",
443
+ "finished": entry.get("status") == "finished",
444
+ "duration": self._format_duration_from_ms(entry.get("duration_ms")),
445
+ }
446
+ for entry in stored
447
+ ]
448
+
449
+ steps: dict[str, dict[str, Any]] = {}
450
+ order: list[str] = []
451
+
452
+ for event in self.ctx.events:
453
+ metadata = event.get("metadata") or {}
454
+ if not self._is_step_event(metadata):
455
+ continue
456
+
457
+ for name, info in self._iter_step_candidates(event, metadata):
458
+ step = self._ensure_step_entry(steps, order, name)
459
+ self._apply_step_update(step, metadata, info, event)
460
+
461
+ return [steps[name] for name in order]
462
+
463
+ @staticmethod
464
+ def _format_duration_from_ms(value: Any) -> str | None:
465
+ try:
466
+ if value is None:
467
+ return None
468
+ duration_ms = float(value)
469
+ except Exception:
470
+ return None
471
+
472
+ if duration_ms <= 0:
473
+ return "<1ms"
474
+ if duration_ms < 1000:
475
+ return f"{int(duration_ms)}ms"
476
+ return f"{duration_ms / 1000:.2f}s"
477
+
478
+ @staticmethod
479
+ def _is_step_event(metadata: dict[str, Any]) -> bool:
480
+ kind = metadata.get("kind")
481
+ return kind in {"agent_step", "agent_thinking_step"}
482
+
483
+ def _iter_step_candidates(
484
+ self, event: dict[str, Any], metadata: dict[str, Any]
485
+ ) -> Iterable[tuple[str, dict[str, Any]]]:
486
+ tool_info = metadata.get("tool_info") or {}
487
+
488
+ yielded = False
489
+ for candidate in self._iter_tool_call_candidates(tool_info):
490
+ yielded = True
491
+ yield candidate
492
+
493
+ if yielded:
494
+ return
495
+
496
+ direct_tool = self._extract_direct_tool(tool_info)
497
+ if direct_tool is not None:
498
+ yield direct_tool
499
+ return
500
+
501
+ completed = self._extract_completed_name(event)
502
+ if completed is not None:
503
+ yield completed, {}
504
+
505
+ @staticmethod
506
+ def _iter_tool_call_candidates(
507
+ tool_info: dict[str, Any],
508
+ ) -> Iterable[tuple[str, dict[str, Any]]]:
509
+ tool_calls = tool_info.get("tool_calls")
510
+ if isinstance(tool_calls, list):
511
+ for call in tool_calls:
512
+ name = call.get("name")
513
+ if name:
514
+ yield name, call
515
+
516
+ @staticmethod
517
+ def _extract_direct_tool(
518
+ tool_info: dict[str, Any],
519
+ ) -> tuple[str, dict[str, Any]] | None:
520
+ if isinstance(tool_info, dict):
521
+ name = tool_info.get("name")
522
+ if name:
523
+ return name, tool_info
524
+ return None
525
+
526
+ @staticmethod
527
+ def _extract_completed_name(event: dict[str, Any]) -> str | None:
528
+ content = event.get("content") or ""
529
+ if isinstance(content, str) and content.startswith("Completed "):
530
+ name = content.replace("Completed ", "").strip()
531
+ if name:
532
+ return name
533
+ return None
534
+
535
+ def _ensure_step_entry(
536
+ self,
537
+ steps: dict[str, dict[str, Any]],
538
+ order: list[str],
539
+ name: str,
540
+ ) -> dict[str, Any]:
541
+ if name not in steps:
542
+ steps[name] = {
543
+ "name": name,
544
+ "title": name,
545
+ "is_delegate": is_delegation_tool(name),
546
+ "duration": None,
547
+ "started_at": None,
548
+ "finished": False,
549
+ }
550
+ order.append(name)
551
+ return steps[name]
552
+
553
+ def _apply_step_update(
554
+ self,
555
+ step: dict[str, Any],
556
+ metadata: dict[str, Any],
557
+ info: dict[str, Any],
558
+ event: dict[str, Any],
559
+ ) -> None:
560
+ status = metadata.get("status")
561
+ event_time = metadata.get("time")
562
+
563
+ if (
564
+ status == "running"
565
+ and step.get("started_at") is None
566
+ and isinstance(event_time, (int, float))
567
+ ):
568
+ try:
569
+ step["started_at"] = float(event_time)
570
+ except Exception:
571
+ step["started_at"] = None
572
+
573
+ if self._is_step_finished(metadata, event):
574
+ step["finished"] = True
575
+
576
+ duration = self._compute_step_duration(step, info, metadata)
577
+ if duration is not None:
578
+ step["duration"] = duration
579
+
580
+ @staticmethod
581
+ def _is_step_finished(metadata: dict[str, Any], event: dict[str, Any]) -> bool:
582
+ status = metadata.get("status")
583
+ return status == "finished" or bool(event.get("final"))
584
+
585
+ def _compute_step_duration(
586
+ self, step: dict[str, Any], info: dict[str, Any], metadata: dict[str, Any]
587
+ ) -> str | None:
588
+ """Calculate a formatted duration string for a step if possible."""
589
+ event_time = metadata.get("time")
590
+ started_at = step.get("started_at")
591
+ duration_value: float | None = None
592
+
593
+ if isinstance(event_time, (int, float)) and isinstance(
594
+ started_at, (int, float)
595
+ ):
596
+ try:
597
+ delta = float(event_time) - float(started_at)
598
+ if delta >= 0:
599
+ duration_value = delta
600
+ except Exception:
601
+ duration_value = None
602
+
603
+ if duration_value is None:
604
+ exec_time = info.get("execution_time")
605
+ if isinstance(exec_time, (int, float)):
606
+ duration_value = float(exec_time)
607
+
608
+ if duration_value is None:
609
+ return None
610
+
611
+ try:
612
+ return format_elapsed_time(duration_value)
613
+ except Exception:
614
+ return None
615
+
616
+
617
+ def run_viewer_session(
618
+ console: Console,
619
+ ctx: ViewerContext,
620
+ export_callback: Callable[[Path], Path],
621
+ ) -> None:
622
+ """Entry point for creating and running the post-run viewer."""
623
+ viewer = PostRunViewer(console, ctx, export_callback)
624
+ viewer.run()
@@ -8,12 +8,19 @@ from __future__ import annotations
8
8
 
9
9
  import os
10
10
  from collections.abc import Callable
11
+ from typing import Any, Literal
11
12
 
12
13
  import httpx
13
14
  from packaging.version import InvalidVersion, Version
15
+ from rich import box
14
16
  from rich.console import Console
15
17
 
16
- from glaip_sdk.cli.utils import command_hint
18
+ from glaip_sdk.branding import (
19
+ ACCENT_STYLE,
20
+ SUCCESS_STYLE,
21
+ WARNING_STYLE,
22
+ )
23
+ from glaip_sdk.cli.utils import command_hint, format_command_hint
17
24
  from glaip_sdk.rich_components import AIPPanel
18
25
 
19
26
  FetchLatestVersion = Callable[[], str | None]
@@ -63,16 +70,20 @@ def _build_update_panel(
63
70
  command_text: str,
64
71
  ) -> AIPPanel:
65
72
  """Create a Rich panel that prompts the user to update."""
73
+ command_markup = format_command_hint(command_text) or command_text
66
74
  message = (
67
- f"[bold yellow]✨ Update available![/bold yellow] "
75
+ f"[{WARNING_STYLE}]✨ Update available![/] "
68
76
  f"{current_version} → {latest_version}\n\n"
69
77
  "See the latest release notes:\n"
70
78
  f"https://pypi.org/project/glaip-sdk/{latest_version}/\n\n"
71
- f"[cyan]Run[/cyan] [bold]{command_text}[/bold] to install."
79
+ f"[{ACCENT_STYLE}]Run[/] {command_markup} to install."
72
80
  )
73
81
  return AIPPanel(
74
82
  message,
75
- title="[bold green]AIP SDK Update[/bold green]",
83
+ title=f"[{SUCCESS_STYLE}]AIP SDK Update[/]",
84
+ box=box.ROUNDED,
85
+ padding=(0, 3),
86
+ expand=False,
76
87
  )
77
88
 
78
89
 
@@ -82,6 +93,9 @@ def maybe_notify_update(
82
93
  package_name: str = "glaip-sdk",
83
94
  console: Console | None = None,
84
95
  fetch_latest_version: FetchLatestVersion | None = None,
96
+ ctx: Any | None = None,
97
+ slash_command: str | None = None,
98
+ style: Literal["panel", "inline"] = "panel",
85
99
  ) -> None:
86
100
  """Check PyPI for a newer version and display a prompt if one exists.
87
101
 
@@ -101,11 +115,21 @@ def maybe_notify_update(
101
115
  if current is None or latest is None or latest <= current:
102
116
  return
103
117
 
104
- command_text = command_hint("update")
118
+ command_text = command_hint("update", slash_command=slash_command, ctx=ctx)
105
119
  if command_text is None:
106
120
  return
107
121
 
108
122
  active_console = console or Console()
123
+ if style == "inline":
124
+ command_markup = format_command_hint(command_text) or command_text
125
+ message = (
126
+ f"[{WARNING_STYLE}]✨ Update[/] "
127
+ f"{current_version} → {latest_version} "
128
+ f"- {command_markup}"
129
+ )
130
+ active_console.print(message)
131
+ return
132
+
109
133
  panel = _build_update_panel(current_version, latest_version, command_text)
110
134
  active_console.print(panel)
111
135