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.
- glaip_sdk/agents/base.py +61 -10
- glaip_sdk/branding.py +113 -2
- glaip_sdk/cli/account_store.py +15 -0
- glaip_sdk/cli/config.py +1 -0
- glaip_sdk/cli/slash/remote_runs_controller.py +2 -0
- glaip_sdk/cli/slash/session.py +331 -30
- glaip_sdk/cli/slash/tui/accounts.tcss +72 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +960 -82
- glaip_sdk/cli/slash/tui/clipboard.py +56 -8
- glaip_sdk/cli/slash/tui/context.py +42 -14
- glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
- glaip_sdk/cli/slash/tui/layouts/harlequin.py +160 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
- glaip_sdk/cli/slash/tui/terminal.py +8 -3
- glaip_sdk/cli/slash/tui/theme/manager.py +28 -2
- glaip_sdk/cli/slash/tui/toast.py +270 -19
- glaip_sdk/cli/tui_settings.py +125 -0
- glaip_sdk/client/run_rendering.py +76 -29
- glaip_sdk/guardrails/__init__.py +80 -0
- glaip_sdk/guardrails/serializer.py +89 -0
- glaip_sdk/payload_schemas/agent.py +1 -0
- glaip_sdk/payload_schemas/guardrails.py +34 -0
- glaip_sdk/runner/langgraph.py +1 -0
- glaip_sdk/tools/base.py +23 -3
- {glaip_sdk-0.7.7.dist-info → glaip_sdk-0.7.12.dist-info}/METADATA +6 -4
- {glaip_sdk-0.7.7.dist-info → glaip_sdk-0.7.12.dist-info}/RECORD +29 -23
- {glaip_sdk-0.7.7.dist-info → glaip_sdk-0.7.12.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.7.7.dist-info → glaip_sdk-0.7.12.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.7.7.dist-info → glaip_sdk-0.7.12.dist-info}/top_level.txt +0 -0
|
@@ -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.
|
|
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
|
-
|
|
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:
|
|
41
|
+
keybinds: KeybindRegistry | None = None
|
|
41
42
|
theme: ThemeManager | None = None
|
|
42
43
|
toasts: ToastBus | None = None
|
|
43
|
-
clipboard:
|
|
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
|
|
51
|
-
theme, toasts, clipboard)
|
|
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
|
|
58
|
+
TUIContext instance with all services initialized.
|
|
55
59
|
"""
|
|
56
|
-
terminal = await TerminalCapabilities.detect()
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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__(
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
table
|
|
278
|
-
table.
|
|
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
|
-
|
|
97
|
-
|
|
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,
|