glaip-sdk 0.6.5b6__py3-none-any.whl → 0.7.12__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 (127) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. glaip_sdk/agents/base.py +217 -42
  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 +119 -0
  8. glaip_sdk/cli/commands/agents/_common.py +561 -0
  9. glaip_sdk/cli/commands/agents/create.py +151 -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 +15 -12
  17. glaip_sdk/cli/commands/configure.py +2 -3
  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/core/output.py +12 -7
  46. glaip_sdk/cli/entrypoint.py +20 -0
  47. glaip_sdk/cli/main.py +127 -39
  48. glaip_sdk/cli/pager.py +3 -3
  49. glaip_sdk/cli/resolution.py +2 -1
  50. glaip_sdk/cli/slash/accounts_controller.py +112 -32
  51. glaip_sdk/cli/slash/agent_session.py +5 -2
  52. glaip_sdk/cli/slash/prompt.py +11 -0
  53. glaip_sdk/cli/slash/remote_runs_controller.py +3 -1
  54. glaip_sdk/cli/slash/session.py +369 -23
  55. glaip_sdk/cli/slash/tui/__init__.py +26 -1
  56. glaip_sdk/cli/slash/tui/accounts.tcss +79 -5
  57. glaip_sdk/cli/slash/tui/accounts_app.py +1027 -88
  58. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  59. glaip_sdk/cli/slash/tui/context.py +87 -0
  60. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  61. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  62. glaip_sdk/cli/slash/tui/layouts/harlequin.py +160 -0
  63. glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
  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 +374 -0
  70. glaip_sdk/cli/transcript/history.py +1 -1
  71. glaip_sdk/cli/transcript/viewer.py +5 -3
  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 +50 -8
  78. glaip_sdk/client/hitl.py +136 -0
  79. glaip_sdk/client/main.py +7 -1
  80. glaip_sdk/client/mcps.py +44 -13
  81. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  82. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +22 -47
  83. glaip_sdk/client/payloads/agent/responses.py +43 -0
  84. glaip_sdk/client/run_rendering.py +414 -3
  85. glaip_sdk/client/schedules.py +439 -0
  86. glaip_sdk/client/tools.py +57 -26
  87. glaip_sdk/guardrails/__init__.py +80 -0
  88. glaip_sdk/guardrails/serializer.py +89 -0
  89. glaip_sdk/hitl/__init__.py +48 -0
  90. glaip_sdk/hitl/base.py +64 -0
  91. glaip_sdk/hitl/callback.py +43 -0
  92. glaip_sdk/hitl/local.py +121 -0
  93. glaip_sdk/hitl/remote.py +523 -0
  94. glaip_sdk/models/__init__.py +17 -0
  95. glaip_sdk/models/agent_runs.py +2 -1
  96. glaip_sdk/models/schedule.py +224 -0
  97. glaip_sdk/payload_schemas/agent.py +1 -0
  98. glaip_sdk/payload_schemas/guardrails.py +34 -0
  99. glaip_sdk/registry/tool.py +273 -59
  100. glaip_sdk/runner/__init__.py +20 -3
  101. glaip_sdk/runner/deps.py +5 -8
  102. glaip_sdk/runner/langgraph.py +318 -42
  103. glaip_sdk/runner/logging_config.py +77 -0
  104. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +104 -5
  105. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +72 -7
  106. glaip_sdk/schedules/__init__.py +22 -0
  107. glaip_sdk/schedules/base.py +291 -0
  108. glaip_sdk/tools/base.py +67 -14
  109. glaip_sdk/utils/__init__.py +1 -0
  110. glaip_sdk/utils/bundler.py +138 -2
  111. glaip_sdk/utils/import_resolver.py +43 -11
  112. glaip_sdk/utils/rendering/renderer/base.py +58 -0
  113. glaip_sdk/utils/runtime_config.py +15 -12
  114. glaip_sdk/utils/sync.py +31 -11
  115. glaip_sdk/utils/tool_detection.py +274 -6
  116. glaip_sdk/utils/tool_storage_provider.py +140 -0
  117. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/METADATA +49 -37
  118. glaip_sdk-0.7.12.dist-info/RECORD +219 -0
  119. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.12.dist-info}/WHEEL +2 -1
  120. glaip_sdk-0.7.12.dist-info/entry_points.txt +2 -0
  121. glaip_sdk-0.7.12.dist-info/top_level.txt +1 -0
  122. glaip_sdk/cli/commands/agents.py +0 -1509
  123. glaip_sdk/cli/commands/mcps.py +0 -1356
  124. glaip_sdk/cli/commands/tools.py +0 -576
  125. glaip_sdk/cli/utils.py +0 -263
  126. glaip_sdk-0.6.5b6.dist-info/RECORD +0 -159
  127. glaip_sdk-0.6.5b6.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,7 +28,22 @@ 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.keybind_registry import KeybindRegistry
34
+ from glaip_sdk.cli.slash.tui.layouts.harlequin import HarlequinScreen
26
35
  from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
36
+ from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
37
+ from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
38
+
39
+ try: # pragma: no cover - optional dependency
40
+ from glaip_sdk.cli.slash.tui.toast import ClipboardToastMixin, Toast, ToastBus, ToastHandlerMixin, ToastVariant
41
+ except Exception: # pragma: no cover - optional dependency
42
+ ClipboardToastMixin = object # type: ignore[assignment, misc]
43
+ Toast = None # type: ignore[assignment]
44
+ ToastBus = None # type: ignore[assignment]
45
+ ToastHandlerMixin = object # type: ignore[assignment, misc]
46
+ ToastVariant = None # type: ignore[assignment]
27
47
  from glaip_sdk.cli.validators import validate_api_key
28
48
  from glaip_sdk.utils.validation import validate_url
29
49
 
@@ -33,6 +53,7 @@ try: # pragma: no cover - optional dependency
33
53
  from textual.binding import Binding
34
54
  from textual.containers import Container, Horizontal, Vertical
35
55
  from textual.screen import ModalScreen
56
+ from textual.suggester import SuggestFromList
36
57
  from textual.widgets import Button, Checkbox, DataTable, Footer, Header, Input, LoadingIndicator, Static
37
58
  except Exception: # pragma: no cover - optional dependency
38
59
  events = None # type: ignore[assignment]
@@ -51,6 +72,14 @@ except Exception: # pragma: no cover - optional dependency
51
72
  LoadingIndicator = None # type: ignore[assignment]
52
73
  ModalScreen = None # type: ignore[assignment]
53
74
  Static = None # type: ignore[assignment]
75
+ SuggestFromList = None # type: ignore[assignment]
76
+ Theme = None # type: ignore[assignment]
77
+
78
+ if App is not None:
79
+ try: # pragma: no cover - optional dependency
80
+ from textual.theme import Theme
81
+ except Exception: # pragma: no cover - optional dependency
82
+ Theme = None # type: ignore[assignment]
54
83
 
55
84
  TEXTUAL_SUPPORTED = App is not None and DataTable is not None
56
85
 
@@ -74,6 +103,30 @@ FORM_KEY_ID = "#form-key"
74
103
  # CSS file name
75
104
  CSS_FILE_NAME = "accounts.tcss"
76
105
 
106
+ KEYBIND_SCOPE = "accounts"
107
+ KEYBIND_CATEGORY = "Accounts"
108
+
109
+
110
+ @dataclass
111
+ class KeybindDef:
112
+ """Keybind definition with action, key, and description."""
113
+
114
+ action: str
115
+ key: str
116
+ description: str
117
+
118
+
119
+ KEYBIND_DEFINITIONS: tuple[KeybindDef, ...] = (
120
+ KeybindDef("switch_row", "enter", "Switch"),
121
+ KeybindDef("focus_filter", "/", "Filter"),
122
+ KeybindDef("add_account", "a", "Add"),
123
+ KeybindDef("edit_account", "e", "Edit"),
124
+ KeybindDef("delete_account", "d", "Delete"),
125
+ KeybindDef("copy_account", "c", "Copy"),
126
+ KeybindDef("clear_or_exit", "escape", "Close"),
127
+ KeybindDef("app_exit", "q", "Close"),
128
+ )
129
+
77
130
 
78
131
  @dataclass
79
132
  class AccountsTUICallbacks:
@@ -201,11 +254,12 @@ def run_accounts_textual(
201
254
  active_account: str | None,
202
255
  env_lock: bool,
203
256
  callbacks: AccountsTUICallbacks,
257
+ ctx: TUIContext | None = None,
204
258
  ) -> None:
205
259
  """Launch the Textual accounts browser if dependencies are available."""
206
260
  if not TEXTUAL_SUPPORTED:
207
261
  return
208
- app = AccountsTextualApp(rows, active_account, env_lock, callbacks)
262
+ app = AccountsTextualApp(rows, active_account, env_lock, callbacks, ctx=ctx)
209
263
  app.run()
210
264
 
211
265
 
@@ -239,6 +293,27 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
239
293
  self._connection_tester = connection_tester
240
294
  self._validate_name = validate_name
241
295
 
296
+ def _get_api_url_suggestions(self, _value: str) -> list[str]:
297
+ """Get API URL suggestions from existing accounts.
298
+
299
+ Args:
300
+ _value: Current input value (unused, but required by Textual's suggestor API).
301
+
302
+ Returns:
303
+ List of unique API URLs from existing accounts.
304
+ """
305
+ try:
306
+ store = get_account_store()
307
+ accounts = store.list_accounts()
308
+ # Extract unique API URLs, excluding the current account's URL in edit mode
309
+ existing_url = self._existing.get("api_url", "")
310
+ urls = {account.get("api_url", "") for account in accounts.values() if account.get("api_url")}
311
+ if existing_url in urls:
312
+ urls.remove(existing_url)
313
+ return sorted(urls)
314
+ except Exception: # pragma: no cover - defensive
315
+ return []
316
+
242
317
  def compose(self) -> ComposeResult:
243
318
  """Render the form controls."""
244
319
  title = "Add account" if self._mode == "add" else "Edit account"
@@ -248,7 +323,17 @@ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
248
323
  id="form-name",
249
324
  disabled=self._mode == "edit",
250
325
  )
251
- url_input = Input(value=self._existing.get("api_url", ""), placeholder="https://api.example.com", id="form-url")
326
+ # Get API URL suggestions and create suggester
327
+ url_suggestions = self._get_api_url_suggestions("")
328
+ url_suggester = None
329
+ if SuggestFromList and url_suggestions:
330
+ url_suggester = SuggestFromList(url_suggestions, case_sensitive=False)
331
+ url_input = Input(
332
+ value=self._existing.get("api_url", ""),
333
+ placeholder="https://api.example.com",
334
+ id="form-url",
335
+ suggester=url_suggester,
336
+ )
252
337
  key_input = Input(value="", placeholder="sk-...", password=True, id="form-key")
253
338
  test_checkbox = Checkbox(
254
339
  "Test connection before save",
@@ -374,21 +459,688 @@ class ConfirmDeleteModal(_ConfirmDeleteBase): # pragma: no cover - interactive
374
459
  self.dismiss(self._name)
375
460
 
376
461
 
377
- class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - interactive
462
+ # Widget IDs for Harlequin layout
463
+ HARLEQUIN_ACCOUNTS_LIST_ID = "#harlequin-accounts-list"
464
+ HARLEQUIN_DETAIL_ID = "#harlequin-detail"
465
+ HARLEQUIN_DETAIL_URL_ID = "#harlequin-detail-url"
466
+ HARLEQUIN_DETAIL_KEY_ID = "#harlequin-detail-key"
467
+ HARLEQUIN_DETAIL_STATUS_ID = "#harlequin-detail-status"
468
+ HARLEQUIN_DETAIL_ACTIONS_ID = "#harlequin-detail-actions"
469
+
470
+
471
+ class AccountsHarlequinScreen( # pragma: no cover - interactive
472
+ ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, HarlequinScreen
473
+ ):
474
+ """Harlequin layout screen for account management.
475
+
476
+ Implements Phase 1 of the TUI Harlequin Layout spec:
477
+ - Left pane (25%): Account Profile names list
478
+ - Right pane (75%): URL, API Key (hidden by default), Connection Status, Action Palette
479
+ """
480
+
481
+ CSS_PATH = CSS_FILE_NAME
482
+
483
+ BINDINGS = [
484
+ Binding("enter", "switch_account", "Switch", show=True) if Binding else None,
485
+ Binding("return", "switch_account", "Switch", show=False) if Binding else None,
486
+ Binding("/", "focus_filter", "Filter", show=True) if Binding else None,
487
+ Binding("a", "add_account", "Add", show=True) if Binding else None,
488
+ Binding("e", "edit_account", "Edit", show=True) if Binding else None,
489
+ Binding("d", "delete_account", "Delete", show=True) if Binding else None,
490
+ Binding("c", "copy_account", "Copy", show=True) if Binding else None,
491
+ Binding("escape", "clear_or_exit", "Close", priority=True) if Binding else None,
492
+ Binding("q", "app_exit", "Close", priority=True) if Binding else None,
493
+ ]
494
+ BINDINGS = [b for b in BINDINGS if b is not None]
495
+
496
+ def __init__(
497
+ self,
498
+ rows: list[dict[str, str | bool]],
499
+ active_account: str | None,
500
+ env_lock: bool,
501
+ callbacks: AccountsTUICallbacks,
502
+ ctx: TUIContext | None = None,
503
+ ) -> None:
504
+ """Initialize the Harlequin accounts screen.
505
+
506
+ Args:
507
+ rows: Account data rows to display.
508
+ active_account: Name of the currently active account.
509
+ env_lock: Whether environment credentials are locking account switching.
510
+ callbacks: Callbacks for account switching operations.
511
+ ctx: Shared TUI context.
512
+ """
513
+ super().__init__(ctx=ctx)
514
+ self._store = get_account_store()
515
+ self._all_rows = rows
516
+ self._active_account = active_account
517
+ self._env_lock = env_lock
518
+ self._account_callbacks = callbacks
519
+ self._keybinds: KeybindRegistry | None = None
520
+ self._toast_bus: ToastBus | None = None
521
+ self._clipboard: ClipboardAdapter | None = None
522
+ self._filter_text: str = ""
523
+ self._is_switching = False
524
+ self._selected_account: dict[str, str | bool] | None = None
525
+ self._key_visible = False
526
+ self._initialize_context_services()
527
+
528
+ def compose(self) -> ComposeResult: # type: ignore[return]
529
+ """Compose the Harlequin layout with account list and detail panes."""
530
+ if not TEXTUAL_SUPPORTED or Horizontal is None or Vertical is None or Container is None:
531
+ return # type: ignore[return-value]
532
+
533
+ # Main container with horizontal split (25/75)
534
+ with Horizontal(id="harlequin-container"):
535
+ # Left pane (25% width) with account list
536
+ with Vertical(id="left-pane"):
537
+ yield Static("Accounts", id="left-pane-title")
538
+ yield Input(placeholder="Filter...", id="harlequin-filter")
539
+ yield DataTable(id=HARLEQUIN_ACCOUNTS_LIST_ID.lstrip("#"))
540
+ # Right pane (75% width) with account details
541
+ with Vertical(id="right-pane"):
542
+ yield Static("Account Details", id="right-pane-title")
543
+ yield Static("", id=HARLEQUIN_DETAIL_ID.lstrip("#"))
544
+ with Vertical(id="detail-fields"):
545
+ yield Static("URL:", classes="detail-label")
546
+ yield Static("", id=HARLEQUIN_DETAIL_URL_ID.lstrip("#"))
547
+ yield Static("API Key:", classes="detail-label")
548
+ yield Static("", id=HARLEQUIN_DETAIL_KEY_ID.lstrip("#"))
549
+ yield Static("Status:", classes="detail-label")
550
+ yield Static("", id=HARLEQUIN_DETAIL_STATUS_ID.lstrip("#"))
551
+ with Horizontal(id=HARLEQUIN_DETAIL_ACTIONS_ID.lstrip("#")):
552
+ yield Button("(a) Add", id="action-add")
553
+ yield Button("(e) Edit", id="action-edit")
554
+ yield Button("(d) Delete", id="action-delete")
555
+ yield Button("(c) Copy", id="action-copy")
556
+ yield Static("", id="harlequin-status")
557
+ # Help text showing keyboard shortcuts at the bottom
558
+ yield Static(
559
+ "[dim]↑↓ Navigate | Enter Switch | a Add | e Edit | d Delete | c Copy | q/Esc Exit[/dim]",
560
+ id="help-text",
561
+ )
562
+
563
+ # Toast container for notifications
564
+ if Toast is not None:
565
+ yield Container(Toast(), id="toast-container")
566
+
567
+ def on_mount(self) -> None:
568
+ """Configure the screen after mount."""
569
+ if not TEXTUAL_SUPPORTED:
570
+ return
571
+
572
+ self._apply_theme()
573
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
574
+ table.add_column("Account", width=None)
575
+ table.cursor_type = "row"
576
+ table.zebra_stripes = True
577
+ self._reload_accounts_list()
578
+ table.focus()
579
+ self._prepare_toasts()
580
+ self._register_keybinds()
581
+ self._update_detail_pane()
582
+
583
+ def _initialize_context_services(self) -> None:
584
+ """Initialize TUI context services."""
585
+
586
+ def _notify(message: ToastBus.Changed) -> None:
587
+ self.post_message(message)
588
+
589
+ ctx = self.ctx if hasattr(self, "ctx") else self._ctx
590
+ if ctx:
591
+ if ctx.keybinds is None:
592
+ ctx.keybinds = KeybindRegistry()
593
+ if ctx.toasts is None and ToastBus is not None:
594
+ ctx.toasts = ToastBus(on_change=_notify)
595
+ if ctx.clipboard is None:
596
+ ctx.clipboard = ClipboardAdapter(terminal=ctx.terminal)
597
+ self._keybinds = ctx.keybinds
598
+ self._toast_bus = ctx.toasts
599
+ self._clipboard = ctx.clipboard
600
+ else:
601
+ terminal = TerminalCapabilities(
602
+ tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
603
+ )
604
+ self._clipboard = ClipboardAdapter(terminal=terminal)
605
+ if ToastBus is not None:
606
+ self._toast_bus = ToastBus(on_change=_notify)
607
+
608
+ def _prepare_toasts(self) -> None:
609
+ """Prepare toast system."""
610
+ if self._toast_bus:
611
+ self._toast_bus.clear()
612
+
613
+ def _register_keybinds(self) -> None:
614
+ """Register keybinds with the registry."""
615
+ if not self._keybinds:
616
+ return
617
+ for keybind_def in KEYBIND_DEFINITIONS:
618
+ scoped_action = f"{KEYBIND_SCOPE}.{keybind_def.action}"
619
+ if self._keybinds.get(scoped_action):
620
+ continue
621
+ try:
622
+ self._keybinds.register(
623
+ action=scoped_action,
624
+ key=keybind_def.key,
625
+ description=keybind_def.description,
626
+ category=KEYBIND_CATEGORY,
627
+ )
628
+ except ValueError as e:
629
+ logging.debug(f"Skipping duplicate keybind registration: {scoped_action}", exc_info=e)
630
+ continue
631
+
632
+ def _reload_accounts_list(self, preferred_name: str | None = None) -> None:
633
+ """Reload the accounts list in the left pane."""
634
+ if not TEXTUAL_SUPPORTED:
635
+ return
636
+
637
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
638
+ table.clear()
639
+
640
+ filtered = self._filtered_rows()
641
+ for row in filtered:
642
+ name = str(row.get("name", ""))
643
+ # Highlight active account
644
+ if row.get("name") == self._active_account:
645
+ name = f"[green]●[/] {name}"
646
+ table.add_row(name)
647
+
648
+ # Move cursor to active or preferred account
649
+ cursor_idx = 0
650
+ target_name = preferred_name or self._active_account
651
+ for idx, row in enumerate(filtered):
652
+ if row.get("name") == target_name:
653
+ cursor_idx = idx
654
+ break
655
+
656
+ if filtered:
657
+ table.cursor_coordinate = (cursor_idx, 0)
658
+ self._update_selected_account(filtered[cursor_idx] if cursor_idx < len(filtered) else None)
659
+ else:
660
+ self._update_selected_account(None)
661
+ self._set_status("No accounts match the current filter.", "yellow")
662
+
663
+ def _filtered_rows(self) -> list[dict[str, str | bool]]:
664
+ """Return filtered account rows."""
665
+ if not self._filter_text:
666
+ return list(self._all_rows)
667
+
668
+ needle = self._filter_text.lower()
669
+ filtered = [
670
+ row
671
+ for row in self._all_rows
672
+ if needle in str(row.get("name", "")).lower() or needle in str(row.get("api_url", "")).lower()
673
+ ]
674
+
675
+ def score(row: dict[str, str | bool]) -> tuple[int, str]:
676
+ name = str(row.get("name", "")).lower()
677
+ url = str(row.get("api_url", "")).lower()
678
+ name_hit = needle in name
679
+ url_hit = needle in url
680
+ priority = 0 if name_hit else (1 if url_hit else 2)
681
+ return (priority, name)
682
+
683
+ return sorted(filtered, key=score)
684
+
685
+ def _update_selected_account(self, account: dict[str, str | bool] | None) -> None:
686
+ """Update the selected account and refresh detail pane."""
687
+ self._selected_account = account
688
+ self._update_detail_pane()
689
+
690
+ def _update_detail_pane(self) -> None:
691
+ """Update the right pane with selected account details."""
692
+ if not TEXTUAL_SUPPORTED:
693
+ return
694
+
695
+ if not self._selected_account:
696
+ detail = self.query_one(HARLEQUIN_DETAIL_ID, Static)
697
+ detail.update("[dim]Select an account to view details[/]")
698
+ url_widget = self.query_one(HARLEQUIN_DETAIL_URL_ID, Static)
699
+ url_widget.update("")
700
+ key_widget = self.query_one(HARLEQUIN_DETAIL_KEY_ID, Static)
701
+ key_widget.update("")
702
+ status_widget = self.query_one(HARLEQUIN_DETAIL_STATUS_ID, Static)
703
+ status_widget.update("")
704
+ return
705
+
706
+ account = self._selected_account
707
+ name = str(account.get("name", ""))
708
+ url = str(account.get("api_url", ""))
709
+ masked_key = str(account.get("masked_key", ""))
710
+ api_key = str(account.get("api_key", ""))
711
+
712
+ # Update detail header
713
+ detail = self.query_one(HARLEQUIN_DETAIL_ID, Static)
714
+ detail.update(f"[bold]{name}[/]")
715
+
716
+ # Update URL
717
+ url_widget = self.query_one(HARLEQUIN_DETAIL_URL_ID, Static)
718
+ url_widget.update(url)
719
+
720
+ # Update API Key (hidden by default, toggle with button)
721
+ key_widget = self.query_one(HARLEQUIN_DETAIL_KEY_ID, Static)
722
+ if self._key_visible and api_key:
723
+ key_widget.update(api_key)
724
+ else:
725
+ key_widget.update(masked_key)
726
+
727
+ # Update Status
728
+ row_for_status = dict(account)
729
+ row_for_status["active"] = row_for_status.get("name") == self._active_account
730
+ status_str = build_account_status_string(row_for_status, use_markup=True)
731
+ status_widget = self.query_one(HARLEQUIN_DETAIL_STATUS_ID, Static)
732
+ status_widget.update(status_str)
733
+
734
+ def _set_status(self, message: str, style: str) -> None:
735
+ """Update status message."""
736
+ if not TEXTUAL_SUPPORTED:
737
+ return
738
+ status = self.query_one("#harlequin-status", Static)
739
+ status.update(f"[{style}]{message}[/]")
740
+
741
+ def _get_selected_name(self) -> str | None:
742
+ """Get the name of the currently selected account."""
743
+ if not TEXTUAL_SUPPORTED or not self._selected_account:
744
+ return None
745
+ return str(self._selected_account.get("name", ""))
746
+
747
+ def action_switch_account(self) -> None:
748
+ """Switch to the currently selected account."""
749
+ if self._env_lock:
750
+ self._set_status("Switching disabled: env credentials in use.", "yellow")
751
+ return
752
+
753
+ # Ensure account is selected from cursor position if not explicitly selected
754
+ if not self._selected_account:
755
+ try:
756
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
757
+ cursor_row = table.cursor_row
758
+ if cursor_row is not None and cursor_row >= 0:
759
+ filtered = self._filtered_rows()
760
+ if cursor_row < len(filtered):
761
+ self._update_selected_account(filtered[cursor_row])
762
+ except Exception:
763
+ pass
764
+
765
+ name = self._get_selected_name()
766
+ if not name:
767
+ self._set_status("No account selected.", "yellow")
768
+ return
769
+
770
+ if self._is_switching:
771
+ self._set_status("Already switching...", "yellow")
772
+ return
773
+
774
+ self._is_switching = True
775
+ host = self._get_host_for_name(name)
776
+ message = f"Connecting to '{name}' ({host})..." if host else f"Connecting to '{name}'..."
777
+ self._set_status(message, "cyan")
778
+ self._queue_switch(name)
779
+
780
+ def _get_host_for_name(self, name: str | None) -> str | None:
781
+ """Return shortened API URL for a given account name."""
782
+ if not name:
783
+ return None
784
+ for row in self._all_rows:
785
+ if row.get("name") == name:
786
+ url = str(row.get("api_url", ""))
787
+ return url if len(url) <= 40 else f"{url[:37]}..."
788
+ return None
789
+
790
+ def _queue_switch(self, name: str) -> None:
791
+ """Run switch in background."""
792
+
793
+ async def perform() -> None:
794
+ try:
795
+ switched, message = await asyncio.to_thread(self._account_callbacks.switch_account, name)
796
+ except Exception as exc:
797
+ self._set_status(f"Switch failed: {exc}", "red")
798
+ return
799
+ finally:
800
+ self._is_switching = False
801
+
802
+ if switched:
803
+ # Refresh active account from store to ensure consistency
804
+ self._active_account = self._store.get_active_account() or name
805
+ status_msg = message or f"Switched to '{name}'."
806
+ if self._toast_bus:
807
+ self._toast_bus.show(message=status_msg, variant="success")
808
+ self._set_status(status_msg, "green")
809
+ # Reload accounts list to update green indicator
810
+ self._reload_accounts_list(preferred_name=name)
811
+ self._update_detail_pane()
812
+ else:
813
+ self._set_status(message or "Switch failed; kept previous account.", "yellow")
814
+
815
+ try:
816
+ self.track_task(perform(), logger=logging.getLogger(__name__))
817
+ except Exception as exc:
818
+ self._is_switching = False
819
+ self._set_status(f"Switch failed to start: {exc}", "red")
820
+
821
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
822
+ """Handle row selection in the accounts list."""
823
+ if not TEXTUAL_SUPPORTED:
824
+ return
825
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
826
+ try:
827
+ table.cursor_coordinate = (event.cursor_row, 0)
828
+ except Exception:
829
+ return
830
+ filtered = self._filtered_rows()
831
+ if event.cursor_row < len(filtered):
832
+ self._update_selected_account(filtered[event.cursor_row])
833
+ if not self._is_switching:
834
+ self.action_switch_account()
835
+
836
+ def on_data_table_cursor_row_changed(self, event: DataTable.CursorRowChanged) -> None: # type: ignore[override]
837
+ """Handle cursor movement in the accounts list."""
838
+ if not TEXTUAL_SUPPORTED:
839
+ return
840
+ filtered = self._filtered_rows()
841
+ if event.cursor_row is not None and event.cursor_row < len(filtered):
842
+ self._update_selected_account(filtered[event.cursor_row])
843
+
844
+ def action_focus_filter(self) -> None:
845
+ """Focus the filter input."""
846
+ if not TEXTUAL_SUPPORTED:
847
+ return
848
+ filter_input = self.query_one("#harlequin-filter", Input)
849
+ filter_input.value = self._filter_text
850
+ filter_input.focus()
851
+
852
+ def on_input_changed(self, event: Input.Changed) -> None:
853
+ """Handle filter input changes."""
854
+ if not TEXTUAL_SUPPORTED:
855
+ return
856
+ if event.input.id == "harlequin-filter":
857
+ self._filter_text = (event.value or "").strip()
858
+ self._reload_accounts_list()
859
+
860
+ def action_add_account(self) -> None:
861
+ """Open add account modal."""
862
+ if self._check_env_lock():
863
+ return
864
+ existing_names = {str(row.get("name", "")) for row in self._all_rows}
865
+ modal = AccountFormModal(
866
+ mode="add",
867
+ existing=None,
868
+ existing_names=existing_names,
869
+ connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
870
+ validate_name=self._store.validate_account_name,
871
+ )
872
+ self.app.push_screen(modal, self._on_form_result)
873
+
874
+ def action_edit_account(self) -> None:
875
+ """Open edit account modal."""
876
+ if self._check_env_lock():
877
+ return
878
+ name = self._get_selected_name()
879
+ if not name:
880
+ self._set_status("Select an account to edit.", "yellow")
881
+ return
882
+ account = self._store.get_account(name)
883
+ if not account:
884
+ self._set_status(f"Account '{name}' not found.", "red")
885
+ return
886
+ existing_names = {str(row.get("name", "")) for row in self._all_rows if str(row.get("name", "")) != name}
887
+ modal = AccountFormModal(
888
+ mode="edit",
889
+ existing={"name": name, "api_url": account.get("api_url", ""), "api_key": account.get("api_key", "")},
890
+ existing_names=existing_names,
891
+ connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
892
+ validate_name=self._store.validate_account_name,
893
+ )
894
+ self.app.push_screen(modal, self._on_form_result)
895
+
896
+ def action_delete_account(self) -> None:
897
+ """Open delete confirmation modal."""
898
+ if self._check_env_lock():
899
+ return
900
+ name = self._get_selected_name()
901
+ if not name:
902
+ self._set_status("Select an account to delete.", "yellow")
903
+ return
904
+ accounts = self._store.list_accounts()
905
+ if len(accounts) <= 1:
906
+ self._set_status("Cannot remove the last remaining account.", "red")
907
+ return
908
+ self.app.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
909
+
910
+ def _ensure_account_selected_from_cursor(self) -> None:
911
+ """Ensure an account is selected, using cursor position if needed."""
912
+ if self._selected_account:
913
+ return
914
+ try:
915
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
916
+ cursor_row = table.cursor_row
917
+ if cursor_row is not None and cursor_row >= 0:
918
+ row = table.get_row_at(cursor_row)
919
+ if row:
920
+ account_name = str(row[0])
921
+ # Find the account data
922
+ for account_data in self._all_rows:
923
+ if str(account_data.get("name", "")) == account_name:
924
+ self._selected_account = account_data
925
+ self._update_detail_pane()
926
+ break
927
+ except Exception:
928
+ pass
929
+
930
+ def action_copy_account(self) -> None:
931
+ """Copy selected account to clipboard."""
932
+ # Get account from cursor position if not explicitly selected
933
+ self._ensure_account_selected_from_cursor()
934
+
935
+ name = self._get_selected_name()
936
+ if not name:
937
+ self._set_status("Select an account to copy.", "yellow")
938
+ return
939
+
940
+ account = self._store.get_account(name)
941
+ if not account:
942
+ return
943
+
944
+ text = f"Account: {name}\nURL: {account.get('api_url', '')}"
945
+ adapter = self._clipboard_adapter()
946
+ writer = self._osc52_writer()
947
+ if writer:
948
+ result = adapter.copy(text, writer=writer)
949
+ else:
950
+ result = adapter.copy(text)
951
+ self._handle_copy_result(name, result)
952
+
953
+ def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
954
+ """Handle copy operation result."""
955
+ if result.success:
956
+ if self._toast_bus:
957
+ self._toast_bus.copy_success(f"Account '{name}'")
958
+ self._set_status(f"Copied '{name}' to clipboard.", "green")
959
+ else:
960
+ if self._toast_bus and ToastVariant is not None:
961
+ self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
962
+ self._set_status(f"Copy failed: {result.message}", "red")
963
+
964
+ def _clipboard_adapter(self) -> ClipboardAdapter:
965
+ """Get clipboard adapter."""
966
+ ctx = self.ctx if hasattr(self, "ctx") else getattr(self, "_ctx", None)
967
+ if ctx is not None and ctx.clipboard is not None:
968
+ return cast(ClipboardAdapter, ctx.clipboard)
969
+ if self._clipboard is not None:
970
+ return self._clipboard
971
+ adapter = ClipboardAdapter(terminal=ctx.terminal if ctx else None)
972
+ if ctx is not None:
973
+ ctx.clipboard = adapter
974
+ else:
975
+ self._clipboard = adapter
976
+ return adapter
977
+
978
+ def _osc52_writer(self) -> Callable[[str], Any] | None:
979
+ """Get OSC52 writer if available."""
980
+ try:
981
+ console = getattr(self, "console", None)
982
+ except Exception:
983
+ return None
984
+ if console is None:
985
+ return None
986
+ output = getattr(console, "file", None)
987
+ if output is None:
988
+ return None
989
+
990
+ def _write(sequence: str, _output=output) -> None:
991
+ _output.write(sequence)
992
+ _output.flush()
993
+
994
+ return _write
995
+
996
+ def _check_env_lock(self) -> bool:
997
+ """Check if env lock prevents mutations."""
998
+ if not self._is_env_locked():
999
+ return False
1000
+ self._env_lock = True
1001
+ self._set_status("Disabled by env-lock.", "yellow")
1002
+ self._refresh_rows()
1003
+ return True
1004
+
1005
+ def _is_env_locked(self) -> bool:
1006
+ """Check if environment credentials are locking operations."""
1007
+ return env_credentials_present(partial=True)
1008
+
1009
+ def _on_form_result(self, payload: dict[str, Any] | None) -> None:
1010
+ """Handle add/edit modal result."""
1011
+ if payload is None:
1012
+ self._set_status("Edit/add cancelled.", "yellow")
1013
+ return
1014
+ self._save_account(payload)
1015
+
1016
+ def _on_delete_result(self, confirmed_name: str | None) -> None:
1017
+ """Handle delete confirmation result."""
1018
+ if not confirmed_name:
1019
+ self._set_status("Delete cancelled.", "yellow")
1020
+ return
1021
+ try:
1022
+ self._store.remove_account(confirmed_name)
1023
+ except AccountStoreError as exc:
1024
+ self._set_status(f"Delete failed: {exc}", "red")
1025
+ return
1026
+ except Exception as exc:
1027
+ self._set_status(f"Unexpected delete error: {exc}", "red")
1028
+ return
1029
+
1030
+ self._set_status(f"Account '{confirmed_name}' deleted.", "green")
1031
+ self._refresh_rows()
1032
+
1033
+ def _save_account(self, payload: dict[str, Any]) -> None:
1034
+ """Save account from modal payload."""
1035
+ if self._is_env_locked():
1036
+ self._set_status("Disabled by env-lock.", "yellow")
1037
+ return
1038
+
1039
+ name = str(payload.get("name", ""))
1040
+ api_url = str(payload.get("api_url", ""))
1041
+ api_key = str(payload.get("api_key", ""))
1042
+ set_active = bool(payload.get("set_active", payload.get("mode") == "add"))
1043
+ is_edit = payload.get("mode") == "edit"
1044
+
1045
+ try:
1046
+ self._store.add_account(name, api_url, api_key, overwrite=is_edit)
1047
+ except AccountStoreError as exc:
1048
+ self._set_status(f"Save failed: {exc}", "red")
1049
+ return
1050
+ except Exception as exc:
1051
+ self._set_status(f"Unexpected save error: {exc}", "red")
1052
+ return
1053
+
1054
+ if set_active:
1055
+ try:
1056
+ self._store.set_active_account(name)
1057
+ self._active_account = name
1058
+ except Exception as exc:
1059
+ self._set_status(f"Saved but could not set active: {exc}", "yellow")
1060
+ else:
1061
+ if self._toast_bus:
1062
+ self._toast_bus.show(message=f"Switched to '{name}'", variant="success")
1063
+
1064
+ self._set_status(f"Account '{name}' saved.", "green")
1065
+ self._refresh_rows(preferred_name=name)
1066
+
1067
+ def _refresh_rows(self, preferred_name: str | None = None) -> None:
1068
+ """Refresh account rows from store."""
1069
+ self._env_lock = self._is_env_locked()
1070
+ self._all_rows, self._active_account = _build_account_rows_from_store(self._store, self._env_lock)
1071
+ self._reload_accounts_list(preferred_name=preferred_name)
1072
+ if self._selected_account:
1073
+ # Refresh selected account details
1074
+ name = str(self._selected_account.get("name", ""))
1075
+ for row in self._all_rows:
1076
+ if row.get("name") == name:
1077
+ self._update_selected_account(row)
1078
+ break
1079
+
1080
+ def action_clear_or_exit(self) -> None:
1081
+ """Clear filter or exit."""
1082
+ if not TEXTUAL_SUPPORTED:
1083
+ return
1084
+ filter_input = self.query_one("#harlequin-filter", Input)
1085
+ if filter_input.has_focus:
1086
+ if filter_input.value or self._filter_text:
1087
+ filter_input.value = ""
1088
+ self._filter_text = ""
1089
+ self._reload_accounts_list()
1090
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
1091
+ table.focus()
1092
+ return
1093
+ self.dismiss()
1094
+
1095
+ def action_app_exit(self) -> None:
1096
+ """Exit the application."""
1097
+ self.dismiss()
1098
+
1099
+ def _apply_theme(self) -> None:
1100
+ """Apply theme from context."""
1101
+ ctx = self.ctx if hasattr(self, "ctx") else getattr(self, "_ctx", None)
1102
+ if not ctx or not ctx.theme or Theme is None:
1103
+ return
1104
+
1105
+ app = self.app
1106
+ if app is None:
1107
+ return
1108
+
1109
+ for name, tokens in _BUILTIN_THEMES.items():
1110
+ app.register_theme(
1111
+ Theme(
1112
+ name=name,
1113
+ primary=tokens.primary,
1114
+ secondary=tokens.secondary,
1115
+ accent=tokens.accent,
1116
+ warning=tokens.warning,
1117
+ error=tokens.error,
1118
+ success=tokens.success,
1119
+ )
1120
+ )
1121
+
1122
+ app.theme = ctx.theme.theme_name
1123
+
1124
+
1125
+ class AccountsTextualApp( # pragma: no cover - interactive
1126
+ ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, _AppBase
1127
+ ):
378
1128
  """Textual application for browsing accounts."""
379
1129
 
380
1130
  CSS_PATH = CSS_FILE_NAME
381
1131
  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),
1132
+ Binding("enter", "switch_row", "Switch", show=True) if Binding else None,
1133
+ Binding("return", "switch_row", "Switch", show=False) if Binding else None,
1134
+ Binding("/", "focus_filter", "Filter", show=True) if Binding else None,
1135
+ Binding("a", "add_account", "Add", show=True) if Binding else None,
1136
+ Binding("e", "edit_account", "Edit", show=True) if Binding else None,
1137
+ Binding("d", "delete_account", "Delete", show=True) if Binding else None,
1138
+ Binding("c", "copy_account", "Copy", show=True) if Binding else None,
388
1139
  # 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),
1140
+ Binding("escape", "clear_or_exit", "Close", priority=True) if Binding else None,
1141
+ Binding("q", "app_exit", "Close", priority=True) if Binding else None,
391
1142
  ]
1143
+ BINDINGS = [b for b in BINDINGS if b is not None]
392
1144
 
393
1145
  def __init__(
394
1146
  self,
@@ -396,6 +1148,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
396
1148
  active_account: str | None,
397
1149
  env_lock: bool,
398
1150
  callbacks: AccountsTUICallbacks,
1151
+ ctx: TUIContext | None = None,
399
1152
  ) -> None:
400
1153
  """Initialize the Textual accounts app.
401
1154
 
@@ -404,66 +1157,88 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
404
1157
  active_account: Name of the currently active account.
405
1158
  env_lock: Whether environment credentials are locking account switching.
406
1159
  callbacks: Callbacks for account switching operations.
1160
+ ctx: Shared TUI context.
407
1161
  """
408
1162
  super().__init__()
409
1163
  self._store = get_account_store()
410
1164
  self._all_rows = rows
411
1165
  self._active_account = active_account
412
1166
  self._env_lock = env_lock
413
- self._callbacks = callbacks
1167
+ self._account_callbacks = callbacks
1168
+ self._ctx = ctx
1169
+ self._keybinds: KeybindRegistry | None = None
1170
+ self._toast_bus: ToastBus | None = None
1171
+ self._clipboard: ClipboardAdapter | None = None
414
1172
  self._filter_text: str = ""
415
1173
  self._is_switching = False
1174
+ self._initialize_context_services()
416
1175
 
417
1176
  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
- )
1177
+ """Build the Textual app (empty, screen is pushed on mount)."""
1178
+ # The app itself is empty; AccountsHarlequinScreen is pushed on mount
1179
+ if not TEXTUAL_SUPPORTED or Footer is None:
1180
+ return # type: ignore[return-value]
448
1181
  yield Footer()
449
1182
 
450
1183
  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()
1184
+ """Push the Harlequin accounts screen on mount."""
1185
+ self._apply_theme()
1186
+ harlequin_screen = AccountsHarlequinScreen(
1187
+ rows=self._all_rows,
1188
+ active_account=self._active_account,
1189
+ env_lock=self._env_lock,
1190
+ callbacks=self._account_callbacks,
1191
+ ctx=self._ctx,
1192
+ )
1193
+ self.push_screen(harlequin_screen)
1194
+
1195
+ def _initialize_context_services(self) -> None:
1196
+ def _notify(message: ToastBus.Changed) -> None:
1197
+ self.post_message(message)
1198
+
1199
+ if self._ctx:
1200
+ if self._ctx.keybinds is None:
1201
+ self._ctx.keybinds = KeybindRegistry()
1202
+ if self._ctx.toasts is None and ToastBus is not None:
1203
+ self._ctx.toasts = ToastBus(on_change=_notify)
1204
+ if self._ctx.clipboard is None:
1205
+ self._ctx.clipboard = ClipboardAdapter(terminal=self._ctx.terminal)
1206
+ self._keybinds = self._ctx.keybinds
1207
+ self._toast_bus = self._ctx.toasts
1208
+ self._clipboard = self._ctx.clipboard
1209
+ else:
1210
+ # Fallback: create services independently when ctx is None
1211
+ terminal = TerminalCapabilities(
1212
+ tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
1213
+ )
1214
+ self._clipboard = ClipboardAdapter(terminal=terminal)
1215
+ if ToastBus is not None:
1216
+ self._toast_bus = ToastBus(on_change=_notify)
1217
+
1218
+ def _prepare_toasts(self) -> None:
1219
+ """Prepare toast system by clearing any existing toasts."""
1220
+ if self._toast_bus:
1221
+ self._toast_bus.clear()
1222
+
1223
+ def _register_keybinds(self) -> None:
1224
+ if not self._keybinds:
1225
+ return
1226
+ for keybind_def in KEYBIND_DEFINITIONS:
1227
+ scoped_action = f"{KEYBIND_SCOPE}.{keybind_def.action}"
1228
+ if self._keybinds.get(scoped_action):
1229
+ continue
1230
+ try:
1231
+ self._keybinds.register(
1232
+ action=scoped_action,
1233
+ key=keybind_def.key,
1234
+ description=keybind_def.description,
1235
+ category=KEYBIND_CATEGORY,
1236
+ )
1237
+ except ValueError as e:
1238
+ # Expected: duplicate registration (already registered by another component)
1239
+ # Silently skip to allow multiple apps to register same keybinds
1240
+ logging.debug(f"Skipping duplicate keybind registration: {scoped_action}", exc_info=e)
1241
+ continue
467
1242
 
468
1243
  def _header_text(self) -> str:
469
1244
  """Build header text with active account and host."""
@@ -488,16 +1263,32 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
488
1263
 
489
1264
  def action_focus_filter(self) -> None:
490
1265
  """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()
1266
+ # Skip if Harlequin screen is active (it handles its own filter focus)
1267
+ if isinstance(self.screen, AccountsHarlequinScreen):
1268
+ return
1269
+ try:
1270
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1271
+ filter_input.value = self._filter_text
1272
+ filter_input.focus()
1273
+ except Exception:
1274
+ # Filter input doesn't exist, skip
1275
+ pass
494
1276
 
495
1277
  def action_switch_row(self) -> None:
496
- """Switch to the currently selected account."""
1278
+ """Switch to the currently selected account.
1279
+
1280
+ Note: This action is for the old table layout. When using HarlequinScreen,
1281
+ the screen handles switching directly. This gracefully skips if the
1282
+ old table doesn't exist.
1283
+ """
1284
+ try:
1285
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1286
+ except Exception:
1287
+ # Harlequin screen is active, let it handle the action
1288
+ return
497
1289
  if self._env_lock:
498
1290
  self._set_status("Switching disabled: env credentials in use.", "yellow")
499
1291
  return
500
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
501
1292
  if table.cursor_row is None:
502
1293
  self._set_status("No account selected.", "yellow")
503
1294
  return
@@ -519,8 +1310,17 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
519
1310
  self._queue_switch(name)
520
1311
 
521
1312
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
522
- """Handle mouse click selection by triggering switch."""
523
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1313
+ """Handle mouse click selection by triggering switch.
1314
+
1315
+ Note: This handler is for the old table layout. When using HarlequinScreen,
1316
+ the screen handles row selection directly. This handler gracefully skips
1317
+ if the old table doesn't exist.
1318
+ """
1319
+ try:
1320
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1321
+ except Exception:
1322
+ # Harlequin screen is active, let it handle the event
1323
+ return
524
1324
  try:
525
1325
  # Move cursor to clicked row then switch
526
1326
  table.cursor_coordinate = (event.cursor_row, 0)
@@ -538,18 +1338,32 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
538
1338
 
539
1339
  def on_input_changed(self, event: Input.Changed) -> None:
540
1340
  """Apply filter live as the user types."""
1341
+ # Skip if Harlequin screen is active (it handles its own filtering)
1342
+ if isinstance(self.screen, AccountsHarlequinScreen):
1343
+ return
541
1344
  self._filter_text = (event.value or "").strip()
542
1345
  self._reload_rows()
543
1346
  self._update_filter_button_visibility()
544
1347
 
545
1348
  def _reload_rows(self, preferred_name: str | None = None) -> None:
546
1349
  """Refresh table rows based on current filter/active state."""
1350
+ # Skip if Harlequin screen is active (it handles its own reloading)
1351
+ try:
1352
+ if isinstance(self.screen, AccountsHarlequinScreen):
1353
+ return
1354
+ except Exception: # pragma: no cover - defensive (e.g., ScreenStackError in tests)
1355
+ # App not fully initialized or no screen pushed, continue with normal reload
1356
+ pass
547
1357
  # Work on a copy to avoid mutating the backing rows list
548
1358
  rows_copy = [dict(row) for row in self._all_rows]
549
1359
  for row in rows_copy:
550
1360
  row["active"] = row.get("name") == self._active_account
551
1361
 
552
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1362
+ try:
1363
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1364
+ except Exception:
1365
+ # Harlequin screen is active, skip
1366
+ return
553
1367
  table.clear()
554
1368
  filtered = self._filtered_rows(rows_copy)
555
1369
  for row in filtered:
@@ -626,11 +1440,36 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
626
1440
  """Hide the loading indicator."""
627
1441
  hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
628
1442
 
1443
+ def _handle_switch_scheduling_error(self, exc: Exception) -> None:
1444
+ """Handle errors when scheduling the switch task fails.
1445
+
1446
+ Args:
1447
+ exc: The exception that occurred during task scheduling.
1448
+ """
1449
+ self._hide_loading()
1450
+ self._is_switching = False
1451
+ error_msg = f"Switch failed to start: {exc}"
1452
+ if self._toast_bus:
1453
+ self._toast_bus.show(message=error_msg, variant="error")
1454
+ try:
1455
+ self._set_status(error_msg, "red")
1456
+ except Exception:
1457
+ # App not mounted yet, status update not possible
1458
+ logging.error(error_msg, exc_info=exc)
1459
+ logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
1460
+
629
1461
  def _clear_filter(self) -> None:
630
1462
  """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 = ""
1463
+ # Skip if Harlequin screen is active (it handles its own filtering)
1464
+ if isinstance(self.screen, AccountsHarlequinScreen):
1465
+ return
1466
+ try:
1467
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1468
+ filter_input.value = ""
1469
+ self._filter_text = ""
1470
+ except Exception:
1471
+ # Filter input doesn't exist, just clear the text
1472
+ self._filter_text = ""
634
1473
  self._update_filter_button_visibility()
635
1474
 
636
1475
  def _queue_switch(self, name: str) -> None:
@@ -638,7 +1477,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
638
1477
 
639
1478
  async def perform() -> None:
640
1479
  try:
641
- switched, message = await asyncio.to_thread(self._callbacks.switch_account, name)
1480
+ switched, message = await asyncio.to_thread(self._account_callbacks.switch_account, name)
642
1481
  except Exception as exc: # pragma: no cover - defensive
643
1482
  self._set_status(f"Switch failed: {exc}", "red")
644
1483
  return
@@ -648,7 +1487,9 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
648
1487
 
649
1488
  if switched:
650
1489
  self._active_account = name
651
- self._set_status(message or f"Switched to '{name}'.", "green")
1490
+ status_msg = message or f"Switched to '{name}'."
1491
+ if self._toast_bus:
1492
+ self._toast_bus.show(message=status_msg, variant="success")
652
1493
  self._update_header()
653
1494
  self._reload_rows()
654
1495
  else:
@@ -657,11 +1498,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
657
1498
  try:
658
1499
  self.track_task(perform(), logger=logging.getLogger(__name__))
659
1500
  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)
1501
+ self._handle_switch_scheduling_error(exc)
665
1502
 
666
1503
  def _update_header(self) -> None:
667
1504
  """Refresh header text to reflect active/lock state."""
@@ -673,15 +1510,30 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
673
1510
 
674
1511
  UX note: helps users reset the list without leaving the TUI.
675
1512
  """
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()
1513
+ # Skip if Harlequin screen is active (it handles its own exit)
1514
+ if isinstance(self.screen, AccountsHarlequinScreen):
1515
+ self.exit()
684
1516
  return
1517
+ try:
1518
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1519
+ if filter_input.has_focus:
1520
+ # Clear when there is text; otherwise just move focus back to the table
1521
+ if filter_input.value or self._filter_text:
1522
+ self._clear_filter()
1523
+ self._reload_rows()
1524
+ try:
1525
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1526
+ table.focus()
1527
+ except Exception:
1528
+ pass
1529
+ return
1530
+ except Exception:
1531
+ # Filter input doesn't exist, just exit
1532
+ pass
1533
+ self.exit()
1534
+
1535
+ def action_app_exit(self) -> None:
1536
+ """Exit the application regardless of focus state."""
685
1537
  self.exit()
686
1538
 
687
1539
  def on_button_pressed(self, event: Button.Pressed) -> None:
@@ -748,6 +1600,66 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
748
1600
  return
749
1601
  self.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
750
1602
 
1603
+ def action_copy_account(self) -> None:
1604
+ """Copy selected account name and URL to clipboard."""
1605
+ name = self._get_selected_name()
1606
+ if not name:
1607
+ self._set_status("Select an account to copy.", "yellow")
1608
+ return
1609
+
1610
+ account = self._store.get_account(name)
1611
+ if not account:
1612
+ return
1613
+
1614
+ text = f"Account: {name}\nURL: {account.get('api_url', '')}"
1615
+ adapter = self._clipboard_adapter()
1616
+ writer = self._osc52_writer()
1617
+ if writer:
1618
+ result = adapter.copy(text, writer=writer)
1619
+ else:
1620
+ result = adapter.copy(text)
1621
+ self._handle_copy_result(name, result)
1622
+
1623
+ def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
1624
+ """Update UI state after a copy attempt."""
1625
+ if result.success:
1626
+ if self._toast_bus:
1627
+ self._toast_bus.copy_success(f"Account '{name}'")
1628
+ self._set_status(f"Copied '{name}' to clipboard.", "green")
1629
+ else:
1630
+ if self._toast_bus and ToastVariant is not None:
1631
+ self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
1632
+ self._set_status(f"Copy failed: {result.message}", "red")
1633
+
1634
+ def _clipboard_adapter(self) -> ClipboardAdapter:
1635
+ if self._ctx is not None and self._ctx.clipboard is not None:
1636
+ return cast(ClipboardAdapter, self._ctx.clipboard)
1637
+ if self._clipboard is not None:
1638
+ return self._clipboard
1639
+ adapter = ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
1640
+ if self._ctx is not None:
1641
+ self._ctx.clipboard = adapter
1642
+ else:
1643
+ self._clipboard = adapter
1644
+ return adapter
1645
+
1646
+ def _osc52_writer(self) -> Callable[[str], Any] | None:
1647
+ try:
1648
+ console = getattr(self, "console", None)
1649
+ except Exception:
1650
+ return None
1651
+ if console is None:
1652
+ return None
1653
+ output = getattr(console, "file", None)
1654
+ if output is None:
1655
+ return None
1656
+
1657
+ def _write(sequence: str, _output=output) -> None:
1658
+ _output.write(sequence)
1659
+ _output.flush()
1660
+
1661
+ return _write
1662
+
751
1663
  def _check_env_lock_hotkey(self) -> bool:
752
1664
  """Prevent mutations when env credentials are present."""
753
1665
  if not self._is_env_locked():
@@ -867,6 +1779,33 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
867
1779
 
868
1780
  def _update_filter_button_visibility(self) -> None:
869
1781
  """Show clear button only when filter has content."""
870
- filter_input = self.query_one(FILTER_INPUT_ID, Input)
871
- clear_btn = self.query_one("#filter-clear", Button)
872
- clear_btn.display = bool(filter_input.value or self._filter_text)
1782
+ # Skip if Harlequin screen is active (it doesn't have this button)
1783
+ if isinstance(self.screen, AccountsHarlequinScreen):
1784
+ return
1785
+ try:
1786
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1787
+ clear_btn = self.query_one("#filter-clear", Button)
1788
+ clear_btn.display = bool(filter_input.value or self._filter_text)
1789
+ except Exception:
1790
+ # Filter input or clear button doesn't exist, skip
1791
+ pass
1792
+
1793
+ def _apply_theme(self) -> None:
1794
+ """Register built-in themes and set the active one from context."""
1795
+ if not self._ctx or not self._ctx.theme or Theme is None:
1796
+ return
1797
+
1798
+ for name, tokens in _BUILTIN_THEMES.items():
1799
+ self.register_theme(
1800
+ Theme(
1801
+ name=name,
1802
+ primary=tokens.primary,
1803
+ secondary=tokens.secondary,
1804
+ accent=tokens.accent,
1805
+ warning=tokens.warning,
1806
+ error=tokens.error,
1807
+ success=tokens.success,
1808
+ )
1809
+ )
1810
+
1811
+ self.theme = self._ctx.theme.theme_name