glaip-sdk 0.6.0__py3-none-any.whl → 0.6.2__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 +112 -57
- glaip_sdk/cli/commands/common_config.py +36 -0
- glaip_sdk/cli/config.py +13 -2
- glaip_sdk/cli/main.py +20 -0
- glaip_sdk/cli/slash/accounts_controller.py +217 -0
- glaip_sdk/cli/slash/accounts_shared.py +19 -0
- glaip_sdk/cli/slash/session.py +57 -7
- glaip_sdk/cli/slash/tui/accounts.tcss +54 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +379 -0
- glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
- glaip_sdk/cli/slash/tui/loading.py +58 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +9 -12
- glaip_sdk/client/agents.py +36 -2
- glaip_sdk/client/main.py +1 -1
- glaip_sdk/registry/__init__.py +1 -1
- glaip_sdk/registry/base.py +1 -1
- glaip_sdk/utils/__init__.py +1 -1
- glaip_sdk/utils/import_resolver.py +0 -8
- glaip_sdk/utils/runtime_config.py +306 -0
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.2.dist-info}/METADATA +1 -1
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.2.dist-info}/RECORD +23 -16
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.2.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.6.0.dist-info → glaip_sdk-0.6.2.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/slash/session.py
CHANGED
|
@@ -34,10 +34,12 @@ from glaip_sdk.branding import (
|
|
|
34
34
|
AIPBranding,
|
|
35
35
|
)
|
|
36
36
|
from glaip_sdk.cli.auth import resolve_api_url_from_context
|
|
37
|
+
from glaip_sdk.cli.account_store import get_account_store
|
|
37
38
|
from glaip_sdk.cli.commands import transcripts as transcripts_cmd
|
|
38
|
-
from glaip_sdk.cli.commands.configure import
|
|
39
|
+
from glaip_sdk.cli.commands.configure import _configure_interactive, load_config
|
|
39
40
|
from glaip_sdk.cli.commands.update import update_command
|
|
40
41
|
from glaip_sdk.cli.hints import format_command_hint
|
|
42
|
+
from glaip_sdk.cli.slash.accounts_controller import AccountsController
|
|
41
43
|
from glaip_sdk.cli.slash.agent_session import AgentRunSession
|
|
42
44
|
from glaip_sdk.cli.slash.prompt import (
|
|
43
45
|
FormattedText,
|
|
@@ -98,6 +100,12 @@ NEW_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
|
|
|
98
100
|
|
|
99
101
|
|
|
100
102
|
DEFAULT_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
|
|
103
|
+
{
|
|
104
|
+
"cli": None,
|
|
105
|
+
"slash": "accounts",
|
|
106
|
+
"description": "Switch account profile",
|
|
107
|
+
"priority": 5,
|
|
108
|
+
},
|
|
101
109
|
{
|
|
102
110
|
"cli": "status",
|
|
103
111
|
"slash": "status",
|
|
@@ -452,7 +460,8 @@ class SlashSession:
|
|
|
452
460
|
"""
|
|
453
461
|
self.console.print(f"[{ACCENT_STYLE}]Launching configuration wizard...[/]")
|
|
454
462
|
try:
|
|
455
|
-
|
|
463
|
+
# Use the modern account-aware wizard directly (bypasses legacy config gating)
|
|
464
|
+
_configure_interactive(account_name=None)
|
|
456
465
|
self._config_cache = None
|
|
457
466
|
if self._suppress_login_layout:
|
|
458
467
|
self._welcome_rendered = False
|
|
@@ -655,6 +664,11 @@ class SlashSession:
|
|
|
655
664
|
controller = RemoteRunsController(self)
|
|
656
665
|
return controller.handle_runs_command(args)
|
|
657
666
|
|
|
667
|
+
def _cmd_accounts(self, args: list[str], _invoked_from_agent: bool) -> bool:
|
|
668
|
+
"""Handle the /accounts command for listing and switching accounts."""
|
|
669
|
+
controller = AccountsController(self)
|
|
670
|
+
return controller.handle_accounts_command(args)
|
|
671
|
+
|
|
658
672
|
def _cmd_agents(self, args: list[str], _invoked_from_agent: bool) -> bool:
|
|
659
673
|
"""Handle the /agents command.
|
|
660
674
|
|
|
@@ -742,6 +756,7 @@ class SlashSession:
|
|
|
742
756
|
hints.append((f"/agents {agent_id}", f"Reopen {agent_label}"))
|
|
743
757
|
hints.extend(
|
|
744
758
|
[
|
|
759
|
+
("/accounts", "Switch account"),
|
|
745
760
|
(self.AGENTS_COMMAND, "Browse agents"),
|
|
746
761
|
(self.STATUS_COMMAND, "Check connection"),
|
|
747
762
|
]
|
|
@@ -796,6 +811,13 @@ class SlashSession:
|
|
|
796
811
|
handler=SlashSession._cmd_status,
|
|
797
812
|
)
|
|
798
813
|
)
|
|
814
|
+
self._register(
|
|
815
|
+
SlashCommand(
|
|
816
|
+
name="accounts",
|
|
817
|
+
help="✨ NEW · Browse and switch stored accounts (Textual with Rich fallback).",
|
|
818
|
+
handler=SlashSession._cmd_accounts,
|
|
819
|
+
)
|
|
820
|
+
)
|
|
799
821
|
self._register(
|
|
800
822
|
SlashCommand(
|
|
801
823
|
name="transcripts",
|
|
@@ -1262,13 +1284,26 @@ class SlashSession:
|
|
|
1262
1284
|
"""Render the main AIP environment header."""
|
|
1263
1285
|
config = self._load_config()
|
|
1264
1286
|
|
|
1287
|
+
account_name, account_host, env_lock = self._get_account_context()
|
|
1265
1288
|
api_url = self._get_api_url(config)
|
|
1266
|
-
status = "Configured" if config.get("api_key") else "Not configured"
|
|
1267
1289
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1290
|
+
host_display = account_host or "Not configured"
|
|
1291
|
+
account_segment = f"[dim]Account[/dim] • {account_name} ({host_display})"
|
|
1292
|
+
if env_lock:
|
|
1293
|
+
account_segment += " 🔒"
|
|
1294
|
+
|
|
1295
|
+
segments = [account_segment]
|
|
1296
|
+
|
|
1297
|
+
if api_url:
|
|
1298
|
+
base_label = "[dim]Base URL[/dim]"
|
|
1299
|
+
if env_lock:
|
|
1300
|
+
base_label = "[dim]Base URL (env)[/dim]"
|
|
1301
|
+
# Always show Base URL when env-lock is active to reveal overrides
|
|
1302
|
+
if env_lock or api_url != account_host:
|
|
1303
|
+
segments.append(f"{base_label} • {api_url}")
|
|
1304
|
+
elif not api_url:
|
|
1305
|
+
segments.append("[dim]Base URL[/dim] • Not configured")
|
|
1306
|
+
|
|
1272
1307
|
agent_info = self._build_agent_status_line(active_agent)
|
|
1273
1308
|
if agent_info:
|
|
1274
1309
|
segments.append(agent_info)
|
|
@@ -1295,6 +1330,21 @@ class SlashSession:
|
|
|
1295
1330
|
"""Get the API URL from context or account store (CLI/palette ignores env credentials)."""
|
|
1296
1331
|
return resolve_api_url_from_context(self.ctx)
|
|
1297
1332
|
|
|
1333
|
+
def _get_account_context(self) -> tuple[str, str, bool]:
|
|
1334
|
+
"""Return active account name, host, and env-lock flag."""
|
|
1335
|
+
try:
|
|
1336
|
+
store = get_account_store()
|
|
1337
|
+
active = store.get_active_account() or "default"
|
|
1338
|
+
account = store.get_account(active) if hasattr(store, "get_account") else None
|
|
1339
|
+
host = ""
|
|
1340
|
+
if account:
|
|
1341
|
+
host = account.get("api_url", "")
|
|
1342
|
+
env_lock = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
|
|
1343
|
+
return active, host, env_lock
|
|
1344
|
+
except Exception:
|
|
1345
|
+
env_lock = bool(os.getenv("AIP_API_URL") or os.getenv("AIP_API_KEY"))
|
|
1346
|
+
return "default", "", env_lock
|
|
1347
|
+
|
|
1298
1348
|
def _build_agent_status_line(self, active_agent: Any | None) -> str | None:
|
|
1299
1349
|
"""Return a short status line about the active or recent agent."""
|
|
1300
1350
|
if active_agent is not None:
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* Styling for /accounts Textual UI
|
|
2
|
+
*
|
|
3
|
+
* Keep layout compact: filter sits tight above the table; header shows active account.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
#header-info {
|
|
7
|
+
padding: 0 1 0 1;
|
|
8
|
+
margin: 0;
|
|
9
|
+
height: 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
#env-lock {
|
|
13
|
+
padding: 0 1 0 1;
|
|
14
|
+
color: yellow;
|
|
15
|
+
height: 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#filter-container {
|
|
19
|
+
padding: 0 1 0 1;
|
|
20
|
+
margin: 0 0 0 0;
|
|
21
|
+
height: auto;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
#filter-label {
|
|
25
|
+
height: 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#filter-input {
|
|
29
|
+
padding: 0 1 0 1;
|
|
30
|
+
margin: 0;
|
|
31
|
+
height: 3;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#accounts-table {
|
|
35
|
+
padding: 0 1 0 1;
|
|
36
|
+
margin: 0 0 0 0;
|
|
37
|
+
height: 1fr;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#status-bar {
|
|
41
|
+
height: 3;
|
|
42
|
+
padding: 0 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#accounts-loading {
|
|
46
|
+
width: 8;
|
|
47
|
+
display: none;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#status {
|
|
51
|
+
padding: 0 1 0 1;
|
|
52
|
+
margin: 0;
|
|
53
|
+
color: cyan;
|
|
54
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Textual UI for the /accounts command.
|
|
2
|
+
|
|
3
|
+
Provides a minimal interactive list with the same columns/order as the Rich
|
|
4
|
+
fallback (name, API URL, masked key, status) and keyboard navigation.
|
|
5
|
+
|
|
6
|
+
Authors:
|
|
7
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from glaip_sdk.cli.slash.accounts_shared import build_account_status_string
|
|
18
|
+
from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
|
|
19
|
+
from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
|
|
20
|
+
|
|
21
|
+
try: # pragma: no cover - optional dependency
|
|
22
|
+
from textual import events
|
|
23
|
+
from textual.app import App, ComposeResult
|
|
24
|
+
from textual.binding import Binding
|
|
25
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
26
|
+
from textual.widgets import DataTable, Footer, Header, Input, LoadingIndicator, Static
|
|
27
|
+
except Exception: # pragma: no cover - optional dependency
|
|
28
|
+
events = None # type: ignore[assignment]
|
|
29
|
+
App = None # type: ignore[assignment]
|
|
30
|
+
ComposeResult = None # type: ignore[assignment]
|
|
31
|
+
Binding = None # type: ignore[assignment]
|
|
32
|
+
Container = None # type: ignore[assignment]
|
|
33
|
+
Horizontal = None # type: ignore[assignment]
|
|
34
|
+
Vertical = None # type: ignore[assignment]
|
|
35
|
+
DataTable = None # type: ignore[assignment]
|
|
36
|
+
Footer = None # type: ignore[assignment]
|
|
37
|
+
Header = None # type: ignore[assignment]
|
|
38
|
+
Input = None # type: ignore[assignment]
|
|
39
|
+
LoadingIndicator = None # type: ignore[assignment]
|
|
40
|
+
Static = None # type: ignore[assignment]
|
|
41
|
+
|
|
42
|
+
TEXTUAL_SUPPORTED = App is not None and DataTable is not None
|
|
43
|
+
|
|
44
|
+
# Widget IDs for Textual UI
|
|
45
|
+
ACCOUNTS_TABLE_ID = "#accounts-table"
|
|
46
|
+
FILTER_INPUT_ID = "#filter-input"
|
|
47
|
+
STATUS_ID = "#status"
|
|
48
|
+
ACCOUNTS_LOADING_ID = "#accounts-loading"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AccountsTUICallbacks:
|
|
53
|
+
"""Callbacks invoked by the Textual UI."""
|
|
54
|
+
|
|
55
|
+
switch_account: Callable[[str], tuple[bool, str]]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run_accounts_textual(
|
|
59
|
+
rows: list[dict[str, str | bool]],
|
|
60
|
+
*,
|
|
61
|
+
active_account: str | None,
|
|
62
|
+
env_lock: bool,
|
|
63
|
+
callbacks: AccountsTUICallbacks,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Launch the Textual accounts browser if dependencies are available."""
|
|
66
|
+
if not TEXTUAL_SUPPORTED:
|
|
67
|
+
return
|
|
68
|
+
app = AccountsTextualApp(rows, active_account, env_lock, callbacks)
|
|
69
|
+
app.run()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AccountsTextualApp(BackgroundTaskMixin, App[None]): # pragma: no cover - interactive
|
|
73
|
+
"""Textual application for browsing accounts."""
|
|
74
|
+
|
|
75
|
+
CSS_PATH = "accounts.tcss"
|
|
76
|
+
BINDINGS = [
|
|
77
|
+
Binding("enter", "switch_row", "Switch", show=True),
|
|
78
|
+
Binding("return", "switch_row", "Switch", show=False),
|
|
79
|
+
Binding("/", "focus_filter", "Filter", show=True),
|
|
80
|
+
# Esc clears filter when focused/non-empty; otherwise exits
|
|
81
|
+
Binding("escape", "clear_or_exit", "Close", priority=True),
|
|
82
|
+
Binding("q", "app_exit", "Close", priority=True),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
rows: list[dict[str, str | bool]],
|
|
88
|
+
active_account: str | None,
|
|
89
|
+
env_lock: bool,
|
|
90
|
+
callbacks: AccountsTUICallbacks,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Initialize the Textual accounts app.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
rows: Account data rows to display.
|
|
96
|
+
active_account: Name of the currently active account.
|
|
97
|
+
env_lock: Whether environment credentials are locking account switching.
|
|
98
|
+
callbacks: Callbacks for account switching operations.
|
|
99
|
+
"""
|
|
100
|
+
super().__init__()
|
|
101
|
+
self._all_rows = rows
|
|
102
|
+
self._active_account = active_account
|
|
103
|
+
self._env_lock = env_lock
|
|
104
|
+
self._callbacks = callbacks
|
|
105
|
+
self._filter_text: str = ""
|
|
106
|
+
self._is_switching = False
|
|
107
|
+
|
|
108
|
+
def compose(self) -> ComposeResult:
|
|
109
|
+
"""Build the Textual layout."""
|
|
110
|
+
header_text = self._header_text()
|
|
111
|
+
yield Static(header_text, id="header-info")
|
|
112
|
+
if self._env_lock:
|
|
113
|
+
yield Static(
|
|
114
|
+
"Env credentials detected (AIP_API_URL/AIP_API_KEY); switching is disabled.",
|
|
115
|
+
id="env-lock",
|
|
116
|
+
)
|
|
117
|
+
filter_bar = Container(
|
|
118
|
+
Static("Filter (/):", id="filter-label"),
|
|
119
|
+
Input(placeholder="Type to filter by name or host", id="filter-input"),
|
|
120
|
+
id="filter-container",
|
|
121
|
+
)
|
|
122
|
+
filter_bar.styles.padding = (0, 0)
|
|
123
|
+
main = Vertical(
|
|
124
|
+
filter_bar,
|
|
125
|
+
DataTable(id=ACCOUNTS_TABLE_ID.lstrip("#")),
|
|
126
|
+
)
|
|
127
|
+
# Avoid large gaps; keep main content filling available space
|
|
128
|
+
main.styles.height = "1fr"
|
|
129
|
+
main.styles.padding = (0, 0)
|
|
130
|
+
yield main
|
|
131
|
+
yield Horizontal(
|
|
132
|
+
LoadingIndicator(id=ACCOUNTS_LOADING_ID.lstrip("#")),
|
|
133
|
+
Static("", id=STATUS_ID.lstrip("#")),
|
|
134
|
+
id="status-bar",
|
|
135
|
+
)
|
|
136
|
+
yield Footer()
|
|
137
|
+
|
|
138
|
+
def on_mount(self) -> None:
|
|
139
|
+
"""Configure table columns and load rows."""
|
|
140
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
141
|
+
table.add_column("Name", width=20)
|
|
142
|
+
table.add_column("API URL", width=40)
|
|
143
|
+
table.add_column("Key (masked)", width=20)
|
|
144
|
+
table.add_column("Status", width=14)
|
|
145
|
+
table.cursor_type = "row"
|
|
146
|
+
table.zebra_stripes = True
|
|
147
|
+
table.styles.height = "1fr" # Fill available space below the filter
|
|
148
|
+
table.styles.margin = 0
|
|
149
|
+
self._reload_rows()
|
|
150
|
+
table.focus()
|
|
151
|
+
# Keep the filter tight to the table
|
|
152
|
+
main = self.query_one(Vertical)
|
|
153
|
+
main.styles.gap = 0
|
|
154
|
+
|
|
155
|
+
def _header_text(self) -> str:
|
|
156
|
+
"""Build header text with active account and host."""
|
|
157
|
+
host = self._get_active_host() or "Not configured"
|
|
158
|
+
lock_icon = " [yellow]🔒[/]" if self._env_lock else ""
|
|
159
|
+
active = self._active_account or "None"
|
|
160
|
+
return f"[green]Active:[/] [bold]{active}[/] ([cyan]{host}[/]){lock_icon}"
|
|
161
|
+
|
|
162
|
+
def _get_active_host(self) -> str | None:
|
|
163
|
+
"""Return the API host for the active account (shortened)."""
|
|
164
|
+
return self._get_host_for_name(self._active_account)
|
|
165
|
+
|
|
166
|
+
def _get_host_for_name(self, name: str | None) -> str | None:
|
|
167
|
+
"""Return shortened API URL for a given account name."""
|
|
168
|
+
if not name:
|
|
169
|
+
return None
|
|
170
|
+
for row in self._all_rows:
|
|
171
|
+
if row.get("name") == name:
|
|
172
|
+
url = str(row.get("api_url", ""))
|
|
173
|
+
return url if len(url) <= 40 else f"{url[:37]}..."
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def action_focus_filter(self) -> None:
|
|
177
|
+
"""Focus the filter input and clear previous text."""
|
|
178
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
179
|
+
filter_input.value = self._filter_text
|
|
180
|
+
filter_input.focus()
|
|
181
|
+
|
|
182
|
+
def action_switch_row(self) -> None:
|
|
183
|
+
"""Switch to the currently selected account."""
|
|
184
|
+
if self._env_lock:
|
|
185
|
+
self._set_status("Switching disabled: env credentials in use.", "yellow")
|
|
186
|
+
return
|
|
187
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
188
|
+
if table.cursor_row is None:
|
|
189
|
+
self._set_status("No account selected.", "yellow")
|
|
190
|
+
return
|
|
191
|
+
try:
|
|
192
|
+
row_key = table.get_row_at(table.cursor_row)[0]
|
|
193
|
+
except Exception:
|
|
194
|
+
self._set_status("Unable to read selected row.", "red")
|
|
195
|
+
return
|
|
196
|
+
name = str(row_key)
|
|
197
|
+
if self._is_switching:
|
|
198
|
+
self._set_status("Already switching...", "yellow")
|
|
199
|
+
return
|
|
200
|
+
self._is_switching = True
|
|
201
|
+
host = self._get_host_for_name(name)
|
|
202
|
+
if host:
|
|
203
|
+
self._show_loading(f"Connecting to '{name}' ({host})...")
|
|
204
|
+
else:
|
|
205
|
+
self._show_loading(f"Connecting to '{name}'...")
|
|
206
|
+
self._queue_switch(name)
|
|
207
|
+
|
|
208
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
|
|
209
|
+
"""Handle mouse click selection by triggering switch."""
|
|
210
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
211
|
+
try:
|
|
212
|
+
# Move cursor to clicked row then switch
|
|
213
|
+
table.cursor_coordinate = (event.cursor_row, 0)
|
|
214
|
+
except Exception:
|
|
215
|
+
return
|
|
216
|
+
self.action_switch_row()
|
|
217
|
+
|
|
218
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
219
|
+
"""Apply filter when user presses Enter inside filter input."""
|
|
220
|
+
self._filter_text = (event.value or "").strip()
|
|
221
|
+
self._reload_rows()
|
|
222
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
223
|
+
table.focus()
|
|
224
|
+
|
|
225
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
226
|
+
"""Apply filter live as the user types."""
|
|
227
|
+
self._filter_text = (event.value or "").strip()
|
|
228
|
+
self._reload_rows()
|
|
229
|
+
|
|
230
|
+
def on_key(self, event: events.Key) -> None: # type: ignore[override]
|
|
231
|
+
"""Let users start typing to filter without pressing '/' first."""
|
|
232
|
+
if not getattr(event, "is_printable", False):
|
|
233
|
+
return
|
|
234
|
+
if not event.character:
|
|
235
|
+
return
|
|
236
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
237
|
+
if filter_input.has_focus:
|
|
238
|
+
return
|
|
239
|
+
filter_input.focus()
|
|
240
|
+
filter_input.value = (filter_input.value or "") + event.character
|
|
241
|
+
filter_input.cursor_position = len(filter_input.value)
|
|
242
|
+
self._filter_text = filter_input.value.strip()
|
|
243
|
+
self._reload_rows()
|
|
244
|
+
event.stop()
|
|
245
|
+
|
|
246
|
+
def _reload_rows(self) -> None:
|
|
247
|
+
"""Refresh table rows based on current filter/active state."""
|
|
248
|
+
# Work on a copy to avoid mutating the backing rows list
|
|
249
|
+
rows_copy = [dict(row) for row in self._all_rows]
|
|
250
|
+
for row in rows_copy:
|
|
251
|
+
row["active"] = row.get("name") == self._active_account
|
|
252
|
+
|
|
253
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
254
|
+
table.clear()
|
|
255
|
+
filtered = self._filtered_rows(rows_copy)
|
|
256
|
+
for row in filtered:
|
|
257
|
+
row_for_status = dict(row)
|
|
258
|
+
row_for_status["active"] = row_for_status.get("name") == self._active_account
|
|
259
|
+
# Use markup to align status colors with Rich fallback (green active badge).
|
|
260
|
+
status = build_account_status_string(row_for_status, use_markup=True)
|
|
261
|
+
# pylint: disable=duplicate-code
|
|
262
|
+
# Reuses shared status builder; columns mirror accounts_controller Rich table.
|
|
263
|
+
table.add_row(
|
|
264
|
+
str(row.get("name", "")),
|
|
265
|
+
str(row.get("api_url", "")),
|
|
266
|
+
str(row.get("masked_key", "")),
|
|
267
|
+
status,
|
|
268
|
+
)
|
|
269
|
+
# Move cursor to active or first row
|
|
270
|
+
cursor_idx = 0
|
|
271
|
+
for idx, row in enumerate(filtered):
|
|
272
|
+
if row.get("name") == self._active_account:
|
|
273
|
+
cursor_idx = idx
|
|
274
|
+
break
|
|
275
|
+
if filtered:
|
|
276
|
+
table.cursor_coordinate = (cursor_idx, 0)
|
|
277
|
+
else:
|
|
278
|
+
self._set_status("No accounts match the current filter.", "yellow")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
# Update status to reflect filter state
|
|
282
|
+
if self._filter_text:
|
|
283
|
+
self._set_status(f"Filtered: {self._filter_text}", "cyan")
|
|
284
|
+
else:
|
|
285
|
+
self._set_status("", "white")
|
|
286
|
+
|
|
287
|
+
def _filtered_rows(self, rows: list[dict[str, str | bool]] | None = None) -> list[dict[str, str | bool]]:
|
|
288
|
+
"""Return rows filtered by name or API URL substring."""
|
|
289
|
+
base_rows = rows if rows is not None else [dict(row) for row in self._all_rows]
|
|
290
|
+
if not self._filter_text:
|
|
291
|
+
return list(base_rows)
|
|
292
|
+
needle = self._filter_text.lower()
|
|
293
|
+
filtered = [
|
|
294
|
+
row
|
|
295
|
+
for row in base_rows
|
|
296
|
+
if needle in str(row.get("name", "")).lower() or needle in str(row.get("api_url", "")).lower()
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
# Sort so name matches surface first, then URL matches, then alphabetically
|
|
300
|
+
def score(row: dict[str, str | bool]) -> tuple[int, str]:
|
|
301
|
+
name = str(row.get("name", "")).lower()
|
|
302
|
+
url = str(row.get("api_url", "")).lower()
|
|
303
|
+
name_hit = needle in name
|
|
304
|
+
url_hit = needle in url
|
|
305
|
+
# Extract nested conditional into clear statement
|
|
306
|
+
if name_hit:
|
|
307
|
+
priority = 0
|
|
308
|
+
elif url_hit:
|
|
309
|
+
priority = 1
|
|
310
|
+
else:
|
|
311
|
+
priority = 2
|
|
312
|
+
return (priority, name)
|
|
313
|
+
|
|
314
|
+
return sorted(filtered, key=score)
|
|
315
|
+
|
|
316
|
+
def _set_status(self, message: str, style: str) -> None:
|
|
317
|
+
"""Update status line with message."""
|
|
318
|
+
status = self.query_one(STATUS_ID, Static)
|
|
319
|
+
status.update(f"[{style}]{message}[/]")
|
|
320
|
+
|
|
321
|
+
def _show_loading(self, message: str | None = None) -> None:
|
|
322
|
+
"""Show the loading indicator and optional status message."""
|
|
323
|
+
show_loading_indicator(self, ACCOUNTS_LOADING_ID, message=message, set_status=self._set_status)
|
|
324
|
+
|
|
325
|
+
def _hide_loading(self) -> None:
|
|
326
|
+
"""Hide the loading indicator."""
|
|
327
|
+
hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
|
|
328
|
+
|
|
329
|
+
def _queue_switch(self, name: str) -> None:
|
|
330
|
+
"""Run switch in background to keep UI responsive."""
|
|
331
|
+
|
|
332
|
+
async def perform() -> None:
|
|
333
|
+
try:
|
|
334
|
+
switched, message = await asyncio.to_thread(self._callbacks.switch_account, name)
|
|
335
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
336
|
+
self._set_status(f"Switch failed: {exc}", "red")
|
|
337
|
+
return
|
|
338
|
+
finally:
|
|
339
|
+
self._hide_loading()
|
|
340
|
+
self._is_switching = False
|
|
341
|
+
|
|
342
|
+
if switched:
|
|
343
|
+
self._active_account = name
|
|
344
|
+
self._set_status(message or f"Switched to '{name}'.", "green")
|
|
345
|
+
self._update_header()
|
|
346
|
+
self._reload_rows()
|
|
347
|
+
else:
|
|
348
|
+
self._set_status(message or "Switch failed; kept previous account.", "yellow")
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
self.track_task(perform(), logger=logging.getLogger(__name__))
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
# If scheduling the task fails, clear loading/switching state and surface the error.
|
|
354
|
+
self._hide_loading()
|
|
355
|
+
self._is_switching = False
|
|
356
|
+
self._set_status(f"Switch failed to start: {exc}", "red")
|
|
357
|
+
logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
|
|
358
|
+
|
|
359
|
+
def _update_header(self) -> None:
|
|
360
|
+
"""Refresh header text to reflect active/lock state."""
|
|
361
|
+
header = self.query_one("#header-info", Static)
|
|
362
|
+
header.update(self._header_text())
|
|
363
|
+
|
|
364
|
+
def action_clear_or_exit(self) -> None:
|
|
365
|
+
"""Clear filter when focused/non-empty; otherwise exit.
|
|
366
|
+
|
|
367
|
+
UX note: helps users reset the list without leaving the TUI.
|
|
368
|
+
"""
|
|
369
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
370
|
+
# Extract nested conditional into clear statement
|
|
371
|
+
should_clear = filter_input.has_focus and (filter_input.value or self._filter_text)
|
|
372
|
+
if should_clear:
|
|
373
|
+
filter_input.value = ""
|
|
374
|
+
self._filter_text = ""
|
|
375
|
+
self._reload_rows()
|
|
376
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
377
|
+
table.focus()
|
|
378
|
+
return
|
|
379
|
+
self.exit()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Shared mixin for tracking background asyncio tasks in Textual apps.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Callable, Coroutine
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BackgroundTaskMixin:
|
|
16
|
+
"""Mixin that tracks background tasks and cleans them up on unmount."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
19
|
+
"""Initialize task tracking set for derived Textual apps."""
|
|
20
|
+
super().__init__(*args, **kwargs)
|
|
21
|
+
self._pending_tasks: set[asyncio.Task[Any]] = set()
|
|
22
|
+
|
|
23
|
+
def track_task(
|
|
24
|
+
self,
|
|
25
|
+
coro: Coroutine[Any, Any, Any],
|
|
26
|
+
*,
|
|
27
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
28
|
+
logger: logging.Logger | None = None,
|
|
29
|
+
) -> asyncio.Task[Any]:
|
|
30
|
+
"""Create and track a background task with optional error handling."""
|
|
31
|
+
task = asyncio.create_task(coro)
|
|
32
|
+
self._pending_tasks.add(task)
|
|
33
|
+
|
|
34
|
+
def _cleanup(finished: asyncio.Task[Any]) -> None:
|
|
35
|
+
self._pending_tasks.discard(finished)
|
|
36
|
+
if finished.cancelled():
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
exc = finished.exception()
|
|
40
|
+
except Exception:
|
|
41
|
+
return
|
|
42
|
+
if exc:
|
|
43
|
+
if on_error:
|
|
44
|
+
on_error(exc)
|
|
45
|
+
elif logger:
|
|
46
|
+
logger.debug("Background task failed", exc_info=exc)
|
|
47
|
+
|
|
48
|
+
task.add_done_callback(_cleanup)
|
|
49
|
+
return task
|
|
50
|
+
|
|
51
|
+
def on_unmount(self) -> None: # pragma: no cover - UI lifecycle hook
|
|
52
|
+
"""Ensure background tasks are cleaned up on exit."""
|
|
53
|
+
pending = [task for task in self._pending_tasks if not task.done()]
|
|
54
|
+
for task in pending:
|
|
55
|
+
try:
|
|
56
|
+
task.cancel()
|
|
57
|
+
except Exception:
|
|
58
|
+
continue
|
|
59
|
+
if pending:
|
|
60
|
+
try:
|
|
61
|
+
loop = asyncio.get_running_loop()
|
|
62
|
+
except RuntimeError:
|
|
63
|
+
loop = None
|
|
64
|
+
if loop and loop.is_running():
|
|
65
|
+
try:
|
|
66
|
+
loop.create_task(asyncio.gather(*pending, return_exceptions=True))
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
self._pending_tasks.clear()
|
|
70
|
+
parent_on_unmount = getattr(super(), "on_unmount", None)
|
|
71
|
+
if callable(parent_on_unmount):
|
|
72
|
+
parent_on_unmount() # type: ignore[misc]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Shared helpers for toggling Textual loading indicators.
|
|
2
|
+
|
|
3
|
+
Note: uses Textual's built-in LoadingIndicator as the MVP; upgrade to the
|
|
4
|
+
PulseIndicator from cli-textual-animated-indicators.md when shipped.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
try: # pragma: no cover - optional dependency
|
|
13
|
+
from textual.widgets import LoadingIndicator
|
|
14
|
+
except Exception: # pragma: no cover - optional dependency
|
|
15
|
+
LoadingIndicator = None # type: ignore[assignment]
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # pragma: no cover - type checking aid
|
|
18
|
+
from textual.widgets import LoadingIndicator as _LoadingIndicatorType
|
|
19
|
+
|
|
20
|
+
LoadingIndicator: type[_LoadingIndicatorType] | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _set_indicator_display(app: object, selector: str, visible: bool) -> None:
|
|
24
|
+
"""Safely toggle a LoadingIndicator's display property."""
|
|
25
|
+
if LoadingIndicator is None:
|
|
26
|
+
return
|
|
27
|
+
try:
|
|
28
|
+
indicator = app.query_one(selector, LoadingIndicator) # type: ignore[arg-type]
|
|
29
|
+
indicator.display = visible
|
|
30
|
+
except Exception:
|
|
31
|
+
# Ignore lookup/rendering errors to keep UI resilient
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def show_loading_indicator(
|
|
36
|
+
app: object,
|
|
37
|
+
selector: str,
|
|
38
|
+
*,
|
|
39
|
+
message: str | None = None,
|
|
40
|
+
set_status: Callable[..., None] | None = None,
|
|
41
|
+
status_style: str = "cyan",
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Show a loading indicator and optionally set a status message."""
|
|
44
|
+
_set_indicator_display(app, selector, True)
|
|
45
|
+
if message and set_status:
|
|
46
|
+
try:
|
|
47
|
+
set_status(message, status_style)
|
|
48
|
+
except TypeError:
|
|
49
|
+
# Fallback for setters that accept only a single arg or kwargs
|
|
50
|
+
try:
|
|
51
|
+
set_status(message)
|
|
52
|
+
except Exception:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def hide_loading_indicator(app: object, selector: str) -> None:
|
|
57
|
+
"""Hide a loading indicator."""
|
|
58
|
+
_set_indicator_display(app, selector, False)
|