glaip-sdk 0.1.4__py3-none-any.whl → 0.2.1__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 (37) hide show
  1. glaip_sdk/__init__.py +1 -1
  2. glaip_sdk/_version.py +1 -0
  3. glaip_sdk/cli/commands/__init__.py +2 -2
  4. glaip_sdk/cli/commands/agents.py +22 -37
  5. glaip_sdk/cli/commands/configure.py +67 -6
  6. glaip_sdk/cli/commands/mcps.py +19 -23
  7. glaip_sdk/cli/commands/tools.py +17 -41
  8. glaip_sdk/cli/commands/transcripts.py +747 -0
  9. glaip_sdk/cli/config.py +6 -1
  10. glaip_sdk/cli/display.py +1 -0
  11. glaip_sdk/cli/main.py +12 -31
  12. glaip_sdk/cli/parsers/__init__.py +1 -3
  13. glaip_sdk/cli/slash/__init__.py +0 -9
  14. glaip_sdk/cli/slash/prompt.py +2 -0
  15. glaip_sdk/cli/slash/session.py +259 -90
  16. glaip_sdk/cli/transcript/__init__.py +12 -52
  17. glaip_sdk/cli/transcript/cache.py +255 -44
  18. glaip_sdk/cli/transcript/capture.py +32 -0
  19. glaip_sdk/cli/transcript/history.py +815 -0
  20. glaip_sdk/cli/transcript/viewer.py +6 -2
  21. glaip_sdk/cli/update_notifier.py +5 -2
  22. glaip_sdk/cli/utils.py +170 -0
  23. glaip_sdk/client/_agent_payloads.py +5 -0
  24. glaip_sdk/payload_schemas/__init__.py +1 -13
  25. glaip_sdk/utils/__init__.py +12 -7
  26. glaip_sdk/utils/datetime_helpers.py +58 -0
  27. glaip_sdk/utils/general.py +0 -33
  28. glaip_sdk/utils/import_export.py +9 -1
  29. glaip_sdk/utils/rendering/renderer/__init__.py +0 -20
  30. glaip_sdk/utils/rendering/renderer/debug.py +3 -20
  31. glaip_sdk/utils/rendering/steps.py +5 -6
  32. glaip_sdk/utils/resource_refs.py +2 -1
  33. glaip_sdk/utils/serialization.py +2 -0
  34. {glaip_sdk-0.1.4.dist-info → glaip_sdk-0.2.1.dist-info}/METADATA +1 -1
  35. {glaip_sdk-0.1.4.dist-info → glaip_sdk-0.2.1.dist-info}/RECORD +37 -34
  36. {glaip_sdk-0.1.4.dist-info → glaip_sdk-0.2.1.dist-info}/WHEEL +0 -0
  37. {glaip_sdk-0.1.4.dist-info → glaip_sdk-0.2.1.dist-info}/entry_points.txt +0 -0
glaip_sdk/cli/config.py CHANGED
@@ -12,7 +12,12 @@ import yaml
12
12
 
13
13
  CONFIG_DIR = Path.home() / ".aip"
14
14
  CONFIG_FILE = CONFIG_DIR / "config.yaml"
15
- _ALLOWED_KEYS = {"api_url", "api_key", "timeout"}
15
+ _ALLOWED_KEYS = {
16
+ "api_url",
17
+ "api_key",
18
+ "timeout",
19
+ "history_default_limit",
20
+ }
16
21
 
17
22
 
18
23
  def _sanitize_config(data: dict[str, Any] | None) -> dict[str, Any]:
glaip_sdk/cli/display.py CHANGED
@@ -208,6 +208,7 @@ def build_resource_result_data(resource: Any, fields: list[str]) -> dict[str, An
208
208
 
209
209
 
210
210
  def _normalise_field_value(field: str, value: Any) -> Any:
211
+ """Convert special sentinel values into display-friendly text."""
211
212
  if value is _MISSING:
212
213
  return "N/A"
213
214
  if hasattr(value, "_mock_name"):
glaip_sdk/cli/main.py CHANGED
@@ -33,11 +33,12 @@ from glaip_sdk.cli.commands.configure import (
33
33
  from glaip_sdk.cli.commands.mcps import mcps_group
34
34
  from glaip_sdk.cli.commands.models import models_group
35
35
  from glaip_sdk.cli.commands.tools import tools_group
36
+ from glaip_sdk.cli.commands.transcripts import transcripts_group
36
37
  from glaip_sdk.cli.commands.update import update_command
37
38
  from glaip_sdk.cli.config import load_config
38
39
  from glaip_sdk.cli.transcript import get_transcript_cache_stats
39
40
  from glaip_sdk.cli.update_notifier import maybe_notify_update
40
- from glaip_sdk.cli.utils import in_slash_mode, sdk_version, spinner_context, update_spinner
41
+ from glaip_sdk.cli.utils import format_size, in_slash_mode, sdk_version, spinner_context, update_spinner
41
42
  from glaip_sdk.config.constants import (
42
43
  DEFAULT_AGENT_RUN_TIMEOUT,
43
44
  )
@@ -55,24 +56,6 @@ except ImportError: # pragma: no cover - optional slash dependencies
55
56
  AVAILABLE_STATUS = "✅ Available"
56
57
 
57
58
 
58
- def _format_size(num: int) -> str:
59
- """Return a human-readable byte size."""
60
- if num <= 0:
61
- return "0B"
62
-
63
- units = ["B", "KB", "MB", "GB", "TB"]
64
- value = float(num)
65
- for unit in units:
66
- if value < 1024 or unit == units[-1]:
67
- if value >= 100 or unit == "B":
68
- return f"{value:.0f}{unit}"
69
- if value >= 10:
70
- return f"{value:.1f}{unit}"
71
- return f"{value:.2f}{unit}"
72
- value /= 1024
73
- return f"{value:.1f}TB" # pragma: no cover - defensive fallback
74
-
75
-
76
59
  @click.group(invoke_without_command=True)
77
60
  @click.version_option(package_name="glaip-sdk", prog_name="aip")
78
61
  @click.option(
@@ -157,6 +140,7 @@ main.add_command(config_group)
157
140
  main.add_command(tools_group)
158
141
  main.add_command(mcps_group)
159
142
  main.add_command(models_group)
143
+ main.add_command(transcripts_group)
160
144
 
161
145
  # Add top-level commands
162
146
  main.add_command(configure_command)
@@ -240,15 +224,15 @@ def _collect_cache_summary() -> tuple[str | None, str | None]:
240
224
  try:
241
225
  cache_stats = get_transcript_cache_stats()
242
226
  except Exception:
243
- return "[dim]Saved run history[/dim]: unavailable", None
227
+ return "[dim]Saved transcripts[/dim]: unavailable", None
244
228
 
245
229
  runs_text = f"{cache_stats.entry_count} runs saved"
246
230
  if cache_stats.total_bytes:
247
- size_part = f" · {_format_size(cache_stats.total_bytes)} used"
231
+ size_part = f" · {format_size(cache_stats.total_bytes)} used"
248
232
  else:
249
233
  size_part = ""
250
234
 
251
- cache_line = f"[dim]Saved run history[/dim]: {runs_text}{size_part} · {cache_stats.cache_dir}"
235
+ cache_line = f"[dim]Saved transcripts[/dim]: {runs_text}{size_part} · {cache_stats.cache_dir}"
252
236
  return cache_line, None
253
237
 
254
238
 
@@ -429,14 +413,11 @@ def update(check_only: bool, force: bool) -> None:
429
413
 
430
414
  # Update using pip
431
415
  try:
432
- cmd = [
433
- sys.executable,
434
- "-m",
435
- "pip",
436
- "install",
437
- "--upgrade",
438
- "glaip-sdk",
439
- ]
416
+ from glaip_sdk.cli.commands.update import _build_upgrade_command
417
+
418
+ cmd = list(_build_upgrade_command(include_prerelease=False))
419
+ # Replace package name with "glaip-sdk" (main.py uses different name)
420
+ cmd[-1] = "glaip-sdk"
440
421
  if force:
441
422
  cmd.insert(5, "--force-reinstall")
442
423
  subprocess.run(cmd, capture_output=True, text=True, check=True)
@@ -491,4 +472,4 @@ def update(check_only: bool, force: bool) -> None:
491
472
 
492
473
 
493
474
  if __name__ == "__main__":
494
- main()
475
+ main() # pylint: disable=no-value-for-parameter
@@ -4,6 +4,4 @@ Authors:
4
4
  Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
5
5
  """
6
6
 
7
- from glaip_sdk.cli.parsers.json_input import parse_json_input
8
-
9
- __all__ = ["parse_json_input"]
7
+ __all__: list[str] = []
@@ -6,19 +6,10 @@ Authors:
6
6
 
7
7
  from glaip_sdk.cli.commands.agents import get as agents_get_command
8
8
  from glaip_sdk.cli.commands.agents import run as agents_run_command
9
- from glaip_sdk.cli.commands.configure import configure_command, load_config
10
- from glaip_sdk.cli.slash.agent_session import AgentRunSession
11
- from glaip_sdk.cli.slash.prompt import _HAS_PROMPT_TOOLKIT
12
9
  from glaip_sdk.cli.slash.session import SlashSession
13
- from glaip_sdk.cli.utils import get_client
14
10
 
15
11
  __all__ = [
16
- "AgentRunSession",
17
12
  "SlashSession",
18
- "_HAS_PROMPT_TOOLKIT",
19
13
  "agents_get_command",
20
14
  "agents_run_command",
21
- "configure_command",
22
- "get_client",
23
- "load_config",
24
15
  ]
@@ -163,6 +163,7 @@ def _create_key_bindings(_session: SlashSession) -> Any:
163
163
  def _iter_command_completions(
164
164
  session: SlashSession, text: str
165
165
  ) -> Iterable[Completion]: # pragma: no cover - thin wrapper
166
+ """Yield completions for global slash commands."""
166
167
  prefix = text[1:]
167
168
  seen: set[str] = set()
168
169
 
@@ -203,6 +204,7 @@ def _generate_command_completions(cmd: Any, prefix: str, text: str, seen: set[st
203
204
  def _iter_contextual_completions(
204
205
  session: SlashSession, text: str
205
206
  ) -> Iterable[Completion]: # pragma: no cover - thin wrapper
207
+ """Yield completions for context-specific slash commands."""
206
208
  prefix = text[1:]
207
209
  seen: set[str] = set()
208
210
 
@@ -33,6 +33,7 @@ from glaip_sdk.branding import (
33
33
  WARNING_STYLE,
34
34
  AIPBranding,
35
35
  )
36
+ from glaip_sdk.cli.commands import transcripts as transcripts_cmd
36
37
  from glaip_sdk.cli.commands.configure import configure_command, load_config
37
38
  from glaip_sdk.cli.commands.update import update_command
38
39
  from glaip_sdk.cli.slash.agent_session import AgentRunSession
@@ -46,9 +47,7 @@ from glaip_sdk.cli.slash.prompt import (
46
47
  )
47
48
  from glaip_sdk.cli.transcript import (
48
49
  export_cached_transcript,
49
- normalise_export_destination,
50
- resolve_manifest_for_export,
51
- suggest_filename,
50
+ load_history_snapshot,
52
51
  )
53
52
  from glaip_sdk.cli.transcript.viewer import ViewerContext, run_viewer_session
54
53
  from glaip_sdk.cli.update_notifier import maybe_notify_update
@@ -56,6 +55,7 @@ from glaip_sdk.cli.utils import (
56
55
  _fuzzy_pick_for_resources,
57
56
  command_hint,
58
57
  format_command_hint,
58
+ format_size,
59
59
  get_client,
60
60
  )
61
61
  from glaip_sdk.rich_components import AIPGrid, AIPPanel, AIPTable
@@ -73,6 +73,39 @@ class SlashCommand:
73
73
  aliases: tuple[str, ...] = ()
74
74
 
75
75
 
76
+ NEW_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
77
+ {
78
+ "cli": "transcripts",
79
+ "slash": "transcripts",
80
+ "description": "Review transcript cache",
81
+ "tag": "NEW",
82
+ "priority": 10,
83
+ },
84
+ )
85
+
86
+
87
+ DEFAULT_QUICK_ACTIONS: tuple[dict[str, Any], ...] = (
88
+ {
89
+ "cli": "status",
90
+ "slash": "status",
91
+ "description": "Connection check",
92
+ "priority": 0,
93
+ },
94
+ {
95
+ "cli": "agents list",
96
+ "slash": "agents",
97
+ "description": "Browse agents",
98
+ "priority": 0,
99
+ },
100
+ {
101
+ "cli": "help",
102
+ "slash": "help",
103
+ "description": "Show all commands",
104
+ "priority": 0,
105
+ },
106
+ )
107
+
108
+
76
109
  class SlashSession:
77
110
  """Interactive command palette controller."""
78
111
 
@@ -99,7 +132,7 @@ class SlashSession:
99
132
  self._active_renderer: Any | None = None
100
133
  self._current_agent: Any | None = None
101
134
 
102
- self._home_placeholder = "Start with / to browse commands"
135
+ self._home_placeholder = "Hint: type / to explore commands · Ctrl+D exits"
103
136
 
104
137
  # Command string constants to avoid duplication
105
138
  self.STATUS_COMMAND = "/status"
@@ -120,9 +153,15 @@ class SlashSession:
120
153
  # ------------------------------------------------------------------
121
154
  # Session orchestration
122
155
  # ------------------------------------------------------------------
123
- def refresh_branding(self, sdk_version: str | None = None) -> None:
156
+ def refresh_branding(
157
+ self,
158
+ sdk_version: str | None = None,
159
+ *,
160
+ branding_cls: type[AIPBranding] | None = None,
161
+ ) -> None:
124
162
  """Refresh branding assets after an in-session SDK upgrade."""
125
- self._branding = AIPBranding.create_from_sdk(
163
+ branding_type = branding_cls or AIPBranding
164
+ self._branding = branding_type.create_from_sdk(
126
165
  sdk_version=sdk_version,
127
166
  package_name="glaip-sdk",
128
167
  )
@@ -263,6 +302,10 @@ class SlashSession:
263
302
  return False
264
303
  return True
265
304
 
305
+ def _continue_session(self) -> bool:
306
+ """Signal that the slash session should remain active."""
307
+ return not self._should_exit
308
+
266
309
  # ------------------------------------------------------------------
267
310
  # Command handlers
268
311
  # ------------------------------------------------------------------
@@ -345,7 +388,7 @@ class SlashSession:
345
388
  self._show_default_quick_actions()
346
389
  except click.ClickException as exc:
347
390
  self.console.print(f"[{ERROR_STYLE}]{exc}[/]")
348
- return True
391
+ return self._continue_session()
349
392
 
350
393
  def _cmd_status(self, _args: list[str], _invoked_from_agent: bool) -> bool:
351
394
  ctx_obj = self.ctx.obj if isinstance(self.ctx.obj, dict) else None
@@ -374,7 +417,116 @@ class SlashSession:
374
417
  ctx_obj.pop("_slash_console", None)
375
418
  else:
376
419
  ctx_obj["_slash_console"] = previous_console
377
- return True
420
+ return self._continue_session()
421
+
422
+ def _cmd_transcripts(self, args: list[str], _invoked_from_agent: bool) -> bool:
423
+ if args and args[0].lower() in {"detail", "show"}:
424
+ if len(args) < 2:
425
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts detail <run_id>[/]")
426
+ return self._continue_session()
427
+ self._show_transcript_detail(args[1])
428
+ return self._continue_session()
429
+
430
+ limit, ok = self._parse_transcripts_limit(args)
431
+ if not ok:
432
+ return self._continue_session()
433
+
434
+ snapshot = load_history_snapshot(limit=limit, ctx=self.ctx)
435
+
436
+ if self._handle_transcripts_empty(snapshot, limit):
437
+ return self._continue_session()
438
+
439
+ self._render_transcripts_snapshot(snapshot)
440
+ return self._continue_session()
441
+
442
+ def _parse_transcripts_limit(self, args: list[str]) -> tuple[int | None, bool]:
443
+ if not args:
444
+ return None, True
445
+ try:
446
+ limit = int(args[0])
447
+ except ValueError:
448
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts [limit][/]")
449
+ return None, False
450
+ if limit < 0:
451
+ self.console.print(f"[{WARNING_STYLE}]Usage: /transcripts [limit][/]")
452
+ return None, False
453
+ return limit, True
454
+
455
+ def _handle_transcripts_empty(self, snapshot: Any, limit: int | None) -> bool:
456
+ if snapshot.cached_entries == 0:
457
+ self.console.print(f"[{WARNING_STYLE}]No cached transcripts yet. Run an agent first.[/]")
458
+ for warning in snapshot.warnings:
459
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
460
+ return True
461
+ if limit == 0 and snapshot.cached_entries:
462
+ self.console.print(f"[{WARNING_STYLE}]Limit is 0; nothing to display.[/]")
463
+ return True
464
+ return False
465
+
466
+ def _render_transcripts_snapshot(self, snapshot: Any) -> None:
467
+ size_text = format_size(snapshot.total_size_bytes)
468
+ header = f"[dim]Manifest: {snapshot.manifest_path} · {snapshot.total_entries} runs · {size_text} used[/]"
469
+ self.console.print(header)
470
+
471
+ if snapshot.limit_clamped:
472
+ self.console.print(
473
+ f"[{WARNING_STYLE}]Requested limit exceeded maximum; showing first {snapshot.limit_applied} runs.[/]"
474
+ )
475
+
476
+ if snapshot.total_entries > len(snapshot.entries):
477
+ subset_message = (
478
+ f"[dim]Showing {len(snapshot.entries)} of {snapshot.total_entries} "
479
+ f"runs (limit={snapshot.limit_applied}).[/]"
480
+ )
481
+ self.console.print(subset_message)
482
+ self.console.print("[dim]Hint: run `/transcripts <limit>` to change how many rows are displayed.[/]")
483
+
484
+ if snapshot.migration_summary:
485
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
486
+
487
+ for warning in snapshot.warnings:
488
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
489
+
490
+ table = transcripts_cmd._build_table(snapshot.entries)
491
+ self.console.print(table)
492
+ self.console.print("[dim]! Missing transcript[/]")
493
+
494
+ def _show_transcript_detail(self, run_id: str) -> None:
495
+ """Render the cached transcript log for a single run."""
496
+ snapshot = load_history_snapshot(ctx=self.ctx)
497
+ entry = snapshot.index.get(run_id)
498
+ if entry is None:
499
+ self.console.print(f"[{WARNING_STYLE}]Run id {run_id} was not found in the cache manifest.[/]")
500
+ return
501
+
502
+ try:
503
+ transcript_path, transcript_text = transcripts_cmd._load_transcript_text(entry)
504
+ except click.ClickException as exc:
505
+ self.console.print(f"[{WARNING_STYLE}]{exc}[/]")
506
+ return
507
+
508
+ meta, events = transcripts_cmd._decode_transcript(transcript_text)
509
+ if transcripts_cmd._maybe_launch_transcript_viewer(
510
+ self.ctx,
511
+ entry,
512
+ meta,
513
+ events,
514
+ console_override=self.console,
515
+ force=True,
516
+ initial_view="transcript",
517
+ ):
518
+ if snapshot.migration_summary:
519
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
520
+ for warning in snapshot.warnings:
521
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
522
+ return
523
+
524
+ if snapshot.migration_summary:
525
+ self.console.print(f"[{INFO_STYLE}]{snapshot.migration_summary}[/]")
526
+ for warning in snapshot.warnings:
527
+ self.console.print(f"[{WARNING_STYLE}]{warning}[/]")
528
+ view = transcripts_cmd._render_transcript_display(entry, snapshot.manifest_path, transcript_path, meta, events)
529
+ self.console.print(view, markup=False, highlight=False, soft_wrap=True, end="")
378
530
 
379
531
  def _cmd_agents(self, args: list[str], _invoked_from_agent: bool) -> bool:
380
532
  client = self._get_client_or_fail()
@@ -442,7 +594,7 @@ class SlashSession:
442
594
  self._render_header()
443
595
 
444
596
  self._show_agent_followup_actions(picked_agent)
445
- return True
597
+ return self._continue_session()
446
598
 
447
599
  def _show_agent_followup_actions(self, picked_agent: Any) -> None:
448
600
  """Show follow-up action hints after agent session."""
@@ -498,6 +650,13 @@ class SlashSession:
498
650
  handler=SlashSession._cmd_status,
499
651
  )
500
652
  )
653
+ self._register(
654
+ SlashCommand(
655
+ name="transcripts",
656
+ help="Review cached transcript history. Add a number (e.g. `/transcripts 5`) to change the row limit.",
657
+ handler=SlashSession._cmd_transcripts,
658
+ )
659
+ )
501
660
  self._register(
502
661
  SlashCommand(
503
662
  name="agents",
@@ -574,55 +733,13 @@ class SlashSession:
574
733
  manifest = ctx_obj.get("_last_transcript_manifest")
575
734
  return payload, manifest
576
735
 
577
- def _cmd_export(self, args: list[str], _invoked_from_agent: bool) -> bool:
736
+ def _cmd_export(self, _args: list[str], _invoked_from_agent: bool) -> bool:
578
737
  """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))
738
+ self.console.print(
739
+ f"[{WARNING_STYLE}]`/export` is deprecated. Use `/transcripts`, select a run, "
740
+ "and open the transcript viewer to export.[/]"
741
+ )
742
+ return True
626
743
 
627
744
  def _cmd_update(self, args: list[str], _invoked_from_agent: bool) -> bool:
628
745
  """Slash handler for `/update` command."""
@@ -981,35 +1098,73 @@ class SlashSession:
981
1098
  return None
982
1099
 
983
1100
  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")
1101
+ new_hints = self._collect_quick_action_hints(NEW_QUICK_ACTIONS, highlight_new=True)
1102
+ evergreen_hints = self._collect_quick_action_hints(DEFAULT_QUICK_ACTIONS)
1103
+ if new_hints or evergreen_hints:
1104
+ self.console.print(f"[dim]{'─' * 40}[/]")
1105
+ self._render_quick_action_group(new_hints, "New commands")
1106
+ self._render_quick_action_group(evergreen_hints, "Quick actions")
1001
1107
  self._default_actions_shown = True
1002
1108
 
1109
+ def _collect_quick_action_hints(
1110
+ self,
1111
+ actions: Iterable[dict[str, Any]],
1112
+ *,
1113
+ highlight_new: bool = False,
1114
+ ) -> list[tuple[str, str]]:
1115
+ collected: list[tuple[str, str]] = []
1116
+ for action in sorted(actions, key=lambda payload: payload.get("priority", 0), reverse=True):
1117
+ hint = self._build_quick_action_hint(action, highlight_new=highlight_new)
1118
+ if hint:
1119
+ collected.append(hint)
1120
+ return collected
1121
+
1122
+ def _build_quick_action_hint(
1123
+ self,
1124
+ action: dict[str, Any],
1125
+ *,
1126
+ highlight_new: bool = False,
1127
+ ) -> tuple[str, str] | None:
1128
+ command = command_hint(action.get("cli"), slash_command=action.get("slash"), ctx=self.ctx)
1129
+ if not command:
1130
+ return None
1131
+ description = action.get("description", "")
1132
+ tag = action.get("tag")
1133
+ if tag:
1134
+ description = f"[{ACCENT_STYLE}]{tag}[/] · {description}"
1135
+ if highlight_new:
1136
+ description = f"✨ {description}"
1137
+ return command, description
1138
+
1139
+ def _render_quick_action_group(self, hints: list[tuple[str, str]], title: str) -> None:
1140
+ if not hints:
1141
+ return
1142
+ formatted_tokens: list[str] = []
1143
+ for command, description in hints:
1144
+ formatted = format_command_hint(command, description)
1145
+ if formatted:
1146
+ formatted_tokens.append(f"• {formatted}")
1147
+ if not formatted_tokens:
1148
+ return
1149
+ header = f"[dim]{title}[/dim] · {formatted_tokens[0]}"
1150
+ self.console.print(header)
1151
+ remaining = formatted_tokens[1:]
1152
+ for chunk in self._chunk_tokens(remaining, size=3):
1153
+ self.console.print(" " + " ".join(chunk))
1154
+
1155
+ def _chunk_tokens(self, tokens: list[str], *, size: int) -> Iterable[list[str]]:
1156
+ for index in range(0, len(tokens), size):
1157
+ yield tokens[index : index + size]
1158
+
1003
1159
  def _render_home_hint(self) -> None:
1004
1160
  if self._home_hint_shown:
1005
1161
  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))
1162
+ hint_text = (
1163
+ f"[{HINT_PREFIX_STYLE}]Hint:[/] "
1164
+ f"Type {format_command_hint('/') or '/'} to explore commands · "
1165
+ "Press [dim]Ctrl+D[/] to quit"
1166
+ )
1167
+ self.console.print(hint_text)
1013
1168
  self._home_hint_shown = True
1014
1169
 
1015
1170
  def _show_quick_actions(
@@ -1019,26 +1174,40 @@ class SlashSession:
1019
1174
  title: str = "Quick actions",
1020
1175
  inline: bool = False,
1021
1176
  ) -> None:
1022
- hint_list = [(command, description) for command, description in hints if command]
1177
+ hint_list = self._normalize_quick_action_hints(hints)
1023
1178
  if not hint_list:
1024
1179
  return
1025
1180
 
1026
1181
  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))
1182
+ self._render_inline_quick_actions(hint_list, title)
1034
1183
  return
1035
1184
 
1185
+ self._render_panel_quick_actions(hint_list, title)
1186
+
1187
+ def _normalize_quick_action_hints(self, hints: Iterable[tuple[str, str]]) -> list[tuple[str, str]]:
1188
+ return [(command, description) for command, description in hints if command]
1189
+
1190
+ def _render_inline_quick_actions(self, hint_list: list[tuple[str, str]], title: str) -> None:
1191
+ tokens: list[str] = []
1192
+ for command, description in hint_list:
1193
+ formatted = format_command_hint(command, description)
1194
+ if formatted:
1195
+ tokens.append(formatted)
1196
+ if not tokens:
1197
+ return
1198
+ prefix = f"[dim]{title}:[/]" if title else ""
1199
+ body = " ".join(tokens)
1200
+ text = f"{prefix} {body}" if prefix else body
1201
+ self.console.print(text.strip())
1202
+
1203
+ def _render_panel_quick_actions(self, hint_list: list[tuple[str, str]], title: str) -> None:
1036
1204
  body_lines: list[Text] = []
1037
1205
  for command, description in hint_list:
1038
1206
  formatted = format_command_hint(command, description)
1039
1207
  if formatted:
1040
1208
  body_lines.append(Text.from_markup(formatted))
1041
-
1209
+ if not body_lines:
1210
+ return
1042
1211
  panel_content = Group(*body_lines)
1043
1212
  self.console.print(AIPPanel(panel_content, title=title, border_style=SECONDARY_LIGHT, expand=False))
1044
1213