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.
Files changed (127) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. glaip_sdk/agents/base.py +217 -42
  3. glaip_sdk/branding.py +113 -2
  4. glaip_sdk/cli/account_store.py +15 -0
  5. glaip_sdk/cli/auth.py +14 -8
  6. glaip_sdk/cli/commands/accounts.py +1 -1
  7. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  8. glaip_sdk/cli/commands/agents/_common.py +561 -0
  9. glaip_sdk/cli/commands/agents/create.py +151 -0
  10. glaip_sdk/cli/commands/agents/delete.py +64 -0
  11. glaip_sdk/cli/commands/agents/get.py +89 -0
  12. glaip_sdk/cli/commands/agents/list.py +129 -0
  13. glaip_sdk/cli/commands/agents/run.py +264 -0
  14. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  15. glaip_sdk/cli/commands/agents/update.py +112 -0
  16. glaip_sdk/cli/commands/common_config.py +15 -12
  17. glaip_sdk/cli/commands/configure.py +2 -3
  18. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  19. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  20. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  21. glaip_sdk/cli/commands/mcps/create.py +152 -0
  22. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  23. glaip_sdk/cli/commands/mcps/get.py +212 -0
  24. glaip_sdk/cli/commands/mcps/list.py +69 -0
  25. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  26. glaip_sdk/cli/commands/mcps/update.py +190 -0
  27. glaip_sdk/cli/commands/models.py +2 -4
  28. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  29. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  30. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  31. glaip_sdk/cli/commands/tools/_common.py +80 -0
  32. glaip_sdk/cli/commands/tools/create.py +228 -0
  33. glaip_sdk/cli/commands/tools/delete.py +61 -0
  34. glaip_sdk/cli/commands/tools/get.py +103 -0
  35. glaip_sdk/cli/commands/tools/list.py +69 -0
  36. glaip_sdk/cli/commands/tools/script.py +49 -0
  37. glaip_sdk/cli/commands/tools/update.py +102 -0
  38. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  39. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  40. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  41. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  42. glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
  43. glaip_sdk/cli/commands/update.py +163 -17
  44. glaip_sdk/cli/config.py +1 -0
  45. glaip_sdk/cli/core/output.py +12 -7
  46. glaip_sdk/cli/entrypoint.py +20 -0
  47. glaip_sdk/cli/main.py +127 -39
  48. glaip_sdk/cli/pager.py +3 -3
  49. glaip_sdk/cli/resolution.py +2 -1
  50. glaip_sdk/cli/slash/accounts_controller.py +112 -32
  51. glaip_sdk/cli/slash/agent_session.py +5 -2
  52. glaip_sdk/cli/slash/prompt.py +11 -0
  53. glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
  54. glaip_sdk/cli/slash/session.py +369 -23
  55. glaip_sdk/cli/slash/tui/__init__.py +26 -1
  56. glaip_sdk/cli/slash/tui/accounts.tcss +79 -5
  57. glaip_sdk/cli/slash/tui/accounts_app.py +1027 -88
  58. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  59. glaip_sdk/cli/slash/tui/context.py +87 -0
  60. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  61. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  62. glaip_sdk/cli/slash/tui/layouts/harlequin.py +160 -0
  63. glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
  64. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  65. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  66. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  67. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  68. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  69. glaip_sdk/cli/slash/tui/toast.py +374 -0
  70. glaip_sdk/cli/transcript/history.py +1 -1
  71. glaip_sdk/cli/transcript/viewer.py +5 -3
  72. glaip_sdk/cli/tui_settings.py +125 -0
  73. glaip_sdk/cli/update_notifier.py +215 -7
  74. glaip_sdk/cli/validators.py +1 -1
  75. glaip_sdk/client/__init__.py +2 -1
  76. glaip_sdk/client/_schedule_payloads.py +89 -0
  77. glaip_sdk/client/agents.py +50 -8
  78. glaip_sdk/client/hitl.py +136 -0
  79. glaip_sdk/client/main.py +7 -1
  80. glaip_sdk/client/mcps.py +44 -13
  81. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  82. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +22 -47
  83. glaip_sdk/client/payloads/agent/responses.py +43 -0
  84. glaip_sdk/client/run_rendering.py +414 -3
  85. glaip_sdk/client/schedules.py +439 -0
  86. glaip_sdk/client/tools.py +57 -26
  87. glaip_sdk/guardrails/__init__.py +80 -0
  88. glaip_sdk/guardrails/serializer.py +89 -0
  89. glaip_sdk/hitl/__init__.py +48 -0
  90. glaip_sdk/hitl/base.py +64 -0
  91. glaip_sdk/hitl/callback.py +43 -0
  92. glaip_sdk/hitl/local.py +121 -0
  93. glaip_sdk/hitl/remote.py +523 -0
  94. glaip_sdk/models/__init__.py +17 -0
  95. glaip_sdk/models/agent_runs.py +2 -1
  96. glaip_sdk/models/schedule.py +224 -0
  97. glaip_sdk/payload_schemas/agent.py +1 -0
  98. glaip_sdk/payload_schemas/guardrails.py +34 -0
  99. glaip_sdk/registry/tool.py +273 -59
  100. glaip_sdk/runner/__init__.py +20 -3
  101. glaip_sdk/runner/deps.py +5 -8
  102. glaip_sdk/runner/langgraph.py +318 -42
  103. glaip_sdk/runner/logging_config.py +77 -0
  104. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +104 -5
  105. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +72 -7
  106. glaip_sdk/schedules/__init__.py +22 -0
  107. glaip_sdk/schedules/base.py +291 -0
  108. glaip_sdk/tools/base.py +67 -14
  109. glaip_sdk/utils/__init__.py +1 -0
  110. glaip_sdk/utils/bundler.py +138 -2
  111. glaip_sdk/utils/import_resolver.py +43 -11
  112. glaip_sdk/utils/rendering/renderer/base.py +58 -0
  113. glaip_sdk/utils/runtime_config.py +15 -12
  114. glaip_sdk/utils/sync.py +31 -11
  115. glaip_sdk/utils/tool_detection.py +274 -6
  116. glaip_sdk/utils/tool_storage_provider.py +140 -0
  117. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/METADATA +49 -37
  118. glaip_sdk-0.7.12.dist-info/RECORD +219 -0
  119. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/WHEEL +2 -1
  120. glaip_sdk-0.7.12.dist-info/entry_points.txt +2 -0
  121. glaip_sdk-0.7.12.dist-info/top_level.txt +1 -0
  122. glaip_sdk/cli/commands/agents.py +0 -1509
  123. glaip_sdk/cli/commands/mcps.py +0 -1356
  124. glaip_sdk/cli/commands/tools.py +0 -576
  125. glaip_sdk/cli/utils.py +0 -263
  126. glaip_sdk-0.6.5b6.dist-info/RECORD +0 -159
  127. 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