@@ -11,12 +11,27 @@ import { withOriginMarker } from "../plugins/origin-marker.js";
1111import type { PluginOrigin } from "../trust/project-trust.js" ;
1212import { sliceToWidth , stringWidth } from "./view/height.js" ;
1313
14+ /** Minimal subcommand shape — mirrors `SubcommandDefinition` without importing it. */
15+ export interface RegistrySubcommandSource {
16+ readonly name : string ;
17+ readonly description : string ;
18+ }
19+
1420/** Minimal registry shape — matches `listCommands()` entries without importing them. */
1521export interface RegistryCommandSource {
1622 readonly name : string ;
1723 readonly description : string ;
1824 /** Discovery origin of the contributing plugin, when the command has one. */
1925 readonly origin ?: PluginOrigin ;
26+ /**
27+ * Free-form arg guidance (frontmatter `argument-hint`). Shown greyed in the
28+ * `/` popup row and spliced into the prompt as selected text on Tab so
29+ * typing replaces it. `undefined` means the command takes no params and
30+ * keeps today's bare `/id` accept behavior.
31+ */
32+ readonly argumentHint ?: string ;
33+ /** Named subcommands (frontmatter `subcommands`); offered as arg rows. */
34+ readonly subcommands ?: readonly RegistrySubcommandSource [ ] ;
2035}
2136
2237/** One entry in the `/` command list: registry command name + display label. */
@@ -27,22 +42,127 @@ export interface PaletteCommand {
2742 readonly keywords ?: readonly string [ ] ;
2843 /** Registry description for the overlay zone; rows stay name-only. */
2944 readonly description ?: string ;
45+ /** Carried arg guidance; rendered after the name in `/` rows. */
46+ readonly argumentHint ?: string ;
47+ /** Carried subcommands; offered as second-stage arg rows. */
48+ readonly subcommands ?: readonly RegistrySubcommandSource [ ] ;
49+ /**
50+ * Render suffix for `/` rows (`/yolo [on|off|toggle]`); `label` itself
51+ * stays `/name` so the name-prefix filter is unchanged.
52+ */
53+ readonly hintLabel ?: string ;
54+ /** Second-stage arg rows only: the owning slash command id. */
55+ readonly parentId ?: string ;
56+ /** Second-stage rows only: text spliced after `/id ` on accept. */
57+ readonly argValue ?: string ;
58+ /** Second-stage rows only: subcommand choice vs hint reminder. */
59+ readonly argKind ?: "subcommand" | "hint" ;
3060}
3161
3262/** Map registry command definitions to `/` list items. */
3363export function commandItemsFromRegistry (
3464 commands : readonly RegistryCommandSource [ ] ,
3565) : PaletteCommand [ ] {
36- return commands . map ( ( c ) => ( {
37- id : c . name ,
38- // Name-only rows keep the slash popup scannable; description is a
39- // dedicated field for the overlay zone and stays in keywords so typed
40- // filter still finds prose matches. Plugin rows carry their origin
41- // marker ([bundled] for bundled, origin label otherwise).
42- label : withOriginMarker ( `/${ c . name } ` , c . origin ) ,
43- description : c . description ,
44- keywords : [ c . name , c . description , "slash" , "command" ] ,
45- } ) ) ;
66+ return commands . map ( ( c ) => {
67+ const subcommands =
68+ c . subcommands !== undefined && c . subcommands . length > 0
69+ ? [ ...c . subcommands ]
70+ : undefined ;
71+ // Explicit hint wins; otherwise derive `[a|b]` from subcommand names so
72+ // stage 1 still advertises that the command takes an argument.
73+ const hintLabel =
74+ c . argumentHint ??
75+ ( subcommands !== undefined
76+ ? `[${ subcommands . map ( ( s ) => s . name ) . join ( "|" ) } ]`
77+ : undefined ) ;
78+ const keywords = [ c . name , c . description , "slash" , "command" ] ;
79+ if ( c . argumentHint !== undefined ) keywords . push ( c . argumentHint ) ;
80+ if ( subcommands !== undefined ) {
81+ for ( const s of subcommands ) keywords . push ( s . name , s . description ) ;
82+ }
83+ return {
84+ id : c . name ,
85+ // Name-only rows keep the slash popup scannable; description is a
86+ // dedicated field for the overlay zone and stays in keywords so typed
87+ // filter still finds prose matches. Plugin rows carry their origin
88+ // marker ([bundled] for bundled, origin label otherwise).
89+ label : withOriginMarker ( `/${ c . name } ` , c . origin ) ,
90+ description : c . description ,
91+ keywords,
92+ ...( c . argumentHint !== undefined ? { argumentHint : c . argumentHint } : { } ) ,
93+ ...( subcommands !== undefined ? { subcommands } : { } ) ,
94+ ...( hintLabel !== undefined ? { hintLabel } : { } ) ,
95+ } ;
96+ } ) ;
97+ }
98+
99+ /**
100+ * Second-stage arg rows for a command with params: subcommand choices
101+ * prefix-filtered by the typed arg, or the free-form hint as a single
102+ * reminder row while the arg is still empty. Pure; the popup branch in
103+ * `openSlashCommands` reuses these with in-place refresh.
104+ */
105+ export function slashArgItems (
106+ cmd : PaletteCommand ,
107+ arg : string ,
108+ ) : PaletteCommand [ ] {
109+ const q = arg . trim ( ) . toLowerCase ( ) ;
110+ const subcommands = cmd . subcommands ?? [ ] ;
111+ if ( subcommands . length > 0 ) {
112+ return subcommands
113+ . filter ( ( s ) => s . name . toLowerCase ( ) . startsWith ( q ) )
114+ . map ( ( s ) => ( {
115+ id : `${ cmd . id } :${ s . name } ` ,
116+ label : s . name ,
117+ keywords : [ s . name , s . description ] ,
118+ description : s . description ,
119+ parentId : cmd . id ,
120+ argValue : s . name ,
121+ argKind : "subcommand" as const ,
122+ } ) ) ;
123+ }
124+ if ( cmd . argumentHint === undefined || q . length > 0 ) return [ ] ;
125+ return [
126+ {
127+ id : `${ cmd . id } :hint` ,
128+ label : cmd . argumentHint ,
129+ keywords : [ cmd . argumentHint ] ,
130+ ...( cmd . description !== undefined
131+ ? { description : cmd . description }
132+ : { } ) ,
133+ parentId : cmd . id ,
134+ argValue : cmd . argumentHint ,
135+ argKind : "hint" as const ,
136+ } ,
137+ ] ;
138+ }
139+
140+ /**
141+ * Bare base text (`/id `) when a value about to be submitted is still exactly
142+ * a Tab-accepted free-form hint: the hint lands as selected text so typing
143+ * replaces it, but submitting it untouched would send the placeholder as the
144+ * argument. The guard is deliberately shape-only, not selection-gated — the
145+ * untouched selection is trivially lost without editing (one arrow key), and
146+ * after that a bare Enter would still submit the literal. An exact `/id
147+ * <hint>` match is always the placeholder no matter how the selection was
148+ * lost: real arguments never equal the hint byte-for-byte. Pure;
149+ * `submitPrompt` applies the result. Returns null when the value is real
150+ * content (subcommand accepts, typed text, unknown commands, bare bases).
151+ */
152+ export function stripUneditedSlashHint (
153+ catalog : readonly PaletteCommand [ ] ,
154+ value : string ,
155+ ) : string | null {
156+ if ( ! value . startsWith ( "/" ) ) return null ;
157+ const space = value . indexOf ( " " ) ;
158+ if ( space < 0 ) return null ;
159+ const tail = value . slice ( space + 1 ) ;
160+ if ( tail . length === 0 ) return null ;
161+ const cmd = catalog . find (
162+ ( c ) => c . id . toLowerCase ( ) === value . slice ( 1 , space ) . toLowerCase ( ) ,
163+ ) ;
164+ if ( cmd ?. argumentHint === undefined || cmd . argumentHint !== tail ) return null ;
165+ return value . slice ( 0 , space + 1 ) ;
46166}
47167
48168/**
@@ -62,11 +182,13 @@ export function filterPaletteCommands(
62182 } ) ;
63183}
64184
65- /** Labels for the shared list viewport. */
185+ /** Labels for the shared list viewport. Hint suffixes ride along on `/` rows. */
66186export function paletteLabels (
67- commands : readonly PaletteCommand [ ] ,
187+ commands : readonly Pick < PaletteCommand , "label" | "hintLabel" > [ ] ,
68188) : readonly string [ ] {
69- return commands . map ( ( c ) => c . label ) ;
189+ return commands . map ( ( c ) =>
190+ c . hintLabel !== undefined ? `${ c . label } ${ c . hintLabel } ` : c . label ,
191+ ) ;
70192}
71193
72194function fitLabel ( label : string , width : number ) : string {
0 commit comments