glaip-sdk 0.6.19__py3-none-any.whl → 0.7.27__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.
Files changed (135) hide show
  1. glaip_sdk/agents/base.py +283 -30
  2. glaip_sdk/agents/component.py +233 -0
  3. glaip_sdk/branding.py +113 -2
  4. glaip_sdk/cli/account_store.py +15 -0
  5. glaip_sdk/cli/auth.py +14 -8
  6. glaip_sdk/cli/commands/accounts.py +1 -1
  7. glaip_sdk/cli/commands/agents/__init__.py +116 -0
  8. glaip_sdk/cli/commands/agents/_common.py +562 -0
  9. glaip_sdk/cli/commands/agents/create.py +155 -0
  10. glaip_sdk/cli/commands/agents/delete.py +64 -0
  11. glaip_sdk/cli/commands/agents/get.py +89 -0
  12. glaip_sdk/cli/commands/agents/list.py +129 -0
  13. glaip_sdk/cli/commands/agents/run.py +264 -0
  14. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  15. glaip_sdk/cli/commands/agents/update.py +112 -0
  16. glaip_sdk/cli/commands/common_config.py +1 -1
  17. glaip_sdk/cli/commands/configure.py +1 -2
  18. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  19. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  20. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  21. glaip_sdk/cli/commands/mcps/create.py +152 -0
  22. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  23. glaip_sdk/cli/commands/mcps/get.py +212 -0
  24. glaip_sdk/cli/commands/mcps/list.py +69 -0
  25. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  26. glaip_sdk/cli/commands/mcps/update.py +190 -0
  27. glaip_sdk/cli/commands/models.py +2 -4
  28. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  29. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  30. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  31. glaip_sdk/cli/commands/tools/_common.py +80 -0
  32. glaip_sdk/cli/commands/tools/create.py +228 -0
  33. glaip_sdk/cli/commands/tools/delete.py +61 -0
  34. glaip_sdk/cli/commands/tools/get.py +103 -0
  35. glaip_sdk/cli/commands/tools/list.py +69 -0
  36. glaip_sdk/cli/commands/tools/script.py +49 -0
  37. glaip_sdk/cli/commands/tools/update.py +102 -0
  38. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  39. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  40. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  41. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  42. glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
  43. glaip_sdk/cli/commands/update.py +163 -17
  44. glaip_sdk/cli/config.py +1 -0
  45. glaip_sdk/cli/entrypoint.py +20 -0
  46. glaip_sdk/cli/main.py +112 -35
  47. glaip_sdk/cli/pager.py +3 -3
  48. glaip_sdk/cli/resolution.py +2 -1
  49. glaip_sdk/cli/slash/accounts_controller.py +3 -1
  50. glaip_sdk/cli/slash/agent_session.py +1 -1
  51. glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
  52. glaip_sdk/cli/slash/session.py +343 -20
  53. glaip_sdk/cli/slash/tui/__init__.py +29 -1
  54. glaip_sdk/cli/slash/tui/accounts.tcss +97 -6
  55. glaip_sdk/cli/slash/tui/accounts_app.py +1117 -126
  56. glaip_sdk/cli/slash/tui/clipboard.py +316 -0
  57. glaip_sdk/cli/slash/tui/context.py +92 -0
  58. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  59. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  60. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  61. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  62. glaip_sdk/cli/slash/tui/loading.py +43 -21
  63. glaip_sdk/cli/slash/tui/remote_runs_app.py +178 -20
  64. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  65. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  66. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  67. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  68. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  69. glaip_sdk/cli/slash/tui/toast.py +388 -0
  70. glaip_sdk/cli/transcript/history.py +1 -1
  71. glaip_sdk/cli/transcript/viewer.py +1 -1
  72. glaip_sdk/cli/tui_settings.py +125 -0
  73. glaip_sdk/cli/update_notifier.py +215 -7
  74. glaip_sdk/cli/validators.py +1 -1
  75. glaip_sdk/client/__init__.py +2 -1
  76. glaip_sdk/client/_schedule_payloads.py +89 -0
  77. glaip_sdk/client/agents.py +293 -17
  78. glaip_sdk/client/base.py +25 -0
  79. glaip_sdk/client/hitl.py +136 -0
  80. glaip_sdk/client/main.py +7 -5
  81. glaip_sdk/client/mcps.py +44 -13
  82. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  83. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +28 -48
  84. glaip_sdk/client/payloads/agent/responses.py +43 -0
  85. glaip_sdk/client/run_rendering.py +109 -30
  86. glaip_sdk/client/schedules.py +439 -0
  87. glaip_sdk/client/tools.py +52 -23
  88. glaip_sdk/config/constants.py +22 -2
  89. glaip_sdk/guardrails/__init__.py +80 -0
  90. glaip_sdk/guardrails/serializer.py +91 -0
  91. glaip_sdk/hitl/__init__.py +35 -2
  92. glaip_sdk/hitl/base.py +64 -0
  93. glaip_sdk/hitl/callback.py +43 -0
  94. glaip_sdk/hitl/local.py +1 -31
  95. glaip_sdk/hitl/remote.py +523 -0
  96. glaip_sdk/models/__init__.py +47 -1
  97. glaip_sdk/models/_provider_mappings.py +101 -0
  98. glaip_sdk/models/_validation.py +97 -0
  99. glaip_sdk/models/agent.py +2 -1
  100. glaip_sdk/models/agent_runs.py +2 -1
  101. glaip_sdk/models/constants.py +141 -0
  102. glaip_sdk/models/model.py +170 -0
  103. glaip_sdk/models/schedule.py +224 -0
  104. glaip_sdk/payload_schemas/agent.py +1 -0
  105. glaip_sdk/payload_schemas/guardrails.py +34 -0
  106. glaip_sdk/ptc.py +145 -0
  107. glaip_sdk/registry/tool.py +270 -57
  108. glaip_sdk/runner/__init__.py +20 -3
  109. glaip_sdk/runner/deps.py +4 -1
  110. glaip_sdk/runner/langgraph.py +251 -27
  111. glaip_sdk/runner/logging_config.py +77 -0
  112. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +30 -9
  113. glaip_sdk/runner/ptc_adapter.py +98 -0
  114. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +25 -2
  115. glaip_sdk/schedules/__init__.py +22 -0
  116. glaip_sdk/schedules/base.py +291 -0
  117. glaip_sdk/tools/base.py +67 -14
  118. glaip_sdk/utils/__init__.py +1 -0
  119. glaip_sdk/utils/agent_config.py +8 -2
  120. glaip_sdk/utils/bundler.py +138 -2
  121. glaip_sdk/utils/import_resolver.py +427 -49
  122. glaip_sdk/utils/runtime_config.py +3 -2
  123. glaip_sdk/utils/sync.py +31 -11
  124. glaip_sdk/utils/tool_detection.py +274 -6
  125. {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/METADATA +22 -8
  126. glaip_sdk-0.7.27.dist-info/RECORD +227 -0
  127. {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/WHEEL +1 -1
  128. glaip_sdk-0.7.27.dist-info/entry_points.txt +2 -0
  129. glaip_sdk/cli/commands/agents.py +0 -1509
  130. glaip_sdk/cli/commands/mcps.py +0 -1356
  131. glaip_sdk/cli/commands/tools.py +0 -576
  132. glaip_sdk/cli/utils.py +0 -263
  133. glaip_sdk-0.6.19.dist-info/RECORD +0 -163
  134. glaip_sdk-0.6.19.dist-info/entry_points.txt +0 -2
  135. {glaip_sdk-0.6.19.dist-info → glaip_sdk-0.7.27.dist-info}/top_level.txt +0 -0
@@ -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
- try: # pragma: no cover - optional dependency
31
- from textual import events
32
- from textual.app import App, ComposeResult
33
- from textual.binding import Binding
34
- from textual.containers import Container, Horizontal, Vertical
35
- from textual.screen import ModalScreen
36
- from textual.widgets import Button, Checkbox, DataTable, Footer, Header, Input, LoadingIndicator, Static
37
- except Exception: # pragma: no cover - optional dependency
38
- events = None # type: ignore[assignment]
39
- App = None # type: ignore[assignment]
40
- ComposeResult = None # type: ignore[assignment]
41
- Binding = None # type: ignore[assignment]
42
- Container = None # type: ignore[assignment]
43
- Horizontal = None # type: ignore[assignment]
44
- Vertical = None # type: ignore[assignment]
45
- Button = None # type: ignore[assignment]
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
- url_input = Input(value=self._existing.get("api_url", ""), placeholder="https://api.example.com", id="form-url")
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
- class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - interactive
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._clip_cache: 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._clip_cache = ctx.clipboard
583
+ else:
584
+ terminal = TerminalCapabilities(
585
+ tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
586
+ )
587
+ self._clip_cache = 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._clip_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 _clip_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._clip_cache is not None:
990
+ return self._clip_cache
991
+ adapter = ClipboardAdapter(terminal=ctx.terminal if ctx else None)
992
+ if ctx is not None:
993
+ ctx.clipboard = adapter
994
+ else:
995
+ self._clip_cache = 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,102 @@ 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._callbacks = callbacks
1189
+ self._account_callbacks = callbacks
1190
+ self._ctx = ctx
1191
+ self._keybinds: KeybindRegistry | None = None
1192
+ self._toast_bus: ToastBus | None = None
1193
+ self._clip_cache: ClipboardAdapter | None = None
414
1194
  self._filter_text: str = ""
415
1195
  self._is_switching = False
1196
+ self._initialize_context_services()
1197
+
1198
+ @property
1199
+ def clipboard(self) -> str:
1200
+ """Return clipboard text for Input paste actions."""
1201
+ result = self._clip_adapter().read()
1202
+ if result.success:
1203
+ return result.text
1204
+ return super().clipboard
1205
+
1206
+ @clipboard.setter
1207
+ def clipboard(self, value: str) -> None:
1208
+ setter = App.clipboard.fset
1209
+ if setter is not None:
1210
+ setter(self, value)
416
1211
 
417
1212
  def compose(self) -> ComposeResult:
418
- """Build the Textual layout."""
419
- header_text = self._header_text()
420
- yield Static(header_text, id="header-info")
421
- if self._env_lock:
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
- )
1213
+ """Build the Textual app (empty, screen is pushed on mount)."""
1214
+ # The app itself is empty; AccountsHarlequinScreen is pushed on mount
1215
+ if not TEXTUAL_SUPPORTED or Footer is None:
1216
+ return # type: ignore[return-value]
448
1217
  yield Footer()
449
1218
 
450
1219
  def on_mount(self) -> None:
451
- """Configure table columns and load rows."""
452
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
453
- table.add_column("Name", width=20)
454
- table.add_column("API URL", width=40)
455
- table.add_column("Key (masked)", width=20)
456
- table.add_column("Status", width=14)
457
- table.cursor_type = "row"
458
- table.zebra_stripes = True
459
- table.styles.height = "1fr" # Fill available space below the filter
460
- table.styles.margin = 0
461
- self._reload_rows()
462
- table.focus()
463
- # Keep the filter tight to the table
464
- main = self.query_one(Vertical)
465
- main.styles.gap = 0
466
- self._update_filter_button_visibility()
1220
+ """Push the Harlequin accounts screen on mount."""
1221
+ self._apply_theme()
1222
+ harlequin_screen = AccountsHarlequinScreen(
1223
+ rows=self._all_rows,
1224
+ active_account=self._active_account,
1225
+ env_lock=self._env_lock,
1226
+ callbacks=self._account_callbacks,
1227
+ ctx=self._ctx,
1228
+ )
1229
+ self.push_screen(harlequin_screen)
1230
+
1231
+ def _initialize_context_services(self) -> None:
1232
+ def _notify(message: ToastBus.Changed) -> None:
1233
+ self.post_message(message)
1234
+
1235
+ if self._ctx:
1236
+ if self._ctx.keybinds is None:
1237
+ self._ctx.keybinds = KeybindRegistry()
1238
+ if self._ctx.toasts is None and ToastBus is not None:
1239
+ self._ctx.toasts = ToastBus(on_change=_notify)
1240
+ if self._ctx.clipboard is None:
1241
+ self._ctx.clipboard = ClipboardAdapter(terminal=self._ctx.terminal)
1242
+ self._keybinds = self._ctx.keybinds
1243
+ self._toast_bus = self._ctx.toasts
1244
+ self._clip_cache = self._ctx.clipboard
1245
+ else:
1246
+ # Fallback: create services independently when ctx is None
1247
+ terminal = TerminalCapabilities(
1248
+ tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
1249
+ )
1250
+ self._clip_cache = ClipboardAdapter(terminal=terminal)
1251
+ if ToastBus is not None:
1252
+ self._toast_bus = ToastBus(on_change=_notify)
1253
+
1254
+ def _prepare_toasts(self) -> None:
1255
+ """Prepare toast system by clearing any existing toasts."""
1256
+ if self._toast_bus:
1257
+ self._toast_bus.clear()
1258
+
1259
+ def _register_keybinds(self) -> None:
1260
+ if not self._keybinds:
1261
+ return
1262
+ for keybind_def in KEYBIND_DEFINITIONS:
1263
+ scoped_action = f"{KEYBIND_SCOPE}.{keybind_def.action}"
1264
+ if self._keybinds.get(scoped_action):
1265
+ continue
1266
+ try:
1267
+ self._keybinds.register(
1268
+ action=scoped_action,
1269
+ key=keybind_def.key,
1270
+ description=keybind_def.description,
1271
+ category=KEYBIND_CATEGORY,
1272
+ )
1273
+ except ValueError as e:
1274
+ # Expected: duplicate registration (already registered by another component)
1275
+ # Silently skip to allow multiple apps to register same keybinds
1276
+ logging.debug(f"Skipping duplicate keybind registration: {scoped_action}", exc_info=e)
1277
+ continue
467
1278
 
468
1279
  def _header_text(self) -> str:
469
1280
  """Build header text with active account and host."""
@@ -488,16 +1299,32 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
488
1299
 
489
1300
  def action_focus_filter(self) -> None:
490
1301
  """Focus the filter input and clear previous text."""
491
- filter_input = self.query_one(FILTER_INPUT_ID, Input)
492
- filter_input.value = self._filter_text
493
- filter_input.focus()
1302
+ # Skip if Harlequin screen is active (it handles its own filter focus)
1303
+ if isinstance(self.screen, AccountsHarlequinScreen):
1304
+ return
1305
+ try:
1306
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1307
+ filter_input.value = self._filter_text
1308
+ filter_input.focus()
1309
+ except Exception:
1310
+ # Filter input doesn't exist, skip
1311
+ pass
494
1312
 
495
1313
  def action_switch_row(self) -> None:
496
- """Switch to the currently selected account."""
1314
+ """Switch to the currently selected account.
1315
+
1316
+ Note: This action is for the old table layout. When using HarlequinScreen,
1317
+ the screen handles switching directly. This gracefully skips if the
1318
+ old table doesn't exist.
1319
+ """
1320
+ try:
1321
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1322
+ except Exception:
1323
+ # Harlequin screen is active, let it handle the action
1324
+ return
497
1325
  if self._env_lock:
498
1326
  self._set_status("Switching disabled: env credentials in use.", "yellow")
499
1327
  return
500
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
501
1328
  if table.cursor_row is None:
502
1329
  self._set_status("No account selected.", "yellow")
503
1330
  return
@@ -519,37 +1346,80 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
519
1346
  self._queue_switch(name)
520
1347
 
521
1348
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
1349
+ """Handle row selection by triggering switch."""
1350
+ self._handle_table_click(self._event_row(event))
1351
+
1352
+ def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: # type: ignore[override]
522
1353
  """Handle mouse click selection by triggering switch."""
523
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1354
+ self._handle_table_click(self._event_row(event))
1355
+
1356
+ def _event_row(self, event: object) -> int | None:
1357
+ """Extract the row index from a DataTable event."""
1358
+ row = getattr(event, "cursor_row", None)
1359
+ if row is not None:
1360
+ return int(row)
1361
+ coordinate = getattr(event, "coordinate", None)
1362
+ return getattr(coordinate, "row", None) if coordinate is not None else None
1363
+
1364
+ def _handle_table_click(self, row: int | None) -> None:
1365
+ """Move the cursor to a clicked row and trigger the switch action."""
1366
+ if row is None:
1367
+ return
1368
+ try:
1369
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1370
+ except Exception:
1371
+ # Harlequin screen is active, let it handle the action
1372
+ return
524
1373
  try:
525
1374
  # Move cursor to clicked row then switch
526
- table.cursor_coordinate = (event.cursor_row, 0)
1375
+ table.cursor_coordinate = Coordinate(row, 0)
527
1376
  except Exception:
528
1377
  return
529
1378
  self.action_switch_row()
530
1379
 
531
1380
  def on_input_submitted(self, event: Input.Submitted) -> None:
532
1381
  """Apply filter when user presses Enter inside filter input."""
1382
+ # Skip if a screen other than the default app screen is active (e.g., Harlequin or Modal)
1383
+ if self.screen.id != "_default":
1384
+ return
1385
+
533
1386
  self._filter_text = (event.value or "").strip()
534
1387
  self._reload_rows()
535
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
536
- table.focus()
1388
+ try:
1389
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1390
+ table.focus()
1391
+ except Exception:
1392
+ pass
537
1393
  self._update_filter_button_visibility()
538
1394
 
539
1395
  def on_input_changed(self, event: Input.Changed) -> None:
540
1396
  """Apply filter live as the user types."""
1397
+ # Skip if a screen other than the default app screen is active (e.g., Harlequin or Modal)
1398
+ if self.screen.id != "_default":
1399
+ return
541
1400
  self._filter_text = (event.value or "").strip()
542
1401
  self._reload_rows()
543
1402
  self._update_filter_button_visibility()
544
1403
 
545
1404
  def _reload_rows(self, preferred_name: str | None = None) -> None:
546
1405
  """Refresh table rows based on current filter/active state."""
1406
+ # Skip if Harlequin screen is active (it handles its own reloading)
1407
+ try:
1408
+ if isinstance(self.screen, AccountsHarlequinScreen):
1409
+ return
1410
+ except Exception: # pragma: no cover - defensive (e.g., ScreenStackError in tests)
1411
+ # App not fully initialized or no screen pushed, continue with normal reload
1412
+ pass
547
1413
  # Work on a copy to avoid mutating the backing rows list
548
1414
  rows_copy = [dict(row) for row in self._all_rows]
549
1415
  for row in rows_copy:
550
1416
  row["active"] = row.get("name") == self._active_account
551
1417
 
552
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1418
+ try:
1419
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1420
+ except Exception:
1421
+ # Harlequin screen is active, skip
1422
+ return
553
1423
  table.clear()
554
1424
  filtered = self._filtered_rows(rows_copy)
555
1425
  for row in filtered:
@@ -626,11 +1496,36 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
626
1496
  """Hide the loading indicator."""
627
1497
  hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
628
1498
 
1499
+ def _handle_switch_scheduling_error(self, exc: Exception) -> None:
1500
+ """Handle errors when scheduling the switch task fails.
1501
+
1502
+ Args:
1503
+ exc: The exception that occurred during task scheduling.
1504
+ """
1505
+ self._hide_loading()
1506
+ self._is_switching = False
1507
+ error_msg = f"Switch failed to start: {exc}"
1508
+ if self._toast_bus:
1509
+ self._toast_bus.show(message=error_msg, variant="error")
1510
+ try:
1511
+ self._set_status(error_msg, "red")
1512
+ except Exception:
1513
+ # App not mounted yet, status update not possible
1514
+ logging.error(error_msg, exc_info=exc)
1515
+ logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
1516
+
629
1517
  def _clear_filter(self) -> None:
630
1518
  """Clear the filter input and reset filter state."""
631
- filter_input = self.query_one(FILTER_INPUT_ID, Input)
632
- filter_input.value = ""
633
- self._filter_text = ""
1519
+ # Skip if Harlequin screen is active (it handles its own filtering)
1520
+ if isinstance(self.screen, AccountsHarlequinScreen):
1521
+ return
1522
+ try:
1523
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1524
+ filter_input.value = ""
1525
+ self._filter_text = ""
1526
+ except Exception:
1527
+ # Filter input doesn't exist, just clear the text
1528
+ self._filter_text = ""
634
1529
  self._update_filter_button_visibility()
635
1530
 
636
1531
  def _queue_switch(self, name: str) -> None:
@@ -638,7 +1533,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
638
1533
 
639
1534
  async def perform() -> None:
640
1535
  try:
641
- switched, message = await asyncio.to_thread(self._callbacks.switch_account, name)
1536
+ switched, message = await asyncio.to_thread(self._account_callbacks.switch_account, name)
642
1537
  except Exception as exc: # pragma: no cover - defensive
643
1538
  self._set_status(f"Switch failed: {exc}", "red")
644
1539
  return
@@ -648,7 +1543,9 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
648
1543
 
649
1544
  if switched:
650
1545
  self._active_account = name
651
- self._set_status(message or f"Switched to '{name}'.", "green")
1546
+ status_msg = message or f"Switched to '{name}'."
1547
+ if self._toast_bus:
1548
+ self._toast_bus.show(message=status_msg, variant="success")
652
1549
  self._update_header()
653
1550
  self._reload_rows()
654
1551
  else:
@@ -657,11 +1554,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
657
1554
  try:
658
1555
  self.track_task(perform(), logger=logging.getLogger(__name__))
659
1556
  except Exception as exc:
660
- # If scheduling the task fails, clear loading/switching state and surface the error.
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)
1557
+ self._handle_switch_scheduling_error(exc)
665
1558
 
666
1559
  def _update_header(self) -> None:
667
1560
  """Refresh header text to reflect active/lock state."""
@@ -673,15 +1566,26 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
673
1566
 
674
1567
  UX note: helps users reset the list without leaving the TUI.
675
1568
  """
676
- filter_input = self.query_one(FILTER_INPUT_ID, Input)
677
- if filter_input.has_focus:
678
- # Clear when there is text; otherwise just move focus back to the table
679
- if filter_input.value or self._filter_text:
680
- self._clear_filter()
681
- self._reload_rows()
682
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
683
- table.focus()
1569
+ # Skip if Harlequin screen is active (it handles its own exit)
1570
+ if isinstance(self.screen, AccountsHarlequinScreen):
1571
+ self.exit()
684
1572
  return
1573
+ try:
1574
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1575
+ if filter_input.has_focus:
1576
+ # Clear when there is text; otherwise just move focus back to the table
1577
+ if filter_input.value or self._filter_text:
1578
+ self._clear_filter()
1579
+ self._reload_rows()
1580
+ try:
1581
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1582
+ table.focus()
1583
+ except Exception:
1584
+ pass
1585
+ return
1586
+ except Exception:
1587
+ # Filter input doesn't exist, just exit
1588
+ pass
685
1589
  self.exit()
686
1590
 
687
1591
  def action_app_exit(self) -> None:
@@ -752,6 +1656,66 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
752
1656
  return
753
1657
  self.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
754
1658
 
1659
+ def action_copy_account(self) -> None:
1660
+ """Copy selected account name and URL to clipboard."""
1661
+ name = self._get_selected_name()
1662
+ if not name:
1663
+ self._set_status("Select an account to copy.", "yellow")
1664
+ return
1665
+
1666
+ account = self._store.get_account(name)
1667
+ if not account:
1668
+ return
1669
+
1670
+ text = f"Account: {name}\nURL: {account.get('api_url', '')}"
1671
+ adapter = self._clip_adapter()
1672
+ writer = self._osc52_writer()
1673
+ if writer:
1674
+ result = adapter.copy(text, writer=writer)
1675
+ else:
1676
+ result = adapter.copy(text)
1677
+ self._handle_copy_result(name, result)
1678
+
1679
+ def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
1680
+ """Update UI state after a copy attempt."""
1681
+ if result.success:
1682
+ if self._toast_bus:
1683
+ self._toast_bus.copy_success(f"Account '{name}'")
1684
+ self._set_status(f"Copied '{name}' to clipboard.", "green")
1685
+ else:
1686
+ if self._toast_bus and ToastVariant is not None:
1687
+ self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
1688
+ self._set_status(f"Copy failed: {result.message}", "red")
1689
+
1690
+ def _clip_adapter(self) -> ClipboardAdapter:
1691
+ if self._ctx is not None and self._ctx.clipboard is not None:
1692
+ return cast(ClipboardAdapter, self._ctx.clipboard)
1693
+ if self._clip_cache is not None:
1694
+ return self._clip_cache
1695
+ adapter = ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
1696
+ if self._ctx is not None:
1697
+ self._ctx.clipboard = adapter
1698
+ else:
1699
+ self._clip_cache = adapter
1700
+ return adapter
1701
+
1702
+ def _osc52_writer(self) -> Callable[[str], Any] | None:
1703
+ try:
1704
+ console = getattr(self, "console", None)
1705
+ except Exception:
1706
+ return None
1707
+ if console is None:
1708
+ return None
1709
+ output = getattr(console, "file", None)
1710
+ if output is None:
1711
+ return None
1712
+
1713
+ def _write(sequence: str, _output: Any = output) -> None:
1714
+ _output.write(sequence)
1715
+ _output.flush()
1716
+
1717
+ return _write
1718
+
755
1719
  def _check_env_lock_hotkey(self) -> bool:
756
1720
  """Prevent mutations when env credentials are present."""
757
1721
  if not self._is_env_locked():
@@ -871,6 +1835,33 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
871
1835
 
872
1836
  def _update_filter_button_visibility(self) -> None:
873
1837
  """Show clear button only when filter has content."""
874
- filter_input = self.query_one(FILTER_INPUT_ID, Input)
875
- clear_btn = self.query_one("#filter-clear", Button)
876
- clear_btn.display = bool(filter_input.value or self._filter_text)
1838
+ # Skip if Harlequin screen is active (it doesn't have this button)
1839
+ if isinstance(self.screen, AccountsHarlequinScreen):
1840
+ return
1841
+ try:
1842
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1843
+ clear_btn = self.query_one("#filter-clear", Button)
1844
+ clear_btn.display = bool(filter_input.value or self._filter_text)
1845
+ except Exception:
1846
+ # Filter input or clear button doesn't exist, skip
1847
+ pass
1848
+
1849
+ def _apply_theme(self) -> None:
1850
+ """Register built-in themes and set the active one from context."""
1851
+ if not self._ctx or not self._ctx.theme or Theme is None:
1852
+ return
1853
+
1854
+ for name, tokens in _BUILTIN_THEMES.items():
1855
+ self.register_theme(
1856
+ Theme(
1857
+ name=name,
1858
+ primary=tokens.primary,
1859
+ secondary=tokens.secondary,
1860
+ accent=tokens.accent,
1861
+ warning=tokens.warning,
1862
+ error=tokens.error,
1863
+ success=tokens.success,
1864
+ )
1865
+ )
1866
+
1867
+ self.theme = self._ctx.theme.theme_name