glaip-sdk 0.6.15b2__py3-none-any.whl → 0.6.16__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 (154) hide show
  1. glaip_sdk/agents/__init__.py +27 -0
  2. glaip_sdk/agents/base.py +1196 -0
  3. glaip_sdk/cli/__init__.py +9 -0
  4. glaip_sdk/cli/account_store.py +540 -0
  5. glaip_sdk/cli/agent_config.py +78 -0
  6. glaip_sdk/cli/auth.py +699 -0
  7. glaip_sdk/cli/commands/__init__.py +5 -0
  8. glaip_sdk/cli/commands/accounts.py +746 -0
  9. glaip_sdk/cli/commands/agents.py +1509 -0
  10. glaip_sdk/cli/commands/common_config.py +104 -0
  11. glaip_sdk/cli/commands/configure.py +896 -0
  12. glaip_sdk/cli/commands/mcps.py +1356 -0
  13. glaip_sdk/cli/commands/models.py +69 -0
  14. glaip_sdk/cli/commands/tools.py +576 -0
  15. glaip_sdk/cli/commands/transcripts.py +755 -0
  16. glaip_sdk/cli/commands/update.py +61 -0
  17. glaip_sdk/cli/config.py +95 -0
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +150 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +851 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +355 -0
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +112 -0
  28. glaip_sdk/cli/main.py +615 -0
  29. glaip_sdk/cli/masking.py +136 -0
  30. glaip_sdk/cli/mcp_validators.py +287 -0
  31. glaip_sdk/cli/pager.py +266 -0
  32. glaip_sdk/cli/parsers/__init__.py +7 -0
  33. glaip_sdk/cli/parsers/json_input.py +177 -0
  34. glaip_sdk/cli/resolution.py +67 -0
  35. glaip_sdk/cli/rich_helpers.py +27 -0
  36. glaip_sdk/cli/slash/__init__.py +15 -0
  37. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  38. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  39. glaip_sdk/cli/slash/agent_session.py +285 -0
  40. glaip_sdk/cli/slash/prompt.py +256 -0
  41. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  42. glaip_sdk/cli/slash/session.py +1708 -0
  43. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  44. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  45. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  46. glaip_sdk/cli/slash/tui/loading.py +58 -0
  47. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  48. glaip_sdk/cli/transcript/__init__.py +31 -0
  49. glaip_sdk/cli/transcript/cache.py +536 -0
  50. glaip_sdk/cli/transcript/capture.py +329 -0
  51. glaip_sdk/cli/transcript/export.py +38 -0
  52. glaip_sdk/cli/transcript/history.py +815 -0
  53. glaip_sdk/cli/transcript/launcher.py +77 -0
  54. glaip_sdk/cli/transcript/viewer.py +374 -0
  55. glaip_sdk/cli/update_notifier.py +290 -0
  56. glaip_sdk/cli/utils.py +263 -0
  57. glaip_sdk/cli/validators.py +238 -0
  58. glaip_sdk/client/__init__.py +11 -0
  59. glaip_sdk/client/_agent_payloads.py +520 -0
  60. glaip_sdk/client/agent_runs.py +147 -0
  61. glaip_sdk/client/agents.py +1335 -0
  62. glaip_sdk/client/base.py +502 -0
  63. glaip_sdk/client/main.py +249 -0
  64. glaip_sdk/client/mcps.py +370 -0
  65. glaip_sdk/client/run_rendering.py +700 -0
  66. glaip_sdk/client/shared.py +21 -0
  67. glaip_sdk/client/tools.py +661 -0
  68. glaip_sdk/client/validators.py +198 -0
  69. glaip_sdk/config/constants.py +52 -0
  70. glaip_sdk/mcps/__init__.py +21 -0
  71. glaip_sdk/mcps/base.py +345 -0
  72. glaip_sdk/models/__init__.py +90 -0
  73. glaip_sdk/models/agent.py +47 -0
  74. glaip_sdk/models/agent_runs.py +116 -0
  75. glaip_sdk/models/common.py +42 -0
  76. glaip_sdk/models/mcp.py +33 -0
  77. glaip_sdk/models/tool.py +33 -0
  78. glaip_sdk/payload_schemas/__init__.py +7 -0
  79. glaip_sdk/payload_schemas/agent.py +85 -0
  80. glaip_sdk/registry/__init__.py +55 -0
  81. glaip_sdk/registry/agent.py +164 -0
  82. glaip_sdk/registry/base.py +139 -0
  83. glaip_sdk/registry/mcp.py +253 -0
  84. glaip_sdk/registry/tool.py +232 -0
  85. glaip_sdk/runner/__init__.py +59 -0
  86. glaip_sdk/runner/base.py +84 -0
  87. glaip_sdk/runner/deps.py +112 -0
  88. glaip_sdk/runner/langgraph.py +782 -0
  89. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  90. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  91. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  92. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  93. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  94. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  95. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  96. glaip_sdk/tools/__init__.py +22 -0
  97. glaip_sdk/tools/base.py +435 -0
  98. glaip_sdk/utils/__init__.py +86 -0
  99. glaip_sdk/utils/a2a/__init__.py +34 -0
  100. glaip_sdk/utils/a2a/event_processor.py +188 -0
  101. glaip_sdk/utils/agent_config.py +194 -0
  102. glaip_sdk/utils/bundler.py +267 -0
  103. glaip_sdk/utils/client.py +111 -0
  104. glaip_sdk/utils/client_utils.py +486 -0
  105. glaip_sdk/utils/datetime_helpers.py +58 -0
  106. glaip_sdk/utils/discovery.py +78 -0
  107. glaip_sdk/utils/display.py +135 -0
  108. glaip_sdk/utils/export.py +143 -0
  109. glaip_sdk/utils/general.py +61 -0
  110. glaip_sdk/utils/import_export.py +168 -0
  111. glaip_sdk/utils/import_resolver.py +492 -0
  112. glaip_sdk/utils/instructions.py +101 -0
  113. glaip_sdk/utils/rendering/__init__.py +115 -0
  114. glaip_sdk/utils/rendering/formatting.py +264 -0
  115. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  116. glaip_sdk/utils/rendering/layout/panels.py +156 -0
  117. glaip_sdk/utils/rendering/layout/progress.py +202 -0
  118. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  119. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  120. glaip_sdk/utils/rendering/models.py +85 -0
  121. glaip_sdk/utils/rendering/renderer/__init__.py +55 -0
  122. glaip_sdk/utils/rendering/renderer/base.py +1024 -0
  123. glaip_sdk/utils/rendering/renderer/config.py +27 -0
  124. glaip_sdk/utils/rendering/renderer/console.py +55 -0
  125. glaip_sdk/utils/rendering/renderer/debug.py +178 -0
  126. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  127. glaip_sdk/utils/rendering/renderer/stream.py +202 -0
  128. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  129. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  130. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  131. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  132. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  133. glaip_sdk/utils/rendering/state.py +204 -0
  134. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  135. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  136. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  137. glaip_sdk/utils/rendering/steps/format.py +176 -0
  138. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  139. glaip_sdk/utils/rendering/timing.py +36 -0
  140. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  141. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  142. glaip_sdk/utils/resource_refs.py +195 -0
  143. glaip_sdk/utils/run_renderer.py +41 -0
  144. glaip_sdk/utils/runtime_config.py +425 -0
  145. glaip_sdk/utils/serialization.py +424 -0
  146. glaip_sdk/utils/sync.py +142 -0
  147. glaip_sdk/utils/tool_detection.py +33 -0
  148. glaip_sdk/utils/validation.py +264 -0
  149. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/METADATA +4 -5
  150. glaip_sdk-0.6.16.dist-info/RECORD +160 -0
  151. glaip_sdk-0.6.15b2.dist-info/RECORD +0 -12
  152. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/WHEEL +0 -0
  153. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/entry_points.txt +0 -0
  154. {glaip_sdk-0.6.15b2.dist-info → glaip_sdk-0.6.16.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1708 @@
1
+ """SlashSession orchestrates the interactive command palette.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import os
11
+ import shlex
12
+ import sys
13
+ from collections.abc import Callable, Iterable
14
+ from dataclasses import dataclass
15
+ from difflib import get_close_matches
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import click
20
+ from rich.console import Console, Group
21
+ from rich.text import Text
22
+
23
+ from glaip_sdk.branding import (
24
+ ACCENT_STYLE,
25
+ ERROR_STYLE,
26
+ HINT_COMMAND_STYLE,
27
+ HINT_DESCRIPTION_COLOR,
28
+ HINT_PREFIX_STYLE,
29
+ INFO_STYLE,
30
+ PRIMARY,
31
+ SECONDARY_LIGHT,
32
+ SUCCESS_STYLE,
33
+ WARNING_STYLE,
34
+ AIPBranding,
35
+ )
36
+ from glaip_sdk.cli.auth import resolve_api_url_from_context
37
+ from glaip_sdk.cli.account_store import get_account_store
38
+ from glaip_sdk.cli.commands import transcripts as transcripts_cmd
39
+ from glaip_sdk.cli.commands.configure import _configure_interactive, load_config
40
+ from glaip_sdk.cli.commands.update import update_command
41
+ from glaip_sdk.cli.hints import format_command_hint
42
+ from glaip_sdk.cli.slash.accounts_controller import AccountsController
43
+ from glaip_sdk.cli.slash.agent_session import AgentRunSession
44
+ from glaip_sdk.cli.slash.accounts_shared import env_credentials_present
45
+ from glaip_sdk.cli.slash.prompt import (
46
+ FormattedText,
47
+ PromptSession,
48
+ Style,
49
+ patch_stdout,
50
+ setup_prompt_toolkit,
51
+ to_formatted_text,
52
+ )
53
+ from glaip_sdk.cli.slash.remote_runs_controller import RemoteRunsController
54
+ from glaip_sdk.cli.transcript import (
55
+ export_cached_transcript,
56
+ load_history_snapshot,
57
+ )
58
+ from glaip_sdk.cli.transcript.viewer import ViewerContext, run_viewer_session
59
+ 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
+ )
67
+ from glaip_sdk.rich_components import AIPGrid, AIPPanel, AIPTable
68
+
69
+ SlashHandler = Callable[["SlashSession", list[str], bool], bool]
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class SlashCommand:
74
+ """Metadata for a slash command entry."""
75
+
76
+ name: str
77
+ help: str
78
+ handler: SlashHandler
79
+ aliases: tuple[str, ...] = ()
80
+ agent_only: bool = False
81
+
82
+
83
+ NEW_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
84
+ {
85
+ "cli": "transcripts",
86
+ "slash": "transcripts",
87
+ "description": "Review transcript cache",
88
+ "tag": "NEW",
89
+ "priority": 10,
90
+ "scope": "global",
91
+ },
92
+ {
93
+ "cli": None,
94
+ "slash": "runs",
95
+ "description": "View remote run history for the active agent",
96
+ "tag": "NEW",
97
+ "priority": 8,
98
+ "scope": "agent",
99
+ },
100
+ )
101
+
102
+
103
+ DEFAULT_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
104
+ {
105
+ "cli": None,
106
+ "slash": "accounts",
107
+ "description": "Switch account profile",
108
+ "priority": 5,
109
+ },
110
+ {
111
+ "cli": "status",
112
+ "slash": "status",
113
+ "description": "Connection check",
114
+ "priority": 0,
115
+ },
116
+ {
117
+ "cli": "agents list",
118
+ "slash": "agents",
119
+ "description": "Browse agents",
120
+ "priority": 0,
121
+ },
122
+ {
123
+ "cli": "help",
124
+ "slash": "help",
125
+ "description": "Show all commands",
126
+ "priority": 0,
127
+ },
128
+ {
129
+ "cli": "configure",
130
+ "slash": "login",
131
+ "description": f"Configure credentials (alias [{HINT_COMMAND_STYLE}]/configure[/])",
132
+ "priority": -1,
133
+ },
134
+ )
135
+
136
+
137
+ HELP_COMMAND = "/help"
138
+
139
+
140
+ def _quick_action_scope(action: dict[str, Any]) -> str:
141
+ """Return the scope for a quick action definition."""
142
+ scope = action.get("scope") or "global"
143
+ if isinstance(scope, str):
144
+ return scope.lower()
145
+ return "global"
146
+
147
+
148
+ class SlashSession:
149
+ """Interactive command palette controller."""
150
+
151
+ def __init__(self, ctx: click.Context, *, console: Console | None = None) -> None:
152
+ """Initialize the slash session.
153
+
154
+ Args:
155
+ ctx: The Click context
156
+ console: Optional console instance, creates default if None
157
+ """
158
+ self.ctx = ctx
159
+ self.console = console or Console()
160
+ self._commands: dict[str, SlashCommand] = {}
161
+ self._unique_commands: dict[str, SlashCommand] = {}
162
+ self._contextual_commands: dict[str, str] = {}
163
+ self._contextual_include_global: bool = True
164
+ self._client: Any | None = None
165
+ self.recent_agents: list[dict[str, str]] = []
166
+ self.last_run_input: str | None = None
167
+ self._should_exit = False
168
+ self._interactive = bool(sys.stdin.isatty() and sys.stdout.isatty())
169
+ self._config_cache: dict[str, Any] | None = None
170
+ self._welcome_rendered = False
171
+ self._active_renderer: Any | None = None
172
+ self._current_agent: Any | None = None
173
+ self._runs_pagination_state: dict[str, dict[str, Any]] = {} # agent_id -> {page, limit, cursor}
174
+
175
+ self._home_placeholder = "Hint: type / to explore commands · Ctrl+D exits"
176
+
177
+ # Command string constants to avoid duplication
178
+ self.STATUS_COMMAND = "/status"
179
+ self.AGENTS_COMMAND = "/agents"
180
+
181
+ self._ptk_session: PromptSession | None = None
182
+ self._ptk_style: Style | None = None
183
+ self._setup_prompt_toolkit()
184
+ self._register_defaults()
185
+ self._branding = AIPBranding.create_from_sdk()
186
+ self._suppress_login_layout = False
187
+ self._default_actions_shown = False
188
+ self._update_prompt_shown = False
189
+ self._update_notifier = maybe_notify_update
190
+ self._home_hint_shown = False
191
+ self._agent_transcript_ready: dict[str, str] = {}
192
+
193
+ # ------------------------------------------------------------------
194
+ # Session orchestration
195
+ # ------------------------------------------------------------------
196
+ def refresh_branding(
197
+ self,
198
+ sdk_version: str | None = None,
199
+ *,
200
+ branding_cls: type[AIPBranding] | None = None,
201
+ ) -> None:
202
+ """Refresh branding assets after an in-session SDK upgrade."""
203
+ branding_type = branding_cls or AIPBranding
204
+ self._branding = branding_type.create_from_sdk(
205
+ sdk_version=sdk_version,
206
+ package_name="glaip-sdk",
207
+ )
208
+ self._welcome_rendered = False
209
+ self.console.print()
210
+ self.console.print(f"[{SUCCESS_STYLE}]CLI updated to {self._branding.version}. Refreshing banner...[/]")
211
+ self._render_header(initial=True)
212
+
213
+ def _setup_prompt_toolkit(self) -> None:
214
+ """Initialize prompt_toolkit session and style."""
215
+ session, style = setup_prompt_toolkit(self, interactive=self._interactive)
216
+ self._ptk_session = session
217
+ self._ptk_style = style
218
+
219
+ def run(self, initial_commands: Iterable[str] | None = None) -> None:
220
+ """Start the command palette session loop."""
221
+ ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else None
222
+ previous_session = None
223
+ if ctx_obj is not None:
224
+ previous_session = ctx_obj.get("_slash_session")
225
+ ctx_obj["_slash_session"] = self
226
+
227
+ try:
228
+ if not self._interactive:
229
+ self._run_non_interactive(initial_commands)
230
+ return
231
+
232
+ if not self._ensure_configuration():
233
+ return
234
+
235
+ self._maybe_show_update_prompt()
236
+ self._render_header(initial=not self._welcome_rendered)
237
+ if not self._default_actions_shown:
238
+ self._show_default_quick_actions()
239
+ self._run_interactive_loop()
240
+ finally:
241
+ if ctx_obj is not None:
242
+ restore_slash_session_context(ctx_obj, previous_session)
243
+
244
+ def _run_interactive_loop(self) -> None:
245
+ """Run the main interactive command loop."""
246
+ while not self._should_exit:
247
+ try:
248
+ raw = self._prompt("› ", placeholder=self._home_placeholder)
249
+ except EOFError:
250
+ self.console.print("\n👋 Closing the command palette.")
251
+ break
252
+ except KeyboardInterrupt:
253
+ self.console.print("")
254
+ continue
255
+
256
+ if not self._process_command(raw):
257
+ break
258
+
259
+ def _process_command(self, raw: str) -> bool:
260
+ """Process a single command input. Returns False if should exit."""
261
+ raw = raw.strip()
262
+ if not raw:
263
+ return True
264
+
265
+ if raw == "/":
266
+ self._render_home_hint()
267
+ self._cmd_help([], invoked_from_agent=False)
268
+ return True
269
+
270
+ if not raw.startswith("/"):
271
+ self.console.print(f"[{INFO_STYLE}]Hint:[/] start commands with `/`. Try `/agents` to select an agent.")
272
+ return True
273
+
274
+ return self.handle_command(raw)
275
+
276
+ def _run_non_interactive(self, initial_commands: Iterable[str] | None = None) -> None:
277
+ """Run slash commands in non-interactive mode."""
278
+ commands = list(initial_commands or [])
279
+ if not commands:
280
+ commands = [line.strip() for line in sys.stdin if line.strip()]
281
+
282
+ for raw in commands:
283
+ if not raw.startswith("/"):
284
+ continue
285
+ if not self.handle_command(raw):
286
+ break
287
+
288
+ def _handle_account_selection(self) -> bool:
289
+ """Handle account selection when accounts exist but none are active.
290
+
291
+ Returns:
292
+ True if configuration is ready after selection, False if user aborted.
293
+ """
294
+ self.console.print(f"[{INFO_STYLE}]No active account selected. Please choose an account:[/]")
295
+ try:
296
+ self._cmd_accounts([], False)
297
+ self._config_cache = None
298
+ return self._check_configuration_after_selection()
299
+ except KeyboardInterrupt:
300
+ self.console.print(f"[{ERROR_STYLE}]Account selection aborted. Closing the command palette.[/]")
301
+ return False
302
+
303
+ def _check_configuration_after_selection(self) -> bool:
304
+ """Check if configuration is ready after account selection.
305
+
306
+ Returns:
307
+ True if configuration is ready, False otherwise.
308
+ """
309
+ return self._configuration_ready()
310
+
311
+ def _handle_new_account_creation(self) -> bool:
312
+ """Handle new account creation when no accounts exist.
313
+
314
+ Returns:
315
+ True if configuration succeeded, False if user aborted.
316
+ """
317
+ previous_tip_env = os.environ.get("AIP_SUPPRESS_CONFIGURE_TIP")
318
+ os.environ["AIP_SUPPRESS_CONFIGURE_TIP"] = "1"
319
+ self._suppress_login_layout = True
320
+ try:
321
+ self._cmd_login([], False)
322
+ return True
323
+ except KeyboardInterrupt:
324
+ self.console.print(f"[{ERROR_STYLE}]Configuration aborted. Closing the command palette.[/]")
325
+ return False
326
+ finally:
327
+ self._suppress_login_layout = False
328
+ if previous_tip_env is None:
329
+ os.environ.pop("AIP_SUPPRESS_CONFIGURE_TIP", None)
330
+ else:
331
+ os.environ["AIP_SUPPRESS_CONFIGURE_TIP"] = previous_tip_env
332
+
333
+ def _ensure_configuration(self) -> bool:
334
+ """Ensure the CLI has both API URL and credentials before continuing."""
335
+ while not self._configuration_ready():
336
+ store = get_account_store()
337
+ accounts = store.list_accounts()
338
+ active_account = store.get_active_account()
339
+
340
+ # If accounts exist but none are active, show accounts list first
341
+ if accounts and (not active_account or active_account not in accounts):
342
+ if not self._handle_account_selection():
343
+ return False
344
+ continue
345
+
346
+ # No accounts exist - prompt for configuration
347
+ if not self._handle_new_account_creation():
348
+ return False
349
+
350
+ return True
351
+
352
+ def _get_credentials_from_context_and_env(self) -> tuple[str, str]:
353
+ """Get credentials from context and environment variables.
354
+
355
+ Returns:
356
+ Tuple of (api_url, api_key) from context/env overrides.
357
+ """
358
+ api_url = ""
359
+ api_key = ""
360
+ if isinstance(self.ctx.obj, dict):
361
+ api_url = self.ctx.obj.get("api_url", "")
362
+ api_key = self.ctx.obj.get("api_key", "")
363
+ # Environment variables take precedence
364
+ env_url = os.getenv("AIP_API_URL", "")
365
+ env_key = os.getenv("AIP_API_KEY", "")
366
+ return (env_url or api_url, env_key or api_key)
367
+
368
+ def _get_credentials_from_account_store(self) -> tuple[str, str] | None:
369
+ """Get credentials from the active account in account store.
370
+
371
+ Returns:
372
+ Tuple of (api_url, api_key) if active account exists, None otherwise.
373
+ """
374
+ store = get_account_store()
375
+ active_account = store.get_active_account()
376
+ if not active_account:
377
+ return None
378
+
379
+ account = store.get_account(active_account)
380
+ if not account:
381
+ return None
382
+
383
+ api_url = account.get("api_url", "")
384
+ api_key = account.get("api_key", "")
385
+ return (api_url, api_key)
386
+
387
+ def _configuration_ready(self) -> bool:
388
+ """Check whether API URL and credentials are available."""
389
+ # Check for explicit overrides in context/env first
390
+ override_url, override_key = self._get_credentials_from_context_and_env()
391
+ if override_url and override_key:
392
+ return True
393
+
394
+ # Read from account store directly to avoid stale cache
395
+ account_creds = self._get_credentials_from_account_store()
396
+ if account_creds is None:
397
+ return False
398
+
399
+ store_url, store_key = account_creds
400
+
401
+ # Use override values if available, otherwise use store values
402
+ api_url = override_url or store_url
403
+ api_key = override_key or store_key
404
+
405
+ return bool(api_url and api_key)
406
+
407
+ def handle_command(self, raw: str, *, invoked_from_agent: bool = False) -> bool:
408
+ """Parse and execute a single slash command string."""
409
+ verb, args = self._parse(raw)
410
+ if not verb:
411
+ self.console.print(f"[{ERROR_STYLE}]Unrecognised command[/]")
412
+ return True
413
+
414
+ command = self._commands.get(verb)
415
+ if command is None:
416
+ suggestion = self._suggest(verb)
417
+ if suggestion:
418
+ self.console.print(f"[{WARNING_STYLE}]Unknown command '{verb}'. Did you mean '/{suggestion}'?[/]")
419
+ else:
420
+ help_command = HELP_COMMAND
421
+ help_hint = format_command_hint(help_command) or help_command
422
+ self.console.print(
423
+ f"[{WARNING_STYLE}]Unknown command '{verb}'. Type {help_hint} for a list of options.[/]"
424
+ )
425
+ return True
426
+
427
+ should_continue = command.handler(self, args, invoked_from_agent)
428
+ if not should_continue:
429
+ self._should_exit = True
430
+ return False
431
+ return True
432
+
433
+ def _continue_session(self) -> bool:
434
+ """Signal that the slash session should remain active."""
435
+ return not self._should_exit
436
+
437
+ # ------------------------------------------------------------------
438
+ # Command handlers
439
+ # ------------------------------------------------------------------
440
+ def _cmd_help(self, _args: list[str], invoked_from_agent: bool) -> bool:
441
+ """Handle the /help command.
442
+
443
+ Args:
444
+ _args: Command arguments (unused).
445
+ invoked_from_agent: Whether invoked from agent context.
446
+
447
+ Returns:
448
+ True to continue session.
449
+ """
450
+ try:
451
+ if invoked_from_agent:
452
+ self._render_agent_help()
453
+ else:
454
+ self._render_global_help(include_agent_hint=True)
455
+ except Exception as exc: # pragma: no cover - UI/display errors
456
+ self.console.print(f"[{ERROR_STYLE}]Error displaying help: {exc}[/]")
457
+ return False
458
+
459
+ return True
460
+
461
+ def _render_agent_help(self) -> None:
462
+ """Render help text for agent context commands."""
463
+ table = AIPTable()
464
+ table.add_column("Input", style=HINT_COMMAND_STYLE, no_wrap=True)
465
+ table.add_column("What happens", style=HINT_DESCRIPTION_COLOR)
466
+ table.add_row("<message>", "Run the active agent once with that prompt.")
467
+ table.add_row("/details", "Show the agent export (prompts to expand instructions).")
468
+ table.add_row(self.STATUS_COMMAND, "Display connection status without leaving.")
469
+ table.add_row("/runs", "✨ NEW · Open the remote run browser for this agent.")
470
+ table.add_row("/export [path]", "Export the latest agent transcript as JSONL.")
471
+ table.add_row("/exit (/back)", "Return to the slash home screen.")
472
+ table.add_row(f"{HELP_COMMAND} (/?)", "Display this context-aware menu.")
473
+
474
+ panel_items = [table]
475
+ if self.last_run_input:
476
+ panel_items.append(Text.from_markup(f"[dim]Last run input:[/] {self.last_run_input}"))
477
+ panel_items.append(
478
+ Text.from_markup(
479
+ "[dim]Global commands (e.g. `/login`, `/status`) remain available inside the agent prompt.[/dim]"
480
+ )
481
+ )
482
+
483
+ self.console.print(
484
+ AIPPanel(
485
+ Group(*panel_items),
486
+ title="Agent Context",
487
+ border_style=PRIMARY,
488
+ )
489
+ )
490
+ new_commands_table = AIPTable()
491
+ new_commands_table.add_column("Command", style=HINT_COMMAND_STYLE, no_wrap=True)
492
+ new_commands_table.add_column("Description", style=HINT_DESCRIPTION_COLOR)
493
+ new_commands_table.add_row(
494
+ "/runs",
495
+ "✨ NEW · View remote run history with keyboard navigation and export options.",
496
+ )
497
+ self.console.print(
498
+ AIPPanel(
499
+ new_commands_table,
500
+ title="New commands",
501
+ border_style=SECONDARY_LIGHT,
502
+ )
503
+ )
504
+
505
+ def _render_global_help(self, *, include_agent_hint: bool = False) -> None:
506
+ """Render help text for global slash commands."""
507
+ table = AIPTable()
508
+ table.add_column("Command", style=HINT_COMMAND_STYLE, no_wrap=True)
509
+ table.add_column("Description", style=HINT_DESCRIPTION_COLOR)
510
+
511
+ for cmd in self._visible_commands(include_agent_only=False):
512
+ aliases = ", ".join(f"/{alias}" for alias in cmd.aliases if alias)
513
+ verb = f"/{cmd.name}"
514
+ if aliases:
515
+ verb = f"{verb} ({aliases})"
516
+ table.add_row(verb, cmd.help)
517
+
518
+ tip = Text.from_markup(
519
+ f"[{HINT_PREFIX_STYLE}]Tip:[/] "
520
+ f"{format_command_hint(self.AGENTS_COMMAND) or self.AGENTS_COMMAND} "
521
+ "lets you jump into an agent run prompt quickly."
522
+ )
523
+
524
+ self.console.print(
525
+ AIPPanel(
526
+ Group(table, tip),
527
+ title="Slash Commands",
528
+ border_style=PRIMARY,
529
+ )
530
+ )
531
+ if include_agent_hint:
532
+ self.console.print(
533
+ "[dim]Additional commands (e.g. `/runs`) become available after you pick an agent with `/agents`. "
534
+ "Those agent-only commands stay hidden here to avoid confusion.[/]"
535
+ )
536
+
537
+ def _cmd_login(self, _args: list[str], _invoked_from_agent: bool) -> bool:
538
+ """Handle the /login command.
539
+
540
+ Args:
541
+ _args: Command arguments (unused).
542
+ _invoked_from_agent: Whether invoked from agent context (unused).
543
+
544
+ Returns:
545
+ True to continue session.
546
+ """
547
+ self.console.print(f"[{ACCENT_STYLE}]Launching configuration wizard...[/]")
548
+ try:
549
+ # Use the modern account-aware wizard directly (bypasses legacy config gating)
550
+ _configure_interactive(account_name=None)
551
+ self.on_account_switched()
552
+ if self._suppress_login_layout:
553
+ self._welcome_rendered = False
554
+ self._default_actions_shown = False
555
+ else:
556
+ self._render_header(initial=True)
557
+ self._show_default_quick_actions()
558
+ except click.ClickException as exc:
559
+ self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
560
+ return self._continue_session()
561
+
562
+ def _cmd_status(self, _args: list[str], _invoked_from_agent: bool) -> bool:
563
+ """Handle the /status command.
564
+
565
+ Args:
566
+ _args: Command arguments (unused).
567
+ _invoked_from_agent: Whether invoked from agent context (unused).
568
+
569
+ Returns:
570
+ True to continue session.
571
+ """
572
+ ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else None
573
+ previous_console = None
574
+ try:
575
+ status_module = importlib.import_module("glaip_sdk.cli.main")
576
+ status_command = status_module.status
577
+
578
+ if ctx_obj is not None:
579
+ previous_console = ctx_obj.get("_slash_console")
580
+ ctx_obj["_slash_console"] = self.console
581
+
582
+ self.ctx.invoke(status_command)
583
+
584
+ hints: list[tuple[str, str]] = [(self.AGENTS_COMMAND, "Browse agents and run them")]
585
+ if self.recent_agents:
586
+ top = self.recent_agents[0]
587
+ label = top.get("name") or top.get("id")
588
+ hints.append((f"/agents {top.get('id')}", f"Reopen {label}"))
589
+ self._show_quick_actions(hints, title="Next actions")
590
+ except click.ClickException as exc:
591
+ self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
592
+ finally:
593
+ if ctx_obj is not None:
594
+ if previous_console is None:
595
+ ctx_obj.pop("_slash_console", None)
596
+ else:
597
+ ctx_obj["_slash_console"] = previous_console
598
+ return self._continue_session()
599
+
600
+ def _cmd_transcripts(self, args: list[str], _invoked_from_agent: bool) -> bool:
601
+ """Handle the /transcripts command.
602
+
603
+ Args:
604
+ args: Command arguments (limit or detail/show with run_id).
605
+ _invoked_from_agent: Whether invoked from agent context (unused).
606
+
607
+ Returns:
608
+ True to continue session.
609
+ """
610
+ if args and args[0].lower() in {"detail", "show"}:
611
+ if len(args) < 2:
612
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts detail <run_id>[/]")
613
+ return self._continue_session()
614
+ self._show_transcript_detail(args[1])
615
+ return self._continue_session()
616
+
617
+ limit, ok = self._parse_transcripts_limit(args)
618
+ if not ok:
619
+ return self._continue_session()
620
+
621
+ snapshot = load_history_snapshot(limit=limit, ctx=self.ctx)
622
+
623
+ if self._handle_transcripts_empty(snapshot, limit):
624
+ return self._continue_session()
625
+
626
+ self._render_transcripts_snapshot(snapshot)
627
+ return self._continue_session()
628
+
629
+ def _parse_transcripts_limit(self, args: list[str]) -> tuple[int | None, bool]:
630
+ """Parse limit argument from transcripts command.
631
+
632
+ Args:
633
+ args: Command arguments.
634
+
635
+ Returns:
636
+ Tuple of (limit value or None, success boolean).
637
+ """
638
+ if not args:
639
+ return None, True
640
+ try:
641
+ limit = int(args[0])
642
+ except ValueError:
643
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts [limit][/]")
644
+ return None, False
645
+ if limit < 0:
646
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts [limit][/]")
647
+ return None, False
648
+ return limit, True
649
+
650
+ def _handle_transcripts_empty(self, snapshot: Any, limit: int | None) -> bool:
651
+ """Handle empty transcript snapshot cases.
652
+
653
+ Args:
654
+ snapshot: Transcript snapshot object.
655
+ limit: Limit value or None.
656
+
657
+ Returns:
658
+ True if empty case was handled, False otherwise.
659
+ """
660
+ if snapshot.cached_entries == 0:
661
+ self.console.print(f"[{WARNING_STYLE}]No cached transcripts yet. Run an agent first.[/]")
662
+ for warning in snapshot.warnings:
663
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
664
+ return True
665
+ if limit == 0 and snapshot.cached_entries:
666
+ self.console.print(f"[{WARNING_STYLE}]Limit is 0; nothing to display.[/]")
667
+ return True
668
+ return False
669
+
670
+ def _render_transcripts_snapshot(self, snapshot: Any) -> None:
671
+ """Render transcript snapshot table and metadata.
672
+
673
+ Args:
674
+ snapshot: Transcript snapshot object to render.
675
+ """
676
+ size_text = format_size(snapshot.total_size_bytes)
677
+ header = f"[dim]Manifest: {snapshot.manifest_path} · {snapshot.total_entries} runs · {size_text} used[/]"
678
+ self.console.print(header)
679
+
680
+ if snapshot.limit_clamped:
681
+ self.console.print(
682
+ f"[{WARNING_STYLE}]Requested limit exceeded maximum; showing first {snapshot.limit_applied} runs.[/]"
683
+ )
684
+
685
+ if snapshot.total_entries > len(snapshot.entries):
686
+ subset_message = (
687
+ f"[dim]Showing {len(snapshot.entries)} of {snapshot.total_entries} "
688
+ f"runs (limit={snapshot.limit_applied}).[/]"
689
+ )
690
+ self.console.print(subset_message)
691
+ self.console.print("[dim]Hint: run `/transcripts <limit>` to change how many rows are displayed.[/]")
692
+
693
+ if snapshot.migration_summary:
694
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
695
+
696
+ for warning in snapshot.warnings:
697
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
698
+
699
+ table = transcripts_cmd._build_table(snapshot.entries)
700
+ self.console.print(table)
701
+ self.console.print("[dim]! Missing transcript[/]")
702
+
703
+ def _show_transcript_detail(self, run_id: str) -> None:
704
+ """Render the cached transcript log for a single run."""
705
+ snapshot = load_history_snapshot(ctx=self.ctx)
706
+ entry = snapshot.index.get(run_id)
707
+ if entry is None:
708
+ self.console.print(f"[{WARNING_STYLE}]Run id {run_id} was not found in the cache manifest.[/]")
709
+ return
710
+
711
+ try:
712
+ transcript_path, transcript_text = transcripts_cmd._load_transcript_text(entry)
713
+ except click.ClickException as exc:
714
+ self.console.print(f"[{WARNING_STYLE}]{exc}[/]")
715
+ return
716
+
717
+ meta, events = transcripts_cmd._decode_transcript(transcript_text)
718
+ if transcripts_cmd._maybe_launch_transcript_viewer(
719
+ self.ctx,
720
+ entry,
721
+ meta,
722
+ events,
723
+ console_override=self.console,
724
+ force=True,
725
+ initial_view="transcript",
726
+ ):
727
+ if snapshot.migration_summary:
728
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
729
+ for warning in snapshot.warnings:
730
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
731
+ return
732
+
733
+ if snapshot.migration_summary:
734
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
735
+ for warning in snapshot.warnings:
736
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
737
+ view = transcripts_cmd._render_transcript_display(entry, snapshot.manifest_path, transcript_path, meta, events)
738
+ self.console.print(view, markup=False, highlight=False, soft_wrap=True, end="")
739
+
740
+ def _cmd_runs(self, args: list[str], _invoked_from_agent: bool) -> bool:
741
+ """Handle the /runs command for browsing remote agent run history.
742
+
743
+ Args:
744
+ args: Command arguments (optional run_id for detail view).
745
+ _invoked_from_agent: Whether invoked from agent context.
746
+
747
+ Returns:
748
+ True to continue session.
749
+ """
750
+ controller = RemoteRunsController(self)
751
+ return controller.handle_runs_command(args)
752
+
753
+ def _cmd_accounts(self, args: list[str], _invoked_from_agent: bool) -> bool:
754
+ """Handle the /accounts command for listing and switching accounts."""
755
+ controller = AccountsController(self)
756
+ return controller.handle_accounts_command(args)
757
+
758
+ def _cmd_agents(self, args: list[str], _invoked_from_agent: bool) -> bool:
759
+ """Handle the /agents command.
760
+
761
+ Args:
762
+ args: Command arguments (optional agent reference).
763
+ _invoked_from_agent: Whether invoked from agent context (unused).
764
+
765
+ Returns:
766
+ True to continue session.
767
+ """
768
+ client = self._get_client_or_fail()
769
+ if not client:
770
+ return True
771
+
772
+ agents = self._get_agents_or_fail(client)
773
+ if not agents:
774
+ return True
775
+
776
+ picked_agent = self._resolve_or_pick_agent(client, agents, args)
777
+
778
+ if not picked_agent:
779
+ return True
780
+
781
+ return self._run_agent_session(picked_agent)
782
+
783
+ def _get_client_or_fail(self) -> Any:
784
+ """Get client or handle failure and return None."""
785
+ try:
786
+ return self._get_client()
787
+ except click.ClickException as exc:
788
+ self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
789
+ return None
790
+
791
+ def _get_agents_or_fail(self, client: Any) -> list:
792
+ """Get agents list or handle failure and return empty list."""
793
+ try:
794
+ agents = client.list_agents()
795
+ if not agents:
796
+ self._handle_no_agents()
797
+ return agents
798
+ except Exception as exc: # pragma: no cover - API failures
799
+ self.console.print(f"[{ERROR_STYLE}]Failed to load agents: {exc}[/]")
800
+ return []
801
+
802
+ def _handle_no_agents(self) -> None:
803
+ """Handle case when no agents are available."""
804
+ hint = command_hint("agents create", slash_command=None, ctx=self.ctx)
805
+ if hint:
806
+ self.console.print(f"[{WARNING_STYLE}]No agents available. Use `{hint}` to add one.[/]")
807
+ else:
808
+ self.console.print(f"[{WARNING_STYLE}]No agents available.[/]")
809
+
810
+ def _resolve_or_pick_agent(self, client: Any, agents: list, args: list[str]) -> Any:
811
+ """Resolve agent from args or pick interactively."""
812
+ if args:
813
+ picked_agent = self._resolve_agent_from_ref(client, agents, args[0])
814
+ if picked_agent is None:
815
+ self.console.print(
816
+ f"[{WARNING_STYLE}]Could not resolve agent '{args[0]}'. Try `/agents` to browse interactively.[/]"
817
+ )
818
+ return None
819
+ else:
820
+ picked_agent = _fuzzy_pick_for_resources(agents, "agent", "")
821
+
822
+ return picked_agent
823
+
824
+ def _run_agent_session(self, picked_agent: Any) -> bool:
825
+ """Run agent session and show follow-up actions."""
826
+ self._remember_agent(picked_agent)
827
+ AgentRunSession(self, picked_agent).run()
828
+
829
+ # Refresh the main palette header and surface follow-up actions
830
+ self._render_header()
831
+
832
+ self._show_agent_followup_actions(picked_agent)
833
+ return self._continue_session()
834
+
835
+ def _show_agent_followup_actions(self, picked_agent: Any) -> None:
836
+ """Show follow-up action hints after agent session."""
837
+ agent_id = str(getattr(picked_agent, "id", ""))
838
+ agent_label = getattr(picked_agent, "name", "") or agent_id or "this agent"
839
+
840
+ hints: list[tuple[str, str]] = []
841
+ if agent_id:
842
+ hints.append((f"/agents {agent_id}", f"Reopen {agent_label}"))
843
+ hints.extend(
844
+ [
845
+ ("/accounts", "Switch account"),
846
+ (self.AGENTS_COMMAND, "Browse agents"),
847
+ (self.STATUS_COMMAND, "Check connection"),
848
+ ]
849
+ )
850
+
851
+ self._show_quick_actions(hints, title="Next actions")
852
+
853
+ def _cmd_exit(self, _args: list[str], invoked_from_agent: bool) -> bool:
854
+ """Handle the /exit command.
855
+
856
+ Args:
857
+ _args: Command arguments (unused).
858
+ invoked_from_agent: Whether invoked from agent context.
859
+
860
+ Returns:
861
+ False to exit session, True to continue.
862
+ """
863
+ if invoked_from_agent:
864
+ # Returning False would stop the full session; we only want to exit
865
+ # the agent context. Raising a custom flag keeps the outer loop
866
+ # running.
867
+ return True
868
+
869
+ self.console.print(f"[{ACCENT_STYLE}]Closing the command palette.[/]")
870
+ return False
871
+
872
+ # ------------------------------------------------------------------
873
+ # Utilities
874
+ # ------------------------------------------------------------------
875
+ def _register_defaults(self) -> None:
876
+ """Register default slash commands."""
877
+ self._register(
878
+ SlashCommand(
879
+ name="help",
880
+ help="Show available command palette commands.",
881
+ handler=SlashSession._cmd_help,
882
+ aliases=("?",),
883
+ )
884
+ )
885
+ self._register(
886
+ SlashCommand(
887
+ name="login",
888
+ help="Configure API credentials (alias `/configure`).",
889
+ handler=SlashSession._cmd_login,
890
+ aliases=("configure",),
891
+ )
892
+ )
893
+ self._register(
894
+ SlashCommand(
895
+ name="status",
896
+ help="Display connection status summary.",
897
+ handler=SlashSession._cmd_status,
898
+ )
899
+ )
900
+ self._register(
901
+ SlashCommand(
902
+ name="accounts",
903
+ help="✨ NEW · Browse and switch stored accounts (Textual with Rich fallback).",
904
+ handler=SlashSession._cmd_accounts,
905
+ )
906
+ )
907
+ self._register(
908
+ SlashCommand(
909
+ name="transcripts",
910
+ help=(
911
+ "✨ NEW · Review cached transcript history. "
912
+ "Add a number (e.g. `/transcripts 5`) to change the row limit."
913
+ ),
914
+ handler=SlashSession._cmd_transcripts,
915
+ )
916
+ )
917
+ self._register(
918
+ SlashCommand(
919
+ name="agents",
920
+ help="Pick an agent and enter a focused run prompt.",
921
+ handler=SlashSession._cmd_agents,
922
+ )
923
+ )
924
+ self._register(
925
+ SlashCommand(
926
+ name="exit",
927
+ help="Exit the command palette.",
928
+ handler=SlashSession._cmd_exit,
929
+ aliases=("q",),
930
+ )
931
+ )
932
+ self._register(
933
+ SlashCommand(
934
+ name="export",
935
+ help="Export the most recent agent transcript.",
936
+ handler=SlashSession._cmd_export,
937
+ )
938
+ )
939
+ self._register(
940
+ SlashCommand(
941
+ name="update",
942
+ help="Upgrade the glaip-sdk package to the latest version.",
943
+ handler=SlashSession._cmd_update,
944
+ )
945
+ )
946
+ self._register(
947
+ SlashCommand(
948
+ name="runs",
949
+ help="✨ NEW · Browse remote agent run history (requires active agent session).",
950
+ handler=SlashSession._cmd_runs,
951
+ agent_only=True,
952
+ )
953
+ )
954
+
955
+ def _register(self, command: SlashCommand) -> None:
956
+ """Register a slash command.
957
+
958
+ Args:
959
+ command: SlashCommand to register.
960
+ """
961
+ self._unique_commands[command.name] = command
962
+ for key in (command.name, *command.aliases):
963
+ self._commands[key] = command
964
+
965
+ def _visible_commands(self, *, include_agent_only: bool) -> list[SlashCommand]:
966
+ """Return the list of commands that should be shown in global listings."""
967
+ commands = sorted(self._unique_commands.values(), key=lambda c: c.name)
968
+ if include_agent_only:
969
+ return commands
970
+ return [cmd for cmd in commands if not cmd.agent_only]
971
+
972
+ def open_transcript_viewer(self, *, announce: bool = True) -> None:
973
+ """Launch the transcript viewer for the most recent run."""
974
+ payload, manifest = self._get_last_transcript()
975
+ if payload is None or manifest is None:
976
+ if announce:
977
+ self.console.print(f"[{WARNING_STYLE}]No transcript is available yet. Run an agent first.[/]")
978
+ return
979
+
980
+ run_id = manifest.get("run_id")
981
+ if not run_id:
982
+ if announce:
983
+ self.console.print(f"[{WARNING_STYLE}]Latest transcript is missing run metadata.[/]")
984
+ return
985
+
986
+ viewer_ctx = ViewerContext(
987
+ manifest_entry=manifest,
988
+ events=list(getattr(payload, "events", []) or []),
989
+ default_output=getattr(payload, "default_output", ""),
990
+ final_output=getattr(payload, "final_output", ""),
991
+ stream_started_at=getattr(payload, "started_at", None),
992
+ meta=getattr(payload, "meta", {}) or {},
993
+ )
994
+
995
+ def _export(destination: Path) -> Path:
996
+ """Export cached transcript to destination.
997
+
998
+ Args:
999
+ destination: Path to export transcript to.
1000
+
1001
+ Returns:
1002
+ Path to exported transcript file.
1003
+ """
1004
+ return export_cached_transcript(destination=destination, run_id=run_id)
1005
+
1006
+ try:
1007
+ run_viewer_session(self.console, viewer_ctx, _export)
1008
+ except Exception as exc: # pragma: no cover - interactive failures
1009
+ if announce:
1010
+ self.console.print(f"[{ERROR_STYLE}]Failed to launch transcript viewer: {exc}[/]")
1011
+
1012
+ def _get_last_transcript(self) -> tuple[Any | None, dict[str, Any] | None]:
1013
+ """Fetch the most recently stored transcript payload and manifest."""
1014
+ ctx_obj = getattr(self.ctx, "obj", None)
1015
+ if not isinstance(ctx_obj, dict):
1016
+ return None, None
1017
+ payload = ctx_obj.get("_last_transcript_payload")
1018
+ manifest = ctx_obj.get("_last_transcript_manifest")
1019
+ return payload, manifest
1020
+
1021
+ def _cmd_export(self, _args: list[str], _invoked_from_agent: bool) -> bool:
1022
+ """Slash handler for `/export` command."""
1023
+ self.console.print(
1024
+ f"[{WARNING_STYLE}]`/export` is deprecated. Use `/transcripts`, select a run, "
1025
+ "and open the transcript viewer to export.[/]"
1026
+ )
1027
+ return True
1028
+
1029
+ def _cmd_update(self, args: list[str], _invoked_from_agent: bool) -> bool:
1030
+ """Slash handler for `/update` command."""
1031
+ if args:
1032
+ self.console.print("Usage: `/update` upgrades glaip-sdk to the latest published version.")
1033
+ return True
1034
+
1035
+ try:
1036
+ self.ctx.invoke(update_command)
1037
+ return True
1038
+ except click.ClickException as exc:
1039
+ self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
1040
+ # Return False for update command failures to indicate the command didn't complete successfully
1041
+ return False
1042
+
1043
+ # ------------------------------------------------------------------
1044
+ # Agent run coordination helpers
1045
+ # ------------------------------------------------------------------
1046
+ def register_active_renderer(self, renderer: Any) -> None:
1047
+ """Register the renderer currently streaming an agent run."""
1048
+ self._active_renderer = renderer
1049
+ self._sync_active_renderer()
1050
+
1051
+ def clear_active_renderer(self, renderer: Any | None = None) -> None:
1052
+ """Clear the active renderer if it matches the provided instance."""
1053
+ if renderer is not None and renderer is not self._active_renderer:
1054
+ return
1055
+ self._active_renderer = None
1056
+
1057
+ def mark_agent_transcript_ready(self, agent_id: str, run_id: str | None) -> None:
1058
+ """Record that an agent has a transcript ready for the current session."""
1059
+ if not agent_id or not run_id:
1060
+ return
1061
+ self._agent_transcript_ready[agent_id] = run_id
1062
+
1063
+ def clear_agent_transcript_ready(self, agent_id: str | None = None) -> None:
1064
+ """Reset transcript-ready state for an agent or for all agents."""
1065
+ if agent_id:
1066
+ self._agent_transcript_ready.pop(agent_id, None)
1067
+ return
1068
+ self._agent_transcript_ready.clear()
1069
+
1070
+ def notify_agent_run_started(self) -> None:
1071
+ """Mark that an agent run is in progress."""
1072
+ self.clear_active_renderer()
1073
+
1074
+ def notify_agent_run_finished(self) -> None:
1075
+ """Mark that the active agent run has completed."""
1076
+ self.clear_active_renderer()
1077
+
1078
+ def _sync_active_renderer(self) -> None:
1079
+ """Ensure the active renderer stays in standard (non-verbose) mode."""
1080
+ renderer = self._active_renderer
1081
+ if renderer is None:
1082
+ return
1083
+
1084
+ applied = False
1085
+ apply_verbose = getattr(renderer, "apply_verbosity", None)
1086
+ if callable(apply_verbose):
1087
+ try:
1088
+ apply_verbose(False)
1089
+ applied = True
1090
+ except Exception:
1091
+ pass
1092
+
1093
+ if not applied and hasattr(renderer, "verbose"):
1094
+ try:
1095
+ renderer.verbose = False
1096
+ except Exception:
1097
+ pass
1098
+
1099
+ def _parse(self, raw: str) -> tuple[str, list[str]]:
1100
+ """Parse a raw command string into verb and arguments.
1101
+
1102
+ Args:
1103
+ raw: Raw command string.
1104
+
1105
+ Returns:
1106
+ Tuple of (verb, args).
1107
+ """
1108
+ try:
1109
+ tokens = shlex.split(raw)
1110
+ except ValueError:
1111
+ return "", []
1112
+
1113
+ if not tokens:
1114
+ return "", []
1115
+
1116
+ head = tokens[0]
1117
+ if head.startswith("/"):
1118
+ head = head[1:]
1119
+
1120
+ return head, tokens[1:]
1121
+
1122
+ def _suggest(self, verb: str) -> str | None:
1123
+ """Suggest a similar command name for an unknown verb.
1124
+
1125
+ Args:
1126
+ verb: Unknown command verb.
1127
+
1128
+ Returns:
1129
+ Suggested command name or None.
1130
+ """
1131
+ keys = [cmd.name for cmd in self._unique_commands.values()]
1132
+ match = get_close_matches(verb, keys, n=1)
1133
+ return match[0] if match else None
1134
+
1135
+ def _convert_message(self, value: Any) -> Any:
1136
+ """Convert a message value to the appropriate format for display."""
1137
+ if FormattedText is not None and to_formatted_text is not None:
1138
+ return to_formatted_text(value)
1139
+ if FormattedText is not None:
1140
+ return FormattedText([("class:prompt", str(value))])
1141
+ return str(value)
1142
+
1143
+ def _get_prompt_kwargs(self, placeholder: str | None) -> dict[str, Any]:
1144
+ """Get prompt kwargs with optional placeholder styling."""
1145
+ prompt_kwargs: dict[str, Any] = {"style": self._ptk_style}
1146
+ if placeholder:
1147
+ placeholder_text = (
1148
+ FormattedText([("class:placeholder", placeholder)]) if FormattedText is not None else placeholder
1149
+ )
1150
+ prompt_kwargs["placeholder"] = placeholder_text
1151
+ return prompt_kwargs
1152
+
1153
+ def _prompt_with_prompt_toolkit(self, message: str | Callable[[], Any], placeholder: str | None) -> str:
1154
+ """Handle prompting with prompt_toolkit."""
1155
+ with patch_stdout(): # pragma: no cover - UI specific
1156
+ if callable(message):
1157
+
1158
+ def prompt_text() -> Any:
1159
+ """Get formatted prompt text from callable message."""
1160
+ return self._convert_message(message())
1161
+ else:
1162
+ prompt_text = self._convert_message(message)
1163
+
1164
+ prompt_kwargs = self._get_prompt_kwargs(placeholder)
1165
+
1166
+ try:
1167
+ return self._ptk_session.prompt(prompt_text, **prompt_kwargs)
1168
+ except TypeError: # pragma: no cover - compatibility with older prompt_toolkit
1169
+ prompt_kwargs.pop("placeholder", None)
1170
+ return self._ptk_session.prompt(prompt_text, **prompt_kwargs)
1171
+
1172
+ def _extract_message_text(self, raw_value: Any) -> str:
1173
+ """Extract text content from various message formats."""
1174
+ if isinstance(raw_value, str):
1175
+ return raw_value
1176
+
1177
+ try:
1178
+ if FormattedText is not None and isinstance(raw_value, FormattedText):
1179
+ return "".join(text for _style, text in raw_value)
1180
+ elif isinstance(raw_value, list):
1181
+ return "".join(segment[1] for segment in raw_value)
1182
+ else:
1183
+ return str(raw_value)
1184
+ except Exception:
1185
+ return str(raw_value)
1186
+
1187
+ def _prompt_with_basic_input(self, message: str | Callable[[], Any], placeholder: str | None) -> str:
1188
+ """Handle prompting with basic input."""
1189
+ if placeholder:
1190
+ self.console.print(f"[dim]{placeholder}[/dim]")
1191
+
1192
+ raw_value = message() if callable(message) else message
1193
+ actual_message = self._extract_message_text(raw_value)
1194
+
1195
+ return input(actual_message)
1196
+
1197
+ def _prompt(self, message: str | Callable[[], Any], *, placeholder: str | None = None) -> str:
1198
+ """Main prompt function with reduced complexity."""
1199
+ if self._ptk_session and self._ptk_style and patch_stdout:
1200
+ return self._prompt_with_prompt_toolkit(message, placeholder)
1201
+
1202
+ return self._prompt_with_basic_input(message, placeholder)
1203
+
1204
+ def _get_client(self) -> Any: # type: ignore[no-any-return]
1205
+ """Get or create the API client instance.
1206
+
1207
+ Returns:
1208
+ API client instance.
1209
+ """
1210
+ if self._client is None:
1211
+ self._client = get_client(self.ctx)
1212
+ return self._client
1213
+
1214
+ def on_account_switched(self, _account_name: str | None = None) -> None:
1215
+ """Reset any state that depends on the active account.
1216
+
1217
+ The active account can change via `/accounts` (or other flows that call
1218
+ AccountStore.set_active_account). The slash session caches a configured
1219
+ client instance, so we must invalidate it to avoid leaking the previous
1220
+ account's API URL/key into subsequent commands like `/agents` or `/runs`.
1221
+
1222
+ This method clears:
1223
+ - Client and config cache (account-specific credentials)
1224
+ - Current agent and recent agents (agent data is account-scoped)
1225
+ - Runs pagination state (runs are account-scoped)
1226
+ - Active renderer and transcript ready state (UI state tied to account context)
1227
+ - Contextual commands (may be account-specific)
1228
+
1229
+ These broader resets ensure a clean slate when switching accounts, preventing
1230
+ stale data from the previous account from appearing in the new account's context.
1231
+ """
1232
+ self._client = None
1233
+ self._config_cache = None
1234
+ self._current_agent = None
1235
+ self.recent_agents = []
1236
+ self._runs_pagination_state.clear()
1237
+ self.clear_active_renderer()
1238
+ self.clear_agent_transcript_ready()
1239
+ self.set_contextual_commands(None)
1240
+
1241
+ def set_contextual_commands(self, commands: dict[str, str] | None, *, include_global: bool = True) -> None:
1242
+ """Set context-specific commands that should appear in completions."""
1243
+ self._contextual_commands = dict(commands or {})
1244
+ self._contextual_include_global = include_global if commands else True
1245
+
1246
+ def get_contextual_commands(self) -> dict[str, str]: # type: ignore[no-any-return]
1247
+ """Return a copy of the currently active contextual commands."""
1248
+ return dict(self._contextual_commands)
1249
+
1250
+ def should_include_global_commands(self) -> bool:
1251
+ """Return whether global slash commands should appear in completions."""
1252
+ return self._contextual_include_global
1253
+
1254
+ def _remember_agent(self, agent: Any) -> None: # type: ignore[no-any-return]
1255
+ """Remember an agent in recent agents list.
1256
+
1257
+ Args:
1258
+ agent: Agent object to remember.
1259
+ """
1260
+ agent_data = {
1261
+ "id": str(getattr(agent, "id", "")),
1262
+ "name": getattr(agent, "name", "") or "",
1263
+ "type": getattr(agent, "type", "") or "",
1264
+ }
1265
+
1266
+ self.recent_agents = [a for a in self.recent_agents if a.get("id") != agent_data["id"]]
1267
+ self.recent_agents.insert(0, agent_data)
1268
+ self.recent_agents = self.recent_agents[:5]
1269
+
1270
+ def _render_header(
1271
+ self,
1272
+ active_agent: Any | None = None,
1273
+ *,
1274
+ focus_agent: bool = False,
1275
+ initial: bool = False,
1276
+ ) -> None:
1277
+ """Render the session header with branding and status.
1278
+
1279
+ Args:
1280
+ active_agent: Optional active agent to display.
1281
+ focus_agent: Whether to focus on agent display.
1282
+ initial: Whether this is the initial render.
1283
+ """
1284
+ if focus_agent and active_agent is not None:
1285
+ self._render_focused_agent_header(active_agent)
1286
+ return
1287
+
1288
+ full_header = initial or not self._welcome_rendered
1289
+ if full_header:
1290
+ self._render_branding_banner()
1291
+ self.console.rule(style=PRIMARY)
1292
+ self._render_main_header(active_agent, full=full_header)
1293
+ if full_header:
1294
+ self._welcome_rendered = True
1295
+ self.console.print()
1296
+
1297
+ def _render_branding_banner(self) -> None:
1298
+ """Render the GL AIP branding banner."""
1299
+ banner = self._branding.get_welcome_banner()
1300
+ heading = "[bold]>_ GDP Labs AI Agents Package (AIP CLI)[/bold]"
1301
+ self.console.print(heading)
1302
+ self.console.print()
1303
+ self.console.print(banner)
1304
+
1305
+ def _maybe_show_update_prompt(self) -> None:
1306
+ """Display update prompt once per session when applicable."""
1307
+ if self._update_prompt_shown:
1308
+ return
1309
+
1310
+ self._update_notifier(
1311
+ self._branding.version,
1312
+ console=self.console,
1313
+ ctx=self.ctx,
1314
+ slash_command="update",
1315
+ style="panel",
1316
+ )
1317
+ self._update_prompt_shown = True
1318
+
1319
+ def _render_focused_agent_header(self, active_agent: Any) -> None:
1320
+ """Render header when focusing on a specific agent."""
1321
+ agent_info = self._get_agent_info(active_agent)
1322
+ transcript_status = self._get_transcript_status(active_agent)
1323
+
1324
+ header_grid = self._build_header_grid(agent_info, transcript_status)
1325
+ keybar = self._build_keybar()
1326
+ header_grid.add_row(keybar, "")
1327
+
1328
+ # Agent-scoped commands like /runs will appear in /help, no need to duplicate here
1329
+ self.console.print(AIPPanel(header_grid, title="Agent Session", border_style=PRIMARY))
1330
+
1331
+ def _get_agent_info(self, active_agent: Any) -> dict[str, str]:
1332
+ """Extract agent information for display."""
1333
+ agent_id = str(getattr(active_agent, "id", ""))
1334
+ return {
1335
+ "id": agent_id,
1336
+ "name": getattr(active_agent, "name", "") or agent_id,
1337
+ "type": getattr(active_agent, "type", "") or "-",
1338
+ "description": getattr(active_agent, "description", "") or "",
1339
+ }
1340
+
1341
+ def _get_transcript_status(self, active_agent: Any) -> dict[str, Any]:
1342
+ """Get transcript status for the active agent."""
1343
+ agent_id = str(getattr(active_agent, "id", ""))
1344
+ payload, manifest = self._get_last_transcript()
1345
+
1346
+ latest_agent_id = (manifest or {}).get("agent_id")
1347
+ has_transcript = bool(payload and manifest and manifest.get("run_id"))
1348
+ run_id = (manifest or {}).get("run_id")
1349
+ transcript_ready = (
1350
+ has_transcript and latest_agent_id == agent_id and self._agent_transcript_ready.get(agent_id) == run_id
1351
+ )
1352
+
1353
+ return {
1354
+ "has_transcript": has_transcript,
1355
+ "transcript_ready": transcript_ready,
1356
+ "run_id": run_id,
1357
+ }
1358
+
1359
+ def _build_header_grid(self, agent_info: dict[str, str], transcript_status: dict[str, Any]) -> AIPGrid:
1360
+ """Build the main header grid with agent information."""
1361
+ header_grid = AIPGrid(expand=True)
1362
+ header_grid.add_column(ratio=3)
1363
+ header_grid.add_column(ratio=1, justify="right")
1364
+
1365
+ primary_line = (
1366
+ f"[bold]{agent_info['name']}[/bold] · [dim]{agent_info['type']}[/dim] · "
1367
+ f"[{ACCENT_STYLE}]{agent_info['id']}[/]"
1368
+ )
1369
+ status_line = f"[{SUCCESS_STYLE}]ready[/]"
1370
+ if not transcript_status["has_transcript"]:
1371
+ status_line += " · no transcript"
1372
+ elif transcript_status["transcript_ready"]:
1373
+ status_line += " · transcript ready"
1374
+ else:
1375
+ status_line += " · transcript pending"
1376
+ header_grid.add_row(primary_line, status_line)
1377
+
1378
+ if agent_info["description"]:
1379
+ header_grid.add_row(f"[dim]{agent_info['description']}[/dim]", "")
1380
+
1381
+ return header_grid
1382
+
1383
+ def _build_keybar(self) -> AIPGrid:
1384
+ """Build the keybar with command hints."""
1385
+ keybar = AIPGrid(expand=True)
1386
+ keybar.add_column(justify="left", ratio=1)
1387
+ keybar.add_column(justify="left", ratio=1)
1388
+ keybar.add_column(justify="left", ratio=1)
1389
+
1390
+ keybar.add_row(
1391
+ format_command_hint(HELP_COMMAND, "Show commands") or "",
1392
+ format_command_hint("/details", "Agent config (expand prompt)") or "",
1393
+ format_command_hint("/exit", "Back") or "",
1394
+ )
1395
+
1396
+ return keybar
1397
+
1398
+ def _render_main_header(self, active_agent: Any | None = None, *, full: bool = False) -> None:
1399
+ """Render the main AIP environment header."""
1400
+ config = self._load_config()
1401
+
1402
+ account_name, account_host, env_lock = self._get_account_context()
1403
+ api_url = self._get_api_url(config)
1404
+
1405
+ host_display = account_host or "Not configured"
1406
+ account_segment = f"[dim]Account[/dim] • {account_name} ({host_display})"
1407
+ if env_lock:
1408
+ account_segment += " 🔒"
1409
+
1410
+ segments = [account_segment]
1411
+
1412
+ if api_url:
1413
+ base_label = "[dim]Base URL[/dim]"
1414
+ if env_lock:
1415
+ base_label = "[dim]Base URL (env)[/dim]"
1416
+ # Always show Base URL when env-lock is active to reveal overrides
1417
+ if env_lock or api_url != account_host:
1418
+ segments.append(f"{base_label} • {api_url}")
1419
+ elif not api_url:
1420
+ segments.append("[dim]Base URL[/dim] • Not configured")
1421
+
1422
+ agent_info = self._build_agent_status_line(active_agent)
1423
+ if agent_info:
1424
+ segments.append(agent_info)
1425
+
1426
+ rendered_line = " ".join(segments)
1427
+
1428
+ if full:
1429
+ self.console.print(rendered_line, soft_wrap=False)
1430
+ return
1431
+
1432
+ status_bar = AIPGrid(expand=True)
1433
+ status_bar.add_column(ratio=1)
1434
+ status_bar.add_row(rendered_line)
1435
+ self.console.print(
1436
+ AIPPanel(
1437
+ status_bar,
1438
+ border_style=PRIMARY,
1439
+ padding=(0, 1),
1440
+ expand=False,
1441
+ )
1442
+ )
1443
+
1444
+ def _get_api_url(self, _config: dict[str, Any] | None = None) -> str | None:
1445
+ """Get the API URL from context or account store (CLI/palette ignores env credentials)."""
1446
+ return resolve_api_url_from_context(self.ctx)
1447
+
1448
+ def _get_account_context(self) -> tuple[str, str, bool]:
1449
+ """Return active account name, host, and env-lock flag."""
1450
+ try:
1451
+ store = get_account_store()
1452
+ active = store.get_active_account() or "default"
1453
+ account = store.get_account(active) if hasattr(store, "get_account") else None
1454
+ host = ""
1455
+ if account:
1456
+ host = account.get("api_url", "")
1457
+ env_lock = env_credentials_present()
1458
+ return active, host, env_lock
1459
+ except Exception:
1460
+ return "default", "", env_credentials_present()
1461
+
1462
+ def _build_agent_status_line(self, active_agent: Any | None) -> str | None:
1463
+ """Return a short status line about the active or recent agent."""
1464
+ if active_agent is not None:
1465
+ agent_id = str(getattr(active_agent, "id", ""))
1466
+ agent_name = getattr(active_agent, "name", "") or agent_id
1467
+ return f"[dim]Active[/dim]: {agent_name} ({agent_id})"
1468
+ if self.recent_agents:
1469
+ recent = self.recent_agents[0]
1470
+ label = recent.get("name") or recent.get("id") or "-"
1471
+ return f"[dim]Recent[/dim]: {label} ({recent.get('id', '-')})"
1472
+ return None
1473
+
1474
+ def _show_default_quick_actions(self) -> None:
1475
+ """Show simplified help hint to discover commands."""
1476
+ self.console.print(f"[dim]{'─' * 40}[/]")
1477
+ help_hint = format_command_hint(HELP_COMMAND, "Show all commands") or HELP_COMMAND
1478
+ self.console.print(f"• {help_hint}")
1479
+ self._default_actions_shown = True
1480
+
1481
+ def _collect_scoped_new_action_hints(self, scope: str) -> list[tuple[str, str]]:
1482
+ """Return new quick action hints filtered by scope."""
1483
+ scoped_actions = [action for action in NEW_QUICK_ACTIONS if _quick_action_scope(action) == scope]
1484
+ # Don't highlight with sparkle emoji in quick actions display - it will show in command palette instead
1485
+ return self._collect_quick_action_hints(scoped_actions)
1486
+
1487
+ def _collect_quick_action_hints(
1488
+ self,
1489
+ actions: Iterable[dict[str, Any]],
1490
+ ) -> list[tuple[str, str]]:
1491
+ """Collect quick action hints from action definitions.
1492
+
1493
+ Args:
1494
+ actions: Iterable of action dictionaries.
1495
+
1496
+ Returns:
1497
+ List of (command, description) tuples.
1498
+ """
1499
+ collected: list[tuple[str, str]] = []
1500
+
1501
+ def sort_key(payload: dict[str, Any]) -> tuple[int, str]:
1502
+ priority = int(payload.get("priority", 0))
1503
+ label = str(payload.get("slash") or payload.get("cli") or "")
1504
+ return (-priority, label.lower())
1505
+
1506
+ for action in sorted(actions, key=sort_key):
1507
+ hint = self._build_quick_action_hint(action)
1508
+ if hint:
1509
+ collected.append(hint)
1510
+ return collected
1511
+
1512
+ def _build_quick_action_hint(
1513
+ self,
1514
+ action: dict[str, Any],
1515
+ ) -> tuple[str, str] | None:
1516
+ """Build a quick action hint from an action definition.
1517
+
1518
+ Args:
1519
+ action: Action dictionary.
1520
+
1521
+ Returns:
1522
+ Tuple of (command, description) or None.
1523
+ """
1524
+ command = command_hint(action.get("cli"), slash_command=action.get("slash"), ctx=self.ctx)
1525
+ if not command:
1526
+ return None
1527
+ description = action.get("description", "")
1528
+ # Don't include tag or sparkle emoji in quick actions display
1529
+ # The NEW tag will only show in the command dropdown (help text)
1530
+ return command, description
1531
+
1532
+ def _render_quick_action_group(self, hints: list[tuple[str, str]], title: str) -> None:
1533
+ """Render a group of quick action hints.
1534
+
1535
+ Args:
1536
+ hints: List of (command, description) tuples.
1537
+ title: Group title.
1538
+ """
1539
+ for line in self._format_quick_action_lines(hints, title):
1540
+ self.console.print(line)
1541
+
1542
+ def _chunk_tokens(self, tokens: list[str], *, size: int) -> Iterable[list[str]]:
1543
+ """Chunk tokens into groups of specified size.
1544
+
1545
+ Args:
1546
+ tokens: List of tokens to chunk.
1547
+ size: Size of each chunk.
1548
+
1549
+ Yields:
1550
+ Lists of tokens.
1551
+ """
1552
+ for index in range(0, len(tokens), size):
1553
+ yield tokens[index : index + size]
1554
+
1555
+ def _render_home_hint(self) -> None:
1556
+ """Render hint text for home screen."""
1557
+ if self._home_hint_shown:
1558
+ return
1559
+ hint_text = (
1560
+ f"[{HINT_PREFIX_STYLE}]Hint:[/] "
1561
+ f"Type {format_command_hint('/') or '/'} to explore commands · "
1562
+ "Press [dim]Ctrl+D[/] to quit"
1563
+ )
1564
+ self.console.print(hint_text)
1565
+ self._home_hint_shown = True
1566
+
1567
+ def _show_quick_actions(
1568
+ self,
1569
+ hints: Iterable[tuple[str, str]],
1570
+ *,
1571
+ title: str = "Quick actions",
1572
+ inline: bool = False,
1573
+ ) -> None:
1574
+ """Show quick action hints.
1575
+
1576
+ Args:
1577
+ hints: Iterable of (command, description) tuples.
1578
+ title: Title for the hints.
1579
+ inline: Whether to render inline or in a panel.
1580
+ """
1581
+ hint_list = self._normalize_quick_action_hints(hints)
1582
+ if not hint_list:
1583
+ return
1584
+
1585
+ if inline:
1586
+ self._render_inline_quick_actions(hint_list, title)
1587
+ return
1588
+
1589
+ self._render_panel_quick_actions(hint_list, title)
1590
+
1591
+ def _normalize_quick_action_hints(self, hints: Iterable[tuple[str, str]]) -> list[tuple[str, str]]:
1592
+ """Normalize quick action hints by filtering out empty commands.
1593
+
1594
+ Args:
1595
+ hints: Iterable of (command, description) tuples.
1596
+
1597
+ Returns:
1598
+ List of normalized hints.
1599
+ """
1600
+ return [(command, description) for command, description in hints if command]
1601
+
1602
+ def _render_inline_quick_actions(self, hint_list: list[tuple[str, str]], title: str) -> None:
1603
+ """Render quick actions inline.
1604
+
1605
+ Args:
1606
+ hint_list: List of (command, description) tuples.
1607
+ title: Title for the hints.
1608
+ """
1609
+ tokens: list[str] = []
1610
+ for command, description in hint_list:
1611
+ formatted = format_command_hint(command, description)
1612
+ if formatted:
1613
+ tokens.append(formatted)
1614
+ if not tokens:
1615
+ return
1616
+ prefix = f"[dim]{title}:[/]" if title else ""
1617
+ body = " ".join(tokens)
1618
+ text = f"{prefix} {body}" if prefix else body
1619
+ self.console.print(text.strip())
1620
+
1621
+ def _render_panel_quick_actions(self, hint_list: list[tuple[str, str]], title: str) -> None:
1622
+ """Render quick actions in a panel.
1623
+
1624
+ Args:
1625
+ hint_list: List of (command, description) tuples.
1626
+ title: Panel title.
1627
+ """
1628
+ body_lines: list[Text] = []
1629
+ for command, description in hint_list:
1630
+ formatted = format_command_hint(command, description)
1631
+ if formatted:
1632
+ body_lines.append(Text.from_markup(formatted))
1633
+ if not body_lines:
1634
+ return
1635
+ panel_content = Group(*body_lines)
1636
+ self.console.print(AIPPanel(panel_content, title=title, border_style=SECONDARY_LIGHT, expand=False))
1637
+
1638
+ def _format_quick_action_lines(self, hints: list[tuple[str, str]], title: str) -> list[str]:
1639
+ """Return formatted lines for quick action hints."""
1640
+ if not hints:
1641
+ return []
1642
+ formatted_tokens: list[str] = []
1643
+ for command, description in hints:
1644
+ formatted = format_command_hint(command, description)
1645
+ if formatted:
1646
+ formatted_tokens.append(f"• {formatted}")
1647
+ if not formatted_tokens:
1648
+ return []
1649
+ lines: list[str] = []
1650
+ # Use vertical layout (1 per line) for better readability
1651
+ chunks = list(self._chunk_tokens(formatted_tokens, size=1))
1652
+ prefix = f"[dim]{title}[/dim]\n " if title else ""
1653
+ for idx, chunk in enumerate(chunks):
1654
+ row = " ".join(chunk)
1655
+ if idx == 0:
1656
+ lines.append(f"{prefix}{row}" if prefix else row)
1657
+ else:
1658
+ lines.append(f" {row}")
1659
+ return lines
1660
+
1661
+ def _load_config(self) -> dict[str, Any]:
1662
+ """Load configuration with caching.
1663
+
1664
+ Returns:
1665
+ Configuration dictionary.
1666
+ """
1667
+ if self._config_cache is None:
1668
+ try:
1669
+ self._config_cache = load_config() or {}
1670
+ except Exception:
1671
+ self._config_cache = {}
1672
+ return self._config_cache
1673
+
1674
+ def _resolve_agent_from_ref(self, client: Any, available_agents: list[Any], ref: str) -> Any | None:
1675
+ """Resolve an agent from a reference string.
1676
+
1677
+ Args:
1678
+ client: API client instance.
1679
+ available_agents: List of available agents.
1680
+ ref: Reference string (ID or name).
1681
+
1682
+ Returns:
1683
+ Resolved agent or None.
1684
+ """
1685
+ ref = ref.strip()
1686
+ if not ref:
1687
+ return None
1688
+
1689
+ try:
1690
+ agent = client.get_agent_by_id(ref)
1691
+ if agent:
1692
+ return agent
1693
+ except Exception: # pragma: no cover - passthrough
1694
+ pass
1695
+
1696
+ matches = [a for a in available_agents if str(getattr(a, "id", "")) == ref]
1697
+ if matches:
1698
+ return matches[0]
1699
+
1700
+ try:
1701
+ found = client.find_agents(name=ref)
1702
+ except Exception: # pragma: no cover - passthrough
1703
+ found = []
1704
+
1705
+ if len(found) == 1:
1706
+ return found[0]
1707
+
1708
+ return None