@oh-my-pi/pi-tui 15.1.2 → 15.1.4

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.1.4] - 2026-05-19
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `renderInlineMarkdown` crashing with `TypeError: undefined is not an object (evaluating 'e.replace')` when called with a non-string value during streaming — partial JSON parsing leaves option label fields temporarily unpopulated, causing the ask tool renderer to fail. ([#1176](https://github.com/can1357/oh-my-pi/issues/1176))
10
+
5
11
  ## [15.0.2] - 2026-05-15
6
12
 
7
13
  ### Added
@@ -35,6 +35,18 @@ export interface AutocompleteProvider {
35
35
  items: AutocompleteItem[];
36
36
  prefix: string;
37
37
  } | null;
38
+ /**
39
+ * Synchronously try to expand text immediately before the cursor (no async I/O).
40
+ * Called after every single-character insert. Implementations MUST cheaply
41
+ * early-return when the trailing context cannot trigger them.
42
+ * Returns the number of characters to delete immediately before the cursor
43
+ * and the literal string to insert in their place, or null to leave the
44
+ * buffer untouched.
45
+ */
46
+ trySyncInlineReplace?(textBeforeCursor: string): {
47
+ replaceLen: number;
48
+ insert: string;
49
+ } | null;
38
50
  }
39
51
  export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
40
52
  #private;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "15.1.2",
4
+ "version": "15.1.4",
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": "15.1.2",
41
- "@oh-my-pi/pi-utils": "15.1.2",
40
+ "@oh-my-pi/pi-natives": "15.1.4",
41
+ "@oh-my-pi/pi-utils": "15.1.4",
42
42
  "lru-cache": "11.3.6",
43
43
  "marked": "^18.0.3"
44
44
  },
@@ -199,6 +199,15 @@ export interface AutocompleteProvider {
199
199
  /** Synchronously try to complete a slash command at the start of a line (no async I/O). */
200
200
  /** Returns matched items and the full prefix, or null if not applicable. */
201
201
  trySyncSlashCompletion?(textBeforeCursor: string): { items: AutocompleteItem[]; prefix: string } | null;
202
+ /**
203
+ * Synchronously try to expand text immediately before the cursor (no async I/O).
204
+ * Called after every single-character insert. Implementations MUST cheaply
205
+ * early-return when the trailing context cannot trigger them.
206
+ * Returns the number of characters to delete immediately before the cursor
207
+ * and the literal string to insert in their place, or null to leave the
208
+ * buffer untouched.
209
+ */
210
+ trySyncInlineReplace?(textBeforeCursor: string): { replaceLen: number; insert: string } | null;
202
211
  }
203
212
 
204
213
  // Combined provider that handles both slash commands and file paths.
@@ -985,6 +985,7 @@ export class Editor implements Component, Focusable {
985
985
  this.#setCursorCol(result.cursorCol);
986
986
 
987
987
  this.#cancelAutocomplete();
988
+ this.onAutocompleteUpdate?.();
988
989
 
989
990
  if (this.onChange) {
990
991
  this.onChange(this.getText());
@@ -1044,6 +1045,7 @@ export class Editor implements Component, Focusable {
1044
1045
  this.#setCursorCol(result.cursorCol);
1045
1046
 
1046
1047
  this.#cancelAutocomplete();
1048
+ this.onAutocompleteUpdate?.();
1047
1049
 
1048
1050
  if (this.onChange) {
1049
1051
  this.onChange(this.getText());
@@ -1493,6 +1495,29 @@ export class Editor implements Component, Focusable {
1493
1495
  this.onChange(this.getText());
1494
1496
  }
1495
1497
 
1498
+ // Synchronous inline replacement (e.g. emoji shortcodes `:joy:` → 😂).
1499
+ // Runs before autocomplete trigger so the popup doesn't briefly chase a
1500
+ // prefix that's about to be rewritten.
1501
+ if (char.length === 1 && this.#autocompleteProvider?.trySyncInlineReplace) {
1502
+ const replaceLine = this.#state.lines[this.#state.cursorLine] || "";
1503
+ const textBeforeCursor = replaceLine.slice(0, this.#state.cursorCol);
1504
+ const replacement = this.#autocompleteProvider.trySyncInlineReplace(textBeforeCursor);
1505
+ if (replacement) {
1506
+ const before = replaceLine.slice(0, this.#state.cursorCol - replacement.replaceLen);
1507
+ const after = replaceLine.slice(this.#state.cursorCol);
1508
+ this.#state.lines[this.#state.cursorLine] = before + replacement.insert + after;
1509
+ this.#setCursorCol(before.length + replacement.insert.length);
1510
+ if (this.onChange) {
1511
+ this.onChange(this.getText());
1512
+ }
1513
+ if (this.#autocompleteState) {
1514
+ this.#cancelAutocomplete();
1515
+ this.onAutocompleteUpdate?.();
1516
+ }
1517
+ return;
1518
+ }
1519
+ }
1520
+
1496
1521
  // Check if we should trigger or update autocomplete
1497
1522
  if (!this.#autocompleteState) {
1498
1523
  // Auto-trigger for "/" at the start of a line (slash commands)
@@ -1529,6 +1554,10 @@ export class Editor implements Component, Focusable {
1529
1554
  else if (textBeforeCursor.match(/#[^\s#]*$/)) {
1530
1555
  this.#tryTriggerAutocomplete();
1531
1556
  }
1557
+ // Check if we're in a :emoji shortcode context
1558
+ else if (textBeforeCursor.match(/(?:^|[\s([{>]):[a-zA-Z0-9_+-]*$/)) {
1559
+ this.#tryTriggerAutocomplete();
1560
+ }
1532
1561
  }
1533
1562
  } else {
1534
1563
  this.#debouncedUpdateAutocomplete();
@@ -47,14 +47,16 @@ export function clearRenderCache(): void {
47
47
  }
48
48
 
49
49
  // Stable numeric IDs for structural theme/style objects (no ID field on type).
50
- // WeakMap so GC can collect orphaned themes/styles without a leak.
51
- const objectIds = new WeakMap<object, number>();
50
+ // Symbol-keyed so the id travels with the object and is invisible to consumers.
51
+ const kObjectId = Symbol("markdown.objectId");
52
+ type WithObjectId = object & { [kObjectId]?: number };
52
53
  let nextObjectId = 0;
53
54
  function objectId(o: object): number {
54
- let id = objectIds.get(o);
55
+ const tagged = o as WithObjectId;
56
+ let id = tagged[kObjectId];
55
57
  if (id === undefined) {
56
58
  id = nextObjectId++;
57
- objectIds.set(o, id);
59
+ tagged[kObjectId] = id;
58
60
  }
59
61
  return id;
60
62
  }
@@ -944,6 +946,8 @@ export class Markdown implements Component {
944
946
  * Unlike the full Markdown component, this produces a single line with no block-level elements.
945
947
  */
946
948
  export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseColor?: (t: string) => string): string {
949
+ // Guard against undefined/null during streaming — partial JSON can leave fields unpopulated.
950
+ if (typeof text !== "string") return (baseColor ?? (t => t))(text != null ? String(text) : "");
947
951
  const tokens = marked.lexer(text);
948
952
  const applyText = baseColor ?? ((t: string) => t);
949
953
  let result = "";
package/src/terminal.ts CHANGED
@@ -591,6 +591,9 @@ export class ProcessTerminal implements Terminal {
591
591
 
592
592
  #safeWrite(data: string): void {
593
593
  if (this.#dead) return;
594
+ // Skip control sequences when stdout isn't a TTY (piped output, tests, log
595
+ // files). They serve no purpose there and would surface as visible noise.
596
+ if (!process.stdout.isTTY) return;
594
597
  try {
595
598
  process.stdout.write(data);
596
599
  } catch (err) {