@oh-my-pi/pi-tui 17.4.2 → 18.0.1

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.
@@ -9,6 +9,7 @@ const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
9
9
  function buildAutocompleteFuzzyDiscoveryProfile(
10
10
  query: string,
11
11
  basePath: string,
12
+ signal?: AbortSignal,
12
13
  ): {
13
14
  query: string;
14
15
  path: string;
@@ -16,6 +17,7 @@ function buildAutocompleteFuzzyDiscoveryProfile(
16
17
  hidden: boolean;
17
18
  gitignore: boolean;
18
19
  cache: boolean;
20
+ signal?: AbortSignal;
19
21
  } {
20
22
  return {
21
23
  query,
@@ -24,6 +26,7 @@ function buildAutocompleteFuzzyDiscoveryProfile(
24
26
  hidden: true,
25
27
  gitignore: true,
26
28
  cache: true,
29
+ ...(signal ? { signal } : {}),
27
30
  };
28
31
  }
29
32
 
@@ -171,6 +174,8 @@ export interface AutocompleteItem {
171
174
  value: string;
172
175
  label: string;
173
176
  description?: string;
177
+ /** Optional type-indicator glyph rendered in an aligned column before the label */
178
+ icon?: string;
174
179
  /** Dim hint text shown inline after cursor when this item is selected */
175
180
  hint?: string;
176
181
  }
@@ -181,6 +186,8 @@ export interface SlashCommand {
181
186
  name: string;
182
187
  aliases?: string[];
183
188
  description?: string;
189
+ /** Optional type-indicator glyph shown before the command name in autocomplete */
190
+ icon?: string;
184
191
  argumentHint?: string;
185
192
  /** Whether the command consumes argument text after the command name. False means the full input stays normal prompt text once args are present. */
186
193
  allowArgs?: boolean;
@@ -194,11 +201,12 @@ export interface SlashCommand {
194
201
  }
195
202
 
196
203
  export interface AutocompleteProvider {
197
- /** Get autocomplete suggestions for current text/cursor position */
204
+ /** Get autocomplete suggestions for current text/cursor position. Expensive providers SHOULD stop when `signal` aborts. */
198
205
  getSuggestions(
199
206
  lines: string[],
200
207
  cursorLine: number,
201
208
  cursorCol: number,
209
+ signal?: AbortSignal,
202
210
  ): Promise<{
203
211
  items: AutocompleteItem[];
204
212
  prefix: string; // What we're matching against (e.g., "/" or "src/")
@@ -237,11 +245,13 @@ export interface AutocompleteProvider {
237
245
  * Force file-path completion (called on Tab). Returns matched items plus the
238
246
  * full prefix, or null when no path token sits before the cursor. Present on
239
247
  * file-aware providers; absent on slash-only ones.
248
+ * Expensive providers SHOULD stop when `signal` aborts.
240
249
  */
241
250
  getForceFileSuggestions?(
242
251
  lines: string[],
243
252
  cursorLine: number,
244
253
  cursorCol: number,
254
+ signal?: AbortSignal,
245
255
  ): Promise<{ items: AutocompleteItem[]; prefix: string } | null>;
246
256
 
247
257
  /** Whether a Tab press should attempt file completion at the cursor. */
@@ -249,6 +259,11 @@ export interface AutocompleteProvider {
249
259
  }
250
260
 
251
261
  type CommandEntry = SlashCommand | AutocompleteItem;
262
+ /** Optional behaviors for {@link CombinedAutocompleteProvider}. */
263
+ export interface CombinedAutocompleteOptions {
264
+ /** Usage count per command name; higher counts rank earlier among equal text-match scores. */
265
+ commandUsage?: (name: string) => number;
266
+ }
252
267
 
253
268
  function getCommandName(cmd: CommandEntry): string | undefined {
254
269
  return "name" in cmd ? cmd.name : cmd.value;
@@ -287,64 +302,79 @@ export function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string):
287
302
  return fuzzyMatch(lowerPrefix, lowerTarget) ? fuzzyScore(lowerPrefix, lowerTarget) : 0;
288
303
  }
289
304
 
290
- function buildSlashCommandCompletions(commands: CommandEntry[], lowerPrefix: string): AutocompleteItem[] {
291
- return commands
292
- .flatMap(cmd => {
293
- const name = getCommandName(cmd);
294
- if (!name) return [];
295
- const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
296
- const staticDesc = getStaticCommandDescription(cmd);
297
- let fullDescMemo: string | undefined;
298
- let fullDescComputed = false;
299
- // Resolve the (possibly live) display description lazily, only once a
300
- // candidate actually matches — getAutocompleteDescription reads live
301
- // session state and must not run for every command on each keystroke.
302
- const resolveFullDesc = (): string | undefined => {
303
- if (!fullDescComputed) {
304
- const displayDesc = getAutocompleteCommandDescription(cmd);
305
- fullDescMemo = hint ? (displayDesc ? `${hint} - ${displayDesc}` : hint) : displayDesc;
306
- fullDescComputed = true;
307
- }
308
- return fullDescMemo;
309
- };
310
- let best: (AutocompleteItem & { score: number }) | undefined;
311
-
312
- const isSkillCommand = name.startsWith("skill:");
313
- const nameScore =
314
- lowerPrefix.length === 0 && isSkillCommand ? 950 : scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
315
- const lowerDesc = staticDesc.toLowerCase();
316
- const descScore =
317
- lowerDesc && fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
318
- const primaryScore = Math.max(nameScore, descScore);
319
- if (primaryScore > 0) {
320
- const fullDesc = resolveFullDesc();
321
- best = {
322
- value: name,
323
- label: "name" in cmd ? cmd.name : cmd.label,
324
- score: primaryScore,
325
- ...(fullDesc && { description: fullDesc }),
305
+ function buildSlashCommandCompletions(
306
+ commands: CommandEntry[],
307
+ lowerPrefix: string,
308
+ commandUsage?: (name: string) => number,
309
+ ): AutocompleteItem[] {
310
+ return (
311
+ commands
312
+ .flatMap(cmd => {
313
+ const name = getCommandName(cmd);
314
+ if (!name) return [];
315
+ const usage = commandUsage?.(name) ?? 0;
316
+ const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
317
+ const staticDesc = getStaticCommandDescription(cmd);
318
+ let fullDescMemo: string | undefined;
319
+ let fullDescComputed = false;
320
+ // Resolve the (possibly live) display description lazily, only once a
321
+ // candidate actually matches — getAutocompleteDescription reads live
322
+ // session state and must not run for every command on each keystroke.
323
+ const resolveFullDesc = (): string | undefined => {
324
+ if (!fullDescComputed) {
325
+ const displayDesc = getAutocompleteCommandDescription(cmd);
326
+ fullDescMemo = hint ? (displayDesc ? `${hint} - ${displayDesc}` : hint) : displayDesc;
327
+ fullDescComputed = true;
328
+ }
329
+ return fullDescMemo;
326
330
  };
327
- }
328
-
329
- if (lowerPrefix.length > 0) {
330
- for (const alias of getCommandAliases(cmd)) {
331
- if (alias === name) continue;
332
- const aliasScore = scoreCommandTextMatch(lowerPrefix, alias.toLowerCase());
333
- if (aliasScore === 0 || (best && aliasScore <= best.score)) continue;
331
+ let best: (AutocompleteItem & { score: number; usage: number }) | undefined;
332
+
333
+ const isSkillCommand = name.startsWith(SKILL_NAMESPACE);
334
+ const nameScore =
335
+ lowerPrefix.length === 0 && isSkillCommand
336
+ ? 950
337
+ : scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
338
+ const lowerDesc = staticDesc.toLowerCase();
339
+ const descScore =
340
+ lowerDesc && fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
341
+ const primaryScore = Math.max(nameScore, descScore);
342
+ if (primaryScore > 0) {
334
343
  const fullDesc = resolveFullDesc();
335
344
  best = {
336
- value: alias,
337
- label: alias,
338
- score: aliasScore,
345
+ value: name,
346
+ label: "name" in cmd ? cmd.name : cmd.label,
347
+ score: primaryScore,
348
+ usage,
349
+ ...(cmd.icon && { icon: cmd.icon }),
339
350
  ...(fullDesc && { description: fullDesc }),
340
351
  };
341
352
  }
342
- }
343
353
 
344
- return best ? [best] : [];
345
- })
346
- .sort((a, b) => b.score - a.score)
347
- .map(({ score: _, ...rest }) => rest);
354
+ if (lowerPrefix.length > 0) {
355
+ for (const alias of getCommandAliases(cmd)) {
356
+ if (alias === name) continue;
357
+ const aliasScore = scoreCommandTextMatch(lowerPrefix, alias.toLowerCase());
358
+ if (aliasScore === 0 || (best && aliasScore <= best.score)) continue;
359
+ const fullDesc = resolveFullDesc();
360
+ best = {
361
+ value: alias,
362
+ label: alias,
363
+ score: aliasScore,
364
+ usage,
365
+ ...(cmd.icon && { icon: cmd.icon }),
366
+ ...(fullDesc && { description: fullDesc }),
367
+ };
368
+ }
369
+ }
370
+
371
+ return best ? [best] : [];
372
+ })
373
+ // Equal text-match scores fall back to usage frequency, then to the
374
+ // stable registry order.
375
+ .sort((a, b) => b.score - a.score || b.usage - a.usage)
376
+ .map(({ score: _, usage: _usage, ...rest }) => rest)
377
+ );
348
378
  }
349
379
 
350
380
  function hasPromptTextBeforeSlash(
@@ -359,7 +389,35 @@ function hasPromptTextBeforeSlash(
359
389
  return textBeforeCursor.slice(0, slashStart).trim() !== "";
360
390
  }
361
391
 
362
- const SKILL_NAMESPACE = "skill:";
392
+ export const SKILL_NAMESPACE = "skill:";
393
+
394
+ /**
395
+ * Collapse `skill:*` commands into a single `/skill:` namespace row while the
396
+ * typed prefix has not committed to the namespace. Until the prefix starts
397
+ * with `skill:`, individual skills never list — a lone group entry (shown only
398
+ * while the prefix is still a prefix of `skill:`) keeps the `/` popup
399
+ * readable. Accepting the group inserts `/skill:` without a trailing space so
400
+ * the reopened popup expands to the individual skills.
401
+ */
402
+ function collapseSkillNamespace(commands: CommandEntry[], lowerPrefix: string): CommandEntry[] {
403
+ if (lowerPrefix.startsWith(SKILL_NAMESPACE)) return commands;
404
+ let skillCount = 0;
405
+ let skillIcon: string | undefined;
406
+ const rest = commands.filter(cmd => {
407
+ if (!getCommandName(cmd)?.startsWith(SKILL_NAMESPACE)) return true;
408
+ skillCount += 1;
409
+ skillIcon ??= cmd.icon;
410
+ return false;
411
+ });
412
+ if (skillCount === 0) return commands;
413
+ if (!SKILL_NAMESPACE.startsWith(lowerPrefix)) return rest;
414
+ rest.push({
415
+ name: SKILL_NAMESPACE,
416
+ description: `${skillCount} skill${skillCount === 1 ? "" : "s"}`,
417
+ ...(skillIcon && { icon: skillIcon }),
418
+ });
419
+ return rest;
420
+ }
363
421
 
364
422
  /**
365
423
  * Whether a mid-prompt slash token (`prose … /tok`) is skill-shaped enough to
@@ -401,22 +459,30 @@ function buildMidPromptSkillCompletions(commands: CommandEntry[], lowerPrefix: s
401
459
  export class CombinedAutocompleteProvider implements AutocompleteProvider {
402
460
  #commands: CommandEntry[];
403
461
  #basePath: string;
462
+ #commandUsage?: (name: string) => number;
404
463
  // Intentionally separate from pi-natives cache: this cache is a local,
405
464
  // per-directory readdir fast-path for prefix completions. Global fuzzy
406
465
  // discovery continues to use native fuzzyFind + shared scan cache.
407
466
  #dirCache: Map<string, { entries: fs.Dirent[]; timestamp: number }> = new Map();
408
467
  readonly #DIR_CACHE_TTL = 2000; // 2 seconds
409
468
 
410
- constructor(commands: CommandEntry[] = [], basePath: string = getProjectDir()) {
469
+ constructor(
470
+ commands: CommandEntry[] = [],
471
+ basePath: string = getProjectDir(),
472
+ options?: CombinedAutocompleteOptions,
473
+ ) {
411
474
  this.#commands = commands;
412
475
  this.#basePath = basePath;
476
+ this.#commandUsage = options?.commandUsage;
413
477
  }
414
478
 
415
479
  async getSuggestions(
416
480
  lines: string[],
417
481
  cursorLine: number,
418
482
  cursorCol: number,
483
+ signal?: AbortSignal,
419
484
  ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
485
+ if (signal?.aborted) return null;
420
486
  const currentLine = lines[cursorLine] || "";
421
487
  const textBeforeCursor = currentLine.slice(0, cursorCol);
422
488
 
@@ -444,7 +510,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
444
510
 
445
511
  const matches = isMidPromptSkillLookup
446
512
  ? buildMidPromptSkillCompletions(this.#commands, lowerPrefix)
447
- : buildSlashCommandCompletions(this.#commands, lowerPrefix);
513
+ : buildSlashCommandCompletions(
514
+ collapseSkillNamespace(this.#commands, lowerPrefix),
515
+ lowerPrefix,
516
+ this.#commandUsage,
517
+ );
448
518
 
449
519
  if (matches.length > 0) {
450
520
  return {
@@ -508,7 +578,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
508
578
  }
509
579
  const suggestions =
510
580
  rawPrefix.length > 0
511
- ? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix })
581
+ ? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix, signal })
512
582
  : await this.#getFileSuggestions("@");
513
583
  if (suggestions.length === 0 && rawPrefix.length > 0) {
514
584
  const fallback = await this.#getFileSuggestions(atPrefix);
@@ -601,14 +671,18 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
601
671
  const slashPrefix = textBeforeCursor.slice(leadingSlashStart);
602
672
  if (!slashPrefix.includes(" ") && !slashPrefix.slice(1).includes("/")) {
603
673
  const beforeSlash = currentLine.slice(0, leadingSlashStart);
604
- const newLine = `${beforeSlash}/${item.value} ${afterCursor}`;
674
+ // The collapsed `/skill:` namespace row completes to the namespace
675
+ // itself: no trailing space, so completion continues with the
676
+ // individual skills instead of finishing a command token.
677
+ const insert = item.value === SKILL_NAMESPACE ? `/${item.value}` : `/${item.value} `;
678
+ const newLine = `${beforeSlash}${insert}${afterCursor}`;
605
679
  const newLines = [...lines];
606
680
  newLines[cursorLine] = newLine;
607
681
 
608
682
  return {
609
683
  lines: newLines,
610
684
  cursorLine,
611
- cursorCol: beforeSlash.length + item.value.length + 2, // +2 for "/" and space
685
+ cursorCol: beforeSlash.length + insert.length,
612
686
  };
613
687
  }
614
688
  }
@@ -951,12 +1025,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
951
1025
  }
952
1026
  }
953
1027
 
954
- async #getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): Promise<AutocompleteItem[]> {
1028
+ async #getFuzzyFileSuggestions(
1029
+ query: string,
1030
+ options: { isQuotedPrefix: boolean; signal?: AbortSignal },
1031
+ ): Promise<AutocompleteItem[]> {
955
1032
  try {
956
1033
  const scopedQuery = await this.#resolveScopedFuzzyQuery(query);
1034
+ if (options.signal?.aborted) return [];
957
1035
  const searchPath = scopedQuery?.baseDir ?? this.#basePath;
958
1036
  const fuzzyQuery = scopedQuery?.query ?? query;
959
- const result = await fuzzyFind(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath));
1037
+ const result = await fuzzyFind(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath, options.signal));
960
1038
  const lowerQuery = fuzzyQuery.toLowerCase();
961
1039
  const filteredMatches = result.matches.filter(entry => {
962
1040
  const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
@@ -999,7 +1077,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
999
1077
  lines: string[],
1000
1078
  cursorLine: number,
1001
1079
  cursorCol: number,
1080
+ signal?: AbortSignal,
1002
1081
  ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
1082
+ if (signal?.aborted) return null;
1003
1083
  const currentLine = lines[cursorLine] || "";
1004
1084
  const textBeforeCursor = currentLine.slice(0, cursorCol);
1005
1085
 
@@ -1069,7 +1149,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
1069
1149
  const prefix = commandText.slice(1);
1070
1150
  const lowerPrefix = prefix.toLowerCase();
1071
1151
 
1072
- const matches = buildSlashCommandCompletions(this.#commands, lowerPrefix);
1152
+ // The `/skill:` namespace row is excluded here: the sync path submits
1153
+ // immediately after applying, and the bare namespace is not a command.
1154
+ const matches = buildSlashCommandCompletions(
1155
+ collapseSkillNamespace(this.#commands, lowerPrefix),
1156
+ lowerPrefix,
1157
+ this.#commandUsage,
1158
+ ).filter(item => item.value !== SKILL_NAMESPACE);
1073
1159
 
1074
1160
  if (matches.length === 0) return null;
1075
1161
  // Mirror `getSuggestions`: preserve leading whitespace so the editor's