@oh-my-pi/pi-tui 16.1.6 → 16.1.8
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 +17 -0
- package/dist/types/autocomplete.d.ts +9 -0
- package/dist/types/components/editor.d.ts +1 -1
- package/package.json +3 -3
- package/src/autocomplete.ts +72 -21
- package/src/components/editor.ts +10 -3
- package/src/components/markdown.ts +48 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.8] - 2026-06-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added an optional synchronous dynamic description hook for slash-command autocomplete items.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed Markdown component to strip inline `<span>` and `<text>` tags while preserving their contents and unescaping nested HTML entities (`<`, `>`, `"`, `'`, `&`), preventing raw LLM block/inline formatting residues from leaking into rendered TUI output.
|
|
14
|
+
|
|
15
|
+
## [16.1.7] - 2026-06-20
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Fixed slash command autocomplete, inline hints, and Enter completion when the slash command is preceded by leading whitespace ([#3095](https://github.com/can1357/oh-my-pi/issues/3095)).
|
|
20
|
+
- Fixed empty `/` autocomplete burying user skill commands below every built-in command, so installed skills appear in the initial slash popup ([#2875](https://github.com/can1357/oh-my-pi/issues/2875)).
|
|
21
|
+
|
|
5
22
|
## [16.1.0] - 2026-06-19
|
|
6
23
|
|
|
7
24
|
### Added
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locate the slash that opens a slash command on the line, allowing leading
|
|
3
|
+
* whitespace. Returns the index of the `/` or `null` when the line is not a
|
|
4
|
+
* slash command. Aligns with `trimStart` semantics so the editor and provider
|
|
5
|
+
* agree on which prefixes count.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findLeadingSlashCommandStart(text: string): number | null;
|
|
1
8
|
export interface AutocompleteItem {
|
|
2
9
|
value: string;
|
|
3
10
|
label: string;
|
|
@@ -11,6 +18,8 @@ export interface SlashCommand {
|
|
|
11
18
|
aliases?: string[];
|
|
12
19
|
description?: string;
|
|
13
20
|
argumentHint?: string;
|
|
21
|
+
/** Dynamic display-only description for slash-command autocomplete. Must be synchronous and side-effect free. */
|
|
22
|
+
getAutocompleteDescription?: () => string | undefined;
|
|
14
23
|
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
|
|
15
24
|
/** Return inline hint text for the current argument state (shown as dim ghost text after cursor) */
|
|
16
25
|
getInlineHint?(argumentText: string): string | null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AutocompleteProvider } from "../autocomplete";
|
|
2
2
|
import type { SymbolTheme } from "../symbols";
|
|
3
3
|
import { type Component, type Focusable } from "../tui";
|
|
4
4
|
import { type SelectListTheme } from "./select-list";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "16.1.
|
|
4
|
+
"version": "16.1.8",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.1.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.1.8",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.1.8",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.5"
|
|
44
44
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -56,6 +56,18 @@ function isTokenStart(text: string, index: number): boolean {
|
|
|
56
56
|
return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Locate the slash that opens a slash command on the line, allowing leading
|
|
61
|
+
* whitespace. Returns the index of the `/` or `null` when the line is not a
|
|
62
|
+
* slash command. Aligns with `trimStart` semantics so the editor and provider
|
|
63
|
+
* agree on which prefixes count.
|
|
64
|
+
*/
|
|
65
|
+
export function findLeadingSlashCommandStart(text: string): number | null {
|
|
66
|
+
const trimmed = text.trimStart();
|
|
67
|
+
if (!trimmed.startsWith("/")) return null;
|
|
68
|
+
return text.length - trimmed.length;
|
|
69
|
+
}
|
|
70
|
+
|
|
59
71
|
function extractQuotedPrefix(text: string): string | null {
|
|
60
72
|
const quoteStart = findUnclosedQuoteStart(text);
|
|
61
73
|
if (quoteStart === null) {
|
|
@@ -163,6 +175,8 @@ export interface SlashCommand {
|
|
|
163
175
|
aliases?: string[];
|
|
164
176
|
description?: string;
|
|
165
177
|
argumentHint?: string;
|
|
178
|
+
/** Dynamic display-only description for slash-command autocomplete. Must be synchronous and side-effect free. */
|
|
179
|
+
getAutocompleteDescription?: () => string | undefined;
|
|
166
180
|
// Function to get argument completions for this command
|
|
167
181
|
// Returns null if no argument completion is available
|
|
168
182
|
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
|
|
@@ -222,6 +236,17 @@ function getCommandAliases(cmd: CommandEntry): string[] {
|
|
|
222
236
|
return cmd.aliases.filter(alias => typeof alias === "string" && alias.length > 0);
|
|
223
237
|
}
|
|
224
238
|
|
|
239
|
+
function getStaticCommandDescription(cmd: CommandEntry): string {
|
|
240
|
+
return cmd.description ?? "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function getAutocompleteCommandDescription(cmd: CommandEntry): string {
|
|
244
|
+
if ("getAutocompleteDescription" in cmd && typeof cmd.getAutocompleteDescription === "function") {
|
|
245
|
+
return cmd.getAutocompleteDescription() ?? cmd.description ?? "";
|
|
246
|
+
}
|
|
247
|
+
return cmd.description ?? "";
|
|
248
|
+
}
|
|
249
|
+
|
|
225
250
|
function commandMatchesNameOrAlias(cmd: CommandEntry, commandName: string): boolean {
|
|
226
251
|
const name = getCommandName(cmd);
|
|
227
252
|
if (name === commandName) return true;
|
|
@@ -245,16 +270,31 @@ function buildSlashCommandCompletions(commands: CommandEntry[], lowerPrefix: str
|
|
|
245
270
|
const name = getCommandName(cmd);
|
|
246
271
|
if (!name) return [];
|
|
247
272
|
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
|
|
248
|
-
const
|
|
249
|
-
|
|
273
|
+
const staticDesc = getStaticCommandDescription(cmd);
|
|
274
|
+
let fullDescMemo: string | undefined;
|
|
275
|
+
let fullDescComputed = false;
|
|
276
|
+
// Resolve the (possibly live) display description lazily, only once a
|
|
277
|
+
// candidate actually matches — getAutocompleteDescription reads live
|
|
278
|
+
// session state and must not run for every command on each keystroke.
|
|
279
|
+
const resolveFullDesc = (): string | undefined => {
|
|
280
|
+
if (!fullDescComputed) {
|
|
281
|
+
const displayDesc = getAutocompleteCommandDescription(cmd);
|
|
282
|
+
fullDescMemo = hint ? (displayDesc ? `${hint} - ${displayDesc}` : hint) : displayDesc;
|
|
283
|
+
fullDescComputed = true;
|
|
284
|
+
}
|
|
285
|
+
return fullDescMemo;
|
|
286
|
+
};
|
|
250
287
|
const candidates: Array<AutocompleteItem & { score: number }> = [];
|
|
251
288
|
|
|
252
|
-
const
|
|
253
|
-
const
|
|
289
|
+
const isSkillCommand = name.startsWith("skill:");
|
|
290
|
+
const nameScore =
|
|
291
|
+
lowerPrefix.length === 0 && isSkillCommand ? 950 : scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
|
|
292
|
+
const lowerDesc = staticDesc.toLowerCase();
|
|
254
293
|
const descScore =
|
|
255
294
|
lowerDesc && fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
|
|
256
295
|
const primaryScore = Math.max(nameScore, descScore);
|
|
257
296
|
if (primaryScore > 0) {
|
|
297
|
+
const fullDesc = resolveFullDesc();
|
|
258
298
|
candidates.push({
|
|
259
299
|
value: name,
|
|
260
300
|
label: "name" in cmd ? cmd.name : cmd.label,
|
|
@@ -268,6 +308,7 @@ function buildSlashCommandCompletions(commands: CommandEntry[], lowerPrefix: str
|
|
|
268
308
|
if (alias === name) continue;
|
|
269
309
|
const aliasScore = scoreCommandTextMatch(lowerPrefix, alias.toLowerCase());
|
|
270
310
|
if (aliasScore === 0) continue;
|
|
311
|
+
const fullDesc = resolveFullDesc();
|
|
271
312
|
candidates.push({
|
|
272
313
|
value: alias,
|
|
273
314
|
label: alias,
|
|
@@ -338,13 +379,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
338
379
|
};
|
|
339
380
|
}
|
|
340
381
|
|
|
341
|
-
|
|
342
|
-
if (
|
|
343
|
-
const
|
|
382
|
+
const slashStart = findLeadingSlashCommandStart(textBeforeCursor);
|
|
383
|
+
if (slashStart !== null) {
|
|
384
|
+
const commandText = textBeforeCursor.slice(slashStart);
|
|
385
|
+
const spaceIndex = commandText.indexOf(" ");
|
|
344
386
|
|
|
345
387
|
if (spaceIndex === -1) {
|
|
346
388
|
// No space yet - complete command names
|
|
347
|
-
const prefix =
|
|
389
|
+
const prefix = commandText.slice(1); // Remove the "/"
|
|
348
390
|
const lowerPrefix = prefix.toLowerCase();
|
|
349
391
|
|
|
350
392
|
const matches = buildSlashCommandCompletions(this.#commands, lowerPrefix);
|
|
@@ -353,12 +395,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
353
395
|
|
|
354
396
|
return {
|
|
355
397
|
items: matches,
|
|
398
|
+
// Preserve the full text-before-cursor (incl. leading
|
|
399
|
+
// whitespace) so the editor's Enter-staleness check
|
|
400
|
+
// (`autocompletePrefix !== currentTextBeforeCursor`)
|
|
401
|
+
// still applies the completion for ` /sk`.
|
|
356
402
|
prefix: textBeforeCursor,
|
|
357
403
|
};
|
|
358
404
|
} else {
|
|
359
405
|
// Space found - complete command arguments
|
|
360
|
-
const commandName =
|
|
361
|
-
const argumentText =
|
|
406
|
+
const commandName = commandText.slice(1, spaceIndex); // Command without "/"
|
|
407
|
+
const argumentText = commandText.slice(spaceIndex + 1); // Text after space
|
|
362
408
|
|
|
363
409
|
const command = this.#commands.find(cmd => commandMatchesNameOrAlias(cmd, commandName));
|
|
364
410
|
if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
|
|
@@ -416,13 +462,12 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
416
462
|
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
|
417
463
|
const afterCursor = currentLine.slice(cursorCol);
|
|
418
464
|
|
|
419
|
-
const slashStart = textBeforeCursor
|
|
420
|
-
const hasOnlyWhitespaceBeforeSlash = slashStart >= 0 && textBeforeCursor.slice(0, slashStart).trim() === "";
|
|
465
|
+
const slashStart = findLeadingSlashCommandStart(textBeforeCursor);
|
|
421
466
|
|
|
422
467
|
// Slash command suggestions can be accepted before the debounced refresh
|
|
423
468
|
// catches up to newly typed characters. Replace the live command token,
|
|
424
469
|
// not only the prefix captured when the suggestion list was rendered.
|
|
425
|
-
if (prefix
|
|
470
|
+
if (findLeadingSlashCommandStart(prefix) !== null && slashStart !== null) {
|
|
426
471
|
const slashPrefix = textBeforeCursor.slice(slashStart);
|
|
427
472
|
if (!slashPrefix.includes(" ") && !slashPrefix.slice(1).includes("/")) {
|
|
428
473
|
const beforeSlash = currentLine.slice(0, slashStart);
|
|
@@ -854,13 +899,15 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
854
899
|
const currentLine = lines[cursorLine] || "";
|
|
855
900
|
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
|
856
901
|
|
|
857
|
-
|
|
902
|
+
const slashStart = findLeadingSlashCommandStart(textBeforeCursor);
|
|
903
|
+
if (slashStart === null) return null;
|
|
858
904
|
|
|
859
|
-
const
|
|
905
|
+
const commandText = textBeforeCursor.slice(slashStart);
|
|
906
|
+
const spaceIndex = commandText.indexOf(" ");
|
|
860
907
|
if (spaceIndex === -1) return null;
|
|
861
908
|
|
|
862
|
-
const commandName =
|
|
863
|
-
const argumentText =
|
|
909
|
+
const commandName = commandText.slice(1, spaceIndex);
|
|
910
|
+
const argumentText = commandText.slice(spaceIndex + 1);
|
|
864
911
|
|
|
865
912
|
const command = this.#commands.find(cmd => commandMatchesNameOrAlias(cmd, commandName));
|
|
866
913
|
|
|
@@ -871,16 +918,20 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
|
|
871
918
|
return command.getInlineHint(argumentText);
|
|
872
919
|
}
|
|
873
920
|
trySyncSlashCompletion(textBeforeCursor: string): { items: AutocompleteItem[]; prefix: string } | null {
|
|
874
|
-
|
|
875
|
-
if (
|
|
876
|
-
|
|
921
|
+
const slashStart = findLeadingSlashCommandStart(textBeforeCursor);
|
|
922
|
+
if (slashStart === null) return null;
|
|
923
|
+
const commandText = textBeforeCursor.slice(slashStart);
|
|
924
|
+
if (commandText.length <= 1) return null; // Bare "/" alone, don't auto-complete
|
|
925
|
+
if (commandText.includes(" ")) return null; // Only complete command name, not args
|
|
877
926
|
|
|
878
|
-
const prefix =
|
|
927
|
+
const prefix = commandText.slice(1);
|
|
879
928
|
const lowerPrefix = prefix.toLowerCase();
|
|
880
929
|
|
|
881
930
|
const matches = buildSlashCommandCompletions(this.#commands, lowerPrefix);
|
|
882
931
|
|
|
883
932
|
if (matches.length === 0) return null;
|
|
933
|
+
// Mirror `getSuggestions`: preserve leading whitespace so the editor's
|
|
934
|
+
// sync apply path passes the full text-before-cursor through.
|
|
884
935
|
return { items: matches, prefix: textBeforeCursor };
|
|
885
936
|
}
|
|
886
937
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
type AutocompleteProvider,
|
|
4
|
+
type CombinedAutocompleteProvider,
|
|
5
|
+
findLeadingSlashCommandStart,
|
|
6
|
+
} from "../autocomplete";
|
|
3
7
|
import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
|
|
4
8
|
import { getKeybindings, type KeybindingsManager } from "../keybindings";
|
|
5
9
|
import { extractPrintableText, matchesKey } from "../keys";
|
|
@@ -1116,7 +1120,10 @@ export class Editor implements Component, Focusable {
|
|
|
1116
1120
|
}
|
|
1117
1121
|
|
|
1118
1122
|
// If Enter was pressed on a slash command, apply completion and submit
|
|
1119
|
-
if (
|
|
1123
|
+
if (
|
|
1124
|
+
(kb.matches(data, "tui.input.submit") || data === "\n") &&
|
|
1125
|
+
findLeadingSlashCommandStart(this.#autocompletePrefix) !== null
|
|
1126
|
+
) {
|
|
1120
1127
|
// Check for stale autocomplete state due to debounce
|
|
1121
1128
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1122
1129
|
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
@@ -1263,7 +1270,7 @@ export class Editor implements Component, Focusable {
|
|
|
1263
1270
|
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
1264
1271
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
1265
1272
|
if (
|
|
1266
|
-
textBeforeCursor
|
|
1273
|
+
findLeadingSlashCommandStart(textBeforeCursor) !== null &&
|
|
1267
1274
|
this.#isInSubmittedSlashCommandContext() &&
|
|
1268
1275
|
this.#autocompleteProvider?.trySyncSlashCompletion
|
|
1269
1276
|
) {
|
|
@@ -32,7 +32,43 @@ function isOsc66Line(line: string): boolean {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
function normalizeHtmlEntitiesForTerminal(raw: string): string {
|
|
35
|
-
|
|
35
|
+
const parseCodePoint = (value: number): string => {
|
|
36
|
+
if (Number.isFinite(value) && value >= 0 && value <= 0x10ffff) {
|
|
37
|
+
try {
|
|
38
|
+
return String.fromCodePoint(value);
|
|
39
|
+
} catch (_) {
|
|
40
|
+
// Fallback to empty string or original if invalid codepoint
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return raw.replace(/&(amp|lt|gt|quot|apos|nbsp|#\d+|#x[0-9a-fA-F]+);/gi, (match, entity) => {
|
|
47
|
+
const lower = entity.toLowerCase();
|
|
48
|
+
switch (lower) {
|
|
49
|
+
case "nbsp":
|
|
50
|
+
return " ";
|
|
51
|
+
case "lt":
|
|
52
|
+
return "<";
|
|
53
|
+
case "gt":
|
|
54
|
+
return ">";
|
|
55
|
+
case "quot":
|
|
56
|
+
return '"';
|
|
57
|
+
case "apos":
|
|
58
|
+
return "'";
|
|
59
|
+
case "amp":
|
|
60
|
+
return "&";
|
|
61
|
+
default: {
|
|
62
|
+
if (lower.startsWith("#x")) {
|
|
63
|
+
return parseCodePoint(Number.parseInt(lower.slice(2), 16));
|
|
64
|
+
}
|
|
65
|
+
if (lower.startsWith("#")) {
|
|
66
|
+
return parseCodePoint(Number(lower.slice(1)));
|
|
67
|
+
}
|
|
68
|
+
return match;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
36
72
|
}
|
|
37
73
|
|
|
38
74
|
interface HtmlListState {
|
|
@@ -50,7 +86,7 @@ function createHtmlNormalizationState(): HtmlNormalizationState {
|
|
|
50
86
|
return { lists: [], openItems: [], itemHasContent: [] };
|
|
51
87
|
}
|
|
52
88
|
|
|
53
|
-
const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li)\b(?:\s[^>]*)?\s*\/?>/gi;
|
|
89
|
+
const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li|span|text)\b(?:\s[^>]*)?\s*\/?>/gi;
|
|
54
90
|
|
|
55
91
|
function htmlTagName(tag: string): string {
|
|
56
92
|
const match = /^<\/?\s*([A-Za-z][A-Za-z0-9:-]*)/.exec(tag);
|
|
@@ -96,22 +132,28 @@ function normalizeHtmlForTerminal(raw: string, state: HtmlNormalizationState = c
|
|
|
96
132
|
const tag = match[0];
|
|
97
133
|
const index = match.index ?? 0;
|
|
98
134
|
const textBeforeTag = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex, index));
|
|
135
|
+
const name = htmlTagName(tag);
|
|
136
|
+
// Every tag handled here is block-level EXCEPT span and text. For block-level tags,
|
|
99
137
|
// HTML formatting whitespace between block/list tags (e.g. the newlines and
|
|
100
138
|
// indentation in pretty-printed `<ul>\n <li>…`) is not rendered content;
|
|
101
139
|
// appending it literally would leak source indentation before bullets and
|
|
102
|
-
// blank rows between items.
|
|
103
|
-
//
|
|
104
|
-
|
|
140
|
+
// blank rows between items. A whitespace-only slice is always insignificant formatting
|
|
141
|
+
// and is dropped. But for inline tags like span and text, surrounding whitespace
|
|
142
|
+
// is significant and must NOT be dropped.
|
|
143
|
+
const isInlineTag = name === "span" || name === "text";
|
|
144
|
+
if (isInlineTag || textBeforeTag.trim() !== "") {
|
|
105
145
|
output += textBeforeTag;
|
|
106
146
|
markCurrentHtmlItemContent(state, textBeforeTag);
|
|
107
147
|
}
|
|
108
148
|
lastIndex = index + tag.length;
|
|
109
149
|
|
|
110
|
-
const name = htmlTagName(tag);
|
|
111
150
|
const isClosing = /^<\//.test(tag);
|
|
112
151
|
const isSelfClosing = /\/\s*>$/.test(tag);
|
|
113
152
|
|
|
114
153
|
switch (name) {
|
|
154
|
+
case "span":
|
|
155
|
+
case "text":
|
|
156
|
+
break;
|
|
115
157
|
case "br":
|
|
116
158
|
output = appendHtmlLineBreak(output, true);
|
|
117
159
|
break;
|