glaip-sdk 0.7.7__py3-none-any.whl → 0.7.9__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.
@@ -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
@@ -47,6 +47,7 @@ _PRESERVE_KEYS = {
47
47
  "version",
48
48
  "active_account",
49
49
  "accounts",
50
+ "tui",
50
51
  }
51
52
 
52
53
 
@@ -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
 
@@ -36,6 +44,7 @@ try: # pragma: no cover - optional dependency
36
44
  from textual.binding import Binding
37
45
  from textual.containers import Container, Horizontal, Vertical
38
46
  from textual.screen import ModalScreen
47
+ from textual.suggester import SuggestFromList
39
48
  from textual.widgets import Button, Checkbox, DataTable, Footer, Header, Input, LoadingIndicator, Static
40
49
  except Exception: # pragma: no cover - optional dependency
41
50
  events = None # type: ignore[assignment]
@@ -54,6 +63,7 @@ except Exception: # pragma: no cover - optional dependency
54
63
  LoadingIndicator = None # type: ignore[assignment]
55
64
  ModalScreen = None # type: ignore[assignment]
56
65
  Static = None # type: ignore[assignment]
66
+ SuggestFromList = None # type: ignore[assignment]
57
67
  Theme = None # type: ignore[assignment]
58
68
 
59
69
  if App is not None:
@@ -84,6 +94,30 @@ FORM_KEY_ID = "#form-key"
84
94
  # CSS file name
85
95
  CSS_FILE_NAME = "accounts.tcss"
86
96
 
97
+ KEYBIND_SCOPE = "accounts"
98
+ KEYBIND_CATEGORY = "Accounts"
99
+
100
+
101
+ @dataclass
102
+ class KeybindDef:
103
+ """Keybind definition with action, key, and description."""
104
+
105
+ action: str
106
+ key: str
107
+ description: str
108
+
109
+
110
+ KEYBIND_DEFINITIONS: tuple[KeybindDef, ...] = (
111
+ KeybindDef("switch_row", "enter", "Switch"),
112
+ KeybindDef("focus_filter", "/", "Filter"),
113
+ KeybindDef("add_account", "a", "Add"),
114
+ KeybindDef("edit_account", "e", "Edit"),
115
+ KeybindDef("delete_account", "d", "Delete"),
116
+ KeybindDef("copy_account", "c", "Copy"),
117
+ KeybindDef("clear_or_exit", "escape", "Close"),
118
+ KeybindDef("app_exit", "q", "Close"),
119
+ )
120
+
87
121
 
88
122
  @dataclass
89
123
  class AccountsTUICallbacks:
@@ -250,6 +284,27 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
250
284
  self._connection_tester = connection_tester
251
285
  self._validate_name = validate_name
252
286
 
287
+ def _get_api_url_suggestions(self, _value: str) -> list[str]:
288
+ """Get API URL suggestions from existing accounts.
289
+
290
+ Args:
291
+ _value: Current input value (unused, but required by Textual's suggestor API).
292
+
293
+ Returns:
294
+ List of unique API URLs from existing accounts.
295
+ """
296
+ try:
297
+ store = get_account_store()
298
+ accounts = store.list_accounts()
299
+ # Extract unique API URLs, excluding the current account's URL in edit mode
300
+ existing_url = self._existing.get("api_url", "")
301
+ urls = {account.get("api_url", "") for account in accounts.values() if account.get("api_url")}
302
+ if existing_url in urls:
303
+ urls.remove(existing_url)
304
+ return sorted(urls)
305
+ except Exception: # pragma: no cover - defensive
306
+ return []
307
+
253
308
  def compose(self) -> ComposeResult:
254
309
  """Render the form controls."""
255
310
  title = "Add account" if self._mode == "add" else "Edit account"
@@ -259,7 +314,17 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
259
314
  id="form-name",
260
315
  disabled=self._mode == "edit",
261
316
  )
262
- url_input = Input(value=self._existing.get("api_url", ""), placeholder="https://api.example.com", id="form-url")
317
+ # Get API URL suggestions and create suggester
318
+ url_suggestions = self._get_api_url_suggestions("")
319
+ url_suggester = None
320
+ if SuggestFromList and url_suggestions:
321
+ url_suggester = SuggestFromList(url_suggestions, case_sensitive=False)
322
+ url_input = Input(
323
+ value=self._existing.get("api_url", ""),
324
+ placeholder="https://api.example.com",
325
+ id="form-url",
326
+ suggester=url_suggester,
327
+ )
263
328
  key_input = Input(value="", placeholder="sk-...", password=True, id="form-key")
264
329
  test_checkbox = Checkbox(
265
330
  "Test connection before save",
@@ -427,8 +492,13 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
427
492
  self._env_lock = env_lock
428
493
  self._callbacks = callbacks
429
494
  self._ctx = ctx
495
+ self._keybinds: KeybindRegistry | None = None
496
+ self._toast_bus: ToastBus | None = None
497
+ self._toast_ready = False
498
+ self._clipboard: ClipboardAdapter | None = None
430
499
  self._filter_text: str = ""
431
500
  self._is_switching = False
501
+ self._initialize_context_services()
432
502
 
433
503
  def compose(self) -> ComposeResult:
434
504
  """Build the Textual layout."""
@@ -481,6 +551,52 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
481
551
  main = self.query_one(Vertical)
482
552
  main.styles.gap = 0
483
553
  self._update_filter_button_visibility()
554
+ self._prepare_toasts()
555
+ self._register_keybinds()
556
+
557
+ def _initialize_context_services(self) -> None:
558
+ if self._ctx:
559
+ if self._ctx.keybinds is None:
560
+ self._ctx.keybinds = KeybindRegistry()
561
+ if self._ctx.toasts is None:
562
+ self._ctx.toasts = ToastBus()
563
+ if self._ctx.clipboard is None:
564
+ self._ctx.clipboard = ClipboardAdapter(terminal=self._ctx.terminal)
565
+ self._keybinds = self._ctx.keybinds
566
+ self._toast_bus = self._ctx.toasts
567
+ self._clipboard = self._ctx.clipboard
568
+ else:
569
+ # Fallback: create services independently when ctx is None
570
+ terminal = TerminalCapabilities(
571
+ tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
572
+ )
573
+ self._clipboard = ClipboardAdapter(terminal=terminal)
574
+
575
+ def _prepare_toasts(self) -> None:
576
+ """Prepare toast system by marking ready and clearing any existing toasts."""
577
+ self._toast_ready = True
578
+ if self._toast_bus:
579
+ self._toast_bus.clear()
580
+
581
+ def _register_keybinds(self) -> None:
582
+ if not self._keybinds:
583
+ return
584
+ for keybind_def in KEYBIND_DEFINITIONS:
585
+ scoped_action = f"{KEYBIND_SCOPE}.{keybind_def.action}"
586
+ if self._keybinds.get(scoped_action):
587
+ continue
588
+ try:
589
+ self._keybinds.register(
590
+ action=scoped_action,
591
+ key=keybind_def.key,
592
+ description=keybind_def.description,
593
+ category=KEYBIND_CATEGORY,
594
+ )
595
+ except ValueError as e:
596
+ # Expected: duplicate registration (already registered by another component)
597
+ # Silently skip to allow multiple apps to register same keybinds
598
+ logging.debug(f"Skipping duplicate keybind registration: {scoped_action}", exc_info=e)
599
+ continue
484
600
 
485
601
  def _header_text(self) -> str:
486
602
  """Build header text with active account and host."""
@@ -643,6 +759,24 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
643
759
  """Hide the loading indicator."""
644
760
  hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
645
761
 
762
+ def _handle_switch_scheduling_error(self, exc: Exception) -> None:
763
+ """Handle errors when scheduling the switch task fails.
764
+
765
+ Args:
766
+ exc: The exception that occurred during task scheduling.
767
+ """
768
+ self._hide_loading()
769
+ self._is_switching = False
770
+ error_msg = f"Switch failed to start: {exc}"
771
+ if self._toast_ready and self._toast_bus:
772
+ self._toast_bus.show(message=error_msg, variant="error")
773
+ try:
774
+ self._set_status(error_msg, "red")
775
+ except Exception:
776
+ # App not mounted yet, status update not possible
777
+ logging.error(error_msg, exc_info=exc)
778
+ logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
779
+
646
780
  def _clear_filter(self) -> None:
647
781
  """Clear the filter input and reset filter state."""
648
782
  filter_input = self.query_one(FILTER_INPUT_ID, Input)
@@ -665,7 +799,9 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
665
799
 
666
800
  if switched:
667
801
  self._active_account = name
668
- self._set_status(message or f"Switched to '{name}'.", "green")
802
+ status_msg = message or f"Switched to '{name}'."
803
+ if self._toast_ready and self._toast_bus:
804
+ self._toast_bus.show(message=status_msg, variant="success")
669
805
  self._update_header()
670
806
  self._reload_rows()
671
807
  else:
@@ -674,11 +810,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
674
810
  try:
675
811
  self.track_task(perform(), logger=logging.getLogger(__name__))
676
812
  except Exception as exc:
677
- # If scheduling the task fails, clear loading/switching state and surface the error.
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)
813
+ self._handle_switch_scheduling_error(exc)
682
814
 
683
815
  def _update_header(self) -> None:
684
816
  """Refresh header text to reflect active/lock state."""
@@ -781,12 +913,32 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
781
913
  return
782
914
 
783
915
  text = f"Account: {name}\nURL: {account.get('api_url', '')}"
784
- adapter = ClipboardAdapter()
785
- result = adapter.copy(text)
916
+ adapter = self._clipboard or ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
917
+ # OSC 52 works by writing to stdout, no custom writer needed
918
+ try:
919
+ asyncio.get_running_loop()
920
+ except RuntimeError:
921
+ result = adapter.copy(text)
922
+ self._handle_copy_result(name, result)
923
+ return
924
+
925
+ async def perform() -> None:
926
+ result = await asyncio.to_thread(adapter.copy, text)
927
+ self._handle_copy_result(name, result)
928
+
929
+ self.track_task(perform(), logger=logging.getLogger(__name__))
786
930
 
931
+ def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
932
+ """Update UI state after a copy attempt."""
787
933
  if result.success:
934
+ if self._toast_ready and self._toast_bus:
935
+ self._toast_bus.copy_success(label=name)
936
+ # Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
788
937
  self._set_status(f"Copied '{name}' to clipboard.", "green")
789
938
  else:
939
+ if self._toast_ready and self._toast_bus:
940
+ self._toast_bus.show(message=f"Copy failed: {result.message}", variant="warning")
941
+ # Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
790
942
  self._set_status(f"Copy failed: {result.message}", "red")
791
943
 
792
944
  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
- if TYPE_CHECKING:
21
- from glaip_sdk.cli.slash.tui.toast import ToastBus
21
+ from glaip_sdk.cli.slash.tui.toast import ToastBus
22
+ from glaip_sdk.cli.tui_settings import load_tui_settings
22
23
 
23
24
 
24
25
  @dataclass
@@ -37,23 +38,47 @@ class TUIContext:
37
38
  """
38
39
 
39
40
  terminal: TerminalCapabilities
40
- keybinds: object | None = None
41
+ keybinds: KeybindRegistry | None = None
41
42
  theme: ThemeManager | None = None
42
43
  toasts: ToastBus | None = None
43
- clipboard: object | None = None
44
+ clipboard: ClipboardAdapter | None = None
44
45
 
45
46
  @classmethod
46
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. Other components (keybinds,
51
- theme, toasts, clipboard) will be set incrementally as they are created.
51
+ returns a populated TUIContext instance with all services initialized
52
+ (keybinds, theme, toasts, clipboard).
52
53
 
53
54
  Returns:
54
- TUIContext instance with terminal capabilities detected.
55
+ TUIContext instance with all services initialized.
55
56
  """
56
57
  terminal = await TerminalCapabilities.detect()
57
- theme_name = os.getenv("AIP_TUI_THEME") or None
58
- theme = ThemeManager(terminal, theme=theme_name)
59
- return cls(terminal=terminal, theme=theme)
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 = self._client.tools.update_via_file(self._id, file_path, **kwargs)
403
+ response = tools_client.update_tool_via_file(self._id, file_path, **kwargs)
394
404
  else:
395
- response = self._client.tools.update(tool_id=self._id, **kwargs)
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
- self._client.tools.delete(tool_id=self._id)
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glaip-sdk
3
- Version: 0.7.7
3
+ Version: 0.7.9
4
4
  Summary: Python SDK and CLI for GL AIP (GDP Labs AI Agent Package) - Build, run, and manage AI agents
5
5
  Author-email: Raymond Christopher <raymond.christopher@gdplabs.id>
6
6
  License: MIT
@@ -20,11 +20,11 @@ Requires-Dist: gllm-core-binary>=0.1.0
20
20
  Requires-Dist: langchain-core>=0.3.0
21
21
  Requires-Dist: gllm-tools-binary>=0.1.3
22
22
  Provides-Extra: local
23
- Requires-Dist: aip-agents-binary[local]>=0.5.21; (python_version >= "3.11" and python_version < "3.13") and extra == "local"
23
+ Requires-Dist: aip-agents-binary[local]>=0.5.23; (python_version >= "3.11" and python_version < "3.13") and extra == "local"
24
24
  Provides-Extra: memory
25
- Requires-Dist: aip-agents-binary[memory]>=0.5.21; (python_version >= "3.11" and python_version < "3.13") and extra == "memory"
25
+ Requires-Dist: aip-agents-binary[memory]>=0.5.23; (python_version >= "3.11" and python_version < "3.13") and extra == "memory"
26
26
  Provides-Extra: privacy
27
- Requires-Dist: aip-agents-binary[privacy]>=0.5.21; (python_version >= "3.11" and python_version < "3.13") and extra == "privacy"
27
+ Requires-Dist: aip-agents-binary[privacy]>=0.5.23; (python_version >= "3.11" and python_version < "3.13") and extra == "privacy"
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: pytest>=7.0.0; extra == "dev"
30
30
  Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
@@ -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=TK4iTV93Q1uD9mCY_2ZMT6EazHKU2jX0qhgWfEM4V-4,18459
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=s0_xBB1e5YE4I_Wc4q-ayY3dwsBU1JrHAF-8ySlim7Y,3040
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=6ihnAnzKD49eeXYW3dYWUAdUEyoXNFwoEoi3kS3WtXM,35999
84
+ glaip_sdk/cli/slash/tui/accounts_app.py,sha256=pQYQz9ljPn4WcXEmyhMk3Lt1h_ZgrMvlYplxN3lbcy4,42411
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=03mo2kgvpyUcNBYz7G2Uyu7X3FJlSUzVoP5Rt9MCZZY,2141
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=X600J_WIBM1CHgsQeMFGFPuaVAFCINFcBXFWmeD4B5Q,2707
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=tEhOHPV8bL5p59l6aB0oYPa51_12bMznQKWAzESZ1FE,16354
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.7.dist-info/METADATA,sha256=ZmjKOkOJYka0gJNYNil9Z8BzTwzy2rMN896Wy0L3ZOI,8365
210
- glaip_sdk-0.7.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
211
- glaip_sdk-0.7.7.dist-info/entry_points.txt,sha256=NkhO6FfgX9Zrjn63GuKphf-dLw7KNJvucAcXc7P3aMk,54
212
- glaip_sdk-0.7.7.dist-info/top_level.txt,sha256=td7yXttiYX2s94-4wFhv-5KdT0rSZ-pnJRSire341hw,10
213
- glaip_sdk-0.7.7.dist-info/RECORD,,
210
+ glaip_sdk-0.7.9.dist-info/METADATA,sha256=Zv0LWJWtHBnmVcDY19GixzfzpVEIGpTUEQeqInAMd5E,8365
211
+ glaip_sdk-0.7.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
212
+ glaip_sdk-0.7.9.dist-info/entry_points.txt,sha256=NkhO6FfgX9Zrjn63GuKphf-dLw7KNJvucAcXc7P3aMk,54
213
+ glaip_sdk-0.7.9.dist-info/top_level.txt,sha256=td7yXttiYX2s94-4wFhv-5KdT0rSZ-pnJRSire341hw,10
214
+ glaip_sdk-0.7.9.dist-info/RECORD,,