glaip-sdk 0.7.27__py3-none-any.whl → 0.7.29__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.
@@ -1,7 +1,7 @@
1
1
  """Textual UI for the /runs command.
2
2
 
3
- This module provides a lightweight Textual application that mirrors the remote
4
- run browser experience using rich widgets (DataTable, modals, footer hints).
3
+ This module provides a Harlequin layout screen for browsing remote runs with
4
+ an always-visible detail pane and transcript panel.
5
5
 
6
6
  Authors:
7
7
  Raymond Christopher (raymond.christopher@gdplabs.id)
@@ -14,30 +14,43 @@ import json
14
14
  import logging
15
15
  from collections.abc import Callable
16
16
  from dataclasses import dataclass
17
- from typing import Any
17
+ from datetime import UTC, datetime
18
+ from typing import Any, cast
18
19
 
20
+ from rich.syntax import Syntax
21
+ from rich.table import Table
19
22
  from rich.text import Text
20
-
21
- from textual.app import App, ComposeResult
23
+ from textual import events
24
+ from textual._context import NoActiveAppError
25
+ from textual.app import App, ComposeResult, ScreenStackError
22
26
  from textual.binding import Binding
23
- from textual.containers import Horizontal, Vertical
27
+ from textual.containers import Horizontal
24
28
  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
29
+ from textual.screen import Screen
30
+ from textual.widgets import DataTable, Footer, Input, RichLog, Static, TextArea
28
31
 
32
+ from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
29
33
  from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
30
34
  from glaip_sdk.cli.slash.tui.context import TUIContext
31
35
  from glaip_sdk.cli.slash.tui.indicators import PulseIndicator
36
+ from glaip_sdk.cli.slash.tui.layouts.harlequin import HarlequinScreen
32
37
  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
38
+ from glaip_sdk.cli.slash.tui.toast import ClipboardToastMixin, ToastBus, ToastHandlerMixin
34
39
 
35
40
  logger = logging.getLogger(__name__)
36
41
 
37
- RUNS_TABLE_ID = "runs"
42
+ RUNS_TABLE_ID = "runs-table"
43
+ RUNS_FILTER_ID = "runs-filter"
38
44
  RUNS_LOADING_ID = "runs-loading"
39
- RUNS_TABLE_SELECTOR = f"#{RUNS_TABLE_ID}"
45
+ RUNS_STATUS_ID = "runs-status"
46
+ RUNS_DETAIL_META_ID = "runs-detail-meta"
47
+ RUNS_DETAIL_INPUT_ID = "runs-detail-input"
48
+ RUNS_DETAIL_TRANSCRIPT_ID = "runs-detail-transcript"
40
49
  RUNS_LOADING_SELECTOR = f"#{RUNS_LOADING_ID}"
50
+ RUNS_SELECT_FIRST_MESSAGE = "Select a run first."
51
+ RUNS_DETAIL_LOG_MESSAGE = "Failed to load run detail %s: %s"
52
+ RUNS_RUN_JSON_LABEL = "Run JSON"
53
+ TRANSCRIPT_ASYNC_THRESHOLD = 50
41
54
 
42
55
 
43
56
  @dataclass
@@ -71,7 +84,7 @@ def run_remote_runs_textual(
71
84
  Returns:
72
85
  Tuple of (page, limit, cursor_index) after the UI exits.
73
86
  """
74
- app = RemoteRunsTextualApp(
87
+ app = _HarlequinRunsApp(
75
88
  initial_page,
76
89
  cursor_idx,
77
90
  callbacks,
@@ -79,263 +92,129 @@ def run_remote_runs_textual(
79
92
  agent_id=agent_id,
80
93
  ctx=ctx,
81
94
  )
82
- app.run()
83
- current_page = getattr(app, "current_page", initial_page)
84
- return current_page.page, current_page.limit, app.cursor_index
95
+ # Enable mouse capture for scrolling within the detail panes.
96
+ app.run(mouse=True)
97
+ try:
98
+ screen = app.screen
99
+ except ScreenStackError:
100
+ screen = None
101
+ if isinstance(screen, RunsHarlequinScreen):
102
+ current_page = screen.current_page
103
+ cursor_index = screen.cursor_index
104
+ limit = getattr(screen, "_limit", current_page.limit)
105
+ else:
106
+ current_page = getattr(app, "current_page", None) or initial_page
107
+ cursor_index = getattr(app, "cursor_index", None)
108
+ if cursor_index is None:
109
+ cursor_index = cursor_idx
110
+ limit = current_page.limit
111
+ return current_page.page, limit, cursor_index
112
+
113
+
114
+ class RunsHarlequinScreen(ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, HarlequinScreen):
115
+ """Harlequin layout screen for browsing remote runs."""
85
116
 
117
+ BINDINGS = [
118
+ Binding("left", "page_left", "Prev page", priority=True),
119
+ Binding("right", "page_right", "Next page", priority=True),
120
+ Binding("pageup", "transcript_page_up", "Transcript Up", priority=True),
121
+ Binding("pagedown", "transcript_page_down", "Transcript Down", priority=True),
122
+ Binding("c", "copy_run_id", "Copy Run ID", priority=True, show=False),
123
+ Binding("i", "copy_input", "Copy Input", priority=True, show=False),
124
+ Binding("t", "copy_transcript", "Copy Transcript", priority=True, show=False),
125
+ Binding("C", "copy_detail_json", "Copy Run JSON", priority=True, show=False),
126
+ Binding("e", "export_run", "Export", priority=True),
127
+ Binding("/", "focus_filter", "Filter", priority=True),
128
+ Binding("q", "app_exit", "Quit", priority=True),
129
+ ]
86
130
 
87
- class RunDetailScreen(ToastHandlerMixin, ClipboardToastMixin, ModalScreen[None]):
88
- """Modal screen displaying run metadata and output timeline."""
131
+ @staticmethod
132
+ def _copy_hint(key: str) -> Text:
133
+ # Use bold yellow to make hints stand out without complex animation
134
+ return Text(f"(copy: {key})", style="bold yellow reverse")
89
135
 
90
136
  CSS = """
91
- Screen { layout: vertical; layers: base toasts; }
137
+ RunsHarlequinScreen {
138
+ layers: base toasts;
139
+ }
140
+
92
141
  #toast-container {
93
- width: 100%;
142
+ width: auto;
94
143
  height: auto;
95
144
  dock: top;
96
145
  align: right top;
97
146
  layer: toasts;
98
147
  }
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._clip_cache: 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
148
 
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"
149
+ #harlequin-container {
150
+ height: 100%;
151
+ }
269
152
 
270
- def action_dismiss_modal(self) -> None:
271
- """Allow q binding to close the modal like Esc."""
272
- self.dismiss(None)
153
+ #left-pane {
154
+ width: 50%;
155
+ min-width: 38;
156
+ padding: 1;
157
+ }
273
158
 
274
- def action_scroll_up(self) -> None:
275
- """Scroll the log view up."""
276
- self._log().action_scroll_up()
159
+ #right-pane {
160
+ width: 50%;
161
+ padding: 1;
162
+ }
277
163
 
278
- def action_scroll_down(self) -> None:
279
- """Scroll the log view down."""
280
- self._log().action_scroll_down()
164
+ .pane-title {
165
+ color: $warning;
166
+ text-style: bold;
167
+ padding-bottom: 1;
168
+ }
281
169
 
282
- def action_page_up(self) -> None:
283
- """Scroll the log view up one page."""
284
- self._log().action_page_up()
170
+ .section-title {
171
+ color: $warning;
172
+ text-style: bold;
173
+ padding-top: 1;
174
+ padding-bottom: 1;
175
+ }
285
176
 
286
- def action_page_down(self) -> None:
287
- """Scroll the log view down one page."""
288
- self._log().action_page_down()
177
+ .detail-block {
178
+ padding-bottom: 1;
179
+ }
289
180
 
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}")
181
+ .detail-input {
182
+ border: solid $secondary;
183
+ padding: 1;
184
+ margin-bottom: 1;
185
+ height: 8;
186
+ min-height: 3;
187
+ overflow-y: auto;
188
+ }
299
189
 
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)
190
+ .step-list {
191
+ border: solid $secondary;
192
+ padding: 1;
193
+ height: 1fr;
194
+ overflow-y: auto;
195
+ }
309
196
 
197
+ .status-bar {
198
+ height: 3;
199
+ align: left middle;
200
+ padding-top: 1;
201
+ margin-bottom: 1;
202
+ }
310
203
 
311
- class RemoteRunsTextualApp(ToastHandlerMixin, App[None]):
312
- """Textual application for browsing remote runs."""
204
+ .status-line {
205
+ color: $text-muted;
206
+ }
313
207
 
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 {{
208
+ .filter-input {
327
209
  height: 3;
328
- padding: 0 1;
329
- }}
330
- """
210
+ margin: 0 1;
211
+ border: solid $accent;
212
+ }
331
213
 
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
- ]
214
+ #runs-table {
215
+ height: 1fr;
216
+ }
217
+ """
339
218
 
340
219
  def __init__(
341
220
  self,
@@ -346,441 +225,836 @@ class RemoteRunsTextualApp(ToastHandlerMixin, App[None]):
346
225
  agent_name: str | None = None,
347
226
  agent_id: str | None = None,
348
227
  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__()
228
+ ) -> None:
229
+ """Initialize the Harlequin runs screen."""
230
+ super().__init__(ctx=ctx)
361
231
  self.current_page = initial_page
362
232
  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()
233
+ self._runs_callbacks = callbacks
234
+ self._agent_name = (agent_name or "").strip()
235
+ self._agent_id = (agent_id or "").strip()
368
236
  self._ctx = ctx
369
237
  self._clip_cache: ClipboardAdapter | None = None
370
- self._active_export_tasks: set[asyncio.Task[None]] = set()
238
+ self._local_toasts: ToastBus | None = None
239
+ self._detail_cache: dict[str, Any] = {}
371
240
  self._page_loader_task: asyncio.Task[Any] | None = None
372
- self._detail_loader_task: asyncio.Task[Any] | None = None
373
- self._table_spinner_active = False
374
-
375
- @property
376
- def clipboard(self) -> str:
377
- """Return clipboard text for Input paste actions."""
378
- if self._ctx is not None:
379
- adapter = self._ctx.clipboard
380
- if adapter is None:
381
- adapter = ClipboardAdapter(terminal=self._ctx.terminal)
382
- self._ctx.clipboard = adapter
383
- result = adapter.read()
384
- if result.success:
385
- return result.text
386
- if self._ctx is None and self._clip_cache is None:
387
- self._clip_cache = ClipboardAdapter(terminal=None)
388
- if self._clip_cache is not None:
389
- result = self._clip_cache.read()
390
- if result.success:
391
- return result.text
392
- return super().clipboard
393
-
394
- @clipboard.setter
395
- def clipboard(self, value: str) -> None:
396
- setter = App.clipboard.fset
397
- if setter is not None:
398
- setter(self, value)
241
+ self._detail_loader_task: asyncio.Task[None] | None = None
242
+ self._detail_loading_id: str | None = None
243
+ self._current_rows: list[Any] = initial_page.data[:]
244
+ self._visible_rows: list[Any] = []
245
+ self._filter_text = ""
246
+ self._limit = initial_page.limit
247
+ self._transcript_render_task: asyncio.Task[None] | None = None
248
+ self._transcript_render_token = 0
249
+ # Initial safe default, but we'll recalculate on mount/resize
250
+ self._preview_limit = 45
251
+ self._initial_load_complete = False
399
252
 
400
253
  def compose(self) -> ComposeResult:
401
- """Build layout."""
402
- yield Header()
403
- yield ToastContainer(Toast(), id="toast-container")
404
- table = DataTable(id=RUNS_TABLE_ID) # pragma: no cover - mocked in tests
405
- table.cursor_type = "row" # pragma: no cover - mocked in tests
406
- table.add_columns( # pragma: no cover - mocked in tests
407
- "Run UUID",
408
- "Type",
409
- "Status",
410
- "Started (UTC)",
411
- "Completed (UTC)",
412
- "Duration",
413
- "Input Preview",
254
+ """Compose the Harlequin layout and footer."""
255
+ yield from super().compose()
256
+ yield Footer()
257
+
258
+ def on_mount(self) -> None:
259
+ """Populate panes with run list and detail widgets."""
260
+ query_one = cast(Any, self).query_one
261
+ left_pane = query_one("#left-pane")
262
+ right_pane = query_one("#right-pane")
263
+
264
+ self._page_title = Static("Run History", classes="pane-title")
265
+ self._agent_label = Static(self._agent_context_label(), classes="section-title")
266
+ self._runs_table = DataTable(id=RUNS_TABLE_ID, cursor_type="row", zebra_stripes=True)
267
+ self._runs_table.add_column("St", width=4)
268
+ self._runs_table.add_column("Started (UTC)", width=13)
269
+ self._runs_table.add_column("Input")
270
+ self._filter_input = Input(placeholder="Filter input...", id=RUNS_FILTER_ID, classes="filter-input")
271
+ self._filter_input.display = False
272
+ self._loading_indicator = PulseIndicator(id=RUNS_LOADING_ID)
273
+ self._status_line = Static("", id=RUNS_STATUS_ID, classes="status-line")
274
+ self._status_bar = Horizontal(self._loading_indicator, self._status_line, classes="status-bar")
275
+
276
+ left_pane.mount(self._page_title)
277
+ left_pane.mount(self._agent_label)
278
+ left_pane.mount(self._runs_table)
279
+ left_pane.mount(self._filter_input)
280
+ left_pane.mount(self._status_bar)
281
+
282
+ self._detail_title = Static("Run Detail", classes="pane-title")
283
+ self._detail_meta = Static("", id=RUNS_DETAIL_META_ID, classes="detail-block")
284
+ self._input_title = Static(
285
+ Text.assemble("Input ", self._copy_hint("i")),
286
+ classes="section-title",
414
287
  )
415
- yield table # pragma: no cover - interactive UI, tested via integration
416
- yield Horizontal( # pragma: no cover - interactive UI, tested via integration
417
- PulseIndicator(id=RUNS_LOADING_ID),
418
- Static(id="status"),
419
- id="status-bar",
288
+ self._input_body = TextArea(
289
+ "",
290
+ id=RUNS_DETAIL_INPUT_ID,
291
+ read_only=True,
292
+ show_line_numbers=False,
293
+ classes="detail-input",
420
294
  )
421
- yield Footer() # pragma: no cover - interactive UI, tested via integration
422
-
423
- def _ensure_toast_bus(self) -> None:
424
- if self._ctx is None or self._ctx.toasts is not None:
425
- return
426
-
427
- def _notify(m: ToastBus.Changed) -> None:
428
- self.post_message(m)
295
+ self._input_body.can_focus = True
296
+ self._transcript_title = Static(
297
+ Text.assemble("Transcript ", self._copy_hint("t")),
298
+ classes="section-title",
299
+ )
300
+ self._transcript_log = RichLog(id=RUNS_DETAIL_TRANSCRIPT_ID, wrap=False, classes="step-list")
301
+ self._transcript_log.can_focus = True
429
302
 
430
- self._ctx.toasts = ToastBus(on_change=_notify)
303
+ right_pane.mount(self._detail_title)
304
+ right_pane.mount(self._detail_meta)
305
+ right_pane.mount(self._input_title)
306
+ right_pane.mount(self._input_body)
307
+ right_pane.mount(self._transcript_title)
308
+ right_pane.mount(self._transcript_log)
431
309
 
432
- def on_mount(self) -> None:
433
- """Render the initial page."""
434
310
  self._ensure_toast_bus()
435
- self._hide_loading()
436
- self._render_page(self.current_page)
437
-
438
- def _render_page(self, runs_page: Any) -> None:
439
- """Populate table rows for a RunsPage."""
440
- table = self.query_one(RUNS_TABLE_SELECTOR, DataTable)
441
- table.clear()
442
- self.current_rows = runs_page.data[:]
443
- for run in self.current_rows:
444
- table.add_row(
445
- str(run.id),
446
- str(run.run_type).title(),
447
- str(run.status).upper(),
448
- run.started_at.strftime("%Y-%m-%d %H:%M:%S") if run.started_at else "—",
449
- run.completed_at.strftime("%Y-%m-%d %H:%M:%S") if run.completed_at else "—",
450
- run.duration_formatted(),
451
- run.input_preview(),
452
- )
453
- if self.current_rows:
454
- self.cursor_index = max(0, min(self.cursor_index, len(self.current_rows) - 1))
455
- table.focus()
456
- table.cursor_coordinate = Coordinate(self.cursor_index, 0)
457
- self.current_page = runs_page
458
- total_pages = max(1, (runs_page.total + runs_page.limit - 1) // runs_page.limit)
459
- agent_display = self.agent_name or "Runs"
460
- header = f"{agent_display} • Page {runs_page.page}/{total_pages} • Page size={runs_page.limit}"
461
- try:
462
- self.sub_title = header
463
- except ReactiveError:
464
- # App not fully initialized (common in tests), skip setting sub_title
465
- logger.debug("Cannot set sub_title: app not fully initialized")
466
- self._clear_status()
467
-
468
- def _agent_context_label(self) -> str:
469
- """Return a descriptive label for the active agent."""
470
- name = self.agent_name
471
- identifier = self.agent_id
472
- if name and identifier:
473
- return f"Agent: {name} ({identifier})"
474
- if name:
475
- return f"Agent: {name}"
476
- if identifier:
477
- return f"Agent: {identifier}"
478
- return "Agent runs"
479
-
480
- def _update_status(self, message: str, *, append: bool = False) -> None:
481
- """Update the footer status text."""
482
311
  try:
483
- static = self.query_one("#status", Static)
484
- except (AttributeError, RuntimeError) as e:
485
- # App not fully initialized (common in tests), just update status_text
486
- logger.debug("Cannot update status widget: app not fully initialized (%s)", type(e).__name__)
487
- if append:
488
- self.status_text = f"{self.status_text}\n{message}"
489
- else:
490
- self.status_text = message
312
+ app = self.app
313
+ except NoActiveAppError:
314
+ app = None
315
+ if app is not None:
316
+ self._calculate_preview_limit(app.size.width)
317
+ self.call_after_refresh(self._check_dynamic_page_size)
318
+ self._render_page()
319
+
320
+ def on_resize(self, event: events.Resize) -> None:
321
+ """Recalculate layout limits on resize."""
322
+ self._calculate_preview_limit(event.size.width)
323
+ self._calculate_page_size(event.size.height)
324
+ self._render_page()
325
+
326
+ def _check_dynamic_page_size(self) -> None:
327
+ """Check if we need to reload with a better page size on startup."""
328
+ if self._initial_load_complete:
491
329
  return
492
- if append:
493
- self.status_text = f"{self.status_text}\n{message}"
494
- else:
495
- self.status_text = message
496
- static.update(self.status_text)
497
-
498
- def _clear_status(self) -> None:
499
- """Clear any status message."""
500
- self.status_text = ""
501
- try:
502
- static = self.query_one("#status", Static)
503
- static.update("")
504
- except (AttributeError, RuntimeError) as e:
505
- # App not fully initialized (common in tests), skip widget update
506
- logger.debug("Cannot clear status widget: app not fully initialized (%s)", type(e).__name__)
507
-
508
- def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: # pragma: no cover - UI hook
509
- """Track cursor position when DataTable selection changes."""
510
- self.cursor_index = getattr(event, "cursor_row", self.cursor_index)
511
-
512
- def _handle_table_click(self, row: int | None) -> None:
513
- if row is None:
330
+ self._initial_load_complete = True
331
+ app = getattr(self, "app", None)
332
+ if app:
333
+ self._calculate_page_size(app.size.height, reload_if_needed=True)
334
+
335
+ def _calculate_preview_limit(self, screen_width: int) -> None:
336
+ """Calculate input preview limit to avoid horizontal scrolling."""
337
+ if screen_width <= 0:
514
338
  return
515
- table = self.query_one(RUNS_TABLE_SELECTOR, DataTable)
516
- self.cursor_index = row
517
- try:
518
- table.cursor_coordinate = Coordinate(row, 0)
519
- except Exception:
339
+ # Left pane is 50%. Fixed cols use roughly 4+13+margins ~= 23 chars.
340
+ # We leave a buffer for borders and scrollbars (~4 chars).
341
+ # Calculation: (screen_width / 2) - 23 - 4
342
+ left_pane_width = screen_width // 2
343
+ available = left_pane_width - 27
344
+ # Ensure a reasonable minimum (e.g. 20 chars) and maximum (e.g. 150)
345
+ self._preview_limit = max(20, min(available, 150))
346
+
347
+ def _calculate_page_size(self, screen_height: int, reload_if_needed: bool = False) -> None:
348
+ """Calculate optimal page size based on height."""
349
+ if screen_height <= 0:
350
+ return
351
+ # Overhead: Title(2) + Agent(3) + Filter(0 or 3) + Status(3) + Header(1) + Borders/Pad(~2)
352
+ # Total overhead approx 11-14 lines.
353
+ overhead = 12
354
+ available_lines = max(1, screen_height - overhead)
355
+
356
+ if available_lines != self._limit:
357
+ self._limit = available_lines
358
+ if reload_if_needed and available_lines > self.current_page.limit:
359
+ self._queue_page_load(self.current_page.page, "Adjusting view...", "Failed to adjust view.")
360
+
361
+ def on_key(self, event: events.Key) -> None:
362
+ """Route left/right navigation to paging instead of table scroll."""
363
+ filter_input = getattr(self, "_filter_input", None)
364
+ if event.key == "left" and (filter_input is None or not filter_input.has_focus):
365
+ self.action_page_left()
366
+ event.stop()
367
+ if event.key == "right" and (filter_input is None or not filter_input.has_focus):
368
+ self.action_page_right()
369
+ event.stop()
370
+ if event.key == "escape" and filter_input is not None:
371
+ if filter_input.has_focus or filter_input.display:
372
+ self.action_clear_filter()
373
+ event.stop()
374
+
375
+ def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
376
+ """Let standard scrolling happen."""
377
+ pass
378
+
379
+ def on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
380
+ """Let standard scrolling happen."""
381
+ pass
382
+
383
+ def on_mouse_down(self, event: events.MouseDown) -> None:
384
+ """Let standard click focus happen."""
385
+ pass
386
+
387
+ def on_input_changed(self, event: Input.Changed) -> None:
388
+ """Filter rows as user types."""
389
+ if event.input.id != RUNS_FILTER_ID:
520
390
  return
521
- self.action_open_detail()
391
+ self._filter_text = event.value.strip().lower()
392
+ self._render_page()
522
393
 
523
- def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # pragma: no cover - UI hook
524
- """Handle row selection event from DataTable."""
525
- self._handle_table_click(getattr(event, "cursor_row", None))
394
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
395
+ """Update the detail pane when selection changes."""
396
+ if event.data_table.id != RUNS_TABLE_ID:
397
+ return
398
+ self.cursor_index = event.cursor_row
399
+ self._render_detail(event.cursor_row)
526
400
 
527
- def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: # pragma: no cover - UI hook
528
- """Handle cell selection event from DataTable."""
529
- row = getattr(event.coordinate, "row", None) if event.coordinate else None
530
- self._handle_table_click(row)
401
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
402
+ """Show detail when a row is selected."""
403
+ if event.data_table.id != RUNS_TABLE_ID:
404
+ return
405
+ self.cursor_index = event.cursor_row
406
+ self._render_detail(event.cursor_row)
531
407
 
532
408
  def action_page_left(self) -> None:
533
- """Navigate to the previous page."""
409
+ """Move to the previous page."""
534
410
  if not self.current_page.has_prev:
535
- self._update_status("Already at the first page.", append=True)
411
+ self._set_status("Already on the first page.")
536
412
  return
537
413
  target_page = max(1, self.current_page.page - 1)
538
- self._queue_page_load(
539
- target_page,
540
- loading_message="Loading previous page…",
541
- failure_message="Failed to load previous page.",
542
- )
414
+ self._queue_page_load(target_page, "Loading previous page...", "Failed to load previous page.")
543
415
 
544
416
  def action_page_right(self) -> None:
545
- """Navigate to the next page."""
417
+ """Move to the next page."""
546
418
  if not self.current_page.has_next:
547
- self._update_status("This is the last page.", append=True)
419
+ self._set_status("This is the last page.")
548
420
  return
549
421
  target_page = self.current_page.page + 1
550
- self._queue_page_load(
551
- target_page,
552
- loading_message="Loading next page…",
553
- failure_message="Failed to load next page.",
554
- )
422
+ self._queue_page_load(target_page, "Loading next page...", "Failed to load next page.")
555
423
 
556
- def _selected_run(self) -> Any | None:
557
- """Return the currently highlighted run."""
558
- if not self.current_rows:
559
- return None
560
- if self.cursor_index < 0 or self.cursor_index >= len(self.current_rows):
561
- return None
562
- return self.current_rows[self.cursor_index]
424
+ def action_transcript_page_up(self) -> None:
425
+ """Scroll the transcript up one page."""
426
+ self._transcript_log.action_page_up()
427
+
428
+ def action_transcript_page_down(self) -> None:
429
+ """Scroll the transcript down one page."""
430
+ self._transcript_log.action_page_down()
563
431
 
564
- def action_open_detail(self) -> None:
565
- """Open detail modal for the selected run."""
566
- run = self._selected_run()
567
- if not run:
568
- self._update_status("No run selected.", append=True)
432
+ def action_focus_filter(self) -> None:
433
+ """Focus the filter input."""
434
+ if not hasattr(self, "_filter_input"): # pragma: no cover - defensive
569
435
  return
570
- if self._detail_loader_task and not self._detail_loader_task.done():
571
- self._update_status("Already loading run detail. Please wait…", append=True)
436
+ self._filter_input.display = True
437
+ self._filter_input.focus()
438
+
439
+ def action_clear_filter(self) -> None:
440
+ """Clear filter and return focus to table."""
441
+ if not hasattr(self, "_filter_input"): # pragma: no cover - defensive
572
442
  return
573
- run_id = str(run.id)
574
- self._show_loading("Loading run detail…", table_spinner=False, footer_message=False)
575
- self._queue_detail_load(run_id)
443
+ if self._filter_input.has_focus or self._filter_input.display:
444
+ self._filter_input.value = ""
445
+ self._filter_text = ""
446
+ self._filter_input.display = False
447
+ if hasattr(self, "_runs_table"):
448
+ self._runs_table.focus()
449
+ self._render_page()
450
+
451
+ def action_app_exit(self) -> None:
452
+ """Exit the parent app."""
453
+ app = getattr(self, "app", None)
454
+ if app is not None:
455
+ app.exit()
576
456
 
577
- async def action_export_run(self) -> None:
578
- """Export the selected run via callback."""
579
- run = self._selected_run()
580
- if not run:
581
- self._update_status("No run selected.", append=True)
457
+ def action_copy_run_id(self) -> None:
458
+ """Copy the run id to the clipboard."""
459
+ run = self._current_run()
460
+ if run is None:
461
+ self._set_status(RUNS_SELECT_FIRST_MESSAGE)
462
+ return
463
+ self._copy_to_clipboard(str(run.id), label="Run ID")
464
+
465
+ def action_copy_input(self) -> None:
466
+ """Copy the full input to the clipboard."""
467
+ run = self._current_run()
468
+ if run is None:
469
+ self._set_status(RUNS_SELECT_FIRST_MESSAGE)
582
470
  return
583
- detail = self.callbacks.fetch_detail(str(run.id))
471
+ text = getattr(run, "input", "") or ""
472
+ if not text:
473
+ self._set_status("No input to copy.")
474
+ return
475
+ self._copy_to_clipboard(str(text), label="Input")
476
+
477
+ def action_copy_transcript(self) -> None:
478
+ """Copy the transcript to the clipboard."""
479
+ run = self._current_run()
480
+ if run is None:
481
+ self._set_status(RUNS_SELECT_FIRST_MESSAGE)
482
+ return
483
+ run_id = str(run.id)
484
+ detail = self._detail_cache.get(run_id)
584
485
  if detail is None:
585
- self._update_status("Failed to load run detail for export.", append=True)
486
+ self._set_status("Transcript not loaded.")
586
487
  return
587
- self._queue_export_job(str(run.id), detail)
588
488
 
589
- def action_close_view(self) -> None:
590
- """Handle quit bindings by closing detail views first, otherwise exiting."""
591
- try:
592
- if isinstance(self.screen, RunDetailScreen):
593
- self.pop_screen()
594
- self._clear_status()
595
- return
596
- except (AttributeError, RuntimeError) as e:
597
- # App not fully initialized (common in tests), skip screen check
598
- logger.debug("Cannot check screen state: app not fully initialized (%s)", type(e).__name__)
599
- self.exit()
600
-
601
- def _queue_page_load(self, target_page: int, *, loading_message: str, failure_message: str) -> None:
602
- """Show a loading indicator and fetch a page after the next refresh."""
603
- limit = self.current_page.limit
604
- self._show_loading(loading_message, footer_message=False)
489
+ output = getattr(detail, "output", []) or []
490
+ if not output:
491
+ self._set_status("No transcript to copy.")
492
+ return
605
493
 
606
- if self._page_loader_task and not self._page_loader_task.done():
607
- self._update_status("Already loading a page. Please wait…", append=True)
494
+ if self._should_async_transcript(output):
495
+ self._set_status("Preparing transcript...")
496
+ try:
497
+ self.track_task(self._copy_transcript_async(list(output)))
498
+ except RuntimeError:
499
+ payload = json.dumps(output, indent=2, ensure_ascii=False, default=str)
500
+ self._copy_to_clipboard(payload, label="Transcript")
608
501
  return
609
502
 
610
- loader_coro = self._load_page_async(target_page, limit, failure_message)
611
503
  try:
612
- task = asyncio.create_task(loader_coro, name="remote-runs-fetch")
613
- except RuntimeError:
614
- logger.debug("No running event loop; loading page synchronously.")
615
- loader_coro.close()
616
- self._load_page_sync(target_page, limit, failure_message)
504
+ payload = json.dumps(output, indent=2, ensure_ascii=False, default=str)
505
+ self._copy_to_clipboard(payload, label="Transcript")
506
+ except Exception as exc:
507
+ self._set_status(f"Failed to serialize transcript: {exc}")
508
+
509
+ def action_copy_detail_json(self) -> None:
510
+ """Copy the run detail JSON to the clipboard."""
511
+ run = self._current_run()
512
+ if run is None:
513
+ self._set_status(RUNS_SELECT_FIRST_MESSAGE)
514
+ return
515
+ detail = self._get_or_load_detail(str(run.id))
516
+ if detail is None:
517
+ self._set_status("Run detail unavailable.")
518
+ return
519
+ output = self._detail_output(detail)
520
+ if self._should_async_transcript(output):
521
+ self._set_status("Preparing run JSON...")
522
+ try:
523
+ self.track_task(self._copy_detail_json_async(detail))
524
+ except RuntimeError:
525
+ payload = self._detail_json_payload(detail)
526
+ if payload is None:
527
+ return
528
+ self._copy_to_clipboard(payload, label=RUNS_RUN_JSON_LABEL)
617
529
  return
618
- except Exception:
619
- loader_coro.close()
620
- raise
621
- task.add_done_callback(self._on_page_loader_done)
622
- self._page_loader_task = task
530
+ payload = self._detail_json_payload(detail)
531
+ if payload is None:
532
+ return
533
+ self._copy_to_clipboard(payload, label=RUNS_RUN_JSON_LABEL)
623
534
 
624
- def _queue_detail_load(self, run_id: str) -> None:
625
- """Fetch run detail asynchronously with spinner feedback."""
626
- loader_coro = self._load_detail_async(run_id)
535
+ def action_export_run(self) -> None:
536
+ """Export the selected run via callback."""
537
+ run = self._current_run()
538
+ if run is None:
539
+ self._set_status(RUNS_SELECT_FIRST_MESSAGE)
540
+ return
541
+ run_id = str(run.id)
542
+ detail = self._get_or_load_detail(run_id)
543
+ if detail is None:
544
+ self._set_status("Failed to load run detail for export.")
545
+ return
546
+ self._queue_export_job(run_id, detail)
547
+
548
+ def _render_page(self) -> None:
549
+ """Render the run list and refresh the detail pane."""
550
+ self._runs_table.clear()
551
+ self._current_rows = self.current_page.data[:]
552
+ self._visible_rows = [run for run in self._current_rows if self._matches_filter(run)]
553
+ for run in self._visible_rows:
554
+ self._runs_table.add_row(
555
+ Text(self._status_label(str(run.status)), style=self._status_style(str(run.status))),
556
+ self._format_started(getattr(run, "started_at", None)),
557
+ self._input_preview(run),
558
+ )
559
+
560
+ if self._visible_rows:
561
+ self.cursor_index = max(0, min(self.cursor_index, len(self._visible_rows) - 1))
562
+ self._runs_table.cursor_coordinate = Coordinate(self.cursor_index, 0)
563
+ if not self._filter_input.has_focus:
564
+ self._runs_table.focus()
565
+ self._render_detail(self.cursor_index)
566
+ else:
567
+ self._clear_detail()
568
+
569
+ total_pages = max(1, (self.current_page.total + self.current_page.limit - 1) // self.current_page.limit)
570
+ filter_suffix = f" | Filter: '{self._filter_text}'" if self._filter_text else ""
571
+ page_title = (
572
+ "Run History "
573
+ f"(Page {self.current_page.page}/{total_pages} | "
574
+ f"Page size={self.current_page.limit}{filter_suffix})"
575
+ )
576
+ self._page_title.update(page_title)
577
+ self._set_status("")
578
+
579
+ def _render_detail(self, row_index: int) -> None:
580
+ """Render the detail pane for the selected row."""
581
+ run = self._get_row(row_index)
582
+ if run is None:
583
+ self._clear_detail()
584
+ return
585
+ run_id = str(run.id)
586
+ detail = self._detail_cache.get(run_id)
587
+
588
+ detail_table = Table.grid(padding=(0, 1))
589
+ detail_table.add_column(style="bold cyan", no_wrap=True)
590
+ detail_table.add_column()
591
+ detail_table.add_row("Run ID", Text.assemble(run_id, " ", self._copy_hint("c")))
592
+ detail_table.add_row("Agent ID", str(getattr(run, "agent_id", "-")))
593
+ detail_table.add_row("Type", str(getattr(run, "run_type", "-")).title())
594
+ status_value = str(getattr(run, "status", "-")).upper()
595
+ detail_table.add_row("Status", Text(status_value, style=self._status_style(status_value)))
596
+ detail_table.add_row("Started (UTC)", self._format_timestamp(getattr(run, "started_at", None)))
597
+ detail_table.add_row("Completed (UTC)", self._format_timestamp(getattr(run, "completed_at", None)))
598
+ detail_table.add_row("Duration", self._format_duration(getattr(run, "duration_formatted", None)))
599
+ self._detail_meta.update(detail_table)
600
+ self._update_input_body(getattr(run, "input", None) or "-")
601
+
602
+ if detail is not None:
603
+ self._update_transcript(detail)
604
+ else:
605
+ self._update_transcript(None)
606
+ self._queue_detail_load(run_id)
607
+
608
+ def _clear_detail(self) -> None:
609
+ """Clear the detail pane when no rows are visible."""
610
+ self._detail_meta.update("No runs available.")
611
+ self._update_input_body("")
612
+ self._update_transcript(None)
613
+
614
+ def _update_input_body(self, text: str) -> None:
615
+ self._input_body.text = text or ""
616
+
617
+ def _queue_page_load(self, page: int, loading_message: str, failure_message: str) -> None:
618
+ if self._page_loader_task and not self._page_loader_task.done():
619
+ self._set_status("Already loading a page. Please wait.")
620
+ return
621
+ self._show_loading(loading_message, footer_message=False)
627
622
  try:
628
- task = asyncio.create_task(loader_coro, name=f"remote-runs-detail-{run_id}")
623
+ task = self.track_task(self._load_page_async(page, self._limit, failure_message))
624
+ self._page_loader_task = task
629
625
  except RuntimeError:
630
- logger.debug("No running event loop; loading run detail synchronously.")
631
- loader_coro.close()
632
- self._load_detail_sync(run_id)
633
- return
634
- except Exception:
635
- loader_coro.close()
636
- raise
637
- task.add_done_callback(self._on_detail_loader_done)
638
- self._detail_loader_task = task
626
+ self._load_page_sync(page, self._limit, failure_message)
639
627
 
640
628
  async def _load_page_async(self, page: int, limit: int, failure_message: str) -> None:
641
- """Fetch the requested page in the background to keep the UI responsive."""
642
629
  try:
643
- new_page = await asyncio.to_thread(self.callbacks.fetch_page, page, limit)
644
- except Exception as exc: # pragma: no cover - defensive logging for unexpected errors
645
- logger.exception("Failed to fetch remote runs page %s: %s", page, exc)
630
+ new_page = await asyncio.to_thread(self._runs_callbacks.fetch_page, page, limit)
631
+ except Exception as exc: # pragma: no cover - defensive
632
+ logger.exception("Failed to fetch runs page %s: %s", page, exc)
646
633
  new_page = None
647
634
  finally:
635
+ self._page_loader_task = None
648
636
  self._hide_loading()
649
637
 
650
638
  if new_page is None:
651
- self._update_status(failure_message)
639
+ self._set_status(failure_message)
652
640
  return
653
- self._render_page(new_page)
641
+ self.current_page = new_page
642
+ self._limit = new_page.limit
643
+ self.cursor_index = 0
644
+ self._render_page()
654
645
 
655
646
  def _load_page_sync(self, page: int, limit: int, failure_message: str) -> None:
656
- """Fallback for fetching a page when asyncio isn't active (tests)."""
657
647
  try:
658
- new_page = self.callbacks.fetch_page(page, limit)
659
- except Exception as exc: # pragma: no cover - defensive logging for unexpected errors
660
- logger.exception("Failed to fetch remote runs page %s: %s", page, exc)
648
+ new_page = self._runs_callbacks.fetch_page(page, limit)
649
+ except Exception as exc: # pragma: no cover - defensive
650
+ logger.exception("Failed to fetch runs page %s: %s", page, exc)
661
651
  new_page = None
662
652
  finally:
653
+ self._page_loader_task = None
663
654
  self._hide_loading()
664
655
 
665
656
  if new_page is None:
666
- self._update_status(failure_message)
667
- return
668
- self._render_page(new_page)
669
-
670
- def _on_page_loader_done(self, task: asyncio.Task[Any]) -> None:
671
- """Reset loader state and surface unexpected failures."""
672
- self._page_loader_task = None
673
- if task.cancelled():
657
+ self._set_status(failure_message)
674
658
  return
675
- exc = task.exception()
676
- if exc:
677
- logger.debug("Page loader encountered an error: %s", exc)
659
+ self.current_page = new_page
660
+ self._limit = new_page.limit
661
+ self.cursor_index = 0
662
+ self._render_page()
678
663
 
679
- def _on_detail_loader_done(self, task: asyncio.Task[Any]) -> None:
680
- """Reset state for the detail fetch task."""
681
- self._detail_loader_task = None
682
- if task.cancelled():
664
+ def _queue_detail_load(self, run_id: str) -> None:
665
+ if self._detail_loading_id == run_id and self._detail_loader_task and not self._detail_loader_task.done():
683
666
  return
684
- exc = task.exception()
685
- if exc:
686
- logger.debug("Detail loader encountered an error: %s", exc)
667
+ self._detail_loading_id = run_id
668
+ self._show_loading("Loading run detail...", footer_message=False)
669
+ try:
670
+ task = self.track_task(self._load_detail_async(run_id))
671
+ self._detail_loader_task = task
672
+ except RuntimeError:
673
+ self._load_detail_sync(run_id)
687
674
 
688
675
  async def _load_detail_async(self, run_id: str) -> None:
689
- """Retrieve run detail via background thread."""
690
676
  try:
691
- detail = await asyncio.to_thread(self.callbacks.fetch_detail, run_id)
692
- except Exception as exc: # pragma: no cover - defensive logging for unexpected errors
693
- logger.exception("Failed to load run detail %s: %s", run_id, exc)
677
+ detail = await asyncio.to_thread(self._runs_callbacks.fetch_detail, run_id)
678
+ except Exception as exc: # pragma: no cover - defensive
679
+ logger.exception(RUNS_DETAIL_LOG_MESSAGE, run_id, exc)
694
680
  detail = None
695
681
  finally:
682
+ self._detail_loader_task = None
696
683
  self._hide_loading()
697
- self._present_run_detail(detail)
684
+ self._handle_detail_loaded(run_id, detail)
698
685
 
699
686
  def _load_detail_sync(self, run_id: str) -> None:
700
- """Synchronous fallback for fetching run detail."""
701
687
  try:
702
- detail = self.callbacks.fetch_detail(run_id)
703
- except Exception as exc: # pragma: no cover - defensive logging for unexpected errors
704
- logger.exception("Failed to load run detail %s: %s", run_id, exc)
688
+ detail = self._runs_callbacks.fetch_detail(run_id)
689
+ except Exception as exc: # pragma: no cover - defensive
690
+ logger.exception(RUNS_DETAIL_LOG_MESSAGE, run_id, exc)
705
691
  detail = None
706
692
  finally:
693
+ self._detail_loader_task = None
707
694
  self._hide_loading()
708
- self._present_run_detail(detail)
695
+ self._handle_detail_loaded(run_id, detail)
709
696
 
710
- def _present_run_detail(self, detail: Any | None) -> None:
711
- """Push the detail modal or surface an error."""
697
+ def _handle_detail_loaded(self, run_id: str, detail: Any | None) -> None:
698
+ self._detail_loading_id = None
712
699
  if detail is None:
713
- self._update_status("Failed to load run detail.", append=True)
700
+ self._set_status("Failed to load run detail.")
714
701
  return
715
- self.push_screen(RunDetailScreen(detail, on_export=self.queue_export_from_detail, ctx=self._ctx))
716
- self._update_status("Detail view: ↑/↓ scroll · PgUp/PgDn · q/Esc close · c copy ID · C copy JSON · e export")
717
-
718
- def queue_export_from_detail(self, detail: Any) -> None:
719
- """Start an export from the detail modal."""
720
- run_id = getattr(detail, "id", None)
721
- if not run_id:
722
- self._update_status("Cannot export run without an identifier.", append=True)
723
- return
724
- self._queue_export_job(str(run_id), detail)
702
+ self._detail_cache[run_id] = detail
703
+ selected = self._current_run()
704
+ if selected and str(selected.id) == run_id:
705
+ self._update_transcript(detail)
706
+
707
+ def _get_or_load_detail(self, run_id: str) -> Any | None:
708
+ detail = self._detail_cache.get(run_id)
709
+ if detail is not None:
710
+ return detail
711
+ try:
712
+ detail = self._runs_callbacks.fetch_detail(run_id)
713
+ except Exception as exc: # pragma: no cover - defensive
714
+ logger.exception(RUNS_DETAIL_LOG_MESSAGE, run_id, exc)
715
+ detail = None
716
+ if detail is not None:
717
+ self._detail_cache[run_id] = detail
718
+ self._update_transcript(detail)
719
+ return detail
725
720
 
726
721
  def _queue_export_job(self, run_id: str, detail: Any) -> None:
727
- """Schedule the export coroutine so it can suspend cleanly."""
728
-
729
- async def runner() -> None:
722
+ async def runner() -> None: # pragma: no cover - defensive
730
723
  await self._perform_export(run_id, detail)
731
724
 
732
725
  try:
733
- self.run_worker(runner(), name="export-run", exclusive=True)
734
- except Exception:
735
- # Store task to prevent premature garbage collection
736
- export_task = asyncio.create_task(runner())
737
- # Keep reference to prevent GC (task will complete on its own)
738
- self._active_export_tasks.add(export_task)
739
- export_task.add_done_callback(self._active_export_tasks.discard)
726
+ run_worker = getattr(cast(Any, self), "run_worker", None)
727
+ if callable(run_worker):
728
+ run_worker(runner(), name="export-run", exclusive=True)
729
+ else:
730
+ raise AttributeError("run_worker not available")
731
+ except Exception: # pragma: no cover - defensive
732
+ self.track_task(runner())
740
733
 
741
734
  async def _perform_export(self, run_id: str, detail: Any) -> None:
742
- """Execute the export callback with suspend mode."""
743
735
  try:
744
- with self.suspend():
745
- success = bool(self.callbacks.export_run(run_id, detail))
736
+ app = getattr(self, "app", None)
737
+ if app is None:
738
+ success = bool(self._runs_callbacks.export_run(run_id, detail))
739
+ else:
740
+ with app.suspend():
741
+ success = bool(self._runs_callbacks.export_run(run_id, detail))
746
742
  except Exception as exc: # pragma: no cover - defensive
747
743
  logger.exception("Export failed: %s", exc)
748
- self._update_status(f"Export failed: {exc}", append=True)
744
+ self._set_status(f"Export failed: {exc}")
749
745
  return
750
746
 
751
747
  if success:
752
- self._update_status("Export complete (see slash console for path).", append=True)
748
+ self._set_status("Export complete (see slash console for path).")
753
749
  else:
754
- self._update_status("Export cancelled.", append=True)
750
+ self._set_status("Export cancelled.")
755
751
 
756
- def _show_loading(
757
- self,
758
- message: str | None = None,
759
- *,
760
- table_spinner: bool = True,
761
- footer_message: bool = True,
762
- ) -> None:
763
- """Display the loading indicator with an optional status message."""
752
+ def _show_loading(self, message: str | None = None, *, footer_message: bool = True) -> None:
764
753
  show_loading_indicator(
765
754
  self,
766
755
  RUNS_LOADING_SELECTOR,
767
756
  message=message,
768
- set_status=self._update_status if footer_message else None,
757
+ set_status=self._set_status if footer_message else None,
769
758
  )
770
- self._set_table_loading(table_spinner)
771
- self._table_spinner_active = table_spinner
772
759
 
773
760
  def _hide_loading(self) -> None:
774
- """Hide the loading indicator."""
775
761
  hide_loading_indicator(self, RUNS_LOADING_SELECTOR)
776
- if self._table_spinner_active:
777
- self._set_table_loading(False)
778
- self._table_spinner_active = False
779
762
 
780
- def _set_table_loading(self, is_loading: bool) -> None:
781
- """Toggle the DataTable loading shimmer."""
763
+ def _ensure_toast_bus(self) -> None:
764
+ def _notify(message: ToastBus.Changed) -> None: # pragma: no cover - defensive
765
+ # Post to self (Screen) so ToastHandlerMixin.on_toast_bus_changed can handle it.
766
+ self.post_message(message)
767
+
768
+ if self._local_toasts is not None:
769
+ self._local_toasts.subscribe(_notify)
770
+ return
771
+
772
+ if self._ctx and self._ctx.toasts is not None: # pragma: no cover - defensive
773
+ self._local_toasts = self._ctx.toasts # pragma: no cover - defensive
774
+ self._local_toasts.subscribe(_notify) # pragma: no cover - defensive
775
+ return # pragma: no cover - defensive
776
+ self._local_toasts = ToastBus(on_change=_notify)
777
+ if self._ctx:
778
+ self._ctx.toasts = self._local_toasts
779
+
780
+ def _announce_status(self, message: str) -> None:
781
+ self._set_status(message)
782
+
783
+ def _append_copy_fallback(self, text: str) -> None:
784
+ self._transcript_log.write(Text(text))
785
+ self._transcript_log.write(Text(""))
786
+
787
+ def _set_status(self, message: str) -> None:
788
+ self._status_line.update(message)
789
+
790
+ def _should_async_transcript(self, output: Any) -> bool:
791
+ if not isinstance(output, list):
792
+ return False
793
+ return len(output) > TRANSCRIPT_ASYNC_THRESHOLD
794
+
795
+ @staticmethod
796
+ def _detail_output(detail: Any) -> Any:
797
+ if isinstance(detail, dict):
798
+ return detail.get("output")
799
+ return getattr(detail, "output", None)
800
+
801
+ @staticmethod
802
+ def _serialize_transcript(output: list[Any]) -> list[str]:
803
+ return [json.dumps(chunk, indent=2, ensure_ascii=False, default=str) for chunk in output]
804
+
805
+ async def _render_transcript_async(self, output: list[Any], token: int) -> None:
782
806
  try:
783
- table = self.query_one(RUNS_TABLE_SELECTOR, DataTable)
784
- table.loading = is_loading
785
- except (AttributeError, RuntimeError) as e:
786
- logger.debug("Cannot toggle table loading state: %s", type(e).__name__)
807
+ rendered = await asyncio.to_thread(self._serialize_transcript, output)
808
+ except Exception as exc:
809
+ self.call_after_refresh(self._render_transcript_error, token, exc)
810
+ return
811
+ self.call_after_refresh(self._apply_transcript_render, token, rendered)
812
+
813
+ def _apply_transcript_render(self, token: int, rendered: list[str]) -> None:
814
+ if token != self._transcript_render_token:
815
+ return
816
+ self._transcript_log.clear()
817
+ if not rendered:
818
+ self._transcript_log.write("No transcript available.")
819
+ return
820
+ for json_str in rendered:
821
+ self._transcript_log.write(Syntax(json_str, "json", theme="monokai", word_wrap=True))
822
+ self._transcript_log.write("")
823
+
824
+ def _render_transcript_error(self, token: int, exc: Exception) -> None:
825
+ if token != self._transcript_render_token:
826
+ return
827
+ self._transcript_log.clear()
828
+ self._transcript_log.write(f"Failed to render transcript: {exc}")
829
+
830
+ def _update_transcript(self, detail: Any | None) -> None:
831
+ self._transcript_log.clear()
832
+ self._transcript_render_token += 1
833
+ token = self._transcript_render_token
834
+ if detail is None:
835
+ self._transcript_log.write("Loading transcript...")
836
+ return
837
+ output = self._detail_output(detail)
838
+ if not output:
839
+ self._transcript_log.write("No transcript available.")
840
+ return
841
+ if self._should_async_transcript(output):
842
+ self._transcript_log.write("Rendering transcript...")
843
+ try:
844
+ self._transcript_render_task = self.track_task(self._render_transcript_async(list(output), token))
845
+ except RuntimeError:
846
+ rendered = self._serialize_transcript(list(output))
847
+ self._apply_transcript_render(token, rendered)
848
+ return
849
+ for chunk in output:
850
+ json_str = json.dumps(chunk, indent=2, ensure_ascii=False, default=str)
851
+ self._transcript_log.write(Syntax(json_str, "json", theme="monokai", word_wrap=True))
852
+ self._transcript_log.write("")
853
+
854
+ async def _copy_transcript_async(self, output: list[Any]) -> None:
855
+ try:
856
+ payload = await asyncio.to_thread(json.dumps, output, indent=2, ensure_ascii=False, default=str)
857
+ except Exception as exc:
858
+ self.call_after_refresh(self._set_status, f"Failed to serialize transcript: {exc}")
859
+ return
860
+ self.call_after_refresh(lambda: self._copy_to_clipboard(payload, label="Transcript"))
861
+
862
+ async def _copy_detail_json_async(self, detail: Any) -> None:
863
+ payload, error = self._detail_payload(detail)
864
+ if error:
865
+ self.call_after_refresh(self._set_status, error)
866
+ return
867
+ if isinstance(payload, str):
868
+ self.call_after_refresh(lambda: self._copy_to_clipboard(payload, label=RUNS_RUN_JSON_LABEL))
869
+ return
870
+ try:
871
+ json_payload = await asyncio.to_thread(
872
+ json.dumps,
873
+ payload,
874
+ indent=2,
875
+ ensure_ascii=False,
876
+ default=str,
877
+ )
878
+ except Exception as exc:
879
+ self.call_after_refresh(self._set_status, f"Failed to serialize run detail: {exc}")
880
+ return
881
+ self.call_after_refresh(lambda: self._copy_to_clipboard(json_payload, label=RUNS_RUN_JSON_LABEL))
882
+
883
+ def _detail_payload(self, detail: Any | None) -> tuple[Any | None, str | None]:
884
+ if detail is None:
885
+ return None, "Run detail unavailable."
886
+ if isinstance(detail, str):
887
+ return detail, None
888
+ if isinstance(detail, dict):
889
+ return detail, None
890
+ if hasattr(detail, "model_dump"):
891
+ return detail.model_dump(mode="json"), None
892
+ if hasattr(detail, "dict"):
893
+ return detail.dict(), None
894
+ return getattr(detail, "__dict__", {"value": detail}), None
895
+
896
+ def _detail_json_payload(self, detail: Any | None) -> str | None:
897
+ payload, error = self._detail_payload(detail)
898
+ if error:
899
+ self._set_status(error)
900
+ return None
901
+ if isinstance(payload, str):
902
+ return payload
903
+ try:
904
+ return json.dumps(payload, indent=2, ensure_ascii=False, default=str)
905
+ except Exception as exc:
906
+ self._set_status(f"Failed to serialize run detail: {exc}")
907
+ return None
908
+
909
+ def _format_timestamp(self, value: datetime | None) -> str:
910
+ if value is None:
911
+ return "-"
912
+ try:
913
+ return value.strftime("%Y-%m-%d %H:%M:%S")
914
+ except Exception: # pragma: no cover - defensive
915
+ return str(value)
916
+
917
+ def _format_started(self, value: datetime | None) -> str:
918
+ if value is None:
919
+ return "-"
920
+ try:
921
+ # Short format: "Jan 02 14:30" (Mon DD HH:MM)
922
+ # This avoids year (redundant for recent) and TZ noise in the table
923
+ if value.tzinfo:
924
+ value = value.astimezone(UTC)
925
+ return value.strftime("%b %d %H:%M")
926
+ except Exception: # pragma: no cover - defensive
927
+ return str(value)
928
+
929
+ def _format_duration(self, formatter: Any) -> str:
930
+ if callable(formatter):
931
+ try:
932
+ value = formatter()
933
+ except Exception: # pragma: no cover - defensive
934
+ value = None
935
+ else:
936
+ value = None
937
+ if not value:
938
+ return "-"
939
+ return str(value).replace("\u2014", "-")
940
+
941
+ def _agent_context_label(self) -> str:
942
+ if self._agent_name and self._agent_id:
943
+ return f"Agent: {self._agent_name} ({self._agent_id})"
944
+ if self._agent_name:
945
+ return f"Agent: {self._agent_name}"
946
+ if self._agent_id:
947
+ return f"Agent: {self._agent_id}"
948
+ return "Agent runs"
949
+
950
+ def _input_preview(self, run: Any) -> str:
951
+ text = getattr(run, "input", None) or ""
952
+ # Strip common prefixes to increase information density
953
+ text = str(text).replace("Execute the ", "").replace(" tool with", "")
954
+
955
+ # Try to parse "tool_name: args" pattern without regex to avoid backtracking.
956
+ parts = text.split(None, 1)
957
+ if len(parts) == 2:
958
+ tool, args = parts
959
+ if tool and args:
960
+ text = f"{tool}: {args}"
961
+
962
+ # Reduce whitespace
963
+ preview = " ".join(text.split())
964
+ if not preview:
965
+ return "-"
966
+ if len(preview) > self._preview_limit:
967
+ return preview[: self._preview_limit - 3] + "..."
968
+ return preview
969
+
970
+ def _matches_filter(self, run: Any) -> bool:
971
+ if not self._filter_text:
972
+ return True
973
+ haystack = " ".join(
974
+ [
975
+ str(getattr(run, "input", "")),
976
+ str(getattr(run, "run_type", "")),
977
+ str(getattr(run, "status", "")),
978
+ ]
979
+ ).lower()
980
+ return self._filter_text in haystack
981
+
982
+ def _get_row(self, row_index: int) -> Any | None:
983
+ if not self._visible_rows:
984
+ return None
985
+ row_index = max(0, min(row_index, len(self._visible_rows) - 1))
986
+ return self._visible_rows[row_index]
987
+
988
+ def _current_run(self) -> Any | None:
989
+ if not self._visible_rows:
990
+ return None
991
+ return self._get_row(self.cursor_index)
992
+
993
+ @staticmethod
994
+ def _status_label(status: str | None) -> str:
995
+ if not status:
996
+ return "-"
997
+ normalized = str(status).strip().lower()
998
+ if normalized in {"ok", "success", "completed", "succeeded"}:
999
+ return "✔"
1000
+ if normalized in {"running", "in_progress"}:
1001
+ return "⟳"
1002
+ if normalized in {"failed", "error", "errored"}:
1003
+ return "✖"
1004
+ if normalized in {"aborted", "cancelled", "canceled"}:
1005
+ return "⊘"
1006
+ return normalized[:3].upper()
1007
+
1008
+ @staticmethod
1009
+ def _status_style(status: str | None) -> str:
1010
+ if not status:
1011
+ return "dim"
1012
+ normalized = str(status).strip().lower()
1013
+ if normalized in {"ok", "success", "completed", "succeeded"}:
1014
+ return "green"
1015
+ if normalized in {"running", "in_progress", "run"}:
1016
+ return "yellow"
1017
+ if normalized in {"failed", "error", "errored", "err", "aborted", "cancelled", "canceled", "abt"}:
1018
+ return "red"
1019
+ return "cyan"
1020
+
1021
+
1022
+ class _HarlequinRunsApp(App[None]):
1023
+ """App wrapper that hosts the Harlequin runs screen."""
1024
+
1025
+ def __init__(
1026
+ self,
1027
+ initial_page: Any,
1028
+ cursor_idx: int,
1029
+ callbacks: RemoteRunsTUICallbacks,
1030
+ *,
1031
+ agent_name: str | None = None,
1032
+ agent_id: str | None = None,
1033
+ ctx: TUIContext | None = None,
1034
+ ) -> None:
1035
+ super().__init__()
1036
+ self._initial_page = initial_page
1037
+ self._initial_cursor = cursor_idx
1038
+ self._callbacks = callbacks
1039
+ self._agent_name = agent_name
1040
+ self._agent_id = agent_id
1041
+ self._ctx = ctx
1042
+ self.current_page = initial_page
1043
+ self.cursor_index = cursor_idx
1044
+
1045
+ def on_mount(self) -> None:
1046
+ screen = RunsHarlequinScreen(
1047
+ self._initial_page,
1048
+ self._initial_cursor,
1049
+ self._callbacks,
1050
+ agent_name=self._agent_name,
1051
+ agent_id=self._agent_id,
1052
+ ctx=self._ctx,
1053
+ )
1054
+ self.push_screen(cast(Screen, screen))
1055
+
1056
+ def on_screen_resume(self, event: Any) -> None: # pragma: no cover - UI hook
1057
+ screen = getattr(event, "screen", None)
1058
+ if isinstance(screen, RunsHarlequinScreen):
1059
+ self.current_page = screen.current_page
1060
+ self.cursor_index = screen.cursor_index