@oh-my-pi/pi-tui 16.1.7 → 16.1.9

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 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 (`&lt;`, `&gt;`, `&quot;`, `&apos;`, `&amp;`), 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.7",
4
+ "version": "16.1.9",
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.7",
41
- "@oh-my-pi/pi-utils": "16.1.7",
40
+ "@oh-my-pi/pi-natives": "16.1.9",
41
+ "@oh-my-pi/pi-utils": "16.1.9",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -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 desc = cmd.description ?? "";
261
- const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
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 = desc.toLowerCase();
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
- return raw.replace(/&nbsp;/gi, " ");
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. Every tag handled here is block-level, so a
103
- // whitespace-only slice is always insignificant formatting and is dropped.
104
- if (textBeforeTag.trim() !== "") {
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;