glaip-sdk 0.0.20__py3-none-any.whl → 0.7.7__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 (216) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1250 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +271 -45
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  12. glaip_sdk/cli/commands/agents/_common.py +561 -0
  13. glaip_sdk/cli/commands/agents/create.py +151 -0
  14. glaip_sdk/cli/commands/agents/delete.py +64 -0
  15. glaip_sdk/cli/commands/agents/get.py +89 -0
  16. glaip_sdk/cli/commands/agents/list.py +129 -0
  17. glaip_sdk/cli/commands/agents/run.py +264 -0
  18. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  19. glaip_sdk/cli/commands/agents/update.py +112 -0
  20. glaip_sdk/cli/commands/common_config.py +104 -0
  21. glaip_sdk/cli/commands/configure.py +734 -143
  22. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  23. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  24. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  25. glaip_sdk/cli/commands/mcps/create.py +152 -0
  26. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  27. glaip_sdk/cli/commands/mcps/get.py +212 -0
  28. glaip_sdk/cli/commands/mcps/list.py +69 -0
  29. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  30. glaip_sdk/cli/commands/mcps/update.py +190 -0
  31. glaip_sdk/cli/commands/models.py +14 -12
  32. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  33. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  34. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  35. glaip_sdk/cli/commands/tools/_common.py +80 -0
  36. glaip_sdk/cli/commands/tools/create.py +228 -0
  37. glaip_sdk/cli/commands/tools/delete.py +61 -0
  38. glaip_sdk/cli/commands/tools/get.py +103 -0
  39. glaip_sdk/cli/commands/tools/list.py +69 -0
  40. glaip_sdk/cli/commands/tools/script.py +49 -0
  41. glaip_sdk/cli/commands/tools/update.py +102 -0
  42. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  43. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  44. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  45. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  46. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  47. glaip_sdk/cli/commands/update.py +164 -23
  48. glaip_sdk/cli/config.py +49 -7
  49. glaip_sdk/cli/constants.py +38 -0
  50. glaip_sdk/cli/context.py +8 -0
  51. glaip_sdk/cli/core/__init__.py +79 -0
  52. glaip_sdk/cli/core/context.py +124 -0
  53. glaip_sdk/cli/core/output.py +851 -0
  54. glaip_sdk/cli/core/prompting.py +649 -0
  55. glaip_sdk/cli/core/rendering.py +187 -0
  56. glaip_sdk/cli/display.py +45 -32
  57. glaip_sdk/cli/entrypoint.py +20 -0
  58. glaip_sdk/cli/hints.py +57 -0
  59. glaip_sdk/cli/io.py +14 -17
  60. glaip_sdk/cli/main.py +344 -167
  61. glaip_sdk/cli/masking.py +21 -33
  62. glaip_sdk/cli/mcp_validators.py +5 -15
  63. glaip_sdk/cli/pager.py +15 -22
  64. glaip_sdk/cli/parsers/__init__.py +1 -3
  65. glaip_sdk/cli/parsers/json_input.py +11 -22
  66. glaip_sdk/cli/resolution.py +5 -10
  67. glaip_sdk/cli/rich_helpers.py +1 -3
  68. glaip_sdk/cli/slash/__init__.py +0 -9
  69. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  70. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  71. glaip_sdk/cli/slash/agent_session.py +65 -29
  72. glaip_sdk/cli/slash/prompt.py +24 -10
  73. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  74. glaip_sdk/cli/slash/session.py +827 -232
  75. glaip_sdk/cli/slash/tui/__init__.py +34 -0
  76. glaip_sdk/cli/slash/tui/accounts.tcss +88 -0
  77. glaip_sdk/cli/slash/tui/accounts_app.py +933 -0
  78. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  79. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  80. glaip_sdk/cli/slash/tui/context.py +59 -0
  81. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  82. glaip_sdk/cli/slash/tui/loading.py +58 -0
  83. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  84. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  85. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  86. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  87. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  88. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  89. glaip_sdk/cli/slash/tui/toast.py +123 -0
  90. glaip_sdk/cli/transcript/__init__.py +12 -52
  91. glaip_sdk/cli/transcript/cache.py +258 -60
  92. glaip_sdk/cli/transcript/capture.py +72 -21
  93. glaip_sdk/cli/transcript/history.py +815 -0
  94. glaip_sdk/cli/transcript/launcher.py +1 -3
  95. glaip_sdk/cli/transcript/viewer.py +79 -329
  96. glaip_sdk/cli/update_notifier.py +385 -24
  97. glaip_sdk/cli/validators.py +16 -18
  98. glaip_sdk/client/__init__.py +3 -1
  99. glaip_sdk/client/_schedule_payloads.py +89 -0
  100. glaip_sdk/client/agent_runs.py +147 -0
  101. glaip_sdk/client/agents.py +370 -100
  102. glaip_sdk/client/base.py +78 -35
  103. glaip_sdk/client/hitl.py +136 -0
  104. glaip_sdk/client/main.py +25 -10
  105. glaip_sdk/client/mcps.py +166 -27
  106. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  107. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +65 -74
  108. glaip_sdk/client/payloads/agent/responses.py +43 -0
  109. glaip_sdk/client/run_rendering.py +583 -79
  110. glaip_sdk/client/schedules.py +439 -0
  111. glaip_sdk/client/shared.py +21 -0
  112. glaip_sdk/client/tools.py +214 -56
  113. glaip_sdk/client/validators.py +20 -48
  114. glaip_sdk/config/constants.py +11 -0
  115. glaip_sdk/exceptions.py +1 -3
  116. glaip_sdk/hitl/__init__.py +48 -0
  117. glaip_sdk/hitl/base.py +64 -0
  118. glaip_sdk/hitl/callback.py +43 -0
  119. glaip_sdk/hitl/local.py +121 -0
  120. glaip_sdk/hitl/remote.py +523 -0
  121. glaip_sdk/icons.py +9 -3
  122. glaip_sdk/mcps/__init__.py +21 -0
  123. glaip_sdk/mcps/base.py +345 -0
  124. glaip_sdk/models/__init__.py +107 -0
  125. glaip_sdk/models/agent.py +47 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/mcp.py +33 -0
  129. glaip_sdk/models/schedule.py +224 -0
  130. glaip_sdk/models/tool.py +33 -0
  131. glaip_sdk/payload_schemas/__init__.py +1 -13
  132. glaip_sdk/payload_schemas/agent.py +1 -3
  133. glaip_sdk/registry/__init__.py +55 -0
  134. glaip_sdk/registry/agent.py +164 -0
  135. glaip_sdk/registry/base.py +139 -0
  136. glaip_sdk/registry/mcp.py +253 -0
  137. glaip_sdk/registry/tool.py +445 -0
  138. glaip_sdk/rich_components.py +58 -2
  139. glaip_sdk/runner/__init__.py +76 -0
  140. glaip_sdk/runner/base.py +84 -0
  141. glaip_sdk/runner/deps.py +112 -0
  142. glaip_sdk/runner/langgraph.py +872 -0
  143. glaip_sdk/runner/logging_config.py +77 -0
  144. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  145. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  146. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  147. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  148. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  149. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  150. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  151. glaip_sdk/schedules/__init__.py +22 -0
  152. glaip_sdk/schedules/base.py +291 -0
  153. glaip_sdk/tools/__init__.py +22 -0
  154. glaip_sdk/tools/base.py +468 -0
  155. glaip_sdk/utils/__init__.py +59 -12
  156. glaip_sdk/utils/a2a/__init__.py +34 -0
  157. glaip_sdk/utils/a2a/event_processor.py +188 -0
  158. glaip_sdk/utils/agent_config.py +4 -14
  159. glaip_sdk/utils/bundler.py +403 -0
  160. glaip_sdk/utils/client.py +111 -0
  161. glaip_sdk/utils/client_utils.py +46 -28
  162. glaip_sdk/utils/datetime_helpers.py +58 -0
  163. glaip_sdk/utils/discovery.py +78 -0
  164. glaip_sdk/utils/display.py +25 -21
  165. glaip_sdk/utils/export.py +143 -0
  166. glaip_sdk/utils/general.py +1 -36
  167. glaip_sdk/utils/import_export.py +15 -16
  168. glaip_sdk/utils/import_resolver.py +524 -0
  169. glaip_sdk/utils/instructions.py +101 -0
  170. glaip_sdk/utils/rendering/__init__.py +115 -1
  171. glaip_sdk/utils/rendering/formatting.py +38 -23
  172. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  173. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  174. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  175. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  176. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  177. glaip_sdk/utils/rendering/models.py +18 -8
  178. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  179. glaip_sdk/utils/rendering/renderer/base.py +534 -882
  180. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  181. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  182. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  183. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  184. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  185. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  186. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  187. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  188. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  189. glaip_sdk/utils/rendering/state.py +204 -0
  190. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  191. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  192. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  193. glaip_sdk/utils/rendering/steps/format.py +176 -0
  194. glaip_sdk/utils/rendering/{steps.py → steps/manager.py} +122 -26
  195. glaip_sdk/utils/rendering/timing.py +36 -0
  196. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  197. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  198. glaip_sdk/utils/resource_refs.py +29 -26
  199. glaip_sdk/utils/runtime_config.py +425 -0
  200. glaip_sdk/utils/serialization.py +32 -46
  201. glaip_sdk/utils/sync.py +162 -0
  202. glaip_sdk/utils/tool_detection.py +301 -0
  203. glaip_sdk/utils/tool_storage_provider.py +140 -0
  204. glaip_sdk/utils/validation.py +20 -28
  205. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/METADATA +78 -23
  206. glaip_sdk-0.7.7.dist-info/RECORD +213 -0
  207. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/WHEEL +2 -1
  208. glaip_sdk-0.7.7.dist-info/entry_points.txt +2 -0
  209. glaip_sdk-0.7.7.dist-info/top_level.txt +1 -0
  210. glaip_sdk/cli/commands/agents.py +0 -1412
  211. glaip_sdk/cli/commands/mcps.py +0 -1225
  212. glaip_sdk/cli/commands/tools.py +0 -597
  213. glaip_sdk/cli/utils.py +0 -1330
  214. glaip_sdk/models.py +0 -259
  215. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  216. glaip_sdk-0.0.20.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,72 @@
1
+ """Shared mixin for tracking background asyncio tasks in Textual apps.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import logging
11
+ from collections.abc import Callable, Coroutine
12
+ from typing import Any
13
+
14
+
15
+ class BackgroundTaskMixin:
16
+ """Mixin that tracks background tasks and cleans them up on unmount."""
17
+
18
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
19
+ """Initialize task tracking set for derived Textual apps."""
20
+ super().__init__(*args, **kwargs)
21
+ self._pending_tasks: set[asyncio.Task[Any]] = set()
22
+
23
+ def track_task(
24
+ self,
25
+ coro: Coroutine[Any, Any, Any],
26
+ *,
27
+ on_error: Callable[[Exception], None] | None = None,
28
+ logger: logging.Logger | None = None,
29
+ ) -> asyncio.Task[Any]:
30
+ """Create and track a background task with optional error handling."""
31
+ task = asyncio.create_task(coro)
32
+ self._pending_tasks.add(task)
33
+
34
+ def _cleanup(finished: asyncio.Task[Any]) -> None:
35
+ self._pending_tasks.discard(finished)
36
+ if finished.cancelled():
37
+ return
38
+ try:
39
+ exc = finished.exception()
40
+ except Exception:
41
+ return
42
+ if exc:
43
+ if on_error:
44
+ on_error(exc)
45
+ elif logger:
46
+ logger.debug("Background task failed", exc_info=exc)
47
+
48
+ task.add_done_callback(_cleanup)
49
+ return task
50
+
51
+ def on_unmount(self) -> None: # pragma: no cover - UI lifecycle hook
52
+ """Ensure background tasks are cleaned up on exit."""
53
+ pending = [task for task in self._pending_tasks if not task.done()]
54
+ for task in pending:
55
+ try:
56
+ task.cancel()
57
+ except Exception:
58
+ continue
59
+ if pending:
60
+ try:
61
+ loop = asyncio.get_running_loop()
62
+ except RuntimeError:
63
+ loop = None
64
+ if loop and loop.is_running():
65
+ try:
66
+ loop.create_task(asyncio.gather(*pending, return_exceptions=True))
67
+ except Exception:
68
+ pass
69
+ self._pending_tasks.clear()
70
+ parent_on_unmount = getattr(super(), "on_unmount", None)
71
+ if callable(parent_on_unmount):
72
+ parent_on_unmount() # type: ignore[misc]
@@ -0,0 +1,147 @@
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
+
49
+ class ClipboardAdapter:
50
+ """Cross-platform clipboard access with OSC 52 fallback."""
51
+
52
+ def __init__(
53
+ self,
54
+ *,
55
+ terminal: TerminalCapabilities | None = None,
56
+ method: ClipboardMethod | None = None,
57
+ ) -> None:
58
+ """Initialize the adapter."""
59
+ self._terminal = terminal
60
+ self._method = method or self._detect_method()
61
+
62
+ @property
63
+ def method(self) -> ClipboardMethod:
64
+ """Return the detected clipboard backend."""
65
+ return self._method
66
+
67
+ def copy(self, text: str, *, writer: Callable[[str], Any] | None = None) -> ClipboardResult:
68
+ """Copy text to clipboard using the best available method.
69
+
70
+ Args:
71
+ text: Text to copy.
72
+ writer: Optional function to write OSC 52 sequence (e.g., self.app.console.write).
73
+ Defaults to sys.stdout.write if not provided.
74
+ """
75
+ if self._method == ClipboardMethod.OSC52:
76
+ return self._copy_osc52(text, writer=writer)
77
+
78
+ command = _SUBPROCESS_COMMANDS.get(self._method)
79
+ if command is None:
80
+ return self._copy_osc52(text, writer=writer)
81
+
82
+ result = self._copy_subprocess(command, text)
83
+ if not result.success:
84
+ return self._copy_osc52(text, writer=writer)
85
+
86
+ return result
87
+
88
+ def _detect_method(self) -> ClipboardMethod:
89
+ if self._terminal.osc52 if self._terminal else detect_osc52_support():
90
+ return ClipboardMethod.OSC52
91
+
92
+ system = platform.system()
93
+ if system == "Darwin":
94
+ return self._detect_darwin_method()
95
+ if system == "Linux":
96
+ return self._detect_linux_method()
97
+ if system == "Windows":
98
+ return self._detect_windows_method()
99
+ return ClipboardMethod.NONE
100
+
101
+ def _detect_darwin_method(self) -> ClipboardMethod:
102
+ return ClipboardMethod.PBCOPY if shutil.which("pbcopy") else ClipboardMethod.NONE
103
+
104
+ def _detect_linux_method(self) -> ClipboardMethod:
105
+ if not os.getenv("DISPLAY") and not os.getenv("WAYLAND_DISPLAY"):
106
+ return ClipboardMethod.NONE
107
+
108
+ for cmd, method in (
109
+ ("xclip", ClipboardMethod.XCLIP),
110
+ ("xsel", ClipboardMethod.XSEL),
111
+ ("wl-copy", ClipboardMethod.WL_COPY),
112
+ ):
113
+ if shutil.which(cmd):
114
+ return method
115
+ return ClipboardMethod.NONE
116
+
117
+ def _detect_windows_method(self) -> ClipboardMethod:
118
+ return ClipboardMethod.CLIP if shutil.which("clip") else ClipboardMethod.NONE
119
+
120
+ def _copy_osc52(self, text: str, *, writer: Callable[[str], Any] | None = None) -> ClipboardResult:
121
+ encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
122
+ sequence = f"\x1b]52;c;{encoded}\x07"
123
+ try:
124
+ if writer:
125
+ writer(sequence)
126
+ else:
127
+ sys.stdout.write(sequence)
128
+ sys.stdout.flush()
129
+ except Exception as exc:
130
+ return ClipboardResult(False, ClipboardMethod.OSC52, str(exc))
131
+
132
+ return ClipboardResult(True, ClipboardMethod.OSC52, "Copied to clipboard")
133
+
134
+ def _copy_subprocess(self, cmd: list[str], text: str) -> ClipboardResult:
135
+ try:
136
+ completed = subprocess.run(
137
+ cmd,
138
+ input=text.encode("utf-8"),
139
+ check=False,
140
+ )
141
+ except OSError as exc:
142
+ return ClipboardResult(False, self._method, str(exc))
143
+
144
+ if completed.returncode == 0:
145
+ return ClipboardResult(True, self._method, "Copied to clipboard")
146
+
147
+ return ClipboardResult(False, self._method, f"Command failed: {completed.returncode}")
@@ -0,0 +1,59 @@
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
+ from typing import TYPE_CHECKING
16
+
17
+ from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
18
+ from glaip_sdk.cli.slash.tui.theme import ThemeManager
19
+
20
+ if TYPE_CHECKING:
21
+ from glaip_sdk.cli.slash.tui.toast import ToastBus
22
+
23
+
24
+ @dataclass
25
+ class TUIContext:
26
+ """Shared context for all TUI components (Python equivalent of OpenCode's providers).
27
+
28
+ This context provides access to all TUI services and state. Components that will
29
+ be implemented in later phases are typed as Optional and will be None initially.
30
+
31
+ Attributes:
32
+ terminal: Terminal capability detection results.
33
+ keybinds: Central keybind registry (Phase 3).
34
+ theme: Theme manager for light/dark mode and color tokens (Phase 2).
35
+ toasts: Toast notification bus (Phase 4).
36
+ clipboard: Clipboard adapter with OSC 52 support (Phase 4).
37
+ """
38
+
39
+ terminal: TerminalCapabilities
40
+ keybinds: object | None = None
41
+ theme: ThemeManager | None = None
42
+ toasts: ToastBus | None = None
43
+ clipboard: object | None = None
44
+
45
+ @classmethod
46
+ async def create(cls) -> TUIContext:
47
+ """Create a TUIContext instance with detected terminal capabilities.
48
+
49
+ This factory method detects terminal capabilities asynchronously and
50
+ returns a populated TUIContext instance. Other components (keybinds,
51
+ theme, toasts, clipboard) will be set incrementally as they are created.
52
+
53
+ Returns:
54
+ TUIContext instance with terminal capabilities detected.
55
+ """
56
+ terminal = await TerminalCapabilities.detect()
57
+ theme_name = os.getenv("AIP_TUI_THEME") or None
58
+ theme = ThemeManager(terminal, theme=theme_name)
59
+ return cls(terminal=terminal, theme=theme)
@@ -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,58 @@
1
+ """Shared helpers for toggling Textual loading indicators.
2
+
3
+ Note: uses Textual's built-in LoadingIndicator as the MVP; upgrade to the
4
+ PulseIndicator from cli-textual-animated-indicators.md when shipped.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from typing import TYPE_CHECKING
11
+
12
+ try: # pragma: no cover - optional dependency
13
+ from textual.widgets import LoadingIndicator
14
+ except Exception: # pragma: no cover - optional dependency
15
+ LoadingIndicator = None # type: ignore[assignment]
16
+
17
+ if TYPE_CHECKING: # pragma: no cover - type checking aid
18
+ from textual.widgets import LoadingIndicator as _LoadingIndicatorType
19
+
20
+ LoadingIndicator: type[_LoadingIndicatorType] | None
21
+
22
+
23
+ def _set_indicator_display(app: object, selector: str, visible: bool) -> None:
24
+ """Safely toggle a LoadingIndicator's display property."""
25
+ if LoadingIndicator is None:
26
+ return
27
+ try:
28
+ indicator = app.query_one(selector, LoadingIndicator) # type: ignore[arg-type]
29
+ indicator.display = visible
30
+ except Exception:
31
+ # Ignore lookup/rendering errors to keep UI resilient
32
+ return
33
+
34
+
35
+ def show_loading_indicator(
36
+ app: object,
37
+ selector: str,
38
+ *,
39
+ message: str | None = None,
40
+ set_status: Callable[..., None] | None = None,
41
+ status_style: str = "cyan",
42
+ ) -> None:
43
+ """Show a loading indicator and optionally set a status message."""
44
+ _set_indicator_display(app, selector, True)
45
+ if message and set_status:
46
+ try:
47
+ set_status(message, status_style)
48
+ except TypeError:
49
+ # Fallback for setters that accept only a single arg or kwargs
50
+ try:
51
+ set_status(message)
52
+ except Exception:
53
+ return
54
+
55
+
56
+ def hide_loading_indicator(app: object, selector: str) -> None:
57
+ """Hide a loading indicator."""
58
+ _set_indicator_display(app, selector, False)