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