glaip-sdk 0.6.2__py3-none-any.whl → 0.6.4__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 +54 -8
- glaip_sdk/cli/account_store.py +36 -18
- glaip_sdk/cli/auth.py +1 -1
- glaip_sdk/cli/commands/accounts.py +2 -2
- glaip_sdk/cli/core/__init__.py +79 -0
- glaip_sdk/cli/core/context.py +124 -0
- glaip_sdk/cli/core/output.py +846 -0
- glaip_sdk/cli/core/prompting.py +649 -0
- glaip_sdk/cli/core/rendering.py +187 -0
- glaip_sdk/cli/slash/accounts_controller.py +308 -25
- glaip_sdk/cli/slash/accounts_shared.py +57 -1
- glaip_sdk/cli/slash/session.py +109 -24
- glaip_sdk/cli/slash/tui/accounts.tcss +33 -1
- glaip_sdk/cli/slash/tui/accounts_app.py +525 -32
- glaip_sdk/cli/slash/tui/remote_runs_app.py +3 -3
- glaip_sdk/cli/utils.py +241 -1732
- glaip_sdk/registry/mcp.py +8 -6
- glaip_sdk/utils/validation.py +3 -3
- {glaip_sdk-0.6.2.dist-info → glaip_sdk-0.6.4.dist-info}/METADATA +1 -1
- {glaip_sdk-0.6.2.dist-info → glaip_sdk-0.6.4.dist-info}/RECORD +22 -17
- {glaip_sdk-0.6.2.dist-info → glaip_sdk-0.6.4.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.6.2.dist-info → glaip_sdk-0.6.4.dist-info}/entry_points.txt +0 -0
|
@@ -13,17 +13,27 @@ import asyncio
|
|
|
13
13
|
import logging
|
|
14
14
|
from collections.abc import Callable
|
|
15
15
|
from dataclasses import dataclass
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
|
|
19
|
+
from glaip_sdk.cli.commands.common_config import check_connection_with_reason
|
|
20
|
+
from glaip_sdk.cli.slash.accounts_shared import (
|
|
21
|
+
build_account_rows,
|
|
22
|
+
build_account_status_string,
|
|
23
|
+
env_credentials_present,
|
|
24
|
+
)
|
|
18
25
|
from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
|
|
19
26
|
from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
|
|
27
|
+
from glaip_sdk.cli.validators import validate_api_key
|
|
28
|
+
from glaip_sdk.utils.validation import validate_url
|
|
20
29
|
|
|
21
30
|
try: # pragma: no cover - optional dependency
|
|
22
31
|
from textual import events
|
|
23
32
|
from textual.app import App, ComposeResult
|
|
24
33
|
from textual.binding import Binding
|
|
25
34
|
from textual.containers import Container, Horizontal, Vertical
|
|
26
|
-
from textual.
|
|
35
|
+
from textual.screen import ModalScreen
|
|
36
|
+
from textual.widgets import Button, Checkbox, DataTable, Footer, Header, Input, LoadingIndicator, Static
|
|
27
37
|
except Exception: # pragma: no cover - optional dependency
|
|
28
38
|
events = None # type: ignore[assignment]
|
|
29
39
|
App = None # type: ignore[assignment]
|
|
@@ -32,20 +42,37 @@ except Exception: # pragma: no cover - optional dependency
|
|
|
32
42
|
Container = None # type: ignore[assignment]
|
|
33
43
|
Horizontal = None # type: ignore[assignment]
|
|
34
44
|
Vertical = None # type: ignore[assignment]
|
|
45
|
+
Button = None # type: ignore[assignment]
|
|
46
|
+
Checkbox = None # type: ignore[assignment]
|
|
35
47
|
DataTable = None # type: ignore[assignment]
|
|
36
48
|
Footer = None # type: ignore[assignment]
|
|
37
49
|
Header = None # type: ignore[assignment]
|
|
38
50
|
Input = None # type: ignore[assignment]
|
|
39
51
|
LoadingIndicator = None # type: ignore[assignment]
|
|
52
|
+
ModalScreen = None # type: ignore[assignment]
|
|
40
53
|
Static = None # type: ignore[assignment]
|
|
41
54
|
|
|
42
55
|
TEXTUAL_SUPPORTED = App is not None and DataTable is not None
|
|
43
56
|
|
|
57
|
+
# Use safe bases so the module remains importable without Textual installed.
|
|
58
|
+
if TEXTUAL_SUPPORTED:
|
|
59
|
+
_AccountFormBase = ModalScreen[dict[str, Any] | None]
|
|
60
|
+
_ConfirmDeleteBase = ModalScreen[str | None]
|
|
61
|
+
_AppBase = App[None]
|
|
62
|
+
else:
|
|
63
|
+
_AccountFormBase = object
|
|
64
|
+
_ConfirmDeleteBase = object
|
|
65
|
+
_AppBase = object
|
|
66
|
+
|
|
44
67
|
# Widget IDs for Textual UI
|
|
45
68
|
ACCOUNTS_TABLE_ID = "#accounts-table"
|
|
46
69
|
FILTER_INPUT_ID = "#filter-input"
|
|
47
70
|
STATUS_ID = "#status"
|
|
48
71
|
ACCOUNTS_LOADING_ID = "#accounts-loading"
|
|
72
|
+
FORM_KEY_ID = "#form-key"
|
|
73
|
+
|
|
74
|
+
# CSS file name
|
|
75
|
+
CSS_FILE_NAME = "accounts.tcss"
|
|
49
76
|
|
|
50
77
|
|
|
51
78
|
@dataclass
|
|
@@ -55,6 +82,119 @@ class AccountsTUICallbacks:
|
|
|
55
82
|
switch_account: Callable[[str], tuple[bool, str]]
|
|
56
83
|
|
|
57
84
|
|
|
85
|
+
def _build_account_rows_from_store(
|
|
86
|
+
store: AccountStore,
|
|
87
|
+
env_lock: bool,
|
|
88
|
+
) -> tuple[list[dict[str, str | bool]], str | None]:
|
|
89
|
+
"""Load account rows with masking and active flag."""
|
|
90
|
+
accounts = store.list_accounts()
|
|
91
|
+
active = store.get_active_account()
|
|
92
|
+
rows = build_account_rows(accounts, active, env_lock)
|
|
93
|
+
return rows, active
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _prepare_account_payload(
|
|
97
|
+
*,
|
|
98
|
+
name: str,
|
|
99
|
+
api_url_input: str,
|
|
100
|
+
api_key_input: str,
|
|
101
|
+
existing_url: str | None,
|
|
102
|
+
existing_key: str | None,
|
|
103
|
+
existing_names: set[str],
|
|
104
|
+
mode: str,
|
|
105
|
+
should_test: bool,
|
|
106
|
+
validate_name: Callable[[str], None],
|
|
107
|
+
connection_tester: Callable[[str, str], tuple[bool, str]],
|
|
108
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
109
|
+
"""Validate and build payload for add/edit operations."""
|
|
110
|
+
name = name.strip()
|
|
111
|
+
api_url_raw = api_url_input.strip()
|
|
112
|
+
api_key_raw = api_key_input.strip()
|
|
113
|
+
|
|
114
|
+
error = _validate_account_name(name, existing_names, mode, validate_name)
|
|
115
|
+
if error:
|
|
116
|
+
return None, error
|
|
117
|
+
|
|
118
|
+
api_url_candidate = api_url_raw or (existing_url or "")
|
|
119
|
+
api_key_candidate = api_key_raw or (existing_key or "")
|
|
120
|
+
|
|
121
|
+
api_url_validated, error = _validate_and_prepare_url(api_url_candidate)
|
|
122
|
+
if error:
|
|
123
|
+
return None, error
|
|
124
|
+
|
|
125
|
+
api_key_validated, error = _validate_and_prepare_key(api_key_candidate)
|
|
126
|
+
if error:
|
|
127
|
+
return None, error
|
|
128
|
+
|
|
129
|
+
if should_test:
|
|
130
|
+
error = _test_connection(api_url_validated, api_key_validated, connection_tester)
|
|
131
|
+
if error:
|
|
132
|
+
return None, error
|
|
133
|
+
|
|
134
|
+
payload: dict[str, Any] = {
|
|
135
|
+
"name": name,
|
|
136
|
+
"api_url": api_url_validated,
|
|
137
|
+
"api_key": api_key_validated,
|
|
138
|
+
"should_test": should_test,
|
|
139
|
+
"mode": mode,
|
|
140
|
+
}
|
|
141
|
+
return payload, None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _validate_account_name(
|
|
145
|
+
name: str,
|
|
146
|
+
existing_names: set[str],
|
|
147
|
+
mode: str,
|
|
148
|
+
validate_name: Callable[[str], None],
|
|
149
|
+
) -> str | None:
|
|
150
|
+
"""Validate account name."""
|
|
151
|
+
if not name:
|
|
152
|
+
return "Account name cannot be empty."
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
validate_name(name)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
return str(exc)
|
|
158
|
+
|
|
159
|
+
if mode == "add" and name in existing_names:
|
|
160
|
+
return f"Account '{name}' already exists. Choose a unique name."
|
|
161
|
+
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _validate_and_prepare_url(api_url_candidate: str) -> tuple[str, str | None]:
|
|
166
|
+
"""Validate and prepare API URL."""
|
|
167
|
+
if not api_url_candidate:
|
|
168
|
+
return "", "API URL is required."
|
|
169
|
+
try:
|
|
170
|
+
return validate_url(api_url_candidate), None
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
return "", str(exc)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _validate_and_prepare_key(api_key_candidate: str) -> tuple[str, str | None]:
|
|
176
|
+
"""Validate and prepare API key."""
|
|
177
|
+
if not api_key_candidate:
|
|
178
|
+
return "", "API key is required."
|
|
179
|
+
try:
|
|
180
|
+
return validate_api_key(api_key_candidate), None
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
return "", str(exc)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _test_connection(
|
|
186
|
+
api_url: str,
|
|
187
|
+
api_key: str,
|
|
188
|
+
connection_tester: Callable[[str, str], tuple[bool, str]],
|
|
189
|
+
) -> str | None:
|
|
190
|
+
"""Test API connection."""
|
|
191
|
+
ok, reason = connection_tester(api_url, api_key)
|
|
192
|
+
if not ok:
|
|
193
|
+
detail = reason or "connection_failed"
|
|
194
|
+
return f"Connection test failed: {detail}"
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
58
198
|
def run_accounts_textual(
|
|
59
199
|
rows: list[dict[str, str | bool]],
|
|
60
200
|
*,
|
|
@@ -69,14 +209,182 @@ def run_accounts_textual(
|
|
|
69
209
|
app.run()
|
|
70
210
|
|
|
71
211
|
|
|
72
|
-
class
|
|
212
|
+
class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
|
|
213
|
+
"""Modal form for add/edit account."""
|
|
214
|
+
|
|
215
|
+
CSS_PATH = CSS_FILE_NAME
|
|
216
|
+
|
|
217
|
+
def __init__(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
mode: str,
|
|
221
|
+
existing: dict[str, str] | None,
|
|
222
|
+
existing_names: set[str],
|
|
223
|
+
connection_tester: Callable[[str, str], tuple[bool, str]],
|
|
224
|
+
validate_name: Callable[[str], None],
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Initialize the account form modal.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
mode: Form mode, either "add" or "edit".
|
|
230
|
+
existing: Existing account data for edit mode.
|
|
231
|
+
existing_names: Set of existing account names for validation.
|
|
232
|
+
connection_tester: Callable to test API connection.
|
|
233
|
+
validate_name: Callable to validate account name.
|
|
234
|
+
"""
|
|
235
|
+
super().__init__()
|
|
236
|
+
self._mode = mode
|
|
237
|
+
self._existing = existing or {}
|
|
238
|
+
self._existing_names = existing_names
|
|
239
|
+
self._connection_tester = connection_tester
|
|
240
|
+
self._validate_name = validate_name
|
|
241
|
+
|
|
242
|
+
def compose(self) -> ComposeResult:
|
|
243
|
+
"""Render the form controls."""
|
|
244
|
+
title = "Add account" if self._mode == "add" else "Edit account"
|
|
245
|
+
name_input = Input(
|
|
246
|
+
value=self._existing.get("name", ""),
|
|
247
|
+
placeholder="account-name",
|
|
248
|
+
id="form-name",
|
|
249
|
+
disabled=self._mode == "edit",
|
|
250
|
+
)
|
|
251
|
+
url_input = Input(value=self._existing.get("api_url", ""), placeholder="https://api.example.com", id="form-url")
|
|
252
|
+
key_input = Input(value="", placeholder="sk-...", password=True, id="form-key")
|
|
253
|
+
test_checkbox = Checkbox(
|
|
254
|
+
"Test connection before save",
|
|
255
|
+
value=True,
|
|
256
|
+
id="form-test",
|
|
257
|
+
)
|
|
258
|
+
status = Static("", id="form-status")
|
|
259
|
+
|
|
260
|
+
yield Static(title, id="form-title")
|
|
261
|
+
yield Static("Name", classes="form-label")
|
|
262
|
+
yield name_input
|
|
263
|
+
yield Static("API URL", classes="form-label")
|
|
264
|
+
yield url_input
|
|
265
|
+
yield Static("API Key", classes="form-label")
|
|
266
|
+
yield key_input
|
|
267
|
+
yield Horizontal(
|
|
268
|
+
Button("Show key", id="toggle-key"),
|
|
269
|
+
Button("Clear key", id="clear-key"),
|
|
270
|
+
id="form-key-actions",
|
|
271
|
+
)
|
|
272
|
+
yield test_checkbox
|
|
273
|
+
yield Horizontal(
|
|
274
|
+
Button("Save", id="form-save", variant="primary"),
|
|
275
|
+
Button("Cancel", id="form-cancel"),
|
|
276
|
+
id="form-actions",
|
|
277
|
+
)
|
|
278
|
+
yield status
|
|
279
|
+
|
|
280
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
281
|
+
"""Handle button presses."""
|
|
282
|
+
btn_id = event.button.id or ""
|
|
283
|
+
if btn_id == "form-cancel":
|
|
284
|
+
self.dismiss(None)
|
|
285
|
+
return
|
|
286
|
+
if btn_id == "toggle-key":
|
|
287
|
+
key_input = self.query_one(FORM_KEY_ID, Input)
|
|
288
|
+
key_input.password = not key_input.password
|
|
289
|
+
key_input.focus()
|
|
290
|
+
return
|
|
291
|
+
if btn_id == "clear-key":
|
|
292
|
+
key_input = self.query_one(FORM_KEY_ID, Input)
|
|
293
|
+
key_input.value = ""
|
|
294
|
+
key_input.focus()
|
|
295
|
+
return
|
|
296
|
+
if btn_id == "form-save":
|
|
297
|
+
self._handle_submit()
|
|
298
|
+
|
|
299
|
+
def _handle_submit(self) -> None:
|
|
300
|
+
"""Validate inputs and dismiss with payload on success."""
|
|
301
|
+
status = self.query_one("#form-status", Static)
|
|
302
|
+
name_input = self.query_one("#form-name", Input)
|
|
303
|
+
url_input = self.query_one("#form-url", Input)
|
|
304
|
+
key_input = self.query_one(FORM_KEY_ID, Input)
|
|
305
|
+
test_checkbox = self.query_one("#form-test", Checkbox)
|
|
306
|
+
|
|
307
|
+
payload, error = _prepare_account_payload(
|
|
308
|
+
name=name_input.value or "",
|
|
309
|
+
api_url_input=url_input.value or "",
|
|
310
|
+
api_key_input=key_input.value or "",
|
|
311
|
+
existing_url=self._existing.get("api_url"),
|
|
312
|
+
existing_key=self._existing.get("api_key"),
|
|
313
|
+
existing_names=self._existing_names,
|
|
314
|
+
mode=self._mode,
|
|
315
|
+
should_test=bool(test_checkbox.value),
|
|
316
|
+
validate_name=self._validate_name,
|
|
317
|
+
connection_tester=self._connection_tester,
|
|
318
|
+
)
|
|
319
|
+
if error:
|
|
320
|
+
status.update(f"[red]{error}[/]")
|
|
321
|
+
if error.startswith("Connection test failed") and hasattr(self.app, "_set_status"):
|
|
322
|
+
try:
|
|
323
|
+
# Surface a status-bar cue so errors remain visible after closing the modal.
|
|
324
|
+
self.app._set_status(error, "yellow") # type: ignore[attr-defined]
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
return
|
|
328
|
+
status.update("[green]Saving...[/]")
|
|
329
|
+
self.dismiss(payload)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class ConfirmDeleteModal(_ConfirmDeleteBase): # pragma: no cover - interactive
|
|
333
|
+
"""Modal requiring typed confirmation for delete."""
|
|
334
|
+
|
|
335
|
+
CSS_PATH = CSS_FILE_NAME
|
|
336
|
+
|
|
337
|
+
def __init__(self, name: str) -> None:
|
|
338
|
+
"""Initialize the delete confirmation modal.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
name: Name of the account to delete.
|
|
342
|
+
"""
|
|
343
|
+
super().__init__()
|
|
344
|
+
self._name = name
|
|
345
|
+
|
|
346
|
+
def compose(self) -> ComposeResult:
|
|
347
|
+
"""Render confirmation form."""
|
|
348
|
+
yield Static(f"Type '{self._name}' to confirm deletion. This cannot be undone.", id="confirm-text")
|
|
349
|
+
yield Input(placeholder=self._name, id="confirm-input")
|
|
350
|
+
yield Horizontal(
|
|
351
|
+
Button("Delete", id="confirm-delete", variant="error"),
|
|
352
|
+
Button("Cancel", id="confirm-cancel"),
|
|
353
|
+
id="confirm-actions",
|
|
354
|
+
)
|
|
355
|
+
yield Static("", id="confirm-status")
|
|
356
|
+
|
|
357
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
358
|
+
"""Handle confirmation buttons."""
|
|
359
|
+
btn_id = event.button.id or ""
|
|
360
|
+
if btn_id == "confirm-cancel":
|
|
361
|
+
self.dismiss(None)
|
|
362
|
+
return
|
|
363
|
+
if btn_id == "confirm-delete":
|
|
364
|
+
self._handle_confirm()
|
|
365
|
+
|
|
366
|
+
def _handle_confirm(self) -> None:
|
|
367
|
+
"""Dismiss with name when confirmation matches."""
|
|
368
|
+
status = self.query_one("#confirm-status", Static)
|
|
369
|
+
input_widget = self.query_one("#confirm-input", Input)
|
|
370
|
+
if (input_widget.value or "").strip() != self._name:
|
|
371
|
+
status.update(f"[yellow]Name does not match; type '{self._name}' to confirm.[/]")
|
|
372
|
+
input_widget.focus()
|
|
373
|
+
return
|
|
374
|
+
self.dismiss(self._name)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - interactive
|
|
73
378
|
"""Textual application for browsing accounts."""
|
|
74
379
|
|
|
75
|
-
CSS_PATH =
|
|
380
|
+
CSS_PATH = CSS_FILE_NAME
|
|
76
381
|
BINDINGS = [
|
|
77
382
|
Binding("enter", "switch_row", "Switch", show=True),
|
|
78
383
|
Binding("return", "switch_row", "Switch", show=False),
|
|
79
384
|
Binding("/", "focus_filter", "Filter", show=True),
|
|
385
|
+
Binding("a", "add_account", "Add", show=True),
|
|
386
|
+
Binding("e", "edit_account", "Edit", show=True),
|
|
387
|
+
Binding("d", "delete_account", "Delete", show=True),
|
|
80
388
|
# Esc clears filter when focused/non-empty; otherwise exits
|
|
81
389
|
Binding("escape", "clear_or_exit", "Close", priority=True),
|
|
82
390
|
Binding("q", "app_exit", "Close", priority=True),
|
|
@@ -98,6 +406,7 @@ class AccountsTextualApp(BackgroundTaskMixin, App[None]): # pragma: no cover -
|
|
|
98
406
|
callbacks: Callbacks for account switching operations.
|
|
99
407
|
"""
|
|
100
408
|
super().__init__()
|
|
409
|
+
self._store = get_account_store()
|
|
101
410
|
self._all_rows = rows
|
|
102
411
|
self._active_account = active_account
|
|
103
412
|
self._env_lock = env_lock
|
|
@@ -111,12 +420,15 @@ class AccountsTextualApp(BackgroundTaskMixin, App[None]): # pragma: no cover -
|
|
|
111
420
|
yield Static(header_text, id="header-info")
|
|
112
421
|
if self._env_lock:
|
|
113
422
|
yield Static(
|
|
114
|
-
"Env credentials detected (AIP_API_URL/AIP_API_KEY);
|
|
423
|
+
"Env credentials detected (AIP_API_URL/AIP_API_KEY); add/edit/delete are disabled.",
|
|
115
424
|
id="env-lock",
|
|
116
425
|
)
|
|
117
|
-
|
|
426
|
+
clear_btn = Button("Clear", id="filter-clear")
|
|
427
|
+
clear_btn.display = False # hide until filter has content
|
|
428
|
+
filter_bar = Horizontal(
|
|
118
429
|
Static("Filter (/):", id="filter-label"),
|
|
119
430
|
Input(placeholder="Type to filter by name or host", id="filter-input"),
|
|
431
|
+
clear_btn,
|
|
120
432
|
id="filter-container",
|
|
121
433
|
)
|
|
122
434
|
filter_bar.styles.padding = (0, 0)
|
|
@@ -151,6 +463,7 @@ class AccountsTextualApp(BackgroundTaskMixin, App[None]): # pragma: no cover -
|
|
|
151
463
|
# Keep the filter tight to the table
|
|
152
464
|
main = self.query_one(Vertical)
|
|
153
465
|
main.styles.gap = 0
|
|
466
|
+
self._update_filter_button_visibility()
|
|
154
467
|
|
|
155
468
|
def _header_text(self) -> str:
|
|
156
469
|
"""Build header text with active account and host."""
|
|
@@ -221,29 +534,15 @@ class AccountsTextualApp(BackgroundTaskMixin, App[None]): # pragma: no cover -
|
|
|
221
534
|
self._reload_rows()
|
|
222
535
|
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
223
536
|
table.focus()
|
|
537
|
+
self._update_filter_button_visibility()
|
|
224
538
|
|
|
225
539
|
def on_input_changed(self, event: Input.Changed) -> None:
|
|
226
540
|
"""Apply filter live as the user types."""
|
|
227
541
|
self._filter_text = (event.value or "").strip()
|
|
228
542
|
self._reload_rows()
|
|
543
|
+
self._update_filter_button_visibility()
|
|
229
544
|
|
|
230
|
-
def
|
|
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:
|
|
545
|
+
def _reload_rows(self, preferred_name: str | None = None) -> None:
|
|
247
546
|
"""Refresh table rows based on current filter/active state."""
|
|
248
547
|
# Work on a copy to avoid mutating the backing rows list
|
|
249
548
|
rows_copy = [dict(row) for row in self._all_rows]
|
|
@@ -268,8 +567,9 @@ class AccountsTextualApp(BackgroundTaskMixin, App[None]): # pragma: no cover -
|
|
|
268
567
|
)
|
|
269
568
|
# Move cursor to active or first row
|
|
270
569
|
cursor_idx = 0
|
|
570
|
+
target_name = preferred_name or self._active_account
|
|
271
571
|
for idx, row in enumerate(filtered):
|
|
272
|
-
if row.get("name") ==
|
|
572
|
+
if row.get("name") == target_name:
|
|
273
573
|
cursor_idx = idx
|
|
274
574
|
break
|
|
275
575
|
if filtered:
|
|
@@ -326,6 +626,13 @@ class AccountsTextualApp(BackgroundTaskMixin, App[None]): # pragma: no cover -
|
|
|
326
626
|
"""Hide the loading indicator."""
|
|
327
627
|
hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
|
|
328
628
|
|
|
629
|
+
def _clear_filter(self) -> None:
|
|
630
|
+
"""Clear the filter input and reset filter state."""
|
|
631
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
632
|
+
filter_input.value = ""
|
|
633
|
+
self._filter_text = ""
|
|
634
|
+
self._update_filter_button_visibility()
|
|
635
|
+
|
|
329
636
|
def _queue_switch(self, name: str) -> None:
|
|
330
637
|
"""Run switch in background to keep UI responsive."""
|
|
331
638
|
|
|
@@ -362,18 +669,204 @@ class AccountsTextualApp(BackgroundTaskMixin, App[None]): # pragma: no cover -
|
|
|
362
669
|
header.update(self._header_text())
|
|
363
670
|
|
|
364
671
|
def action_clear_or_exit(self) -> None:
|
|
365
|
-
"""Clear filter when focused
|
|
672
|
+
"""Clear or exit filter when focused; otherwise exit app.
|
|
366
673
|
|
|
367
674
|
UX note: helps users reset the list without leaving the TUI.
|
|
368
675
|
"""
|
|
369
676
|
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
self._reload_rows()
|
|
677
|
+
if filter_input.has_focus:
|
|
678
|
+
# Clear when there is text; otherwise just move focus back to the table
|
|
679
|
+
if filter_input.value or self._filter_text:
|
|
680
|
+
self._clear_filter()
|
|
681
|
+
self._reload_rows()
|
|
376
682
|
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
377
683
|
table.focus()
|
|
378
684
|
return
|
|
379
685
|
self.exit()
|
|
686
|
+
|
|
687
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
688
|
+
"""Handle filter bar buttons."""
|
|
689
|
+
if event.button.id == "filter-clear":
|
|
690
|
+
self._clear_filter()
|
|
691
|
+
self._reload_rows()
|
|
692
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
693
|
+
table.focus()
|
|
694
|
+
|
|
695
|
+
def action_add_account(self) -> None:
|
|
696
|
+
"""Open add account modal."""
|
|
697
|
+
if self._check_env_lock_hotkey():
|
|
698
|
+
return
|
|
699
|
+
if self._should_block_actions():
|
|
700
|
+
return
|
|
701
|
+
existing_names = {str(row.get("name", "")) for row in self._all_rows}
|
|
702
|
+
modal = AccountFormModal(
|
|
703
|
+
mode="add",
|
|
704
|
+
existing=None,
|
|
705
|
+
existing_names=existing_names,
|
|
706
|
+
connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
|
|
707
|
+
validate_name=self._store.validate_account_name,
|
|
708
|
+
)
|
|
709
|
+
self.push_screen(modal, self._on_form_result)
|
|
710
|
+
|
|
711
|
+
def action_edit_account(self) -> None:
|
|
712
|
+
"""Open edit account modal for selected row."""
|
|
713
|
+
if self._check_env_lock_hotkey():
|
|
714
|
+
return
|
|
715
|
+
if self._should_block_actions():
|
|
716
|
+
return
|
|
717
|
+
name = self._get_selected_name()
|
|
718
|
+
if not name:
|
|
719
|
+
self._set_status("Select an account to edit.", "yellow")
|
|
720
|
+
return
|
|
721
|
+
account = self._store.get_account(name)
|
|
722
|
+
if not account:
|
|
723
|
+
self._set_status(f"Account '{name}' not found.", "red")
|
|
724
|
+
return
|
|
725
|
+
existing_names = {str(row.get("name", "")) for row in self._all_rows if str(row.get("name", "")) != name}
|
|
726
|
+
modal = AccountFormModal(
|
|
727
|
+
mode="edit",
|
|
728
|
+
existing={"name": name, "api_url": account.get("api_url", ""), "api_key": account.get("api_key", "")},
|
|
729
|
+
existing_names=existing_names,
|
|
730
|
+
connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
|
|
731
|
+
validate_name=self._store.validate_account_name,
|
|
732
|
+
)
|
|
733
|
+
self.push_screen(modal, self._on_form_result)
|
|
734
|
+
|
|
735
|
+
def action_delete_account(self) -> None:
|
|
736
|
+
"""Open delete confirmation modal."""
|
|
737
|
+
if self._check_env_lock_hotkey():
|
|
738
|
+
return
|
|
739
|
+
if self._should_block_actions():
|
|
740
|
+
return
|
|
741
|
+
name = self._get_selected_name()
|
|
742
|
+
if not name:
|
|
743
|
+
self._set_status("Select an account to delete.", "yellow")
|
|
744
|
+
return
|
|
745
|
+
accounts = self._store.list_accounts()
|
|
746
|
+
if len(accounts) <= 1:
|
|
747
|
+
self._set_status("Cannot remove the last remaining account.", "red")
|
|
748
|
+
return
|
|
749
|
+
self.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
|
|
750
|
+
|
|
751
|
+
def _check_env_lock_hotkey(self) -> bool:
|
|
752
|
+
"""Prevent mutations when env credentials are present."""
|
|
753
|
+
if not self._is_env_locked():
|
|
754
|
+
return False
|
|
755
|
+
self._env_lock = True
|
|
756
|
+
self._set_status("Disabled by env-lock.", "yellow")
|
|
757
|
+
# Refresh UI to reflect env-lock state (header/banners/rows)
|
|
758
|
+
self._refresh_rows(preferred_name=self._active_account)
|
|
759
|
+
return True
|
|
760
|
+
|
|
761
|
+
def _on_form_result(self, payload: dict[str, Any] | None) -> None:
|
|
762
|
+
"""Handle add/edit modal result."""
|
|
763
|
+
if payload is None:
|
|
764
|
+
self._set_status("Edit/add cancelled.", "yellow")
|
|
765
|
+
return
|
|
766
|
+
self._save_account(payload)
|
|
767
|
+
|
|
768
|
+
def _on_delete_result(self, confirmed_name: str | None) -> None:
|
|
769
|
+
"""Handle delete confirmation result."""
|
|
770
|
+
if not confirmed_name:
|
|
771
|
+
self._set_status("Delete cancelled.", "yellow")
|
|
772
|
+
return
|
|
773
|
+
try:
|
|
774
|
+
self._store.remove_account(confirmed_name)
|
|
775
|
+
except AccountStoreError as exc:
|
|
776
|
+
self._set_status(f"Delete failed: {exc}", "red")
|
|
777
|
+
return
|
|
778
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
779
|
+
self._set_status(f"Unexpected delete error: {exc}", "red")
|
|
780
|
+
return
|
|
781
|
+
|
|
782
|
+
self._set_status(f"Account '{confirmed_name}' deleted.", "green")
|
|
783
|
+
# Clear filter before refresh to show all accounts
|
|
784
|
+
self._clear_filter()
|
|
785
|
+
# Refresh rows without preferred name to show all accounts
|
|
786
|
+
# Active account will be cleared if the deleted account was active
|
|
787
|
+
self._refresh_rows(preferred_name=None)
|
|
788
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
789
|
+
table.focus()
|
|
790
|
+
|
|
791
|
+
def _save_account(self, payload: dict[str, Any]) -> None:
|
|
792
|
+
"""Persist account data from modal payload."""
|
|
793
|
+
if self._is_env_locked():
|
|
794
|
+
self._set_status("Disabled by env-lock.", "yellow")
|
|
795
|
+
return
|
|
796
|
+
|
|
797
|
+
name = str(payload.get("name", ""))
|
|
798
|
+
api_url = str(payload.get("api_url", ""))
|
|
799
|
+
api_key = str(payload.get("api_key", ""))
|
|
800
|
+
set_active = bool(payload.get("set_active", payload.get("mode") == "add"))
|
|
801
|
+
is_edit = payload.get("mode") == "edit"
|
|
802
|
+
|
|
803
|
+
try:
|
|
804
|
+
self._store.add_account(name, api_url, api_key, overwrite=is_edit)
|
|
805
|
+
except AccountStoreError as exc:
|
|
806
|
+
self._set_status(f"Save failed: {exc}", "red")
|
|
807
|
+
return
|
|
808
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
809
|
+
self._set_status(f"Unexpected save error: {exc}", "red")
|
|
810
|
+
return
|
|
811
|
+
|
|
812
|
+
if set_active:
|
|
813
|
+
try:
|
|
814
|
+
self._store.set_active_account(name)
|
|
815
|
+
self._active_account = name
|
|
816
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
817
|
+
self._set_status(f"Saved but could not set active: {exc}", "yellow")
|
|
818
|
+
else:
|
|
819
|
+
self._announce_active_change(name)
|
|
820
|
+
self._update_header()
|
|
821
|
+
|
|
822
|
+
self._set_status(f"Account '{name}' saved.", "green")
|
|
823
|
+
# Clear filter before refresh to show all accounts
|
|
824
|
+
self._clear_filter()
|
|
825
|
+
# Refresh rows with preferred name to highlight the saved account
|
|
826
|
+
self._refresh_rows(preferred_name=name)
|
|
827
|
+
# Return focus to the table for immediate hotkey use
|
|
828
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
829
|
+
table.focus()
|
|
830
|
+
|
|
831
|
+
def _refresh_rows(self, preferred_name: str | None = None) -> None:
|
|
832
|
+
"""Reload rows from store and preserve filter/cursor."""
|
|
833
|
+
self._env_lock = self._is_env_locked()
|
|
834
|
+
self._all_rows, self._active_account = _build_account_rows_from_store(self._store, self._env_lock)
|
|
835
|
+
self._reload_rows(preferred_name=preferred_name)
|
|
836
|
+
self._update_header()
|
|
837
|
+
|
|
838
|
+
def _get_selected_name(self) -> str | None:
|
|
839
|
+
"""Return selected account name, if any."""
|
|
840
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
841
|
+
if table.cursor_row is None:
|
|
842
|
+
return None
|
|
843
|
+
try:
|
|
844
|
+
row = table.get_row_at(table.cursor_row)
|
|
845
|
+
except Exception:
|
|
846
|
+
return None
|
|
847
|
+
return str(row[0]) if row else None
|
|
848
|
+
|
|
849
|
+
def _is_env_locked(self) -> bool:
|
|
850
|
+
"""Return True when env credentials are set (even partially)."""
|
|
851
|
+
return env_credentials_present(partial=True)
|
|
852
|
+
|
|
853
|
+
def _announce_active_change(self, name: str) -> None:
|
|
854
|
+
"""Surface active account change in status bar."""
|
|
855
|
+
account = self._store.get_account(name) or {}
|
|
856
|
+
host = account.get("api_url", "")
|
|
857
|
+
host_suffix = f" • {host}" if host else ""
|
|
858
|
+
self._set_status(f"Active account ➜ {name}{host_suffix}", "green")
|
|
859
|
+
|
|
860
|
+
def _should_block_actions(self) -> bool:
|
|
861
|
+
"""Return True when mutating hotkeys are blocked by filter focus."""
|
|
862
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
863
|
+
if filter_input.has_focus:
|
|
864
|
+
self._set_status("Exit filter (Esc or Clear) to add/edit/delete.", "yellow")
|
|
865
|
+
return True
|
|
866
|
+
return False
|
|
867
|
+
|
|
868
|
+
def _update_filter_button_visibility(self) -> None:
|
|
869
|
+
"""Show clear button only when filter has content."""
|
|
870
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
871
|
+
clear_btn = self.query_one("#filter-clear", Button)
|
|
872
|
+
clear_btn.display = bool(filter_input.value or self._filter_text)
|
|
@@ -284,13 +284,13 @@ class RemoteRunsTextualApp(App[None]):
|
|
|
284
284
|
"Duration",
|
|
285
285
|
"Input Preview",
|
|
286
286
|
)
|
|
287
|
-
yield table
|
|
288
|
-
yield Horizontal(
|
|
287
|
+
yield table # pragma: no cover - interactive UI, tested via integration
|
|
288
|
+
yield Horizontal( # pragma: no cover - interactive UI, tested via integration
|
|
289
289
|
LoadingIndicator(id=RUNS_LOADING_ID),
|
|
290
290
|
Static(id="status"),
|
|
291
291
|
id="status-bar",
|
|
292
292
|
)
|
|
293
|
-
yield Footer()
|
|
293
|
+
yield Footer() # pragma: no cover - interactive UI, tested via integration
|
|
294
294
|
|
|
295
295
|
def on_mount(self) -> None:
|
|
296
296
|
"""Render the initial page."""
|