glaip-sdk 0.6.5b6__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 (116) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. glaip_sdk/agents/base.py +156 -32
  3. glaip_sdk/cli/auth.py +14 -8
  4. glaip_sdk/cli/commands/accounts.py +1 -1
  5. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  6. glaip_sdk/cli/commands/agents/_common.py +561 -0
  7. glaip_sdk/cli/commands/agents/create.py +151 -0
  8. glaip_sdk/cli/commands/agents/delete.py +64 -0
  9. glaip_sdk/cli/commands/agents/get.py +89 -0
  10. glaip_sdk/cli/commands/agents/list.py +129 -0
  11. glaip_sdk/cli/commands/agents/run.py +264 -0
  12. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  13. glaip_sdk/cli/commands/agents/update.py +112 -0
  14. glaip_sdk/cli/commands/common_config.py +15 -12
  15. glaip_sdk/cli/commands/configure.py +2 -3
  16. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  17. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  18. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  19. glaip_sdk/cli/commands/mcps/create.py +152 -0
  20. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  21. glaip_sdk/cli/commands/mcps/get.py +212 -0
  22. glaip_sdk/cli/commands/mcps/list.py +69 -0
  23. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  24. glaip_sdk/cli/commands/mcps/update.py +190 -0
  25. glaip_sdk/cli/commands/models.py +2 -4
  26. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  27. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  28. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  29. glaip_sdk/cli/commands/tools/_common.py +80 -0
  30. glaip_sdk/cli/commands/tools/create.py +228 -0
  31. glaip_sdk/cli/commands/tools/delete.py +61 -0
  32. glaip_sdk/cli/commands/tools/get.py +103 -0
  33. glaip_sdk/cli/commands/tools/list.py +69 -0
  34. glaip_sdk/cli/commands/tools/script.py +49 -0
  35. glaip_sdk/cli/commands/tools/update.py +102 -0
  36. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  37. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  38. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  39. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  40. glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
  41. glaip_sdk/cli/commands/update.py +163 -17
  42. glaip_sdk/cli/core/output.py +12 -7
  43. glaip_sdk/cli/entrypoint.py +20 -0
  44. glaip_sdk/cli/main.py +127 -39
  45. glaip_sdk/cli/pager.py +3 -3
  46. glaip_sdk/cli/resolution.py +2 -1
  47. glaip_sdk/cli/slash/accounts_controller.py +112 -32
  48. glaip_sdk/cli/slash/agent_session.py +5 -2
  49. glaip_sdk/cli/slash/prompt.py +11 -0
  50. glaip_sdk/cli/slash/remote_runs_controller.py +1 -1
  51. glaip_sdk/cli/slash/session.py +58 -13
  52. glaip_sdk/cli/slash/tui/__init__.py +26 -1
  53. glaip_sdk/cli/slash/tui/accounts.tcss +7 -5
  54. glaip_sdk/cli/slash/tui/accounts_app.py +70 -9
  55. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  56. glaip_sdk/cli/slash/tui/context.py +59 -0
  57. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  58. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  59. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  60. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  61. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  62. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  63. glaip_sdk/cli/slash/tui/toast.py +123 -0
  64. glaip_sdk/cli/transcript/history.py +1 -1
  65. glaip_sdk/cli/transcript/viewer.py +5 -3
  66. glaip_sdk/cli/update_notifier.py +215 -7
  67. glaip_sdk/cli/validators.py +1 -1
  68. glaip_sdk/client/__init__.py +2 -1
  69. glaip_sdk/client/_schedule_payloads.py +89 -0
  70. glaip_sdk/client/agents.py +50 -8
  71. glaip_sdk/client/hitl.py +136 -0
  72. glaip_sdk/client/main.py +7 -1
  73. glaip_sdk/client/mcps.py +44 -13
  74. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  75. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +22 -47
  76. glaip_sdk/client/payloads/agent/responses.py +43 -0
  77. glaip_sdk/client/run_rendering.py +367 -3
  78. glaip_sdk/client/schedules.py +439 -0
  79. glaip_sdk/client/tools.py +57 -26
  80. glaip_sdk/hitl/__init__.py +48 -0
  81. glaip_sdk/hitl/base.py +64 -0
  82. glaip_sdk/hitl/callback.py +43 -0
  83. glaip_sdk/hitl/local.py +121 -0
  84. glaip_sdk/hitl/remote.py +523 -0
  85. glaip_sdk/models/__init__.py +17 -0
  86. glaip_sdk/models/agent_runs.py +2 -1
  87. glaip_sdk/models/schedule.py +224 -0
  88. glaip_sdk/registry/tool.py +273 -59
  89. glaip_sdk/runner/__init__.py +20 -3
  90. glaip_sdk/runner/deps.py +5 -8
  91. glaip_sdk/runner/langgraph.py +317 -42
  92. glaip_sdk/runner/logging_config.py +77 -0
  93. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +104 -5
  94. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +72 -7
  95. glaip_sdk/schedules/__init__.py +22 -0
  96. glaip_sdk/schedules/base.py +291 -0
  97. glaip_sdk/tools/base.py +44 -11
  98. glaip_sdk/utils/__init__.py +1 -0
  99. glaip_sdk/utils/bundler.py +138 -2
  100. glaip_sdk/utils/import_resolver.py +43 -11
  101. glaip_sdk/utils/rendering/renderer/base.py +58 -0
  102. glaip_sdk/utils/runtime_config.py +15 -12
  103. glaip_sdk/utils/sync.py +31 -11
  104. glaip_sdk/utils/tool_detection.py +274 -6
  105. glaip_sdk/utils/tool_storage_provider.py +140 -0
  106. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.7.dist-info}/METADATA +47 -37
  107. glaip_sdk-0.7.7.dist-info/RECORD +213 -0
  108. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.7.dist-info}/WHEEL +2 -1
  109. glaip_sdk-0.7.7.dist-info/entry_points.txt +2 -0
  110. glaip_sdk-0.7.7.dist-info/top_level.txt +1 -0
  111. glaip_sdk/cli/commands/agents.py +0 -1509
  112. glaip_sdk/cli/commands/mcps.py +0 -1356
  113. glaip_sdk/cli/commands/tools.py +0 -576
  114. glaip_sdk/cli/utils.py +0 -263
  115. glaip_sdk-0.6.5b6.dist-info/RECORD +0 -159
  116. glaip_sdk-0.6.5b6.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,402 @@
1
+ """Terminal capability detection for TUI applications.
2
+
3
+ This module provides terminal capability detection including TTY status, ANSI support,
4
+ OSC 52 clipboard support, mouse support, truecolor support, and OSC 11 background
5
+ color detection for automatic theme selection.
6
+
7
+ Authors:
8
+ Raymond Christopher (raymond.christopher@gdplabs.id)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import os
15
+ import re
16
+ import select
17
+ import sys
18
+ import time
19
+ from dataclasses import dataclass
20
+ from typing import Literal
21
+
22
+ # Windows compatibility: termios and tty may not be available
23
+ try:
24
+ import termios
25
+ import tty
26
+
27
+ _TERMIOS_AVAILABLE = True
28
+ except ImportError: # pragma: no cover
29
+ # Platform-specific: Windows doesn't have termios/tty modules
30
+ # This exception is only raised on Windows or systems without termios support
31
+ # Testing would require complex module reloading and platform-specific test setup
32
+ _TERMIOS_AVAILABLE = False
33
+
34
+
35
+ @dataclass
36
+ class TerminalCapabilities:
37
+ """Terminal feature detection results.
38
+
39
+ Attributes:
40
+ tty: Whether stdout is a TTY.
41
+ ansi: Whether ANSI escape sequences are supported.
42
+ osc52: Whether OSC 52 (clipboard) is supported.
43
+ osc11_bg: Raw RGB color string from OSC 11 query, or None if not detected.
44
+ mouse: Whether mouse support is available.
45
+ truecolor: Whether truecolor (24-bit) color is supported.
46
+ """
47
+
48
+ tty: bool
49
+ ansi: bool
50
+ osc52: bool
51
+ osc11_bg: str | None
52
+ mouse: bool
53
+ truecolor: bool
54
+
55
+ @property
56
+ def background_mode(self) -> Literal["light", "dark"]:
57
+ """Derive light/dark mode from OSC 11 background color.
58
+
59
+ Returns:
60
+ "light" if luminance > 0.5, "dark" otherwise. Defaults to "dark"
61
+ if osc11_bg is None.
62
+ """
63
+ if self.osc11_bg is None:
64
+ return "dark"
65
+
66
+ rgb = _parse_color_response(self.osc11_bg)
67
+ if rgb is None:
68
+ return "dark"
69
+
70
+ luminance = _calculate_luminance(rgb[0], rgb[1], rgb[2])
71
+ return "light" if luminance > 0.5 else "dark"
72
+
73
+ @classmethod
74
+ async def detect(cls) -> TerminalCapabilities:
75
+ """Detect terminal capabilities asynchronously with fast timeout.
76
+
77
+ This method performs capability detection including OSC 11 background
78
+ color detection with a 100ms timeout. The method completes quickly
79
+ (< 100ms) as required by the roadmap. OSC 11 detection may return None
80
+ if the terminal doesn't respond within the timeout; use
81
+ detect_terminal_background() for full 1-second timeout when needed.
82
+
83
+ Returns:
84
+ TerminalCapabilities instance with detected capabilities.
85
+ """
86
+ tty_available = sys.stdout.isatty()
87
+ term = os.environ.get("TERM", "")
88
+ colorterm = os.environ.get("COLORTERM", "")
89
+
90
+ # Basic capability detection
91
+ ansi = tty_available and term not in ("dumb", "")
92
+ osc52 = detect_osc52_support()
93
+ mouse = tty_available and term not in ("dumb", "")
94
+ truecolor = colorterm in ("truecolor", "24bit")
95
+
96
+ # OSC 11 detection: use fast path (<100ms timeout)
97
+ osc11_bg: str | None = await _detect_osc11_fast()
98
+
99
+ return cls(
100
+ tty=tty_available,
101
+ ansi=ansi,
102
+ osc52=osc52,
103
+ osc11_bg=osc11_bg,
104
+ mouse=mouse,
105
+ truecolor=truecolor,
106
+ )
107
+
108
+
109
+ async def detect_terminal_background() -> str | None:
110
+ """Detect terminal background color using OSC 11 with full timeout.
111
+
112
+ This function can be called separately to await OSC 11 detection with the
113
+ full 1-second timeout. Useful for theme initialization where a slight delay
114
+ is acceptable.
115
+
116
+ Returns:
117
+ Raw RGB color string from terminal, or None if detection fails or times out.
118
+ """
119
+ if not sys.stdout.isatty() or not sys.stdin.isatty():
120
+ return None
121
+
122
+ if not _TERMIOS_AVAILABLE:
123
+ return None
124
+
125
+ return await _detect_osc11_full()
126
+
127
+
128
+ async def _detect_osc11_fast() -> str | None:
129
+ """Fast-path OSC 11 detection (used by detect())."""
130
+ return await _detect_osc11_impl(timeout=0.1)
131
+
132
+
133
+ async def _detect_osc11_full() -> str | None:
134
+ """Full-timeout OSC 11 detection (used by detect_terminal_background())."""
135
+ return await _detect_osc11_impl(timeout=1.0)
136
+
137
+
138
+ def _read_osc11_char_with_timeout(start_time: float, timeout_seconds: float) -> str | None:
139
+ """Read a single character from stdin with timeout.
140
+
141
+ Args:
142
+ start_time: Start time for timeout calculation.
143
+ timeout_seconds: Maximum time to wait.
144
+
145
+ Returns:
146
+ Character read or None on timeout/error.
147
+ """
148
+ elapsed = time.time() - start_time
149
+ if elapsed >= timeout_seconds:
150
+ return None
151
+
152
+ try:
153
+ remaining = timeout_seconds - elapsed
154
+ ready, _, _ = select.select([sys.stdin], [], [], min(0.1, remaining))
155
+ if not ready:
156
+ return None
157
+
158
+ char = sys.stdin.read(1)
159
+ return char if char else None
160
+ except (OSError, ValueError):
161
+ return None
162
+
163
+
164
+ def _check_osc11_complete(response_text: str, response_length: int) -> str | None:
165
+ """Check if OSC 11 response is complete.
166
+
167
+ Args:
168
+ response_text: Current response text.
169
+ response_length: Length of response characters.
170
+
171
+ Returns:
172
+ Matched color string if complete, None otherwise.
173
+ """
174
+ match = _match_osc11_response(response_text)
175
+ if match:
176
+ return match
177
+
178
+ # If we see BEL (\x07) terminator, check one more time then give up
179
+ if "\x07" in response_text and response_length >= 10:
180
+ return None
181
+
182
+ return None
183
+
184
+
185
+ def _read_osc11_response_sync(timeout_seconds: float) -> str | None:
186
+ """Synchronously read OSC 11 response from stdin.
187
+
188
+ This runs in a thread to avoid blocking the event loop.
189
+
190
+ Args:
191
+ timeout_seconds: Maximum time to wait.
192
+
193
+ Returns:
194
+ Color string or None.
195
+ """
196
+ response_chars: list[str] = []
197
+ start_time = time.time()
198
+ max_chars = 200 # Reasonable limit to prevent infinite loops
199
+
200
+ while len(response_chars) < max_chars:
201
+ elapsed = time.time() - start_time
202
+ if elapsed >= timeout_seconds:
203
+ return None
204
+
205
+ char = _read_osc11_char_with_timeout(start_time, timeout_seconds)
206
+ if char is None:
207
+ # Check timeout again after failed read
208
+ if time.time() - start_time >= timeout_seconds:
209
+ return None
210
+ continue
211
+
212
+ response_chars.append(char)
213
+ response_text = "".join(response_chars)
214
+
215
+ result = _check_osc11_complete(response_text, len(response_chars))
216
+ if result is not None:
217
+ return result
218
+
219
+ return None
220
+
221
+
222
+ async def _detect_osc11_impl(timeout: float) -> str | None:
223
+ """Internal OSC 11 detection implementation.
224
+
225
+ Args:
226
+ timeout: Maximum time to wait for terminal response in seconds.
227
+
228
+ Returns:
229
+ Raw RGB color string, or None on timeout/error.
230
+ """
231
+ if not _TERMIOS_AVAILABLE:
232
+ return None
233
+
234
+ old_settings = None
235
+ try:
236
+ # Save terminal settings
237
+ old_settings = termios.tcgetattr(sys.stdin)
238
+ tty.setraw(sys.stdin.fileno())
239
+
240
+ # Send OSC 11 query
241
+ sys.stdout.write("\x1b]11;?\x07")
242
+ sys.stdout.flush()
243
+
244
+ # Read response in a thread to avoid blocking
245
+ try:
246
+ result = await asyncio.wait_for(asyncio.to_thread(_read_osc11_response_sync, timeout), timeout=timeout)
247
+ return result
248
+ except TimeoutError:
249
+ return None
250
+
251
+ except Exception:
252
+ return None
253
+ finally:
254
+ # Restore terminal settings
255
+ if old_settings is not None:
256
+ try:
257
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
258
+ except Exception:
259
+ pass
260
+
261
+
262
+ def _match_osc11_response(text: str) -> str | None:
263
+ """Extract OSC 11 color response from text.
264
+
265
+ Args:
266
+ text: Raw text from stdin.
267
+
268
+ Returns:
269
+ Color string (e.g., "rgb:RRRR/GGGG/BBBB") or None if not found.
270
+ """
271
+ # Match OSC 11 response: \x1b]11;...\x07
272
+ match = re.search(r"\x1b\]11;([^\x07\x1b]+)", text)
273
+ if match:
274
+ return match.group(1)
275
+ return None
276
+
277
+
278
+ def _parse_color_response(color_str: str) -> tuple[int, int, int] | None:
279
+ """Parse RGB color from various terminal color formats.
280
+
281
+ Supports:
282
+ - rgb:RRRR/GGGG/BBBB (16-bit per channel)
283
+ - rgb:RR/GG/BB (8-bit per channel)
284
+ - #RRGGBB (hex)
285
+ - rgb(R,G,B) (decimal)
286
+
287
+ Args:
288
+ color_str: Color string from terminal.
289
+
290
+ Returns:
291
+ Tuple of (R, G, B) values in 0-255 range, or None if parsing fails.
292
+ """
293
+ if not color_str:
294
+ return None
295
+
296
+ try:
297
+ if color_str.startswith("rgb:"):
298
+ # Format: rgb:RRRR/GGGG/BBBB (16-bit) or rgb:RR/GG/BB (8-bit)
299
+ parts = color_str[4:].split("/")
300
+ if len(parts) == 3:
301
+ r_val = int(parts[0], 16)
302
+ g_val = int(parts[1], 16)
303
+ b_val = int(parts[2], 16)
304
+
305
+ # Convert 16-bit to 8-bit: if hex string has 4 digits, it's 16-bit
306
+ # and we take the high byte (>> 8). If 2 digits, it's already 8-bit.
307
+ if len(parts[0]) == 4: # 16-bit format
308
+ r_val = r_val >> 8
309
+ if len(parts[1]) == 4: # 16-bit format
310
+ g_val = g_val >> 8
311
+ if len(parts[2]) == 4: # 16-bit format
312
+ b_val = b_val >> 8
313
+
314
+ return (r_val, g_val, b_val)
315
+
316
+ elif color_str.startswith("#"):
317
+ # Format: #RRGGBB
318
+ if len(color_str) == 7:
319
+ r = int(color_str[1:3], 16)
320
+ g = int(color_str[3:5], 16)
321
+ b = int(color_str[5:7], 16)
322
+ return (r, g, b)
323
+
324
+ elif color_str.startswith("rgb("):
325
+ # Format: rgb(R,G,B)
326
+ parts = color_str[4:-1].split(",")
327
+ if len(parts) == 3:
328
+ r = int(parts[0].strip())
329
+ g = int(parts[1].strip())
330
+ b = int(parts[2].strip())
331
+ return (r, g, b)
332
+
333
+ except (ValueError, IndexError):
334
+ pass
335
+
336
+ return None
337
+
338
+
339
+ def _calculate_luminance(r: int, g: int, b: int) -> float:
340
+ """Calculate relative luminance from RGB values.
341
+
342
+ Uses the relative luminance formula from WCAG:
343
+ L = 0.299*R + 0.587*G + 0.114*B
344
+
345
+ Args:
346
+ r: Red component (0-255).
347
+ g: Green component (0-255).
348
+ b: Blue component (0-255).
349
+
350
+ Returns:
351
+ Luminance value normalized to 0.0-1.0 range.
352
+ """
353
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
354
+
355
+
356
+ def _check_terminal_in_env(env_value: str, terminals: list[str]) -> bool:
357
+ """Check if any terminal name appears in environment value.
358
+
359
+ Args:
360
+ env_value: Environment variable value to check.
361
+ terminals: List of terminal names to search for.
362
+
363
+ Returns:
364
+ True if any terminal name is found in env_value.
365
+ """
366
+ return any(terminal in env_value for terminal in terminals)
367
+
368
+
369
+ def detect_osc52_support() -> bool:
370
+ """Check if terminal likely supports OSC 52 (clipboard).
371
+
372
+ Returns:
373
+ True if terminal name suggests OSC 52 support.
374
+ """
375
+ term = os.environ.get("TERM", "").lower()
376
+ term_program = os.environ.get("TERM_PROGRAM", "").lower()
377
+ term_program_version = os.environ.get("TERM_PROGRAM_VERSION", "").lower()
378
+
379
+ # Known terminals that support OSC 52
380
+ osc52_terminals = [
381
+ "iterm",
382
+ "kitty",
383
+ "alacritty",
384
+ "wezterm",
385
+ "vscode",
386
+ "windows terminal",
387
+ "mintty", # Windows terminal emulator
388
+ ]
389
+
390
+ # Check TERM_PROGRAM first (most reliable)
391
+ if term_program and _check_terminal_in_env(term_program, osc52_terminals):
392
+ return True
393
+
394
+ # Check TERM_PROGRAM_VERSION (VS Code uses this)
395
+ if term_program_version and _check_terminal_in_env(term_program_version, osc52_terminals):
396
+ return True
397
+
398
+ # Check TERM (less reliable but sometimes works)
399
+ if term and _check_terminal_in_env(term, osc52_terminals):
400
+ return True
401
+
402
+ return False
@@ -0,0 +1,15 @@
1
+ """Theme system primitives for Textual TUIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from glaip_sdk.cli.slash.tui.theme.catalog import get_builtin_theme, list_builtin_themes
6
+ from glaip_sdk.cli.slash.tui.theme.manager import ThemeManager, ThemeMode
7
+ from glaip_sdk.cli.slash.tui.theme.tokens import ThemeTokens
8
+
9
+ __all__ = [
10
+ "ThemeManager",
11
+ "ThemeMode",
12
+ "ThemeTokens",
13
+ "get_builtin_theme",
14
+ "list_builtin_themes",
15
+ ]
@@ -0,0 +1,79 @@
1
+ """Built-in theme catalog for TUI applications.
2
+
3
+ This module implements Phase 2 of the TUI Theme System spec, providing a foundational
4
+ set of 12 color tokens (primary, secondary, accent, background, background_panel, text,
5
+ text_muted, success, warning, error, info). Additional tokens (e.g., diff.added,
6
+ syntax.*, backgroundElevated, textDim) will be added in future phases per the spec's
7
+ "100+ color tokens" requirement.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from glaip_sdk.cli.slash.tui.theme.tokens import ThemeModeLiteral, ThemeTokens
13
+
14
+ _BUILTIN_THEMES: dict[str, ThemeTokens] = {
15
+ "gl-dark": ThemeTokens(
16
+ name="gl-dark",
17
+ mode="dark",
18
+ primary="#6EA8FE",
19
+ secondary="#ADB5BD",
20
+ accent="#C77DFF",
21
+ background="#0B0F19",
22
+ background_panel="#111827",
23
+ text="#E5E7EB",
24
+ text_muted="#9CA3AF",
25
+ success="#34D399",
26
+ warning="#FBBF24",
27
+ error="#F87171",
28
+ info="#60A5FA",
29
+ ),
30
+ "gl-light": ThemeTokens(
31
+ name="gl-light",
32
+ mode="light",
33
+ primary="#1D4ED8",
34
+ secondary="#4B5563",
35
+ accent="#7C3AED",
36
+ background="#FFFFFF",
37
+ background_panel="#F3F4F6",
38
+ text="#111827",
39
+ text_muted="#4B5563",
40
+ success="#059669",
41
+ warning="#B45309",
42
+ error="#B91C1C",
43
+ info="#1D4ED8",
44
+ ),
45
+ "gl-high-contrast": ThemeTokens(
46
+ name="gl-high-contrast",
47
+ mode="dark",
48
+ # High-contrast theme uses uniform colors (#FFFFFF on #000000) to maximize
49
+ # contrast for accessibility. Semantic distinctions (success/warning/error)
50
+ # are intentionally uniform to prioritize maximum readability over color
51
+ # coding, per accessibility best practices for high-contrast modes.
52
+ primary="#FFFFFF",
53
+ secondary="#FFFFFF",
54
+ accent="#FFFFFF",
55
+ background="#000000",
56
+ background_panel="#000000",
57
+ text="#FFFFFF",
58
+ text_muted="#FFFFFF",
59
+ success="#FFFFFF",
60
+ warning="#FFFFFF",
61
+ error="#FFFFFF",
62
+ info="#FFFFFF",
63
+ ),
64
+ }
65
+
66
+
67
+ def get_builtin_theme(name: str) -> ThemeTokens | None:
68
+ """Return a built-in theme by name."""
69
+ return _BUILTIN_THEMES.get(name)
70
+
71
+
72
+ def list_builtin_themes() -> list[str]:
73
+ """List available built-in theme names."""
74
+ return sorted(_BUILTIN_THEMES)
75
+
76
+
77
+ def default_theme_name_for_mode(mode: ThemeModeLiteral) -> str:
78
+ """Return the default theme name for the given light/dark mode."""
79
+ return "gl-light" if mode == "light" else "gl-dark"
@@ -0,0 +1,86 @@
1
+ """Theme manager for TUI applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from enum import Enum
7
+ from typing import Literal
8
+
9
+ from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
10
+ from glaip_sdk.cli.slash.tui.theme.catalog import default_theme_name_for_mode, get_builtin_theme
11
+ from glaip_sdk.cli.slash.tui.theme.tokens import ThemeTokens
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ThemeMode(str, Enum):
17
+ """User-selectable theme mode."""
18
+
19
+ AUTO = "auto"
20
+ LIGHT = "light"
21
+ DARK = "dark"
22
+
23
+
24
+ class ThemeManager:
25
+ """Resolve active theme tokens from terminal state and user preferences."""
26
+
27
+ def __init__(
28
+ self,
29
+ terminal: TerminalCapabilities,
30
+ *,
31
+ mode: ThemeMode | str = ThemeMode.AUTO,
32
+ theme: str | None = None,
33
+ ) -> None:
34
+ """Initialize the theme manager."""
35
+ self._terminal = terminal
36
+ self._mode = self._coerce_mode(mode)
37
+ self._theme = theme
38
+
39
+ @property
40
+ def mode(self) -> ThemeMode:
41
+ """Return configured mode (auto/light/dark)."""
42
+ return self._mode
43
+
44
+ @property
45
+ def effective_mode(self) -> Literal["light", "dark"]:
46
+ """Return resolved light/dark mode."""
47
+ if self._mode == ThemeMode.AUTO:
48
+ return self._terminal.background_mode
49
+ return "light" if self._mode == ThemeMode.LIGHT else "dark"
50
+
51
+ @property
52
+ def theme_name(self) -> str:
53
+ """Return resolved theme name."""
54
+ return self._theme or default_theme_name_for_mode(self.effective_mode)
55
+
56
+ @property
57
+ def tokens(self) -> ThemeTokens:
58
+ """Return tokens for the resolved theme."""
59
+ chosen = get_builtin_theme(self.theme_name)
60
+ if chosen is not None:
61
+ return chosen
62
+
63
+ fallback_name = default_theme_name_for_mode(self.effective_mode)
64
+ fallback = get_builtin_theme(fallback_name)
65
+ if fallback is None:
66
+ raise RuntimeError(f"Missing default theme: {fallback_name}")
67
+
68
+ return fallback
69
+
70
+ def set_mode(self, mode: ThemeMode | str) -> None:
71
+ """Set auto/light/dark mode."""
72
+ self._mode = self._coerce_mode(mode)
73
+
74
+ def set_theme(self, theme: str | None) -> None:
75
+ """Set explicit theme name (or None to use the default)."""
76
+ self._theme = theme
77
+
78
+ def _coerce_mode(self, mode: ThemeMode | str) -> ThemeMode:
79
+ """Coerce a mode value to ThemeMode enum, defaulting to AUTO on invalid input."""
80
+ if isinstance(mode, ThemeMode):
81
+ return mode
82
+ try:
83
+ return ThemeMode(mode)
84
+ except ValueError:
85
+ logger.warning(f"Invalid theme mode '{mode}', defaulting to AUTO")
86
+ return ThemeMode.AUTO
@@ -0,0 +1,55 @@
1
+ """Theme token definitions for TUI applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ ThemeModeLiteral = Literal["light", "dark"]
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ThemeTokens:
13
+ """Color token set for a built-in theme."""
14
+
15
+ name: str
16
+ mode: ThemeModeLiteral
17
+
18
+ primary: str
19
+ secondary: str
20
+ accent: str
21
+
22
+ background: str
23
+ background_panel: str
24
+
25
+ text: str
26
+ text_muted: str
27
+
28
+ success: str
29
+ warning: str
30
+ error: str
31
+ info: str
32
+
33
+ def as_dict(self) -> dict[str, str]:
34
+ """Return color tokens as a plain dictionary.
35
+
36
+ Returns only color tokens (primary, secondary, accent, background, etc.),
37
+ excluding metadata fields (name, mode). This is intentional for use cases
38
+ like Textual TCSS mapping where only color values are needed.
39
+
40
+ Returns:
41
+ Dictionary mapping color token names to hex color strings.
42
+ """
43
+ return {
44
+ "primary": self.primary,
45
+ "secondary": self.secondary,
46
+ "accent": self.accent,
47
+ "background": self.background,
48
+ "background_panel": self.background_panel,
49
+ "text": self.text,
50
+ "text_muted": self.text_muted,
51
+ "success": self.success,
52
+ "warning": self.warning,
53
+ "error": self.error,
54
+ "info": self.info,
55
+ }