@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.
- package/CHANGELOG.md +67 -0
- package/README.md +1 -1
- package/dist/types/autocomplete.d.ts +17 -6
- package/dist/types/components/editor.d.ts +42 -5
- package/dist/types/components/image.d.ts +2 -5
- package/dist/types/components/markdown.d.ts +18 -24
- package/dist/types/components/select-list.d.ts +9 -0
- package/dist/types/components/text.d.ts +0 -1
- package/dist/types/keybindings.d.ts +5 -0
- package/dist/types/terminal-capabilities.d.ts +10 -3
- package/dist/types/terminal.d.ts +42 -2
- package/dist/types/tui.d.ts +63 -218
- package/dist/types/utils.d.ts +3 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +147 -61
- package/src/components/editor.ts +407 -157
- package/src/components/image.ts +4 -6
- package/src/components/loader.ts +1 -9
- package/src/components/markdown.ts +133 -417
- package/src/components/select-list.ts +67 -15
- package/src/components/text.ts +8 -18
- package/src/fuzzy.ts +63 -6
- package/src/keybindings.ts +5 -0
- package/src/stdin-buffer.ts +130 -20
- package/src/terminal-capabilities.ts +20 -3
- package/src/terminal.ts +147 -22
- package/src/tui.ts +628 -3510
- package/src/utils.ts +12 -3
package/src/autocomplete.ts
CHANGED
|
@@ -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(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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:
|
|
337
|
-
label:
|
|
338
|
-
score:
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 +
|
|
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(
|
|
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
|
-
|
|
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
|