glaip-sdk 0.1.0__py3-none-any.whl → 0.6.10__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 (156) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +10 -3
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1191 -0
  5. glaip_sdk/branding.py +15 -6
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/agent_config.py +2 -6
  8. glaip_sdk/cli/auth.py +265 -45
  9. glaip_sdk/cli/commands/__init__.py +2 -2
  10. glaip_sdk/cli/commands/accounts.py +746 -0
  11. glaip_sdk/cli/commands/agents.py +251 -173
  12. glaip_sdk/cli/commands/common_config.py +101 -0
  13. glaip_sdk/cli/commands/configure.py +735 -143
  14. glaip_sdk/cli/commands/mcps.py +266 -134
  15. glaip_sdk/cli/commands/models.py +13 -9
  16. glaip_sdk/cli/commands/tools.py +67 -88
  17. glaip_sdk/cli/commands/transcripts.py +755 -0
  18. glaip_sdk/cli/commands/update.py +3 -8
  19. glaip_sdk/cli/config.py +49 -7
  20. glaip_sdk/cli/constants.py +38 -0
  21. glaip_sdk/cli/context.py +8 -0
  22. glaip_sdk/cli/core/__init__.py +79 -0
  23. glaip_sdk/cli/core/context.py +124 -0
  24. glaip_sdk/cli/core/output.py +846 -0
  25. glaip_sdk/cli/core/prompting.py +649 -0
  26. glaip_sdk/cli/core/rendering.py +187 -0
  27. glaip_sdk/cli/display.py +45 -32
  28. glaip_sdk/cli/hints.py +57 -0
  29. glaip_sdk/cli/io.py +14 -17
  30. glaip_sdk/cli/main.py +232 -143
  31. glaip_sdk/cli/masking.py +21 -33
  32. glaip_sdk/cli/mcp_validators.py +5 -15
  33. glaip_sdk/cli/pager.py +12 -19
  34. glaip_sdk/cli/parsers/__init__.py +1 -3
  35. glaip_sdk/cli/parsers/json_input.py +11 -22
  36. glaip_sdk/cli/resolution.py +3 -9
  37. glaip_sdk/cli/rich_helpers.py +1 -3
  38. glaip_sdk/cli/slash/__init__.py +0 -9
  39. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  40. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  41. glaip_sdk/cli/slash/agent_session.py +65 -29
  42. glaip_sdk/cli/slash/prompt.py +24 -10
  43. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  44. glaip_sdk/cli/slash/session.py +807 -225
  45. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  46. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  47. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  48. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  49. glaip_sdk/cli/slash/tui/loading.py +58 -0
  50. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  51. glaip_sdk/cli/transcript/__init__.py +12 -52
  52. glaip_sdk/cli/transcript/cache.py +258 -60
  53. glaip_sdk/cli/transcript/capture.py +72 -21
  54. glaip_sdk/cli/transcript/history.py +815 -0
  55. glaip_sdk/cli/transcript/launcher.py +1 -3
  56. glaip_sdk/cli/transcript/viewer.py +79 -499
  57. glaip_sdk/cli/update_notifier.py +177 -24
  58. glaip_sdk/cli/utils.py +242 -1308
  59. glaip_sdk/cli/validators.py +16 -18
  60. glaip_sdk/client/__init__.py +2 -1
  61. glaip_sdk/client/_agent_payloads.py +53 -37
  62. glaip_sdk/client/agent_runs.py +147 -0
  63. glaip_sdk/client/agents.py +320 -92
  64. glaip_sdk/client/base.py +78 -35
  65. glaip_sdk/client/main.py +19 -10
  66. glaip_sdk/client/mcps.py +123 -15
  67. glaip_sdk/client/run_rendering.py +136 -101
  68. glaip_sdk/client/shared.py +21 -0
  69. glaip_sdk/client/tools.py +163 -34
  70. glaip_sdk/client/validators.py +20 -48
  71. glaip_sdk/config/constants.py +11 -0
  72. glaip_sdk/exceptions.py +1 -3
  73. glaip_sdk/mcps/__init__.py +21 -0
  74. glaip_sdk/mcps/base.py +345 -0
  75. glaip_sdk/models/__init__.py +90 -0
  76. glaip_sdk/models/agent.py +47 -0
  77. glaip_sdk/models/agent_runs.py +116 -0
  78. glaip_sdk/models/common.py +42 -0
  79. glaip_sdk/models/mcp.py +33 -0
  80. glaip_sdk/models/tool.py +33 -0
  81. glaip_sdk/payload_schemas/__init__.py +1 -13
  82. glaip_sdk/payload_schemas/agent.py +1 -3
  83. glaip_sdk/registry/__init__.py +55 -0
  84. glaip_sdk/registry/agent.py +164 -0
  85. glaip_sdk/registry/base.py +139 -0
  86. glaip_sdk/registry/mcp.py +253 -0
  87. glaip_sdk/registry/tool.py +232 -0
  88. glaip_sdk/rich_components.py +58 -2
  89. glaip_sdk/runner/__init__.py +59 -0
  90. glaip_sdk/runner/base.py +84 -0
  91. glaip_sdk/runner/deps.py +115 -0
  92. glaip_sdk/runner/langgraph.py +706 -0
  93. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  94. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  95. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  96. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  97. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  98. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  99. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  100. glaip_sdk/tools/__init__.py +22 -0
  101. glaip_sdk/tools/base.py +435 -0
  102. glaip_sdk/utils/__init__.py +58 -12
  103. glaip_sdk/utils/a2a/__init__.py +34 -0
  104. glaip_sdk/utils/a2a/event_processor.py +188 -0
  105. glaip_sdk/utils/agent_config.py +4 -14
  106. glaip_sdk/utils/bundler.py +267 -0
  107. glaip_sdk/utils/client.py +111 -0
  108. glaip_sdk/utils/client_utils.py +46 -28
  109. glaip_sdk/utils/datetime_helpers.py +58 -0
  110. glaip_sdk/utils/discovery.py +78 -0
  111. glaip_sdk/utils/display.py +25 -21
  112. glaip_sdk/utils/export.py +143 -0
  113. glaip_sdk/utils/general.py +1 -36
  114. glaip_sdk/utils/import_export.py +15 -16
  115. glaip_sdk/utils/import_resolver.py +492 -0
  116. glaip_sdk/utils/instructions.py +101 -0
  117. glaip_sdk/utils/rendering/__init__.py +115 -1
  118. glaip_sdk/utils/rendering/formatting.py +7 -35
  119. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  120. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +10 -3
  121. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +73 -12
  122. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  123. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  124. glaip_sdk/utils/rendering/models.py +3 -6
  125. glaip_sdk/utils/rendering/renderer/__init__.py +9 -49
  126. glaip_sdk/utils/rendering/renderer/base.py +258 -1577
  127. glaip_sdk/utils/rendering/renderer/config.py +1 -5
  128. glaip_sdk/utils/rendering/renderer/debug.py +30 -34
  129. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  130. glaip_sdk/utils/rendering/renderer/stream.py +10 -51
  131. glaip_sdk/utils/rendering/renderer/summary_window.py +79 -0
  132. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  133. glaip_sdk/utils/rendering/renderer/toggle.py +1 -3
  134. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  135. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  136. glaip_sdk/utils/rendering/state.py +204 -0
  137. glaip_sdk/utils/rendering/step_tree_state.py +1 -3
  138. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  139. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +76 -517
  140. glaip_sdk/utils/rendering/steps/format.py +176 -0
  141. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  142. glaip_sdk/utils/rendering/timing.py +36 -0
  143. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  144. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  145. glaip_sdk/utils/resource_refs.py +29 -26
  146. glaip_sdk/utils/runtime_config.py +425 -0
  147. glaip_sdk/utils/serialization.py +32 -46
  148. glaip_sdk/utils/sync.py +142 -0
  149. glaip_sdk/utils/tool_detection.py +33 -0
  150. glaip_sdk/utils/validation.py +20 -28
  151. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/METADATA +42 -4
  152. glaip_sdk-0.6.10.dist-info/RECORD +159 -0
  153. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.dist-info}/WHEEL +1 -1
  154. glaip_sdk/models.py +0 -259
  155. glaip_sdk-0.1.0.dist-info/RECORD +0 -82
  156. {glaip_sdk-0.1.0.dist-info → glaip_sdk-0.6.10.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,8 +193,25 @@ class SlashSession:
120
193
  # ------------------------------------------------------------------
121
194
  # Session orchestration
122
195
  # ------------------------------------------------------------------
196
+ def refresh_branding(
197
+ self,
198
+ sdk_version: str | None = None,
199
+ *,
200
+ branding_cls: type[AIPBranding] | None = None,
201
+ ) -> None:
202
+ """Refresh branding assets after an in-session SDK upgrade."""
203
+ branding_type = branding_cls or AIPBranding
204
+ self._branding = branding_type.create_from_sdk(
205
+ sdk_version=sdk_version,
206
+ package_name="glaip-sdk",
207
+ )
208
+ self._welcome_rendered = False
209
+ self.console.print()
210
+ self.console.print(f"[{SUCCESS_STYLE}]CLI updated to {self._branding.version}. Refreshing banner...[/]")
211
+ self._render_header(initial=True)
123
212
 
124
213
  def _setup_prompt_toolkit(self) -> None:
214
+ """Initialize prompt_toolkit session and style."""
125
215
  session, style = setup_prompt_toolkit(self, interactive=self._interactive)
126
216
  self._ptk_session = session
127
217
  self._ptk_style = style
@@ -149,10 +239,7 @@ class SlashSession:
149
239
  self._run_interactive_loop()
150
240
  finally:
151
241
  if ctx_obj is not None:
152
- if previous_session is None:
153
- ctx_obj.pop("_slash_session", None)
154
- else:
155
- ctx_obj["_slash_session"] = previous_session
242
+ restore_slash_session_context(ctx_obj, previous_session)
156
243
 
157
244
  def _run_interactive_loop(self) -> None:
158
245
  """Run the main interactive command loop."""
@@ -181,16 +268,12 @@ class SlashSession:
181
268
  return True
182
269
 
183
270
  if not raw.startswith("/"):
184
- self.console.print(
185
- f"[{INFO_STYLE}]Hint:[/] start commands with `/`. Try `/agents` to select an agent."
186
- )
271
+ self.console.print(f"[{INFO_STYLE}]Hint:[/] start commands with `/`. Try `/agents` to select an agent.")
187
272
  return True
188
273
 
189
274
  return self.handle_command(raw)
190
275
 
191
- def _run_non_interactive(
192
- self, initial_commands: Iterable[str] | None = None
193
- ) -> None:
276
+ def _run_non_interactive(self, initial_commands: Iterable[str] | None = None) -> None:
194
277
  """Run slash commands in non-interactive mode."""
195
278
  commands = list(initial_commands or [])
196
279
  if not commands:
@@ -202,38 +285,124 @@ class SlashSession:
202
285
  if not self.handle_command(raw):
203
286
  break
204
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
+
205
333
  def _ensure_configuration(self) -> bool:
206
334
  """Ensure the CLI has both API URL and credentials before continuing."""
207
335
  while not self._configuration_ready():
208
- self.console.print(
209
- f"[{WARNING_STYLE}]Configuration required.[/] Launching `/login` wizard..."
210
- )
211
- self._suppress_login_layout = True
212
- try:
213
- self._cmd_login([], False)
214
- except KeyboardInterrupt:
215
- self.console.print(
216
- f"[{ERROR_STYLE}]Configuration aborted. Closing the command palette.[/]"
217
- )
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():
218
348
  return False
219
- finally:
220
- self._suppress_login_layout = False
221
349
 
222
350
  return True
223
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
+
224
387
  def _configuration_ready(self) -> bool:
225
388
  """Check whether API URL and credentials are available."""
226
- config = self._load_config()
227
- api_url = self._get_api_url(config)
228
- if not api_url:
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:
229
397
  return False
230
398
 
231
- api_key: str | None = None
232
- if isinstance(self.ctx.obj, dict):
233
- api_key = self.ctx.obj.get("api_key")
399
+ store_url, store_key = account_creds
234
400
 
235
- api_key = api_key or config.get("api_key") or os.getenv("AIP_API_KEY")
236
- return bool(api_key)
401
+ # Use override values if available, otherwise use store values
402
+ api_url = override_url or store_url
403
+ api_key = override_key or store_key
404
+
405
+ return bool(api_url and api_key)
237
406
 
238
407
  def handle_command(self, raw: str, *, invoked_from_agent: bool = False) -> bool:
239
408
  """Parse and execute a single slash command string."""
@@ -246,11 +415,9 @@ class SlashSession:
246
415
  if command is None:
247
416
  suggestion = self._suggest(verb)
248
417
  if suggestion:
249
- self.console.print(
250
- f"[{WARNING_STYLE}]Unknown command '{verb}'. Did you mean '/{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,21 +459,21 @@ 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:
294
- panel_items.append(
295
- Text.from_markup(f"[dim]Last run input:[/] {self.last_run_input}")
296
- )
476
+ panel_items.append(Text.from_markup(f"[dim]Last run input:[/] {self.last_run_input}"))
297
477
  panel_items.append(
298
478
  Text.from_markup(
299
479
  "[dim]Global commands (e.g. `/login`, `/status`) remain available inside the agent prompt.[/dim]"
@@ -307,13 +487,28 @@ class SlashSession:
307
487
  border_style=PRIMARY,
308
488
  )
309
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
+ )
310
504
 
311
- 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."""
312
507
  table = AIPTable()
313
508
  table.add_column("Command", style=HINT_COMMAND_STYLE, no_wrap=True)
314
509
  table.add_column("Description", style=HINT_DESCRIPTION_COLOR)
315
510
 
316
- for cmd in sorted(self._unique_commands.values(), key=lambda c: c.name):
511
+ for cmd in self._visible_commands(include_agent_only=False):
317
512
  aliases = ", ".join(f"/{alias}" for alias in cmd.aliases if alias)
318
513
  verb = f"/{cmd.name}"
319
514
  if aliases:
@@ -321,7 +516,9 @@ class SlashSession:
321
516
  table.add_row(verb, cmd.help)
322
517
 
323
518
  tip = Text.from_markup(
324
- f"[{HINT_PREFIX_STYLE}]Tip:[/] {format_command_hint(self.AGENTS_COMMAND) or self.AGENTS_COMMAND} lets you jump into an agent run prompt quickly."
519
+ f"[{HINT_PREFIX_STYLE}]Tip:[/] "
520
+ f"{format_command_hint(self.AGENTS_COMMAND) or self.AGENTS_COMMAND} "
521
+ "lets you jump into an agent run prompt quickly."
325
522
  )
326
523
 
327
524
  self.console.print(
@@ -331,12 +528,27 @@ 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)
339
- self._config_cache = None
549
+ # Use the modern account-aware wizard directly (bypasses legacy config gating)
550
+ _configure_interactive(account_name=None)
551
+ self.on_account_switched()
340
552
  if self._suppress_login_layout:
341
553
  self._welcome_rendered = False
342
554
  self._default_actions_shown = False
@@ -345,14 +557,23 @@ 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:
354
575
  status_module = importlib.import_module("glaip_sdk.cli.main")
355
- status_command = getattr(status_module, "status")
576
+ status_command = status_module.status
356
577
 
357
578
  if ctx_obj is not None:
358
579
  previous_console = ctx_obj.get("_slash_console")
@@ -360,9 +581,7 @@ class SlashSession:
360
581
 
361
582
  self.ctx.invoke(status_command)
362
583
 
363
- hints: list[tuple[str, str]] = [
364
- (self.AGENTS_COMMAND, "Browse agents and run them")
365
- ]
584
+ hints: list[tuple[str, str]] = [(self.AGENTS_COMMAND, "Browse agents and run them")]
366
585
  if self.recent_agents:
367
586
  top = self.recent_agents[0]
368
587
  label = top.get("name") or top.get("id")
@@ -376,9 +595,176 @@ class SlashSession:
376
595
  ctx_obj.pop("_slash_console", None)
377
596
  else:
378
597
  ctx_obj["_slash_console"] = previous_console
379
- 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)
380
757
 
381
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
+ """
382
768
  client = self._get_client_or_fail()
383
769
  if not client:
384
770
  return True
@@ -417,9 +803,7 @@ class SlashSession:
417
803
  """Handle case when no agents are available."""
418
804
  hint = command_hint("agents create", slash_command=None, ctx=self.ctx)
419
805
  if hint:
420
- self.console.print(
421
- f"[{WARNING_STYLE}]No agents available. Use `{hint}` to add one.[/]"
422
- )
806
+ self.console.print(f"[{WARNING_STYLE}]No agents available. Use `{hint}` to add one.[/]")
423
807
  else:
424
808
  self.console.print(f"[{WARNING_STYLE}]No agents available.[/]")
425
809
 
@@ -446,7 +830,7 @@ class SlashSession:
446
830
  self._render_header()
447
831
 
448
832
  self._show_agent_followup_actions(picked_agent)
449
- return True
833
+ return self._continue_session()
450
834
 
451
835
  def _show_agent_followup_actions(self, picked_agent: Any) -> None:
452
836
  """Show follow-up action hints after agent session."""
@@ -458,6 +842,7 @@ class SlashSession:
458
842
  hints.append((f"/agents {agent_id}", f"Reopen {agent_label}"))
459
843
  hints.extend(
460
844
  [
845
+ ("/accounts", "Switch account"),
461
846
  (self.AGENTS_COMMAND, "Browse agents"),
462
847
  (self.STATUS_COMMAND, "Check connection"),
463
848
  ]
@@ -466,6 +851,15 @@ class SlashSession:
466
851
  self._show_quick_actions(hints, title="Next actions")
467
852
 
468
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
+ """
469
863
  if invoked_from_agent:
470
864
  # Returning False would stop the full session; we only want to exit
471
865
  # the agent context. Raising a custom flag keeps the outer loop
@@ -479,6 +873,7 @@ class SlashSession:
479
873
  # Utilities
480
874
  # ------------------------------------------------------------------
481
875
  def _register_defaults(self) -> None:
876
+ """Register default slash commands."""
482
877
  self._register(
483
878
  SlashCommand(
484
879
  name="help",
@@ -490,7 +885,7 @@ class SlashSession:
490
885
  self._register(
491
886
  SlashCommand(
492
887
  name="login",
493
- help="Run `/login` (alias `/configure`) to set credentials.",
888
+ help="Configure API credentials (alias `/configure`).",
494
889
  handler=SlashSession._cmd_login,
495
890
  aliases=("configure",),
496
891
  )
@@ -502,6 +897,23 @@ class SlashSession:
502
897
  handler=SlashSession._cmd_status,
503
898
  )
504
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
+ )
505
917
  self._register(
506
918
  SlashCommand(
507
919
  name="agents",
@@ -531,28 +943,44 @@ class SlashSession:
531
943
  handler=SlashSession._cmd_update,
532
944
  )
533
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
+ )
534
954
 
535
955
  def _register(self, command: SlashCommand) -> None:
956
+ """Register a slash command.
957
+
958
+ Args:
959
+ command: SlashCommand to register.
960
+ """
536
961
  self._unique_commands[command.name] = command
537
962
  for key in (command.name, *command.aliases):
538
963
  self._commands[key] = command
539
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
+
540
972
  def open_transcript_viewer(self, *, announce: bool = True) -> None:
541
973
  """Launch the transcript viewer for the most recent run."""
542
974
  payload, manifest = self._get_last_transcript()
543
975
  if payload is None or manifest is None:
544
976
  if announce:
545
- self.console.print(
546
- f"[{WARNING_STYLE}]No transcript is available yet. Run an agent first.[/]"
547
- )
977
+ self.console.print(f"[{WARNING_STYLE}]No transcript is available yet. Run an agent first.[/]")
548
978
  return
549
979
 
550
980
  run_id = manifest.get("run_id")
551
981
  if not run_id:
552
982
  if announce:
553
- self.console.print(
554
- f"[{WARNING_STYLE}]Latest transcript is missing run metadata.[/]"
555
- )
983
+ self.console.print(f"[{WARNING_STYLE}]Latest transcript is missing run metadata.[/]")
556
984
  return
557
985
 
558
986
  viewer_ctx = ViewerContext(
@@ -565,15 +993,21 @@ class SlashSession:
565
993
  )
566
994
 
567
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
+ """
568
1004
  return export_cached_transcript(destination=destination, run_id=run_id)
569
1005
 
570
1006
  try:
571
1007
  run_viewer_session(self.console, viewer_ctx, _export)
572
1008
  except Exception as exc: # pragma: no cover - interactive failures
573
1009
  if announce:
574
- self.console.print(
575
- f"[{ERROR_STYLE}]Failed to launch transcript viewer: {exc}[/]"
576
- )
1010
+ self.console.print(f"[{ERROR_STYLE}]Failed to launch transcript viewer: {exc}[/]")
577
1011
 
578
1012
  def _get_last_transcript(self) -> tuple[Any | None, dict[str, Any] | None]:
579
1013
  """Fetch the most recently stored transcript payload and manifest."""
@@ -584,65 +1018,18 @@ class SlashSession:
584
1018
  manifest = ctx_obj.get("_last_transcript_manifest")
585
1019
  return payload, manifest
586
1020
 
587
- 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:
588
1022
  """Slash handler for `/export` command."""
589
- path_arg = args[0] if args else None
590
- run_id = args[1] if len(args) > 1 else None
591
-
592
- manifest_entry = resolve_manifest_for_export(self.ctx, run_id)
593
- if manifest_entry is None:
594
- if run_id:
595
- self.console.print(
596
- f"[{WARNING_STYLE}]No cached transcript found with run id {run_id!r}. Omit the run id to export the most recent run.[/]"
597
- )
598
- else:
599
- self.console.print(
600
- f"[{WARNING_STYLE}]No cached transcripts available yet. Run an agent first.[/]"
601
- )
602
- return False
603
-
604
- destination = self._resolve_export_destination(path_arg, manifest_entry)
605
- if destination is None:
606
- return False
607
-
608
- try:
609
- exported = export_cached_transcript(
610
- destination=destination,
611
- run_id=manifest_entry.get("run_id"),
612
- )
613
- except FileNotFoundError as exc:
614
- self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
615
- return False
616
- except Exception as exc: # pragma: no cover - unexpected IO failures
617
- self.console.print(f"[{ERROR_STYLE}]Failed to export transcript: {exc}[/]")
618
- return False
619
- else:
620
- self.console.print(f"[{SUCCESS_STYLE}]Transcript exported to[/] {exported}")
621
- return True
622
-
623
- def _resolve_export_destination(
624
- self, path_arg: str | None, manifest_entry: dict[str, Any]
625
- ) -> Path | None:
626
- if path_arg:
627
- return normalise_export_destination(Path(path_arg))
628
-
629
- default_name = suggest_filename(manifest_entry)
630
- prompt = f"Save transcript to [{default_name}]: "
631
- try:
632
- response = self.console.input(prompt)
633
- except EOFError:
634
- self.console.print("[dim]Export cancelled.[/dim]")
635
- return None
636
-
637
- chosen = response.strip() or default_name
638
- return normalise_export_destination(Path(chosen))
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
639
1028
 
640
1029
  def _cmd_update(self, args: list[str], _invoked_from_agent: bool) -> bool:
641
1030
  """Slash handler for `/update` command."""
642
1031
  if args:
643
- self.console.print(
644
- "Usage: `/update` upgrades glaip-sdk to the latest published version."
645
- )
1032
+ self.console.print("Usage: `/update` upgrades glaip-sdk to the latest published version.")
646
1033
  return True
647
1034
 
648
1035
  try:
@@ -710,6 +1097,14 @@ class SlashSession:
710
1097
  pass
711
1098
 
712
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
+ """
713
1108
  try:
714
1109
  tokens = shlex.split(raw)
715
1110
  except ValueError:
@@ -725,6 +1120,14 @@ class SlashSession:
725
1120
  return head, tokens[1:]
726
1121
 
727
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
+ """
728
1131
  keys = [cmd.name for cmd in self._unique_commands.values()]
729
1132
  match = get_close_matches(verb, keys, n=1)
730
1133
  return match[0] if match else None
@@ -742,21 +1145,18 @@ class SlashSession:
742
1145
  prompt_kwargs: dict[str, Any] = {"style": self._ptk_style}
743
1146
  if placeholder:
744
1147
  placeholder_text = (
745
- FormattedText([("class:placeholder", placeholder)])
746
- if FormattedText is not None
747
- else placeholder
1148
+ FormattedText([("class:placeholder", placeholder)]) if FormattedText is not None else placeholder
748
1149
  )
749
1150
  prompt_kwargs["placeholder"] = placeholder_text
750
1151
  return prompt_kwargs
751
1152
 
752
- def _prompt_with_prompt_toolkit(
753
- self, message: str | Callable[[], Any], placeholder: str | None
754
- ) -> str:
1153
+ def _prompt_with_prompt_toolkit(self, message: str | Callable[[], Any], placeholder: str | None) -> str:
755
1154
  """Handle prompting with prompt_toolkit."""
756
1155
  with patch_stdout(): # pragma: no cover - UI specific
757
1156
  if callable(message):
758
1157
 
759
1158
  def prompt_text() -> Any:
1159
+ """Get formatted prompt text from callable message."""
760
1160
  return self._convert_message(message())
761
1161
  else:
762
1162
  prompt_text = self._convert_message(message)
@@ -765,9 +1165,7 @@ class SlashSession:
765
1165
 
766
1166
  try:
767
1167
  return self._ptk_session.prompt(prompt_text, **prompt_kwargs)
768
- except (
769
- TypeError
770
- ): # pragma: no cover - compatibility with older prompt_toolkit
1168
+ except TypeError: # pragma: no cover - compatibility with older prompt_toolkit
771
1169
  prompt_kwargs.pop("placeholder", None)
772
1170
  return self._ptk_session.prompt(prompt_text, **prompt_kwargs)
773
1171
 
@@ -786,9 +1184,7 @@ class SlashSession:
786
1184
  except Exception:
787
1185
  return str(raw_value)
788
1186
 
789
- def _prompt_with_basic_input(
790
- self, message: str | Callable[[], Any], placeholder: str | None
791
- ) -> str:
1187
+ def _prompt_with_basic_input(self, message: str | Callable[[], Any], placeholder: str | None) -> str:
792
1188
  """Handle prompting with basic input."""
793
1189
  if placeholder:
794
1190
  self.console.print(f"[dim]{placeholder}[/dim]")
@@ -798,9 +1194,7 @@ class SlashSession:
798
1194
 
799
1195
  return input(actual_message)
800
1196
 
801
- def _prompt(
802
- self, message: str | Callable[[], Any], *, placeholder: str | None = None
803
- ) -> str:
1197
+ def _prompt(self, message: str | Callable[[], Any], *, placeholder: str | None = None) -> str:
804
1198
  """Main prompt function with reduced complexity."""
805
1199
  if self._ptk_session and self._ptk_style and patch_stdout:
806
1200
  return self._prompt_with_prompt_toolkit(message, placeholder)
@@ -808,13 +1202,43 @@ class SlashSession:
808
1202
  return self._prompt_with_basic_input(message, placeholder)
809
1203
 
810
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
+ """
811
1210
  if self._client is None:
812
1211
  self._client = get_client(self.ctx)
813
1212
  return self._client
814
1213
 
815
- def set_contextual_commands(
816
- self, commands: dict[str, str] | None, *, include_global: bool = True
817
- ) -> None:
1214
+ def on_account_switched(self, _account_name: str | None = None) -> None:
1215
+ """Reset any state that depends on the active account.
1216
+
1217
+ The active account can change via `/accounts` (or other flows that call
1218
+ AccountStore.set_active_account). The slash session caches a configured
1219
+ client instance, so we must invalidate it to avoid leaking the previous
1220
+ account's API URL/key into subsequent commands like `/agents` or `/runs`.
1221
+
1222
+ This method clears:
1223
+ - Client and config cache (account-specific credentials)
1224
+ - Current agent and recent agents (agent data is account-scoped)
1225
+ - Runs pagination state (runs are account-scoped)
1226
+ - Active renderer and transcript ready state (UI state tied to account context)
1227
+ - Contextual commands (may be account-specific)
1228
+
1229
+ These broader resets ensure a clean slate when switching accounts, preventing
1230
+ stale data from the previous account from appearing in the new account's context.
1231
+ """
1232
+ self._client = None
1233
+ self._config_cache = None
1234
+ self._current_agent = None
1235
+ self.recent_agents = []
1236
+ self._runs_pagination_state.clear()
1237
+ self.clear_active_renderer()
1238
+ self.clear_agent_transcript_ready()
1239
+ self.set_contextual_commands(None)
1240
+
1241
+ def set_contextual_commands(self, commands: dict[str, str] | None, *, include_global: bool = True) -> None:
818
1242
  """Set context-specific commands that should appear in completions."""
819
1243
  self._contextual_commands = dict(commands or {})
820
1244
  self._contextual_include_global = include_global if commands else True
@@ -828,15 +1252,18 @@ class SlashSession:
828
1252
  return self._contextual_include_global
829
1253
 
830
1254
  def _remember_agent(self, agent: Any) -> None: # type: ignore[no-any-return]
1255
+ """Remember an agent in recent agents list.
1256
+
1257
+ Args:
1258
+ agent: Agent object to remember.
1259
+ """
831
1260
  agent_data = {
832
1261
  "id": str(getattr(agent, "id", "")),
833
1262
  "name": getattr(agent, "name", "") or "",
834
1263
  "type": getattr(agent, "type", "") or "",
835
1264
  }
836
1265
 
837
- self.recent_agents = [
838
- a for a in self.recent_agents if a.get("id") != agent_data["id"]
839
- ]
1266
+ self.recent_agents = [a for a in self.recent_agents if a.get("id") != agent_data["id"]]
840
1267
  self.recent_agents.insert(0, agent_data)
841
1268
  self.recent_agents = self.recent_agents[:5]
842
1269
 
@@ -847,6 +1274,13 @@ class SlashSession:
847
1274
  focus_agent: bool = False,
848
1275
  initial: bool = False,
849
1276
  ) -> None:
1277
+ """Render the session header with branding and status.
1278
+
1279
+ Args:
1280
+ active_agent: Optional active agent to display.
1281
+ focus_agent: Whether to focus on agent display.
1282
+ initial: Whether this is the initial render.
1283
+ """
850
1284
  if focus_agent and active_agent is not None:
851
1285
  self._render_focused_agent_header(active_agent)
852
1286
  return
@@ -889,11 +1323,10 @@ class SlashSession:
889
1323
 
890
1324
  header_grid = self._build_header_grid(agent_info, transcript_status)
891
1325
  keybar = self._build_keybar()
892
-
893
1326
  header_grid.add_row(keybar, "")
894
- self.console.print(
895
- AIPPanel(header_grid, title="Agent Session", border_style=PRIMARY)
896
- )
1327
+
1328
+ # Agent-scoped commands like /runs will appear in /help, no need to duplicate here
1329
+ self.console.print(AIPPanel(header_grid, title="Agent Session", border_style=PRIMARY))
897
1330
 
898
1331
  def _get_agent_info(self, active_agent: Any) -> dict[str, str]:
899
1332
  """Extract agent information for display."""
@@ -914,9 +1347,7 @@ class SlashSession:
914
1347
  has_transcript = bool(payload and manifest and manifest.get("run_id"))
915
1348
  run_id = (manifest or {}).get("run_id")
916
1349
  transcript_ready = (
917
- has_transcript
918
- and latest_agent_id == agent_id
919
- and self._agent_transcript_ready.get(agent_id) == run_id
1350
+ has_transcript and latest_agent_id == agent_id and self._agent_transcript_ready.get(agent_id) == run_id
920
1351
  )
921
1352
 
922
1353
  return {
@@ -925,9 +1356,7 @@ class SlashSession:
925
1356
  "run_id": run_id,
926
1357
  }
927
1358
 
928
- def _build_header_grid(
929
- self, agent_info: dict[str, str], transcript_status: dict[str, Any]
930
- ) -> AIPGrid:
1359
+ def _build_header_grid(self, agent_info: dict[str, str], transcript_status: dict[str, Any]) -> AIPGrid:
931
1360
  """Build the main header grid with agent information."""
932
1361
  header_grid = AIPGrid(expand=True)
933
1362
  header_grid.add_column(ratio=3)
@@ -938,18 +1367,16 @@ class SlashSession:
938
1367
  f"[{ACCENT_STYLE}]{agent_info['id']}[/]"
939
1368
  )
940
1369
  status_line = f"[{SUCCESS_STYLE}]ready[/]"
941
- status_line += (
942
- " · transcript ready"
943
- if transcript_status["transcript_ready"]
944
- else " · transcript pending"
945
- )
1370
+ if not transcript_status["has_transcript"]:
1371
+ status_line += " · no transcript"
1372
+ elif transcript_status["transcript_ready"]:
1373
+ status_line += " · transcript ready"
1374
+ else:
1375
+ status_line += " · transcript pending"
946
1376
  header_grid.add_row(primary_line, status_line)
947
1377
 
948
1378
  if agent_info["description"]:
949
- description = agent_info["description"]
950
- if not transcript_status["transcript_ready"]:
951
- description = f"{description} (transcript pending)"
952
- header_grid.add_row(f"[dim]{description}[/dim]", "")
1379
+ header_grid.add_row(f"[dim]{agent_info['description']}[/dim]", "")
953
1380
 
954
1381
  return header_grid
955
1382
 
@@ -958,28 +1385,40 @@ class SlashSession:
958
1385
  keybar = AIPGrid(expand=True)
959
1386
  keybar.add_column(justify="left", ratio=1)
960
1387
  keybar.add_column(justify="left", ratio=1)
1388
+ keybar.add_column(justify="left", ratio=1)
961
1389
 
962
1390
  keybar.add_row(
963
- format_command_hint("/help", "Show commands") or "",
964
- format_command_hint("/details", "Agent config") or "",
1391
+ format_command_hint(HELP_COMMAND, "Show commands") or "",
1392
+ format_command_hint("/details", "Agent config (expand prompt)") or "",
965
1393
  format_command_hint("/exit", "Back") or "",
966
1394
  )
967
1395
 
968
1396
  return keybar
969
1397
 
970
- def _render_main_header(
971
- self, active_agent: Any | None = None, *, full: bool = False
972
- ) -> None:
1398
+ def _render_main_header(self, active_agent: Any | None = None, *, full: bool = False) -> None:
973
1399
  """Render the main AIP environment header."""
974
1400
  config = self._load_config()
975
1401
 
1402
+ account_name, account_host, env_lock = self._get_account_context()
976
1403
  api_url = self._get_api_url(config)
977
- status = "Configured" if config.get("api_key") else "Not configured"
978
1404
 
979
- segments = [
980
- f"[dim]Base URL[/dim] • {api_url or 'Not configured'}",
981
- f"[dim]Credentials[/dim] • {status}",
982
- ]
1405
+ host_display = account_host or "Not configured"
1406
+ account_segment = f"[dim]Account[/dim] • {account_name} ({host_display})"
1407
+ if env_lock:
1408
+ account_segment += " 🔒"
1409
+
1410
+ segments = [account_segment]
1411
+
1412
+ if api_url:
1413
+ base_label = "[dim]Base URL[/dim]"
1414
+ if env_lock:
1415
+ base_label = "[dim]Base URL (env)[/dim]"
1416
+ # Always show Base URL when env-lock is active to reveal overrides
1417
+ if env_lock or api_url != account_host:
1418
+ segments.append(f"{base_label} • {api_url}")
1419
+ elif not api_url:
1420
+ segments.append("[dim]Base URL[/dim] • Not configured")
1421
+
983
1422
  agent_info = self._build_agent_status_line(active_agent)
984
1423
  if agent_info:
985
1424
  segments.append(agent_info)
@@ -987,7 +1426,7 @@ class SlashSession:
987
1426
  rendered_line = " ".join(segments)
988
1427
 
989
1428
  if full:
990
- self.console.print(rendered_line)
1429
+ self.console.print(rendered_line, soft_wrap=False)
991
1430
  return
992
1431
 
993
1432
  status_bar = AIPGrid(expand=True)
@@ -1002,12 +1441,23 @@ class SlashSession:
1002
1441
  )
1003
1442
  )
1004
1443
 
1005
- def _get_api_url(self, config: dict[str, Any]) -> str | None:
1006
- """Get the API URL from various sources."""
1007
- api_url = None
1008
- if isinstance(self.ctx.obj, dict):
1009
- api_url = self.ctx.obj.get("api_url")
1010
- return api_url or config.get("api_url") or os.getenv("AIP_API_URL")
1444
+ def _get_api_url(self, _config: dict[str, Any] | None = None) -> str | None:
1445
+ """Get the API URL from context or account store (CLI/palette ignores env credentials)."""
1446
+ return resolve_api_url_from_context(self.ctx)
1447
+
1448
+ def _get_account_context(self) -> tuple[str, str, bool]:
1449
+ """Return active account name, host, and env-lock flag."""
1450
+ try:
1451
+ store = get_account_store()
1452
+ active = store.get_active_account() or "default"
1453
+ account = store.get_account(active) if hasattr(store, "get_account") else None
1454
+ host = ""
1455
+ if account:
1456
+ host = account.get("api_url", "")
1457
+ env_lock = env_credentials_present()
1458
+ return active, host, env_lock
1459
+ except Exception:
1460
+ return "default", "", env_credentials_present()
1011
1461
 
1012
1462
  def _build_agent_status_line(self, active_agent: Any | None) -> str | None:
1013
1463
  """Return a short status line about the active or recent agent."""
@@ -1022,35 +1472,96 @@ class SlashSession:
1022
1472
  return None
1023
1473
 
1024
1474
  def _show_default_quick_actions(self) -> None:
1025
- hints: list[tuple[str | None, str]] = [
1026
- (
1027
- command_hint("status", slash_command="status", ctx=self.ctx),
1028
- "Connection check",
1029
- ),
1030
- (
1031
- command_hint("agents list", slash_command="agents", ctx=self.ctx),
1032
- "Browse agents",
1033
- ),
1034
- (
1035
- command_hint("help", slash_command="help", ctx=self.ctx),
1036
- "Show all commands",
1037
- ),
1038
- ]
1039
- filtered = [(cmd, desc) for cmd, desc in hints if cmd]
1040
- if filtered:
1041
- self._show_quick_actions(filtered, title="Quick actions")
1475
+ """Show simplified help hint to discover commands."""
1476
+ self.console.print(f"[dim]{'─' * 40}[/]")
1477
+ help_hint = format_command_hint(HELP_COMMAND, "Show all commands") or HELP_COMMAND
1478
+ self.console.print(f" {help_hint}")
1042
1479
  self._default_actions_shown = True
1043
1480
 
1481
+ def _collect_scoped_new_action_hints(self, scope: str) -> list[tuple[str, str]]:
1482
+ """Return new quick action hints filtered by scope."""
1483
+ scoped_actions = [action for action in NEW_QUICK_ACTIONS if _quick_action_scope(action) == scope]
1484
+ # Don't highlight with sparkle emoji in quick actions display - it will show in command palette instead
1485
+ return self._collect_quick_action_hints(scoped_actions)
1486
+
1487
+ def _collect_quick_action_hints(
1488
+ self,
1489
+ actions: Iterable[dict[str, Any]],
1490
+ ) -> list[tuple[str, str]]:
1491
+ """Collect quick action hints from action definitions.
1492
+
1493
+ Args:
1494
+ actions: Iterable of action dictionaries.
1495
+
1496
+ Returns:
1497
+ List of (command, description) tuples.
1498
+ """
1499
+ collected: list[tuple[str, str]] = []
1500
+
1501
+ def sort_key(payload: dict[str, Any]) -> tuple[int, str]:
1502
+ priority = int(payload.get("priority", 0))
1503
+ label = str(payload.get("slash") or payload.get("cli") or "")
1504
+ return (-priority, label.lower())
1505
+
1506
+ for action in sorted(actions, key=sort_key):
1507
+ hint = self._build_quick_action_hint(action)
1508
+ if hint:
1509
+ collected.append(hint)
1510
+ return collected
1511
+
1512
+ def _build_quick_action_hint(
1513
+ self,
1514
+ action: dict[str, Any],
1515
+ ) -> tuple[str, str] | None:
1516
+ """Build a quick action hint from an action definition.
1517
+
1518
+ Args:
1519
+ action: Action dictionary.
1520
+
1521
+ Returns:
1522
+ Tuple of (command, description) or None.
1523
+ """
1524
+ command = command_hint(action.get("cli"), slash_command=action.get("slash"), ctx=self.ctx)
1525
+ if not command:
1526
+ return None
1527
+ description = action.get("description", "")
1528
+ # Don't include tag or sparkle emoji in quick actions display
1529
+ # The NEW tag will only show in the command dropdown (help text)
1530
+ return command, description
1531
+
1532
+ def _render_quick_action_group(self, hints: list[tuple[str, str]], title: str) -> None:
1533
+ """Render a group of quick action hints.
1534
+
1535
+ Args:
1536
+ hints: List of (command, description) tuples.
1537
+ title: Group title.
1538
+ """
1539
+ for line in self._format_quick_action_lines(hints, title):
1540
+ self.console.print(line)
1541
+
1542
+ def _chunk_tokens(self, tokens: list[str], *, size: int) -> Iterable[list[str]]:
1543
+ """Chunk tokens into groups of specified size.
1544
+
1545
+ Args:
1546
+ tokens: List of tokens to chunk.
1547
+ size: Size of each chunk.
1548
+
1549
+ Yields:
1550
+ Lists of tokens.
1551
+ """
1552
+ for index in range(0, len(tokens), size):
1553
+ yield tokens[index : index + size]
1554
+
1044
1555
  def _render_home_hint(self) -> None:
1556
+ """Render hint text for home screen."""
1045
1557
  if self._home_hint_shown:
1046
1558
  return
1047
- hint_lines = [
1048
- f"[{HINT_PREFIX_STYLE}]Hint:[/]",
1049
- f" Type {format_command_hint('/') or '/'} to explore commands",
1050
- " Press [dim]Ctrl+C[/] to cancel the current entry",
1051
- " Press [dim]Ctrl+D[/] to quit",
1052
- ]
1053
- self.console.print("\n".join(hint_lines))
1559
+ hint_text = (
1560
+ f"[{HINT_PREFIX_STYLE}]Hint:[/] "
1561
+ f"Type {format_command_hint('/') or '/'} to explore commands · "
1562
+ "Press [dim]Ctrl+D[/] to quit"
1563
+ )
1564
+ self.console.print(hint_text)
1054
1565
  self._home_hint_shown = True
1055
1566
 
1056
1567
  def _show_quick_actions(
@@ -1060,36 +1571,99 @@ class SlashSession:
1060
1571
  title: str = "Quick actions",
1061
1572
  inline: bool = False,
1062
1573
  ) -> None:
1063
- hint_list = [
1064
- (command, description) for command, description in hints if command
1065
- ]
1574
+ """Show quick action hints.
1575
+
1576
+ Args:
1577
+ hints: Iterable of (command, description) tuples.
1578
+ title: Title for the hints.
1579
+ inline: Whether to render inline or in a panel.
1580
+ """
1581
+ hint_list = self._normalize_quick_action_hints(hints)
1066
1582
  if not hint_list:
1067
1583
  return
1068
1584
 
1069
1585
  if inline:
1070
- lines: list[str] = []
1071
- for command, description in hint_list:
1072
- formatted = format_command_hint(command, description)
1073
- if formatted:
1074
- lines.append(formatted)
1075
- if lines:
1076
- self.console.print("\n".join(lines))
1586
+ self._render_inline_quick_actions(hint_list, title)
1077
1587
  return
1078
1588
 
1589
+ self._render_panel_quick_actions(hint_list, title)
1590
+
1591
+ def _normalize_quick_action_hints(self, hints: Iterable[tuple[str, str]]) -> list[tuple[str, str]]:
1592
+ """Normalize quick action hints by filtering out empty commands.
1593
+
1594
+ Args:
1595
+ hints: Iterable of (command, description) tuples.
1596
+
1597
+ Returns:
1598
+ List of normalized hints.
1599
+ """
1600
+ return [(command, description) for command, description in hints if command]
1601
+
1602
+ def _render_inline_quick_actions(self, hint_list: list[tuple[str, str]], title: str) -> None:
1603
+ """Render quick actions inline.
1604
+
1605
+ Args:
1606
+ hint_list: List of (command, description) tuples.
1607
+ title: Title for the hints.
1608
+ """
1609
+ tokens: list[str] = []
1610
+ for command, description in hint_list:
1611
+ formatted = format_command_hint(command, description)
1612
+ if formatted:
1613
+ tokens.append(formatted)
1614
+ if not tokens:
1615
+ return
1616
+ prefix = f"[dim]{title}:[/]" if title else ""
1617
+ body = " ".join(tokens)
1618
+ text = f"{prefix} {body}" if prefix else body
1619
+ self.console.print(text.strip())
1620
+
1621
+ def _render_panel_quick_actions(self, hint_list: list[tuple[str, str]], title: str) -> None:
1622
+ """Render quick actions in a panel.
1623
+
1624
+ Args:
1625
+ hint_list: List of (command, description) tuples.
1626
+ title: Panel title.
1627
+ """
1079
1628
  body_lines: list[Text] = []
1080
1629
  for command, description in hint_list:
1081
1630
  formatted = format_command_hint(command, description)
1082
1631
  if formatted:
1083
1632
  body_lines.append(Text.from_markup(formatted))
1084
-
1633
+ if not body_lines:
1634
+ return
1085
1635
  panel_content = Group(*body_lines)
1086
- self.console.print(
1087
- AIPPanel(
1088
- panel_content, title=title, border_style=SECONDARY_LIGHT, expand=False
1089
- )
1090
- )
1636
+ self.console.print(AIPPanel(panel_content, title=title, border_style=SECONDARY_LIGHT, expand=False))
1637
+
1638
+ def _format_quick_action_lines(self, hints: list[tuple[str, str]], title: str) -> list[str]:
1639
+ """Return formatted lines for quick action hints."""
1640
+ if not hints:
1641
+ return []
1642
+ formatted_tokens: list[str] = []
1643
+ for command, description in hints:
1644
+ formatted = format_command_hint(command, description)
1645
+ if formatted:
1646
+ formatted_tokens.append(f"• {formatted}")
1647
+ if not formatted_tokens:
1648
+ return []
1649
+ lines: list[str] = []
1650
+ # Use vertical layout (1 per line) for better readability
1651
+ chunks = list(self._chunk_tokens(formatted_tokens, size=1))
1652
+ prefix = f"[dim]{title}[/dim]\n " if title else ""
1653
+ for idx, chunk in enumerate(chunks):
1654
+ row = " ".join(chunk)
1655
+ if idx == 0:
1656
+ lines.append(f"{prefix}{row}" if prefix else row)
1657
+ else:
1658
+ lines.append(f" {row}")
1659
+ return lines
1091
1660
 
1092
1661
  def _load_config(self) -> dict[str, Any]:
1662
+ """Load configuration with caching.
1663
+
1664
+ Returns:
1665
+ Configuration dictionary.
1666
+ """
1093
1667
  if self._config_cache is None:
1094
1668
  try:
1095
1669
  self._config_cache = load_config() or {}
@@ -1097,9 +1671,17 @@ class SlashSession:
1097
1671
  self._config_cache = {}
1098
1672
  return self._config_cache
1099
1673
 
1100
- def _resolve_agent_from_ref(
1101
- self, client: Any, available_agents: list[Any], ref: str
1102
- ) -> Any | None:
1674
+ def _resolve_agent_from_ref(self, client: Any, available_agents: list[Any], ref: str) -> Any | None:
1675
+ """Resolve an agent from a reference string.
1676
+
1677
+ Args:
1678
+ client: API client instance.
1679
+ available_agents: List of available agents.
1680
+ ref: Reference string (ID or name).
1681
+
1682
+ Returns:
1683
+ Resolved agent or None.
1684
+ """
1103
1685
  ref = ref.strip()
1104
1686
  if not ref:
1105
1687
  return None