glaip-sdk 0.7.9__py3-none-any.whl → 0.7.11__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 +61 -10
- glaip_sdk/branding.py +113 -2
- glaip_sdk/cli/slash/remote_runs_controller.py +2 -0
- glaip_sdk/cli/slash/session.py +331 -30
- glaip_sdk/cli/slash/tui/accounts.tcss +72 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +827 -101
- glaip_sdk/cli/slash/tui/clipboard.py +56 -8
- glaip_sdk/cli/slash/tui/context.py +5 -2
- glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
- glaip_sdk/cli/slash/tui/layouts/harlequin.py +160 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
- glaip_sdk/cli/slash/tui/terminal.py +8 -3
- glaip_sdk/cli/slash/tui/toast.py +270 -19
- glaip_sdk/client/run_rendering.py +76 -29
- glaip_sdk/guardrails/__init__.py +80 -0
- glaip_sdk/guardrails/serializer.py +89 -0
- glaip_sdk/payload_schemas/agent.py +1 -0
- glaip_sdk/payload_schemas/guardrails.py +34 -0
- glaip_sdk/runner/langgraph.py +1 -0
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.11.dist-info}/METADATA +3 -1
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.11.dist-info}/RECORD +24 -19
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.11.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.11.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.11.dist-info}/top_level.txt +0 -0
|
@@ -18,7 +18,7 @@ import asyncio
|
|
|
18
18
|
import logging
|
|
19
19
|
from collections.abc import Callable
|
|
20
20
|
from dataclasses import dataclass
|
|
21
|
-
from typing import Any
|
|
21
|
+
from typing import Any, cast
|
|
22
22
|
|
|
23
23
|
from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
|
|
24
24
|
from glaip_sdk.cli.commands.common_config import check_connection_with_reason
|
|
@@ -31,10 +31,19 @@ from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
|
|
|
31
31
|
from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter, ClipboardResult
|
|
32
32
|
from glaip_sdk.cli.slash.tui.context import TUIContext
|
|
33
33
|
from glaip_sdk.cli.slash.tui.keybind_registry import KeybindRegistry
|
|
34
|
+
from glaip_sdk.cli.slash.tui.layouts.harlequin import HarlequinScreen
|
|
34
35
|
from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
|
|
35
36
|
from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
|
|
36
37
|
from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
|
|
37
|
-
|
|
38
|
+
|
|
39
|
+
try: # pragma: no cover - optional dependency
|
|
40
|
+
from glaip_sdk.cli.slash.tui.toast import ClipboardToastMixin, Toast, ToastBus, ToastHandlerMixin, ToastVariant
|
|
41
|
+
except Exception: # pragma: no cover - optional dependency
|
|
42
|
+
ClipboardToastMixin = object # type: ignore[assignment, misc]
|
|
43
|
+
Toast = None # type: ignore[assignment]
|
|
44
|
+
ToastBus = None # type: ignore[assignment]
|
|
45
|
+
ToastHandlerMixin = object # type: ignore[assignment, misc]
|
|
46
|
+
ToastVariant = None # type: ignore[assignment]
|
|
38
47
|
from glaip_sdk.cli.validators import validate_api_key
|
|
39
48
|
from glaip_sdk.utils.validation import validate_url
|
|
40
49
|
|
|
@@ -450,7 +459,672 @@ class ConfirmDeleteModal(_ConfirmDeleteBase): # pragma: no cover - interactive
|
|
|
450
459
|
self.dismiss(self._name)
|
|
451
460
|
|
|
452
461
|
|
|
453
|
-
|
|
462
|
+
# Widget IDs for Harlequin layout
|
|
463
|
+
HARLEQUIN_ACCOUNTS_LIST_ID = "#harlequin-accounts-list"
|
|
464
|
+
HARLEQUIN_DETAIL_ID = "#harlequin-detail"
|
|
465
|
+
HARLEQUIN_DETAIL_URL_ID = "#harlequin-detail-url"
|
|
466
|
+
HARLEQUIN_DETAIL_KEY_ID = "#harlequin-detail-key"
|
|
467
|
+
HARLEQUIN_DETAIL_STATUS_ID = "#harlequin-detail-status"
|
|
468
|
+
HARLEQUIN_DETAIL_ACTIONS_ID = "#harlequin-detail-actions"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class AccountsHarlequinScreen( # pragma: no cover - interactive
|
|
472
|
+
ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, HarlequinScreen
|
|
473
|
+
):
|
|
474
|
+
"""Harlequin layout screen for account management.
|
|
475
|
+
|
|
476
|
+
Implements Phase 1 of the TUI Harlequin Layout spec:
|
|
477
|
+
- Left pane (25%): Account Profile names list
|
|
478
|
+
- Right pane (75%): URL, API Key (hidden by default), Connection Status, Action Palette
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
CSS_PATH = CSS_FILE_NAME
|
|
482
|
+
|
|
483
|
+
BINDINGS = [
|
|
484
|
+
Binding("enter", "switch_account", "Switch", show=True) if Binding else None,
|
|
485
|
+
Binding("return", "switch_account", "Switch", show=False) if Binding else None,
|
|
486
|
+
Binding("/", "focus_filter", "Filter", show=True) if Binding else None,
|
|
487
|
+
Binding("a", "add_account", "Add", show=True) if Binding else None,
|
|
488
|
+
Binding("e", "edit_account", "Edit", show=True) if Binding else None,
|
|
489
|
+
Binding("d", "delete_account", "Delete", show=True) if Binding else None,
|
|
490
|
+
Binding("c", "copy_account", "Copy", show=True) if Binding else None,
|
|
491
|
+
Binding("escape", "clear_or_exit", "Close", priority=True) if Binding else None,
|
|
492
|
+
Binding("q", "app_exit", "Close", priority=True) if Binding else None,
|
|
493
|
+
]
|
|
494
|
+
BINDINGS = [b for b in BINDINGS if b is not None]
|
|
495
|
+
|
|
496
|
+
def __init__(
|
|
497
|
+
self,
|
|
498
|
+
rows: list[dict[str, str | bool]],
|
|
499
|
+
active_account: str | None,
|
|
500
|
+
env_lock: bool,
|
|
501
|
+
callbacks: AccountsTUICallbacks,
|
|
502
|
+
ctx: TUIContext | None = None,
|
|
503
|
+
) -> None:
|
|
504
|
+
"""Initialize the Harlequin accounts screen.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
rows: Account data rows to display.
|
|
508
|
+
active_account: Name of the currently active account.
|
|
509
|
+
env_lock: Whether environment credentials are locking account switching.
|
|
510
|
+
callbacks: Callbacks for account switching operations.
|
|
511
|
+
ctx: Shared TUI context.
|
|
512
|
+
"""
|
|
513
|
+
super().__init__(ctx=ctx)
|
|
514
|
+
self._store = get_account_store()
|
|
515
|
+
self._all_rows = rows
|
|
516
|
+
self._active_account = active_account
|
|
517
|
+
self._env_lock = env_lock
|
|
518
|
+
self._account_callbacks = callbacks
|
|
519
|
+
self._keybinds: KeybindRegistry | None = None
|
|
520
|
+
self._toast_bus: ToastBus | None = None
|
|
521
|
+
self._clipboard: ClipboardAdapter | None = None
|
|
522
|
+
self._filter_text: str = ""
|
|
523
|
+
self._is_switching = False
|
|
524
|
+
self._selected_account: dict[str, str | bool] | None = None
|
|
525
|
+
self._key_visible = False
|
|
526
|
+
self._initialize_context_services()
|
|
527
|
+
|
|
528
|
+
def compose(self) -> ComposeResult: # type: ignore[return]
|
|
529
|
+
"""Compose the Harlequin layout with account list and detail panes."""
|
|
530
|
+
if not TEXTUAL_SUPPORTED or Horizontal is None or Vertical is None or Container is None:
|
|
531
|
+
return # type: ignore[return-value]
|
|
532
|
+
|
|
533
|
+
# Main container with horizontal split (25/75)
|
|
534
|
+
with Horizontal(id="harlequin-container"):
|
|
535
|
+
# Left pane (25% width) with account list
|
|
536
|
+
with Vertical(id="left-pane"):
|
|
537
|
+
yield Static("Accounts", id="left-pane-title")
|
|
538
|
+
yield Input(placeholder="Filter...", id="harlequin-filter")
|
|
539
|
+
yield DataTable(id=HARLEQUIN_ACCOUNTS_LIST_ID.lstrip("#"))
|
|
540
|
+
# Right pane (75% width) with account details
|
|
541
|
+
with Vertical(id="right-pane"):
|
|
542
|
+
yield Static("Account Details", id="right-pane-title")
|
|
543
|
+
yield Static("", id=HARLEQUIN_DETAIL_ID.lstrip("#"))
|
|
544
|
+
with Vertical(id="detail-fields"):
|
|
545
|
+
yield Static("URL:", classes="detail-label")
|
|
546
|
+
yield Static("", id=HARLEQUIN_DETAIL_URL_ID.lstrip("#"))
|
|
547
|
+
yield Static("API Key:", classes="detail-label")
|
|
548
|
+
yield Static("", id=HARLEQUIN_DETAIL_KEY_ID.lstrip("#"))
|
|
549
|
+
yield Static("Status:", classes="detail-label")
|
|
550
|
+
yield Static("", id=HARLEQUIN_DETAIL_STATUS_ID.lstrip("#"))
|
|
551
|
+
with Horizontal(id=HARLEQUIN_DETAIL_ACTIONS_ID.lstrip("#")):
|
|
552
|
+
yield Button("(a) Add", id="action-add")
|
|
553
|
+
yield Button("(e) Edit", id="action-edit")
|
|
554
|
+
yield Button("(d) Delete", id="action-delete")
|
|
555
|
+
yield Button("(c) Copy", id="action-copy")
|
|
556
|
+
yield Static("", id="harlequin-status")
|
|
557
|
+
# Help text showing keyboard shortcuts at the bottom
|
|
558
|
+
yield Static(
|
|
559
|
+
"[dim]↑↓ Navigate | Enter Switch | a Add | e Edit | d Delete | c Copy | q/Esc Exit[/dim]",
|
|
560
|
+
id="help-text",
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
# Toast container for notifications
|
|
564
|
+
if Toast is not None:
|
|
565
|
+
yield Container(Toast(), id="toast-container")
|
|
566
|
+
|
|
567
|
+
def on_mount(self) -> None:
|
|
568
|
+
"""Configure the screen after mount."""
|
|
569
|
+
if not TEXTUAL_SUPPORTED:
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
self._apply_theme()
|
|
573
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
574
|
+
table.add_column("Account", width=None)
|
|
575
|
+
table.cursor_type = "row"
|
|
576
|
+
table.zebra_stripes = True
|
|
577
|
+
self._reload_accounts_list()
|
|
578
|
+
table.focus()
|
|
579
|
+
self._prepare_toasts()
|
|
580
|
+
self._register_keybinds()
|
|
581
|
+
self._update_detail_pane()
|
|
582
|
+
|
|
583
|
+
def _initialize_context_services(self) -> None:
|
|
584
|
+
"""Initialize TUI context services."""
|
|
585
|
+
|
|
586
|
+
def _notify(message: ToastBus.Changed) -> None:
|
|
587
|
+
self.post_message(message)
|
|
588
|
+
|
|
589
|
+
ctx = self.ctx if hasattr(self, "ctx") else self._ctx
|
|
590
|
+
if ctx:
|
|
591
|
+
if ctx.keybinds is None:
|
|
592
|
+
ctx.keybinds = KeybindRegistry()
|
|
593
|
+
if ctx.toasts is None and ToastBus is not None:
|
|
594
|
+
ctx.toasts = ToastBus(on_change=_notify)
|
|
595
|
+
if ctx.clipboard is None:
|
|
596
|
+
ctx.clipboard = ClipboardAdapter(terminal=ctx.terminal)
|
|
597
|
+
self._keybinds = ctx.keybinds
|
|
598
|
+
self._toast_bus = ctx.toasts
|
|
599
|
+
self._clipboard = ctx.clipboard
|
|
600
|
+
else:
|
|
601
|
+
terminal = TerminalCapabilities(
|
|
602
|
+
tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
|
|
603
|
+
)
|
|
604
|
+
self._clipboard = ClipboardAdapter(terminal=terminal)
|
|
605
|
+
if ToastBus is not None:
|
|
606
|
+
self._toast_bus = ToastBus(on_change=_notify)
|
|
607
|
+
|
|
608
|
+
def _prepare_toasts(self) -> None:
|
|
609
|
+
"""Prepare toast system."""
|
|
610
|
+
if self._toast_bus:
|
|
611
|
+
self._toast_bus.clear()
|
|
612
|
+
|
|
613
|
+
def _register_keybinds(self) -> None:
|
|
614
|
+
"""Register keybinds with the registry."""
|
|
615
|
+
if not self._keybinds:
|
|
616
|
+
return
|
|
617
|
+
for keybind_def in KEYBIND_DEFINITIONS:
|
|
618
|
+
scoped_action = f"{KEYBIND_SCOPE}.{keybind_def.action}"
|
|
619
|
+
if self._keybinds.get(scoped_action):
|
|
620
|
+
continue
|
|
621
|
+
try:
|
|
622
|
+
self._keybinds.register(
|
|
623
|
+
action=scoped_action,
|
|
624
|
+
key=keybind_def.key,
|
|
625
|
+
description=keybind_def.description,
|
|
626
|
+
category=KEYBIND_CATEGORY,
|
|
627
|
+
)
|
|
628
|
+
except ValueError as e:
|
|
629
|
+
logging.debug(f"Skipping duplicate keybind registration: {scoped_action}", exc_info=e)
|
|
630
|
+
continue
|
|
631
|
+
|
|
632
|
+
def _reload_accounts_list(self, preferred_name: str | None = None) -> None:
|
|
633
|
+
"""Reload the accounts list in the left pane."""
|
|
634
|
+
if not TEXTUAL_SUPPORTED:
|
|
635
|
+
return
|
|
636
|
+
|
|
637
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
638
|
+
table.clear()
|
|
639
|
+
|
|
640
|
+
filtered = self._filtered_rows()
|
|
641
|
+
for row in filtered:
|
|
642
|
+
name = str(row.get("name", ""))
|
|
643
|
+
# Highlight active account
|
|
644
|
+
if row.get("name") == self._active_account:
|
|
645
|
+
name = f"[green]●[/] {name}"
|
|
646
|
+
table.add_row(name)
|
|
647
|
+
|
|
648
|
+
# Move cursor to active or preferred account
|
|
649
|
+
cursor_idx = 0
|
|
650
|
+
target_name = preferred_name or self._active_account
|
|
651
|
+
for idx, row in enumerate(filtered):
|
|
652
|
+
if row.get("name") == target_name:
|
|
653
|
+
cursor_idx = idx
|
|
654
|
+
break
|
|
655
|
+
|
|
656
|
+
if filtered:
|
|
657
|
+
table.cursor_coordinate = (cursor_idx, 0)
|
|
658
|
+
self._update_selected_account(filtered[cursor_idx] if cursor_idx < len(filtered) else None)
|
|
659
|
+
else:
|
|
660
|
+
self._update_selected_account(None)
|
|
661
|
+
self._set_status("No accounts match the current filter.", "yellow")
|
|
662
|
+
|
|
663
|
+
def _filtered_rows(self) -> list[dict[str, str | bool]]:
|
|
664
|
+
"""Return filtered account rows."""
|
|
665
|
+
if not self._filter_text:
|
|
666
|
+
return list(self._all_rows)
|
|
667
|
+
|
|
668
|
+
needle = self._filter_text.lower()
|
|
669
|
+
filtered = [
|
|
670
|
+
row
|
|
671
|
+
for row in self._all_rows
|
|
672
|
+
if needle in str(row.get("name", "")).lower() or needle in str(row.get("api_url", "")).lower()
|
|
673
|
+
]
|
|
674
|
+
|
|
675
|
+
def score(row: dict[str, str | bool]) -> tuple[int, str]:
|
|
676
|
+
name = str(row.get("name", "")).lower()
|
|
677
|
+
url = str(row.get("api_url", "")).lower()
|
|
678
|
+
name_hit = needle in name
|
|
679
|
+
url_hit = needle in url
|
|
680
|
+
priority = 0 if name_hit else (1 if url_hit else 2)
|
|
681
|
+
return (priority, name)
|
|
682
|
+
|
|
683
|
+
return sorted(filtered, key=score)
|
|
684
|
+
|
|
685
|
+
def _update_selected_account(self, account: dict[str, str | bool] | None) -> None:
|
|
686
|
+
"""Update the selected account and refresh detail pane."""
|
|
687
|
+
self._selected_account = account
|
|
688
|
+
self._update_detail_pane()
|
|
689
|
+
|
|
690
|
+
def _update_detail_pane(self) -> None:
|
|
691
|
+
"""Update the right pane with selected account details."""
|
|
692
|
+
if not TEXTUAL_SUPPORTED:
|
|
693
|
+
return
|
|
694
|
+
|
|
695
|
+
if not self._selected_account:
|
|
696
|
+
detail = self.query_one(HARLEQUIN_DETAIL_ID, Static)
|
|
697
|
+
detail.update("[dim]Select an account to view details[/]")
|
|
698
|
+
url_widget = self.query_one(HARLEQUIN_DETAIL_URL_ID, Static)
|
|
699
|
+
url_widget.update("")
|
|
700
|
+
key_widget = self.query_one(HARLEQUIN_DETAIL_KEY_ID, Static)
|
|
701
|
+
key_widget.update("")
|
|
702
|
+
status_widget = self.query_one(HARLEQUIN_DETAIL_STATUS_ID, Static)
|
|
703
|
+
status_widget.update("")
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
account = self._selected_account
|
|
707
|
+
name = str(account.get("name", ""))
|
|
708
|
+
url = str(account.get("api_url", ""))
|
|
709
|
+
masked_key = str(account.get("masked_key", ""))
|
|
710
|
+
api_key = str(account.get("api_key", ""))
|
|
711
|
+
|
|
712
|
+
# Update detail header
|
|
713
|
+
detail = self.query_one(HARLEQUIN_DETAIL_ID, Static)
|
|
714
|
+
detail.update(f"[bold]{name}[/]")
|
|
715
|
+
|
|
716
|
+
# Update URL
|
|
717
|
+
url_widget = self.query_one(HARLEQUIN_DETAIL_URL_ID, Static)
|
|
718
|
+
url_widget.update(url)
|
|
719
|
+
|
|
720
|
+
# Update API Key (hidden by default, toggle with button)
|
|
721
|
+
key_widget = self.query_one(HARLEQUIN_DETAIL_KEY_ID, Static)
|
|
722
|
+
if self._key_visible and api_key:
|
|
723
|
+
key_widget.update(api_key)
|
|
724
|
+
else:
|
|
725
|
+
key_widget.update(masked_key)
|
|
726
|
+
|
|
727
|
+
# Update Status
|
|
728
|
+
row_for_status = dict(account)
|
|
729
|
+
row_for_status["active"] = row_for_status.get("name") == self._active_account
|
|
730
|
+
status_str = build_account_status_string(row_for_status, use_markup=True)
|
|
731
|
+
status_widget = self.query_one(HARLEQUIN_DETAIL_STATUS_ID, Static)
|
|
732
|
+
status_widget.update(status_str)
|
|
733
|
+
|
|
734
|
+
def _set_status(self, message: str, style: str) -> None:
|
|
735
|
+
"""Update status message."""
|
|
736
|
+
if not TEXTUAL_SUPPORTED:
|
|
737
|
+
return
|
|
738
|
+
status = self.query_one("#harlequin-status", Static)
|
|
739
|
+
status.update(f"[{style}]{message}[/]")
|
|
740
|
+
|
|
741
|
+
def _get_selected_name(self) -> str | None:
|
|
742
|
+
"""Get the name of the currently selected account."""
|
|
743
|
+
if not TEXTUAL_SUPPORTED or not self._selected_account:
|
|
744
|
+
return None
|
|
745
|
+
return str(self._selected_account.get("name", ""))
|
|
746
|
+
|
|
747
|
+
def action_switch_account(self) -> None:
|
|
748
|
+
"""Switch to the currently selected account."""
|
|
749
|
+
if self._env_lock:
|
|
750
|
+
self._set_status("Switching disabled: env credentials in use.", "yellow")
|
|
751
|
+
return
|
|
752
|
+
|
|
753
|
+
# Ensure account is selected from cursor position if not explicitly selected
|
|
754
|
+
if not self._selected_account:
|
|
755
|
+
try:
|
|
756
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
757
|
+
cursor_row = table.cursor_row
|
|
758
|
+
if cursor_row is not None and cursor_row >= 0:
|
|
759
|
+
filtered = self._filtered_rows()
|
|
760
|
+
if cursor_row < len(filtered):
|
|
761
|
+
self._update_selected_account(filtered[cursor_row])
|
|
762
|
+
except Exception:
|
|
763
|
+
pass
|
|
764
|
+
|
|
765
|
+
name = self._get_selected_name()
|
|
766
|
+
if not name:
|
|
767
|
+
self._set_status("No account selected.", "yellow")
|
|
768
|
+
return
|
|
769
|
+
|
|
770
|
+
if self._is_switching:
|
|
771
|
+
self._set_status("Already switching...", "yellow")
|
|
772
|
+
return
|
|
773
|
+
|
|
774
|
+
self._is_switching = True
|
|
775
|
+
host = self._get_host_for_name(name)
|
|
776
|
+
message = f"Connecting to '{name}' ({host})..." if host else f"Connecting to '{name}'..."
|
|
777
|
+
self._set_status(message, "cyan")
|
|
778
|
+
self._queue_switch(name)
|
|
779
|
+
|
|
780
|
+
def _get_host_for_name(self, name: str | None) -> str | None:
|
|
781
|
+
"""Return shortened API URL for a given account name."""
|
|
782
|
+
if not name:
|
|
783
|
+
return None
|
|
784
|
+
for row in self._all_rows:
|
|
785
|
+
if row.get("name") == name:
|
|
786
|
+
url = str(row.get("api_url", ""))
|
|
787
|
+
return url if len(url) <= 40 else f"{url[:37]}..."
|
|
788
|
+
return None
|
|
789
|
+
|
|
790
|
+
def _queue_switch(self, name: str) -> None:
|
|
791
|
+
"""Run switch in background."""
|
|
792
|
+
|
|
793
|
+
async def perform() -> None:
|
|
794
|
+
try:
|
|
795
|
+
switched, message = await asyncio.to_thread(self._account_callbacks.switch_account, name)
|
|
796
|
+
except Exception as exc:
|
|
797
|
+
self._set_status(f"Switch failed: {exc}", "red")
|
|
798
|
+
return
|
|
799
|
+
finally:
|
|
800
|
+
self._is_switching = False
|
|
801
|
+
|
|
802
|
+
if switched:
|
|
803
|
+
# Refresh active account from store to ensure consistency
|
|
804
|
+
self._active_account = self._store.get_active_account() or name
|
|
805
|
+
status_msg = message or f"Switched to '{name}'."
|
|
806
|
+
if self._toast_bus:
|
|
807
|
+
self._toast_bus.show(message=status_msg, variant="success")
|
|
808
|
+
self._set_status(status_msg, "green")
|
|
809
|
+
# Reload accounts list to update green indicator
|
|
810
|
+
self._reload_accounts_list(preferred_name=name)
|
|
811
|
+
self._update_detail_pane()
|
|
812
|
+
else:
|
|
813
|
+
self._set_status(message or "Switch failed; kept previous account.", "yellow")
|
|
814
|
+
|
|
815
|
+
try:
|
|
816
|
+
self.track_task(perform(), logger=logging.getLogger(__name__))
|
|
817
|
+
except Exception as exc:
|
|
818
|
+
self._is_switching = False
|
|
819
|
+
self._set_status(f"Switch failed to start: {exc}", "red")
|
|
820
|
+
|
|
821
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
|
|
822
|
+
"""Handle row selection in the accounts list."""
|
|
823
|
+
if not TEXTUAL_SUPPORTED:
|
|
824
|
+
return
|
|
825
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
826
|
+
try:
|
|
827
|
+
table.cursor_coordinate = (event.cursor_row, 0)
|
|
828
|
+
except Exception:
|
|
829
|
+
return
|
|
830
|
+
filtered = self._filtered_rows()
|
|
831
|
+
if event.cursor_row < len(filtered):
|
|
832
|
+
self._update_selected_account(filtered[event.cursor_row])
|
|
833
|
+
if not self._is_switching:
|
|
834
|
+
self.action_switch_account()
|
|
835
|
+
|
|
836
|
+
def on_data_table_cursor_row_changed(self, event: DataTable.CursorRowChanged) -> None: # type: ignore[override]
|
|
837
|
+
"""Handle cursor movement in the accounts list."""
|
|
838
|
+
if not TEXTUAL_SUPPORTED:
|
|
839
|
+
return
|
|
840
|
+
filtered = self._filtered_rows()
|
|
841
|
+
if event.cursor_row is not None and event.cursor_row < len(filtered):
|
|
842
|
+
self._update_selected_account(filtered[event.cursor_row])
|
|
843
|
+
|
|
844
|
+
def action_focus_filter(self) -> None:
|
|
845
|
+
"""Focus the filter input."""
|
|
846
|
+
if not TEXTUAL_SUPPORTED:
|
|
847
|
+
return
|
|
848
|
+
filter_input = self.query_one("#harlequin-filter", Input)
|
|
849
|
+
filter_input.value = self._filter_text
|
|
850
|
+
filter_input.focus()
|
|
851
|
+
|
|
852
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
853
|
+
"""Handle filter input changes."""
|
|
854
|
+
if not TEXTUAL_SUPPORTED:
|
|
855
|
+
return
|
|
856
|
+
if event.input.id == "harlequin-filter":
|
|
857
|
+
self._filter_text = (event.value or "").strip()
|
|
858
|
+
self._reload_accounts_list()
|
|
859
|
+
|
|
860
|
+
def action_add_account(self) -> None:
|
|
861
|
+
"""Open add account modal."""
|
|
862
|
+
if self._check_env_lock():
|
|
863
|
+
return
|
|
864
|
+
existing_names = {str(row.get("name", "")) for row in self._all_rows}
|
|
865
|
+
modal = AccountFormModal(
|
|
866
|
+
mode="add",
|
|
867
|
+
existing=None,
|
|
868
|
+
existing_names=existing_names,
|
|
869
|
+
connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
|
|
870
|
+
validate_name=self._store.validate_account_name,
|
|
871
|
+
)
|
|
872
|
+
self.app.push_screen(modal, self._on_form_result)
|
|
873
|
+
|
|
874
|
+
def action_edit_account(self) -> None:
|
|
875
|
+
"""Open edit account modal."""
|
|
876
|
+
if self._check_env_lock():
|
|
877
|
+
return
|
|
878
|
+
name = self._get_selected_name()
|
|
879
|
+
if not name:
|
|
880
|
+
self._set_status("Select an account to edit.", "yellow")
|
|
881
|
+
return
|
|
882
|
+
account = self._store.get_account(name)
|
|
883
|
+
if not account:
|
|
884
|
+
self._set_status(f"Account '{name}' not found.", "red")
|
|
885
|
+
return
|
|
886
|
+
existing_names = {str(row.get("name", "")) for row in self._all_rows if str(row.get("name", "")) != name}
|
|
887
|
+
modal = AccountFormModal(
|
|
888
|
+
mode="edit",
|
|
889
|
+
existing={"name": name, "api_url": account.get("api_url", ""), "api_key": account.get("api_key", "")},
|
|
890
|
+
existing_names=existing_names,
|
|
891
|
+
connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
|
|
892
|
+
validate_name=self._store.validate_account_name,
|
|
893
|
+
)
|
|
894
|
+
self.app.push_screen(modal, self._on_form_result)
|
|
895
|
+
|
|
896
|
+
def action_delete_account(self) -> None:
|
|
897
|
+
"""Open delete confirmation modal."""
|
|
898
|
+
if self._check_env_lock():
|
|
899
|
+
return
|
|
900
|
+
name = self._get_selected_name()
|
|
901
|
+
if not name:
|
|
902
|
+
self._set_status("Select an account to delete.", "yellow")
|
|
903
|
+
return
|
|
904
|
+
accounts = self._store.list_accounts()
|
|
905
|
+
if len(accounts) <= 1:
|
|
906
|
+
self._set_status("Cannot remove the last remaining account.", "red")
|
|
907
|
+
return
|
|
908
|
+
self.app.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
|
|
909
|
+
|
|
910
|
+
def _ensure_account_selected_from_cursor(self) -> None:
|
|
911
|
+
"""Ensure an account is selected, using cursor position if needed."""
|
|
912
|
+
if self._selected_account:
|
|
913
|
+
return
|
|
914
|
+
try:
|
|
915
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
916
|
+
cursor_row = table.cursor_row
|
|
917
|
+
if cursor_row is not None and cursor_row >= 0:
|
|
918
|
+
row = table.get_row_at(cursor_row)
|
|
919
|
+
if row:
|
|
920
|
+
account_name = str(row[0])
|
|
921
|
+
# Find the account data
|
|
922
|
+
for account_data in self._all_rows:
|
|
923
|
+
if str(account_data.get("name", "")) == account_name:
|
|
924
|
+
self._selected_account = account_data
|
|
925
|
+
self._update_detail_pane()
|
|
926
|
+
break
|
|
927
|
+
except Exception:
|
|
928
|
+
pass
|
|
929
|
+
|
|
930
|
+
def action_copy_account(self) -> None:
|
|
931
|
+
"""Copy selected account to clipboard."""
|
|
932
|
+
# Get account from cursor position if not explicitly selected
|
|
933
|
+
self._ensure_account_selected_from_cursor()
|
|
934
|
+
|
|
935
|
+
name = self._get_selected_name()
|
|
936
|
+
if not name:
|
|
937
|
+
self._set_status("Select an account to copy.", "yellow")
|
|
938
|
+
return
|
|
939
|
+
|
|
940
|
+
account = self._store.get_account(name)
|
|
941
|
+
if not account:
|
|
942
|
+
return
|
|
943
|
+
|
|
944
|
+
text = f"Account: {name}\nURL: {account.get('api_url', '')}"
|
|
945
|
+
adapter = self._clipboard_adapter()
|
|
946
|
+
writer = self._osc52_writer()
|
|
947
|
+
if writer:
|
|
948
|
+
result = adapter.copy(text, writer=writer)
|
|
949
|
+
else:
|
|
950
|
+
result = adapter.copy(text)
|
|
951
|
+
self._handle_copy_result(name, result)
|
|
952
|
+
|
|
953
|
+
def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
|
|
954
|
+
"""Handle copy operation result."""
|
|
955
|
+
if result.success:
|
|
956
|
+
if self._toast_bus:
|
|
957
|
+
self._toast_bus.copy_success(f"Account '{name}'")
|
|
958
|
+
self._set_status(f"Copied '{name}' to clipboard.", "green")
|
|
959
|
+
else:
|
|
960
|
+
if self._toast_bus and ToastVariant is not None:
|
|
961
|
+
self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
|
|
962
|
+
self._set_status(f"Copy failed: {result.message}", "red")
|
|
963
|
+
|
|
964
|
+
def _clipboard_adapter(self) -> ClipboardAdapter:
|
|
965
|
+
"""Get clipboard adapter."""
|
|
966
|
+
ctx = self.ctx if hasattr(self, "ctx") else getattr(self, "_ctx", None)
|
|
967
|
+
if ctx is not None and ctx.clipboard is not None:
|
|
968
|
+
return cast(ClipboardAdapter, ctx.clipboard)
|
|
969
|
+
if self._clipboard is not None:
|
|
970
|
+
return self._clipboard
|
|
971
|
+
adapter = ClipboardAdapter(terminal=ctx.terminal if ctx else None)
|
|
972
|
+
if ctx is not None:
|
|
973
|
+
ctx.clipboard = adapter
|
|
974
|
+
else:
|
|
975
|
+
self._clipboard = adapter
|
|
976
|
+
return adapter
|
|
977
|
+
|
|
978
|
+
def _osc52_writer(self) -> Callable[[str], Any] | None:
|
|
979
|
+
"""Get OSC52 writer if available."""
|
|
980
|
+
try:
|
|
981
|
+
console = getattr(self, "console", None)
|
|
982
|
+
except Exception:
|
|
983
|
+
return None
|
|
984
|
+
if console is None:
|
|
985
|
+
return None
|
|
986
|
+
output = getattr(console, "file", None)
|
|
987
|
+
if output is None:
|
|
988
|
+
return None
|
|
989
|
+
|
|
990
|
+
def _write(sequence: str, _output=output) -> None:
|
|
991
|
+
_output.write(sequence)
|
|
992
|
+
_output.flush()
|
|
993
|
+
|
|
994
|
+
return _write
|
|
995
|
+
|
|
996
|
+
def _check_env_lock(self) -> bool:
|
|
997
|
+
"""Check if env lock prevents mutations."""
|
|
998
|
+
if not self._is_env_locked():
|
|
999
|
+
return False
|
|
1000
|
+
self._env_lock = True
|
|
1001
|
+
self._set_status("Disabled by env-lock.", "yellow")
|
|
1002
|
+
self._refresh_rows()
|
|
1003
|
+
return True
|
|
1004
|
+
|
|
1005
|
+
def _is_env_locked(self) -> bool:
|
|
1006
|
+
"""Check if environment credentials are locking operations."""
|
|
1007
|
+
return env_credentials_present(partial=True)
|
|
1008
|
+
|
|
1009
|
+
def _on_form_result(self, payload: dict[str, Any] | None) -> None:
|
|
1010
|
+
"""Handle add/edit modal result."""
|
|
1011
|
+
if payload is None:
|
|
1012
|
+
self._set_status("Edit/add cancelled.", "yellow")
|
|
1013
|
+
return
|
|
1014
|
+
self._save_account(payload)
|
|
1015
|
+
|
|
1016
|
+
def _on_delete_result(self, confirmed_name: str | None) -> None:
|
|
1017
|
+
"""Handle delete confirmation result."""
|
|
1018
|
+
if not confirmed_name:
|
|
1019
|
+
self._set_status("Delete cancelled.", "yellow")
|
|
1020
|
+
return
|
|
1021
|
+
try:
|
|
1022
|
+
self._store.remove_account(confirmed_name)
|
|
1023
|
+
except AccountStoreError as exc:
|
|
1024
|
+
self._set_status(f"Delete failed: {exc}", "red")
|
|
1025
|
+
return
|
|
1026
|
+
except Exception as exc:
|
|
1027
|
+
self._set_status(f"Unexpected delete error: {exc}", "red")
|
|
1028
|
+
return
|
|
1029
|
+
|
|
1030
|
+
self._set_status(f"Account '{confirmed_name}' deleted.", "green")
|
|
1031
|
+
self._refresh_rows()
|
|
1032
|
+
|
|
1033
|
+
def _save_account(self, payload: dict[str, Any]) -> None:
|
|
1034
|
+
"""Save account from modal payload."""
|
|
1035
|
+
if self._is_env_locked():
|
|
1036
|
+
self._set_status("Disabled by env-lock.", "yellow")
|
|
1037
|
+
return
|
|
1038
|
+
|
|
1039
|
+
name = str(payload.get("name", ""))
|
|
1040
|
+
api_url = str(payload.get("api_url", ""))
|
|
1041
|
+
api_key = str(payload.get("api_key", ""))
|
|
1042
|
+
set_active = bool(payload.get("set_active", payload.get("mode") == "add"))
|
|
1043
|
+
is_edit = payload.get("mode") == "edit"
|
|
1044
|
+
|
|
1045
|
+
try:
|
|
1046
|
+
self._store.add_account(name, api_url, api_key, overwrite=is_edit)
|
|
1047
|
+
except AccountStoreError as exc:
|
|
1048
|
+
self._set_status(f"Save failed: {exc}", "red")
|
|
1049
|
+
return
|
|
1050
|
+
except Exception as exc:
|
|
1051
|
+
self._set_status(f"Unexpected save error: {exc}", "red")
|
|
1052
|
+
return
|
|
1053
|
+
|
|
1054
|
+
if set_active:
|
|
1055
|
+
try:
|
|
1056
|
+
self._store.set_active_account(name)
|
|
1057
|
+
self._active_account = name
|
|
1058
|
+
except Exception as exc:
|
|
1059
|
+
self._set_status(f"Saved but could not set active: {exc}", "yellow")
|
|
1060
|
+
else:
|
|
1061
|
+
if self._toast_bus:
|
|
1062
|
+
self._toast_bus.show(message=f"Switched to '{name}'", variant="success")
|
|
1063
|
+
|
|
1064
|
+
self._set_status(f"Account '{name}' saved.", "green")
|
|
1065
|
+
self._refresh_rows(preferred_name=name)
|
|
1066
|
+
|
|
1067
|
+
def _refresh_rows(self, preferred_name: str | None = None) -> None:
|
|
1068
|
+
"""Refresh account rows from store."""
|
|
1069
|
+
self._env_lock = self._is_env_locked()
|
|
1070
|
+
self._all_rows, self._active_account = _build_account_rows_from_store(self._store, self._env_lock)
|
|
1071
|
+
self._reload_accounts_list(preferred_name=preferred_name)
|
|
1072
|
+
if self._selected_account:
|
|
1073
|
+
# Refresh selected account details
|
|
1074
|
+
name = str(self._selected_account.get("name", ""))
|
|
1075
|
+
for row in self._all_rows:
|
|
1076
|
+
if row.get("name") == name:
|
|
1077
|
+
self._update_selected_account(row)
|
|
1078
|
+
break
|
|
1079
|
+
|
|
1080
|
+
def action_clear_or_exit(self) -> None:
|
|
1081
|
+
"""Clear filter or exit."""
|
|
1082
|
+
if not TEXTUAL_SUPPORTED:
|
|
1083
|
+
return
|
|
1084
|
+
filter_input = self.query_one("#harlequin-filter", Input)
|
|
1085
|
+
if filter_input.has_focus:
|
|
1086
|
+
if filter_input.value or self._filter_text:
|
|
1087
|
+
filter_input.value = ""
|
|
1088
|
+
self._filter_text = ""
|
|
1089
|
+
self._reload_accounts_list()
|
|
1090
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
1091
|
+
table.focus()
|
|
1092
|
+
return
|
|
1093
|
+
self.dismiss()
|
|
1094
|
+
|
|
1095
|
+
def action_app_exit(self) -> None:
|
|
1096
|
+
"""Exit the application."""
|
|
1097
|
+
self.dismiss()
|
|
1098
|
+
|
|
1099
|
+
def _apply_theme(self) -> None:
|
|
1100
|
+
"""Apply theme from context."""
|
|
1101
|
+
ctx = self.ctx if hasattr(self, "ctx") else getattr(self, "_ctx", None)
|
|
1102
|
+
if not ctx or not ctx.theme or Theme is None:
|
|
1103
|
+
return
|
|
1104
|
+
|
|
1105
|
+
app = self.app
|
|
1106
|
+
if app is None:
|
|
1107
|
+
return
|
|
1108
|
+
|
|
1109
|
+
for name, tokens in _BUILTIN_THEMES.items():
|
|
1110
|
+
app.register_theme(
|
|
1111
|
+
Theme(
|
|
1112
|
+
name=name,
|
|
1113
|
+
primary=tokens.primary,
|
|
1114
|
+
secondary=tokens.secondary,
|
|
1115
|
+
accent=tokens.accent,
|
|
1116
|
+
warning=tokens.warning,
|
|
1117
|
+
error=tokens.error,
|
|
1118
|
+
success=tokens.success,
|
|
1119
|
+
)
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
app.theme = ctx.theme.theme_name
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
class AccountsTextualApp( # pragma: no cover - interactive
|
|
1126
|
+
ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, _AppBase
|
|
1127
|
+
):
|
|
454
1128
|
"""Textual application for browsing accounts."""
|
|
455
1129
|
|
|
456
1130
|
CSS_PATH = CSS_FILE_NAME
|
|
@@ -490,76 +1164,43 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
490
1164
|
self._all_rows = rows
|
|
491
1165
|
self._active_account = active_account
|
|
492
1166
|
self._env_lock = env_lock
|
|
493
|
-
self.
|
|
1167
|
+
self._account_callbacks = callbacks
|
|
494
1168
|
self._ctx = ctx
|
|
495
1169
|
self._keybinds: KeybindRegistry | None = None
|
|
496
1170
|
self._toast_bus: ToastBus | None = None
|
|
497
|
-
self._toast_ready = False
|
|
498
1171
|
self._clipboard: ClipboardAdapter | None = None
|
|
499
1172
|
self._filter_text: str = ""
|
|
500
1173
|
self._is_switching = False
|
|
501
1174
|
self._initialize_context_services()
|
|
502
1175
|
|
|
503
1176
|
def compose(self) -> ComposeResult:
|
|
504
|
-
"""Build the Textual
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
yield Static(
|
|
509
|
-
"Env credentials detected (AIP_API_URL/AIP_API_KEY); add/edit/delete are disabled.",
|
|
510
|
-
id="env-lock",
|
|
511
|
-
)
|
|
512
|
-
clear_btn = Button("Clear", id="filter-clear")
|
|
513
|
-
clear_btn.display = False # hide until filter has content
|
|
514
|
-
filter_bar = Horizontal(
|
|
515
|
-
Static("Filter (/):", id="filter-label"),
|
|
516
|
-
Input(placeholder="Type to filter by name or host", id="filter-input"),
|
|
517
|
-
clear_btn,
|
|
518
|
-
id="filter-container",
|
|
519
|
-
)
|
|
520
|
-
filter_bar.styles.padding = (0, 0)
|
|
521
|
-
main = Vertical(
|
|
522
|
-
filter_bar,
|
|
523
|
-
DataTable(id=ACCOUNTS_TABLE_ID.lstrip("#")),
|
|
524
|
-
)
|
|
525
|
-
# Avoid large gaps; keep main content filling available space
|
|
526
|
-
main.styles.height = "1fr"
|
|
527
|
-
main.styles.padding = (0, 0)
|
|
528
|
-
yield main
|
|
529
|
-
yield Horizontal(
|
|
530
|
-
LoadingIndicator(id=ACCOUNTS_LOADING_ID.lstrip("#")),
|
|
531
|
-
Static("", id=STATUS_ID.lstrip("#")),
|
|
532
|
-
id="status-bar",
|
|
533
|
-
)
|
|
1177
|
+
"""Build the Textual app (empty, screen is pushed on mount)."""
|
|
1178
|
+
# The app itself is empty; AccountsHarlequinScreen is pushed on mount
|
|
1179
|
+
if not TEXTUAL_SUPPORTED or Footer is None:
|
|
1180
|
+
return # type: ignore[return-value]
|
|
534
1181
|
yield Footer()
|
|
535
1182
|
|
|
536
1183
|
def on_mount(self) -> None:
|
|
537
|
-
"""
|
|
1184
|
+
"""Push the Harlequin accounts screen on mount."""
|
|
538
1185
|
self._apply_theme()
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
table.styles.margin = 0
|
|
548
|
-
self._reload_rows()
|
|
549
|
-
table.focus()
|
|
550
|
-
# Keep the filter tight to the table
|
|
551
|
-
main = self.query_one(Vertical)
|
|
552
|
-
main.styles.gap = 0
|
|
553
|
-
self._update_filter_button_visibility()
|
|
554
|
-
self._prepare_toasts()
|
|
555
|
-
self._register_keybinds()
|
|
1186
|
+
harlequin_screen = AccountsHarlequinScreen(
|
|
1187
|
+
rows=self._all_rows,
|
|
1188
|
+
active_account=self._active_account,
|
|
1189
|
+
env_lock=self._env_lock,
|
|
1190
|
+
callbacks=self._account_callbacks,
|
|
1191
|
+
ctx=self._ctx,
|
|
1192
|
+
)
|
|
1193
|
+
self.push_screen(harlequin_screen)
|
|
556
1194
|
|
|
557
1195
|
def _initialize_context_services(self) -> None:
|
|
1196
|
+
def _notify(message: ToastBus.Changed) -> None:
|
|
1197
|
+
self.post_message(message)
|
|
1198
|
+
|
|
558
1199
|
if self._ctx:
|
|
559
1200
|
if self._ctx.keybinds is None:
|
|
560
1201
|
self._ctx.keybinds = KeybindRegistry()
|
|
561
|
-
if self._ctx.toasts is None:
|
|
562
|
-
self._ctx.toasts = ToastBus()
|
|
1202
|
+
if self._ctx.toasts is None and ToastBus is not None:
|
|
1203
|
+
self._ctx.toasts = ToastBus(on_change=_notify)
|
|
563
1204
|
if self._ctx.clipboard is None:
|
|
564
1205
|
self._ctx.clipboard = ClipboardAdapter(terminal=self._ctx.terminal)
|
|
565
1206
|
self._keybinds = self._ctx.keybinds
|
|
@@ -571,10 +1212,11 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
571
1212
|
tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
|
|
572
1213
|
)
|
|
573
1214
|
self._clipboard = ClipboardAdapter(terminal=terminal)
|
|
1215
|
+
if ToastBus is not None:
|
|
1216
|
+
self._toast_bus = ToastBus(on_change=_notify)
|
|
574
1217
|
|
|
575
1218
|
def _prepare_toasts(self) -> None:
|
|
576
|
-
"""Prepare toast system by
|
|
577
|
-
self._toast_ready = True
|
|
1219
|
+
"""Prepare toast system by clearing any existing toasts."""
|
|
578
1220
|
if self._toast_bus:
|
|
579
1221
|
self._toast_bus.clear()
|
|
580
1222
|
|
|
@@ -621,16 +1263,32 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
621
1263
|
|
|
622
1264
|
def action_focus_filter(self) -> None:
|
|
623
1265
|
"""Focus the filter input and clear previous text."""
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
1266
|
+
# Skip if Harlequin screen is active (it handles its own filter focus)
|
|
1267
|
+
if isinstance(self.screen, AccountsHarlequinScreen):
|
|
1268
|
+
return
|
|
1269
|
+
try:
|
|
1270
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
1271
|
+
filter_input.value = self._filter_text
|
|
1272
|
+
filter_input.focus()
|
|
1273
|
+
except Exception:
|
|
1274
|
+
# Filter input doesn't exist, skip
|
|
1275
|
+
pass
|
|
627
1276
|
|
|
628
1277
|
def action_switch_row(self) -> None:
|
|
629
|
-
"""Switch to the currently selected account.
|
|
1278
|
+
"""Switch to the currently selected account.
|
|
1279
|
+
|
|
1280
|
+
Note: This action is for the old table layout. When using HarlequinScreen,
|
|
1281
|
+
the screen handles switching directly. This gracefully skips if the
|
|
1282
|
+
old table doesn't exist.
|
|
1283
|
+
"""
|
|
1284
|
+
try:
|
|
1285
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
1286
|
+
except Exception:
|
|
1287
|
+
# Harlequin screen is active, let it handle the action
|
|
1288
|
+
return
|
|
630
1289
|
if self._env_lock:
|
|
631
1290
|
self._set_status("Switching disabled: env credentials in use.", "yellow")
|
|
632
1291
|
return
|
|
633
|
-
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
634
1292
|
if table.cursor_row is None:
|
|
635
1293
|
self._set_status("No account selected.", "yellow")
|
|
636
1294
|
return
|
|
@@ -652,8 +1310,17 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
652
1310
|
self._queue_switch(name)
|
|
653
1311
|
|
|
654
1312
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
|
|
655
|
-
"""Handle mouse click selection by triggering switch.
|
|
656
|
-
|
|
1313
|
+
"""Handle mouse click selection by triggering switch.
|
|
1314
|
+
|
|
1315
|
+
Note: This handler is for the old table layout. When using HarlequinScreen,
|
|
1316
|
+
the screen handles row selection directly. This handler gracefully skips
|
|
1317
|
+
if the old table doesn't exist.
|
|
1318
|
+
"""
|
|
1319
|
+
try:
|
|
1320
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
1321
|
+
except Exception:
|
|
1322
|
+
# Harlequin screen is active, let it handle the event
|
|
1323
|
+
return
|
|
657
1324
|
try:
|
|
658
1325
|
# Move cursor to clicked row then switch
|
|
659
1326
|
table.cursor_coordinate = (event.cursor_row, 0)
|
|
@@ -671,18 +1338,32 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
671
1338
|
|
|
672
1339
|
def on_input_changed(self, event: Input.Changed) -> None:
|
|
673
1340
|
"""Apply filter live as the user types."""
|
|
1341
|
+
# Skip if Harlequin screen is active (it handles its own filtering)
|
|
1342
|
+
if isinstance(self.screen, AccountsHarlequinScreen):
|
|
1343
|
+
return
|
|
674
1344
|
self._filter_text = (event.value or "").strip()
|
|
675
1345
|
self._reload_rows()
|
|
676
1346
|
self._update_filter_button_visibility()
|
|
677
1347
|
|
|
678
1348
|
def _reload_rows(self, preferred_name: str | None = None) -> None:
|
|
679
1349
|
"""Refresh table rows based on current filter/active state."""
|
|
1350
|
+
# Skip if Harlequin screen is active (it handles its own reloading)
|
|
1351
|
+
try:
|
|
1352
|
+
if isinstance(self.screen, AccountsHarlequinScreen):
|
|
1353
|
+
return
|
|
1354
|
+
except Exception: # pragma: no cover - defensive (e.g., ScreenStackError in tests)
|
|
1355
|
+
# App not fully initialized or no screen pushed, continue with normal reload
|
|
1356
|
+
pass
|
|
680
1357
|
# Work on a copy to avoid mutating the backing rows list
|
|
681
1358
|
rows_copy = [dict(row) for row in self._all_rows]
|
|
682
1359
|
for row in rows_copy:
|
|
683
1360
|
row["active"] = row.get("name") == self._active_account
|
|
684
1361
|
|
|
685
|
-
|
|
1362
|
+
try:
|
|
1363
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
1364
|
+
except Exception:
|
|
1365
|
+
# Harlequin screen is active, skip
|
|
1366
|
+
return
|
|
686
1367
|
table.clear()
|
|
687
1368
|
filtered = self._filtered_rows(rows_copy)
|
|
688
1369
|
for row in filtered:
|
|
@@ -768,7 +1449,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
768
1449
|
self._hide_loading()
|
|
769
1450
|
self._is_switching = False
|
|
770
1451
|
error_msg = f"Switch failed to start: {exc}"
|
|
771
|
-
if self.
|
|
1452
|
+
if self._toast_bus:
|
|
772
1453
|
self._toast_bus.show(message=error_msg, variant="error")
|
|
773
1454
|
try:
|
|
774
1455
|
self._set_status(error_msg, "red")
|
|
@@ -779,9 +1460,16 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
779
1460
|
|
|
780
1461
|
def _clear_filter(self) -> None:
|
|
781
1462
|
"""Clear the filter input and reset filter state."""
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
1463
|
+
# Skip if Harlequin screen is active (it handles its own filtering)
|
|
1464
|
+
if isinstance(self.screen, AccountsHarlequinScreen):
|
|
1465
|
+
return
|
|
1466
|
+
try:
|
|
1467
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
1468
|
+
filter_input.value = ""
|
|
1469
|
+
self._filter_text = ""
|
|
1470
|
+
except Exception:
|
|
1471
|
+
# Filter input doesn't exist, just clear the text
|
|
1472
|
+
self._filter_text = ""
|
|
785
1473
|
self._update_filter_button_visibility()
|
|
786
1474
|
|
|
787
1475
|
def _queue_switch(self, name: str) -> None:
|
|
@@ -789,7 +1477,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
789
1477
|
|
|
790
1478
|
async def perform() -> None:
|
|
791
1479
|
try:
|
|
792
|
-
switched, message = await asyncio.to_thread(self.
|
|
1480
|
+
switched, message = await asyncio.to_thread(self._account_callbacks.switch_account, name)
|
|
793
1481
|
except Exception as exc: # pragma: no cover - defensive
|
|
794
1482
|
self._set_status(f"Switch failed: {exc}", "red")
|
|
795
1483
|
return
|
|
@@ -800,7 +1488,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
800
1488
|
if switched:
|
|
801
1489
|
self._active_account = name
|
|
802
1490
|
status_msg = message or f"Switched to '{name}'."
|
|
803
|
-
if self.
|
|
1491
|
+
if self._toast_bus:
|
|
804
1492
|
self._toast_bus.show(message=status_msg, variant="success")
|
|
805
1493
|
self._update_header()
|
|
806
1494
|
self._reload_rows()
|
|
@@ -822,15 +1510,26 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
822
1510
|
|
|
823
1511
|
UX note: helps users reset the list without leaving the TUI.
|
|
824
1512
|
"""
|
|
825
|
-
|
|
826
|
-
if
|
|
827
|
-
|
|
828
|
-
if filter_input.value or self._filter_text:
|
|
829
|
-
self._clear_filter()
|
|
830
|
-
self._reload_rows()
|
|
831
|
-
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
832
|
-
table.focus()
|
|
1513
|
+
# Skip if Harlequin screen is active (it handles its own exit)
|
|
1514
|
+
if isinstance(self.screen, AccountsHarlequinScreen):
|
|
1515
|
+
self.exit()
|
|
833
1516
|
return
|
|
1517
|
+
try:
|
|
1518
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
1519
|
+
if filter_input.has_focus:
|
|
1520
|
+
# Clear when there is text; otherwise just move focus back to the table
|
|
1521
|
+
if filter_input.value or self._filter_text:
|
|
1522
|
+
self._clear_filter()
|
|
1523
|
+
self._reload_rows()
|
|
1524
|
+
try:
|
|
1525
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
1526
|
+
table.focus()
|
|
1527
|
+
except Exception:
|
|
1528
|
+
pass
|
|
1529
|
+
return
|
|
1530
|
+
except Exception:
|
|
1531
|
+
# Filter input doesn't exist, just exit
|
|
1532
|
+
pass
|
|
834
1533
|
self.exit()
|
|
835
1534
|
|
|
836
1535
|
def action_app_exit(self) -> None:
|
|
@@ -913,34 +1612,54 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
913
1612
|
return
|
|
914
1613
|
|
|
915
1614
|
text = f"Account: {name}\nURL: {account.get('api_url', '')}"
|
|
916
|
-
adapter = self.
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1615
|
+
adapter = self._clipboard_adapter()
|
|
1616
|
+
writer = self._osc52_writer()
|
|
1617
|
+
if writer:
|
|
1618
|
+
result = adapter.copy(text, writer=writer)
|
|
1619
|
+
else:
|
|
921
1620
|
result = adapter.copy(text)
|
|
922
|
-
|
|
923
|
-
return
|
|
924
|
-
|
|
925
|
-
async def perform() -> None:
|
|
926
|
-
result = await asyncio.to_thread(adapter.copy, text)
|
|
927
|
-
self._handle_copy_result(name, result)
|
|
928
|
-
|
|
929
|
-
self.track_task(perform(), logger=logging.getLogger(__name__))
|
|
1621
|
+
self._handle_copy_result(name, result)
|
|
930
1622
|
|
|
931
1623
|
def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
|
|
932
1624
|
"""Update UI state after a copy attempt."""
|
|
933
1625
|
if result.success:
|
|
934
|
-
if self.
|
|
935
|
-
self._toast_bus.copy_success(
|
|
936
|
-
# Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
|
|
1626
|
+
if self._toast_bus:
|
|
1627
|
+
self._toast_bus.copy_success(f"Account '{name}'")
|
|
937
1628
|
self._set_status(f"Copied '{name}' to clipboard.", "green")
|
|
938
1629
|
else:
|
|
939
|
-
if self.
|
|
940
|
-
self._toast_bus.show(message=f"Copy failed: {result.message}", variant=
|
|
941
|
-
# Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
|
|
1630
|
+
if self._toast_bus and ToastVariant is not None:
|
|
1631
|
+
self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
|
|
942
1632
|
self._set_status(f"Copy failed: {result.message}", "red")
|
|
943
1633
|
|
|
1634
|
+
def _clipboard_adapter(self) -> ClipboardAdapter:
|
|
1635
|
+
if self._ctx is not None and self._ctx.clipboard is not None:
|
|
1636
|
+
return cast(ClipboardAdapter, self._ctx.clipboard)
|
|
1637
|
+
if self._clipboard is not None:
|
|
1638
|
+
return self._clipboard
|
|
1639
|
+
adapter = ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
|
|
1640
|
+
if self._ctx is not None:
|
|
1641
|
+
self._ctx.clipboard = adapter
|
|
1642
|
+
else:
|
|
1643
|
+
self._clipboard = adapter
|
|
1644
|
+
return adapter
|
|
1645
|
+
|
|
1646
|
+
def _osc52_writer(self) -> Callable[[str], Any] | None:
|
|
1647
|
+
try:
|
|
1648
|
+
console = getattr(self, "console", None)
|
|
1649
|
+
except Exception:
|
|
1650
|
+
return None
|
|
1651
|
+
if console is None:
|
|
1652
|
+
return None
|
|
1653
|
+
output = getattr(console, "file", None)
|
|
1654
|
+
if output is None:
|
|
1655
|
+
return None
|
|
1656
|
+
|
|
1657
|
+
def _write(sequence: str, _output=output) -> None:
|
|
1658
|
+
_output.write(sequence)
|
|
1659
|
+
_output.flush()
|
|
1660
|
+
|
|
1661
|
+
return _write
|
|
1662
|
+
|
|
944
1663
|
def _check_env_lock_hotkey(self) -> bool:
|
|
945
1664
|
"""Prevent mutations when env credentials are present."""
|
|
946
1665
|
if not self._is_env_locked():
|
|
@@ -1060,9 +1779,16 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
1060
1779
|
|
|
1061
1780
|
def _update_filter_button_visibility(self) -> None:
|
|
1062
1781
|
"""Show clear button only when filter has content."""
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1782
|
+
# Skip if Harlequin screen is active (it doesn't have this button)
|
|
1783
|
+
if isinstance(self.screen, AccountsHarlequinScreen):
|
|
1784
|
+
return
|
|
1785
|
+
try:
|
|
1786
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
1787
|
+
clear_btn = self.query_one("#filter-clear", Button)
|
|
1788
|
+
clear_btn.display = bool(filter_input.value or self._filter_text)
|
|
1789
|
+
except Exception:
|
|
1790
|
+
# Filter input or clear button doesn't exist, skip
|
|
1791
|
+
pass
|
|
1066
1792
|
|
|
1067
1793
|
def _apply_theme(self) -> None:
|
|
1068
1794
|
"""Register built-in themes and set the active one from context."""
|