glaip-sdk 0.6.5b6__py3-none-any.whl → 0.7.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. glaip_sdk/agents/base.py +217 -42
  3. glaip_sdk/branding.py +113 -2
  4. glaip_sdk/cli/account_store.py +15 -0
  5. glaip_sdk/cli/auth.py +14 -8
  6. glaip_sdk/cli/commands/accounts.py +1 -1
  7. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  8. glaip_sdk/cli/commands/agents/_common.py +561 -0
  9. glaip_sdk/cli/commands/agents/create.py +151 -0
  10. glaip_sdk/cli/commands/agents/delete.py +64 -0
  11. glaip_sdk/cli/commands/agents/get.py +89 -0
  12. glaip_sdk/cli/commands/agents/list.py +129 -0
  13. glaip_sdk/cli/commands/agents/run.py +264 -0
  14. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  15. glaip_sdk/cli/commands/agents/update.py +112 -0
  16. glaip_sdk/cli/commands/common_config.py +15 -12
  17. glaip_sdk/cli/commands/configure.py +2 -3
  18. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  19. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  20. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  21. glaip_sdk/cli/commands/mcps/create.py +152 -0
  22. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  23. glaip_sdk/cli/commands/mcps/get.py +212 -0
  24. glaip_sdk/cli/commands/mcps/list.py +69 -0
  25. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  26. glaip_sdk/cli/commands/mcps/update.py +190 -0
  27. glaip_sdk/cli/commands/models.py +2 -4
  28. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  29. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  30. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  31. glaip_sdk/cli/commands/tools/_common.py +80 -0
  32. glaip_sdk/cli/commands/tools/create.py +228 -0
  33. glaip_sdk/cli/commands/tools/delete.py +61 -0
  34. glaip_sdk/cli/commands/tools/get.py +103 -0
  35. glaip_sdk/cli/commands/tools/list.py +69 -0
  36. glaip_sdk/cli/commands/tools/script.py +49 -0
  37. glaip_sdk/cli/commands/tools/update.py +102 -0
  38. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  39. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  40. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  41. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  42. glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
  43. glaip_sdk/cli/commands/update.py +163 -17
  44. glaip_sdk/cli/config.py +1 -0
  45. glaip_sdk/cli/core/output.py +12 -7
  46. glaip_sdk/cli/entrypoint.py +20 -0
  47. glaip_sdk/cli/main.py +127 -39
  48. glaip_sdk/cli/pager.py +3 -3
  49. glaip_sdk/cli/resolution.py +2 -1
  50. glaip_sdk/cli/slash/accounts_controller.py +112 -32
  51. glaip_sdk/cli/slash/agent_session.py +5 -2
  52. glaip_sdk/cli/slash/prompt.py +11 -0
  53. glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
  54. glaip_sdk/cli/slash/session.py +369 -23
  55. glaip_sdk/cli/slash/tui/__init__.py +26 -1
  56. glaip_sdk/cli/slash/tui/accounts.tcss +79 -5
  57. glaip_sdk/cli/slash/tui/accounts_app.py +1027 -88
  58. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  59. glaip_sdk/cli/slash/tui/context.py +87 -0
  60. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  61. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  62. glaip_sdk/cli/slash/tui/layouts/harlequin.py +160 -0
  63. glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
  64. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  65. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  66. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  67. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  68. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  69. glaip_sdk/cli/slash/tui/toast.py +374 -0
  70. glaip_sdk/cli/transcript/history.py +1 -1
  71. glaip_sdk/cli/transcript/viewer.py +5 -3
  72. glaip_sdk/cli/tui_settings.py +125 -0
  73. glaip_sdk/cli/update_notifier.py +215 -7
  74. glaip_sdk/cli/validators.py +1 -1
  75. glaip_sdk/client/__init__.py +2 -1
  76. glaip_sdk/client/_schedule_payloads.py +89 -0
  77. glaip_sdk/client/agents.py +50 -8
  78. glaip_sdk/client/hitl.py +136 -0
  79. glaip_sdk/client/main.py +7 -1
  80. glaip_sdk/client/mcps.py +44 -13
  81. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  82. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +22 -47
  83. glaip_sdk/client/payloads/agent/responses.py +43 -0
  84. glaip_sdk/client/run_rendering.py +414 -3
  85. glaip_sdk/client/schedules.py +439 -0
  86. glaip_sdk/client/tools.py +57 -26
  87. glaip_sdk/guardrails/__init__.py +80 -0
  88. glaip_sdk/guardrails/serializer.py +89 -0
  89. glaip_sdk/hitl/__init__.py +48 -0
  90. glaip_sdk/hitl/base.py +64 -0
  91. glaip_sdk/hitl/callback.py +43 -0
  92. glaip_sdk/hitl/local.py +121 -0
  93. glaip_sdk/hitl/remote.py +523 -0
  94. glaip_sdk/models/__init__.py +17 -0
  95. glaip_sdk/models/agent_runs.py +2 -1
  96. glaip_sdk/models/schedule.py +224 -0
  97. glaip_sdk/payload_schemas/agent.py +1 -0
  98. glaip_sdk/payload_schemas/guardrails.py +34 -0
  99. glaip_sdk/registry/tool.py +273 -59
  100. glaip_sdk/runner/__init__.py +20 -3
  101. glaip_sdk/runner/deps.py +5 -8
  102. glaip_sdk/runner/langgraph.py +318 -42
  103. glaip_sdk/runner/logging_config.py +77 -0
  104. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +104 -5
  105. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +72 -7
  106. glaip_sdk/schedules/__init__.py +22 -0
  107. glaip_sdk/schedules/base.py +291 -0
  108. glaip_sdk/tools/base.py +67 -14
  109. glaip_sdk/utils/__init__.py +1 -0
  110. glaip_sdk/utils/bundler.py +138 -2
  111. glaip_sdk/utils/import_resolver.py +43 -11
  112. glaip_sdk/utils/rendering/renderer/base.py +58 -0
  113. glaip_sdk/utils/runtime_config.py +15 -12
  114. glaip_sdk/utils/sync.py +31 -11
  115. glaip_sdk/utils/tool_detection.py +274 -6
  116. glaip_sdk/utils/tool_storage_provider.py +140 -0
  117. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/METADATA +49 -37
  118. glaip_sdk-0.7.12.dist-info/RECORD +219 -0
  119. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/WHEEL +2 -1
  120. glaip_sdk-0.7.12.dist-info/entry_points.txt +2 -0
  121. glaip_sdk-0.7.12.dist-info/top_level.txt +1 -0
  122. glaip_sdk/cli/commands/agents.py +0 -1509
  123. glaip_sdk/cli/commands/mcps.py +0 -1356
  124. glaip_sdk/cli/commands/tools.py +0 -576
  125. glaip_sdk/cli/utils.py +0 -263
  126. glaip_sdk-0.6.5b6.dist-info/RECORD +0 -159
  127. glaip_sdk-0.6.5b6.dist-info/entry_points.txt +0 -3
@@ -19,12 +19,16 @@ from typing import Any
19
19
  from rich.text import Text
20
20
  from textual.app import App, ComposeResult
21
21
  from textual.binding import Binding
22
- from textual.containers import Container, Horizontal
22
+ from textual.containers import Container, Horizontal, Vertical
23
+ from textual.coordinate import Coordinate
23
24
  from textual.reactive import ReactiveError
24
25
  from textual.screen import ModalScreen
25
26
  from textual.widgets import DataTable, Footer, Header, LoadingIndicator, RichLog, Static
26
27
 
28
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
29
+ from glaip_sdk.cli.slash.tui.context import TUIContext
27
30
  from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
31
+ from glaip_sdk.cli.slash.tui.toast import ClipboardToastMixin, Toast, ToastBus, ToastHandlerMixin
28
32
 
29
33
  logger = logging.getLogger(__name__)
30
34
 
@@ -50,6 +54,7 @@ def run_remote_runs_textual(
50
54
  *,
51
55
  agent_name: str | None = None,
52
56
  agent_id: str | None = None,
57
+ ctx: TUIContext | None = None,
53
58
  ) -> tuple[int, int, int]:
54
59
  """Launch the Textual application and return the final pagination state.
55
60
 
@@ -59,6 +64,7 @@ def run_remote_runs_textual(
59
64
  callbacks: Data provider callback bundle.
60
65
  agent_name: Optional agent name for display purposes.
61
66
  agent_id: Optional agent ID for display purposes.
67
+ ctx: Shared TUI context.
62
68
 
63
69
  Returns:
64
70
  Tuple of (page, limit, cursor_index) after the UI exits.
@@ -69,15 +75,27 @@ def run_remote_runs_textual(
69
75
  callbacks,
70
76
  agent_name=agent_name,
71
77
  agent_id=agent_id,
78
+ ctx=ctx,
72
79
  )
73
80
  app.run()
74
81
  current_page = getattr(app, "current_page", initial_page)
75
82
  return current_page.page, current_page.limit, app.cursor_index
76
83
 
77
84
 
78
- class RunDetailScreen(ModalScreen[None]):
85
+ class RunDetailScreen(ToastHandlerMixin, ClipboardToastMixin, ModalScreen[None]):
79
86
  """Modal screen displaying run metadata and output timeline."""
80
87
 
88
+ CSS = """
89
+ Screen { layout: vertical; layers: base toasts; }
90
+ #toast-container {
91
+ width: 100%;
92
+ height: auto;
93
+ dock: top;
94
+ align: right top;
95
+ layer: toasts;
96
+ }
97
+ """
98
+
81
99
  BINDINGS = [
82
100
  Binding("escape", "dismiss", "Close", priority=True),
83
101
  Binding("q", "dismiss_modal", "Close", priority=True),
@@ -85,14 +103,24 @@ class RunDetailScreen(ModalScreen[None]):
85
103
  Binding("down", "scroll_down", "Down"),
86
104
  Binding("pageup", "page_up", "PgUp"),
87
105
  Binding("pagedown", "page_down", "PgDn"),
106
+ Binding("c", "copy_run_id", "Copy ID"),
107
+ Binding("C", "copy_detail_json", "Copy JSON"),
88
108
  Binding("e", "export_detail", "Export"),
89
109
  ]
90
110
 
91
- def __init__(self, detail: Any, on_export: Callable[[Any], None] | None = None):
111
+ def __init__(
112
+ self,
113
+ detail: Any,
114
+ on_export: Callable[[Any], None] | None = None,
115
+ ctx: TUIContext | None = None,
116
+ ) -> None:
92
117
  """Initialize the run detail screen."""
93
118
  super().__init__()
94
119
  self.detail = detail
95
120
  self._on_export = on_export
121
+ self._ctx = ctx
122
+ self._clipboard: ClipboardAdapter | None = None
123
+ self._local_toasts: ToastBus | None = None
96
124
 
97
125
  def compose(self) -> ComposeResult:
98
126
  """Render metadata and events."""
@@ -116,14 +144,17 @@ class RunDetailScreen(ModalScreen[None]):
116
144
  duration = self.detail.duration_formatted() if getattr(self.detail, "duration_formatted", None) else None
117
145
  add_meta("Duration", duration, "bold")
118
146
 
119
- yield Container(
147
+ main_content = Vertical(
120
148
  Static(meta_text, id="detail-meta"),
121
149
  RichLog(id="detail-events", wrap=False),
122
150
  )
151
+ yield main_content
152
+ yield Container(Toast(), id="toast-container")
123
153
  yield Footer()
124
154
 
125
155
  def on_mount(self) -> None:
126
156
  """Populate and focus the log."""
157
+ self._ensure_toast_bus()
127
158
  log = self.query_one("#detail-events", RichLog)
128
159
  log.can_focus = True
129
160
  log.write(Text("Events", style="bold"))
@@ -149,6 +180,61 @@ class RunDetailScreen(ModalScreen[None]):
149
180
  def _log(self) -> RichLog:
150
181
  return self.query_one("#detail-events", RichLog)
151
182
 
183
+ def action_copy_run_id(self) -> None:
184
+ """Copy the run id to the clipboard."""
185
+ run_id = getattr(self.detail, "id", None)
186
+ if not run_id:
187
+ self._announce_status("Run ID unavailable.")
188
+ return
189
+ self._copy_to_clipboard(str(run_id), label="Run ID")
190
+
191
+ def action_copy_detail_json(self) -> None:
192
+ """Copy the run detail JSON to the clipboard."""
193
+ payload = self._detail_json_payload()
194
+ if payload is None:
195
+ return
196
+ self._copy_to_clipboard(payload, label="Run JSON")
197
+
198
+ def _detail_json_payload(self) -> str | None:
199
+ detail = self.detail
200
+ if detail is None:
201
+ self._announce_status("Run detail unavailable.")
202
+ return None
203
+ if isinstance(detail, str):
204
+ return detail
205
+ if isinstance(detail, dict):
206
+ payload = detail
207
+ elif hasattr(detail, "model_dump"):
208
+ payload = detail.model_dump(mode="json")
209
+ elif hasattr(detail, "dict"):
210
+ payload = detail.dict()
211
+ else:
212
+ payload = getattr(detail, "__dict__", {"value": detail})
213
+ try:
214
+ return json.dumps(payload, indent=2, ensure_ascii=False, default=str)
215
+ except Exception as exc:
216
+ self._announce_status(f"Failed to serialize run detail: {exc}")
217
+ return None
218
+
219
+ def _append_copy_fallback(self, text: str) -> None:
220
+ try:
221
+ log = self._log()
222
+ except Exception:
223
+ self._announce_status(text)
224
+ return
225
+ log.write(Text(text))
226
+ log.write(Text(""))
227
+
228
+ def _ensure_toast_bus(self) -> None:
229
+ """Ensure toast bus is initialized and connected to message handler."""
230
+ if self._local_toasts is not None:
231
+ return # pragma: no cover - early return when already initialized
232
+
233
+ def _notify(m: ToastBus.Changed) -> None:
234
+ self.post_message(m)
235
+
236
+ self._local_toasts = ToastBus(on_change=_notify)
237
+
152
238
  @staticmethod
153
239
  def _status_style(status: str | None) -> str:
154
240
  """Return a Rich style name for the status pill."""
@@ -220,11 +306,18 @@ class RunDetailScreen(ModalScreen[None]):
220
306
  update_status(message, append=True)
221
307
 
222
308
 
223
- class RemoteRunsTextualApp(App[None]):
309
+ class RemoteRunsTextualApp(ToastHandlerMixin, App[None]):
224
310
  """Textual application for browsing remote runs."""
225
311
 
226
312
  CSS = f"""
227
- Screen {{ layout: vertical; }}
313
+ Screen {{ layout: vertical; layers: base toasts; }}
314
+ #toast-container {{
315
+ width: 100%;
316
+ height: auto;
317
+ dock: top;
318
+ align: right top;
319
+ layer: toasts;
320
+ }}
228
321
  #status-bar {{ height: 3; padding: 0 1; }}
229
322
  #agent-context {{ min-width: 25; padding-right: 1; }}
230
323
  #{RUNS_LOADING_ID} {{ width: 8; }}
@@ -247,6 +340,7 @@ class RemoteRunsTextualApp(App[None]):
247
340
  *,
248
341
  agent_name: str | None = None,
249
342
  agent_id: str | None = None,
343
+ ctx: TUIContext | None = None,
250
344
  ):
251
345
  """Initialize the remote runs Textual application.
252
346
 
@@ -256,6 +350,7 @@ class RemoteRunsTextualApp(App[None]):
256
350
  callbacks: Callback bundle for data operations.
257
351
  agent_name: Optional agent name for display purposes.
258
352
  agent_id: Optional agent ID for display purposes.
353
+ ctx: Shared TUI context.
259
354
  """
260
355
  super().__init__()
261
356
  self.current_page = initial_page
@@ -265,6 +360,7 @@ class RemoteRunsTextualApp(App[None]):
265
360
  self.current_rows = initial_page.data[:]
266
361
  self.agent_name = (agent_name or "").strip()
267
362
  self.agent_id = (agent_id or "").strip()
363
+ self._ctx = ctx
268
364
  self._active_export_tasks: set[asyncio.Task[None]] = set()
269
365
  self._page_loader_task: asyncio.Task[Any] | None = None
270
366
  self._detail_loader_task: asyncio.Task[Any] | None = None
@@ -273,9 +369,10 @@ class RemoteRunsTextualApp(App[None]):
273
369
  def compose(self) -> ComposeResult:
274
370
  """Build layout."""
275
371
  yield Header()
276
- table = DataTable(id=RUNS_TABLE_ID)
277
- table.cursor_type = "row"
278
- table.add_columns(
372
+ yield Container(Toast(), id="toast-container")
373
+ table = DataTable(id=RUNS_TABLE_ID) # pragma: no cover - mocked in tests
374
+ table.cursor_type = "row" # pragma: no cover - mocked in tests
375
+ table.add_columns( # pragma: no cover - mocked in tests
279
376
  "Run UUID",
280
377
  "Type",
281
378
  "Status",
@@ -292,8 +389,18 @@ class RemoteRunsTextualApp(App[None]):
292
389
  )
293
390
  yield Footer() # pragma: no cover - interactive UI, tested via integration
294
391
 
392
+ def _ensure_toast_bus(self) -> None:
393
+ if self._ctx is None or self._ctx.toasts is not None:
394
+ return
395
+
396
+ def _notify(m: ToastBus.Changed) -> None:
397
+ self.post_message(m)
398
+
399
+ self._ctx.toasts = ToastBus(on_change=_notify)
400
+
295
401
  def on_mount(self) -> None:
296
402
  """Render the initial page."""
403
+ self._ensure_toast_bus()
297
404
  self._hide_loading()
298
405
  self._render_page(self.current_page)
299
406
 
@@ -315,7 +422,7 @@ class RemoteRunsTextualApp(App[None]):
315
422
  if self.current_rows:
316
423
  self.cursor_index = max(0, min(self.cursor_index, len(self.current_rows) - 1))
317
424
  table.focus()
318
- table.cursor_coordinate = (self.cursor_index, 0)
425
+ table.cursor_coordinate = Coordinate(self.cursor_index, 0)
319
426
  self.current_page = runs_page
320
427
  total_pages = max(1, (runs_page.total + runs_page.limit - 1) // runs_page.limit)
321
428
  agent_display = self.agent_name or "Runs"
@@ -554,8 +661,8 @@ class RemoteRunsTextualApp(App[None]):
554
661
  if detail is None:
555
662
  self._update_status("Failed to load run detail.", append=True)
556
663
  return
557
- self.push_screen(RunDetailScreen(detail, on_export=self.queue_export_from_detail))
558
- self._update_status("Detail view: ↑/↓ scroll · PgUp/PgDn · q/Esc close · e export")
664
+ self.push_screen(RunDetailScreen(detail, on_export=self.queue_export_from_detail, ctx=self._ctx))
665
+ self._update_status("Detail view: ↑/↓ scroll · PgUp/PgDn · q/Esc close · c copy ID · C copy JSON · e export")
559
666
 
560
667
  def queue_export_from_detail(self, detail: Any) -> None:
561
668
  """Start an export from the detail modal."""
@@ -0,0 +1,407 @@
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, *, detect_osc11: bool = True) -> 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
+ Args:
84
+ detect_osc11: When False, skip OSC 11 background detection.
85
+
86
+ Returns:
87
+ TerminalCapabilities instance with detected capabilities.
88
+ """
89
+ tty_available = sys.stdout.isatty()
90
+ term = os.environ.get("TERM", "")
91
+ colorterm = os.environ.get("COLORTERM", "")
92
+
93
+ # Basic capability detection
94
+ ansi = tty_available and term not in ("dumb", "")
95
+ osc52 = detect_osc52_support()
96
+ mouse = tty_available and term not in ("dumb", "")
97
+ truecolor = colorterm in ("truecolor", "24bit")
98
+
99
+ osc11_bg: str | None = None
100
+ if detect_osc11 and tty_available and sys.stdin.isatty():
101
+ # OSC 11 detection: use fast path (<100ms timeout)
102
+ osc11_bg = await _detect_osc11_fast()
103
+
104
+ return cls(
105
+ tty=tty_available,
106
+ ansi=ansi,
107
+ osc52=osc52,
108
+ osc11_bg=osc11_bg,
109
+ mouse=mouse,
110
+ truecolor=truecolor,
111
+ )
112
+
113
+
114
+ async def detect_terminal_background() -> str | None:
115
+ """Detect terminal background color using OSC 11 with full timeout.
116
+
117
+ This function can be called separately to await OSC 11 detection with the
118
+ full 1-second timeout. Useful for theme initialization where a slight delay
119
+ is acceptable.
120
+
121
+ Returns:
122
+ Raw RGB color string from terminal, or None if detection fails or times out.
123
+ """
124
+ if not sys.stdout.isatty() or not sys.stdin.isatty():
125
+ return None
126
+
127
+ if not _TERMIOS_AVAILABLE:
128
+ return None
129
+
130
+ return await _detect_osc11_full()
131
+
132
+
133
+ async def _detect_osc11_fast() -> str | None:
134
+ """Fast-path OSC 11 detection (used by detect())."""
135
+ return await _detect_osc11_impl(timeout=0.1)
136
+
137
+
138
+ async def _detect_osc11_full() -> str | None:
139
+ """Full-timeout OSC 11 detection (used by detect_terminal_background())."""
140
+ return await _detect_osc11_impl(timeout=1.0)
141
+
142
+
143
+ def _read_osc11_char_with_timeout(start_time: float, timeout_seconds: float) -> str | None:
144
+ """Read a single character from stdin with timeout.
145
+
146
+ Args:
147
+ start_time: Start time for timeout calculation.
148
+ timeout_seconds: Maximum time to wait.
149
+
150
+ Returns:
151
+ Character read or None on timeout/error.
152
+ """
153
+ elapsed = time.time() - start_time
154
+ if elapsed >= timeout_seconds:
155
+ return None
156
+
157
+ try:
158
+ remaining = timeout_seconds - elapsed
159
+ ready, _, _ = select.select([sys.stdin], [], [], min(0.1, remaining))
160
+ if not ready:
161
+ return None
162
+
163
+ char = sys.stdin.read(1)
164
+ return char if char else None
165
+ except (OSError, ValueError):
166
+ return None
167
+
168
+
169
+ def _check_osc11_complete(response_text: str, response_length: int) -> str | None:
170
+ """Check if OSC 11 response is complete.
171
+
172
+ Args:
173
+ response_text: Current response text.
174
+ response_length: Length of response characters.
175
+
176
+ Returns:
177
+ Matched color string if complete, None otherwise.
178
+ """
179
+ match = _match_osc11_response(response_text)
180
+ if match:
181
+ return match
182
+
183
+ # If we see BEL (\x07) terminator, check one more time then give up
184
+ if "\x07" in response_text and response_length >= 10:
185
+ return None
186
+
187
+ return None
188
+
189
+
190
+ def _read_osc11_response_sync(timeout_seconds: float) -> str | None:
191
+ """Synchronously read OSC 11 response from stdin.
192
+
193
+ This runs in a thread to avoid blocking the event loop.
194
+
195
+ Args:
196
+ timeout_seconds: Maximum time to wait.
197
+
198
+ Returns:
199
+ Color string or None.
200
+ """
201
+ response_chars: list[str] = []
202
+ start_time = time.time()
203
+ max_chars = 200 # Reasonable limit to prevent infinite loops
204
+
205
+ while len(response_chars) < max_chars:
206
+ elapsed = time.time() - start_time
207
+ if elapsed >= timeout_seconds:
208
+ return None
209
+
210
+ char = _read_osc11_char_with_timeout(start_time, timeout_seconds)
211
+ if char is None:
212
+ # Check timeout again after failed read
213
+ if time.time() - start_time >= timeout_seconds:
214
+ return None
215
+ continue
216
+
217
+ response_chars.append(char)
218
+ response_text = "".join(response_chars)
219
+
220
+ result = _check_osc11_complete(response_text, len(response_chars))
221
+ if result is not None:
222
+ return result
223
+
224
+ return None
225
+
226
+
227
+ async def _detect_osc11_impl(timeout: float) -> str | None:
228
+ """Internal OSC 11 detection implementation.
229
+
230
+ Args:
231
+ timeout: Maximum time to wait for terminal response in seconds.
232
+
233
+ Returns:
234
+ Raw RGB color string, or None on timeout/error.
235
+ """
236
+ if not _TERMIOS_AVAILABLE:
237
+ return None
238
+
239
+ old_settings = None
240
+ try:
241
+ # Save terminal settings
242
+ old_settings = termios.tcgetattr(sys.stdin)
243
+ tty.setraw(sys.stdin.fileno())
244
+
245
+ # Send OSC 11 query
246
+ sys.stdout.write("\x1b]11;?\x07")
247
+ sys.stdout.flush()
248
+
249
+ # Read response in a thread to avoid blocking
250
+ try:
251
+ result = await asyncio.wait_for(asyncio.to_thread(_read_osc11_response_sync, timeout), timeout=timeout)
252
+ return result
253
+ except TimeoutError:
254
+ return None
255
+
256
+ except Exception:
257
+ return None
258
+ finally:
259
+ # Restore terminal settings
260
+ if old_settings is not None:
261
+ try:
262
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
263
+ except Exception:
264
+ pass
265
+
266
+
267
+ def _match_osc11_response(text: str) -> str | None:
268
+ """Extract OSC 11 color response from text.
269
+
270
+ Args:
271
+ text: Raw text from stdin.
272
+
273
+ Returns:
274
+ Color string (e.g., "rgb:RRRR/GGGG/BBBB") or None if not found.
275
+ """
276
+ # Match OSC 11 response: \x1b]11;...\x07
277
+ match = re.search(r"\x1b\]11;([^\x07\x1b]+)", text)
278
+ if match:
279
+ return match.group(1)
280
+ return None
281
+
282
+
283
+ def _parse_color_response(color_str: str) -> tuple[int, int, int] | None:
284
+ """Parse RGB color from various terminal color formats.
285
+
286
+ Supports:
287
+ - rgb:RRRR/GGGG/BBBB (16-bit per channel)
288
+ - rgb:RR/GG/BB (8-bit per channel)
289
+ - #RRGGBB (hex)
290
+ - rgb(R,G,B) (decimal)
291
+
292
+ Args:
293
+ color_str: Color string from terminal.
294
+
295
+ Returns:
296
+ Tuple of (R, G, B) values in 0-255 range, or None if parsing fails.
297
+ """
298
+ if not color_str:
299
+ return None
300
+
301
+ try:
302
+ if color_str.startswith("rgb:"):
303
+ # Format: rgb:RRRR/GGGG/BBBB (16-bit) or rgb:RR/GG/BB (8-bit)
304
+ parts = color_str[4:].split("/")
305
+ if len(parts) == 3:
306
+ r_val = int(parts[0], 16)
307
+ g_val = int(parts[1], 16)
308
+ b_val = int(parts[2], 16)
309
+
310
+ # Convert 16-bit to 8-bit: if hex string has 4 digits, it's 16-bit
311
+ # and we take the high byte (>> 8). If 2 digits, it's already 8-bit.
312
+ if len(parts[0]) == 4: # 16-bit format
313
+ r_val = r_val >> 8
314
+ if len(parts[1]) == 4: # 16-bit format
315
+ g_val = g_val >> 8
316
+ if len(parts[2]) == 4: # 16-bit format
317
+ b_val = b_val >> 8
318
+
319
+ return (r_val, g_val, b_val)
320
+
321
+ elif color_str.startswith("#"):
322
+ # Format: #RRGGBB
323
+ if len(color_str) == 7:
324
+ r = int(color_str[1:3], 16)
325
+ g = int(color_str[3:5], 16)
326
+ b = int(color_str[5:7], 16)
327
+ return (r, g, b)
328
+
329
+ elif color_str.startswith("rgb("):
330
+ # Format: rgb(R,G,B)
331
+ parts = color_str[4:-1].split(",")
332
+ if len(parts) == 3:
333
+ r = int(parts[0].strip())
334
+ g = int(parts[1].strip())
335
+ b = int(parts[2].strip())
336
+ return (r, g, b)
337
+
338
+ except (ValueError, IndexError):
339
+ pass
340
+
341
+ return None
342
+
343
+
344
+ def _calculate_luminance(r: int, g: int, b: int) -> float:
345
+ """Calculate relative luminance from RGB values.
346
+
347
+ Uses the relative luminance formula from WCAG:
348
+ L = 0.299*R + 0.587*G + 0.114*B
349
+
350
+ Args:
351
+ r: Red component (0-255).
352
+ g: Green component (0-255).
353
+ b: Blue component (0-255).
354
+
355
+ Returns:
356
+ Luminance value normalized to 0.0-1.0 range.
357
+ """
358
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0
359
+
360
+
361
+ def _check_terminal_in_env(env_value: str, terminals: list[str]) -> bool:
362
+ """Check if any terminal name appears in environment value.
363
+
364
+ Args:
365
+ env_value: Environment variable value to check.
366
+ terminals: List of terminal names to search for.
367
+
368
+ Returns:
369
+ True if any terminal name is found in env_value.
370
+ """
371
+ return any(terminal in env_value for terminal in terminals)
372
+
373
+
374
+ def detect_osc52_support() -> bool:
375
+ """Check if terminal likely supports OSC 52 (clipboard).
376
+
377
+ Returns:
378
+ True if terminal name suggests OSC 52 support.
379
+ """
380
+ term = os.environ.get("TERM", "").lower()
381
+ term_program = os.environ.get("TERM_PROGRAM", "").lower()
382
+ term_program_version = os.environ.get("TERM_PROGRAM_VERSION", "").lower()
383
+
384
+ # Known terminals that support OSC 52
385
+ osc52_terminals = [
386
+ "iterm",
387
+ "kitty",
388
+ "alacritty",
389
+ "wezterm",
390
+ "vscode",
391
+ "windows terminal",
392
+ "mintty", # Windows terminal emulator
393
+ ]
394
+
395
+ # Check TERM_PROGRAM first (most reliable)
396
+ if term_program and _check_terminal_in_env(term_program, osc52_terminals):
397
+ return True
398
+
399
+ # Check TERM_PROGRAM_VERSION (VS Code uses this)
400
+ if term_program_version and _check_terminal_in_env(term_program_version, osc52_terminals):
401
+ return True
402
+
403
+ # Check TERM (less reliable but sometimes works)
404
+ if term and _check_terminal_in_env(term, osc52_terminals):
405
+ return True
406
+
407
+ 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
+ ]