glaip-sdk 0.7.8__py3-none-any.whl → 0.7.10__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/agents/base.py +61 -10
- glaip_sdk/cli/slash/remote_runs_controller.py +2 -0
- glaip_sdk/cli/slash/tui/accounts.tcss +12 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +72 -30
- glaip_sdk/cli/slash/tui/clipboard.py +56 -8
- glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
- glaip_sdk/cli/slash/tui/toast.py +270 -19
- glaip_sdk/client/run_rendering.py +76 -29
- glaip_sdk/guardrails/__init__.py +80 -0
- glaip_sdk/guardrails/serializer.py +89 -0
- glaip_sdk/payload_schemas/agent.py +1 -0
- glaip_sdk/payload_schemas/guardrails.py +34 -0
- glaip_sdk/runner/langgraph.py +1 -0
- {glaip_sdk-0.7.8.dist-info → glaip_sdk-0.7.10.dist-info}/METADATA +6 -4
- {glaip_sdk-0.7.8.dist-info → glaip_sdk-0.7.10.dist-info}/RECORD +18 -15
- {glaip_sdk-0.7.8.dist-info → glaip_sdk-0.7.10.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.7.8.dist-info → glaip_sdk-0.7.10.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.7.8.dist-info → glaip_sdk-0.7.10.dist-info}/top_level.txt +0 -0
glaip_sdk/agents/base.py
CHANGED
|
@@ -54,6 +54,7 @@ from glaip_sdk.utils.resource_refs import is_uuid
|
|
|
54
54
|
|
|
55
55
|
if TYPE_CHECKING:
|
|
56
56
|
from glaip_sdk.client.schedules import AgentScheduleManager
|
|
57
|
+
from glaip_sdk.guardrails import GuardrailManager
|
|
57
58
|
from glaip_sdk.models import AgentResponse
|
|
58
59
|
from glaip_sdk.registry import AgentRegistry, MCPRegistry, ToolRegistry
|
|
59
60
|
|
|
@@ -130,6 +131,7 @@ class Agent:
|
|
|
130
131
|
agents: list | None = None,
|
|
131
132
|
mcps: list | None = None,
|
|
132
133
|
model: str | None = _UNSET, # type: ignore[assignment]
|
|
134
|
+
guardrail: GuardrailManager | None = None,
|
|
133
135
|
_client: Any = None,
|
|
134
136
|
**kwargs: Any,
|
|
135
137
|
) -> None:
|
|
@@ -147,7 +149,9 @@ class Agent:
|
|
|
147
149
|
agents: List of sub-agents (Agent classes, instances, or strings).
|
|
148
150
|
mcps: List of MCPs.
|
|
149
151
|
model: Model identifier.
|
|
152
|
+
guardrail: The guardrail manager for content safety.
|
|
150
153
|
_client: Internal client reference (set automatically).
|
|
154
|
+
|
|
151
155
|
**kwargs: Additional configuration parameters:
|
|
152
156
|
- timeout: Execution timeout in seconds.
|
|
153
157
|
- metadata: Optional metadata dictionary.
|
|
@@ -173,6 +177,7 @@ class Agent:
|
|
|
173
177
|
self._agents = agents
|
|
174
178
|
self._mcps = mcps
|
|
175
179
|
self._model = model
|
|
180
|
+
self._guardrail = guardrail
|
|
176
181
|
self._language_model_id: str | None = None
|
|
177
182
|
# Extract parameters from kwargs with _UNSET defaults
|
|
178
183
|
self._timeout = kwargs.pop("timeout", Agent._UNSET) # type: ignore[assignment]
|
|
@@ -452,6 +457,11 @@ class Agent:
|
|
|
452
457
|
return self._mcp_configs
|
|
453
458
|
return None
|
|
454
459
|
|
|
460
|
+
@property
|
|
461
|
+
def guardrail(self) -> GuardrailManager | None:
|
|
462
|
+
"""The guardrail manager for content safety."""
|
|
463
|
+
return self._guardrail
|
|
464
|
+
|
|
455
465
|
@property
|
|
456
466
|
def a2a_profile(self) -> dict[str, Any] | None:
|
|
457
467
|
"""A2A (Agent-to-Agent) profile configuration.
|
|
@@ -547,11 +557,20 @@ class Agent:
|
|
|
547
557
|
|
|
548
558
|
# Handle agent_config with timeout
|
|
549
559
|
# The timeout property is a convenience that maps to agent_config.execution_timeout
|
|
550
|
-
|
|
560
|
+
raw_config = self.agent_config if self.agent_config is not self._UNSET else {}
|
|
561
|
+
agent_config = dict(raw_config) if raw_config else {}
|
|
562
|
+
|
|
551
563
|
if self.timeout and "execution_timeout" not in agent_config:
|
|
552
564
|
agent_config["execution_timeout"] = self.timeout
|
|
553
|
-
|
|
554
|
-
|
|
565
|
+
|
|
566
|
+
if self.guardrail:
|
|
567
|
+
from glaip_sdk.guardrails.serializer import ( # noqa: PLC0415
|
|
568
|
+
serialize_guardrail_manager,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
agent_config["guardrails"] = serialize_guardrail_manager(self.guardrail)
|
|
572
|
+
|
|
573
|
+
config["agent_config"] = agent_config
|
|
555
574
|
|
|
556
575
|
# Handle tool_configs - resolve tool names/classes to IDs
|
|
557
576
|
if self.tool_configs:
|
|
@@ -583,11 +602,20 @@ class Agent:
|
|
|
583
602
|
|
|
584
603
|
Returns:
|
|
585
604
|
List of resolved MCP IDs for the API payload.
|
|
605
|
+
|
|
606
|
+
Raises:
|
|
607
|
+
ValueError: If an MCP fails to resolve to a valid ID.
|
|
586
608
|
"""
|
|
587
609
|
if not self.mcps:
|
|
588
610
|
return []
|
|
589
611
|
|
|
590
|
-
|
|
612
|
+
resolved_ids: list[str] = []
|
|
613
|
+
for mcp_ref in self.mcps:
|
|
614
|
+
mcp = registry.resolve(mcp_ref)
|
|
615
|
+
if not mcp.id:
|
|
616
|
+
raise ValueError(f"Failed to resolve ID for MCP: {mcp_ref}")
|
|
617
|
+
resolved_ids.append(mcp.id)
|
|
618
|
+
return resolved_ids
|
|
591
619
|
|
|
592
620
|
def _resolve_tools(self, registry: ToolRegistry) -> list[str]:
|
|
593
621
|
"""Resolve tool references to IDs using ToolRegistry.
|
|
@@ -601,12 +629,20 @@ class Agent:
|
|
|
601
629
|
|
|
602
630
|
Returns:
|
|
603
631
|
List of resolved tool IDs for the API payload.
|
|
632
|
+
|
|
633
|
+
Raises:
|
|
634
|
+
ValueError: If a tool fails to resolve to a valid ID.
|
|
604
635
|
"""
|
|
605
636
|
if not self.tools:
|
|
606
637
|
return []
|
|
607
638
|
|
|
608
|
-
|
|
609
|
-
|
|
639
|
+
resolved_ids: list[str] = []
|
|
640
|
+
for tool_ref in self.tools:
|
|
641
|
+
tool = registry.resolve(tool_ref)
|
|
642
|
+
if not tool.id:
|
|
643
|
+
raise ValueError(f"Failed to resolve ID for tool: {tool_ref}")
|
|
644
|
+
resolved_ids.append(tool.id)
|
|
645
|
+
return resolved_ids
|
|
610
646
|
|
|
611
647
|
def _resolve_tool_configs(self, registry: ToolRegistry) -> dict[str, Any]:
|
|
612
648
|
"""Resolve tool_configs keys from tool names/classes to tool IDs.
|
|
@@ -649,6 +685,8 @@ class Agent:
|
|
|
649
685
|
try:
|
|
650
686
|
# Resolve key (tool name/class) to Tool object, get ID
|
|
651
687
|
tool = registry.resolve(key)
|
|
688
|
+
if not tool.id:
|
|
689
|
+
raise ValueError(f"Resolved tool has no ID: {key}")
|
|
652
690
|
resolved[tool.id] = config
|
|
653
691
|
except (ValueError, KeyError) as e:
|
|
654
692
|
raise ValueError(f"Failed to resolve tool config key: {key}") from e
|
|
@@ -684,6 +722,8 @@ class Agent:
|
|
|
684
722
|
resolved_id = key
|
|
685
723
|
else:
|
|
686
724
|
mcp = registry.resolve(key)
|
|
725
|
+
if not mcp.id:
|
|
726
|
+
raise ValueError(f"Resolved MCP has no ID: {key}")
|
|
687
727
|
resolved_id = mcp.id
|
|
688
728
|
|
|
689
729
|
if resolved_id in resolved:
|
|
@@ -699,7 +739,7 @@ class Agent:
|
|
|
699
739
|
|
|
700
740
|
return resolved
|
|
701
741
|
|
|
702
|
-
def _resolve_agents(self, registry: AgentRegistry) -> list:
|
|
742
|
+
def _resolve_agents(self, registry: AgentRegistry) -> list[str]:
|
|
703
743
|
"""Resolve sub-agent references using AgentRegistry.
|
|
704
744
|
|
|
705
745
|
Uses the global AgentRegistry to cache Agent objects across deployments.
|
|
@@ -711,12 +751,20 @@ class Agent:
|
|
|
711
751
|
|
|
712
752
|
Returns:
|
|
713
753
|
List of resolved agent IDs for the API payload.
|
|
754
|
+
|
|
755
|
+
Raises:
|
|
756
|
+
ValueError: If an agent fails to resolve to a valid ID.
|
|
714
757
|
"""
|
|
715
758
|
if not self.agents:
|
|
716
759
|
return []
|
|
717
760
|
|
|
718
|
-
|
|
719
|
-
|
|
761
|
+
resolved_ids: list[str] = []
|
|
762
|
+
for agent_ref in self.agents:
|
|
763
|
+
agent = registry.resolve(agent_ref)
|
|
764
|
+
if not agent.id:
|
|
765
|
+
raise ValueError(f"Failed to resolve ID for agent: {agent_ref}")
|
|
766
|
+
resolved_ids.append(agent.id)
|
|
767
|
+
return resolved_ids
|
|
720
768
|
|
|
721
769
|
def _create_or_update_agent(
|
|
722
770
|
self,
|
|
@@ -807,6 +855,8 @@ class Agent:
|
|
|
807
855
|
Returns:
|
|
808
856
|
Dictionary containing Agent attributes.
|
|
809
857
|
"""
|
|
858
|
+
config = self._build_config(get_tool_registry(), get_mcp_registry())
|
|
859
|
+
|
|
810
860
|
data = {
|
|
811
861
|
"id": self._id,
|
|
812
862
|
"name": self.name,
|
|
@@ -820,10 +870,11 @@ class Agent:
|
|
|
820
870
|
"mcps": self.mcps,
|
|
821
871
|
"timeout": self.timeout,
|
|
822
872
|
"metadata": self.metadata,
|
|
823
|
-
"agent_config":
|
|
873
|
+
"agent_config": config.get("agent_config"),
|
|
824
874
|
"tool_configs": self.tool_configs,
|
|
825
875
|
"mcp_configs": self.mcp_configs,
|
|
826
876
|
"a2a_profile": self.a2a_profile,
|
|
877
|
+
"guardrail": self.guardrail,
|
|
827
878
|
"created_at": self._created_at,
|
|
828
879
|
"updated_at": self._updated_at,
|
|
829
880
|
}
|
|
@@ -294,12 +294,14 @@ class RemoteRunsController:
|
|
|
294
294
|
fetch_detail=fetch_detail,
|
|
295
295
|
export_run=export_run,
|
|
296
296
|
)
|
|
297
|
+
tui_ctx = getattr(self.session, "tui_ctx", None)
|
|
297
298
|
page, limit, cursor = run_remote_runs_textual(
|
|
298
299
|
runs_page,
|
|
299
300
|
state.get("cursor", 0),
|
|
300
301
|
callbacks,
|
|
301
302
|
agent_name=agent_name,
|
|
302
303
|
agent_id=agent_id,
|
|
304
|
+
ctx=tui_ctx,
|
|
303
305
|
)
|
|
304
306
|
state["page"] = page
|
|
305
307
|
state["limit"] = limit
|
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
* Keep layout compact: filter sits tight above the table; header shows active account.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
Screen {
|
|
7
|
+
layers: base toasts;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
#toast-container {
|
|
11
|
+
width: 100%;
|
|
12
|
+
height: auto;
|
|
13
|
+
dock: top;
|
|
14
|
+
align: right top;
|
|
15
|
+
layer: toasts;
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
#header-info {
|
|
7
19
|
padding: 0 1 0 1;
|
|
8
20
|
margin: 0;
|
|
@@ -18,7 +18,7 @@ import asyncio
|
|
|
18
18
|
import logging
|
|
19
19
|
from collections.abc import Callable
|
|
20
20
|
from dataclasses import dataclass
|
|
21
|
-
from typing import Any
|
|
21
|
+
from typing import Any, cast
|
|
22
22
|
|
|
23
23
|
from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
|
|
24
24
|
from glaip_sdk.cli.commands.common_config import check_connection_with_reason
|
|
@@ -34,7 +34,15 @@ from glaip_sdk.cli.slash.tui.keybind_registry import KeybindRegistry
|
|
|
34
34
|
from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
|
|
35
35
|
from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
|
|
36
36
|
from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
try: # pragma: no cover - optional dependency
|
|
39
|
+
from glaip_sdk.cli.slash.tui.toast import ClipboardToastMixin, Toast, ToastBus, ToastHandlerMixin, ToastVariant
|
|
40
|
+
except Exception: # pragma: no cover - optional dependency
|
|
41
|
+
ClipboardToastMixin = object # type: ignore[assignment, misc]
|
|
42
|
+
Toast = None # type: ignore[assignment]
|
|
43
|
+
ToastBus = None # type: ignore[assignment]
|
|
44
|
+
ToastHandlerMixin = object # type: ignore[assignment, misc]
|
|
45
|
+
ToastVariant = None # type: ignore[assignment]
|
|
38
46
|
from glaip_sdk.cli.validators import validate_api_key
|
|
39
47
|
from glaip_sdk.utils.validation import validate_url
|
|
40
48
|
|
|
@@ -44,6 +52,7 @@ try: # pragma: no cover - optional dependency
|
|
|
44
52
|
from textual.binding import Binding
|
|
45
53
|
from textual.containers import Container, Horizontal, Vertical
|
|
46
54
|
from textual.screen import ModalScreen
|
|
55
|
+
from textual.suggester import SuggestFromList
|
|
47
56
|
from textual.widgets import Button, Checkbox, DataTable, Footer, Header, Input, LoadingIndicator, Static
|
|
48
57
|
except Exception: # pragma: no cover - optional dependency
|
|
49
58
|
events = None # type: ignore[assignment]
|
|
@@ -62,6 +71,7 @@ except Exception: # pragma: no cover - optional dependency
|
|
|
62
71
|
LoadingIndicator = None # type: ignore[assignment]
|
|
63
72
|
ModalScreen = None # type: ignore[assignment]
|
|
64
73
|
Static = None # type: ignore[assignment]
|
|
74
|
+
SuggestFromList = None # type: ignore[assignment]
|
|
65
75
|
Theme = None # type: ignore[assignment]
|
|
66
76
|
|
|
67
77
|
if App is not None:
|
|
@@ -312,11 +322,16 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
|
|
|
312
322
|
id="form-name",
|
|
313
323
|
disabled=self._mode == "edit",
|
|
314
324
|
)
|
|
325
|
+
# Get API URL suggestions and create suggester
|
|
326
|
+
url_suggestions = self._get_api_url_suggestions("")
|
|
327
|
+
url_suggester = None
|
|
328
|
+
if SuggestFromList and url_suggestions:
|
|
329
|
+
url_suggester = SuggestFromList(url_suggestions, case_sensitive=False)
|
|
315
330
|
url_input = Input(
|
|
316
331
|
value=self._existing.get("api_url", ""),
|
|
317
332
|
placeholder="https://api.example.com",
|
|
318
333
|
id="form-url",
|
|
319
|
-
|
|
334
|
+
suggester=url_suggester,
|
|
320
335
|
)
|
|
321
336
|
key_input = Input(value="", placeholder="sk-...", password=True, id="form-key")
|
|
322
337
|
test_checkbox = Checkbox(
|
|
@@ -443,7 +458,9 @@ class ConfirmDeleteModal(_ConfirmDeleteBase): # pragma: no cover - interactive
|
|
|
443
458
|
self.dismiss(self._name)
|
|
444
459
|
|
|
445
460
|
|
|
446
|
-
class AccountsTextualApp(
|
|
461
|
+
class AccountsTextualApp( # pragma: no cover - interactive
|
|
462
|
+
ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, _AppBase
|
|
463
|
+
):
|
|
447
464
|
"""Textual application for browsing accounts."""
|
|
448
465
|
|
|
449
466
|
CSS_PATH = CSS_FILE_NAME
|
|
@@ -487,7 +504,6 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
487
504
|
self._ctx = ctx
|
|
488
505
|
self._keybinds: KeybindRegistry | None = None
|
|
489
506
|
self._toast_bus: ToastBus | None = None
|
|
490
|
-
self._toast_ready = False
|
|
491
507
|
self._clipboard: ClipboardAdapter | None = None
|
|
492
508
|
self._filter_text: str = ""
|
|
493
509
|
self._is_switching = False
|
|
@@ -519,6 +535,8 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
519
535
|
main.styles.height = "1fr"
|
|
520
536
|
main.styles.padding = (0, 0)
|
|
521
537
|
yield main
|
|
538
|
+
if Toast is not None:
|
|
539
|
+
yield Container(Toast(), id="toast-container")
|
|
522
540
|
yield Horizontal(
|
|
523
541
|
LoadingIndicator(id=ACCOUNTS_LOADING_ID.lstrip("#")),
|
|
524
542
|
Static("", id=STATUS_ID.lstrip("#")),
|
|
@@ -548,11 +566,14 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
548
566
|
self._register_keybinds()
|
|
549
567
|
|
|
550
568
|
def _initialize_context_services(self) -> None:
|
|
569
|
+
def _notify(message: ToastBus.Changed) -> None:
|
|
570
|
+
self.post_message(message)
|
|
571
|
+
|
|
551
572
|
if self._ctx:
|
|
552
573
|
if self._ctx.keybinds is None:
|
|
553
574
|
self._ctx.keybinds = KeybindRegistry()
|
|
554
|
-
if self._ctx.toasts is None:
|
|
555
|
-
self._ctx.toasts = ToastBus()
|
|
575
|
+
if self._ctx.toasts is None and ToastBus is not None:
|
|
576
|
+
self._ctx.toasts = ToastBus(on_change=_notify)
|
|
556
577
|
if self._ctx.clipboard is None:
|
|
557
578
|
self._ctx.clipboard = ClipboardAdapter(terminal=self._ctx.terminal)
|
|
558
579
|
self._keybinds = self._ctx.keybinds
|
|
@@ -564,10 +585,11 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
564
585
|
tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
|
|
565
586
|
)
|
|
566
587
|
self._clipboard = ClipboardAdapter(terminal=terminal)
|
|
588
|
+
if ToastBus is not None:
|
|
589
|
+
self._toast_bus = ToastBus(on_change=_notify)
|
|
567
590
|
|
|
568
591
|
def _prepare_toasts(self) -> None:
|
|
569
|
-
"""Prepare toast system by
|
|
570
|
-
self._toast_ready = True
|
|
592
|
+
"""Prepare toast system by clearing any existing toasts."""
|
|
571
593
|
if self._toast_bus:
|
|
572
594
|
self._toast_bus.clear()
|
|
573
595
|
|
|
@@ -761,7 +783,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
761
783
|
self._hide_loading()
|
|
762
784
|
self._is_switching = False
|
|
763
785
|
error_msg = f"Switch failed to start: {exc}"
|
|
764
|
-
if self.
|
|
786
|
+
if self._toast_bus:
|
|
765
787
|
self._toast_bus.show(message=error_msg, variant="error")
|
|
766
788
|
try:
|
|
767
789
|
self._set_status(error_msg, "red")
|
|
@@ -793,7 +815,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
793
815
|
if switched:
|
|
794
816
|
self._active_account = name
|
|
795
817
|
status_msg = message or f"Switched to '{name}'."
|
|
796
|
-
if self.
|
|
818
|
+
if self._toast_bus:
|
|
797
819
|
self._toast_bus.show(message=status_msg, variant="success")
|
|
798
820
|
self._update_header()
|
|
799
821
|
self._reload_rows()
|
|
@@ -906,34 +928,54 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
906
928
|
return
|
|
907
929
|
|
|
908
930
|
text = f"Account: {name}\nURL: {account.get('api_url', '')}"
|
|
909
|
-
adapter = self.
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
931
|
+
adapter = self._clipboard_adapter()
|
|
932
|
+
writer = self._osc52_writer()
|
|
933
|
+
if writer:
|
|
934
|
+
result = adapter.copy(text, writer=writer)
|
|
935
|
+
else:
|
|
914
936
|
result = adapter.copy(text)
|
|
915
|
-
|
|
916
|
-
return
|
|
917
|
-
|
|
918
|
-
async def perform() -> None:
|
|
919
|
-
result = await asyncio.to_thread(adapter.copy, text)
|
|
920
|
-
self._handle_copy_result(name, result)
|
|
921
|
-
|
|
922
|
-
self.track_task(perform(), logger=logging.getLogger(__name__))
|
|
937
|
+
self._handle_copy_result(name, result)
|
|
923
938
|
|
|
924
939
|
def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
|
|
925
940
|
"""Update UI state after a copy attempt."""
|
|
926
941
|
if result.success:
|
|
927
|
-
if self.
|
|
928
|
-
self._toast_bus.copy_success(
|
|
929
|
-
# Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
|
|
942
|
+
if self._toast_bus:
|
|
943
|
+
self._toast_bus.copy_success(f"Account '{name}'")
|
|
930
944
|
self._set_status(f"Copied '{name}' to clipboard.", "green")
|
|
931
945
|
else:
|
|
932
|
-
if self.
|
|
933
|
-
self._toast_bus.show(message=f"Copy failed: {result.message}", variant=
|
|
934
|
-
# Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
|
|
946
|
+
if self._toast_bus and ToastVariant is not None:
|
|
947
|
+
self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
|
|
935
948
|
self._set_status(f"Copy failed: {result.message}", "red")
|
|
936
949
|
|
|
950
|
+
def _clipboard_adapter(self) -> ClipboardAdapter:
|
|
951
|
+
if self._ctx is not None and self._ctx.clipboard is not None:
|
|
952
|
+
return cast(ClipboardAdapter, self._ctx.clipboard)
|
|
953
|
+
if self._clipboard is not None:
|
|
954
|
+
return self._clipboard
|
|
955
|
+
adapter = ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
|
|
956
|
+
if self._ctx is not None:
|
|
957
|
+
self._ctx.clipboard = adapter
|
|
958
|
+
else:
|
|
959
|
+
self._clipboard = adapter
|
|
960
|
+
return adapter
|
|
961
|
+
|
|
962
|
+
def _osc52_writer(self) -> Callable[[str], Any] | None:
|
|
963
|
+
try:
|
|
964
|
+
console = getattr(self, "console", None)
|
|
965
|
+
except Exception:
|
|
966
|
+
return None
|
|
967
|
+
if console is None:
|
|
968
|
+
return None
|
|
969
|
+
output = getattr(console, "file", None)
|
|
970
|
+
if output is None:
|
|
971
|
+
return None
|
|
972
|
+
|
|
973
|
+
def _write(sequence: str, _output=output) -> None:
|
|
974
|
+
_output.write(sequence)
|
|
975
|
+
_output.flush()
|
|
976
|
+
|
|
977
|
+
return _write
|
|
978
|
+
|
|
937
979
|
def _check_env_lock_hotkey(self) -> bool:
|
|
938
980
|
"""Prevent mutations when env credentials are present."""
|
|
939
981
|
if not self._is_env_locked():
|
|
@@ -45,6 +45,36 @@ _SUBPROCESS_COMMANDS: dict[ClipboardMethod, list[str]] = {
|
|
|
45
45
|
ClipboardMethod.CLIP: ["clip"],
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
_ENV_CLIPBOARD_METHOD = "AIP_TUI_CLIPBOARD_METHOD"
|
|
49
|
+
_ENV_CLIPBOARD_FORCE = "AIP_TUI_CLIPBOARD_FORCE"
|
|
50
|
+
_ENV_METHOD_MAP = {
|
|
51
|
+
"osc52": ClipboardMethod.OSC52,
|
|
52
|
+
"pbcopy": ClipboardMethod.PBCOPY,
|
|
53
|
+
"xclip": ClipboardMethod.XCLIP,
|
|
54
|
+
"xsel": ClipboardMethod.XSEL,
|
|
55
|
+
"wl-copy": ClipboardMethod.WL_COPY,
|
|
56
|
+
"wl_copy": ClipboardMethod.WL_COPY,
|
|
57
|
+
"clip": ClipboardMethod.CLIP,
|
|
58
|
+
"none": ClipboardMethod.NONE,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_env_method() -> ClipboardMethod | None:
|
|
63
|
+
raw = os.getenv(_ENV_CLIPBOARD_METHOD)
|
|
64
|
+
if not raw:
|
|
65
|
+
return None
|
|
66
|
+
value = raw.strip().lower()
|
|
67
|
+
if value in ("auto", "default"):
|
|
68
|
+
return None
|
|
69
|
+
return _ENV_METHOD_MAP.get(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_env_force_enabled() -> bool:
|
|
73
|
+
raw = os.getenv(_ENV_CLIPBOARD_FORCE)
|
|
74
|
+
if not raw:
|
|
75
|
+
return False
|
|
76
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
77
|
+
|
|
48
78
|
|
|
49
79
|
class ClipboardAdapter:
|
|
50
80
|
"""Cross-platform clipboard access with OSC 52 fallback."""
|
|
@@ -57,7 +87,16 @@ class ClipboardAdapter:
|
|
|
57
87
|
) -> None:
|
|
58
88
|
"""Initialize the adapter."""
|
|
59
89
|
self._terminal = terminal
|
|
60
|
-
self.
|
|
90
|
+
self._force_method = False
|
|
91
|
+
if method is not None:
|
|
92
|
+
self._method = method
|
|
93
|
+
else:
|
|
94
|
+
env_method = _resolve_env_method()
|
|
95
|
+
if env_method is not None:
|
|
96
|
+
self._method = env_method
|
|
97
|
+
self._force_method = _is_env_force_enabled()
|
|
98
|
+
else:
|
|
99
|
+
self._method = self._detect_method()
|
|
61
100
|
|
|
62
101
|
@property
|
|
63
102
|
def method(self) -> ClipboardMethod:
|
|
@@ -77,25 +116,34 @@ class ClipboardAdapter:
|
|
|
77
116
|
|
|
78
117
|
command = _SUBPROCESS_COMMANDS.get(self._method)
|
|
79
118
|
if command is None:
|
|
119
|
+
if self._force_method:
|
|
120
|
+
return ClipboardResult(False, self._method, "Forced clipboard method unavailable.")
|
|
80
121
|
return self._copy_osc52(text, writer=writer)
|
|
81
122
|
|
|
82
123
|
result = self._copy_subprocess(command, text)
|
|
83
124
|
if not result.success:
|
|
125
|
+
if self._force_method:
|
|
126
|
+
return result
|
|
84
127
|
return self._copy_osc52(text, writer=writer)
|
|
85
128
|
|
|
86
129
|
return result
|
|
87
130
|
|
|
88
131
|
def _detect_method(self) -> ClipboardMethod:
|
|
132
|
+
system = platform.system()
|
|
133
|
+
method = ClipboardMethod.NONE
|
|
134
|
+
if system == "Darwin":
|
|
135
|
+
method = self._detect_darwin_method()
|
|
136
|
+
elif system == "Linux":
|
|
137
|
+
method = self._detect_linux_method()
|
|
138
|
+
elif system == "Windows":
|
|
139
|
+
method = self._detect_windows_method()
|
|
140
|
+
|
|
141
|
+
if method is not ClipboardMethod.NONE:
|
|
142
|
+
return method
|
|
143
|
+
|
|
89
144
|
if self._terminal.osc52 if self._terminal else detect_osc52_support():
|
|
90
145
|
return ClipboardMethod.OSC52
|
|
91
146
|
|
|
92
|
-
system = platform.system()
|
|
93
|
-
if system == "Darwin":
|
|
94
|
-
return self._detect_darwin_method()
|
|
95
|
-
if system == "Linux":
|
|
96
|
-
return self._detect_linux_method()
|
|
97
|
-
if system == "Windows":
|
|
98
|
-
return self._detect_windows_method()
|
|
99
147
|
return ClipboardMethod.NONE
|
|
100
148
|
|
|
101
149
|
def _detect_darwin_method(self) -> ClipboardMethod:
|