glaip-sdk 0.7.26__py3-none-any.whl → 0.7.28__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.
- glaip_sdk/cli/slash/tui/__init__.py +2 -2
- glaip_sdk/cli/slash/tui/accounts_app.py +109 -47
- glaip_sdk/cli/slash/tui/remote_runs_app.py +854 -580
- glaip_sdk/cli/slash/tui/toast.py +29 -3
- glaip_sdk/utils/import_resolver.py +384 -38
- {glaip_sdk-0.7.26.dist-info → glaip_sdk-0.7.28.dist-info}/METADATA +1 -1
- {glaip_sdk-0.7.26.dist-info → glaip_sdk-0.7.28.dist-info}/RECORD +10 -10
- {glaip_sdk-0.7.26.dist-info → glaip_sdk-0.7.28.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.7.26.dist-info → glaip_sdk-0.7.28.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.7.26.dist-info → glaip_sdk-0.7.28.dist-info}/top_level.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Textual UI for the /runs command.
|
|
2
2
|
|
|
3
|
-
This module provides a
|
|
4
|
-
|
|
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
|
|
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.
|
|
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
|
|
27
|
+
from textual.containers import Horizontal
|
|
24
28
|
from textual.coordinate import Coordinate
|
|
25
|
-
from textual.
|
|
26
|
-
from textual.
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
137
|
+
RunsHarlequinScreen {
|
|
138
|
+
layers: base toasts;
|
|
139
|
+
}
|
|
140
|
+
|
|
92
141
|
#toast-container {
|
|
93
|
-
width:
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
153
|
+
#left-pane {
|
|
154
|
+
width: 50%;
|
|
155
|
+
min-width: 38;
|
|
156
|
+
padding: 1;
|
|
157
|
+
}
|
|
273
158
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
159
|
+
#right-pane {
|
|
160
|
+
width: 50%;
|
|
161
|
+
padding: 1;
|
|
162
|
+
}
|
|
277
163
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
164
|
+
.pane-title {
|
|
165
|
+
color: $warning;
|
|
166
|
+
text-style: bold;
|
|
167
|
+
padding-bottom: 1;
|
|
168
|
+
}
|
|
281
169
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
170
|
+
.section-title {
|
|
171
|
+
color: $warning;
|
|
172
|
+
text-style: bold;
|
|
173
|
+
padding-top: 1;
|
|
174
|
+
padding-bottom: 1;
|
|
175
|
+
}
|
|
285
176
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
177
|
+
.detail-block {
|
|
178
|
+
padding-bottom: 1;
|
|
179
|
+
}
|
|
289
180
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
312
|
-
|
|
204
|
+
.status-line {
|
|
205
|
+
color: $text-muted;
|
|
206
|
+
}
|
|
313
207
|
|
|
314
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
210
|
+
margin: 0 1;
|
|
211
|
+
border: solid $accent;
|
|
212
|
+
}
|
|
331
213
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
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.
|
|
364
|
-
self.
|
|
365
|
-
self.
|
|
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.
|
|
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[
|
|
373
|
-
self.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
"""
|
|
402
|
-
yield
|
|
403
|
-
yield
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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.
|
|
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
|
-
|
|
484
|
-
except
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
self.
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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.
|
|
391
|
+
self._filter_text = event.value.strip().lower()
|
|
392
|
+
self._render_page()
|
|
522
393
|
|
|
523
|
-
def
|
|
524
|
-
"""
|
|
525
|
-
|
|
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
|
|
528
|
-
"""
|
|
529
|
-
|
|
530
|
-
|
|
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
|
-
"""
|
|
409
|
+
"""Move to the previous page."""
|
|
534
410
|
if not self.current_page.has_prev:
|
|
535
|
-
self.
|
|
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
|
-
"""
|
|
417
|
+
"""Move to the next page."""
|
|
546
418
|
if not self.current_page.has_next:
|
|
547
|
-
self.
|
|
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
|
|
557
|
-
"""
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
|
565
|
-
"""
|
|
566
|
-
|
|
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
|
-
|
|
571
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
578
|
-
"""
|
|
579
|
-
run = self.
|
|
580
|
-
if
|
|
581
|
-
self.
|
|
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
|
-
|
|
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.
|
|
486
|
+
self._set_status("Transcript not loaded.")
|
|
586
487
|
return
|
|
587
|
-
self._queue_export_job(str(run.id), detail)
|
|
588
488
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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.
|
|
607
|
-
self.
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
|
625
|
-
"""
|
|
626
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
644
|
-
except Exception as exc: # pragma: no cover - defensive
|
|
645
|
-
logger.exception("Failed to fetch
|
|
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.
|
|
639
|
+
self._set_status(failure_message)
|
|
652
640
|
return
|
|
653
|
-
self.
|
|
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.
|
|
659
|
-
except Exception as exc: # pragma: no cover - defensive
|
|
660
|
-
logger.exception("Failed to fetch
|
|
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.
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
|
680
|
-
|
|
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
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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.
|
|
692
|
-
except Exception as exc: # pragma: no cover - defensive
|
|
693
|
-
logger.exception(
|
|
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.
|
|
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.
|
|
703
|
-
except Exception as exc: # pragma: no cover - defensive
|
|
704
|
-
logger.exception(
|
|
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.
|
|
695
|
+
self._handle_detail_loaded(run_id, detail)
|
|
709
696
|
|
|
710
|
-
def
|
|
711
|
-
|
|
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.
|
|
700
|
+
self._set_status("Failed to load run detail.")
|
|
714
701
|
return
|
|
715
|
-
self.
|
|
716
|
-
self.
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
return
|
|
724
|
-
|
|
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
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
745
|
-
|
|
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.
|
|
744
|
+
self._set_status(f"Export failed: {exc}")
|
|
749
745
|
return
|
|
750
746
|
|
|
751
747
|
if success:
|
|
752
|
-
self.
|
|
748
|
+
self._set_status("Export complete (see slash console for path).")
|
|
753
749
|
else:
|
|
754
|
-
self.
|
|
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.
|
|
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
|
|
781
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|