glaip-sdk 0.6.5b6__py3-none-any.whl → 0.7.7__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 (116) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. glaip_sdk/agents/base.py +156 -32
  3. glaip_sdk/cli/auth.py +14 -8
  4. glaip_sdk/cli/commands/accounts.py +1 -1
  5. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  6. glaip_sdk/cli/commands/agents/_common.py +561 -0
  7. glaip_sdk/cli/commands/agents/create.py +151 -0
  8. glaip_sdk/cli/commands/agents/delete.py +64 -0
  9. glaip_sdk/cli/commands/agents/get.py +89 -0
  10. glaip_sdk/cli/commands/agents/list.py +129 -0
  11. glaip_sdk/cli/commands/agents/run.py +264 -0
  12. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  13. glaip_sdk/cli/commands/agents/update.py +112 -0
  14. glaip_sdk/cli/commands/common_config.py +15 -12
  15. glaip_sdk/cli/commands/configure.py +2 -3
  16. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  17. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  18. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  19. glaip_sdk/cli/commands/mcps/create.py +152 -0
  20. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  21. glaip_sdk/cli/commands/mcps/get.py +212 -0
  22. glaip_sdk/cli/commands/mcps/list.py +69 -0
  23. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  24. glaip_sdk/cli/commands/mcps/update.py +190 -0
  25. glaip_sdk/cli/commands/models.py +2 -4
  26. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  27. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  28. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  29. glaip_sdk/cli/commands/tools/_common.py +80 -0
  30. glaip_sdk/cli/commands/tools/create.py +228 -0
  31. glaip_sdk/cli/commands/tools/delete.py +61 -0
  32. glaip_sdk/cli/commands/tools/get.py +103 -0
  33. glaip_sdk/cli/commands/tools/list.py +69 -0
  34. glaip_sdk/cli/commands/tools/script.py +49 -0
  35. glaip_sdk/cli/commands/tools/update.py +102 -0
  36. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  37. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  38. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  39. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  40. glaip_sdk/cli/commands/{transcripts.py → transcripts_original.py} +2 -1
  41. glaip_sdk/cli/commands/update.py +163 -17
  42. glaip_sdk/cli/core/output.py +12 -7
  43. glaip_sdk/cli/entrypoint.py +20 -0
  44. glaip_sdk/cli/main.py +127 -39
  45. glaip_sdk/cli/pager.py +3 -3
  46. glaip_sdk/cli/resolution.py +2 -1
  47. glaip_sdk/cli/slash/accounts_controller.py +112 -32
  48. glaip_sdk/cli/slash/agent_session.py +5 -2
  49. glaip_sdk/cli/slash/prompt.py +11 -0
  50. glaip_sdk/cli/slash/remote_runs_controller.py +1 -1
  51. glaip_sdk/cli/slash/session.py +58 -13
  52. glaip_sdk/cli/slash/tui/__init__.py +26 -1
  53. glaip_sdk/cli/slash/tui/accounts.tcss +7 -5
  54. glaip_sdk/cli/slash/tui/accounts_app.py +70 -9
  55. glaip_sdk/cli/slash/tui/clipboard.py +147 -0
  56. glaip_sdk/cli/slash/tui/context.py +59 -0
  57. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  58. glaip_sdk/cli/slash/tui/terminal.py +402 -0
  59. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  60. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  61. glaip_sdk/cli/slash/tui/theme/manager.py +86 -0
  62. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  63. glaip_sdk/cli/slash/tui/toast.py +123 -0
  64. glaip_sdk/cli/transcript/history.py +1 -1
  65. glaip_sdk/cli/transcript/viewer.py +5 -3
  66. glaip_sdk/cli/update_notifier.py +215 -7
  67. glaip_sdk/cli/validators.py +1 -1
  68. glaip_sdk/client/__init__.py +2 -1
  69. glaip_sdk/client/_schedule_payloads.py +89 -0
  70. glaip_sdk/client/agents.py +50 -8
  71. glaip_sdk/client/hitl.py +136 -0
  72. glaip_sdk/client/main.py +7 -1
  73. glaip_sdk/client/mcps.py +44 -13
  74. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  75. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +22 -47
  76. glaip_sdk/client/payloads/agent/responses.py +43 -0
  77. glaip_sdk/client/run_rendering.py +367 -3
  78. glaip_sdk/client/schedules.py +439 -0
  79. glaip_sdk/client/tools.py +57 -26
  80. glaip_sdk/hitl/__init__.py +48 -0
  81. glaip_sdk/hitl/base.py +64 -0
  82. glaip_sdk/hitl/callback.py +43 -0
  83. glaip_sdk/hitl/local.py +121 -0
  84. glaip_sdk/hitl/remote.py +523 -0
  85. glaip_sdk/models/__init__.py +17 -0
  86. glaip_sdk/models/agent_runs.py +2 -1
  87. glaip_sdk/models/schedule.py +224 -0
  88. glaip_sdk/registry/tool.py +273 -59
  89. glaip_sdk/runner/__init__.py +20 -3
  90. glaip_sdk/runner/deps.py +5 -8
  91. glaip_sdk/runner/langgraph.py +317 -42
  92. glaip_sdk/runner/logging_config.py +77 -0
  93. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +104 -5
  94. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +72 -7
  95. glaip_sdk/schedules/__init__.py +22 -0
  96. glaip_sdk/schedules/base.py +291 -0
  97. glaip_sdk/tools/base.py +44 -11
  98. glaip_sdk/utils/__init__.py +1 -0
  99. glaip_sdk/utils/bundler.py +138 -2
  100. glaip_sdk/utils/import_resolver.py +43 -11
  101. glaip_sdk/utils/rendering/renderer/base.py +58 -0
  102. glaip_sdk/utils/runtime_config.py +15 -12
  103. glaip_sdk/utils/sync.py +31 -11
  104. glaip_sdk/utils/tool_detection.py +274 -6
  105. glaip_sdk/utils/tool_storage_provider.py +140 -0
  106. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.7.dist-info}/METADATA +47 -37
  107. glaip_sdk-0.7.7.dist-info/RECORD +213 -0
  108. {glaip_sdk-0.6.5b6.dist-info → glaip_sdk-0.7.7.dist-info}/WHEEL +2 -1
  109. glaip_sdk-0.7.7.dist-info/entry_points.txt +2 -0
  110. glaip_sdk-0.7.7.dist-info/top_level.txt +1 -0
  111. glaip_sdk/cli/commands/agents.py +0 -1509
  112. glaip_sdk/cli/commands/mcps.py +0 -1356
  113. glaip_sdk/cli/commands/tools.py +0 -576
  114. glaip_sdk/cli/utils.py +0 -263
  115. glaip_sdk-0.6.5b6.dist-info/RECORD +0 -159
  116. glaip_sdk-0.6.5b6.dist-info/entry_points.txt +0 -3
@@ -151,79 +151,149 @@ class AccountsController:
151
151
 
152
152
  def _render_textual(self, rows: list[dict[str, str | bool]], store: AccountStore, env_lock: bool) -> None:
153
153
  """Launch the Textual accounts browser."""
154
- callbacks = AccountsTUICallbacks(switch_account=lambda name: self._switch_account(store, name, env_lock))
154
+ active_before = store.get_active_account()
155
+ notified = False
156
+
157
+ def _switch_in_textual(name: str) -> tuple[bool, str]:
158
+ nonlocal notified
159
+ switched, message = self._switch_account(
160
+ store,
161
+ name,
162
+ env_lock,
163
+ emit_console=False,
164
+ invalidate_session=True,
165
+ )
166
+ if switched:
167
+ notified = True
168
+ return switched, message
169
+
170
+ callbacks = AccountsTUICallbacks(switch_account=_switch_in_textual)
155
171
  active = next((row["name"] for row in rows if row.get("active")), None)
156
- run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks)
157
- # Exit snapshot: surface a success banner when a switch occurred inside the TUI
158
- active_after = store.get_active_account() or "default"
172
+ try:
173
+ # Inject TUI context for theme support
174
+ tui_ctx = getattr(self.session, "tui_ctx", None)
175
+ run_accounts_textual(rows, active_account=active, env_lock=env_lock, callbacks=callbacks, ctx=tui_ctx)
176
+ except Exception as exc: # pragma: no cover - defensive around Textual failures
177
+ self.console.print(f"[{WARNING_STYLE}]Accounts browser exited unexpectedly: {exc}[/]")
178
+
179
+ # Exit snapshot: surface a success banner when a switch occurred inside the TUI.
180
+ # Always notify when the active account changed, even if Textual raised.
181
+ active_after = store.get_active_account()
182
+ if active_after != active_before and not notified:
183
+ self._notify_account_switched(active_after)
159
184
  if active_after != active:
160
185
  host_after = ""
161
- account_after = store.get_account(active_after) if hasattr(store, "get_account") else None
186
+ display_account = active_after or "default"
187
+ account_after = store.get_account(display_account) if hasattr(store, "get_account") else None
162
188
  if account_after:
163
189
  host_after = account_after.get("api_url", "")
164
190
  host_suffix = f" • {host_after}" if host_after else ""
165
191
  self.console.print(
166
192
  AIPPanel(
167
- f"[{SUCCESS_STYLE}]Active account ➜ {active_after}[/]{host_suffix}",
193
+ f"[{SUCCESS_STYLE}]Active account ➜ {display_account}[/]{host_suffix}",
168
194
  title="✅ Account Switched",
169
195
  border_style=SUCCESS_STYLE,
170
196
  )
171
197
  )
172
198
 
173
- def _switch_account(self, store: AccountStore, name: str, env_lock: bool) -> tuple[bool, str]:
174
- """Validate and switch active account; returns (success, message)."""
199
+ def _format_connection_error_message(self, error_reason: str, account_name: str, api_url: str) -> str:
200
+ """Format error message for connection validation failures."""
201
+ code, detail = self._parse_error_reason(error_reason)
202
+ if code == "connection_failed":
203
+ return f"Switch aborted: cannot reach {api_url}. Check URL or network."
204
+ if code == "api_failed":
205
+ return f"Switch aborted: API error for '{account_name}'. Check credentials."
206
+ detail_suffix = f": {detail}" if detail else ""
207
+ return f"Switch aborted: {code or 'Validation failed'}{detail_suffix}"
208
+
209
+ def _emit_error_message(self, msg: str, style: str = ERROR_STYLE) -> None:
210
+ """Emit an error or warning message to the console."""
211
+ self.console.print(f"[{style}]{msg}[/]")
212
+
213
+ def _validate_account_switch(
214
+ self, store: AccountStore, name: str, env_lock: bool, emit_console: bool
215
+ ) -> tuple[bool, str, dict[str, str] | None]:
216
+ """Validate account switch prerequisites; returns (is_valid, error_msg, account_dict)."""
175
217
  if env_lock:
176
218
  msg = "Env credentials detected (AIP_API_URL/AIP_API_KEY); switching is disabled."
177
- self.console.print(f"[{WARNING_STYLE}]{msg}[/]")
178
- return False, msg
219
+ if emit_console:
220
+ self._emit_error_message(msg, WARNING_STYLE)
221
+ return False, msg, None
179
222
 
180
223
  account = store.get_account(name)
181
224
  if not account:
182
225
  msg = f"Account '{name}' not found."
183
- self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
184
- return False, msg
226
+ if emit_console:
227
+ self._emit_error_message(msg)
228
+ return False, msg, None
185
229
 
186
230
  api_url = account.get("api_url", "")
187
231
  api_key = account.get("api_key", "")
188
232
  if not api_url or not api_key:
189
233
  edit_cmd = f"aip accounts edit {name}"
190
234
  msg = f"Account '{name}' is missing credentials. Use `/login` or `{edit_cmd}`."
191
- self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
192
- return False, msg
235
+ if emit_console:
236
+ self._emit_error_message(msg)
237
+ return False, msg, None
193
238
 
194
239
  ok, error_reason = check_connection_with_reason(api_url, api_key, abort_on_error=False)
195
240
  if not ok:
196
- code, detail = self._parse_error_reason(error_reason)
197
- if code == "connection_failed":
198
- msg = f"Switch aborted: cannot reach {api_url}. Check URL or network."
199
- elif code == "api_failed":
200
- msg = f"Switch aborted: API error for '{name}'. Check credentials."
201
- else:
202
- detail_suffix = f": {detail}" if detail else ""
203
- msg = f"Switch aborted: {code or 'Validation failed'}{detail_suffix}"
204
- self.console.print(f"[{WARNING_STYLE}]{msg}[/]")
205
- return False, msg
241
+ msg = self._format_connection_error_message(error_reason, name, api_url)
242
+ if emit_console:
243
+ self._emit_error_message(msg, WARNING_STYLE)
244
+ return False, msg, None
206
245
 
246
+ return True, "", account
247
+
248
+ def _execute_account_switch(
249
+ self, store: AccountStore, name: str, account: dict[str, str], invalidate_session: bool, emit_console: bool
250
+ ) -> tuple[bool, str]:
251
+ """Execute the account switch and emit success message."""
207
252
  try:
208
253
  store.set_active_account(name)
254
+ api_url = account.get("api_url", "")
255
+ api_key = account.get("api_key", "")
209
256
  masked_key = mask_api_key_display(api_key)
210
- self.console.print(
211
- AIPPanel(
212
- f"[{SUCCESS_STYLE}]Active account ➜ {name}[/]\nAPI URL: {api_url}\nKey: {masked_key}",
213
- title="✅ Account Switched",
214
- border_style=SUCCESS_STYLE,
257
+ if invalidate_session:
258
+ self._notify_account_switched(name)
259
+ if emit_console:
260
+ self.console.print(
261
+ AIPPanel(
262
+ f"[{SUCCESS_STYLE}]Active account ➜ {name}[/]\nAPI URL: {api_url}\nKey: {masked_key}",
263
+ title="✅ Account Switched",
264
+ border_style=SUCCESS_STYLE,
265
+ )
215
266
  )
216
- )
217
267
  return True, f"Switched to '{name}'."
218
268
  except AccountStoreError as exc:
219
269
  msg = f"Failed to set active account: {exc}"
220
- self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
270
+ if emit_console:
271
+ self._emit_error_message(msg)
221
272
  return False, msg
222
273
  except Exception as exc: # NOSONAR(S1045) - catch-all needed for unexpected errors
223
274
  msg = f"Unexpected error while switching to '{name}': {exc}"
224
- self.console.print(f"[{ERROR_STYLE}]{msg}[/]")
275
+ if emit_console:
276
+ self._emit_error_message(msg)
225
277
  return False, msg
226
278
 
279
+ def _switch_account(
280
+ self,
281
+ store: AccountStore,
282
+ name: str,
283
+ env_lock: bool,
284
+ *,
285
+ emit_console: bool = True,
286
+ invalidate_session: bool = True,
287
+ ) -> tuple[bool, str]:
288
+ """Validate and switch active account; returns (success, message)."""
289
+ is_valid, error_msg, account = self._validate_account_switch(store, name, env_lock, emit_console)
290
+ if not is_valid:
291
+ return False, error_msg
292
+
293
+ if account is None: # Defensive – should never happen, but avoid crashing in production
294
+ return False, "Unable to locate account after validation."
295
+ return self._execute_account_switch(store, name, account, invalidate_session, emit_console)
296
+
227
297
  @staticmethod
228
298
  def _parse_error_reason(reason: str | None) -> tuple[str, str]:
229
299
  """Parse error reason into (code, detail) to avoid fragile substring checks."""
@@ -404,8 +474,18 @@ class AccountsController:
404
474
  except Exception as exc:
405
475
  self.console.print(f"[{WARNING_STYLE}]Account saved but could not set active: {exc}[/]")
406
476
  else:
477
+ self._notify_account_switched(name)
407
478
  self._announce_active_change(store, name)
408
479
 
480
+ def _notify_account_switched(self, name: str | None) -> None:
481
+ """Best-effort notify the hosting session that the active account changed."""
482
+ notify = getattr(self.session, "on_account_switched", None)
483
+ if callable(notify):
484
+ try:
485
+ notify(name)
486
+ except Exception: # pragma: no cover - best-effort callback
487
+ pass
488
+
409
489
  def _confirm_delete_prompt(self, name: str) -> bool:
410
490
  """Ask for delete confirmation; return True when confirmed."""
411
491
  self.console.print(f"[{WARNING_STYLE}]Type '{name}' to confirm deletion. This cannot be undone.[/]")
@@ -17,7 +17,7 @@ from glaip_sdk.cli.commands.agents import run as agents_run_command
17
17
  from glaip_sdk.cli.constants import DEFAULT_AGENT_INSTRUCTION_PREVIEW_LIMIT
18
18
  from glaip_sdk.cli.hints import format_command_hint
19
19
  from glaip_sdk.cli.slash.prompt import _HAS_PROMPT_TOOLKIT, FormattedText
20
- from glaip_sdk.cli.utils import bind_slash_session_context
20
+ from glaip_sdk.cli.core.context import bind_slash_session_context
21
21
 
22
22
  if TYPE_CHECKING: # pragma: no cover - type checking only
23
23
  from glaip_sdk.cli.slash.session import SlashSession
@@ -38,7 +38,10 @@ class AgentRunSession:
38
38
  self.console = session.console
39
39
  self._agent_id = str(getattr(agent, "id", ""))
40
40
  self._agent_name = getattr(agent, "name", "") or self._agent_id
41
- self._prompt_placeholder: str = "Chat with this agent here; use / for shortcuts. Alt+Enter inserts a newline."
41
+ self._prompt_placeholder: str = (
42
+ "Chat with this agent here; use / for shortcuts. "
43
+ "Alt+Enter inserts a newline. Ctrl+T opens the last transcript."
44
+ )
42
45
  self._contextual_completion_help: dict[str, str] = {
43
46
  "details": "Show this agent's configuration (+ expands prompt).",
44
47
  "help": "Display this context-aware menu.",
@@ -162,6 +162,17 @@ def _create_key_bindings(_session: SlashSession) -> Any:
162
162
  if buffer.complete_state is not None:
163
163
  buffer.cancel_completion()
164
164
 
165
+ @bindings.add("c-t") # type: ignore[misc]
166
+ def _handle_ctrl_t_key(event: Any) -> None: # vulture: ignore
167
+ """Handle Ctrl+T key - open the transcript viewer (when available)."""
168
+ buffer = event.app.current_buffer
169
+ if buffer.complete_state is not None:
170
+ buffer.cancel_completion()
171
+
172
+ open_viewer = getattr(_session, "open_transcript_viewer", None)
173
+ if callable(open_viewer):
174
+ open_viewer(announce=True)
175
+
165
176
  return bindings
166
177
 
167
178
 
@@ -30,7 +30,7 @@ from glaip_sdk.branding import (
30
30
  )
31
31
  from glaip_sdk.cli.constants import DEFAULT_REMOTE_RUNS_PAGE_LIMIT
32
32
  from glaip_sdk.cli.slash.tui.remote_runs_app import RemoteRunsTUICallbacks, run_remote_runs_textual
33
- from glaip_sdk.cli.utils import prompt_export_choice_questionary, questionary_safe_ask
33
+ from glaip_sdk.cli.core.prompting import prompt_export_choice_questionary, questionary_safe_ask
34
34
  from glaip_sdk.exceptions import (
35
35
  AuthenticationError,
36
36
  ForbiddenError,
@@ -6,6 +6,7 @@ Authors:
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  import importlib
10
11
  import os
11
12
  import shlex
@@ -51,19 +52,17 @@ from glaip_sdk.cli.slash.prompt import (
51
52
  to_formatted_text,
52
53
  )
53
54
  from glaip_sdk.cli.slash.remote_runs_controller import RemoteRunsController
55
+ from glaip_sdk.cli.slash.tui.context import TUIContext
54
56
  from glaip_sdk.cli.transcript import (
55
57
  export_cached_transcript,
56
58
  load_history_snapshot,
57
59
  )
58
60
  from glaip_sdk.cli.transcript.viewer import ViewerContext, run_viewer_session
59
61
  from glaip_sdk.cli.update_notifier import maybe_notify_update
60
- from glaip_sdk.cli.utils import (
61
- _fuzzy_pick_for_resources,
62
- command_hint,
63
- format_size,
64
- get_client,
65
- restore_slash_session_context,
66
- )
62
+ from glaip_sdk.cli.core.context import get_client, restore_slash_session_context
63
+ from glaip_sdk.cli.core.output import format_size
64
+ from glaip_sdk.cli.core.prompting import _fuzzy_pick_for_resources
65
+ from glaip_sdk.cli.hints import command_hint
67
66
  from glaip_sdk.rich_components import AIPGrid, AIPPanel, AIPTable
68
67
 
69
68
  SlashHandler = Callable[["SlashSession", list[str], bool], bool]
@@ -189,6 +188,7 @@ class SlashSession:
189
188
  self._update_notifier = maybe_notify_update
190
189
  self._home_hint_shown = False
191
190
  self._agent_transcript_ready: dict[str, str] = {}
191
+ self.tui_ctx: TUIContext | None = None
192
192
 
193
193
  # ------------------------------------------------------------------
194
194
  # Session orchestration
@@ -218,6 +218,22 @@ class SlashSession:
218
218
 
219
219
  def run(self, initial_commands: Iterable[str] | None = None) -> None:
220
220
  """Start the command palette session loop."""
221
+ # Initialize TUI context asynchronously
222
+ try:
223
+ self.tui_ctx = asyncio.run(TUIContext.create())
224
+ except RuntimeError:
225
+ try:
226
+ loop = asyncio.get_event_loop()
227
+ except RuntimeError:
228
+ self.tui_ctx = None
229
+ else:
230
+ if loop.is_running():
231
+ self.tui_ctx = None
232
+ else:
233
+ self.tui_ctx = loop.run_until_complete(TUIContext.create())
234
+ except Exception:
235
+ self.tui_ctx = None
236
+
221
237
  ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else None
222
238
  previous_session = None
223
239
  if ctx_obj is not None:
@@ -548,7 +564,7 @@ class SlashSession:
548
564
  try:
549
565
  # Use the modern account-aware wizard directly (bypasses legacy config gating)
550
566
  _configure_interactive(account_name=None)
551
- self._config_cache = None
567
+ self.on_account_switched()
552
568
  if self._suppress_login_layout:
553
569
  self._welcome_rendered = False
554
570
  self._default_actions_shown = False
@@ -1211,6 +1227,33 @@ class SlashSession:
1211
1227
  self._client = get_client(self.ctx)
1212
1228
  return self._client
1213
1229
 
1230
+ def on_account_switched(self, _account_name: str | None = None) -> None:
1231
+ """Reset any state that depends on the active account.
1232
+
1233
+ The active account can change via `/accounts` (or other flows that call
1234
+ AccountStore.set_active_account). The slash session caches a configured
1235
+ client instance, so we must invalidate it to avoid leaking the previous
1236
+ account's API URL/key into subsequent commands like `/agents` or `/runs`.
1237
+
1238
+ This method clears:
1239
+ - Client and config cache (account-specific credentials)
1240
+ - Current agent and recent agents (agent data is account-scoped)
1241
+ - Runs pagination state (runs are account-scoped)
1242
+ - Active renderer and transcript ready state (UI state tied to account context)
1243
+ - Contextual commands (may be account-specific)
1244
+
1245
+ These broader resets ensure a clean slate when switching accounts, preventing
1246
+ stale data from the previous account from appearing in the new account's context.
1247
+ """
1248
+ self._client = None
1249
+ self._config_cache = None
1250
+ self._current_agent = None
1251
+ self.recent_agents = []
1252
+ self._runs_pagination_state.clear()
1253
+ self.clear_active_renderer()
1254
+ self.clear_agent_transcript_ready()
1255
+ self.set_contextual_commands(None)
1256
+
1214
1257
  def set_contextual_commands(self, commands: dict[str, str] | None, *, include_global: bool = True) -> None:
1215
1258
  """Set context-specific commands that should appear in completions."""
1216
1259
  self._contextual_commands = dict(commands or {})
@@ -1340,14 +1383,16 @@ class SlashSession:
1340
1383
  f"[{ACCENT_STYLE}]{agent_info['id']}[/]"
1341
1384
  )
1342
1385
  status_line = f"[{SUCCESS_STYLE}]ready[/]"
1343
- status_line += " · transcript ready" if transcript_status["transcript_ready"] else " · transcript pending"
1386
+ if not transcript_status["has_transcript"]:
1387
+ status_line += " · no transcript"
1388
+ elif transcript_status["transcript_ready"]:
1389
+ status_line += " · transcript ready"
1390
+ else:
1391
+ status_line += " · transcript pending"
1344
1392
  header_grid.add_row(primary_line, status_line)
1345
1393
 
1346
1394
  if agent_info["description"]:
1347
- description = agent_info["description"]
1348
- if not transcript_status["transcript_ready"]:
1349
- description = f"{description} (transcript pending)"
1350
- header_grid.add_row(f"[dim]{description}[/dim]", "")
1395
+ header_grid.add_row(f"[dim]{agent_info['description']}[/dim]", "")
1351
1396
 
1352
1397
  return header_grid
1353
1398
 
@@ -1,9 +1,34 @@
1
1
  """Textual UI helpers for slash commands."""
2
2
 
3
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter, ClipboardResult
4
+ from glaip_sdk.cli.slash.tui.context import TUIContext
5
+ from glaip_sdk.cli.slash.tui.keybind_registry import (
6
+ Keybind,
7
+ KeybindRegistry,
8
+ format_key_sequence,
9
+ parse_key_sequence,
10
+ )
11
+ from glaip_sdk.cli.slash.tui.toast import ToastBus, ToastVariant
3
12
  from glaip_sdk.cli.slash.tui.remote_runs_app import (
4
13
  RemoteRunsTextualApp,
5
14
  RemoteRunsTUICallbacks,
6
15
  run_remote_runs_textual,
7
16
  )
17
+ from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities, detect_terminal_background
8
18
 
9
- __all__ = ["RemoteRunsTextualApp", "RemoteRunsTUICallbacks", "run_remote_runs_textual"]
19
+ __all__ = [
20
+ "TUIContext",
21
+ "ToastBus",
22
+ "ToastVariant",
23
+ "TerminalCapabilities",
24
+ "detect_terminal_background",
25
+ "RemoteRunsTextualApp",
26
+ "RemoteRunsTUICallbacks",
27
+ "run_remote_runs_textual",
28
+ "KeybindRegistry",
29
+ "Keybind",
30
+ "parse_key_sequence",
31
+ "format_key_sequence",
32
+ "ClipboardAdapter",
33
+ "ClipboardResult",
34
+ ]
@@ -11,7 +11,7 @@
11
11
 
12
12
  #env-lock {
13
13
  padding: 0 1 0 1;
14
- color: yellow;
14
+ color: $warning;
15
15
  height: 1;
16
16
  }
17
17
 
@@ -45,6 +45,7 @@
45
45
  padding: 0 1 0 1;
46
46
  margin: 0 0 0 0;
47
47
  height: 1fr;
48
+ border: tall $primary;
48
49
  }
49
50
 
50
51
  #status-bar {
@@ -58,11 +59,12 @@
58
59
  }
59
60
 
60
61
  #status {
61
- padding: 0 1 0 1;
62
- margin: 0;
63
- color: cyan;
62
+ height: 3;
63
+ padding: 0 1;
64
+ color: $secondary;
64
65
  }
65
66
 
67
+
66
68
  .form-label {
67
69
  padding: 0 1 0 1;
68
70
  }
@@ -73,7 +75,7 @@
73
75
 
74
76
  #form-status, #confirm-status {
75
77
  padding: 0 1;
76
- color: yellow;
78
+ color: $warning;
77
79
  }
78
80
 
79
81
  #form-test {
@@ -23,7 +23,10 @@ from glaip_sdk.cli.slash.accounts_shared import (
23
23
  env_credentials_present,
24
24
  )
25
25
  from glaip_sdk.cli.slash.tui.background_tasks import BackgroundTaskMixin
26
+ from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter
27
+ from glaip_sdk.cli.slash.tui.context import TUIContext
26
28
  from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
29
+ from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
27
30
  from glaip_sdk.cli.validators import validate_api_key
28
31
  from glaip_sdk.utils.validation import validate_url
29
32
 
@@ -51,6 +54,13 @@ except Exception: # pragma: no cover - optional dependency
51
54
  LoadingIndicator = None # type: ignore[assignment]
52
55
  ModalScreen = None # type: ignore[assignment]
53
56
  Static = None # type: ignore[assignment]
57
+ Theme = None # type: ignore[assignment]
58
+
59
+ if App is not None:
60
+ try: # pragma: no cover - optional dependency
61
+ from textual.theme import Theme
62
+ except Exception: # pragma: no cover - optional dependency
63
+ Theme = None # type: ignore[assignment]
54
64
 
55
65
  TEXTUAL_SUPPORTED = App is not None and DataTable is not None
56
66
 
@@ -201,11 +211,12 @@ def run_accounts_textual(
201
211
  active_account: str | None,
202
212
  env_lock: bool,
203
213
  callbacks: AccountsTUICallbacks,
214
+ ctx: TUIContext | None = None,
204
215
  ) -> None:
205
216
  """Launch the Textual accounts browser if dependencies are available."""
206
217
  if not TEXTUAL_SUPPORTED:
207
218
  return
208
- app = AccountsTextualApp(rows, active_account, env_lock, callbacks)
219
+ app = AccountsTextualApp(rows, active_account, env_lock, callbacks, ctx=ctx)
209
220
  app.run()
210
221
 
211
222
 
@@ -379,16 +390,18 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
379
390
 
380
391
  CSS_PATH = CSS_FILE_NAME
381
392
  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),
393
+ Binding("enter", "switch_row", "Switch", show=True) if Binding else None,
394
+ Binding("return", "switch_row", "Switch", show=False) if Binding else None,
395
+ Binding("/", "focus_filter", "Filter", show=True) if Binding else None,
396
+ Binding("a", "add_account", "Add", show=True) if Binding else None,
397
+ Binding("e", "edit_account", "Edit", show=True) if Binding else None,
398
+ Binding("d", "delete_account", "Delete", show=True) if Binding else None,
399
+ Binding("c", "copy_account", "Copy", show=True) if Binding else None,
388
400
  # 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),
401
+ Binding("escape", "clear_or_exit", "Close", priority=True) if Binding else None,
402
+ Binding("q", "app_exit", "Close", priority=True) if Binding else None,
391
403
  ]
404
+ BINDINGS = [b for b in BINDINGS if b is not None]
392
405
 
393
406
  def __init__(
394
407
  self,
@@ -396,6 +409,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
396
409
  active_account: str | None,
397
410
  env_lock: bool,
398
411
  callbacks: AccountsTUICallbacks,
412
+ ctx: TUIContext | None = None,
399
413
  ) -> None:
400
414
  """Initialize the Textual accounts app.
401
415
 
@@ -404,6 +418,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
404
418
  active_account: Name of the currently active account.
405
419
  env_lock: Whether environment credentials are locking account switching.
406
420
  callbacks: Callbacks for account switching operations.
421
+ ctx: Shared TUI context.
407
422
  """
408
423
  super().__init__()
409
424
  self._store = get_account_store()
@@ -411,6 +426,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
411
426
  self._active_account = active_account
412
427
  self._env_lock = env_lock
413
428
  self._callbacks = callbacks
429
+ self._ctx = ctx
414
430
  self._filter_text: str = ""
415
431
  self._is_switching = False
416
432
 
@@ -449,6 +465,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
449
465
 
450
466
  def on_mount(self) -> None:
451
467
  """Configure table columns and load rows."""
468
+ self._apply_theme()
452
469
  table = self.query_one(ACCOUNTS_TABLE_ID, DataTable)
453
470
  table.add_column("Name", width=20)
454
471
  table.add_column("API URL", width=40)
@@ -684,6 +701,10 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
684
701
  return
685
702
  self.exit()
686
703
 
704
+ def action_app_exit(self) -> None:
705
+ """Exit the application regardless of focus state."""
706
+ self.exit()
707
+
687
708
  def on_button_pressed(self, event: Button.Pressed) -> None:
688
709
  """Handle filter bar buttons."""
689
710
  if event.button.id == "filter-clear":
@@ -748,6 +769,26 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
748
769
  return
749
770
  self.push_screen(ConfirmDeleteModal(name), self._on_delete_result)
750
771
 
772
+ def action_copy_account(self) -> None:
773
+ """Copy selected account name and URL to clipboard."""
774
+ name = self._get_selected_name()
775
+ if not name:
776
+ self._set_status("Select an account to copy.", "yellow")
777
+ return
778
+
779
+ account = self._store.get_account(name)
780
+ if not account:
781
+ return
782
+
783
+ text = f"Account: {name}\nURL: {account.get('api_url', '')}"
784
+ adapter = ClipboardAdapter()
785
+ result = adapter.copy(text)
786
+
787
+ if result.success:
788
+ self._set_status(f"Copied '{name}' to clipboard.", "green")
789
+ else:
790
+ self._set_status(f"Copy failed: {result.message}", "red")
791
+
751
792
  def _check_env_lock_hotkey(self) -> bool:
752
793
  """Prevent mutations when env credentials are present."""
753
794
  if not self._is_env_locked():
@@ -870,3 +911,23 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
870
911
  filter_input = self.query_one(FILTER_INPUT_ID, Input)
871
912
  clear_btn = self.query_one("#filter-clear", Button)
872
913
  clear_btn.display = bool(filter_input.value or self._filter_text)
914
+
915
+ def _apply_theme(self) -> None:
916
+ """Register built-in themes and set the active one from context."""
917
+ if not self._ctx or not self._ctx.theme or Theme is None:
918
+ return
919
+
920
+ for name, tokens in _BUILTIN_THEMES.items():
921
+ self.register_theme(
922
+ Theme(
923
+ name=name,
924
+ primary=tokens.primary,
925
+ secondary=tokens.secondary,
926
+ accent=tokens.accent,
927
+ warning=tokens.warning,
928
+ error=tokens.error,
929
+ success=tokens.success,
930
+ )
931
+ )
932
+
933
+ self.theme = self._ctx.theme.theme_name