glaip-sdk 0.6.0__py3-none-any.whl → 0.6.1__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 +41 -34
- glaip_sdk/cli/commands/common_config.py +36 -0
- glaip_sdk/cli/config.py +13 -2
- glaip_sdk/cli/main.py +20 -0
- glaip_sdk/cli/slash/accounts_controller.py +217 -0
- glaip_sdk/cli/slash/accounts_shared.py +19 -0
- glaip_sdk/cli/slash/session.py +57 -7
- glaip_sdk/cli/slash/tui/accounts.tcss +54 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +379 -0
- glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
- glaip_sdk/cli/slash/tui/loading.py +58 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +9 -12
- glaip_sdk/client/main.py +1 -1
- glaip_sdk/registry/__init__.py +1 -1
- glaip_sdk/registry/base.py +1 -1
- glaip_sdk/utils/__init__.py +1 -1
- glaip_sdk/utils/import_resolver.py +0 -8
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.1.dist-info}/METADATA +1 -1
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.1.dist-info}/RECORD +21 -15
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.1.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.1.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Textual UI for the /accounts command.
|
|
2
|
+
|
|
3
|
+
Provides a minimal interactive list with the same columns/order as the Rich
|
|
4
|
+
fallback (name, API URL, masked key, status) and keyboard navigation.
|
|
5
|
+
|
|
6
|
+
Authors:
|
|
7
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from glaip_sdk.cli.slash.accounts_shared import build_account_status_string
|
|
18
|
+
from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
|
|
19
|
+
from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
|
|
20
|
+
|
|
21
|
+
try: # pragma: no cover - optional dependency
|
|
22
|
+
from textual import events
|
|
23
|
+
from textual.app import App, ComposeResult
|
|
24
|
+
from textual.binding import Binding
|
|
25
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
26
|
+
from textual.widgets import DataTable, Footer, Header, Input, LoadingIndicator, Static
|
|
27
|
+
except Exception: # pragma: no cover - optional dependency
|
|
28
|
+
events = None # type: ignore[assignment]
|
|
29
|
+
App = None # type: ignore[assignment]
|
|
30
|
+
ComposeResult = None # type: ignore[assignment]
|
|
31
|
+
Binding = None # type: ignore[assignment]
|
|
32
|
+
Container = None # type: ignore[assignment]
|
|
33
|
+
Horizontal = None # type: ignore[assignment]
|
|
34
|
+
Vertical = None # type: ignore[assignment]
|
|
35
|
+
DataTable = None # type: ignore[assignment]
|
|
36
|
+
Footer = None # type: ignore[assignment]
|
|
37
|
+
Header = None # type: ignore[assignment]
|
|
38
|
+
Input = None # type: ignore[assignment]
|
|
39
|
+
LoadingIndicator = None # type: ignore[assignment]
|
|
40
|
+
Static = None # type: ignore[assignment]
|
|
41
|
+
|
|
42
|
+
TEXTUAL_SUPPORTED = App is not None and DataTable is not None
|
|
43
|
+
|
|
44
|
+
# Widget IDs for Textual UI
|
|
45
|
+
ACCOUNTS_TABLE_ID = "#accounts-table"
|
|
46
|
+
FILTER_INPUT_ID = "#filter-input"
|
|
47
|
+
STATUS_ID = "#status"
|
|
48
|
+
ACCOUNTS_LOADING_ID = "#accounts-loading"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AccountsTUICallbacks:
|
|
53
|
+
"""Callbacks invoked by the Textual UI."""
|
|
54
|
+
|
|
55
|
+
switch_account: Callable[[str], tuple[bool, str]]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_accounts_textual(
|
|
59
|
+
rows: list[dict[str, str | bool]],
|
|
60
|
+
*,
|
|
61
|
+
active_account: str | None,
|
|
62
|
+
env_lock: bool,
|
|
63
|
+
callbacks: AccountsTUICallbacks,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Launch the Textual accounts browser if dependencies are available."""
|
|
66
|
+
if not TEXTUAL_SUPPORTED:
|
|
67
|
+
return
|
|
68
|
+
app = AccountsTextualApp(rows, active_account, env_lock, callbacks)
|
|
69
|
+
app.run()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AccountsTextualApp(BackgroundTaskMixin, App[None]): # pragma: no cover - interactive
|
|
73
|
+
"""Textual application for browsing accounts."""
|
|
74
|
+
|
|
75
|
+
CSS_PATH = "accounts.tcss"
|
|
76
|
+
BINDINGS = [
|
|
77
|
+
Binding("enter", "switch_row", "Switch", show=True),
|
|
78
|
+
Binding("return", "switch_row", "Switch", show=False),
|
|
79
|
+
Binding("/", "focus_filter", "Filter", show=True),
|
|
80
|
+
# Esc clears filter when focused/non-empty; otherwise exits
|
|
81
|
+
Binding("escape", "clear_or_exit", "Close", priority=True),
|
|
82
|
+
Binding("q", "app_exit", "Close", priority=True),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
rows: list[dict[str, str | bool]],
|
|
88
|
+
active_account: str | None,
|
|
89
|
+
env_lock: bool,
|
|
90
|
+
callbacks: AccountsTUICallbacks,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Initialize the Textual accounts app.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
rows: Account data rows to display.
|
|
96
|
+
active_account: Name of the currently active account.
|
|
97
|
+
env_lock: Whether environment credentials are locking account switching.
|
|
98
|
+
callbacks: Callbacks for account switching operations.
|
|
99
|
+
"""
|
|
100
|
+
super().__init__()
|
|
101
|
+
self._all_rows = rows
|
|
102
|
+
self._active_account = active_account
|
|
103
|
+
self._env_lock = env_lock
|
|
104
|
+
self._callbacks = callbacks
|
|
105
|
+
self._filter_text: str = ""
|
|
106
|
+
self._is_switching = False
|
|
107
|
+
|
|
108
|
+
def compose(self) -> ComposeResult:
|
|
109
|
+
"""Build the Textual layout."""
|
|
110
|
+
header_text = self._header_text()
|
|
111
|
+
yield Static(header_text, id="header-info")
|
|
112
|
+
if self._env_lock:
|
|
113
|
+
yield Static(
|
|
114
|
+
"Env credentials detected (AIP_API_URL/AIP_API_KEY); switching is disabled.",
|
|
115
|
+
id="env-lock",
|
|
116
|
+
)
|
|
117
|
+
filter_bar = Container(
|
|
118
|
+
Static("Filter (/):", id="filter-label"),
|
|
119
|
+
Input(placeholder="Type to filter by name or host", id="filter-input"),
|
|
120
|
+
id="filter-container",
|
|
121
|
+
)
|
|
122
|
+
filter_bar.styles.padding = (0, 0)
|
|
123
|
+
main = Vertical(
|
|
124
|
+
filter_bar,
|
|
125
|
+
DataTable(id=ACCOUNTS_TABLE_ID.lstrip("#")),
|
|
126
|
+
)
|
|
127
|
+
# Avoid large gaps; keep main content filling available space
|
|
128
|
+
main.styles.height = "1fr"
|
|
129
|
+
main.styles.padding = (0, 0)
|
|
130
|
+
yield main
|
|
131
|
+
yield Horizontal(
|
|
132
|
+
LoadingIndicator(id=ACCOUNTS_LOADING_ID.lstrip("#")),
|
|
133
|
+
Static("", id=STATUS_ID.lstrip("#")),
|
|
134
|
+
id="status-bar",
|
|
135
|
+
)
|
|
136
|
+
yield Footer()
|
|
137
|
+
|
|
138
|
+
def on_mount(self) -> None:
|
|
139
|
+
"""Configure table columns and load rows."""
|
|
140
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
141
|
+
table.add_column("Name", width=20)
|
|
142
|
+
table.add_column("API URL", width=40)
|
|
143
|
+
table.add_column("Key (masked)", width=20)
|
|
144
|
+
table.add_column("Status", width=14)
|
|
145
|
+
table.cursor_type = "row"
|
|
146
|
+
table.zebra_stripes = True
|
|
147
|
+
table.styles.height = "1fr" # Fill available space below the filter
|
|
148
|
+
table.styles.margin = 0
|
|
149
|
+
self._reload_rows()
|
|
150
|
+
table.focus()
|
|
151
|
+
# Keep the filter tight to the table
|
|
152
|
+
main = self.query_one(Vertical)
|
|
153
|
+
main.styles.gap = 0
|
|
154
|
+
|
|
155
|
+
def _header_text(self) -> str:
|
|
156
|
+
"""Build header text with active account and host."""
|
|
157
|
+
host = self._get_active_host() or "Not configured"
|
|
158
|
+
lock_icon = " [yellow]🔒[/]" if self._env_lock else ""
|
|
159
|
+
active = self._active_account or "None"
|
|
160
|
+
return f"[green]Active:[/] [bold]{active}[/] ([cyan]{host}[/]){lock_icon}"
|
|
161
|
+
|
|
162
|
+
def _get_active_host(self) -> str | None:
|
|
163
|
+
"""Return the API host for the active account (shortened)."""
|
|
164
|
+
return self._get_host_for_name(self._active_account)
|
|
165
|
+
|
|
166
|
+
def _get_host_for_name(self, name: str | None) -> str | None:
|
|
167
|
+
"""Return shortened API URL for a given account name."""
|
|
168
|
+
if not name:
|
|
169
|
+
return None
|
|
170
|
+
for row in self._all_rows:
|
|
171
|
+
if row.get("name") == name:
|
|
172
|
+
url = str(row.get("api_url", ""))
|
|
173
|
+
return url if len(url) <= 40 else f"{url[:37]}..."
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def action_focus_filter(self) -> None:
|
|
177
|
+
"""Focus the filter input and clear previous text."""
|
|
178
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
179
|
+
filter_input.value = self._filter_text
|
|
180
|
+
filter_input.focus()
|
|
181
|
+
|
|
182
|
+
def action_switch_row(self) -> None:
|
|
183
|
+
"""Switch to the currently selected account."""
|
|
184
|
+
if self._env_lock:
|
|
185
|
+
self._set_status("Switching disabled: env credentials in use.", "yellow")
|
|
186
|
+
return
|
|
187
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
188
|
+
if table.cursor_row is None:
|
|
189
|
+
self._set_status("No account selected.", "yellow")
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
row_key = table.get_row_at(table.cursor_row)[0]
|
|
193
|
+
except Exception:
|
|
194
|
+
self._set_status("Unable to read selected row.", "red")
|
|
195
|
+
return
|
|
196
|
+
name = str(row_key)
|
|
197
|
+
if self._is_switching:
|
|
198
|
+
self._set_status("Already switching...", "yellow")
|
|
199
|
+
return
|
|
200
|
+
self._is_switching = True
|
|
201
|
+
host = self._get_host_for_name(name)
|
|
202
|
+
if host:
|
|
203
|
+
self._show_loading(f"Connecting to '{name}' ({host})...")
|
|
204
|
+
else:
|
|
205
|
+
self._show_loading(f"Connecting to '{name}'...")
|
|
206
|
+
self._queue_switch(name)
|
|
207
|
+
|
|
208
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
|
|
209
|
+
"""Handle mouse click selection by triggering switch."""
|
|
210
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
211
|
+
try:
|
|
212
|
+
# Move cursor to clicked row then switch
|
|
213
|
+
table.cursor_coordinate = (event.cursor_row, 0)
|
|
214
|
+
except Exception:
|
|
215
|
+
return
|
|
216
|
+
self.action_switch_row()
|
|
217
|
+
|
|
218
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
219
|
+
"""Apply filter when user presses Enter inside filter input."""
|
|
220
|
+
self._filter_text = (event.value or "").strip()
|
|
221
|
+
self._reload_rows()
|
|
222
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
223
|
+
table.focus()
|
|
224
|
+
|
|
225
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
226
|
+
"""Apply filter live as the user types."""
|
|
227
|
+
self._filter_text = (event.value or "").strip()
|
|
228
|
+
self._reload_rows()
|
|
229
|
+
|
|
230
|
+
def on_key(self, event: events.Key) -> None: # type: ignore[override]
|
|
231
|
+
"""Let users start typing to filter without pressing '/' first."""
|
|
232
|
+
if not getattr(event, "is_printable", False):
|
|
233
|
+
return
|
|
234
|
+
if not event.character:
|
|
235
|
+
return
|
|
236
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
237
|
+
if filter_input.has_focus:
|
|
238
|
+
return
|
|
239
|
+
filter_input.focus()
|
|
240
|
+
filter_input.value = (filter_input.value or "") + event.character
|
|
241
|
+
filter_input.cursor_position = len(filter_input.value)
|
|
242
|
+
self._filter_text = filter_input.value.strip()
|
|
243
|
+
self._reload_rows()
|
|
244
|
+
event.stop()
|
|
245
|
+
|
|
246
|
+
def _reload_rows(self) -> None:
|
|
247
|
+
"""Refresh table rows based on current filter/active state."""
|
|
248
|
+
# Work on a copy to avoid mutating the backing rows list
|
|
249
|
+
rows_copy = [dict(row) for row in self._all_rows]
|
|
250
|
+
for row in rows_copy:
|
|
251
|
+
row["active"] = row.get("name") == self._active_account
|
|
252
|
+
|
|
253
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
254
|
+
table.clear()
|
|
255
|
+
filtered = self._filtered_rows(rows_copy)
|
|
256
|
+
for row in filtered:
|
|
257
|
+
row_for_status = dict(row)
|
|
258
|
+
row_for_status["active"] = row_for_status.get("name") == self._active_account
|
|
259
|
+
# Use markup to align status colors with Rich fallback (green active badge).
|
|
260
|
+
status = build_account_status_string(row_for_status, use_markup=True)
|
|
261
|
+
# pylint: disable=duplicate-code
|
|
262
|
+
# Reuses shared status builder; columns mirror accounts_controller Rich table.
|
|
263
|
+
table.add_row(
|
|
264
|
+
str(row.get("name", "")),
|
|
265
|
+
str(row.get("api_url", "")),
|
|
266
|
+
str(row.get("masked_key", "")),
|
|
267
|
+
status,
|
|
268
|
+
)
|
|
269
|
+
# Move cursor to active or first row
|
|
270
|
+
cursor_idx = 0
|
|
271
|
+
for idx, row in enumerate(filtered):
|
|
272
|
+
if row.get("name") == self._active_account:
|
|
273
|
+
cursor_idx = idx
|
|
274
|
+
break
|
|
275
|
+
if filtered:
|
|
276
|
+
table.cursor_coordinate = (cursor_idx, 0)
|
|
277
|
+
else:
|
|
278
|
+
self._set_status("No accounts match the current filter.", "yellow")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
# Update status to reflect filter state
|
|
282
|
+
if self._filter_text:
|
|
283
|
+
self._set_status(f"Filtered: {self._filter_text}", "cyan")
|
|
284
|
+
else:
|
|
285
|
+
self._set_status("", "white")
|
|
286
|
+
|
|
287
|
+
def _filtered_rows(self, rows: list[dict[str, str | bool]] | None = None) -> list[dict[str, str | bool]]:
|
|
288
|
+
"""Return rows filtered by name or API URL substring."""
|
|
289
|
+
base_rows = rows if rows is not None else [dict(row) for row in self._all_rows]
|
|
290
|
+
if not self._filter_text:
|
|
291
|
+
return list(base_rows)
|
|
292
|
+
needle = self._filter_text.lower()
|
|
293
|
+
filtered = [
|
|
294
|
+
row
|
|
295
|
+
for row in base_rows
|
|
296
|
+
if needle in str(row.get("name", "")).lower() or needle in str(row.get("api_url", "")).lower()
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
# Sort so name matches surface first, then URL matches, then alphabetically
|
|
300
|
+
def score(row: dict[str, str | bool]) -> tuple[int, str]:
|
|
301
|
+
name = str(row.get("name", "")).lower()
|
|
302
|
+
url = str(row.get("api_url", "")).lower()
|
|
303
|
+
name_hit = needle in name
|
|
304
|
+
url_hit = needle in url
|
|
305
|
+
# Extract nested conditional into clear statement
|
|
306
|
+
if name_hit:
|
|
307
|
+
priority = 0
|
|
308
|
+
elif url_hit:
|
|
309
|
+
priority = 1
|
|
310
|
+
else:
|
|
311
|
+
priority = 2
|
|
312
|
+
return (priority, name)
|
|
313
|
+
|
|
314
|
+
return sorted(filtered, key=score)
|
|
315
|
+
|
|
316
|
+
def _set_status(self, message: str, style: str) -> None:
|
|
317
|
+
"""Update status line with message."""
|
|
318
|
+
status = self.query_one(STATUS_ID, Static)
|
|
319
|
+
status.update(f"[{style}]{message}[/]")
|
|
320
|
+
|
|
321
|
+
def _show_loading(self, message: str | None = None) -> None:
|
|
322
|
+
"""Show the loading indicator and optional status message."""
|
|
323
|
+
show_loading_indicator(self, ACCOUNTS_LOADING_ID, message=message, set_status=self._set_status)
|
|
324
|
+
|
|
325
|
+
def _hide_loading(self) -> None:
|
|
326
|
+
"""Hide the loading indicator."""
|
|
327
|
+
hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
|
|
328
|
+
|
|
329
|
+
def _queue_switch(self, name: str) -> None:
|
|
330
|
+
"""Run switch in background to keep UI responsive."""
|
|
331
|
+
|
|
332
|
+
async def perform() -> None:
|
|
333
|
+
try:
|
|
334
|
+
switched, message = await asyncio.to_thread(self._callbacks.switch_account, name)
|
|
335
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
336
|
+
self._set_status(f"Switch failed: {exc}", "red")
|
|
337
|
+
return
|
|
338
|
+
finally:
|
|
339
|
+
self._hide_loading()
|
|
340
|
+
self._is_switching = False
|
|
341
|
+
|
|
342
|
+
if switched:
|
|
343
|
+
self._active_account = name
|
|
344
|
+
self._set_status(message or f"Switched to '{name}'.", "green")
|
|
345
|
+
self._update_header()
|
|
346
|
+
self._reload_rows()
|
|
347
|
+
else:
|
|
348
|
+
self._set_status(message or "Switch failed; kept previous account.", "yellow")
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
self.track_task(perform(), logger=logging.getLogger(__name__))
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
# If scheduling the task fails, clear loading/switching state and surface the error.
|
|
354
|
+
self._hide_loading()
|
|
355
|
+
self._is_switching = False
|
|
356
|
+
self._set_status(f"Switch failed to start: {exc}", "red")
|
|
357
|
+
logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
|
|
358
|
+
|
|
359
|
+
def _update_header(self) -> None:
|
|
360
|
+
"""Refresh header text to reflect active/lock state."""
|
|
361
|
+
header = self.query_one("#header-info", Static)
|
|
362
|
+
header.update(self._header_text())
|
|
363
|
+
|
|
364
|
+
def action_clear_or_exit(self) -> None:
|
|
365
|
+
"""Clear filter when focused/non-empty; otherwise exit.
|
|
366
|
+
|
|
367
|
+
UX note: helps users reset the list without leaving the TUI.
|
|
368
|
+
"""
|
|
369
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
370
|
+
# Extract nested conditional into clear statement
|
|
371
|
+
should_clear = filter_input.has_focus and (filter_input.value or self._filter_text)
|
|
372
|
+
if should_clear:
|
|
373
|
+
filter_input.value = ""
|
|
374
|
+
self._filter_text = ""
|
|
375
|
+
self._reload_rows()
|
|
376
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
377
|
+
table.focus()
|
|
378
|
+
return
|
|
379
|
+
self.exit()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Shared mixin for tracking background asyncio tasks in Textual apps.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable, Coroutine
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BackgroundTaskMixin:
|
|
16
|
+
"""Mixin that tracks background tasks and cleans them up on unmount."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
19
|
+
"""Initialize task tracking set for derived Textual apps."""
|
|
20
|
+
super().__init__(*args, **kwargs)
|
|
21
|
+
self._pending_tasks: set[asyncio.Task[Any]] = set()
|
|
22
|
+
|
|
23
|
+
def track_task(
|
|
24
|
+
self,
|
|
25
|
+
coro: Coroutine[Any, Any, Any],
|
|
26
|
+
*,
|
|
27
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
28
|
+
logger: logging.Logger | None = None,
|
|
29
|
+
) -> asyncio.Task[Any]:
|
|
30
|
+
"""Create and track a background task with optional error handling."""
|
|
31
|
+
task = asyncio.create_task(coro)
|
|
32
|
+
self._pending_tasks.add(task)
|
|
33
|
+
|
|
34
|
+
def _cleanup(finished: asyncio.Task[Any]) -> None:
|
|
35
|
+
self._pending_tasks.discard(finished)
|
|
36
|
+
if finished.cancelled():
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
exc = finished.exception()
|
|
40
|
+
except Exception:
|
|
41
|
+
return
|
|
42
|
+
if exc:
|
|
43
|
+
if on_error:
|
|
44
|
+
on_error(exc)
|
|
45
|
+
elif logger:
|
|
46
|
+
logger.debug("Background task failed", exc_info=exc)
|
|
47
|
+
|
|
48
|
+
task.add_done_callback(_cleanup)
|
|
49
|
+
return task
|
|
50
|
+
|
|
51
|
+
def on_unmount(self) -> None: # pragma: no cover - UI lifecycle hook
|
|
52
|
+
"""Ensure background tasks are cleaned up on exit."""
|
|
53
|
+
pending = [task for task in self._pending_tasks if not task.done()]
|
|
54
|
+
for task in pending:
|
|
55
|
+
try:
|
|
56
|
+
task.cancel()
|
|
57
|
+
except Exception:
|
|
58
|
+
continue
|
|
59
|
+
if pending:
|
|
60
|
+
try:
|
|
61
|
+
loop = asyncio.get_running_loop()
|
|
62
|
+
except RuntimeError:
|
|
63
|
+
loop = None
|
|
64
|
+
if loop and loop.is_running():
|
|
65
|
+
try:
|
|
66
|
+
loop.create_task(asyncio.gather(*pending, return_exceptions=True))
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
self._pending_tasks.clear()
|
|
70
|
+
parent_on_unmount = getattr(super(), "on_unmount", None)
|
|
71
|
+
if callable(parent_on_unmount):
|
|
72
|
+
parent_on_unmount() # type: ignore[misc]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shared helpers for toggling Textual loading indicators.
|
|
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.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
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]
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # pragma: no cover - type checking aid
|
|
18
|
+
from textual.widgets import LoadingIndicator as _LoadingIndicatorType
|
|
19
|
+
|
|
20
|
+
LoadingIndicator: type[_LoadingIndicatorType] | None
|
|
21
|
+
|
|
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:
|
|
26
|
+
return
|
|
27
|
+
try:
|
|
28
|
+
indicator = app.query_one(selector, LoadingIndicator) # type: ignore[arg-type]
|
|
29
|
+
indicator.display = visible
|
|
30
|
+
except Exception:
|
|
31
|
+
# Ignore lookup/rendering errors to keep UI resilient
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def show_loading_indicator(
|
|
36
|
+
app: object,
|
|
37
|
+
selector: str,
|
|
38
|
+
*,
|
|
39
|
+
message: str | None = None,
|
|
40
|
+
set_status: Callable[..., None] | None = None,
|
|
41
|
+
status_style: str = "cyan",
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Show a loading indicator and optionally set a status message."""
|
|
44
|
+
_set_indicator_display(app, selector, True)
|
|
45
|
+
if message and set_status:
|
|
46
|
+
try:
|
|
47
|
+
set_status(message, status_style)
|
|
48
|
+
except TypeError:
|
|
49
|
+
# Fallback for setters that accept only a single arg or kwargs
|
|
50
|
+
try:
|
|
51
|
+
set_status(message)
|
|
52
|
+
except Exception:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def hide_loading_indicator(app: object, selector: str) -> None:
|
|
57
|
+
"""Hide a loading indicator."""
|
|
58
|
+
_set_indicator_display(app, selector, False)
|
|
@@ -24,6 +24,8 @@ from textual.reactive import ReactiveError
|
|
|
24
24
|
from textual.screen import ModalScreen
|
|
25
25
|
from textual.widgets import DataTable, Footer, Header, LoadingIndicator, RichLog, Static
|
|
26
26
|
|
|
27
|
+
from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
|
|
28
|
+
|
|
27
29
|
logger = logging.getLogger(__name__)
|
|
28
30
|
|
|
29
31
|
RUNS_TABLE_ID = "runs"
|
|
@@ -601,23 +603,18 @@ class RemoteRunsTextualApp(App[None]):
|
|
|
601
603
|
footer_message: bool = True,
|
|
602
604
|
) -> None:
|
|
603
605
|
"""Display the loading indicator with an optional status message."""
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
606
|
+
show_loading_indicator(
|
|
607
|
+
self,
|
|
608
|
+
RUNS_LOADING_SELECTOR,
|
|
609
|
+
message=message if footer_message else None,
|
|
610
|
+
set_status=self._update_status if footer_message else None,
|
|
611
|
+
)
|
|
609
612
|
self._set_table_loading(table_spinner)
|
|
610
613
|
self._table_spinner_active = table_spinner
|
|
611
|
-
if message and footer_message:
|
|
612
|
-
self._update_status(message)
|
|
613
614
|
|
|
614
615
|
def _hide_loading(self) -> None:
|
|
615
616
|
"""Hide the loading indicator."""
|
|
616
|
-
|
|
617
|
-
indicator = self.query_one(RUNS_LOADING_SELECTOR, LoadingIndicator)
|
|
618
|
-
indicator.display = False
|
|
619
|
-
except (AttributeError, RuntimeError) as e:
|
|
620
|
-
logger.debug("Cannot hide loading indicator: %s", type(e).__name__)
|
|
617
|
+
hide_loading_indicator(self, RUNS_LOADING_SELECTOR)
|
|
621
618
|
if self._table_spinner_active:
|
|
622
619
|
self._set_table_loading(False)
|
|
623
620
|
self._table_spinner_active = False
|
glaip_sdk/client/main.py
CHANGED
|
@@ -16,7 +16,7 @@ from glaip_sdk.client.mcps import MCPClient
|
|
|
16
16
|
from glaip_sdk.client.shared import build_shared_config
|
|
17
17
|
from glaip_sdk.client.tools import ToolClient
|
|
18
18
|
|
|
19
|
-
if TYPE_CHECKING:
|
|
19
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
20
20
|
from glaip_sdk.agents import Agent
|
|
21
21
|
from glaip_sdk.client._agent_payloads import AgentListResult
|
|
22
22
|
from glaip_sdk.mcps import MCP
|
glaip_sdk/registry/__init__.py
CHANGED
|
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING
|
|
|
17
17
|
from glaip_sdk.registry.base import BaseRegistry
|
|
18
18
|
|
|
19
19
|
# Lazy imports to avoid circular dependencies
|
|
20
|
-
if TYPE_CHECKING:
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
21
21
|
from glaip_sdk.registry.agent import AgentRegistry, get_agent_registry
|
|
22
22
|
from glaip_sdk.registry.mcp import MCPRegistry, get_mcp_registry
|
|
23
23
|
from glaip_sdk.registry.tool import ToolRegistry, get_tool_registry
|
glaip_sdk/registry/base.py
CHANGED
|
@@ -40,7 +40,7 @@ class BaseRegistry(ABC, Generic[T]):
|
|
|
40
40
|
_cache: Internal cache mapping names to objects.
|
|
41
41
|
|
|
42
42
|
Example:
|
|
43
|
-
>>> class MyRegistry(BaseRegistry
|
|
43
|
+
>>> class MyRegistry(BaseRegistry):
|
|
44
44
|
... def _extract_name(self, ref: Any) -> str:
|
|
45
45
|
... return ref.name if hasattr(ref, 'name') else str(ref)
|
|
46
46
|
...
|
glaip_sdk/utils/__init__.py
CHANGED
|
@@ -22,7 +22,7 @@ from glaip_sdk.utils.rendering.steps import StepManager
|
|
|
22
22
|
from glaip_sdk.utils.resource_refs import is_uuid, sanitize_name
|
|
23
23
|
|
|
24
24
|
# Lazy imports to avoid circular dependencies
|
|
25
|
-
if TYPE_CHECKING:
|
|
25
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
26
26
|
from glaip_sdk.utils.bundler import ToolBundler
|
|
27
27
|
from glaip_sdk.utils.client import get_client, reset_client, set_client
|
|
28
28
|
from glaip_sdk.utils.discovery import find_agent, find_tool
|
|
@@ -79,14 +79,6 @@ class ImportResolver:
|
|
|
79
79
|
Returns:
|
|
80
80
|
True if import is local.
|
|
81
81
|
"""
|
|
82
|
-
# Handle relative imports
|
|
83
|
-
if node.module and node.module.startswith("."):
|
|
84
|
-
module_name = node.module.lstrip(".")
|
|
85
|
-
if module_name:
|
|
86
|
-
potential_file = self.tool_dir / f"{module_name}.py"
|
|
87
|
-
return potential_file.exists()
|
|
88
|
-
return False
|
|
89
|
-
|
|
90
82
|
if not node.module:
|
|
91
83
|
return False
|
|
92
84
|
|