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.
- glaip_sdk/__init__.py +42 -5
- glaip_sdk/agents/base.py +217 -42
- glaip_sdk/branding.py +113 -2
- glaip_sdk/cli/account_store.py +15 -0
- glaip_sdk/cli/auth.py +14 -8
- glaip_sdk/cli/commands/accounts.py +1 -1
- glaip_sdk/cli/commands/agents/__init__.py +119 -0
- glaip_sdk/cli/commands/agents/_common.py +561 -0
- glaip_sdk/cli/commands/agents/create.py +151 -0
- glaip_sdk/cli/commands/agents/delete.py +64 -0
- glaip_sdk/cli/commands/agents/get.py +89 -0
- glaip_sdk/cli/commands/agents/list.py +129 -0
- glaip_sdk/cli/commands/agents/run.py +264 -0
- glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
- glaip_sdk/cli/commands/agents/update.py +112 -0
- glaip_sdk/cli/commands/common_config.py +15 -12
- glaip_sdk/cli/commands/configure.py +2 -3
- glaip_sdk/cli/commands/mcps/__init__.py +94 -0
- glaip_sdk/cli/commands/mcps/_common.py +459 -0
- glaip_sdk/cli/commands/mcps/connect.py +82 -0
- glaip_sdk/cli/commands/mcps/create.py +152 -0
- glaip_sdk/cli/commands/mcps/delete.py +73 -0
- glaip_sdk/cli/commands/mcps/get.py +212 -0
- glaip_sdk/cli/commands/mcps/list.py +69 -0
- glaip_sdk/cli/commands/mcps/tools.py +235 -0
- glaip_sdk/cli/commands/mcps/update.py +190 -0
- glaip_sdk/cli/commands/models.py +2 -4
- glaip_sdk/cli/commands/shared/__init__.py +21 -0
- glaip_sdk/cli/commands/shared/formatters.py +91 -0
- glaip_sdk/cli/commands/tools/__init__.py +69 -0
- glaip_sdk/cli/commands/tools/_common.py +80 -0
- glaip_sdk/cli/commands/tools/create.py +228 -0
- glaip_sdk/cli/commands/tools/delete.py +61 -0
- glaip_sdk/cli/commands/tools/get.py +103 -0
- glaip_sdk/cli/commands/tools/list.py +69 -0
- glaip_sdk/cli/commands/tools/script.py +49 -0
- glaip_sdk/cli/commands/tools/update.py +102 -0
- glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
- glaip_sdk/cli/commands/transcripts/_common.py +9 -0
- glaip_sdk/cli/commands/transcripts/clear.py +5 -0
- glaip_sdk/cli/commands/transcripts/detail.py +5 -0
- glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
- glaip_sdk/cli/commands/update.py +163 -17
- glaip_sdk/cli/config.py +1 -0
- glaip_sdk/cli/core/output.py +12 -7
- glaip_sdk/cli/entrypoint.py +20 -0
- glaip_sdk/cli/main.py +127 -39
- glaip_sdk/cli/pager.py +3 -3
- glaip_sdk/cli/resolution.py +2 -1
- glaip_sdk/cli/slash/accounts_controller.py +112 -32
- glaip_sdk/cli/slash/agent_session.py +5 -2
- glaip_sdk/cli/slash/prompt.py +11 -0
- glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
- glaip_sdk/cli/slash/session.py +369 -23
- glaip_sdk/cli/slash/tui/__init__.py +26 -1
- glaip_sdk/cli/slash/tui/accounts.tcss +79 -5
- glaip_sdk/cli/slash/tui/accounts_app.py +1027 -88
- glaip_sdk/cli/slash/tui/clipboard.py +195 -0
- glaip_sdk/cli/slash/tui/context.py +87 -0
- glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
- glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
- glaip_sdk/cli/slash/tui/layouts/harlequin.py +160 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
- glaip_sdk/cli/slash/tui/terminal.py +407 -0
- glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
- glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
- glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
- glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
- glaip_sdk/cli/slash/tui/toast.py +374 -0
- glaip_sdk/cli/transcript/history.py +1 -1
- glaip_sdk/cli/transcript/viewer.py +5 -3
- glaip_sdk/cli/tui_settings.py +125 -0
- glaip_sdk/cli/update_notifier.py +215 -7
- glaip_sdk/cli/validators.py +1 -1
- glaip_sdk/client/__init__.py +2 -1
- glaip_sdk/client/_schedule_payloads.py +89 -0
- glaip_sdk/client/agents.py +50 -8
- glaip_sdk/client/hitl.py +136 -0
- glaip_sdk/client/main.py +7 -1
- glaip_sdk/client/mcps.py +44 -13
- glaip_sdk/client/payloads/agent/__init__.py +23 -0
- glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +22 -47
- glaip_sdk/client/payloads/agent/responses.py +43 -0
- glaip_sdk/client/run_rendering.py +414 -3
- glaip_sdk/client/schedules.py +439 -0
- glaip_sdk/client/tools.py +57 -26
- glaip_sdk/guardrails/__init__.py +80 -0
- glaip_sdk/guardrails/serializer.py +89 -0
- glaip_sdk/hitl/__init__.py +48 -0
- glaip_sdk/hitl/base.py +64 -0
- glaip_sdk/hitl/callback.py +43 -0
- glaip_sdk/hitl/local.py +121 -0
- glaip_sdk/hitl/remote.py +523 -0
- glaip_sdk/models/__init__.py +17 -0
- glaip_sdk/models/agent_runs.py +2 -1
- glaip_sdk/models/schedule.py +224 -0
- glaip_sdk/payload_schemas/agent.py +1 -0
- glaip_sdk/payload_schemas/guardrails.py +34 -0
- glaip_sdk/registry/tool.py +273 -59
- glaip_sdk/runner/__init__.py +20 -3
- glaip_sdk/runner/deps.py +5 -8
- glaip_sdk/runner/langgraph.py +318 -42
- glaip_sdk/runner/logging_config.py +77 -0
- glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +104 -5
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +72 -7
- glaip_sdk/schedules/__init__.py +22 -0
- glaip_sdk/schedules/base.py +291 -0
- glaip_sdk/tools/base.py +67 -14
- glaip_sdk/utils/__init__.py +1 -0
- glaip_sdk/utils/bundler.py +138 -2
- glaip_sdk/utils/import_resolver.py +43 -11
- glaip_sdk/utils/rendering/renderer/base.py +58 -0
- glaip_sdk/utils/runtime_config.py +15 -12
- glaip_sdk/utils/sync.py +31 -11
- glaip_sdk/utils/tool_detection.py +274 -6
- glaip_sdk/utils/tool_storage_provider.py +140 -0
- {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/METADATA +49 -37
- glaip_sdk-0.7.12.dist-info/RECORD +219 -0
- {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/WHEEL +2 -1
- glaip_sdk-0.7.12.dist-info/entry_points.txt +2 -0
- glaip_sdk-0.7.12.dist-info/top_level.txt +1 -0
- glaip_sdk/cli/commands/agents.py +0 -1509
- glaip_sdk/cli/commands/mcps.py +0 -1356
- glaip_sdk/cli/commands/tools.py +0 -576
- glaip_sdk/cli/utils.py +0 -263
- glaip_sdk-0.6.5b6.dist-info/RECORD +0 -159
- 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__(
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
table
|
|
278
|
-
table.
|
|
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
|
+
]
|