glaip-sdk 0.1.2__py3-none-any.whl → 0.7.17__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1413 -0
  5. glaip_sdk/branding.py +126 -2
  6. glaip_sdk/cli/account_store.py +555 -0
  7. glaip_sdk/cli/auth.py +260 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents/__init__.py +116 -0
  11. glaip_sdk/cli/commands/agents/_common.py +562 -0
  12. glaip_sdk/cli/commands/agents/create.py +155 -0
  13. glaip_sdk/cli/commands/agents/delete.py +64 -0
  14. glaip_sdk/cli/commands/agents/get.py +89 -0
  15. glaip_sdk/cli/commands/agents/list.py +129 -0
  16. glaip_sdk/cli/commands/agents/run.py +264 -0
  17. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  18. glaip_sdk/cli/commands/agents/update.py +112 -0
  19. glaip_sdk/cli/commands/common_config.py +104 -0
  20. glaip_sdk/cli/commands/configure.py +728 -113
  21. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  22. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  23. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  24. glaip_sdk/cli/commands/mcps/create.py +152 -0
  25. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  26. glaip_sdk/cli/commands/mcps/get.py +212 -0
  27. glaip_sdk/cli/commands/mcps/list.py +69 -0
  28. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  29. glaip_sdk/cli/commands/mcps/update.py +190 -0
  30. glaip_sdk/cli/commands/models.py +12 -8
  31. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  32. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  33. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  34. glaip_sdk/cli/commands/tools/_common.py +80 -0
  35. glaip_sdk/cli/commands/tools/create.py +228 -0
  36. glaip_sdk/cli/commands/tools/delete.py +61 -0
  37. glaip_sdk/cli/commands/tools/get.py +103 -0
  38. glaip_sdk/cli/commands/tools/list.py +69 -0
  39. glaip_sdk/cli/commands/tools/script.py +49 -0
  40. glaip_sdk/cli/commands/tools/update.py +102 -0
  41. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  42. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  43. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  44. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  45. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  46. glaip_sdk/cli/commands/update.py +163 -17
  47. glaip_sdk/cli/config.py +49 -4
  48. glaip_sdk/cli/constants.py +38 -0
  49. glaip_sdk/cli/context.py +8 -0
  50. glaip_sdk/cli/core/__init__.py +79 -0
  51. glaip_sdk/cli/core/context.py +124 -0
  52. glaip_sdk/cli/core/output.py +851 -0
  53. glaip_sdk/cli/core/prompting.py +649 -0
  54. glaip_sdk/cli/core/rendering.py +187 -0
  55. glaip_sdk/cli/display.py +41 -20
  56. glaip_sdk/cli/entrypoint.py +20 -0
  57. glaip_sdk/cli/hints.py +57 -0
  58. glaip_sdk/cli/io.py +6 -3
  59. glaip_sdk/cli/main.py +340 -143
  60. glaip_sdk/cli/masking.py +21 -33
  61. glaip_sdk/cli/pager.py +12 -13
  62. glaip_sdk/cli/parsers/__init__.py +1 -3
  63. glaip_sdk/cli/resolution.py +2 -1
  64. glaip_sdk/cli/slash/__init__.py +0 -9
  65. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  66. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  67. glaip_sdk/cli/slash/agent_session.py +62 -21
  68. glaip_sdk/cli/slash/prompt.py +21 -0
  69. glaip_sdk/cli/slash/remote_runs_controller.py +568 -0
  70. glaip_sdk/cli/slash/session.py +1105 -153
  71. glaip_sdk/cli/slash/tui/__init__.py +36 -0
  72. glaip_sdk/cli/slash/tui/accounts.tcss +177 -0
  73. glaip_sdk/cli/slash/tui/accounts_app.py +1853 -0
  74. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  75. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  76. glaip_sdk/cli/slash/tui/context.py +92 -0
  77. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  78. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  79. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  80. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  81. glaip_sdk/cli/slash/tui/loading.py +80 -0
  82. glaip_sdk/cli/slash/tui/remote_runs_app.py +760 -0
  83. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  84. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  85. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  86. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  87. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  88. glaip_sdk/cli/slash/tui/toast.py +388 -0
  89. glaip_sdk/cli/transcript/__init__.py +12 -52
  90. glaip_sdk/cli/transcript/cache.py +255 -44
  91. glaip_sdk/cli/transcript/capture.py +66 -1
  92. glaip_sdk/cli/transcript/history.py +815 -0
  93. glaip_sdk/cli/transcript/viewer.py +72 -463
  94. glaip_sdk/cli/tui_settings.py +125 -0
  95. glaip_sdk/cli/update_notifier.py +227 -10
  96. glaip_sdk/cli/validators.py +5 -6
  97. glaip_sdk/client/__init__.py +3 -1
  98. glaip_sdk/client/_schedule_payloads.py +89 -0
  99. glaip_sdk/client/agent_runs.py +147 -0
  100. glaip_sdk/client/agents.py +576 -44
  101. glaip_sdk/client/base.py +26 -0
  102. glaip_sdk/client/hitl.py +136 -0
  103. glaip_sdk/client/main.py +25 -14
  104. glaip_sdk/client/mcps.py +165 -24
  105. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  106. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +63 -47
  107. glaip_sdk/client/payloads/agent/responses.py +43 -0
  108. glaip_sdk/client/run_rendering.py +546 -92
  109. glaip_sdk/client/schedules.py +439 -0
  110. glaip_sdk/client/shared.py +21 -0
  111. glaip_sdk/client/tools.py +206 -32
  112. glaip_sdk/config/constants.py +33 -2
  113. glaip_sdk/guardrails/__init__.py +80 -0
  114. glaip_sdk/guardrails/serializer.py +89 -0
  115. glaip_sdk/hitl/__init__.py +48 -0
  116. glaip_sdk/hitl/base.py +64 -0
  117. glaip_sdk/hitl/callback.py +43 -0
  118. glaip_sdk/hitl/local.py +121 -0
  119. glaip_sdk/hitl/remote.py +523 -0
  120. glaip_sdk/mcps/__init__.py +21 -0
  121. glaip_sdk/mcps/base.py +345 -0
  122. glaip_sdk/models/__init__.py +136 -0
  123. glaip_sdk/models/_provider_mappings.py +101 -0
  124. glaip_sdk/models/_validation.py +97 -0
  125. glaip_sdk/models/agent.py +48 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/constants.py +141 -0
  129. glaip_sdk/models/mcp.py +33 -0
  130. glaip_sdk/models/model.py +170 -0
  131. glaip_sdk/models/schedule.py +224 -0
  132. glaip_sdk/models/tool.py +33 -0
  133. glaip_sdk/payload_schemas/__init__.py +1 -13
  134. glaip_sdk/payload_schemas/agent.py +1 -0
  135. glaip_sdk/payload_schemas/guardrails.py +34 -0
  136. glaip_sdk/registry/__init__.py +55 -0
  137. glaip_sdk/registry/agent.py +164 -0
  138. glaip_sdk/registry/base.py +139 -0
  139. glaip_sdk/registry/mcp.py +253 -0
  140. glaip_sdk/registry/tool.py +445 -0
  141. glaip_sdk/rich_components.py +58 -2
  142. glaip_sdk/runner/__init__.py +76 -0
  143. glaip_sdk/runner/base.py +84 -0
  144. glaip_sdk/runner/deps.py +115 -0
  145. glaip_sdk/runner/langgraph.py +1055 -0
  146. glaip_sdk/runner/logging_config.py +77 -0
  147. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  148. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  149. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  150. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +116 -0
  151. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  152. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  153. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  154. glaip_sdk/schedules/__init__.py +22 -0
  155. glaip_sdk/schedules/base.py +291 -0
  156. glaip_sdk/tools/__init__.py +22 -0
  157. glaip_sdk/tools/base.py +488 -0
  158. glaip_sdk/utils/__init__.py +59 -12
  159. glaip_sdk/utils/a2a/__init__.py +34 -0
  160. glaip_sdk/utils/a2a/event_processor.py +188 -0
  161. glaip_sdk/utils/agent_config.py +8 -2
  162. glaip_sdk/utils/bundler.py +403 -0
  163. glaip_sdk/utils/client.py +111 -0
  164. glaip_sdk/utils/client_utils.py +39 -7
  165. glaip_sdk/utils/datetime_helpers.py +58 -0
  166. glaip_sdk/utils/discovery.py +78 -0
  167. glaip_sdk/utils/display.py +23 -15
  168. glaip_sdk/utils/export.py +143 -0
  169. glaip_sdk/utils/general.py +0 -33
  170. glaip_sdk/utils/import_export.py +12 -7
  171. glaip_sdk/utils/import_resolver.py +524 -0
  172. glaip_sdk/utils/instructions.py +101 -0
  173. glaip_sdk/utils/rendering/__init__.py +115 -1
  174. glaip_sdk/utils/rendering/formatting.py +5 -30
  175. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  176. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  177. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  178. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  179. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  180. glaip_sdk/utils/rendering/models.py +1 -0
  181. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  182. glaip_sdk/utils/rendering/renderer/base.py +299 -1434
  183. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  184. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  185. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  186. glaip_sdk/utils/rendering/renderer/stream.py +4 -33
  187. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  188. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  189. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  190. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  191. glaip_sdk/utils/rendering/state.py +204 -0
  192. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  193. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  194. glaip_sdk/utils/rendering/steps/format.py +176 -0
  195. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  196. glaip_sdk/utils/rendering/timing.py +36 -0
  197. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  198. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  199. glaip_sdk/utils/resource_refs.py +25 -13
  200. glaip_sdk/utils/runtime_config.py +426 -0
  201. glaip_sdk/utils/serialization.py +18 -0
  202. glaip_sdk/utils/sync.py +162 -0
  203. glaip_sdk/utils/tool_detection.py +301 -0
  204. glaip_sdk/utils/tool_storage_provider.py +140 -0
  205. glaip_sdk/utils/validation.py +16 -24
  206. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/METADATA +69 -23
  207. glaip_sdk-0.7.17.dist-info/RECORD +224 -0
  208. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/WHEEL +2 -1
  209. glaip_sdk-0.7.17.dist-info/entry_points.txt +2 -0
  210. glaip_sdk-0.7.17.dist-info/top_level.txt +1 -0
  211. glaip_sdk/cli/commands/agents.py +0 -1369
  212. glaip_sdk/cli/commands/mcps.py +0 -1187
  213. glaip_sdk/cli/commands/tools.py +0 -584
  214. glaip_sdk/cli/utils.py +0 -1278
  215. glaip_sdk/models.py +0 -240
  216. glaip_sdk-0.1.2.dist-info/RECORD +0 -82
  217. glaip_sdk-0.1.2.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,1853 @@
1
+ """Textual UI for the /accounts command.
2
+
3
+ Provides a minimal interactive list with the same columns/order as the Rich
4
+ fallback (name, API URL, masked key, status) and keyboard navigation.
5
+
6
+ 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
+
11
+ Authors:
12
+ Raymond Christopher (raymond.christopher@gdplabs.id)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import logging
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from typing import Any, cast
22
+
23
+ from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
24
+ from glaip_sdk.cli.commands.common_config import check_connection_with_reason
25
+ from glaip_sdk.cli.slash.accounts_shared import (
26
+ build_account_rows,
27
+ build_account_status_string,
28
+ env_credentials_present,
29
+ )
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
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
+ )
48
+ from glaip_sdk.cli.validators import validate_api_key
49
+ from glaip_sdk.utils.validation import validate_url
50
+
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]
67
+
68
+ # Widget IDs for Textual UI
69
+ ACCOUNTS_TABLE_ID = "#accounts-table"
70
+ FILTER_INPUT_ID = "#filter-input"
71
+ STATUS_ID = "#status"
72
+ ACCOUNTS_LOADING_ID = "#accounts-loading"
73
+ FORM_KEY_ID = "#form-key"
74
+
75
+ # CSS file name
76
+ CSS_FILE_NAME = "accounts.tcss"
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
+
102
+
103
+ @dataclass
104
+ class AccountsTUICallbacks:
105
+ """Callbacks invoked by the Textual UI."""
106
+
107
+ switch_account: Callable[[str], tuple[bool, str]]
108
+
109
+
110
+ def _build_account_rows_from_store(
111
+ store: AccountStore,
112
+ env_lock: bool,
113
+ ) -> tuple[list[dict[str, str | bool]], str | None]:
114
+ """Load account rows with masking and active flag."""
115
+ accounts = store.list_accounts()
116
+ active = store.get_active_account()
117
+ rows = build_account_rows(accounts, active, env_lock)
118
+ return rows, active
119
+
120
+
121
+ def _prepare_account_payload(
122
+ *,
123
+ name: str,
124
+ api_url_input: str,
125
+ api_key_input: str,
126
+ existing_url: str | None,
127
+ existing_key: str | None,
128
+ existing_names: set[str],
129
+ mode: str,
130
+ should_test: bool,
131
+ validate_name: Callable[[str], None],
132
+ connection_tester: Callable[[str, str], tuple[bool, str]],
133
+ ) -> tuple[dict[str, Any] | None, str | None]:
134
+ """Validate and build payload for add/edit operations."""
135
+ name = name.strip()
136
+ api_url_raw = api_url_input.strip()
137
+ api_key_raw = api_key_input.strip()
138
+
139
+ error = _validate_account_name(name, existing_names, mode, validate_name)
140
+ if error:
141
+ return None, error
142
+
143
+ api_url_candidate = api_url_raw or (existing_url or "")
144
+ api_key_candidate = api_key_raw or (existing_key or "")
145
+
146
+ api_url_validated, error = _validate_and_prepare_url(api_url_candidate)
147
+ if error:
148
+ return None, error
149
+
150
+ api_key_validated, error = _validate_and_prepare_key(api_key_candidate)
151
+ if error:
152
+ return None, error
153
+
154
+ if should_test:
155
+ error = _test_connection(api_url_validated, api_key_validated, connection_tester)
156
+ if error:
157
+ return None, error
158
+
159
+ payload: dict[str, Any] = {
160
+ "name": name,
161
+ "api_url": api_url_validated,
162
+ "api_key": api_key_validated,
163
+ "should_test": should_test,
164
+ "mode": mode,
165
+ }
166
+ return payload, None
167
+
168
+
169
+ def _validate_account_name(
170
+ name: str,
171
+ existing_names: set[str],
172
+ mode: str,
173
+ validate_name: Callable[[str], None],
174
+ ) -> str | None:
175
+ """Validate account name."""
176
+ if not name:
177
+ return "Account name cannot be empty."
178
+
179
+ try:
180
+ validate_name(name)
181
+ except Exception as exc:
182
+ return str(exc)
183
+
184
+ if mode == "add" and name in existing_names:
185
+ return f"Account '{name}' already exists. Choose a unique name."
186
+
187
+ return None
188
+
189
+
190
+ def _validate_and_prepare_url(api_url_candidate: str) -> tuple[str, str | None]:
191
+ """Validate and prepare API URL."""
192
+ if not api_url_candidate:
193
+ return "", "API URL is required."
194
+ try:
195
+ return validate_url(api_url_candidate), None
196
+ except Exception as exc:
197
+ return "", str(exc)
198
+
199
+
200
+ def _validate_and_prepare_key(api_key_candidate: str) -> tuple[str, str | None]:
201
+ """Validate and prepare API key."""
202
+ if not api_key_candidate:
203
+ return "", "API key is required."
204
+ try:
205
+ return validate_api_key(api_key_candidate), None
206
+ except Exception as exc:
207
+ return "", str(exc)
208
+
209
+
210
+ def _test_connection(
211
+ api_url: str,
212
+ api_key: str,
213
+ connection_tester: Callable[[str, str], tuple[bool, str]],
214
+ ) -> str | None:
215
+ """Test API connection."""
216
+ ok, reason = connection_tester(api_url, api_key)
217
+ if not ok:
218
+ detail = reason or "connection_failed"
219
+ return f"Connection test failed: {detail}"
220
+ return None
221
+
222
+
223
+ def run_accounts_textual(
224
+ rows: list[dict[str, str | bool]],
225
+ *,
226
+ active_account: str | None,
227
+ env_lock: bool,
228
+ callbacks: AccountsTUICallbacks,
229
+ ctx: TUIContext | None = None,
230
+ ) -> None:
231
+ """Launch the Textual accounts browser if dependencies are available."""
232
+ if not TEXTUAL_SUPPORTED:
233
+ return
234
+ app = AccountsTextualApp(rows, active_account, env_lock, callbacks, ctx=ctx)
235
+ app.run()
236
+
237
+
238
+ class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
239
+ """Modal form for add/edit account."""
240
+
241
+ CSS_PATH = CSS_FILE_NAME
242
+
243
+ def __init__(
244
+ self,
245
+ *,
246
+ mode: str,
247
+ existing: dict[str, str] | None,
248
+ existing_names: set[str],
249
+ connection_tester: Callable[[str, str], tuple[bool, str]],
250
+ validate_name: Callable[[str], None],
251
+ ) -> None:
252
+ """Initialize the account form modal.
253
+
254
+ Args:
255
+ mode: Form mode, either "add" or "edit".
256
+ existing: Existing account data for edit mode.
257
+ existing_names: Set of existing account names for validation.
258
+ connection_tester: Callable to test API connection.
259
+ validate_name: Callable to validate account name.
260
+ """
261
+ super().__init__()
262
+ self._mode = mode
263
+ self._existing = existing or {}
264
+ self._existing_names = existing_names
265
+ self._connection_tester = connection_tester
266
+ self._validate_name = validate_name
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
+
289
+ def compose(self) -> ComposeResult:
290
+ """Render the form controls."""
291
+ title = "Add account" if self._mode == "add" else "Edit account"
292
+ name_input = Input(
293
+ value=self._existing.get("name", ""),
294
+ placeholder="account-name",
295
+ id="form-name",
296
+ disabled=self._mode == "edit",
297
+ )
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
+ )
309
+ key_input = Input(value="", placeholder="sk-...", password=True, id="form-key")
310
+ test_checkbox = Checkbox(
311
+ "Test connection before save",
312
+ value=True,
313
+ id="form-test",
314
+ )
315
+ status = Static("", id="form-status")
316
+
317
+ yield Static(title, id="form-title")
318
+ yield Static("Name", classes="form-label")
319
+ yield name_input
320
+ yield Static("API URL", classes="form-label")
321
+ yield url_input
322
+ yield Static("API Key", classes="form-label")
323
+ yield key_input
324
+ yield Horizontal(
325
+ Button("Show key", id="toggle-key"),
326
+ Button("Clear key", id="clear-key"),
327
+ id="form-key-actions",
328
+ )
329
+ yield test_checkbox
330
+ yield Horizontal(
331
+ Button("Save", id="form-save", variant="primary"),
332
+ Button("Cancel", id="form-cancel"),
333
+ id="form-actions",
334
+ )
335
+ yield status
336
+
337
+ def on_button_pressed(self, event: Button.Pressed) -> None:
338
+ """Handle button presses."""
339
+ btn_id = event.button.id or ""
340
+ if btn_id == "form-cancel":
341
+ self.dismiss(None)
342
+ return
343
+ if btn_id == "toggle-key":
344
+ key_input = self.query_one(FORM_KEY_ID, Input)
345
+ key_input.password = not key_input.password
346
+ key_input.focus()
347
+ return
348
+ if btn_id == "clear-key":
349
+ key_input = self.query_one(FORM_KEY_ID, Input)
350
+ key_input.value = ""
351
+ key_input.focus()
352
+ return
353
+ if btn_id == "form-save":
354
+ self._handle_submit()
355
+
356
+ def on_input_submitted(self, _event: Input.Submitted) -> None:
357
+ """Handle Enter key to save."""
358
+ self._handle_submit()
359
+
360
+ def _handle_submit(self) -> None:
361
+ """Validate inputs and dismiss with payload on success."""
362
+ status = self.query_one("#form-status", Static)
363
+ name_input = self.query_one("#form-name", Input)
364
+ url_input = self.query_one("#form-url", Input)
365
+ key_input = self.query_one(FORM_KEY_ID, Input)
366
+ test_checkbox = self.query_one("#form-test", Checkbox)
367
+
368
+ payload, error = _prepare_account_payload(
369
+ name=name_input.value or "",
370
+ api_url_input=url_input.value or "",
371
+ api_key_input=key_input.value or "",
372
+ existing_url=self._existing.get("api_url"),
373
+ existing_key=self._existing.get("api_key"),
374
+ existing_names=self._existing_names,
375
+ mode=self._mode,
376
+ should_test=bool(test_checkbox.value),
377
+ validate_name=self._validate_name,
378
+ connection_tester=self._connection_tester,
379
+ )
380
+ if error:
381
+ status.update(f"[red]{error}[/]")
382
+ if error.startswith("Connection test failed") and hasattr(self.app, "_set_status"):
383
+ try:
384
+ # Surface a status-bar cue so errors remain visible after closing the modal.
385
+ self.app._set_status(error, "yellow") # type: ignore[attr-defined]
386
+ except Exception:
387
+ pass
388
+ return
389
+ status.update("[green]Saving...[/]")
390
+ self.dismiss(payload)
391
+
392
+
393
+ class ConfirmDeleteModal(_ConfirmDeleteBase): # pragma: no cover - interactive
394
+ """Modal requiring typed confirmation for delete."""
395
+
396
+ CSS_PATH = CSS_FILE_NAME
397
+
398
+ def __init__(self, name: str) -> None:
399
+ """Initialize the delete confirmation modal.
400
+
401
+ Args:
402
+ name: Name of the account to delete.
403
+ """
404
+ super().__init__()
405
+ self._name = name
406
+
407
+ def compose(self) -> ComposeResult:
408
+ """Render confirmation form."""
409
+ yield Static(f"Type '{self._name}' to confirm deletion. This cannot be undone.", id="confirm-text")
410
+ yield Input(placeholder=self._name, id="confirm-input")
411
+ yield Horizontal(
412
+ Button("Delete", id="confirm-delete", variant="error"),
413
+ Button("Cancel", id="confirm-cancel"),
414
+ id="confirm-actions",
415
+ )
416
+ yield Static("", id="confirm-status")
417
+
418
+ def on_button_pressed(self, event: Button.Pressed) -> None:
419
+ """Handle confirmation buttons."""
420
+ btn_id = event.button.id or ""
421
+ if btn_id == "confirm-cancel":
422
+ self.dismiss(None)
423
+ return
424
+ if btn_id == "confirm-delete":
425
+ self._handle_confirm()
426
+
427
+ def on_input_submitted(self, _event: Input.Submitted) -> None:
428
+ """Handle Enter key in confirmation input."""
429
+ self._handle_confirm()
430
+
431
+ def _handle_confirm(self) -> None:
432
+ """Dismiss with name when confirmation matches."""
433
+ status = self.query_one("#confirm-status", Static)
434
+ input_widget = self.query_one("#confirm-input", Input)
435
+ if (input_widget.value or "").strip() != self._name:
436
+ status.update(f"[yellow]Name does not match; type '{self._name}' to confirm.[/]")
437
+ input_widget.focus()
438
+ return
439
+ self.dismiss(self._name)
440
+
441
+
442
+ # Widget IDs for Harlequin layout
443
+ HARLEQUIN_ACCOUNTS_LIST_ID = "#harlequin-accounts-list"
444
+ HARLEQUIN_DETAIL_ID = "#harlequin-detail"
445
+ HARLEQUIN_DETAIL_URL_ID = "#harlequin-detail-url"
446
+ HARLEQUIN_DETAIL_KEY_ID = "#harlequin-detail-key"
447
+ HARLEQUIN_DETAIL_STATUS_ID = "#harlequin-detail-status"
448
+ HARLEQUIN_DETAIL_ACTIONS_ID = "#harlequin-detail-actions"
449
+
450
+
451
+ class AccountsHarlequinScreen( # pragma: no cover - interactive
452
+ ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, HarlequinScreen
453
+ ):
454
+ """Harlequin layout screen for account management.
455
+
456
+ Implements Phase 1 of the TUI Harlequin Layout spec:
457
+ - Left pane (25%): Account Profile names list
458
+ - Right pane (75%): URL, API Key (hidden by default), Connection Status, Action Palette
459
+ """
460
+
461
+ CSS_PATH = CSS_FILE_NAME
462
+
463
+ BINDINGS = [
464
+ Binding("enter", "switch_account", "Switch", show=True) if Binding else None,
465
+ Binding("return", "switch_account", "Switch", show=False) if Binding else None,
466
+ Binding("/", "focus_filter", "Filter", show=True) if Binding else None,
467
+ Binding("a", "add_account", "Add", show=True) if Binding else None,
468
+ Binding("e", "edit_account", "Edit", show=True) if Binding else None,
469
+ Binding("d", "delete_account", "Delete", show=True) if Binding else None,
470
+ Binding("c", "copy_account", "Copy", show=True) if Binding else None,
471
+ Binding("escape", "clear_or_exit", "Close", priority=True) if Binding else None,
472
+ Binding("q", "app_exit", "Close", priority=True) if Binding else None,
473
+ ]
474
+ BINDINGS = [b for b in BINDINGS if b is not None]
475
+
476
+ def __init__(
477
+ self,
478
+ rows: list[dict[str, str | bool]],
479
+ active_account: str | None,
480
+ env_lock: bool,
481
+ callbacks: AccountsTUICallbacks,
482
+ ctx: TUIContext | None = None,
483
+ ) -> None:
484
+ """Initialize the Harlequin accounts screen.
485
+
486
+ Args:
487
+ rows: Account data rows to display.
488
+ active_account: Name of the currently active account.
489
+ env_lock: Whether environment credentials are locking account switching.
490
+ callbacks: Callbacks for account switching operations.
491
+ ctx: Shared TUI context.
492
+ """
493
+ super().__init__(ctx=ctx)
494
+ self._ctx = ctx
495
+ self._store = get_account_store()
496
+ self._all_rows = rows
497
+ self._active_account = active_account
498
+ self._env_lock = env_lock
499
+ self._account_callbacks = callbacks
500
+ self._keybinds: KeybindRegistry | None = None
501
+ self._toast_bus: ToastBus | None = None
502
+ self._clipboard: ClipboardAdapter | None = None
503
+ self._filter_text: str = ""
504
+ self._is_switching = False
505
+ self._selected_account: dict[str, str | bool] | None = None
506
+ self._key_visible = False
507
+ self._initialize_context_services()
508
+
509
+ def compose(self) -> ComposeResult: # type: ignore[return]
510
+ """Compose the Harlequin layout with account list and detail panes."""
511
+ if not TEXTUAL_SUPPORTED or Horizontal is None or Vertical is None or Static is None:
512
+ return # type: ignore[return-value]
513
+
514
+ # Main container with horizontal split (25/75)
515
+ with Horizontal(id="harlequin-container"):
516
+ # Left pane (25% width) with account list
517
+ with Vertical(id="left-pane"):
518
+ yield Static("Accounts", id="left-pane-title")
519
+ yield Input(placeholder="Filter...", id="harlequin-filter")
520
+ yield DataTable(id=HARLEQUIN_ACCOUNTS_LIST_ID.lstrip("#"))
521
+ # Right pane (75% width) with account details
522
+ with Vertical(id="right-pane"):
523
+ yield Static("Account Details", id="right-pane-title")
524
+ yield Static("", id=HARLEQUIN_DETAIL_ID.lstrip("#"))
525
+ with Vertical(id="detail-fields"):
526
+ yield Static("URL:", classes="detail-label")
527
+ yield Static("", id=HARLEQUIN_DETAIL_URL_ID.lstrip("#"))
528
+ yield Static("API Key:", classes="detail-label")
529
+ yield Static("", id=HARLEQUIN_DETAIL_KEY_ID.lstrip("#"))
530
+ yield Static("Status:", classes="detail-label")
531
+ yield Static("", id=HARLEQUIN_DETAIL_STATUS_ID.lstrip("#"))
532
+ with Horizontal(id=HARLEQUIN_DETAIL_ACTIONS_ID.lstrip("#")):
533
+ yield Button("(a) Add", id="action-add")
534
+ yield Button("(e) Edit", id="action-edit")
535
+ yield Button("(d) Delete", id="action-delete")
536
+ yield Button("(c) Copy", id="action-copy")
537
+ yield PulseIndicator(id="harlequin-loading")
538
+ yield Static("", id="harlequin-status")
539
+ # Help text showing keyboard shortcuts at the bottom
540
+ yield Static(
541
+ "[dim]↑↓ Navigate | Enter Switch | a Add | e Edit | d Delete | c Copy | q/Esc Exit[/dim]",
542
+ id="help-text",
543
+ )
544
+
545
+ # Toast container for notifications
546
+ if Toast is not None and ToastContainer is not None:
547
+ yield ToastContainer(Toast(), id="toast-container")
548
+
549
+ def on_mount(self) -> None:
550
+ """Configure the screen after mount."""
551
+ if not TEXTUAL_SUPPORTED:
552
+ return
553
+
554
+ self._apply_theme()
555
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
556
+ table.add_column("Account", width=None)
557
+ table.cursor_type = "row"
558
+ table.zebra_stripes = True
559
+ self._reload_accounts_list()
560
+ table.focus()
561
+ self._prepare_toasts()
562
+ self._register_keybinds()
563
+ self._update_detail_pane()
564
+ self._hide_loading()
565
+
566
+ def _initialize_context_services(self) -> None:
567
+ """Initialize TUI context services."""
568
+
569
+ def _notify(message: ToastBus.Changed) -> None:
570
+ self.post_message(message)
571
+
572
+ ctx = self.ctx if hasattr(self, "ctx") else self._ctx
573
+ if ctx:
574
+ if ctx.keybinds is None:
575
+ ctx.keybinds = KeybindRegistry()
576
+ if ctx.toasts is None and ToastBus is not None:
577
+ ctx.toasts = ToastBus(on_change=_notify)
578
+ if ctx.clipboard is None:
579
+ ctx.clipboard = ClipboardAdapter(terminal=ctx.terminal)
580
+ self._keybinds = ctx.keybinds
581
+ self._toast_bus = ctx.toasts
582
+ self._clipboard = ctx.clipboard
583
+ else:
584
+ terminal = TerminalCapabilities(
585
+ tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
586
+ )
587
+ self._clipboard = ClipboardAdapter(terminal=terminal)
588
+ if ToastBus is not None:
589
+ self._toast_bus = ToastBus(on_change=_notify)
590
+
591
+ def _prepare_toasts(self) -> None:
592
+ """Prepare toast system."""
593
+ if self._toast_bus:
594
+ self._toast_bus.clear()
595
+
596
+ def _register_keybinds(self) -> None:
597
+ """Register keybinds with the registry."""
598
+ if not self._keybinds:
599
+ return
600
+ for keybind_def in KEYBIND_DEFINITIONS:
601
+ scoped_action = f"{KEYBIND_SCOPE}.{keybind_def.action}"
602
+ if self._keybinds.get(scoped_action):
603
+ continue
604
+ try:
605
+ self._keybinds.register(
606
+ action=scoped_action,
607
+ key=keybind_def.key,
608
+ description=keybind_def.description,
609
+ category=KEYBIND_CATEGORY,
610
+ )
611
+ except ValueError as e:
612
+ logging.debug(f"Skipping duplicate keybind registration: {scoped_action}", exc_info=e)
613
+ continue
614
+
615
+ def _reload_accounts_list(self, preferred_name: str | None = None) -> None:
616
+ """Reload the accounts list in the left pane."""
617
+ if not TEXTUAL_SUPPORTED:
618
+ return
619
+
620
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
621
+ table.clear()
622
+
623
+ filtered = self._filtered_rows()
624
+ for row in filtered:
625
+ name = str(row.get("name", ""))
626
+ # Highlight active account
627
+ if row.get("name") == self._active_account:
628
+ name = f"[green]●[/] {name}"
629
+ table.add_row(name)
630
+
631
+ # Move cursor to active or preferred account
632
+ cursor_idx = 0
633
+ target_name = preferred_name or self._active_account
634
+ for idx, row in enumerate(filtered):
635
+ if row.get("name") == target_name:
636
+ cursor_idx = idx
637
+ break
638
+
639
+ if filtered:
640
+ table.cursor_coordinate = (cursor_idx, 0)
641
+ self._update_selected_account(filtered[cursor_idx] if cursor_idx < len(filtered) else None)
642
+ else:
643
+ self._update_selected_account(None)
644
+ self._set_status("No accounts match the current filter.", "yellow")
645
+
646
+ def _filtered_rows(self) -> list[dict[str, str | bool]]:
647
+ """Return filtered account rows."""
648
+ if not self._filter_text:
649
+ return list(self._all_rows)
650
+
651
+ needle = self._filter_text.lower()
652
+ filtered = [
653
+ row
654
+ for row in self._all_rows
655
+ if needle in str(row.get("name", "")).lower() or needle in str(row.get("api_url", "")).lower()
656
+ ]
657
+
658
+ def score(row: dict[str, str | bool]) -> tuple[int, str]:
659
+ name = str(row.get("name", "")).lower()
660
+ url = str(row.get("api_url", "")).lower()
661
+ name_hit = needle in name
662
+ url_hit = needle in url
663
+ priority = 0 if name_hit else (1 if url_hit else 2)
664
+ return (priority, name)
665
+
666
+ return sorted(filtered, key=score)
667
+
668
+ def _update_selected_account(self, account: dict[str, str | bool] | None) -> None:
669
+ """Update the selected account and refresh detail pane."""
670
+ self._selected_account = account
671
+ self._update_detail_pane()
672
+
673
+ def _update_detail_pane(self) -> None:
674
+ """Update the right pane with selected account details."""
675
+ if not TEXTUAL_SUPPORTED:
676
+ return
677
+
678
+ if not self._selected_account:
679
+ detail = self.query_one(HARLEQUIN_DETAIL_ID, Static)
680
+ detail.update("[dim]Select an account to view details[/]")
681
+ url_widget = self.query_one(HARLEQUIN_DETAIL_URL_ID, Static)
682
+ url_widget.update("")
683
+ key_widget = self.query_one(HARLEQUIN_DETAIL_KEY_ID, Static)
684
+ key_widget.update("")
685
+ status_widget = self.query_one(HARLEQUIN_DETAIL_STATUS_ID, Static)
686
+ status_widget.update("")
687
+ return
688
+
689
+ account = self._selected_account
690
+ name = str(account.get("name", ""))
691
+ url = str(account.get("api_url", ""))
692
+ masked_key = str(account.get("masked_key", ""))
693
+ api_key = str(account.get("api_key", ""))
694
+
695
+ # Update detail header
696
+ detail = self.query_one(HARLEQUIN_DETAIL_ID, Static)
697
+ detail.update(f"[bold]{name}[/]")
698
+
699
+ # Update URL
700
+ url_widget = self.query_one(HARLEQUIN_DETAIL_URL_ID, Static)
701
+ url_widget.update(url)
702
+
703
+ # Update API Key (hidden by default, toggle with button)
704
+ key_widget = self.query_one(HARLEQUIN_DETAIL_KEY_ID, Static)
705
+ if self._key_visible and api_key:
706
+ key_widget.update(api_key)
707
+ else:
708
+ key_widget.update(masked_key)
709
+
710
+ # Update Status
711
+ row_for_status = dict(account)
712
+ row_for_status["active"] = row_for_status.get("name") == self._active_account
713
+ status_str = build_account_status_string(row_for_status, use_markup=True)
714
+ status_widget = self.query_one(HARLEQUIN_DETAIL_STATUS_ID, Static)
715
+ status_widget.update(status_str)
716
+
717
+ def _set_status(self, message: str, style: str) -> None:
718
+ """Update status message."""
719
+ if not TEXTUAL_SUPPORTED:
720
+ return
721
+ status = self.query_one("#harlequin-status", Static)
722
+ status.update(f"[{style}]{message}[/]")
723
+
724
+ def _get_selected_name(self) -> str | None:
725
+ """Get the name of the currently selected account."""
726
+ if not TEXTUAL_SUPPORTED or not self._selected_account:
727
+ return None
728
+ return str(self._selected_account.get("name", ""))
729
+
730
+ def _show_loading(self, message: str | None = None) -> None:
731
+ show_loading_indicator(self, "#harlequin-loading", message=message, set_status=self._set_status)
732
+
733
+ def _hide_loading(self) -> None:
734
+ hide_loading_indicator(self, "#harlequin-loading")
735
+
736
+ def action_switch_account(self) -> None:
737
+ """Switch to the currently selected account."""
738
+ if self._env_lock:
739
+ self._set_status("Switching disabled: env credentials in use.", "yellow")
740
+ return
741
+
742
+ # Ensure account is selected from cursor position if not explicitly selected
743
+ if not self._selected_account:
744
+ try:
745
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
746
+ cursor_row = table.cursor_row
747
+ if cursor_row is not None and cursor_row >= 0:
748
+ filtered = self._filtered_rows()
749
+ if cursor_row < len(filtered):
750
+ self._update_selected_account(filtered[cursor_row])
751
+ except Exception:
752
+ pass
753
+
754
+ name = self._get_selected_name()
755
+ if not name:
756
+ self._set_status("No account selected.", "yellow")
757
+ return
758
+
759
+ if self._is_switching:
760
+ self._set_status("Already switching...", "yellow")
761
+ return
762
+
763
+ self._is_switching = True
764
+ host = self._get_host_for_name(name)
765
+ message = f"Connecting to '{name}' ({host})..." if host else f"Connecting to '{name}'..."
766
+ self._set_status(message, "cyan")
767
+ self._queue_switch(name)
768
+
769
+ def _get_host_for_name(self, name: str | None) -> str | None:
770
+ """Return shortened API URL for a given account name."""
771
+ if not name:
772
+ return None
773
+ for row in self._all_rows:
774
+ if row.get("name") == name:
775
+ url = str(row.get("api_url", ""))
776
+ return url if len(url) <= 40 else f"{url[:37]}..."
777
+ return None
778
+
779
+ def _queue_switch(self, name: str) -> None:
780
+ """Run switch in background."""
781
+
782
+ async def perform() -> None:
783
+ try:
784
+ switched, message = await asyncio.to_thread(self._account_callbacks.switch_account, name)
785
+ except Exception as exc:
786
+ self._set_status(f"Switch failed: {exc}", "red")
787
+ return
788
+ finally:
789
+ self._hide_loading()
790
+ self._is_switching = False
791
+
792
+ if switched:
793
+ # Refresh active account from store to ensure consistency
794
+ self._active_account = self._store.get_active_account() or name
795
+ status_msg = message or f"Switched to '{name}'."
796
+ if self._toast_bus:
797
+ self._toast_bus.show(message=status_msg, variant="success")
798
+ self._set_status(status_msg, "green")
799
+ # Reload accounts list to update green indicator
800
+ self._reload_accounts_list(preferred_name=name)
801
+ self._update_detail_pane()
802
+ else:
803
+ self._set_status(message or "Switch failed; kept previous account.", "yellow")
804
+
805
+ try:
806
+ self._show_loading(f"Connecting to '{name}'...")
807
+ self.track_task(perform(), logger=logging.getLogger(__name__))
808
+ except Exception as exc:
809
+ self._hide_loading()
810
+ self._is_switching = False
811
+ self._set_status(f"Switch failed to start: {exc}", "red")
812
+
813
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
814
+ """Handle row selection in the accounts list."""
815
+ if not TEXTUAL_SUPPORTED:
816
+ return
817
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
818
+ try:
819
+ table.cursor_coordinate = (event.cursor_row, 0)
820
+ except Exception:
821
+ return
822
+ filtered = self._filtered_rows()
823
+ if event.cursor_row < len(filtered):
824
+ self._update_selected_account(filtered[event.cursor_row])
825
+ if not self._is_switching:
826
+ self.action_switch_account()
827
+
828
+ def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: # type: ignore[override]
829
+ """Handle mouse click selection by triggering switch."""
830
+ if not TEXTUAL_SUPPORTED:
831
+ return
832
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
833
+ try:
834
+ table.cursor_coordinate = (event.coordinate.row, 0)
835
+ except Exception:
836
+ return
837
+ filtered = self._filtered_rows()
838
+ if event.coordinate.row < len(filtered):
839
+ self._update_selected_account(filtered[event.coordinate.row])
840
+ if not self._is_switching:
841
+ self.action_switch_account()
842
+
843
+ def on_data_table_cursor_row_changed(self, event: DataTable.CursorRowChanged) -> None: # type: ignore[override]
844
+ """Handle cursor movement in the accounts list."""
845
+ if not TEXTUAL_SUPPORTED:
846
+ return
847
+ filtered = self._filtered_rows()
848
+ if event.cursor_row is not None and event.cursor_row < len(filtered):
849
+ self._update_selected_account(filtered[event.cursor_row])
850
+
851
+ def action_focus_filter(self) -> None:
852
+ """Focus the filter input."""
853
+ if not TEXTUAL_SUPPORTED:
854
+ return
855
+ filter_input = self.query_one("#harlequin-filter", Input)
856
+ filter_input.value = self._filter_text
857
+ filter_input.focus()
858
+
859
+ def on_input_changed(self, event: Input.Changed) -> None:
860
+ """Handle filter input changes."""
861
+ if not TEXTUAL_SUPPORTED:
862
+ return
863
+ if event.input.id == "harlequin-filter":
864
+ self._filter_text = (event.value or "").strip()
865
+ self._reload_accounts_list()
866
+
867
+ def on_input_submitted(self, event: Input.Submitted) -> None:
868
+ """Handle Enter key in Harlequin filter input."""
869
+ if event.input.id == "harlequin-filter":
870
+ try:
871
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
872
+ table.focus()
873
+ except Exception:
874
+ pass
875
+
876
+ def action_add_account(self) -> None:
877
+ """Open add account modal."""
878
+ if self._check_env_lock():
879
+ return
880
+ existing_names = {str(row.get("name", "")) for row in self._all_rows}
881
+ modal = AccountFormModal(
882
+ mode="add",
883
+ existing=None,
884
+ existing_names=existing_names,
885
+ connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
886
+ validate_name=self._store.validate_account_name,
887
+ )
888
+ self.app.push_screen(modal, self._on_form_result)
889
+
890
+ def action_edit_account(self) -> None:
891
+ """Open edit account modal."""
892
+ if self._check_env_lock():
893
+ return
894
+ # Get account from cursor position if not explicitly selected
895
+ self._ensure_account_selected_from_cursor()
896
+ name = self._get_selected_name()
897
+ if not name:
898
+ self._set_status("Select an account to edit.", "yellow")
899
+ return
900
+ account = self._store.get_account(name)
901
+ if not account:
902
+ self._set_status(f"Account '{name}' not found.", "red")
903
+ return
904
+ existing_names = {str(row.get("name", "")) for row in self._all_rows if str(row.get("name", "")) != name}
905
+ modal = AccountFormModal(
906
+ mode="edit",
907
+ existing={"name": name, "api_url": account.get("api_url", ""), "api_key": account.get("api_key", "")},
908
+ existing_names=existing_names,
909
+ connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
910
+ validate_name=self._store.validate_account_name,
911
+ )
912
+ self.app.push_screen(modal, self._on_form_result)
913
+
914
+ def action_delete_account(self) -> None:
915
+ """Open delete confirmation modal."""
916
+ if self._check_env_lock():
917
+ return
918
+ # Get account from cursor position if not explicitly selected
919
+ self._ensure_account_selected_from_cursor()
920
+ name = self._get_selected_name()
921
+ if not name:
922
+ self._set_status("Select an account to delete.", "yellow")
923
+ return
924
+ accounts = self._store.list_accounts()
925
+ if len(accounts) <= 1:
926
+ self._set_status("Cannot remove the last remaining account.", "red")
927
+ return
928
+ self.app.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
929
+
930
+ def _ensure_account_selected_from_cursor(self) -> None:
931
+ """Ensure an account is selected, using cursor position if needed."""
932
+ if self._selected_account:
933
+ return
934
+ try:
935
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
936
+ cursor_row = table.cursor_row
937
+ if cursor_row is not None and cursor_row >= 0:
938
+ row = table.get_row_at(cursor_row)
939
+ if row:
940
+ account_name = str(row[0])
941
+ # Find the account data
942
+ for account_data in self._all_rows:
943
+ if str(account_data.get("name", "")) == account_name:
944
+ self._selected_account = account_data
945
+ self._update_detail_pane()
946
+ break
947
+ except Exception:
948
+ pass
949
+
950
+ def action_copy_account(self) -> None:
951
+ """Copy selected account to clipboard."""
952
+ # Get account from cursor position if not explicitly selected
953
+ self._ensure_account_selected_from_cursor()
954
+
955
+ name = self._get_selected_name()
956
+ if not name:
957
+ self._set_status("Select an account to copy.", "yellow")
958
+ return
959
+
960
+ account = self._store.get_account(name)
961
+ if not account:
962
+ return
963
+
964
+ text = f"Account: {name}\nURL: {account.get('api_url', '')}"
965
+ adapter = self._clipboard_adapter()
966
+ writer = self._osc52_writer()
967
+ if writer:
968
+ result = adapter.copy(text, writer=writer)
969
+ else:
970
+ result = adapter.copy(text)
971
+ self._handle_copy_result(name, result)
972
+
973
+ def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
974
+ """Handle copy operation result."""
975
+ if result.success:
976
+ if self._toast_bus:
977
+ self._toast_bus.copy_success(f"Account '{name}'")
978
+ self._set_status(f"Copied '{name}' to clipboard.", "green")
979
+ else:
980
+ if self._toast_bus and ToastVariant is not None:
981
+ self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
982
+ self._set_status(f"Copy failed: {result.message}", "red")
983
+
984
+ def _clipboard_adapter(self) -> ClipboardAdapter:
985
+ """Get clipboard adapter."""
986
+ ctx = self.ctx if hasattr(self, "ctx") else getattr(self, "_ctx", None)
987
+ if ctx is not None and ctx.clipboard is not None:
988
+ return cast(ClipboardAdapter, ctx.clipboard)
989
+ if self._clipboard is not None:
990
+ return self._clipboard
991
+ adapter = ClipboardAdapter(terminal=ctx.terminal if ctx else None)
992
+ if ctx is not None:
993
+ ctx.clipboard = adapter
994
+ else:
995
+ self._clipboard = adapter
996
+ return adapter
997
+
998
+ def _osc52_writer(self) -> Callable[[str], Any] | None:
999
+ """Get OSC52 writer if available."""
1000
+ try:
1001
+ console = getattr(self, "console", None)
1002
+ except Exception:
1003
+ return None
1004
+ if console is None:
1005
+ return None
1006
+ output = getattr(console, "file", None)
1007
+ if output is None:
1008
+ return None
1009
+
1010
+ def _write(sequence: str, _output: Any = output) -> None:
1011
+ _output.write(sequence)
1012
+ _output.flush()
1013
+
1014
+ return _write
1015
+
1016
+ def _check_env_lock(self) -> bool:
1017
+ """Check if env lock prevents mutations."""
1018
+ if not self._is_env_locked():
1019
+ return False
1020
+ self._env_lock = True
1021
+ self._set_status("Disabled by env-lock.", "yellow")
1022
+ self._refresh_rows()
1023
+ return True
1024
+
1025
+ def _is_env_locked(self) -> bool:
1026
+ """Check if environment credentials are locking operations."""
1027
+ return env_credentials_present(partial=True)
1028
+
1029
+ def _on_form_result(self, payload: dict[str, Any] | None) -> None:
1030
+ """Handle add/edit modal result."""
1031
+ if payload is None:
1032
+ self._set_status("Edit/add cancelled.", "yellow")
1033
+ return
1034
+ self._save_account(payload)
1035
+
1036
+ def _on_delete_result(self, confirmed_name: str | None) -> None:
1037
+ """Handle delete confirmation result."""
1038
+ if not confirmed_name:
1039
+ self._set_status("Delete cancelled.", "yellow")
1040
+ return
1041
+ try:
1042
+ self._store.remove_account(confirmed_name)
1043
+ except AccountStoreError as exc:
1044
+ self._set_status(f"Delete failed: {exc}", "red")
1045
+ return
1046
+ except Exception as exc:
1047
+ self._set_status(f"Unexpected delete error: {exc}", "red")
1048
+ return
1049
+
1050
+ self._set_status(f"Account '{confirmed_name}' deleted.", "green")
1051
+ self._refresh_rows()
1052
+
1053
+ def _save_account(self, payload: dict[str, Any]) -> None:
1054
+ """Save account from modal payload."""
1055
+ if self._is_env_locked():
1056
+ self._set_status("Disabled by env-lock.", "yellow")
1057
+ return
1058
+
1059
+ name = str(payload.get("name", ""))
1060
+ api_url = str(payload.get("api_url", ""))
1061
+ api_key = str(payload.get("api_key", ""))
1062
+ set_active = bool(payload.get("set_active", payload.get("mode") == "add"))
1063
+ is_edit = payload.get("mode") == "edit"
1064
+
1065
+ try:
1066
+ self._store.add_account(name, api_url, api_key, overwrite=is_edit)
1067
+ except AccountStoreError as exc:
1068
+ self._set_status(f"Save failed: {exc}", "red")
1069
+ return
1070
+ except Exception as exc:
1071
+ self._set_status(f"Unexpected save error: {exc}", "red")
1072
+ return
1073
+
1074
+ if set_active:
1075
+ try:
1076
+ self._store.set_active_account(name)
1077
+ self._active_account = name
1078
+ except Exception as exc:
1079
+ self._set_status(f"Saved but could not set active: {exc}", "yellow")
1080
+ else:
1081
+ if self._toast_bus:
1082
+ self._toast_bus.show(message=f"Switched to '{name}'", variant="success")
1083
+
1084
+ self._set_status(f"Account '{name}' saved.", "green")
1085
+ self._refresh_rows(preferred_name=name)
1086
+
1087
+ def _refresh_rows(self, preferred_name: str | None = None) -> None:
1088
+ """Refresh account rows from store."""
1089
+ self._env_lock = self._is_env_locked()
1090
+ self._all_rows, self._active_account = _build_account_rows_from_store(self._store, self._env_lock)
1091
+ self._reload_accounts_list(preferred_name=preferred_name)
1092
+ if self._selected_account:
1093
+ # Refresh selected account details
1094
+ name = str(self._selected_account.get("name", ""))
1095
+ for row in self._all_rows:
1096
+ if row.get("name") == name:
1097
+ self._update_selected_account(row)
1098
+ break
1099
+
1100
+ def action_clear_or_exit(self) -> None:
1101
+ """Clear filter or exit."""
1102
+ if not TEXTUAL_SUPPORTED:
1103
+ return
1104
+ filter_input = self.query_one("#harlequin-filter", Input)
1105
+ if filter_input.has_focus:
1106
+ if filter_input.value or self._filter_text:
1107
+ filter_input.value = ""
1108
+ self._filter_text = ""
1109
+ self._reload_accounts_list()
1110
+ table = self.query_one(HARLEQUIN_ACCOUNTS_LIST_ID, DataTable)
1111
+ table.focus()
1112
+ return
1113
+ self.dismiss()
1114
+
1115
+ def action_app_exit(self) -> None:
1116
+ """Exit the application."""
1117
+ self.dismiss()
1118
+
1119
+ def _apply_theme(self) -> None:
1120
+ """Apply theme from context."""
1121
+ ctx = self.ctx if hasattr(self, "ctx") else getattr(self, "_ctx", None)
1122
+ if not ctx or not ctx.theme or Theme is None:
1123
+ return
1124
+
1125
+ app = self.app
1126
+ if app is None:
1127
+ return
1128
+
1129
+ for name, tokens in _BUILTIN_THEMES.items():
1130
+ app.register_theme(
1131
+ Theme(
1132
+ name=name,
1133
+ primary=tokens.primary,
1134
+ secondary=tokens.secondary,
1135
+ accent=tokens.accent,
1136
+ warning=tokens.warning,
1137
+ error=tokens.error,
1138
+ success=tokens.success,
1139
+ background=tokens.background,
1140
+ surface=tokens.background_panel,
1141
+ )
1142
+ )
1143
+
1144
+ app.theme = ctx.theme.theme_name
1145
+
1146
+
1147
+ class AccountsTextualApp( # pragma: no cover - interactive
1148
+ ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, _AppBase
1149
+ ):
1150
+ """Textual application for browsing accounts."""
1151
+
1152
+ CSS_PATH = CSS_FILE_NAME
1153
+ BINDINGS = [
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,
1161
+ # Esc clears filter when focused/non-empty; otherwise exits
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,
1164
+ ]
1165
+ BINDINGS = [b for b in BINDINGS if b is not None]
1166
+
1167
+ def __init__(
1168
+ self,
1169
+ rows: list[dict[str, str | bool]],
1170
+ active_account: str | None,
1171
+ env_lock: bool,
1172
+ callbacks: AccountsTUICallbacks,
1173
+ ctx: TUIContext | None = None,
1174
+ ) -> None:
1175
+ """Initialize the Textual accounts app.
1176
+
1177
+ Args:
1178
+ rows: Account data rows to display.
1179
+ active_account: Name of the currently active account.
1180
+ env_lock: Whether environment credentials are locking account switching.
1181
+ callbacks: Callbacks for account switching operations.
1182
+ ctx: Shared TUI context.
1183
+ """
1184
+ super().__init__()
1185
+ self._store = get_account_store()
1186
+ self._all_rows = rows
1187
+ self._active_account = active_account
1188
+ self._env_lock = env_lock
1189
+ self._account_callbacks = callbacks
1190
+ self._ctx = ctx
1191
+ self._keybinds: KeybindRegistry | None = None
1192
+ self._toast_bus: ToastBus | None = None
1193
+ self._clipboard: ClipboardAdapter | None = None
1194
+ self._filter_text: str = ""
1195
+ self._is_switching = False
1196
+ self._initialize_context_services()
1197
+
1198
+ def compose(self) -> ComposeResult:
1199
+ """Build the Textual app (empty, screen is pushed on mount)."""
1200
+ # The app itself is empty; AccountsHarlequinScreen is pushed on mount
1201
+ if not TEXTUAL_SUPPORTED or Footer is None:
1202
+ return # type: ignore[return-value]
1203
+ yield Footer()
1204
+
1205
+ def on_mount(self) -> None:
1206
+ """Push the Harlequin accounts screen on mount."""
1207
+ self._apply_theme()
1208
+ harlequin_screen = AccountsHarlequinScreen(
1209
+ rows=self._all_rows,
1210
+ active_account=self._active_account,
1211
+ env_lock=self._env_lock,
1212
+ callbacks=self._account_callbacks,
1213
+ ctx=self._ctx,
1214
+ )
1215
+ self.push_screen(harlequin_screen)
1216
+
1217
+ def _initialize_context_services(self) -> None:
1218
+ def _notify(message: ToastBus.Changed) -> None:
1219
+ self.post_message(message)
1220
+
1221
+ if self._ctx:
1222
+ if self._ctx.keybinds is None:
1223
+ self._ctx.keybinds = KeybindRegistry()
1224
+ if self._ctx.toasts is None and ToastBus is not None:
1225
+ self._ctx.toasts = ToastBus(on_change=_notify)
1226
+ if self._ctx.clipboard is None:
1227
+ self._ctx.clipboard = ClipboardAdapter(terminal=self._ctx.terminal)
1228
+ self._keybinds = self._ctx.keybinds
1229
+ self._toast_bus = self._ctx.toasts
1230
+ self._clipboard = self._ctx.clipboard
1231
+ else:
1232
+ # Fallback: create services independently when ctx is None
1233
+ terminal = TerminalCapabilities(
1234
+ tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
1235
+ )
1236
+ self._clipboard = ClipboardAdapter(terminal=terminal)
1237
+ if ToastBus is not None:
1238
+ self._toast_bus = ToastBus(on_change=_notify)
1239
+
1240
+ def _prepare_toasts(self) -> None:
1241
+ """Prepare toast system by clearing any existing toasts."""
1242
+ if self._toast_bus:
1243
+ self._toast_bus.clear()
1244
+
1245
+ def _register_keybinds(self) -> None:
1246
+ if not self._keybinds:
1247
+ return
1248
+ for keybind_def in KEYBIND_DEFINITIONS:
1249
+ scoped_action = f"{KEYBIND_SCOPE}.{keybind_def.action}"
1250
+ if self._keybinds.get(scoped_action):
1251
+ continue
1252
+ try:
1253
+ self._keybinds.register(
1254
+ action=scoped_action,
1255
+ key=keybind_def.key,
1256
+ description=keybind_def.description,
1257
+ category=KEYBIND_CATEGORY,
1258
+ )
1259
+ except ValueError as e:
1260
+ # Expected: duplicate registration (already registered by another component)
1261
+ # Silently skip to allow multiple apps to register same keybinds
1262
+ logging.debug(f"Skipping duplicate keybind registration: {scoped_action}", exc_info=e)
1263
+ continue
1264
+
1265
+ def _header_text(self) -> str:
1266
+ """Build header text with active account and host."""
1267
+ host = self._get_active_host() or "Not configured"
1268
+ lock_icon = " [yellow]🔒[/]" if self._env_lock else ""
1269
+ active = self._active_account or "None"
1270
+ return f"[green]Active:[/] [bold]{active}[/] ([cyan]{host}[/]){lock_icon}"
1271
+
1272
+ def _get_active_host(self) -> str | None:
1273
+ """Return the API host for the active account (shortened)."""
1274
+ return self._get_host_for_name(self._active_account)
1275
+
1276
+ def _get_host_for_name(self, name: str | None) -> str | None:
1277
+ """Return shortened API URL for a given account name."""
1278
+ if not name:
1279
+ return None
1280
+ for row in self._all_rows:
1281
+ if row.get("name") == name:
1282
+ url = str(row.get("api_url", ""))
1283
+ return url if len(url) <= 40 else f"{url[:37]}..."
1284
+ return None
1285
+
1286
+ def action_focus_filter(self) -> None:
1287
+ """Focus the filter input and clear previous text."""
1288
+ # Skip if Harlequin screen is active (it handles its own filter focus)
1289
+ if isinstance(self.screen, AccountsHarlequinScreen):
1290
+ return
1291
+ try:
1292
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1293
+ filter_input.value = self._filter_text
1294
+ filter_input.focus()
1295
+ except Exception:
1296
+ # Filter input doesn't exist, skip
1297
+ pass
1298
+
1299
+ def action_switch_row(self) -> None:
1300
+ """Switch to the currently selected account.
1301
+
1302
+ Note: This action is for the old table layout. When using HarlequinScreen,
1303
+ the screen handles switching directly. This gracefully skips if the
1304
+ old table doesn't exist.
1305
+ """
1306
+ try:
1307
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1308
+ except Exception:
1309
+ # Harlequin screen is active, let it handle the action
1310
+ return
1311
+ if self._env_lock:
1312
+ self._set_status("Switching disabled: env credentials in use.", "yellow")
1313
+ return
1314
+ if table.cursor_row is None:
1315
+ self._set_status("No account selected.", "yellow")
1316
+ return
1317
+ try:
1318
+ row_key = table.get_row_at(table.cursor_row)[0]
1319
+ except Exception:
1320
+ self._set_status("Unable to read selected row.", "red")
1321
+ return
1322
+ name = str(row_key)
1323
+ if self._is_switching:
1324
+ self._set_status("Already switching...", "yellow")
1325
+ return
1326
+ self._is_switching = True
1327
+ host = self._get_host_for_name(name)
1328
+ if host:
1329
+ self._show_loading(f"Connecting to '{name}' ({host})...")
1330
+ else:
1331
+ self._show_loading(f"Connecting to '{name}'...")
1332
+ self._queue_switch(name)
1333
+
1334
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: # type: ignore[override]
1335
+ """Handle row selection by triggering switch."""
1336
+ self._handle_table_click(self._event_row(event))
1337
+
1338
+ def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None: # type: ignore[override]
1339
+ """Handle mouse click selection by triggering switch."""
1340
+ self._handle_table_click(self._event_row(event))
1341
+
1342
+ def _event_row(self, event: object) -> int | None:
1343
+ """Extract the row index from a DataTable event."""
1344
+ row = getattr(event, "cursor_row", None)
1345
+ if row is not None:
1346
+ return int(row)
1347
+ coordinate = getattr(event, "coordinate", None)
1348
+ return getattr(coordinate, "row", None) if coordinate is not None else None
1349
+
1350
+ def _handle_table_click(self, row: int | None) -> None:
1351
+ """Move the cursor to a clicked row and trigger the switch action."""
1352
+ if row is None:
1353
+ return
1354
+ try:
1355
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1356
+ except Exception:
1357
+ # Harlequin screen is active, let it handle the action
1358
+ return
1359
+ try:
1360
+ # Move cursor to clicked row then switch
1361
+ table.cursor_coordinate = Coordinate(row, 0)
1362
+ except Exception:
1363
+ return
1364
+ self.action_switch_row()
1365
+
1366
+ def on_input_submitted(self, event: Input.Submitted) -> None:
1367
+ """Apply filter when user presses Enter inside filter input."""
1368
+ # Skip if a screen other than the default app screen is active (e.g., Harlequin or Modal)
1369
+ if self.screen.id != "_default":
1370
+ return
1371
+
1372
+ self._filter_text = (event.value or "").strip()
1373
+ self._reload_rows()
1374
+ try:
1375
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1376
+ table.focus()
1377
+ except Exception:
1378
+ pass
1379
+ self._update_filter_button_visibility()
1380
+
1381
+ def on_input_changed(self, event: Input.Changed) -> None:
1382
+ """Apply filter live as the user types."""
1383
+ # Skip if a screen other than the default app screen is active (e.g., Harlequin or Modal)
1384
+ if self.screen.id != "_default":
1385
+ return
1386
+ self._filter_text = (event.value or "").strip()
1387
+ self._reload_rows()
1388
+ self._update_filter_button_visibility()
1389
+
1390
+ def _reload_rows(self, preferred_name: str | None = None) -> None:
1391
+ """Refresh table rows based on current filter/active state."""
1392
+ # Skip if Harlequin screen is active (it handles its own reloading)
1393
+ try:
1394
+ if isinstance(self.screen, AccountsHarlequinScreen):
1395
+ return
1396
+ except Exception: # pragma: no cover - defensive (e.g., ScreenStackError in tests)
1397
+ # App not fully initialized or no screen pushed, continue with normal reload
1398
+ pass
1399
+ # Work on a copy to avoid mutating the backing rows list
1400
+ rows_copy = [dict(row) for row in self._all_rows]
1401
+ for row in rows_copy:
1402
+ row["active"] = row.get("name") == self._active_account
1403
+
1404
+ try:
1405
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1406
+ except Exception:
1407
+ # Harlequin screen is active, skip
1408
+ return
1409
+ table.clear()
1410
+ filtered = self._filtered_rows(rows_copy)
1411
+ for row in filtered:
1412
+ row_for_status = dict(row)
1413
+ row_for_status["active"] = row_for_status.get("name") == self._active_account
1414
+ # Use markup to align status colors with Rich fallback (green active badge).
1415
+ status = build_account_status_string(row_for_status, use_markup=True)
1416
+ # pylint: disable=duplicate-code
1417
+ # Reuses shared status builder; columns mirror accounts_controller Rich table.
1418
+ table.add_row(
1419
+ str(row.get("name", "")),
1420
+ str(row.get("api_url", "")),
1421
+ str(row.get("masked_key", "")),
1422
+ status,
1423
+ )
1424
+ # Move cursor to active or first row
1425
+ cursor_idx = 0
1426
+ target_name = preferred_name or self._active_account
1427
+ for idx, row in enumerate(filtered):
1428
+ if row.get("name") == target_name:
1429
+ cursor_idx = idx
1430
+ break
1431
+ if filtered:
1432
+ table.cursor_coordinate = (cursor_idx, 0)
1433
+ else:
1434
+ self._set_status("No accounts match the current filter.", "yellow")
1435
+ return
1436
+
1437
+ # Update status to reflect filter state
1438
+ if self._filter_text:
1439
+ self._set_status(f"Filtered: {self._filter_text}", "cyan")
1440
+ else:
1441
+ self._set_status("", "white")
1442
+
1443
+ def _filtered_rows(self, rows: list[dict[str, str | bool]] | None = None) -> list[dict[str, str | bool]]:
1444
+ """Return rows filtered by name or API URL substring."""
1445
+ base_rows = rows if rows is not None else [dict(row) for row in self._all_rows]
1446
+ if not self._filter_text:
1447
+ return list(base_rows)
1448
+ needle = self._filter_text.lower()
1449
+ filtered = [
1450
+ row
1451
+ for row in base_rows
1452
+ if needle in str(row.get("name", "")).lower() or needle in str(row.get("api_url", "")).lower()
1453
+ ]
1454
+
1455
+ # Sort so name matches surface first, then URL matches, then alphabetically
1456
+ def score(row: dict[str, str | bool]) -> tuple[int, str]:
1457
+ name = str(row.get("name", "")).lower()
1458
+ url = str(row.get("api_url", "")).lower()
1459
+ name_hit = needle in name
1460
+ url_hit = needle in url
1461
+ # Extract nested conditional into clear statement
1462
+ if name_hit:
1463
+ priority = 0
1464
+ elif url_hit:
1465
+ priority = 1
1466
+ else:
1467
+ priority = 2
1468
+ return (priority, name)
1469
+
1470
+ return sorted(filtered, key=score)
1471
+
1472
+ def _set_status(self, message: str, style: str) -> None:
1473
+ """Update status line with message."""
1474
+ status = self.query_one(STATUS_ID, Static)
1475
+ status.update(f"[{style}]{message}[/]")
1476
+
1477
+ def _show_loading(self, message: str | None = None) -> None:
1478
+ """Show the loading indicator and optional status message."""
1479
+ show_loading_indicator(self, ACCOUNTS_LOADING_ID, message=message, set_status=self._set_status)
1480
+
1481
+ def _hide_loading(self) -> None:
1482
+ """Hide the loading indicator."""
1483
+ hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
1484
+
1485
+ def _handle_switch_scheduling_error(self, exc: Exception) -> None:
1486
+ """Handle errors when scheduling the switch task fails.
1487
+
1488
+ Args:
1489
+ exc: The exception that occurred during task scheduling.
1490
+ """
1491
+ self._hide_loading()
1492
+ self._is_switching = False
1493
+ error_msg = f"Switch failed to start: {exc}"
1494
+ if self._toast_bus:
1495
+ self._toast_bus.show(message=error_msg, variant="error")
1496
+ try:
1497
+ self._set_status(error_msg, "red")
1498
+ except Exception:
1499
+ # App not mounted yet, status update not possible
1500
+ logging.error(error_msg, exc_info=exc)
1501
+ logging.getLogger(__name__).debug("Failed to schedule switch task", exc_info=exc)
1502
+
1503
+ def _clear_filter(self) -> None:
1504
+ """Clear the filter input and reset filter state."""
1505
+ # Skip if Harlequin screen is active (it handles its own filtering)
1506
+ if isinstance(self.screen, AccountsHarlequinScreen):
1507
+ return
1508
+ try:
1509
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1510
+ filter_input.value = ""
1511
+ self._filter_text = ""
1512
+ except Exception:
1513
+ # Filter input doesn't exist, just clear the text
1514
+ self._filter_text = ""
1515
+ self._update_filter_button_visibility()
1516
+
1517
+ def _queue_switch(self, name: str) -> None:
1518
+ """Run switch in background to keep UI responsive."""
1519
+
1520
+ async def perform() -> None:
1521
+ try:
1522
+ switched, message = await asyncio.to_thread(self._account_callbacks.switch_account, name)
1523
+ except Exception as exc: # pragma: no cover - defensive
1524
+ self._set_status(f"Switch failed: {exc}", "red")
1525
+ return
1526
+ finally:
1527
+ self._hide_loading()
1528
+ self._is_switching = False
1529
+
1530
+ if switched:
1531
+ self._active_account = name
1532
+ status_msg = message or f"Switched to '{name}'."
1533
+ if self._toast_bus:
1534
+ self._toast_bus.show(message=status_msg, variant="success")
1535
+ self._update_header()
1536
+ self._reload_rows()
1537
+ else:
1538
+ self._set_status(message or "Switch failed; kept previous account.", "yellow")
1539
+
1540
+ try:
1541
+ self.track_task(perform(), logger=logging.getLogger(__name__))
1542
+ except Exception as exc:
1543
+ self._handle_switch_scheduling_error(exc)
1544
+
1545
+ def _update_header(self) -> None:
1546
+ """Refresh header text to reflect active/lock state."""
1547
+ header = self.query_one("#header-info", Static)
1548
+ header.update(self._header_text())
1549
+
1550
+ def action_clear_or_exit(self) -> None:
1551
+ """Clear or exit filter when focused; otherwise exit app.
1552
+
1553
+ UX note: helps users reset the list without leaving the TUI.
1554
+ """
1555
+ # Skip if Harlequin screen is active (it handles its own exit)
1556
+ if isinstance(self.screen, AccountsHarlequinScreen):
1557
+ self.exit()
1558
+ return
1559
+ try:
1560
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1561
+ if filter_input.has_focus:
1562
+ # Clear when there is text; otherwise just move focus back to the table
1563
+ if filter_input.value or self._filter_text:
1564
+ self._clear_filter()
1565
+ self._reload_rows()
1566
+ try:
1567
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1568
+ table.focus()
1569
+ except Exception:
1570
+ pass
1571
+ return
1572
+ except Exception:
1573
+ # Filter input doesn't exist, just exit
1574
+ pass
1575
+ self.exit()
1576
+
1577
+ def action_app_exit(self) -> None:
1578
+ """Exit the application regardless of focus state."""
1579
+ self.exit()
1580
+
1581
+ def on_button_pressed(self, event: Button.Pressed) -> None:
1582
+ """Handle filter bar buttons."""
1583
+ if event.button.id == "filter-clear":
1584
+ self._clear_filter()
1585
+ self._reload_rows()
1586
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1587
+ table.focus()
1588
+
1589
+ def action_add_account(self) -> None:
1590
+ """Open add account modal."""
1591
+ if self._check_env_lock_hotkey():
1592
+ return
1593
+ if self._should_block_actions():
1594
+ return
1595
+ existing_names = {str(row.get("name", "")) for row in self._all_rows}
1596
+ modal = AccountFormModal(
1597
+ mode="add",
1598
+ existing=None,
1599
+ existing_names=existing_names,
1600
+ connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
1601
+ validate_name=self._store.validate_account_name,
1602
+ )
1603
+ self.push_screen(modal, self._on_form_result)
1604
+
1605
+ def action_edit_account(self) -> None:
1606
+ """Open edit account modal for selected row."""
1607
+ if self._check_env_lock_hotkey():
1608
+ return
1609
+ if self._should_block_actions():
1610
+ return
1611
+ name = self._get_selected_name()
1612
+ if not name:
1613
+ self._set_status("Select an account to edit.", "yellow")
1614
+ return
1615
+ account = self._store.get_account(name)
1616
+ if not account:
1617
+ self._set_status(f"Account '{name}' not found.", "red")
1618
+ return
1619
+ existing_names = {str(row.get("name", "")) for row in self._all_rows if str(row.get("name", "")) != name}
1620
+ modal = AccountFormModal(
1621
+ mode="edit",
1622
+ existing={"name": name, "api_url": account.get("api_url", ""), "api_key": account.get("api_key", "")},
1623
+ existing_names=existing_names,
1624
+ connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
1625
+ validate_name=self._store.validate_account_name,
1626
+ )
1627
+ self.push_screen(modal, self._on_form_result)
1628
+
1629
+ def action_delete_account(self) -> None:
1630
+ """Open delete confirmation modal."""
1631
+ if self._check_env_lock_hotkey():
1632
+ return
1633
+ if self._should_block_actions():
1634
+ return
1635
+ name = self._get_selected_name()
1636
+ if not name:
1637
+ self._set_status("Select an account to delete.", "yellow")
1638
+ return
1639
+ accounts = self._store.list_accounts()
1640
+ if len(accounts) <= 1:
1641
+ self._set_status("Cannot remove the last remaining account.", "red")
1642
+ return
1643
+ self.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
1644
+
1645
+ def action_copy_account(self) -> None:
1646
+ """Copy selected account name and URL to clipboard."""
1647
+ name = self._get_selected_name()
1648
+ if not name:
1649
+ self._set_status("Select an account to copy.", "yellow")
1650
+ return
1651
+
1652
+ account = self._store.get_account(name)
1653
+ if not account:
1654
+ return
1655
+
1656
+ text = f"Account: {name}\nURL: {account.get('api_url', '')}"
1657
+ adapter = self._clipboard_adapter()
1658
+ writer = self._osc52_writer()
1659
+ if writer:
1660
+ result = adapter.copy(text, writer=writer)
1661
+ else:
1662
+ result = adapter.copy(text)
1663
+ self._handle_copy_result(name, result)
1664
+
1665
+ def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
1666
+ """Update UI state after a copy attempt."""
1667
+ if result.success:
1668
+ if self._toast_bus:
1669
+ self._toast_bus.copy_success(f"Account '{name}'")
1670
+ self._set_status(f"Copied '{name}' to clipboard.", "green")
1671
+ else:
1672
+ if self._toast_bus and ToastVariant is not None:
1673
+ self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
1674
+ self._set_status(f"Copy failed: {result.message}", "red")
1675
+
1676
+ def _clipboard_adapter(self) -> ClipboardAdapter:
1677
+ if self._ctx is not None and self._ctx.clipboard is not None:
1678
+ return cast(ClipboardAdapter, self._ctx.clipboard)
1679
+ if self._clipboard is not None:
1680
+ return self._clipboard
1681
+ adapter = ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
1682
+ if self._ctx is not None:
1683
+ self._ctx.clipboard = adapter
1684
+ else:
1685
+ self._clipboard = adapter
1686
+ return adapter
1687
+
1688
+ def _osc52_writer(self) -> Callable[[str], Any] | None:
1689
+ try:
1690
+ console = getattr(self, "console", None)
1691
+ except Exception:
1692
+ return None
1693
+ if console is None:
1694
+ return None
1695
+ output = getattr(console, "file", None)
1696
+ if output is None:
1697
+ return None
1698
+
1699
+ def _write(sequence: str, _output: Any = output) -> None:
1700
+ _output.write(sequence)
1701
+ _output.flush()
1702
+
1703
+ return _write
1704
+
1705
+ def _check_env_lock_hotkey(self) -> bool:
1706
+ """Prevent mutations when env credentials are present."""
1707
+ if not self._is_env_locked():
1708
+ return False
1709
+ self._env_lock = True
1710
+ self._set_status("Disabled by env-lock.", "yellow")
1711
+ # Refresh UI to reflect env-lock state (header/banners/rows)
1712
+ self._refresh_rows(preferred_name=self._active_account)
1713
+ return True
1714
+
1715
+ def _on_form_result(self, payload: dict[str, Any] | None) -> None:
1716
+ """Handle add/edit modal result."""
1717
+ if payload is None:
1718
+ self._set_status("Edit/add cancelled.", "yellow")
1719
+ return
1720
+ self._save_account(payload)
1721
+
1722
+ def _on_delete_result(self, confirmed_name: str | None) -> None:
1723
+ """Handle delete confirmation result."""
1724
+ if not confirmed_name:
1725
+ self._set_status("Delete cancelled.", "yellow")
1726
+ return
1727
+ try:
1728
+ self._store.remove_account(confirmed_name)
1729
+ except AccountStoreError as exc:
1730
+ self._set_status(f"Delete failed: {exc}", "red")
1731
+ return
1732
+ except Exception as exc: # pragma: no cover - defensive
1733
+ self._set_status(f"Unexpected delete error: {exc}", "red")
1734
+ return
1735
+
1736
+ self._set_status(f"Account '{confirmed_name}' deleted.", "green")
1737
+ # Clear filter before refresh to show all accounts
1738
+ self._clear_filter()
1739
+ # Refresh rows without preferred name to show all accounts
1740
+ # Active account will be cleared if the deleted account was active
1741
+ self._refresh_rows(preferred_name=None)
1742
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1743
+ table.focus()
1744
+
1745
+ def _save_account(self, payload: dict[str, Any]) -> None:
1746
+ """Persist account data from modal payload."""
1747
+ if self._is_env_locked():
1748
+ self._set_status("Disabled by env-lock.", "yellow")
1749
+ return
1750
+
1751
+ name = str(payload.get("name", ""))
1752
+ api_url = str(payload.get("api_url", ""))
1753
+ api_key = str(payload.get("api_key", ""))
1754
+ set_active = bool(payload.get("set_active", payload.get("mode") == "add"))
1755
+ is_edit = payload.get("mode") == "edit"
1756
+
1757
+ try:
1758
+ self._store.add_account(name, api_url, api_key, overwrite=is_edit)
1759
+ except AccountStoreError as exc:
1760
+ self._set_status(f"Save failed: {exc}", "red")
1761
+ return
1762
+ except Exception as exc: # pragma: no cover - defensive
1763
+ self._set_status(f"Unexpected save error: {exc}", "red")
1764
+ return
1765
+
1766
+ if set_active:
1767
+ try:
1768
+ self._store.set_active_account(name)
1769
+ self._active_account = name
1770
+ except Exception as exc: # pragma: no cover - defensive
1771
+ self._set_status(f"Saved but could not set active: {exc}", "yellow")
1772
+ else:
1773
+ self._announce_active_change(name)
1774
+ self._update_header()
1775
+
1776
+ self._set_status(f"Account '{name}' saved.", "green")
1777
+ # Clear filter before refresh to show all accounts
1778
+ self._clear_filter()
1779
+ # Refresh rows with preferred name to highlight the saved account
1780
+ self._refresh_rows(preferred_name=name)
1781
+ # Return focus to the table for immediate hotkey use
1782
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1783
+ table.focus()
1784
+
1785
+ def _refresh_rows(self, preferred_name: str | None = None) -> None:
1786
+ """Reload rows from store and preserve filter/cursor."""
1787
+ self._env_lock = self._is_env_locked()
1788
+ self._all_rows, self._active_account = _build_account_rows_from_store(self._store, self._env_lock)
1789
+ self._reload_rows(preferred_name=preferred_name)
1790
+ self._update_header()
1791
+
1792
+ def _get_selected_name(self) -> str | None:
1793
+ """Return selected account name, if any."""
1794
+ table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
1795
+ if table.cursor_row is None:
1796
+ return None
1797
+ try:
1798
+ row = table.get_row_at(table.cursor_row)
1799
+ except Exception:
1800
+ return None
1801
+ return str(row[0]) if row else None
1802
+
1803
+ def _is_env_locked(self) -> bool:
1804
+ """Return True when env credentials are set (even partially)."""
1805
+ return env_credentials_present(partial=True)
1806
+
1807
+ def _announce_active_change(self, name: str) -> None:
1808
+ """Surface active account change in status bar."""
1809
+ account = self._store.get_account(name) or {}
1810
+ host = account.get("api_url", "")
1811
+ host_suffix = f" • {host}" if host else ""
1812
+ self._set_status(f"Active account ➜ {name}{host_suffix}", "green")
1813
+
1814
+ def _should_block_actions(self) -> bool:
1815
+ """Return True when mutating hotkeys are blocked by filter focus."""
1816
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1817
+ if filter_input.has_focus:
1818
+ self._set_status("Exit filter (Esc or Clear) to add/edit/delete.", "yellow")
1819
+ return True
1820
+ return False
1821
+
1822
+ def _update_filter_button_visibility(self) -> None:
1823
+ """Show clear button only when filter has content."""
1824
+ # Skip if Harlequin screen is active (it doesn't have this button)
1825
+ if isinstance(self.screen, AccountsHarlequinScreen):
1826
+ return
1827
+ try:
1828
+ filter_input = self.query_one(FILTER_INPUT_ID, Input)
1829
+ clear_btn = self.query_one("#filter-clear", Button)
1830
+ clear_btn.display = bool(filter_input.value or self._filter_text)
1831
+ except Exception:
1832
+ # Filter input or clear button doesn't exist, skip
1833
+ pass
1834
+
1835
+ def _apply_theme(self) -> None:
1836
+ """Register built-in themes and set the active one from context."""
1837
+ if not self._ctx or not self._ctx.theme or Theme is None:
1838
+ return
1839
+
1840
+ for name, tokens in _BUILTIN_THEMES.items():
1841
+ self.register_theme(
1842
+ Theme(
1843
+ name=name,
1844
+ primary=tokens.primary,
1845
+ secondary=tokens.secondary,
1846
+ accent=tokens.accent,
1847
+ warning=tokens.warning,
1848
+ error=tokens.error,
1849
+ success=tokens.success,
1850
+ )
1851
+ )
1852
+
1853
+ self.theme = self._ctx.theme.theme_name