glaip-sdk 0.0.20__py3-none-any.whl → 0.7.7__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 (216) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1250 -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 +271 -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/__init__.py +119 -0
  12. glaip_sdk/cli/commands/agents/_common.py +561 -0
  13. glaip_sdk/cli/commands/agents/create.py +151 -0
  14. glaip_sdk/cli/commands/agents/delete.py +64 -0
  15. glaip_sdk/cli/commands/agents/get.py +89 -0
  16. glaip_sdk/cli/commands/agents/list.py +129 -0
  17. glaip_sdk/cli/commands/agents/run.py +264 -0
  18. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  19. glaip_sdk/cli/commands/agents/update.py +112 -0
  20. glaip_sdk/cli/commands/common_config.py +104 -0
  21. glaip_sdk/cli/commands/configure.py +734 -143
  22. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  23. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  24. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  25. glaip_sdk/cli/commands/mcps/create.py +152 -0
  26. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  27. glaip_sdk/cli/commands/mcps/get.py +212 -0
  28. glaip_sdk/cli/commands/mcps/list.py +69 -0
  29. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  30. glaip_sdk/cli/commands/mcps/update.py +190 -0
  31. glaip_sdk/cli/commands/models.py +14 -12
  32. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  33. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  34. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  35. glaip_sdk/cli/commands/tools/_common.py +80 -0
  36. glaip_sdk/cli/commands/tools/create.py +228 -0
  37. glaip_sdk/cli/commands/tools/delete.py +61 -0
  38. glaip_sdk/cli/commands/tools/get.py +103 -0
  39. glaip_sdk/cli/commands/tools/list.py +69 -0
  40. glaip_sdk/cli/commands/tools/script.py +49 -0
  41. glaip_sdk/cli/commands/tools/update.py +102 -0
  42. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  43. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  44. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  45. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  46. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  47. glaip_sdk/cli/commands/update.py +164 -23
  48. glaip_sdk/cli/config.py +49 -7
  49. glaip_sdk/cli/constants.py +38 -0
  50. glaip_sdk/cli/context.py +8 -0
  51. glaip_sdk/cli/core/__init__.py +79 -0
  52. glaip_sdk/cli/core/context.py +124 -0
  53. glaip_sdk/cli/core/output.py +851 -0
  54. glaip_sdk/cli/core/prompting.py +649 -0
  55. glaip_sdk/cli/core/rendering.py +187 -0
  56. glaip_sdk/cli/display.py +45 -32
  57. glaip_sdk/cli/entrypoint.py +20 -0
  58. glaip_sdk/cli/hints.py +57 -0
  59. glaip_sdk/cli/io.py +14 -17
  60. glaip_sdk/cli/main.py +344 -167
  61. glaip_sdk/cli/masking.py +21 -33
  62. glaip_sdk/cli/mcp_validators.py +5 -15
  63. glaip_sdk/cli/pager.py +15 -22
  64. glaip_sdk/cli/parsers/__init__.py +1 -3
  65. glaip_sdk/cli/parsers/json_input.py +11 -22
  66. glaip_sdk/cli/resolution.py +5 -10
  67. glaip_sdk/cli/rich_helpers.py +1 -3
  68. glaip_sdk/cli/slash/__init__.py +0 -9
  69. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  70. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  71. glaip_sdk/cli/slash/agent_session.py +65 -29
  72. glaip_sdk/cli/slash/prompt.py +24 -10
  73. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  74. glaip_sdk/cli/slash/session.py +827 -232
  75. glaip_sdk/cli/slash/tui/__init__.py +34 -0
  76. glaip_sdk/cli/slash/tui/accounts.tcss +88 -0
  77. glaip_sdk/cli/slash/tui/accounts_app.py +933 -0
  78. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  79. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  80. glaip_sdk/cli/slash/tui/context.py +59 -0
  81. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  82. glaip_sdk/cli/slash/tui/loading.py +58 -0
  83. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  84. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  85. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  86. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  87. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  88. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  89. glaip_sdk/cli/slash/tui/toast.py +123 -0
  90. glaip_sdk/cli/transcript/__init__.py +12 -52
  91. glaip_sdk/cli/transcript/cache.py +258 -60
  92. glaip_sdk/cli/transcript/capture.py +72 -21
  93. glaip_sdk/cli/transcript/history.py +815 -0
  94. glaip_sdk/cli/transcript/launcher.py +1 -3
  95. glaip_sdk/cli/transcript/viewer.py +79 -329
  96. glaip_sdk/cli/update_notifier.py +385 -24
  97. glaip_sdk/cli/validators.py +16 -18
  98. glaip_sdk/client/__init__.py +3 -1
  99. glaip_sdk/client/_schedule_payloads.py +89 -0
  100. glaip_sdk/client/agent_runs.py +147 -0
  101. glaip_sdk/client/agents.py +370 -100
  102. glaip_sdk/client/base.py +78 -35
  103. glaip_sdk/client/hitl.py +136 -0
  104. glaip_sdk/client/main.py +25 -10
  105. glaip_sdk/client/mcps.py +166 -27
  106. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  107. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +65 -74
  108. glaip_sdk/client/payloads/agent/responses.py +43 -0
  109. glaip_sdk/client/run_rendering.py +583 -79
  110. glaip_sdk/client/schedules.py +439 -0
  111. glaip_sdk/client/shared.py +21 -0
  112. glaip_sdk/client/tools.py +214 -56
  113. glaip_sdk/client/validators.py +20 -48
  114. glaip_sdk/config/constants.py +11 -0
  115. glaip_sdk/exceptions.py +1 -3
  116. glaip_sdk/hitl/__init__.py +48 -0
  117. glaip_sdk/hitl/base.py +64 -0
  118. glaip_sdk/hitl/callback.py +43 -0
  119. glaip_sdk/hitl/local.py +121 -0
  120. glaip_sdk/hitl/remote.py +523 -0
  121. glaip_sdk/icons.py +9 -3
  122. glaip_sdk/mcps/__init__.py +21 -0
  123. glaip_sdk/mcps/base.py +345 -0
  124. glaip_sdk/models/__init__.py +107 -0
  125. glaip_sdk/models/agent.py +47 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/mcp.py +33 -0
  129. glaip_sdk/models/schedule.py +224 -0
  130. glaip_sdk/models/tool.py +33 -0
  131. glaip_sdk/payload_schemas/__init__.py +1 -13
  132. glaip_sdk/payload_schemas/agent.py +1 -3
  133. glaip_sdk/registry/__init__.py +55 -0
  134. glaip_sdk/registry/agent.py +164 -0
  135. glaip_sdk/registry/base.py +139 -0
  136. glaip_sdk/registry/mcp.py +253 -0
  137. glaip_sdk/registry/tool.py +445 -0
  138. glaip_sdk/rich_components.py +58 -2
  139. glaip_sdk/runner/__init__.py +76 -0
  140. glaip_sdk/runner/base.py +84 -0
  141. glaip_sdk/runner/deps.py +112 -0
  142. glaip_sdk/runner/langgraph.py +872 -0
  143. glaip_sdk/runner/logging_config.py +77 -0
  144. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  145. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  146. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  147. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  148. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  149. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  150. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  151. glaip_sdk/schedules/__init__.py +22 -0
  152. glaip_sdk/schedules/base.py +291 -0
  153. glaip_sdk/tools/__init__.py +22 -0
  154. glaip_sdk/tools/base.py +468 -0
  155. glaip_sdk/utils/__init__.py +59 -12
  156. glaip_sdk/utils/a2a/__init__.py +34 -0
  157. glaip_sdk/utils/a2a/event_processor.py +188 -0
  158. glaip_sdk/utils/agent_config.py +4 -14
  159. glaip_sdk/utils/bundler.py +403 -0
  160. glaip_sdk/utils/client.py +111 -0
  161. glaip_sdk/utils/client_utils.py +46 -28
  162. glaip_sdk/utils/datetime_helpers.py +58 -0
  163. glaip_sdk/utils/discovery.py +78 -0
  164. glaip_sdk/utils/display.py +25 -21
  165. glaip_sdk/utils/export.py +143 -0
  166. glaip_sdk/utils/general.py +1 -36
  167. glaip_sdk/utils/import_export.py +15 -16
  168. glaip_sdk/utils/import_resolver.py +524 -0
  169. glaip_sdk/utils/instructions.py +101 -0
  170. glaip_sdk/utils/rendering/__init__.py +115 -1
  171. glaip_sdk/utils/rendering/formatting.py +38 -23
  172. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  173. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  174. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  175. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  176. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  177. glaip_sdk/utils/rendering/models.py +18 -8
  178. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  179. glaip_sdk/utils/rendering/renderer/base.py +534 -882
  180. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  181. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  182. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  183. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  184. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  185. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  186. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  187. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  188. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  189. glaip_sdk/utils/rendering/state.py +204 -0
  190. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  191. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  192. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  193. glaip_sdk/utils/rendering/steps/format.py +176 -0
  194. glaip_sdk/utils/rendering/{steps.py → steps/manager.py} +122 -26
  195. glaip_sdk/utils/rendering/timing.py +36 -0
  196. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  197. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  198. glaip_sdk/utils/resource_refs.py +29 -26
  199. glaip_sdk/utils/runtime_config.py +425 -0
  200. glaip_sdk/utils/serialization.py +32 -46
  201. glaip_sdk/utils/sync.py +162 -0
  202. glaip_sdk/utils/tool_detection.py +301 -0
  203. glaip_sdk/utils/tool_storage_provider.py +140 -0
  204. glaip_sdk/utils/validation.py +20 -28
  205. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/METADATA +78 -23
  206. glaip_sdk-0.7.7.dist-info/RECORD +213 -0
  207. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/WHEEL +2 -1
  208. glaip_sdk-0.7.7.dist-info/entry_points.txt +2 -0
  209. glaip_sdk-0.7.7.dist-info/top_level.txt +1 -0
  210. glaip_sdk/cli/commands/agents.py +0 -1412
  211. glaip_sdk/cli/commands/mcps.py +0 -1225
  212. glaip_sdk/cli/commands/tools.py +0 -597
  213. glaip_sdk/cli/utils.py +0 -1330
  214. glaip_sdk/models.py +0 -259
  215. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  216. glaip_sdk-0.0.20.dist-info/entry_points.txt +0 -3
@@ -20,9 +20,7 @@ from glaip_sdk.cli.transcript.capture import StoredTranscriptContext
20
20
  from glaip_sdk.cli.transcript.viewer import ViewerContext, run_viewer_session
21
21
 
22
22
 
23
- def should_launch_post_run_viewer(
24
- ctx: Any, console: Console, *, slash_mode: bool
25
- ) -> bool:
23
+ def should_launch_post_run_viewer(ctx: Any, console: Console, *, slash_mode: bool) -> bool:
26
24
  """Return True if the viewer should open automatically."""
27
25
  if slash_mode:
28
26
  return False
@@ -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,28 +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.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,
24
+ from glaip_sdk.cli.core.prompting 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,
35
33
  )
36
34
 
37
35
  EXPORT_CANCELLED_MESSAGE = "[dim]Export cancelled.[/dim]"
38
36
 
39
37
 
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]
38
+ ViewerContext = PresenterViewerContext
50
39
 
51
40
 
52
41
  class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
@@ -57,18 +46,18 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
57
46
  console: Console,
58
47
  ctx: ViewerContext,
59
48
  export_callback: Callable[[Path], Path],
49
+ *,
50
+ initial_view: str = "default",
60
51
  ) -> None:
61
52
  """Initialize viewer state for a captured transcript."""
62
53
  self.console = console
63
54
  self.ctx = ctx
64
55
  self._export_callback = export_callback
65
- self._view_mode = "default"
56
+ self._view_mode = initial_view if initial_view in {"default", "transcript"} else "default"
66
57
 
67
58
  def run(self) -> None:
68
59
  """Enter the interactive loop."""
69
- if not self.ctx.events and not (
70
- self.ctx.default_output or self.ctx.final_output
71
- ):
60
+ if not self.ctx.events and not (self.ctx.default_output or self.ctx.final_output):
72
61
  return
73
62
  if self._view_mode == "transcript":
74
63
  self._render()
@@ -79,15 +68,14 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
79
68
  # Rendering helpers
80
69
  # ------------------------------------------------------------------
81
70
  def _render(self) -> None:
71
+ """Render the transcript viewer interface."""
82
72
  try:
83
73
  if self.console.is_terminal:
84
74
  self.console.clear()
85
75
  except Exception: # pragma: no cover - platform quirks
86
76
  pass
87
77
 
88
- header = (
89
- f"Agent transcript viewer · run {self.ctx.manifest_entry.get('run_id')}"
90
- )
78
+ header = f"Agent transcript viewer · run {self.ctx.manifest_entry.get('run_id')}"
91
79
  agent_label = self.ctx.manifest_entry.get("agent_name") or "unknown agent"
92
80
  model = self.ctx.manifest_entry.get("model") or self.ctx.meta.get("model")
93
81
  agent_id = self.ctx.manifest_entry.get("agent_id")
@@ -103,66 +91,19 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
103
91
  self.console.print(f"[dim]{' · '.join(subtitle_parts)}[/]")
104
92
  self.console.print()
105
93
 
106
- query = self._get_user_query()
107
-
108
94
  if self._view_mode == "default":
109
- self._render_default_view(query)
95
+ presenter_render_post_run_view(self.console, self.ctx)
110
96
  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()
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)
161
101
 
162
102
  # ------------------------------------------------------------------
163
103
  # Interaction loops
164
104
  # ------------------------------------------------------------------
165
105
  def _fallback_loop(self) -> None:
106
+ """Fallback interaction loop for non-interactive terminals."""
166
107
  while True:
167
108
  try:
168
109
  ch = click.getchar()
@@ -183,6 +124,14 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
183
124
  continue
184
125
 
185
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
+ """
186
135
  lowered = raw.lower()
187
136
  if lowered in {"exit", "quit", "q"}:
188
137
  return True
@@ -212,9 +161,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
212
161
  raw = str(path)
213
162
  return raw if len(raw) <= 80 else f"…{raw[-77:]}"
214
163
 
215
- selection = self._prompt_export_choice(
216
- default_path, _display_path(default_path)
217
- )
164
+ selection = self._prompt_export_choice(default_path, _display_path(default_path))
218
165
  if selection is None:
219
166
  self._legacy_export_prompt(default_path, _display_path)
220
167
  return
@@ -240,42 +187,12 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
240
187
  except Exception as exc: # pragma: no cover - unexpected IO failures
241
188
  self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
242
189
 
243
- def _prompt_export_choice(
244
- self, default_path: Path, default_display: str
245
- ) -> tuple[str, Any] | None:
190
+ def _prompt_export_choice(self, default_path: Path, default_display: str) -> tuple[str, Any] | None:
246
191
  """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:
192
+ if not self.console.is_terminal:
274
193
  return None
275
194
 
276
- if answer is None:
277
- return ("cancel", None)
278
- return answer
195
+ return prompt_export_choice_questionary(default_path, default_display)
279
196
 
280
197
  def _prompt_custom_destination(self) -> Path | None:
281
198
  """Prompt for custom export path with filesystem completion."""
@@ -283,11 +200,12 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
283
200
  return None
284
201
 
285
202
  try:
286
- response = questionary.path(
203
+ question = questionary.path(
287
204
  "Destination path (Tab to autocomplete):",
288
205
  default="",
289
206
  only_directories=False,
290
- ).ask()
207
+ )
208
+ response = questionary_safe_ask(question)
291
209
  except Exception:
292
210
  return None
293
211
 
@@ -299,9 +217,7 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
299
217
  candidate = Path.cwd() / candidate
300
218
  return candidate
301
219
 
302
- def _legacy_export_prompt(
303
- self, default_path: Path, formatter: Callable[[Path], str]
304
- ) -> None:
220
+ def _legacy_export_prompt(self, default_path: Path, formatter: Callable[[Path], str]) -> None:
305
221
  """Fallback export workflow when interactive UI is unavailable."""
306
222
  self.console.print("[dim]Export options (fallback mode)[/dim]")
307
223
  self.console.print(f" 1. Save to default ({formatter(default_path)})")
@@ -347,176 +263,22 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
347
263
  self.console.print(f"[red]Failed to export transcript: {exc}[/red]")
348
264
 
349
265
  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)
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]")
372
268
  self.console.print()
373
269
 
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
270
  @staticmethod
517
271
  def _extract_direct_tool(
518
272
  tool_info: dict[str, Any],
519
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
+ """
520
282
  if isinstance(tool_info, dict):
521
283
  name = tool_info.get("name")
522
284
  if name:
@@ -525,6 +287,14 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
525
287
 
526
288
  @staticmethod
527
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
+ """
528
298
  content = event.get("content") or ""
529
299
  if isinstance(content, str) and content.startswith("Completed "):
530
300
  name = content.replace("Completed ", "").strip()
@@ -538,6 +308,16 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
538
308
  order: list[str],
539
309
  name: str,
540
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
+ """
541
321
  if name not in steps:
542
322
  steps[name] = {
543
323
  "name": name,
@@ -557,14 +337,18 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
557
337
  info: dict[str, Any],
558
338
  event: dict[str, Any],
559
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
+ """
560
348
  status = metadata.get("status")
561
349
  event_time = metadata.get("time")
562
350
 
563
- if (
564
- status == "running"
565
- and step.get("started_at") is None
566
- and isinstance(event_time, (int, float))
567
- ):
351
+ if status == "running" and step.get("started_at") is None and isinstance(event_time, (int, float)):
568
352
  try:
569
353
  step["started_at"] = float(event_time)
570
354
  except Exception:
@@ -577,48 +361,14 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
577
361
  if duration is not None:
578
362
  step["duration"] = duration
579
363
 
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
364
 
617
365
  def run_viewer_session(
618
366
  console: Console,
619
367
  ctx: ViewerContext,
620
368
  export_callback: Callable[[Path], Path],
369
+ *,
370
+ initial_view: str = "default",
621
371
  ) -> None:
622
372
  """Entry point for creating and running the post-run viewer."""
623
- viewer = PostRunViewer(console, ctx, export_callback)
373
+ viewer = PostRunViewer(console, ctx, export_callback, initial_view=initial_view)
624
374
  viewer.run()