glaip-sdk 0.7.8__py3-none-any.whl → 0.7.10__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/cli/slash/remote_runs_controller.py +2 -0
- glaip_sdk/cli/slash/tui/accounts.tcss +12 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +72 -30
- glaip_sdk/cli/slash/tui/clipboard.py +56 -8
- glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
- glaip_sdk/cli/slash/tui/toast.py +270 -19
- 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-0.7.8.dist-info → glaip_sdk-0.7.10.dist-info}/METADATA +6 -4
- {glaip_sdk-0.7.8.dist-info → glaip_sdk-0.7.10.dist-info}/RECORD +18 -15
- {glaip_sdk-0.7.8.dist-info → glaip_sdk-0.7.10.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.7.8.dist-info → glaip_sdk-0.7.10.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.7.8.dist-info → glaip_sdk-0.7.10.dist-info}/top_level.txt +0 -0
|
@@ -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."""
|
glaip_sdk/cli/slash/tui/toast.py
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
"""Toast
|
|
2
|
-
|
|
3
|
-
Authors:
|
|
4
|
-
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
5
|
-
"""
|
|
1
|
+
"""Toast widgets and state management for the TUI."""
|
|
6
2
|
|
|
7
3
|
from __future__ import annotations
|
|
8
4
|
|
|
9
5
|
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
10
7
|
from dataclasses import dataclass
|
|
11
8
|
from enum import Enum
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual.message import Message
|
|
13
|
+
from textual.widgets import Static
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
class ToastVariant(str, Enum):
|
|
15
|
-
"""Toast message variant."""
|
|
17
|
+
"""Toast message variant for styling and behavior."""
|
|
16
18
|
|
|
17
19
|
INFO = "info"
|
|
18
20
|
SUCCESS = "success"
|
|
@@ -30,7 +32,7 @@ DEFAULT_TOAST_DURATIONS_SECONDS: dict[ToastVariant, float] = {
|
|
|
30
32
|
|
|
31
33
|
@dataclass(frozen=True, slots=True)
|
|
32
34
|
class ToastState:
|
|
33
|
-
"""Immutable toast
|
|
35
|
+
"""Immutable toast notification state."""
|
|
34
36
|
|
|
35
37
|
message: str
|
|
36
38
|
variant: ToastVariant
|
|
@@ -38,16 +40,25 @@ class ToastState:
|
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
class ToastBus:
|
|
41
|
-
"""
|
|
43
|
+
"""Toast state manager with auto-dismiss functionality."""
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
"""
|
|
45
|
+
class Changed(Message):
|
|
46
|
+
"""Message sent when toast state changes."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, state: ToastState | None) -> None:
|
|
49
|
+
"""Initialize the changed message with new toast state."""
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.state = state
|
|
52
|
+
|
|
53
|
+
def __init__(self, on_change: Callable[[ToastBus.Changed], None] | None = None) -> None:
|
|
54
|
+
"""Initialize the toast bus with optional change callback."""
|
|
45
55
|
self._state: ToastState | None = None
|
|
46
56
|
self._dismiss_task: asyncio.Task[None] | None = None
|
|
57
|
+
self._on_change = on_change
|
|
47
58
|
|
|
48
59
|
@property
|
|
49
60
|
def state(self) -> ToastState | None:
|
|
50
|
-
"""Return the current toast state."""
|
|
61
|
+
"""Return the current toast state, or None if no toast is shown."""
|
|
51
62
|
return self._state
|
|
52
63
|
|
|
53
64
|
def show(
|
|
@@ -57,7 +68,14 @@ class ToastBus:
|
|
|
57
68
|
*,
|
|
58
69
|
duration_seconds: float | None = None,
|
|
59
70
|
) -> None:
|
|
60
|
-
"""
|
|
71
|
+
"""Show a toast notification with the given message and variant.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
message: The message to display in the toast.
|
|
75
|
+
variant: The visual variant of the toast (INFO, SUCCESS, WARNING, ERROR).
|
|
76
|
+
duration_seconds: Optional custom duration in seconds. If None, uses default
|
|
77
|
+
duration for the variant (2s for SUCCESS, 3s for INFO/WARNING, 5s for ERROR).
|
|
78
|
+
"""
|
|
61
79
|
resolved_variant = self._coerce_variant(variant)
|
|
62
80
|
resolved_duration = (
|
|
63
81
|
DEFAULT_TOAST_DURATIONS_SECONDS[resolved_variant] if duration_seconds is None else float(duration_seconds)
|
|
@@ -80,23 +98,26 @@ class ToastBus:
|
|
|
80
98
|
) from None
|
|
81
99
|
|
|
82
100
|
self._dismiss_task = loop.create_task(self._auto_dismiss(resolved_duration))
|
|
101
|
+
self._notify_changed()
|
|
83
102
|
|
|
84
103
|
def clear(self) -> None:
|
|
85
|
-
"""Clear the current toast."""
|
|
104
|
+
"""Clear the current toast notification immediately."""
|
|
86
105
|
self._cancel_dismiss_task()
|
|
87
106
|
self._state = None
|
|
107
|
+
self._notify_changed()
|
|
88
108
|
|
|
89
109
|
def copy_success(self, label: str | None = None) -> None:
|
|
90
|
-
"""Show
|
|
110
|
+
"""Show a success toast for clipboard copy operations.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
label: Optional label for what was copied (e.g., "Run ID", "JSON").
|
|
114
|
+
"""
|
|
91
115
|
message = "Copied to clipboard" if not label else f"Copied {label} to clipboard"
|
|
92
116
|
self.show(message=message, variant=ToastVariant.SUCCESS)
|
|
93
117
|
|
|
94
118
|
def copy_failed(self) -> None:
|
|
95
|
-
"""Show clipboard
|
|
96
|
-
self.show(
|
|
97
|
-
message="Clipboard unavailable. Text printed below",
|
|
98
|
-
variant=ToastVariant.WARNING,
|
|
99
|
-
)
|
|
119
|
+
"""Show a warning toast when clipboard copy fails."""
|
|
120
|
+
self.show(message="Clipboard unavailable. Text printed below.", variant=ToastVariant.WARNING)
|
|
100
121
|
|
|
101
122
|
def _coerce_variant(self, variant: ToastVariant | str) -> ToastVariant:
|
|
102
123
|
if isinstance(variant, ToastVariant):
|
|
@@ -121,3 +142,233 @@ class ToastBus:
|
|
|
121
142
|
|
|
122
143
|
self._state = None
|
|
123
144
|
self._dismiss_task = None
|
|
145
|
+
self._notify_changed()
|
|
146
|
+
|
|
147
|
+
def _notify_changed(self) -> None:
|
|
148
|
+
if self._on_change:
|
|
149
|
+
self._on_change(ToastBus.Changed(self._state))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ToastHandlerMixin:
|
|
153
|
+
"""Mixin providing common toast handling functionality.
|
|
154
|
+
|
|
155
|
+
Classes that inherit from this mixin can handle ToastBus.Changed messages
|
|
156
|
+
by automatically updating all Toast widgets in the component tree.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def on_toast_bus_changed(self, message: ToastBus.Changed) -> None:
|
|
160
|
+
"""Refresh the toast widget when the toast bus updates.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
message: The toast bus changed message containing the new state.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
for toast in self.query(Toast):
|
|
167
|
+
toast.update_state(message.state)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ClipboardToastMixin:
|
|
173
|
+
"""Mixin providing clipboard and toast orchestration functionality.
|
|
174
|
+
|
|
175
|
+
Classes that inherit from this mixin get shared clipboard adapter selection,
|
|
176
|
+
OSC52 writer setup, toast bus lookup, and copy-success/failure orchestration.
|
|
177
|
+
This consolidates duplicate clipboard/toast logic across TUI apps.
|
|
178
|
+
|
|
179
|
+
Expected attributes:
|
|
180
|
+
_ctx: TUIContext | None - Shared TUI context (optional)
|
|
181
|
+
_clipboard: ClipboardAdapter | None - Cached clipboard adapter (optional)
|
|
182
|
+
_local_toasts: ToastBus | None - Local toast bus instance (optional)
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def _clipboard_adapter(self) -> Any: # ClipboardAdapter
|
|
186
|
+
"""Get or create a clipboard adapter instance.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
ClipboardAdapter instance, preferring context's adapter if available.
|
|
190
|
+
"""
|
|
191
|
+
# Import here to avoid circular dependency
|
|
192
|
+
from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter # noqa: PLC0415
|
|
193
|
+
|
|
194
|
+
ctx = getattr(self, "_ctx", None)
|
|
195
|
+
clipboard = getattr(self, "_clipboard", None)
|
|
196
|
+
|
|
197
|
+
if ctx is not None and ctx.clipboard is not None:
|
|
198
|
+
return cast(ClipboardAdapter, ctx.clipboard)
|
|
199
|
+
if clipboard is not None:
|
|
200
|
+
return clipboard
|
|
201
|
+
|
|
202
|
+
adapter = ClipboardAdapter(terminal=ctx.terminal if ctx else None)
|
|
203
|
+
if ctx is not None:
|
|
204
|
+
ctx.clipboard = adapter
|
|
205
|
+
else:
|
|
206
|
+
self._clipboard = adapter
|
|
207
|
+
return adapter
|
|
208
|
+
|
|
209
|
+
def _osc52_writer(self) -> Callable[[str], Any] | None:
|
|
210
|
+
"""Get an OSC52 writer function if console output is available.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Writer function that writes OSC52 sequences to console output, or None.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
# Try self.app.console first (for Screen subclasses)
|
|
217
|
+
if hasattr(self, "app") and hasattr(self.app, "console"):
|
|
218
|
+
console = self.app.console
|
|
219
|
+
# Fall back to self.console (for App subclasses)
|
|
220
|
+
else:
|
|
221
|
+
console = getattr(self, "console", None)
|
|
222
|
+
except Exception:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
if console is None:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
output = getattr(console, "file", None)
|
|
229
|
+
if output is None:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def _write(sequence: str, _output: Any = output) -> None:
|
|
233
|
+
_output.write(sequence)
|
|
234
|
+
_output.flush()
|
|
235
|
+
|
|
236
|
+
return _write
|
|
237
|
+
|
|
238
|
+
def _toast_bus(self) -> ToastBus | None:
|
|
239
|
+
"""Get the toast bus instance.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
ToastBus instance, preferring context's bus if available, or None.
|
|
243
|
+
"""
|
|
244
|
+
local_toasts = getattr(self, "_local_toasts", None)
|
|
245
|
+
ctx = getattr(self, "_ctx", None)
|
|
246
|
+
|
|
247
|
+
if local_toasts is not None:
|
|
248
|
+
return local_toasts
|
|
249
|
+
if ctx is not None and ctx.toasts is not None:
|
|
250
|
+
return ctx.toasts
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
def _copy_to_clipboard(self, text: str, *, label: str | None = None) -> None:
|
|
254
|
+
"""Copy text to clipboard and show toast notification.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
text: The text to copy to clipboard.
|
|
258
|
+
label: Optional label for what was copied (e.g., "Run ID", "JSON").
|
|
259
|
+
"""
|
|
260
|
+
adapter = self._clipboard_adapter()
|
|
261
|
+
writer = self._osc52_writer()
|
|
262
|
+
if writer:
|
|
263
|
+
result = adapter.copy(text, writer=writer)
|
|
264
|
+
else:
|
|
265
|
+
result = adapter.copy(text)
|
|
266
|
+
|
|
267
|
+
toasts = self._toast_bus()
|
|
268
|
+
if result.success:
|
|
269
|
+
if toasts:
|
|
270
|
+
toasts.copy_success(label)
|
|
271
|
+
else:
|
|
272
|
+
# Fallback to status announcement if toast bus unavailable
|
|
273
|
+
if hasattr(self, "_announce_status"):
|
|
274
|
+
if label:
|
|
275
|
+
self._announce_status(f"Copied {label} to clipboard.")
|
|
276
|
+
else:
|
|
277
|
+
self._announce_status("Copied to clipboard.")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
# Copy failed
|
|
281
|
+
if toasts:
|
|
282
|
+
toasts.copy_failed()
|
|
283
|
+
else:
|
|
284
|
+
# Fallback to status announcement if toast bus unavailable
|
|
285
|
+
if hasattr(self, "_announce_status"):
|
|
286
|
+
self._announce_status("Clipboard unavailable. Text printed below.")
|
|
287
|
+
|
|
288
|
+
# Append fallback text output
|
|
289
|
+
if hasattr(self, "_append_copy_fallback"):
|
|
290
|
+
self._append_copy_fallback(text)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class Toast(Static):
|
|
294
|
+
"""A Textual widget that displays toast notifications at the top-right of the screen.
|
|
295
|
+
|
|
296
|
+
The Toast widget is updated via `update_state()` calls from message handlers
|
|
297
|
+
(e.g., `on_toast_bus_changed`). The widget does not auto-subscribe to ToastBus
|
|
298
|
+
state changes; the app must call `update_state()` when toast state changes.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
DEFAULT_CSS = """
|
|
302
|
+
#toast-container {
|
|
303
|
+
width: 100%;
|
|
304
|
+
height: auto;
|
|
305
|
+
dock: top;
|
|
306
|
+
align: right top;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
Toast {
|
|
310
|
+
width: auto;
|
|
311
|
+
min-width: 20;
|
|
312
|
+
max-width: 40;
|
|
313
|
+
height: auto;
|
|
314
|
+
padding: 0 1;
|
|
315
|
+
margin: 1 2;
|
|
316
|
+
background: $surface;
|
|
317
|
+
color: $text;
|
|
318
|
+
border: solid $primary;
|
|
319
|
+
display: none;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
Toast.visible {
|
|
323
|
+
display: block;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
Toast.info {
|
|
327
|
+
border: solid $accent;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
Toast.success {
|
|
331
|
+
border: solid $success;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
Toast.warning {
|
|
335
|
+
border: solid $warning;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
Toast.error {
|
|
339
|
+
border: solid $error;
|
|
340
|
+
}
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
def __init__(self) -> None:
|
|
344
|
+
"""Initialize the Toast widget.
|
|
345
|
+
|
|
346
|
+
The widget is updated via `update_state()` calls from message handlers
|
|
347
|
+
(e.g., `on_toast_bus_changed`). The widget does not auto-subscribe to
|
|
348
|
+
a ToastBus; the app must call `update_state()` when toast state changes.
|
|
349
|
+
"""
|
|
350
|
+
super().__init__("")
|
|
351
|
+
|
|
352
|
+
def update_state(self, state: ToastState | None) -> None:
|
|
353
|
+
"""Update the toast display based on the provided state.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
state: The toast state to display, or None to hide the toast.
|
|
357
|
+
"""
|
|
358
|
+
if not state:
|
|
359
|
+
self.remove_class("visible")
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
icon = "ℹ️"
|
|
363
|
+
if state.variant == ToastVariant.SUCCESS:
|
|
364
|
+
icon = "✅"
|
|
365
|
+
elif state.variant == ToastVariant.WARNING:
|
|
366
|
+
icon = "⚠️"
|
|
367
|
+
elif state.variant == ToastVariant.ERROR:
|
|
368
|
+
icon = "❌"
|
|
369
|
+
|
|
370
|
+
self.update(Text.assemble((f"{icon} ", "bold"), state.message))
|
|
371
|
+
|
|
372
|
+
self.remove_class("info", "success", "warning", "error")
|
|
373
|
+
self.add_class(state.variant.value)
|
|
374
|
+
self.add_class("visible")
|