glaip-sdk 0.7.0__py3-none-any.whl → 0.7.2__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.
@@ -89,7 +89,7 @@ class TerminalCapabilities:
89
89
 
90
90
  # Basic capability detection
91
91
  ansi = tty_available and term not in ("dumb", "")
92
- osc52 = _detect_osc52_support()
92
+ osc52 = detect_osc52_support()
93
93
  mouse = tty_available and term not in ("dumb", "")
94
94
  truecolor = colorterm in ("truecolor", "24bit")
95
95
 
@@ -366,7 +366,7 @@ def _check_terminal_in_env(env_value: str, terminals: list[str]) -> bool:
366
366
  return any(terminal in env_value for terminal in terminals)
367
367
 
368
368
 
369
- def _detect_osc52_support() -> bool:
369
+ def detect_osc52_support() -> bool:
370
370
  """Check if terminal likely supports OSC 52 (clipboard).
371
371
 
372
372
  Returns:
@@ -0,0 +1,15 @@
1
+ """Theme system primitives for Textual TUIs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from glaip_sdk.cli.slash.tui.theme.catalog import get_builtin_theme, list_builtin_themes
6
+ from glaip_sdk.cli.slash.tui.theme.manager import ThemeManager, ThemeMode
7
+ from glaip_sdk.cli.slash.tui.theme.tokens import ThemeTokens
8
+
9
+ __all__ = [
10
+ "ThemeManager",
11
+ "ThemeMode",
12
+ "ThemeTokens",
13
+ "get_builtin_theme",
14
+ "list_builtin_themes",
15
+ ]
@@ -0,0 +1,79 @@
1
+ """Built-in theme catalog for TUI applications.
2
+
3
+ This module implements Phase 2 of the TUI Theme System spec, providing a foundational
4
+ set of 12 color tokens (primary, secondary, accent, background, background_panel, text,
5
+ text_muted, success, warning, error, info). Additional tokens (e.g., diff.added,
6
+ syntax.*, backgroundElevated, textDim) will be added in future phases per the spec's
7
+ "100+ color tokens" requirement.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from glaip_sdk.cli.slash.tui.theme.tokens import ThemeModeLiteral, ThemeTokens
13
+
14
+ _BUILTIN_THEMES: dict[str, ThemeTokens] = {
15
+ "gl-dark": ThemeTokens(
16
+ name="gl-dark",
17
+ mode="dark",
18
+ primary="#6EA8FE",
19
+ secondary="#ADB5BD",
20
+ accent="#C77DFF",
21
+ background="#0B0F19",
22
+ background_panel="#111827",
23
+ text="#E5E7EB",
24
+ text_muted="#9CA3AF",
25
+ success="#34D399",
26
+ warning="#FBBF24",
27
+ error="#F87171",
28
+ info="#60A5FA",
29
+ ),
30
+ "gl-light": ThemeTokens(
31
+ name="gl-light",
32
+ mode="light",
33
+ primary="#1D4ED8",
34
+ secondary="#4B5563",
35
+ accent="#7C3AED",
36
+ background="#FFFFFF",
37
+ background_panel="#F3F4F6",
38
+ text="#111827",
39
+ text_muted="#4B5563",
40
+ success="#059669",
41
+ warning="#B45309",
42
+ error="#B91C1C",
43
+ info="#1D4ED8",
44
+ ),
45
+ "gl-high-contrast": ThemeTokens(
46
+ name="gl-high-contrast",
47
+ mode="dark",
48
+ # High-contrast theme uses uniform colors (#FFFFFF on #000000) to maximize
49
+ # contrast for accessibility. Semantic distinctions (success/warning/error)
50
+ # are intentionally uniform to prioritize maximum readability over color
51
+ # coding, per accessibility best practices for high-contrast modes.
52
+ primary="#FFFFFF",
53
+ secondary="#FFFFFF",
54
+ accent="#FFFFFF",
55
+ background="#000000",
56
+ background_panel="#000000",
57
+ text="#FFFFFF",
58
+ text_muted="#FFFFFF",
59
+ success="#FFFFFF",
60
+ warning="#FFFFFF",
61
+ error="#FFFFFF",
62
+ info="#FFFFFF",
63
+ ),
64
+ }
65
+
66
+
67
+ def get_builtin_theme(name: str) -> ThemeTokens | None:
68
+ """Return a built-in theme by name."""
69
+ return _BUILTIN_THEMES.get(name)
70
+
71
+
72
+ def list_builtin_themes() -> list[str]:
73
+ """List available built-in theme names."""
74
+ return sorted(_BUILTIN_THEMES)
75
+
76
+
77
+ def default_theme_name_for_mode(mode: ThemeModeLiteral) -> str:
78
+ """Return the default theme name for the given light/dark mode."""
79
+ return "gl-light" if mode == "light" else "gl-dark"
@@ -0,0 +1,86 @@
1
+ """Theme manager for TUI applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from enum import Enum
7
+ from typing import Literal
8
+
9
+ from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
10
+ from glaip_sdk.cli.slash.tui.theme.catalog import default_theme_name_for_mode, get_builtin_theme
11
+ from glaip_sdk.cli.slash.tui.theme.tokens import ThemeTokens
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class ThemeMode(str, Enum):
17
+ """User-selectable theme mode."""
18
+
19
+ AUTO = "auto"
20
+ LIGHT = "light"
21
+ DARK = "dark"
22
+
23
+
24
+ class ThemeManager:
25
+ """Resolve active theme tokens from terminal state and user preferences."""
26
+
27
+ def __init__(
28
+ self,
29
+ terminal: TerminalCapabilities,
30
+ *,
31
+ mode: ThemeMode | str = ThemeMode.AUTO,
32
+ theme: str | None = None,
33
+ ) -> None:
34
+ """Initialize the theme manager."""
35
+ self._terminal = terminal
36
+ self._mode = self._coerce_mode(mode)
37
+ self._theme = theme
38
+
39
+ @property
40
+ def mode(self) -> ThemeMode:
41
+ """Return configured mode (auto/light/dark)."""
42
+ return self._mode
43
+
44
+ @property
45
+ def effective_mode(self) -> Literal["light", "dark"]:
46
+ """Return resolved light/dark mode."""
47
+ if self._mode == ThemeMode.AUTO:
48
+ return self._terminal.background_mode
49
+ return "light" if self._mode == ThemeMode.LIGHT else "dark"
50
+
51
+ @property
52
+ def theme_name(self) -> str:
53
+ """Return resolved theme name."""
54
+ return self._theme or default_theme_name_for_mode(self.effective_mode)
55
+
56
+ @property
57
+ def tokens(self) -> ThemeTokens:
58
+ """Return tokens for the resolved theme."""
59
+ chosen = get_builtin_theme(self.theme_name)
60
+ if chosen is not None:
61
+ return chosen
62
+
63
+ fallback_name = default_theme_name_for_mode(self.effective_mode)
64
+ fallback = get_builtin_theme(fallback_name)
65
+ if fallback is None:
66
+ raise RuntimeError(f"Missing default theme: {fallback_name}")
67
+
68
+ return fallback
69
+
70
+ def set_mode(self, mode: ThemeMode | str) -> None:
71
+ """Set auto/light/dark mode."""
72
+ self._mode = self._coerce_mode(mode)
73
+
74
+ def set_theme(self, theme: str | None) -> None:
75
+ """Set explicit theme name (or None to use the default)."""
76
+ self._theme = theme
77
+
78
+ def _coerce_mode(self, mode: ThemeMode | str) -> ThemeMode:
79
+ """Coerce a mode value to ThemeMode enum, defaulting to AUTO on invalid input."""
80
+ if isinstance(mode, ThemeMode):
81
+ return mode
82
+ try:
83
+ return ThemeMode(mode)
84
+ except ValueError:
85
+ logger.warning(f"Invalid theme mode '{mode}', defaulting to AUTO")
86
+ return ThemeMode.AUTO
@@ -0,0 +1,55 @@
1
+ """Theme token definitions for TUI applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ ThemeModeLiteral = Literal["light", "dark"]
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ThemeTokens:
13
+ """Color token set for a built-in theme."""
14
+
15
+ name: str
16
+ mode: ThemeModeLiteral
17
+
18
+ primary: str
19
+ secondary: str
20
+ accent: str
21
+
22
+ background: str
23
+ background_panel: str
24
+
25
+ text: str
26
+ text_muted: str
27
+
28
+ success: str
29
+ warning: str
30
+ error: str
31
+ info: str
32
+
33
+ def as_dict(self) -> dict[str, str]:
34
+ """Return color tokens as a plain dictionary.
35
+
36
+ Returns only color tokens (primary, secondary, accent, background, etc.),
37
+ excluding metadata fields (name, mode). This is intentional for use cases
38
+ like Textual TCSS mapping where only color values are needed.
39
+
40
+ Returns:
41
+ Dictionary mapping color token names to hex color strings.
42
+ """
43
+ return {
44
+ "primary": self.primary,
45
+ "secondary": self.secondary,
46
+ "accent": self.accent,
47
+ "background": self.background,
48
+ "background_panel": self.background_panel,
49
+ "text": self.text,
50
+ "text_muted": self.text_muted,
51
+ "success": self.success,
52
+ "warning": self.warning,
53
+ "error": self.error,
54
+ "info": self.info,
55
+ }
@@ -0,0 +1,123 @@
1
+ """Toast notification helpers for Textual TUIs.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+
13
+
14
+ class ToastVariant(str, Enum):
15
+ """Toast message variant."""
16
+
17
+ INFO = "info"
18
+ SUCCESS = "success"
19
+ WARNING = "warning"
20
+ ERROR = "error"
21
+
22
+
23
+ DEFAULT_TOAST_DURATIONS_SECONDS: dict[ToastVariant, float] = {
24
+ ToastVariant.SUCCESS: 2.0,
25
+ ToastVariant.INFO: 3.0,
26
+ ToastVariant.WARNING: 3.0,
27
+ ToastVariant.ERROR: 5.0,
28
+ }
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class ToastState:
33
+ """Immutable toast payload."""
34
+
35
+ message: str
36
+ variant: ToastVariant
37
+ duration_seconds: float
38
+
39
+
40
+ class ToastBus:
41
+ """Single-toast state holder with auto-dismiss."""
42
+
43
+ def __init__(self) -> None:
44
+ """Initialize the bus."""
45
+ self._state: ToastState | None = None
46
+ self._dismiss_task: asyncio.Task[None] | None = None
47
+
48
+ @property
49
+ def state(self) -> ToastState | None:
50
+ """Return the current toast state."""
51
+ return self._state
52
+
53
+ def show(
54
+ self,
55
+ message: str,
56
+ variant: ToastVariant | str = ToastVariant.INFO,
57
+ *,
58
+ duration_seconds: float | None = None,
59
+ ) -> None:
60
+ """Set toast state and schedule auto-dismiss."""
61
+ resolved_variant = self._coerce_variant(variant)
62
+ resolved_duration = (
63
+ DEFAULT_TOAST_DURATIONS_SECONDS[resolved_variant] if duration_seconds is None else float(duration_seconds)
64
+ )
65
+
66
+ self._state = ToastState(
67
+ message=message,
68
+ variant=resolved_variant,
69
+ duration_seconds=resolved_duration,
70
+ )
71
+
72
+ self._cancel_dismiss_task()
73
+
74
+ try:
75
+ loop = asyncio.get_running_loop()
76
+ except RuntimeError:
77
+ raise RuntimeError(
78
+ "Cannot schedule toast auto-dismiss: no running event loop. "
79
+ "ToastBus.show() must be called from within an async context."
80
+ ) from None
81
+
82
+ self._dismiss_task = loop.create_task(self._auto_dismiss(resolved_duration))
83
+
84
+ def clear(self) -> None:
85
+ """Clear the current toast."""
86
+ self._cancel_dismiss_task()
87
+ self._state = None
88
+
89
+ def copy_success(self, label: str | None = None) -> None:
90
+ """Show clipboard success toast."""
91
+ message = "Copied to clipboard" if not label else f"Copied {label} to clipboard"
92
+ self.show(message=message, variant=ToastVariant.SUCCESS)
93
+
94
+ def copy_failed(self) -> None:
95
+ """Show clipboard failure toast."""
96
+ self.show(
97
+ message="Clipboard unavailable. Text printed below",
98
+ variant=ToastVariant.WARNING,
99
+ )
100
+
101
+ def _coerce_variant(self, variant: ToastVariant | str) -> ToastVariant:
102
+ if isinstance(variant, ToastVariant):
103
+ return variant
104
+ try:
105
+ return ToastVariant(variant)
106
+ except ValueError:
107
+ return ToastVariant.INFO
108
+
109
+ def _cancel_dismiss_task(self) -> None:
110
+ if self._dismiss_task is None:
111
+ return
112
+ if not self._dismiss_task.done():
113
+ self._dismiss_task.cancel()
114
+ self._dismiss_task = None
115
+
116
+ async def _auto_dismiss(self, duration_seconds: float) -> None:
117
+ try:
118
+ await asyncio.sleep(duration_seconds)
119
+ except asyncio.CancelledError:
120
+ return
121
+
122
+ self._state = None
123
+ self._dismiss_task = None
@@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Any, BinaryIO
17
17
 
18
18
  if TYPE_CHECKING:
19
19
  from glaip_sdk.client.schedules import ScheduleClient
20
+ from glaip_sdk.hitl.remote import RemoteHITLHandler
20
21
 
21
22
  import httpx
22
23
  from glaip_sdk.agents import Agent
@@ -415,6 +416,7 @@ class AgentClient(BaseClient):
415
416
  timeout_seconds: float,
416
417
  agent_name: str | None,
417
418
  meta: dict[str, Any],
419
+ hitl_handler: "RemoteHITLHandler | None" = None,
418
420
  ) -> tuple[str, dict[str, Any], float | None, float | None]:
419
421
  """Process stream events from an HTTP response.
420
422
 
@@ -424,6 +426,7 @@ class AgentClient(BaseClient):
424
426
  timeout_seconds: Timeout in seconds.
425
427
  agent_name: Optional agent name.
426
428
  meta: Metadata dictionary.
429
+ hitl_handler: Optional HITL handler for approval callbacks.
427
430
 
428
431
  Returns:
429
432
  Tuple of (final_text, stats_usage, started_monotonic, finished_monotonic).
@@ -435,6 +438,7 @@ class AgentClient(BaseClient):
435
438
  timeout_seconds,
436
439
  agent_name,
437
440
  meta,
441
+ hitl_handler=hitl_handler,
438
442
  )
439
443
 
440
444
  def _finalize_renderer(
@@ -1112,6 +1116,7 @@ class AgentClient(BaseClient):
1112
1116
  *,
1113
1117
  renderer: RichStreamRenderer | str | None = "auto",
1114
1118
  runtime_config: dict[str, Any] | None = None,
1119
+ hitl_handler: "RemoteHITLHandler | None" = None,
1115
1120
  **kwargs,
1116
1121
  ) -> str:
1117
1122
  """Run an agent with a message, streaming via a renderer.
@@ -1129,6 +1134,8 @@ class AgentClient(BaseClient):
1129
1134
  "mcp_configs": {"mcp-id": {"setting": "on"}},
1130
1135
  "agent_config": {"planning": True},
1131
1136
  }
1137
+ hitl_handler: Optional RemoteHITLHandler for approval callbacks.
1138
+ Set GLAIP_HITL_AUTO_APPROVE=true for auto-approval without handler.
1132
1139
  **kwargs: Additional arguments to pass to the run API.
1133
1140
 
1134
1141
  Returns:
@@ -1185,6 +1192,7 @@ class AgentClient(BaseClient):
1185
1192
  timeout_seconds,
1186
1193
  agent_name,
1187
1194
  meta,
1195
+ hitl_handler=hitl_handler,
1188
1196
  )
1189
1197
 
1190
1198
  except KeyboardInterrupt:
@@ -1201,6 +1209,13 @@ class AgentClient(BaseClient):
1201
1209
  if multipart_data:
1202
1210
  multipart_data.close()
1203
1211
 
1212
+ # Wait for pending HITL decisions before returning
1213
+ if hitl_handler and hasattr(hitl_handler, "wait_for_pending_decisions"):
1214
+ try:
1215
+ hitl_handler.wait_for_pending_decisions(timeout=30)
1216
+ except Exception as e:
1217
+ logger.warning(f"Error waiting for HITL decisions: {e}")
1218
+
1204
1219
  return self._finalize_renderer(
1205
1220
  r,
1206
1221
  final_text,
@@ -1282,6 +1297,7 @@ class AgentClient(BaseClient):
1282
1297
  *,
1283
1298
  request_timeout: float | None = None,
1284
1299
  runtime_config: dict[str, Any] | None = None,
1300
+ hitl_handler: "RemoteHITLHandler | None" = None,
1285
1301
  **kwargs,
1286
1302
  ) -> AsyncGenerator[dict, None]:
1287
1303
  """Async run an agent with a message, yielding streaming JSON chunks.
@@ -1298,16 +1314,26 @@ class AgentClient(BaseClient):
1298
1314
  "mcp_configs": {"mcp-id": {"setting": "on"}},
1299
1315
  "agent_config": {"planning": True},
1300
1316
  }
1317
+ hitl_handler: Optional HITL handler for remote approval requests.
1318
+ Note: Async HITL support is currently deferred. This parameter
1319
+ is accepted for API consistency but will raise NotImplementedError
1320
+ if provided.
1301
1321
  **kwargs: Additional arguments (chat_history, pii_mapping, etc.)
1302
1322
 
1303
1323
  Yields:
1304
1324
  Dictionary containing parsed JSON chunks from the streaming response
1305
1325
 
1306
1326
  Raises:
1327
+ NotImplementedError: If hitl_handler is provided (async HITL not yet supported)
1307
1328
  AgentTimeoutError: When agent execution times out
1308
1329
  httpx.TimeoutException: When general timeout occurs
1309
1330
  Exception: For other unexpected errors
1310
1331
  """
1332
+ if hitl_handler is not None:
1333
+ raise NotImplementedError(
1334
+ "Async HITL support is currently deferred. "
1335
+ "Please use the synchronous run_agent() method with hitl_handler."
1336
+ )
1311
1337
  # Include runtime_config in kwargs only when caller hasn't already provided it
1312
1338
  if runtime_config is not None and "runtime_config" not in kwargs:
1313
1339
  kwargs["runtime_config"] = runtime_config
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """HITL REST client for manual approval operations.
3
+
4
+ Authors:
5
+ GLAIP SDK Team
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from glaip_sdk.client.base import BaseClient
11
+ from glaip_sdk.hitl.base import HITLDecision
12
+
13
+
14
+ class HITLClient(BaseClient):
15
+ """Client for HITL REST endpoints.
16
+
17
+ Use for manual approval workflows separate from agent runs.
18
+
19
+ Example:
20
+ >>> # List pending approvals
21
+ >>> pending = client.hitl.list_pending()
22
+ >>>
23
+ >>> # Approve a request
24
+ >>> client.hitl.approve(
25
+ ... request_id="bc4d0a77-7800-470e-a91c-7fd663a66b4d",
26
+ ... operator_input="Verified and approved",
27
+ ... )
28
+ """
29
+
30
+ def approve(
31
+ self,
32
+ request_id: str,
33
+ operator_input: str | None = None,
34
+ run_id: str | None = None,
35
+ ) -> dict[str, Any]:
36
+ """Approve a HITL request.
37
+
38
+ Args:
39
+ request_id: HITL request ID from SSE stream
40
+ operator_input: Optional notes/reason for approval
41
+ run_id: Optional client-side run correlation ID
42
+
43
+ Returns:
44
+ Response dict: {"status": "ok", "message": "..."}
45
+ """
46
+ return self._post_decision(
47
+ request_id,
48
+ HITLDecision.APPROVED,
49
+ operator_input,
50
+ run_id,
51
+ )
52
+
53
+ def reject(
54
+ self,
55
+ request_id: str,
56
+ operator_input: str | None = None,
57
+ run_id: str | None = None,
58
+ ) -> dict[str, Any]:
59
+ """Reject a HITL request.
60
+
61
+ Args:
62
+ request_id: HITL request ID
63
+ operator_input: Optional reason for rejection
64
+ run_id: Optional run correlation ID
65
+
66
+ Returns:
67
+ Response dict
68
+ """
69
+ return self._post_decision(
70
+ request_id,
71
+ HITLDecision.REJECTED,
72
+ operator_input,
73
+ run_id,
74
+ )
75
+
76
+ def skip(
77
+ self,
78
+ request_id: str,
79
+ operator_input: str | None = None,
80
+ run_id: str | None = None,
81
+ ) -> dict[str, Any]:
82
+ """Skip a HITL request.
83
+
84
+ Args:
85
+ request_id: HITL request ID
86
+ operator_input: Optional notes
87
+ run_id: Optional run correlation ID
88
+
89
+ Returns:
90
+ Response dict
91
+ """
92
+ return self._post_decision(
93
+ request_id,
94
+ HITLDecision.SKIPPED,
95
+ operator_input,
96
+ run_id,
97
+ )
98
+
99
+ def _post_decision(
100
+ self,
101
+ request_id: str,
102
+ decision: HITLDecision,
103
+ operator_input: str | None,
104
+ run_id: str | None,
105
+ ) -> dict[str, Any]:
106
+ """Post HITL decision to backend."""
107
+ payload = {
108
+ "request_id": request_id,
109
+ "decision": decision.value,
110
+ }
111
+
112
+ if operator_input:
113
+ payload["operator_input"] = operator_input
114
+ if run_id:
115
+ payload["run_id"] = run_id
116
+
117
+ return self._request("POST", "/agents/hitl/decision", json=payload)
118
+
119
+ def list_pending(self) -> list[dict[str, Any]]:
120
+ """List all pending HITL requests.
121
+
122
+ Returns:
123
+ List of pending request dicts with metadata:
124
+ [
125
+ {
126
+ "request_id": "...",
127
+ "tool": "...",
128
+ "arguments": {...},
129
+ "created_at": "...",
130
+ "agent_id": "...",
131
+ "hitl_metadata": {...},
132
+ },
133
+ ...
134
+ ]
135
+ """
136
+ return self._request("GET", "/agents/hitl/pending")
glaip_sdk/client/main.py CHANGED
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any
12
12
 
13
13
  from glaip_sdk.client.agents import AgentClient
14
14
  from glaip_sdk.client.base import BaseClient
15
+ from glaip_sdk.client.hitl import HITLClient
15
16
  from glaip_sdk.client.mcps import MCPClient
16
17
  from glaip_sdk.client.schedules import ScheduleClient
17
18
  from glaip_sdk.client.shared import build_shared_config
@@ -40,6 +41,7 @@ class Client(BaseClient):
40
41
  self.tools = ToolClient(**shared_config)
41
42
  self.mcps = MCPClient(**shared_config)
42
43
  self.schedules = ScheduleClient(**shared_config)
44
+ self.hitl = HITLClient(**shared_config)
43
45
 
44
46
  # ---- Core API Methods (Public Interface) ----
45
47