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
@@ -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,112 @@
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.account_store import AccountStore, AccountStoreError
10
+ from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
11
+ from glaip_sdk.cli.slash.tui.theme.catalog import default_theme_name_for_mode, get_builtin_theme
12
+ from glaip_sdk.cli.slash.tui.theme.tokens import ThemeTokens
13
+ from glaip_sdk.cli.tui_settings import persist_tui_theme
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ThemeMode(str, Enum):
19
+ """User-selectable theme mode."""
20
+
21
+ AUTO = "auto"
22
+ LIGHT = "light"
23
+ DARK = "dark"
24
+
25
+
26
+ class ThemeManager:
27
+ """Resolve active theme tokens from terminal state and user preferences."""
28
+
29
+ def __init__(
30
+ self,
31
+ terminal: TerminalCapabilities,
32
+ *,
33
+ mode: ThemeMode | str = ThemeMode.AUTO,
34
+ theme: str | None = None,
35
+ settings_store: AccountStore | None = None,
36
+ ) -> None:
37
+ """Initialize the theme manager."""
38
+ self._terminal = terminal
39
+ self._mode = self._coerce_mode(mode)
40
+ self._theme = self._normalize_theme_name(theme)
41
+ self._settings_store = settings_store
42
+
43
+ @property
44
+ def mode(self) -> ThemeMode:
45
+ """Return configured mode (auto/light/dark)."""
46
+ return self._mode
47
+
48
+ @property
49
+ def effective_mode(self) -> Literal["light", "dark"]:
50
+ """Return resolved light/dark mode."""
51
+ if self._mode == ThemeMode.AUTO:
52
+ return self._terminal.background_mode
53
+ return "light" if self._mode == ThemeMode.LIGHT else "dark"
54
+
55
+ @property
56
+ def theme_name(self) -> str:
57
+ """Return resolved theme name."""
58
+ return self._theme or default_theme_name_for_mode(self.effective_mode)
59
+
60
+ @property
61
+ def tokens(self) -> ThemeTokens:
62
+ """Return tokens for the resolved theme."""
63
+ chosen = get_builtin_theme(self.theme_name)
64
+ if chosen is not None:
65
+ return chosen
66
+
67
+ fallback_name = default_theme_name_for_mode(self.effective_mode)
68
+ fallback = get_builtin_theme(fallback_name)
69
+ if fallback is None:
70
+ raise RuntimeError(f"Missing default theme: {fallback_name}")
71
+
72
+ return fallback
73
+
74
+ def set_mode(self, mode: ThemeMode | str) -> None:
75
+ """Set auto/light/dark mode."""
76
+ self._mode = self._coerce_mode(mode)
77
+ self._persist_preferences()
78
+
79
+ def set_theme(self, theme: str | None) -> None:
80
+ """Set explicit theme name (or None to use the default)."""
81
+ self._theme = self._normalize_theme_name(theme)
82
+ self._persist_preferences()
83
+
84
+ def _coerce_mode(self, mode: ThemeMode | str) -> ThemeMode:
85
+ """Coerce a mode value to ThemeMode enum, defaulting to AUTO on invalid input."""
86
+ if isinstance(mode, ThemeMode):
87
+ return mode
88
+ try:
89
+ return ThemeMode(mode)
90
+ except ValueError:
91
+ logger.warning(f"Invalid theme mode '{mode}', defaulting to AUTO")
92
+ return ThemeMode.AUTO
93
+
94
+ def _persist_preferences(self) -> None:
95
+ if self._settings_store is None:
96
+ return
97
+ try:
98
+ persist_tui_theme(mode=self._mode.value, name=self._theme, store=self._settings_store)
99
+ except (OSError, AccountStoreError) as exc:
100
+ # Log recoverable errors (permissions, I/O) as warnings
101
+ logger.warning(f"Failed to persist TUI theme preferences: {exc}")
102
+ except Exception as exc:
103
+ # Log unexpected errors at error level for debugging
104
+ logger.error(f"Unexpected error persisting TUI theme preferences: {exc}", exc_info=True)
105
+
106
+ def _normalize_theme_name(self, theme: str | None) -> str | None:
107
+ if not isinstance(theme, str):
108
+ return None
109
+ cleaned = theme.strip()
110
+ if not cleaned or cleaned.lower() == "default":
111
+ return None
112
+ return cleaned
@@ -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
+ }
@@ -0,0 +1,374 @@
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.widgets import Static
14
+
15
+
16
+ class ToastVariant(str, Enum):
17
+ """Toast message variant for styling and behavior."""
18
+
19
+ INFO = "info"
20
+ SUCCESS = "success"
21
+ WARNING = "warning"
22
+ ERROR = "error"
23
+
24
+
25
+ DEFAULT_TOAST_DURATIONS_SECONDS: dict[ToastVariant, float] = {
26
+ ToastVariant.SUCCESS: 2.0,
27
+ ToastVariant.INFO: 3.0,
28
+ ToastVariant.WARNING: 3.0,
29
+ ToastVariant.ERROR: 5.0,
30
+ }
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class ToastState:
35
+ """Immutable toast notification state."""
36
+
37
+ message: str
38
+ variant: ToastVariant
39
+ duration_seconds: float
40
+
41
+
42
+ class ToastBus:
43
+ """Toast state manager with auto-dismiss functionality."""
44
+
45
+ class Changed(Message):
46
+ """Message sent when toast state changes."""
47
+
48
+ def __init__(self, state: ToastState | None) -> None:
49
+ """Initialize the changed message with new toast state."""
50
+ super().__init__()
51
+ self.state = state
52
+
53
+ def __init__(self, on_change: Callable[[ToastBus.Changed], None] | None = None) -> None:
54
+ """Initialize the toast bus with optional change callback."""
55
+ self._state: ToastState | None = None
56
+ self._dismiss_task: asyncio.Task[None] | None = None
57
+ self._on_change = on_change
58
+
59
+ @property
60
+ def state(self) -> ToastState | None:
61
+ """Return the current toast state, or None if no toast is shown."""
62
+ return self._state
63
+
64
+ def show(
65
+ self,
66
+ message: str,
67
+ variant: ToastVariant | str = ToastVariant.INFO,
68
+ *,
69
+ duration_seconds: float | None = None,
70
+ ) -> None:
71
+ """Show a toast notification with the given message and variant.
72
+
73
+ Args:
74
+ message: The message to display in the toast.
75
+ variant: The visual variant of the toast (INFO, SUCCESS, WARNING, ERROR).
76
+ duration_seconds: Optional custom duration in seconds. If None, uses default
77
+ duration for the variant (2s for SUCCESS, 3s for INFO/WARNING, 5s for ERROR).
78
+ """
79
+ resolved_variant = self._coerce_variant(variant)
80
+ resolved_duration = (
81
+ DEFAULT_TOAST_DURATIONS_SECONDS[resolved_variant] if duration_seconds is None else float(duration_seconds)
82
+ )
83
+
84
+ self._state = ToastState(
85
+ message=message,
86
+ variant=resolved_variant,
87
+ duration_seconds=resolved_duration,
88
+ )
89
+
90
+ self._cancel_dismiss_task()
91
+
92
+ try:
93
+ loop = asyncio.get_running_loop()
94
+ except RuntimeError:
95
+ raise RuntimeError(
96
+ "Cannot schedule toast auto-dismiss: no running event loop. "
97
+ "ToastBus.show() must be called from within an async context."
98
+ ) from None
99
+
100
+ self._dismiss_task = loop.create_task(self._auto_dismiss(resolved_duration))
101
+ self._notify_changed()
102
+
103
+ def clear(self) -> None:
104
+ """Clear the current toast notification immediately."""
105
+ self._cancel_dismiss_task()
106
+ self._state = None
107
+ self._notify_changed()
108
+
109
+ def copy_success(self, label: str | None = None) -> None:
110
+ """Show a success toast for clipboard copy operations.
111
+
112
+ Args:
113
+ label: Optional label for what was copied (e.g., "Run ID", "JSON").
114
+ """
115
+ message = "Copied to clipboard" if not label else f"Copied {label} to clipboard"
116
+ self.show(message=message, variant=ToastVariant.SUCCESS)
117
+
118
+ def copy_failed(self) -> None:
119
+ """Show a warning toast when clipboard copy fails."""
120
+ self.show(message="Clipboard unavailable. Text printed below.", variant=ToastVariant.WARNING)
121
+
122
+ def _coerce_variant(self, variant: ToastVariant | str) -> ToastVariant:
123
+ if isinstance(variant, ToastVariant):
124
+ return variant
125
+ try:
126
+ return ToastVariant(variant)
127
+ except ValueError:
128
+ return ToastVariant.INFO
129
+
130
+ def _cancel_dismiss_task(self) -> None:
131
+ if self._dismiss_task is None:
132
+ return
133
+ if not self._dismiss_task.done():
134
+ self._dismiss_task.cancel()
135
+ self._dismiss_task = None
136
+
137
+ async def _auto_dismiss(self, duration_seconds: float) -> None:
138
+ try:
139
+ await asyncio.sleep(duration_seconds)
140
+ except asyncio.CancelledError:
141
+ return
142
+
143
+ self._state = None
144
+ self._dismiss_task = None
145
+ self._notify_changed()
146
+
147
+ def _notify_changed(self) -> None:
148
+ if self._on_change:
149
+ self._on_change(ToastBus.Changed(self._state))
150
+
151
+
152
+ class ToastHandlerMixin:
153
+ """Mixin providing common toast handling functionality.
154
+
155
+ Classes that inherit from this mixin can handle ToastBus.Changed messages
156
+ by automatically updating all Toast widgets in the component tree.
157
+ """
158
+
159
+ def on_toast_bus_changed(self, message: ToastBus.Changed) -> None:
160
+ """Refresh the toast widget when the toast bus updates.
161
+
162
+ Args:
163
+ message: The toast bus changed message containing the new state.
164
+ """
165
+ try:
166
+ for toast in self.query(Toast):
167
+ toast.update_state(message.state)
168
+ except Exception:
169
+ pass
170
+
171
+
172
+ class ClipboardToastMixin:
173
+ """Mixin providing clipboard and toast orchestration functionality.
174
+
175
+ Classes that inherit from this mixin get shared clipboard adapter selection,
176
+ OSC52 writer setup, toast bus lookup, and copy-success/failure orchestration.
177
+ This consolidates duplicate clipboard/toast logic across TUI apps.
178
+
179
+ Expected attributes:
180
+ _ctx: TUIContext | None - Shared TUI context (optional)
181
+ _clipboard: ClipboardAdapter | None - Cached clipboard adapter (optional)
182
+ _local_toasts: ToastBus | None - Local toast bus instance (optional)
183
+ """
184
+
185
+ def _clipboard_adapter(self) -> Any: # ClipboardAdapter
186
+ """Get or create a clipboard adapter instance.
187
+
188
+ Returns:
189
+ ClipboardAdapter instance, preferring context's adapter if available.
190
+ """
191
+ # Import here to avoid circular dependency
192
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter # noqa: PLC0415
193
+
194
+ ctx = getattr(self, "_ctx", None)
195
+ clipboard = getattr(self, "_clipboard", None)
196
+
197
+ if ctx is not None and ctx.clipboard is not None:
198
+ return cast(ClipboardAdapter, ctx.clipboard)
199
+ if clipboard is not None:
200
+ return clipboard
201
+
202
+ adapter = ClipboardAdapter(terminal=ctx.terminal if ctx else None)
203
+ if ctx is not None:
204
+ ctx.clipboard = adapter
205
+ else:
206
+ self._clipboard = adapter
207
+ return adapter
208
+
209
+ def _osc52_writer(self) -> Callable[[str], Any] | None:
210
+ """Get an OSC52 writer function if console output is available.
211
+
212
+ Returns:
213
+ Writer function that writes OSC52 sequences to console output, or None.
214
+ """
215
+ try:
216
+ # Try self.app.console first (for Screen subclasses)
217
+ if hasattr(self, "app") and hasattr(self.app, "console"):
218
+ console = self.app.console
219
+ # Fall back to self.console (for App subclasses)
220
+ else:
221
+ console = getattr(self, "console", None)
222
+ except Exception:
223
+ return None
224
+
225
+ if console is None:
226
+ return None
227
+
228
+ output = getattr(console, "file", None)
229
+ if output is None:
230
+ return None
231
+
232
+ def _write(sequence: str, _output: Any = output) -> None:
233
+ _output.write(sequence)
234
+ _output.flush()
235
+
236
+ return _write
237
+
238
+ def _toast_bus(self) -> ToastBus | None:
239
+ """Get the toast bus instance.
240
+
241
+ Returns:
242
+ ToastBus instance, preferring context's bus if available, or None.
243
+ """
244
+ local_toasts = getattr(self, "_local_toasts", None)
245
+ ctx = getattr(self, "_ctx", None)
246
+
247
+ if local_toasts is not None:
248
+ return local_toasts
249
+ if ctx is not None and ctx.toasts is not None:
250
+ return ctx.toasts
251
+ return None
252
+
253
+ def _copy_to_clipboard(self, text: str, *, label: str | None = None) -> None:
254
+ """Copy text to clipboard and show toast notification.
255
+
256
+ Args:
257
+ text: The text to copy to clipboard.
258
+ label: Optional label for what was copied (e.g., "Run ID", "JSON").
259
+ """
260
+ adapter = self._clipboard_adapter()
261
+ writer = self._osc52_writer()
262
+ if writer:
263
+ result = adapter.copy(text, writer=writer)
264
+ else:
265
+ result = adapter.copy(text)
266
+
267
+ toasts = self._toast_bus()
268
+ if result.success:
269
+ if toasts:
270
+ toasts.copy_success(label)
271
+ else:
272
+ # Fallback to status announcement if toast bus unavailable
273
+ if hasattr(self, "_announce_status"):
274
+ if label:
275
+ self._announce_status(f"Copied {label} to clipboard.")
276
+ else:
277
+ self._announce_status("Copied to clipboard.")
278
+ return
279
+
280
+ # Copy failed
281
+ if toasts:
282
+ toasts.copy_failed()
283
+ else:
284
+ # Fallback to status announcement if toast bus unavailable
285
+ if hasattr(self, "_announce_status"):
286
+ self._announce_status("Clipboard unavailable. Text printed below.")
287
+
288
+ # Append fallback text output
289
+ if hasattr(self, "_append_copy_fallback"):
290
+ self._append_copy_fallback(text)
291
+
292
+
293
+ class Toast(Static):
294
+ """A Textual widget that displays toast notifications at the top-right of the screen.
295
+
296
+ The Toast widget is updated via `update_state()` calls from message handlers
297
+ (e.g., `on_toast_bus_changed`). The widget does not auto-subscribe to ToastBus
298
+ state changes; the app must call `update_state()` when toast state changes.
299
+ """
300
+
301
+ DEFAULT_CSS = """
302
+ #toast-container {
303
+ width: 100%;
304
+ height: auto;
305
+ dock: top;
306
+ align: right top;
307
+ }
308
+
309
+ Toast {
310
+ width: auto;
311
+ min-width: 20;
312
+ max-width: 40;
313
+ height: auto;
314
+ padding: 0 1;
315
+ margin: 1 2;
316
+ background: $surface;
317
+ color: $text;
318
+ border: solid $primary;
319
+ display: none;
320
+ }
321
+
322
+ Toast.visible {
323
+ display: block;
324
+ }
325
+
326
+ Toast.info {
327
+ border: solid $accent;
328
+ }
329
+
330
+ Toast.success {
331
+ border: solid $success;
332
+ }
333
+
334
+ Toast.warning {
335
+ border: solid $warning;
336
+ }
337
+
338
+ Toast.error {
339
+ border: solid $error;
340
+ }
341
+ """
342
+
343
+ def __init__(self) -> None:
344
+ """Initialize the Toast widget.
345
+
346
+ The widget is updated via `update_state()` calls from message handlers
347
+ (e.g., `on_toast_bus_changed`). The widget does not auto-subscribe to
348
+ a ToastBus; the app must call `update_state()` when toast state changes.
349
+ """
350
+ super().__init__("")
351
+
352
+ def update_state(self, state: ToastState | None) -> None:
353
+ """Update the toast display based on the provided state.
354
+
355
+ Args:
356
+ state: The toast state to display, or None to hide the toast.
357
+ """
358
+ if not state:
359
+ self.remove_class("visible")
360
+ return
361
+
362
+ icon = "ℹ️"
363
+ if state.variant == ToastVariant.SUCCESS:
364
+ icon = "✅"
365
+ elif state.variant == ToastVariant.WARNING:
366
+ icon = "⚠️"
367
+ elif state.variant == ToastVariant.ERROR:
368
+ icon = "❌"
369
+
370
+ self.update(Text.assemble((f"{icon} ", "bold"), state.message))
371
+
372
+ self.remove_class("info", "success", "warning", "error")
373
+ self.add_class(state.variant.value)
374
+ 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
  # ------------------------------------------------------------------