glaip-sdk 0.6.5b3__py3-none-any.whl → 0.7.17__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/__init__.py +42 -5
- glaip_sdk/agents/base.py +362 -39
- glaip_sdk/branding.py +113 -2
- glaip_sdk/cli/account_store.py +15 -0
- glaip_sdk/cli/auth.py +14 -8
- glaip_sdk/cli/commands/accounts.py +1 -1
- glaip_sdk/cli/commands/agents/__init__.py +116 -0
- glaip_sdk/cli/commands/agents/_common.py +562 -0
- glaip_sdk/cli/commands/agents/create.py +155 -0
- glaip_sdk/cli/commands/agents/delete.py +64 -0
- glaip_sdk/cli/commands/agents/get.py +89 -0
- glaip_sdk/cli/commands/agents/list.py +129 -0
- glaip_sdk/cli/commands/agents/run.py +264 -0
- glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
- glaip_sdk/cli/commands/agents/update.py +112 -0
- glaip_sdk/cli/commands/common_config.py +15 -12
- glaip_sdk/cli/commands/configure.py +2 -3
- glaip_sdk/cli/commands/mcps/__init__.py +94 -0
- glaip_sdk/cli/commands/mcps/_common.py +459 -0
- glaip_sdk/cli/commands/mcps/connect.py +82 -0
- glaip_sdk/cli/commands/mcps/create.py +152 -0
- glaip_sdk/cli/commands/mcps/delete.py +73 -0
- glaip_sdk/cli/commands/mcps/get.py +212 -0
- glaip_sdk/cli/commands/mcps/list.py +69 -0
- glaip_sdk/cli/commands/mcps/tools.py +235 -0
- glaip_sdk/cli/commands/mcps/update.py +190 -0
- glaip_sdk/cli/commands/models.py +2 -4
- glaip_sdk/cli/commands/shared/__init__.py +21 -0
- glaip_sdk/cli/commands/shared/formatters.py +91 -0
- glaip_sdk/cli/commands/tools/__init__.py +69 -0
- glaip_sdk/cli/commands/tools/_common.py +80 -0
- glaip_sdk/cli/commands/tools/create.py +228 -0
- glaip_sdk/cli/commands/tools/delete.py +61 -0
- glaip_sdk/cli/commands/tools/get.py +103 -0
- glaip_sdk/cli/commands/tools/list.py +69 -0
- glaip_sdk/cli/commands/tools/script.py +49 -0
- glaip_sdk/cli/commands/tools/update.py +102 -0
- glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
- glaip_sdk/cli/commands/transcripts/_common.py +9 -0
- glaip_sdk/cli/commands/transcripts/clear.py +5 -0
- glaip_sdk/cli/commands/transcripts/detail.py +5 -0
- glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
- glaip_sdk/cli/commands/update.py +163 -17
- glaip_sdk/cli/config.py +1 -0
- glaip_sdk/cli/core/output.py +12 -7
- glaip_sdk/cli/entrypoint.py +20 -0
- glaip_sdk/cli/main.py +127 -39
- glaip_sdk/cli/pager.py +3 -3
- glaip_sdk/cli/resolution.py +2 -1
- glaip_sdk/cli/slash/accounts_controller.py +112 -32
- glaip_sdk/cli/slash/agent_session.py +5 -2
- glaip_sdk/cli/slash/prompt.py +11 -0
- glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
- glaip_sdk/cli/slash/session.py +375 -25
- glaip_sdk/cli/slash/tui/__init__.py +28 -1
- glaip_sdk/cli/slash/tui/accounts.tcss +97 -6
- glaip_sdk/cli/slash/tui/accounts_app.py +1107 -126
- glaip_sdk/cli/slash/tui/clipboard.py +195 -0
- glaip_sdk/cli/slash/tui/context.py +92 -0
- glaip_sdk/cli/slash/tui/indicators.py +341 -0
- glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
- glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
- glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
- glaip_sdk/cli/slash/tui/loading.py +43 -21
- glaip_sdk/cli/slash/tui/remote_runs_app.py +152 -20
- glaip_sdk/cli/slash/tui/terminal.py +407 -0
- glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
- glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
- glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
- glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
- glaip_sdk/cli/slash/tui/toast.py +388 -0
- glaip_sdk/cli/transcript/history.py +1 -1
- glaip_sdk/cli/transcript/viewer.py +5 -3
- glaip_sdk/cli/tui_settings.py +125 -0
- glaip_sdk/cli/update_notifier.py +215 -7
- glaip_sdk/cli/validators.py +1 -1
- glaip_sdk/client/__init__.py +2 -1
- glaip_sdk/client/_schedule_payloads.py +89 -0
- glaip_sdk/client/agents.py +290 -16
- glaip_sdk/client/base.py +25 -0
- glaip_sdk/client/hitl.py +136 -0
- glaip_sdk/client/main.py +7 -5
- glaip_sdk/client/mcps.py +44 -13
- glaip_sdk/client/payloads/agent/__init__.py +23 -0
- glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +28 -48
- glaip_sdk/client/payloads/agent/responses.py +43 -0
- glaip_sdk/client/run_rendering.py +414 -3
- glaip_sdk/client/schedules.py +439 -0
- glaip_sdk/client/tools.py +57 -26
- glaip_sdk/config/constants.py +22 -2
- glaip_sdk/guardrails/__init__.py +80 -0
- glaip_sdk/guardrails/serializer.py +89 -0
- glaip_sdk/hitl/__init__.py +48 -0
- glaip_sdk/hitl/base.py +64 -0
- glaip_sdk/hitl/callback.py +43 -0
- glaip_sdk/hitl/local.py +121 -0
- glaip_sdk/hitl/remote.py +523 -0
- glaip_sdk/models/__init__.py +47 -1
- glaip_sdk/models/_provider_mappings.py +101 -0
- glaip_sdk/models/_validation.py +97 -0
- glaip_sdk/models/agent.py +2 -1
- glaip_sdk/models/agent_runs.py +2 -1
- glaip_sdk/models/constants.py +141 -0
- glaip_sdk/models/model.py +170 -0
- glaip_sdk/models/schedule.py +224 -0
- glaip_sdk/payload_schemas/agent.py +1 -0
- glaip_sdk/payload_schemas/guardrails.py +34 -0
- glaip_sdk/registry/tool.py +273 -66
- glaip_sdk/runner/__init__.py +76 -0
- glaip_sdk/runner/base.py +84 -0
- glaip_sdk/runner/deps.py +115 -0
- glaip_sdk/runner/langgraph.py +1055 -0
- glaip_sdk/runner/logging_config.py +77 -0
- glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
- glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
- glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
- glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +116 -0
- glaip_sdk/runner/tool_adapter/__init__.py +18 -0
- glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
- glaip_sdk/schedules/__init__.py +22 -0
- glaip_sdk/schedules/base.py +291 -0
- glaip_sdk/tools/base.py +67 -14
- glaip_sdk/utils/__init__.py +1 -0
- glaip_sdk/utils/a2a/__init__.py +34 -0
- glaip_sdk/utils/a2a/event_processor.py +188 -0
- glaip_sdk/utils/agent_config.py +8 -2
- glaip_sdk/utils/bundler.py +138 -2
- glaip_sdk/utils/import_resolver.py +43 -11
- glaip_sdk/utils/rendering/renderer/base.py +58 -0
- glaip_sdk/utils/runtime_config.py +120 -0
- glaip_sdk/utils/sync.py +31 -11
- glaip_sdk/utils/tool_detection.py +301 -0
- glaip_sdk/utils/tool_storage_provider.py +140 -0
- {glaip_sdk-0.6.5b3.dist-info → glaip_sdk-0.7.17.dist-info}/METADATA +49 -38
- glaip_sdk-0.7.17.dist-info/RECORD +224 -0
- {glaip_sdk-0.6.5b3.dist-info → glaip_sdk-0.7.17.dist-info}/WHEEL +2 -1
- glaip_sdk-0.7.17.dist-info/entry_points.txt +2 -0
- glaip_sdk-0.7.17.dist-info/top_level.txt +1 -0
- glaip_sdk/cli/commands/agents.py +0 -1509
- glaip_sdk/cli/commands/mcps.py +0 -1356
- glaip_sdk/cli/commands/tools.py +0 -576
- glaip_sdk/cli/utils.py +0 -263
- glaip_sdk-0.6.5b3.dist-info/RECORD +0 -145
- glaip_sdk-0.6.5b3.dist-info/entry_points.txt +0 -3
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
Provides a minimal interactive list with the same columns/order as the Rich
|
|
4
4
|
fallback (name, API URL, masked key, status) and keyboard navigation.
|
|
5
5
|
|
|
6
|
+
Integrates with TUI foundation services:
|
|
7
|
+
- KeybindRegistry: Centralized keybind registration with scoped actions
|
|
8
|
+
- ClipboardAdapter: Cross-platform clipboard operations with OSC 52 support
|
|
9
|
+
- ToastBus: Non-blocking toast notifications for user feedback
|
|
10
|
+
|
|
6
11
|
Authors:
|
|
7
12
|
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
8
13
|
"""
|
|
@@ -13,7 +18,7 @@ import asyncio
|
|
|
13
18
|
import logging
|
|
14
19
|
from collections.abc import Callable
|
|
15
20
|
from dataclasses import dataclass
|
|
16
|
-
from typing import Any
|
|
21
|
+
from typing import Any, cast
|
|
17
22
|
|
|
18
23
|
from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
|
|
19
24
|
from glaip_sdk.cli.commands.common_config import check_connection_with_reason
|
|
@@ -23,46 +28,42 @@ from glaip_sdk.cli.slash.accounts_shared import (
|
|
|
23
28
|
env_credentials_present,
|
|
24
29
|
)
|
|
25
30
|
from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
|
|
31
|
+
from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter, ClipboardResult
|
|
32
|
+
from glaip_sdk.cli.slash.tui.context import TUIContext
|
|
33
|
+
from glaip_sdk.cli.slash.tui.indicators import PulseIndicator
|
|
34
|
+
from glaip_sdk.cli.slash.tui.keybind_registry import KeybindRegistry
|
|
35
|
+
from glaip_sdk.cli.slash.tui.layouts.harlequin import HarlequinScreen
|
|
26
36
|
from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
|
|
37
|
+
from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
|
|
38
|
+
from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
|
|
39
|
+
|
|
40
|
+
from glaip_sdk.cli.slash.tui.toast import (
|
|
41
|
+
ClipboardToastMixin,
|
|
42
|
+
Toast,
|
|
43
|
+
ToastBus,
|
|
44
|
+
ToastContainer,
|
|
45
|
+
ToastHandlerMixin,
|
|
46
|
+
ToastVariant,
|
|
47
|
+
)
|
|
27
48
|
from glaip_sdk.cli.validators import validate_api_key
|
|
28
49
|
from glaip_sdk.utils.validation import validate_url
|
|
29
50
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
Checkbox = None # type: ignore[assignment]
|
|
47
|
-
DataTable = None # type: ignore[assignment]
|
|
48
|
-
Footer = None # type: ignore[assignment]
|
|
49
|
-
Header = None # type: ignore[assignment]
|
|
50
|
-
Input = None # type: ignore[assignment]
|
|
51
|
-
LoadingIndicator = None # type: ignore[assignment]
|
|
52
|
-
ModalScreen = None # type: ignore[assignment]
|
|
53
|
-
Static = None # type: ignore[assignment]
|
|
54
|
-
|
|
55
|
-
TEXTUAL_SUPPORTED = App is not None and DataTable is not None
|
|
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
|
|
51
|
+
from textual.app import App, ComposeResult
|
|
52
|
+
from textual.binding import Binding
|
|
53
|
+
from textual.containers import Horizontal, Vertical
|
|
54
|
+
from textual.coordinate import Coordinate
|
|
55
|
+
from textual.screen import ModalScreen
|
|
56
|
+
from textual.suggester import SuggestFromList
|
|
57
|
+
from textual.theme import Theme
|
|
58
|
+
from textual.widgets import Button, Checkbox, DataTable, Footer, Input, Static
|
|
59
|
+
|
|
60
|
+
# Harlequin layout requires specific widget support
|
|
61
|
+
TEXTUAL_SUPPORTED = True
|
|
62
|
+
|
|
63
|
+
# Use standard Textual base classes
|
|
64
|
+
_AccountFormBase = ModalScreen[dict[str, Any] | None]
|
|
65
|
+
_ConfirmDeleteBase = ModalScreen[str | None]
|
|
66
|
+
_AppBase = App[None]
|
|
66
67
|
|
|
67
68
|
# Widget IDs for Textual UI
|
|
68
69
|
ACCOUNTS_TABLE_ID = "#accounts-table"
|
|
@@ -74,6 +75,30 @@ FORM_KEY_ID = "#form-key"
|
|
|
74
75
|
# CSS file name
|
|
75
76
|
CSS_FILE_NAME = "accounts.tcss"
|
|
76
77
|
|
|
78
|
+
KEYBIND_SCOPE = "accounts"
|
|
79
|
+
KEYBIND_CATEGORY = "Accounts"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class KeybindDef:
|
|
84
|
+
"""Keybind definition with action, key, and description."""
|
|
85
|
+
|
|
86
|
+
action: str
|
|
87
|
+
key: str
|
|
88
|
+
description: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
KEYBIND_DEFINITIONS: tuple[KeybindDef, ...] = (
|
|
92
|
+
KeybindDef("switch_row", "enter", "Switch"),
|
|
93
|
+
KeybindDef("focus_filter", "/", "Filter"),
|
|
94
|
+
KeybindDef("add_account", "a", "Add"),
|
|
95
|
+
KeybindDef("edit_account", "e", "Edit"),
|
|
96
|
+
KeybindDef("delete_account", "d", "Delete"),
|
|
97
|
+
KeybindDef("copy_account", "c", "Copy"),
|
|
98
|
+
KeybindDef("clear_or_exit", "escape", "Close"),
|
|
99
|
+
KeybindDef("app_exit", "q", "Close"),
|
|
100
|
+
)
|
|
101
|
+
|
|
77
102
|
|
|
78
103
|
@dataclass
|
|
79
104
|
class AccountsTUICallbacks:
|
|
@@ -201,11 +226,12 @@ def run_accounts_textual(
|
|
|
201
226
|
active_account: str | None,
|
|
202
227
|
env_lock: bool,
|
|
203
228
|
callbacks: AccountsTUICallbacks,
|
|
229
|
+
ctx: TUIContext | None = None,
|
|
204
230
|
) -> None:
|
|
205
231
|
"""Launch the Textual accounts browser if dependencies are available."""
|
|
206
232
|
if not TEXTUAL_SUPPORTED:
|
|
207
233
|
return
|
|
208
|
-
app = AccountsTextualApp(rows, active_account, env_lock, callbacks)
|
|
234
|
+
app = AccountsTextualApp(rows, active_account, env_lock, callbacks, ctx=ctx)
|
|
209
235
|
app.run()
|
|
210
236
|
|
|
211
237
|
|
|
@@ -239,6 +265,27 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
|
|
|
239
265
|
self._connection_tester = connection_tester
|
|
240
266
|
self._validate_name = validate_name
|
|
241
267
|
|
|
268
|
+
def _get_api_url_suggestions(self, _value: str) -> list[str]:
|
|
269
|
+
"""Get API URL suggestions from existing accounts.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
_value: Current input value (unused, but required by Textual's suggestor API).
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
List of unique API URLs from existing accounts.
|
|
276
|
+
"""
|
|
277
|
+
try:
|
|
278
|
+
store = get_account_store()
|
|
279
|
+
accounts = store.list_accounts()
|
|
280
|
+
# Extract unique API URLs, excluding the current account's URL in edit mode
|
|
281
|
+
existing_url = self._existing.get("api_url", "")
|
|
282
|
+
urls = {account.get("api_url", "") for account in accounts.values() if account.get("api_url")}
|
|
283
|
+
if existing_url in urls:
|
|
284
|
+
urls.remove(existing_url)
|
|
285
|
+
return sorted(urls)
|
|
286
|
+
except Exception: # pragma: no cover - defensive
|
|
287
|
+
return []
|
|
288
|
+
|
|
242
289
|
def compose(self) -> ComposeResult:
|
|
243
290
|
"""Render the form controls."""
|
|
244
291
|
title = "Add account" if self._mode == "add" else "Edit account"
|
|
@@ -248,7 +295,17 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
|
|
|
248
295
|
id="form-name",
|
|
249
296
|
disabled=self._mode == "edit",
|
|
250
297
|
)
|
|
251
|
-
|
|
298
|
+
# Get API URL suggestions and create suggester
|
|
299
|
+
url_suggestions = self._get_api_url_suggestions("")
|
|
300
|
+
url_suggester = None
|
|
301
|
+
if SuggestFromList and url_suggestions:
|
|
302
|
+
url_suggester = SuggestFromList(url_suggestions, case_sensitive=False)
|
|
303
|
+
url_input = Input(
|
|
304
|
+
value=self._existing.get("api_url", ""),
|
|
305
|
+
placeholder="https://api.example.com",
|
|
306
|
+
id="form-url",
|
|
307
|
+
suggester=url_suggester,
|
|
308
|
+
)
|
|
252
309
|
key_input = Input(value="", placeholder="sk-...", password=True, id="form-key")
|
|
253
310
|
test_checkbox = Checkbox(
|
|
254
311
|
"Test connection before save",
|
|
@@ -296,6 +353,10 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
|
|
|
296
353
|
if btn_id == "form-save":
|
|
297
354
|
self._handle_submit()
|
|
298
355
|
|
|
356
|
+
def on_input_submitted(self, _event: Input.Submitted) -> None:
|
|
357
|
+
"""Handle Enter key to save."""
|
|
358
|
+
self._handle_submit()
|
|
359
|
+
|
|
299
360
|
def _handle_submit(self) -> None:
|
|
300
361
|
"""Validate inputs and dismiss with payload on success."""
|
|
301
362
|
status = self.query_one("#form-status", Static)
|
|
@@ -363,6 +424,10 @@ class ConfirmDeleteModal(_ConfirmDeleteBase): # pragma: no cover - interactive
|
|
|
363
424
|
if btn_id == "confirm-delete":
|
|
364
425
|
self._handle_confirm()
|
|
365
426
|
|
|
427
|
+
def on_input_submitted(self, _event: Input.Submitted) -> None:
|
|
428
|
+
"""Handle Enter key in confirmation input."""
|
|
429
|
+
self._handle_confirm()
|
|
430
|
+
|
|
366
431
|
def _handle_confirm(self) -> None:
|
|
367
432
|
"""Dismiss with name when confirmation matches."""
|
|
368
433
|
status = self.query_one("#confirm-status", Static)
|
|
@@ -374,21 +439,730 @@ class ConfirmDeleteModal(_ConfirmDeleteBase): # pragma: no cover - interactive
|
|
|
374
439
|
self.dismiss(self._name)
|
|
375
440
|
|
|
376
441
|
|
|
377
|
-
|
|
442
|
+
# Widget IDs for Harlequin layout
|
|
443
|
+
HARLEQUIN_ACCOUNTS_LIST_ID = "#harlequin-accounts-list"
|
|
444
|
+
HARLEQUIN_DETAIL_ID = "#harlequin-detail"
|
|
445
|
+
HARLEQUIN_DETAIL_URL_ID = "#harlequin-detail-url"
|
|
446
|
+
HARLEQUIN_DETAIL_KEY_ID = "#harlequin-detail-key"
|
|
447
|
+
HARLEQUIN_DETAIL_STATUS_ID = "#harlequin-detail-status"
|
|
448
|
+
HARLEQUIN_DETAIL_ACTIONS_ID = "#harlequin-detail-actions"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class AccountsHarlequinScreen( # pragma: no cover - interactive
|
|
452
|
+
ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, HarlequinScreen
|
|
453
|
+
):
|
|
454
|
+
"""Harlequin layout screen for account management.
|
|
455
|
+
|
|
456
|
+
Implements Phase 1 of the TUI Harlequin Layout spec:
|
|
457
|
+
- Left pane (25%): Account Profile names list
|
|
458
|
+
- Right pane (75%): URL, API Key (hidden by default), Connection Status, Action Palette
|
|
459
|
+
"""
|
|
460
|
+
|
|
461
|
+
CSS_PATH = CSS_FILE_NAME
|
|
462
|
+
|
|
463
|
+
BINDINGS = [
|
|
464
|
+
Binding("enter", "switch_account", "Switch", show=True) if Binding else None,
|
|
465
|
+
Binding("return", "switch_account", "Switch", show=False) if Binding else None,
|
|
466
|
+
Binding("/", "focus_filter", "Filter", show=True) if Binding else None,
|
|
467
|
+
Binding("a", "add_account", "Add", show=True) if Binding else None,
|
|
468
|
+
Binding("e", "edit_account", "Edit", show=True) if Binding else None,
|
|
469
|
+
Binding("d", "delete_account", "Delete", show=True) if Binding else None,
|
|
470
|
+
Binding("c", "copy_account", "Copy", show=True) if Binding else None,
|
|
471
|
+
Binding("escape", "clear_or_exit", "Close", priority=True) if Binding else None,
|
|
472
|
+
Binding("q", "app_exit", "Close", priority=True) if Binding else None,
|
|
473
|
+
]
|
|
474
|
+
BINDINGS = [b for b in BINDINGS if b is not None]
|
|
475
|
+
|
|
476
|
+
def __init__(
|
|
477
|
+
self,
|
|
478
|
+
rows: list[dict[str, str | bool]],
|
|
479
|
+
active_account: str | None,
|
|
480
|
+
env_lock: bool,
|
|
481
|
+
callbacks: AccountsTUICallbacks,
|
|
482
|
+
ctx: TUIContext | None = None,
|
|
483
|
+
) -> None:
|
|
484
|
+
"""Initialize the Harlequin accounts screen.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
rows: Account data rows to display.
|
|
488
|
+
active_account: Name of the currently active account.
|
|
489
|
+
env_lock: Whether environment credentials are locking account switching.
|
|
490
|
+
callbacks: Callbacks for account switching operations.
|
|
491
|
+
ctx: Shared TUI context.
|
|
492
|
+
"""
|
|
493
|
+
super().__init__(ctx=ctx)
|
|
494
|
+
self._ctx = ctx
|
|
495
|
+
self._store = get_account_store()
|
|
496
|
+
self._all_rows = rows
|
|
497
|
+
self._active_account = active_account
|
|
498
|
+
self._env_lock = env_lock
|
|
499
|
+
self._account_callbacks = callbacks
|
|
500
|
+
self._keybinds: KeybindRegistry | None = None
|
|
501
|
+
self._toast_bus: ToastBus | None = None
|
|
502
|
+
self._clipboard: ClipboardAdapter | None = None
|
|
503
|
+
self._filter_text: str = ""
|
|
504
|
+
self._is_switching = False
|
|
505
|
+
self._selected_account: dict[str, str | bool] | None = None
|
|
506
|
+
self._key_visible = False
|
|
507
|
+
self._initialize_context_services()
|
|
508
|
+
|
|
509
|
+
def compose(self) -> ComposeResult: # type: ignore[return]
|
|
510
|
+
"""Compose the Harlequin layout with account list and detail panes."""
|
|
511
|
+
if not TEXTUAL_SUPPORTED or Horizontal is None or Vertical is None or Static is None:
|
|
512
|
+
return # type: ignore[return-value]
|
|
513
|
+
|
|
514
|
+
# Main container with horizontal split (25/75)
|
|
515
|
+
with Horizontal(id="harlequin-container"):
|
|
516
|
+
# Left pane (25% width) with account list
|
|
517
|
+
with Vertical(id="left-pane"):
|
|
518
|
+
yield Static("Accounts", id="left-pane-title")
|
|
519
|
+
yield Input(placeholder="Filter...", id="harlequin-filter")
|
|
520
|
+
yield DataTable(id=HARLEQUIN_ACCOUNTS_LIST_ID.lstrip("#"))
|
|
521
|
+
# Right pane (75% width) with account details
|
|
522
|
+
with Vertical(id="right-pane"):
|
|
523
|
+
yield Static("Account Details", id="right-pane-title")
|
|
524
|
+
yield Static("", id=HARLEQUIN_DETAIL_ID.lstrip("#"))
|
|
525
|
+
with Vertical(id="detail-fields"):
|
|
526
|
+
yield Static("URL:", classes="detail-label")
|
|
527
|
+
yield Static("", id=HARLEQUIN_DETAIL_URL_ID.lstrip("#"))
|
|
528
|
+
yield Static("API Key:", classes="detail-label")
|
|
529
|
+
yield Static("", id=HARLEQUIN_DETAIL_KEY_ID.lstrip("#"))
|
|
530
|
+
yield Static("Status:", classes="detail-label")
|
|
531
|
+
yield Static("", id=HARLEQUIN_DETAIL_STATUS_ID.lstrip("#"))
|
|
532
|
+
with Horizontal(id=HARLEQUIN_DETAIL_ACTIONS_ID.lstrip("#")):
|
|
533
|
+
yield Button("(a) Add", id="action-add")
|
|
534
|
+
yield Button("(e) Edit", id="action-edit")
|
|
535
|
+
yield Button("(d) Delete", id="action-delete")
|
|
536
|
+
yield Button("(c) Copy", id="action-copy")
|
|
537
|
+
yield PulseIndicator(id="harlequin-loading")
|
|
538
|
+
yield Static("", id="harlequin-status")
|
|
539
|
+
# Help text showing keyboard shortcuts at the bottom
|
|
540
|
+
yield Static(
|
|
541
|
+
"[dim]↑↓ Navigate | Enter Switch | a Add | e Edit | d Delete | c Copy | q/Esc Exit[/dim]",
|
|
542
|
+
id="help-text",
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
# Toast container for notifications
|
|
546
|
+
if Toast is not None and ToastContainer is not None:
|
|
547
|
+
yield ToastContainer(Toast(), id="toast-container")
|
|
548
|
+
|
|
549
|
+
def on_mount(self) -> None:
|
|
550
|
+
"""Configure the screen after mount."""
|
|
551
|
+
if not TEXTUAL_SUPPORTED:
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
self._apply_theme()
|
|
555
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
556
|
+
table.add_column("Account", width=None)
|
|
557
|
+
table.cursor_type = "row"
|
|
558
|
+
table.zebra_stripes = True
|
|
559
|
+
self._reload_accounts_list()
|
|
560
|
+
table.focus()
|
|
561
|
+
self._prepare_toasts()
|
|
562
|
+
self._register_keybinds()
|
|
563
|
+
self._update_detail_pane()
|
|
564
|
+
self._hide_loading()
|
|
565
|
+
|
|
566
|
+
def _initialize_context_services(self) -> None:
|
|
567
|
+
"""Initialize TUI context services."""
|
|
568
|
+
|
|
569
|
+
def _notify(message: ToastBus.Changed) -> None:
|
|
570
|
+
self.post_message(message)
|
|
571
|
+
|
|
572
|
+
ctx = self.ctx if hasattr(self, "ctx") else self._ctx
|
|
573
|
+
if ctx:
|
|
574
|
+
if ctx.keybinds is None:
|
|
575
|
+
ctx.keybinds = KeybindRegistry()
|
|
576
|
+
if ctx.toasts is None and ToastBus is not None:
|
|
577
|
+
ctx.toasts = ToastBus(on_change=_notify)
|
|
578
|
+
if ctx.clipboard is None:
|
|
579
|
+
ctx.clipboard = ClipboardAdapter(terminal=ctx.terminal)
|
|
580
|
+
self._keybinds = ctx.keybinds
|
|
581
|
+
self._toast_bus = ctx.toasts
|
|
582
|
+
self._clipboard = ctx.clipboard
|
|
583
|
+
else:
|
|
584
|
+
terminal = TerminalCapabilities(
|
|
585
|
+
tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
|
|
586
|
+
)
|
|
587
|
+
self._clipboard = ClipboardAdapter(terminal=terminal)
|
|
588
|
+
if ToastBus is not None:
|
|
589
|
+
self._toast_bus = ToastBus(on_change=_notify)
|
|
590
|
+
|
|
591
|
+
def _prepare_toasts(self) -> None:
|
|
592
|
+
"""Prepare toast system."""
|
|
593
|
+
if self._toast_bus:
|
|
594
|
+
self._toast_bus.clear()
|
|
595
|
+
|
|
596
|
+
def _register_keybinds(self) -> None:
|
|
597
|
+
"""Register keybinds with the registry."""
|
|
598
|
+
if not self._keybinds:
|
|
599
|
+
return
|
|
600
|
+
for keybind_def in KEYBIND_DEFINITIONS:
|
|
601
|
+
scoped_action = f"{KEYBIND_SCOPE}.{keybind_def.action}"
|
|
602
|
+
if self._keybinds.get(scoped_action):
|
|
603
|
+
continue
|
|
604
|
+
try:
|
|
605
|
+
self._keybinds.register(
|
|
606
|
+
action=scoped_action,
|
|
607
|
+
key=keybind_def.key,
|
|
608
|
+
description=keybind_def.description,
|
|
609
|
+
category=KEYBIND_CATEGORY,
|
|
610
|
+
)
|
|
611
|
+
except ValueError as e:
|
|
612
|
+
logging.debug(f"Skipping duplicate keybind registration: {scoped_action}", exc_info=e)
|
|
613
|
+
continue
|
|
614
|
+
|
|
615
|
+
def _reload_accounts_list(self, preferred_name: str | None = None) -> None:
|
|
616
|
+
"""Reload the accounts list in the left pane."""
|
|
617
|
+
if not TEXTUAL_SUPPORTED:
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
621
|
+
table.clear()
|
|
622
|
+
|
|
623
|
+
filtered = self._filtered_rows()
|
|
624
|
+
for row in filtered:
|
|
625
|
+
name = str(row.get("name", ""))
|
|
626
|
+
# Highlight active account
|
|
627
|
+
if row.get("name") == self._active_account:
|
|
628
|
+
name = f"[green]●[/] {name}"
|
|
629
|
+
table.add_row(name)
|
|
630
|
+
|
|
631
|
+
# Move cursor to active or preferred account
|
|
632
|
+
cursor_idx = 0
|
|
633
|
+
target_name = preferred_name or self._active_account
|
|
634
|
+
for idx, row in enumerate(filtered):
|
|
635
|
+
if row.get("name") == target_name:
|
|
636
|
+
cursor_idx = idx
|
|
637
|
+
break
|
|
638
|
+
|
|
639
|
+
if filtered:
|
|
640
|
+
table.cursor_coordinate = (cursor_idx, 0)
|
|
641
|
+
self._update_selected_account(filtered[cursor_idx] if cursor_idx < len(filtered) else None)
|
|
642
|
+
else:
|
|
643
|
+
self._update_selected_account(None)
|
|
644
|
+
self._set_status("No accounts match the current filter.", "yellow")
|
|
645
|
+
|
|
646
|
+
def _filtered_rows(self) -> list[dict[str, str | bool]]:
|
|
647
|
+
"""Return filtered account rows."""
|
|
648
|
+
if not self._filter_text:
|
|
649
|
+
return list(self._all_rows)
|
|
650
|
+
|
|
651
|
+
needle = self._filter_text.lower()
|
|
652
|
+
filtered = [
|
|
653
|
+
row
|
|
654
|
+
for row in self._all_rows
|
|
655
|
+
if needle in str(row.get("name", "")).lower() or needle in str(row.get("api_url", "")).lower()
|
|
656
|
+
]
|
|
657
|
+
|
|
658
|
+
def score(row: dict[str, str | bool]) -> tuple[int, str]:
|
|
659
|
+
name = str(row.get("name", "")).lower()
|
|
660
|
+
url = str(row.get("api_url", "")).lower()
|
|
661
|
+
name_hit = needle in name
|
|
662
|
+
url_hit = needle in url
|
|
663
|
+
priority = 0 if name_hit else (1 if url_hit else 2)
|
|
664
|
+
return (priority, name)
|
|
665
|
+
|
|
666
|
+
return sorted(filtered, key=score)
|
|
667
|
+
|
|
668
|
+
def _update_selected_account(self, account: dict[str, str | bool] | None) -> None:
|
|
669
|
+
"""Update the selected account and refresh detail pane."""
|
|
670
|
+
self._selected_account = account
|
|
671
|
+
self._update_detail_pane()
|
|
672
|
+
|
|
673
|
+
def _update_detail_pane(self) -> None:
|
|
674
|
+
"""Update the right pane with selected account details."""
|
|
675
|
+
if not TEXTUAL_SUPPORTED:
|
|
676
|
+
return
|
|
677
|
+
|
|
678
|
+
if not self._selected_account:
|
|
679
|
+
detail = self.query_one(HARLEQUIN_DETAIL_ID, Static)
|
|
680
|
+
detail.update("[dim]Select an account to view details[/]")
|
|
681
|
+
url_widget = self.query_one(HARLEQUIN_DETAIL_URL_ID, Static)
|
|
682
|
+
url_widget.update("")
|
|
683
|
+
key_widget = self.query_one(HARLEQUIN_DETAIL_KEY_ID, Static)
|
|
684
|
+
key_widget.update("")
|
|
685
|
+
status_widget = self.query_one(HARLEQUIN_DETAIL_STATUS_ID, Static)
|
|
686
|
+
status_widget.update("")
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
account = self._selected_account
|
|
690
|
+
name = str(account.get("name", ""))
|
|
691
|
+
url = str(account.get("api_url", ""))
|
|
692
|
+
masked_key = str(account.get("masked_key", ""))
|
|
693
|
+
api_key = str(account.get("api_key", ""))
|
|
694
|
+
|
|
695
|
+
# Update detail header
|
|
696
|
+
detail = self.query_one(HARLEQUIN_DETAIL_ID, Static)
|
|
697
|
+
detail.update(f"[bold]{name}[/]")
|
|
698
|
+
|
|
699
|
+
# Update URL
|
|
700
|
+
url_widget = self.query_one(HARLEQUIN_DETAIL_URL_ID, Static)
|
|
701
|
+
url_widget.update(url)
|
|
702
|
+
|
|
703
|
+
# Update API Key (hidden by default, toggle with button)
|
|
704
|
+
key_widget = self.query_one(HARLEQUIN_DETAIL_KEY_ID, Static)
|
|
705
|
+
if self._key_visible and api_key:
|
|
706
|
+
key_widget.update(api_key)
|
|
707
|
+
else:
|
|
708
|
+
key_widget.update(masked_key)
|
|
709
|
+
|
|
710
|
+
# Update Status
|
|
711
|
+
row_for_status = dict(account)
|
|
712
|
+
row_for_status["active"] = row_for_status.get("name") == self._active_account
|
|
713
|
+
status_str = build_account_status_string(row_for_status, use_markup=True)
|
|
714
|
+
status_widget = self.query_one(HARLEQUIN_DETAIL_STATUS_ID, Static)
|
|
715
|
+
status_widget.update(status_str)
|
|
716
|
+
|
|
717
|
+
def _set_status(self, message: str, style: str) -> None:
|
|
718
|
+
"""Update status message."""
|
|
719
|
+
if not TEXTUAL_SUPPORTED:
|
|
720
|
+
return
|
|
721
|
+
status = self.query_one("#harlequin-status", Static)
|
|
722
|
+
status.update(f"[{style}]{message}[/]")
|
|
723
|
+
|
|
724
|
+
def _get_selected_name(self) -> str | None:
|
|
725
|
+
"""Get the name of the currently selected account."""
|
|
726
|
+
if not TEXTUAL_SUPPORTED or not self._selected_account:
|
|
727
|
+
return None
|
|
728
|
+
return str(self._selected_account.get("name", ""))
|
|
729
|
+
|
|
730
|
+
def _show_loading(self, message: str | None = None) -> None:
|
|
731
|
+
show_loading_indicator(self, "#harlequin-loading", message=message, set_status=self._set_status)
|
|
732
|
+
|
|
733
|
+
def _hide_loading(self) -> None:
|
|
734
|
+
hide_loading_indicator(self, "#harlequin-loading")
|
|
735
|
+
|
|
736
|
+
def action_switch_account(self) -> None:
|
|
737
|
+
"""Switch to the currently selected account."""
|
|
738
|
+
if self._env_lock:
|
|
739
|
+
self._set_status("Switching disabled: env credentials in use.", "yellow")
|
|
740
|
+
return
|
|
741
|
+
|
|
742
|
+
# Ensure account is selected from cursor position if not explicitly selected
|
|
743
|
+
if not self._selected_account:
|
|
744
|
+
try:
|
|
745
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
746
|
+
cursor_row = table.cursor_row
|
|
747
|
+
if cursor_row is not None and cursor_row >= 0:
|
|
748
|
+
filtered = self._filtered_rows()
|
|
749
|
+
if cursor_row < len(filtered):
|
|
750
|
+
self._update_selected_account(filtered[cursor_row])
|
|
751
|
+
except Exception:
|
|
752
|
+
pass
|
|
753
|
+
|
|
754
|
+
name = self._get_selected_name()
|
|
755
|
+
if not name:
|
|
756
|
+
self._set_status("No account selected.", "yellow")
|
|
757
|
+
return
|
|
758
|
+
|
|
759
|
+
if self._is_switching:
|
|
760
|
+
self._set_status("Already switching...", "yellow")
|
|
761
|
+
return
|
|
762
|
+
|
|
763
|
+
self._is_switching = True
|
|
764
|
+
host = self._get_host_for_name(name)
|
|
765
|
+
message = f"Connecting to '{name}' ({host})..." if host else f"Connecting to '{name}'..."
|
|
766
|
+
self._set_status(message, "cyan")
|
|
767
|
+
self._queue_switch(name)
|
|
768
|
+
|
|
769
|
+
def _get_host_for_name(self, name: str | None) -> str | None:
|
|
770
|
+
"""Return shortened API URL for a given account name."""
|
|
771
|
+
if not name:
|
|
772
|
+
return None
|
|
773
|
+
for row in self._all_rows:
|
|
774
|
+
if row.get("name") == name:
|
|
775
|
+
url = str(row.get("api_url", ""))
|
|
776
|
+
return url if len(url) <= 40 else f"{url[:37]}..."
|
|
777
|
+
return None
|
|
778
|
+
|
|
779
|
+
def _queue_switch(self, name: str) -> None:
|
|
780
|
+
"""Run switch in background."""
|
|
781
|
+
|
|
782
|
+
async def perform() -> None:
|
|
783
|
+
try:
|
|
784
|
+
switched, message = await asyncio.to_thread(self._account_callbacks.switch_account, name)
|
|
785
|
+
except Exception as exc:
|
|
786
|
+
self._set_status(f"Switch failed: {exc}", "red")
|
|
787
|
+
return
|
|
788
|
+
finally:
|
|
789
|
+
self._hide_loading()
|
|
790
|
+
self._is_switching = False
|
|
791
|
+
|
|
792
|
+
if switched:
|
|
793
|
+
# Refresh active account from store to ensure consistency
|
|
794
|
+
self._active_account = self._store.get_active_account() or name
|
|
795
|
+
status_msg = message or f"Switched to '{name}'."
|
|
796
|
+
if self._toast_bus:
|
|
797
|
+
self._toast_bus.show(message=status_msg, variant="success")
|
|
798
|
+
self._set_status(status_msg, "green")
|
|
799
|
+
# Reload accounts list to update green indicator
|
|
800
|
+
self._reload_accounts_list(preferred_name=name)
|
|
801
|
+
self._update_detail_pane()
|
|
802
|
+
else:
|
|
803
|
+
self._set_status(message or "Switch failed; kept previous account.", "yellow")
|
|
804
|
+
|
|
805
|
+
try:
|
|
806
|
+
self._show_loading(f"Connecting to '{name}'...")
|
|
807
|
+
self.track_task(perform(), logger=logging.getLogger(__name__))
|
|
808
|
+
except Exception as exc:
|
|
809
|
+
self._hide_loading()
|
|
810
|
+
self._is_switching = False
|
|
811
|
+
self._set_status(f"Switch failed to start: {exc}", "red")
|
|
812
|
+
|
|
813
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
|
|
814
|
+
"""Handle row selection in the accounts list."""
|
|
815
|
+
if not TEXTUAL_SUPPORTED:
|
|
816
|
+
return
|
|
817
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
818
|
+
try:
|
|
819
|
+
table.cursor_coordinate = (event.cursor_row, 0)
|
|
820
|
+
except Exception:
|
|
821
|
+
return
|
|
822
|
+
filtered = self._filtered_rows()
|
|
823
|
+
if event.cursor_row < len(filtered):
|
|
824
|
+
self._update_selected_account(filtered[event.cursor_row])
|
|
825
|
+
if not self._is_switching:
|
|
826
|
+
self.action_switch_account()
|
|
827
|
+
|
|
828
|
+
def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: # type: ignore[override]
|
|
829
|
+
"""Handle mouse click selection by triggering switch."""
|
|
830
|
+
if not TEXTUAL_SUPPORTED:
|
|
831
|
+
return
|
|
832
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
833
|
+
try:
|
|
834
|
+
table.cursor_coordinate = (event.coordinate.row, 0)
|
|
835
|
+
except Exception:
|
|
836
|
+
return
|
|
837
|
+
filtered = self._filtered_rows()
|
|
838
|
+
if event.coordinate.row < len(filtered):
|
|
839
|
+
self._update_selected_account(filtered[event.coordinate.row])
|
|
840
|
+
if not self._is_switching:
|
|
841
|
+
self.action_switch_account()
|
|
842
|
+
|
|
843
|
+
def on_data_table_cursor_row_changed(self, event: DataTable.CursorRowChanged) -> None: # type: ignore[override]
|
|
844
|
+
"""Handle cursor movement in the accounts list."""
|
|
845
|
+
if not TEXTUAL_SUPPORTED:
|
|
846
|
+
return
|
|
847
|
+
filtered = self._filtered_rows()
|
|
848
|
+
if event.cursor_row is not None and event.cursor_row < len(filtered):
|
|
849
|
+
self._update_selected_account(filtered[event.cursor_row])
|
|
850
|
+
|
|
851
|
+
def action_focus_filter(self) -> None:
|
|
852
|
+
"""Focus the filter input."""
|
|
853
|
+
if not TEXTUAL_SUPPORTED:
|
|
854
|
+
return
|
|
855
|
+
filter_input = self.query_one("#harlequin-filter", Input)
|
|
856
|
+
filter_input.value = self._filter_text
|
|
857
|
+
filter_input.focus()
|
|
858
|
+
|
|
859
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
860
|
+
"""Handle filter input changes."""
|
|
861
|
+
if not TEXTUAL_SUPPORTED:
|
|
862
|
+
return
|
|
863
|
+
if event.input.id == "harlequin-filter":
|
|
864
|
+
self._filter_text = (event.value or "").strip()
|
|
865
|
+
self._reload_accounts_list()
|
|
866
|
+
|
|
867
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
868
|
+
"""Handle Enter key in Harlequin filter input."""
|
|
869
|
+
if event.input.id == "harlequin-filter":
|
|
870
|
+
try:
|
|
871
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
872
|
+
table.focus()
|
|
873
|
+
except Exception:
|
|
874
|
+
pass
|
|
875
|
+
|
|
876
|
+
def action_add_account(self) -> None:
|
|
877
|
+
"""Open add account modal."""
|
|
878
|
+
if self._check_env_lock():
|
|
879
|
+
return
|
|
880
|
+
existing_names = {str(row.get("name", "")) for row in self._all_rows}
|
|
881
|
+
modal = AccountFormModal(
|
|
882
|
+
mode="add",
|
|
883
|
+
existing=None,
|
|
884
|
+
existing_names=existing_names,
|
|
885
|
+
connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
|
|
886
|
+
validate_name=self._store.validate_account_name,
|
|
887
|
+
)
|
|
888
|
+
self.app.push_screen(modal, self._on_form_result)
|
|
889
|
+
|
|
890
|
+
def action_edit_account(self) -> None:
|
|
891
|
+
"""Open edit account modal."""
|
|
892
|
+
if self._check_env_lock():
|
|
893
|
+
return
|
|
894
|
+
# Get account from cursor position if not explicitly selected
|
|
895
|
+
self._ensure_account_selected_from_cursor()
|
|
896
|
+
name = self._get_selected_name()
|
|
897
|
+
if not name:
|
|
898
|
+
self._set_status("Select an account to edit.", "yellow")
|
|
899
|
+
return
|
|
900
|
+
account = self._store.get_account(name)
|
|
901
|
+
if not account:
|
|
902
|
+
self._set_status(f"Account '{name}' not found.", "red")
|
|
903
|
+
return
|
|
904
|
+
existing_names = {str(row.get("name", "")) for row in self._all_rows if str(row.get("name", "")) != name}
|
|
905
|
+
modal = AccountFormModal(
|
|
906
|
+
mode="edit",
|
|
907
|
+
existing={"name": name, "api_url": account.get("api_url", ""), "api_key": account.get("api_key", "")},
|
|
908
|
+
existing_names=existing_names,
|
|
909
|
+
connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
|
|
910
|
+
validate_name=self._store.validate_account_name,
|
|
911
|
+
)
|
|
912
|
+
self.app.push_screen(modal, self._on_form_result)
|
|
913
|
+
|
|
914
|
+
def action_delete_account(self) -> None:
|
|
915
|
+
"""Open delete confirmation modal."""
|
|
916
|
+
if self._check_env_lock():
|
|
917
|
+
return
|
|
918
|
+
# Get account from cursor position if not explicitly selected
|
|
919
|
+
self._ensure_account_selected_from_cursor()
|
|
920
|
+
name = self._get_selected_name()
|
|
921
|
+
if not name:
|
|
922
|
+
self._set_status("Select an account to delete.", "yellow")
|
|
923
|
+
return
|
|
924
|
+
accounts = self._store.list_accounts()
|
|
925
|
+
if len(accounts) <= 1:
|
|
926
|
+
self._set_status("Cannot remove the last remaining account.", "red")
|
|
927
|
+
return
|
|
928
|
+
self.app.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
|
|
929
|
+
|
|
930
|
+
def _ensure_account_selected_from_cursor(self) -> None:
|
|
931
|
+
"""Ensure an account is selected, using cursor position if needed."""
|
|
932
|
+
if self._selected_account:
|
|
933
|
+
return
|
|
934
|
+
try:
|
|
935
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
936
|
+
cursor_row = table.cursor_row
|
|
937
|
+
if cursor_row is not None and cursor_row >= 0:
|
|
938
|
+
row = table.get_row_at(cursor_row)
|
|
939
|
+
if row:
|
|
940
|
+
account_name = str(row[0])
|
|
941
|
+
# Find the account data
|
|
942
|
+
for account_data in self._all_rows:
|
|
943
|
+
if str(account_data.get("name", "")) == account_name:
|
|
944
|
+
self._selected_account = account_data
|
|
945
|
+
self._update_detail_pane()
|
|
946
|
+
break
|
|
947
|
+
except Exception:
|
|
948
|
+
pass
|
|
949
|
+
|
|
950
|
+
def action_copy_account(self) -> None:
|
|
951
|
+
"""Copy selected account to clipboard."""
|
|
952
|
+
# Get account from cursor position if not explicitly selected
|
|
953
|
+
self._ensure_account_selected_from_cursor()
|
|
954
|
+
|
|
955
|
+
name = self._get_selected_name()
|
|
956
|
+
if not name:
|
|
957
|
+
self._set_status("Select an account to copy.", "yellow")
|
|
958
|
+
return
|
|
959
|
+
|
|
960
|
+
account = self._store.get_account(name)
|
|
961
|
+
if not account:
|
|
962
|
+
return
|
|
963
|
+
|
|
964
|
+
text = f"Account: {name}\nURL: {account.get('api_url', '')}"
|
|
965
|
+
adapter = self._clipboard_adapter()
|
|
966
|
+
writer = self._osc52_writer()
|
|
967
|
+
if writer:
|
|
968
|
+
result = adapter.copy(text, writer=writer)
|
|
969
|
+
else:
|
|
970
|
+
result = adapter.copy(text)
|
|
971
|
+
self._handle_copy_result(name, result)
|
|
972
|
+
|
|
973
|
+
def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
|
|
974
|
+
"""Handle copy operation result."""
|
|
975
|
+
if result.success:
|
|
976
|
+
if self._toast_bus:
|
|
977
|
+
self._toast_bus.copy_success(f"Account '{name}'")
|
|
978
|
+
self._set_status(f"Copied '{name}' to clipboard.", "green")
|
|
979
|
+
else:
|
|
980
|
+
if self._toast_bus and ToastVariant is not None:
|
|
981
|
+
self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
|
|
982
|
+
self._set_status(f"Copy failed: {result.message}", "red")
|
|
983
|
+
|
|
984
|
+
def _clipboard_adapter(self) -> ClipboardAdapter:
|
|
985
|
+
"""Get clipboard adapter."""
|
|
986
|
+
ctx = self.ctx if hasattr(self, "ctx") else getattr(self, "_ctx", None)
|
|
987
|
+
if ctx is not None and ctx.clipboard is not None:
|
|
988
|
+
return cast(ClipboardAdapter, ctx.clipboard)
|
|
989
|
+
if self._clipboard is not None:
|
|
990
|
+
return self._clipboard
|
|
991
|
+
adapter = ClipboardAdapter(terminal=ctx.terminal if ctx else None)
|
|
992
|
+
if ctx is not None:
|
|
993
|
+
ctx.clipboard = adapter
|
|
994
|
+
else:
|
|
995
|
+
self._clipboard = adapter
|
|
996
|
+
return adapter
|
|
997
|
+
|
|
998
|
+
def _osc52_writer(self) -> Callable[[str], Any] | None:
|
|
999
|
+
"""Get OSC52 writer if available."""
|
|
1000
|
+
try:
|
|
1001
|
+
console = getattr(self, "console", None)
|
|
1002
|
+
except Exception:
|
|
1003
|
+
return None
|
|
1004
|
+
if console is None:
|
|
1005
|
+
return None
|
|
1006
|
+
output = getattr(console, "file", None)
|
|
1007
|
+
if output is None:
|
|
1008
|
+
return None
|
|
1009
|
+
|
|
1010
|
+
def _write(sequence: str, _output: Any = output) -> None:
|
|
1011
|
+
_output.write(sequence)
|
|
1012
|
+
_output.flush()
|
|
1013
|
+
|
|
1014
|
+
return _write
|
|
1015
|
+
|
|
1016
|
+
def _check_env_lock(self) -> bool:
|
|
1017
|
+
"""Check if env lock prevents mutations."""
|
|
1018
|
+
if not self._is_env_locked():
|
|
1019
|
+
return False
|
|
1020
|
+
self._env_lock = True
|
|
1021
|
+
self._set_status("Disabled by env-lock.", "yellow")
|
|
1022
|
+
self._refresh_rows()
|
|
1023
|
+
return True
|
|
1024
|
+
|
|
1025
|
+
def _is_env_locked(self) -> bool:
|
|
1026
|
+
"""Check if environment credentials are locking operations."""
|
|
1027
|
+
return env_credentials_present(partial=True)
|
|
1028
|
+
|
|
1029
|
+
def _on_form_result(self, payload: dict[str, Any] | None) -> None:
|
|
1030
|
+
"""Handle add/edit modal result."""
|
|
1031
|
+
if payload is None:
|
|
1032
|
+
self._set_status("Edit/add cancelled.", "yellow")
|
|
1033
|
+
return
|
|
1034
|
+
self._save_account(payload)
|
|
1035
|
+
|
|
1036
|
+
def _on_delete_result(self, confirmed_name: str | None) -> None:
|
|
1037
|
+
"""Handle delete confirmation result."""
|
|
1038
|
+
if not confirmed_name:
|
|
1039
|
+
self._set_status("Delete cancelled.", "yellow")
|
|
1040
|
+
return
|
|
1041
|
+
try:
|
|
1042
|
+
self._store.remove_account(confirmed_name)
|
|
1043
|
+
except AccountStoreError as exc:
|
|
1044
|
+
self._set_status(f"Delete failed: {exc}", "red")
|
|
1045
|
+
return
|
|
1046
|
+
except Exception as exc:
|
|
1047
|
+
self._set_status(f"Unexpected delete error: {exc}", "red")
|
|
1048
|
+
return
|
|
1049
|
+
|
|
1050
|
+
self._set_status(f"Account '{confirmed_name}' deleted.", "green")
|
|
1051
|
+
self._refresh_rows()
|
|
1052
|
+
|
|
1053
|
+
def _save_account(self, payload: dict[str, Any]) -> None:
|
|
1054
|
+
"""Save account from modal payload."""
|
|
1055
|
+
if self._is_env_locked():
|
|
1056
|
+
self._set_status("Disabled by env-lock.", "yellow")
|
|
1057
|
+
return
|
|
1058
|
+
|
|
1059
|
+
name = str(payload.get("name", ""))
|
|
1060
|
+
api_url = str(payload.get("api_url", ""))
|
|
1061
|
+
api_key = str(payload.get("api_key", ""))
|
|
1062
|
+
set_active = bool(payload.get("set_active", payload.get("mode") == "add"))
|
|
1063
|
+
is_edit = payload.get("mode") == "edit"
|
|
1064
|
+
|
|
1065
|
+
try:
|
|
1066
|
+
self._store.add_account(name, api_url, api_key, overwrite=is_edit)
|
|
1067
|
+
except AccountStoreError as exc:
|
|
1068
|
+
self._set_status(f"Save failed: {exc}", "red")
|
|
1069
|
+
return
|
|
1070
|
+
except Exception as exc:
|
|
1071
|
+
self._set_status(f"Unexpected save error: {exc}", "red")
|
|
1072
|
+
return
|
|
1073
|
+
|
|
1074
|
+
if set_active:
|
|
1075
|
+
try:
|
|
1076
|
+
self._store.set_active_account(name)
|
|
1077
|
+
self._active_account = name
|
|
1078
|
+
except Exception as exc:
|
|
1079
|
+
self._set_status(f"Saved but could not set active: {exc}", "yellow")
|
|
1080
|
+
else:
|
|
1081
|
+
if self._toast_bus:
|
|
1082
|
+
self._toast_bus.show(message=f"Switched to '{name}'", variant="success")
|
|
1083
|
+
|
|
1084
|
+
self._set_status(f"Account '{name}' saved.", "green")
|
|
1085
|
+
self._refresh_rows(preferred_name=name)
|
|
1086
|
+
|
|
1087
|
+
def _refresh_rows(self, preferred_name: str | None = None) -> None:
|
|
1088
|
+
"""Refresh account rows from store."""
|
|
1089
|
+
self._env_lock = self._is_env_locked()
|
|
1090
|
+
self._all_rows, self._active_account = _build_account_rows_from_store(self._store, self._env_lock)
|
|
1091
|
+
self._reload_accounts_list(preferred_name=preferred_name)
|
|
1092
|
+
if self._selected_account:
|
|
1093
|
+
# Refresh selected account details
|
|
1094
|
+
name = str(self._selected_account.get("name", ""))
|
|
1095
|
+
for row in self._all_rows:
|
|
1096
|
+
if row.get("name") == name:
|
|
1097
|
+
self._update_selected_account(row)
|
|
1098
|
+
break
|
|
1099
|
+
|
|
1100
|
+
def action_clear_or_exit(self) -> None:
|
|
1101
|
+
"""Clear filter or exit."""
|
|
1102
|
+
if not TEXTUAL_SUPPORTED:
|
|
1103
|
+
return
|
|
1104
|
+
filter_input = self.query_one("#harlequin-filter", Input)
|
|
1105
|
+
if filter_input.has_focus:
|
|
1106
|
+
if filter_input.value or self._filter_text:
|
|
1107
|
+
filter_input.value = ""
|
|
1108
|
+
self._filter_text = ""
|
|
1109
|
+
self._reload_accounts_list()
|
|
1110
|
+
table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
|
|
1111
|
+
table.focus()
|
|
1112
|
+
return
|
|
1113
|
+
self.dismiss()
|
|
1114
|
+
|
|
1115
|
+
def action_app_exit(self) -> None:
|
|
1116
|
+
"""Exit the application."""
|
|
1117
|
+
self.dismiss()
|
|
1118
|
+
|
|
1119
|
+
def _apply_theme(self) -> None:
|
|
1120
|
+
"""Apply theme from context."""
|
|
1121
|
+
ctx = self.ctx if hasattr(self, "ctx") else getattr(self, "_ctx", None)
|
|
1122
|
+
if not ctx or not ctx.theme or Theme is None:
|
|
1123
|
+
return
|
|
1124
|
+
|
|
1125
|
+
app = self.app
|
|
1126
|
+
if app is None:
|
|
1127
|
+
return
|
|
1128
|
+
|
|
1129
|
+
for name, tokens in _BUILTIN_THEMES.items():
|
|
1130
|
+
app.register_theme(
|
|
1131
|
+
Theme(
|
|
1132
|
+
name=name,
|
|
1133
|
+
primary=tokens.primary,
|
|
1134
|
+
secondary=tokens.secondary,
|
|
1135
|
+
accent=tokens.accent,
|
|
1136
|
+
warning=tokens.warning,
|
|
1137
|
+
error=tokens.error,
|
|
1138
|
+
success=tokens.success,
|
|
1139
|
+
background=tokens.background,
|
|
1140
|
+
surface=tokens.background_panel,
|
|
1141
|
+
)
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
app.theme = ctx.theme.theme_name
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
class AccountsTextualApp( # pragma: no cover - interactive
|
|
1148
|
+
ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, _AppBase
|
|
1149
|
+
):
|
|
378
1150
|
"""Textual application for browsing accounts."""
|
|
379
1151
|
|
|
380
1152
|
CSS_PATH = CSS_FILE_NAME
|
|
381
1153
|
BINDINGS = [
|
|
382
|
-
Binding("enter", "switch_row", "Switch", show=True),
|
|
383
|
-
Binding("return", "switch_row", "Switch", show=False),
|
|
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),
|
|
1154
|
+
Binding("enter", "switch_row", "Switch", show=True) if Binding else None,
|
|
1155
|
+
Binding("return", "switch_row", "Switch", show=False) if Binding else None,
|
|
1156
|
+
Binding("/", "focus_filter", "Filter", show=True) if Binding else None,
|
|
1157
|
+
Binding("a", "add_account", "Add", show=True) if Binding else None,
|
|
1158
|
+
Binding("e", "edit_account", "Edit", show=True) if Binding else None,
|
|
1159
|
+
Binding("d", "delete_account", "Delete", show=True) if Binding else None,
|
|
1160
|
+
Binding("c", "copy_account", "Copy", show=True) if Binding else None,
|
|
388
1161
|
# Esc clears filter when focused/non-empty; otherwise exits
|
|
389
|
-
Binding("escape", "clear_or_exit", "Close", priority=True),
|
|
390
|
-
Binding("q", "app_exit", "Close", priority=True),
|
|
1162
|
+
Binding("escape", "clear_or_exit", "Close", priority=True) if Binding else None,
|
|
1163
|
+
Binding("q", "app_exit", "Close", priority=True) if Binding else None,
|
|
391
1164
|
]
|
|
1165
|
+
BINDINGS = [b for b in BINDINGS if b is not None]
|
|
392
1166
|
|
|
393
1167
|
def __init__(
|
|
394
1168
|
self,
|
|
@@ -396,6 +1170,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
396
1170
|
active_account: str | None,
|
|
397
1171
|
env_lock: bool,
|
|
398
1172
|
callbacks: AccountsTUICallbacks,
|
|
1173
|
+
ctx: TUIContext | None = None,
|
|
399
1174
|
) -> None:
|
|
400
1175
|
"""Initialize the Textual accounts app.
|
|
401
1176
|
|
|
@@ -404,66 +1179,88 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
404
1179
|
active_account: Name of the currently active account.
|
|
405
1180
|
env_lock: Whether environment credentials are locking account switching.
|
|
406
1181
|
callbacks: Callbacks for account switching operations.
|
|
1182
|
+
ctx: Shared TUI context.
|
|
407
1183
|
"""
|
|
408
1184
|
super().__init__()
|
|
409
1185
|
self._store = get_account_store()
|
|
410
1186
|
self._all_rows = rows
|
|
411
1187
|
self._active_account = active_account
|
|
412
1188
|
self._env_lock = env_lock
|
|
413
|
-
self.
|
|
1189
|
+
self._account_callbacks = callbacks
|
|
1190
|
+
self._ctx = ctx
|
|
1191
|
+
self._keybinds: KeybindRegistry | None = None
|
|
1192
|
+
self._toast_bus: ToastBus | None = None
|
|
1193
|
+
self._clipboard: ClipboardAdapter | None = None
|
|
414
1194
|
self._filter_text: str = ""
|
|
415
1195
|
self._is_switching = False
|
|
1196
|
+
self._initialize_context_services()
|
|
416
1197
|
|
|
417
1198
|
def compose(self) -> ComposeResult:
|
|
418
|
-
"""Build the Textual
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
yield Static(
|
|
423
|
-
"Env credentials detected (AIP_API_URL/AIP_API_KEY); add/edit/delete are disabled.",
|
|
424
|
-
id="env-lock",
|
|
425
|
-
)
|
|
426
|
-
clear_btn = Button("Clear", id="filter-clear")
|
|
427
|
-
clear_btn.display = False # hide until filter has content
|
|
428
|
-
filter_bar = Horizontal(
|
|
429
|
-
Static("Filter (/):", id="filter-label"),
|
|
430
|
-
Input(placeholder="Type to filter by name or host", id="filter-input"),
|
|
431
|
-
clear_btn,
|
|
432
|
-
id="filter-container",
|
|
433
|
-
)
|
|
434
|
-
filter_bar.styles.padding = (0, 0)
|
|
435
|
-
main = Vertical(
|
|
436
|
-
filter_bar,
|
|
437
|
-
DataTable(id=ACCOUNTS_TABLE_ID.lstrip("#")),
|
|
438
|
-
)
|
|
439
|
-
# Avoid large gaps; keep main content filling available space
|
|
440
|
-
main.styles.height = "1fr"
|
|
441
|
-
main.styles.padding = (0, 0)
|
|
442
|
-
yield main
|
|
443
|
-
yield Horizontal(
|
|
444
|
-
LoadingIndicator(id=ACCOUNTS_LOADING_ID.lstrip("#")),
|
|
445
|
-
Static("", id=STATUS_ID.lstrip("#")),
|
|
446
|
-
id="status-bar",
|
|
447
|
-
)
|
|
1199
|
+
"""Build the Textual app (empty, screen is pushed on mount)."""
|
|
1200
|
+
# The app itself is empty; AccountsHarlequinScreen is pushed on mount
|
|
1201
|
+
if not TEXTUAL_SUPPORTED or Footer is None:
|
|
1202
|
+
return # type: ignore[return-value]
|
|
448
1203
|
yield Footer()
|
|
449
1204
|
|
|
450
1205
|
def on_mount(self) -> None:
|
|
451
|
-
"""
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
self.
|
|
1206
|
+
"""Push the Harlequin accounts screen on mount."""
|
|
1207
|
+
self._apply_theme()
|
|
1208
|
+
harlequin_screen = AccountsHarlequinScreen(
|
|
1209
|
+
rows=self._all_rows,
|
|
1210
|
+
active_account=self._active_account,
|
|
1211
|
+
env_lock=self._env_lock,
|
|
1212
|
+
callbacks=self._account_callbacks,
|
|
1213
|
+
ctx=self._ctx,
|
|
1214
|
+
)
|
|
1215
|
+
self.push_screen(harlequin_screen)
|
|
1216
|
+
|
|
1217
|
+
def _initialize_context_services(self) -> None:
|
|
1218
|
+
def _notify(message: ToastBus.Changed) -> None:
|
|
1219
|
+
self.post_message(message)
|
|
1220
|
+
|
|
1221
|
+
if self._ctx:
|
|
1222
|
+
if self._ctx.keybinds is None:
|
|
1223
|
+
self._ctx.keybinds = KeybindRegistry()
|
|
1224
|
+
if self._ctx.toasts is None and ToastBus is not None:
|
|
1225
|
+
self._ctx.toasts = ToastBus(on_change=_notify)
|
|
1226
|
+
if self._ctx.clipboard is None:
|
|
1227
|
+
self._ctx.clipboard = ClipboardAdapter(terminal=self._ctx.terminal)
|
|
1228
|
+
self._keybinds = self._ctx.keybinds
|
|
1229
|
+
self._toast_bus = self._ctx.toasts
|
|
1230
|
+
self._clipboard = self._ctx.clipboard
|
|
1231
|
+
else:
|
|
1232
|
+
# Fallback: create services independently when ctx is None
|
|
1233
|
+
terminal = TerminalCapabilities(
|
|
1234
|
+
tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
|
|
1235
|
+
)
|
|
1236
|
+
self._clipboard = ClipboardAdapter(terminal=terminal)
|
|
1237
|
+
if ToastBus is not None:
|
|
1238
|
+
self._toast_bus = ToastBus(on_change=_notify)
|
|
1239
|
+
|
|
1240
|
+
def _prepare_toasts(self) -> None:
|
|
1241
|
+
"""Prepare toast system by clearing any existing toasts."""
|
|
1242
|
+
if self._toast_bus:
|
|
1243
|
+
self._toast_bus.clear()
|
|
1244
|
+
|
|
1245
|
+
def _register_keybinds(self) -> None:
|
|
1246
|
+
if not self._keybinds:
|
|
1247
|
+
return
|
|
1248
|
+
for keybind_def in KEYBIND_DEFINITIONS:
|
|
1249
|
+
scoped_action = f"{KEYBIND_SCOPE}.{keybind_def.action}"
|
|
1250
|
+
if self._keybinds.get(scoped_action):
|
|
1251
|
+
continue
|
|
1252
|
+
try:
|
|
1253
|
+
self._keybinds.register(
|
|
1254
|
+
action=scoped_action,
|
|
1255
|
+
key=keybind_def.key,
|
|
1256
|
+
description=keybind_def.description,
|
|
1257
|
+
category=KEYBIND_CATEGORY,
|
|
1258
|
+
)
|
|
1259
|
+
except ValueError as e:
|
|
1260
|
+
# Expected: duplicate registration (already registered by another component)
|
|
1261
|
+
# Silently skip to allow multiple apps to register same keybinds
|
|
1262
|
+
logging.debug(f"Skipping duplicate keybind registration: {scoped_action}", exc_info=e)
|
|
1263
|
+
continue
|
|
467
1264
|
|
|
468
1265
|
def _header_text(self) -> str:
|
|
469
1266
|
"""Build header text with active account and host."""
|
|
@@ -488,16 +1285,32 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
488
1285
|
|
|
489
1286
|
def action_focus_filter(self) -> None:
|
|
490
1287
|
"""Focus the filter input and clear previous text."""
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
1288
|
+
# Skip if Harlequin screen is active (it handles its own filter focus)
|
|
1289
|
+
if isinstance(self.screen, AccountsHarlequinScreen):
|
|
1290
|
+
return
|
|
1291
|
+
try:
|
|
1292
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
1293
|
+
filter_input.value = self._filter_text
|
|
1294
|
+
filter_input.focus()
|
|
1295
|
+
except Exception:
|
|
1296
|
+
# Filter input doesn't exist, skip
|
|
1297
|
+
pass
|
|
494
1298
|
|
|
495
1299
|
def action_switch_row(self) -> None:
|
|
496
|
-
"""Switch to the currently selected account.
|
|
1300
|
+
"""Switch to the currently selected account.
|
|
1301
|
+
|
|
1302
|
+
Note: This action is for the old table layout. When using HarlequinScreen,
|
|
1303
|
+
the screen handles switching directly. This gracefully skips if the
|
|
1304
|
+
old table doesn't exist.
|
|
1305
|
+
"""
|
|
1306
|
+
try:
|
|
1307
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
1308
|
+
except Exception:
|
|
1309
|
+
# Harlequin screen is active, let it handle the action
|
|
1310
|
+
return
|
|
497
1311
|
if self._env_lock:
|
|
498
1312
|
self._set_status("Switching disabled: env credentials in use.", "yellow")
|
|
499
1313
|
return
|
|
500
|
-
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
501
1314
|
if table.cursor_row is None:
|
|
502
1315
|
self._set_status("No account selected.", "yellow")
|
|
503
1316
|
return
|
|
@@ -519,37 +1332,80 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
519
1332
|
self._queue_switch(name)
|
|
520
1333
|
|
|
521
1334
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
|
|
1335
|
+
"""Handle row selection by triggering switch."""
|
|
1336
|
+
self._handle_table_click(self._event_row(event))
|
|
1337
|
+
|
|
1338
|
+
def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: # type: ignore[override]
|
|
522
1339
|
"""Handle mouse click selection by triggering switch."""
|
|
523
|
-
|
|
1340
|
+
self._handle_table_click(self._event_row(event))
|
|
1341
|
+
|
|
1342
|
+
def _event_row(self, event: object) -> int | None:
|
|
1343
|
+
"""Extract the row index from a DataTable event."""
|
|
1344
|
+
row = getattr(event, "cursor_row", None)
|
|
1345
|
+
if row is not None:
|
|
1346
|
+
return int(row)
|
|
1347
|
+
coordinate = getattr(event, "coordinate", None)
|
|
1348
|
+
return getattr(coordinate, "row", None) if coordinate is not None else None
|
|
1349
|
+
|
|
1350
|
+
def _handle_table_click(self, row: int | None) -> None:
|
|
1351
|
+
"""Move the cursor to a clicked row and trigger the switch action."""
|
|
1352
|
+
if row is None:
|
|
1353
|
+
return
|
|
1354
|
+
try:
|
|
1355
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
1356
|
+
except Exception:
|
|
1357
|
+
# Harlequin screen is active, let it handle the action
|
|
1358
|
+
return
|
|
524
1359
|
try:
|
|
525
1360
|
# Move cursor to clicked row then switch
|
|
526
|
-
table.cursor_coordinate = (
|
|
1361
|
+
table.cursor_coordinate = Coordinate(row, 0)
|
|
527
1362
|
except Exception:
|
|
528
1363
|
return
|
|
529
1364
|
self.action_switch_row()
|
|
530
1365
|
|
|
531
1366
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
532
1367
|
"""Apply filter when user presses Enter inside filter input."""
|
|
1368
|
+
# Skip if a screen other than the default app screen is active (e.g., Harlequin or Modal)
|
|
1369
|
+
if self.screen.id != "_default":
|
|
1370
|
+
return
|
|
1371
|
+
|
|
533
1372
|
self._filter_text = (event.value or "").strip()
|
|
534
1373
|
self._reload_rows()
|
|
535
|
-
|
|
536
|
-
|
|
1374
|
+
try:
|
|
1375
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
1376
|
+
table.focus()
|
|
1377
|
+
except Exception:
|
|
1378
|
+
pass
|
|
537
1379
|
self._update_filter_button_visibility()
|
|
538
1380
|
|
|
539
1381
|
def on_input_changed(self, event: Input.Changed) -> None:
|
|
540
1382
|
"""Apply filter live as the user types."""
|
|
1383
|
+
# Skip if a screen other than the default app screen is active (e.g., Harlequin or Modal)
|
|
1384
|
+
if self.screen.id != "_default":
|
|
1385
|
+
return
|
|
541
1386
|
self._filter_text = (event.value or "").strip()
|
|
542
1387
|
self._reload_rows()
|
|
543
1388
|
self._update_filter_button_visibility()
|
|
544
1389
|
|
|
545
1390
|
def _reload_rows(self, preferred_name: str | None = None) -> None:
|
|
546
1391
|
"""Refresh table rows based on current filter/active state."""
|
|
1392
|
+
# Skip if Harlequin screen is active (it handles its own reloading)
|
|
1393
|
+
try:
|
|
1394
|
+
if isinstance(self.screen, AccountsHarlequinScreen):
|
|
1395
|
+
return
|
|
1396
|
+
except Exception: # pragma: no cover - defensive (e.g., ScreenStackError in tests)
|
|
1397
|
+
# App not fully initialized or no screen pushed, continue with normal reload
|
|
1398
|
+
pass
|
|
547
1399
|
# Work on a copy to avoid mutating the backing rows list
|
|
548
1400
|
rows_copy = [dict(row) for row in self._all_rows]
|
|
549
1401
|
for row in rows_copy:
|
|
550
1402
|
row["active"] = row.get("name") == self._active_account
|
|
551
1403
|
|
|
552
|
-
|
|
1404
|
+
try:
|
|
1405
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
1406
|
+
except Exception:
|
|
1407
|
+
# Harlequin screen is active, skip
|
|
1408
|
+
return
|
|
553
1409
|
table.clear()
|
|
554
1410
|
filtered = self._filtered_rows(rows_copy)
|
|
555
1411
|
for row in filtered:
|
|
@@ -626,11 +1482,36 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
626
1482
|
"""Hide the loading indicator."""
|
|
627
1483
|
hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
|
|
628
1484
|
|
|
1485
|
+
def _handle_switch_scheduling_error(self, exc: Exception) -> None:
|
|
1486
|
+
"""Handle errors when scheduling the switch task fails.
|
|
1487
|
+
|
|
1488
|
+
Args:
|
|
1489
|
+
exc: The exception that occurred during task scheduling.
|
|
1490
|
+
"""
|
|
1491
|
+
self._hide_loading()
|
|
1492
|
+
self._is_switching = False
|
|
1493
|
+
error_msg = f"Switch failed to start: {exc}"
|
|
1494
|
+
if self._toast_bus:
|
|
1495
|
+
self._toast_bus.show(message=error_msg, variant="error")
|
|
1496
|
+
try:
|
|
1497
|
+
self._set_status(error_msg, "red")
|
|
1498
|
+
except Exception:
|
|
1499
|
+
# App not mounted yet, status update not possible
|
|
1500
|
+
logging.error(error_msg, exc_info=exc)
|
|
1501
|
+
logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
|
|
1502
|
+
|
|
629
1503
|
def _clear_filter(self) -> None:
|
|
630
1504
|
"""Clear the filter input and reset filter state."""
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1505
|
+
# Skip if Harlequin screen is active (it handles its own filtering)
|
|
1506
|
+
if isinstance(self.screen, AccountsHarlequinScreen):
|
|
1507
|
+
return
|
|
1508
|
+
try:
|
|
1509
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
1510
|
+
filter_input.value = ""
|
|
1511
|
+
self._filter_text = ""
|
|
1512
|
+
except Exception:
|
|
1513
|
+
# Filter input doesn't exist, just clear the text
|
|
1514
|
+
self._filter_text = ""
|
|
634
1515
|
self._update_filter_button_visibility()
|
|
635
1516
|
|
|
636
1517
|
def _queue_switch(self, name: str) -> None:
|
|
@@ -638,7 +1519,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
638
1519
|
|
|
639
1520
|
async def perform() -> None:
|
|
640
1521
|
try:
|
|
641
|
-
switched, message = await asyncio.to_thread(self.
|
|
1522
|
+
switched, message = await asyncio.to_thread(self._account_callbacks.switch_account, name)
|
|
642
1523
|
except Exception as exc: # pragma: no cover - defensive
|
|
643
1524
|
self._set_status(f"Switch failed: {exc}", "red")
|
|
644
1525
|
return
|
|
@@ -648,7 +1529,9 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
648
1529
|
|
|
649
1530
|
if switched:
|
|
650
1531
|
self._active_account = name
|
|
651
|
-
|
|
1532
|
+
status_msg = message or f"Switched to '{name}'."
|
|
1533
|
+
if self._toast_bus:
|
|
1534
|
+
self._toast_bus.show(message=status_msg, variant="success")
|
|
652
1535
|
self._update_header()
|
|
653
1536
|
self._reload_rows()
|
|
654
1537
|
else:
|
|
@@ -657,11 +1540,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
657
1540
|
try:
|
|
658
1541
|
self.track_task(perform(), logger=logging.getLogger(__name__))
|
|
659
1542
|
except Exception as exc:
|
|
660
|
-
|
|
661
|
-
self._hide_loading()
|
|
662
|
-
self._is_switching = False
|
|
663
|
-
self._set_status(f"Switch failed to start: {exc}", "red")
|
|
664
|
-
logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
|
|
1543
|
+
self._handle_switch_scheduling_error(exc)
|
|
665
1544
|
|
|
666
1545
|
def _update_header(self) -> None:
|
|
667
1546
|
"""Refresh header text to reflect active/lock state."""
|
|
@@ -673,15 +1552,30 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
673
1552
|
|
|
674
1553
|
UX note: helps users reset the list without leaving the TUI.
|
|
675
1554
|
"""
|
|
676
|
-
|
|
677
|
-
if
|
|
678
|
-
|
|
679
|
-
if filter_input.value or self._filter_text:
|
|
680
|
-
self._clear_filter()
|
|
681
|
-
self._reload_rows()
|
|
682
|
-
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
683
|
-
table.focus()
|
|
1555
|
+
# Skip if Harlequin screen is active (it handles its own exit)
|
|
1556
|
+
if isinstance(self.screen, AccountsHarlequinScreen):
|
|
1557
|
+
self.exit()
|
|
684
1558
|
return
|
|
1559
|
+
try:
|
|
1560
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
1561
|
+
if filter_input.has_focus:
|
|
1562
|
+
# Clear when there is text; otherwise just move focus back to the table
|
|
1563
|
+
if filter_input.value or self._filter_text:
|
|
1564
|
+
self._clear_filter()
|
|
1565
|
+
self._reload_rows()
|
|
1566
|
+
try:
|
|
1567
|
+
table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
|
|
1568
|
+
table.focus()
|
|
1569
|
+
except Exception:
|
|
1570
|
+
pass
|
|
1571
|
+
return
|
|
1572
|
+
except Exception:
|
|
1573
|
+
# Filter input doesn't exist, just exit
|
|
1574
|
+
pass
|
|
1575
|
+
self.exit()
|
|
1576
|
+
|
|
1577
|
+
def action_app_exit(self) -> None:
|
|
1578
|
+
"""Exit the application regardless of focus state."""
|
|
685
1579
|
self.exit()
|
|
686
1580
|
|
|
687
1581
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
@@ -748,6 +1642,66 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
748
1642
|
return
|
|
749
1643
|
self.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
|
|
750
1644
|
|
|
1645
|
+
def action_copy_account(self) -> None:
|
|
1646
|
+
"""Copy selected account name and URL to clipboard."""
|
|
1647
|
+
name = self._get_selected_name()
|
|
1648
|
+
if not name:
|
|
1649
|
+
self._set_status("Select an account to copy.", "yellow")
|
|
1650
|
+
return
|
|
1651
|
+
|
|
1652
|
+
account = self._store.get_account(name)
|
|
1653
|
+
if not account:
|
|
1654
|
+
return
|
|
1655
|
+
|
|
1656
|
+
text = f"Account: {name}\nURL: {account.get('api_url', '')}"
|
|
1657
|
+
adapter = self._clipboard_adapter()
|
|
1658
|
+
writer = self._osc52_writer()
|
|
1659
|
+
if writer:
|
|
1660
|
+
result = adapter.copy(text, writer=writer)
|
|
1661
|
+
else:
|
|
1662
|
+
result = adapter.copy(text)
|
|
1663
|
+
self._handle_copy_result(name, result)
|
|
1664
|
+
|
|
1665
|
+
def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
|
|
1666
|
+
"""Update UI state after a copy attempt."""
|
|
1667
|
+
if result.success:
|
|
1668
|
+
if self._toast_bus:
|
|
1669
|
+
self._toast_bus.copy_success(f"Account '{name}'")
|
|
1670
|
+
self._set_status(f"Copied '{name}' to clipboard.", "green")
|
|
1671
|
+
else:
|
|
1672
|
+
if self._toast_bus and ToastVariant is not None:
|
|
1673
|
+
self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
|
|
1674
|
+
self._set_status(f"Copy failed: {result.message}", "red")
|
|
1675
|
+
|
|
1676
|
+
def _clipboard_adapter(self) -> ClipboardAdapter:
|
|
1677
|
+
if self._ctx is not None and self._ctx.clipboard is not None:
|
|
1678
|
+
return cast(ClipboardAdapter, self._ctx.clipboard)
|
|
1679
|
+
if self._clipboard is not None:
|
|
1680
|
+
return self._clipboard
|
|
1681
|
+
adapter = ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
|
|
1682
|
+
if self._ctx is not None:
|
|
1683
|
+
self._ctx.clipboard = adapter
|
|
1684
|
+
else:
|
|
1685
|
+
self._clipboard = adapter
|
|
1686
|
+
return adapter
|
|
1687
|
+
|
|
1688
|
+
def _osc52_writer(self) -> Callable[[str], Any] | None:
|
|
1689
|
+
try:
|
|
1690
|
+
console = getattr(self, "console", None)
|
|
1691
|
+
except Exception:
|
|
1692
|
+
return None
|
|
1693
|
+
if console is None:
|
|
1694
|
+
return None
|
|
1695
|
+
output = getattr(console, "file", None)
|
|
1696
|
+
if output is None:
|
|
1697
|
+
return None
|
|
1698
|
+
|
|
1699
|
+
def _write(sequence: str, _output: Any = output) -> None:
|
|
1700
|
+
_output.write(sequence)
|
|
1701
|
+
_output.flush()
|
|
1702
|
+
|
|
1703
|
+
return _write
|
|
1704
|
+
|
|
751
1705
|
def _check_env_lock_hotkey(self) -> bool:
|
|
752
1706
|
"""Prevent mutations when env credentials are present."""
|
|
753
1707
|
if not self._is_env_locked():
|
|
@@ -867,6 +1821,33 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
|
|
|
867
1821
|
|
|
868
1822
|
def _update_filter_button_visibility(self) -> None:
|
|
869
1823
|
"""Show clear button only when filter has content."""
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1824
|
+
# Skip if Harlequin screen is active (it doesn't have this button)
|
|
1825
|
+
if isinstance(self.screen, AccountsHarlequinScreen):
|
|
1826
|
+
return
|
|
1827
|
+
try:
|
|
1828
|
+
filter_input = self.query_one(FILTER_INPUT_ID, Input)
|
|
1829
|
+
clear_btn = self.query_one("#filter-clear", Button)
|
|
1830
|
+
clear_btn.display = bool(filter_input.value or self._filter_text)
|
|
1831
|
+
except Exception:
|
|
1832
|
+
# Filter input or clear button doesn't exist, skip
|
|
1833
|
+
pass
|
|
1834
|
+
|
|
1835
|
+
def _apply_theme(self) -> None:
|
|
1836
|
+
"""Register built-in themes and set the active one from context."""
|
|
1837
|
+
if not self._ctx or not self._ctx.theme or Theme is None:
|
|
1838
|
+
return
|
|
1839
|
+
|
|
1840
|
+
for name, tokens in _BUILTIN_THEMES.items():
|
|
1841
|
+
self.register_theme(
|
|
1842
|
+
Theme(
|
|
1843
|
+
name=name,
|
|
1844
|
+
primary=tokens.primary,
|
|
1845
|
+
secondary=tokens.secondary,
|
|
1846
|
+
accent=tokens.accent,
|
|
1847
|
+
warning=tokens.warning,
|
|
1848
|
+
error=tokens.error,
|
|
1849
|
+
success=tokens.success,
|
|
1850
|
+
)
|
|
1851
|
+
)
|
|
1852
|
+
|
|
1853
|
+
self.theme = self._ctx.theme.theme_name
|