glaip-sdk 0.6.12__py3-none-any.whl → 0.6.14__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 (156) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. {glaip_sdk-0.6.12.dist-info → glaip_sdk-0.6.14.dist-info}/METADATA +31 -37
  3. glaip_sdk-0.6.14.dist-info/RECORD +12 -0
  4. {glaip_sdk-0.6.12.dist-info → glaip_sdk-0.6.14.dist-info}/WHEEL +2 -1
  5. glaip_sdk-0.6.14.dist-info/entry_points.txt +2 -0
  6. glaip_sdk-0.6.14.dist-info/top_level.txt +1 -0
  7. glaip_sdk/agents/__init__.py +0 -27
  8. glaip_sdk/agents/base.py +0 -1191
  9. glaip_sdk/cli/__init__.py +0 -9
  10. glaip_sdk/cli/account_store.py +0 -540
  11. glaip_sdk/cli/agent_config.py +0 -78
  12. glaip_sdk/cli/auth.py +0 -699
  13. glaip_sdk/cli/commands/__init__.py +0 -5
  14. glaip_sdk/cli/commands/accounts.py +0 -746
  15. glaip_sdk/cli/commands/agents.py +0 -1509
  16. glaip_sdk/cli/commands/common_config.py +0 -101
  17. glaip_sdk/cli/commands/configure.py +0 -896
  18. glaip_sdk/cli/commands/mcps.py +0 -1356
  19. glaip_sdk/cli/commands/models.py +0 -69
  20. glaip_sdk/cli/commands/tools.py +0 -576
  21. glaip_sdk/cli/commands/transcripts.py +0 -755
  22. glaip_sdk/cli/commands/update.py +0 -61
  23. glaip_sdk/cli/config.py +0 -95
  24. glaip_sdk/cli/constants.py +0 -38
  25. glaip_sdk/cli/context.py +0 -150
  26. glaip_sdk/cli/core/__init__.py +0 -79
  27. glaip_sdk/cli/core/context.py +0 -124
  28. glaip_sdk/cli/core/output.py +0 -846
  29. glaip_sdk/cli/core/prompting.py +0 -649
  30. glaip_sdk/cli/core/rendering.py +0 -187
  31. glaip_sdk/cli/display.py +0 -355
  32. glaip_sdk/cli/hints.py +0 -57
  33. glaip_sdk/cli/io.py +0 -112
  34. glaip_sdk/cli/main.py +0 -604
  35. glaip_sdk/cli/masking.py +0 -136
  36. glaip_sdk/cli/mcp_validators.py +0 -287
  37. glaip_sdk/cli/pager.py +0 -266
  38. glaip_sdk/cli/parsers/__init__.py +0 -7
  39. glaip_sdk/cli/parsers/json_input.py +0 -177
  40. glaip_sdk/cli/resolution.py +0 -67
  41. glaip_sdk/cli/rich_helpers.py +0 -27
  42. glaip_sdk/cli/slash/__init__.py +0 -15
  43. glaip_sdk/cli/slash/accounts_controller.py +0 -578
  44. glaip_sdk/cli/slash/accounts_shared.py +0 -75
  45. glaip_sdk/cli/slash/agent_session.py +0 -285
  46. glaip_sdk/cli/slash/prompt.py +0 -256
  47. glaip_sdk/cli/slash/remote_runs_controller.py +0 -566
  48. glaip_sdk/cli/slash/session.py +0 -1708
  49. glaip_sdk/cli/slash/tui/__init__.py +0 -9
  50. glaip_sdk/cli/slash/tui/accounts_app.py +0 -876
  51. glaip_sdk/cli/slash/tui/background_tasks.py +0 -72
  52. glaip_sdk/cli/slash/tui/loading.py +0 -58
  53. glaip_sdk/cli/slash/tui/remote_runs_app.py +0 -628
  54. glaip_sdk/cli/transcript/__init__.py +0 -31
  55. glaip_sdk/cli/transcript/cache.py +0 -536
  56. glaip_sdk/cli/transcript/capture.py +0 -329
  57. glaip_sdk/cli/transcript/export.py +0 -38
  58. glaip_sdk/cli/transcript/history.py +0 -815
  59. glaip_sdk/cli/transcript/launcher.py +0 -77
  60. glaip_sdk/cli/transcript/viewer.py +0 -374
  61. glaip_sdk/cli/update_notifier.py +0 -290
  62. glaip_sdk/cli/utils.py +0 -263
  63. glaip_sdk/cli/validators.py +0 -238
  64. glaip_sdk/client/__init__.py +0 -11
  65. glaip_sdk/client/_agent_payloads.py +0 -520
  66. glaip_sdk/client/agent_runs.py +0 -147
  67. glaip_sdk/client/agents.py +0 -1335
  68. glaip_sdk/client/base.py +0 -502
  69. glaip_sdk/client/main.py +0 -249
  70. glaip_sdk/client/mcps.py +0 -370
  71. glaip_sdk/client/run_rendering.py +0 -700
  72. glaip_sdk/client/shared.py +0 -21
  73. glaip_sdk/client/tools.py +0 -661
  74. glaip_sdk/client/validators.py +0 -198
  75. glaip_sdk/config/constants.py +0 -52
  76. glaip_sdk/mcps/__init__.py +0 -21
  77. glaip_sdk/mcps/base.py +0 -345
  78. glaip_sdk/models/__init__.py +0 -90
  79. glaip_sdk/models/agent.py +0 -47
  80. glaip_sdk/models/agent_runs.py +0 -116
  81. glaip_sdk/models/common.py +0 -42
  82. glaip_sdk/models/mcp.py +0 -33
  83. glaip_sdk/models/tool.py +0 -33
  84. glaip_sdk/payload_schemas/__init__.py +0 -7
  85. glaip_sdk/payload_schemas/agent.py +0 -85
  86. glaip_sdk/registry/__init__.py +0 -55
  87. glaip_sdk/registry/agent.py +0 -164
  88. glaip_sdk/registry/base.py +0 -139
  89. glaip_sdk/registry/mcp.py +0 -253
  90. glaip_sdk/registry/tool.py +0 -232
  91. glaip_sdk/runner/__init__.py +0 -59
  92. glaip_sdk/runner/base.py +0 -84
  93. glaip_sdk/runner/deps.py +0 -115
  94. glaip_sdk/runner/langgraph.py +0 -782
  95. glaip_sdk/runner/mcp_adapter/__init__.py +0 -13
  96. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +0 -43
  97. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +0 -257
  98. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +0 -95
  99. glaip_sdk/runner/tool_adapter/__init__.py +0 -18
  100. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +0 -44
  101. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +0 -219
  102. glaip_sdk/tools/__init__.py +0 -22
  103. glaip_sdk/tools/base.py +0 -435
  104. glaip_sdk/utils/__init__.py +0 -86
  105. glaip_sdk/utils/a2a/__init__.py +0 -34
  106. glaip_sdk/utils/a2a/event_processor.py +0 -188
  107. glaip_sdk/utils/agent_config.py +0 -194
  108. glaip_sdk/utils/bundler.py +0 -267
  109. glaip_sdk/utils/client.py +0 -111
  110. glaip_sdk/utils/client_utils.py +0 -486
  111. glaip_sdk/utils/datetime_helpers.py +0 -58
  112. glaip_sdk/utils/discovery.py +0 -78
  113. glaip_sdk/utils/display.py +0 -135
  114. glaip_sdk/utils/export.py +0 -143
  115. glaip_sdk/utils/general.py +0 -61
  116. glaip_sdk/utils/import_export.py +0 -168
  117. glaip_sdk/utils/import_resolver.py +0 -492
  118. glaip_sdk/utils/instructions.py +0 -101
  119. glaip_sdk/utils/rendering/__init__.py +0 -115
  120. glaip_sdk/utils/rendering/formatting.py +0 -264
  121. glaip_sdk/utils/rendering/layout/__init__.py +0 -64
  122. glaip_sdk/utils/rendering/layout/panels.py +0 -156
  123. glaip_sdk/utils/rendering/layout/progress.py +0 -202
  124. glaip_sdk/utils/rendering/layout/summary.py +0 -74
  125. glaip_sdk/utils/rendering/layout/transcript.py +0 -606
  126. glaip_sdk/utils/rendering/models.py +0 -85
  127. glaip_sdk/utils/rendering/renderer/__init__.py +0 -55
  128. glaip_sdk/utils/rendering/renderer/base.py +0 -1024
  129. glaip_sdk/utils/rendering/renderer/config.py +0 -27
  130. glaip_sdk/utils/rendering/renderer/console.py +0 -55
  131. glaip_sdk/utils/rendering/renderer/debug.py +0 -178
  132. glaip_sdk/utils/rendering/renderer/factory.py +0 -138
  133. glaip_sdk/utils/rendering/renderer/stream.py +0 -202
  134. glaip_sdk/utils/rendering/renderer/summary_window.py +0 -79
  135. glaip_sdk/utils/rendering/renderer/thinking.py +0 -273
  136. glaip_sdk/utils/rendering/renderer/toggle.py +0 -182
  137. glaip_sdk/utils/rendering/renderer/tool_panels.py +0 -442
  138. glaip_sdk/utils/rendering/renderer/transcript_mode.py +0 -162
  139. glaip_sdk/utils/rendering/state.py +0 -204
  140. glaip_sdk/utils/rendering/step_tree_state.py +0 -100
  141. glaip_sdk/utils/rendering/steps/__init__.py +0 -34
  142. glaip_sdk/utils/rendering/steps/event_processor.py +0 -778
  143. glaip_sdk/utils/rendering/steps/format.py +0 -176
  144. glaip_sdk/utils/rendering/steps/manager.py +0 -387
  145. glaip_sdk/utils/rendering/timing.py +0 -36
  146. glaip_sdk/utils/rendering/viewer/__init__.py +0 -21
  147. glaip_sdk/utils/rendering/viewer/presenter.py +0 -184
  148. glaip_sdk/utils/resource_refs.py +0 -195
  149. glaip_sdk/utils/run_renderer.py +0 -41
  150. glaip_sdk/utils/runtime_config.py +0 -425
  151. glaip_sdk/utils/serialization.py +0 -424
  152. glaip_sdk/utils/sync.py +0 -142
  153. glaip_sdk/utils/tool_detection.py +0 -33
  154. glaip_sdk/utils/validation.py +0 -264
  155. glaip_sdk-0.6.12.dist-info/RECORD +0 -159
  156. glaip_sdk-0.6.12.dist-info/entry_points.txt +0 -3
@@ -1,876 +0,0 @@
1
- """Textual UI for the /accounts command.
2
-
3
- Provides a minimal interactive list with the same columns/order as the Rich
4
- fallback (name, API URL, masked key, status) and keyboard navigation.
5
-
6
- Authors:
7
- Raymond Christopher (raymond.christopher@gdplabs.id)
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import asyncio
13
- import logging
14
- from collections.abc import Callable
15
- from dataclasses import dataclass
16
- from typing import Any
17
-
18
- from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
19
- from glaip_sdk.cli.commands.common_config import check_connection_with_reason
20
- from glaip_sdk.cli.slash.accounts_shared import (
21
- build_account_rows,
22
- build_account_status_string,
23
- env_credentials_present,
24
- )
25
- from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
26
- from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
27
- from glaip_sdk.cli.validators import validate_api_key
28
- from glaip_sdk.utils.validation import validate_url
29
-
30
- try: # pragma: no cover - optional dependency
31
- from textual import events
32
- from textual.app import App, ComposeResult
33
- from textual.binding import Binding
34
- from textual.containers import Container, Horizontal, Vertical
35
- from textual.screen import ModalScreen
36
- from textual.widgets import Button, Checkbox, DataTable, Footer, Header, Input, LoadingIndicator, Static
37
- except Exception: # pragma: no cover - optional dependency
38
- events = None # type: ignore[assignment]
39
- App = None # type: ignore[assignment]
40
- ComposeResult = None # type: ignore[assignment]
41
- Binding = None # type: ignore[assignment]
42
- Container = None # type: ignore[assignment]
43
- Horizontal = None # type: ignore[assignment]
44
- Vertical = None # type: ignore[assignment]
45
- Button = None # type: ignore[assignment]
46
- Checkbox = None # type: ignore[assignment]
47
- DataTable = None # type: ignore[assignment]
48
- Footer = None # type: ignore[assignment]
49
- Header = None # type: ignore[assignment]
50
- Input = None # type: ignore[assignment]
51
- LoadingIndicator = None # type: ignore[assignment]
52
- ModalScreen = None # type: ignore[assignment]
53
- Static = None # type: ignore[assignment]
54
-
55
- TEXTUAL_SUPPORTED = App is not None and DataTable is not None
56
-
57
- # Use safe bases so the module remains importable without Textual installed.
58
- if TEXTUAL_SUPPORTED:
59
- _AccountFormBase = ModalScreen[dict[str, Any] | None]
60
- _ConfirmDeleteBase = ModalScreen[str | None]
61
- _AppBase = App[None]
62
- else:
63
- _AccountFormBase = object
64
- _ConfirmDeleteBase = object
65
- _AppBase = object
66
-
67
- # Widget IDs for Textual UI
68
- ACCOUNTS_TABLE_ID = "#accounts-table"
69
- FILTER_INPUT_ID = "#filter-input"
70
- STATUS_ID = "#status"
71
- ACCOUNTS_LOADING_ID = "#accounts-loading"
72
- FORM_KEY_ID = "#form-key"
73
-
74
- # CSS file name
75
- CSS_FILE_NAME = "accounts.tcss"
76
-
77
-
78
- @dataclass
79
- class AccountsTUICallbacks:
80
- """Callbacks invoked by the Textual UI."""
81
-
82
- switch_account: Callable[[str], tuple[bool, str]]
83
-
84
-
85
- def _build_account_rows_from_store(
86
- store: AccountStore,
87
- env_lock: bool,
88
- ) -> tuple[list[dict[str, str | bool]], str | None]:
89
- """Load account rows with masking and active flag."""
90
- accounts = store.list_accounts()
91
- active = store.get_active_account()
92
- rows = build_account_rows(accounts, active, env_lock)
93
- return rows, active
94
-
95
-
96
- def _prepare_account_payload(
97
- *,
98
- name: str,
99
- api_url_input: str,
100
- api_key_input: str,
101
- existing_url: str | None,
102
- existing_key: str | None,
103
- existing_names: set[str],
104
- mode: str,
105
- should_test: bool,
106
- validate_name: Callable[[str], None],
107
- connection_tester: Callable[[str, str], tuple[bool, str]],
108
- ) -> tuple[dict[str, Any] | None, str | None]:
109
- """Validate and build payload for add/edit operations."""
110
- name = name.strip()
111
- api_url_raw = api_url_input.strip()
112
- api_key_raw = api_key_input.strip()
113
-
114
- error = _validate_account_name(name, existing_names, mode, validate_name)
115
- if error:
116
- return None, error
117
-
118
- api_url_candidate = api_url_raw or (existing_url or "")
119
- api_key_candidate = api_key_raw or (existing_key or "")
120
-
121
- api_url_validated, error = _validate_and_prepare_url(api_url_candidate)
122
- if error:
123
- return None, error
124
-
125
- api_key_validated, error = _validate_and_prepare_key(api_key_candidate)
126
- if error:
127
- return None, error
128
-
129
- if should_test:
130
- error = _test_connection(api_url_validated, api_key_validated, connection_tester)
131
- if error:
132
- return None, error
133
-
134
- payload: dict[str, Any] = {
135
- "name": name,
136
- "api_url": api_url_validated,
137
- "api_key": api_key_validated,
138
- "should_test": should_test,
139
- "mode": mode,
140
- }
141
- return payload, None
142
-
143
-
144
- def _validate_account_name(
145
- name: str,
146
- existing_names: set[str],
147
- mode: str,
148
- validate_name: Callable[[str], None],
149
- ) -> str | None:
150
- """Validate account name."""
151
- if not name:
152
- return "Account name cannot be empty."
153
-
154
- try:
155
- validate_name(name)
156
- except Exception as exc:
157
- return str(exc)
158
-
159
- if mode == "add" and name in existing_names:
160
- return f"Account '{name}' already exists. Choose a unique name."
161
-
162
- return None
163
-
164
-
165
- def _validate_and_prepare_url(api_url_candidate: str) -> tuple[str, str | None]:
166
- """Validate and prepare API URL."""
167
- if not api_url_candidate:
168
- return "", "API URL is required."
169
- try:
170
- return validate_url(api_url_candidate), None
171
- except Exception as exc:
172
- return "", str(exc)
173
-
174
-
175
- def _validate_and_prepare_key(api_key_candidate: str) -> tuple[str, str | None]:
176
- """Validate and prepare API key."""
177
- if not api_key_candidate:
178
- return "", "API key is required."
179
- try:
180
- return validate_api_key(api_key_candidate), None
181
- except Exception as exc:
182
- return "", str(exc)
183
-
184
-
185
- def _test_connection(
186
- api_url: str,
187
- api_key: str,
188
- connection_tester: Callable[[str, str], tuple[bool, str]],
189
- ) -> str | None:
190
- """Test API connection."""
191
- ok, reason = connection_tester(api_url, api_key)
192
- if not ok:
193
- detail = reason or "connection_failed"
194
- return f"Connection test failed: {detail}"
195
- return None
196
-
197
-
198
- def run_accounts_textual(
199
- rows: list[dict[str, str | bool]],
200
- *,
201
- active_account: str | None,
202
- env_lock: bool,
203
- callbacks: AccountsTUICallbacks,
204
- ) -> None:
205
- """Launch the Textual accounts browser if dependencies are available."""
206
- if not TEXTUAL_SUPPORTED:
207
- return
208
- app = AccountsTextualApp(rows, active_account, env_lock, callbacks)
209
- app.run()
210
-
211
-
212
- class AccountFormModal(_AccountFormBase): # pragma: no cover - interactive
213
- """Modal form for add/edit account."""
214
-
215
- CSS_PATH = CSS_FILE_NAME
216
-
217
- def __init__(
218
- self,
219
- *,
220
- mode: str,
221
- existing: dict[str, str] | None,
222
- existing_names: set[str],
223
- connection_tester: Callable[[str, str], tuple[bool, str]],
224
- validate_name: Callable[[str], None],
225
- ) -> None:
226
- """Initialize the account form modal.
227
-
228
- Args:
229
- mode: Form mode, either "add" or "edit".
230
- existing: Existing account data for edit mode.
231
- existing_names: Set of existing account names for validation.
232
- connection_tester: Callable to test API connection.
233
- validate_name: Callable to validate account name.
234
- """
235
- super().__init__()
236
- self._mode = mode
237
- self._existing = existing or {}
238
- self._existing_names = existing_names
239
- self._connection_tester = connection_tester
240
- self._validate_name = validate_name
241
-
242
- def compose(self) -> ComposeResult:
243
- """Render the form controls."""
244
- title = "Add account" if self._mode == "add" else "Edit account"
245
- name_input = Input(
246
- value=self._existing.get("name", ""),
247
- placeholder="account-name",
248
- id="form-name",
249
- disabled=self._mode == "edit",
250
- )
251
- url_input = Input(value=self._existing.get("api_url", ""), placeholder="https://api.example.com", id="form-url")
252
- key_input = Input(value="", placeholder="sk-...", password=True, id="form-key")
253
- test_checkbox = Checkbox(
254
- "Test connection before save",
255
- value=True,
256
- id="form-test",
257
- )
258
- status = Static("", id="form-status")
259
-
260
- yield Static(title, id="form-title")
261
- yield Static("Name", classes="form-label")
262
- yield name_input
263
- yield Static("API URL", classes="form-label")
264
- yield url_input
265
- yield Static("API Key", classes="form-label")
266
- yield key_input
267
- yield Horizontal(
268
- Button("Show key", id="toggle-key"),
269
- Button("Clear key", id="clear-key"),
270
- id="form-key-actions",
271
- )
272
- yield test_checkbox
273
- yield Horizontal(
274
- Button("Save", id="form-save", variant="primary"),
275
- Button("Cancel", id="form-cancel"),
276
- id="form-actions",
277
- )
278
- yield status
279
-
280
- def on_button_pressed(self, event: Button.Pressed) -> None:
281
- """Handle button presses."""
282
- btn_id = event.button.id or ""
283
- if btn_id == "form-cancel":
284
- self.dismiss(None)
285
- return
286
- if btn_id == "toggle-key":
287
- key_input = self.query_one(FORM_KEY_ID, Input)
288
- key_input.password = not key_input.password
289
- key_input.focus()
290
- return
291
- if btn_id == "clear-key":
292
- key_input = self.query_one(FORM_KEY_ID, Input)
293
- key_input.value = ""
294
- key_input.focus()
295
- return
296
- if btn_id == "form-save":
297
- self._handle_submit()
298
-
299
- def _handle_submit(self) -> None:
300
- """Validate inputs and dismiss with payload on success."""
301
- status = self.query_one("#form-status", Static)
302
- name_input = self.query_one("#form-name", Input)
303
- url_input = self.query_one("#form-url", Input)
304
- key_input = self.query_one(FORM_KEY_ID, Input)
305
- test_checkbox = self.query_one("#form-test", Checkbox)
306
-
307
- payload, error = _prepare_account_payload(
308
- name=name_input.value or "",
309
- api_url_input=url_input.value or "",
310
- api_key_input=key_input.value or "",
311
- existing_url=self._existing.get("api_url"),
312
- existing_key=self._existing.get("api_key"),
313
- existing_names=self._existing_names,
314
- mode=self._mode,
315
- should_test=bool(test_checkbox.value),
316
- validate_name=self._validate_name,
317
- connection_tester=self._connection_tester,
318
- )
319
- if error:
320
- status.update(f"[red]{error}[/]")
321
- if error.startswith("Connection test failed") and hasattr(self.app, "_set_status"):
322
- try:
323
- # Surface a status-bar cue so errors remain visible after closing the modal.
324
- self.app._set_status(error, "yellow") # type: ignore[attr-defined]
325
- except Exception:
326
- pass
327
- return
328
- status.update("[green]Saving...[/]")
329
- self.dismiss(payload)
330
-
331
-
332
- class ConfirmDeleteModal(_ConfirmDeleteBase): # pragma: no cover - interactive
333
- """Modal requiring typed confirmation for delete."""
334
-
335
- CSS_PATH = CSS_FILE_NAME
336
-
337
- def __init__(self, name: str) -> None:
338
- """Initialize the delete confirmation modal.
339
-
340
- Args:
341
- name: Name of the account to delete.
342
- """
343
- super().__init__()
344
- self._name = name
345
-
346
- def compose(self) -> ComposeResult:
347
- """Render confirmation form."""
348
- yield Static(f"Type '{self._name}' to confirm deletion. This cannot be undone.", id="confirm-text")
349
- yield Input(placeholder=self._name, id="confirm-input")
350
- yield Horizontal(
351
- Button("Delete", id="confirm-delete", variant="error"),
352
- Button("Cancel", id="confirm-cancel"),
353
- id="confirm-actions",
354
- )
355
- yield Static("", id="confirm-status")
356
-
357
- def on_button_pressed(self, event: Button.Pressed) -> None:
358
- """Handle confirmation buttons."""
359
- btn_id = event.button.id or ""
360
- if btn_id == "confirm-cancel":
361
- self.dismiss(None)
362
- return
363
- if btn_id == "confirm-delete":
364
- self._handle_confirm()
365
-
366
- def _handle_confirm(self) -> None:
367
- """Dismiss with name when confirmation matches."""
368
- status = self.query_one("#confirm-status", Static)
369
- input_widget = self.query_one("#confirm-input", Input)
370
- if (input_widget.value or "").strip() != self._name:
371
- status.update(f"[yellow]Name does not match; type '{self._name}' to confirm.[/]")
372
- input_widget.focus()
373
- return
374
- self.dismiss(self._name)
375
-
376
-
377
- class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - interactive
378
- """Textual application for browsing accounts."""
379
-
380
- CSS_PATH = CSS_FILE_NAME
381
- 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),
388
- # 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),
391
- ]
392
-
393
- def __init__(
394
- self,
395
- rows: list[dict[str, str | bool]],
396
- active_account: str | None,
397
- env_lock: bool,
398
- callbacks: AccountsTUICallbacks,
399
- ) -> None:
400
- """Initialize the Textual accounts app.
401
-
402
- Args:
403
- rows: Account data rows to display.
404
- active_account: Name of the currently active account.
405
- env_lock: Whether environment credentials are locking account switching.
406
- callbacks: Callbacks for account switching operations.
407
- """
408
- super().__init__()
409
- self._store = get_account_store()
410
- self._all_rows = rows
411
- self._active_account = active_account
412
- self._env_lock = env_lock
413
- self._callbacks = callbacks
414
- self._filter_text: str = ""
415
- self._is_switching = False
416
-
417
- 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
- )
448
- yield Footer()
449
-
450
- 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()
467
-
468
- def _header_text(self) -> str:
469
- """Build header text with active account and host."""
470
- host = self._get_active_host() or "Not configured"
471
- lock_icon = " [yellow]🔒[/]" if self._env_lock else ""
472
- active = self._active_account or "None"
473
- return f"[green]Active:[/] [bold]{active}[/] ([cyan]{host}[/]){lock_icon}"
474
-
475
- def _get_active_host(self) -> str | None:
476
- """Return the API host for the active account (shortened)."""
477
- return self._get_host_for_name(self._active_account)
478
-
479
- def _get_host_for_name(self, name: str | None) -> str | None:
480
- """Return shortened API URL for a given account name."""
481
- if not name:
482
- return None
483
- for row in self._all_rows:
484
- if row.get("name") == name:
485
- url = str(row.get("api_url", ""))
486
- return url if len(url) <= 40 else f"{url[:37]}..."
487
- return None
488
-
489
- def action_focus_filter(self) -> None:
490
- """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()
494
-
495
- def action_switch_row(self) -> None:
496
- """Switch to the currently selected account."""
497
- if self._env_lock:
498
- self._set_status("Switching disabled: env credentials in use.", "yellow")
499
- return
500
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
501
- if table.cursor_row is None:
502
- self._set_status("No account selected.", "yellow")
503
- return
504
- try:
505
- row_key = table.get_row_at(table.cursor_row)[0]
506
- except Exception:
507
- self._set_status("Unable to read selected row.", "red")
508
- return
509
- name = str(row_key)
510
- if self._is_switching:
511
- self._set_status("Already switching...", "yellow")
512
- return
513
- self._is_switching = True
514
- host = self._get_host_for_name(name)
515
- if host:
516
- self._show_loading(f"Connecting to '{name}' ({host})...")
517
- else:
518
- self._show_loading(f"Connecting to '{name}'...")
519
- self._queue_switch(name)
520
-
521
- 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)
524
- try:
525
- # Move cursor to clicked row then switch
526
- table.cursor_coordinate = (event.cursor_row, 0)
527
- except Exception:
528
- return
529
- self.action_switch_row()
530
-
531
- def on_input_submitted(self, event: Input.Submitted) -> None:
532
- """Apply filter when user presses Enter inside filter input."""
533
- self._filter_text = (event.value or "").strip()
534
- self._reload_rows()
535
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
536
- table.focus()
537
- self._update_filter_button_visibility()
538
-
539
- def on_input_changed(self, event: Input.Changed) -> None:
540
- """Apply filter live as the user types."""
541
- self._filter_text = (event.value or "").strip()
542
- self._reload_rows()
543
- self._update_filter_button_visibility()
544
-
545
- def _reload_rows(self, preferred_name: str | None = None) -> None:
546
- """Refresh table rows based on current filter/active state."""
547
- # Work on a copy to avoid mutating the backing rows list
548
- rows_copy = [dict(row) for row in self._all_rows]
549
- for row in rows_copy:
550
- row["active"] = row.get("name") == self._active_account
551
-
552
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
553
- table.clear()
554
- filtered = self._filtered_rows(rows_copy)
555
- for row in filtered:
556
- row_for_status = dict(row)
557
- row_for_status["active"] = row_for_status.get("name") == self._active_account
558
- # Use markup to align status colors with Rich fallback (green active badge).
559
- status = build_account_status_string(row_for_status, use_markup=True)
560
- # pylint: disable=duplicate-code
561
- # Reuses shared status builder; columns mirror accounts_controller Rich table.
562
- table.add_row(
563
- str(row.get("name", "")),
564
- str(row.get("api_url", "")),
565
- str(row.get("masked_key", "")),
566
- status,
567
- )
568
- # Move cursor to active or first row
569
- cursor_idx = 0
570
- target_name = preferred_name or self._active_account
571
- for idx, row in enumerate(filtered):
572
- if row.get("name") == target_name:
573
- cursor_idx = idx
574
- break
575
- if filtered:
576
- table.cursor_coordinate = (cursor_idx, 0)
577
- else:
578
- self._set_status("No accounts match the current filter.", "yellow")
579
- return
580
-
581
- # Update status to reflect filter state
582
- if self._filter_text:
583
- self._set_status(f"Filtered: {self._filter_text}", "cyan")
584
- else:
585
- self._set_status("", "white")
586
-
587
- def _filtered_rows(self, rows: list[dict[str, str | bool]] | None = None) -> list[dict[str, str | bool]]:
588
- """Return rows filtered by name or API URL substring."""
589
- base_rows = rows if rows is not None else [dict(row) for row in self._all_rows]
590
- if not self._filter_text:
591
- return list(base_rows)
592
- needle = self._filter_text.lower()
593
- filtered = [
594
- row
595
- for row in base_rows
596
- if needle in str(row.get("name", "")).lower() or needle in str(row.get("api_url", "")).lower()
597
- ]
598
-
599
- # Sort so name matches surface first, then URL matches, then alphabetically
600
- def score(row: dict[str, str | bool]) -> tuple[int, str]:
601
- name = str(row.get("name", "")).lower()
602
- url = str(row.get("api_url", "")).lower()
603
- name_hit = needle in name
604
- url_hit = needle in url
605
- # Extract nested conditional into clear statement
606
- if name_hit:
607
- priority = 0
608
- elif url_hit:
609
- priority = 1
610
- else:
611
- priority = 2
612
- return (priority, name)
613
-
614
- return sorted(filtered, key=score)
615
-
616
- def _set_status(self, message: str, style: str) -> None:
617
- """Update status line with message."""
618
- status = self.query_one(STATUS_ID, Static)
619
- status.update(f"[{style}]{message}[/]")
620
-
621
- def _show_loading(self, message: str | None = None) -> None:
622
- """Show the loading indicator and optional status message."""
623
- show_loading_indicator(self, ACCOUNTS_LOADING_ID, message=message, set_status=self._set_status)
624
-
625
- def _hide_loading(self) -> None:
626
- """Hide the loading indicator."""
627
- hide_loading_indicator(self, ACCOUNTS_LOADING_ID)
628
-
629
- def _clear_filter(self) -> None:
630
- """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 = ""
634
- self._update_filter_button_visibility()
635
-
636
- def _queue_switch(self, name: str) -> None:
637
- """Run switch in background to keep UI responsive."""
638
-
639
- async def perform() -> None:
640
- try:
641
- switched, message = await asyncio.to_thread(self._callbacks.switch_account, name)
642
- except Exception as exc: # pragma: no cover - defensive
643
- self._set_status(f"Switch failed: {exc}", "red")
644
- return
645
- finally:
646
- self._hide_loading()
647
- self._is_switching = False
648
-
649
- if switched:
650
- self._active_account = name
651
- self._set_status(message or f"Switched to '{name}'.", "green")
652
- self._update_header()
653
- self._reload_rows()
654
- else:
655
- self._set_status(message or "Switch failed; kept previous account.", "yellow")
656
-
657
- try:
658
- self.track_task(perform(), logger=logging.getLogger(__name__))
659
- 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)
665
-
666
- def _update_header(self) -> None:
667
- """Refresh header text to reflect active/lock state."""
668
- header = self.query_one("#header-info", Static)
669
- header.update(self._header_text())
670
-
671
- def action_clear_or_exit(self) -> None:
672
- """Clear or exit filter when focused; otherwise exit app.
673
-
674
- UX note: helps users reset the list without leaving the TUI.
675
- """
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()
684
- return
685
- self.exit()
686
-
687
- def action_app_exit(self) -> None:
688
- """Exit the application regardless of focus state."""
689
- self.exit()
690
-
691
- def on_button_pressed(self, event: Button.Pressed) -> None:
692
- """Handle filter bar buttons."""
693
- if event.button.id == "filter-clear":
694
- self._clear_filter()
695
- self._reload_rows()
696
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
697
- table.focus()
698
-
699
- def action_add_account(self) -> None:
700
- """Open add account modal."""
701
- if self._check_env_lock_hotkey():
702
- return
703
- if self._should_block_actions():
704
- return
705
- existing_names = {str(row.get("name", "")) for row in self._all_rows}
706
- modal = AccountFormModal(
707
- mode="add",
708
- existing=None,
709
- existing_names=existing_names,
710
- connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
711
- validate_name=self._store.validate_account_name,
712
- )
713
- self.push_screen(modal, self._on_form_result)
714
-
715
- def action_edit_account(self) -> None:
716
- """Open edit account modal for selected row."""
717
- if self._check_env_lock_hotkey():
718
- return
719
- if self._should_block_actions():
720
- return
721
- name = self._get_selected_name()
722
- if not name:
723
- self._set_status("Select an account to edit.", "yellow")
724
- return
725
- account = self._store.get_account(name)
726
- if not account:
727
- self._set_status(f"Account '{name}' not found.", "red")
728
- return
729
- existing_names = {str(row.get("name", "")) for row in self._all_rows if str(row.get("name", "")) != name}
730
- modal = AccountFormModal(
731
- mode="edit",
732
- existing={"name": name, "api_url": account.get("api_url", ""), "api_key": account.get("api_key", "")},
733
- existing_names=existing_names,
734
- connection_tester=lambda url, key: check_connection_with_reason(url, key, abort_on_error=False),
735
- validate_name=self._store.validate_account_name,
736
- )
737
- self.push_screen(modal, self._on_form_result)
738
-
739
- def action_delete_account(self) -> None:
740
- """Open delete confirmation modal."""
741
- if self._check_env_lock_hotkey():
742
- return
743
- if self._should_block_actions():
744
- return
745
- name = self._get_selected_name()
746
- if not name:
747
- self._set_status("Select an account to delete.", "yellow")
748
- return
749
- accounts = self._store.list_accounts()
750
- if len(accounts) <= 1:
751
- self._set_status("Cannot remove the last remaining account.", "red")
752
- return
753
- self.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
754
-
755
- def _check_env_lock_hotkey(self) -> bool:
756
- """Prevent mutations when env credentials are present."""
757
- if not self._is_env_locked():
758
- return False
759
- self._env_lock = True
760
- self._set_status("Disabled by env-lock.", "yellow")
761
- # Refresh UI to reflect env-lock state (header/banners/rows)
762
- self._refresh_rows(preferred_name=self._active_account)
763
- return True
764
-
765
- def _on_form_result(self, payload: dict[str, Any] | None) -> None:
766
- """Handle add/edit modal result."""
767
- if payload is None:
768
- self._set_status("Edit/add cancelled.", "yellow")
769
- return
770
- self._save_account(payload)
771
-
772
- def _on_delete_result(self, confirmed_name: str | None) -> None:
773
- """Handle delete confirmation result."""
774
- if not confirmed_name:
775
- self._set_status("Delete cancelled.", "yellow")
776
- return
777
- try:
778
- self._store.remove_account(confirmed_name)
779
- except AccountStoreError as exc:
780
- self._set_status(f"Delete failed: {exc}", "red")
781
- return
782
- except Exception as exc: # pragma: no cover - defensive
783
- self._set_status(f"Unexpected delete error: {exc}", "red")
784
- return
785
-
786
- self._set_status(f"Account '{confirmed_name}' deleted.", "green")
787
- # Clear filter before refresh to show all accounts
788
- self._clear_filter()
789
- # Refresh rows without preferred name to show all accounts
790
- # Active account will be cleared if the deleted account was active
791
- self._refresh_rows(preferred_name=None)
792
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
793
- table.focus()
794
-
795
- def _save_account(self, payload: dict[str, Any]) -> None:
796
- """Persist account data from modal payload."""
797
- if self._is_env_locked():
798
- self._set_status("Disabled by env-lock.", "yellow")
799
- return
800
-
801
- name = str(payload.get("name", ""))
802
- api_url = str(payload.get("api_url", ""))
803
- api_key = str(payload.get("api_key", ""))
804
- set_active = bool(payload.get("set_active", payload.get("mode") == "add"))
805
- is_edit = payload.get("mode") == "edit"
806
-
807
- try:
808
- self._store.add_account(name, api_url, api_key, overwrite=is_edit)
809
- except AccountStoreError as exc:
810
- self._set_status(f"Save failed: {exc}", "red")
811
- return
812
- except Exception as exc: # pragma: no cover - defensive
813
- self._set_status(f"Unexpected save error: {exc}", "red")
814
- return
815
-
816
- if set_active:
817
- try:
818
- self._store.set_active_account(name)
819
- self._active_account = name
820
- except Exception as exc: # pragma: no cover - defensive
821
- self._set_status(f"Saved but could not set active: {exc}", "yellow")
822
- else:
823
- self._announce_active_change(name)
824
- self._update_header()
825
-
826
- self._set_status(f"Account '{name}' saved.", "green")
827
- # Clear filter before refresh to show all accounts
828
- self._clear_filter()
829
- # Refresh rows with preferred name to highlight the saved account
830
- self._refresh_rows(preferred_name=name)
831
- # Return focus to the table for immediate hotkey use
832
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
833
- table.focus()
834
-
835
- def _refresh_rows(self, preferred_name: str | None = None) -> None:
836
- """Reload rows from store and preserve filter/cursor."""
837
- self._env_lock = self._is_env_locked()
838
- self._all_rows, self._active_account = _build_account_rows_from_store(self._store, self._env_lock)
839
- self._reload_rows(preferred_name=preferred_name)
840
- self._update_header()
841
-
842
- def _get_selected_name(self) -> str | None:
843
- """Return selected account name, if any."""
844
- table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
845
- if table.cursor_row is None:
846
- return None
847
- try:
848
- row = table.get_row_at(table.cursor_row)
849
- except Exception:
850
- return None
851
- return str(row[0]) if row else None
852
-
853
- def _is_env_locked(self) -> bool:
854
- """Return True when env credentials are set (even partially)."""
855
- return env_credentials_present(partial=True)
856
-
857
- def _announce_active_change(self, name: str) -> None:
858
- """Surface active account change in status bar."""
859
- account = self._store.get_account(name) or {}
860
- host = account.get("api_url", "")
861
- host_suffix = f" • {host}" if host else ""
862
- self._set_status(f"Active account ➜ {name}{host_suffix}", "green")
863
-
864
- def _should_block_actions(self) -> bool:
865
- """Return True when mutating hotkeys are blocked by filter focus."""
866
- filter_input = self.query_one(FILTER_INPUT_ID, Input)
867
- if filter_input.has_focus:
868
- self._set_status("Exit filter (Esc or Clear) to add/edit/delete.", "yellow")
869
- return True
870
- return False
871
-
872
- def _update_filter_button_visibility(self) -> None:
873
- """Show clear button only when filter has content."""
874
- filter_input = self.query_one(FILTER_INPUT_ID, Input)
875
- clear_btn = self.query_one("#filter-clear", Button)
876
- clear_btn.display = bool(filter_input.value or self._filter_text)