glaip-sdk 0.6.19__py3-none-any.whl → 0.7.27__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 (135) hide show
  1. glaip_sdk/agents/base.py +283 -30
  2. glaip_sdk/agents/component.py +233 -0
  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 +116 -0
  8. glaip_sdk/cli/commands/agents/_common.py +562 -0
  9. glaip_sdk/cli/commands/agents/create.py +155 -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 +1 -1
  17. glaip_sdk/cli/commands/configure.py +1 -2
  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/entrypoint.py +20 -0
  46. glaip_sdk/cli/main.py +112 -35
  47. glaip_sdk/cli/pager.py +3 -3
  48. glaip_sdk/cli/resolution.py +2 -1
  49. glaip_sdk/cli/slash/accounts_controller.py +3 -1
  50. glaip_sdk/cli/slash/agent_session.py +1 -1
  51. glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
  52. glaip_sdk/cli/slash/session.py +343 -20
  53. glaip_sdk/cli/slash/tui/__init__.py +29 -1
  54. glaip_sdk/cli/slash/tui/accounts.tcss +97 -6
  55. glaip_sdk/cli/slash/tui/accounts_app.py +1117 -126
  56. glaip_sdk/cli/slash/tui/clipboard.py +316 -0
  57. glaip_sdk/cli/slash/tui/context.py +92 -0
  58. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  59. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  60. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  61. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  62. glaip_sdk/cli/slash/tui/loading.py +43 -21
  63. glaip_sdk/cli/slash/tui/remote_runs_app.py +178 -20
  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 +388 -0
  70. glaip_sdk/cli/transcript/history.py +1 -1
  71. glaip_sdk/cli/transcript/viewer.py +1 -1
  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 +293 -17
  78. glaip_sdk/client/base.py +25 -0
  79. glaip_sdk/client/hitl.py +136 -0
  80. glaip_sdk/client/main.py +7 -5
  81. glaip_sdk/client/mcps.py +44 -13
  82. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  83. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +28 -48
  84. glaip_sdk/client/payloads/agent/responses.py +43 -0
  85. glaip_sdk/client/run_rendering.py +109 -30
  86. glaip_sdk/client/schedules.py +439 -0
  87. glaip_sdk/client/tools.py +52 -23
  88. glaip_sdk/config/constants.py +22 -2
  89. glaip_sdk/guardrails/__init__.py +80 -0
  90. glaip_sdk/guardrails/serializer.py +91 -0
  91. glaip_sdk/hitl/__init__.py +35 -2
  92. glaip_sdk/hitl/base.py +64 -0
  93. glaip_sdk/hitl/callback.py +43 -0
  94. glaip_sdk/hitl/local.py +1 -31
  95. glaip_sdk/hitl/remote.py +523 -0
  96. glaip_sdk/models/__init__.py +47 -1
  97. glaip_sdk/models/_provider_mappings.py +101 -0
  98. glaip_sdk/models/_validation.py +97 -0
  99. glaip_sdk/models/agent.py +2 -1
  100. glaip_sdk/models/agent_runs.py +2 -1
  101. glaip_sdk/models/constants.py +141 -0
  102. glaip_sdk/models/model.py +170 -0
  103. glaip_sdk/models/schedule.py +224 -0
  104. glaip_sdk/payload_schemas/agent.py +1 -0
  105. glaip_sdk/payload_schemas/guardrails.py +34 -0
  106. glaip_sdk/ptc.py +145 -0
  107. glaip_sdk/registry/tool.py +270 -57
  108. glaip_sdk/runner/__init__.py +20 -3
  109. glaip_sdk/runner/deps.py +4 -1
  110. glaip_sdk/runner/langgraph.py +251 -27
  111. glaip_sdk/runner/logging_config.py +77 -0
  112. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +30 -9
  113. glaip_sdk/runner/ptc_adapter.py +98 -0
  114. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +25 -2
  115. glaip_sdk/schedules/__init__.py +22 -0
  116. glaip_sdk/schedules/base.py +291 -0
  117. glaip_sdk/tools/base.py +67 -14
  118. glaip_sdk/utils/__init__.py +1 -0
  119. glaip_sdk/utils/agent_config.py +8 -2
  120. glaip_sdk/utils/bundler.py +138 -2
  121. glaip_sdk/utils/import_resolver.py +427 -49
  122. glaip_sdk/utils/runtime_config.py +3 -2
  123. glaip_sdk/utils/sync.py +31 -11
  124. glaip_sdk/utils/tool_detection.py +274 -6
  125. {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/METADATA +22 -8
  126. glaip_sdk-0.7.27.dist-info/RECORD +227 -0
  127. {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/WHEEL +1 -1
  128. glaip_sdk-0.7.27.dist-info/entry_points.txt +2 -0
  129. glaip_sdk/cli/commands/agents.py +0 -1509
  130. glaip_sdk/cli/commands/mcps.py +0 -1356
  131. glaip_sdk/cli/commands/tools.py +0 -576
  132. glaip_sdk/cli/utils.py +0 -263
  133. glaip_sdk-0.6.19.dist-info/RECORD +0 -163
  134. glaip_sdk-0.6.19.dist-info/entry_points.txt +0 -2
  135. {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/top_level.txt +0 -0
@@ -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"
@@ -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"]