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,760 @@
1
+ """Textual UI for the /runs command.
2
+
3
+ This module provides a lightweight Textual application that mirrors the remote
4
+ run browser experience using rich widgets (DataTable, modals, footer hints).
5
+
6
+ Authors:
7
+ Raymond Christopher (raymond.christopher@gdplabs.id)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from rich.text import Text
20
+
21
+ from textual.app import App, ComposeResult
22
+ from textual.binding import Binding
23
+ from textual.containers import Horizontal, Vertical
24
+ from textual.coordinate import Coordinate
25
+ from textual.reactive import ReactiveError
26
+ from textual.screen import ModalScreen
27
+ from textual.widgets import DataTable, Footer, Header, RichLog, Static
28
+
29
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
30
+ from glaip_sdk.cli.slash.tui.context import TUIContext
31
+ from glaip_sdk.cli.slash.tui.indicators import PulseIndicator
32
+ from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
33
+ from glaip_sdk.cli.slash.tui.toast import ClipboardToastMixin, Toast, ToastBus, ToastContainer, ToastHandlerMixin
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ RUNS_TABLE_ID = "runs"
38
+ RUNS_LOADING_ID = "runs-loading"
39
+ RUNS_TABLE_SELECTOR = f"#{RUNS_TABLE_ID}"
40
+ RUNS_LOADING_SELECTOR = f"#{RUNS_LOADING_ID}"
41
+
42
+
43
+ @dataclass
44
+ class RemoteRunsTUICallbacks:
45
+ """Callbacks invoked by the Textual UI for data operations."""
46
+
47
+ fetch_page: Callable[[int, int], Any | None]
48
+ fetch_detail: Callable[[str], Any | None]
49
+ export_run: Callable[[str, Any | None], bool]
50
+
51
+
52
+ def run_remote_runs_textual(
53
+ initial_page: Any,
54
+ cursor_idx: int,
55
+ callbacks: RemoteRunsTUICallbacks,
56
+ *,
57
+ agent_name: str | None = None,
58
+ agent_id: str | None = None,
59
+ ctx: TUIContext | None = None,
60
+ ) -> tuple[int, int, int]:
61
+ """Launch the Textual application and return the final pagination state.
62
+
63
+ Args:
64
+ initial_page: RunsPage instance loaded before launching the UI.
65
+ cursor_idx: Previously selected row index.
66
+ callbacks: Data provider callback bundle.
67
+ agent_name: Optional agent name for display purposes.
68
+ agent_id: Optional agent ID for display purposes.
69
+ ctx: Shared TUI context.
70
+
71
+ Returns:
72
+ Tuple of (page, limit, cursor_index) after the UI exits.
73
+ """
74
+ app = RemoteRunsTextualApp(
75
+ initial_page,
76
+ cursor_idx,
77
+ callbacks,
78
+ agent_name=agent_name,
79
+ agent_id=agent_id,
80
+ ctx=ctx,
81
+ )
82
+ app.run()
83
+ current_page = getattr(app, "current_page", initial_page)
84
+ return current_page.page, current_page.limit, app.cursor_index
85
+
86
+
87
+ class RunDetailScreen(ToastHandlerMixin, ClipboardToastMixin, ModalScreen[None]):
88
+ """Modal screen displaying run metadata and output timeline."""
89
+
90
+ CSS = """
91
+ Screen { layout: vertical; layers: base toasts; }
92
+ #toast-container {
93
+ width: 100%;
94
+ height: auto;
95
+ dock: top;
96
+ align: right top;
97
+ layer: toasts;
98
+ }
99
+ """
100
+
101
+ BINDINGS = [
102
+ Binding("escape", "dismiss", "Close", priority=True),
103
+ Binding("q", "dismiss_modal", "Close", priority=True),
104
+ Binding("up", "scroll_up", "Up"),
105
+ Binding("down", "scroll_down", "Down"),
106
+ Binding("pageup", "page_up", "PgUp"),
107
+ Binding("pagedown", "page_down", "PgDn"),
108
+ Binding("c", "copy_run_id", "Copy ID"),
109
+ Binding("C", "copy_detail_json", "Copy JSON"),
110
+ Binding("e", "export_detail", "Export"),
111
+ ]
112
+
113
+ def __init__(
114
+ self,
115
+ detail: Any,
116
+ on_export: Callable[[Any], None] | None = None,
117
+ ctx: TUIContext | None = None,
118
+ ) -> None:
119
+ """Initialize the run detail screen."""
120
+ super().__init__()
121
+ self.detail = detail
122
+ self._on_export = on_export
123
+ self._ctx = ctx
124
+ self._clipboard: ClipboardAdapter | None = None
125
+ self._local_toasts: ToastBus | None = None
126
+
127
+ def compose(self) -> ComposeResult:
128
+ """Render metadata and events."""
129
+ meta_text = Text()
130
+
131
+ def add_meta(label: str, value: Any | None, value_style: str | None = None) -> None:
132
+ if value in (None, ""):
133
+ return
134
+ if len(meta_text) > 0:
135
+ meta_text.append("\n")
136
+ meta_text.append(f"{label}: ", style="bold cyan")
137
+ meta_text.append(str(value), style=value_style)
138
+
139
+ add_meta("Run ID", self.detail.id)
140
+ add_meta("Agent ID", getattr(self.detail, "agent_id", "-"))
141
+ add_meta("Type", getattr(self.detail, "run_type", "-"), "bold yellow")
142
+ status_value = getattr(self.detail, "status", "-")
143
+ add_meta("Status", status_value, self._status_style(status_value))
144
+ add_meta("Started", getattr(self.detail, "started_at", None))
145
+ add_meta("Completed", getattr(self.detail, "completed_at", None))
146
+ duration = self.detail.duration_formatted() if getattr(self.detail, "duration_formatted", None) else None
147
+ add_meta("Duration", duration, "bold")
148
+
149
+ main_content = Vertical(
150
+ Static(meta_text, id="detail-meta"),
151
+ RichLog(id="detail-events", wrap=False),
152
+ )
153
+ yield main_content
154
+ yield ToastContainer(Toast(), id="toast-container")
155
+ yield Footer()
156
+
157
+ def on_mount(self) -> None:
158
+ """Populate and focus the log."""
159
+ self._ensure_toast_bus()
160
+ log = self.query_one("#detail-events", RichLog)
161
+ log.can_focus = True
162
+ log.write(Text("Events", style="bold"))
163
+ for chunk in getattr(self.detail, "output", []):
164
+ event_type = chunk.get("event_type", "event")
165
+ status = chunk.get("status", "-")
166
+ timestamp = chunk.get("received_at") or "-"
167
+ header = Text()
168
+ header.append(timestamp, style="cyan")
169
+ header.append(" ")
170
+ header.append(event_type, style=self._event_type_style(event_type))
171
+ header.append(" ")
172
+ header.append("[")
173
+ header.append(status, style=self._status_style(status))
174
+ header.append("]")
175
+ log.write(header)
176
+
177
+ payload = Text(json.dumps(chunk, indent=2, ensure_ascii=False), style="dim")
178
+ log.write(payload)
179
+ log.write(Text(""))
180
+ log.focus()
181
+
182
+ def _log(self) -> RichLog:
183
+ return self.query_one("#detail-events", RichLog)
184
+
185
+ def action_copy_run_id(self) -> None:
186
+ """Copy the run id to the clipboard."""
187
+ run_id = getattr(self.detail, "id", None)
188
+ if not run_id:
189
+ self._announce_status("Run ID unavailable.")
190
+ return
191
+ self._copy_to_clipboard(str(run_id), label="Run ID")
192
+
193
+ def action_copy_detail_json(self) -> None:
194
+ """Copy the run detail JSON to the clipboard."""
195
+ payload = self._detail_json_payload()
196
+ if payload is None:
197
+ return
198
+ self._copy_to_clipboard(payload, label="Run JSON")
199
+
200
+ def _detail_json_payload(self) -> str | None:
201
+ detail = self.detail
202
+ if detail is None:
203
+ self._announce_status("Run detail unavailable.")
204
+ return None
205
+ if isinstance(detail, str):
206
+ return detail
207
+ if isinstance(detail, dict):
208
+ payload = detail
209
+ elif hasattr(detail, "model_dump"):
210
+ payload = detail.model_dump(mode="json")
211
+ elif hasattr(detail, "dict"):
212
+ payload = detail.dict()
213
+ else:
214
+ payload = getattr(detail, "__dict__", {"value": detail})
215
+ try:
216
+ return json.dumps(payload, indent=2, ensure_ascii=False, default=str)
217
+ except Exception as exc:
218
+ self._announce_status(f"Failed to serialize run detail: {exc}")
219
+ return None
220
+
221
+ def _append_copy_fallback(self, text: str) -> None:
222
+ try:
223
+ log = self._log()
224
+ except Exception:
225
+ self._announce_status(text)
226
+ return
227
+ log.write(Text(text))
228
+ log.write(Text(""))
229
+
230
+ def _ensure_toast_bus(self) -> None:
231
+ """Ensure toast bus is initialized and connected to message handler."""
232
+ if self._local_toasts is not None:
233
+ return # pragma: no cover - early return when already initialized
234
+
235
+ def _notify(m: ToastBus.Changed) -> None:
236
+ self.post_message(m)
237
+
238
+ self._local_toasts = ToastBus(on_change=_notify)
239
+
240
+ @staticmethod
241
+ def _status_style(status: str | None) -> str:
242
+ """Return a Rich style name for the status pill."""
243
+ if not status:
244
+ return "dim"
245
+ normalized = str(status).lower()
246
+ if normalized in {"success", "succeeded", "completed", "ok"}:
247
+ return "green"
248
+ if normalized in {"failed", "error", "errored", "cancelled"}:
249
+ return "red"
250
+ if normalized in {"running", "in_progress", "queued"}:
251
+ return "yellow"
252
+ return "cyan"
253
+
254
+ @staticmethod
255
+ def _event_type_style(event_type: str | None) -> str:
256
+ """Return a highlight color for the event type label."""
257
+ if not event_type:
258
+ return "white"
259
+ normalized = str(event_type).lower()
260
+ if "error" in normalized or "fail" in normalized:
261
+ return "red"
262
+ if "status" in normalized:
263
+ return "magenta"
264
+ if "tool" in normalized:
265
+ return "yellow"
266
+ if "stream" in normalized:
267
+ return "cyan"
268
+ return "green"
269
+
270
+ def action_dismiss_modal(self) -> None:
271
+ """Allow q binding to close the modal like Esc."""
272
+ self.dismiss(None)
273
+
274
+ def action_scroll_up(self) -> None:
275
+ """Scroll the log view up."""
276
+ self._log().action_scroll_up()
277
+
278
+ def action_scroll_down(self) -> None:
279
+ """Scroll the log view down."""
280
+ self._log().action_scroll_down()
281
+
282
+ def action_page_up(self) -> None:
283
+ """Scroll the log view up one page."""
284
+ self._log().action_page_up()
285
+
286
+ def action_page_down(self) -> None:
287
+ """Scroll the log view down one page."""
288
+ self._log().action_page_down()
289
+
290
+ def action_export_detail(self) -> None:
291
+ """Trigger export from the detail modal."""
292
+ if self._on_export is None:
293
+ self._announce_status("Export unavailable in this terminal mode.")
294
+ return
295
+ try:
296
+ self._on_export(self.detail)
297
+ except Exception as exc: # pragma: no cover - defensive
298
+ self._announce_status(f"Export failed: {exc}")
299
+
300
+ def _announce_status(self, message: str) -> None:
301
+ """Send status text to the parent app when available."""
302
+ try:
303
+ app = self.app
304
+ except AttributeError:
305
+ return
306
+ update_status = getattr(app, "_update_status", None)
307
+ if callable(update_status):
308
+ update_status(message, append=True)
309
+
310
+
311
+ class RemoteRunsTextualApp(ToastHandlerMixin, App[None]):
312
+ """Textual application for browsing remote runs."""
313
+
314
+ CSS = f"""
315
+ #toast-container {{
316
+ width: 100%;
317
+ height: auto;
318
+ dock: top;
319
+ align: right top;
320
+ layer: toasts;
321
+ }}
322
+ #{RUNS_LOADING_ID} {{
323
+ width: auto;
324
+ display: none;
325
+ }}
326
+ #status-bar {{
327
+ height: 3;
328
+ padding: 0 1;
329
+ }}
330
+ """
331
+
332
+ BINDINGS = [
333
+ Binding("q", "close_view", "Quit", priority=True),
334
+ Binding("escape", "close_view", "Quit", show=False, priority=True),
335
+ Binding("left", "page_left", "Prev page", priority=True),
336
+ Binding("right", "page_right", "Next page", priority=True),
337
+ Binding("enter", "open_detail", "Select Run", priority=True),
338
+ ]
339
+
340
+ def __init__(
341
+ self,
342
+ initial_page: Any,
343
+ cursor_idx: int,
344
+ callbacks: RemoteRunsTUICallbacks,
345
+ *,
346
+ agent_name: str | None = None,
347
+ agent_id: str | None = None,
348
+ ctx: TUIContext | None = None,
349
+ ):
350
+ """Initialize the remote runs Textual application.
351
+
352
+ Args:
353
+ initial_page: RunsPage instance to display initially.
354
+ cursor_idx: Initial cursor position in the table.
355
+ callbacks: Callback bundle for data operations.
356
+ agent_name: Optional agent name for display purposes.
357
+ agent_id: Optional agent ID for display purposes.
358
+ ctx: Shared TUI context.
359
+ """
360
+ super().__init__()
361
+ self.current_page = initial_page
362
+ self.cursor_index = max(0, min(cursor_idx, max(len(initial_page.data) - 1, 0)))
363
+ self.callbacks = callbacks
364
+ self.status_text = ""
365
+ self.current_rows = initial_page.data[:]
366
+ self.agent_name = (agent_name or "").strip()
367
+ self.agent_id = (agent_id or "").strip()
368
+ self._ctx = ctx
369
+ self._active_export_tasks: set[asyncio.Task[None]] = set()
370
+ self._page_loader_task: asyncio.Task[Any] | None = None
371
+ self._detail_loader_task: asyncio.Task[Any] | None = None
372
+ self._table_spinner_active = False
373
+
374
+ def compose(self) -> ComposeResult:
375
+ """Build layout."""
376
+ yield Header()
377
+ yield ToastContainer(Toast(), id="toast-container")
378
+ table = DataTable(id=RUNS_TABLE_ID) # pragma: no cover - mocked in tests
379
+ table.cursor_type = "row" # pragma: no cover - mocked in tests
380
+ table.add_columns( # pragma: no cover - mocked in tests
381
+ "Run UUID",
382
+ "Type",
383
+ "Status",
384
+ "Started (UTC)",
385
+ "Completed (UTC)",
386
+ "Duration",
387
+ "Input Preview",
388
+ )
389
+ yield table # pragma: no cover - interactive UI, tested via integration
390
+ yield Horizontal( # pragma: no cover - interactive UI, tested via integration
391
+ PulseIndicator(id=RUNS_LOADING_ID),
392
+ Static(id="status"),
393
+ id="status-bar",
394
+ )
395
+ yield Footer() # pragma: no cover - interactive UI, tested via integration
396
+
397
+ def _ensure_toast_bus(self) -> None:
398
+ if self._ctx is None or self._ctx.toasts is not None:
399
+ return
400
+
401
+ def _notify(m: ToastBus.Changed) -> None:
402
+ self.post_message(m)
403
+
404
+ self._ctx.toasts = ToastBus(on_change=_notify)
405
+
406
+ def on_mount(self) -> None:
407
+ """Render the initial page."""
408
+ self._ensure_toast_bus()
409
+ self._hide_loading()
410
+ self._render_page(self.current_page)
411
+
412
+ def _render_page(self, runs_page: Any) -> None:
413
+ """Populate table rows for a RunsPage."""
414
+ table = self.query_one(RUNS_TABLE_SELECTOR, DataTable)
415
+ table.clear()
416
+ self.current_rows = runs_page.data[:]
417
+ for run in self.current_rows:
418
+ table.add_row(
419
+ str(run.id),
420
+ str(run.run_type).title(),
421
+ str(run.status).upper(),
422
+ run.started_at.strftime("%Y-%m-%d %H:%M:%S") if run.started_at else "—",
423
+ run.completed_at.strftime("%Y-%m-%d %H:%M:%S") if run.completed_at else "—",
424
+ run.duration_formatted(),
425
+ run.input_preview(),
426
+ )
427
+ if self.current_rows:
428
+ self.cursor_index = max(0, min(self.cursor_index, len(self.current_rows) - 1))
429
+ table.focus()
430
+ table.cursor_coordinate = Coordinate(self.cursor_index, 0)
431
+ self.current_page = runs_page
432
+ total_pages = max(1, (runs_page.total + runs_page.limit - 1) // runs_page.limit)
433
+ agent_display = self.agent_name or "Runs"
434
+ header = f"{agent_display} • Page {runs_page.page}/{total_pages} • Page size={runs_page.limit}"
435
+ try:
436
+ self.sub_title = header
437
+ except ReactiveError:
438
+ # App not fully initialized (common in tests), skip setting sub_title
439
+ logger.debug("Cannot set sub_title: app not fully initialized")
440
+ self._clear_status()
441
+
442
+ def _agent_context_label(self) -> str:
443
+ """Return a descriptive label for the active agent."""
444
+ name = self.agent_name
445
+ identifier = self.agent_id
446
+ if name and identifier:
447
+ return f"Agent: {name} ({identifier})"
448
+ if name:
449
+ return f"Agent: {name}"
450
+ if identifier:
451
+ return f"Agent: {identifier}"
452
+ return "Agent runs"
453
+
454
+ def _update_status(self, message: str, *, append: bool = False) -> None:
455
+ """Update the footer status text."""
456
+ try:
457
+ static = self.query_one("#status", Static)
458
+ except (AttributeError, RuntimeError) as e:
459
+ # App not fully initialized (common in tests), just update status_text
460
+ logger.debug("Cannot update status widget: app not fully initialized (%s)", type(e).__name__)
461
+ if append:
462
+ self.status_text = f"{self.status_text}\n{message}"
463
+ else:
464
+ self.status_text = message
465
+ return
466
+ if append:
467
+ self.status_text = f"{self.status_text}\n{message}"
468
+ else:
469
+ self.status_text = message
470
+ static.update(self.status_text)
471
+
472
+ def _clear_status(self) -> None:
473
+ """Clear any status message."""
474
+ self.status_text = ""
475
+ try:
476
+ static = self.query_one("#status", Static)
477
+ static.update("")
478
+ except (AttributeError, RuntimeError) as e:
479
+ # App not fully initialized (common in tests), skip widget update
480
+ logger.debug("Cannot clear status widget: app not fully initialized (%s)", type(e).__name__)
481
+
482
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: # pragma: no cover - UI hook
483
+ """Track cursor position when DataTable selection changes."""
484
+ self.cursor_index = getattr(event, "cursor_row", self.cursor_index)
485
+
486
+ def _handle_table_click(self, row: int | None) -> None:
487
+ if row is None:
488
+ return
489
+ table = self.query_one(RUNS_TABLE_SELECTOR, DataTable)
490
+ self.cursor_index = row
491
+ try:
492
+ table.cursor_coordinate = Coordinate(row, 0)
493
+ except Exception:
494
+ return
495
+ self.action_open_detail()
496
+
497
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # pragma: no cover - UI hook
498
+ """Handle row selection event from DataTable."""
499
+ self._handle_table_click(getattr(event, "cursor_row", None))
500
+
501
+ def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: # pragma: no cover - UI hook
502
+ """Handle cell selection event from DataTable."""
503
+ row = getattr(event.coordinate, "row", None) if event.coordinate else None
504
+ self._handle_table_click(row)
505
+
506
+ def action_page_left(self) -> None:
507
+ """Navigate to the previous page."""
508
+ if not self.current_page.has_prev:
509
+ self._update_status("Already at the first page.", append=True)
510
+ return
511
+ target_page = max(1, self.current_page.page - 1)
512
+ self._queue_page_load(
513
+ target_page,
514
+ loading_message="Loading previous page…",
515
+ failure_message="Failed to load previous page.",
516
+ )
517
+
518
+ def action_page_right(self) -> None:
519
+ """Navigate to the next page."""
520
+ if not self.current_page.has_next:
521
+ self._update_status("This is the last page.", append=True)
522
+ return
523
+ target_page = self.current_page.page + 1
524
+ self._queue_page_load(
525
+ target_page,
526
+ loading_message="Loading next page…",
527
+ failure_message="Failed to load next page.",
528
+ )
529
+
530
+ def _selected_run(self) -> Any | None:
531
+ """Return the currently highlighted run."""
532
+ if not self.current_rows:
533
+ return None
534
+ if self.cursor_index < 0 or self.cursor_index >= len(self.current_rows):
535
+ return None
536
+ return self.current_rows[self.cursor_index]
537
+
538
+ def action_open_detail(self) -> None:
539
+ """Open detail modal for the selected run."""
540
+ run = self._selected_run()
541
+ if not run:
542
+ self._update_status("No run selected.", append=True)
543
+ return
544
+ if self._detail_loader_task and not self._detail_loader_task.done():
545
+ self._update_status("Already loading run detail. Please wait…", append=True)
546
+ return
547
+ run_id = str(run.id)
548
+ self._show_loading("Loading run detail…", table_spinner=False, footer_message=False)
549
+ self._queue_detail_load(run_id)
550
+
551
+ async def action_export_run(self) -> None:
552
+ """Export the selected run via callback."""
553
+ run = self._selected_run()
554
+ if not run:
555
+ self._update_status("No run selected.", append=True)
556
+ return
557
+ detail = self.callbacks.fetch_detail(str(run.id))
558
+ if detail is None:
559
+ self._update_status("Failed to load run detail for export.", append=True)
560
+ return
561
+ self._queue_export_job(str(run.id), detail)
562
+
563
+ def action_close_view(self) -> None:
564
+ """Handle quit bindings by closing detail views first, otherwise exiting."""
565
+ try:
566
+ if isinstance(self.screen, RunDetailScreen):
567
+ self.pop_screen()
568
+ self._clear_status()
569
+ return
570
+ except (AttributeError, RuntimeError) as e:
571
+ # App not fully initialized (common in tests), skip screen check
572
+ logger.debug("Cannot check screen state: app not fully initialized (%s)", type(e).__name__)
573
+ self.exit()
574
+
575
+ def _queue_page_load(self, target_page: int, *, loading_message: str, failure_message: str) -> None:
576
+ """Show a loading indicator and fetch a page after the next refresh."""
577
+ limit = self.current_page.limit
578
+ self._show_loading(loading_message, footer_message=False)
579
+
580
+ if self._page_loader_task and not self._page_loader_task.done():
581
+ self._update_status("Already loading a page. Please wait…", append=True)
582
+ return
583
+
584
+ loader_coro = self._load_page_async(target_page, limit, failure_message)
585
+ try:
586
+ task = asyncio.create_task(loader_coro, name="remote-runs-fetch")
587
+ except RuntimeError:
588
+ logger.debug("No running event loop; loading page synchronously.")
589
+ loader_coro.close()
590
+ self._load_page_sync(target_page, limit, failure_message)
591
+ return
592
+ except Exception:
593
+ loader_coro.close()
594
+ raise
595
+ task.add_done_callback(self._on_page_loader_done)
596
+ self._page_loader_task = task
597
+
598
+ def _queue_detail_load(self, run_id: str) -> None:
599
+ """Fetch run detail asynchronously with spinner feedback."""
600
+ loader_coro = self._load_detail_async(run_id)
601
+ try:
602
+ task = asyncio.create_task(loader_coro, name=f"remote-runs-detail-{run_id}")
603
+ except RuntimeError:
604
+ logger.debug("No running event loop; loading run detail synchronously.")
605
+ loader_coro.close()
606
+ self._load_detail_sync(run_id)
607
+ return
608
+ except Exception:
609
+ loader_coro.close()
610
+ raise
611
+ task.add_done_callback(self._on_detail_loader_done)
612
+ self._detail_loader_task = task
613
+
614
+ async def _load_page_async(self, page: int, limit: int, failure_message: str) -> None:
615
+ """Fetch the requested page in the background to keep the UI responsive."""
616
+ try:
617
+ new_page = await asyncio.to_thread(self.callbacks.fetch_page, page, limit)
618
+ except Exception as exc: # pragma: no cover - defensive logging for unexpected errors
619
+ logger.exception("Failed to fetch remote runs page %s: %s", page, exc)
620
+ new_page = None
621
+ finally:
622
+ self._hide_loading()
623
+
624
+ if new_page is None:
625
+ self._update_status(failure_message)
626
+ return
627
+ self._render_page(new_page)
628
+
629
+ def _load_page_sync(self, page: int, limit: int, failure_message: str) -> None:
630
+ """Fallback for fetching a page when asyncio isn't active (tests)."""
631
+ try:
632
+ new_page = self.callbacks.fetch_page(page, limit)
633
+ except Exception as exc: # pragma: no cover - defensive logging for unexpected errors
634
+ logger.exception("Failed to fetch remote runs page %s: %s", page, exc)
635
+ new_page = None
636
+ finally:
637
+ self._hide_loading()
638
+
639
+ if new_page is None:
640
+ self._update_status(failure_message)
641
+ return
642
+ self._render_page(new_page)
643
+
644
+ def _on_page_loader_done(self, task: asyncio.Task[Any]) -> None:
645
+ """Reset loader state and surface unexpected failures."""
646
+ self._page_loader_task = None
647
+ if task.cancelled():
648
+ return
649
+ exc = task.exception()
650
+ if exc:
651
+ logger.debug("Page loader encountered an error: %s", exc)
652
+
653
+ def _on_detail_loader_done(self, task: asyncio.Task[Any]) -> None:
654
+ """Reset state for the detail fetch task."""
655
+ self._detail_loader_task = None
656
+ if task.cancelled():
657
+ return
658
+ exc = task.exception()
659
+ if exc:
660
+ logger.debug("Detail loader encountered an error: %s", exc)
661
+
662
+ async def _load_detail_async(self, run_id: str) -> None:
663
+ """Retrieve run detail via background thread."""
664
+ try:
665
+ detail = await asyncio.to_thread(self.callbacks.fetch_detail, run_id)
666
+ except Exception as exc: # pragma: no cover - defensive logging for unexpected errors
667
+ logger.exception("Failed to load run detail %s: %s", run_id, exc)
668
+ detail = None
669
+ finally:
670
+ self._hide_loading()
671
+ self._present_run_detail(detail)
672
+
673
+ def _load_detail_sync(self, run_id: str) -> None:
674
+ """Synchronous fallback for fetching run detail."""
675
+ try:
676
+ detail = self.callbacks.fetch_detail(run_id)
677
+ except Exception as exc: # pragma: no cover - defensive logging for unexpected errors
678
+ logger.exception("Failed to load run detail %s: %s", run_id, exc)
679
+ detail = None
680
+ finally:
681
+ self._hide_loading()
682
+ self._present_run_detail(detail)
683
+
684
+ def _present_run_detail(self, detail: Any | None) -> None:
685
+ """Push the detail modal or surface an error."""
686
+ if detail is None:
687
+ self._update_status("Failed to load run detail.", append=True)
688
+ return
689
+ self.push_screen(RunDetailScreen(detail, on_export=self.queue_export_from_detail, ctx=self._ctx))
690
+ self._update_status("Detail view: ↑/↓ scroll · PgUp/PgDn · q/Esc close · c copy ID · C copy JSON · e export")
691
+
692
+ def queue_export_from_detail(self, detail: Any) -> None:
693
+ """Start an export from the detail modal."""
694
+ run_id = getattr(detail, "id", None)
695
+ if not run_id:
696
+ self._update_status("Cannot export run without an identifier.", append=True)
697
+ return
698
+ self._queue_export_job(str(run_id), detail)
699
+
700
+ def _queue_export_job(self, run_id: str, detail: Any) -> None:
701
+ """Schedule the export coroutine so it can suspend cleanly."""
702
+
703
+ async def runner() -> None:
704
+ await self._perform_export(run_id, detail)
705
+
706
+ try:
707
+ self.run_worker(runner(), name="export-run", exclusive=True)
708
+ except Exception:
709
+ # Store task to prevent premature garbage collection
710
+ export_task = asyncio.create_task(runner())
711
+ # Keep reference to prevent GC (task will complete on its own)
712
+ self._active_export_tasks.add(export_task)
713
+ export_task.add_done_callback(self._active_export_tasks.discard)
714
+
715
+ async def _perform_export(self, run_id: str, detail: Any) -> None:
716
+ """Execute the export callback with suspend mode."""
717
+ try:
718
+ with self.suspend():
719
+ success = bool(self.callbacks.export_run(run_id, detail))
720
+ except Exception as exc: # pragma: no cover - defensive
721
+ logger.exception("Export failed: %s", exc)
722
+ self._update_status(f"Export failed: {exc}", append=True)
723
+ return
724
+
725
+ if success:
726
+ self._update_status("Export complete (see slash console for path).", append=True)
727
+ else:
728
+ self._update_status("Export cancelled.", append=True)
729
+
730
+ def _show_loading(
731
+ self,
732
+ message: str | None = None,
733
+ *,
734
+ table_spinner: bool = True,
735
+ footer_message: bool = True,
736
+ ) -> None:
737
+ """Display the loading indicator with an optional status message."""
738
+ show_loading_indicator(
739
+ self,
740
+ RUNS_LOADING_SELECTOR,
741
+ message=message,
742
+ set_status=self._update_status if footer_message else None,
743
+ )
744
+ self._set_table_loading(table_spinner)
745
+ self._table_spinner_active = table_spinner
746
+
747
+ def _hide_loading(self) -> None:
748
+ """Hide the loading indicator."""
749
+ hide_loading_indicator(self, RUNS_LOADING_SELECTOR)
750
+ if self._table_spinner_active:
751
+ self._set_table_loading(False)
752
+ self._table_spinner_active = False
753
+
754
+ def _set_table_loading(self, is_loading: bool) -> None:
755
+ """Toggle the DataTable loading shimmer."""
756
+ try:
757
+ table = self.query_one(RUNS_TABLE_SELECTOR, DataTable)
758
+ table.loading = is_loading
759
+ except (AttributeError, RuntimeError) as e:
760
+ logger.debug("Cannot toggle table loading state: %s", type(e).__name__)