@oh-my-pi/pi-tui 16.1.7 → 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 +10 -0
- package/dist/types/autocomplete.d.ts +2 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +30 -3
- package/src/components/markdown.ts +48 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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
|
+
|
|
5
15
|
## [16.1.7] - 2026-06-20
|
|
6
16
|
|
|
7
17
|
### Fixed
|
|
@@ -18,6 +18,8 @@ export interface SlashCommand {
|
|
|
18
18
|
aliases?: string[];
|
|
19
19
|
description?: string;
|
|
20
20
|
argumentHint?: string;
|
|
21
|
+
/** Dynamic display-only description for slash-command autocomplete. Must be synchronous and side-effect free. */
|
|
22
|
+
getAutocompleteDescription?: () => string | undefined;
|
|
21
23
|
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
|
|
22
24
|
/** Return inline hint text for the current argument state (shown as dim ghost text after cursor) */
|
|
23
25
|
getInlineHint?(argumentText: string): string | null;
|
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
|
@@ -175,6 +175,8 @@ export interface SlashCommand {
|
|
|
175
175
|
aliases?: string[];
|
|
176
176
|
description?: string;
|
|
177
177
|
argumentHint?: string;
|
|
178
|
+
/** Dynamic display-only description for slash-command autocomplete. Must be synchronous and side-effect free. */
|
|
179
|
+
getAutocompleteDescription?: () => string | undefined;
|
|
178
180
|
// Function to get argument completions for this command
|
|
179
181
|
// Returns null if no argument completion is available
|
|
180
182
|
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
|
|
@@ -234,6 +236,17 @@ function getCommandAliases(cmd: CommandEntry): string[] {
|
|
|
234
236
|
return cmd.aliases.filter(alias => typeof alias === "string" && alias.length > 0);
|
|
235
237
|
}
|
|
236
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
|
+
|
|
237
250
|
function commandMatchesNameOrAlias(cmd: CommandEntry, commandName: string): boolean {
|
|
238
251
|
const name = getCommandName(cmd);
|
|
239
252
|
if (name === commandName) return true;
|
|
@@ -257,18 +270,31 @@ function buildSlashCommandCompletions(commands: CommandEntry[], lowerPrefix: str
|
|
|
257
270
|
const name = getCommandName(cmd);
|
|
258
271
|
if (!name) return [];
|
|
259
272
|
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
|
|
260
|
-
const
|
|
261
|
-
|
|
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
|
+
};
|
|
262
287
|
const candidates: Array<AutocompleteItem & { score: number }> = [];
|
|
263
288
|
|
|
264
289
|
const isSkillCommand = name.startsWith("skill:");
|
|
265
290
|
const nameScore =
|
|
266
291
|
lowerPrefix.length === 0 && isSkillCommand ? 950 : scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
|
|
267
|
-
const lowerDesc =
|
|
292
|
+
const lowerDesc = staticDesc.toLowerCase();
|
|
268
293
|
const descScore =
|
|
269
294
|
lowerDesc && fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
|
|
270
295
|
const primaryScore = Math.max(nameScore, descScore);
|
|
271
296
|
if (primaryScore > 0) {
|
|
297
|
+
const fullDesc = resolveFullDesc();
|
|
272
298
|
candidates.push({
|
|
273
299
|
value: name,
|
|
274
300
|
label: "name" in cmd ? cmd.name : cmd.label,
|
|
@@ -282,6 +308,7 @@ function buildSlashCommandCompletions(commands: CommandEntry[], lowerPrefix: str
|
|
|
282
308
|
if (alias === name) continue;
|
|
283
309
|
const aliasScore = scoreCommandTextMatch(lowerPrefix, alias.toLowerCase());
|
|
284
310
|
if (aliasScore === 0) continue;
|
|
311
|
+
const fullDesc = resolveFullDesc();
|
|
285
312
|
candidates.push({
|
|
286
313
|
value: alias,
|
|
287
314
|
label: alias,
|
|
@@ -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;
|