glaip-sdk 0.6.19__py3-none-any.whl → 0.7.27__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 +283 -30
- glaip_sdk/agents/component.py +233 -0
- glaip_sdk/branding.py +113 -2
- glaip_sdk/cli/account_store.py +15 -0
- glaip_sdk/cli/auth.py +14 -8
- glaip_sdk/cli/commands/accounts.py +1 -1
- glaip_sdk/cli/commands/agents/__init__.py +116 -0
- glaip_sdk/cli/commands/agents/_common.py +562 -0
- glaip_sdk/cli/commands/agents/create.py +155 -0
- glaip_sdk/cli/commands/agents/delete.py +64 -0
- glaip_sdk/cli/commands/agents/get.py +89 -0
- glaip_sdk/cli/commands/agents/list.py +129 -0
- glaip_sdk/cli/commands/agents/run.py +264 -0
- glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
- glaip_sdk/cli/commands/agents/update.py +112 -0
- glaip_sdk/cli/commands/common_config.py +1 -1
- glaip_sdk/cli/commands/configure.py +1 -2
- glaip_sdk/cli/commands/mcps/__init__.py +94 -0
- glaip_sdk/cli/commands/mcps/_common.py +459 -0
- glaip_sdk/cli/commands/mcps/connect.py +82 -0
- glaip_sdk/cli/commands/mcps/create.py +152 -0
- glaip_sdk/cli/commands/mcps/delete.py +73 -0
- glaip_sdk/cli/commands/mcps/get.py +212 -0
- glaip_sdk/cli/commands/mcps/list.py +69 -0
- glaip_sdk/cli/commands/mcps/tools.py +235 -0
- glaip_sdk/cli/commands/mcps/update.py +190 -0
- glaip_sdk/cli/commands/models.py +2 -4
- glaip_sdk/cli/commands/shared/__init__.py +21 -0
- glaip_sdk/cli/commands/shared/formatters.py +91 -0
- glaip_sdk/cli/commands/tools/__init__.py +69 -0
- glaip_sdk/cli/commands/tools/_common.py +80 -0
- glaip_sdk/cli/commands/tools/create.py +228 -0
- glaip_sdk/cli/commands/tools/delete.py +61 -0
- glaip_sdk/cli/commands/tools/get.py +103 -0
- glaip_sdk/cli/commands/tools/list.py +69 -0
- glaip_sdk/cli/commands/tools/script.py +49 -0
- glaip_sdk/cli/commands/tools/update.py +102 -0
- glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
- glaip_sdk/cli/commands/transcripts/_common.py +9 -0
- glaip_sdk/cli/commands/transcripts/clear.py +5 -0
- glaip_sdk/cli/commands/transcripts/detail.py +5 -0
- glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
- glaip_sdk/cli/commands/update.py +163 -17
- glaip_sdk/cli/config.py +1 -0
- glaip_sdk/cli/entrypoint.py +20 -0
- glaip_sdk/cli/main.py +112 -35
- glaip_sdk/cli/pager.py +3 -3
- glaip_sdk/cli/resolution.py +2 -1
- glaip_sdk/cli/slash/accounts_controller.py +3 -1
- glaip_sdk/cli/slash/agent_session.py +1 -1
- glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
- glaip_sdk/cli/slash/session.py +343 -20
- glaip_sdk/cli/slash/tui/__init__.py +29 -1
- glaip_sdk/cli/slash/tui/accounts.tcss +97 -6
- glaip_sdk/cli/slash/tui/accounts_app.py +1117 -126
- glaip_sdk/cli/slash/tui/clipboard.py +316 -0
- glaip_sdk/cli/slash/tui/context.py +92 -0
- glaip_sdk/cli/slash/tui/indicators.py +341 -0
- glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
- glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
- glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
- glaip_sdk/cli/slash/tui/loading.py +43 -21
- glaip_sdk/cli/slash/tui/remote_runs_app.py +178 -20
- glaip_sdk/cli/slash/tui/terminal.py +407 -0
- glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
- glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
- glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
- glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
- glaip_sdk/cli/slash/tui/toast.py +388 -0
- glaip_sdk/cli/transcript/history.py +1 -1
- glaip_sdk/cli/transcript/viewer.py +1 -1
- glaip_sdk/cli/tui_settings.py +125 -0
- glaip_sdk/cli/update_notifier.py +215 -7
- glaip_sdk/cli/validators.py +1 -1
- glaip_sdk/client/__init__.py +2 -1
- glaip_sdk/client/_schedule_payloads.py +89 -0
- glaip_sdk/client/agents.py +293 -17
- glaip_sdk/client/base.py +25 -0
- glaip_sdk/client/hitl.py +136 -0
- glaip_sdk/client/main.py +7 -5
- glaip_sdk/client/mcps.py +44 -13
- glaip_sdk/client/payloads/agent/__init__.py +23 -0
- glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +28 -48
- glaip_sdk/client/payloads/agent/responses.py +43 -0
- glaip_sdk/client/run_rendering.py +109 -30
- glaip_sdk/client/schedules.py +439 -0
- glaip_sdk/client/tools.py +52 -23
- glaip_sdk/config/constants.py +22 -2
- glaip_sdk/guardrails/__init__.py +80 -0
- glaip_sdk/guardrails/serializer.py +91 -0
- glaip_sdk/hitl/__init__.py +35 -2
- glaip_sdk/hitl/base.py +64 -0
- glaip_sdk/hitl/callback.py +43 -0
- glaip_sdk/hitl/local.py +1 -31
- glaip_sdk/hitl/remote.py +523 -0
- glaip_sdk/models/__init__.py +47 -1
- glaip_sdk/models/_provider_mappings.py +101 -0
- glaip_sdk/models/_validation.py +97 -0
- glaip_sdk/models/agent.py +2 -1
- glaip_sdk/models/agent_runs.py +2 -1
- glaip_sdk/models/constants.py +141 -0
- glaip_sdk/models/model.py +170 -0
- glaip_sdk/models/schedule.py +224 -0
- glaip_sdk/payload_schemas/agent.py +1 -0
- glaip_sdk/payload_schemas/guardrails.py +34 -0
- glaip_sdk/ptc.py +145 -0
- glaip_sdk/registry/tool.py +270 -57
- glaip_sdk/runner/__init__.py +20 -3
- glaip_sdk/runner/deps.py +4 -1
- glaip_sdk/runner/langgraph.py +251 -27
- glaip_sdk/runner/logging_config.py +77 -0
- glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +30 -9
- glaip_sdk/runner/ptc_adapter.py +98 -0
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +25 -2
- glaip_sdk/schedules/__init__.py +22 -0
- glaip_sdk/schedules/base.py +291 -0
- glaip_sdk/tools/base.py +67 -14
- glaip_sdk/utils/__init__.py +1 -0
- glaip_sdk/utils/agent_config.py +8 -2
- glaip_sdk/utils/bundler.py +138 -2
- glaip_sdk/utils/import_resolver.py +427 -49
- glaip_sdk/utils/runtime_config.py +3 -2
- glaip_sdk/utils/sync.py +31 -11
- glaip_sdk/utils/tool_detection.py +274 -6
- {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/METADATA +22 -8
- glaip_sdk-0.7.27.dist-info/RECORD +227 -0
- {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/WHEEL +1 -1
- glaip_sdk-0.7.27.dist-info/entry_points.txt +2 -0
- glaip_sdk/cli/commands/agents.py +0 -1509
- glaip_sdk/cli/commands/mcps.py +0 -1356
- glaip_sdk/cli/commands/tools.py +0 -576
- glaip_sdk/cli/utils.py +0 -263
- glaip_sdk-0.6.19.dist-info/RECORD +0 -163
- glaip_sdk-0.6.19.dist-info/entry_points.txt +0 -2
- {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Harlequin layout base class for multi-pane TUI screens.
|
|
2
|
+
|
|
3
|
+
This module provides the HarlequinScreen base class, which implements a modern
|
|
4
|
+
multi-pane "Harlequin" layout pattern for data-rich TUI screens. The layout uses
|
|
5
|
+
a 25/75 split with a list on the left and detail content on the right.
|
|
6
|
+
|
|
7
|
+
The Harlequin pattern is inspired by the Harlequin SQL client and provides:
|
|
8
|
+
- Left Pane (25%): ListView or compact table for item selection
|
|
9
|
+
- Right Pane (75%): Detail dashboard showing all fields, status, and action buttons
|
|
10
|
+
- Black background (#000000) that overrides terminal transparency
|
|
11
|
+
- Primary Blue borders (#005CB8)
|
|
12
|
+
|
|
13
|
+
Authors:
|
|
14
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
try: # pragma: no cover - optional dependency
|
|
22
|
+
from textual.screen import Screen
|
|
23
|
+
from textual.widget import Widget
|
|
24
|
+
except Exception: # pragma: no cover - optional dependency
|
|
25
|
+
|
|
26
|
+
class Screen: # type: ignore[no-redef]
|
|
27
|
+
"""Fallback Screen stub when Textual is unavailable."""
|
|
28
|
+
|
|
29
|
+
def __class_getitem__(cls, _):
|
|
30
|
+
"""Return the class for typing subscripts."""
|
|
31
|
+
return cls
|
|
32
|
+
|
|
33
|
+
Widget = None # type: ignore[assignment]
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from glaip_sdk.cli.slash.tui.context import TUIContext
|
|
37
|
+
|
|
38
|
+
try: # pragma: no cover - optional dependency
|
|
39
|
+
from glaip_sdk.cli.slash.tui.toast import Toast, ToastContainer
|
|
40
|
+
except Exception: # pragma: no cover - optional dependency
|
|
41
|
+
Toast = None # type: ignore[assignment, misc]
|
|
42
|
+
ToastContainer = None # type: ignore[assignment, misc]
|
|
43
|
+
|
|
44
|
+
# GDP Labs Brand Palette
|
|
45
|
+
PRIMARY_BLUE = "#005CB8"
|
|
46
|
+
BLACK_BACKGROUND = "#000000"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if Widget is not None:
|
|
50
|
+
|
|
51
|
+
class HarlequinContainer(Widget):
|
|
52
|
+
"""Base container for the Harlequin layout."""
|
|
53
|
+
|
|
54
|
+
DEFAULT_CSS = """
|
|
55
|
+
HarlequinContainer {
|
|
56
|
+
layout: horizontal;
|
|
57
|
+
}
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
class HarlequinPane(Widget):
|
|
61
|
+
"""Pane container for Harlequin layout sections."""
|
|
62
|
+
|
|
63
|
+
DEFAULT_CSS = """
|
|
64
|
+
HarlequinPane {
|
|
65
|
+
layout: vertical;
|
|
66
|
+
}
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
else:
|
|
70
|
+
HarlequinContainer = None # type: ignore[assignment, misc]
|
|
71
|
+
HarlequinPane = None # type: ignore[assignment, misc]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class HarlequinScreen(Screen[None]): # type: ignore[misc]
|
|
75
|
+
"""Base class for Harlequin-style multi-pane screens.
|
|
76
|
+
|
|
77
|
+
This screen provides a 25/75 split layout with a left pane for navigation
|
|
78
|
+
and a right pane for details. The layout uses a black background that
|
|
79
|
+
overrides terminal transparency and primary blue borders.
|
|
80
|
+
|
|
81
|
+
Subclasses should override `compose()` to add their specific widgets to
|
|
82
|
+
the left and right panes. Use the container IDs "left-pane" and "right-pane"
|
|
83
|
+
to target specific panes in CSS or when querying widgets.
|
|
84
|
+
|
|
85
|
+
Example:
|
|
86
|
+
```python
|
|
87
|
+
class AccountHarlequinScreen(HarlequinScreen):
|
|
88
|
+
def compose(self) -> ComposeResult:
|
|
89
|
+
yield from super().compose()
|
|
90
|
+
# Add widgets to left and right panes
|
|
91
|
+
self.query_one("#left-pane").mount(AccountListView())
|
|
92
|
+
self.query_one("#right-pane").mount(AccountDetailView())
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
CSS:
|
|
96
|
+
The screen includes default styling for the Harlequin layout:
|
|
97
|
+
- Black background (#000000) for the entire screen
|
|
98
|
+
- Primary blue borders (#005CB8) for panes
|
|
99
|
+
- 25% width for left pane, 75% width for right pane
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
CSS = """
|
|
103
|
+
HarlequinScreen {
|
|
104
|
+
background: #000000;
|
|
105
|
+
layers: base toasts;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#harlequin-container {
|
|
109
|
+
width: 100%;
|
|
110
|
+
height: 100%;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#left-pane {
|
|
114
|
+
width: 25%;
|
|
115
|
+
border: solid #005CB8;
|
|
116
|
+
background: #000000;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#right-pane {
|
|
120
|
+
width: 75%;
|
|
121
|
+
border: solid #005CB8;
|
|
122
|
+
background: #000000;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#toast-container {
|
|
126
|
+
width: 100%;
|
|
127
|
+
height: auto;
|
|
128
|
+
dock: top;
|
|
129
|
+
align: right top;
|
|
130
|
+
layer: toasts;
|
|
131
|
+
}
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
ctx: TUIContext | None = None,
|
|
138
|
+
name: str | None = None,
|
|
139
|
+
id: str | None = None,
|
|
140
|
+
classes: str | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Initialize the Harlequin screen.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
ctx: Optional TUI context for accessing services (keybinds, theme, toasts, clipboard).
|
|
146
|
+
name: Optional name for the screen.
|
|
147
|
+
id: Optional ID for the screen.
|
|
148
|
+
classes: Optional CSS classes for the screen.
|
|
149
|
+
"""
|
|
150
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
151
|
+
self._ctx: TUIContext | None = ctx
|
|
152
|
+
|
|
153
|
+
def compose(self) -> Any:
|
|
154
|
+
"""Compose the Harlequin layout with left and right panes.
|
|
155
|
+
|
|
156
|
+
This method creates the base 25/75 split layout. Subclasses should
|
|
157
|
+
call `super().compose()` and then add their specific widgets to the
|
|
158
|
+
left and right panes.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
ComposeResult yielding the base layout containers.
|
|
162
|
+
"""
|
|
163
|
+
if HarlequinContainer is None or HarlequinPane is None:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
# Main container with horizontal split (25/75)
|
|
167
|
+
yield HarlequinContainer(
|
|
168
|
+
HarlequinPane(id="left-pane"),
|
|
169
|
+
HarlequinPane(id="right-pane"),
|
|
170
|
+
id="harlequin-container",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Toast container for notifications
|
|
174
|
+
if Toast is not None and ToastContainer is not None:
|
|
175
|
+
yield ToastContainer(Toast(), id="toast-container")
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def ctx(self) -> TUIContext | None:
|
|
179
|
+
"""Get the TUI context if available.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
TUIContext instance or None if not provided.
|
|
183
|
+
"""
|
|
184
|
+
return self._ctx
|
|
@@ -1,58 +1,80 @@
|
|
|
1
1
|
"""Shared helpers for toggling Textual loading indicators.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
This module provides unified helpers for showing/hiding both the built-in
|
|
4
|
+
Textual LoadingIndicator and the custom PulseIndicator.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
from collections.abc import Callable
|
|
10
|
-
from typing import
|
|
10
|
+
from typing import Any
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
from textual.widgets import LoadingIndicator
|
|
14
|
-
except Exception: # pragma: no cover - optional dependency
|
|
15
|
-
LoadingIndicator = None # type: ignore[assignment]
|
|
12
|
+
from textual.widgets import LoadingIndicator
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
from textual.widgets import LoadingIndicator as _LoadingIndicatorType
|
|
14
|
+
from glaip_sdk.cli.slash.tui.indicators import PulseIndicator
|
|
19
15
|
|
|
20
|
-
LoadingIndicator: type[_LoadingIndicatorType] | None
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
17
|
+
def _set_indicator_display(app: Any, selector: str, visible: bool) -> None:
|
|
18
|
+
try:
|
|
19
|
+
indicator = app.query_one(selector, PulseIndicator)
|
|
20
|
+
if visible:
|
|
21
|
+
indicator.display = True
|
|
22
|
+
indicator.start()
|
|
23
|
+
else:
|
|
24
|
+
indicator.stop()
|
|
25
|
+
indicator.display = False
|
|
26
26
|
return
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
|
|
27
30
|
try:
|
|
28
|
-
indicator = app.query_one(selector, LoadingIndicator)
|
|
31
|
+
indicator = app.query_one(selector, LoadingIndicator)
|
|
29
32
|
indicator.display = visible
|
|
30
33
|
except Exception:
|
|
31
|
-
# Ignore lookup/rendering errors to keep UI resilient
|
|
32
34
|
return
|
|
33
35
|
|
|
34
36
|
|
|
35
37
|
def show_loading_indicator(
|
|
36
|
-
app:
|
|
38
|
+
app: Any,
|
|
37
39
|
selector: str,
|
|
38
40
|
*,
|
|
39
41
|
message: str | None = None,
|
|
40
42
|
set_status: Callable[..., None] | None = None,
|
|
41
43
|
status_style: str = "cyan",
|
|
42
44
|
) -> None:
|
|
43
|
-
"""Show a loading indicator and optionally set a status message.
|
|
45
|
+
"""Show a loading indicator (PulseIndicator or LoadingIndicator) and optionally set a status message.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
app: Textual app instance containing the indicator widget
|
|
49
|
+
selector: CSS selector for the indicator widget
|
|
50
|
+
message: Optional message to display in the indicator
|
|
51
|
+
set_status: Optional callback to set status message (for fallback display)
|
|
52
|
+
status_style: Style for status message if set_status is provided
|
|
53
|
+
"""
|
|
44
54
|
_set_indicator_display(app, selector, True)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
indicator = app.query_one(selector, PulseIndicator)
|
|
58
|
+
if message:
|
|
59
|
+
indicator.update_message(message)
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
45
63
|
if message and set_status:
|
|
46
64
|
try:
|
|
47
65
|
set_status(message, status_style)
|
|
48
66
|
except TypeError:
|
|
49
|
-
# Fallback for setters that accept only a single arg or kwargs
|
|
50
67
|
try:
|
|
51
68
|
set_status(message)
|
|
52
69
|
except Exception:
|
|
53
70
|
return
|
|
54
71
|
|
|
55
72
|
|
|
56
|
-
def hide_loading_indicator(app:
|
|
57
|
-
"""Hide a loading indicator.
|
|
73
|
+
def hide_loading_indicator(app: Any, selector: str) -> None:
|
|
74
|
+
"""Hide a loading indicator (PulseIndicator or LoadingIndicator).
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
app: Textual app instance containing the indicator widget
|
|
78
|
+
selector: CSS selector for the indicator widget
|
|
79
|
+
"""
|
|
58
80
|
_set_indicator_display(app, selector, False)
|
|
@@ -17,14 +17,20 @@ from dataclasses import dataclass
|
|
|
17
17
|
from typing import Any
|
|
18
18
|
|
|
19
19
|
from rich.text import Text
|
|
20
|
+
|
|
20
21
|
from textual.app import App, ComposeResult
|
|
21
22
|
from textual.binding import Binding
|
|
22
|
-
from textual.containers import
|
|
23
|
+
from textual.containers import Horizontal, Vertical
|
|
24
|
+
from textual.coordinate import Coordinate
|
|
23
25
|
from textual.reactive import ReactiveError
|
|
24
26
|
from textual.screen import ModalScreen
|
|
25
|
-
from textual.widgets import DataTable, Footer, Header,
|
|
27
|
+
from textual.widgets import DataTable, Footer, Header, RichLog, Static
|
|
26
28
|
|
|
29
|
+
from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
|
|
30
|
+
from glaip_sdk.cli.slash.tui.context import TUIContext
|
|
31
|
+
from glaip_sdk.cli.slash.tui.indicators import PulseIndicator
|
|
27
32
|
from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
|
|
33
|
+
from glaip_sdk.cli.slash.tui.toast import ClipboardToastMixin, Toast, ToastBus, ToastContainer, ToastHandlerMixin
|
|
28
34
|
|
|
29
35
|
logger = logging.getLogger(__name__)
|
|
30
36
|
|
|
@@ -50,6 +56,7 @@ def run_remote_runs_textual(
|
|
|
50
56
|
*,
|
|
51
57
|
agent_name: str | None = None,
|
|
52
58
|
agent_id: str | None = None,
|
|
59
|
+
ctx: TUIContext | None = None,
|
|
53
60
|
) -> tuple[int, int, int]:
|
|
54
61
|
"""Launch the Textual application and return the final pagination state.
|
|
55
62
|
|
|
@@ -59,6 +66,7 @@ def run_remote_runs_textual(
|
|
|
59
66
|
callbacks: Data provider callback bundle.
|
|
60
67
|
agent_name: Optional agent name for display purposes.
|
|
61
68
|
agent_id: Optional agent ID for display purposes.
|
|
69
|
+
ctx: Shared TUI context.
|
|
62
70
|
|
|
63
71
|
Returns:
|
|
64
72
|
Tuple of (page, limit, cursor_index) after the UI exits.
|
|
@@ -69,15 +77,27 @@ def run_remote_runs_textual(
|
|
|
69
77
|
callbacks,
|
|
70
78
|
agent_name=agent_name,
|
|
71
79
|
agent_id=agent_id,
|
|
80
|
+
ctx=ctx,
|
|
72
81
|
)
|
|
73
82
|
app.run()
|
|
74
83
|
current_page = getattr(app, "current_page", initial_page)
|
|
75
84
|
return current_page.page, current_page.limit, app.cursor_index
|
|
76
85
|
|
|
77
86
|
|
|
78
|
-
class RunDetailScreen(ModalScreen[None]):
|
|
87
|
+
class RunDetailScreen(ToastHandlerMixin, ClipboardToastMixin, ModalScreen[None]):
|
|
79
88
|
"""Modal screen displaying run metadata and output timeline."""
|
|
80
89
|
|
|
90
|
+
CSS = """
|
|
91
|
+
Screen { layout: vertical; layers: base toasts; }
|
|
92
|
+
#toast-container {
|
|
93
|
+
width: 100%;
|
|
94
|
+
height: auto;
|
|
95
|
+
dock: top;
|
|
96
|
+
align: right top;
|
|
97
|
+
layer: toasts;
|
|
98
|
+
}
|
|
99
|
+
"""
|
|
100
|
+
|
|
81
101
|
BINDINGS = [
|
|
82
102
|
Binding("escape", "dismiss", "Close", priority=True),
|
|
83
103
|
Binding("q", "dismiss_modal", "Close", priority=True),
|
|
@@ -85,14 +105,24 @@ class RunDetailScreen(ModalScreen[None]):
|
|
|
85
105
|
Binding("down", "scroll_down", "Down"),
|
|
86
106
|
Binding("pageup", "page_up", "PgUp"),
|
|
87
107
|
Binding("pagedown", "page_down", "PgDn"),
|
|
108
|
+
Binding("c", "copy_run_id", "Copy ID"),
|
|
109
|
+
Binding("C", "copy_detail_json", "Copy JSON"),
|
|
88
110
|
Binding("e", "export_detail", "Export"),
|
|
89
111
|
]
|
|
90
112
|
|
|
91
|
-
def __init__(
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
detail: Any,
|
|
116
|
+
on_export: Callable[[Any], None] | None = None,
|
|
117
|
+
ctx: TUIContext | None = None,
|
|
118
|
+
) -> None:
|
|
92
119
|
"""Initialize the run detail screen."""
|
|
93
120
|
super().__init__()
|
|
94
121
|
self.detail = detail
|
|
95
122
|
self._on_export = on_export
|
|
123
|
+
self._ctx = ctx
|
|
124
|
+
self._clip_cache: ClipboardAdapter | None = None
|
|
125
|
+
self._local_toasts: ToastBus | None = None
|
|
96
126
|
|
|
97
127
|
def compose(self) -> ComposeResult:
|
|
98
128
|
"""Render metadata and events."""
|
|
@@ -116,14 +146,17 @@ class RunDetailScreen(ModalScreen[None]):
|
|
|
116
146
|
duration = self.detail.duration_formatted() if getattr(self.detail, "duration_formatted", None) else None
|
|
117
147
|
add_meta("Duration", duration, "bold")
|
|
118
148
|
|
|
119
|
-
|
|
149
|
+
main_content = Vertical(
|
|
120
150
|
Static(meta_text, id="detail-meta"),
|
|
121
151
|
RichLog(id="detail-events", wrap=False),
|
|
122
152
|
)
|
|
153
|
+
yield main_content
|
|
154
|
+
yield ToastContainer(Toast(), id="toast-container")
|
|
123
155
|
yield Footer()
|
|
124
156
|
|
|
125
157
|
def on_mount(self) -> None:
|
|
126
158
|
"""Populate and focus the log."""
|
|
159
|
+
self._ensure_toast_bus()
|
|
127
160
|
log = self.query_one("#detail-events", RichLog)
|
|
128
161
|
log.can_focus = True
|
|
129
162
|
log.write(Text("Events", style="bold"))
|
|
@@ -149,6 +182,61 @@ class RunDetailScreen(ModalScreen[None]):
|
|
|
149
182
|
def _log(self) -> RichLog:
|
|
150
183
|
return self.query_one("#detail-events", RichLog)
|
|
151
184
|
|
|
185
|
+
def action_copy_run_id(self) -> None:
|
|
186
|
+
"""Copy the run id to the clipboard."""
|
|
187
|
+
run_id = getattr(self.detail, "id", None)
|
|
188
|
+
if not run_id:
|
|
189
|
+
self._announce_status("Run ID unavailable.")
|
|
190
|
+
return
|
|
191
|
+
self._copy_to_clipboard(str(run_id), label="Run ID")
|
|
192
|
+
|
|
193
|
+
def action_copy_detail_json(self) -> None:
|
|
194
|
+
"""Copy the run detail JSON to the clipboard."""
|
|
195
|
+
payload = self._detail_json_payload()
|
|
196
|
+
if payload is None:
|
|
197
|
+
return
|
|
198
|
+
self._copy_to_clipboard(payload, label="Run JSON")
|
|
199
|
+
|
|
200
|
+
def _detail_json_payload(self) -> str | None:
|
|
201
|
+
detail = self.detail
|
|
202
|
+
if detail is None:
|
|
203
|
+
self._announce_status("Run detail unavailable.")
|
|
204
|
+
return None
|
|
205
|
+
if isinstance(detail, str):
|
|
206
|
+
return detail
|
|
207
|
+
if isinstance(detail, dict):
|
|
208
|
+
payload = detail
|
|
209
|
+
elif hasattr(detail, "model_dump"):
|
|
210
|
+
payload = detail.model_dump(mode="json")
|
|
211
|
+
elif hasattr(detail, "dict"):
|
|
212
|
+
payload = detail.dict()
|
|
213
|
+
else:
|
|
214
|
+
payload = getattr(detail, "__dict__", {"value": detail})
|
|
215
|
+
try:
|
|
216
|
+
return json.dumps(payload, indent=2, ensure_ascii=False, default=str)
|
|
217
|
+
except Exception as exc:
|
|
218
|
+
self._announce_status(f"Failed to serialize run detail: {exc}")
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def _append_copy_fallback(self, text: str) -> None:
|
|
222
|
+
try:
|
|
223
|
+
log = self._log()
|
|
224
|
+
except Exception:
|
|
225
|
+
self._announce_status(text)
|
|
226
|
+
return
|
|
227
|
+
log.write(Text(text))
|
|
228
|
+
log.write(Text(""))
|
|
229
|
+
|
|
230
|
+
def _ensure_toast_bus(self) -> None:
|
|
231
|
+
"""Ensure toast bus is initialized and connected to message handler."""
|
|
232
|
+
if self._local_toasts is not None:
|
|
233
|
+
return # pragma: no cover - early return when already initialized
|
|
234
|
+
|
|
235
|
+
def _notify(m: ToastBus.Changed) -> None:
|
|
236
|
+
self.post_message(m)
|
|
237
|
+
|
|
238
|
+
self._local_toasts = ToastBus(on_change=_notify)
|
|
239
|
+
|
|
152
240
|
@staticmethod
|
|
153
241
|
def _status_style(status: str | None) -> str:
|
|
154
242
|
"""Return a Rich style name for the status pill."""
|
|
@@ -220,15 +308,25 @@ class RunDetailScreen(ModalScreen[None]):
|
|
|
220
308
|
update_status(message, append=True)
|
|
221
309
|
|
|
222
310
|
|
|
223
|
-
class RemoteRunsTextualApp(App[None]):
|
|
311
|
+
class RemoteRunsTextualApp(ToastHandlerMixin, App[None]):
|
|
224
312
|
"""Textual application for browsing remote runs."""
|
|
225
313
|
|
|
226
314
|
CSS = f"""
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
315
|
+
#toast-container {{
|
|
316
|
+
width: 100%;
|
|
317
|
+
height: auto;
|
|
318
|
+
dock: top;
|
|
319
|
+
align: right top;
|
|
320
|
+
layer: toasts;
|
|
321
|
+
}}
|
|
322
|
+
#{RUNS_LOADING_ID} {{
|
|
323
|
+
width: auto;
|
|
324
|
+
display: none;
|
|
325
|
+
}}
|
|
326
|
+
#status-bar {{
|
|
327
|
+
height: 3;
|
|
328
|
+
padding: 0 1;
|
|
329
|
+
}}
|
|
232
330
|
"""
|
|
233
331
|
|
|
234
332
|
BINDINGS = [
|
|
@@ -247,6 +345,7 @@ class RemoteRunsTextualApp(App[None]):
|
|
|
247
345
|
*,
|
|
248
346
|
agent_name: str | None = None,
|
|
249
347
|
agent_id: str | None = None,
|
|
348
|
+
ctx: TUIContext | None = None,
|
|
250
349
|
):
|
|
251
350
|
"""Initialize the remote runs Textual application.
|
|
252
351
|
|
|
@@ -256,6 +355,7 @@ class RemoteRunsTextualApp(App[None]):
|
|
|
256
355
|
callbacks: Callback bundle for data operations.
|
|
257
356
|
agent_name: Optional agent name for display purposes.
|
|
258
357
|
agent_id: Optional agent ID for display purposes.
|
|
358
|
+
ctx: Shared TUI context.
|
|
259
359
|
"""
|
|
260
360
|
super().__init__()
|
|
261
361
|
self.current_page = initial_page
|
|
@@ -265,17 +365,45 @@ class RemoteRunsTextualApp(App[None]):
|
|
|
265
365
|
self.current_rows = initial_page.data[:]
|
|
266
366
|
self.agent_name = (agent_name or "").strip()
|
|
267
367
|
self.agent_id = (agent_id or "").strip()
|
|
368
|
+
self._ctx = ctx
|
|
369
|
+
self._clip_cache: ClipboardAdapter | None = None
|
|
268
370
|
self._active_export_tasks: set[asyncio.Task[None]] = set()
|
|
269
371
|
self._page_loader_task: asyncio.Task[Any] | None = None
|
|
270
372
|
self._detail_loader_task: asyncio.Task[Any] | None = None
|
|
271
373
|
self._table_spinner_active = False
|
|
272
374
|
|
|
375
|
+
@property
|
|
376
|
+
def clipboard(self) -> str:
|
|
377
|
+
"""Return clipboard text for Input paste actions."""
|
|
378
|
+
if self._ctx is not None:
|
|
379
|
+
adapter = self._ctx.clipboard
|
|
380
|
+
if adapter is None:
|
|
381
|
+
adapter = ClipboardAdapter(terminal=self._ctx.terminal)
|
|
382
|
+
self._ctx.clipboard = adapter
|
|
383
|
+
result = adapter.read()
|
|
384
|
+
if result.success:
|
|
385
|
+
return result.text
|
|
386
|
+
if self._ctx is None and self._clip_cache is None:
|
|
387
|
+
self._clip_cache = ClipboardAdapter(terminal=None)
|
|
388
|
+
if self._clip_cache is not None:
|
|
389
|
+
result = self._clip_cache.read()
|
|
390
|
+
if result.success:
|
|
391
|
+
return result.text
|
|
392
|
+
return super().clipboard
|
|
393
|
+
|
|
394
|
+
@clipboard.setter
|
|
395
|
+
def clipboard(self, value: str) -> None:
|
|
396
|
+
setter = App.clipboard.fset
|
|
397
|
+
if setter is not None:
|
|
398
|
+
setter(self, value)
|
|
399
|
+
|
|
273
400
|
def compose(self) -> ComposeResult:
|
|
274
401
|
"""Build layout."""
|
|
275
402
|
yield Header()
|
|
276
|
-
|
|
277
|
-
table
|
|
278
|
-
table.
|
|
403
|
+
yield ToastContainer(Toast(), id="toast-container")
|
|
404
|
+
table = DataTable(id=RUNS_TABLE_ID) # pragma: no cover - mocked in tests
|
|
405
|
+
table.cursor_type = "row" # pragma: no cover - mocked in tests
|
|
406
|
+
table.add_columns( # pragma: no cover - mocked in tests
|
|
279
407
|
"Run UUID",
|
|
280
408
|
"Type",
|
|
281
409
|
"Status",
|
|
@@ -286,14 +414,24 @@ class RemoteRunsTextualApp(App[None]):
|
|
|
286
414
|
)
|
|
287
415
|
yield table # pragma: no cover - interactive UI, tested via integration
|
|
288
416
|
yield Horizontal( # pragma: no cover - interactive UI, tested via integration
|
|
289
|
-
|
|
417
|
+
PulseIndicator(id=RUNS_LOADING_ID),
|
|
290
418
|
Static(id="status"),
|
|
291
419
|
id="status-bar",
|
|
292
420
|
)
|
|
293
421
|
yield Footer() # pragma: no cover - interactive UI, tested via integration
|
|
294
422
|
|
|
423
|
+
def _ensure_toast_bus(self) -> None:
|
|
424
|
+
if self._ctx is None or self._ctx.toasts is not None:
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
def _notify(m: ToastBus.Changed) -> None:
|
|
428
|
+
self.post_message(m)
|
|
429
|
+
|
|
430
|
+
self._ctx.toasts = ToastBus(on_change=_notify)
|
|
431
|
+
|
|
295
432
|
def on_mount(self) -> None:
|
|
296
433
|
"""Render the initial page."""
|
|
434
|
+
self._ensure_toast_bus()
|
|
297
435
|
self._hide_loading()
|
|
298
436
|
self._render_page(self.current_page)
|
|
299
437
|
|
|
@@ -315,7 +453,7 @@ class RemoteRunsTextualApp(App[None]):
|
|
|
315
453
|
if self.current_rows:
|
|
316
454
|
self.cursor_index = max(0, min(self.cursor_index, len(self.current_rows) - 1))
|
|
317
455
|
table.focus()
|
|
318
|
-
table.cursor_coordinate = (self.cursor_index, 0)
|
|
456
|
+
table.cursor_coordinate = Coordinate(self.cursor_index, 0)
|
|
319
457
|
self.current_page = runs_page
|
|
320
458
|
total_pages = max(1, (runs_page.total + runs_page.limit - 1) // runs_page.limit)
|
|
321
459
|
agent_display = self.agent_name or "Runs"
|
|
@@ -371,6 +509,26 @@ class RemoteRunsTextualApp(App[None]):
|
|
|
371
509
|
"""Track cursor position when DataTable selection changes."""
|
|
372
510
|
self.cursor_index = getattr(event, "cursor_row", self.cursor_index)
|
|
373
511
|
|
|
512
|
+
def _handle_table_click(self, row: int | None) -> None:
|
|
513
|
+
if row is None:
|
|
514
|
+
return
|
|
515
|
+
table = self.query_one(RUNS_TABLE_SELECTOR, DataTable)
|
|
516
|
+
self.cursor_index = row
|
|
517
|
+
try:
|
|
518
|
+
table.cursor_coordinate = Coordinate(row, 0)
|
|
519
|
+
except Exception:
|
|
520
|
+
return
|
|
521
|
+
self.action_open_detail()
|
|
522
|
+
|
|
523
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # pragma: no cover - UI hook
|
|
524
|
+
"""Handle row selection event from DataTable."""
|
|
525
|
+
self._handle_table_click(getattr(event, "cursor_row", None))
|
|
526
|
+
|
|
527
|
+
def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: # pragma: no cover - UI hook
|
|
528
|
+
"""Handle cell selection event from DataTable."""
|
|
529
|
+
row = getattr(event.coordinate, "row", None) if event.coordinate else None
|
|
530
|
+
self._handle_table_click(row)
|
|
531
|
+
|
|
374
532
|
def action_page_left(self) -> None:
|
|
375
533
|
"""Navigate to the previous page."""
|
|
376
534
|
if not self.current_page.has_prev:
|
|
@@ -413,7 +571,7 @@ class RemoteRunsTextualApp(App[None]):
|
|
|
413
571
|
self._update_status("Already loading run detail. Please wait…", append=True)
|
|
414
572
|
return
|
|
415
573
|
run_id = str(run.id)
|
|
416
|
-
self._show_loading("Loading run detail…", table_spinner=False)
|
|
574
|
+
self._show_loading("Loading run detail…", table_spinner=False, footer_message=False)
|
|
417
575
|
self._queue_detail_load(run_id)
|
|
418
576
|
|
|
419
577
|
async def action_export_run(self) -> None:
|
|
@@ -554,8 +712,8 @@ class RemoteRunsTextualApp(App[None]):
|
|
|
554
712
|
if detail is None:
|
|
555
713
|
self._update_status("Failed to load run detail.", append=True)
|
|
556
714
|
return
|
|
557
|
-
self.push_screen(RunDetailScreen(detail, on_export=self.queue_export_from_detail))
|
|
558
|
-
self._update_status("Detail view: ↑/↓ scroll · PgUp/PgDn · q/Esc close · e export")
|
|
715
|
+
self.push_screen(RunDetailScreen(detail, on_export=self.queue_export_from_detail, ctx=self._ctx))
|
|
716
|
+
self._update_status("Detail view: ↑/↓ scroll · PgUp/PgDn · q/Esc close · c copy ID · C copy JSON · e export")
|
|
559
717
|
|
|
560
718
|
def queue_export_from_detail(self, detail: Any) -> None:
|
|
561
719
|
"""Start an export from the detail modal."""
|
|
@@ -606,7 +764,7 @@ class RemoteRunsTextualApp(App[None]):
|
|
|
606
764
|
show_loading_indicator(
|
|
607
765
|
self,
|
|
608
766
|
RUNS_LOADING_SELECTOR,
|
|
609
|
-
message=message
|
|
767
|
+
message=message,
|
|
610
768
|
set_status=self._update_status if footer_message else None,
|
|
611
769
|
)
|
|
612
770
|
self._set_table_loading(table_spinner)
|