glaip-sdk 0.7.0__py3-none-any.whl → 0.7.1__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/accounts_controller.py +3 -1
- glaip_sdk/cli/slash/session.py +19 -0
- glaip_sdk/cli/slash/tui/__init__.py +16 -0
- glaip_sdk/cli/slash/tui/accounts.tcss +7 -5
- glaip_sdk/cli/slash/tui/accounts_app.py +66 -9
- glaip_sdk/cli/slash/tui/clipboard.py +147 -0
- glaip_sdk/cli/slash/tui/context.py +11 -3
- glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
- glaip_sdk/cli/slash/tui/terminal.py +2 -2
- glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
- glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
- glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
- glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
- glaip_sdk/cli/slash/tui/toast.py +123 -0
- {glaip_sdk-0.7.0.dist-info → glaip_sdk-0.7.1.dist-info}/METADATA +1 -1
- {glaip_sdk-0.7.0.dist-info → glaip_sdk-0.7.1.dist-info}/RECORD +19 -12
- {glaip_sdk-0.7.0.dist-info → glaip_sdk-0.7.1.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.7.0.dist-info → glaip_sdk-0.7.1.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.7.0.dist-info → glaip_sdk-0.7.1.dist-info}/top_level.txt +0 -0
|
@@ -170,7 +170,9 @@ class AccountsController:
|
|
|
170
170
|
callbacks = AccountsTUICallbacks(switch_account=_switch_in_textual)
|
|
171
171
|
active = next((row["name"] for row in rows if row.get("active")), None)
|
|
172
172
|
try:
|
|
173
|
-
|
|
173
|
+
# Inject TUI context for theme support
|
|
174
|
+
tui_ctx = getattr(self.session, "tui_ctx", None)
|
|
175
|
+
run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks, ctx=tui_ctx)
|
|
174
176
|
except Exception as exc: # pragma: no cover - defensive around Textual failures
|
|
175
177
|
self.console.print(f"[{WARNING_STYLE}]Accounts browser exited unexpectedly: {exc}[/]")
|
|
176
178
|
|
glaip_sdk/cli/slash/session.py
CHANGED
|
@@ -6,6 +6,7 @@ Authors:
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import asyncio
|
|
9
10
|
import importlib
|
|
10
11
|
import os
|
|
11
12
|
import shlex
|
|
@@ -51,6 +52,7 @@ from glaip_sdk.cli.slash.prompt import (
|
|
|
51
52
|
to_formatted_text,
|
|
52
53
|
)
|
|
53
54
|
from glaip_sdk.cli.slash.remote_runs_controller import RemoteRunsController
|
|
55
|
+
from glaip_sdk.cli.slash.tui.context import TUIContext
|
|
54
56
|
from glaip_sdk.cli.transcript import (
|
|
55
57
|
export_cached_transcript,
|
|
56
58
|
load_history_snapshot,
|
|
@@ -186,6 +188,7 @@ class SlashSession:
|
|
|
186
188
|
self._update_notifier = maybe_notify_update
|
|
187
189
|
self._home_hint_shown = False
|
|
188
190
|
self._agent_transcript_ready: dict[str, str] = {}
|
|
191
|
+
self.tui_ctx: TUIContext | None = None
|
|
189
192
|
|
|
190
193
|
# ------------------------------------------------------------------
|
|
191
194
|
# Session orchestration
|
|
@@ -215,6 +218,22 @@ class SlashSession:
|
|
|
215
218
|
|
|
216
219
|
def run(self, initial_commands: Iterable[str] | None = None) -> None:
|
|
217
220
|
"""Start the command palette session loop."""
|
|
221
|
+
# Initialize TUI context asynchronously
|
|
222
|
+
try:
|
|
223
|
+
self.tui_ctx = asyncio.run(TUIContext.create())
|
|
224
|
+
except RuntimeError:
|
|
225
|
+
try:
|
|
226
|
+
loop = asyncio.get_event_loop()
|
|
227
|
+
except RuntimeError:
|
|
228
|
+
self.tui_ctx = None
|
|
229
|
+
else:
|
|
230
|
+
if loop.is_running():
|
|
231
|
+
self.tui_ctx = None
|
|
232
|
+
else:
|
|
233
|
+
self.tui_ctx = loop.run_until_complete(TUIContext.create())
|
|
234
|
+
except Exception:
|
|
235
|
+
self.tui_ctx = None
|
|
236
|
+
|
|
218
237
|
ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else None
|
|
219
238
|
previous_session = None
|
|
220
239
|
if ctx_obj is not None:
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
"""Textual UI helpers for slash commands."""
|
|
2
2
|
|
|
3
|
+
from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter, ClipboardResult
|
|
3
4
|
from glaip_sdk.cli.slash.tui.context import TUIContext
|
|
5
|
+
from glaip_sdk.cli.slash.tui.keybind_registry import (
|
|
6
|
+
Keybind,
|
|
7
|
+
KeybindRegistry,
|
|
8
|
+
format_key_sequence,
|
|
9
|
+
parse_key_sequence,
|
|
10
|
+
)
|
|
11
|
+
from glaip_sdk.cli.slash.tui.toast import ToastBus, ToastVariant
|
|
4
12
|
from glaip_sdk.cli.slash.tui.remote_runs_app import (
|
|
5
13
|
RemoteRunsTextualApp,
|
|
6
14
|
RemoteRunsTUICallbacks,
|
|
@@ -10,9 +18,17 @@ from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities, detect_termin
|
|
|
10
18
|
|
|
11
19
|
__all__ = [
|
|
12
20
|
"TUIContext",
|
|
21
|
+
"ToastBus",
|
|
22
|
+
"ToastVariant",
|
|
13
23
|
"TerminalCapabilities",
|
|
14
24
|
"detect_terminal_background",
|
|
15
25
|
"RemoteRunsTextualApp",
|
|
16
26
|
"RemoteRunsTUICallbacks",
|
|
17
27
|
"run_remote_runs_textual",
|
|
28
|
+
"KeybindRegistry",
|
|
29
|
+
"Keybind",
|
|
30
|
+
"parse_key_sequence",
|
|
31
|
+
"format_key_sequence",
|
|
32
|
+
"ClipboardAdapter",
|
|
33
|
+
"ClipboardResult",
|
|
18
34
|
]
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
#env-lock {
|
|
13
13
|
padding: 0 1 0 1;
|
|
14
|
-
color:
|
|
14
|
+
color: $warning;
|
|
15
15
|
height: 1;
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
padding: 0 1 0 1;
|
|
46
46
|
margin: 0 0 0 0;
|
|
47
47
|
height: 1fr;
|
|
48
|
+
border: tall $primary;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
#status-bar {
|
|
@@ -58,11 +59,12 @@
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
#status {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
color:
|
|
62
|
+
height: 3;
|
|
63
|
+
padding: 0 1;
|
|
64
|
+
color: $secondary;
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
|
|
66
68
|
.form-label {
|
|
67
69
|
padding: 0 1 0 1;
|
|
68
70
|
}
|
|
@@ -73,7 +75,7 @@
|
|
|
73
75
|
|
|
74
76
|
#form-status, #confirm-status {
|
|
75
77
|
padding: 0 1;
|
|
76
|
-
color:
|
|
78
|
+
color: $warning;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
#form-test {
|
|
@@ -23,7 +23,10 @@ from glaip_sdk.cli.slash.accounts_shared import (
|
|
|
23
23
|
env_credentials_present,
|
|
24
24
|
)
|
|
25
25
|
from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
|
|
26
|
+
from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
|
|
27
|
+
from glaip_sdk.cli.slash.tui.context import TUIContext
|
|
26
28
|
from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
|
|
29
|
+
from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
|
|
27
30
|
from glaip_sdk.cli.validators import validate_api_key
|
|
28
31
|
from glaip_sdk.utils.validation import validate_url
|
|
29
32
|
|
|
@@ -51,6 +54,13 @@ except Exception: # pragma: no cover - optional dependency
|
|
|
51
54
|
LoadingIndicator = None # type: ignore[assignment]
|
|
52
55
|
ModalScreen = None # type: ignore[assignment]
|
|
53
56
|
Static = None # type: ignore[assignment]
|
|
57
|
+
Theme = None # type: ignore[assignment]
|
|
58
|
+
|
|
59
|
+
if App is not None:
|
|
60
|
+
try: # pragma: no cover - optional dependency
|
|
61
|
+
from textual.theme import Theme
|
|
62
|
+
except Exception: # pragma: no cover - optional dependency
|
|
63
|
+
Theme = None # type: ignore[assignment]
|
|
54
64
|
|
|
55
65
|
TEXTUAL_SUPPORTED = App is not None and DataTable is not None
|
|
56
66
|
|
|
@@ -201,11 +211,12 @@ def run_accounts_textual(
|
|
|
201
211
|
active_account: str | None,
|
|
202
212
|
env_lock: bool,
|
|
203
213
|
callbacks: AccountsTUICallbacks,
|
|
214
|
+
ctx: TUIContext | None = None,
|
|
204
215
|
) -> None:
|
|
205
216
|
"""Launch the Textual accounts browser if dependencies are available."""
|
|
206
217
|
if not TEXTUAL_SUPPORTED:
|
|
207
218
|
return
|
|
208
|
-
app = AccountsTextualApp(rows, active_account, env_lock, callbacks)
|
|
219
|
+
app = AccountsTextualApp(rows, active_account, env_lock, callbacks, ctx=ctx)
|
|
209
220
|
app.run()
|
|
210
221
|
|
|
211
222
|
|
|
@@ -379,16 +390,18 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
379
390
|
|
|
380
391
|
CSS_PATH = CSS_FILE_NAME
|
|
381
392
|
BINDINGS = [
|
|
382
|
-
Binding("enter", "switch_row", "Switch", show=True),
|
|
383
|
-
Binding("return", "switch_row", "Switch", show=False),
|
|
384
|
-
Binding("/", "focus_filter", "Filter", show=True),
|
|
385
|
-
Binding("a", "add_account", "Add", show=True),
|
|
386
|
-
Binding("e", "edit_account", "Edit", show=True),
|
|
387
|
-
Binding("d", "delete_account", "Delete", show=True),
|
|
393
|
+
Binding("enter", "switch_row", "Switch", show=True) if Binding else None,
|
|
394
|
+
Binding("return", "switch_row", "Switch", show=False) if Binding else None,
|
|
395
|
+
Binding("/", "focus_filter", "Filter", show=True) if Binding else None,
|
|
396
|
+
Binding("a", "add_account", "Add", show=True) if Binding else None,
|
|
397
|
+
Binding("e", "edit_account", "Edit", show=True) if Binding else None,
|
|
398
|
+
Binding("d", "delete_account", "Delete", show=True) if Binding else None,
|
|
399
|
+
Binding("c", "copy_account", "Copy", show=True) if Binding else None,
|
|
388
400
|
# Esc clears filter when focused/non-empty; otherwise exits
|
|
389
|
-
Binding("escape", "clear_or_exit", "Close", priority=True),
|
|
390
|
-
Binding("q", "app_exit", "Close", priority=True),
|
|
401
|
+
Binding("escape", "clear_or_exit", "Close", priority=True) if Binding else None,
|
|
402
|
+
Binding("q", "app_exit", "Close", priority=True) if Binding else None,
|
|
391
403
|
]
|
|
404
|
+
BINDINGS = [b for b in BINDINGS if b is not None]
|
|
392
405
|
|
|
393
406
|
def __init__(
|
|
394
407
|
self,
|
|
@@ -396,6 +409,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
396
409
|
active_account: str | None,
|
|
397
410
|
env_lock: bool,
|
|
398
411
|
callbacks: AccountsTUICallbacks,
|
|
412
|
+
ctx: TUIContext | None = None,
|
|
399
413
|
) -> None:
|
|
400
414
|
"""Initialize the Textual accounts app.
|
|
401
415
|
|
|
@@ -404,6 +418,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
404
418
|
active_account: Name of the currently active account.
|
|
405
419
|
env_lock: Whether environment credentials are locking account switching.
|
|
406
420
|
callbacks: Callbacks for account switching operations.
|
|
421
|
+
ctx: Shared TUI context.
|
|
407
422
|
"""
|
|
408
423
|
super().__init__()
|
|
409
424
|
self._store = get_account_store()
|
|
@@ -411,6 +426,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
411
426
|
self._active_account = active_account
|
|
412
427
|
self._env_lock = env_lock
|
|
413
428
|
self._callbacks = callbacks
|
|
429
|
+
self._ctx = ctx
|
|
414
430
|
self._filter_text: str = ""
|
|
415
431
|
self._is_switching = False
|
|
416
432
|
|
|
@@ -449,6 +465,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
449
465
|
|
|
450
466
|
def on_mount(self) -> None:
|
|
451
467
|
"""Configure table columns and load rows."""
|
|
468
|
+
self._apply_theme()
|
|
452
469
|
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
453
470
|
table.add_column("Name", width=20)
|
|
454
471
|
table.add_column("API URL", width=40)
|
|
@@ -752,6 +769,26 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
752
769
|
return
|
|
753
770
|
self.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
|
|
754
771
|
|
|
772
|
+
def action_copy_account(self) -> None:
|
|
773
|
+
"""Copy selected account name and URL to clipboard."""
|
|
774
|
+
name = self._get_selected_name()
|
|
775
|
+
if not name:
|
|
776
|
+
self._set_status("Select an account to copy.", "yellow")
|
|
777
|
+
return
|
|
778
|
+
|
|
779
|
+
account = self._store.get_account(name)
|
|
780
|
+
if not account:
|
|
781
|
+
return
|
|
782
|
+
|
|
783
|
+
text = f"Account: {name}\nURL: {account.get('api_url', '')}"
|
|
784
|
+
adapter = ClipboardAdapter()
|
|
785
|
+
result = adapter.copy(text)
|
|
786
|
+
|
|
787
|
+
if result.success:
|
|
788
|
+
self._set_status(f"Copied '{name}' to clipboard.", "green")
|
|
789
|
+
else:
|
|
790
|
+
self._set_status(f"Copy failed: {result.message}", "red")
|
|
791
|
+
|
|
755
792
|
def _check_env_lock_hotkey(self) -> bool:
|
|
756
793
|
"""Prevent mutations when env credentials are present."""
|
|
757
794
|
if not self._is_env_locked():
|
|
@@ -874,3 +911,23 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
874
911
|
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
875
912
|
clear_btn = self.query_one("#filter-clear", Button)
|
|
876
913
|
clear_btn.display = bool(filter_input.value or self._filter_text)
|
|
914
|
+
|
|
915
|
+
def _apply_theme(self) -> None:
|
|
916
|
+
"""Register built-in themes and set the active one from context."""
|
|
917
|
+
if not self._ctx or not self._ctx.theme or Theme is None:
|
|
918
|
+
return
|
|
919
|
+
|
|
920
|
+
for name, tokens in _BUILTIN_THEMES.items():
|
|
921
|
+
self.register_theme(
|
|
922
|
+
Theme(
|
|
923
|
+
name=name,
|
|
924
|
+
primary=tokens.primary,
|
|
925
|
+
secondary=tokens.secondary,
|
|
926
|
+
accent=tokens.accent,
|
|
927
|
+
warning=tokens.warning,
|
|
928
|
+
error=tokens.error,
|
|
929
|
+
success=tokens.success,
|
|
930
|
+
)
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
self.theme = self._ctx.theme.theme_name
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Clipboard adapter for TUI copy actions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from typing import Any
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
|
|
16
|
+
from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities, detect_osc52_support
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClipboardMethod(str, Enum):
|
|
20
|
+
"""Supported clipboard backends."""
|
|
21
|
+
|
|
22
|
+
OSC52 = "osc52"
|
|
23
|
+
PBCOPY = "pbcopy"
|
|
24
|
+
XCLIP = "xclip"
|
|
25
|
+
XSEL = "xsel"
|
|
26
|
+
WL_COPY = "wl-copy"
|
|
27
|
+
CLIP = "clip"
|
|
28
|
+
NONE = "none"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class ClipboardResult:
|
|
33
|
+
"""Result of a clipboard operation."""
|
|
34
|
+
|
|
35
|
+
success: bool
|
|
36
|
+
method: ClipboardMethod
|
|
37
|
+
message: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_SUBPROCESS_COMMANDS: dict[ClipboardMethod, list[str]] = {
|
|
41
|
+
ClipboardMethod.PBCOPY: ["pbcopy"],
|
|
42
|
+
ClipboardMethod.XCLIP: ["xclip", "-selection", "clipboard"],
|
|
43
|
+
ClipboardMethod.XSEL: ["xsel", "--clipboard", "--input"],
|
|
44
|
+
ClipboardMethod.WL_COPY: ["wl-copy"],
|
|
45
|
+
ClipboardMethod.CLIP: ["clip"],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ClipboardAdapter:
|
|
50
|
+
"""Cross-platform clipboard access with OSC 52 fallback."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
terminal: TerminalCapabilities | None = None,
|
|
56
|
+
method: ClipboardMethod | None = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Initialize the adapter."""
|
|
59
|
+
self._terminal = terminal
|
|
60
|
+
self._method = method or self._detect_method()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def method(self) -> ClipboardMethod:
|
|
64
|
+
"""Return the detected clipboard backend."""
|
|
65
|
+
return self._method
|
|
66
|
+
|
|
67
|
+
def copy(self, text: str, *, writer: Callable[[str], Any] | None = None) -> ClipboardResult:
|
|
68
|
+
"""Copy text to clipboard using the best available method.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
text: Text to copy.
|
|
72
|
+
writer: Optional function to write OSC 52 sequence (e.g., self.app.console.write).
|
|
73
|
+
Defaults to sys.stdout.write if not provided.
|
|
74
|
+
"""
|
|
75
|
+
if self._method == ClipboardMethod.OSC52:
|
|
76
|
+
return self._copy_osc52(text, writer=writer)
|
|
77
|
+
|
|
78
|
+
command = _SUBPROCESS_COMMANDS.get(self._method)
|
|
79
|
+
if command is None:
|
|
80
|
+
return self._copy_osc52(text, writer=writer)
|
|
81
|
+
|
|
82
|
+
result = self._copy_subprocess(command, text)
|
|
83
|
+
if not result.success:
|
|
84
|
+
return self._copy_osc52(text, writer=writer)
|
|
85
|
+
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
def _detect_method(self) -> ClipboardMethod:
|
|
89
|
+
if self._terminal.osc52 if self._terminal else detect_osc52_support():
|
|
90
|
+
return ClipboardMethod.OSC52
|
|
91
|
+
|
|
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
|
+
return ClipboardMethod.NONE
|
|
100
|
+
|
|
101
|
+
def _detect_darwin_method(self) -> ClipboardMethod:
|
|
102
|
+
return ClipboardMethod.PBCOPY if shutil.which("pbcopy") else ClipboardMethod.NONE
|
|
103
|
+
|
|
104
|
+
def _detect_linux_method(self) -> ClipboardMethod:
|
|
105
|
+
if not os.getenv("DISPLAY") and not os.getenv("WAYLAND_DISPLAY"):
|
|
106
|
+
return ClipboardMethod.NONE
|
|
107
|
+
|
|
108
|
+
for cmd, method in (
|
|
109
|
+
("xclip", ClipboardMethod.XCLIP),
|
|
110
|
+
("xsel", ClipboardMethod.XSEL),
|
|
111
|
+
("wl-copy", ClipboardMethod.WL_COPY),
|
|
112
|
+
):
|
|
113
|
+
if shutil.which(cmd):
|
|
114
|
+
return method
|
|
115
|
+
return ClipboardMethod.NONE
|
|
116
|
+
|
|
117
|
+
def _detect_windows_method(self) -> ClipboardMethod:
|
|
118
|
+
return ClipboardMethod.CLIP if shutil.which("clip") else ClipboardMethod.NONE
|
|
119
|
+
|
|
120
|
+
def _copy_osc52(self, text: str, *, writer: Callable[[str], Any] | None = None) -> ClipboardResult:
|
|
121
|
+
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
122
|
+
sequence = f"\x1b]52;c;{encoded}\x07"
|
|
123
|
+
try:
|
|
124
|
+
if writer:
|
|
125
|
+
writer(sequence)
|
|
126
|
+
else:
|
|
127
|
+
sys.stdout.write(sequence)
|
|
128
|
+
sys.stdout.flush()
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
return ClipboardResult(False, ClipboardMethod.OSC52, str(exc))
|
|
131
|
+
|
|
132
|
+
return ClipboardResult(True, ClipboardMethod.OSC52, "Copied to clipboard")
|
|
133
|
+
|
|
134
|
+
def _copy_subprocess(self, cmd: list[str], text: str) -> ClipboardResult:
|
|
135
|
+
try:
|
|
136
|
+
completed = subprocess.run(
|
|
137
|
+
cmd,
|
|
138
|
+
input=text.encode("utf-8"),
|
|
139
|
+
check=False,
|
|
140
|
+
)
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
return ClipboardResult(False, self._method, str(exc))
|
|
143
|
+
|
|
144
|
+
if completed.returncode == 0:
|
|
145
|
+
return ClipboardResult(True, self._method, "Copied to clipboard")
|
|
146
|
+
|
|
147
|
+
return ClipboardResult(False, self._method, f"Command failed: {completed.returncode}")
|
|
@@ -10,9 +10,15 @@ Authors:
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import os
|
|
13
14
|
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
14
16
|
|
|
15
17
|
from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
|
|
18
|
+
from glaip_sdk.cli.slash.tui.theme import ThemeManager
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from glaip_sdk.cli.slash.tui.toast import ToastBus
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
@dataclass
|
|
@@ -32,8 +38,8 @@ class TUIContext:
|
|
|
32
38
|
|
|
33
39
|
terminal: TerminalCapabilities
|
|
34
40
|
keybinds: object | None = None
|
|
35
|
-
theme:
|
|
36
|
-
toasts:
|
|
41
|
+
theme: ThemeManager | None = None
|
|
42
|
+
toasts: ToastBus | None = None
|
|
37
43
|
clipboard: object | None = None
|
|
38
44
|
|
|
39
45
|
@classmethod
|
|
@@ -48,4 +54,6 @@ class TUIContext:
|
|
|
48
54
|
TUIContext instance with terminal capabilities detected.
|
|
49
55
|
"""
|
|
50
56
|
terminal = await TerminalCapabilities.detect()
|
|
51
|
-
|
|
57
|
+
theme_name = os.getenv("AIP_TUI_THEME") or None
|
|
58
|
+
theme = ThemeManager(terminal, theme=theme_name)
|
|
59
|
+
return cls(terminal=terminal, theme=theme)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Keybinding registry helpers for TUI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
DEFAULT_LEADER = "ctrl+x"
|
|
9
|
+
_LEADER_TOKEN = "<leader>"
|
|
10
|
+
|
|
11
|
+
_MODIFIER_ORDER = ("ctrl", "alt", "shift", "meta")
|
|
12
|
+
_MODIFIER_SYNONYMS = {
|
|
13
|
+
"control": "ctrl",
|
|
14
|
+
"ctl": "ctrl",
|
|
15
|
+
"cmd": "meta",
|
|
16
|
+
"command": "meta",
|
|
17
|
+
"option": "alt",
|
|
18
|
+
"return": "enter",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_KEY_SYNONYMS = {
|
|
22
|
+
"esc": "escape",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_KEY_DISPLAY = {
|
|
26
|
+
"escape": "Esc",
|
|
27
|
+
"enter": "Enter",
|
|
28
|
+
"space": "Space",
|
|
29
|
+
"tab": "Tab",
|
|
30
|
+
"backspace": "Backspace",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Keybind:
|
|
36
|
+
"""A registered keybinding."""
|
|
37
|
+
|
|
38
|
+
action: str
|
|
39
|
+
sequence: tuple[str, ...]
|
|
40
|
+
description: str
|
|
41
|
+
category: str | None = None
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
"""Return a readable representation of the keybind."""
|
|
45
|
+
return (
|
|
46
|
+
f"Keybind(action={self.action!r}, sequence={self.sequence}, "
|
|
47
|
+
f"description={self.description!r}, category={self.category!r})"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class KeybindRegistry:
|
|
52
|
+
"""Central registry of keybindings and associated metadata."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, *, leader: str = DEFAULT_LEADER) -> None:
|
|
55
|
+
"""Initialize the registry."""
|
|
56
|
+
normalized = _normalize_chord(leader)
|
|
57
|
+
self._leader = normalized or DEFAULT_LEADER
|
|
58
|
+
self._keybinds: dict[str, Keybind] = {}
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def leader(self) -> str:
|
|
62
|
+
"""Return the normalized leader chord."""
|
|
63
|
+
return self._leader
|
|
64
|
+
|
|
65
|
+
def register(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
action: str,
|
|
69
|
+
key: str,
|
|
70
|
+
description: str,
|
|
71
|
+
category: str | None = None,
|
|
72
|
+
) -> Keybind:
|
|
73
|
+
"""Register a keybinding for an action."""
|
|
74
|
+
if action in self._keybinds:
|
|
75
|
+
raise ValueError(f"Action already registered: {action}")
|
|
76
|
+
|
|
77
|
+
sequence = parse_key_sequence(key)
|
|
78
|
+
keybind = Keybind(action=action, sequence=sequence, description=description, category=category)
|
|
79
|
+
self._keybinds[action] = keybind
|
|
80
|
+
return keybind
|
|
81
|
+
|
|
82
|
+
def get(self, action: str) -> Keybind | None:
|
|
83
|
+
"""Return keybind for action, if present."""
|
|
84
|
+
return self._keybinds.get(action)
|
|
85
|
+
|
|
86
|
+
def actions(self) -> list[str]:
|
|
87
|
+
"""Return sorted list of registered actions."""
|
|
88
|
+
return sorted(self._keybinds)
|
|
89
|
+
|
|
90
|
+
def matches(self, action: str, sequence: str | Iterable[str]) -> bool:
|
|
91
|
+
"""Return True if the provided sequence matches the action's keybind."""
|
|
92
|
+
keybind = self._keybinds.get(action)
|
|
93
|
+
if keybind is None:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
candidate = _coerce_sequence(sequence)
|
|
97
|
+
return candidate == keybind.sequence
|
|
98
|
+
|
|
99
|
+
def format(self, action: str) -> str:
|
|
100
|
+
"""Return a human-readable sequence for an action."""
|
|
101
|
+
keybind = self._keybinds.get(action)
|
|
102
|
+
if keybind is None:
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
return format_key_sequence(keybind.sequence, leader=self._leader)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_key_sequence(key: str) -> tuple[str, ...]:
|
|
109
|
+
"""Parse a key sequence string into normalized tokens."""
|
|
110
|
+
tokens = [token for token in key.strip().split() if token]
|
|
111
|
+
normalized: list[str] = []
|
|
112
|
+
|
|
113
|
+
for token in tokens:
|
|
114
|
+
if token.lower() == _LEADER_TOKEN:
|
|
115
|
+
normalized.append(_LEADER_TOKEN)
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
chord = _normalize_chord(token)
|
|
119
|
+
if chord:
|
|
120
|
+
normalized.append(chord)
|
|
121
|
+
|
|
122
|
+
return tuple(normalized)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def format_key_sequence(sequence: tuple[str, ...], *, leader: str = DEFAULT_LEADER) -> str:
|
|
126
|
+
"""Format a normalized sequence into a display string."""
|
|
127
|
+
rendered: list[str] = []
|
|
128
|
+
|
|
129
|
+
for token in sequence:
|
|
130
|
+
if token == _LEADER_TOKEN:
|
|
131
|
+
rendered.append(_format_token(leader))
|
|
132
|
+
continue
|
|
133
|
+
rendered.append(_format_token(token))
|
|
134
|
+
|
|
135
|
+
return " ".join(rendered)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _coerce_sequence(sequence: str | Iterable[str]) -> tuple[str, ...]:
|
|
139
|
+
if isinstance(sequence, str):
|
|
140
|
+
return parse_key_sequence(sequence)
|
|
141
|
+
|
|
142
|
+
tokens: list[str] = []
|
|
143
|
+
for token in sequence:
|
|
144
|
+
if not token:
|
|
145
|
+
continue
|
|
146
|
+
if token.lower() == _LEADER_TOKEN:
|
|
147
|
+
tokens.append(_LEADER_TOKEN)
|
|
148
|
+
continue
|
|
149
|
+
chord = _normalize_chord(token)
|
|
150
|
+
if chord:
|
|
151
|
+
tokens.append(chord)
|
|
152
|
+
|
|
153
|
+
return tuple(tokens)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _normalize_chord(chord: str) -> str:
|
|
157
|
+
"""Normalize a key chord string to canonical form.
|
|
158
|
+
|
|
159
|
+
Normalization rules:
|
|
160
|
+
- Converts separators: both '-' and '+' are normalized to '+'
|
|
161
|
+
- Handles synonyms: 'control'/'ctl' -> 'ctrl', 'cmd'/'command' -> 'meta', 'option' -> 'alt'
|
|
162
|
+
- Deduplicates modifiers: 'ctrl+ctrl+l' -> 'ctrl+l'
|
|
163
|
+
- Orders modifiers: ctrl < alt < shift < meta (unknown modifiers sort last)
|
|
164
|
+
- Case-insensitive: 'Ctrl+L' == 'ctrl+l' == 'CTRL-L'
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
chord: Key chord string (e.g., "Ctrl+L", "ctrl-l", "CTRL+CTRL+L")
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Normalized chord string (e.g., "ctrl+l") or empty string if invalid.
|
|
171
|
+
"""
|
|
172
|
+
parts = [part for part in chord.replace("-", "+").split("+") if part.strip()]
|
|
173
|
+
if not parts:
|
|
174
|
+
return ""
|
|
175
|
+
|
|
176
|
+
normalized_parts = [_normalize_key_part(part) for part in parts]
|
|
177
|
+
if len(normalized_parts) == 1:
|
|
178
|
+
return normalized_parts[0]
|
|
179
|
+
|
|
180
|
+
modifiers, key = normalized_parts[:-1], normalized_parts[-1]
|
|
181
|
+
|
|
182
|
+
seen: set[str] = set()
|
|
183
|
+
unique_mods: list[str] = []
|
|
184
|
+
for mod in modifiers:
|
|
185
|
+
if mod in seen:
|
|
186
|
+
continue
|
|
187
|
+
seen.add(mod)
|
|
188
|
+
unique_mods.append(mod)
|
|
189
|
+
|
|
190
|
+
unique_mods.sort(key=_modifier_sort_key)
|
|
191
|
+
return "+".join([*unique_mods, key])
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _normalize_key_part(part: str) -> str:
|
|
195
|
+
token = part.strip().lower()
|
|
196
|
+
token = _MODIFIER_SYNONYMS.get(token, token)
|
|
197
|
+
return _KEY_SYNONYMS.get(token, token)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _modifier_sort_key(modifier: str) -> int:
|
|
201
|
+
try:
|
|
202
|
+
return _MODIFIER_ORDER.index(modifier)
|
|
203
|
+
except ValueError:
|
|
204
|
+
return len(_MODIFIER_ORDER)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _format_token(token: str) -> str:
|
|
208
|
+
if "+" in token:
|
|
209
|
+
return _format_chord(token)
|
|
210
|
+
|
|
211
|
+
return _KEY_DISPLAY.get(token, token)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _format_chord(chord: str) -> str:
|
|
215
|
+
parts = chord.split("+")
|
|
216
|
+
modifiers, key = parts[:-1], parts[-1]
|
|
217
|
+
|
|
218
|
+
rendered_mods: list[str] = []
|
|
219
|
+
for mod in modifiers:
|
|
220
|
+
if mod == "ctrl":
|
|
221
|
+
rendered_mods.append("Ctrl")
|
|
222
|
+
elif mod == "alt":
|
|
223
|
+
rendered_mods.append("Alt")
|
|
224
|
+
elif mod == "shift":
|
|
225
|
+
rendered_mods.append("Shift")
|
|
226
|
+
elif mod == "meta":
|
|
227
|
+
rendered_mods.append("Meta")
|
|
228
|
+
else:
|
|
229
|
+
rendered_mods.append(mod.title())
|
|
230
|
+
|
|
231
|
+
rendered_key = _KEY_DISPLAY.get(key, key)
|
|
232
|
+
if len(rendered_key) == 1 and rendered_key.isalpha():
|
|
233
|
+
rendered_key = rendered_key.upper()
|
|
234
|
+
|
|
235
|
+
return "+".join([*rendered_mods, rendered_key])
|
|
@@ -89,7 +89,7 @@ class TerminalCapabilities:
|
|
|
89
89
|
|
|
90
90
|
# Basic capability detection
|
|
91
91
|
ansi = tty_available and term not in ("dumb", "")
|
|
92
|
-
osc52 =
|
|
92
|
+
osc52 = detect_osc52_support()
|
|
93
93
|
mouse = tty_available and term not in ("dumb", "")
|
|
94
94
|
truecolor = colorterm in ("truecolor", "24bit")
|
|
95
95
|
|
|
@@ -366,7 +366,7 @@ def _check_terminal_in_env(env_value: str, terminals: list[str]) -> bool:
|
|
|
366
366
|
return any(terminal in env_value for terminal in terminals)
|
|
367
367
|
|
|
368
368
|
|
|
369
|
-
def
|
|
369
|
+
def detect_osc52_support() -> bool:
|
|
370
370
|
"""Check if terminal likely supports OSC 52 (clipboard).
|
|
371
371
|
|
|
372
372
|
Returns:
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Theme system primitives for Textual TUIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from glaip_sdk.cli.slash.tui.theme.catalog import get_builtin_theme, list_builtin_themes
|
|
6
|
+
from glaip_sdk.cli.slash.tui.theme.manager import ThemeManager, ThemeMode
|
|
7
|
+
from glaip_sdk.cli.slash.tui.theme.tokens import ThemeTokens
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ThemeManager",
|
|
11
|
+
"ThemeMode",
|
|
12
|
+
"ThemeTokens",
|
|
13
|
+
"get_builtin_theme",
|
|
14
|
+
"list_builtin_themes",
|
|
15
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Built-in theme catalog for TUI applications.
|
|
2
|
+
|
|
3
|
+
This module implements Phase 2 of the TUI Theme System spec, providing a foundational
|
|
4
|
+
set of 12 color tokens (primary, secondary, accent, background, background_panel, text,
|
|
5
|
+
text_muted, success, warning, error, info). Additional tokens (e.g., diff.added,
|
|
6
|
+
syntax.*, backgroundElevated, textDim) will be added in future phases per the spec's
|
|
7
|
+
"100+ color tokens" requirement.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from glaip_sdk.cli.slash.tui.theme.tokens import ThemeModeLiteral, ThemeTokens
|
|
13
|
+
|
|
14
|
+
_BUILTIN_THEMES: dict[str, ThemeTokens] = {
|
|
15
|
+
"gl-dark": ThemeTokens(
|
|
16
|
+
name="gl-dark",
|
|
17
|
+
mode="dark",
|
|
18
|
+
primary="#6EA8FE",
|
|
19
|
+
secondary="#ADB5BD",
|
|
20
|
+
accent="#C77DFF",
|
|
21
|
+
background="#0B0F19",
|
|
22
|
+
background_panel="#111827",
|
|
23
|
+
text="#E5E7EB",
|
|
24
|
+
text_muted="#9CA3AF",
|
|
25
|
+
success="#34D399",
|
|
26
|
+
warning="#FBBF24",
|
|
27
|
+
error="#F87171",
|
|
28
|
+
info="#60A5FA",
|
|
29
|
+
),
|
|
30
|
+
"gl-light": ThemeTokens(
|
|
31
|
+
name="gl-light",
|
|
32
|
+
mode="light",
|
|
33
|
+
primary="#1D4ED8",
|
|
34
|
+
secondary="#4B5563",
|
|
35
|
+
accent="#7C3AED",
|
|
36
|
+
background="#FFFFFF",
|
|
37
|
+
background_panel="#F3F4F6",
|
|
38
|
+
text="#111827",
|
|
39
|
+
text_muted="#4B5563",
|
|
40
|
+
success="#059669",
|
|
41
|
+
warning="#B45309",
|
|
42
|
+
error="#B91C1C",
|
|
43
|
+
info="#1D4ED8",
|
|
44
|
+
),
|
|
45
|
+
"gl-high-contrast": ThemeTokens(
|
|
46
|
+
name="gl-high-contrast",
|
|
47
|
+
mode="dark",
|
|
48
|
+
# High-contrast theme uses uniform colors (#FFFFFF on #000000) to maximize
|
|
49
|
+
# contrast for accessibility. Semantic distinctions (success/warning/error)
|
|
50
|
+
# are intentionally uniform to prioritize maximum readability over color
|
|
51
|
+
# coding, per accessibility best practices for high-contrast modes.
|
|
52
|
+
primary="#FFFFFF",
|
|
53
|
+
secondary="#FFFFFF",
|
|
54
|
+
accent="#FFFFFF",
|
|
55
|
+
background="#000000",
|
|
56
|
+
background_panel="#000000",
|
|
57
|
+
text="#FFFFFF",
|
|
58
|
+
text_muted="#FFFFFF",
|
|
59
|
+
success="#FFFFFF",
|
|
60
|
+
warning="#FFFFFF",
|
|
61
|
+
error="#FFFFFF",
|
|
62
|
+
info="#FFFFFF",
|
|
63
|
+
),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_builtin_theme(name: str) -> ThemeTokens | None:
|
|
68
|
+
"""Return a built-in theme by name."""
|
|
69
|
+
return _BUILTIN_THEMES.get(name)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def list_builtin_themes() -> list[str]:
|
|
73
|
+
"""List available built-in theme names."""
|
|
74
|
+
return sorted(_BUILTIN_THEMES)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def default_theme_name_for_mode(mode: ThemeModeLiteral) -> str:
|
|
78
|
+
"""Return the default theme name for the given light/dark mode."""
|
|
79
|
+
return "gl-light" if mode == "light" else "gl-dark"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Theme manager for TUI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
|
|
10
|
+
from glaip_sdk.cli.slash.tui.theme.catalog import default_theme_name_for_mode, get_builtin_theme
|
|
11
|
+
from glaip_sdk.cli.slash.tui.theme.tokens import ThemeTokens
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ThemeMode(str, Enum):
|
|
17
|
+
"""User-selectable theme mode."""
|
|
18
|
+
|
|
19
|
+
AUTO = "auto"
|
|
20
|
+
LIGHT = "light"
|
|
21
|
+
DARK = "dark"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ThemeManager:
|
|
25
|
+
"""Resolve active theme tokens from terminal state and user preferences."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
terminal: TerminalCapabilities,
|
|
30
|
+
*,
|
|
31
|
+
mode: ThemeMode | str = ThemeMode.AUTO,
|
|
32
|
+
theme: str | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Initialize the theme manager."""
|
|
35
|
+
self._terminal = terminal
|
|
36
|
+
self._mode = self._coerce_mode(mode)
|
|
37
|
+
self._theme = theme
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def mode(self) -> ThemeMode:
|
|
41
|
+
"""Return configured mode (auto/light/dark)."""
|
|
42
|
+
return self._mode
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def effective_mode(self) -> Literal["light", "dark"]:
|
|
46
|
+
"""Return resolved light/dark mode."""
|
|
47
|
+
if self._mode == ThemeMode.AUTO:
|
|
48
|
+
return self._terminal.background_mode
|
|
49
|
+
return "light" if self._mode == ThemeMode.LIGHT else "dark"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def theme_name(self) -> str:
|
|
53
|
+
"""Return resolved theme name."""
|
|
54
|
+
return self._theme or default_theme_name_for_mode(self.effective_mode)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def tokens(self) -> ThemeTokens:
|
|
58
|
+
"""Return tokens for the resolved theme."""
|
|
59
|
+
chosen = get_builtin_theme(self.theme_name)
|
|
60
|
+
if chosen is not None:
|
|
61
|
+
return chosen
|
|
62
|
+
|
|
63
|
+
fallback_name = default_theme_name_for_mode(self.effective_mode)
|
|
64
|
+
fallback = get_builtin_theme(fallback_name)
|
|
65
|
+
if fallback is None:
|
|
66
|
+
raise RuntimeError(f"Missing default theme: {fallback_name}")
|
|
67
|
+
|
|
68
|
+
return fallback
|
|
69
|
+
|
|
70
|
+
def set_mode(self, mode: ThemeMode | str) -> None:
|
|
71
|
+
"""Set auto/light/dark mode."""
|
|
72
|
+
self._mode = self._coerce_mode(mode)
|
|
73
|
+
|
|
74
|
+
def set_theme(self, theme: str | None) -> None:
|
|
75
|
+
"""Set explicit theme name (or None to use the default)."""
|
|
76
|
+
self._theme = theme
|
|
77
|
+
|
|
78
|
+
def _coerce_mode(self, mode: ThemeMode | str) -> ThemeMode:
|
|
79
|
+
"""Coerce a mode value to ThemeMode enum, defaulting to AUTO on invalid input."""
|
|
80
|
+
if isinstance(mode, ThemeMode):
|
|
81
|
+
return mode
|
|
82
|
+
try:
|
|
83
|
+
return ThemeMode(mode)
|
|
84
|
+
except ValueError:
|
|
85
|
+
logger.warning(f"Invalid theme mode '{mode}', defaulting to AUTO")
|
|
86
|
+
return ThemeMode.AUTO
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Theme token definitions for TUI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
ThemeModeLiteral = Literal["light", "dark"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ThemeTokens:
|
|
13
|
+
"""Color token set for a built-in theme."""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
mode: ThemeModeLiteral
|
|
17
|
+
|
|
18
|
+
primary: str
|
|
19
|
+
secondary: str
|
|
20
|
+
accent: str
|
|
21
|
+
|
|
22
|
+
background: str
|
|
23
|
+
background_panel: str
|
|
24
|
+
|
|
25
|
+
text: str
|
|
26
|
+
text_muted: str
|
|
27
|
+
|
|
28
|
+
success: str
|
|
29
|
+
warning: str
|
|
30
|
+
error: str
|
|
31
|
+
info: str
|
|
32
|
+
|
|
33
|
+
def as_dict(self) -> dict[str, str]:
|
|
34
|
+
"""Return color tokens as a plain dictionary.
|
|
35
|
+
|
|
36
|
+
Returns only color tokens (primary, secondary, accent, background, etc.),
|
|
37
|
+
excluding metadata fields (name, mode). This is intentional for use cases
|
|
38
|
+
like Textual TCSS mapping where only color values are needed.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Dictionary mapping color token names to hex color strings.
|
|
42
|
+
"""
|
|
43
|
+
return {
|
|
44
|
+
"primary": self.primary,
|
|
45
|
+
"secondary": self.secondary,
|
|
46
|
+
"accent": self.accent,
|
|
47
|
+
"background": self.background,
|
|
48
|
+
"background_panel": self.background_panel,
|
|
49
|
+
"text": self.text,
|
|
50
|
+
"text_muted": self.text_muted,
|
|
51
|
+
"success": self.success,
|
|
52
|
+
"warning": self.warning,
|
|
53
|
+
"error": self.error,
|
|
54
|
+
"info": self.info,
|
|
55
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Toast notification helpers for Textual TUIs.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ToastVariant(str, Enum):
|
|
15
|
+
"""Toast message variant."""
|
|
16
|
+
|
|
17
|
+
INFO = "info"
|
|
18
|
+
SUCCESS = "success"
|
|
19
|
+
WARNING = "warning"
|
|
20
|
+
ERROR = "error"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DEFAULT_TOAST_DURATIONS_SECONDS: dict[ToastVariant, float] = {
|
|
24
|
+
ToastVariant.SUCCESS: 2.0,
|
|
25
|
+
ToastVariant.INFO: 3.0,
|
|
26
|
+
ToastVariant.WARNING: 3.0,
|
|
27
|
+
ToastVariant.ERROR: 5.0,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class ToastState:
|
|
33
|
+
"""Immutable toast payload."""
|
|
34
|
+
|
|
35
|
+
message: str
|
|
36
|
+
variant: ToastVariant
|
|
37
|
+
duration_seconds: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ToastBus:
|
|
41
|
+
"""Single-toast state holder with auto-dismiss."""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
"""Initialize the bus."""
|
|
45
|
+
self._state: ToastState | None = None
|
|
46
|
+
self._dismiss_task: asyncio.Task[None] | None = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def state(self) -> ToastState | None:
|
|
50
|
+
"""Return the current toast state."""
|
|
51
|
+
return self._state
|
|
52
|
+
|
|
53
|
+
def show(
|
|
54
|
+
self,
|
|
55
|
+
message: str,
|
|
56
|
+
variant: ToastVariant | str = ToastVariant.INFO,
|
|
57
|
+
*,
|
|
58
|
+
duration_seconds: float | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Set toast state and schedule auto-dismiss."""
|
|
61
|
+
resolved_variant = self._coerce_variant(variant)
|
|
62
|
+
resolved_duration = (
|
|
63
|
+
DEFAULT_TOAST_DURATIONS_SECONDS[resolved_variant] if duration_seconds is None else float(duration_seconds)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self._state = ToastState(
|
|
67
|
+
message=message,
|
|
68
|
+
variant=resolved_variant,
|
|
69
|
+
duration_seconds=resolved_duration,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
self._cancel_dismiss_task()
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
loop = asyncio.get_running_loop()
|
|
76
|
+
except RuntimeError:
|
|
77
|
+
raise RuntimeError(
|
|
78
|
+
"Cannot schedule toast auto-dismiss: no running event loop. "
|
|
79
|
+
"ToastBus.show() must be called from within an async context."
|
|
80
|
+
) from None
|
|
81
|
+
|
|
82
|
+
self._dismiss_task = loop.create_task(self._auto_dismiss(resolved_duration))
|
|
83
|
+
|
|
84
|
+
def clear(self) -> None:
|
|
85
|
+
"""Clear the current toast."""
|
|
86
|
+
self._cancel_dismiss_task()
|
|
87
|
+
self._state = None
|
|
88
|
+
|
|
89
|
+
def copy_success(self, label: str | None = None) -> None:
|
|
90
|
+
"""Show clipboard success toast."""
|
|
91
|
+
message = "Copied to clipboard" if not label else f"Copied {label} to clipboard"
|
|
92
|
+
self.show(message=message, variant=ToastVariant.SUCCESS)
|
|
93
|
+
|
|
94
|
+
def copy_failed(self) -> None:
|
|
95
|
+
"""Show clipboard failure toast."""
|
|
96
|
+
self.show(
|
|
97
|
+
message="Clipboard unavailable. Text printed below",
|
|
98
|
+
variant=ToastVariant.WARNING,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def _coerce_variant(self, variant: ToastVariant | str) -> ToastVariant:
|
|
102
|
+
if isinstance(variant, ToastVariant):
|
|
103
|
+
return variant
|
|
104
|
+
try:
|
|
105
|
+
return ToastVariant(variant)
|
|
106
|
+
except ValueError:
|
|
107
|
+
return ToastVariant.INFO
|
|
108
|
+
|
|
109
|
+
def _cancel_dismiss_task(self) -> None:
|
|
110
|
+
if self._dismiss_task is None:
|
|
111
|
+
return
|
|
112
|
+
if not self._dismiss_task.done():
|
|
113
|
+
self._dismiss_task.cancel()
|
|
114
|
+
self._dismiss_task = None
|
|
115
|
+
|
|
116
|
+
async def _auto_dismiss(self, duration_seconds: float) -> None:
|
|
117
|
+
try:
|
|
118
|
+
await asyncio.sleep(duration_seconds)
|
|
119
|
+
except asyncio.CancelledError:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
self._state = None
|
|
123
|
+
self._dismiss_task = None
|
|
@@ -71,20 +71,27 @@ glaip_sdk/cli/core/rendering.py,sha256=QgbYzTcKH8wa7-BdR3UgiS3KBx1QYZjDcV2Hyy5ox
|
|
|
71
71
|
glaip_sdk/cli/parsers/__init__.py,sha256=NzLrSH6GOdNoewXtKNpB6GwrauA8rb_IGYV6cz5Hn3o,113
|
|
72
72
|
glaip_sdk/cli/parsers/json_input.py,sha256=kxoxeIlgfsaH2jhe6apZAgSxAtwlpSINLTMRsZZYboQ,5630
|
|
73
73
|
glaip_sdk/cli/slash/__init__.py,sha256=J9TPL2UcNTkW8eifG6nRmAEGHhyEgdYMYk4cHaaObC0,386
|
|
74
|
-
glaip_sdk/cli/slash/accounts_controller.py,sha256
|
|
74
|
+
glaip_sdk/cli/slash/accounts_controller.py,sha256=SceJlc2F2ZdlSDkuWO3Js3akL89bVtQLyGM_oA-F2qI,24928
|
|
75
75
|
glaip_sdk/cli/slash/accounts_shared.py,sha256=Mq5HxlI0YsVEQ0KKISWvyBZhzOFFWCzwRbhF5xwvUbM,2626
|
|
76
76
|
glaip_sdk/cli/slash/agent_session.py,sha256=tuVOme-NbEyr6rwJvsBEKZYWQmsaRf4piJeRvIGu0ns,11384
|
|
77
77
|
glaip_sdk/cli/slash/prompt.py,sha256=q4f1c2zr7ZMUeO6AgOBF2Nz4qgMOXrVPt6WzPRQMbAM,8501
|
|
78
78
|
glaip_sdk/cli/slash/remote_runs_controller.py,sha256=a5X5rYgb9l6dHhvTewRUCj-hAo7mKRnuM_MwGvxs8jI,21363
|
|
79
|
-
glaip_sdk/cli/slash/session.py,sha256=
|
|
80
|
-
glaip_sdk/cli/slash/tui/__init__.py,sha256=
|
|
81
|
-
glaip_sdk/cli/slash/tui/accounts.tcss,sha256=
|
|
82
|
-
glaip_sdk/cli/slash/tui/accounts_app.py,sha256=
|
|
79
|
+
glaip_sdk/cli/slash/session.py,sha256=Zn2hXND_Tfameh_PI8g4VIMd7GPWxwhtPNMN9p6cF7M,65081
|
|
80
|
+
glaip_sdk/cli/slash/tui/__init__.py,sha256=oBUzeoslYwPKVlhqhgg4I7480b77vQNc9ec0NgdTC1s,977
|
|
81
|
+
glaip_sdk/cli/slash/tui/accounts.tcss,sha256=BCjIuTetmVjydv6DCliY38Cze2LUEu7IY44sL5nIuLU,1194
|
|
82
|
+
glaip_sdk/cli/slash/tui/accounts_app.py,sha256=6ihnAnzKD49eeXYW3dYWUAdUEyoXNFwoEoi3kS3WtXM,35999
|
|
83
83
|
glaip_sdk/cli/slash/tui/background_tasks.py,sha256=SAe1mV2vXB3mJcSGhelU950vf8Lifjhws9iomyIVFKw,2422
|
|
84
|
-
glaip_sdk/cli/slash/tui/
|
|
84
|
+
glaip_sdk/cli/slash/tui/clipboard.py,sha256=HL_RWIdONyRmDtTYuDzxJTS_mRcLxuR37Ac9Ug5nh40,4730
|
|
85
|
+
glaip_sdk/cli/slash/tui/context.py,sha256=03mo2kgvpyUcNBYz7G2Uyu7X3FJlSUzVoP5Rt9MCZZY,2141
|
|
86
|
+
glaip_sdk/cli/slash/tui/keybind_registry.py,sha256=_rK05BxTxNudYc4iJ9gDxpgeUkjDAq8rarIT-9A-jyM,6739
|
|
85
87
|
glaip_sdk/cli/slash/tui/loading.py,sha256=nW5pv_Tnl9FUOPR3Qf2O5gt1AGHSo3b5-Uofg34F6AE,1909
|
|
86
88
|
glaip_sdk/cli/slash/tui/remote_runs_app.py,sha256=RCrI-c5ilKV6Iy1lz2Aok9xo2Ou02vqcXACMXTdodnE,24716
|
|
87
|
-
glaip_sdk/cli/slash/tui/terminal.py,sha256=
|
|
89
|
+
glaip_sdk/cli/slash/tui/terminal.py,sha256=iC31XChTL34gXY6vXdSIX3HmD36tuA9EYTPZ2Sn4uOI,12108
|
|
90
|
+
glaip_sdk/cli/slash/tui/toast.py,sha256=LP_myZwgnrdowrRxGK24lMlx7iZt7iOwFhrbc4NW0DY,3493
|
|
91
|
+
glaip_sdk/cli/slash/tui/theme/__init__.py,sha256=rtM2ik83YNCRcI1qh_Sf3rnxco2OvCNNT3NbHY6cLvw,432
|
|
92
|
+
glaip_sdk/cli/slash/tui/theme/catalog.py,sha256=G52eU3h8YI9D8XUALVg1KVZ4Lq65VnZdgPS3F_P7XLE,2544
|
|
93
|
+
glaip_sdk/cli/slash/tui/theme/manager.py,sha256=X600J_WIBM1CHgsQeMFGFPuaVAFCINFcBXFWmeD4B5Q,2707
|
|
94
|
+
glaip_sdk/cli/slash/tui/theme/tokens.py,sha256=ympMRny_d-gHtmnPR-lmNZ-C9SGBy2q-MH81l0L1h-Y,1423
|
|
88
95
|
glaip_sdk/cli/transcript/__init__.py,sha256=yiYHyNtebMCu3BXu56Xm5RBC2tDc865q8UGPnoe6QRs,920
|
|
89
96
|
glaip_sdk/cli/transcript/cache.py,sha256=Wi1uln6HP1U6F-MRTrfnxi9bn6XJTxwWXhREIRPoMqQ,17439
|
|
90
97
|
glaip_sdk/cli/transcript/capture.py,sha256=t8j_62cC6rhb51oCluZd17N04vcXqyjkhPRcRd3ZcmM,10291
|
|
@@ -193,8 +200,8 @@ glaip_sdk/utils/rendering/steps/format.py,sha256=Chnq7OBaj8XMeBntSBxrX5zSmrYeGcO
|
|
|
193
200
|
glaip_sdk/utils/rendering/steps/manager.py,sha256=BiBmTeQMQhjRMykgICXsXNYh1hGsss-fH9BIGVMWFi0,13194
|
|
194
201
|
glaip_sdk/utils/rendering/viewer/__init__.py,sha256=XrxmE2cMAozqrzo1jtDFm8HqNtvDcYi2mAhXLXn5CjI,457
|
|
195
202
|
glaip_sdk/utils/rendering/viewer/presenter.py,sha256=mlLMTjnyeyPVtsyrAbz1BJu9lFGQSlS-voZ-_Cuugv0,5725
|
|
196
|
-
glaip_sdk-0.7.
|
|
197
|
-
glaip_sdk-0.7.
|
|
198
|
-
glaip_sdk-0.7.
|
|
199
|
-
glaip_sdk-0.7.
|
|
200
|
-
glaip_sdk-0.7.
|
|
203
|
+
glaip_sdk-0.7.1.dist-info/METADATA,sha256=Pb8KaBV3ypCfgVvXTZd8mWSP_MgdaqFFdUTEmgPal5I,8365
|
|
204
|
+
glaip_sdk-0.7.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
205
|
+
glaip_sdk-0.7.1.dist-info/entry_points.txt,sha256=65vNPUggyYnVGhuw7RhNJ8Fp2jygTcX0yxJBcBY3iLU,48
|
|
206
|
+
glaip_sdk-0.7.1.dist-info/top_level.txt,sha256=td7yXttiYX2s94-4wFhv-5KdT0rSZ-pnJRSire341hw,10
|
|
207
|
+
glaip_sdk-0.7.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|