glaip-sdk 0.6.5b6__py3-none-any.whl → 0.7.12__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 +217 -42
- 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 +119 -0
- glaip_sdk/cli/commands/agents/_common.py +561 -0
- glaip_sdk/cli/commands/agents/create.py +151 -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 +369 -23
- glaip_sdk/cli/slash/tui/__init__.py +26 -1
- glaip_sdk/cli/slash/tui/accounts.tcss +79 -5
- glaip_sdk/cli/slash/tui/accounts_app.py +1027 -88
- glaip_sdk/cli/slash/tui/clipboard.py +195 -0
- glaip_sdk/cli/slash/tui/context.py +87 -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 +160 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
- 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 +374 -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 +50 -8
- glaip_sdk/client/hitl.py +136 -0
- glaip_sdk/client/main.py +7 -1
- 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} +22 -47
- 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/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 +17 -0
- glaip_sdk/models/agent_runs.py +2 -1
- 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 -59
- glaip_sdk/runner/__init__.py +20 -3
- glaip_sdk/runner/deps.py +5 -8
- glaip_sdk/runner/langgraph.py +318 -42
- glaip_sdk/runner/logging_config.py +77 -0
- glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +104 -5
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +72 -7
- 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/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 +15 -12
- glaip_sdk/utils/sync.py +31 -11
- glaip_sdk/utils/tool_detection.py +274 -6
- glaip_sdk/utils/tool_storage_provider.py +140 -0
- {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/METADATA +49 -37
- glaip_sdk-0.7.12.dist-info/RECORD +219 -0
- {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/WHEEL +2 -1
- glaip_sdk-0.7.12.dist-info/entry_points.txt +2 -0
- glaip_sdk-0.7.12.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.5b6.dist-info/RECORD +0 -159
- glaip_sdk-0.6.5b6.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,87 @@
|
|
|
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
|
+
# Handle env var override: normalize empty strings and "default" to None
|
|
65
|
+
# Empty string from os.getenv() is falsy, so strip() result becomes None in the or expression
|
|
66
|
+
env_theme = os.getenv("AIP_TUI_THEME")
|
|
67
|
+
env_theme = env_theme.strip() if env_theme else None
|
|
68
|
+
if env_theme and env_theme.lower() == "default":
|
|
69
|
+
env_theme = None
|
|
70
|
+
|
|
71
|
+
theme_name = env_theme or settings.theme_name
|
|
72
|
+
theme = ThemeManager(
|
|
73
|
+
terminal,
|
|
74
|
+
mode=settings.theme_mode,
|
|
75
|
+
theme=theme_name,
|
|
76
|
+
settings_store=store,
|
|
77
|
+
)
|
|
78
|
+
keybinds = KeybindRegistry()
|
|
79
|
+
toasts = ToastBus()
|
|
80
|
+
clipboard = ClipboardAdapter(terminal=terminal)
|
|
81
|
+
return cls(
|
|
82
|
+
terminal=terminal,
|
|
83
|
+
keybinds=keybinds,
|
|
84
|
+
theme=theme,
|
|
85
|
+
toasts=toasts,
|
|
86
|
+
clipboard=clipboard,
|
|
87
|
+
)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Keybinding registry helpers for TUI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
DEFAULT_LEADER = "ctrl+x"
|
|
9
|
+
_LEADER_TOKEN = "<leader>"
|
|
10
|
+
|
|
11
|
+
_MODIFIER_ORDER = ("ctrl", "alt", "shift", "meta")
|
|
12
|
+
_MODIFIER_SYNONYMS = {
|
|
13
|
+
"control": "ctrl",
|
|
14
|
+
"ctl": "ctrl",
|
|
15
|
+
"cmd": "meta",
|
|
16
|
+
"command": "meta",
|
|
17
|
+
"option": "alt",
|
|
18
|
+
"return": "enter",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_KEY_SYNONYMS = {
|
|
22
|
+
"esc": "escape",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_KEY_DISPLAY = {
|
|
26
|
+
"escape": "Esc",
|
|
27
|
+
"enter": "Enter",
|
|
28
|
+
"space": "Space",
|
|
29
|
+
"tab": "Tab",
|
|
30
|
+
"backspace": "Backspace",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Keybind:
|
|
36
|
+
"""A registered keybinding."""
|
|
37
|
+
|
|
38
|
+
action: str
|
|
39
|
+
sequence: tuple[str, ...]
|
|
40
|
+
description: str
|
|
41
|
+
category: str | None = None
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
"""Return a readable representation of the keybind."""
|
|
45
|
+
return (
|
|
46
|
+
f"Keybind(action={self.action!r}, sequence={self.sequence}, "
|
|
47
|
+
f"description={self.description!r}, category={self.category!r})"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class KeybindRegistry:
|
|
52
|
+
"""Central registry of keybindings and associated metadata."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, *, leader: str = DEFAULT_LEADER) -> None:
|
|
55
|
+
"""Initialize the registry."""
|
|
56
|
+
normalized = _normalize_chord(leader)
|
|
57
|
+
self._leader = normalized or DEFAULT_LEADER
|
|
58
|
+
self._keybinds: dict[str, Keybind] = {}
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def leader(self) -> str:
|
|
62
|
+
"""Return the normalized leader chord."""
|
|
63
|
+
return self._leader
|
|
64
|
+
|
|
65
|
+
def register(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
action: str,
|
|
69
|
+
key: str,
|
|
70
|
+
description: str,
|
|
71
|
+
category: str | None = None,
|
|
72
|
+
) -> Keybind:
|
|
73
|
+
"""Register a keybinding for an action."""
|
|
74
|
+
if action in self._keybinds:
|
|
75
|
+
raise ValueError(f"Action already registered: {action}")
|
|
76
|
+
|
|
77
|
+
sequence = parse_key_sequence(key)
|
|
78
|
+
keybind = Keybind(action=action, sequence=sequence, description=description, category=category)
|
|
79
|
+
self._keybinds[action] = keybind
|
|
80
|
+
return keybind
|
|
81
|
+
|
|
82
|
+
def get(self, action: str) -> Keybind | None:
|
|
83
|
+
"""Return keybind for action, if present."""
|
|
84
|
+
return self._keybinds.get(action)
|
|
85
|
+
|
|
86
|
+
def actions(self) -> list[str]:
|
|
87
|
+
"""Return sorted list of registered actions."""
|
|
88
|
+
return sorted(self._keybinds)
|
|
89
|
+
|
|
90
|
+
def matches(self, action: str, sequence: str | Iterable[str]) -> bool:
|
|
91
|
+
"""Return True if the provided sequence matches the action's keybind."""
|
|
92
|
+
keybind = self._keybinds.get(action)
|
|
93
|
+
if keybind is None:
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
candidate = _coerce_sequence(sequence)
|
|
97
|
+
return candidate == keybind.sequence
|
|
98
|
+
|
|
99
|
+
def format(self, action: str) -> str:
|
|
100
|
+
"""Return a human-readable sequence for an action."""
|
|
101
|
+
keybind = self._keybinds.get(action)
|
|
102
|
+
if keybind is None:
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
return format_key_sequence(keybind.sequence, leader=self._leader)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_key_sequence(key: str) -> tuple[str, ...]:
|
|
109
|
+
"""Parse a key sequence string into normalized tokens."""
|
|
110
|
+
tokens = [token for token in key.strip().split() if token]
|
|
111
|
+
normalized: list[str] = []
|
|
112
|
+
|
|
113
|
+
for token in tokens:
|
|
114
|
+
if token.lower() == _LEADER_TOKEN:
|
|
115
|
+
normalized.append(_LEADER_TOKEN)
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
chord = _normalize_chord(token)
|
|
119
|
+
if chord:
|
|
120
|
+
normalized.append(chord)
|
|
121
|
+
|
|
122
|
+
return tuple(normalized)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def format_key_sequence(sequence: tuple[str, ...], *, leader: str = DEFAULT_LEADER) -> str:
|
|
126
|
+
"""Format a normalized sequence into a display string."""
|
|
127
|
+
rendered: list[str] = []
|
|
128
|
+
|
|
129
|
+
for token in sequence:
|
|
130
|
+
if token == _LEADER_TOKEN:
|
|
131
|
+
rendered.append(_format_token(leader))
|
|
132
|
+
continue
|
|
133
|
+
rendered.append(_format_token(token))
|
|
134
|
+
|
|
135
|
+
return " ".join(rendered)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _coerce_sequence(sequence: str | Iterable[str]) -> tuple[str, ...]:
|
|
139
|
+
if isinstance(sequence, str):
|
|
140
|
+
return parse_key_sequence(sequence)
|
|
141
|
+
|
|
142
|
+
tokens: list[str] = []
|
|
143
|
+
for token in sequence:
|
|
144
|
+
if not token:
|
|
145
|
+
continue
|
|
146
|
+
if token.lower() == _LEADER_TOKEN:
|
|
147
|
+
tokens.append(_LEADER_TOKEN)
|
|
148
|
+
continue
|
|
149
|
+
chord = _normalize_chord(token)
|
|
150
|
+
if chord:
|
|
151
|
+
tokens.append(chord)
|
|
152
|
+
|
|
153
|
+
return tuple(tokens)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _normalize_chord(chord: str) -> str:
|
|
157
|
+
"""Normalize a key chord string to canonical form.
|
|
158
|
+
|
|
159
|
+
Normalization rules:
|
|
160
|
+
- Converts separators: both '-' and '+' are normalized to '+'
|
|
161
|
+
- Handles synonyms: 'control'/'ctl' -> 'ctrl', 'cmd'/'command' -> 'meta', 'option' -> 'alt'
|
|
162
|
+
- Deduplicates modifiers: 'ctrl+ctrl+l' -> 'ctrl+l'
|
|
163
|
+
- Orders modifiers: ctrl < alt < shift < meta (unknown modifiers sort last)
|
|
164
|
+
- Case-insensitive: 'Ctrl+L' == 'ctrl+l' == 'CTRL-L'
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
chord: Key chord string (e.g., "Ctrl+L", "ctrl-l", "CTRL+CTRL+L")
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Normalized chord string (e.g., "ctrl+l") or empty string if invalid.
|
|
171
|
+
"""
|
|
172
|
+
parts = [part for part in chord.replace("-", "+").split("+") if part.strip()]
|
|
173
|
+
if not parts:
|
|
174
|
+
return ""
|
|
175
|
+
|
|
176
|
+
normalized_parts = [_normalize_key_part(part) for part in parts]
|
|
177
|
+
if len(normalized_parts) == 1:
|
|
178
|
+
return normalized_parts[0]
|
|
179
|
+
|
|
180
|
+
modifiers, key = normalized_parts[:-1], normalized_parts[-1]
|
|
181
|
+
|
|
182
|
+
seen: set[str] = set()
|
|
183
|
+
unique_mods: list[str] = []
|
|
184
|
+
for mod in modifiers:
|
|
185
|
+
if mod in seen:
|
|
186
|
+
continue
|
|
187
|
+
seen.add(mod)
|
|
188
|
+
unique_mods.append(mod)
|
|
189
|
+
|
|
190
|
+
unique_mods.sort(key=_modifier_sort_key)
|
|
191
|
+
return "+".join([*unique_mods, key])
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _normalize_key_part(part: str) -> str:
|
|
195
|
+
token = part.strip().lower()
|
|
196
|
+
token = _MODIFIER_SYNONYMS.get(token, token)
|
|
197
|
+
return _KEY_SYNONYMS.get(token, token)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _modifier_sort_key(modifier: str) -> int:
|
|
201
|
+
try:
|
|
202
|
+
return _MODIFIER_ORDER.index(modifier)
|
|
203
|
+
except ValueError:
|
|
204
|
+
return len(_MODIFIER_ORDER)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _format_token(token: str) -> str:
|
|
208
|
+
if "+" in token:
|
|
209
|
+
return _format_chord(token)
|
|
210
|
+
|
|
211
|
+
return _KEY_DISPLAY.get(token, token)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _format_chord(chord: str) -> str:
|
|
215
|
+
parts = chord.split("+")
|
|
216
|
+
modifiers, key = parts[:-1], parts[-1]
|
|
217
|
+
|
|
218
|
+
rendered_mods: list[str] = []
|
|
219
|
+
for mod in modifiers:
|
|
220
|
+
if mod == "ctrl":
|
|
221
|
+
rendered_mods.append("Ctrl")
|
|
222
|
+
elif mod == "alt":
|
|
223
|
+
rendered_mods.append("Alt")
|
|
224
|
+
elif mod == "shift":
|
|
225
|
+
rendered_mods.append("Shift")
|
|
226
|
+
elif mod == "meta":
|
|
227
|
+
rendered_mods.append("Meta")
|
|
228
|
+
else:
|
|
229
|
+
rendered_mods.append(mod.title())
|
|
230
|
+
|
|
231
|
+
rendered_key = _KEY_DISPLAY.get(key, key)
|
|
232
|
+
if len(rendered_key) == 1 and rendered_key.isalpha():
|
|
233
|
+
rendered_key = rendered_key.upper()
|
|
234
|
+
|
|
235
|
+
return "+".join([*rendered_mods, rendered_key])
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Layout components for TUI applications.
|
|
2
|
+
|
|
3
|
+
This package provides reusable layout components following the Harlequin pattern
|
|
4
|
+
for multi-pane data-rich screens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
try: # pragma: no cover - optional dependency
|
|
10
|
+
from glaip_sdk.cli.slash.tui.layouts.harlequin import HarlequinScreen
|
|
11
|
+
except Exception: # pragma: no cover - optional dependency
|
|
12
|
+
HarlequinScreen = None # type: ignore[assignment, misc]
|
|
13
|
+
|
|
14
|
+
__all__ = ["HarlequinScreen"]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Harlequin layout base class for multi-pane TUI screens.
|
|
2
|
+
|
|
3
|
+
This module provides the HarlequinScreen base class, which implements a modern
|
|
4
|
+
multi-pane "Harlequin" layout pattern for data-rich TUI screens. The layout uses
|
|
5
|
+
a 25/75 split with a list on the left and detail content on the right.
|
|
6
|
+
|
|
7
|
+
The Harlequin pattern is inspired by the Harlequin SQL client and provides:
|
|
8
|
+
- Left Pane (25%): ListView or compact table for item selection
|
|
9
|
+
- Right Pane (75%): Detail dashboard showing all fields, status, and action buttons
|
|
10
|
+
- Black background (#000000) that overrides terminal transparency
|
|
11
|
+
- Primary Blue borders (#005CB8)
|
|
12
|
+
|
|
13
|
+
Authors:
|
|
14
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
try: # pragma: no cover - optional dependency
|
|
22
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
23
|
+
from textual.screen import Screen
|
|
24
|
+
except Exception: # pragma: no cover - optional dependency
|
|
25
|
+
|
|
26
|
+
class Screen: # type: ignore[no-redef]
|
|
27
|
+
"""Fallback Screen stub when Textual is unavailable."""
|
|
28
|
+
|
|
29
|
+
def __class_getitem__(cls, _):
|
|
30
|
+
"""Return the class for typing subscripts."""
|
|
31
|
+
return cls
|
|
32
|
+
|
|
33
|
+
Horizontal = None # type: ignore[assignment]
|
|
34
|
+
Vertical = None # type: ignore[assignment]
|
|
35
|
+
Container = None # type: ignore[assignment]
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from glaip_sdk.cli.slash.tui.context import TUIContext
|
|
39
|
+
|
|
40
|
+
try: # pragma: no cover - optional dependency
|
|
41
|
+
from glaip_sdk.cli.slash.tui.toast import Toast
|
|
42
|
+
except Exception: # pragma: no cover - optional dependency
|
|
43
|
+
Toast = None # type: ignore[assignment, misc]
|
|
44
|
+
|
|
45
|
+
# GDP Labs Brand Palette
|
|
46
|
+
PRIMARY_BLUE = "#005CB8"
|
|
47
|
+
BLACK_BACKGROUND = "#000000"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HarlequinScreen(Screen[None]): # type: ignore[misc]
|
|
51
|
+
"""Base class for Harlequin-style multi-pane screens.
|
|
52
|
+
|
|
53
|
+
This screen provides a 25/75 split layout with a left pane for navigation
|
|
54
|
+
and a right pane for details. The layout uses a black background that
|
|
55
|
+
overrides terminal transparency and primary blue borders.
|
|
56
|
+
|
|
57
|
+
Subclasses should override `compose()` to add their specific widgets to
|
|
58
|
+
the left and right panes. Use the container IDs "left-pane" and "right-pane"
|
|
59
|
+
to target specific panes in CSS or when querying widgets.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
```python
|
|
63
|
+
class AccountHarlequinScreen(HarlequinScreen):
|
|
64
|
+
def compose(self) -> ComposeResult:
|
|
65
|
+
yield from super().compose()
|
|
66
|
+
# Add widgets to left and right panes
|
|
67
|
+
self.query_one("#left-pane").mount(AccountListView())
|
|
68
|
+
self.query_one("#right-pane").mount(AccountDetailView())
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
CSS:
|
|
72
|
+
The screen includes default styling for the Harlequin layout:
|
|
73
|
+
- Black background (#000000) for the entire screen
|
|
74
|
+
- Primary blue borders (#005CB8) for panes
|
|
75
|
+
- 25% width for left pane, 75% width for right pane
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
CSS = """
|
|
79
|
+
HarlequinScreen {
|
|
80
|
+
background: #000000;
|
|
81
|
+
layers: base toasts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#harlequin-container {
|
|
85
|
+
width: 100%;
|
|
86
|
+
height: 100%;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#left-pane {
|
|
90
|
+
width: 25%;
|
|
91
|
+
border: solid #005CB8;
|
|
92
|
+
background: #000000;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#right-pane {
|
|
96
|
+
width: 75%;
|
|
97
|
+
border: solid #005CB8;
|
|
98
|
+
background: #000000;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#toast-container {
|
|
102
|
+
width: 100%;
|
|
103
|
+
height: auto;
|
|
104
|
+
dock: top;
|
|
105
|
+
align: right top;
|
|
106
|
+
layer: toasts;
|
|
107
|
+
}
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
ctx: TUIContext | None = None,
|
|
114
|
+
name: str | None = None,
|
|
115
|
+
id: str | None = None,
|
|
116
|
+
classes: str | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Initialize the Harlequin screen.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
ctx: Optional TUI context for accessing services (keybinds, theme, toasts, clipboard).
|
|
122
|
+
name: Optional name for the screen.
|
|
123
|
+
id: Optional ID for the screen.
|
|
124
|
+
classes: Optional CSS classes for the screen.
|
|
125
|
+
"""
|
|
126
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
127
|
+
self._ctx: TUIContext | None = ctx
|
|
128
|
+
|
|
129
|
+
def compose(self) -> Any:
|
|
130
|
+
"""Compose the Harlequin layout with left and right panes.
|
|
131
|
+
|
|
132
|
+
This method creates the base 25/75 split layout. Subclasses should
|
|
133
|
+
call `super().compose()` and then add their specific widgets to the
|
|
134
|
+
left and right panes.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
ComposeResult yielding the base layout containers.
|
|
138
|
+
"""
|
|
139
|
+
if Horizontal is None or Vertical is None or Container is None:
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
# Main container with horizontal split (25/75)
|
|
143
|
+
yield Horizontal(
|
|
144
|
+
Vertical(id="left-pane"),
|
|
145
|
+
Vertical(id="right-pane"),
|
|
146
|
+
id="harlequin-container",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Toast container for notifications
|
|
150
|
+
if Toast is not None and Container is not None:
|
|
151
|
+
yield Container(Toast(), id="toast-container")
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def ctx(self) -> TUIContext | None:
|
|
155
|
+
"""Get the TUI context if available.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
TUIContext instance or None if not provided.
|
|
159
|
+
"""
|
|
160
|
+
return self._ctx
|