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