glaip-sdk 0.1.2__py3-none-any.whl → 0.7.17__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 (217) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1413 -0
  5. glaip_sdk/branding.py +126 -2
  6. glaip_sdk/cli/account_store.py +555 -0
  7. glaip_sdk/cli/auth.py +260 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents/__init__.py +116 -0
  11. glaip_sdk/cli/commands/agents/_common.py +562 -0
  12. glaip_sdk/cli/commands/agents/create.py +155 -0
  13. glaip_sdk/cli/commands/agents/delete.py +64 -0
  14. glaip_sdk/cli/commands/agents/get.py +89 -0
  15. glaip_sdk/cli/commands/agents/list.py +129 -0
  16. glaip_sdk/cli/commands/agents/run.py +264 -0
  17. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  18. glaip_sdk/cli/commands/agents/update.py +112 -0
  19. glaip_sdk/cli/commands/common_config.py +104 -0
  20. glaip_sdk/cli/commands/configure.py +728 -113
  21. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  22. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  23. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  24. glaip_sdk/cli/commands/mcps/create.py +152 -0
  25. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  26. glaip_sdk/cli/commands/mcps/get.py +212 -0
  27. glaip_sdk/cli/commands/mcps/list.py +69 -0
  28. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  29. glaip_sdk/cli/commands/mcps/update.py +190 -0
  30. glaip_sdk/cli/commands/models.py +12 -8
  31. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  32. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  33. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  34. glaip_sdk/cli/commands/tools/_common.py +80 -0
  35. glaip_sdk/cli/commands/tools/create.py +228 -0
  36. glaip_sdk/cli/commands/tools/delete.py +61 -0
  37. glaip_sdk/cli/commands/tools/get.py +103 -0
  38. glaip_sdk/cli/commands/tools/list.py +69 -0
  39. glaip_sdk/cli/commands/tools/script.py +49 -0
  40. glaip_sdk/cli/commands/tools/update.py +102 -0
  41. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  42. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  43. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  44. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  45. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  46. glaip_sdk/cli/commands/update.py +163 -17
  47. glaip_sdk/cli/config.py +49 -4
  48. glaip_sdk/cli/constants.py +38 -0
  49. glaip_sdk/cli/context.py +8 -0
  50. glaip_sdk/cli/core/__init__.py +79 -0
  51. glaip_sdk/cli/core/context.py +124 -0
  52. glaip_sdk/cli/core/output.py +851 -0
  53. glaip_sdk/cli/core/prompting.py +649 -0
  54. glaip_sdk/cli/core/rendering.py +187 -0
  55. glaip_sdk/cli/display.py +41 -20
  56. glaip_sdk/cli/entrypoint.py +20 -0
  57. glaip_sdk/cli/hints.py +57 -0
  58. glaip_sdk/cli/io.py +6 -3
  59. glaip_sdk/cli/main.py +340 -143
  60. glaip_sdk/cli/masking.py +21 -33
  61. glaip_sdk/cli/pager.py +12 -13
  62. glaip_sdk/cli/parsers/__init__.py +1 -3
  63. glaip_sdk/cli/resolution.py +2 -1
  64. glaip_sdk/cli/slash/__init__.py +0 -9
  65. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  66. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  67. glaip_sdk/cli/slash/agent_session.py +62 -21
  68. glaip_sdk/cli/slash/prompt.py +21 -0
  69. glaip_sdk/cli/slash/remote_runs_controller.py +568 -0
  70. glaip_sdk/cli/slash/session.py +1105 -153
  71. glaip_sdk/cli/slash/tui/__init__.py +36 -0
  72. glaip_sdk/cli/slash/tui/accounts.tcss +177 -0
  73. glaip_sdk/cli/slash/tui/accounts_app.py +1853 -0
  74. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  75. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  76. glaip_sdk/cli/slash/tui/context.py +92 -0
  77. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  78. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  79. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  80. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  81. glaip_sdk/cli/slash/tui/loading.py +80 -0
  82. glaip_sdk/cli/slash/tui/remote_runs_app.py +760 -0
  83. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  84. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  85. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  86. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  87. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  88. glaip_sdk/cli/slash/tui/toast.py +388 -0
  89. glaip_sdk/cli/transcript/__init__.py +12 -52
  90. glaip_sdk/cli/transcript/cache.py +255 -44
  91. glaip_sdk/cli/transcript/capture.py +66 -1
  92. glaip_sdk/cli/transcript/history.py +815 -0
  93. glaip_sdk/cli/transcript/viewer.py +72 -463
  94. glaip_sdk/cli/tui_settings.py +125 -0
  95. glaip_sdk/cli/update_notifier.py +227 -10
  96. glaip_sdk/cli/validators.py +5 -6
  97. glaip_sdk/client/__init__.py +3 -1
  98. glaip_sdk/client/_schedule_payloads.py +89 -0
  99. glaip_sdk/client/agent_runs.py +147 -0
  100. glaip_sdk/client/agents.py +576 -44
  101. glaip_sdk/client/base.py +26 -0
  102. glaip_sdk/client/hitl.py +136 -0
  103. glaip_sdk/client/main.py +25 -14
  104. glaip_sdk/client/mcps.py +165 -24
  105. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  106. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +63 -47
  107. glaip_sdk/client/payloads/agent/responses.py +43 -0
  108. glaip_sdk/client/run_rendering.py +546 -92
  109. glaip_sdk/client/schedules.py +439 -0
  110. glaip_sdk/client/shared.py +21 -0
  111. glaip_sdk/client/tools.py +206 -32
  112. glaip_sdk/config/constants.py +33 -2
  113. glaip_sdk/guardrails/__init__.py +80 -0
  114. glaip_sdk/guardrails/serializer.py +89 -0
  115. glaip_sdk/hitl/__init__.py +48 -0
  116. glaip_sdk/hitl/base.py +64 -0
  117. glaip_sdk/hitl/callback.py +43 -0
  118. glaip_sdk/hitl/local.py +121 -0
  119. glaip_sdk/hitl/remote.py +523 -0
  120. glaip_sdk/mcps/__init__.py +21 -0
  121. glaip_sdk/mcps/base.py +345 -0
  122. glaip_sdk/models/__init__.py +136 -0
  123. glaip_sdk/models/_provider_mappings.py +101 -0
  124. glaip_sdk/models/_validation.py +97 -0
  125. glaip_sdk/models/agent.py +48 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/constants.py +141 -0
  129. glaip_sdk/models/mcp.py +33 -0
  130. glaip_sdk/models/model.py +170 -0
  131. glaip_sdk/models/schedule.py +224 -0
  132. glaip_sdk/models/tool.py +33 -0
  133. glaip_sdk/payload_schemas/__init__.py +1 -13
  134. glaip_sdk/payload_schemas/agent.py +1 -0
  135. glaip_sdk/payload_schemas/guardrails.py +34 -0
  136. glaip_sdk/registry/__init__.py +55 -0
  137. glaip_sdk/registry/agent.py +164 -0
  138. glaip_sdk/registry/base.py +139 -0
  139. glaip_sdk/registry/mcp.py +253 -0
  140. glaip_sdk/registry/tool.py +445 -0
  141. glaip_sdk/rich_components.py +58 -2
  142. glaip_sdk/runner/__init__.py +76 -0
  143. glaip_sdk/runner/base.py +84 -0
  144. glaip_sdk/runner/deps.py +115 -0
  145. glaip_sdk/runner/langgraph.py +1055 -0
  146. glaip_sdk/runner/logging_config.py +77 -0
  147. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  148. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  149. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  150. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +116 -0
  151. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  152. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  153. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  154. glaip_sdk/schedules/__init__.py +22 -0
  155. glaip_sdk/schedules/base.py +291 -0
  156. glaip_sdk/tools/__init__.py +22 -0
  157. glaip_sdk/tools/base.py +488 -0
  158. glaip_sdk/utils/__init__.py +59 -12
  159. glaip_sdk/utils/a2a/__init__.py +34 -0
  160. glaip_sdk/utils/a2a/event_processor.py +188 -0
  161. glaip_sdk/utils/agent_config.py +8 -2
  162. glaip_sdk/utils/bundler.py +403 -0
  163. glaip_sdk/utils/client.py +111 -0
  164. glaip_sdk/utils/client_utils.py +39 -7
  165. glaip_sdk/utils/datetime_helpers.py +58 -0
  166. glaip_sdk/utils/discovery.py +78 -0
  167. glaip_sdk/utils/display.py +23 -15
  168. glaip_sdk/utils/export.py +143 -0
  169. glaip_sdk/utils/general.py +0 -33
  170. glaip_sdk/utils/import_export.py +12 -7
  171. glaip_sdk/utils/import_resolver.py +524 -0
  172. glaip_sdk/utils/instructions.py +101 -0
  173. glaip_sdk/utils/rendering/__init__.py +115 -1
  174. glaip_sdk/utils/rendering/formatting.py +5 -30
  175. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  176. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  177. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  178. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  179. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  180. glaip_sdk/utils/rendering/models.py +1 -0
  181. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  182. glaip_sdk/utils/rendering/renderer/base.py +299 -1434
  183. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  184. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  185. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  186. glaip_sdk/utils/rendering/renderer/stream.py +4 -33
  187. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  188. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  189. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  190. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  191. glaip_sdk/utils/rendering/state.py +204 -0
  192. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  193. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  194. glaip_sdk/utils/rendering/steps/format.py +176 -0
  195. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  196. glaip_sdk/utils/rendering/timing.py +36 -0
  197. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  198. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  199. glaip_sdk/utils/resource_refs.py +25 -13
  200. glaip_sdk/utils/runtime_config.py +426 -0
  201. glaip_sdk/utils/serialization.py +18 -0
  202. glaip_sdk/utils/sync.py +162 -0
  203. glaip_sdk/utils/tool_detection.py +301 -0
  204. glaip_sdk/utils/tool_storage_provider.py +140 -0
  205. glaip_sdk/utils/validation.py +16 -24
  206. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/METADATA +69 -23
  207. glaip_sdk-0.7.17.dist-info/RECORD +224 -0
  208. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/WHEEL +2 -1
  209. glaip_sdk-0.7.17.dist-info/entry_points.txt +2 -0
  210. glaip_sdk-0.7.17.dist-info/top_level.txt +1 -0
  211. glaip_sdk/cli/commands/agents.py +0 -1369
  212. glaip_sdk/cli/commands/mcps.py +0 -1187
  213. glaip_sdk/cli/commands/tools.py +0 -584
  214. glaip_sdk/cli/utils.py +0 -1278
  215. glaip_sdk/models.py +0 -240
  216. glaip_sdk-0.1.2.dist-info/RECORD +0 -82
  217. glaip_sdk-0.1.2.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,568 @@
1
+ """Remote runs controller for browsing agent run history.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import click
15
+ from rich.console import Group
16
+ from rich.text import Text
17
+
18
+ try: # pragma: no cover - optional dependency
19
+ import questionary
20
+ from questionary import Choice
21
+ except Exception: # pragma: no cover - optional dependency
22
+ questionary = None # type: ignore[assignment]
23
+ Choice = None # type: ignore[assignment]
24
+
25
+ from glaip_sdk.branding import (
26
+ ERROR_STYLE,
27
+ INFO_STYLE,
28
+ SUCCESS_STYLE,
29
+ WARNING_STYLE,
30
+ )
31
+ from glaip_sdk.cli.constants import DEFAULT_REMOTE_RUNS_PAGE_LIMIT
32
+ from glaip_sdk.cli.slash.tui.remote_runs_app import RemoteRunsTUICallbacks, run_remote_runs_textual
33
+ from glaip_sdk.cli.core.prompting import prompt_export_choice_questionary, questionary_safe_ask
34
+ from glaip_sdk.exceptions import (
35
+ AuthenticationError,
36
+ ForbiddenError,
37
+ NotFoundError,
38
+ TimeoutError,
39
+ ValidationError,
40
+ )
41
+ from glaip_sdk.rich_components import RemoteRunsTable
42
+ from glaip_sdk.utils.export import export_remote_transcript_jsonl
43
+ from glaip_sdk.utils.rendering import render_remote_sse_transcript
44
+
45
+ if TYPE_CHECKING: # pragma: no cover - type checking only
46
+ from glaip_sdk.cli.slash.session import SlashSession
47
+
48
+
49
+ class RemoteRunsController:
50
+ """Controller for browsing remote agent run history."""
51
+
52
+ def __init__(self, session: SlashSession) -> None:
53
+ """Initialize the remote runs controller.
54
+
55
+ Args:
56
+ session: The slash session context.
57
+ """
58
+ self.session = session
59
+ self.console = session.console
60
+ self.ctx = session.ctx
61
+
62
+ self._snapshot_notice_shown = False
63
+
64
+ def handle_runs_command(self, args: list[str]) -> bool:
65
+ """Handle the /runs command for browsing remote agent run history.
66
+
67
+ Args:
68
+ args: Command arguments (optional run_id for detail view).
69
+
70
+ Returns:
71
+ True to continue session.
72
+ """
73
+ current_agent = getattr(self.session, "_current_agent", None)
74
+ if not current_agent:
75
+ self.console.print(
76
+ f"[{WARNING_STYLE}]Open /agents and select an agent first to browse remote run history.[/]"
77
+ )
78
+ return self._continue_session()
79
+
80
+ agent_id = str(getattr(current_agent, "id", ""))
81
+ if not agent_id:
82
+ self.console.print(f"[{ERROR_STYLE}]Invalid agent context.[/]")
83
+ return self._continue_session()
84
+
85
+ if args:
86
+ run_id = args[0]
87
+ self.show_run_detail(agent_id, run_id)
88
+ return self._continue_session()
89
+
90
+ client = self._get_client_or_fail()
91
+ if not client:
92
+ return self._continue_session()
93
+
94
+ agent_name = getattr(current_agent, "name", "") or None
95
+ self.open_remote_runs_browser(client, agent_id, agent_name=agent_name)
96
+ return self._continue_session()
97
+
98
+ def open_remote_runs_browser(self, client: Any, agent_id: str, *, agent_name: str | None = None) -> None:
99
+ """Fetch and render the remote runs table for the current agent.
100
+
101
+ Args:
102
+ client: API client instance.
103
+ agent_id: UUID of the agent.
104
+ agent_name: Optional display name for the agent.
105
+ """
106
+ state = self._get_runs_state(agent_id)
107
+ runs_page = self._fetch_remote_runs_page(client, agent_id, state)
108
+ if runs_page is None:
109
+ return
110
+
111
+ state["page"] = runs_page.page
112
+ state["limit"] = runs_page.limit
113
+ cursor = state.get("cursor", 0)
114
+ if runs_page.data:
115
+ cursor = max(0, min(cursor, len(runs_page.data) - 1))
116
+ state["cursor"] = cursor
117
+
118
+ if self._should_use_textual_browser():
119
+ self._run_textual_browser(client, agent_id, runs_page, state, agent_name=agent_name)
120
+ return
121
+
122
+ if not self._snapshot_notice_shown:
123
+ self.console.print(
124
+ f"[{INFO_STYLE}]Interactive remote history requires a TTY. Showing the latest snapshot instead.[/]"
125
+ )
126
+ self._snapshot_notice_shown = True
127
+ self._render_runs_table(runs_page, agent_id, cursor_idx=cursor)
128
+
129
+ def _get_runs_state(self, agent_id: str) -> dict[str, Any]:
130
+ """Return the persisted pagination state for an agent.
131
+
132
+ Args:
133
+ agent_id: UUID of the agent.
134
+
135
+ Returns:
136
+ Dictionary with page, limit, and cursor state.
137
+ """
138
+ pagination_state = getattr(self.session, "_runs_pagination_state", {})
139
+ state = pagination_state.setdefault(
140
+ agent_id,
141
+ {"page": 1, "limit": DEFAULT_REMOTE_RUNS_PAGE_LIMIT, "cursor": 0},
142
+ )
143
+ state.setdefault("page", 1)
144
+ state.setdefault("limit", DEFAULT_REMOTE_RUNS_PAGE_LIMIT)
145
+ state.setdefault("cursor", 0)
146
+ return state
147
+
148
+ def _fetch_remote_runs_page(
149
+ self,
150
+ client: Any,
151
+ agent_id: str,
152
+ state: dict[str, Any],
153
+ *,
154
+ allow_reset: bool = True,
155
+ ) -> Any | None:
156
+ """Fetch a RunsPage while handling common error flows.
157
+
158
+ Args:
159
+ client: API client instance.
160
+ agent_id: UUID of the agent.
161
+ state: Pagination state dictionary.
162
+ allow_reset: Whether to reset pagination on validation errors.
163
+
164
+ Returns:
165
+ RunsPage instance or None on error.
166
+ """
167
+ try:
168
+ return client.agents.runs.list_runs(agent_id, limit=state["limit"], page=state["page"])
169
+ except AuthenticationError:
170
+ self.console.print(f"[{ERROR_STYLE}]Authentication failed. Run /login to refresh credentials.[/]")
171
+ except ForbiddenError as exc:
172
+ self.console.print(f"[{ERROR_STYLE}]Access denied: {exc}[/]")
173
+ except NotFoundError:
174
+ self.console.print(
175
+ f"[{WARNING_STYLE}]Agent not found or access revoked. Re-open /agents to select again.[/]"
176
+ )
177
+ pagination_state = getattr(self.session, "_runs_pagination_state", {})
178
+ pagination_state.pop(agent_id, None)
179
+ except TimeoutError:
180
+ ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else {}
181
+ timeout_seconds = ctx_obj.get("timeout", 30)
182
+ self.console.print(
183
+ f"[{WARNING_STYLE}]Remote history timed out after {timeout_seconds}s. Press Enter to retry.[/]"
184
+ )
185
+ except ValidationError:
186
+ if allow_reset:
187
+ self.console.print(
188
+ f"[{WARNING_STYLE}]Invalid pagination request (page {state['page']}, limit {state['limit']}). "
189
+ "Resetting to defaults.[/]"
190
+ )
191
+ state["page"] = 1
192
+ state["limit"] = DEFAULT_REMOTE_RUNS_PAGE_LIMIT
193
+ return self._fetch_remote_runs_page(client, agent_id, state, allow_reset=False)
194
+ self.console.print(f"[{ERROR_STYLE}]Pagination request rejected by backend.[/]")
195
+ except Exception as exc: # pragma: no cover - unexpected API failure
196
+ self.console.print(f"[{ERROR_STYLE}]Error fetching runs: {exc}[/]")
197
+ return None
198
+
199
+ def _build_runs_table_renderable(
200
+ self,
201
+ runs_page: Any,
202
+ agent_id: str,
203
+ *,
204
+ cursor_idx: int = 0,
205
+ ) -> Group:
206
+ """Build the Rich renderable for the runs table view."""
207
+ current_agent = getattr(self.session, "_current_agent", None)
208
+ agent_label = getattr(current_agent, "name", agent_id) if current_agent else agent_id
209
+ total_pages = 1
210
+ if runs_page.limit:
211
+ total_pages = max(1, math.ceil(runs_page.total / runs_page.limit))
212
+ header = (
213
+ f"[dim]Agent: {agent_label} ({agent_id}) · Limit={runs_page.limit} · "
214
+ f"Page {runs_page.page}/{total_pages} (use ←/→ to paginate)[/]"
215
+ )
216
+ renderables: list[Any] = [Text.from_markup(f"\n{header}")]
217
+
218
+ if runs_page.total == 0:
219
+ renderables.append(
220
+ Text.from_markup(f"[{WARNING_STYLE}]No remote runs yet. Trigger `/agents run` to create a run.[/]")
221
+ )
222
+ return Group(*renderables)
223
+
224
+ table = RemoteRunsTable(title="Remote Runs — ↑/↓ rows · ←/→ pages · q/Esc exit")
225
+ for idx, run in enumerate(runs_page.data):
226
+ run_type_str = run.run_type.title()
227
+ status_str = run.status.upper()
228
+ started_str = run.started_at.strftime("%Y-%m-%d %H:%M:%S") if run.started_at else "—"
229
+ completed_str = run.completed_at.strftime("%Y-%m-%d %H:%M:%S") if run.completed_at else "—"
230
+ duration_str = run.duration_formatted()
231
+ input_preview = run.input_preview()
232
+ table.add_run_row(
233
+ str(run.id),
234
+ run_type_str,
235
+ status_str,
236
+ started_str,
237
+ completed_str,
238
+ duration_str,
239
+ input_preview,
240
+ selected=idx == cursor_idx,
241
+ )
242
+
243
+ renderables.append(table)
244
+ renderables.append(Text.from_markup("[dim]Enter detail · e export JSONL · q/Esc exit[/]"))
245
+ return Group(*renderables)
246
+
247
+ def _render_runs_table(self, runs_page: Any, agent_id: str, *, cursor_idx: int = 0) -> None:
248
+ """Render runs table with pagination info."""
249
+ renderable = self._build_runs_table_renderable(
250
+ runs_page,
251
+ agent_id,
252
+ cursor_idx=cursor_idx,
253
+ )
254
+ self.console.print(renderable)
255
+
256
+ def _should_use_textual_browser(self) -> bool:
257
+ """Return True when Textual-based navigation can be used."""
258
+ ctx_obj = getattr(self.session.ctx, "obj", {})
259
+ interactive = bool(getattr(self.session, "_interactive", False))
260
+ if not interactive and isinstance(ctx_obj, dict) and ctx_obj.get("tty"):
261
+ interactive = True
262
+ if not interactive:
263
+ return False
264
+ try:
265
+ stdin_tty = sys.stdin.isatty()
266
+ stdout_tty = sys.stdout.isatty()
267
+ except Exception:
268
+ return False
269
+ return bool(stdin_tty and stdout_tty)
270
+
271
+ def _run_textual_browser(
272
+ self,
273
+ client: Any,
274
+ agent_id: str,
275
+ runs_page: Any,
276
+ state: dict[str, Any],
277
+ *,
278
+ agent_name: str | None = None,
279
+ ) -> None:
280
+ """Launch the Textual UI for browsing runs."""
281
+
282
+ def fetch_page(page: int, limit: int) -> Any | None:
283
+ fetch_state = {"page": page, "limit": limit}
284
+ return self._fetch_remote_runs_page(client, agent_id, fetch_state)
285
+
286
+ def fetch_detail(run_id: str) -> Any | None:
287
+ return self._load_run_detail(client, agent_id, run_id)
288
+
289
+ def export_run(run_id: str, detail: Any | None) -> bool:
290
+ return self.export_remote_run(agent_id, run_id, client, detail)
291
+
292
+ callbacks = RemoteRunsTUICallbacks(
293
+ fetch_page=fetch_page,
294
+ fetch_detail=fetch_detail,
295
+ export_run=export_run,
296
+ )
297
+ tui_ctx = getattr(self.session, "tui_ctx", None)
298
+ page, limit, cursor = run_remote_runs_textual(
299
+ runs_page,
300
+ state.get("cursor", 0),
301
+ callbacks,
302
+ agent_name=agent_name,
303
+ agent_id=agent_id,
304
+ ctx=tui_ctx,
305
+ )
306
+ state["page"] = page
307
+ state["limit"] = limit
308
+ state["cursor"] = cursor
309
+
310
+ def _load_run_detail(self, client: Any, agent_id: str, run_id: str) -> Any | None:
311
+ """Return detailed run payload, handling errors."""
312
+ try:
313
+ return client.agents.runs.get_run(agent_id, run_id)
314
+ except AuthenticationError:
315
+ self.console.print(f"[{ERROR_STYLE}]Authentication failed while loading run detail.[/]")
316
+ except NotFoundError:
317
+ self.console.print(f"[{WARNING_STYLE}]Run no longer exists. It may have been cleaned up.[/]")
318
+ except TimeoutError:
319
+ self.console.print(f"[{WARNING_STYLE}]Fetching remote transcript timed out. Try again.[/]")
320
+ except Exception as exc: # pragma: no cover - unexpected API failure
321
+ self.console.print(f"[{ERROR_STYLE}]Error fetching run detail: {exc}[/]")
322
+ return None
323
+
324
+ def show_run_detail(self, agent_id: str, run_id: str) -> Any | None:
325
+ """Show detailed run information with SSE events.
326
+
327
+ Args:
328
+ agent_id: UUID of the agent.
329
+ run_id: UUID of the run.
330
+
331
+ Returns:
332
+ RunWithOutput instance or None on error.
333
+ """
334
+ client = self._get_client_or_fail()
335
+ if not client:
336
+ return None
337
+
338
+ run_detail = self._load_run_detail(client, agent_id, run_id)
339
+ if run_detail is None:
340
+ return None
341
+
342
+ self.console.print()
343
+ render_remote_sse_transcript(run_detail, self.console, show_metadata=True)
344
+ return run_detail
345
+
346
+ def export_remote_run(
347
+ self,
348
+ agent_id: str,
349
+ run_id: str,
350
+ client: Any,
351
+ detail: Any | None,
352
+ ) -> bool:
353
+ """Export the selected remote run to JSONL.
354
+
355
+ Args:
356
+ agent_id: UUID of the agent.
357
+ run_id: UUID of the run.
358
+ client: API client instance.
359
+ detail: Cached RunWithOutput instance or None.
360
+ """
361
+ run_detail = detail or self._load_run_detail(client, agent_id, run_id)
362
+ if run_detail is None:
363
+ return False
364
+
365
+ destination = self._prompt_remote_export_path(run_id)
366
+ if destination is None:
367
+ self.console.print("[dim]Export cancelled.[/]")
368
+ return False
369
+
370
+ overwrite = False
371
+ if destination.exists():
372
+ if not click.confirm(f"{destination} already exists. Overwrite?", default=False):
373
+ self.console.print("[dim]Export cancelled.[/]")
374
+ return False
375
+ overwrite = True
376
+
377
+ try:
378
+ agent_label = self._resolve_agent_name()
379
+ exported = export_remote_transcript_jsonl(
380
+ run_detail,
381
+ destination,
382
+ overwrite=overwrite,
383
+ agent_name=agent_label,
384
+ )
385
+ self.console.print(f"[{SUCCESS_STYLE}]Remote transcript exported to {exported}[/]")
386
+ return True
387
+ except FileExistsError:
388
+ self.console.print(f"[{WARNING_STYLE}]File already exists and overwrite was disabled: {destination}[/]")
389
+ except Exception as exc: # pragma: no cover - unexpected IO failures
390
+ self.console.print(f"[{ERROR_STYLE}]Failed to export transcript: {exc}[/]")
391
+ return False
392
+
393
+ def _resolve_agent_name(self) -> str | None:
394
+ """Return the friendly agent name for the active session if available."""
395
+ current_agent = getattr(self.session, "_current_agent", None)
396
+ if current_agent is None:
397
+ return None
398
+ return getattr(current_agent, "name", None) or getattr(current_agent, "display_name", None)
399
+
400
+ def _prompt_remote_export_path(self, run_id: str) -> Path | None:
401
+ """Prompt the operator for an export destination.
402
+
403
+ Args:
404
+ run_id: UUID of the run.
405
+
406
+ Returns:
407
+ Path object or None if cancelled.
408
+ """
409
+ # Default to current working directory for exports (user can override via prompt).
410
+ # This is safe as the user explicitly initiates the export operation.
411
+ default_path = Path.cwd() / f"run_{run_id}.jsonl" # noqa: S108
412
+ try:
413
+ result = self._handle_questionary_export_prompt(default_path)
414
+ if result is not None:
415
+ return result
416
+ return None
417
+ except RuntimeError:
418
+ pass
419
+
420
+ is_terminal = bool(getattr(self.console, "is_terminal", False))
421
+ if not is_terminal:
422
+ return default_path
423
+
424
+ return self._prompt_cli_export_choice(default_path)
425
+
426
+ def _handle_questionary_export_prompt(self, default_path: Path) -> Path | None:
427
+ """Handle questionary-based export prompt with error handling.
428
+
429
+ Args:
430
+ default_path: Default export path.
431
+
432
+ Returns:
433
+ Selected path or None if cancelled.
434
+
435
+ Raises:
436
+ RuntimeError: If questionary prompt is unavailable or fails.
437
+ """
438
+ selection = self._prompt_questionary_export_choice(default_path)
439
+ if selection is None:
440
+ return None
441
+
442
+ choice, _ = selection
443
+ if choice == "default":
444
+ return default_path
445
+
446
+ if choice == "custom":
447
+ return self._prompt_questionary_custom_destination(default_path)
448
+
449
+ # choice == "cancel" or any other value
450
+ return None
451
+
452
+ def _prompt_questionary_export_choice(self, default_path: Path) -> tuple[str, Path | None] | None:
453
+ """Render the questionary export menu and return the selected action."""
454
+ display_path = self._format_export_display_path(default_path)
455
+ result = prompt_export_choice_questionary(default_path, display_path)
456
+ if result is None:
457
+ raise RuntimeError("Questionary prompt unavailable")
458
+ return result
459
+
460
+ def _prompt_questionary_custom_destination(self, default_path: Path) -> Path | None:
461
+ """Prompt for a custom destination using questionary path picker."""
462
+ if questionary is None:
463
+ raise RuntimeError("Questionary prompt unavailable")
464
+
465
+ try:
466
+ prompt = questionary.path(
467
+ "Destination path (Tab to autocomplete):",
468
+ default="",
469
+ only_directories=False,
470
+ )
471
+ response = questionary_safe_ask(prompt)
472
+ except Exception as exc: # pragma: no cover - questionary failure
473
+ raise RuntimeError("Questionary path prompt failed") from exc
474
+
475
+ return self._resolve_export_path(response, default_path, allow_default=False)
476
+
477
+ def _prompt_cli_export_choice(self, default_path: Path) -> Path | None:
478
+ """Render a click-based export menu when questionary isn't available."""
479
+ display_path = self._format_export_display_path(default_path)
480
+ self.console.print()
481
+ self.console.print("Remote export options:")
482
+ self.console.print(f" 1. Save to default ({display_path})")
483
+ self.console.print(" 2. Choose a different path")
484
+ self.console.print(" 3. Cancel")
485
+ try:
486
+ selection = click.prompt(
487
+ "Select an option",
488
+ type=click.Choice(["1", "2", "3"]),
489
+ default="1",
490
+ show_choices=False,
491
+ )
492
+ except (click.Abort, EOFError, KeyboardInterrupt):
493
+ return None
494
+
495
+ if selection == "1":
496
+ return default_path
497
+ if selection == "2":
498
+ return self._prompt_click_export_path(default_path)
499
+ return None
500
+
501
+ def _prompt_click_export_path(self, default_path: Path) -> Path | None:
502
+ """Prompt for a custom export destination using click only."""
503
+ default_ref = self._format_export_display_path(default_path)
504
+ self.console.print(
505
+ f"[dim]Enter a custom destination path. Leave blank to cancel. Default reference: {default_ref}[/]"
506
+ )
507
+ try:
508
+ response = click.prompt(
509
+ "Custom path",
510
+ default="",
511
+ show_default=False,
512
+ )
513
+ except (click.Abort, EOFError, KeyboardInterrupt):
514
+ return None
515
+
516
+ return self._resolve_export_path(response, default_path, allow_default=False)
517
+
518
+ def _resolve_export_path(self, response: str | None, default_path: Path, *, allow_default: bool) -> Path | None:
519
+ """Normalise export path input into a Path instance."""
520
+ value = (response or "").strip()
521
+ if not value:
522
+ return default_path if allow_default else None
523
+
524
+ candidate = Path(value).expanduser()
525
+ if not candidate.is_absolute():
526
+ # Resolve relative paths against current working directory.
527
+ # This is safe as the user explicitly provided this path via prompt.
528
+ candidate = Path.cwd() / candidate # noqa: S108
529
+ return candidate
530
+
531
+ def _format_export_display_path(self, path: Path) -> str:
532
+ """Return a user-friendly string for default export paths."""
533
+ cwd = Path.cwd()
534
+ try:
535
+ relative = path.relative_to(cwd)
536
+ return str(Path(".") / relative)
537
+ except ValueError:
538
+ pass
539
+
540
+ home = Path.home()
541
+ try:
542
+ relative_home = path.relative_to(home)
543
+ suffix = f"/{relative_home}" if relative_home.parts else ""
544
+ return f"~{suffix}"
545
+ except ValueError:
546
+ pass
547
+
548
+ return str(path)
549
+
550
+ def _get_client_or_fail(self) -> Any:
551
+ """Get client or handle failure and return None.
552
+
553
+ Returns:
554
+ API client instance or None on error.
555
+ """
556
+ try:
557
+ return self.session._get_client()
558
+ except click.ClickException as exc:
559
+ self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
560
+ return None
561
+
562
+ def _continue_session(self) -> bool:
563
+ """Signal that the slash session should remain active.
564
+
565
+ Returns:
566
+ True to continue session.
567
+ """
568
+ return not getattr(self.session, "_should_exit", False)