glaip-sdk 0.7.7__py3-none-any.whl → 0.7.8__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/account_store.py +15 -0
- glaip_sdk/cli/config.py +1 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +155 -10
- glaip_sdk/cli/slash/tui/context.py +37 -12
- glaip_sdk/cli/slash/tui/theme/manager.py +28 -2
- glaip_sdk/cli/tui_settings.py +125 -0
- glaip_sdk/tools/base.py +23 -3
- {glaip_sdk-0.7.7.dist-info → glaip_sdk-0.7.8.dist-info}/METADATA +1 -1
- {glaip_sdk-0.7.7.dist-info → glaip_sdk-0.7.8.dist-info}/RECORD +12 -11
- {glaip_sdk-0.7.7.dist-info → glaip_sdk-0.7.8.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.7.7.dist-info → glaip_sdk-0.7.8.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.7.7.dist-info → glaip_sdk-0.7.8.dist-info}/top_level.txt +0 -0
glaip_sdk/cli/account_store.py
CHANGED
|
@@ -523,6 +523,21 @@ class AccountStore:
|
|
|
523
523
|
|
|
524
524
|
self._save_config(config)
|
|
525
525
|
|
|
526
|
+
def save_config_updates(self, config: dict[str, Any]) -> None:
|
|
527
|
+
"""Save config updates, preserving all existing keys.
|
|
528
|
+
|
|
529
|
+
This method allows external code to update arbitrary config keys
|
|
530
|
+
(e.g., TUI preferences) while preserving the full config structure.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
config: Complete configuration dictionary to save. This should
|
|
534
|
+
include all keys that should be preserved, not just updates.
|
|
535
|
+
|
|
536
|
+
Raises:
|
|
537
|
+
AccountStoreError: If config file cannot be written.
|
|
538
|
+
"""
|
|
539
|
+
self._save_config(config)
|
|
540
|
+
|
|
526
541
|
|
|
527
542
|
# Global instance for convenience
|
|
528
543
|
_account_store = AccountStore()
|
glaip_sdk/cli/config.py
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
Provides a minimal interactive list with the same columns/order as the Rich
|
|
4
4
|
fallback (name, API URL, masked key, status) and keyboard navigation.
|
|
5
5
|
|
|
6
|
+
Integrates with TUI foundation services:
|
|
7
|
+
- KeybindRegistry: Centralized keybind registration with scoped actions
|
|
8
|
+
- ClipboardAdapter: Cross-platform clipboard operations with OSC 52 support
|
|
9
|
+
- ToastBus: Non-blocking toast notifications for user feedback
|
|
10
|
+
|
|
6
11
|
Authors:
|
|
7
12
|
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
8
13
|
"""
|
|
@@ -23,10 +28,13 @@ from glaip_sdk.cli.slash.accounts_shared import (
|
|
|
23
28
|
env_credentials_present,
|
|
24
29
|
)
|
|
25
30
|
from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
|
|
26
|
-
from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
|
|
31
|
+
from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter, ClipboardResult
|
|
27
32
|
from glaip_sdk.cli.slash.tui.context import TUIContext
|
|
33
|
+
from glaip_sdk.cli.slash.tui.keybind_registry import KeybindRegistry
|
|
28
34
|
from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
|
|
35
|
+
from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
|
|
29
36
|
from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
|
|
37
|
+
from glaip_sdk.cli.slash.tui.toast import ToastBus
|
|
30
38
|
from glaip_sdk.cli.validators import validate_api_key
|
|
31
39
|
from glaip_sdk.utils.validation import validate_url
|
|
32
40
|
|
|
@@ -84,6 +92,30 @@ FORM_KEY_ID = "#form-key"
|
|
|
84
92
|
# CSS file name
|
|
85
93
|
CSS_FILE_NAME = "accounts.tcss"
|
|
86
94
|
|
|
95
|
+
KEYBIND_SCOPE = "accounts"
|
|
96
|
+
KEYBIND_CATEGORY = "Accounts"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class KeybindDef:
|
|
101
|
+
"""Keybind definition with action, key, and description."""
|
|
102
|
+
|
|
103
|
+
action: str
|
|
104
|
+
key: str
|
|
105
|
+
description: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
KEYBIND_DEFINITIONS: tuple[KeybindDef, ...] = (
|
|
109
|
+
KeybindDef("switch_row", "enter", "Switch"),
|
|
110
|
+
KeybindDef("focus_filter", "/", "Filter"),
|
|
111
|
+
KeybindDef("add_account", "a", "Add"),
|
|
112
|
+
KeybindDef("edit_account", "e", "Edit"),
|
|
113
|
+
KeybindDef("delete_account", "d", "Delete"),
|
|
114
|
+
KeybindDef("copy_account", "c", "Copy"),
|
|
115
|
+
KeybindDef("clear_or_exit", "escape", "Close"),
|
|
116
|
+
KeybindDef("app_exit", "q", "Close"),
|
|
117
|
+
)
|
|
118
|
+
|
|
87
119
|
|
|
88
120
|
@dataclass
|
|
89
121
|
class AccountsTUICallbacks:
|
|
@@ -250,6 +282,27 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
|
|
|
250
282
|
self._connection_tester = connection_tester
|
|
251
283
|
self._validate_name = validate_name
|
|
252
284
|
|
|
285
|
+
def _get_api_url_suggestions(self, _value: str) -> list[str]:
|
|
286
|
+
"""Get API URL suggestions from existing accounts.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
_value: Current input value (unused, but required by Textual's suggestor API).
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
List of unique API URLs from existing accounts.
|
|
293
|
+
"""
|
|
294
|
+
try:
|
|
295
|
+
store = get_account_store()
|
|
296
|
+
accounts = store.list_accounts()
|
|
297
|
+
# Extract unique API URLs, excluding the current account's URL in edit mode
|
|
298
|
+
existing_url = self._existing.get("api_url", "")
|
|
299
|
+
urls = {account.get("api_url", "") for account in accounts.values() if account.get("api_url")}
|
|
300
|
+
if existing_url in urls:
|
|
301
|
+
urls.remove(existing_url)
|
|
302
|
+
return sorted(urls)
|
|
303
|
+
except Exception: # pragma: no cover - defensive
|
|
304
|
+
return []
|
|
305
|
+
|
|
253
306
|
def compose(self) -> ComposeResult:
|
|
254
307
|
"""Render the form controls."""
|
|
255
308
|
title = "Add account" if self._mode == "add" else "Edit account"
|
|
@@ -259,7 +312,12 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
|
|
|
259
312
|
id="form-name",
|
|
260
313
|
disabled=self._mode == "edit",
|
|
261
314
|
)
|
|
262
|
-
url_input = Input(
|
|
315
|
+
url_input = Input(
|
|
316
|
+
value=self._existing.get("api_url", ""),
|
|
317
|
+
placeholder="https://api.example.com",
|
|
318
|
+
id="form-url",
|
|
319
|
+
suggestions=self._get_api_url_suggestions,
|
|
320
|
+
)
|
|
263
321
|
key_input = Input(value="", placeholder="sk-...", password=True, id="form-key")
|
|
264
322
|
test_checkbox = Checkbox(
|
|
265
323
|
"Test connection before save",
|
|
@@ -427,8 +485,13 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
427
485
|
self._env_lock = env_lock
|
|
428
486
|
self._callbacks = callbacks
|
|
429
487
|
self._ctx = ctx
|
|
488
|
+
self._keybinds: KeybindRegistry | None = None
|
|
489
|
+
self._toast_bus: ToastBus | None = None
|
|
490
|
+
self._toast_ready = False
|
|
491
|
+
self._clipboard: ClipboardAdapter | None = None
|
|
430
492
|
self._filter_text: str = ""
|
|
431
493
|
self._is_switching = False
|
|
494
|
+
self._initialize_context_services()
|
|
432
495
|
|
|
433
496
|
def compose(self) -> ComposeResult:
|
|
434
497
|
"""Build the Textual layout."""
|
|
@@ -481,6 +544,52 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
481
544
|
main = self.query_one(Vertical)
|
|
482
545
|
main.styles.gap = 0
|
|
483
546
|
self._update_filter_button_visibility()
|
|
547
|
+
self._prepare_toasts()
|
|
548
|
+
self._register_keybinds()
|
|
549
|
+
|
|
550
|
+
def _initialize_context_services(self) -> None:
|
|
551
|
+
if self._ctx:
|
|
552
|
+
if self._ctx.keybinds is None:
|
|
553
|
+
self._ctx.keybinds = KeybindRegistry()
|
|
554
|
+
if self._ctx.toasts is None:
|
|
555
|
+
self._ctx.toasts = ToastBus()
|
|
556
|
+
if self._ctx.clipboard is None:
|
|
557
|
+
self._ctx.clipboard = ClipboardAdapter(terminal=self._ctx.terminal)
|
|
558
|
+
self._keybinds = self._ctx.keybinds
|
|
559
|
+
self._toast_bus = self._ctx.toasts
|
|
560
|
+
self._clipboard = self._ctx.clipboard
|
|
561
|
+
else:
|
|
562
|
+
# Fallback: create services independently when ctx is None
|
|
563
|
+
terminal = TerminalCapabilities(
|
|
564
|
+
tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
|
|
565
|
+
)
|
|
566
|
+
self._clipboard = ClipboardAdapter(terminal=terminal)
|
|
567
|
+
|
|
568
|
+
def _prepare_toasts(self) -> None:
|
|
569
|
+
"""Prepare toast system by marking ready and clearing any existing toasts."""
|
|
570
|
+
self._toast_ready = True
|
|
571
|
+
if self._toast_bus:
|
|
572
|
+
self._toast_bus.clear()
|
|
573
|
+
|
|
574
|
+
def _register_keybinds(self) -> None:
|
|
575
|
+
if not self._keybinds:
|
|
576
|
+
return
|
|
577
|
+
for keybind_def in KEYBIND_DEFINITIONS:
|
|
578
|
+
scoped_action = f"{KEYBIND_SCOPE}.{keybind_def.action}"
|
|
579
|
+
if self._keybinds.get(scoped_action):
|
|
580
|
+
continue
|
|
581
|
+
try:
|
|
582
|
+
self._keybinds.register(
|
|
583
|
+
action=scoped_action,
|
|
584
|
+
key=keybind_def.key,
|
|
585
|
+
description=keybind_def.description,
|
|
586
|
+
category=KEYBIND_CATEGORY,
|
|
587
|
+
)
|
|
588
|
+
except ValueError as e:
|
|
589
|
+
# Expected: duplicate registration (already registered by another component)
|
|
590
|
+
# Silently skip to allow multiple apps to register same keybinds
|
|
591
|
+
logging.debug(f"Skipping duplicate keybind registration: {scoped_action}", exc_info=e)
|
|
592
|
+
continue
|
|
484
593
|
|
|
485
594
|
def _header_text(self) -> str:
|
|
486
595
|
"""Build header text with active account and host."""
|
|
@@ -643,6 +752,24 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
643
752
|
"""Hide the loading indicator."""
|
|
644
753
|
hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
|
|
645
754
|
|
|
755
|
+
def _handle_switch_scheduling_error(self, exc: Exception) -> None:
|
|
756
|
+
"""Handle errors when scheduling the switch task fails.
|
|
757
|
+
|
|
758
|
+
Args:
|
|
759
|
+
exc: The exception that occurred during task scheduling.
|
|
760
|
+
"""
|
|
761
|
+
self._hide_loading()
|
|
762
|
+
self._is_switching = False
|
|
763
|
+
error_msg = f"Switch failed to start: {exc}"
|
|
764
|
+
if self._toast_ready and self._toast_bus:
|
|
765
|
+
self._toast_bus.show(message=error_msg, variant="error")
|
|
766
|
+
try:
|
|
767
|
+
self._set_status(error_msg, "red")
|
|
768
|
+
except Exception:
|
|
769
|
+
# App not mounted yet, status update not possible
|
|
770
|
+
logging.error(error_msg, exc_info=exc)
|
|
771
|
+
logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
|
|
772
|
+
|
|
646
773
|
def _clear_filter(self) -> None:
|
|
647
774
|
"""Clear the filter input and reset filter state."""
|
|
648
775
|
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
@@ -665,7 +792,9 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
665
792
|
|
|
666
793
|
if switched:
|
|
667
794
|
self._active_account = name
|
|
668
|
-
|
|
795
|
+
status_msg = message or f"Switched to '{name}'."
|
|
796
|
+
if self._toast_ready and self._toast_bus:
|
|
797
|
+
self._toast_bus.show(message=status_msg, variant="success")
|
|
669
798
|
self._update_header()
|
|
670
799
|
self._reload_rows()
|
|
671
800
|
else:
|
|
@@ -674,11 +803,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
674
803
|
try:
|
|
675
804
|
self.track_task(perform(), logger=logging.getLogger(__name__))
|
|
676
805
|
except Exception as exc:
|
|
677
|
-
|
|
678
|
-
self._hide_loading()
|
|
679
|
-
self._is_switching = False
|
|
680
|
-
self._set_status(f"Switch failed to start: {exc}", "red")
|
|
681
|
-
logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
|
|
806
|
+
self._handle_switch_scheduling_error(exc)
|
|
682
807
|
|
|
683
808
|
def _update_header(self) -> None:
|
|
684
809
|
"""Refresh header text to reflect active/lock state."""
|
|
@@ -781,12 +906,32 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
781
906
|
return
|
|
782
907
|
|
|
783
908
|
text = f"Account: {name}\nURL: {account.get('api_url', '')}"
|
|
784
|
-
adapter = ClipboardAdapter()
|
|
785
|
-
|
|
909
|
+
adapter = self._clipboard or ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
|
|
910
|
+
# OSC 52 works by writing to stdout, no custom writer needed
|
|
911
|
+
try:
|
|
912
|
+
asyncio.get_running_loop()
|
|
913
|
+
except RuntimeError:
|
|
914
|
+
result = adapter.copy(text)
|
|
915
|
+
self._handle_copy_result(name, result)
|
|
916
|
+
return
|
|
917
|
+
|
|
918
|
+
async def perform() -> None:
|
|
919
|
+
result = await asyncio.to_thread(adapter.copy, text)
|
|
920
|
+
self._handle_copy_result(name, result)
|
|
921
|
+
|
|
922
|
+
self.track_task(perform(), logger=logging.getLogger(__name__))
|
|
786
923
|
|
|
924
|
+
def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
|
|
925
|
+
"""Update UI state after a copy attempt."""
|
|
787
926
|
if result.success:
|
|
927
|
+
if self._toast_ready and self._toast_bus:
|
|
928
|
+
self._toast_bus.copy_success(label=name)
|
|
929
|
+
# Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
|
|
788
930
|
self._set_status(f"Copied '{name}' to clipboard.", "green")
|
|
789
931
|
else:
|
|
932
|
+
if self._toast_ready and self._toast_bus:
|
|
933
|
+
self._toast_bus.show(message=f"Copy failed: {result.message}", variant="warning")
|
|
934
|
+
# Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
|
|
790
935
|
self._set_status(f"Copy failed: {result.message}", "red")
|
|
791
936
|
|
|
792
937
|
def _check_env_lock_hotkey(self) -> bool:
|
|
@@ -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,47 @@ 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
47
|
async def create(cls) -> 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).
|
|
52
53
|
|
|
53
54
|
Returns:
|
|
54
|
-
TUIContext instance with
|
|
55
|
+
TUIContext instance with all services initialized.
|
|
55
56
|
"""
|
|
56
57
|
terminal = await TerminalCapabilities.detect()
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
store = get_account_store()
|
|
59
|
+
settings = load_tui_settings(store=store)
|
|
60
|
+
|
|
61
|
+
# Handle env var override: normalize empty strings and "default" to None
|
|
62
|
+
# Empty string from os.getenv() is falsy, so strip() result becomes None in the or expression
|
|
63
|
+
env_theme = os.getenv("AIP_TUI_THEME")
|
|
64
|
+
env_theme = env_theme.strip() if env_theme else None
|
|
65
|
+
if env_theme and env_theme.lower() == "default":
|
|
66
|
+
env_theme = None
|
|
67
|
+
|
|
68
|
+
theme_name = env_theme or settings.theme_name
|
|
69
|
+
theme = ThemeManager(
|
|
70
|
+
terminal,
|
|
71
|
+
mode=settings.theme_mode,
|
|
72
|
+
theme=theme_name,
|
|
73
|
+
settings_store=store,
|
|
74
|
+
)
|
|
75
|
+
keybinds = KeybindRegistry()
|
|
76
|
+
toasts = ToastBus()
|
|
77
|
+
clipboard = ClipboardAdapter(terminal=terminal)
|
|
78
|
+
return cls(
|
|
79
|
+
terminal=terminal,
|
|
80
|
+
keybinds=keybinds,
|
|
81
|
+
theme=theme,
|
|
82
|
+
toasts=toasts,
|
|
83
|
+
clipboard=clipboard,
|
|
84
|
+
)
|
|
@@ -6,9 +6,11 @@ import logging
|
|
|
6
6
|
from enum import Enum
|
|
7
7
|
from typing import Literal
|
|
8
8
|
|
|
9
|
+
from glaip_sdk.cli.account_store import AccountStore, AccountStoreError
|
|
9
10
|
from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
|
|
10
11
|
from glaip_sdk.cli.slash.tui.theme.catalog import default_theme_name_for_mode, get_builtin_theme
|
|
11
12
|
from glaip_sdk.cli.slash.tui.theme.tokens import ThemeTokens
|
|
13
|
+
from glaip_sdk.cli.tui_settings import persist_tui_theme
|
|
12
14
|
|
|
13
15
|
logger = logging.getLogger(__name__)
|
|
14
16
|
|
|
@@ -30,11 +32,13 @@ class ThemeManager:
|
|
|
30
32
|
*,
|
|
31
33
|
mode: ThemeMode | str = ThemeMode.AUTO,
|
|
32
34
|
theme: str | None = None,
|
|
35
|
+
settings_store: AccountStore | None = None,
|
|
33
36
|
) -> None:
|
|
34
37
|
"""Initialize the theme manager."""
|
|
35
38
|
self._terminal = terminal
|
|
36
39
|
self._mode = self._coerce_mode(mode)
|
|
37
|
-
self._theme = theme
|
|
40
|
+
self._theme = self._normalize_theme_name(theme)
|
|
41
|
+
self._settings_store = settings_store
|
|
38
42
|
|
|
39
43
|
@property
|
|
40
44
|
def mode(self) -> ThemeMode:
|
|
@@ -70,10 +74,12 @@ class ThemeManager:
|
|
|
70
74
|
def set_mode(self, mode: ThemeMode | str) -> None:
|
|
71
75
|
"""Set auto/light/dark mode."""
|
|
72
76
|
self._mode = self._coerce_mode(mode)
|
|
77
|
+
self._persist_preferences()
|
|
73
78
|
|
|
74
79
|
def set_theme(self, theme: str | None) -> None:
|
|
75
80
|
"""Set explicit theme name (or None to use the default)."""
|
|
76
|
-
self._theme = theme
|
|
81
|
+
self._theme = self._normalize_theme_name(theme)
|
|
82
|
+
self._persist_preferences()
|
|
77
83
|
|
|
78
84
|
def _coerce_mode(self, mode: ThemeMode | str) -> ThemeMode:
|
|
79
85
|
"""Coerce a mode value to ThemeMode enum, defaulting to AUTO on invalid input."""
|
|
@@ -84,3 +90,23 @@ class ThemeManager:
|
|
|
84
90
|
except ValueError:
|
|
85
91
|
logger.warning(f"Invalid theme mode '{mode}', defaulting to AUTO")
|
|
86
92
|
return ThemeMode.AUTO
|
|
93
|
+
|
|
94
|
+
def _persist_preferences(self) -> None:
|
|
95
|
+
if self._settings_store is None:
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
persist_tui_theme(mode=self._mode.value, name=self._theme, store=self._settings_store)
|
|
99
|
+
except (OSError, AccountStoreError) as exc:
|
|
100
|
+
# Log recoverable errors (permissions, I/O) as warnings
|
|
101
|
+
logger.warning(f"Failed to persist TUI theme preferences: {exc}")
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
# Log unexpected errors at error level for debugging
|
|
104
|
+
logger.error(f"Unexpected error persisting TUI theme preferences: {exc}", exc_info=True)
|
|
105
|
+
|
|
106
|
+
def _normalize_theme_name(self, theme: str | None) -> str | None:
|
|
107
|
+
if not isinstance(theme, str):
|
|
108
|
+
return None
|
|
109
|
+
cleaned = theme.strip()
|
|
110
|
+
if not cleaned or cleaned.lower() == "default":
|
|
111
|
+
return None
|
|
112
|
+
return cleaned
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Typed loader/saver for TUI preferences stored in config.yaml.
|
|
2
|
+
|
|
3
|
+
This module implements the TUI preferences store as defined in
|
|
4
|
+
`specs/architecture/tui-preferences-store/spec.md`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Literal, cast
|
|
11
|
+
|
|
12
|
+
from glaip_sdk.cli.account_store import AccountStore, get_account_store
|
|
13
|
+
|
|
14
|
+
ThemeModeValue = Literal["auto", "light", "dark"]
|
|
15
|
+
|
|
16
|
+
_DEFAULT_THEME_MODE: ThemeModeValue = "auto"
|
|
17
|
+
_DEFAULT_LEADER = "ctrl+x"
|
|
18
|
+
_DEFAULT_MOUSE_CAPTURE = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class TUISettings:
|
|
23
|
+
"""Resolved TUI preferences from config.yaml."""
|
|
24
|
+
|
|
25
|
+
theme_mode: ThemeModeValue = _DEFAULT_THEME_MODE
|
|
26
|
+
theme_name: str | None = None
|
|
27
|
+
leader: str = _DEFAULT_LEADER
|
|
28
|
+
keybind_overrides: dict[str, str] = field(default_factory=dict)
|
|
29
|
+
mouse_capture: bool = _DEFAULT_MOUSE_CAPTURE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_tui_settings(*, store: AccountStore | None = None) -> TUISettings:
|
|
33
|
+
"""Load TUI preferences from the CLI config file."""
|
|
34
|
+
store = store or get_account_store()
|
|
35
|
+
config = store.load_config()
|
|
36
|
+
|
|
37
|
+
tui = _as_dict(config.get("tui"))
|
|
38
|
+
theme = _as_dict(tui.get("theme"))
|
|
39
|
+
mode = _coerce_theme_mode(theme.get("mode"))
|
|
40
|
+
name = _normalize_theme_name(theme.get("name"))
|
|
41
|
+
|
|
42
|
+
keybinds = _as_dict(tui.get("keybinds"))
|
|
43
|
+
leader = keybinds.get("leader")
|
|
44
|
+
if not isinstance(leader, str) or not leader.strip():
|
|
45
|
+
leader = _DEFAULT_LEADER
|
|
46
|
+
|
|
47
|
+
overrides = _coerce_keybind_overrides(keybinds.get("overrides"))
|
|
48
|
+
|
|
49
|
+
mouse_capture = tui.get("mouse_capture")
|
|
50
|
+
if not isinstance(mouse_capture, bool):
|
|
51
|
+
mouse_capture = _DEFAULT_MOUSE_CAPTURE
|
|
52
|
+
|
|
53
|
+
return TUISettings(
|
|
54
|
+
theme_mode=mode,
|
|
55
|
+
theme_name=name,
|
|
56
|
+
leader=leader,
|
|
57
|
+
keybind_overrides=overrides,
|
|
58
|
+
mouse_capture=mouse_capture,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def update_tui_settings(patch: dict[str, Any], *, store: AccountStore | None = None) -> None:
|
|
63
|
+
"""Update TUI preferences, preserving unrelated config keys."""
|
|
64
|
+
store = store or get_account_store()
|
|
65
|
+
config = store.load_config()
|
|
66
|
+
|
|
67
|
+
existing = _as_dict(config.get("tui"))
|
|
68
|
+
config["tui"] = _merge_dict(existing, patch)
|
|
69
|
+
|
|
70
|
+
store.save_config_updates(config)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def persist_tui_theme(*, mode: ThemeModeValue, name: str | None, store: AccountStore | None = None) -> None:
|
|
74
|
+
"""Persist theme preferences in the tui.theme namespace."""
|
|
75
|
+
update_tui_settings(
|
|
76
|
+
{"theme": {"mode": _coerce_theme_mode(mode), "name": _serialize_theme_name(name)}},
|
|
77
|
+
store=store,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _as_dict(value: Any) -> dict[str, Any]:
|
|
82
|
+
if isinstance(value, dict):
|
|
83
|
+
return dict(value)
|
|
84
|
+
return {}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _coerce_theme_mode(value: Any) -> ThemeModeValue:
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
lowered = value.strip().lower()
|
|
90
|
+
if lowered in {"auto", "light", "dark"}:
|
|
91
|
+
return cast(ThemeModeValue, lowered)
|
|
92
|
+
return _DEFAULT_THEME_MODE
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _normalize_theme_name(value: Any) -> str | None:
|
|
96
|
+
if not isinstance(value, str):
|
|
97
|
+
return None
|
|
98
|
+
cleaned = value.strip()
|
|
99
|
+
if not cleaned or cleaned.lower() == "default":
|
|
100
|
+
return None
|
|
101
|
+
return cleaned
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _serialize_theme_name(name: str | None) -> str:
|
|
105
|
+
if isinstance(name, str):
|
|
106
|
+
cleaned = name.strip()
|
|
107
|
+
if cleaned:
|
|
108
|
+
return cleaned
|
|
109
|
+
return "default"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _coerce_keybind_overrides(value: Any) -> dict[str, str]:
|
|
113
|
+
if not isinstance(value, dict):
|
|
114
|
+
return {}
|
|
115
|
+
return {key: val for key, val in value.items() if isinstance(key, str) and isinstance(val, str)}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
|
119
|
+
merged = dict(base)
|
|
120
|
+
for key, value in patch.items():
|
|
121
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
122
|
+
merged[key] = _merge_dict(cast(dict[str, Any], merged[key]), value)
|
|
123
|
+
else:
|
|
124
|
+
merged[key] = value
|
|
125
|
+
return merged
|
glaip_sdk/tools/base.py
CHANGED
|
@@ -387,12 +387,22 @@ class Tool:
|
|
|
387
387
|
if not self._client:
|
|
388
388
|
raise RuntimeError(_CLIENT_NOT_AVAILABLE_MSG)
|
|
389
389
|
|
|
390
|
+
# Handle both Client (has .tools) and ToolClient (direct methods)
|
|
391
|
+
# Priority: Check if client has a 'tools' attribute (Client instance)
|
|
392
|
+
# Otherwise, use client directly (ToolClient instance)
|
|
393
|
+
if hasattr(self._client, "tools") and self._client.tools is not None:
|
|
394
|
+
# Main Client instance - use the tools sub-client
|
|
395
|
+
tools_client = self._client.tools
|
|
396
|
+
else:
|
|
397
|
+
# ToolClient instance - use directly
|
|
398
|
+
tools_client = self._client
|
|
399
|
+
|
|
390
400
|
# Check if file upload is requested
|
|
391
401
|
if "file" in kwargs:
|
|
392
402
|
file_path = kwargs.pop("file")
|
|
393
|
-
response =
|
|
403
|
+
response = tools_client.update_tool_via_file(self._id, file_path, **kwargs)
|
|
394
404
|
else:
|
|
395
|
-
response =
|
|
405
|
+
response = tools_client.update_tool(tool_id=self._id, **kwargs)
|
|
396
406
|
|
|
397
407
|
# Update local properties from response
|
|
398
408
|
if hasattr(response, "name") and response.name:
|
|
@@ -416,7 +426,17 @@ class Tool:
|
|
|
416
426
|
if not self._client:
|
|
417
427
|
raise RuntimeError(_CLIENT_NOT_AVAILABLE_MSG)
|
|
418
428
|
|
|
419
|
-
|
|
429
|
+
# Handle both Client (has .tools) and ToolClient (direct methods)
|
|
430
|
+
# Priority: Check if client has a 'tools' attribute (Client instance)
|
|
431
|
+
# Otherwise, use client directly (ToolClient instance)
|
|
432
|
+
if hasattr(self._client, "tools") and self._client.tools is not None:
|
|
433
|
+
# Main Client instance - use the tools sub-client
|
|
434
|
+
tools_client = self._client.tools
|
|
435
|
+
else:
|
|
436
|
+
# ToolClient instance - use directly
|
|
437
|
+
tools_client = self._client
|
|
438
|
+
|
|
439
|
+
tools_client.delete_tool(self._id)
|
|
420
440
|
self._id = None
|
|
421
441
|
self._client = None
|
|
422
442
|
|
|
@@ -7,10 +7,10 @@ glaip_sdk/rich_components.py,sha256=44Z0V1ZQleVh9gUDGwRR5mriiYFnVGOhm7fFxZYbP8c,
|
|
|
7
7
|
glaip_sdk/agents/__init__.py,sha256=VfYov56edbWuySXFEbWJ_jLXgwnFzPk1KB-9-mfsUCc,776
|
|
8
8
|
glaip_sdk/agents/base.py,sha256=GQnzCw2cqlrbxwdoWFfhBcBlEDgubY4tlD6gr1b3zps,44539
|
|
9
9
|
glaip_sdk/cli/__init__.py,sha256=xCCfuF1Yc7mpCDcfhHZTX0vizvtrDSLeT8MJ3V7m5A0,156
|
|
10
|
-
glaip_sdk/cli/account_store.py,sha256=
|
|
10
|
+
glaip_sdk/cli/account_store.py,sha256=u_memecwEQssustZs2wYBrHbEmKUlDfmmL-zO1F3n3A,19034
|
|
11
11
|
glaip_sdk/cli/agent_config.py,sha256=YAbFKrTNTRqNA6b0i0Q3pH-01rhHDRi5v8dxSFwGSwM,2401
|
|
12
12
|
glaip_sdk/cli/auth.py,sha256=bqOHMGIOCg3KXssme3uJBBjEbK0rCEppQ6oq-gJ-hzA,24276
|
|
13
|
-
glaip_sdk/cli/config.py,sha256=
|
|
13
|
+
glaip_sdk/cli/config.py,sha256=hFKQSdqELQZLKBuFnhEirNLDyPJwEmpQuD8nHuWxokg,3051
|
|
14
14
|
glaip_sdk/cli/constants.py,sha256=zqcVtzfj6huW97gbCmhkFqntge1H-c1vnkGqTazADgU,895
|
|
15
15
|
glaip_sdk/cli/context.py,sha256=--Y5vc6lgoAV7cRoUAr9UxSQaLmkMg29FolA7EwoRqM,3803
|
|
16
16
|
glaip_sdk/cli/display.py,sha256=ojgWdGeD5KUnGOmWNqqK4JP-1EaWHWX--DWze3BmIz0,12137
|
|
@@ -23,6 +23,7 @@ glaip_sdk/cli/mcp_validators.py,sha256=cwbz7p_p7_9xVuuF96OBQOdmEgo5UObU6iWWQ2X03
|
|
|
23
23
|
glaip_sdk/cli/pager.py,sha256=TmiMDNpUMuZju7QJ6A_ITqIoEf8Dhv8U6mTXx2Fga1k,7935
|
|
24
24
|
glaip_sdk/cli/resolution.py,sha256=AGvv7kllLcuvk_jdaArJqH3lId4IDEXpHceRZwy14xY,2448
|
|
25
25
|
glaip_sdk/cli/rich_helpers.py,sha256=kO47N8e506rxrN6Oc9mbAWN3Qb536oQPWZy1s9A616g,819
|
|
26
|
+
glaip_sdk/cli/tui_settings.py,sha256=ey9bSlolEj3_SMmjVVV_PY0FkfH1psAFPOl03NlykvI,3867
|
|
26
27
|
glaip_sdk/cli/update_notifier.py,sha256=0zpWxr4nSyz0tiLWyC7EEO2deAnVmsRcVlMV79G2QMI,18049
|
|
27
28
|
glaip_sdk/cli/validators.py,sha256=k4J2ACYJPF6UmWJfENt9OHWdp4RNArVxR3hoeqauO88,5629
|
|
28
29
|
glaip_sdk/cli/commands/__init__.py,sha256=6Z3ASXDut0lAbUX_umBFtxPzzFyqoiZfVeTahThFu1A,219
|
|
@@ -80,10 +81,10 @@ glaip_sdk/cli/slash/remote_runs_controller.py,sha256=a5X5rYgb9l6dHhvTewRUCj-hAo7
|
|
|
80
81
|
glaip_sdk/cli/slash/session.py,sha256=Zn2hXND_Tfameh_PI8g4VIMd7GPWxwhtPNMN9p6cF7M,65081
|
|
81
82
|
glaip_sdk/cli/slash/tui/__init__.py,sha256=oBUzeoslYwPKVlhqhgg4I7480b77vQNc9ec0NgdTC1s,977
|
|
82
83
|
glaip_sdk/cli/slash/tui/accounts.tcss,sha256=BCjIuTetmVjydv6DCliY38Cze2LUEu7IY44sL5nIuLU,1194
|
|
83
|
-
glaip_sdk/cli/slash/tui/accounts_app.py,sha256=
|
|
84
|
+
glaip_sdk/cli/slash/tui/accounts_app.py,sha256=baxAN1kUs3xyOvk6VCrdwcdEL89D37FkLlFP8mXrKHE,42049
|
|
84
85
|
glaip_sdk/cli/slash/tui/background_tasks.py,sha256=SAe1mV2vXB3mJcSGhelU950vf8Lifjhws9iomyIVFKw,2422
|
|
85
86
|
glaip_sdk/cli/slash/tui/clipboard.py,sha256=HL_RWIdONyRmDtTYuDzxJTS_mRcLxuR37Ac9Ug5nh40,4730
|
|
86
|
-
glaip_sdk/cli/slash/tui/context.py,sha256=
|
|
87
|
+
glaip_sdk/cli/slash/tui/context.py,sha256=XgHsXPl8LDDwIueP_jhP6xqpx_zHOk0FF19rWob_z5Q,3128
|
|
87
88
|
glaip_sdk/cli/slash/tui/keybind_registry.py,sha256=_rK05BxTxNudYc4iJ9gDxpgeUkjDAq8rarIT-9A-jyM,6739
|
|
88
89
|
glaip_sdk/cli/slash/tui/loading.py,sha256=nW5pv_Tnl9FUOPR3Qf2O5gt1AGHSo3b5-Uofg34F6AE,1909
|
|
89
90
|
glaip_sdk/cli/slash/tui/remote_runs_app.py,sha256=RCrI-c5ilKV6Iy1lz2Aok9xo2Ou02vqcXACMXTdodnE,24716
|
|
@@ -91,7 +92,7 @@ glaip_sdk/cli/slash/tui/terminal.py,sha256=iC31XChTL34gXY6vXdSIX3HmD36tuA9EYTPZ2
|
|
|
91
92
|
glaip_sdk/cli/slash/tui/toast.py,sha256=LP_myZwgnrdowrRxGK24lMlx7iZt7iOwFhrbc4NW0DY,3493
|
|
92
93
|
glaip_sdk/cli/slash/tui/theme/__init__.py,sha256=rtM2ik83YNCRcI1qh_Sf3rnxco2OvCNNT3NbHY6cLvw,432
|
|
93
94
|
glaip_sdk/cli/slash/tui/theme/catalog.py,sha256=G52eU3h8YI9D8XUALVg1KVZ4Lq65VnZdgPS3F_P7XLE,2544
|
|
94
|
-
glaip_sdk/cli/slash/tui/theme/manager.py,sha256=
|
|
95
|
+
glaip_sdk/cli/slash/tui/theme/manager.py,sha256=LBnxEMIwz-8cAlZGYk5tIoAJbOJyGYsmDlyuGJ-LlX4,3945
|
|
95
96
|
glaip_sdk/cli/slash/tui/theme/tokens.py,sha256=ympMRny_d-gHtmnPR-lmNZ-C9SGBy2q-MH81l0L1h-Y,1423
|
|
96
97
|
glaip_sdk/cli/transcript/__init__.py,sha256=yiYHyNtebMCu3BXu56Xm5RBC2tDc865q8UGPnoe6QRs,920
|
|
97
98
|
glaip_sdk/cli/transcript/cache.py,sha256=Wi1uln6HP1U6F-MRTrfnxi9bn6XJTxwWXhREIRPoMqQ,17439
|
|
@@ -153,7 +154,7 @@ glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py,sha256=SgfQM5NgKyYBs34ju
|
|
|
153
154
|
glaip_sdk/schedules/__init__.py,sha256=Ty__lE8ta3a6O7EiEsSXliVOwA3EBLKxKRsjAJt2WUg,482
|
|
154
155
|
glaip_sdk/schedules/base.py,sha256=ZRKWknoxQOYMhX8mjQ7S7oqpy6Wr0xdbzcgIrycsEQ8,9727
|
|
155
156
|
glaip_sdk/tools/__init__.py,sha256=rhGzEqQFCzeMrxmikBuNrMz4PyYczwic28boDKVmoHs,585
|
|
156
|
-
glaip_sdk/tools/base.py,sha256=
|
|
157
|
+
glaip_sdk/tools/base.py,sha256=KRaWWX5cKAvEKtBr4iSOaKQlQ973A4pNOW2KVvA1aYs,17353
|
|
157
158
|
glaip_sdk/utils/__init__.py,sha256=5a1kNLtUriwd1qAT6RU083GOyABS7LMZQacDP4yS9S4,2830
|
|
158
159
|
glaip_sdk/utils/agent_config.py,sha256=RhcHsSOVwOaSC2ggnPuHn36Aa0keGJhs8KGb2InvzRk,7262
|
|
159
160
|
glaip_sdk/utils/bundler.py,sha256=fLumFj1MqqqGA1Mwn05v_cEKPALv3rIPEMvaURpxZ80,15171
|
|
@@ -206,8 +207,8 @@ glaip_sdk/utils/rendering/steps/format.py,sha256=Chnq7OBaj8XMeBntSBxrX5zSmrYeGcO
|
|
|
206
207
|
glaip_sdk/utils/rendering/steps/manager.py,sha256=BiBmTeQMQhjRMykgICXsXNYh1hGsss-fH9BIGVMWFi0,13194
|
|
207
208
|
glaip_sdk/utils/rendering/viewer/__init__.py,sha256=XrxmE2cMAozqrzo1jtDFm8HqNtvDcYi2mAhXLXn5CjI,457
|
|
208
209
|
glaip_sdk/utils/rendering/viewer/presenter.py,sha256=mlLMTjnyeyPVtsyrAbz1BJu9lFGQSlS-voZ-_Cuugv0,5725
|
|
209
|
-
glaip_sdk-0.7.
|
|
210
|
-
glaip_sdk-0.7.
|
|
211
|
-
glaip_sdk-0.7.
|
|
212
|
-
glaip_sdk-0.7.
|
|
213
|
-
glaip_sdk-0.7.
|
|
210
|
+
glaip_sdk-0.7.8.dist-info/METADATA,sha256=srMagYZpJce1Yhs0KJo0636l_He6YAFCS1HVtEY8k9Q,8365
|
|
211
|
+
glaip_sdk-0.7.8.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
212
|
+
glaip_sdk-0.7.8.dist-info/entry_points.txt,sha256=NkhO6FfgX9Zrjn63GuKphf-dLw7KNJvucAcXc7P3aMk,54
|
|
213
|
+
glaip_sdk-0.7.8.dist-info/top_level.txt,sha256=td7yXttiYX2s94-4wFhv-5KdT0rSZ-pnJRSire341hw,10
|
|
214
|
+
glaip_sdk-0.7.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|