glaip-sdk 0.1.2__py3-none-any.whl → 0.7.17__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 (217) hide show
  1. glaip_sdk/__init__.py +44 -4
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1413 -0
  5. glaip_sdk/branding.py +126 -2
  6. glaip_sdk/cli/account_store.py +555 -0
  7. glaip_sdk/cli/auth.py +260 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents/__init__.py +116 -0
  11. glaip_sdk/cli/commands/agents/_common.py +562 -0
  12. glaip_sdk/cli/commands/agents/create.py +155 -0
  13. glaip_sdk/cli/commands/agents/delete.py +64 -0
  14. glaip_sdk/cli/commands/agents/get.py +89 -0
  15. glaip_sdk/cli/commands/agents/list.py +129 -0
  16. glaip_sdk/cli/commands/agents/run.py +264 -0
  17. glaip_sdk/cli/commands/agents/sync_langflow.py +72 -0
  18. glaip_sdk/cli/commands/agents/update.py +112 -0
  19. glaip_sdk/cli/commands/common_config.py +104 -0
  20. glaip_sdk/cli/commands/configure.py +728 -113
  21. glaip_sdk/cli/commands/mcps/__init__.py +94 -0
  22. glaip_sdk/cli/commands/mcps/_common.py +459 -0
  23. glaip_sdk/cli/commands/mcps/connect.py +82 -0
  24. glaip_sdk/cli/commands/mcps/create.py +152 -0
  25. glaip_sdk/cli/commands/mcps/delete.py +73 -0
  26. glaip_sdk/cli/commands/mcps/get.py +212 -0
  27. glaip_sdk/cli/commands/mcps/list.py +69 -0
  28. glaip_sdk/cli/commands/mcps/tools.py +235 -0
  29. glaip_sdk/cli/commands/mcps/update.py +190 -0
  30. glaip_sdk/cli/commands/models.py +12 -8
  31. glaip_sdk/cli/commands/shared/__init__.py +21 -0
  32. glaip_sdk/cli/commands/shared/formatters.py +91 -0
  33. glaip_sdk/cli/commands/tools/__init__.py +69 -0
  34. glaip_sdk/cli/commands/tools/_common.py +80 -0
  35. glaip_sdk/cli/commands/tools/create.py +228 -0
  36. glaip_sdk/cli/commands/tools/delete.py +61 -0
  37. glaip_sdk/cli/commands/tools/get.py +103 -0
  38. glaip_sdk/cli/commands/tools/list.py +69 -0
  39. glaip_sdk/cli/commands/tools/script.py +49 -0
  40. glaip_sdk/cli/commands/tools/update.py +102 -0
  41. glaip_sdk/cli/commands/transcripts/__init__.py +90 -0
  42. glaip_sdk/cli/commands/transcripts/_common.py +9 -0
  43. glaip_sdk/cli/commands/transcripts/clear.py +5 -0
  44. glaip_sdk/cli/commands/transcripts/detail.py +5 -0
  45. glaip_sdk/cli/commands/transcripts_original.py +756 -0
  46. glaip_sdk/cli/commands/update.py +163 -17
  47. glaip_sdk/cli/config.py +49 -4
  48. glaip_sdk/cli/constants.py +38 -0
  49. glaip_sdk/cli/context.py +8 -0
  50. glaip_sdk/cli/core/__init__.py +79 -0
  51. glaip_sdk/cli/core/context.py +124 -0
  52. glaip_sdk/cli/core/output.py +851 -0
  53. glaip_sdk/cli/core/prompting.py +649 -0
  54. glaip_sdk/cli/core/rendering.py +187 -0
  55. glaip_sdk/cli/display.py +41 -20
  56. glaip_sdk/cli/entrypoint.py +20 -0
  57. glaip_sdk/cli/hints.py +57 -0
  58. glaip_sdk/cli/io.py +6 -3
  59. glaip_sdk/cli/main.py +340 -143
  60. glaip_sdk/cli/masking.py +21 -33
  61. glaip_sdk/cli/pager.py +12 -13
  62. glaip_sdk/cli/parsers/__init__.py +1 -3
  63. glaip_sdk/cli/resolution.py +2 -1
  64. glaip_sdk/cli/slash/__init__.py +0 -9
  65. glaip_sdk/cli/slash/accounts_controller.py +580 -0
  66. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  67. glaip_sdk/cli/slash/agent_session.py +62 -21
  68. glaip_sdk/cli/slash/prompt.py +21 -0
  69. glaip_sdk/cli/slash/remote_runs_controller.py +568 -0
  70. glaip_sdk/cli/slash/session.py +1105 -153
  71. glaip_sdk/cli/slash/tui/__init__.py +36 -0
  72. glaip_sdk/cli/slash/tui/accounts.tcss +177 -0
  73. glaip_sdk/cli/slash/tui/accounts_app.py +1853 -0
  74. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  75. glaip_sdk/cli/slash/tui/clipboard.py +195 -0
  76. glaip_sdk/cli/slash/tui/context.py +92 -0
  77. glaip_sdk/cli/slash/tui/indicators.py +341 -0
  78. glaip_sdk/cli/slash/tui/keybind_registry.py +235 -0
  79. glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
  80. glaip_sdk/cli/slash/tui/layouts/harlequin.py +184 -0
  81. glaip_sdk/cli/slash/tui/loading.py +80 -0
  82. glaip_sdk/cli/slash/tui/remote_runs_app.py +760 -0
  83. glaip_sdk/cli/slash/tui/terminal.py +407 -0
  84. glaip_sdk/cli/slash/tui/theme/__init__.py +15 -0
  85. glaip_sdk/cli/slash/tui/theme/catalog.py +79 -0
  86. glaip_sdk/cli/slash/tui/theme/manager.py +112 -0
  87. glaip_sdk/cli/slash/tui/theme/tokens.py +55 -0
  88. glaip_sdk/cli/slash/tui/toast.py +388 -0
  89. glaip_sdk/cli/transcript/__init__.py +12 -52
  90. glaip_sdk/cli/transcript/cache.py +255 -44
  91. glaip_sdk/cli/transcript/capture.py +66 -1
  92. glaip_sdk/cli/transcript/history.py +815 -0
  93. glaip_sdk/cli/transcript/viewer.py +72 -463
  94. glaip_sdk/cli/tui_settings.py +125 -0
  95. glaip_sdk/cli/update_notifier.py +227 -10
  96. glaip_sdk/cli/validators.py +5 -6
  97. glaip_sdk/client/__init__.py +3 -1
  98. glaip_sdk/client/_schedule_payloads.py +89 -0
  99. glaip_sdk/client/agent_runs.py +147 -0
  100. glaip_sdk/client/agents.py +576 -44
  101. glaip_sdk/client/base.py +26 -0
  102. glaip_sdk/client/hitl.py +136 -0
  103. glaip_sdk/client/main.py +25 -14
  104. glaip_sdk/client/mcps.py +165 -24
  105. glaip_sdk/client/payloads/agent/__init__.py +23 -0
  106. glaip_sdk/client/{_agent_payloads.py → payloads/agent/requests.py} +63 -47
  107. glaip_sdk/client/payloads/agent/responses.py +43 -0
  108. glaip_sdk/client/run_rendering.py +546 -92
  109. glaip_sdk/client/schedules.py +439 -0
  110. glaip_sdk/client/shared.py +21 -0
  111. glaip_sdk/client/tools.py +206 -32
  112. glaip_sdk/config/constants.py +33 -2
  113. glaip_sdk/guardrails/__init__.py +80 -0
  114. glaip_sdk/guardrails/serializer.py +89 -0
  115. glaip_sdk/hitl/__init__.py +48 -0
  116. glaip_sdk/hitl/base.py +64 -0
  117. glaip_sdk/hitl/callback.py +43 -0
  118. glaip_sdk/hitl/local.py +121 -0
  119. glaip_sdk/hitl/remote.py +523 -0
  120. glaip_sdk/mcps/__init__.py +21 -0
  121. glaip_sdk/mcps/base.py +345 -0
  122. glaip_sdk/models/__init__.py +136 -0
  123. glaip_sdk/models/_provider_mappings.py +101 -0
  124. glaip_sdk/models/_validation.py +97 -0
  125. glaip_sdk/models/agent.py +48 -0
  126. glaip_sdk/models/agent_runs.py +117 -0
  127. glaip_sdk/models/common.py +42 -0
  128. glaip_sdk/models/constants.py +141 -0
  129. glaip_sdk/models/mcp.py +33 -0
  130. glaip_sdk/models/model.py +170 -0
  131. glaip_sdk/models/schedule.py +224 -0
  132. glaip_sdk/models/tool.py +33 -0
  133. glaip_sdk/payload_schemas/__init__.py +1 -13
  134. glaip_sdk/payload_schemas/agent.py +1 -0
  135. glaip_sdk/payload_schemas/guardrails.py +34 -0
  136. glaip_sdk/registry/__init__.py +55 -0
  137. glaip_sdk/registry/agent.py +164 -0
  138. glaip_sdk/registry/base.py +139 -0
  139. glaip_sdk/registry/mcp.py +253 -0
  140. glaip_sdk/registry/tool.py +445 -0
  141. glaip_sdk/rich_components.py +58 -2
  142. glaip_sdk/runner/__init__.py +76 -0
  143. glaip_sdk/runner/base.py +84 -0
  144. glaip_sdk/runner/deps.py +115 -0
  145. glaip_sdk/runner/langgraph.py +1055 -0
  146. glaip_sdk/runner/logging_config.py +77 -0
  147. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  148. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  149. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  150. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +116 -0
  151. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  152. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  153. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +242 -0
  154. glaip_sdk/schedules/__init__.py +22 -0
  155. glaip_sdk/schedules/base.py +291 -0
  156. glaip_sdk/tools/__init__.py +22 -0
  157. glaip_sdk/tools/base.py +488 -0
  158. glaip_sdk/utils/__init__.py +59 -12
  159. glaip_sdk/utils/a2a/__init__.py +34 -0
  160. glaip_sdk/utils/a2a/event_processor.py +188 -0
  161. glaip_sdk/utils/agent_config.py +8 -2
  162. glaip_sdk/utils/bundler.py +403 -0
  163. glaip_sdk/utils/client.py +111 -0
  164. glaip_sdk/utils/client_utils.py +39 -7
  165. glaip_sdk/utils/datetime_helpers.py +58 -0
  166. glaip_sdk/utils/discovery.py +78 -0
  167. glaip_sdk/utils/display.py +23 -15
  168. glaip_sdk/utils/export.py +143 -0
  169. glaip_sdk/utils/general.py +0 -33
  170. glaip_sdk/utils/import_export.py +12 -7
  171. glaip_sdk/utils/import_resolver.py +524 -0
  172. glaip_sdk/utils/instructions.py +101 -0
  173. glaip_sdk/utils/rendering/__init__.py +115 -1
  174. glaip_sdk/utils/rendering/formatting.py +5 -30
  175. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  176. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  177. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  178. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  179. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  180. glaip_sdk/utils/rendering/models.py +1 -0
  181. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  182. glaip_sdk/utils/rendering/renderer/base.py +299 -1434
  183. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  184. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  185. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  186. glaip_sdk/utils/rendering/renderer/stream.py +4 -33
  187. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  188. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  189. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  190. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  191. glaip_sdk/utils/rendering/state.py +204 -0
  192. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  193. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  194. glaip_sdk/utils/rendering/steps/format.py +176 -0
  195. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  196. glaip_sdk/utils/rendering/timing.py +36 -0
  197. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  198. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  199. glaip_sdk/utils/resource_refs.py +25 -13
  200. glaip_sdk/utils/runtime_config.py +426 -0
  201. glaip_sdk/utils/serialization.py +18 -0
  202. glaip_sdk/utils/sync.py +162 -0
  203. glaip_sdk/utils/tool_detection.py +301 -0
  204. glaip_sdk/utils/tool_storage_provider.py +140 -0
  205. glaip_sdk/utils/validation.py +16 -24
  206. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/METADATA +69 -23
  207. glaip_sdk-0.7.17.dist-info/RECORD +224 -0
  208. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.7.17.dist-info}/WHEEL +2 -1
  209. glaip_sdk-0.7.17.dist-info/entry_points.txt +2 -0
  210. glaip_sdk-0.7.17.dist-info/top_level.txt +1 -0
  211. glaip_sdk/cli/commands/agents.py +0 -1369
  212. glaip_sdk/cli/commands/mcps.py +0 -1187
  213. glaip_sdk/cli/commands/tools.py +0 -584
  214. glaip_sdk/cli/utils.py +0 -1278
  215. glaip_sdk/models.py +0 -240
  216. glaip_sdk-0.1.2.dist-info/RECORD +0 -82
  217. glaip_sdk-0.1.2.dist-info/entry_points.txt +0 -3
@@ -6,10 +6,13 @@ 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
12
13
  import sys
14
+ import threading
15
+ import time
13
16
  from collections.abc import Callable, Iterable
14
17
  from dataclasses import dataclass
15
18
  from difflib import get_close_matches
@@ -18,6 +21,7 @@ from typing import Any
18
21
 
19
22
  import click
20
23
  from rich.console import Console, Group
24
+ from rich.live import Live
21
25
  from rich.text import Text
22
26
 
23
27
  from glaip_sdk.branding import (
@@ -32,9 +36,19 @@ from glaip_sdk.branding import (
32
36
  SUCCESS_STYLE,
33
37
  WARNING_STYLE,
34
38
  AIPBranding,
39
+ LogoAnimator,
35
40
  )
36
- from glaip_sdk.cli.commands.configure import configure_command, load_config
41
+ from glaip_sdk.cli.account_store import get_account_store
42
+ from glaip_sdk.cli.auth import resolve_api_url_from_context
43
+ from glaip_sdk.cli.commands import transcripts as transcripts_cmd
44
+ from glaip_sdk.cli.commands.configure import _configure_interactive, load_config
37
45
  from glaip_sdk.cli.commands.update import update_command
46
+ from glaip_sdk.cli.core.context import get_client, restore_slash_session_context
47
+ from glaip_sdk.cli.core.output import format_size
48
+ from glaip_sdk.cli.core.prompting import _fuzzy_pick_for_resources
49
+ from glaip_sdk.cli.hints import command_hint, format_command_hint
50
+ from glaip_sdk.cli.slash.accounts_controller import AccountsController
51
+ from glaip_sdk.cli.slash.accounts_shared import env_credentials_present
38
52
  from glaip_sdk.cli.slash.agent_session import AgentRunSession
39
53
  from glaip_sdk.cli.slash.prompt import (
40
54
  FormattedText,
@@ -44,20 +58,14 @@ from glaip_sdk.cli.slash.prompt import (
44
58
  setup_prompt_toolkit,
45
59
  to_formatted_text,
46
60
  )
61
+ from glaip_sdk.cli.slash.remote_runs_controller import RemoteRunsController
62
+ from glaip_sdk.cli.slash.tui.context import TUIContext
47
63
  from glaip_sdk.cli.transcript import (
48
64
  export_cached_transcript,
49
- normalise_export_destination,
50
- resolve_manifest_for_export,
51
- suggest_filename,
65
+ load_history_snapshot,
52
66
  )
53
67
  from glaip_sdk.cli.transcript.viewer import ViewerContext, run_viewer_session
54
68
  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
- )
61
69
  from glaip_sdk.rich_components import AIPGrid, AIPPanel, AIPTable
62
70
 
63
71
  SlashHandler = Callable[["SlashSession", list[str], bool], bool]
@@ -71,6 +79,88 @@ class SlashCommand:
71
79
  help: str
72
80
  handler: SlashHandler
73
81
  aliases: tuple[str, ...] = ()
82
+ agent_only: bool = False
83
+
84
+
85
+ NEW_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
86
+ {
87
+ "cli": "transcripts",
88
+ "slash": "transcripts",
89
+ "description": "Review transcript cache",
90
+ "tag": "NEW",
91
+ "priority": 10,
92
+ "scope": "global",
93
+ },
94
+ {
95
+ "cli": None,
96
+ "slash": "runs",
97
+ "description": "View remote run history for the active agent",
98
+ "tag": "NEW",
99
+ "priority": 8,
100
+ "scope": "agent",
101
+ },
102
+ )
103
+
104
+
105
+ DEFAULT_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
106
+ {
107
+ "cli": None,
108
+ "slash": "accounts",
109
+ "description": "Switch account profile",
110
+ "priority": 5,
111
+ },
112
+ {
113
+ "cli": "status",
114
+ "slash": "status",
115
+ "description": "Connection check",
116
+ "priority": 0,
117
+ },
118
+ {
119
+ "cli": "agents list",
120
+ "slash": "agents",
121
+ "description": "Browse agents",
122
+ "priority": 0,
123
+ },
124
+ {
125
+ "cli": "help",
126
+ "slash": "help",
127
+ "description": "Show all commands",
128
+ "priority": 0,
129
+ },
130
+ {
131
+ "cli": "configure",
132
+ "slash": "login",
133
+ "description": f"Configure credentials (alias [{HINT_COMMAND_STYLE}]/configure[/])",
134
+ "priority": -1,
135
+ },
136
+ )
137
+
138
+
139
+ HELP_COMMAND = "/help"
140
+
141
+
142
+ def _quick_action_scope(action: dict[str, Any]) -> str:
143
+ """Return the scope for a quick action definition."""
144
+ scope = action.get("scope") or "global"
145
+ if isinstance(scope, str):
146
+ return scope.lower()
147
+ return "global"
148
+
149
+
150
+ @dataclass
151
+ class AnimationState:
152
+ """State for logo animation shared between threads.
153
+
154
+ Uses mutable lists for integer values to allow thread-safe updates
155
+ without requiring locks or atomic operations.
156
+ """
157
+
158
+ pulse_step: list[int] # Current animation step position
159
+ pulse_direction: list[int] # Direction of pulse (1 or -1)
160
+ step_size: list[int] # Step size for animation
161
+ current_status: list[str] # Current status message
162
+ animation_running: threading.Event # Event signaling animation is running
163
+ stop_requested: threading.Event # Event signaling stop was requested
74
164
 
75
165
 
76
166
  class SlashSession:
@@ -84,7 +174,11 @@ class SlashSession:
84
174
  console: Optional console instance, creates default if None
85
175
  """
86
176
  self.ctx = ctx
87
- self.console = console or Console()
177
+ self._interactive = bool(sys.stdin.isatty() and sys.stdout.isatty())
178
+ if console is None:
179
+ self.console = AIPBranding._make_console(force_terminal=self._interactive, soft_wrap=False)
180
+ else:
181
+ self.console = console
88
182
  self._commands: dict[str, SlashCommand] = {}
89
183
  self._unique_commands: dict[str, SlashCommand] = {}
90
184
  self._contextual_commands: dict[str, str] = {}
@@ -93,13 +187,13 @@ class SlashSession:
93
187
  self.recent_agents: list[dict[str, str]] = []
94
188
  self.last_run_input: str | None = None
95
189
  self._should_exit = False
96
- self._interactive = bool(sys.stdin.isatty() and sys.stdout.isatty())
97
190
  self._config_cache: dict[str, Any] | None = None
98
191
  self._welcome_rendered = False
99
192
  self._active_renderer: Any | None = None
100
193
  self._current_agent: Any | None = None
194
+ self._runs_pagination_state: dict[str, dict[str, Any]] = {} # agent_id -> {page, limit, cursor}
101
195
 
102
- self._home_placeholder = "Start with / to browse commands"
196
+ self._home_placeholder = "Hint: type / to explore commands · Ctrl+D exits"
103
197
 
104
198
  # Command string constants to avoid duplication
105
199
  self.STATUS_COMMAND = "/status"
@@ -116,13 +210,29 @@ class SlashSession:
116
210
  self._update_notifier = maybe_notify_update
117
211
  self._home_hint_shown = False
118
212
  self._agent_transcript_ready: dict[str, str] = {}
213
+ self.tui_ctx: TUIContext | None = None
214
+
215
+ # Animation configuration constants
216
+ ANIMATION_FPS = 20
217
+ ANIMATION_FRAME_DURATION = 1.0 / ANIMATION_FPS # 0.05 seconds
218
+ ANIMATION_STARTUP_DELAY = 0.1 # Delay to ensure animation starts
219
+
220
+ # Startup UI constants
221
+ INITIALIZING_STATUS = "Initializing..."
222
+ CLI_HEADING_MARKUP = "[bold]>_ GDP Labs AI Agents Package (AIP CLI)[/bold]"
119
223
 
120
224
  # ------------------------------------------------------------------
121
225
  # Session orchestration
122
226
  # ------------------------------------------------------------------
123
- def refresh_branding(self, sdk_version: str | None = None) -> None:
227
+ def refresh_branding(
228
+ self,
229
+ sdk_version: str | None = None,
230
+ *,
231
+ branding_cls: type[AIPBranding] | None = None,
232
+ ) -> None:
124
233
  """Refresh branding assets after an in-session SDK upgrade."""
125
- self._branding = AIPBranding.create_from_sdk(
234
+ branding_type = branding_cls or AIPBranding
235
+ self._branding = branding_type.create_from_sdk(
126
236
  sdk_version=sdk_version,
127
237
  package_name="glaip-sdk",
128
238
  )
@@ -132,6 +242,7 @@ class SlashSession:
132
242
  self._render_header(initial=True)
133
243
 
134
244
  def _setup_prompt_toolkit(self) -> None:
245
+ """Initialize prompt_toolkit session and style."""
135
246
  session, style = setup_prompt_toolkit(self, interactive=self._interactive)
136
247
  self._ptk_session = session
137
248
  self._ptk_style = style
@@ -149,20 +260,301 @@ class SlashSession:
149
260
  self._run_non_interactive(initial_commands)
150
261
  return
151
262
 
152
- if not self._ensure_configuration():
263
+ # Use animated logo during initialization if supported
264
+ animator = LogoAnimator(console=self.console)
265
+ if animator.should_animate() and self._interactive:
266
+ config_available = self._run_with_animated_logo(animator)
267
+ else:
268
+ # Fallback to static logo for non-TTY or NO_COLOR
269
+ config_available = self._run_with_static_logo(animator)
270
+
271
+ if not config_available:
153
272
  return
154
273
 
155
- self._maybe_show_update_prompt()
156
- self._render_header(initial=not self._welcome_rendered)
274
+ self._render_header(initial=not self._welcome_rendered, show_branding=False)
157
275
  if not self._default_actions_shown:
158
276
  self._show_default_quick_actions()
159
277
  self._run_interactive_loop()
160
278
  finally:
161
279
  if ctx_obj is not None:
162
- if previous_session is None:
163
- ctx_obj.pop("_slash_session", None)
280
+ restore_slash_session_context(ctx_obj, previous_session)
281
+
282
+ def _initialize_tui_context(self) -> None:
283
+ """Initialize TUI context with error handling.
284
+
285
+ Sets self.tui_ctx to None if initialization fails.
286
+ """
287
+ try:
288
+ self.tui_ctx = asyncio.run(TUIContext.create(detect_osc11=False))
289
+ except RuntimeError:
290
+ try:
291
+ loop = asyncio.get_event_loop()
292
+ except RuntimeError:
293
+ self.tui_ctx = None
294
+ else:
295
+ if loop.is_running():
296
+ self.tui_ctx = None
164
297
  else:
165
- ctx_obj["_slash_session"] = previous_session
298
+ self.tui_ctx = loop.run_until_complete(TUIContext.create(detect_osc11=False))
299
+ except Exception:
300
+ self.tui_ctx = None
301
+
302
+ def _run_initialization_tasks(
303
+ self,
304
+ current_status: list[str],
305
+ animation_running: threading.Event,
306
+ status_callback: Callable[[str], None] | None = None,
307
+ ) -> bool:
308
+ """Run initialization tasks with status updates.
309
+
310
+ Args:
311
+ current_status: Mutable list with current status message.
312
+ animation_running: Event to signal animation state.
313
+ status_callback: Optional callback to invoke when status changes.
314
+
315
+ Returns:
316
+ True if configuration is available, False otherwise.
317
+ """
318
+ # Task 1: TUI Context.
319
+ current_status[0] = "Detecting terminal..."
320
+ if status_callback:
321
+ status_callback(current_status[0])
322
+ self._initialize_tui_context()
323
+
324
+ # Task 2: Configuration.
325
+ current_status[0] = "Connecting to API..."
326
+ if status_callback:
327
+ status_callback(current_status[0])
328
+ if not self._ensure_configuration():
329
+ animation_running.clear()
330
+ return False
331
+
332
+ # Task 3: Updates.
333
+ current_status[0] = "Checking for updates..."
334
+ if status_callback:
335
+ status_callback(current_status[0])
336
+ # Defer update prompt if we are in animated initialization to avoid blocking/cluttering
337
+ self._maybe_show_update_prompt(defer=bool(status_callback is None))
338
+ return True
339
+
340
+ def _update_pulse_step(
341
+ self,
342
+ state: AnimationState,
343
+ animator: LogoAnimator,
344
+ ) -> bool:
345
+ """Update pulse step and direction.
346
+
347
+ Args:
348
+ state: Animation state container.
349
+ animator: LogoAnimator instance for animation.
350
+
351
+ Returns:
352
+ True if animation should continue, False if should stop.
353
+ """
354
+ state.pulse_step[0] += state.pulse_direction[0] * state.step_size[0]
355
+ if state.pulse_step[0] >= animator.max_width + 5:
356
+ state.pulse_step[0] = animator.max_width + 5
357
+ state.pulse_direction[0] = -1
358
+ return not state.stop_requested.is_set()
359
+ if state.pulse_step[0] <= -5:
360
+ state.pulse_step[0] = -5
361
+ state.pulse_direction[0] = 1
362
+ return not state.stop_requested.is_set()
363
+ return True
364
+
365
+ def _create_animation_updater(
366
+ self,
367
+ animator: LogoAnimator,
368
+ state: AnimationState,
369
+ heading: Text,
370
+ ) -> Callable[[Live], None]:
371
+ """Create animation update function for background thread.
372
+
373
+ Args:
374
+ animator: LogoAnimator instance for animation.
375
+ state: Animation state container.
376
+ heading: Text heading for frames.
377
+
378
+ Returns:
379
+ Function to update animation in background thread.
380
+ """
381
+
382
+ def build_frame(step: int, status_text: str) -> Group:
383
+ return Group(heading, Text(""), animator.generate_frame(step, status_text))
384
+
385
+ def update_animation(live: Live) -> None:
386
+ """Update animation in background thread."""
387
+ while state.animation_running.is_set():
388
+ # Calculate next step
389
+ if not self._update_pulse_step(state, animator):
390
+ break
391
+
392
+ # Update frame with current status
393
+ try:
394
+ live.update(build_frame(state.pulse_step[0], state.current_status[0]))
395
+ except Exception:
396
+ # Animation may be stopped, ignore errors
397
+ break
398
+ time.sleep(self.ANIMATION_FRAME_DURATION)
399
+ state.animation_running.clear()
400
+
401
+ return update_animation
402
+
403
+ def _stop_animation_thread(
404
+ self,
405
+ animation_thread: threading.Thread,
406
+ state: AnimationState,
407
+ ) -> None:
408
+ """Stop animation thread gracefully.
409
+
410
+ Args:
411
+ animation_thread: Thread running animation.
412
+ state: Animation state container.
413
+ """
414
+ state.stop_requested.set()
415
+ state.step_size[0] = 3
416
+ animation_thread.join(timeout=1.5)
417
+ if animation_thread.is_alive():
418
+ state.animation_running.clear()
419
+ animation_thread.join(timeout=0.2)
420
+
421
+ def _run_animated_initialization(
422
+ self,
423
+ live: Live,
424
+ animator: LogoAnimator,
425
+ state: AnimationState,
426
+ heading: Text,
427
+ banner: Text,
428
+ ) -> bool:
429
+ """Run initialization tasks with animated logo.
430
+
431
+ Args:
432
+ live: Live context for animation updates.
433
+ animator: LogoAnimator instance for animation.
434
+ state: Animation state container.
435
+ heading: Text heading for frames.
436
+ banner: Text banner for final display.
437
+
438
+ Returns:
439
+ True if configuration is available, False otherwise.
440
+ """
441
+
442
+ def build_banner() -> Group:
443
+ return Group(heading, Text(""), banner)
444
+
445
+ update_animation = self._create_animation_updater(
446
+ animator,
447
+ state,
448
+ heading,
449
+ )
450
+
451
+ # Start animation thread.
452
+ animation_thread = threading.Thread(target=update_animation, args=(live,), daemon=True)
453
+ animation_thread.start()
454
+
455
+ # Small delay to ensure animation starts.
456
+ time.sleep(self.ANIMATION_STARTUP_DELAY)
457
+
458
+ # Run initialization tasks.
459
+ if not self._run_initialization_tasks(state.current_status, state.animation_running, status_callback=None):
460
+ return False
461
+
462
+ # Stop animation and show final banner.
463
+ self._stop_animation_thread(animation_thread, state)
464
+ live.update(build_banner())
465
+ return True
466
+
467
+ def _run_with_animated_logo(self, animator: LogoAnimator) -> bool:
468
+ """Run initialization with animated logo.
469
+
470
+ Args:
471
+ animator: LogoAnimator instance for animation.
472
+
473
+ Returns:
474
+ True if configuration is available, False otherwise.
475
+ """
476
+ state = AnimationState(
477
+ pulse_step=[0], # Use list for mutable shared state.
478
+ pulse_direction=[1], # Use list for mutable shared state.
479
+ step_size=[1],
480
+ current_status=[self.INITIALIZING_STATUS],
481
+ animation_running=threading.Event(),
482
+ stop_requested=threading.Event(),
483
+ )
484
+ state.animation_running.set()
485
+ heading = Text.from_markup(self.CLI_HEADING_MARKUP)
486
+ banner = Text.from_markup(self._branding.get_welcome_banner())
487
+
488
+ def build_frame(step: int, status_text: str) -> Group:
489
+ return Group(heading, Text(""), animator.generate_frame(step, status_text))
490
+
491
+ try:
492
+ with Live(
493
+ build_frame(0, state.current_status[0]),
494
+ console=self.console,
495
+ refresh_per_second=self.ANIMATION_FPS,
496
+ transient=False,
497
+ ) as live:
498
+ return self._run_animated_initialization(
499
+ live,
500
+ animator,
501
+ state,
502
+ heading,
503
+ banner,
504
+ )
505
+ except KeyboardInterrupt:
506
+ # Graceful exit on Ctrl+C
507
+ state.animation_running.clear()
508
+ # Align with static path: show heading and cancellation message
509
+ heading = Text.from_markup(self.CLI_HEADING_MARKUP)
510
+ self.console.print(Group(heading, Text(""), animator.static_frame("Initialization cancelled.")))
511
+ return False
512
+
513
+ def _run_with_static_logo(self, animator: LogoAnimator) -> bool:
514
+ """Run initialization with static logo (non-TTY or NO_COLOR).
515
+
516
+ Args:
517
+ animator: LogoAnimator instance for static display.
518
+
519
+ Returns:
520
+ True if configuration is available, False otherwise.
521
+ """
522
+ heading = Text.from_markup(self.CLI_HEADING_MARKUP)
523
+ banner = Text.from_markup(self._branding.get_welcome_banner())
524
+
525
+ def build_frame(status_text: str) -> Group:
526
+ return Group(heading, Text(""), animator.static_frame(status_text))
527
+
528
+ def build_banner() -> Group:
529
+ return Group(heading, Text(""), banner)
530
+
531
+ try:
532
+ with Live(
533
+ build_frame(self.INITIALIZING_STATUS),
534
+ console=self.console,
535
+ refresh_per_second=4,
536
+ transient=False,
537
+ ) as live:
538
+ # Run initialization tasks with status updates, reusing shared logic.
539
+ current_status = [self.INITIALIZING_STATUS]
540
+ animation_running = threading.Event()
541
+ animation_running.set()
542
+
543
+ # Update Live display when status changes via callback.
544
+ def update_display(status: str) -> None:
545
+ """Update Live display with current status."""
546
+ live.update(build_frame(status))
547
+
548
+ if not self._run_initialization_tasks(
549
+ current_status, animation_running, status_callback=update_display
550
+ ):
551
+ return False
552
+
553
+ live.update(build_banner())
554
+ return True
555
+ except KeyboardInterrupt:
556
+ self.console.print(Group(heading, Text(""), animator.static_frame("Initialization cancelled.")))
557
+ return False
166
558
 
167
559
  def _run_interactive_loop(self) -> None:
168
560
  """Run the main interactive command loop."""
@@ -208,34 +600,124 @@ class SlashSession:
208
600
  if not self.handle_command(raw):
209
601
  break
210
602
 
603
+ def _handle_account_selection(self) -> bool:
604
+ """Handle account selection when accounts exist but none are active.
605
+
606
+ Returns:
607
+ True if configuration is ready after selection, False if user aborted.
608
+ """
609
+ self.console.print(f"[{INFO_STYLE}]No active account selected. Please choose an account:[/]")
610
+ try:
611
+ self._cmd_accounts([], False)
612
+ self._config_cache = None
613
+ return self._check_configuration_after_selection()
614
+ except KeyboardInterrupt:
615
+ self.console.print(f"[{ERROR_STYLE}]Account selection aborted. Closing the command palette.[/]")
616
+ return False
617
+
618
+ def _check_configuration_after_selection(self) -> bool:
619
+ """Check if configuration is ready after account selection.
620
+
621
+ Returns:
622
+ True if configuration is ready, False otherwise.
623
+ """
624
+ return self._configuration_ready()
625
+
626
+ def _handle_new_account_creation(self) -> bool:
627
+ """Handle new account creation when no accounts exist.
628
+
629
+ Returns:
630
+ True if configuration succeeded, False if user aborted.
631
+ """
632
+ previous_tip_env = os.environ.get("AIP_SUPPRESS_CONFIGURE_TIP")
633
+ os.environ["AIP_SUPPRESS_CONFIGURE_TIP"] = "1"
634
+ self._suppress_login_layout = True
635
+ try:
636
+ self._cmd_login([], False)
637
+ return True
638
+ except KeyboardInterrupt:
639
+ self.console.print(f"[{ERROR_STYLE}]Configuration aborted. Closing the command palette.[/]")
640
+ return False
641
+ finally:
642
+ self._suppress_login_layout = False
643
+ if previous_tip_env is None:
644
+ os.environ.pop("AIP_SUPPRESS_CONFIGURE_TIP", None)
645
+ else:
646
+ os.environ["AIP_SUPPRESS_CONFIGURE_TIP"] = previous_tip_env
647
+
211
648
  def _ensure_configuration(self) -> bool:
212
649
  """Ensure the CLI has both API URL and credentials before continuing."""
213
650
  while not self._configuration_ready():
214
- self.console.print(f"[{WARNING_STYLE}]Configuration required.[/] Launching `/login` wizard...")
215
- self._suppress_login_layout = True
216
- try:
217
- self._cmd_login([], False)
218
- except KeyboardInterrupt:
219
- self.console.print(f"[{ERROR_STYLE}]Configuration aborted. Closing the command palette.[/]")
651
+ store = get_account_store()
652
+ accounts = store.list_accounts()
653
+ active_account = store.get_active_account()
654
+
655
+ # If accounts exist but none are active, show accounts list first
656
+ if accounts and (not active_account or active_account not in accounts):
657
+ if not self._handle_account_selection():
658
+ return False
659
+ continue
660
+
661
+ # No accounts exist - prompt for configuration
662
+ if not self._handle_new_account_creation():
220
663
  return False
221
- finally:
222
- self._suppress_login_layout = False
223
664
 
224
665
  return True
225
666
 
667
+ def _get_credentials_from_context_and_env(self) -> tuple[str, str]:
668
+ """Get credentials from context and environment variables.
669
+
670
+ Returns:
671
+ Tuple of (api_url, api_key) from context/env overrides.
672
+ """
673
+ api_url = ""
674
+ api_key = ""
675
+ if isinstance(self.ctx.obj, dict):
676
+ api_url = self.ctx.obj.get("api_url", "")
677
+ api_key = self.ctx.obj.get("api_key", "")
678
+ # Environment variables take precedence
679
+ env_url = os.getenv("AIP_API_URL", "")
680
+ env_key = os.getenv("AIP_API_KEY", "")
681
+ return (env_url or api_url, env_key or api_key)
682
+
683
+ def _get_credentials_from_account_store(self) -> tuple[str, str] | None:
684
+ """Get credentials from the active account in account store.
685
+
686
+ Returns:
687
+ Tuple of (api_url, api_key) if active account exists, None otherwise.
688
+ """
689
+ store = get_account_store()
690
+ active_account = store.get_active_account()
691
+ if not active_account:
692
+ return None
693
+
694
+ account = store.get_account(active_account)
695
+ if not account:
696
+ return None
697
+
698
+ api_url = account.get("api_url", "")
699
+ api_key = account.get("api_key", "")
700
+ return (api_url, api_key)
701
+
226
702
  def _configuration_ready(self) -> bool:
227
703
  """Check whether API URL and credentials are available."""
228
- config = self._load_config()
229
- api_url = self._get_api_url(config)
230
- if not api_url:
704
+ # Check for explicit overrides in context/env first
705
+ override_url, override_key = self._get_credentials_from_context_and_env()
706
+ if override_url and override_key:
707
+ return True
708
+
709
+ # Read from account store directly to avoid stale cache
710
+ account_creds = self._get_credentials_from_account_store()
711
+ if account_creds is None:
231
712
  return False
232
713
 
233
- api_key: str | None = None
234
- if isinstance(self.ctx.obj, dict):
235
- api_key = self.ctx.obj.get("api_key")
714
+ store_url, store_key = account_creds
715
+
716
+ # Use override values if available, otherwise use store values
717
+ api_url = override_url or store_url
718
+ api_key = override_key or store_key
236
719
 
237
- api_key = api_key or config.get("api_key") or os.getenv("AIP_API_KEY")
238
- return bool(api_key)
720
+ return bool(api_url and api_key)
239
721
 
240
722
  def handle_command(self, raw: str, *, invoked_from_agent: bool = False) -> bool:
241
723
  """Parse and execute a single slash command string."""
@@ -250,7 +732,7 @@ class SlashSession:
250
732
  if suggestion:
251
733
  self.console.print(f"[{WARNING_STYLE}]Unknown command '{verb}'. Did you mean '/{suggestion}'?[/]")
252
734
  else:
253
- help_command = "/help"
735
+ help_command = HELP_COMMAND
254
736
  help_hint = format_command_hint(help_command) or help_command
255
737
  self.console.print(
256
738
  f"[{WARNING_STYLE}]Unknown command '{verb}'. Type {help_hint} for a list of options.[/]"
@@ -263,15 +745,28 @@ class SlashSession:
263
745
  return False
264
746
  return True
265
747
 
748
+ def _continue_session(self) -> bool:
749
+ """Signal that the slash session should remain active."""
750
+ return not self._should_exit
751
+
266
752
  # ------------------------------------------------------------------
267
753
  # Command handlers
268
754
  # ------------------------------------------------------------------
269
755
  def _cmd_help(self, _args: list[str], invoked_from_agent: bool) -> bool:
756
+ """Handle the /help command.
757
+
758
+ Args:
759
+ _args: Command arguments (unused).
760
+ invoked_from_agent: Whether invoked from agent context.
761
+
762
+ Returns:
763
+ True to continue session.
764
+ """
270
765
  try:
271
766
  if invoked_from_agent:
272
767
  self._render_agent_help()
273
768
  else:
274
- self._render_global_help()
769
+ self._render_global_help(include_agent_hint=True)
275
770
  except Exception as exc: # pragma: no cover - UI/display errors
276
771
  self.console.print(f"[{ERROR_STYLE}]Error displaying help: {exc}[/]")
277
772
  return False
@@ -279,15 +774,17 @@ class SlashSession:
279
774
  return True
280
775
 
281
776
  def _render_agent_help(self) -> None:
777
+ """Render help text for agent context commands."""
282
778
  table = AIPTable()
283
779
  table.add_column("Input", style=HINT_COMMAND_STYLE, no_wrap=True)
284
780
  table.add_column("What happens", style=HINT_DESCRIPTION_COLOR)
285
781
  table.add_row("<message>", "Run the active agent once with that prompt.")
286
- table.add_row("/details", "Show the full agent export and metadata.")
782
+ table.add_row("/details", "Show the agent export (prompts to expand instructions).")
287
783
  table.add_row(self.STATUS_COMMAND, "Display connection status without leaving.")
784
+ table.add_row("/runs", "✨ NEW · Open the remote run browser for this agent.")
288
785
  table.add_row("/export [path]", "Export the latest agent transcript as JSONL.")
289
786
  table.add_row("/exit (/back)", "Return to the slash home screen.")
290
- table.add_row("/help (/?)", "Display this context-aware menu.")
787
+ table.add_row(f"{HELP_COMMAND} (/?)", "Display this context-aware menu.")
291
788
 
292
789
  panel_items = [table]
293
790
  if self.last_run_input:
@@ -305,13 +802,28 @@ class SlashSession:
305
802
  border_style=PRIMARY,
306
803
  )
307
804
  )
805
+ new_commands_table = AIPTable()
806
+ new_commands_table.add_column("Command", style=HINT_COMMAND_STYLE, no_wrap=True)
807
+ new_commands_table.add_column("Description", style=HINT_DESCRIPTION_COLOR)
808
+ new_commands_table.add_row(
809
+ "/runs",
810
+ "✨ NEW · View remote run history with keyboard navigation and export options.",
811
+ )
812
+ self.console.print(
813
+ AIPPanel(
814
+ new_commands_table,
815
+ title="New commands",
816
+ border_style=SECONDARY_LIGHT,
817
+ )
818
+ )
308
819
 
309
- def _render_global_help(self) -> None:
820
+ def _render_global_help(self, *, include_agent_hint: bool = False) -> None:
821
+ """Render help text for global slash commands."""
310
822
  table = AIPTable()
311
823
  table.add_column("Command", style=HINT_COMMAND_STYLE, no_wrap=True)
312
824
  table.add_column("Description", style=HINT_DESCRIPTION_COLOR)
313
825
 
314
- for cmd in sorted(self._unique_commands.values(), key=lambda c: c.name):
826
+ for cmd in self._visible_commands(include_agent_only=False):
315
827
  aliases = ", ".join(f"/{alias}" for alias in cmd.aliases if alias)
316
828
  verb = f"/{cmd.name}"
317
829
  if aliases:
@@ -331,12 +843,27 @@ class SlashSession:
331
843
  border_style=PRIMARY,
332
844
  )
333
845
  )
846
+ if include_agent_hint:
847
+ self.console.print(
848
+ "[dim]Additional commands (e.g. `/runs`) become available after you pick an agent with `/agents`. "
849
+ "Those agent-only commands stay hidden here to avoid confusion.[/]"
850
+ )
334
851
 
335
852
  def _cmd_login(self, _args: list[str], _invoked_from_agent: bool) -> bool:
853
+ """Handle the /login command.
854
+
855
+ Args:
856
+ _args: Command arguments (unused).
857
+ _invoked_from_agent: Whether invoked from agent context (unused).
858
+
859
+ Returns:
860
+ True to continue session.
861
+ """
336
862
  self.console.print(f"[{ACCENT_STYLE}]Launching configuration wizard...[/]")
337
863
  try:
338
- self.ctx.invoke(configure_command)
339
- self._config_cache = None
864
+ # Use the modern account-aware wizard directly (bypasses legacy config gating)
865
+ _configure_interactive(account_name=None)
866
+ self.on_account_switched()
340
867
  if self._suppress_login_layout:
341
868
  self._welcome_rendered = False
342
869
  self._default_actions_shown = False
@@ -345,9 +872,18 @@ class SlashSession:
345
872
  self._show_default_quick_actions()
346
873
  except click.ClickException as exc:
347
874
  self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
348
- return True
875
+ return self._continue_session()
349
876
 
350
877
  def _cmd_status(self, _args: list[str], _invoked_from_agent: bool) -> bool:
878
+ """Handle the /status command.
879
+
880
+ Args:
881
+ _args: Command arguments (unused).
882
+ _invoked_from_agent: Whether invoked from agent context (unused).
883
+
884
+ Returns:
885
+ True to continue session.
886
+ """
351
887
  ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else None
352
888
  previous_console = None
353
889
  try:
@@ -374,9 +910,176 @@ class SlashSession:
374
910
  ctx_obj.pop("_slash_console", None)
375
911
  else:
376
912
  ctx_obj["_slash_console"] = previous_console
377
- return True
913
+ return self._continue_session()
914
+
915
+ def _cmd_transcripts(self, args: list[str], _invoked_from_agent: bool) -> bool:
916
+ """Handle the /transcripts command.
917
+
918
+ Args:
919
+ args: Command arguments (limit or detail/show with run_id).
920
+ _invoked_from_agent: Whether invoked from agent context (unused).
921
+
922
+ Returns:
923
+ True to continue session.
924
+ """
925
+ if args and args[0].lower() in {"detail", "show"}:
926
+ if len(args) < 2:
927
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts detail <run_id>[/]")
928
+ return self._continue_session()
929
+ self._show_transcript_detail(args[1])
930
+ return self._continue_session()
931
+
932
+ limit, ok = self._parse_transcripts_limit(args)
933
+ if not ok:
934
+ return self._continue_session()
935
+
936
+ snapshot = load_history_snapshot(limit=limit, ctx=self.ctx)
937
+
938
+ if self._handle_transcripts_empty(snapshot, limit):
939
+ return self._continue_session()
940
+
941
+ self._render_transcripts_snapshot(snapshot)
942
+ return self._continue_session()
943
+
944
+ def _parse_transcripts_limit(self, args: list[str]) -> tuple[int | None, bool]:
945
+ """Parse limit argument from transcripts command.
946
+
947
+ Args:
948
+ args: Command arguments.
949
+
950
+ Returns:
951
+ Tuple of (limit value or None, success boolean).
952
+ """
953
+ if not args:
954
+ return None, True
955
+ try:
956
+ limit = int(args[0])
957
+ except ValueError:
958
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts [limit][/]")
959
+ return None, False
960
+ if limit < 0:
961
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts [limit][/]")
962
+ return None, False
963
+ return limit, True
964
+
965
+ def _handle_transcripts_empty(self, snapshot: Any, limit: int | None) -> bool:
966
+ """Handle empty transcript snapshot cases.
967
+
968
+ Args:
969
+ snapshot: Transcript snapshot object.
970
+ limit: Limit value or None.
971
+
972
+ Returns:
973
+ True if empty case was handled, False otherwise.
974
+ """
975
+ if snapshot.cached_entries == 0:
976
+ self.console.print(f"[{WARNING_STYLE}]No cached transcripts yet. Run an agent first.[/]")
977
+ for warning in snapshot.warnings:
978
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
979
+ return True
980
+ if limit == 0 and snapshot.cached_entries:
981
+ self.console.print(f"[{WARNING_STYLE}]Limit is 0; nothing to display.[/]")
982
+ return True
983
+ return False
984
+
985
+ def _render_transcripts_snapshot(self, snapshot: Any) -> None:
986
+ """Render transcript snapshot table and metadata.
987
+
988
+ Args:
989
+ snapshot: Transcript snapshot object to render.
990
+ """
991
+ size_text = format_size(snapshot.total_size_bytes)
992
+ header = f"[dim]Manifest: {snapshot.manifest_path} · {snapshot.total_entries} runs · {size_text} used[/]"
993
+ self.console.print(header)
994
+
995
+ if snapshot.limit_clamped:
996
+ self.console.print(
997
+ f"[{WARNING_STYLE}]Requested limit exceeded maximum; showing first {snapshot.limit_applied} runs.[/]"
998
+ )
999
+
1000
+ if snapshot.total_entries > len(snapshot.entries):
1001
+ subset_message = (
1002
+ f"[dim]Showing {len(snapshot.entries)} of {snapshot.total_entries} "
1003
+ f"runs (limit={snapshot.limit_applied}).[/]"
1004
+ )
1005
+ self.console.print(subset_message)
1006
+ self.console.print("[dim]Hint: run `/transcripts <limit>` to change how many rows are displayed.[/]")
1007
+
1008
+ if snapshot.migration_summary:
1009
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
1010
+
1011
+ for warning in snapshot.warnings:
1012
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
1013
+
1014
+ table = transcripts_cmd._build_table(snapshot.entries)
1015
+ self.console.print(table)
1016
+ self.console.print("[dim]! Missing transcript[/]")
1017
+
1018
+ def _show_transcript_detail(self, run_id: str) -> None:
1019
+ """Render the cached transcript log for a single run."""
1020
+ snapshot = load_history_snapshot(ctx=self.ctx)
1021
+ entry = snapshot.index.get(run_id)
1022
+ if entry is None:
1023
+ self.console.print(f"[{WARNING_STYLE}]Run id {run_id} was not found in the cache manifest.[/]")
1024
+ return
1025
+
1026
+ try:
1027
+ transcript_path, transcript_text = transcripts_cmd._load_transcript_text(entry)
1028
+ except click.ClickException as exc:
1029
+ self.console.print(f"[{WARNING_STYLE}]{exc}[/]")
1030
+ return
1031
+
1032
+ meta, events = transcripts_cmd._decode_transcript(transcript_text)
1033
+ if transcripts_cmd._maybe_launch_transcript_viewer(
1034
+ self.ctx,
1035
+ entry,
1036
+ meta,
1037
+ events,
1038
+ console_override=self.console,
1039
+ force=True,
1040
+ initial_view="transcript",
1041
+ ):
1042
+ if snapshot.migration_summary:
1043
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
1044
+ for warning in snapshot.warnings:
1045
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
1046
+ return
1047
+
1048
+ if snapshot.migration_summary:
1049
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
1050
+ for warning in snapshot.warnings:
1051
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
1052
+ view = transcripts_cmd._render_transcript_display(entry, snapshot.manifest_path, transcript_path, meta, events)
1053
+ self.console.print(view, markup=False, highlight=False, soft_wrap=True, end="")
1054
+
1055
+ def _cmd_runs(self, args: list[str], _invoked_from_agent: bool) -> bool:
1056
+ """Handle the /runs command for browsing remote agent run history.
1057
+
1058
+ Args:
1059
+ args: Command arguments (optional run_id for detail view).
1060
+ _invoked_from_agent: Whether invoked from agent context.
1061
+
1062
+ Returns:
1063
+ True to continue session.
1064
+ """
1065
+ controller = RemoteRunsController(self)
1066
+ return controller.handle_runs_command(args)
1067
+
1068
+ def _cmd_accounts(self, args: list[str], _invoked_from_agent: bool) -> bool:
1069
+ """Handle the /accounts command for listing and switching accounts."""
1070
+ controller = AccountsController(self)
1071
+ return controller.handle_accounts_command(args)
378
1072
 
379
1073
  def _cmd_agents(self, args: list[str], _invoked_from_agent: bool) -> bool:
1074
+ """Handle the /agents command.
1075
+
1076
+ Args:
1077
+ args: Command arguments (optional agent reference).
1078
+ _invoked_from_agent: Whether invoked from agent context (unused).
1079
+
1080
+ Returns:
1081
+ True to continue session.
1082
+ """
380
1083
  client = self._get_client_or_fail()
381
1084
  if not client:
382
1085
  return True
@@ -442,7 +1145,7 @@ class SlashSession:
442
1145
  self._render_header()
443
1146
 
444
1147
  self._show_agent_followup_actions(picked_agent)
445
- return True
1148
+ return self._continue_session()
446
1149
 
447
1150
  def _show_agent_followup_actions(self, picked_agent: Any) -> None:
448
1151
  """Show follow-up action hints after agent session."""
@@ -454,6 +1157,7 @@ class SlashSession:
454
1157
  hints.append((f"/agents {agent_id}", f"Reopen {agent_label}"))
455
1158
  hints.extend(
456
1159
  [
1160
+ ("/accounts", "Switch account"),
457
1161
  (self.AGENTS_COMMAND, "Browse agents"),
458
1162
  (self.STATUS_COMMAND, "Check connection"),
459
1163
  ]
@@ -462,6 +1166,15 @@ class SlashSession:
462
1166
  self._show_quick_actions(hints, title="Next actions")
463
1167
 
464
1168
  def _cmd_exit(self, _args: list[str], invoked_from_agent: bool) -> bool:
1169
+ """Handle the /exit command.
1170
+
1171
+ Args:
1172
+ _args: Command arguments (unused).
1173
+ invoked_from_agent: Whether invoked from agent context.
1174
+
1175
+ Returns:
1176
+ False to exit session, True to continue.
1177
+ """
465
1178
  if invoked_from_agent:
466
1179
  # Returning False would stop the full session; we only want to exit
467
1180
  # the agent context. Raising a custom flag keeps the outer loop
@@ -475,6 +1188,7 @@ class SlashSession:
475
1188
  # Utilities
476
1189
  # ------------------------------------------------------------------
477
1190
  def _register_defaults(self) -> None:
1191
+ """Register default slash commands."""
478
1192
  self._register(
479
1193
  SlashCommand(
480
1194
  name="help",
@@ -486,7 +1200,7 @@ class SlashSession:
486
1200
  self._register(
487
1201
  SlashCommand(
488
1202
  name="login",
489
- help="Run `/login` (alias `/configure`) to set credentials.",
1203
+ help="Configure API credentials (alias `/configure`).",
490
1204
  handler=SlashSession._cmd_login,
491
1205
  aliases=("configure",),
492
1206
  )
@@ -498,6 +1212,23 @@ class SlashSession:
498
1212
  handler=SlashSession._cmd_status,
499
1213
  )
500
1214
  )
1215
+ self._register(
1216
+ SlashCommand(
1217
+ name="accounts",
1218
+ help="✨ NEW · Browse and switch stored accounts (Textual with Rich fallback).",
1219
+ handler=SlashSession._cmd_accounts,
1220
+ )
1221
+ )
1222
+ self._register(
1223
+ SlashCommand(
1224
+ name="transcripts",
1225
+ help=(
1226
+ "✨ NEW · Review cached transcript history. "
1227
+ "Add a number (e.g. `/transcripts 5`) to change the row limit."
1228
+ ),
1229
+ handler=SlashSession._cmd_transcripts,
1230
+ )
1231
+ )
501
1232
  self._register(
502
1233
  SlashCommand(
503
1234
  name="agents",
@@ -527,12 +1258,32 @@ class SlashSession:
527
1258
  handler=SlashSession._cmd_update,
528
1259
  )
529
1260
  )
1261
+ self._register(
1262
+ SlashCommand(
1263
+ name="runs",
1264
+ help="✨ NEW · Browse remote agent run history (requires active agent session).",
1265
+ handler=SlashSession._cmd_runs,
1266
+ agent_only=True,
1267
+ )
1268
+ )
530
1269
 
531
1270
  def _register(self, command: SlashCommand) -> None:
1271
+ """Register a slash command.
1272
+
1273
+ Args:
1274
+ command: SlashCommand to register.
1275
+ """
532
1276
  self._unique_commands[command.name] = command
533
1277
  for key in (command.name, *command.aliases):
534
1278
  self._commands[key] = command
535
1279
 
1280
+ def _visible_commands(self, *, include_agent_only: bool) -> list[SlashCommand]:
1281
+ """Return the list of commands that should be shown in global listings."""
1282
+ commands = sorted(self._unique_commands.values(), key=lambda c: c.name)
1283
+ if include_agent_only:
1284
+ return commands
1285
+ return [cmd for cmd in commands if not cmd.agent_only]
1286
+
536
1287
  def open_transcript_viewer(self, *, announce: bool = True) -> None:
537
1288
  """Launch the transcript viewer for the most recent run."""
538
1289
  payload, manifest = self._get_last_transcript()
@@ -557,6 +1308,14 @@ class SlashSession:
557
1308
  )
558
1309
 
559
1310
  def _export(destination: Path) -> Path:
1311
+ """Export cached transcript to destination.
1312
+
1313
+ Args:
1314
+ destination: Path to export transcript to.
1315
+
1316
+ Returns:
1317
+ Path to exported transcript file.
1318
+ """
560
1319
  return export_cached_transcript(destination=destination, run_id=run_id)
561
1320
 
562
1321
  try:
@@ -574,55 +1333,13 @@ class SlashSession:
574
1333
  manifest = ctx_obj.get("_last_transcript_manifest")
575
1334
  return payload, manifest
576
1335
 
577
- def _cmd_export(self, args: list[str], _invoked_from_agent: bool) -> bool:
1336
+ def _cmd_export(self, _args: list[str], _invoked_from_agent: bool) -> bool:
578
1337
  """Slash handler for `/export` command."""
579
- path_arg = args[0] if args else None
580
- run_id = args[1] if len(args) > 1 else None
581
-
582
- manifest_entry = resolve_manifest_for_export(self.ctx, run_id)
583
- if manifest_entry is None:
584
- if run_id:
585
- self.console.print(
586
- f"[{WARNING_STYLE}]No cached transcript found with run id {run_id!r}. "
587
- "Omit the run id to export the most recent run.[/]"
588
- )
589
- else:
590
- self.console.print(f"[{WARNING_STYLE}]No cached transcripts available yet. Run an agent first.[/]")
591
- return False
592
-
593
- destination = self._resolve_export_destination(path_arg, manifest_entry)
594
- if destination is None:
595
- return False
596
-
597
- try:
598
- exported = export_cached_transcript(
599
- destination=destination,
600
- run_id=manifest_entry.get("run_id"),
601
- )
602
- except FileNotFoundError as exc:
603
- self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
604
- return False
605
- except Exception as exc: # pragma: no cover - unexpected IO failures
606
- self.console.print(f"[{ERROR_STYLE}]Failed to export transcript: {exc}[/]")
607
- return False
608
- else:
609
- self.console.print(f"[{SUCCESS_STYLE}]Transcript exported to[/] {exported}")
610
- return True
611
-
612
- def _resolve_export_destination(self, path_arg: str | None, manifest_entry: dict[str, Any]) -> Path | None:
613
- if path_arg:
614
- return normalise_export_destination(Path(path_arg))
615
-
616
- default_name = suggest_filename(manifest_entry)
617
- prompt = f"Save transcript to [{default_name}]: "
618
- try:
619
- response = self.console.input(prompt)
620
- except EOFError:
621
- self.console.print("[dim]Export cancelled.[/dim]")
622
- return None
623
-
624
- chosen = response.strip() or default_name
625
- return normalise_export_destination(Path(chosen))
1338
+ self.console.print(
1339
+ f"[{WARNING_STYLE}]`/export` is deprecated. Use `/transcripts`, select a run, "
1340
+ "and open the transcript viewer to export.[/]"
1341
+ )
1342
+ return True
626
1343
 
627
1344
  def _cmd_update(self, args: list[str], _invoked_from_agent: bool) -> bool:
628
1345
  """Slash handler for `/update` command."""
@@ -695,6 +1412,14 @@ class SlashSession:
695
1412
  pass
696
1413
 
697
1414
  def _parse(self, raw: str) -> tuple[str, list[str]]:
1415
+ """Parse a raw command string into verb and arguments.
1416
+
1417
+ Args:
1418
+ raw: Raw command string.
1419
+
1420
+ Returns:
1421
+ Tuple of (verb, args).
1422
+ """
698
1423
  try:
699
1424
  tokens = shlex.split(raw)
700
1425
  except ValueError:
@@ -710,6 +1435,14 @@ class SlashSession:
710
1435
  return head, tokens[1:]
711
1436
 
712
1437
  def _suggest(self, verb: str) -> str | None:
1438
+ """Suggest a similar command name for an unknown verb.
1439
+
1440
+ Args:
1441
+ verb: Unknown command verb.
1442
+
1443
+ Returns:
1444
+ Suggested command name or None.
1445
+ """
713
1446
  keys = [cmd.name for cmd in self._unique_commands.values()]
714
1447
  match = get_close_matches(verb, keys, n=1)
715
1448
  return match[0] if match else None
@@ -738,6 +1471,7 @@ class SlashSession:
738
1471
  if callable(message):
739
1472
 
740
1473
  def prompt_text() -> Any:
1474
+ """Get formatted prompt text from callable message."""
741
1475
  return self._convert_message(message())
742
1476
  else:
743
1477
  prompt_text = self._convert_message(message)
@@ -783,10 +1517,42 @@ class SlashSession:
783
1517
  return self._prompt_with_basic_input(message, placeholder)
784
1518
 
785
1519
  def _get_client(self) -> Any: # type: ignore[no-any-return]
1520
+ """Get or create the API client instance.
1521
+
1522
+ Returns:
1523
+ API client instance.
1524
+ """
786
1525
  if self._client is None:
787
1526
  self._client = get_client(self.ctx)
788
1527
  return self._client
789
1528
 
1529
+ def on_account_switched(self, _account_name: str | None = None) -> None:
1530
+ """Reset any state that depends on the active account.
1531
+
1532
+ The active account can change via `/accounts` (or other flows that call
1533
+ AccountStore.set_active_account). The slash session caches a configured
1534
+ client instance, so we must invalidate it to avoid leaking the previous
1535
+ account's API URL/key into subsequent commands like `/agents` or `/runs`.
1536
+
1537
+ This method clears:
1538
+ - Client and config cache (account-specific credentials)
1539
+ - Current agent and recent agents (agent data is account-scoped)
1540
+ - Runs pagination state (runs are account-scoped)
1541
+ - Active renderer and transcript ready state (UI state tied to account context)
1542
+ - Contextual commands (may be account-specific)
1543
+
1544
+ These broader resets ensure a clean slate when switching accounts, preventing
1545
+ stale data from the previous account from appearing in the new account's context.
1546
+ """
1547
+ self._client = None
1548
+ self._config_cache = None
1549
+ self._current_agent = None
1550
+ self.recent_agents = []
1551
+ self._runs_pagination_state.clear()
1552
+ self.clear_active_renderer()
1553
+ self.clear_agent_transcript_ready()
1554
+ self.set_contextual_commands(None)
1555
+
790
1556
  def set_contextual_commands(self, commands: dict[str, str] | None, *, include_global: bool = True) -> None:
791
1557
  """Set context-specific commands that should appear in completions."""
792
1558
  self._contextual_commands = dict(commands or {})
@@ -801,6 +1567,11 @@ class SlashSession:
801
1567
  return self._contextual_include_global
802
1568
 
803
1569
  def _remember_agent(self, agent: Any) -> None: # type: ignore[no-any-return]
1570
+ """Remember an agent in recent agents list.
1571
+
1572
+ Args:
1573
+ agent: Agent object to remember.
1574
+ """
804
1575
  agent_data = {
805
1576
  "id": str(getattr(agent, "id", "")),
806
1577
  "name": getattr(agent, "name", "") or "",
@@ -817,14 +1588,24 @@ class SlashSession:
817
1588
  *,
818
1589
  focus_agent: bool = False,
819
1590
  initial: bool = False,
1591
+ show_branding: bool = True,
820
1592
  ) -> None:
1593
+ """Render the session header with branding and status.
1594
+
1595
+ Args:
1596
+ active_agent: Optional active agent to display.
1597
+ focus_agent: Whether to focus on agent display.
1598
+ initial: Whether this is the initial render.
1599
+ show_branding: Whether to render the branding banner.
1600
+ """
821
1601
  if focus_agent and active_agent is not None:
822
1602
  self._render_focused_agent_header(active_agent)
823
1603
  return
824
1604
 
825
1605
  full_header = initial or not self._welcome_rendered
826
- if full_header:
1606
+ if full_header and show_branding:
827
1607
  self._render_branding_banner()
1608
+ if full_header:
828
1609
  self.console.rule(style=PRIMARY)
829
1610
  self._render_main_header(active_agent, full=full_header)
830
1611
  if full_header:
@@ -834,14 +1615,17 @@ class SlashSession:
834
1615
  def _render_branding_banner(self) -> None:
835
1616
  """Render the GL AIP branding banner."""
836
1617
  banner = self._branding.get_welcome_banner()
837
- heading = "[bold]>_ GDP Labs AI Agents Package (AIP CLI)[/bold]"
1618
+ heading = self.CLI_HEADING_MARKUP
838
1619
  self.console.print(heading)
839
1620
  self.console.print()
840
1621
  self.console.print(banner)
841
1622
 
842
- def _maybe_show_update_prompt(self) -> None:
1623
+ def _maybe_show_update_prompt(self, *, defer: bool = False) -> None:
843
1624
  """Display update prompt once per session when applicable."""
844
- if self._update_prompt_shown:
1625
+ if self._update_prompt_shown or (defer and not self._update_prompt_shown):
1626
+ if defer:
1627
+ # Just mark as ready to show, but don't show yet
1628
+ return
845
1629
  return
846
1630
 
847
1631
  self._update_notifier(
@@ -860,8 +1644,9 @@ class SlashSession:
860
1644
 
861
1645
  header_grid = self._build_header_grid(agent_info, transcript_status)
862
1646
  keybar = self._build_keybar()
863
-
864
1647
  header_grid.add_row(keybar, "")
1648
+
1649
+ # Agent-scoped commands like /runs will appear in /help, no need to duplicate here
865
1650
  self.console.print(AIPPanel(header_grid, title="Agent Session", border_style=PRIMARY))
866
1651
 
867
1652
  def _get_agent_info(self, active_agent: Any) -> dict[str, str]:
@@ -903,14 +1688,16 @@ class SlashSession:
903
1688
  f"[{ACCENT_STYLE}]{agent_info['id']}[/]"
904
1689
  )
905
1690
  status_line = f"[{SUCCESS_STYLE}]ready[/]"
906
- status_line += " · transcript ready" if transcript_status["transcript_ready"] else " · transcript pending"
1691
+ if not transcript_status["has_transcript"]:
1692
+ status_line += " · no transcript"
1693
+ elif transcript_status["transcript_ready"]:
1694
+ status_line += " · transcript ready"
1695
+ else:
1696
+ status_line += " · transcript pending"
907
1697
  header_grid.add_row(primary_line, status_line)
908
1698
 
909
1699
  if agent_info["description"]:
910
- description = agent_info["description"]
911
- if not transcript_status["transcript_ready"]:
912
- description = f"{description} (transcript pending)"
913
- header_grid.add_row(f"[dim]{description}[/dim]", "")
1700
+ header_grid.add_row(f"[dim]{agent_info['description']}[/dim]", "")
914
1701
 
915
1702
  return header_grid
916
1703
 
@@ -919,10 +1706,11 @@ class SlashSession:
919
1706
  keybar = AIPGrid(expand=True)
920
1707
  keybar.add_column(justify="left", ratio=1)
921
1708
  keybar.add_column(justify="left", ratio=1)
1709
+ keybar.add_column(justify="left", ratio=1)
922
1710
 
923
1711
  keybar.add_row(
924
- format_command_hint("/help", "Show commands") or "",
925
- format_command_hint("/details", "Agent config") or "",
1712
+ format_command_hint(HELP_COMMAND, "Show commands") or "",
1713
+ format_command_hint("/details", "Agent config (expand prompt)") or "",
926
1714
  format_command_hint("/exit", "Back") or "",
927
1715
  )
928
1716
 
@@ -932,13 +1720,26 @@ class SlashSession:
932
1720
  """Render the main AIP environment header."""
933
1721
  config = self._load_config()
934
1722
 
1723
+ account_name, account_host, env_lock = self._get_account_context()
935
1724
  api_url = self._get_api_url(config)
936
- status = "Configured" if config.get("api_key") else "Not configured"
937
1725
 
938
- segments = [
939
- f"[dim]Base URL[/dim] • {api_url or 'Not configured'}",
940
- f"[dim]Credentials[/dim] • {status}",
941
- ]
1726
+ host_display = account_host or "Not configured"
1727
+ account_segment = f"[dim]Account[/dim] • {account_name} ({host_display})"
1728
+ if env_lock:
1729
+ account_segment += " 🔒"
1730
+
1731
+ segments = [account_segment]
1732
+
1733
+ if api_url:
1734
+ base_label = "[dim]Base URL[/dim]"
1735
+ if env_lock:
1736
+ base_label = "[dim]Base URL (env)[/dim]"
1737
+ # Always show Base URL when env-lock is active to reveal overrides
1738
+ if env_lock or api_url != account_host:
1739
+ segments.append(f"{base_label} • {api_url}")
1740
+ elif not api_url:
1741
+ segments.append("[dim]Base URL[/dim] • Not configured")
1742
+
942
1743
  agent_info = self._build_agent_status_line(active_agent)
943
1744
  if agent_info:
944
1745
  segments.append(agent_info)
@@ -961,12 +1762,23 @@ class SlashSession:
961
1762
  )
962
1763
  )
963
1764
 
964
- def _get_api_url(self, config: dict[str, Any]) -> str | None:
965
- """Get the API URL from various sources."""
966
- api_url = None
967
- if isinstance(self.ctx.obj, dict):
968
- api_url = self.ctx.obj.get("api_url")
969
- return api_url or config.get("api_url") or os.getenv("AIP_API_URL")
1765
+ def _get_api_url(self, _config: dict[str, Any] | None = None) -> str | None:
1766
+ """Get the API URL from context or account store (CLI/palette ignores env credentials)."""
1767
+ return resolve_api_url_from_context(self.ctx)
1768
+
1769
+ def _get_account_context(self) -> tuple[str, str, bool]:
1770
+ """Return active account name, host, and env-lock flag."""
1771
+ try:
1772
+ store = get_account_store()
1773
+ active = store.get_active_account() or "default"
1774
+ account = store.get_account(active) if hasattr(store, "get_account") else None
1775
+ host = ""
1776
+ if account:
1777
+ host = account.get("api_url", "")
1778
+ env_lock = env_credentials_present()
1779
+ return active, host, env_lock
1780
+ except Exception:
1781
+ return "default", "", env_credentials_present()
970
1782
 
971
1783
  def _build_agent_status_line(self, active_agent: Any | None) -> str | None:
972
1784
  """Return a short status line about the active or recent agent."""
@@ -981,35 +1793,96 @@ class SlashSession:
981
1793
  return None
982
1794
 
983
1795
  def _show_default_quick_actions(self) -> None:
984
- hints: list[tuple[str | None, str]] = [
985
- (
986
- command_hint("status", slash_command="status", ctx=self.ctx),
987
- "Connection check",
988
- ),
989
- (
990
- command_hint("agents list", slash_command="agents", ctx=self.ctx),
991
- "Browse agents",
992
- ),
993
- (
994
- command_hint("help", slash_command="help", ctx=self.ctx),
995
- "Show all commands",
996
- ),
997
- ]
998
- filtered = [(cmd, desc) for cmd, desc in hints if cmd]
999
- if filtered:
1000
- self._show_quick_actions(filtered, title="Quick actions")
1796
+ """Show simplified help hint to discover commands."""
1797
+ self.console.print(f"[dim]{'─' * 40}[/]")
1798
+ help_hint = format_command_hint(HELP_COMMAND, "Show all commands") or HELP_COMMAND
1799
+ self.console.print(f" {help_hint}")
1001
1800
  self._default_actions_shown = True
1002
1801
 
1802
+ def _collect_scoped_new_action_hints(self, scope: str) -> list[tuple[str, str]]:
1803
+ """Return new quick action hints filtered by scope."""
1804
+ scoped_actions = [action for action in NEW_QUICK_ACTIONS if _quick_action_scope(action) == scope]
1805
+ # Don't highlight with sparkle emoji in quick actions display - it will show in command palette instead
1806
+ return self._collect_quick_action_hints(scoped_actions)
1807
+
1808
+ def _collect_quick_action_hints(
1809
+ self,
1810
+ actions: Iterable[dict[str, Any]],
1811
+ ) -> list[tuple[str, str]]:
1812
+ """Collect quick action hints from action definitions.
1813
+
1814
+ Args:
1815
+ actions: Iterable of action dictionaries.
1816
+
1817
+ Returns:
1818
+ List of (command, description) tuples.
1819
+ """
1820
+ collected: list[tuple[str, str]] = []
1821
+
1822
+ def sort_key(payload: dict[str, Any]) -> tuple[int, str]:
1823
+ priority = int(payload.get("priority", 0))
1824
+ label = str(payload.get("slash") or payload.get("cli") or "")
1825
+ return (-priority, label.lower())
1826
+
1827
+ for action in sorted(actions, key=sort_key):
1828
+ hint = self._build_quick_action_hint(action)
1829
+ if hint:
1830
+ collected.append(hint)
1831
+ return collected
1832
+
1833
+ def _build_quick_action_hint(
1834
+ self,
1835
+ action: dict[str, Any],
1836
+ ) -> tuple[str, str] | None:
1837
+ """Build a quick action hint from an action definition.
1838
+
1839
+ Args:
1840
+ action: Action dictionary.
1841
+
1842
+ Returns:
1843
+ Tuple of (command, description) or None.
1844
+ """
1845
+ command = command_hint(action.get("cli"), slash_command=action.get("slash"), ctx=self.ctx)
1846
+ if not command:
1847
+ return None
1848
+ description = action.get("description", "")
1849
+ # Don't include tag or sparkle emoji in quick actions display
1850
+ # The NEW tag will only show in the command dropdown (help text)
1851
+ return command, description
1852
+
1853
+ def _render_quick_action_group(self, hints: list[tuple[str, str]], title: str) -> None:
1854
+ """Render a group of quick action hints.
1855
+
1856
+ Args:
1857
+ hints: List of (command, description) tuples.
1858
+ title: Group title.
1859
+ """
1860
+ for line in self._format_quick_action_lines(hints, title):
1861
+ self.console.print(line)
1862
+
1863
+ def _chunk_tokens(self, tokens: list[str], *, size: int) -> Iterable[list[str]]:
1864
+ """Chunk tokens into groups of specified size.
1865
+
1866
+ Args:
1867
+ tokens: List of tokens to chunk.
1868
+ size: Size of each chunk.
1869
+
1870
+ Yields:
1871
+ Lists of tokens.
1872
+ """
1873
+ for index in range(0, len(tokens), size):
1874
+ yield tokens[index : index + size]
1875
+
1003
1876
  def _render_home_hint(self) -> None:
1877
+ """Render hint text for home screen."""
1004
1878
  if self._home_hint_shown:
1005
1879
  return
1006
- hint_lines = [
1007
- f"[{HINT_PREFIX_STYLE}]Hint:[/]",
1008
- f" Type {format_command_hint('/') or '/'} to explore commands",
1009
- " Press [dim]Ctrl+C[/] to cancel the current entry",
1010
- " Press [dim]Ctrl+D[/] to quit",
1011
- ]
1012
- self.console.print("\n".join(hint_lines))
1880
+ hint_text = (
1881
+ f"[{HINT_PREFIX_STYLE}]Hint:[/] "
1882
+ f"Type {format_command_hint('/') or '/'} to explore commands · "
1883
+ "Press [dim]Ctrl+D[/] to quit"
1884
+ )
1885
+ self.console.print(hint_text)
1013
1886
  self._home_hint_shown = True
1014
1887
 
1015
1888
  def _show_quick_actions(
@@ -1019,30 +1892,99 @@ class SlashSession:
1019
1892
  title: str = "Quick actions",
1020
1893
  inline: bool = False,
1021
1894
  ) -> None:
1022
- hint_list = [(command, description) for command, description in hints if command]
1895
+ """Show quick action hints.
1896
+
1897
+ Args:
1898
+ hints: Iterable of (command, description) tuples.
1899
+ title: Title for the hints.
1900
+ inline: Whether to render inline or in a panel.
1901
+ """
1902
+ hint_list = self._normalize_quick_action_hints(hints)
1023
1903
  if not hint_list:
1024
1904
  return
1025
1905
 
1026
1906
  if inline:
1027
- lines: list[str] = []
1028
- for command, description in hint_list:
1029
- formatted = format_command_hint(command, description)
1030
- if formatted:
1031
- lines.append(formatted)
1032
- if lines:
1033
- self.console.print("\n".join(lines))
1907
+ self._render_inline_quick_actions(hint_list, title)
1034
1908
  return
1035
1909
 
1910
+ self._render_panel_quick_actions(hint_list, title)
1911
+
1912
+ def _normalize_quick_action_hints(self, hints: Iterable[tuple[str, str]]) -> list[tuple[str, str]]:
1913
+ """Normalize quick action hints by filtering out empty commands.
1914
+
1915
+ Args:
1916
+ hints: Iterable of (command, description) tuples.
1917
+
1918
+ Returns:
1919
+ List of normalized hints.
1920
+ """
1921
+ return [(command, description) for command, description in hints if command]
1922
+
1923
+ def _render_inline_quick_actions(self, hint_list: list[tuple[str, str]], title: str) -> None:
1924
+ """Render quick actions inline.
1925
+
1926
+ Args:
1927
+ hint_list: List of (command, description) tuples.
1928
+ title: Title for the hints.
1929
+ """
1930
+ tokens: list[str] = []
1931
+ for command, description in hint_list:
1932
+ formatted = format_command_hint(command, description)
1933
+ if formatted:
1934
+ tokens.append(formatted)
1935
+ if not tokens:
1936
+ return
1937
+ prefix = f"[dim]{title}:[/]" if title else ""
1938
+ body = " ".join(tokens)
1939
+ text = f"{prefix} {body}" if prefix else body
1940
+ self.console.print(text.strip())
1941
+
1942
+ def _render_panel_quick_actions(self, hint_list: list[tuple[str, str]], title: str) -> None:
1943
+ """Render quick actions in a panel.
1944
+
1945
+ Args:
1946
+ hint_list: List of (command, description) tuples.
1947
+ title: Panel title.
1948
+ """
1036
1949
  body_lines: list[Text] = []
1037
1950
  for command, description in hint_list:
1038
1951
  formatted = format_command_hint(command, description)
1039
1952
  if formatted:
1040
1953
  body_lines.append(Text.from_markup(formatted))
1041
-
1954
+ if not body_lines:
1955
+ return
1042
1956
  panel_content = Group(*body_lines)
1043
1957
  self.console.print(AIPPanel(panel_content, title=title, border_style=SECONDARY_LIGHT, expand=False))
1044
1958
 
1959
+ def _format_quick_action_lines(self, hints: list[tuple[str, str]], title: str) -> list[str]:
1960
+ """Return formatted lines for quick action hints."""
1961
+ if not hints:
1962
+ return []
1963
+ formatted_tokens: list[str] = []
1964
+ for command, description in hints:
1965
+ formatted = format_command_hint(command, description)
1966
+ if formatted:
1967
+ formatted_tokens.append(f"• {formatted}")
1968
+ if not formatted_tokens:
1969
+ return []
1970
+ lines: list[str] = []
1971
+ # Use vertical layout (1 per line) for better readability
1972
+ chunks = list(self._chunk_tokens(formatted_tokens, size=1))
1973
+ prefix = f"[dim]{title}[/dim]\n " if title else ""
1974
+ for idx, chunk in enumerate(chunks):
1975
+ row = " ".join(chunk)
1976
+ if idx == 0:
1977
+ lines.append(f"{prefix}{row}" if prefix else row)
1978
+ else:
1979
+ lines.append(f" {row}")
1980
+ return lines
1981
+
1045
1982
  def _load_config(self) -> dict[str, Any]:
1983
+ """Load configuration with caching.
1984
+
1985
+ Returns:
1986
+ Configuration dictionary.
1987
+ """
1046
1988
  if self._config_cache is None:
1047
1989
  try:
1048
1990
  self._config_cache = load_config() or {}
@@ -1051,6 +1993,16 @@ class SlashSession:
1051
1993
  return self._config_cache
1052
1994
 
1053
1995
  def _resolve_agent_from_ref(self, client: Any, available_agents: list[Any], ref: str) -> Any | None:
1996
+ """Resolve an agent from a reference string.
1997
+
1998
+ Args:
1999
+ client: API client instance.
2000
+ available_agents: List of available agents.
2001
+ ref: Reference string (ID or name).
2002
+
2003
+ Returns:
2004
+ Resolved agent or None.
2005
+ """
1054
2006
  ref = ref.strip()
1055
2007
  if not ref:
1056
2008
  return None