@oh-my-pi/pi-tui 16.3.11 → 16.3.12

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.3.12] - 2026-07-08
6
+
7
+ ### Fixed
8
+
9
+ - Fixed selector rendering when a legacy theme omits symbol settings by falling back to an ASCII cursor instead of crashing ([#4745](https://github.com/can1357/oh-my-pi/issues/4745)).
10
+ - Kept slash command autocomplete rows compact by truncating descriptions instead of wrapping them into multi-line blocks.
11
+ - Fixed mid-prompt skill autocomplete so Tab and Enter accept the highlighted `/skill:<name>` suggestion and Backspace dismisses the popup immediately after removing the triggering slash ([#4619](https://github.com/can1357/oh-my-pi/issues/4619)).
12
+ - Fixed submitted slash-command arguments treating `@` file-reference tokens as prompt-composer autocomplete triggers when the command does not define argument completions. ([#4600](https://github.com/can1357/oh-my-pi/issues/4600))
13
+ - Fixed box-drawing tree lines (`├── item` — directory layouts, decision trees) in prose shearing apart when they wrap: continuation rows now hang under the node text with ancestor rails carried through (`├` → `│`, `└` → blank) instead of restarting at column 0. Applies to prose paragraphs (including inside blockquotes) only when a line with a branch-connector prefix (`├──`, `└─`, …) actually overflows; fitting lines, non-tree prose, and code blocks render byte-for-byte as before.
14
+
5
15
  ## [16.3.10] - 2026-07-06
6
16
 
7
17
  ### Fixed
@@ -73,6 +73,7 @@ export interface AutocompleteProvider {
73
73
  shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;
74
74
  }
75
75
  type CommandEntry = SlashCommand | AutocompleteItem;
76
+ export declare function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number;
76
77
  export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
77
78
  #private;
78
79
  constructor(commands?: CommandEntry[], basePath?: string);
@@ -251,6 +251,8 @@ export declare class Container implements Component {
251
251
  addChild(component: Component): void;
252
252
  removeChild(component: Component): void;
253
253
  clear(): void;
254
+ /** Dispose every child, then detach it from this container. */
255
+ disposeChildren(): void;
254
256
  invalidate(): void;
255
257
  /**
256
258
  * Propagate teardown to children. Call when the container's children are
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.3.11",
4
+ "version": "16.3.12",
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.3.11",
41
- "@oh-my-pi/pi-utils": "16.3.11",
40
+ "@oh-my-pi/pi-natives": "16.3.12",
41
+ "@oh-my-pi/pi-utils": "16.3.12",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -276,7 +276,7 @@ function commandMatchesNameOrAlias(cmd: CommandEntry, commandName: string): bool
276
276
  return getCommandAliases(cmd).includes(commandName);
277
277
  }
278
278
 
279
- function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number {
279
+ export function scoreCommandTextMatch(lowerPrefix: string, lowerTarget: string): number {
280
280
  if (lowerPrefix.length === 0) return 1;
281
281
  if (lowerPrefix === lowerTarget) return 1000;
282
282
  // Flat score for every prefix match so same-prefix commands keep registry
@@ -3,6 +3,7 @@ import {
3
3
  type AutocompleteProvider,
4
4
  findLeadingSlashCommandStart,
5
5
  findTrailingSlashCommandStart,
6
+ scoreCommandTextMatch,
6
7
  } from "../autocomplete";
7
8
  import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
8
9
  import { getKeybindings, type KeybindingsManager } from "../keybindings";
@@ -21,7 +22,7 @@ import {
21
22
  truncateToWidth,
22
23
  visibleWidth,
23
24
  } from "../utils";
24
- import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
25
+ import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
25
26
 
26
27
  const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
27
28
  overflowSearch: false,
@@ -31,7 +32,6 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
31
32
  minPrimaryColumnWidth: 12,
32
33
  maxPrimaryColumnWidth: 32,
33
34
  overflowSearch: false,
34
- wrapDescription: true,
35
35
  };
36
36
 
37
37
  function sanitizeLoadedText(text: string): string {
@@ -1119,16 +1119,16 @@ export class Editor implements Component, Focusable {
1119
1119
 
1120
1120
  // If Tab was pressed, always apply the selection
1121
1121
  if (kb.matches(data, "tui.input.tab")) {
1122
+ const selected = this.#autocompleteList.getSelectedItem();
1122
1123
  // Check for stale autocomplete state due to buffer edits since last refresh
1123
1124
  // (destructive keys or paste can outrun the debounced update).
1124
1125
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1125
1126
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1126
- if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
1127
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1127
1128
  // Autocomplete is stale - silently cancel; Tab has no fallback action here.
1128
1129
  this.#cancelAutocomplete();
1129
1130
  return;
1130
1131
  }
1131
- const selected = this.#autocompleteList.getSelectedItem();
1132
1132
  if (selected && this.#autocompleteProvider) {
1133
1133
  const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
1134
1134
  const result = this.#autocompleteProvider.applyCompletion(
@@ -1166,14 +1166,14 @@ export class Editor implements Component, Focusable {
1166
1166
  findLeadingSlashCommandStart(this.#autocompletePrefix) !== null &&
1167
1167
  !this.#selectedCompletionIsPath()
1168
1168
  ) {
1169
+ const selected = this.#autocompleteList.getSelectedItem();
1169
1170
  // Check for stale autocomplete state due to debounce
1170
1171
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1171
1172
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1172
- if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
1173
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1173
1174
  // Autocomplete is stale - cancel and fall through to normal submission
1174
1175
  this.#cancelAutocomplete();
1175
1176
  } else {
1176
- const selected = this.#autocompleteList.getSelectedItem();
1177
1177
  if (selected && this.#autocompleteProvider) {
1178
1178
  const result = this.#autocompleteProvider.applyCompletion(
1179
1179
  this.#state.lines,
@@ -1194,14 +1194,14 @@ export class Editor implements Component, Focusable {
1194
1194
  }
1195
1195
  // If Enter was pressed on a file path, apply completion
1196
1196
  else if (kb.matches(data, "tui.input.submit") || data === "\n") {
1197
+ const selected = this.#autocompleteList.getSelectedItem();
1197
1198
  // Check for stale autocomplete state due to buffer edits since last refresh.
1198
1199
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1199
1200
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1200
- if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor)) {
1201
+ if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1201
1202
  // Autocomplete is stale - cancel and fall through to normal submission
1202
1203
  this.#cancelAutocomplete();
1203
1204
  } else {
1204
- const selected = this.#autocompleteList.getSelectedItem();
1205
1205
  if (selected && this.#autocompleteProvider) {
1206
1206
  const result = this.#autocompleteProvider.applyCompletion(
1207
1207
  this.#state.lines,
@@ -2070,8 +2070,15 @@ export class Editor implements Component, Focusable {
2070
2070
  this.#resetKillSequence();
2071
2071
  this.#recordUndoState();
2072
2072
 
2073
+ let removedMidPromptSlashTrigger = false;
2074
+
2073
2075
  if (this.#state.cursorCol > 0) {
2074
2076
  const line = this.#state.lines[this.#state.cursorLine] || "";
2077
+ const textBeforeCursor = line.slice(0, this.#state.cursorCol);
2078
+ const trailingSlashStart = findTrailingSlashCommandStart(textBeforeCursor);
2079
+ removedMidPromptSlashTrigger =
2080
+ trailingSlashStart === this.#state.cursorCol - 1 &&
2081
+ (!this.#hasOnlyWhitespaceBeforeCursorLine() || textBeforeCursor.slice(0, trailingSlashStart).trim() !== "");
2075
2082
  // An atomic placeholder token (image/paste marker) deletes as a unit, so a single
2076
2083
  // backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text.
2077
2084
  const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1);
@@ -2111,7 +2118,12 @@ export class Editor implements Component, Focusable {
2111
2118
 
2112
2119
  // Update or re-trigger autocomplete after backspace
2113
2120
  if (this.#autocompleteState) {
2114
- this.#debouncedUpdateAutocomplete();
2121
+ if (removedMidPromptSlashTrigger) {
2122
+ this.#cancelAutocomplete();
2123
+ this.onAutocompleteUpdate?.();
2124
+ } else {
2125
+ this.#debouncedUpdateAutocomplete();
2126
+ }
2115
2127
  } else {
2116
2128
  // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
2117
2129
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
@@ -2881,13 +2893,36 @@ export class Editor implements Component, Focusable {
2881
2893
  * engages for command-shaped selections: absolute-path completions (`/tmp/fo`
2882
2894
  * via the no-command-match fall-through) share the leading-slash prefix shape
2883
2895
  * but must use the live-suffix path rule so the apply slice stays anchored.
2896
+ * - Mid-prompt skill branch re-anchors when the popup item is a skill and the
2897
+ * current text still ends in a trailing slash token, matching the provider's
2898
+ * mid-prompt replacement branch.
2884
2899
  * - `@`-file branch re-anchors via `#extractAtPrefix`; safe when the current text
2885
2900
  * still ends in a whitespace-anchored `@<token>`.
2886
2901
  * - Everything else is stale — accepting it would corrupt the buffer (issue #4295).
2887
2902
  */
2888
- #autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string): boolean {
2903
+ #autocompletePrefixMatchesCursorText(currentTextBeforeCursor: string, item?: SelectItem | null): boolean {
2889
2904
  if (currentTextBeforeCursor === this.#autocompletePrefix) return true;
2890
2905
 
2906
+ if (item?.value.startsWith("skill:") && findTrailingSlashCommandStart(this.#autocompletePrefix) !== null) {
2907
+ const currentTrailingStart = findTrailingSlashCommandStart(currentTextBeforeCursor);
2908
+ if (currentTrailingStart !== null) {
2909
+ const token = currentTextBeforeCursor.slice(currentTrailingStart);
2910
+ if (!token.includes(" ") && !token.slice(1).includes("/")) {
2911
+ // Guard the timing window where the popup was built for an earlier
2912
+ // query (e.g. bare `/`) and the user typed further characters before
2913
+ // the 100 ms debounced refresh fired: accept the stale skill only
2914
+ // when the current query would still surface it. `tmp` after a bare
2915
+ // slash therefore falls through to file completion instead of
2916
+ // rewriting the user's `/tmp` to `/skill:…`.
2917
+ const lowerToken = token.slice(1).toLowerCase();
2918
+ if (scoreCommandTextMatch(lowerToken, item.value.toLowerCase()) > 0) return true;
2919
+ if (item.description && scoreCommandTextMatch(lowerToken, item.description.toLowerCase()) > 0)
2920
+ return true;
2921
+ }
2922
+ }
2923
+ return false;
2924
+ }
2925
+
2891
2926
  if (findLeadingSlashCommandStart(this.#autocompletePrefix) !== null && !this.#selectedCompletionIsPath()) {
2892
2927
  const currentLeadingStart = findLeadingSlashCommandStart(currentTextBeforeCursor);
2893
2928
  if (currentLeadingStart !== null) {
@@ -251,6 +251,155 @@ function splitTerminalLines(text: string): string[] {
251
251
  return lines;
252
252
  }
253
253
 
254
+ // ---------------------------------------------------------------------------
255
+ // Tree-guide hanging wrap
256
+ //
257
+ // Models routinely emit box-drawing trees ("├── item") inside plain
258
+ // paragraphs — directory layouts, decision trees. The lexer sees those lines
259
+ // as ordinary prose, so the generic wrap pass restarts wrapped continuations
260
+ // at column 0 and visually shears the tree apart (doubly fast for CJK text,
261
+ // where every glyph is two cells wide). Mirror the guide semantics of
262
+ // `tree(1)` / rich.tree instead: wrap the node text within the cells that
263
+ // remain after the guide prefix, and indent every continuation row under the
264
+ // node text — branch glyphs swap to their pass-through form (`├` → `│`,
265
+ // `└` → blank) so the rails of still-open ancestors stay visually joined.
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /** Continuation glyph for each guide character a tree prefix may contain. */
269
+ const TREE_GUIDE_CONTINUATION: Record<string, string> = {
270
+ "│": "│",
271
+ "┃": "┃",
272
+ "║": "║",
273
+ "├": "│",
274
+ "┣": "┃",
275
+ "╠": "║",
276
+ "└": " ",
277
+ "┗": " ",
278
+ "╚": " ",
279
+ "╰": " ",
280
+ "─": " ",
281
+ "━": " ",
282
+ "═": " ",
283
+ " ": " ",
284
+ };
285
+
286
+ /** Cheap pre-gate: any guide glyph at all. The structural test is TREE_BRANCH_CONNECTOR_RE. */
287
+ const TREE_GUIDE_ANCHOR_RE = /[│┃║├┣╠└┗╚╰]/;
288
+
289
+ /**
290
+ * A prefix qualifies as tree-shaped only when a branch/corner glyph is
291
+ * immediately followed by a horizontal connector (`├──`, `└─`, `╰──`, …).
292
+ * A lone rail or branch glyph used as prose ("│ is the Unicode vertical box
293
+ * drawing glyph…") never qualifies, so such paragraphs keep the plain wrap.
294
+ */
295
+ const TREE_BRANCH_CONNECTOR_RE = /[├┣╠└┗╚╰][─━═]/;
296
+
297
+ /** Below this many content cells a hanging wrap degenerates; keep the plain wrap. */
298
+ const MIN_TREE_CONTENT_WIDTH = 8;
299
+
300
+ const SGR_SEQUENCE_STICKY = /\x1b\[[0-9;:]*m/y;
301
+ const SGR_SEQUENCE_GLOBAL = /\x1b\[[0-9;:]*m/g;
302
+
303
+ /**
304
+ * Everything before the last full SGR reset is dead state — drop it so the
305
+ * re-played `carry` stays bounded by the paragraph's live style run instead
306
+ * of its whole code history.
307
+ */
308
+ function compactSgrCarry(carry: string): string {
309
+ const shortReset = carry.lastIndexOf("\x1b[m");
310
+ const longReset = carry.lastIndexOf("\x1b[0m");
311
+ const cut = Math.max(shortReset === -1 ? -1 : shortReset + 3, longReset === -1 ? -1 : longReset + 4);
312
+ return cut === -1 ? carry : carry.slice(cut);
313
+ }
314
+
315
+ interface TreeGuidePrefix {
316
+ /** Index of the first char past the guide run (start of the node text). */
317
+ end: number;
318
+ /** SGR sequences interleaved with the guides, in order (zero visible width). */
319
+ codes: string;
320
+ /** Guide characters with SGR stripped, exactly as they appear on screen. */
321
+ guides: string;
322
+ }
323
+
324
+ /**
325
+ * Match the leading box-drawing guide run of a rendered line (e.g. `│ ├── `),
326
+ * tolerating interleaved SGR styling. Returns undefined unless the run
327
+ * contains a branch glyph joined to a horizontal connector and node text
328
+ * follows, so dash art, indented prose, and lone glyphs used as prose are
329
+ * never treated as a tree.
330
+ */
331
+ function matchTreeGuidePrefix(line: string): TreeGuidePrefix | undefined {
332
+ let codes = "";
333
+ let guides = "";
334
+ let i = 0;
335
+ while (i < line.length) {
336
+ if (line.charCodeAt(i) === 0x1b) {
337
+ SGR_SEQUENCE_STICKY.lastIndex = i;
338
+ const match = SGR_SEQUENCE_STICKY.exec(line);
339
+ if (!match) break;
340
+ codes += match[0];
341
+ i = SGR_SEQUENCE_STICKY.lastIndex;
342
+ continue;
343
+ }
344
+ const char = line[i]!;
345
+ if (!(char in TREE_GUIDE_CONTINUATION)) break;
346
+ guides += char;
347
+ i++;
348
+ }
349
+ if (i >= line.length || !TREE_BRANCH_CONNECTOR_RE.test(guides)) return undefined;
350
+ return { end: i, codes, guides };
351
+ }
352
+
353
+ /**
354
+ * Hanging wrap for box-drawing tree lines inside prose block text.
355
+ *
356
+ * Returns undefined when no line needs the treatment, so paragraphs without
357
+ * overflowing tree lines keep their exact current render. When a paragraph
358
+ * does hang, its lines are returned pre-split and style-self-contained: the
359
+ * SGR state open at each line start is re-played onto that line (`carry`),
360
+ * because the caller's wrap pass — which normally carries SGR state across
361
+ * the newlines of a single entry — no longer sees them as one entry.
362
+ */
363
+ function hangWrapTreeGuideLines(text: string, width: number): string[] | undefined {
364
+ if (width < MIN_TREE_CONTENT_WIDTH || !TREE_GUIDE_ANCHOR_RE.test(text)) return undefined;
365
+
366
+ const sourceLines = text.split("\n");
367
+ const hangs = (line: string): TreeGuidePrefix | undefined => {
368
+ if (visibleWidth(line) <= width) return undefined;
369
+ const prefix = matchTreeGuidePrefix(line);
370
+ if (!prefix) return undefined;
371
+ if (width - visibleWidth(prefix.guides) < MIN_TREE_CONTENT_WIDTH) return undefined;
372
+ return prefix;
373
+ };
374
+ if (!sourceLines.some(line => hangs(line) !== undefined)) return undefined;
375
+
376
+ const out: string[] = [];
377
+ let carry = "";
378
+ for (const line of sourceLines) {
379
+ const prefix = hangs(line);
380
+ if (!prefix) {
381
+ out.push(carry ? carry + line : line);
382
+ carry = compactSgrCarry(carry + (line.match(SGR_SEQUENCE_GLOBAL)?.join("") ?? ""));
383
+ continue;
384
+ }
385
+ // Re-play the SGR state ahead of the node text so the wrapper carries
386
+ // it onto every continuation row; the codes are zero-width, so measured
387
+ // row widths are unaffected.
388
+ const activeCodes = carry + prefix.codes;
389
+ const rows = wrapTextWithAnsi(activeCodes + line.slice(prefix.end), width - visibleWidth(prefix.guides));
390
+ let hang = "";
391
+ for (const guide of prefix.guides) hang += TREE_GUIDE_CONTINUATION[guide] ?? " ";
392
+ const hangShortfall = visibleWidth(prefix.guides) - visibleWidth(hang);
393
+ if (hangShortfall > 0) hang += padding(hangShortfall);
394
+ out.push(carry + line.slice(0, prefix.end) + rows[0]!.slice(activeCodes.length));
395
+ for (let i = 1; i < rows.length; i++) {
396
+ out.push(activeCodes + hang + rows[i]!);
397
+ }
398
+ carry = compactSgrCarry(carry + (line.match(SGR_SEQUENCE_GLOBAL)?.join("") ?? ""));
399
+ }
400
+ return out;
401
+ }
402
+
254
403
  class StrictStrikethroughTokenizer extends Tokenizer {
255
404
  override del(src: string): Tokens.Del | undefined {
256
405
  const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
@@ -1383,7 +1532,7 @@ export class Markdown implements Component {
1383
1532
  break;
1384
1533
  }
1385
1534
  const paragraphText = this.#renderInlineTokens(token.tokens || [], styleContext);
1386
- lines.push(paragraphText);
1535
+ lines.push(...(hangWrapTreeGuideLines(paragraphText, width) ?? [paragraphText]));
1387
1536
  // Don't add spacing if next token is space or list
1388
1537
  if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
1389
1538
  lines.push("");
@@ -12,6 +12,8 @@ const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
12
12
  const PRIMARY_COLUMN_GAP = 2;
13
13
  const MIN_DESCRIPTION_WIDTH = 10;
14
14
 
15
+ const DEFAULT_CURSOR_SYMBOL = ">";
16
+
15
17
  function sanitizeSingleLine(text: string): string {
16
18
  return replaceTabs(text)
17
19
  .replace(/[\r\n]+/g, " ")
@@ -372,9 +374,8 @@ export class SelectList implements Component, MouseRoutable {
372
374
  width: number,
373
375
  primaryColumnWidth: number,
374
376
  ): SelectItemLayout {
375
- const prefix = isSelected
376
- ? `${this.theme.symbols.cursor} `
377
- : padding(visibleWidth(this.theme.symbols.cursor) + 1);
377
+ const cursor = this.theme.symbols?.cursor ?? DEFAULT_CURSOR_SYMBOL;
378
+ const prefix = isSelected ? `${cursor} ` : padding(visibleWidth(cursor) + 1);
378
379
  const prefixWidth = visibleWidth(prefix);
379
380
  const descriptionSingleLine = item.description ? sanitizeSingleLine(item.description) : undefined;
380
381
 
package/src/tui.ts CHANGED
@@ -503,6 +503,12 @@ export class Container implements Component {
503
503
  this.#memoLines = undefined;
504
504
  }
505
505
 
506
+ /** Dispose every child, then detach it from this container. */
507
+ disposeChildren(): void {
508
+ this.dispose();
509
+ this.clear();
510
+ }
511
+
506
512
  invalidate(): void {
507
513
  this.#memoLines = undefined;
508
514
  for (const child of this.children) {