glaip-sdk 0.1.2__py3-none-any.whl → 0.6.5b3__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 (129) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1090 -0
  5. glaip_sdk/branding.py +13 -0
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/auth.py +254 -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.py +214 -74
  11. glaip_sdk/cli/commands/common_config.py +101 -0
  12. glaip_sdk/cli/commands/configure.py +729 -113
  13. glaip_sdk/cli/commands/mcps.py +241 -72
  14. glaip_sdk/cli/commands/models.py +11 -5
  15. glaip_sdk/cli/commands/tools.py +49 -57
  16. glaip_sdk/cli/commands/transcripts.py +755 -0
  17. glaip_sdk/cli/config.py +48 -4
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +8 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +846 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +41 -20
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +6 -3
  28. glaip_sdk/cli/main.py +228 -119
  29. glaip_sdk/cli/masking.py +21 -33
  30. glaip_sdk/cli/pager.py +9 -10
  31. glaip_sdk/cli/parsers/__init__.py +1 -3
  32. glaip_sdk/cli/slash/__init__.py +0 -9
  33. glaip_sdk/cli/slash/accounts_controller.py +500 -0
  34. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  35. glaip_sdk/cli/slash/agent_session.py +58 -20
  36. glaip_sdk/cli/slash/prompt.py +10 -0
  37. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  38. glaip_sdk/cli/slash/session.py +736 -134
  39. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  40. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  41. glaip_sdk/cli/slash/tui/accounts_app.py +872 -0
  42. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  43. glaip_sdk/cli/slash/tui/loading.py +58 -0
  44. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  45. glaip_sdk/cli/transcript/__init__.py +12 -52
  46. glaip_sdk/cli/transcript/cache.py +255 -44
  47. glaip_sdk/cli/transcript/capture.py +66 -1
  48. glaip_sdk/cli/transcript/history.py +815 -0
  49. glaip_sdk/cli/transcript/viewer.py +70 -463
  50. glaip_sdk/cli/update_notifier.py +14 -5
  51. glaip_sdk/cli/utils.py +243 -1258
  52. glaip_sdk/cli/validators.py +5 -6
  53. glaip_sdk/client/__init__.py +2 -1
  54. glaip_sdk/client/_agent_payloads.py +45 -9
  55. glaip_sdk/client/agent_runs.py +147 -0
  56. glaip_sdk/client/agents.py +287 -29
  57. glaip_sdk/client/base.py +1 -0
  58. glaip_sdk/client/main.py +19 -10
  59. glaip_sdk/client/mcps.py +122 -12
  60. glaip_sdk/client/run_rendering.py +133 -90
  61. glaip_sdk/client/shared.py +21 -0
  62. glaip_sdk/client/tools.py +153 -10
  63. glaip_sdk/config/constants.py +11 -0
  64. glaip_sdk/mcps/__init__.py +21 -0
  65. glaip_sdk/mcps/base.py +345 -0
  66. glaip_sdk/models/__init__.py +90 -0
  67. glaip_sdk/models/agent.py +47 -0
  68. glaip_sdk/models/agent_runs.py +116 -0
  69. glaip_sdk/models/common.py +42 -0
  70. glaip_sdk/models/mcp.py +33 -0
  71. glaip_sdk/models/tool.py +33 -0
  72. glaip_sdk/payload_schemas/__init__.py +1 -13
  73. glaip_sdk/registry/__init__.py +55 -0
  74. glaip_sdk/registry/agent.py +164 -0
  75. glaip_sdk/registry/base.py +139 -0
  76. glaip_sdk/registry/mcp.py +253 -0
  77. glaip_sdk/registry/tool.py +238 -0
  78. glaip_sdk/rich_components.py +58 -2
  79. glaip_sdk/tools/__init__.py +22 -0
  80. glaip_sdk/tools/base.py +435 -0
  81. glaip_sdk/utils/__init__.py +58 -12
  82. glaip_sdk/utils/bundler.py +267 -0
  83. glaip_sdk/utils/client.py +111 -0
  84. glaip_sdk/utils/client_utils.py +39 -7
  85. glaip_sdk/utils/datetime_helpers.py +58 -0
  86. glaip_sdk/utils/discovery.py +78 -0
  87. glaip_sdk/utils/display.py +23 -15
  88. glaip_sdk/utils/export.py +143 -0
  89. glaip_sdk/utils/general.py +0 -33
  90. glaip_sdk/utils/import_export.py +12 -7
  91. glaip_sdk/utils/import_resolver.py +492 -0
  92. glaip_sdk/utils/instructions.py +101 -0
  93. glaip_sdk/utils/rendering/__init__.py +115 -1
  94. glaip_sdk/utils/rendering/formatting.py +5 -30
  95. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  96. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  97. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  98. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  99. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  100. glaip_sdk/utils/rendering/models.py +1 -0
  101. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  102. glaip_sdk/utils/rendering/renderer/base.py +241 -1434
  103. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  104. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  105. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  106. glaip_sdk/utils/rendering/renderer/stream.py +4 -33
  107. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  108. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  109. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  110. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  111. glaip_sdk/utils/rendering/state.py +204 -0
  112. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  113. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  114. glaip_sdk/utils/rendering/steps/format.py +176 -0
  115. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  116. glaip_sdk/utils/rendering/timing.py +36 -0
  117. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  118. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  119. glaip_sdk/utils/resource_refs.py +25 -13
  120. glaip_sdk/utils/runtime_config.py +306 -0
  121. glaip_sdk/utils/serialization.py +18 -0
  122. glaip_sdk/utils/sync.py +142 -0
  123. glaip_sdk/utils/validation.py +16 -24
  124. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.6.5b3.dist-info}/METADATA +39 -4
  125. glaip_sdk-0.6.5b3.dist-info/RECORD +145 -0
  126. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.6.5b3.dist-info}/WHEEL +1 -1
  127. glaip_sdk/models.py +0 -240
  128. glaip_sdk-0.1.2.dist-info/RECORD +0 -82
  129. {glaip_sdk-0.1.2.dist-info → glaip_sdk-0.6.5b3.dist-info}/entry_points.txt +0 -0
@@ -33,9 +33,15 @@ from glaip_sdk.branding import (
33
33
  WARNING_STYLE,
34
34
  AIPBranding,
35
35
  )
36
- from glaip_sdk.cli.commands.configure import configure_command, load_config
36
+ from glaip_sdk.cli.auth import resolve_api_url_from_context
37
+ from glaip_sdk.cli.account_store import get_account_store
38
+ from glaip_sdk.cli.commands import transcripts as transcripts_cmd
39
+ from glaip_sdk.cli.commands.configure import _configure_interactive, load_config
37
40
  from glaip_sdk.cli.commands.update import update_command
41
+ from glaip_sdk.cli.hints import format_command_hint
42
+ from glaip_sdk.cli.slash.accounts_controller import AccountsController
38
43
  from glaip_sdk.cli.slash.agent_session import AgentRunSession
44
+ from glaip_sdk.cli.slash.accounts_shared import env_credentials_present
39
45
  from glaip_sdk.cli.slash.prompt import (
40
46
  FormattedText,
41
47
  PromptSession,
@@ -44,19 +50,19 @@ from glaip_sdk.cli.slash.prompt import (
44
50
  setup_prompt_toolkit,
45
51
  to_formatted_text,
46
52
  )
53
+ from glaip_sdk.cli.slash.remote_runs_controller import RemoteRunsController
47
54
  from glaip_sdk.cli.transcript import (
48
55
  export_cached_transcript,
49
- normalise_export_destination,
50
- resolve_manifest_for_export,
51
- suggest_filename,
56
+ load_history_snapshot,
52
57
  )
53
58
  from glaip_sdk.cli.transcript.viewer import ViewerContext, run_viewer_session
54
59
  from glaip_sdk.cli.update_notifier import maybe_notify_update
55
60
  from glaip_sdk.cli.utils import (
56
61
  _fuzzy_pick_for_resources,
57
62
  command_hint,
58
- format_command_hint,
63
+ format_size,
59
64
  get_client,
65
+ restore_slash_session_context,
60
66
  )
61
67
  from glaip_sdk.rich_components import AIPGrid, AIPPanel, AIPTable
62
68
 
@@ -71,6 +77,72 @@ class SlashCommand:
71
77
  help: str
72
78
  handler: SlashHandler
73
79
  aliases: tuple[str, ...] = ()
80
+ agent_only: bool = False
81
+
82
+
83
+ NEW_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
84
+ {
85
+ "cli": "transcripts",
86
+ "slash": "transcripts",
87
+ "description": "Review transcript cache",
88
+ "tag": "NEW",
89
+ "priority": 10,
90
+ "scope": "global",
91
+ },
92
+ {
93
+ "cli": None,
94
+ "slash": "runs",
95
+ "description": "View remote run history for the active agent",
96
+ "tag": "NEW",
97
+ "priority": 8,
98
+ "scope": "agent",
99
+ },
100
+ )
101
+
102
+
103
+ DEFAULT_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
104
+ {
105
+ "cli": None,
106
+ "slash": "accounts",
107
+ "description": "Switch account profile",
108
+ "priority": 5,
109
+ },
110
+ {
111
+ "cli": "status",
112
+ "slash": "status",
113
+ "description": "Connection check",
114
+ "priority": 0,
115
+ },
116
+ {
117
+ "cli": "agents list",
118
+ "slash": "agents",
119
+ "description": "Browse agents",
120
+ "priority": 0,
121
+ },
122
+ {
123
+ "cli": "help",
124
+ "slash": "help",
125
+ "description": "Show all commands",
126
+ "priority": 0,
127
+ },
128
+ {
129
+ "cli": "configure",
130
+ "slash": "login",
131
+ "description": f"Configure credentials (alias [{HINT_COMMAND_STYLE}]/configure[/])",
132
+ "priority": -1,
133
+ },
134
+ )
135
+
136
+
137
+ HELP_COMMAND = "/help"
138
+
139
+
140
+ def _quick_action_scope(action: dict[str, Any]) -> str:
141
+ """Return the scope for a quick action definition."""
142
+ scope = action.get("scope") or "global"
143
+ if isinstance(scope, str):
144
+ return scope.lower()
145
+ return "global"
74
146
 
75
147
 
76
148
  class SlashSession:
@@ -98,8 +170,9 @@ class SlashSession:
98
170
  self._welcome_rendered = False
99
171
  self._active_renderer: Any | None = None
100
172
  self._current_agent: Any | None = None
173
+ self._runs_pagination_state: dict[str, dict[str, Any]] = {} # agent_id -> {page, limit, cursor}
101
174
 
102
- self._home_placeholder = "Start with / to browse commands"
175
+ self._home_placeholder = "Hint: type / to explore commands · Ctrl+D exits"
103
176
 
104
177
  # Command string constants to avoid duplication
105
178
  self.STATUS_COMMAND = "/status"
@@ -120,9 +193,15 @@ class SlashSession:
120
193
  # ------------------------------------------------------------------
121
194
  # Session orchestration
122
195
  # ------------------------------------------------------------------
123
- def refresh_branding(self, sdk_version: str | None = None) -> None:
196
+ def refresh_branding(
197
+ self,
198
+ sdk_version: str | None = None,
199
+ *,
200
+ branding_cls: type[AIPBranding] | None = None,
201
+ ) -> None:
124
202
  """Refresh branding assets after an in-session SDK upgrade."""
125
- self._branding = AIPBranding.create_from_sdk(
203
+ branding_type = branding_cls or AIPBranding
204
+ self._branding = branding_type.create_from_sdk(
126
205
  sdk_version=sdk_version,
127
206
  package_name="glaip-sdk",
128
207
  )
@@ -132,6 +211,7 @@ class SlashSession:
132
211
  self._render_header(initial=True)
133
212
 
134
213
  def _setup_prompt_toolkit(self) -> None:
214
+ """Initialize prompt_toolkit session and style."""
135
215
  session, style = setup_prompt_toolkit(self, interactive=self._interactive)
136
216
  self._ptk_session = session
137
217
  self._ptk_style = style
@@ -159,10 +239,7 @@ class SlashSession:
159
239
  self._run_interactive_loop()
160
240
  finally:
161
241
  if ctx_obj is not None:
162
- if previous_session is None:
163
- ctx_obj.pop("_slash_session", None)
164
- else:
165
- ctx_obj["_slash_session"] = previous_session
242
+ restore_slash_session_context(ctx_obj, previous_session)
166
243
 
167
244
  def _run_interactive_loop(self) -> None:
168
245
  """Run the main interactive command loop."""
@@ -208,34 +285,124 @@ class SlashSession:
208
285
  if not self.handle_command(raw):
209
286
  break
210
287
 
288
+ def _handle_account_selection(self) -> bool:
289
+ """Handle account selection when accounts exist but none are active.
290
+
291
+ Returns:
292
+ True if configuration is ready after selection, False if user aborted.
293
+ """
294
+ self.console.print(f"[{INFO_STYLE}]No active account selected. Please choose an account:[/]")
295
+ try:
296
+ self._cmd_accounts([], False)
297
+ self._config_cache = None
298
+ return self._check_configuration_after_selection()
299
+ except KeyboardInterrupt:
300
+ self.console.print(f"[{ERROR_STYLE}]Account selection aborted. Closing the command palette.[/]")
301
+ return False
302
+
303
+ def _check_configuration_after_selection(self) -> bool:
304
+ """Check if configuration is ready after account selection.
305
+
306
+ Returns:
307
+ True if configuration is ready, False otherwise.
308
+ """
309
+ return self._configuration_ready()
310
+
311
+ def _handle_new_account_creation(self) -> bool:
312
+ """Handle new account creation when no accounts exist.
313
+
314
+ Returns:
315
+ True if configuration succeeded, False if user aborted.
316
+ """
317
+ previous_tip_env = os.environ.get("AIP_SUPPRESS_CONFIGURE_TIP")
318
+ os.environ["AIP_SUPPRESS_CONFIGURE_TIP"] = "1"
319
+ self._suppress_login_layout = True
320
+ try:
321
+ self._cmd_login([], False)
322
+ return True
323
+ except KeyboardInterrupt:
324
+ self.console.print(f"[{ERROR_STYLE}]Configuration aborted. Closing the command palette.[/]")
325
+ return False
326
+ finally:
327
+ self._suppress_login_layout = False
328
+ if previous_tip_env is None:
329
+ os.environ.pop("AIP_SUPPRESS_CONFIGURE_TIP", None)
330
+ else:
331
+ os.environ["AIP_SUPPRESS_CONFIGURE_TIP"] = previous_tip_env
332
+
211
333
  def _ensure_configuration(self) -> bool:
212
334
  """Ensure the CLI has both API URL and credentials before continuing."""
213
335
  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.[/]")
336
+ store = get_account_store()
337
+ accounts = store.list_accounts()
338
+ active_account = store.get_active_account()
339
+
340
+ # If accounts exist but none are active, show accounts list first
341
+ if accounts and (not active_account or active_account not in accounts):
342
+ if not self._handle_account_selection():
343
+ return False
344
+ continue
345
+
346
+ # No accounts exist - prompt for configuration
347
+ if not self._handle_new_account_creation():
220
348
  return False
221
- finally:
222
- self._suppress_login_layout = False
223
349
 
224
350
  return True
225
351
 
352
+ def _get_credentials_from_context_and_env(self) -> tuple[str, str]:
353
+ """Get credentials from context and environment variables.
354
+
355
+ Returns:
356
+ Tuple of (api_url, api_key) from context/env overrides.
357
+ """
358
+ api_url = ""
359
+ api_key = ""
360
+ if isinstance(self.ctx.obj, dict):
361
+ api_url = self.ctx.obj.get("api_url", "")
362
+ api_key = self.ctx.obj.get("api_key", "")
363
+ # Environment variables take precedence
364
+ env_url = os.getenv("AIP_API_URL", "")
365
+ env_key = os.getenv("AIP_API_KEY", "")
366
+ return (env_url or api_url, env_key or api_key)
367
+
368
+ def _get_credentials_from_account_store(self) -> tuple[str, str] | None:
369
+ """Get credentials from the active account in account store.
370
+
371
+ Returns:
372
+ Tuple of (api_url, api_key) if active account exists, None otherwise.
373
+ """
374
+ store = get_account_store()
375
+ active_account = store.get_active_account()
376
+ if not active_account:
377
+ return None
378
+
379
+ account = store.get_account(active_account)
380
+ if not account:
381
+ return None
382
+
383
+ api_url = account.get("api_url", "")
384
+ api_key = account.get("api_key", "")
385
+ return (api_url, api_key)
386
+
226
387
  def _configuration_ready(self) -> bool:
227
388
  """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:
389
+ # Check for explicit overrides in context/env first
390
+ override_url, override_key = self._get_credentials_from_context_and_env()
391
+ if override_url and override_key:
392
+ return True
393
+
394
+ # Read from account store directly to avoid stale cache
395
+ account_creds = self._get_credentials_from_account_store()
396
+ if account_creds is None:
231
397
  return False
232
398
 
233
- api_key: str | None = None
234
- if isinstance(self.ctx.obj, dict):
235
- api_key = self.ctx.obj.get("api_key")
399
+ store_url, store_key = account_creds
400
+
401
+ # Use override values if available, otherwise use store values
402
+ api_url = override_url or store_url
403
+ api_key = override_key or store_key
236
404
 
237
- api_key = api_key or config.get("api_key") or os.getenv("AIP_API_KEY")
238
- return bool(api_key)
405
+ return bool(api_url and api_key)
239
406
 
240
407
  def handle_command(self, raw: str, *, invoked_from_agent: bool = False) -> bool:
241
408
  """Parse and execute a single slash command string."""
@@ -250,7 +417,7 @@ class SlashSession:
250
417
  if suggestion:
251
418
  self.console.print(f"[{WARNING_STYLE}]Unknown command '{verb}'. Did you mean '/{suggestion}'?[/]")
252
419
  else:
253
- help_command = "/help"
420
+ help_command = HELP_COMMAND
254
421
  help_hint = format_command_hint(help_command) or help_command
255
422
  self.console.print(
256
423
  f"[{WARNING_STYLE}]Unknown command '{verb}'. Type {help_hint} for a list of options.[/]"
@@ -263,15 +430,28 @@ class SlashSession:
263
430
  return False
264
431
  return True
265
432
 
433
+ def _continue_session(self) -> bool:
434
+ """Signal that the slash session should remain active."""
435
+ return not self._should_exit
436
+
266
437
  # ------------------------------------------------------------------
267
438
  # Command handlers
268
439
  # ------------------------------------------------------------------
269
440
  def _cmd_help(self, _args: list[str], invoked_from_agent: bool) -> bool:
441
+ """Handle the /help command.
442
+
443
+ Args:
444
+ _args: Command arguments (unused).
445
+ invoked_from_agent: Whether invoked from agent context.
446
+
447
+ Returns:
448
+ True to continue session.
449
+ """
270
450
  try:
271
451
  if invoked_from_agent:
272
452
  self._render_agent_help()
273
453
  else:
274
- self._render_global_help()
454
+ self._render_global_help(include_agent_hint=True)
275
455
  except Exception as exc: # pragma: no cover - UI/display errors
276
456
  self.console.print(f"[{ERROR_STYLE}]Error displaying help: {exc}[/]")
277
457
  return False
@@ -279,15 +459,17 @@ class SlashSession:
279
459
  return True
280
460
 
281
461
  def _render_agent_help(self) -> None:
462
+ """Render help text for agent context commands."""
282
463
  table = AIPTable()
283
464
  table.add_column("Input", style=HINT_COMMAND_STYLE, no_wrap=True)
284
465
  table.add_column("What happens", style=HINT_DESCRIPTION_COLOR)
285
466
  table.add_row("<message>", "Run the active agent once with that prompt.")
286
- table.add_row("/details", "Show the full agent export and metadata.")
467
+ table.add_row("/details", "Show the agent export (prompts to expand instructions).")
287
468
  table.add_row(self.STATUS_COMMAND, "Display connection status without leaving.")
469
+ table.add_row("/runs", "✨ NEW · Open the remote run browser for this agent.")
288
470
  table.add_row("/export [path]", "Export the latest agent transcript as JSONL.")
289
471
  table.add_row("/exit (/back)", "Return to the slash home screen.")
290
- table.add_row("/help (/?)", "Display this context-aware menu.")
472
+ table.add_row(f"{HELP_COMMAND} (/?)", "Display this context-aware menu.")
291
473
 
292
474
  panel_items = [table]
293
475
  if self.last_run_input:
@@ -305,13 +487,28 @@ class SlashSession:
305
487
  border_style=PRIMARY,
306
488
  )
307
489
  )
490
+ new_commands_table = AIPTable()
491
+ new_commands_table.add_column("Command", style=HINT_COMMAND_STYLE, no_wrap=True)
492
+ new_commands_table.add_column("Description", style=HINT_DESCRIPTION_COLOR)
493
+ new_commands_table.add_row(
494
+ "/runs",
495
+ "✨ NEW · View remote run history with keyboard navigation and export options.",
496
+ )
497
+ self.console.print(
498
+ AIPPanel(
499
+ new_commands_table,
500
+ title="New commands",
501
+ border_style=SECONDARY_LIGHT,
502
+ )
503
+ )
308
504
 
309
- def _render_global_help(self) -> None:
505
+ def _render_global_help(self, *, include_agent_hint: bool = False) -> None:
506
+ """Render help text for global slash commands."""
310
507
  table = AIPTable()
311
508
  table.add_column("Command", style=HINT_COMMAND_STYLE, no_wrap=True)
312
509
  table.add_column("Description", style=HINT_DESCRIPTION_COLOR)
313
510
 
314
- for cmd in sorted(self._unique_commands.values(), key=lambda c: c.name):
511
+ for cmd in self._visible_commands(include_agent_only=False):
315
512
  aliases = ", ".join(f"/{alias}" for alias in cmd.aliases if alias)
316
513
  verb = f"/{cmd.name}"
317
514
  if aliases:
@@ -331,11 +528,26 @@ class SlashSession:
331
528
  border_style=PRIMARY,
332
529
  )
333
530
  )
531
+ if include_agent_hint:
532
+ self.console.print(
533
+ "[dim]Additional commands (e.g. `/runs`) become available after you pick an agent with `/agents`. "
534
+ "Those agent-only commands stay hidden here to avoid confusion.[/]"
535
+ )
334
536
 
335
537
  def _cmd_login(self, _args: list[str], _invoked_from_agent: bool) -> bool:
538
+ """Handle the /login command.
539
+
540
+ Args:
541
+ _args: Command arguments (unused).
542
+ _invoked_from_agent: Whether invoked from agent context (unused).
543
+
544
+ Returns:
545
+ True to continue session.
546
+ """
336
547
  self.console.print(f"[{ACCENT_STYLE}]Launching configuration wizard...[/]")
337
548
  try:
338
- self.ctx.invoke(configure_command)
549
+ # Use the modern account-aware wizard directly (bypasses legacy config gating)
550
+ _configure_interactive(account_name=None)
339
551
  self._config_cache = None
340
552
  if self._suppress_login_layout:
341
553
  self._welcome_rendered = False
@@ -345,9 +557,18 @@ class SlashSession:
345
557
  self._show_default_quick_actions()
346
558
  except click.ClickException as exc:
347
559
  self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
348
- return True
560
+ return self._continue_session()
349
561
 
350
562
  def _cmd_status(self, _args: list[str], _invoked_from_agent: bool) -> bool:
563
+ """Handle the /status command.
564
+
565
+ Args:
566
+ _args: Command arguments (unused).
567
+ _invoked_from_agent: Whether invoked from agent context (unused).
568
+
569
+ Returns:
570
+ True to continue session.
571
+ """
351
572
  ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else None
352
573
  previous_console = None
353
574
  try:
@@ -374,9 +595,176 @@ class SlashSession:
374
595
  ctx_obj.pop("_slash_console", None)
375
596
  else:
376
597
  ctx_obj["_slash_console"] = previous_console
377
- return True
598
+ return self._continue_session()
599
+
600
+ def _cmd_transcripts(self, args: list[str], _invoked_from_agent: bool) -> bool:
601
+ """Handle the /transcripts command.
602
+
603
+ Args:
604
+ args: Command arguments (limit or detail/show with run_id).
605
+ _invoked_from_agent: Whether invoked from agent context (unused).
606
+
607
+ Returns:
608
+ True to continue session.
609
+ """
610
+ if args and args[0].lower() in {"detail", "show"}:
611
+ if len(args) < 2:
612
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts detail <run_id>[/]")
613
+ return self._continue_session()
614
+ self._show_transcript_detail(args[1])
615
+ return self._continue_session()
616
+
617
+ limit, ok = self._parse_transcripts_limit(args)
618
+ if not ok:
619
+ return self._continue_session()
620
+
621
+ snapshot = load_history_snapshot(limit=limit, ctx=self.ctx)
622
+
623
+ if self._handle_transcripts_empty(snapshot, limit):
624
+ return self._continue_session()
625
+
626
+ self._render_transcripts_snapshot(snapshot)
627
+ return self._continue_session()
628
+
629
+ def _parse_transcripts_limit(self, args: list[str]) -> tuple[int | None, bool]:
630
+ """Parse limit argument from transcripts command.
631
+
632
+ Args:
633
+ args: Command arguments.
634
+
635
+ Returns:
636
+ Tuple of (limit value or None, success boolean).
637
+ """
638
+ if not args:
639
+ return None, True
640
+ try:
641
+ limit = int(args[0])
642
+ except ValueError:
643
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts [limit][/]")
644
+ return None, False
645
+ if limit < 0:
646
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts [limit][/]")
647
+ return None, False
648
+ return limit, True
649
+
650
+ def _handle_transcripts_empty(self, snapshot: Any, limit: int | None) -> bool:
651
+ """Handle empty transcript snapshot cases.
652
+
653
+ Args:
654
+ snapshot: Transcript snapshot object.
655
+ limit: Limit value or None.
656
+
657
+ Returns:
658
+ True if empty case was handled, False otherwise.
659
+ """
660
+ if snapshot.cached_entries == 0:
661
+ self.console.print(f"[{WARNING_STYLE}]No cached transcripts yet. Run an agent first.[/]")
662
+ for warning in snapshot.warnings:
663
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
664
+ return True
665
+ if limit == 0 and snapshot.cached_entries:
666
+ self.console.print(f"[{WARNING_STYLE}]Limit is 0; nothing to display.[/]")
667
+ return True
668
+ return False
669
+
670
+ def _render_transcripts_snapshot(self, snapshot: Any) -> None:
671
+ """Render transcript snapshot table and metadata.
672
+
673
+ Args:
674
+ snapshot: Transcript snapshot object to render.
675
+ """
676
+ size_text = format_size(snapshot.total_size_bytes)
677
+ header = f"[dim]Manifest: {snapshot.manifest_path} · {snapshot.total_entries} runs · {size_text} used[/]"
678
+ self.console.print(header)
679
+
680
+ if snapshot.limit_clamped:
681
+ self.console.print(
682
+ f"[{WARNING_STYLE}]Requested limit exceeded maximum; showing first {snapshot.limit_applied} runs.[/]"
683
+ )
684
+
685
+ if snapshot.total_entries > len(snapshot.entries):
686
+ subset_message = (
687
+ f"[dim]Showing {len(snapshot.entries)} of {snapshot.total_entries} "
688
+ f"runs (limit={snapshot.limit_applied}).[/]"
689
+ )
690
+ self.console.print(subset_message)
691
+ self.console.print("[dim]Hint: run `/transcripts <limit>` to change how many rows are displayed.[/]")
692
+
693
+ if snapshot.migration_summary:
694
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
695
+
696
+ for warning in snapshot.warnings:
697
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
698
+
699
+ table = transcripts_cmd._build_table(snapshot.entries)
700
+ self.console.print(table)
701
+ self.console.print("[dim]! Missing transcript[/]")
702
+
703
+ def _show_transcript_detail(self, run_id: str) -> None:
704
+ """Render the cached transcript log for a single run."""
705
+ snapshot = load_history_snapshot(ctx=self.ctx)
706
+ entry = snapshot.index.get(run_id)
707
+ if entry is None:
708
+ self.console.print(f"[{WARNING_STYLE}]Run id {run_id} was not found in the cache manifest.[/]")
709
+ return
710
+
711
+ try:
712
+ transcript_path, transcript_text = transcripts_cmd._load_transcript_text(entry)
713
+ except click.ClickException as exc:
714
+ self.console.print(f"[{WARNING_STYLE}]{exc}[/]")
715
+ return
716
+
717
+ meta, events = transcripts_cmd._decode_transcript(transcript_text)
718
+ if transcripts_cmd._maybe_launch_transcript_viewer(
719
+ self.ctx,
720
+ entry,
721
+ meta,
722
+ events,
723
+ console_override=self.console,
724
+ force=True,
725
+ initial_view="transcript",
726
+ ):
727
+ if snapshot.migration_summary:
728
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
729
+ for warning in snapshot.warnings:
730
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
731
+ return
732
+
733
+ if snapshot.migration_summary:
734
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
735
+ for warning in snapshot.warnings:
736
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
737
+ view = transcripts_cmd._render_transcript_display(entry, snapshot.manifest_path, transcript_path, meta, events)
738
+ self.console.print(view, markup=False, highlight=False, soft_wrap=True, end="")
739
+
740
+ def _cmd_runs(self, args: list[str], _invoked_from_agent: bool) -> bool:
741
+ """Handle the /runs command for browsing remote agent run history.
742
+
743
+ Args:
744
+ args: Command arguments (optional run_id for detail view).
745
+ _invoked_from_agent: Whether invoked from agent context.
746
+
747
+ Returns:
748
+ True to continue session.
749
+ """
750
+ controller = RemoteRunsController(self)
751
+ return controller.handle_runs_command(args)
752
+
753
+ def _cmd_accounts(self, args: list[str], _invoked_from_agent: bool) -> bool:
754
+ """Handle the /accounts command for listing and switching accounts."""
755
+ controller = AccountsController(self)
756
+ return controller.handle_accounts_command(args)
378
757
 
379
758
  def _cmd_agents(self, args: list[str], _invoked_from_agent: bool) -> bool:
759
+ """Handle the /agents command.
760
+
761
+ Args:
762
+ args: Command arguments (optional agent reference).
763
+ _invoked_from_agent: Whether invoked from agent context (unused).
764
+
765
+ Returns:
766
+ True to continue session.
767
+ """
380
768
  client = self._get_client_or_fail()
381
769
  if not client:
382
770
  return True
@@ -442,7 +830,7 @@ class SlashSession:
442
830
  self._render_header()
443
831
 
444
832
  self._show_agent_followup_actions(picked_agent)
445
- return True
833
+ return self._continue_session()
446
834
 
447
835
  def _show_agent_followup_actions(self, picked_agent: Any) -> None:
448
836
  """Show follow-up action hints after agent session."""
@@ -454,6 +842,7 @@ class SlashSession:
454
842
  hints.append((f"/agents {agent_id}", f"Reopen {agent_label}"))
455
843
  hints.extend(
456
844
  [
845
+ ("/accounts", "Switch account"),
457
846
  (self.AGENTS_COMMAND, "Browse agents"),
458
847
  (self.STATUS_COMMAND, "Check connection"),
459
848
  ]
@@ -462,6 +851,15 @@ class SlashSession:
462
851
  self._show_quick_actions(hints, title="Next actions")
463
852
 
464
853
  def _cmd_exit(self, _args: list[str], invoked_from_agent: bool) -> bool:
854
+ """Handle the /exit command.
855
+
856
+ Args:
857
+ _args: Command arguments (unused).
858
+ invoked_from_agent: Whether invoked from agent context.
859
+
860
+ Returns:
861
+ False to exit session, True to continue.
862
+ """
465
863
  if invoked_from_agent:
466
864
  # Returning False would stop the full session; we only want to exit
467
865
  # the agent context. Raising a custom flag keeps the outer loop
@@ -475,6 +873,7 @@ class SlashSession:
475
873
  # Utilities
476
874
  # ------------------------------------------------------------------
477
875
  def _register_defaults(self) -> None:
876
+ """Register default slash commands."""
478
877
  self._register(
479
878
  SlashCommand(
480
879
  name="help",
@@ -486,7 +885,7 @@ class SlashSession:
486
885
  self._register(
487
886
  SlashCommand(
488
887
  name="login",
489
- help="Run `/login` (alias `/configure`) to set credentials.",
888
+ help="Configure API credentials (alias `/configure`).",
490
889
  handler=SlashSession._cmd_login,
491
890
  aliases=("configure",),
492
891
  )
@@ -498,6 +897,23 @@ class SlashSession:
498
897
  handler=SlashSession._cmd_status,
499
898
  )
500
899
  )
900
+ self._register(
901
+ SlashCommand(
902
+ name="accounts",
903
+ help="✨ NEW · Browse and switch stored accounts (Textual with Rich fallback).",
904
+ handler=SlashSession._cmd_accounts,
905
+ )
906
+ )
907
+ self._register(
908
+ SlashCommand(
909
+ name="transcripts",
910
+ help=(
911
+ "✨ NEW · Review cached transcript history. "
912
+ "Add a number (e.g. `/transcripts 5`) to change the row limit."
913
+ ),
914
+ handler=SlashSession._cmd_transcripts,
915
+ )
916
+ )
501
917
  self._register(
502
918
  SlashCommand(
503
919
  name="agents",
@@ -527,12 +943,32 @@ class SlashSession:
527
943
  handler=SlashSession._cmd_update,
528
944
  )
529
945
  )
946
+ self._register(
947
+ SlashCommand(
948
+ name="runs",
949
+ help="✨ NEW · Browse remote agent run history (requires active agent session).",
950
+ handler=SlashSession._cmd_runs,
951
+ agent_only=True,
952
+ )
953
+ )
530
954
 
531
955
  def _register(self, command: SlashCommand) -> None:
956
+ """Register a slash command.
957
+
958
+ Args:
959
+ command: SlashCommand to register.
960
+ """
532
961
  self._unique_commands[command.name] = command
533
962
  for key in (command.name, *command.aliases):
534
963
  self._commands[key] = command
535
964
 
965
+ def _visible_commands(self, *, include_agent_only: bool) -> list[SlashCommand]:
966
+ """Return the list of commands that should be shown in global listings."""
967
+ commands = sorted(self._unique_commands.values(), key=lambda c: c.name)
968
+ if include_agent_only:
969
+ return commands
970
+ return [cmd for cmd in commands if not cmd.agent_only]
971
+
536
972
  def open_transcript_viewer(self, *, announce: bool = True) -> None:
537
973
  """Launch the transcript viewer for the most recent run."""
538
974
  payload, manifest = self._get_last_transcript()
@@ -557,6 +993,14 @@ class SlashSession:
557
993
  )
558
994
 
559
995
  def _export(destination: Path) -> Path:
996
+ """Export cached transcript to destination.
997
+
998
+ Args:
999
+ destination: Path to export transcript to.
1000
+
1001
+ Returns:
1002
+ Path to exported transcript file.
1003
+ """
560
1004
  return export_cached_transcript(destination=destination, run_id=run_id)
561
1005
 
562
1006
  try:
@@ -574,55 +1018,13 @@ class SlashSession:
574
1018
  manifest = ctx_obj.get("_last_transcript_manifest")
575
1019
  return payload, manifest
576
1020
 
577
- def _cmd_export(self, args: list[str], _invoked_from_agent: bool) -> bool:
1021
+ def _cmd_export(self, _args: list[str], _invoked_from_agent: bool) -> bool:
578
1022
  """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))
1023
+ self.console.print(
1024
+ f"[{WARNING_STYLE}]`/export` is deprecated. Use `/transcripts`, select a run, "
1025
+ "and open the transcript viewer to export.[/]"
1026
+ )
1027
+ return True
626
1028
 
627
1029
  def _cmd_update(self, args: list[str], _invoked_from_agent: bool) -> bool:
628
1030
  """Slash handler for `/update` command."""
@@ -695,6 +1097,14 @@ class SlashSession:
695
1097
  pass
696
1098
 
697
1099
  def _parse(self, raw: str) -> tuple[str, list[str]]:
1100
+ """Parse a raw command string into verb and arguments.
1101
+
1102
+ Args:
1103
+ raw: Raw command string.
1104
+
1105
+ Returns:
1106
+ Tuple of (verb, args).
1107
+ """
698
1108
  try:
699
1109
  tokens = shlex.split(raw)
700
1110
  except ValueError:
@@ -710,6 +1120,14 @@ class SlashSession:
710
1120
  return head, tokens[1:]
711
1121
 
712
1122
  def _suggest(self, verb: str) -> str | None:
1123
+ """Suggest a similar command name for an unknown verb.
1124
+
1125
+ Args:
1126
+ verb: Unknown command verb.
1127
+
1128
+ Returns:
1129
+ Suggested command name or None.
1130
+ """
713
1131
  keys = [cmd.name for cmd in self._unique_commands.values()]
714
1132
  match = get_close_matches(verb, keys, n=1)
715
1133
  return match[0] if match else None
@@ -738,6 +1156,7 @@ class SlashSession:
738
1156
  if callable(message):
739
1157
 
740
1158
  def prompt_text() -> Any:
1159
+ """Get formatted prompt text from callable message."""
741
1160
  return self._convert_message(message())
742
1161
  else:
743
1162
  prompt_text = self._convert_message(message)
@@ -783,6 +1202,11 @@ class SlashSession:
783
1202
  return self._prompt_with_basic_input(message, placeholder)
784
1203
 
785
1204
  def _get_client(self) -> Any: # type: ignore[no-any-return]
1205
+ """Get or create the API client instance.
1206
+
1207
+ Returns:
1208
+ API client instance.
1209
+ """
786
1210
  if self._client is None:
787
1211
  self._client = get_client(self.ctx)
788
1212
  return self._client
@@ -801,6 +1225,11 @@ class SlashSession:
801
1225
  return self._contextual_include_global
802
1226
 
803
1227
  def _remember_agent(self, agent: Any) -> None: # type: ignore[no-any-return]
1228
+ """Remember an agent in recent agents list.
1229
+
1230
+ Args:
1231
+ agent: Agent object to remember.
1232
+ """
804
1233
  agent_data = {
805
1234
  "id": str(getattr(agent, "id", "")),
806
1235
  "name": getattr(agent, "name", "") or "",
@@ -818,6 +1247,13 @@ class SlashSession:
818
1247
  focus_agent: bool = False,
819
1248
  initial: bool = False,
820
1249
  ) -> None:
1250
+ """Render the session header with branding and status.
1251
+
1252
+ Args:
1253
+ active_agent: Optional active agent to display.
1254
+ focus_agent: Whether to focus on agent display.
1255
+ initial: Whether this is the initial render.
1256
+ """
821
1257
  if focus_agent and active_agent is not None:
822
1258
  self._render_focused_agent_header(active_agent)
823
1259
  return
@@ -860,8 +1296,9 @@ class SlashSession:
860
1296
 
861
1297
  header_grid = self._build_header_grid(agent_info, transcript_status)
862
1298
  keybar = self._build_keybar()
863
-
864
1299
  header_grid.add_row(keybar, "")
1300
+
1301
+ # Agent-scoped commands like /runs will appear in /help, no need to duplicate here
865
1302
  self.console.print(AIPPanel(header_grid, title="Agent Session", border_style=PRIMARY))
866
1303
 
867
1304
  def _get_agent_info(self, active_agent: Any) -> dict[str, str]:
@@ -919,10 +1356,11 @@ class SlashSession:
919
1356
  keybar = AIPGrid(expand=True)
920
1357
  keybar.add_column(justify="left", ratio=1)
921
1358
  keybar.add_column(justify="left", ratio=1)
1359
+ keybar.add_column(justify="left", ratio=1)
922
1360
 
923
1361
  keybar.add_row(
924
- format_command_hint("/help", "Show commands") or "",
925
- format_command_hint("/details", "Agent config") or "",
1362
+ format_command_hint(HELP_COMMAND, "Show commands") or "",
1363
+ format_command_hint("/details", "Agent config (expand prompt)") or "",
926
1364
  format_command_hint("/exit", "Back") or "",
927
1365
  )
928
1366
 
@@ -932,13 +1370,26 @@ class SlashSession:
932
1370
  """Render the main AIP environment header."""
933
1371
  config = self._load_config()
934
1372
 
1373
+ account_name, account_host, env_lock = self._get_account_context()
935
1374
  api_url = self._get_api_url(config)
936
- status = "Configured" if config.get("api_key") else "Not configured"
937
1375
 
938
- segments = [
939
- f"[dim]Base URL[/dim] • {api_url or 'Not configured'}",
940
- f"[dim]Credentials[/dim] • {status}",
941
- ]
1376
+ host_display = account_host or "Not configured"
1377
+ account_segment = f"[dim]Account[/dim] • {account_name} ({host_display})"
1378
+ if env_lock:
1379
+ account_segment += " 🔒"
1380
+
1381
+ segments = [account_segment]
1382
+
1383
+ if api_url:
1384
+ base_label = "[dim]Base URL[/dim]"
1385
+ if env_lock:
1386
+ base_label = "[dim]Base URL (env)[/dim]"
1387
+ # Always show Base URL when env-lock is active to reveal overrides
1388
+ if env_lock or api_url != account_host:
1389
+ segments.append(f"{base_label} • {api_url}")
1390
+ elif not api_url:
1391
+ segments.append("[dim]Base URL[/dim] • Not configured")
1392
+
942
1393
  agent_info = self._build_agent_status_line(active_agent)
943
1394
  if agent_info:
944
1395
  segments.append(agent_info)
@@ -961,12 +1412,23 @@ class SlashSession:
961
1412
  )
962
1413
  )
963
1414
 
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")
1415
+ def _get_api_url(self, _config: dict[str, Any] | None = None) -> str | None:
1416
+ """Get the API URL from context or account store (CLI/palette ignores env credentials)."""
1417
+ return resolve_api_url_from_context(self.ctx)
1418
+
1419
+ def _get_account_context(self) -> tuple[str, str, bool]:
1420
+ """Return active account name, host, and env-lock flag."""
1421
+ try:
1422
+ store = get_account_store()
1423
+ active = store.get_active_account() or "default"
1424
+ account = store.get_account(active) if hasattr(store, "get_account") else None
1425
+ host = ""
1426
+ if account:
1427
+ host = account.get("api_url", "")
1428
+ env_lock = env_credentials_present()
1429
+ return active, host, env_lock
1430
+ except Exception:
1431
+ return "default", "", env_credentials_present()
970
1432
 
971
1433
  def _build_agent_status_line(self, active_agent: Any | None) -> str | None:
972
1434
  """Return a short status line about the active or recent agent."""
@@ -981,35 +1443,96 @@ class SlashSession:
981
1443
  return None
982
1444
 
983
1445
  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")
1446
+ """Show simplified help hint to discover commands."""
1447
+ self.console.print(f"[dim]{'─' * 40}[/]")
1448
+ help_hint = format_command_hint(HELP_COMMAND, "Show all commands") or HELP_COMMAND
1449
+ self.console.print(f" {help_hint}")
1001
1450
  self._default_actions_shown = True
1002
1451
 
1452
+ def _collect_scoped_new_action_hints(self, scope: str) -> list[tuple[str, str]]:
1453
+ """Return new quick action hints filtered by scope."""
1454
+ scoped_actions = [action for action in NEW_QUICK_ACTIONS if _quick_action_scope(action) == scope]
1455
+ # Don't highlight with sparkle emoji in quick actions display - it will show in command palette instead
1456
+ return self._collect_quick_action_hints(scoped_actions)
1457
+
1458
+ def _collect_quick_action_hints(
1459
+ self,
1460
+ actions: Iterable[dict[str, Any]],
1461
+ ) -> list[tuple[str, str]]:
1462
+ """Collect quick action hints from action definitions.
1463
+
1464
+ Args:
1465
+ actions: Iterable of action dictionaries.
1466
+
1467
+ Returns:
1468
+ List of (command, description) tuples.
1469
+ """
1470
+ collected: list[tuple[str, str]] = []
1471
+
1472
+ def sort_key(payload: dict[str, Any]) -> tuple[int, str]:
1473
+ priority = int(payload.get("priority", 0))
1474
+ label = str(payload.get("slash") or payload.get("cli") or "")
1475
+ return (-priority, label.lower())
1476
+
1477
+ for action in sorted(actions, key=sort_key):
1478
+ hint = self._build_quick_action_hint(action)
1479
+ if hint:
1480
+ collected.append(hint)
1481
+ return collected
1482
+
1483
+ def _build_quick_action_hint(
1484
+ self,
1485
+ action: dict[str, Any],
1486
+ ) -> tuple[str, str] | None:
1487
+ """Build a quick action hint from an action definition.
1488
+
1489
+ Args:
1490
+ action: Action dictionary.
1491
+
1492
+ Returns:
1493
+ Tuple of (command, description) or None.
1494
+ """
1495
+ command = command_hint(action.get("cli"), slash_command=action.get("slash"), ctx=self.ctx)
1496
+ if not command:
1497
+ return None
1498
+ description = action.get("description", "")
1499
+ # Don't include tag or sparkle emoji in quick actions display
1500
+ # The NEW tag will only show in the command dropdown (help text)
1501
+ return command, description
1502
+
1503
+ def _render_quick_action_group(self, hints: list[tuple[str, str]], title: str) -> None:
1504
+ """Render a group of quick action hints.
1505
+
1506
+ Args:
1507
+ hints: List of (command, description) tuples.
1508
+ title: Group title.
1509
+ """
1510
+ for line in self._format_quick_action_lines(hints, title):
1511
+ self.console.print(line)
1512
+
1513
+ def _chunk_tokens(self, tokens: list[str], *, size: int) -> Iterable[list[str]]:
1514
+ """Chunk tokens into groups of specified size.
1515
+
1516
+ Args:
1517
+ tokens: List of tokens to chunk.
1518
+ size: Size of each chunk.
1519
+
1520
+ Yields:
1521
+ Lists of tokens.
1522
+ """
1523
+ for index in range(0, len(tokens), size):
1524
+ yield tokens[index : index + size]
1525
+
1003
1526
  def _render_home_hint(self) -> None:
1527
+ """Render hint text for home screen."""
1004
1528
  if self._home_hint_shown:
1005
1529
  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))
1530
+ hint_text = (
1531
+ f"[{HINT_PREFIX_STYLE}]Hint:[/] "
1532
+ f"Type {format_command_hint('/') or '/'} to explore commands · "
1533
+ "Press [dim]Ctrl+D[/] to quit"
1534
+ )
1535
+ self.console.print(hint_text)
1013
1536
  self._home_hint_shown = True
1014
1537
 
1015
1538
  def _show_quick_actions(
@@ -1019,30 +1542,99 @@ class SlashSession:
1019
1542
  title: str = "Quick actions",
1020
1543
  inline: bool = False,
1021
1544
  ) -> None:
1022
- hint_list = [(command, description) for command, description in hints if command]
1545
+ """Show quick action hints.
1546
+
1547
+ Args:
1548
+ hints: Iterable of (command, description) tuples.
1549
+ title: Title for the hints.
1550
+ inline: Whether to render inline or in a panel.
1551
+ """
1552
+ hint_list = self._normalize_quick_action_hints(hints)
1023
1553
  if not hint_list:
1024
1554
  return
1025
1555
 
1026
1556
  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))
1557
+ self._render_inline_quick_actions(hint_list, title)
1034
1558
  return
1035
1559
 
1560
+ self._render_panel_quick_actions(hint_list, title)
1561
+
1562
+ def _normalize_quick_action_hints(self, hints: Iterable[tuple[str, str]]) -> list[tuple[str, str]]:
1563
+ """Normalize quick action hints by filtering out empty commands.
1564
+
1565
+ Args:
1566
+ hints: Iterable of (command, description) tuples.
1567
+
1568
+ Returns:
1569
+ List of normalized hints.
1570
+ """
1571
+ return [(command, description) for command, description in hints if command]
1572
+
1573
+ def _render_inline_quick_actions(self, hint_list: list[tuple[str, str]], title: str) -> None:
1574
+ """Render quick actions inline.
1575
+
1576
+ Args:
1577
+ hint_list: List of (command, description) tuples.
1578
+ title: Title for the hints.
1579
+ """
1580
+ tokens: list[str] = []
1581
+ for command, description in hint_list:
1582
+ formatted = format_command_hint(command, description)
1583
+ if formatted:
1584
+ tokens.append(formatted)
1585
+ if not tokens:
1586
+ return
1587
+ prefix = f"[dim]{title}:[/]" if title else ""
1588
+ body = " ".join(tokens)
1589
+ text = f"{prefix} {body}" if prefix else body
1590
+ self.console.print(text.strip())
1591
+
1592
+ def _render_panel_quick_actions(self, hint_list: list[tuple[str, str]], title: str) -> None:
1593
+ """Render quick actions in a panel.
1594
+
1595
+ Args:
1596
+ hint_list: List of (command, description) tuples.
1597
+ title: Panel title.
1598
+ """
1036
1599
  body_lines: list[Text] = []
1037
1600
  for command, description in hint_list:
1038
1601
  formatted = format_command_hint(command, description)
1039
1602
  if formatted:
1040
1603
  body_lines.append(Text.from_markup(formatted))
1041
-
1604
+ if not body_lines:
1605
+ return
1042
1606
  panel_content = Group(*body_lines)
1043
1607
  self.console.print(AIPPanel(panel_content, title=title, border_style=SECONDARY_LIGHT, expand=False))
1044
1608
 
1609
+ def _format_quick_action_lines(self, hints: list[tuple[str, str]], title: str) -> list[str]:
1610
+ """Return formatted lines for quick action hints."""
1611
+ if not hints:
1612
+ return []
1613
+ formatted_tokens: list[str] = []
1614
+ for command, description in hints:
1615
+ formatted = format_command_hint(command, description)
1616
+ if formatted:
1617
+ formatted_tokens.append(f"• {formatted}")
1618
+ if not formatted_tokens:
1619
+ return []
1620
+ lines: list[str] = []
1621
+ # Use vertical layout (1 per line) for better readability
1622
+ chunks = list(self._chunk_tokens(formatted_tokens, size=1))
1623
+ prefix = f"[dim]{title}[/dim]\n " if title else ""
1624
+ for idx, chunk in enumerate(chunks):
1625
+ row = " ".join(chunk)
1626
+ if idx == 0:
1627
+ lines.append(f"{prefix}{row}" if prefix else row)
1628
+ else:
1629
+ lines.append(f" {row}")
1630
+ return lines
1631
+
1045
1632
  def _load_config(self) -> dict[str, Any]:
1633
+ """Load configuration with caching.
1634
+
1635
+ Returns:
1636
+ Configuration dictionary.
1637
+ """
1046
1638
  if self._config_cache is None:
1047
1639
  try:
1048
1640
  self._config_cache = load_config() or {}
@@ -1051,6 +1643,16 @@ class SlashSession:
1051
1643
  return self._config_cache
1052
1644
 
1053
1645
  def _resolve_agent_from_ref(self, client: Any, available_agents: list[Any], ref: str) -> Any | None:
1646
+ """Resolve an agent from a reference string.
1647
+
1648
+ Args:
1649
+ client: API client instance.
1650
+ available_agents: List of available agents.
1651
+ ref: Reference string (ID or name).
1652
+
1653
+ Returns:
1654
+ Resolved agent or None.
1655
+ """
1054
1656
  ref = ref.strip()
1055
1657
  if not ref:
1056
1658
  return None