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.
Files changed (135) hide show
  1. glaip_sdk/agents/base.py +283 -30
  2. glaip_sdk/agents/component.py +233 -0
  3. glaip_sdk/branding.py +113 -2
  4. glaip_sdk/cli/account_store.py +15 -0
  5. glaip_sdk/cli/auth.py +14 -8
  6. glaip_sdk/cli/commands/accounts.py +1 -1
  7. glaip_sdk/cli/commands/agents/__init__.py +116 -0
  8. glaip_sdk/cli/commands/agents/_common.py +562 -0
  9. glaip_sdk/cli/commands/agents/create.py +155 -0
  10. glaip_sdk/cli/commands/agents/delete.py +64 -0
  11. glaip_sdk/cli/commands/agents/get.py +89 -0
  12. glaip_sdk/cli/commands/agents/list.py +129 -0
  13. glaip_sdk/cli/commands/agents/run.py +264 -0
  14. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  15. glaip_sdk/cli/commands/agents/update.py +112 -0
  16. glaip_sdk/cli/commands/common_config.py +1 -1
  17. glaip_sdk/cli/commands/configure.py +1 -2
  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/entrypoint.py +20 -0
  46. glaip_sdk/cli/main.py +112 -35
  47. glaip_sdk/cli/pager.py +3 -3
  48. glaip_sdk/cli/resolution.py +2 -1
  49. glaip_sdk/cli/slash/accounts_controller.py +3 -1
  50. glaip_sdk/cli/slash/agent_session.py +1 -1
  51. glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
  52. glaip_sdk/cli/slash/session.py +343 -20
  53. glaip_sdk/cli/slash/tui/__init__.py +29 -1
  54. glaip_sdk/cli/slash/tui/accounts.tcss +97 -6
  55. glaip_sdk/cli/slash/tui/accounts_app.py +1117 -126
  56. glaip_sdk/cli/slash/tui/clipboard.py +316 -0
  57. glaip_sdk/cli/slash/tui/context.py +92 -0
  58. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  59. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  60. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  61. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  62. glaip_sdk/cli/slash/tui/loading.py +43 -21
  63. glaip_sdk/cli/slash/tui/remote_runs_app.py +178 -20
  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 +388 -0
  70. glaip_sdk/cli/transcript/history.py +1 -1
  71. glaip_sdk/cli/transcript/viewer.py +1 -1
  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 +293 -17
  78. glaip_sdk/client/base.py +25 -0
  79. glaip_sdk/client/hitl.py +136 -0
  80. glaip_sdk/client/main.py +7 -5
  81. glaip_sdk/client/mcps.py +44 -13
  82. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  83. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +28 -48
  84. glaip_sdk/client/payloads/agent/responses.py +43 -0
  85. glaip_sdk/client/run_rendering.py +109 -30
  86. glaip_sdk/client/schedules.py +439 -0
  87. glaip_sdk/client/tools.py +52 -23
  88. glaip_sdk/config/constants.py +22 -2
  89. glaip_sdk/guardrails/__init__.py +80 -0
  90. glaip_sdk/guardrails/serializer.py +91 -0
  91. glaip_sdk/hitl/__init__.py +35 -2
  92. glaip_sdk/hitl/base.py +64 -0
  93. glaip_sdk/hitl/callback.py +43 -0
  94. glaip_sdk/hitl/local.py +1 -31
  95. glaip_sdk/hitl/remote.py +523 -0
  96. glaip_sdk/models/__init__.py +47 -1
  97. glaip_sdk/models/_provider_mappings.py +101 -0
  98. glaip_sdk/models/_validation.py +97 -0
  99. glaip_sdk/models/agent.py +2 -1
  100. glaip_sdk/models/agent_runs.py +2 -1
  101. glaip_sdk/models/constants.py +141 -0
  102. glaip_sdk/models/model.py +170 -0
  103. glaip_sdk/models/schedule.py +224 -0
  104. glaip_sdk/payload_schemas/agent.py +1 -0
  105. glaip_sdk/payload_schemas/guardrails.py +34 -0
  106. glaip_sdk/ptc.py +145 -0
  107. glaip_sdk/registry/tool.py +270 -57
  108. glaip_sdk/runner/__init__.py +20 -3
  109. glaip_sdk/runner/deps.py +4 -1
  110. glaip_sdk/runner/langgraph.py +251 -27
  111. glaip_sdk/runner/logging_config.py +77 -0
  112. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +30 -9
  113. glaip_sdk/runner/ptc_adapter.py +98 -0
  114. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +25 -2
  115. glaip_sdk/schedules/__init__.py +22 -0
  116. glaip_sdk/schedules/base.py +291 -0
  117. glaip_sdk/tools/base.py +67 -14
  118. glaip_sdk/utils/__init__.py +1 -0
  119. glaip_sdk/utils/agent_config.py +8 -2
  120. glaip_sdk/utils/bundler.py +138 -2
  121. glaip_sdk/utils/import_resolver.py +427 -49
  122. glaip_sdk/utils/runtime_config.py +3 -2
  123. glaip_sdk/utils/sync.py +31 -11
  124. glaip_sdk/utils/tool_detection.py +274 -6
  125. {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/METADATA +22 -8
  126. glaip_sdk-0.7.27.dist-info/RECORD +227 -0
  127. {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/WHEEL +1 -1
  128. glaip_sdk-0.7.27.dist-info/entry_points.txt +2 -0
  129. glaip_sdk/cli/commands/agents.py +0 -1509
  130. glaip_sdk/cli/commands/mcps.py +0 -1356
  131. glaip_sdk/cli/commands/tools.py +0 -576
  132. glaip_sdk/cli/utils.py +0 -263
  133. glaip_sdk-0.6.19.dist-info/RECORD +0 -163
  134. glaip_sdk-0.6.19.dist-info/entry_points.txt +0 -2
  135. {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
- Note: uses Textual's built-in LoadingIndicator as the MVP; upgrade to the
4
- PulseIndicator from cli-textual-animated-indicators.md when shipped.
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 TYPE_CHECKING
10
+ from typing import Any
11
11
 
12
- try: # pragma: no cover - optional dependency
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
- if TYPE_CHECKING: # pragma: no cover - type checking aid
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
- def _set_indicator_display(app: object, selector: str, visible: bool) -> None:
24
- """Safely toggle a LoadingIndicator's display property."""
25
- if LoadingIndicator is None:
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) # type: ignore[arg-type]
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: object,
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: object, selector: str) -> None:
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 Container, Horizontal
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, LoadingIndicator, RichLog, Static
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__(self, detail: Any, on_export: Callable[[Any], None] | None = None):
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
- yield Container(
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
- Screen {{ layout: vertical; }}
228
- #status-bar {{ height: 3; padding: 0 1; }}
229
- #agent-context {{ min-width: 25; padding-right: 1; }}
230
- #{RUNS_LOADING_ID} {{ width: 8; }}
231
- #status {{ padding-left: 1; }}
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
- table = DataTable(id=RUNS_TABLE_ID)
277
- table.cursor_type = "row"
278
- table.add_columns(
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
- LoadingIndicator(id=RUNS_LOADING_ID),
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 if footer_message else None,
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)