@oh-my-pi/pi-tui 18.0.0 → 18.0.3

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
 
@@ -198,11 +201,12 @@ export interface SlashCommand {
198
201
  }
199
202
 
200
203
  export interface AutocompleteProvider {
201
- /** Get autocomplete suggestions for current text/cursor position */
204
+ /** Get autocomplete suggestions for current text/cursor position. Expensive providers SHOULD stop when `signal` aborts. */
202
205
  getSuggestions(
203
206
  lines: string[],
204
207
  cursorLine: number,
205
208
  cursorCol: number,
209
+ signal?: AbortSignal,
206
210
  ): Promise<{
207
211
  items: AutocompleteItem[];
208
212
  prefix: string; // What we're matching against (e.g., "/" or "src/")
@@ -241,11 +245,13 @@ export interface AutocompleteProvider {
241
245
  * Force file-path completion (called on Tab). Returns matched items plus the
242
246
  * full prefix, or null when no path token sits before the cursor. Present on
243
247
  * file-aware providers; absent on slash-only ones.
248
+ * Expensive providers SHOULD stop when `signal` aborts.
244
249
  */
245
250
  getForceFileSuggestions?(
246
251
  lines: string[],
247
252
  cursorLine: number,
248
253
  cursorCol: number,
254
+ signal?: AbortSignal,
249
255
  ): Promise<{ items: AutocompleteItem[]; prefix: string } | null>;
250
256
 
251
257
  /** Whether a Tab press should attempt file completion at the cursor. */
@@ -324,7 +330,7 @@ function buildSlashCommandCompletions(
324
330
  };
325
331
  let best: (AutocompleteItem & { score: number; usage: number }) | undefined;
326
332
 
327
- const isSkillCommand = name.startsWith("skill:");
333
+ const isSkillCommand = name.startsWith(SKILL_NAMESPACE);
328
334
  const nameScore =
329
335
  lowerPrefix.length === 0 && isSkillCommand
330
336
  ? 950
@@ -383,7 +389,35 @@ function hasPromptTextBeforeSlash(
383
389
  return textBeforeCursor.slice(0, slashStart).trim() !== "";
384
390
  }
385
391
 
386
- 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
+ }
387
421
 
388
422
  /**
389
423
  * Whether a mid-prompt slash token (`prose … /tok`) is skill-shaped enough to
@@ -446,7 +480,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
446
480
  lines: string[],
447
481
  cursorLine: number,
448
482
  cursorCol: number,
483
+ signal?: AbortSignal,
449
484
  ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
485
+ if (signal?.aborted) return null;
450
486
  const currentLine = lines[cursorLine] || "";
451
487
  const textBeforeCursor = currentLine.slice(0, cursorCol);
452
488
 
@@ -474,7 +510,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
474
510
 
475
511
  const matches = isMidPromptSkillLookup
476
512
  ? buildMidPromptSkillCompletions(this.#commands, lowerPrefix)
477
- : buildSlashCommandCompletions(this.#commands, lowerPrefix, this.#commandUsage);
513
+ : buildSlashCommandCompletions(
514
+ collapseSkillNamespace(this.#commands, lowerPrefix),
515
+ lowerPrefix,
516
+ this.#commandUsage,
517
+ );
478
518
 
479
519
  if (matches.length > 0) {
480
520
  return {
@@ -538,7 +578,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
538
578
  }
539
579
  const suggestions =
540
580
  rawPrefix.length > 0
541
- ? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix })
581
+ ? await this.#getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix, signal })
542
582
  : await this.#getFileSuggestions("@");
543
583
  if (suggestions.length === 0 && rawPrefix.length > 0) {
544
584
  const fallback = await this.#getFileSuggestions(atPrefix);
@@ -631,14 +671,18 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
631
671
  const slashPrefix = textBeforeCursor.slice(leadingSlashStart);
632
672
  if (!slashPrefix.includes(" ") && !slashPrefix.slice(1).includes("/")) {
633
673
  const beforeSlash = currentLine.slice(0, leadingSlashStart);
634
- 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}`;
635
679
  const newLines = [...lines];
636
680
  newLines[cursorLine] = newLine;
637
681
 
638
682
  return {
639
683
  lines: newLines,
640
684
  cursorLine,
641
- cursorCol: beforeSlash.length + item.value.length + 2, // +2 for "/" and space
685
+ cursorCol: beforeSlash.length + insert.length,
642
686
  };
643
687
  }
644
688
  }
@@ -981,12 +1025,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
981
1025
  }
982
1026
  }
983
1027
 
984
- async #getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): Promise<AutocompleteItem[]> {
1028
+ async #getFuzzyFileSuggestions(
1029
+ query: string,
1030
+ options: { isQuotedPrefix: boolean; signal?: AbortSignal },
1031
+ ): Promise<AutocompleteItem[]> {
985
1032
  try {
986
1033
  const scopedQuery = await this.#resolveScopedFuzzyQuery(query);
1034
+ if (options.signal?.aborted) return [];
987
1035
  const searchPath = scopedQuery?.baseDir ?? this.#basePath;
988
1036
  const fuzzyQuery = scopedQuery?.query ?? query;
989
- const result = await fuzzyFind(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath));
1037
+ const result = await fuzzyFind(buildAutocompleteFuzzyDiscoveryProfile(fuzzyQuery, searchPath, options.signal));
990
1038
  const lowerQuery = fuzzyQuery.toLowerCase();
991
1039
  const filteredMatches = result.matches.filter(entry => {
992
1040
  const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
@@ -1029,7 +1077,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
1029
1077
  lines: string[],
1030
1078
  cursorLine: number,
1031
1079
  cursorCol: number,
1080
+ signal?: AbortSignal,
1032
1081
  ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> {
1082
+ if (signal?.aborted) return null;
1033
1083
  const currentLine = lines[cursorLine] || "";
1034
1084
  const textBeforeCursor = currentLine.slice(0, cursorCol);
1035
1085
 
@@ -1099,7 +1149,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
1099
1149
  const prefix = commandText.slice(1);
1100
1150
  const lowerPrefix = prefix.toLowerCase();
1101
1151
 
1102
- const matches = buildSlashCommandCompletions(this.#commands, lowerPrefix, this.#commandUsage);
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);
1103
1159
 
1104
1160
  if (matches.length === 0) return null;
1105
1161
  // Mirror `getSuggestions`: preserve leading whitespace so the editor's