glaip-sdk 0.1.2__py3-none-any.whl → 0.7.17__py3-none-any.whl

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