@oh-my-pi/pi-tui 16.2.13 → 16.3.2

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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.0] - 2026-07-02
6
+
7
+ ### Fixed
8
+
9
+ - Fixed a potential event loop hang when processing oversized, unterminated terminal escape sequences (OSC/DCS/APC).
10
+ - Fixed an issue where large Windows terminal session restores could get truncated mid-frame during ConPTY full-paint resume.
11
+
5
12
  ## [16.2.13] - 2026-07-01
6
13
 
7
14
  ### Fixed
@@ -591,7 +598,7 @@
591
598
 
592
599
  ### Changed
593
600
 
594
- - Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe ([#1799](https://github.com/can1357/oh-my-pi/issues/1799)).
601
+ - Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe.
595
602
  - Changed synchronized-output defaults to a conservative opt-in profile: DEC 2026 paint wrappers stay disabled for remote/multiplexer/VTE/unknown terminals unless explicitly forced, while the autowrap guards remain active.
596
603
 
597
604
  ### Fixed
@@ -12,6 +12,22 @@ export type PasteResult = {
12
12
  * instead of leaking the printable escape tail into the buffer.
13
13
  */
14
14
  export declare function decodeReencodedPasteControls(text: string): string;
15
+ /**
16
+ * Options for {@link BracketedPasteHandler}.
17
+ */
18
+ export type BracketedPasteHandlerOptions = {
19
+ /**
20
+ * Byte cap for buffered paste content (default: 64 MiB). When exceeded,
21
+ * paste mode is aborted and the accumulated content is delivered as
22
+ * `pasteContent` on the same `process()` call so a lost/corrupted end
23
+ * marker cannot consume unbounded memory. Mirrors `StdinBuffer#abortPaste`
24
+ * — defense in depth for callers that bypass `StdinBuffer` (issue #4073
25
+ * case B). The normal `ProcessTerminal` path re-wraps `StdinBuffer`'s
26
+ * bounded paste with both markers, so this cap only fires on alternate
27
+ * callers.
28
+ */
29
+ byteLimit?: number;
30
+ };
15
31
  /**
16
32
  * Handles bracketed paste mode buffering for terminal input components.
17
33
  *
@@ -21,13 +37,15 @@ export declare function decodeReencodedPasteControls(text: string): string;
21
37
  */
22
38
  export declare class BracketedPasteHandler {
23
39
  #private;
40
+ constructor(options?: BracketedPasteHandlerOptions);
24
41
  /**
25
42
  * Process incoming terminal data for bracketed paste sequences.
26
43
  *
27
44
  * @returns `{ handled: false }` if the data contains no paste sequence and
28
45
  * should be processed normally. `{ handled: true }` if the data was
29
46
  * consumed by paste buffering — `pasteContent` is set when a complete
30
- * paste has been assembled; omitted when still buffering.
47
+ * paste has been assembled (or the byte cap has aborted a runaway
48
+ * buffer); omitted when still buffering.
31
49
  */
32
50
  process(data: string): PasteResult;
33
51
  }
@@ -60,8 +60,24 @@ export declare class Editor implements Component, Focusable {
60
60
  /**
61
61
  * Set custom content for the top border (e.g., status line).
62
62
  * Pass undefined to use the default plain border.
63
+ *
64
+ * Eager: the passed value is cached and reused every frame. Callers that
65
+ * mutate status upstream must recompute and call this again. Prefer
66
+ * {@link setTopBorderProvider} for high-frequency updates — it collapses
67
+ * per-event rebuilds to one per painted frame.
63
68
  */
64
69
  setTopBorder(content: EditorTopBorder | undefined): void;
70
+ /**
71
+ * Install a lazy provider invoked once per editor render with the current
72
+ * `availableWidth`. Overrides any eager content set via {@link setTopBorder}
73
+ * — pass `undefined` to detach and fall back to the eager slot.
74
+ *
75
+ * Use this when the top border derives from state that mutates far faster
76
+ * than the render cadence (session events, streaming, subagent updates).
77
+ * The TUI already throttles renders, so a provider is invoked at most once
78
+ * per frame and never does wasted work between paints.
79
+ */
80
+ setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void;
65
81
  /**
66
82
  * Show or hide the editor border chrome.
67
83
  */
@@ -22,3 +22,11 @@ export declare function fuzzyMatch(query: string, text: string): FuzzyMatch;
22
22
  */
23
23
  export declare function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => string): FuzzyFilterResult<T>[];
24
24
  export declare function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[];
25
+ /**
26
+ * Clear the fuzzy search-index cache. Intended for tests/benchmarks so a fresh
27
+ * cold-start typing session can be measured on demand; not part of the supported
28
+ * TUI API.
29
+ *
30
+ * @internal
31
+ */
32
+ export declare function resetFuzzyIndexCache(): void;
@@ -40,6 +40,8 @@ export declare class TerminalInfo {
40
40
  * the module and so a tmux session attached/detached mid-run is observed.
41
41
  */
42
42
  export declare function isInsideTmux(env?: NodeJS.ProcessEnv): boolean;
43
+ /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
44
+ export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
43
45
  /**
44
46
  * Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
45
47
  * ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips
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.2.13",
4
+ "version": "16.3.2",
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.2.13",
41
- "@oh-my-pi/pi-utils": "16.2.13",
40
+ "@oh-my-pi/pi-natives": "16.3.2",
41
+ "@oh-my-pi/pi-utils": "16.3.2",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.5"
44
44
  },
@@ -40,6 +40,25 @@ export function decodeReencodedPasteControls(text: string): string {
40
40
  .replace(REENCODED_CTRL_XTERM, decodeReencodedCtrlByte);
41
41
  }
42
42
 
43
+ /**
44
+ * Options for {@link BracketedPasteHandler}.
45
+ */
46
+ export type BracketedPasteHandlerOptions = {
47
+ /**
48
+ * Byte cap for buffered paste content (default: 64 MiB). When exceeded,
49
+ * paste mode is aborted and the accumulated content is delivered as
50
+ * `pasteContent` on the same `process()` call so a lost/corrupted end
51
+ * marker cannot consume unbounded memory. Mirrors `StdinBuffer#abortPaste`
52
+ * — defense in depth for callers that bypass `StdinBuffer` (issue #4073
53
+ * case B). The normal `ProcessTerminal` path re-wraps `StdinBuffer`'s
54
+ * bounded paste with both markers, so this cap only fires on alternate
55
+ * callers.
56
+ */
57
+ byteLimit?: number;
58
+ };
59
+
60
+ const DEFAULT_BYTE_LIMIT = 64 * 1024 * 1024;
61
+
43
62
  /**
44
63
  * Handles bracketed paste mode buffering for terminal input components.
45
64
  *
@@ -50,6 +69,11 @@ export function decodeReencodedPasteControls(text: string): string {
50
69
  export class BracketedPasteHandler {
51
70
  #buffer = "";
52
71
  #active = false;
72
+ readonly #byteLimit: number;
73
+
74
+ constructor(options: BracketedPasteHandlerOptions = {}) {
75
+ this.#byteLimit = options.byteLimit ?? DEFAULT_BYTE_LIMIT;
76
+ }
53
77
 
54
78
  /**
55
79
  * Process incoming terminal data for bracketed paste sequences.
@@ -57,7 +81,8 @@ export class BracketedPasteHandler {
57
81
  * @returns `{ handled: false }` if the data contains no paste sequence and
58
82
  * should be processed normally. `{ handled: true }` if the data was
59
83
  * consumed by paste buffering — `pasteContent` is set when a complete
60
- * paste has been assembled; omitted when still buffering.
84
+ * paste has been assembled (or the byte cap has aborted a runaway
85
+ * buffer); omitted when still buffering.
61
86
  */
62
87
  process(data: string): PasteResult {
63
88
  if (data.includes(PASTE_START)) {
@@ -71,14 +96,28 @@ export class BracketedPasteHandler {
71
96
  this.#buffer += data;
72
97
 
73
98
  const endIndex = this.#buffer.indexOf(PASTE_END);
74
- if (endIndex === -1) return { handled: true, remaining: "" };
99
+ if (endIndex !== -1) {
100
+ const pasteContent = this.#buffer.substring(0, endIndex);
101
+ const remaining = this.#buffer.substring(endIndex + PASTE_END.length);
102
+
103
+ this.#buffer = "";
104
+ this.#active = false;
75
105
 
76
- const pasteContent = this.#buffer.substring(0, endIndex);
77
- const remaining = this.#buffer.substring(endIndex + PASTE_END.length);
106
+ return { handled: true, pasteContent, remaining };
107
+ }
78
108
 
79
- this.#buffer = "";
80
- this.#active = false;
109
+ // Byte cap: a lost/corrupted end marker (ssh/tmux truncation) must not
110
+ // consume unbounded memory. Deliver the accumulated bytes so they are
111
+ // neither lost nor held forever, and reset paste mode so subsequent
112
+ // input recovers. See `StdinBuffer#abortPaste` for the sibling recovery
113
+ // semantics inside `StdinBuffer`.
114
+ if (this.#buffer.length > this.#byteLimit) {
115
+ const pasteContent = this.#buffer;
116
+ this.#buffer = "";
117
+ this.#active = false;
118
+ return { handled: true, pasteContent, remaining: "" };
119
+ }
81
120
 
82
- return { handled: true, pasteContent, remaining };
121
+ return { handled: true, remaining: "" };
83
122
  }
84
123
  }
@@ -467,8 +467,12 @@ export class Editor implements Component, Focusable {
467
467
  onAutocompleteCancel?: () => void;
468
468
  disableSubmit: boolean = false;
469
469
 
470
- // Custom top border (for status line integration)
470
+ // Custom top border (for status line integration). Either an eager `content`
471
+ // (set once, reused every frame) or a `provider` that recomputes lazily just
472
+ // before the editor paints — the second form lets the host coalesce
473
+ // per-event rebuilds down to one per rendered frame (see #4145).
471
474
  #topBorderContent?: EditorTopBorder;
475
+ #topBorderProvider?: (availableWidth: number) => EditorTopBorder | undefined;
472
476
  #borderVisible = true;
473
477
 
474
478
  constructor(theme: EditorTheme) {
@@ -483,11 +487,30 @@ export class Editor implements Component, Focusable {
483
487
  /**
484
488
  * Set custom content for the top border (e.g., status line).
485
489
  * Pass undefined to use the default plain border.
490
+ *
491
+ * Eager: the passed value is cached and reused every frame. Callers that
492
+ * mutate status upstream must recompute and call this again. Prefer
493
+ * {@link setTopBorderProvider} for high-frequency updates — it collapses
494
+ * per-event rebuilds to one per painted frame.
486
495
  */
487
496
  setTopBorder(content: EditorTopBorder | undefined): void {
488
497
  this.#topBorderContent = content;
489
498
  }
490
499
 
500
+ /**
501
+ * Install a lazy provider invoked once per editor render with the current
502
+ * `availableWidth`. Overrides any eager content set via {@link setTopBorder}
503
+ * — pass `undefined` to detach and fall back to the eager slot.
504
+ *
505
+ * Use this when the top border derives from state that mutates far faster
506
+ * than the render cadence (session events, streaming, subagent updates).
507
+ * The TUI already throttles renders, so a provider is invoked at most once
508
+ * per frame and never does wasted work between paints.
509
+ */
510
+ setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void {
511
+ this.#topBorderProvider = provider;
512
+ }
513
+
491
514
  /**
492
515
  * Show or hide the editor border chrome.
493
516
  */
@@ -806,8 +829,12 @@ export class Editor implements Component, Focusable {
806
829
  if (borderVisible) {
807
830
  // Render top border: ╭─ [status content] ────────────────╮
808
831
  const topFillWidth = Math.max(0, width - borderWidth * 2);
809
- if (this.#topBorderContent) {
810
- const { content, width: statusWidth } = this.#topBorderContent;
832
+ // Provider (lazy) wins over eager content — a host that installs both
833
+ // wants the coalesced path; falling back to eager keeps existing
834
+ // setTopBorder callers working unchanged.
835
+ const topBorder = this.#topBorderProvider ? this.#topBorderProvider(topFillWidth) : this.#topBorderContent;
836
+ if (topBorder) {
837
+ const { content, width: statusWidth } = topBorder;
811
838
  if (statusWidth <= topFillWidth) {
812
839
  // Status fits - add fill after it
813
840
  const fillWidth = topFillWidth - statusWidth;
package/src/fuzzy.ts CHANGED
@@ -52,7 +52,37 @@ function normalizeForSearch(value: string): string {
52
52
  .replace(/\s+/g, " ");
53
53
  }
54
54
 
55
+ // Module-level memo of the per-text search index. `buildSearchIndex` is a pure
56
+ // function of `text`, but selectors call it once per candidate per keystroke —
57
+ // the same stable candidate list is re-filtered as the user types. Caching the
58
+ // index across keystrokes eliminates the redundant normalize + word-split + Set
59
+ // build on every character. Consumers only read the result, so sharing is safe.
60
+ //
61
+ // Admission is conservative so the cache helps the repeated-filter hot path
62
+ // without paying for one-off text: only short texts are cached (long inputs —
63
+ // pasted prompts, transcripts searched via the message selector — would bloat
64
+ // memory), and admission stops at the cap instead of evicting, so a stream of
65
+ // unique texts (message/session search) can't churn the map.
66
+ const INDEX_CACHE_MAX = 4096;
67
+ const MAX_CACHED_TEXT_LEN = 4096;
68
+ const indexCache = new Map<string, SearchIndex>();
69
+
55
70
  function buildSearchIndex(text: string): SearchIndex {
71
+ // Long inputs (pasted prompts, transcripts) are never cached; bypass the Map
72
+ // entirely so they don't pay a hash lookup on every search.
73
+ if (text.length > MAX_CACHED_TEXT_LEN) return buildUncachedSearchIndex(text);
74
+
75
+ const cached = indexCache.get(text);
76
+ if (cached !== undefined) return cached;
77
+
78
+ const result = buildUncachedSearchIndex(text);
79
+ if (indexCache.size < INDEX_CACHE_MAX) {
80
+ indexCache.set(text, result);
81
+ }
82
+ return result;
83
+ }
84
+
85
+ function buildUncachedSearchIndex(text: string): SearchIndex {
56
86
  const normalized = normalizeForSearch(text);
57
87
  if (normalized.length === 0) {
58
88
  return { normalized, compact: "", compactWordStarts: new Set(), words: [] };
@@ -236,9 +266,22 @@ function scoreToken(token: string, index: SearchIndex): FuzzyMatch {
236
266
  return best;
237
267
  }
238
268
 
239
- export function fuzzyMatch(query: string, text: string): FuzzyMatch {
240
- const normalizedQuery = normalizeForSearch(query);
241
- if (normalizedQuery.length === 0) {
269
+ /** A query normalized and split once, so `fuzzyRank` doesn't re-normalize the
270
+ * same query for every candidate in the list. */
271
+ interface PreparedQuery {
272
+ normalized: string;
273
+ tokens: string[];
274
+ compact: string;
275
+ }
276
+
277
+ function prepareQuery(query: string): PreparedQuery | null {
278
+ const normalized = normalizeForSearch(query);
279
+ if (normalized.length === 0) return null;
280
+ return { normalized, tokens: normalized.split(" "), compact: normalized.replaceAll(" ", "") };
281
+ }
282
+
283
+ function fuzzyMatchCore(pq: PreparedQuery | null, text: string): FuzzyMatch {
284
+ if (pq === null) {
242
285
  return { matches: true, score: 0 };
243
286
  }
244
287
 
@@ -248,20 +291,19 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
248
291
  }
249
292
 
250
293
  let totalScore = 0;
251
- const phraseIndex = index.normalized.indexOf(normalizedQuery);
252
- if (phraseIndex >= 0 && isWordBoundaryPhrase(index.normalized, phraseIndex, normalizedQuery.length)) {
294
+ const phraseIndex = index.normalized.indexOf(pq.normalized);
295
+ if (phraseIndex >= 0 && isWordBoundaryPhrase(index.normalized, phraseIndex, pq.normalized.length)) {
253
296
  totalScore -= PHRASE_BONUS;
254
297
  totalScore += phraseIndex * 0.01;
255
298
  }
256
299
 
257
- const compactQuery = normalizedQuery.replaceAll(" ", "");
258
- const compactPhraseIndex = index.compact.indexOf(compactQuery);
300
+ const compactPhraseIndex = index.compact.indexOf(pq.compact);
259
301
  if (compactPhraseIndex >= 0 && index.compactWordStarts.has(compactPhraseIndex)) {
260
302
  totalScore -= COMPACT_PHRASE_BONUS;
261
303
  totalScore += compactPhraseIndex * 0.01;
262
304
  }
263
305
 
264
- for (const token of normalizedQuery.split(" ")) {
306
+ for (const token of pq.tokens) {
265
307
  const match = scoreToken(token, index);
266
308
  if (!match.matches) {
267
309
  return { matches: false, score: 0 };
@@ -272,6 +314,10 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
272
314
  return { matches: true, score: totalScore };
273
315
  }
274
316
 
317
+ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
318
+ return fuzzyMatchCore(prepareQuery(query), text);
319
+ }
320
+
275
321
  /**
276
322
  * Filter and sort items by fuzzy match quality (best matches first).
277
323
  * Supports space-separated tokens: all tokens must match.
@@ -281,9 +327,10 @@ export function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => st
281
327
  return items.map(item => ({ item, score: 0 }));
282
328
  }
283
329
 
330
+ const pq = prepareQuery(query);
284
331
  const results: FuzzyFilterResult<T>[] = [];
285
332
  for (const item of items) {
286
- const match = fuzzyMatch(query, getText(item));
333
+ const match = fuzzyMatchCore(pq, getText(item));
287
334
  if (match.matches) {
288
335
  results.push({ item, score: match.score });
289
336
  }
@@ -296,3 +343,14 @@ export function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => st
296
343
  export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
297
344
  return fuzzyRank(items, query, getText).map(result => result.item);
298
345
  }
346
+
347
+ /**
348
+ * Clear the fuzzy search-index cache. Intended for tests/benchmarks so a fresh
349
+ * cold-start typing session can be measured on demand; not part of the supported
350
+ * TUI API.
351
+ *
352
+ * @internal
353
+ */
354
+ export function resetFuzzyIndexCache(): void {
355
+ indexCache.clear();
356
+ }