glaip-sdk 0.6.1__py3-none-any.whl → 0.6.3__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.
@@ -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
- from glaip_sdk.cli.slash.accounts_shared import build_account_status_string
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.widgets import DataTable, Footer, Header, Input, LoadingIndicator, Static
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 AccountsTextualApp(BackgroundTaskMixin, App[None]): # pragma: no cover - interactive
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 = "accounts.tcss"
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); switching is disabled.",
423
+ "Env credentials detected (AIP_API_URL/AIP_API_KEY); add/edit/delete are disabled.",
115
424
  id="env-lock",
116
425
  )
117
- filter_bar = Container(
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 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:
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") == self._active_account:
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/non-empty; otherwise exit.
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
- # 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()
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."""