@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 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 (`&lt;`, `&gt;`, `&quot;`, `&apos;`, `&amp;`), 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 { AutocompleteProvider } from "../autocomplete";
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.6",
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.6",
41
- "@oh-my-pi/pi-utils": "16.1.6",
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
  },
@@ -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 desc = cmd.description ?? "";
249
- 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
+ };
250
287
  const candidates: Array<AutocompleteItem & { score: number }> = [];
251
288
 
252
- const nameScore = scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
253
- const lowerDesc = desc.toLowerCase();
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
- // Check for slash commands
342
- if (textBeforeCursor.startsWith("/")) {
343
- const spaceIndex = textBeforeCursor.indexOf(" ");
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 = textBeforeCursor.slice(1); // Remove the "/"
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 = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
361
- const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
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.indexOf("/");
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.startsWith("/") && hasOnlyWhitespaceBeforeSlash) {
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
- if (!textBeforeCursor.startsWith("/")) return null;
902
+ const slashStart = findLeadingSlashCommandStart(textBeforeCursor);
903
+ if (slashStart === null) return null;
858
904
 
859
- const spaceIndex = textBeforeCursor.indexOf(" ");
905
+ const commandText = textBeforeCursor.slice(slashStart);
906
+ const spaceIndex = commandText.indexOf(" ");
860
907
  if (spaceIndex === -1) return null;
861
908
 
862
- const commandName = textBeforeCursor.slice(1, spaceIndex);
863
- const argumentText = textBeforeCursor.slice(spaceIndex + 1);
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
- if (!textBeforeCursor.startsWith("/")) return null;
875
- if (textBeforeCursor.length <= 1) return null; // Bare "/" alone, don't auto-complete
876
- if (textBeforeCursor.includes(" ")) return null; // Only complete command name, not args
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 = textBeforeCursor.slice(1);
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
  }
@@ -1,5 +1,9 @@
1
1
  import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
2
- import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
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 ((kb.matches(data, "tui.input.submit") || data === "\n") && this.#autocompletePrefix.startsWith("/")) {
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.startsWith("/") &&
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
- 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;