glaip-sdk 0.6.5b3__py3-none-any.whl → 0.7.17__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/__init__.py +42 -5
- glaip_sdk/agents/base.py +362 -39
- glaip_sdk/branding.py +113 -2
- glaip_sdk/cli/account_store.py +15 -0
- glaip_sdk/cli/auth.py +14 -8
- glaip_sdk/cli/commands/accounts.py +1 -1
- glaip_sdk/cli/commands/agents/__init__.py +116 -0
- glaip_sdk/cli/commands/agents/_common.py +562 -0
- glaip_sdk/cli/commands/agents/create.py +155 -0
- glaip_sdk/cli/commands/agents/delete.py +64 -0
- glaip_sdk/cli/commands/agents/get.py +89 -0
- glaip_sdk/cli/commands/agents/list.py +129 -0
- glaip_sdk/cli/commands/agents/run.py +264 -0
- glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
- glaip_sdk/cli/commands/agents/update.py +112 -0
- glaip_sdk/cli/commands/common_config.py +15 -12
- glaip_sdk/cli/commands/configure.py +2 -3
- glaip_sdk/cli/commands/mcps/__init__.py +94 -0
- glaip_sdk/cli/commands/mcps/_common.py +459 -0
- glaip_sdk/cli/commands/mcps/connect.py +82 -0
- glaip_sdk/cli/commands/mcps/create.py +152 -0
- glaip_sdk/cli/commands/mcps/delete.py +73 -0
- glaip_sdk/cli/commands/mcps/get.py +212 -0
- glaip_sdk/cli/commands/mcps/list.py +69 -0
- glaip_sdk/cli/commands/mcps/tools.py +235 -0
- glaip_sdk/cli/commands/mcps/update.py +190 -0
- glaip_sdk/cli/commands/models.py +2 -4
- glaip_sdk/cli/commands/shared/__init__.py +21 -0
- glaip_sdk/cli/commands/shared/formatters.py +91 -0
- glaip_sdk/cli/commands/tools/__init__.py +69 -0
- glaip_sdk/cli/commands/tools/_common.py +80 -0
- glaip_sdk/cli/commands/tools/create.py +228 -0
- glaip_sdk/cli/commands/tools/delete.py +61 -0
- glaip_sdk/cli/commands/tools/get.py +103 -0
- glaip_sdk/cli/commands/tools/list.py +69 -0
- glaip_sdk/cli/commands/tools/script.py +49 -0
- glaip_sdk/cli/commands/tools/update.py +102 -0
- glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
- glaip_sdk/cli/commands/transcripts/_common.py +9 -0
- glaip_sdk/cli/commands/transcripts/clear.py +5 -0
- glaip_sdk/cli/commands/transcripts/detail.py +5 -0
- glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
- glaip_sdk/cli/commands/update.py +163 -17
- glaip_sdk/cli/config.py +1 -0
- glaip_sdk/cli/core/output.py +12 -7
- glaip_sdk/cli/entrypoint.py +20 -0
- glaip_sdk/cli/main.py +127 -39
- glaip_sdk/cli/pager.py +3 -3
- glaip_sdk/cli/resolution.py +2 -1
- glaip_sdk/cli/slash/accounts_controller.py +112 -32
- glaip_sdk/cli/slash/agent_session.py +5 -2
- glaip_sdk/cli/slash/prompt.py +11 -0
- glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
- glaip_sdk/cli/slash/session.py +375 -25
- glaip_sdk/cli/slash/tui/__init__.py +28 -1
- glaip_sdk/cli/slash/tui/accounts.tcss +97 -6
- glaip_sdk/cli/slash/tui/accounts_app.py +1107 -126
- glaip_sdk/cli/slash/tui/clipboard.py +195 -0
- glaip_sdk/cli/slash/tui/context.py +92 -0
- glaip_sdk/cli/slash/tui/indicators.py +341 -0
- glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
- glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
- glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
- glaip_sdk/cli/slash/tui/loading.py +43 -21
- glaip_sdk/cli/slash/tui/remote_runs_app.py +152 -20
- glaip_sdk/cli/slash/tui/terminal.py +407 -0
- 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 +112 -0
- glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
- glaip_sdk/cli/slash/tui/toast.py +388 -0
- glaip_sdk/cli/transcript/history.py +1 -1
- glaip_sdk/cli/transcript/viewer.py +5 -3
- glaip_sdk/cli/tui_settings.py +125 -0
- glaip_sdk/cli/update_notifier.py +215 -7
- glaip_sdk/cli/validators.py +1 -1
- glaip_sdk/client/__init__.py +2 -1
- glaip_sdk/client/_schedule_payloads.py +89 -0
- glaip_sdk/client/agents.py +290 -16
- glaip_sdk/client/base.py +25 -0
- glaip_sdk/client/hitl.py +136 -0
- glaip_sdk/client/main.py +7 -5
- glaip_sdk/client/mcps.py +44 -13
- glaip_sdk/client/payloads/agent/__init__.py +23 -0
- glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +28 -48
- glaip_sdk/client/payloads/agent/responses.py +43 -0
- glaip_sdk/client/run_rendering.py +414 -3
- glaip_sdk/client/schedules.py +439 -0
- glaip_sdk/client/tools.py +57 -26
- glaip_sdk/config/constants.py +22 -2
- glaip_sdk/guardrails/__init__.py +80 -0
- glaip_sdk/guardrails/serializer.py +89 -0
- glaip_sdk/hitl/__init__.py +48 -0
- glaip_sdk/hitl/base.py +64 -0
- glaip_sdk/hitl/callback.py +43 -0
- glaip_sdk/hitl/local.py +121 -0
- glaip_sdk/hitl/remote.py +523 -0
- glaip_sdk/models/__init__.py +47 -1
- glaip_sdk/models/_provider_mappings.py +101 -0
- glaip_sdk/models/_validation.py +97 -0
- glaip_sdk/models/agent.py +2 -1
- glaip_sdk/models/agent_runs.py +2 -1
- glaip_sdk/models/constants.py +141 -0
- glaip_sdk/models/model.py +170 -0
- glaip_sdk/models/schedule.py +224 -0
- glaip_sdk/payload_schemas/agent.py +1 -0
- glaip_sdk/payload_schemas/guardrails.py +34 -0
- glaip_sdk/registry/tool.py +273 -66
- glaip_sdk/runner/__init__.py +76 -0
- glaip_sdk/runner/base.py +84 -0
- glaip_sdk/runner/deps.py +115 -0
- glaip_sdk/runner/langgraph.py +1055 -0
- glaip_sdk/runner/logging_config.py +77 -0
- glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
- glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
- glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
- glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +116 -0
- glaip_sdk/runner/tool_adapter/__init__.py +18 -0
- glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
- glaip_sdk/schedules/__init__.py +22 -0
- glaip_sdk/schedules/base.py +291 -0
- glaip_sdk/tools/base.py +67 -14
- glaip_sdk/utils/__init__.py +1 -0
- glaip_sdk/utils/a2a/__init__.py +34 -0
- glaip_sdk/utils/a2a/event_processor.py +188 -0
- glaip_sdk/utils/agent_config.py +8 -2
- glaip_sdk/utils/bundler.py +138 -2
- glaip_sdk/utils/import_resolver.py +43 -11
- glaip_sdk/utils/rendering/renderer/base.py +58 -0
- glaip_sdk/utils/runtime_config.py +120 -0
- glaip_sdk/utils/sync.py +31 -11
- glaip_sdk/utils/tool_detection.py +301 -0
- glaip_sdk/utils/tool_storage_provider.py +140 -0
- {glaip_sdk-0.6.5b3.dist-info → glaip_sdk-0.7.17.dist-info}/METADATA +49 -38
- glaip_sdk-0.7.17.dist-info/RECORD +224 -0
- {glaip_sdk-0.6.5b3.dist-info → glaip_sdk-0.7.17.dist-info}/WHEEL +2 -1
- glaip_sdk-0.7.17.dist-info/entry_points.txt +2 -0
- glaip_sdk-0.7.17.dist-info/top_level.txt +1 -0
- glaip_sdk/cli/commands/agents.py +0 -1509
- glaip_sdk/cli/commands/mcps.py +0 -1356
- glaip_sdk/cli/commands/tools.py +0 -576
- glaip_sdk/cli/utils.py +0 -263
- glaip_sdk-0.6.5b3.dist-info/RECORD +0 -145
- glaip_sdk-0.6.5b3.dist-info/entry_points.txt +0 -3
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
_ENV_CLIPBOARD_METHOD = "AIP_TUI_CLIPBOARD_METHOD"
|
|
49
|
+
_ENV_CLIPBOARD_FORCE = "AIP_TUI_CLIPBOARD_FORCE"
|
|
50
|
+
_ENV_METHOD_MAP = {
|
|
51
|
+
"osc52": ClipboardMethod.OSC52,
|
|
52
|
+
"pbcopy": ClipboardMethod.PBCOPY,
|
|
53
|
+
"xclip": ClipboardMethod.XCLIP,
|
|
54
|
+
"xsel": ClipboardMethod.XSEL,
|
|
55
|
+
"wl-copy": ClipboardMethod.WL_COPY,
|
|
56
|
+
"wl_copy": ClipboardMethod.WL_COPY,
|
|
57
|
+
"clip": ClipboardMethod.CLIP,
|
|
58
|
+
"none": ClipboardMethod.NONE,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_env_method() -> ClipboardMethod | None:
|
|
63
|
+
raw = os.getenv(_ENV_CLIPBOARD_METHOD)
|
|
64
|
+
if not raw:
|
|
65
|
+
return None
|
|
66
|
+
value = raw.strip().lower()
|
|
67
|
+
if value in ("auto", "default"):
|
|
68
|
+
return None
|
|
69
|
+
return _ENV_METHOD_MAP.get(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_env_force_enabled() -> bool:
|
|
73
|
+
raw = os.getenv(_ENV_CLIPBOARD_FORCE)
|
|
74
|
+
if not raw:
|
|
75
|
+
return False
|
|
76
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ClipboardAdapter:
|
|
80
|
+
"""Cross-platform clipboard access with OSC 52 fallback."""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
terminal: TerminalCapabilities | None = None,
|
|
86
|
+
method: ClipboardMethod | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Initialize the adapter."""
|
|
89
|
+
self._terminal = terminal
|
|
90
|
+
self._force_method = False
|
|
91
|
+
if method is not None:
|
|
92
|
+
self._method = method
|
|
93
|
+
else:
|
|
94
|
+
env_method = _resolve_env_method()
|
|
95
|
+
if env_method is not None:
|
|
96
|
+
self._method = env_method
|
|
97
|
+
self._force_method = _is_env_force_enabled()
|
|
98
|
+
else:
|
|
99
|
+
self._method = self._detect_method()
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def method(self) -> ClipboardMethod:
|
|
103
|
+
"""Return the detected clipboard backend."""
|
|
104
|
+
return self._method
|
|
105
|
+
|
|
106
|
+
def copy(self, text: str, *, writer: Callable[[str], Any] | None = None) -> ClipboardResult:
|
|
107
|
+
"""Copy text to clipboard using the best available method.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
text: Text to copy.
|
|
111
|
+
writer: Optional function to write OSC 52 sequence (e.g., self.app.console.write).
|
|
112
|
+
Defaults to sys.stdout.write if not provided.
|
|
113
|
+
"""
|
|
114
|
+
if self._method == ClipboardMethod.OSC52:
|
|
115
|
+
return self._copy_osc52(text, writer=writer)
|
|
116
|
+
|
|
117
|
+
command = _SUBPROCESS_COMMANDS.get(self._method)
|
|
118
|
+
if command is None:
|
|
119
|
+
if self._force_method:
|
|
120
|
+
return ClipboardResult(False, self._method, "Forced clipboard method unavailable.")
|
|
121
|
+
return self._copy_osc52(text, writer=writer)
|
|
122
|
+
|
|
123
|
+
result = self._copy_subprocess(command, text)
|
|
124
|
+
if not result.success:
|
|
125
|
+
if self._force_method:
|
|
126
|
+
return result
|
|
127
|
+
return self._copy_osc52(text, writer=writer)
|
|
128
|
+
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
def _detect_method(self) -> ClipboardMethod:
|
|
132
|
+
system = platform.system()
|
|
133
|
+
method = ClipboardMethod.NONE
|
|
134
|
+
if system == "Darwin":
|
|
135
|
+
method = self._detect_darwin_method()
|
|
136
|
+
elif system == "Linux":
|
|
137
|
+
method = self._detect_linux_method()
|
|
138
|
+
elif system == "Windows":
|
|
139
|
+
method = self._detect_windows_method()
|
|
140
|
+
|
|
141
|
+
if method is not ClipboardMethod.NONE:
|
|
142
|
+
return method
|
|
143
|
+
|
|
144
|
+
if self._terminal.osc52 if self._terminal else detect_osc52_support():
|
|
145
|
+
return ClipboardMethod.OSC52
|
|
146
|
+
|
|
147
|
+
return ClipboardMethod.NONE
|
|
148
|
+
|
|
149
|
+
def _detect_darwin_method(self) -> ClipboardMethod:
|
|
150
|
+
return ClipboardMethod.PBCOPY if shutil.which("pbcopy") else ClipboardMethod.NONE
|
|
151
|
+
|
|
152
|
+
def _detect_linux_method(self) -> ClipboardMethod:
|
|
153
|
+
if not os.getenv("DISPLAY") and not os.getenv("WAYLAND_DISPLAY"):
|
|
154
|
+
return ClipboardMethod.NONE
|
|
155
|
+
|
|
156
|
+
for cmd, method in (
|
|
157
|
+
("xclip", ClipboardMethod.XCLIP),
|
|
158
|
+
("xsel", ClipboardMethod.XSEL),
|
|
159
|
+
("wl-copy", ClipboardMethod.WL_COPY),
|
|
160
|
+
):
|
|
161
|
+
if shutil.which(cmd):
|
|
162
|
+
return method
|
|
163
|
+
return ClipboardMethod.NONE
|
|
164
|
+
|
|
165
|
+
def _detect_windows_method(self) -> ClipboardMethod:
|
|
166
|
+
return ClipboardMethod.CLIP if shutil.which("clip") else ClipboardMethod.NONE
|
|
167
|
+
|
|
168
|
+
def _copy_osc52(self, text: str, *, writer: Callable[[str], Any] | None = None) -> ClipboardResult:
|
|
169
|
+
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
|
|
170
|
+
sequence = f"\x1b]52;c;{encoded}\x07"
|
|
171
|
+
try:
|
|
172
|
+
if writer:
|
|
173
|
+
writer(sequence)
|
|
174
|
+
else:
|
|
175
|
+
sys.stdout.write(sequence)
|
|
176
|
+
sys.stdout.flush()
|
|
177
|
+
except Exception as exc:
|
|
178
|
+
return ClipboardResult(False, ClipboardMethod.OSC52, str(exc))
|
|
179
|
+
|
|
180
|
+
return ClipboardResult(True, ClipboardMethod.OSC52, "Copied to clipboard")
|
|
181
|
+
|
|
182
|
+
def _copy_subprocess(self, cmd: list[str], text: str) -> ClipboardResult:
|
|
183
|
+
try:
|
|
184
|
+
completed = subprocess.run(
|
|
185
|
+
cmd,
|
|
186
|
+
input=text.encode("utf-8"),
|
|
187
|
+
check=False,
|
|
188
|
+
)
|
|
189
|
+
except OSError as exc:
|
|
190
|
+
return ClipboardResult(False, self._method, str(exc))
|
|
191
|
+
|
|
192
|
+
if completed.returncode == 0:
|
|
193
|
+
return ClipboardResult(True, self._method, "Copied to clipboard")
|
|
194
|
+
|
|
195
|
+
return ClipboardResult(False, self._method, f"Command failed: {completed.returncode}")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Shared context for all TUI components.
|
|
2
|
+
|
|
3
|
+
This module provides the TUIContext dataclass, which serves as the Python equivalent
|
|
4
|
+
of OpenCode's nested provider pattern. It provides a single container for all TUI
|
|
5
|
+
services and state that can be injected into components.
|
|
6
|
+
|
|
7
|
+
Authors:
|
|
8
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass
|
|
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
|
|
19
|
+
from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
|
|
20
|
+
from glaip_sdk.cli.slash.tui.theme import ThemeManager
|
|
21
|
+
from glaip_sdk.cli.slash.tui.toast import ToastBus
|
|
22
|
+
from glaip_sdk.cli.tui_settings import load_tui_settings
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class TUIContext:
|
|
27
|
+
"""Shared context for all TUI components (Python equivalent of OpenCode's providers).
|
|
28
|
+
|
|
29
|
+
This context provides access to all TUI services and state. Components that will
|
|
30
|
+
be implemented in later phases are typed as Optional and will be None initially.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
terminal: Terminal capability detection results.
|
|
34
|
+
keybinds: Central keybind registry (Phase 3).
|
|
35
|
+
theme: Theme manager for light/dark mode and color tokens (Phase 2).
|
|
36
|
+
toasts: Toast notification bus (Phase 4).
|
|
37
|
+
clipboard: Clipboard adapter with OSC 52 support (Phase 4).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
terminal: TerminalCapabilities
|
|
41
|
+
keybinds: KeybindRegistry | None = None
|
|
42
|
+
theme: ThemeManager | None = None
|
|
43
|
+
toasts: ToastBus | None = None
|
|
44
|
+
clipboard: ClipboardAdapter | None = None
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
async def create(cls, *, detect_osc11: bool = True) -> TUIContext:
|
|
48
|
+
"""Create a TUIContext instance with detected terminal capabilities.
|
|
49
|
+
|
|
50
|
+
This factory method detects terminal capabilities asynchronously and
|
|
51
|
+
returns a populated TUIContext instance with all services initialized
|
|
52
|
+
(keybinds, theme, toasts, clipboard).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
detect_osc11: When False, skip OSC 11 background detection.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
TUIContext instance with all services initialized.
|
|
59
|
+
"""
|
|
60
|
+
terminal = await TerminalCapabilities.detect(detect_osc11=detect_osc11)
|
|
61
|
+
store = get_account_store()
|
|
62
|
+
settings = load_tui_settings(store=store)
|
|
63
|
+
|
|
64
|
+
env_theme = os.getenv("AIP_TUI_THEME")
|
|
65
|
+
env_theme = env_theme.strip() if env_theme else None
|
|
66
|
+
if env_theme and env_theme.lower() == "default":
|
|
67
|
+
env_theme = None
|
|
68
|
+
|
|
69
|
+
env_mouse = os.getenv("AIP_TUI_MOUSE_CAPTURE")
|
|
70
|
+
mouse_capture = settings.mouse_capture
|
|
71
|
+
if env_mouse is not None:
|
|
72
|
+
mouse_capture = env_mouse.lower() == "true"
|
|
73
|
+
|
|
74
|
+
terminal.mouse = mouse_capture
|
|
75
|
+
|
|
76
|
+
theme_name = env_theme or settings.theme_name
|
|
77
|
+
theme = ThemeManager(
|
|
78
|
+
terminal,
|
|
79
|
+
mode=settings.theme_mode,
|
|
80
|
+
theme=theme_name,
|
|
81
|
+
settings_store=store,
|
|
82
|
+
)
|
|
83
|
+
keybinds = KeybindRegistry()
|
|
84
|
+
toasts = ToastBus()
|
|
85
|
+
clipboard = ClipboardAdapter(terminal=terminal)
|
|
86
|
+
return cls(
|
|
87
|
+
terminal=terminal,
|
|
88
|
+
keybinds=keybinds,
|
|
89
|
+
theme=theme,
|
|
90
|
+
toasts=toasts,
|
|
91
|
+
clipboard=clipboard,
|
|
92
|
+
)
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""TUI animated indicators for waiting states."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
from textual._context import NoActiveAppError
|
|
9
|
+
from textual.timer import Timer
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
|
|
12
|
+
from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
|
|
13
|
+
|
|
14
|
+
DEFAULT_MESSAGE = "Processing…"
|
|
15
|
+
DEFAULT_WIDTH = 20
|
|
16
|
+
DEFAULT_SPEED_MS = 40
|
|
17
|
+
|
|
18
|
+
BAR_GLYPH = " "
|
|
19
|
+
PULSE_GLYPH = "█"
|
|
20
|
+
|
|
21
|
+
VARIANT_STYLES: dict[str, str] = {
|
|
22
|
+
# Default hex colors matching gl-dark theme (see theme/catalog.py)
|
|
23
|
+
# These are used as fallbacks when the app theme is not active
|
|
24
|
+
"accent": "#C77DFF",
|
|
25
|
+
"primary": "#6EA8FE",
|
|
26
|
+
"success": "#34D399",
|
|
27
|
+
"warning": "#FBBF24",
|
|
28
|
+
"error": "#F87171",
|
|
29
|
+
"info": "#60A5FA",
|
|
30
|
+
"subtle": "#9CA3AF",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PulseIndicator(Static):
|
|
35
|
+
"""A Codex-style moving light/pulse indicator for waiting states.
|
|
36
|
+
|
|
37
|
+
Mirrors the 'Knight Rider' / Cylon scanner animation pattern.
|
|
38
|
+
Specified in specs/architecture/cli-textual-animated-indicators/spec.md
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
DEFAULT_CSS = """
|
|
42
|
+
PulseIndicator {
|
|
43
|
+
width: auto;
|
|
44
|
+
height: 3;
|
|
45
|
+
content-align: center middle;
|
|
46
|
+
padding: 0 2;
|
|
47
|
+
border: round #666666;
|
|
48
|
+
color: $text;
|
|
49
|
+
background: $surface;
|
|
50
|
+
}
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
message: str | None = None,
|
|
56
|
+
*,
|
|
57
|
+
width: int = DEFAULT_WIDTH,
|
|
58
|
+
speed_ms: int = DEFAULT_SPEED_MS,
|
|
59
|
+
variant: str = "accent",
|
|
60
|
+
low_motion: bool = False,
|
|
61
|
+
**kwargs: Any,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Initialize the PulseIndicator."""
|
|
64
|
+
super().__init__(**kwargs)
|
|
65
|
+
self._width = self._coerce_width(width)
|
|
66
|
+
self._speed_ms = self._coerce_speed(speed_ms)
|
|
67
|
+
self._variant = self._coerce_variant(variant)
|
|
68
|
+
self._message = self._normalize_message(message)
|
|
69
|
+
self._low_motion = bool(low_motion)
|
|
70
|
+
self._position = 0
|
|
71
|
+
self._direction = 1
|
|
72
|
+
self._timer: Timer | None = None
|
|
73
|
+
self._pending_render: Text | None = None
|
|
74
|
+
self.can_focus = False
|
|
75
|
+
self.accessible_label = self._message
|
|
76
|
+
|
|
77
|
+
def on_mount(self) -> None:
|
|
78
|
+
"""Handle component mounting."""
|
|
79
|
+
# Initial render happens here to ensure component is ready for updates
|
|
80
|
+
self._safe_update(self._render_static() if self._low_motion else self._render_frame())
|
|
81
|
+
if self._pending_render is not None:
|
|
82
|
+
return
|
|
83
|
+
if self._timer is None and not self._low_motion:
|
|
84
|
+
self._timer = self.set_interval(self._speed_ms / 1000, self._tick)
|
|
85
|
+
|
|
86
|
+
def start(self, message: str | None = None) -> None:
|
|
87
|
+
"""Start the pulse animation."""
|
|
88
|
+
if message is not None:
|
|
89
|
+
self.update_message(message)
|
|
90
|
+
self._apply_pending_render()
|
|
91
|
+
self._cancel_timer()
|
|
92
|
+
if self._low_motion:
|
|
93
|
+
self._position = 0
|
|
94
|
+
self._safe_update(self._render_static())
|
|
95
|
+
return
|
|
96
|
+
self._timer = self.set_interval(self._speed_ms / 1000, self._tick)
|
|
97
|
+
self._safe_update(self._render_frame())
|
|
98
|
+
|
|
99
|
+
def stop(self, message: str | None = None) -> None:
|
|
100
|
+
"""Stop the pulse animation."""
|
|
101
|
+
if message is not None:
|
|
102
|
+
self.update_message(message)
|
|
103
|
+
self._cancel_timer()
|
|
104
|
+
self._position = 0
|
|
105
|
+
self._direction = 1
|
|
106
|
+
self._safe_update(self._render_static())
|
|
107
|
+
|
|
108
|
+
def update_message(self, message: str) -> None:
|
|
109
|
+
"""Update the display message."""
|
|
110
|
+
self._message = self._normalize_message(message)
|
|
111
|
+
self.accessible_label = self._message
|
|
112
|
+
self._safe_update(self._render_static() if self._low_motion else self._render_frame())
|
|
113
|
+
|
|
114
|
+
def _tick(self) -> None:
|
|
115
|
+
self._position += self._direction
|
|
116
|
+
if self._position >= self._width - 1:
|
|
117
|
+
self._position = self._width - 1
|
|
118
|
+
self._direction = -1
|
|
119
|
+
elif self._position <= 0:
|
|
120
|
+
self._position = 0
|
|
121
|
+
self._direction = 1
|
|
122
|
+
self._safe_update(self._render_frame())
|
|
123
|
+
|
|
124
|
+
def _render_frame(self) -> Text:
|
|
125
|
+
bar = self._render_bar(self._position, active=True)
|
|
126
|
+
bar.append(" ")
|
|
127
|
+
bar.append(self._message, style=self._message_style)
|
|
128
|
+
return bar
|
|
129
|
+
|
|
130
|
+
def _render_static(self) -> Text:
|
|
131
|
+
bar = self._render_bar(0, active=False)
|
|
132
|
+
bar.append(" ")
|
|
133
|
+
bar.append(self._message, style=self._message_style)
|
|
134
|
+
return bar
|
|
135
|
+
|
|
136
|
+
def _render_bar(self, position: int, *, active: bool) -> Text:
|
|
137
|
+
bg = self._resolve_style("on #111111", "$surface", is_bg=True)
|
|
138
|
+
bar = Text("[", style=f"grey37 {bg}")
|
|
139
|
+
|
|
140
|
+
p = position
|
|
141
|
+
v = self._active_style
|
|
142
|
+
|
|
143
|
+
for index in range(self._width):
|
|
144
|
+
if not active:
|
|
145
|
+
glyph = "█"
|
|
146
|
+
style = f"dim {v} {bg}"
|
|
147
|
+
else:
|
|
148
|
+
glyph, style = self._get_pulse_glyph_and_style(index, p, v, bg)
|
|
149
|
+
|
|
150
|
+
bar.append(glyph, style=style)
|
|
151
|
+
|
|
152
|
+
bar.append("]", style=f"grey37 {bg}")
|
|
153
|
+
return bar
|
|
154
|
+
|
|
155
|
+
def _get_pulse_glyph_and_style(self, index: int, p: int, v: str, bg: str) -> tuple[str, str]:
|
|
156
|
+
"""Determine glyph and style for a bar position during animation."""
|
|
157
|
+
dist = abs(index - p)
|
|
158
|
+
if dist == 0:
|
|
159
|
+
return "█", f"bold white {bg}"
|
|
160
|
+
if dist == 1:
|
|
161
|
+
return "█", f"{v} {bg}"
|
|
162
|
+
if dist == 2:
|
|
163
|
+
return "▓", f"dim {v} {bg}"
|
|
164
|
+
if dist == 3:
|
|
165
|
+
return "▒", f"dim {v} {bg}"
|
|
166
|
+
return " ", bg
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def _active_style(self) -> str:
|
|
170
|
+
token = f"${self._variant}"
|
|
171
|
+
fallback = VARIANT_STYLES.get(self._variant, VARIANT_STYLES["accent"])
|
|
172
|
+
return self._resolve_style(fallback, token)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def _message_style(self) -> str:
|
|
176
|
+
token = "$text-muted" if self._variant == "subtle" else "$text"
|
|
177
|
+
fallback = VARIANT_STYLES["subtle"] if self._variant == "subtle" else "white"
|
|
178
|
+
return self._resolve_style(fallback, token)
|
|
179
|
+
|
|
180
|
+
def _resolve_style(self, fallback: str, token: str | None = None, *, is_bg: bool = False) -> str:
|
|
181
|
+
"""Resolve a theme token to a Rich style string with fallback."""
|
|
182
|
+
try:
|
|
183
|
+
# Standard resolution sequence
|
|
184
|
+
res = self._do_resolve(token, is_bg)
|
|
185
|
+
if res:
|
|
186
|
+
return res
|
|
187
|
+
|
|
188
|
+
# Specific background resolution fallback
|
|
189
|
+
if is_bg:
|
|
190
|
+
res = self._do_resolve("$surface", True) or self._do_resolve("$background", True)
|
|
191
|
+
if res:
|
|
192
|
+
return res
|
|
193
|
+
except (NoActiveAppError, AttributeError):
|
|
194
|
+
pass
|
|
195
|
+
return fallback
|
|
196
|
+
|
|
197
|
+
def _do_resolve(self, token: str | None, is_bg: bool) -> str | None:
|
|
198
|
+
"""Internal resolver that tries multiple sources."""
|
|
199
|
+
if not token:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
# 1. Try resolving via component styles
|
|
203
|
+
if token.startswith("$"):
|
|
204
|
+
res = self._resolve_from_component(token, is_bg)
|
|
205
|
+
if res:
|
|
206
|
+
return res
|
|
207
|
+
|
|
208
|
+
# 2. Try direct variable lookup (App.theme_variables or Theme.variables)
|
|
209
|
+
res = self._resolve_from_theme_vars(token.lstrip("$"), is_bg)
|
|
210
|
+
if res:
|
|
211
|
+
return res
|
|
212
|
+
|
|
213
|
+
# 3. Try our built-in theme catalog
|
|
214
|
+
return self._resolve_from_catalog(token.lstrip("$"), is_bg)
|
|
215
|
+
|
|
216
|
+
def _resolve_from_component(self, token: str, is_bg: bool) -> str | None:
|
|
217
|
+
"""Resolve style from Textual component registry."""
|
|
218
|
+
try:
|
|
219
|
+
style = self.app.get_component_rich_style(token)
|
|
220
|
+
color = style.bgcolor if is_bg else style.color
|
|
221
|
+
if color:
|
|
222
|
+
return self._color_to_rich_style(color, is_bg)
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
def _resolve_from_theme_vars(self, var_name: str, is_bg: bool) -> str | None:
|
|
228
|
+
"""Resolve color from theme variables dictionary."""
|
|
229
|
+
try:
|
|
230
|
+
app = self.app
|
|
231
|
+
# Check theme_variables first
|
|
232
|
+
val = getattr(app, "theme_variables", {}).get(var_name)
|
|
233
|
+
if val is None:
|
|
234
|
+
# Fallback to current theme object's variables (Textual 0.52+)
|
|
235
|
+
theme_obj = app.get_theme(app.theme)
|
|
236
|
+
if theme_obj and hasattr(theme_obj, "variables"):
|
|
237
|
+
val = theme_obj.variables.get(var_name)
|
|
238
|
+
|
|
239
|
+
if val:
|
|
240
|
+
return self._color_to_rich_style(val, is_bg)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
def _resolve_from_catalog(self, var_name: str, is_bg: bool) -> str | None:
|
|
246
|
+
"""Resolve color from our built-in theme catalog."""
|
|
247
|
+
try:
|
|
248
|
+
theme_name = getattr(self.app, "theme", "gl-dark")
|
|
249
|
+
theme_tokens = _BUILTIN_THEMES.get(theme_name, _BUILTIN_THEMES["gl-dark"])
|
|
250
|
+
val = getattr(theme_tokens, var_name.replace("-", "_"), None)
|
|
251
|
+
if val:
|
|
252
|
+
return self._color_to_rich_style(val, is_bg)
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
def _color_to_rich_style(self, color: Any, is_bg: bool) -> str | None:
|
|
258
|
+
"""Convert any color-like object to a Rich-compatible style string."""
|
|
259
|
+
if not color:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
# 1. Textual Color objects
|
|
263
|
+
if hasattr(color, "hex") and color.hex.startswith("#"):
|
|
264
|
+
return f"on {color.hex}" if is_bg else color.hex
|
|
265
|
+
|
|
266
|
+
# 2. Rich Color objects (with triplets)
|
|
267
|
+
if hasattr(color, "triplet") and color.triplet:
|
|
268
|
+
hex_val = color.triplet.hex
|
|
269
|
+
return f"on {hex_val}" if is_bg else hex_val
|
|
270
|
+
|
|
271
|
+
# 3. Strings or named colors
|
|
272
|
+
return self._str_color_to_style(color, is_bg)
|
|
273
|
+
|
|
274
|
+
def _str_color_to_style(self, color: Any, is_bg: bool) -> str | None:
|
|
275
|
+
"""Helper to convert string-based colors to style."""
|
|
276
|
+
if color is None:
|
|
277
|
+
return None
|
|
278
|
+
c_str = str(color).strip()
|
|
279
|
+
if not c_str:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
if c_str.startswith("#"):
|
|
283
|
+
return f"on {c_str}" if is_bg else c_str
|
|
284
|
+
|
|
285
|
+
# If it's a named color like 'white', Rich understands it directly
|
|
286
|
+
# but we skip Textual's 'color(N)' internal format.
|
|
287
|
+
if not c_str.startswith("color(") and not c_str.startswith("auto"):
|
|
288
|
+
return f"on {c_str}" if is_bg else c_str
|
|
289
|
+
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
def _safe_update(self, renderable: Text) -> None:
|
|
293
|
+
try:
|
|
294
|
+
self.update(renderable)
|
|
295
|
+
self._pending_render = None
|
|
296
|
+
except NoActiveAppError:
|
|
297
|
+
self._pending_render = renderable
|
|
298
|
+
|
|
299
|
+
def _apply_pending_render(self) -> None:
|
|
300
|
+
if self._pending_render is None:
|
|
301
|
+
return
|
|
302
|
+
try:
|
|
303
|
+
self.update(self._pending_render)
|
|
304
|
+
self._pending_render = None
|
|
305
|
+
except NoActiveAppError:
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
def _cancel_timer(self) -> None:
|
|
309
|
+
if self._timer is None:
|
|
310
|
+
return
|
|
311
|
+
try:
|
|
312
|
+
self._timer.stop()
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
self._timer = None
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _normalize_message(message: str | None) -> str:
|
|
319
|
+
if message is None:
|
|
320
|
+
return DEFAULT_MESSAGE
|
|
321
|
+
cleaned = str(message).strip()
|
|
322
|
+
return cleaned if cleaned else DEFAULT_MESSAGE
|
|
323
|
+
|
|
324
|
+
@staticmethod
|
|
325
|
+
def _coerce_width(width: int) -> int:
|
|
326
|
+
if not isinstance(width, int):
|
|
327
|
+
return DEFAULT_WIDTH
|
|
328
|
+
return width if width > 0 else DEFAULT_WIDTH
|
|
329
|
+
|
|
330
|
+
@staticmethod
|
|
331
|
+
def _coerce_speed(speed_ms: int) -> int:
|
|
332
|
+
if not isinstance(speed_ms, int):
|
|
333
|
+
return DEFAULT_SPEED_MS
|
|
334
|
+
return speed_ms if speed_ms > 0 else DEFAULT_SPEED_MS
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _coerce_variant(variant: str) -> str:
|
|
338
|
+
if not isinstance(variant, str):
|
|
339
|
+
return "accent"
|
|
340
|
+
normalized = variant.strip().lower()
|
|
341
|
+
return normalized if normalized in VARIANT_STYLES else "accent"
|