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