glaip-sdk 0.6.5b3__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 (145) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. glaip_sdk/agents/base.py +362 -39
  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 +116 -0
  8. glaip_sdk/cli/commands/agents/_common.py +562 -0
  9. glaip_sdk/cli/commands/agents/create.py +155 -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 +375 -25
  55. glaip_sdk/cli/slash/tui/__init__.py +28 -1
  56. glaip_sdk/cli/slash/tui/accounts.tcss +97 -6
  57. glaip_sdk/cli/slash/tui/accounts_app.py +1107 -126
  58. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  59. glaip_sdk/cli/slash/tui/context.py +92 -0
  60. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  61. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  62. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  63. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  64. glaip_sdk/cli/slash/tui/loading.py +43 -21
  65. glaip_sdk/cli/slash/tui/remote_runs_app.py +152 -20
  66. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  67. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  68. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  69. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  70. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  71. glaip_sdk/cli/slash/tui/toast.py +388 -0
  72. glaip_sdk/cli/transcript/history.py +1 -1
  73. glaip_sdk/cli/transcript/viewer.py +5 -3
  74. glaip_sdk/cli/tui_settings.py +125 -0
  75. glaip_sdk/cli/update_notifier.py +215 -7
  76. glaip_sdk/cli/validators.py +1 -1
  77. glaip_sdk/client/__init__.py +2 -1
  78. glaip_sdk/client/_schedule_payloads.py +89 -0
  79. glaip_sdk/client/agents.py +290 -16
  80. glaip_sdk/client/base.py +25 -0
  81. glaip_sdk/client/hitl.py +136 -0
  82. glaip_sdk/client/main.py +7 -5
  83. glaip_sdk/client/mcps.py +44 -13
  84. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  85. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +28 -48
  86. glaip_sdk/client/payloads/agent/responses.py +43 -0
  87. glaip_sdk/client/run_rendering.py +414 -3
  88. glaip_sdk/client/schedules.py +439 -0
  89. glaip_sdk/client/tools.py +57 -26
  90. glaip_sdk/config/constants.py +22 -2
  91. glaip_sdk/guardrails/__init__.py +80 -0
  92. glaip_sdk/guardrails/serializer.py +89 -0
  93. glaip_sdk/hitl/__init__.py +48 -0
  94. glaip_sdk/hitl/base.py +64 -0
  95. glaip_sdk/hitl/callback.py +43 -0
  96. glaip_sdk/hitl/local.py +121 -0
  97. glaip_sdk/hitl/remote.py +523 -0
  98. glaip_sdk/models/__init__.py +47 -1
  99. glaip_sdk/models/_provider_mappings.py +101 -0
  100. glaip_sdk/models/_validation.py +97 -0
  101. glaip_sdk/models/agent.py +2 -1
  102. glaip_sdk/models/agent_runs.py +2 -1
  103. glaip_sdk/models/constants.py +141 -0
  104. glaip_sdk/models/model.py +170 -0
  105. glaip_sdk/models/schedule.py +224 -0
  106. glaip_sdk/payload_schemas/agent.py +1 -0
  107. glaip_sdk/payload_schemas/guardrails.py +34 -0
  108. glaip_sdk/registry/tool.py +273 -66
  109. glaip_sdk/runner/__init__.py +76 -0
  110. glaip_sdk/runner/base.py +84 -0
  111. glaip_sdk/runner/deps.py +115 -0
  112. glaip_sdk/runner/langgraph.py +1055 -0
  113. glaip_sdk/runner/logging_config.py +77 -0
  114. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  115. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  116. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  117. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +116 -0
  118. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  119. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  120. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  121. glaip_sdk/schedules/__init__.py +22 -0
  122. glaip_sdk/schedules/base.py +291 -0
  123. glaip_sdk/tools/base.py +67 -14
  124. glaip_sdk/utils/__init__.py +1 -0
  125. glaip_sdk/utils/a2a/__init__.py +34 -0
  126. glaip_sdk/utils/a2a/event_processor.py +188 -0
  127. glaip_sdk/utils/agent_config.py +8 -2
  128. glaip_sdk/utils/bundler.py +138 -2
  129. glaip_sdk/utils/import_resolver.py +43 -11
  130. glaip_sdk/utils/rendering/renderer/base.py +58 -0
  131. glaip_sdk/utils/runtime_config.py +120 -0
  132. glaip_sdk/utils/sync.py +31 -11
  133. glaip_sdk/utils/tool_detection.py +301 -0
  134. glaip_sdk/utils/tool_storage_provider.py +140 -0
  135. {glaip_sdk-0.6.5b3.dist-info → glaip_sdk-0.7.17.dist-info}/METADATA +49 -38
  136. glaip_sdk-0.7.17.dist-info/RECORD +224 -0
  137. {glaip_sdk-0.6.5b3.dist-info → glaip_sdk-0.7.17.dist-info}/WHEEL +2 -1
  138. glaip_sdk-0.7.17.dist-info/entry_points.txt +2 -0
  139. glaip_sdk-0.7.17.dist-info/top_level.txt +1 -0
  140. glaip_sdk/cli/commands/agents.py +0 -1509
  141. glaip_sdk/cli/commands/mcps.py +0 -1356
  142. glaip_sdk/cli/commands/tools.py +0 -576
  143. glaip_sdk/cli/utils.py +0 -263
  144. glaip_sdk-0.6.5b3.dist-info/RECORD +0 -145
  145. glaip_sdk-0.6.5b3.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,388 @@
1
+ """Toast widgets and state management for the TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from typing import Any, cast
10
+
11
+ from rich.text import Text
12
+ from textual.message import Message
13
+ from textual.widget import Widget
14
+ from textual.widgets import Static
15
+
16
+
17
+ class ToastVariant(str, Enum):
18
+ """Toast message variant for styling and behavior."""
19
+
20
+ INFO = "info"
21
+ SUCCESS = "success"
22
+ WARNING = "warning"
23
+ ERROR = "error"
24
+
25
+
26
+ DEFAULT_TOAST_DURATIONS_SECONDS: dict[ToastVariant, float] = {
27
+ ToastVariant.SUCCESS: 2.0,
28
+ ToastVariant.INFO: 3.0,
29
+ ToastVariant.WARNING: 3.0,
30
+ ToastVariant.ERROR: 5.0,
31
+ }
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ToastState:
36
+ """Immutable toast notification state."""
37
+
38
+ message: str
39
+ variant: ToastVariant
40
+ duration_seconds: float
41
+
42
+
43
+ class ToastBus:
44
+ """Toast state manager with auto-dismiss functionality."""
45
+
46
+ class Changed(Message):
47
+ """Message sent when toast state changes."""
48
+
49
+ def __init__(self, state: ToastState | None) -> None:
50
+ """Initialize the changed message with new toast state."""
51
+ super().__init__()
52
+ self.state = state
53
+
54
+ def __init__(self, on_change: Callable[[ToastBus.Changed], None] | None = None) -> None:
55
+ """Initialize the toast bus with optional change callback."""
56
+ self._state: ToastState | None = None
57
+ self._dismiss_task: asyncio.Task[None] | None = None
58
+ self._on_change = on_change
59
+
60
+ @property
61
+ def state(self) -> ToastState | None:
62
+ """Return the current toast state, or None if no toast is shown."""
63
+ return self._state
64
+
65
+ def show(
66
+ self,
67
+ message: str,
68
+ variant: ToastVariant | str = ToastVariant.INFO,
69
+ *,
70
+ duration_seconds: float | None = None,
71
+ ) -> None:
72
+ """Show a toast notification with the given message and variant.
73
+
74
+ Args:
75
+ message: The message to display in the toast.
76
+ variant: The visual variant of the toast (INFO, SUCCESS, WARNING, ERROR).
77
+ duration_seconds: Optional custom duration in seconds. If None, uses default
78
+ duration for the variant (2s for SUCCESS, 3s for INFO/WARNING, 5s for ERROR).
79
+ """
80
+ resolved_variant = self._coerce_variant(variant)
81
+ resolved_duration = (
82
+ DEFAULT_TOAST_DURATIONS_SECONDS[resolved_variant] if duration_seconds is None else float(duration_seconds)
83
+ )
84
+
85
+ self._state = ToastState(
86
+ message=message,
87
+ variant=resolved_variant,
88
+ duration_seconds=resolved_duration,
89
+ )
90
+
91
+ self._cancel_dismiss_task()
92
+
93
+ try:
94
+ loop = asyncio.get_running_loop()
95
+ except RuntimeError:
96
+ raise RuntimeError(
97
+ "Cannot schedule toast auto-dismiss: no running event loop. "
98
+ "ToastBus.show() must be called from within an async context."
99
+ ) from None
100
+
101
+ self._dismiss_task = loop.create_task(self._auto_dismiss(resolved_duration))
102
+ self._notify_changed()
103
+
104
+ def clear(self) -> None:
105
+ """Clear the current toast notification immediately."""
106
+ self._cancel_dismiss_task()
107
+ self._state = None
108
+ self._notify_changed()
109
+
110
+ def copy_success(self, label: str | None = None) -> None:
111
+ """Show a success toast for clipboard copy operations.
112
+
113
+ Args:
114
+ label: Optional label for what was copied (e.g., "Run ID", "JSON").
115
+ """
116
+ message = "Copied to clipboard" if not label else f"Copied {label} to clipboard"
117
+ self.show(message=message, variant=ToastVariant.SUCCESS)
118
+
119
+ def copy_failed(self) -> None:
120
+ """Show a warning toast when clipboard copy fails."""
121
+ self.show(message="Clipboard unavailable. Text printed below.", variant=ToastVariant.WARNING)
122
+
123
+ def _coerce_variant(self, variant: ToastVariant | str) -> ToastVariant:
124
+ if isinstance(variant, ToastVariant):
125
+ return variant
126
+ try:
127
+ return ToastVariant(variant)
128
+ except ValueError:
129
+ return ToastVariant.INFO
130
+
131
+ def _cancel_dismiss_task(self) -> None:
132
+ if self._dismiss_task is None:
133
+ return
134
+ if not self._dismiss_task.done():
135
+ self._dismiss_task.cancel()
136
+ self._dismiss_task = None
137
+
138
+ async def _auto_dismiss(self, duration_seconds: float) -> None:
139
+ try:
140
+ await asyncio.sleep(duration_seconds)
141
+ except asyncio.CancelledError:
142
+ return
143
+
144
+ self._state = None
145
+ self._dismiss_task = None
146
+ self._notify_changed()
147
+
148
+ def _notify_changed(self) -> None:
149
+ if self._on_change:
150
+ self._on_change(ToastBus.Changed(self._state))
151
+
152
+
153
+ class ToastHandlerMixin:
154
+ """Mixin providing common toast handling functionality.
155
+
156
+ Classes that inherit from this mixin can handle ToastBus.Changed messages
157
+ by automatically updating all Toast widgets in the component tree.
158
+ """
159
+
160
+ def on_toast_bus_changed(self, message: ToastBus.Changed) -> None:
161
+ """Refresh the toast widget when the toast bus updates.
162
+
163
+ Args:
164
+ message: The toast bus changed message containing the new state.
165
+ """
166
+ try:
167
+ for toast in self.query(Toast):
168
+ toast.update_state(message.state)
169
+ except Exception:
170
+ pass
171
+
172
+
173
+ class ClipboardToastMixin:
174
+ """Mixin providing clipboard and toast orchestration functionality.
175
+
176
+ Classes that inherit from this mixin get shared clipboard adapter selection,
177
+ OSC52 writer setup, toast bus lookup, and copy-success/failure orchestration.
178
+ This consolidates duplicate clipboard/toast logic across TUI apps.
179
+
180
+ Expected attributes:
181
+ _ctx: TUIContext | None - Shared TUI context (optional)
182
+ _clipboard: ClipboardAdapter | None - Cached clipboard adapter (optional)
183
+ _local_toasts: ToastBus | None - Local toast bus instance (optional)
184
+ """
185
+
186
+ def _clipboard_adapter(self) -> Any: # ClipboardAdapter
187
+ """Get or create a clipboard adapter instance.
188
+
189
+ Returns:
190
+ ClipboardAdapter instance, preferring context's adapter if available.
191
+ """
192
+ # Import here to avoid circular dependency
193
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter # noqa: PLC0415
194
+
195
+ ctx = getattr(self, "_ctx", None)
196
+ clipboard = getattr(self, "_clipboard", None)
197
+
198
+ if ctx is not None and ctx.clipboard is not None:
199
+ return cast(ClipboardAdapter, ctx.clipboard)
200
+ if clipboard is not None:
201
+ return clipboard
202
+
203
+ adapter = ClipboardAdapter(terminal=ctx.terminal if ctx else None)
204
+ if ctx is not None:
205
+ ctx.clipboard = adapter
206
+ else:
207
+ self._clipboard = adapter
208
+ return adapter
209
+
210
+ def _osc52_writer(self) -> Callable[[str], Any] | None:
211
+ """Get an OSC52 writer function if console output is available.
212
+
213
+ Returns:
214
+ Writer function that writes OSC52 sequences to console output, or None.
215
+ """
216
+ try:
217
+ # Try self.app.console first (for Screen subclasses)
218
+ if hasattr(self, "app") and hasattr(self.app, "console"):
219
+ console = self.app.console
220
+ # Fall back to self.console (for App subclasses)
221
+ else:
222
+ console = getattr(self, "console", None)
223
+ except Exception:
224
+ return None
225
+
226
+ if console is None:
227
+ return None
228
+
229
+ output = getattr(console, "file", None)
230
+ if output is None:
231
+ return None
232
+
233
+ def _write(sequence: str, _output: Any = output) -> None:
234
+ _output.write(sequence)
235
+ _output.flush()
236
+
237
+ return _write
238
+
239
+ def _toast_bus(self) -> ToastBus | None:
240
+ """Get the toast bus instance.
241
+
242
+ Returns:
243
+ ToastBus instance, preferring context's bus if available, or None.
244
+ """
245
+ local_toasts = getattr(self, "_local_toasts", None)
246
+ ctx = getattr(self, "_ctx", None)
247
+
248
+ if local_toasts is not None:
249
+ return local_toasts
250
+ if ctx is not None and ctx.toasts is not None:
251
+ return ctx.toasts
252
+ return None
253
+
254
+ def _copy_to_clipboard(self, text: str, *, label: str | None = None) -> None:
255
+ """Copy text to clipboard and show toast notification.
256
+
257
+ Args:
258
+ text: The text to copy to clipboard.
259
+ label: Optional label for what was copied (e.g., "Run ID", "JSON").
260
+ """
261
+ adapter = self._clipboard_adapter()
262
+ writer = self._osc52_writer()
263
+ if writer:
264
+ result = adapter.copy(text, writer=writer)
265
+ else:
266
+ result = adapter.copy(text)
267
+
268
+ toasts = self._toast_bus()
269
+ if result.success:
270
+ if toasts:
271
+ toasts.copy_success(label)
272
+ else:
273
+ # Fallback to status announcement if toast bus unavailable
274
+ if hasattr(self, "_announce_status"):
275
+ if label:
276
+ self._announce_status(f"Copied {label} to clipboard.")
277
+ else:
278
+ self._announce_status("Copied to clipboard.")
279
+ return
280
+
281
+ # Copy failed
282
+ if toasts:
283
+ toasts.copy_failed()
284
+ else:
285
+ # Fallback to status announcement if toast bus unavailable
286
+ if hasattr(self, "_announce_status"):
287
+ self._announce_status("Clipboard unavailable. Text printed below.")
288
+
289
+ # Append fallback text output
290
+ if hasattr(self, "_append_copy_fallback"):
291
+ self._append_copy_fallback(text)
292
+
293
+
294
+ class ToastContainer(Widget):
295
+ """Simple wrapper for docking toast widgets without relying on containers.
296
+
297
+ This class exists to provide a lightweight widget wrapper for toast containers
298
+ that avoids direct dependency on Textual's Container class. It allows the toast
299
+ system to work consistently across different Textual versions and provides a
300
+ stable API for toast container composition.
301
+
302
+ Usage:
303
+ yield ToastContainer(Toast(), id="toast-container")
304
+ """
305
+
306
+
307
+ class Toast(Static):
308
+ """A Textual widget that displays toast notifications at the top-right of the screen.
309
+
310
+ The Toast widget is updated via `update_state()` calls from message handlers
311
+ (e.g., `on_toast_bus_changed`). The widget does not auto-subscribe to ToastBus
312
+ state changes; the app must call `update_state()` when toast state changes.
313
+ """
314
+
315
+ DEFAULT_CSS = """
316
+ #toast-container {
317
+ width: 100%;
318
+ height: auto;
319
+ dock: top;
320
+ align: right top;
321
+ }
322
+
323
+ Toast {
324
+ width: auto;
325
+ min-width: 20;
326
+ max-width: 40;
327
+ height: auto;
328
+ padding: 0 1;
329
+ margin: 1 2;
330
+ background: $surface;
331
+ color: $text;
332
+ border: solid $primary;
333
+ display: none;
334
+ }
335
+
336
+ Toast.visible {
337
+ display: block;
338
+ }
339
+
340
+ Toast.info {
341
+ border: solid $accent;
342
+ }
343
+
344
+ Toast.success {
345
+ border: solid $success;
346
+ }
347
+
348
+ Toast.warning {
349
+ border: solid $warning;
350
+ }
351
+
352
+ Toast.error {
353
+ border: solid $error;
354
+ }
355
+ """
356
+
357
+ def __init__(self) -> None:
358
+ """Initialize the Toast widget.
359
+
360
+ The widget is updated via `update_state()` calls from message handlers
361
+ (e.g., `on_toast_bus_changed`). The widget does not auto-subscribe to
362
+ a ToastBus; the app must call `update_state()` when toast state changes.
363
+ """
364
+ super().__init__("")
365
+
366
+ def update_state(self, state: ToastState | None) -> None:
367
+ """Update the toast display based on the provided state.
368
+
369
+ Args:
370
+ state: The toast state to display, or None to hide the toast.
371
+ """
372
+ if not state:
373
+ self.remove_class("visible")
374
+ return
375
+
376
+ icon = "ℹ️"
377
+ if state.variant == ToastVariant.SUCCESS:
378
+ icon = "✅"
379
+ elif state.variant == ToastVariant.WARNING:
380
+ icon = "⚠️"
381
+ elif state.variant == ToastVariant.ERROR:
382
+ icon = "❌"
383
+
384
+ self.update(Text.assemble((f"{icon} ", "bold"), state.message))
385
+
386
+ self.remove_class("info", "success", "warning", "error")
387
+ self.add_class(state.variant.value)
388
+ self.add_class("visible")
@@ -23,7 +23,7 @@ from glaip_sdk.cli.transcript.cache import ( # Reuse helpers even if marked pri
23
23
  transcript_path_candidates,
24
24
  write_manifest,
25
25
  )
26
- from glaip_sdk.cli.utils import parse_json_line
26
+ from glaip_sdk.cli.core.output import parse_json_line
27
27
  from glaip_sdk.utils.datetime_helpers import coerce_datetime
28
28
 
29
29
  DEFAULT_HISTORY_LIMIT = 10
@@ -21,8 +21,9 @@ except Exception: # pragma: no cover - optional dependency
21
21
  Choice = None # type: ignore[assignment]
22
22
 
23
23
  from glaip_sdk.cli.transcript.cache import suggest_filename
24
- from glaip_sdk.cli.utils import prompt_export_choice_questionary, questionary_safe_ask
24
+ from glaip_sdk.cli.core.prompting import prompt_export_choice_questionary, questionary_safe_ask
25
25
  from glaip_sdk.utils.rendering.layout.progress import is_delegation_tool
26
+ from glaip_sdk.utils.rendering.layout.transcript import DEFAULT_TRANSCRIPT_THEME
26
27
  from glaip_sdk.utils.rendering.viewer import (
27
28
  ViewerContext as PresenterViewerContext,
28
29
  prepare_viewer_snapshot as presenter_prepare_viewer_snapshot,
@@ -93,8 +94,9 @@ class PostRunViewer: # pragma: no cover - interactive flows are not unit tested
93
94
  if self._view_mode == "default":
94
95
  presenter_render_post_run_view(self.console, self.ctx)
95
96
  else:
96
- snapshot, state = presenter_prepare_viewer_snapshot(self.ctx, glyphs=None)
97
- presenter_render_transcript_view(self.console, snapshot)
97
+ theme = DEFAULT_TRANSCRIPT_THEME
98
+ snapshot, state = presenter_prepare_viewer_snapshot(self.ctx, glyphs=None, theme=theme)
99
+ presenter_render_transcript_view(self.console, snapshot, theme=theme)
98
100
  presenter_render_transcript_events(self.console, state.events)
99
101
 
100
102
  # ------------------------------------------------------------------
@@ -0,0 +1,125 @@
1
+ """Typed loader/saver for TUI preferences stored in config.yaml.
2
+
3
+ This module implements the TUI preferences store as defined in
4
+ `specs/architecture/tui-preferences-store/spec.md`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Literal, cast
11
+
12
+ from glaip_sdk.cli.account_store import AccountStore, get_account_store
13
+
14
+ ThemeModeValue = Literal["auto", "light", "dark"]
15
+
16
+ _DEFAULT_THEME_MODE: ThemeModeValue = "auto"
17
+ _DEFAULT_LEADER = "ctrl+x"
18
+ _DEFAULT_MOUSE_CAPTURE = False
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class TUISettings:
23
+ """Resolved TUI preferences from config.yaml."""
24
+
25
+ theme_mode: ThemeModeValue = _DEFAULT_THEME_MODE
26
+ theme_name: str | None = None
27
+ leader: str = _DEFAULT_LEADER
28
+ keybind_overrides: dict[str, str] = field(default_factory=dict)
29
+ mouse_capture: bool = _DEFAULT_MOUSE_CAPTURE
30
+
31
+
32
+ def load_tui_settings(*, store: AccountStore | None = None) -> TUISettings:
33
+ """Load TUI preferences from the CLI config file."""
34
+ store = store or get_account_store()
35
+ config = store.load_config()
36
+
37
+ tui = _as_dict(config.get("tui"))
38
+ theme = _as_dict(tui.get("theme"))
39
+ mode = _coerce_theme_mode(theme.get("mode"))
40
+ name = _normalize_theme_name(theme.get("name"))
41
+
42
+ keybinds = _as_dict(tui.get("keybinds"))
43
+ leader = keybinds.get("leader")
44
+ if not isinstance(leader, str) or not leader.strip():
45
+ leader = _DEFAULT_LEADER
46
+
47
+ overrides = _coerce_keybind_overrides(keybinds.get("overrides"))
48
+
49
+ mouse_capture = tui.get("mouse_capture")
50
+ if not isinstance(mouse_capture, bool):
51
+ mouse_capture = _DEFAULT_MOUSE_CAPTURE
52
+
53
+ return TUISettings(
54
+ theme_mode=mode,
55
+ theme_name=name,
56
+ leader=leader,
57
+ keybind_overrides=overrides,
58
+ mouse_capture=mouse_capture,
59
+ )
60
+
61
+
62
+ def update_tui_settings(patch: dict[str, Any], *, store: AccountStore | None = None) -> None:
63
+ """Update TUI preferences, preserving unrelated config keys."""
64
+ store = store or get_account_store()
65
+ config = store.load_config()
66
+
67
+ existing = _as_dict(config.get("tui"))
68
+ config["tui"] = _merge_dict(existing, patch)
69
+
70
+ store.save_config_updates(config)
71
+
72
+
73
+ def persist_tui_theme(*, mode: ThemeModeValue, name: str | None, store: AccountStore | None = None) -> None:
74
+ """Persist theme preferences in the tui.theme namespace."""
75
+ update_tui_settings(
76
+ {"theme": {"mode": _coerce_theme_mode(mode), "name": _serialize_theme_name(name)}},
77
+ store=store,
78
+ )
79
+
80
+
81
+ def _as_dict(value: Any) -> dict[str, Any]:
82
+ if isinstance(value, dict):
83
+ return dict(value)
84
+ return {}
85
+
86
+
87
+ def _coerce_theme_mode(value: Any) -> ThemeModeValue:
88
+ if isinstance(value, str):
89
+ lowered = value.strip().lower()
90
+ if lowered in {"auto", "light", "dark"}:
91
+ return cast(ThemeModeValue, lowered)
92
+ return _DEFAULT_THEME_MODE
93
+
94
+
95
+ def _normalize_theme_name(value: Any) -> str | None:
96
+ if not isinstance(value, str):
97
+ return None
98
+ cleaned = value.strip()
99
+ if not cleaned or cleaned.lower() == "default":
100
+ return None
101
+ return cleaned
102
+
103
+
104
+ def _serialize_theme_name(name: str | None) -> str:
105
+ if isinstance(name, str):
106
+ cleaned = name.strip()
107
+ if cleaned:
108
+ return cleaned
109
+ return "default"
110
+
111
+
112
+ def _coerce_keybind_overrides(value: Any) -> dict[str, str]:
113
+ if not isinstance(value, dict):
114
+ return {}
115
+ return {key: val for key, val in value.items() if isinstance(key, str) and isinstance(val, str)}
116
+
117
+
118
+ def _merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
119
+ merged = dict(base)
120
+ for key, value in patch.items():
121
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
122
+ merged[key] = _merge_dict(cast(dict[str, Any], merged[key]), value)
123
+ else:
124
+ merged[key] = value
125
+ return merged