glaip-sdk 0.7.7__py3-none-any.whl → 0.7.12__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.
@@ -45,6 +45,36 @@ _SUBPROCESS_COMMANDS: dict[ClipboardMethod, list[str]] = {
45
45
  ClipboardMethod.CLIP: ["clip"],
46
46
  }
47
47
 
48
+ _ENV_CLIPBOARD_METHOD = "AIP_TUI_CLIPBOARD_METHOD"
49
+ _ENV_CLIPBOARD_FORCE = "AIP_TUI_CLIPBOARD_FORCE"
50
+ _ENV_METHOD_MAP = {
51
+ "osc52": ClipboardMethod.OSC52,
52
+ "pbcopy": ClipboardMethod.PBCOPY,
53
+ "xclip": ClipboardMethod.XCLIP,
54
+ "xsel": ClipboardMethod.XSEL,
55
+ "wl-copy": ClipboardMethod.WL_COPY,
56
+ "wl_copy": ClipboardMethod.WL_COPY,
57
+ "clip": ClipboardMethod.CLIP,
58
+ "none": ClipboardMethod.NONE,
59
+ }
60
+
61
+
62
+ def _resolve_env_method() -> ClipboardMethod | None:
63
+ raw = os.getenv(_ENV_CLIPBOARD_METHOD)
64
+ if not raw:
65
+ return None
66
+ value = raw.strip().lower()
67
+ if value in ("auto", "default"):
68
+ return None
69
+ return _ENV_METHOD_MAP.get(value)
70
+
71
+
72
+ def _is_env_force_enabled() -> bool:
73
+ raw = os.getenv(_ENV_CLIPBOARD_FORCE)
74
+ if not raw:
75
+ return False
76
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
77
+
48
78
 
49
79
  class ClipboardAdapter:
50
80
  """Cross-platform clipboard access with OSC 52 fallback."""
@@ -57,7 +87,16 @@ class ClipboardAdapter:
57
87
  ) -> None:
58
88
  """Initialize the adapter."""
59
89
  self._terminal = terminal
60
- self._method = method or self._detect_method()
90
+ self._force_method = False
91
+ if method is not None:
92
+ self._method = method
93
+ else:
94
+ env_method = _resolve_env_method()
95
+ if env_method is not None:
96
+ self._method = env_method
97
+ self._force_method = _is_env_force_enabled()
98
+ else:
99
+ self._method = self._detect_method()
61
100
 
62
101
  @property
63
102
  def method(self) -> ClipboardMethod:
@@ -77,25 +116,34 @@ class ClipboardAdapter:
77
116
 
78
117
  command = _SUBPROCESS_COMMANDS.get(self._method)
79
118
  if command is None:
119
+ if self._force_method:
120
+ return ClipboardResult(False, self._method, "Forced clipboard method unavailable.")
80
121
  return self._copy_osc52(text, writer=writer)
81
122
 
82
123
  result = self._copy_subprocess(command, text)
83
124
  if not result.success:
125
+ if self._force_method:
126
+ return result
84
127
  return self._copy_osc52(text, writer=writer)
85
128
 
86
129
  return result
87
130
 
88
131
  def _detect_method(self) -> ClipboardMethod:
132
+ system = platform.system()
133
+ method = ClipboardMethod.NONE
134
+ if system == "Darwin":
135
+ method = self._detect_darwin_method()
136
+ elif system == "Linux":
137
+ method = self._detect_linux_method()
138
+ elif system == "Windows":
139
+ method = self._detect_windows_method()
140
+
141
+ if method is not ClipboardMethod.NONE:
142
+ return method
143
+
89
144
  if self._terminal.osc52 if self._terminal else detect_osc52_support():
90
145
  return ClipboardMethod.OSC52
91
146
 
92
- system = platform.system()
93
- if system == "Darwin":
94
- return self._detect_darwin_method()
95
- if system == "Linux":
96
- return self._detect_linux_method()
97
- if system == "Windows":
98
- return self._detect_windows_method()
99
147
  return ClipboardMethod.NONE
100
148
 
101
149
  def _detect_darwin_method(self) -> ClipboardMethod:
@@ -12,13 +12,14 @@ from __future__ import annotations
12
12
 
13
13
  import os
14
14
  from dataclasses import dataclass
15
- from typing import TYPE_CHECKING
16
15
 
16
+ from glaip_sdk.cli.account_store import get_account_store
17
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
18
+ from glaip_sdk.cli.slash.tui.keybind_registry import KeybindRegistry
17
19
  from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
18
20
  from glaip_sdk.cli.slash.tui.theme import ThemeManager
19
-
20
- if TYPE_CHECKING:
21
- from glaip_sdk.cli.slash.tui.toast import ToastBus
21
+ from glaip_sdk.cli.slash.tui.toast import ToastBus
22
+ from glaip_sdk.cli.tui_settings import load_tui_settings
22
23
 
23
24
 
24
25
  @dataclass
@@ -37,23 +38,50 @@ class TUIContext:
37
38
  """
38
39
 
39
40
  terminal: TerminalCapabilities
40
- keybinds: object | None = None
41
+ keybinds: KeybindRegistry | None = None
41
42
  theme: ThemeManager | None = None
42
43
  toasts: ToastBus | None = None
43
- clipboard: object | None = None
44
+ clipboard: ClipboardAdapter | None = None
44
45
 
45
46
  @classmethod
46
- async def create(cls) -> TUIContext:
47
+ async def create(cls, *, detect_osc11: bool = True) -> TUIContext:
47
48
  """Create a TUIContext instance with detected terminal capabilities.
48
49
 
49
50
  This factory method detects terminal capabilities asynchronously and
50
- returns a populated TUIContext instance. Other components (keybinds,
51
- theme, toasts, clipboard) will be set incrementally as they are created.
51
+ returns a populated TUIContext instance with all services initialized
52
+ (keybinds, theme, toasts, clipboard).
53
+
54
+ Args:
55
+ detect_osc11: When False, skip OSC 11 background detection.
52
56
 
53
57
  Returns:
54
- TUIContext instance with terminal capabilities detected.
58
+ TUIContext instance with all services initialized.
55
59
  """
56
- terminal = await TerminalCapabilities.detect()
57
- theme_name = os.getenv("AIP_TUI_THEME") or None
58
- theme = ThemeManager(terminal, theme=theme_name)
59
- return cls(terminal=terminal, theme=theme)
60
+ terminal = await TerminalCapabilities.detect(detect_osc11=detect_osc11)
61
+ store = get_account_store()
62
+ settings = load_tui_settings(store=store)
63
+
64
+ # Handle env var override: normalize empty strings and "default" to None
65
+ # Empty string from os.getenv() is falsy, so strip() result becomes None in the or expression
66
+ env_theme = os.getenv("AIP_TUI_THEME")
67
+ env_theme = env_theme.strip() if env_theme else None
68
+ if env_theme and env_theme.lower() == "default":
69
+ env_theme = None
70
+
71
+ theme_name = env_theme or settings.theme_name
72
+ theme = ThemeManager(
73
+ terminal,
74
+ mode=settings.theme_mode,
75
+ theme=theme_name,
76
+ settings_store=store,
77
+ )
78
+ keybinds = KeybindRegistry()
79
+ toasts = ToastBus()
80
+ clipboard = ClipboardAdapter(terminal=terminal)
81
+ return cls(
82
+ terminal=terminal,
83
+ keybinds=keybinds,
84
+ theme=theme,
85
+ toasts=toasts,
86
+ clipboard=clipboard,
87
+ )
@@ -0,0 +1,14 @@
1
+ """Layout components for TUI applications.
2
+
3
+ This package provides reusable layout components following the Harlequin pattern
4
+ for multi-pane data-rich screens.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ try: # pragma: no cover - optional dependency
10
+ from glaip_sdk.cli.slash.tui.layouts.harlequin import HarlequinScreen
11
+ except Exception: # pragma: no cover - optional dependency
12
+ HarlequinScreen = None # type: ignore[assignment, misc]
13
+
14
+ __all__ = ["HarlequinScreen"]
@@ -0,0 +1,160 @@
1
+ """Harlequin layout base class for multi-pane TUI screens.
2
+
3
+ This module provides the HarlequinScreen base class, which implements a modern
4
+ multi-pane "Harlequin" layout pattern for data-rich TUI screens. The layout uses
5
+ a 25/75 split with a list on the left and detail content on the right.
6
+
7
+ The Harlequin pattern is inspired by the Harlequin SQL client and provides:
8
+ - Left Pane (25%): ListView or compact table for item selection
9
+ - Right Pane (75%): Detail dashboard showing all fields, status, and action buttons
10
+ - Black background (#000000) that overrides terminal transparency
11
+ - Primary Blue borders (#005CB8)
12
+
13
+ Authors:
14
+ Raymond Christopher (raymond.christopher@gdplabs.id)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ try: # pragma: no cover - optional dependency
22
+ from textual.containers import Container, Horizontal, Vertical
23
+ from textual.screen import Screen
24
+ except Exception: # pragma: no cover - optional dependency
25
+
26
+ class Screen: # type: ignore[no-redef]
27
+ """Fallback Screen stub when Textual is unavailable."""
28
+
29
+ def __class_getitem__(cls, _):
30
+ """Return the class for typing subscripts."""
31
+ return cls
32
+
33
+ Horizontal = None # type: ignore[assignment]
34
+ Vertical = None # type: ignore[assignment]
35
+ Container = None # type: ignore[assignment]
36
+
37
+ if TYPE_CHECKING:
38
+ from glaip_sdk.cli.slash.tui.context import TUIContext
39
+
40
+ try: # pragma: no cover - optional dependency
41
+ from glaip_sdk.cli.slash.tui.toast import Toast
42
+ except Exception: # pragma: no cover - optional dependency
43
+ Toast = None # type: ignore[assignment, misc]
44
+
45
+ # GDP Labs Brand Palette
46
+ PRIMARY_BLUE = "#005CB8"
47
+ BLACK_BACKGROUND = "#000000"
48
+
49
+
50
+ class HarlequinScreen(Screen[None]): # type: ignore[misc]
51
+ """Base class for Harlequin-style multi-pane screens.
52
+
53
+ This screen provides a 25/75 split layout with a left pane for navigation
54
+ and a right pane for details. The layout uses a black background that
55
+ overrides terminal transparency and primary blue borders.
56
+
57
+ Subclasses should override `compose()` to add their specific widgets to
58
+ the left and right panes. Use the container IDs "left-pane" and "right-pane"
59
+ to target specific panes in CSS or when querying widgets.
60
+
61
+ Example:
62
+ ```python
63
+ class AccountHarlequinScreen(HarlequinScreen):
64
+ def compose(self) -> ComposeResult:
65
+ yield from super().compose()
66
+ # Add widgets to left and right panes
67
+ self.query_one("#left-pane").mount(AccountListView())
68
+ self.query_one("#right-pane").mount(AccountDetailView())
69
+ ```
70
+
71
+ CSS:
72
+ The screen includes default styling for the Harlequin layout:
73
+ - Black background (#000000) for the entire screen
74
+ - Primary blue borders (#005CB8) for panes
75
+ - 25% width for left pane, 75% width for right pane
76
+ """
77
+
78
+ CSS = """
79
+ HarlequinScreen {
80
+ background: #000000;
81
+ layers: base toasts;
82
+ }
83
+
84
+ #harlequin-container {
85
+ width: 100%;
86
+ height: 100%;
87
+ }
88
+
89
+ #left-pane {
90
+ width: 25%;
91
+ border: solid #005CB8;
92
+ background: #000000;
93
+ }
94
+
95
+ #right-pane {
96
+ width: 75%;
97
+ border: solid #005CB8;
98
+ background: #000000;
99
+ }
100
+
101
+ #toast-container {
102
+ width: 100%;
103
+ height: auto;
104
+ dock: top;
105
+ align: right top;
106
+ layer: toasts;
107
+ }
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ *,
113
+ ctx: TUIContext | None = None,
114
+ name: str | None = None,
115
+ id: str | None = None,
116
+ classes: str | None = None,
117
+ ) -> None:
118
+ """Initialize the Harlequin screen.
119
+
120
+ Args:
121
+ ctx: Optional TUI context for accessing services (keybinds, theme, toasts, clipboard).
122
+ name: Optional name for the screen.
123
+ id: Optional ID for the screen.
124
+ classes: Optional CSS classes for the screen.
125
+ """
126
+ super().__init__(name=name, id=id, classes=classes)
127
+ self._ctx: TUIContext | None = ctx
128
+
129
+ def compose(self) -> Any:
130
+ """Compose the Harlequin layout with left and right panes.
131
+
132
+ This method creates the base 25/75 split layout. Subclasses should
133
+ call `super().compose()` and then add their specific widgets to the
134
+ left and right panes.
135
+
136
+ Returns:
137
+ ComposeResult yielding the base layout containers.
138
+ """
139
+ if Horizontal is None or Vertical is None or Container is None:
140
+ return
141
+
142
+ # Main container with horizontal split (25/75)
143
+ yield Horizontal(
144
+ Vertical(id="left-pane"),
145
+ Vertical(id="right-pane"),
146
+ id="harlequin-container",
147
+ )
148
+
149
+ # Toast container for notifications
150
+ if Toast is not None and Container is not None:
151
+ yield Container(Toast(), id="toast-container")
152
+
153
+ @property
154
+ def ctx(self) -> TUIContext | None:
155
+ """Get the TUI context if available.
156
+
157
+ Returns:
158
+ TUIContext instance or None if not provided.
159
+ """
160
+ return self._ctx
@@ -19,12 +19,16 @@ from typing import Any
19
19
  from rich.text import Text
20
20
  from textual.app import App, ComposeResult
21
21
  from textual.binding import Binding
22
- from textual.containers import Container, Horizontal
22
+ from textual.containers import Container, Horizontal, Vertical
23
+ from textual.coordinate import Coordinate
23
24
  from textual.reactive import ReactiveError
24
25
  from textual.screen import ModalScreen
25
26
  from textual.widgets import DataTable, Footer, Header, LoadingIndicator, RichLog, Static
26
27
 
28
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
29
+ from glaip_sdk.cli.slash.tui.context import TUIContext
27
30
  from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
31
+ from glaip_sdk.cli.slash.tui.toast import ClipboardToastMixin, Toast, ToastBus, ToastHandlerMixin
28
32
 
29
33
  logger = logging.getLogger(__name__)
30
34
 
@@ -50,6 +54,7 @@ def run_remote_runs_textual(
50
54
  *,
51
55
  agent_name: str | None = None,
52
56
  agent_id: str | None = None,
57
+ ctx: TUIContext | None = None,
53
58
  ) -> tuple[int, int, int]:
54
59
  """Launch the Textual application and return the final pagination state.
55
60
 
@@ -59,6 +64,7 @@ def run_remote_runs_textual(
59
64
  callbacks: Data provider callback bundle.
60
65
  agent_name: Optional agent name for display purposes.
61
66
  agent_id: Optional agent ID for display purposes.
67
+ ctx: Shared TUI context.
62
68
 
63
69
  Returns:
64
70
  Tuple of (page, limit, cursor_index) after the UI exits.
@@ -69,15 +75,27 @@ def run_remote_runs_textual(
69
75
  callbacks,
70
76
  agent_name=agent_name,
71
77
  agent_id=agent_id,
78
+ ctx=ctx,
72
79
  )
73
80
  app.run()
74
81
  current_page = getattr(app, "current_page", initial_page)
75
82
  return current_page.page, current_page.limit, app.cursor_index
76
83
 
77
84
 
78
- class RunDetailScreen(ModalScreen[None]):
85
+ class RunDetailScreen(ToastHandlerMixin, ClipboardToastMixin, ModalScreen[None]):
79
86
  """Modal screen displaying run metadata and output timeline."""
80
87
 
88
+ CSS = """
89
+ Screen { layout: vertical; layers: base toasts; }
90
+ #toast-container {
91
+ width: 100%;
92
+ height: auto;
93
+ dock: top;
94
+ align: right top;
95
+ layer: toasts;
96
+ }
97
+ """
98
+
81
99
  BINDINGS = [
82
100
  Binding("escape", "dismiss", "Close", priority=True),
83
101
  Binding("q", "dismiss_modal", "Close", priority=True),
@@ -85,14 +103,24 @@ class RunDetailScreen(ModalScreen[None]):
85
103
  Binding("down", "scroll_down", "Down"),
86
104
  Binding("pageup", "page_up", "PgUp"),
87
105
  Binding("pagedown", "page_down", "PgDn"),
106
+ Binding("c", "copy_run_id", "Copy ID"),
107
+ Binding("C", "copy_detail_json", "Copy JSON"),
88
108
  Binding("e", "export_detail", "Export"),
89
109
  ]
90
110
 
91
- def __init__(self, detail: Any, on_export: Callable[[Any], None] | None = None):
111
+ def __init__(
112
+ self,
113
+ detail: Any,
114
+ on_export: Callable[[Any], None] | None = None,
115
+ ctx: TUIContext | None = None,
116
+ ) -> None:
92
117
  """Initialize the run detail screen."""
93
118
  super().__init__()
94
119
  self.detail = detail
95
120
  self._on_export = on_export
121
+ self._ctx = ctx
122
+ self._clipboard: ClipboardAdapter | None = None
123
+ self._local_toasts: ToastBus | None = None
96
124
 
97
125
  def compose(self) -> ComposeResult:
98
126
  """Render metadata and events."""
@@ -116,14 +144,17 @@ class RunDetailScreen(ModalScreen[None]):
116
144
  duration = self.detail.duration_formatted() if getattr(self.detail, "duration_formatted", None) else None
117
145
  add_meta("Duration", duration, "bold")
118
146
 
119
- yield Container(
147
+ main_content = Vertical(
120
148
  Static(meta_text, id="detail-meta"),
121
149
  RichLog(id="detail-events", wrap=False),
122
150
  )
151
+ yield main_content
152
+ yield Container(Toast(), id="toast-container")
123
153
  yield Footer()
124
154
 
125
155
  def on_mount(self) -> None:
126
156
  """Populate and focus the log."""
157
+ self._ensure_toast_bus()
127
158
  log = self.query_one("#detail-events", RichLog)
128
159
  log.can_focus = True
129
160
  log.write(Text("Events", style="bold"))
@@ -149,6 +180,61 @@ class RunDetailScreen(ModalScreen[None]):
149
180
  def _log(self) -> RichLog:
150
181
  return self.query_one("#detail-events", RichLog)
151
182
 
183
+ def action_copy_run_id(self) -> None:
184
+ """Copy the run id to the clipboard."""
185
+ run_id = getattr(self.detail, "id", None)
186
+ if not run_id:
187
+ self._announce_status("Run ID unavailable.")
188
+ return
189
+ self._copy_to_clipboard(str(run_id), label="Run ID")
190
+
191
+ def action_copy_detail_json(self) -> None:
192
+ """Copy the run detail JSON to the clipboard."""
193
+ payload = self._detail_json_payload()
194
+ if payload is None:
195
+ return
196
+ self._copy_to_clipboard(payload, label="Run JSON")
197
+
198
+ def _detail_json_payload(self) -> str | None:
199
+ detail = self.detail
200
+ if detail is None:
201
+ self._announce_status("Run detail unavailable.")
202
+ return None
203
+ if isinstance(detail, str):
204
+ return detail
205
+ if isinstance(detail, dict):
206
+ payload = detail
207
+ elif hasattr(detail, "model_dump"):
208
+ payload = detail.model_dump(mode="json")
209
+ elif hasattr(detail, "dict"):
210
+ payload = detail.dict()
211
+ else:
212
+ payload = getattr(detail, "__dict__", {"value": detail})
213
+ try:
214
+ return json.dumps(payload, indent=2, ensure_ascii=False, default=str)
215
+ except Exception as exc:
216
+ self._announce_status(f"Failed to serialize run detail: {exc}")
217
+ return None
218
+
219
+ def _append_copy_fallback(self, text: str) -> None:
220
+ try:
221
+ log = self._log()
222
+ except Exception:
223
+ self._announce_status(text)
224
+ return
225
+ log.write(Text(text))
226
+ log.write(Text(""))
227
+
228
+ def _ensure_toast_bus(self) -> None:
229
+ """Ensure toast bus is initialized and connected to message handler."""
230
+ if self._local_toasts is not None:
231
+ return # pragma: no cover - early return when already initialized
232
+
233
+ def _notify(m: ToastBus.Changed) -> None:
234
+ self.post_message(m)
235
+
236
+ self._local_toasts = ToastBus(on_change=_notify)
237
+
152
238
  @staticmethod
153
239
  def _status_style(status: str | None) -> str:
154
240
  """Return a Rich style name for the status pill."""
@@ -220,11 +306,18 @@ class RunDetailScreen(ModalScreen[None]):
220
306
  update_status(message, append=True)
221
307
 
222
308
 
223
- class RemoteRunsTextualApp(App[None]):
309
+ class RemoteRunsTextualApp(ToastHandlerMixin, App[None]):
224
310
  """Textual application for browsing remote runs."""
225
311
 
226
312
  CSS = f"""
227
- Screen {{ layout: vertical; }}
313
+ Screen {{ layout: vertical; layers: base toasts; }}
314
+ #toast-container {{
315
+ width: 100%;
316
+ height: auto;
317
+ dock: top;
318
+ align: right top;
319
+ layer: toasts;
320
+ }}
228
321
  #status-bar {{ height: 3; padding: 0 1; }}
229
322
  #agent-context {{ min-width: 25; padding-right: 1; }}
230
323
  #{RUNS_LOADING_ID} {{ width: 8; }}
@@ -247,6 +340,7 @@ class RemoteRunsTextualApp(App[None]):
247
340
  *,
248
341
  agent_name: str | None = None,
249
342
  agent_id: str | None = None,
343
+ ctx: TUIContext | None = None,
250
344
  ):
251
345
  """Initialize the remote runs Textual application.
252
346
 
@@ -256,6 +350,7 @@ class RemoteRunsTextualApp(App[None]):
256
350
  callbacks: Callback bundle for data operations.
257
351
  agent_name: Optional agent name for display purposes.
258
352
  agent_id: Optional agent ID for display purposes.
353
+ ctx: Shared TUI context.
259
354
  """
260
355
  super().__init__()
261
356
  self.current_page = initial_page
@@ -265,6 +360,7 @@ class RemoteRunsTextualApp(App[None]):
265
360
  self.current_rows = initial_page.data[:]
266
361
  self.agent_name = (agent_name or "").strip()
267
362
  self.agent_id = (agent_id or "").strip()
363
+ self._ctx = ctx
268
364
  self._active_export_tasks: set[asyncio.Task[None]] = set()
269
365
  self._page_loader_task: asyncio.Task[Any] | None = None
270
366
  self._detail_loader_task: asyncio.Task[Any] | None = None
@@ -273,9 +369,10 @@ class RemoteRunsTextualApp(App[None]):
273
369
  def compose(self) -> ComposeResult:
274
370
  """Build layout."""
275
371
  yield Header()
276
- table = DataTable(id=RUNS_TABLE_ID)
277
- table.cursor_type = "row"
278
- table.add_columns(
372
+ yield Container(Toast(), id="toast-container")
373
+ table = DataTable(id=RUNS_TABLE_ID) # pragma: no cover - mocked in tests
374
+ table.cursor_type = "row" # pragma: no cover - mocked in tests
375
+ table.add_columns( # pragma: no cover - mocked in tests
279
376
  "Run UUID",
280
377
  "Type",
281
378
  "Status",
@@ -292,8 +389,18 @@ class RemoteRunsTextualApp(App[None]):
292
389
  )
293
390
  yield Footer() # pragma: no cover - interactive UI, tested via integration
294
391
 
392
+ def _ensure_toast_bus(self) -> None:
393
+ if self._ctx is None or self._ctx.toasts is not None:
394
+ return
395
+
396
+ def _notify(m: ToastBus.Changed) -> None:
397
+ self.post_message(m)
398
+
399
+ self._ctx.toasts = ToastBus(on_change=_notify)
400
+
295
401
  def on_mount(self) -> None:
296
402
  """Render the initial page."""
403
+ self._ensure_toast_bus()
297
404
  self._hide_loading()
298
405
  self._render_page(self.current_page)
299
406
 
@@ -315,7 +422,7 @@ class RemoteRunsTextualApp(App[None]):
315
422
  if self.current_rows:
316
423
  self.cursor_index = max(0, min(self.cursor_index, len(self.current_rows) - 1))
317
424
  table.focus()
318
- table.cursor_coordinate = (self.cursor_index, 0)
425
+ table.cursor_coordinate = Coordinate(self.cursor_index, 0)
319
426
  self.current_page = runs_page
320
427
  total_pages = max(1, (runs_page.total + runs_page.limit - 1) // runs_page.limit)
321
428
  agent_display = self.agent_name or "Runs"
@@ -554,8 +661,8 @@ class RemoteRunsTextualApp(App[None]):
554
661
  if detail is None:
555
662
  self._update_status("Failed to load run detail.", append=True)
556
663
  return
557
- self.push_screen(RunDetailScreen(detail, on_export=self.queue_export_from_detail))
558
- self._update_status("Detail view: ↑/↓ scroll · PgUp/PgDn · q/Esc close · e export")
664
+ self.push_screen(RunDetailScreen(detail, on_export=self.queue_export_from_detail, ctx=self._ctx))
665
+ self._update_status("Detail view: ↑/↓ scroll · PgUp/PgDn · q/Esc close · c copy ID · C copy JSON · e export")
559
666
 
560
667
  def queue_export_from_detail(self, detail: Any) -> None:
561
668
  """Start an export from the detail modal."""
@@ -71,7 +71,7 @@ class TerminalCapabilities:
71
71
  return "light" if luminance > 0.5 else "dark"
72
72
 
73
73
  @classmethod
74
- async def detect(cls) -> TerminalCapabilities:
74
+ async def detect(cls, *, detect_osc11: bool = True) -> TerminalCapabilities:
75
75
  """Detect terminal capabilities asynchronously with fast timeout.
76
76
 
77
77
  This method performs capability detection including OSC 11 background
@@ -80,6 +80,9 @@ class TerminalCapabilities:
80
80
  if the terminal doesn't respond within the timeout; use
81
81
  detect_terminal_background() for full 1-second timeout when needed.
82
82
 
83
+ Args:
84
+ detect_osc11: When False, skip OSC 11 background detection.
85
+
83
86
  Returns:
84
87
  TerminalCapabilities instance with detected capabilities.
85
88
  """
@@ -93,8 +96,10 @@ class TerminalCapabilities:
93
96
  mouse = tty_available and term not in ("dumb", "")
94
97
  truecolor = colorterm in ("truecolor", "24bit")
95
98
 
96
- # OSC 11 detection: use fast path (<100ms timeout)
97
- osc11_bg: str | None = await _detect_osc11_fast()
99
+ osc11_bg: str | None = None
100
+ if detect_osc11 and tty_available and sys.stdin.isatty():
101
+ # OSC 11 detection: use fast path (<100ms timeout)
102
+ osc11_bg = await _detect_osc11_fast()
98
103
 
99
104
  return cls(
100
105
  tty=tty_available,