glaip-sdk 0.0.20__py3-none-any.whl → 0.7.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1250 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +271 -45
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents/__init__.py +119 -0
  12. glaip_sdk/cli/commands/agents/_common.py +561 -0
  13. glaip_sdk/cli/commands/agents/create.py +151 -0
  14. glaip_sdk/cli/commands/agents/delete.py +64 -0
  15. glaip_sdk/cli/commands/agents/get.py +89 -0
  16. glaip_sdk/cli/commands/agents/list.py +129 -0
  17. glaip_sdk/cli/commands/agents/run.py +264 -0
  18. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  19. glaip_sdk/cli/commands/agents/update.py +112 -0
  20. glaip_sdk/cli/commands/common_config.py +104 -0
  21. glaip_sdk/cli/commands/configure.py +734 -143
  22. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  23. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  24. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  25. glaip_sdk/cli/commands/mcps/create.py +152 -0
  26. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  27. glaip_sdk/cli/commands/mcps/get.py +212 -0
  28. glaip_sdk/cli/commands/mcps/list.py +69 -0
  29. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  30. glaip_sdk/cli/commands/mcps/update.py +190 -0
  31. glaip_sdk/cli/commands/models.py +14 -12
  32. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  33. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  34. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  35. glaip_sdk/cli/commands/tools/_common.py +80 -0
  36. glaip_sdk/cli/commands/tools/create.py +228 -0
  37. glaip_sdk/cli/commands/tools/delete.py +61 -0
  38. glaip_sdk/cli/commands/tools/get.py +103 -0
  39. glaip_sdk/cli/commands/tools/list.py +69 -0
  40. glaip_sdk/cli/commands/tools/script.py +49 -0
  41. glaip_sdk/cli/commands/tools/update.py +102 -0
  42. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  43. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  44. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  45. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  46. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  47. glaip_sdk/cli/commands/update.py +164 -23
  48. glaip_sdk/cli/config.py +49 -7
  49. glaip_sdk/cli/constants.py +38 -0
  50. glaip_sdk/cli/context.py +8 -0
  51. glaip_sdk/cli/core/__init__.py +79 -0
  52. glaip_sdk/cli/core/context.py +124 -0
  53. glaip_sdk/cli/core/output.py +851 -0
  54. glaip_sdk/cli/core/prompting.py +649 -0
  55. glaip_sdk/cli/core/rendering.py +187 -0
  56. glaip_sdk/cli/display.py +45 -32
  57. glaip_sdk/cli/entrypoint.py +20 -0
  58. glaip_sdk/cli/hints.py +57 -0
  59. glaip_sdk/cli/io.py +14 -17
  60. glaip_sdk/cli/main.py +344 -167
  61. glaip_sdk/cli/masking.py +21 -33
  62. glaip_sdk/cli/mcp_validators.py +5 -15
  63. glaip_sdk/cli/pager.py +15 -22
  64. glaip_sdk/cli/parsers/__init__.py +1 -3
  65. glaip_sdk/cli/parsers/json_input.py +11 -22
  66. glaip_sdk/cli/resolution.py +5 -10
  67. glaip_sdk/cli/rich_helpers.py +1 -3
  68. glaip_sdk/cli/slash/__init__.py +0 -9
  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 +65 -29
  72. glaip_sdk/cli/slash/prompt.py +24 -10
  73. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  74. glaip_sdk/cli/slash/session.py +827 -232
  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 +12 -52
  91. glaip_sdk/cli/transcript/cache.py +258 -60
  92. glaip_sdk/cli/transcript/capture.py +72 -21
  93. glaip_sdk/cli/transcript/history.py +815 -0
  94. glaip_sdk/cli/transcript/launcher.py +1 -3
  95. glaip_sdk/cli/transcript/viewer.py +79 -329
  96. glaip_sdk/cli/update_notifier.py +385 -24
  97. glaip_sdk/cli/validators.py +16 -18
  98. glaip_sdk/client/__init__.py +3 -1
  99. glaip_sdk/client/_schedule_payloads.py +89 -0
  100. glaip_sdk/client/agent_runs.py +147 -0
  101. glaip_sdk/client/agents.py +370 -100
  102. glaip_sdk/client/base.py +78 -35
  103. glaip_sdk/client/hitl.py +136 -0
  104. glaip_sdk/client/main.py +25 -10
  105. glaip_sdk/client/mcps.py +166 -27
  106. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  107. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +65 -74
  108. glaip_sdk/client/payloads/agent/responses.py +43 -0
  109. glaip_sdk/client/run_rendering.py +583 -79
  110. glaip_sdk/client/schedules.py +439 -0
  111. glaip_sdk/client/shared.py +21 -0
  112. glaip_sdk/client/tools.py +214 -56
  113. glaip_sdk/client/validators.py +20 -48
  114. glaip_sdk/config/constants.py +11 -0
  115. glaip_sdk/exceptions.py +1 -3
  116. glaip_sdk/hitl/__init__.py +48 -0
  117. glaip_sdk/hitl/base.py +64 -0
  118. glaip_sdk/hitl/callback.py +43 -0
  119. glaip_sdk/hitl/local.py +121 -0
  120. glaip_sdk/hitl/remote.py +523 -0
  121. glaip_sdk/icons.py +9 -3
  122. glaip_sdk/mcps/__init__.py +21 -0
  123. glaip_sdk/mcps/base.py +345 -0
  124. glaip_sdk/models/__init__.py +107 -0
  125. glaip_sdk/models/agent.py +47 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/mcp.py +33 -0
  129. glaip_sdk/models/schedule.py +224 -0
  130. glaip_sdk/models/tool.py +33 -0
  131. glaip_sdk/payload_schemas/__init__.py +1 -13
  132. glaip_sdk/payload_schemas/agent.py +1 -3
  133. glaip_sdk/registry/__init__.py +55 -0
  134. glaip_sdk/registry/agent.py +164 -0
  135. glaip_sdk/registry/base.py +139 -0
  136. glaip_sdk/registry/mcp.py +253 -0
  137. glaip_sdk/registry/tool.py +445 -0
  138. glaip_sdk/rich_components.py +58 -2
  139. glaip_sdk/runner/__init__.py +76 -0
  140. glaip_sdk/runner/base.py +84 -0
  141. glaip_sdk/runner/deps.py +112 -0
  142. glaip_sdk/runner/langgraph.py +872 -0
  143. glaip_sdk/runner/logging_config.py +77 -0
  144. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  145. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  146. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  147. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  148. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  149. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  150. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  151. glaip_sdk/schedules/__init__.py +22 -0
  152. glaip_sdk/schedules/base.py +291 -0
  153. glaip_sdk/tools/__init__.py +22 -0
  154. glaip_sdk/tools/base.py +468 -0
  155. glaip_sdk/utils/__init__.py +59 -12
  156. glaip_sdk/utils/a2a/__init__.py +34 -0
  157. glaip_sdk/utils/a2a/event_processor.py +188 -0
  158. glaip_sdk/utils/agent_config.py +4 -14
  159. glaip_sdk/utils/bundler.py +403 -0
  160. glaip_sdk/utils/client.py +111 -0
  161. glaip_sdk/utils/client_utils.py +46 -28
  162. glaip_sdk/utils/datetime_helpers.py +58 -0
  163. glaip_sdk/utils/discovery.py +78 -0
  164. glaip_sdk/utils/display.py +25 -21
  165. glaip_sdk/utils/export.py +143 -0
  166. glaip_sdk/utils/general.py +1 -36
  167. glaip_sdk/utils/import_export.py +15 -16
  168. glaip_sdk/utils/import_resolver.py +524 -0
  169. glaip_sdk/utils/instructions.py +101 -0
  170. glaip_sdk/utils/rendering/__init__.py +115 -1
  171. glaip_sdk/utils/rendering/formatting.py +38 -23
  172. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  173. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  174. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  175. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  176. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  177. glaip_sdk/utils/rendering/models.py +18 -8
  178. glaip_sdk/utils/rendering/renderer/__init__.py +9 -51
  179. glaip_sdk/utils/rendering/renderer/base.py +534 -882
  180. glaip_sdk/utils/rendering/renderer/config.py +4 -10
  181. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  182. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  183. glaip_sdk/utils/rendering/renderer/stream.py +13 -54
  184. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  185. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  186. glaip_sdk/utils/rendering/renderer/toggle.py +182 -0
  187. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  188. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  189. glaip_sdk/utils/rendering/state.py +204 -0
  190. glaip_sdk/utils/rendering/step_tree_state.py +100 -0
  191. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  192. glaip_sdk/utils/rendering/steps/event_processor.py +778 -0
  193. glaip_sdk/utils/rendering/steps/format.py +176 -0
  194. glaip_sdk/utils/rendering/{steps.py → steps/manager.py} +122 -26
  195. glaip_sdk/utils/rendering/timing.py +36 -0
  196. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  197. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  198. glaip_sdk/utils/resource_refs.py +29 -26
  199. glaip_sdk/utils/runtime_config.py +425 -0
  200. glaip_sdk/utils/serialization.py +32 -46
  201. glaip_sdk/utils/sync.py +162 -0
  202. glaip_sdk/utils/tool_detection.py +301 -0
  203. glaip_sdk/utils/tool_storage_provider.py +140 -0
  204. glaip_sdk/utils/validation.py +20 -28
  205. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/METADATA +78 -23
  206. glaip_sdk-0.7.7.dist-info/RECORD +213 -0
  207. {glaip_sdk-0.0.20.dist-info → glaip_sdk-0.7.7.dist-info}/WHEEL +2 -1
  208. glaip_sdk-0.7.7.dist-info/entry_points.txt +2 -0
  209. glaip_sdk-0.7.7.dist-info/top_level.txt +1 -0
  210. glaip_sdk/cli/commands/agents.py +0 -1412
  211. glaip_sdk/cli/commands/mcps.py +0 -1225
  212. glaip_sdk/cli/commands/tools.py +0 -597
  213. glaip_sdk/cli/utils.py +0 -1330
  214. glaip_sdk/models.py +0 -259
  215. glaip_sdk-0.0.20.dist-info/RECORD +0 -80
  216. glaip_sdk-0.0.20.dist-info/entry_points.txt +0 -3
@@ -6,6 +6,7 @@ Authors:
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import asyncio
9
10
  import importlib
10
11
  import os
11
12
  import shlex
@@ -33,9 +34,15 @@ from glaip_sdk.branding import (
33
34
  WARNING_STYLE,
34
35
  AIPBranding,
35
36
  )
36
- from glaip_sdk.cli.commands.configure import configure_command, load_config
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
37
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
38
44
  from glaip_sdk.cli.slash.agent_session import AgentRunSession
45
+ from glaip_sdk.cli.slash.accounts_shared import env_credentials_present
39
46
  from glaip_sdk.cli.slash.prompt import (
40
47
  FormattedText,
41
48
  PromptSession,
@@ -44,20 +51,18 @@ from glaip_sdk.cli.slash.prompt import (
44
51
  setup_prompt_toolkit,
45
52
  to_formatted_text,
46
53
  )
54
+ from glaip_sdk.cli.slash.remote_runs_controller import RemoteRunsController
55
+ from glaip_sdk.cli.slash.tui.context import TUIContext
47
56
  from glaip_sdk.cli.transcript import (
48
57
  export_cached_transcript,
49
- normalise_export_destination,
50
- resolve_manifest_for_export,
51
- suggest_filename,
58
+ load_history_snapshot,
52
59
  )
53
60
  from glaip_sdk.cli.transcript.viewer import ViewerContext, run_viewer_session
54
61
  from glaip_sdk.cli.update_notifier import maybe_notify_update
55
- from glaip_sdk.cli.utils import (
56
- _fuzzy_pick_for_resources,
57
- command_hint,
58
- format_command_hint,
59
- get_client,
60
- )
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
61
66
  from glaip_sdk.rich_components import AIPGrid, AIPPanel, AIPTable
62
67
 
63
68
  SlashHandler = Callable[["SlashSession", list[str], bool], bool]
@@ -71,6 +76,72 @@ class SlashCommand:
71
76
  help: str
72
77
  handler: SlashHandler
73
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"
74
145
 
75
146
 
76
147
  class SlashSession:
@@ -98,8 +169,9 @@ class SlashSession:
98
169
  self._welcome_rendered = False
99
170
  self._active_renderer: Any | None = None
100
171
  self._current_agent: Any | None = None
172
+ self._runs_pagination_state: dict[str, dict[str, Any]] = {} # agent_id -> {page, limit, cursor}
101
173
 
102
- self._home_placeholder = "Start with / to browse commands"
174
+ self._home_placeholder = "Hint: type / to explore commands · Ctrl+D exits"
103
175
 
104
176
  # Command string constants to avoid duplication
105
177
  self.STATUS_COMMAND = "/status"
@@ -116,18 +188,52 @@ class SlashSession:
116
188
  self._update_notifier = maybe_notify_update
117
189
  self._home_hint_shown = False
118
190
  self._agent_transcript_ready: dict[str, str] = {}
191
+ self.tui_ctx: TUIContext | None = None
119
192
 
120
193
  # ------------------------------------------------------------------
121
194
  # Session orchestration
122
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)
123
212
 
124
213
  def _setup_prompt_toolkit(self) -> None:
214
+ """Initialize prompt_toolkit session and style."""
125
215
  session, style = setup_prompt_toolkit(self, interactive=self._interactive)
126
216
  self._ptk_session = session
127
217
  self._ptk_style = style
128
218
 
129
219
  def run(self, initial_commands: Iterable[str] | None = None) -> None:
130
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
+
131
237
  ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else None
132
238
  previous_session = None
133
239
  if ctx_obj is not None:
@@ -149,10 +255,7 @@ class SlashSession:
149
255
  self._run_interactive_loop()
150
256
  finally:
151
257
  if ctx_obj is not None:
152
- if previous_session is None:
153
- ctx_obj.pop("_slash_session", None)
154
- else:
155
- ctx_obj["_slash_session"] = previous_session
258
+ restore_slash_session_context(ctx_obj, previous_session)
156
259
 
157
260
  def _run_interactive_loop(self) -> None:
158
261
  """Run the main interactive command loop."""
@@ -181,16 +284,12 @@ class SlashSession:
181
284
  return True
182
285
 
183
286
  if not raw.startswith("/"):
184
- self.console.print(
185
- f"[{INFO_STYLE}]Hint:[/] start commands with `/`. Try `/agents` to select an agent."
186
- )
287
+ self.console.print(f"[{INFO_STYLE}]Hint:[/] start commands with `/`. Try `/agents` to select an agent.")
187
288
  return True
188
289
 
189
290
  return self.handle_command(raw)
190
291
 
191
- def _run_non_interactive(
192
- self, initial_commands: Iterable[str] | None = None
193
- ) -> None:
292
+ def _run_non_interactive(self, initial_commands: Iterable[str] | None = None) -> None:
194
293
  """Run slash commands in non-interactive mode."""
195
294
  commands = list(initial_commands or [])
196
295
  if not commands:
@@ -202,38 +301,124 @@ class SlashSession:
202
301
  if not self.handle_command(raw):
203
302
  break
204
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
+
205
349
  def _ensure_configuration(self) -> bool:
206
350
  """Ensure the CLI has both API URL and credentials before continuing."""
207
351
  while not self._configuration_ready():
208
- self.console.print(
209
- f"[{WARNING_STYLE}]Configuration required.[/] Launching `/login` wizard..."
210
- )
211
- self._suppress_login_layout = True
212
- try:
213
- self._cmd_login([], False)
214
- except KeyboardInterrupt:
215
- self.console.print(
216
- f"[{ERROR_STYLE}]Configuration aborted. Closing the command palette.[/]"
217
- )
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():
218
364
  return False
219
- finally:
220
- self._suppress_login_layout = False
221
365
 
222
366
  return True
223
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
+
224
403
  def _configuration_ready(self) -> bool:
225
404
  """Check whether API URL and credentials are available."""
226
- config = self._load_config()
227
- api_url = self._get_api_url(config)
228
- if not api_url:
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:
229
413
  return False
230
414
 
231
- api_key: str | None = None
232
- if isinstance(self.ctx.obj, dict):
233
- api_key = self.ctx.obj.get("api_key")
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
234
420
 
235
- api_key = api_key or config.get("api_key") or os.getenv("AIP_API_KEY")
236
- return bool(api_key)
421
+ return bool(api_url and api_key)
237
422
 
238
423
  def handle_command(self, raw: str, *, invoked_from_agent: bool = False) -> bool:
239
424
  """Parse and execute a single slash command string."""
@@ -246,11 +431,9 @@ class SlashSession:
246
431
  if command is None:
247
432
  suggestion = self._suggest(verb)
248
433
  if suggestion:
249
- self.console.print(
250
- f"[{WARNING_STYLE}]Unknown command '{verb}'. Did you mean '/{suggestion}'?[/]"
251
- )
434
+ self.console.print(f"[{WARNING_STYLE}]Unknown command '{verb}'. Did you mean '/{suggestion}'?[/]")
252
435
  else:
253
- help_command = "/help"
436
+ help_command = HELP_COMMAND
254
437
  help_hint = format_command_hint(help_command) or help_command
255
438
  self.console.print(
256
439
  f"[{WARNING_STYLE}]Unknown command '{verb}'. Type {help_hint} for a list of options.[/]"
@@ -263,15 +446,28 @@ class SlashSession:
263
446
  return False
264
447
  return True
265
448
 
449
+ def _continue_session(self) -> bool:
450
+ """Signal that the slash session should remain active."""
451
+ return not self._should_exit
452
+
266
453
  # ------------------------------------------------------------------
267
454
  # Command handlers
268
455
  # ------------------------------------------------------------------
269
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
+ """
270
466
  try:
271
467
  if invoked_from_agent:
272
468
  self._render_agent_help()
273
469
  else:
274
- self._render_global_help()
470
+ self._render_global_help(include_agent_hint=True)
275
471
  except Exception as exc: # pragma: no cover - UI/display errors
276
472
  self.console.print(f"[{ERROR_STYLE}]Error displaying help: {exc}[/]")
277
473
  return False
@@ -279,21 +475,21 @@ class SlashSession:
279
475
  return True
280
476
 
281
477
  def _render_agent_help(self) -> None:
478
+ """Render help text for agent context commands."""
282
479
  table = AIPTable()
283
480
  table.add_column("Input", style=HINT_COMMAND_STYLE, no_wrap=True)
284
481
  table.add_column("What happens", style=HINT_DESCRIPTION_COLOR)
285
482
  table.add_row("<message>", "Run the active agent once with that prompt.")
286
- table.add_row("/details", "Show the full agent export and metadata.")
483
+ table.add_row("/details", "Show the agent export (prompts to expand instructions).")
287
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.")
288
486
  table.add_row("/export [path]", "Export the latest agent transcript as JSONL.")
289
487
  table.add_row("/exit (/back)", "Return to the slash home screen.")
290
- table.add_row("/help (/?)", "Display this context-aware menu.")
488
+ table.add_row(f"{HELP_COMMAND} (/?)", "Display this context-aware menu.")
291
489
 
292
490
  panel_items = [table]
293
491
  if self.last_run_input:
294
- panel_items.append(
295
- Text.from_markup(f"[dim]Last run input:[/] {self.last_run_input}")
296
- )
492
+ panel_items.append(Text.from_markup(f"[dim]Last run input:[/] {self.last_run_input}"))
297
493
  panel_items.append(
298
494
  Text.from_markup(
299
495
  "[dim]Global commands (e.g. `/login`, `/status`) remain available inside the agent prompt.[/dim]"
@@ -307,13 +503,28 @@ class SlashSession:
307
503
  border_style=PRIMARY,
308
504
  )
309
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
+ )
310
520
 
311
- def _render_global_help(self) -> None:
521
+ def _render_global_help(self, *, include_agent_hint: bool = False) -> None:
522
+ """Render help text for global slash commands."""
312
523
  table = AIPTable()
313
524
  table.add_column("Command", style=HINT_COMMAND_STYLE, no_wrap=True)
314
525
  table.add_column("Description", style=HINT_DESCRIPTION_COLOR)
315
526
 
316
- for cmd in sorted(self._unique_commands.values(), key=lambda c: c.name):
527
+ for cmd in self._visible_commands(include_agent_only=False):
317
528
  aliases = ", ".join(f"/{alias}" for alias in cmd.aliases if alias)
318
529
  verb = f"/{cmd.name}"
319
530
  if aliases:
@@ -321,7 +532,9 @@ class SlashSession:
321
532
  table.add_row(verb, cmd.help)
322
533
 
323
534
  tip = Text.from_markup(
324
- f"[{HINT_PREFIX_STYLE}]Tip:[/] {format_command_hint(self.AGENTS_COMMAND) or self.AGENTS_COMMAND} lets you jump into an agent run prompt quickly."
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."
325
538
  )
326
539
 
327
540
  self.console.print(
@@ -331,12 +544,27 @@ class SlashSession:
331
544
  border_style=PRIMARY,
332
545
  )
333
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
+ )
334
552
 
335
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
+ """
336
563
  self.console.print(f"[{ACCENT_STYLE}]Launching configuration wizard...[/]")
337
564
  try:
338
- self.ctx.invoke(configure_command)
339
- self._config_cache = None
565
+ # Use the modern account-aware wizard directly (bypasses legacy config gating)
566
+ _configure_interactive(account_name=None)
567
+ self.on_account_switched()
340
568
  if self._suppress_login_layout:
341
569
  self._welcome_rendered = False
342
570
  self._default_actions_shown = False
@@ -345,14 +573,23 @@ class SlashSession:
345
573
  self._show_default_quick_actions()
346
574
  except click.ClickException as exc:
347
575
  self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
348
- return True
576
+ return self._continue_session()
349
577
 
350
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
+ """
351
588
  ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else None
352
589
  previous_console = None
353
590
  try:
354
591
  status_module = importlib.import_module("glaip_sdk.cli.main")
355
- status_command = getattr(status_module, "status")
592
+ status_command = status_module.status
356
593
 
357
594
  if ctx_obj is not None:
358
595
  previous_console = ctx_obj.get("_slash_console")
@@ -360,9 +597,7 @@ class SlashSession:
360
597
 
361
598
  self.ctx.invoke(status_command)
362
599
 
363
- hints: list[tuple[str, str]] = [
364
- (self.AGENTS_COMMAND, "Browse agents and run them")
365
- ]
600
+ hints: list[tuple[str, str]] = [(self.AGENTS_COMMAND, "Browse agents and run them")]
366
601
  if self.recent_agents:
367
602
  top = self.recent_agents[0]
368
603
  label = top.get("name") or top.get("id")
@@ -376,9 +611,176 @@ class SlashSession:
376
611
  ctx_obj.pop("_slash_console", None)
377
612
  else:
378
613
  ctx_obj["_slash_console"] = previous_console
379
- return True
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)
380
773
 
381
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
+ """
382
784
  client = self._get_client_or_fail()
383
785
  if not client:
384
786
  return True
@@ -417,9 +819,7 @@ class SlashSession:
417
819
  """Handle case when no agents are available."""
418
820
  hint = command_hint("agents create", slash_command=None, ctx=self.ctx)
419
821
  if hint:
420
- self.console.print(
421
- f"[{WARNING_STYLE}]No agents available. Use `{hint}` to add one.[/]"
422
- )
822
+ self.console.print(f"[{WARNING_STYLE}]No agents available. Use `{hint}` to add one.[/]")
423
823
  else:
424
824
  self.console.print(f"[{WARNING_STYLE}]No agents available.[/]")
425
825
 
@@ -446,7 +846,7 @@ class SlashSession:
446
846
  self._render_header()
447
847
 
448
848
  self._show_agent_followup_actions(picked_agent)
449
- return True
849
+ return self._continue_session()
450
850
 
451
851
  def _show_agent_followup_actions(self, picked_agent: Any) -> None:
452
852
  """Show follow-up action hints after agent session."""
@@ -458,6 +858,7 @@ class SlashSession:
458
858
  hints.append((f"/agents {agent_id}", f"Reopen {agent_label}"))
459
859
  hints.extend(
460
860
  [
861
+ ("/accounts", "Switch account"),
461
862
  (self.AGENTS_COMMAND, "Browse agents"),
462
863
  (self.STATUS_COMMAND, "Check connection"),
463
864
  ]
@@ -466,6 +867,15 @@ class SlashSession:
466
867
  self._show_quick_actions(hints, title="Next actions")
467
868
 
468
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
+ """
469
879
  if invoked_from_agent:
470
880
  # Returning False would stop the full session; we only want to exit
471
881
  # the agent context. Raising a custom flag keeps the outer loop
@@ -479,6 +889,7 @@ class SlashSession:
479
889
  # Utilities
480
890
  # ------------------------------------------------------------------
481
891
  def _register_defaults(self) -> None:
892
+ """Register default slash commands."""
482
893
  self._register(
483
894
  SlashCommand(
484
895
  name="help",
@@ -490,7 +901,7 @@ class SlashSession:
490
901
  self._register(
491
902
  SlashCommand(
492
903
  name="login",
493
- help="Run `/login` (alias `/configure`) to set credentials.",
904
+ help="Configure API credentials (alias `/configure`).",
494
905
  handler=SlashSession._cmd_login,
495
906
  aliases=("configure",),
496
907
  )
@@ -502,6 +913,23 @@ class SlashSession:
502
913
  handler=SlashSession._cmd_status,
503
914
  )
504
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
+ )
505
933
  self._register(
506
934
  SlashCommand(
507
935
  name="agents",
@@ -531,28 +959,44 @@ class SlashSession:
531
959
  handler=SlashSession._cmd_update,
532
960
  )
533
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
+ )
534
970
 
535
971
  def _register(self, command: SlashCommand) -> None:
972
+ """Register a slash command.
973
+
974
+ Args:
975
+ command: SlashCommand to register.
976
+ """
536
977
  self._unique_commands[command.name] = command
537
978
  for key in (command.name, *command.aliases):
538
979
  self._commands[key] = command
539
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
+
540
988
  def open_transcript_viewer(self, *, announce: bool = True) -> None:
541
989
  """Launch the transcript viewer for the most recent run."""
542
990
  payload, manifest = self._get_last_transcript()
543
991
  if payload is None or manifest is None:
544
992
  if announce:
545
- self.console.print(
546
- f"[{WARNING_STYLE}]No transcript is available yet. Run an agent first.[/]"
547
- )
993
+ self.console.print(f"[{WARNING_STYLE}]No transcript is available yet. Run an agent first.[/]")
548
994
  return
549
995
 
550
996
  run_id = manifest.get("run_id")
551
997
  if not run_id:
552
998
  if announce:
553
- self.console.print(
554
- f"[{WARNING_STYLE}]Latest transcript is missing run metadata.[/]"
555
- )
999
+ self.console.print(f"[{WARNING_STYLE}]Latest transcript is missing run metadata.[/]")
556
1000
  return
557
1001
 
558
1002
  viewer_ctx = ViewerContext(
@@ -565,15 +1009,21 @@ class SlashSession:
565
1009
  )
566
1010
 
567
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
+ """
568
1020
  return export_cached_transcript(destination=destination, run_id=run_id)
569
1021
 
570
1022
  try:
571
1023
  run_viewer_session(self.console, viewer_ctx, _export)
572
1024
  except Exception as exc: # pragma: no cover - interactive failures
573
1025
  if announce:
574
- self.console.print(
575
- f"[{ERROR_STYLE}]Failed to launch transcript viewer: {exc}[/]"
576
- )
1026
+ self.console.print(f"[{ERROR_STYLE}]Failed to launch transcript viewer: {exc}[/]")
577
1027
 
578
1028
  def _get_last_transcript(self) -> tuple[Any | None, dict[str, Any] | None]:
579
1029
  """Fetch the most recently stored transcript payload and manifest."""
@@ -584,65 +1034,18 @@ class SlashSession:
584
1034
  manifest = ctx_obj.get("_last_transcript_manifest")
585
1035
  return payload, manifest
586
1036
 
587
- def _cmd_export(self, args: list[str], _invoked_from_agent: bool) -> bool:
1037
+ def _cmd_export(self, _args: list[str], _invoked_from_agent: bool) -> bool:
588
1038
  """Slash handler for `/export` command."""
589
- path_arg = args[0] if args else None
590
- run_id = args[1] if len(args) > 1 else None
591
-
592
- manifest_entry = resolve_manifest_for_export(self.ctx, run_id)
593
- if manifest_entry is None:
594
- if run_id:
595
- self.console.print(
596
- f"[{WARNING_STYLE}]No cached transcript found with run id {run_id!r}. Omit the run id to export the most recent run.[/]"
597
- )
598
- else:
599
- self.console.print(
600
- f"[{WARNING_STYLE}]No cached transcripts available yet. Run an agent first.[/]"
601
- )
602
- return False
603
-
604
- destination = self._resolve_export_destination(path_arg, manifest_entry)
605
- if destination is None:
606
- return False
607
-
608
- try:
609
- exported = export_cached_transcript(
610
- destination=destination,
611
- run_id=manifest_entry.get("run_id"),
612
- )
613
- except FileNotFoundError as exc:
614
- self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
615
- return False
616
- except Exception as exc: # pragma: no cover - unexpected IO failures
617
- self.console.print(f"[{ERROR_STYLE}]Failed to export transcript: {exc}[/]")
618
- return False
619
- else:
620
- self.console.print(f"[{SUCCESS_STYLE}]Transcript exported to[/] {exported}")
621
- return True
622
-
623
- def _resolve_export_destination(
624
- self, path_arg: str | None, manifest_entry: dict[str, Any]
625
- ) -> Path | None:
626
- if path_arg:
627
- return normalise_export_destination(Path(path_arg))
628
-
629
- default_name = suggest_filename(manifest_entry)
630
- prompt = f"Save transcript to [{default_name}]: "
631
- try:
632
- response = self.console.input(prompt)
633
- except EOFError:
634
- self.console.print("[dim]Export cancelled.[/dim]")
635
- return None
636
-
637
- chosen = response.strip() or default_name
638
- return normalise_export_destination(Path(chosen))
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
639
1044
 
640
1045
  def _cmd_update(self, args: list[str], _invoked_from_agent: bool) -> bool:
641
1046
  """Slash handler for `/update` command."""
642
1047
  if args:
643
- self.console.print(
644
- "Usage: `/update` upgrades glaip-sdk to the latest published version."
645
- )
1048
+ self.console.print("Usage: `/update` upgrades glaip-sdk to the latest published version.")
646
1049
  return True
647
1050
 
648
1051
  try:
@@ -710,6 +1113,14 @@ class SlashSession:
710
1113
  pass
711
1114
 
712
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
+ """
713
1124
  try:
714
1125
  tokens = shlex.split(raw)
715
1126
  except ValueError:
@@ -725,6 +1136,14 @@ class SlashSession:
725
1136
  return head, tokens[1:]
726
1137
 
727
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
+ """
728
1147
  keys = [cmd.name for cmd in self._unique_commands.values()]
729
1148
  match = get_close_matches(verb, keys, n=1)
730
1149
  return match[0] if match else None
@@ -742,21 +1161,18 @@ class SlashSession:
742
1161
  prompt_kwargs: dict[str, Any] = {"style": self._ptk_style}
743
1162
  if placeholder:
744
1163
  placeholder_text = (
745
- FormattedText([("class:placeholder", placeholder)])
746
- if FormattedText is not None
747
- else placeholder
1164
+ FormattedText([("class:placeholder", placeholder)]) if FormattedText is not None else placeholder
748
1165
  )
749
1166
  prompt_kwargs["placeholder"] = placeholder_text
750
1167
  return prompt_kwargs
751
1168
 
752
- def _prompt_with_prompt_toolkit(
753
- self, message: str | Callable[[], Any], placeholder: str | None
754
- ) -> str:
1169
+ def _prompt_with_prompt_toolkit(self, message: str | Callable[[], Any], placeholder: str | None) -> str:
755
1170
  """Handle prompting with prompt_toolkit."""
756
1171
  with patch_stdout(): # pragma: no cover - UI specific
757
1172
  if callable(message):
758
1173
 
759
1174
  def prompt_text() -> Any:
1175
+ """Get formatted prompt text from callable message."""
760
1176
  return self._convert_message(message())
761
1177
  else:
762
1178
  prompt_text = self._convert_message(message)
@@ -765,9 +1181,7 @@ class SlashSession:
765
1181
 
766
1182
  try:
767
1183
  return self._ptk_session.prompt(prompt_text, **prompt_kwargs)
768
- except (
769
- TypeError
770
- ): # pragma: no cover - compatibility with older prompt_toolkit
1184
+ except TypeError: # pragma: no cover - compatibility with older prompt_toolkit
771
1185
  prompt_kwargs.pop("placeholder", None)
772
1186
  return self._ptk_session.prompt(prompt_text, **prompt_kwargs)
773
1187
 
@@ -786,9 +1200,7 @@ class SlashSession:
786
1200
  except Exception:
787
1201
  return str(raw_value)
788
1202
 
789
- def _prompt_with_basic_input(
790
- self, message: str | Callable[[], Any], placeholder: str | None
791
- ) -> str:
1203
+ def _prompt_with_basic_input(self, message: str | Callable[[], Any], placeholder: str | None) -> str:
792
1204
  """Handle prompting with basic input."""
793
1205
  if placeholder:
794
1206
  self.console.print(f"[dim]{placeholder}[/dim]")
@@ -798,9 +1210,7 @@ class SlashSession:
798
1210
 
799
1211
  return input(actual_message)
800
1212
 
801
- def _prompt(
802
- self, message: str | Callable[[], Any], *, placeholder: str | None = None
803
- ) -> str:
1213
+ def _prompt(self, message: str | Callable[[], Any], *, placeholder: str | None = None) -> str:
804
1214
  """Main prompt function with reduced complexity."""
805
1215
  if self._ptk_session and self._ptk_style and patch_stdout:
806
1216
  return self._prompt_with_prompt_toolkit(message, placeholder)
@@ -808,13 +1218,43 @@ class SlashSession:
808
1218
  return self._prompt_with_basic_input(message, placeholder)
809
1219
 
810
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
+ """
811
1226
  if self._client is None:
812
1227
  self._client = get_client(self.ctx)
813
1228
  return self._client
814
1229
 
815
- def set_contextual_commands(
816
- self, commands: dict[str, str] | None, *, include_global: bool = True
817
- ) -> None:
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:
818
1258
  """Set context-specific commands that should appear in completions."""
819
1259
  self._contextual_commands = dict(commands or {})
820
1260
  self._contextual_include_global = include_global if commands else True
@@ -828,15 +1268,18 @@ class SlashSession:
828
1268
  return self._contextual_include_global
829
1269
 
830
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
+ """
831
1276
  agent_data = {
832
1277
  "id": str(getattr(agent, "id", "")),
833
1278
  "name": getattr(agent, "name", "") or "",
834
1279
  "type": getattr(agent, "type", "") or "",
835
1280
  }
836
1281
 
837
- self.recent_agents = [
838
- a for a in self.recent_agents if a.get("id") != agent_data["id"]
839
- ]
1282
+ self.recent_agents = [a for a in self.recent_agents if a.get("id") != agent_data["id"]]
840
1283
  self.recent_agents.insert(0, agent_data)
841
1284
  self.recent_agents = self.recent_agents[:5]
842
1285
 
@@ -847,6 +1290,13 @@ class SlashSession:
847
1290
  focus_agent: bool = False,
848
1291
  initial: bool = False,
849
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
+ """
850
1300
  if focus_agent and active_agent is not None:
851
1301
  self._render_focused_agent_header(active_agent)
852
1302
  return
@@ -889,11 +1339,10 @@ class SlashSession:
889
1339
 
890
1340
  header_grid = self._build_header_grid(agent_info, transcript_status)
891
1341
  keybar = self._build_keybar()
892
-
893
1342
  header_grid.add_row(keybar, "")
894
- self.console.print(
895
- AIPPanel(header_grid, title="Agent Session", border_style=PRIMARY)
896
- )
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))
897
1346
 
898
1347
  def _get_agent_info(self, active_agent: Any) -> dict[str, str]:
899
1348
  """Extract agent information for display."""
@@ -914,9 +1363,7 @@ class SlashSession:
914
1363
  has_transcript = bool(payload and manifest and manifest.get("run_id"))
915
1364
  run_id = (manifest or {}).get("run_id")
916
1365
  transcript_ready = (
917
- has_transcript
918
- and latest_agent_id == agent_id
919
- and self._agent_transcript_ready.get(agent_id) == run_id
1366
+ has_transcript and latest_agent_id == agent_id and self._agent_transcript_ready.get(agent_id) == run_id
920
1367
  )
921
1368
 
922
1369
  return {
@@ -925,9 +1372,7 @@ class SlashSession:
925
1372
  "run_id": run_id,
926
1373
  }
927
1374
 
928
- def _build_header_grid(
929
- self, agent_info: dict[str, str], transcript_status: dict[str, Any]
930
- ) -> AIPGrid:
1375
+ def _build_header_grid(self, agent_info: dict[str, str], transcript_status: dict[str, Any]) -> AIPGrid:
931
1376
  """Build the main header grid with agent information."""
932
1377
  header_grid = AIPGrid(expand=True)
933
1378
  header_grid.add_column(ratio=3)
@@ -938,18 +1383,16 @@ class SlashSession:
938
1383
  f"[{ACCENT_STYLE}]{agent_info['id']}[/]"
939
1384
  )
940
1385
  status_line = f"[{SUCCESS_STYLE}]ready[/]"
941
- status_line += (
942
- " · transcript ready"
943
- if transcript_status["transcript_ready"]
944
- else " · transcript pending"
945
- )
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"
946
1392
  header_grid.add_row(primary_line, status_line)
947
1393
 
948
1394
  if agent_info["description"]:
949
- description = agent_info["description"]
950
- if not transcript_status["transcript_ready"]:
951
- description = f"{description} (transcript pending)"
952
- header_grid.add_row(f"[dim]{description}[/dim]", "")
1395
+ header_grid.add_row(f"[dim]{agent_info['description']}[/dim]", "")
953
1396
 
954
1397
  return header_grid
955
1398
 
@@ -959,30 +1402,39 @@ class SlashSession:
959
1402
  keybar.add_column(justify="left", ratio=1)
960
1403
  keybar.add_column(justify="left", ratio=1)
961
1404
  keybar.add_column(justify="left", ratio=1)
962
- keybar.add_column(justify="left", ratio=1)
963
1405
 
964
1406
  keybar.add_row(
965
- format_command_hint("/help", "Show commands") or "",
966
- format_command_hint("/details", "Agent config") or "",
1407
+ format_command_hint(HELP_COMMAND, "Show commands") or "",
1408
+ format_command_hint("/details", "Agent config (expand prompt)") or "",
967
1409
  format_command_hint("/exit", "Back") or "",
968
- "[bold]Alt+Enter[/bold] [dim]Line break[/dim]",
969
1410
  )
970
1411
 
971
1412
  return keybar
972
1413
 
973
- def _render_main_header(
974
- self, active_agent: Any | None = None, *, full: bool = False
975
- ) -> None:
1414
+ def _render_main_header(self, active_agent: Any | None = None, *, full: bool = False) -> None:
976
1415
  """Render the main AIP environment header."""
977
1416
  config = self._load_config()
978
1417
 
1418
+ account_name, account_host, env_lock = self._get_account_context()
979
1419
  api_url = self._get_api_url(config)
980
- status = "Configured" if config.get("api_key") else "Not configured"
981
1420
 
982
- segments = [
983
- f"[dim]Base URL[/dim] • {api_url or 'Not configured'}",
984
- f"[dim]Credentials[/dim] • {status}",
985
- ]
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
+
986
1438
  agent_info = self._build_agent_status_line(active_agent)
987
1439
  if agent_info:
988
1440
  segments.append(agent_info)
@@ -990,7 +1442,7 @@ class SlashSession:
990
1442
  rendered_line = " ".join(segments)
991
1443
 
992
1444
  if full:
993
- self.console.print(rendered_line)
1445
+ self.console.print(rendered_line, soft_wrap=False)
994
1446
  return
995
1447
 
996
1448
  status_bar = AIPGrid(expand=True)
@@ -1005,12 +1457,23 @@ class SlashSession:
1005
1457
  )
1006
1458
  )
1007
1459
 
1008
- def _get_api_url(self, config: dict[str, Any]) -> str | None:
1009
- """Get the API URL from various sources."""
1010
- api_url = None
1011
- if isinstance(self.ctx.obj, dict):
1012
- api_url = self.ctx.obj.get("api_url")
1013
- return api_url or config.get("api_url") or os.getenv("AIP_API_URL")
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()
1014
1477
 
1015
1478
  def _build_agent_status_line(self, active_agent: Any | None) -> str | None:
1016
1479
  """Return a short status line about the active or recent agent."""
@@ -1025,35 +1488,96 @@ class SlashSession:
1025
1488
  return None
1026
1489
 
1027
1490
  def _show_default_quick_actions(self) -> None:
1028
- hints: list[tuple[str | None, str]] = [
1029
- (
1030
- command_hint("status", slash_command="status", ctx=self.ctx),
1031
- "Connection check",
1032
- ),
1033
- (
1034
- command_hint("agents list", slash_command="agents", ctx=self.ctx),
1035
- "Browse agents",
1036
- ),
1037
- (
1038
- command_hint("help", slash_command="help", ctx=self.ctx),
1039
- "Show all commands",
1040
- ),
1041
- ]
1042
- filtered = [(cmd, desc) for cmd, desc in hints if cmd]
1043
- if filtered:
1044
- self._show_quick_actions(filtered, title="Quick actions")
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}")
1045
1495
  self._default_actions_shown = True
1046
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
+
1047
1571
  def _render_home_hint(self) -> None:
1572
+ """Render hint text for home screen."""
1048
1573
  if self._home_hint_shown:
1049
1574
  return
1050
- hint_lines = [
1051
- f"[{HINT_PREFIX_STYLE}]Hint:[/]",
1052
- f" Type {format_command_hint('/') or '/'} to explore commands",
1053
- " Press [dim]Ctrl+C[/] to cancel the current entry",
1054
- " Press [dim]Ctrl+D[/] to quit",
1055
- ]
1056
- self.console.print("\n".join(hint_lines))
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)
1057
1581
  self._home_hint_shown = True
1058
1582
 
1059
1583
  def _show_quick_actions(
@@ -1063,36 +1587,99 @@ class SlashSession:
1063
1587
  title: str = "Quick actions",
1064
1588
  inline: bool = False,
1065
1589
  ) -> None:
1066
- hint_list = [
1067
- (command, description) for command, description in hints if command
1068
- ]
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)
1069
1598
  if not hint_list:
1070
1599
  return
1071
1600
 
1072
1601
  if inline:
1073
- lines: list[str] = []
1074
- for command, description in hint_list:
1075
- formatted = format_command_hint(command, description)
1076
- if formatted:
1077
- lines.append(formatted)
1078
- if lines:
1079
- self.console.print("\n".join(lines))
1602
+ self._render_inline_quick_actions(hint_list, title)
1080
1603
  return
1081
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
+ """
1082
1644
  body_lines: list[Text] = []
1083
1645
  for command, description in hint_list:
1084
1646
  formatted = format_command_hint(command, description)
1085
1647
  if formatted:
1086
1648
  body_lines.append(Text.from_markup(formatted))
1087
-
1649
+ if not body_lines:
1650
+ return
1088
1651
  panel_content = Group(*body_lines)
1089
- self.console.print(
1090
- AIPPanel(
1091
- panel_content, title=title, border_style=SECONDARY_LIGHT, expand=False
1092
- )
1093
- )
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
1094
1676
 
1095
1677
  def _load_config(self) -> dict[str, Any]:
1678
+ """Load configuration with caching.
1679
+
1680
+ Returns:
1681
+ Configuration dictionary.
1682
+ """
1096
1683
  if self._config_cache is None:
1097
1684
  try:
1098
1685
  self._config_cache = load_config() or {}
@@ -1100,9 +1687,17 @@ class SlashSession:
1100
1687
  self._config_cache = {}
1101
1688
  return self._config_cache
1102
1689
 
1103
- def _resolve_agent_from_ref(
1104
- self, client: Any, available_agents: list[Any], ref: str
1105
- ) -> Any | None:
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
+ """
1106
1701
  ref = ref.strip()
1107
1702
  if not ref:
1108
1703
  return None