@oh-my-pi/pi-tui 15.10.9 → 15.10.11

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +5 -5
  3. package/dist/types/components/box.d.ts +1 -1
  4. package/dist/types/components/editor.d.ts +1 -1
  5. package/dist/types/components/image.d.ts +1 -1
  6. package/dist/types/components/input.d.ts +1 -1
  7. package/dist/types/components/loader.d.ts +1 -1
  8. package/dist/types/components/markdown.d.ts +5 -1
  9. package/dist/types/components/scroll-view.d.ts +1 -1
  10. package/dist/types/components/select-list.d.ts +1 -1
  11. package/dist/types/components/settings-list.d.ts +9 -6
  12. package/dist/types/components/spacer.d.ts +1 -1
  13. package/dist/types/components/tab-bar.d.ts +1 -1
  14. package/dist/types/components/text.d.ts +1 -1
  15. package/dist/types/components/truncated-text.d.ts +1 -1
  16. package/dist/types/kill-ring.d.ts +0 -7
  17. package/dist/types/stdin-buffer.d.ts +11 -0
  18. package/dist/types/terminal-capabilities.d.ts +1 -44
  19. package/dist/types/terminal.d.ts +0 -36
  20. package/dist/types/tui.d.ts +76 -72
  21. package/package.json +3 -3
  22. package/src/components/box.ts +43 -63
  23. package/src/components/editor.ts +187 -49
  24. package/src/components/image.ts +29 -5
  25. package/src/components/input.ts +1 -1
  26. package/src/components/loader.ts +1 -1
  27. package/src/components/markdown.ts +68 -50
  28. package/src/components/scroll-view.ts +1 -1
  29. package/src/components/select-list.ts +1 -1
  30. package/src/components/settings-list.ts +150 -26
  31. package/src/components/spacer.ts +9 -5
  32. package/src/components/tab-bar.ts +1 -1
  33. package/src/components/text.ts +1 -1
  34. package/src/components/truncated-text.ts +10 -2
  35. package/src/kill-ring.ts +5 -0
  36. package/src/stdin-buffer.ts +103 -27
  37. package/src/terminal-capabilities.ts +5 -150
  38. package/src/terminal.ts +99 -40
  39. package/src/tui.ts +802 -1827
package/CHANGELOG.md CHANGED
@@ -2,6 +2,50 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.11] - 2026-06-10
6
+
7
+ ### Added
8
+
9
+ - `SettingsList` now supports type-to-search filtering with Escape clearing an active query before canceling.
10
+
11
+ ### Changed
12
+
13
+ - Preserved list selection by item ID when replacing settings so focus stays on the same setting
14
+ - Displayed a no matching settings message and search-editing hint when filtering returns no matches
15
+ - Expanded settings search matching to include IDs, current values, descriptions, and option values as well as labels
16
+ - Raised the stdin split-escape flush window from 10ms to 50ms: over laggy links (ssh, slow multiplexers) a CSI sequence split across reads was flushed as literal data, leaking `[` + `A` style fragments into the editor as typed text
17
+ - Lengthened the OSC 11 appearance poll on terminals without Mode 2031 from 2s to 30s — each poll's query write cleared the user's active text selection, breaking copy every two seconds on Alacritty/Warp/older WezTerm
18
+ - Rewrote `StdinBuffer.extractCompleteSequences` to index-based scanning: the previous per-iteration `slice` + `Array.from(remaining)[0]` made plain-text bursts O(n²), turning a 100KB non-bracketed paste into a multi-second freeze
19
+ - Capped the editor undo stack at 100 entries with word-level coalescing of consecutive single-character inserts (matching `Input`), capped the kill ring at 60 entries, cached word-wrap layout per (line, width) so each render and key handler shares one wrap pass, and batched ≤1000-char single-line pastes into one insert + one trigger-detection pass instead of per-character replay
20
+ - Virtualized the frame pipeline around a stable-prefix contract — the renderer no longer does O(total transcript) work per frame. `Component.render` now returns `readonly string[]`: results are component-owned, callers must not mutate them, and an unchanged component returns the same array reference (reference equality proves byte-identical rows). `Container.render` memoizes its concatenation on child references (children are still rendered every frame for their side effects); `Box` replaced its content-hashing cache with the same child-reference memo (no more per-frame `leftPad + line` rebuilds and full-content hashing); `Markdown`, `Spacer`, and `TruncatedText` return their cached arrays by reference instead of defensive copies. The TUI composes a persistent frame from per-child segments and an opt-in `RenderStablePrefix` report (consumable floor semantics for in-place mutators like the transcript), so marker extraction, line preparation (persistent prepared-frame replacing the per-frame rebuilt cache arrays), and the committed-prefix audit now run only over rows at/after the first changed row instead of every line of the transcript every frame
21
+ - Rewrote the render core around an append-only native-scrollback contract. Committed rows are immutable: rows enter terminal history exactly once, in order, when the component-reported commit boundary (`NativeScrollbackLiveRegion`) marks them final, and the visible window repaints in place with relative moves. The engine no longer probes the terminal's scroll position or guesses whether a destructive rebuild is safe — the entire ED3-risk/defer/checkpoint machinery (viewport probes, eager streaming mode, dirty-scrollback reconciliation, deferred shrink/mutation intents, streaming high-water rebuilds, ConPTY-specific defer paths) is deleted. ED3 (`CSI 3 J`) now fires only on explicit user gestures: session replace, resize outside multiplexers, and `resetDisplay()`. This structurally removes the yank / flash / duplicated-rows / invisible-until-resize failure families tracked across #1610, #1635, #1651, #1682, #1719, #1746, #1799, #1823, #1962, #1974, #2000, #2011, #2154.
22
+ - A frame that shrinks into its committed prefix re-anchors the visible window at the new tail and restarts commit bookkeeping; previously committed rows stay in history (history is never rewritten without a gesture).
23
+ - Overlays now composite into the visible window slice only and freeze commits while visible, so overlay pixels can never enter native scrollback and closing an overlay no longer triggers a destructive history rebuild.
24
+ - Inline-image budget demotion now deletes the demoted image's graphics by id and lets the window diff repaint the text fallback — no more mid-session destructive full replay when the image cap is exceeded.
25
+ - The render-stress harness now validates the contract with a shadow commit ledger (an independent reimplementation of the ledger math fed only by observed frames and bytes), asserting scrollback equals the committed prefix row-for-row and that tape growth matches physical scroll exactly, across randomized op sequences, resizes, overlays, and multiplexer scenarios. The ghostty-web virtual terminal additionally survives libghostty-vt 0.4's WASM allocator traps via an event-log replay/compaction recovery, and strips non-spacing combining marks on input (a margin-aligned combining cluster deterministically corrupts that engine; mark placement through it was already unverifiable).
26
+
27
+ ### Fixed
28
+
29
+ - Fixed Windows rendering degrading into CP437 mojibake (`Γöé`/`ΓöÇ` instead of box-drawing borders and Nerd Font glyphs) after a console-sharing child process changed the console codepage (e.g. PHP CLI's implicit `chcp`, php.net request #73716): the breakage stayed latent until the next full repaint such as ctrl+o expand. The terminal now re-asserts the UTF-8 codepage (output and input) before each stdout write
30
+ - Fixed crash recovery leaving the shell unusable: `emergencyTerminalRestore` (and `terminal.stop()`) never left the alt screen nor disabled mouse tracking, so a crash during a fullscreen overlay stranded the user on the alternate buffer with any-motion mouse reporting spewing escape garbage until a manual `reset`
31
+ - Fixed bracketed paste with a lost `ESC[201~` end marker (ssh/tmux truncation) silently eating all subsequent input forever while growing memory unboundedly — paste mode now has an inactivity watchdog (1s) and a byte cap (64 MiB) that exit paste mode and deliver the accumulated bytes through the paste event
32
+ - Fixed vertical cursor movement using UTF-16 code units as visual columns: Up/Down over emoji/CJK lines could land the cursor mid-surrogate-pair, rendering a lone surrogate and permanently corrupting the buffer on the next insert; movement now walks graphemes and snaps the target offset to a cluster boundary, also fixing column drift across wide glyphs
33
+ - Fixed cursor positions inside whitespace trimmed at a word-wrap boundary mapping to no layout line — the cursor vanished and the viewport jumped to the buffer's last line; the preceding chunk now owns the skipped whitespace run
34
+ - Fixed word-delete and kill-to-line operations (Ctrl+W/Alt+D/Ctrl+U/Ctrl+K) cutting through atomic paste markers, leaving `[Paste #1, +30` junk that no longer expanded to the pasted content on submit — delete ranges now extend over any atomic token they intersect
35
+ - Fixed the kitty CSI-u printable dedup swallowing a real keystroke arbitrarily long after the duplicated event; the pending codepoint now expires after 25ms
36
+ - Fixed `resetDisplay()` being a no-op on the alt screen: the redraw gesture could not repair a corrupted fullscreen modal because `#emitAltFrame` skipped identical-string repaints without consulting the force-repaint flag
37
+ - Fixed the ghostty initial-image paint deferral consuming resize/cursor state before abandoning the frame, which could misclassify the deferred render's reflow and corrupt the paint — the deferral check now runs before any frame state is touched
38
+ - Fixed the terminal-cursor inline-hint branch adding the full hint width to the line accounting even though the rendered hint was truncated, misaligning right padding whenever the hint overflowed
39
+ - Fixed nested markdown list detection sniffing for hardcoded `\x1b[36m` (chalk cyan): every shipped theme emits truecolor/256-color SGR for bullets, so nested items doubled their indentation per level on all real themes; nesting is now tagged structurally by the list renderer. Ordered-list continuation lines also hang by the actual bullet width, so wrapped text under `10.`+ items aligns
40
+ - Fixed committed transcript rows silently vanishing when a component re-laid-out content the engine had already scrolled into native history — a TTSR stream rewind truncating a streamed block, or the image budget demoting a committed inline image to its one-line fallback, shifted every row below by the height delta and the engine kept committing from the stale index, skipping that many rows of everything after (missing interruption banners, half-cut images in scrollback). The engine now audits its committed prefix every ordinary frame: an in-place edit or restyle keeps its alignment (stale styling in history remains the accepted artifact), while any shift re-anchors the commit index at the first moved row and recommits from there — history keeps the stale copy and gains a fresh one. Duplication, never loss. The detector (`findCommittedPrefixResync`, exported for the stress harness's shadow ledger) samples the prefix tail SGR-stripped so theme restyles and single-row edits never trigger spurious recommits.
41
+ - Fixed budget-demoted inline images shrinking their transcript block: the text fallback is now height-preserving once a graphic has rendered (reserved rows plus the fallback line), so demotion never shifts content below a committed image.
42
+ - Fixed stale trailing cells bleeding into committed history on combining-heavy rows: the native width model can over-count Arabic/combining clusters, classifying a short-rendering row as full-width and skipping the trailing erase — the previous occupant's cells then scrolled into scrollback baked into the committed row. Non-ASCII row rewrites now erase the line before writing.
43
+
44
+ ### Removed
45
+
46
+ - Removed the probe/defer API surface: `TUI.setEagerNativeScrollbackRebuild()`, `TUI.refreshNativeScrollbackIfDirty()`, `TUI.setClearOnShrink()`/`getClearOnShrink()`, `RenderRequestOptions.allowUnknownViewportMutation`, `NativeScrollbackRefreshOptions`, `Terminal.isNativeViewportAtBottom()`, `Terminal.hasEagerEraseScrollbackRisk()`, and the `eagerEraseScrollbackRisk`/`submitPinsViewportToTail` capability fields with their detectors.
47
+ - Removed the `PI_TUI_ED3_SAFE`, `PI_CLEAR_ON_SHRINK`, and `PI_TUI_DEBUG` environment variables (the levers they tuned no longer exist; `PI_DEBUG_REDRAW` now logs the commit-ledger state per frame).
48
+
5
49
  ## [15.10.9] - 2026-06-09
6
50
 
7
51
  ### Added
@@ -23,6 +67,7 @@
23
67
  ### Added
24
68
 
25
69
  - Added `TUI.getFocused()` accessor and `Input.pasteText(text)` method so callers consuming non-bracketed paste transports (e.g. kitty's OSC 5522 enhanced clipboard) can route a paste payload to the currently focused modal Input rather than always to the primary editor. Mirrors the existing `Editor.pasteText` semantics: newlines stripped, tabs normalized, NFC normalization applied. ([#2127](https://github.com/can1357/oh-my-pi/issues/2127))
70
+
26
71
  ### Fixed
27
72
 
28
73
  - Fixed tmux/screen/zellij rewind/branch (`requestRender(true, { clearScrollback: true })`) permanently anchoring the input box to the pane top and overlaying scrollback after a streamed reply had grown past the viewport. `#emitFullPaint` only reset `#scrollbackHighWater` inside the `clearScrollback` branch and otherwise raised it monotonically, so inside multiplexers (where `\x1b[3J` is a no-op and `clearScrollback` is forced off) the streaming peak survived the rewind; on the next frame `#planLiveRegionPinnedRender` saw the stale high-water and anchored `renderViewportTop` past the actual content, repainting every visible row blank and parking the cursor at screen row 0 for the rest of the session. A full repaint with `clearViewport: true` re-emits the entire transcript from row 0, so `#scrollbackHighWater` is now assigned (not max-clamped) to the natural push count regardless of whether ED 3 was issued ([#2130](https://github.com/can1357/oh-my-pi/issues/2130)).
package/README.md CHANGED
@@ -62,7 +62,7 @@ All components implement:
62
62
 
63
63
  ```typescript
64
64
  interface Component {
65
- render(width: number): string[];
65
+ render(width: number): readonly string[];
66
66
  handleInput?(data: string): void;
67
67
  invalidate?(): void;
68
68
  }
@@ -70,7 +70,7 @@ interface Component {
70
70
 
71
71
  | Method | Description |
72
72
  | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
73
- | `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. |
73
+ | `render(width)` | Returns an array of strings, one per line. Each line **must not exceed `width`** or the TUI will error. Use `truncateToWidth()` or manual wrapping to ensure this. The result is component-owned and immutable to callers; return the same array reference when unchanged (enables renderer memoization) and a new array when content changed. |
74
74
  | `handleInput?(data)` | Called when the component has focus and receives keyboard input. The `data` string contains raw terminal input (may include ANSI escape sequences). |
75
75
  | `invalidate?()` | Called to clear any cached render state. Components should re-render from scratch on the next `render()` call. |
76
76
 
@@ -590,7 +590,7 @@ class MyInteractiveComponent implements Component {
590
590
  }
591
591
  }
592
592
 
593
- render(width: number): string[] {
593
+ render(width: number): readonly string[] {
594
594
  return this.items.map((item, i) => {
595
595
  const prefix = i === this.selectedIndex ? "> " : " ";
596
596
  return truncateToWidth(prefix + item, width);
@@ -614,7 +614,7 @@ class MyComponent implements Component {
614
614
  this.text = text;
615
615
  }
616
616
 
617
- render(width: number): string[] {
617
+ render(width: number): readonly string[] {
618
618
  // Option 1: Truncate long lines
619
619
  return [truncateToWidth(this.text, width)];
620
620
 
@@ -656,7 +656,7 @@ class CachedComponent implements Component {
656
656
  private cachedWidth?: number;
657
657
  private cachedLines?: string[];
658
658
 
659
- render(width: number): string[] {
659
+ render(width: number): readonly string[] {
660
660
  if (this.cachedLines && this.cachedWidth === width) {
661
661
  return this.cachedLines;
662
662
  }
@@ -13,5 +13,5 @@ export declare class Box implements Component {
13
13
  setPaddingY(paddingY: number): void;
14
14
  setBgFn(bgFn?: (text: string) => string): void;
15
15
  invalidate(): void;
16
- render(width: number): string[];
16
+ render(width: number): readonly string[];
17
17
  }
@@ -81,7 +81,7 @@ export declare class Editor implements Component, Focusable {
81
81
  */
82
82
  addToHistory(text: string): void;
83
83
  invalidate(): void;
84
- render(width: number): string[];
84
+ render(width: number): readonly string[];
85
85
  handleInput(data: string): void;
86
86
  getText(): string;
87
87
  /**
@@ -82,5 +82,5 @@ export declare class Image implements Component {
82
82
  #private;
83
83
  constructor(base64Data: string, mimeType: string, theme: ImageTheme, options?: ImageOptions, dimensions?: ImageDimensions);
84
84
  invalidate(): void;
85
- render(width: number): string[];
85
+ render(width: number): readonly string[];
86
86
  }
@@ -17,5 +17,5 @@ export declare class Input implements Component, Focusable {
17
17
  * (e.g. kitty's OSC 5522 enhanced clipboard read). Mirrors `Editor.pasteText`. */
18
18
  pasteText(text: string): void;
19
19
  invalidate(): void;
20
- render(width: number): string[];
20
+ render(width: number): readonly string[];
21
21
  }
@@ -10,7 +10,7 @@ export declare class Loader extends Text {
10
10
  private messageColorFn;
11
11
  private message;
12
12
  constructor(ui: TUI, spinnerColorFn: ColorFn, messageColorFn: LoaderMessageColorFn, message?: string, spinnerFrames?: string[]);
13
- render(width: number): string[];
13
+ render(width: number): readonly string[];
14
14
  start(): void;
15
15
  stop(): void;
16
16
  /** Lifecycle teardown: stop the animation timer. Idempotent. */
@@ -49,10 +49,14 @@ export interface MarkdownTheme {
49
49
  }
50
50
  export declare class Markdown implements Component {
51
51
  #private;
52
+ /** When true, skip the module-level LRU (lookup and insert) for this instance's
53
+ * renders. Set for in-flight streaming partials whose text changes every frame —
54
+ * caching those churns the LRU with near-duplicate full-message snapshots. */
55
+ transientRenderCache: boolean;
52
56
  constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
53
57
  setText(text: string): void;
54
58
  invalidate(): void;
55
- render(width: number): string[];
59
+ render(width: number): readonly string[];
56
60
  }
57
61
  /**
58
62
  * Render inline markdown (bold, italic, code, links, strikethrough) to a styled string.
@@ -57,6 +57,6 @@ export declare class ScrollView implements Component {
57
57
  */
58
58
  handleScrollKey(data: string): boolean;
59
59
  invalidate(): void;
60
- render(width: number): string[];
60
+ render(width: number): readonly string[];
61
61
  }
62
62
  export {};
@@ -50,7 +50,7 @@ export declare class SelectList implements Component {
50
50
  setFilter(filter: string): void;
51
51
  setSelectedIndex(index: number): void;
52
52
  invalidate(): void;
53
- render(width: number): string[];
53
+ render(width: number): readonly string[];
54
54
  handleInput(keyData: string): void;
55
55
  getSelectedItem(): SelectItem | null;
56
56
  }
@@ -25,17 +25,20 @@ export interface SettingsListTheme {
25
25
  export declare class SettingsList implements Component {
26
26
  #private;
27
27
  constructor(items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void);
28
+ getSearchQuery(): string;
29
+ hasSearchQuery(): boolean;
30
+ clearSearch(): void;
28
31
  /** Update an item's currentValue */
29
32
  updateValue(id: string, newValue: string): void;
30
33
  /**
31
- * Replace the entire items array. Selection is preserved when the prior
32
- * index is still valid, otherwise clamped to the last item (or 0 if the
33
- * list is now empty). An open submenu is left untouched its lifetime
34
- * is bounded by its own done callback, and `#closeSubmenu` re-clamps the
35
- * restored index against the new list on the way out.
34
+ * Replace the entire items array. Selection is preserved by item id when
35
+ * the previous selection still survives the active filter, otherwise
36
+ * clamped to the last filtered item (or 0 if there are no matches).
37
+ * An open submenu is left untouched its lifetime is bounded by its own
38
+ * done callback, and `#closeSubmenu` re-clamps the restored index on exit.
36
39
  */
37
40
  setItems(items: SettingItem[]): void;
38
41
  invalidate(): void;
39
- render(width: number): string[];
42
+ render(width: number): readonly string[];
40
43
  handleInput(data: string): void;
41
44
  }
@@ -7,5 +7,5 @@ export declare class Spacer implements Component {
7
7
  constructor(lines?: number);
8
8
  setLines(lines: number): void;
9
9
  invalidate(): void;
10
- render(_width: number): string[];
10
+ render(_width: number): readonly string[];
11
11
  }
@@ -52,5 +52,5 @@ export declare class TabBar implements Component {
52
52
  */
53
53
  handleInput(data: string): boolean;
54
54
  /** Render the tab bar, wrapping to multiple lines if needed */
55
- render(width: number): string[];
55
+ render(width: number): readonly string[];
56
56
  }
@@ -9,5 +9,5 @@ export declare class Text implements Component {
9
9
  setText(text: string): boolean;
10
10
  setCustomBgFn(customBgFn?: (text: string) => string): void;
11
11
  invalidate(): void;
12
- render(width: number): string[];
12
+ render(width: number): readonly string[];
13
13
  }
@@ -6,5 +6,5 @@ export declare class TruncatedText implements Component {
6
6
  #private;
7
7
  constructor(text: string, paddingX?: number, paddingY?: number);
8
8
  invalidate(): void;
9
- render(width: number): string[];
9
+ render(width: number): readonly string[];
10
10
  }
@@ -1,10 +1,3 @@
1
- /**
2
- * Ring buffer for Emacs-style kill/yank operations.
3
- *
4
- * Tracks killed (deleted) text entries. Consecutive kills can accumulate
5
- * into a single entry. Supports yank (paste most recent) and yank-pop
6
- * (cycle through older entries).
7
- */
8
1
  export declare class KillRing {
9
2
  #private;
10
3
  /**
@@ -23,6 +23,17 @@ export type StdinBufferOptions = {
23
23
  * After this time, a genuinely incomplete escape is flushed.
24
24
  */
25
25
  timeout?: number;
26
+ /**
27
+ * Paste-mode inactivity watchdog (default: 1000ms). If no input arrives for
28
+ * this long while waiting for the bracketed-paste end marker, the paste is
29
+ * assumed truncated: accumulated bytes are delivered and input recovers.
30
+ */
31
+ pasteTimeout?: number;
32
+ /**
33
+ * Paste-mode byte cap (default: 64 MiB). Exceeding it aborts paste mode the
34
+ * same way, bounding memory when the end marker never arrives.
35
+ */
36
+ pasteByteLimit?: number;
26
37
  };
27
38
  export type StdinBufferEventMap = {
28
39
  data: [string];
@@ -16,22 +16,13 @@ export declare class TerminalInfo {
16
16
  readonly trueColor: boolean;
17
17
  readonly hyperlinks: boolean;
18
18
  readonly notifyProtocol: NotifyProtocol;
19
- readonly eagerEraseScrollbackRisk: boolean;
20
19
  readonly deccara: boolean;
21
20
  readonly supportsScreenToScrollback: boolean;
22
21
  /** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
23
22
  readonly textSizing: boolean;
24
- constructor(id: TerminalId, imageProtocol: ImageProtocol | null, trueColor: boolean, hyperlinks: boolean, notifyProtocol?: NotifyProtocol, eagerEraseScrollbackRisk?: boolean, deccara?: boolean, supportsScreenToScrollback?: boolean,
23
+ constructor(id: TerminalId, imageProtocol: ImageProtocol | null, trueColor: boolean, hyperlinks: boolean, notifyProtocol?: NotifyProtocol, deccara?: boolean, supportsScreenToScrollback?: boolean,
25
24
  /** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
26
25
  textSizing?: boolean);
27
- /**
28
- * Whether a prompt-submit keystroke scrolls this host to its tail, so the
29
- * native-scrollback reconciliation checkpoint may ED3-rebuild even when the
30
- * viewport position is unprobeable. Assigned by the TERMINAL builder from
31
- * {@link detectSubmitPinsViewportToTail}; readonly but tests opt in via the
32
- * {@link setTerminalSubmitPinsViewportToTail} mutable-cast setter.
33
- */
34
- readonly submitPinsViewportToTail: boolean;
35
26
  /**
36
27
  * Mutable clone for the {@link TERMINAL} singleton: copies every field and
37
28
  * keeps the prototype methods, so the builder and runtime setters flip
@@ -50,36 +41,6 @@ export declare function isNotificationSuppressed(): boolean;
50
41
  * Windows Terminal introduced SIXEL support in preview 1.22.
51
42
  */
52
43
  export declare function isWindowsTerminalPreviewSixelSupported(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
53
- /**
54
- * Whether live-frame native scrollback rebuilds are unsafe when the terminal
55
- * viewport position is unobservable.
56
- *
57
- * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). Many
58
- * terminals either clamp a scrolled reader back to the active tail or erase host
59
- * scrollback when ED3 lands. The important property is not the brand name — it
60
- * is that an unknown viewport position cannot be proven safe. Environment
61
- * markers are therefore only used to prove *risk* or a strongly-known profile;
62
- * unknown POSIX/remote/multiplexer shapes default to risky for passive renders.
63
- *
64
- * Native win32 is excluded here because the renderer has dedicated ConPTY
65
- * deferral paths; a `WT_SESSION` sighting on POSIX means Windows Terminal is the
66
- * outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
67
- */
68
- export declare function detectTerminalEagerEraseScrollbackRisk(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
69
- /**
70
- * Whether a prompt-submit keystroke scrolls this terminal to its tail, making the
71
- * native-scrollback reconciliation checkpoint (`refreshNativeScrollbackIfDirty`)
72
- * safe to ED3-rebuild even when the viewport position cannot be probed.
73
- *
74
- * True only for recognized genuine *local* terminals where typing into the prompt
75
- * brings the host viewport to the bottom. False — the checkpoint keeps deferring
76
- * until a positive at-tail probe — for hosts whose scrollback a keystroke does not
77
- * move: Windows consoles/ConPTY, Windows Terminal (incl. WSL), SSH, multiplexers,
78
- * and unrecognized profiles. This is the per-terminal counterpart to the blanket
79
- * block from #1610/#1682/#1746: those hosts genuinely cannot treat a submit as
80
- * proof of at-tail, but genuine local terminals can.
81
- */
82
- export declare function detectSubmitPinsViewportToTail(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
83
44
  /**
84
45
  * Resolve an explicit user override for DEC 2026 synchronized output. Returns
85
46
  * `false` for an opt-out, `true` for a force-on, or `null` when the user has
@@ -134,11 +95,9 @@ export declare const TERMINAL_ID: TerminalId;
134
95
  export interface RuntimeTerminal extends TerminalInfo {
135
96
  imageProtocol: ImageProtocol | null;
136
97
  hyperlinks: boolean;
137
- eagerEraseScrollbackRisk: boolean;
138
98
  deccara: boolean;
139
99
  supportsScreenToScrollback: boolean;
140
100
  textSizing: boolean;
141
- submitPinsViewportToTail: boolean;
142
101
  }
143
102
  export declare const TERMINAL: RuntimeTerminal;
144
103
  /**
@@ -153,8 +112,6 @@ export declare function setTerminalImageProtocol(imageProtocol: ImageProtocol |
153
112
  export declare function setTerminalDeccara(enabled: boolean): void;
154
113
  /** Override screen-to-scrollback clear support for targeted renderer tests. */
155
114
  export declare function setTerminalScreenToScrollback(enabled: boolean): void;
156
- /** Override submit-pins-viewport-to-tail for checkpoint reconciliation tests. */
157
- export declare function setTerminalSubmitPinsViewportToTail(enabled: boolean): void;
158
115
  /**
159
116
  * Enable/disable OSC 66 text-sizing at runtime. The coding-agent calls this from
160
117
  * the `tui.textSizing` setting (gated on the terminal's static `textSizing`
@@ -48,42 +48,6 @@ export interface Terminal {
48
48
  clearScreen(): void;
49
49
  setTitle(title: string): void;
50
50
  setProgress(active: boolean): void;
51
- /**
52
- * Returns whether the native terminal viewport is at the scrollback tail when
53
- * the host exposes that state. `undefined` means the terminal cannot report it.
54
- *
55
- * `ProcessTerminal` deliberately does not implement this — no real terminal
56
- * can answer it truthfully:
57
- *
58
- * - POSIX terminals expose no scrollback-position API at all.
59
- * - Every modern Windows terminal host (Windows Terminal, VS Code, Tabby,
60
- * Hyper, Alacritty, WezTerm, JetBrains, …) fronts console apps through
61
- * ConPTY, where kernel32's `GetConsoleScreenBufferInfo` describes the
62
- * pseudo-console buffer. That buffer is pinned to the visible grid —
63
- * scrollback lives in the host UI, invisible to console APIs
64
- * (microsoft/terminal#10191) — so a probe reads "at bottom" no matter
65
- * where the user scrolled. Trusting it let streaming-time rebuilds emit
66
- * `\x1b[3J` and yank scrolled readers: #1635 (Windows Terminal), #1746
67
- * (Tabby and other ConPTY hosts). No env var distinguishes these hosts
68
- * (Tabby sets none), so trust cannot be conditional on the environment.
69
- * - Legacy conhost (the only non-ConPTY host) keeps a real scrollback
70
- * buffer, but its window follows the output cursor: a probe comparing
71
- * `srWindow.Bottom` against `dwSize.Y - 1` reads "scrolled up" for a user
72
- * following live output until all ~9001 buffer rows fill, permanently
73
- * blocking checkpoint scrollback reconciliation.
74
- *
75
- * The renderer treats a missing implementation / `undefined` as "unknown":
76
- * live mutations defer destructive rebuilds and reconcile native scrollback
77
- * at explicit checkpoints (prompt submit), where the user's keystroke has
78
- * already pinned the host viewport to the bottom. Only test terminals
79
- * (xterm.js-backed) implement this with a real answer.
80
- */
81
- isNativeViewportAtBottom?(): boolean | undefined;
82
- /**
83
- * Override the global terminal-profile ED3 risk decision for custom/test
84
- * terminals. `undefined` falls back to the resolved `TERMINAL` profile.
85
- */
86
- hasEagerEraseScrollbackRisk?(): boolean | undefined;
87
51
  /**
88
52
  * Register a callback for terminal appearance (dark/light) changes.
89
53
  * Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
@@ -24,14 +24,26 @@ export interface TUIStartOptions {
24
24
  }
25
25
  /**
26
26
  * Component interface - all components must implement this
27
+ *
28
+ * Render contract: the returned array (and its rows) belongs to the component.
29
+ * Callers MUST NOT mutate it — components are allowed to return a cached array
30
+ * and will return the exact same reference for as long as their rendered
31
+ * content is unchanged. Conversely, a component MUST return a fresh array
32
+ * reference whenever its content changed; reference equality across two
33
+ * render() calls is the engine's proof that the rows are byte-identical
34
+ * (containers memoize their concatenation on it, and the TUI derives the
35
+ * frame's stable prefix from it). A component that mutates a previously
36
+ * returned array in place must implement {@link RenderStablePrefix} to declare
37
+ * which leading rows survived.
27
38
  */
28
39
  export interface Component {
29
40
  /**
30
- * Render the component to lines for the given viewport width
31
- * @param width - Current viewport width
32
- * @returns Array of strings, each representing a line
41
+ * Render the component to an array of physical rows at the given width.
42
+ * The result is component-owned and `readonly` to the caller; an unchanged
43
+ * component may (and should) return the same array reference it returned
44
+ * last time.
33
45
  */
34
- render(width: number): string[];
46
+ render(width: number): readonly string[];
35
47
  /**
36
48
  * Optional handler for keyboard input when component has focus
37
49
  */
@@ -55,27 +67,50 @@ export interface Component {
55
67
  dispose?(): void;
56
68
  }
57
69
  /**
58
- * Optional component seam for native-scrollback pinning. A component that
59
- * renders a stable prefix followed by a live/transient suffix reports the local
60
- * line index where that suffix begins after each render. TUI treats that suffix
61
- * and every root child rendered below it — as not yet safe to commit to native
62
- * scrollback on ED3-risk terminals whose viewport position is unobservable.
70
+ * Component seam for append-only native-scrollback commits. A component that
71
+ * renders a finalized prefix followed by a live/mutating suffix reports the
72
+ * local line index where that suffix begins after each render. The engine
73
+ * commits rows to native scrollback only up to that boundary; everything
74
+ * below repaints in place inside the visible window and never enters history
75
+ * until it finalizes.
63
76
  *
64
77
  * `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
65
- * inside that live suffix: the line index up to which the live region is
66
- * append-only (its earlier rows never re-layout, only new rows append at the
67
- * bottom a streaming assistant message). Rows in `[liveRegionStart,
68
- * commitSafeEnd)` that scroll above the viewport are safe to commit to native
69
- * scrollback even though they are technically live, because they will never
70
- * change. Without this, a single live block that alone overflows the viewport
71
- * loses its scrolled-off head (committed nowhere, repainted nowhere). Volatile
72
- * live blocks (tool previews that collapse) omit it, so their mutable rows stay
73
- * deferred. Defaults to `liveRegionStart` when absent.
78
+ * inside the live suffix: the line index up to which the live region is
79
+ * append-only (earlier rows never re-layout a streaming assistant message).
80
+ * Rows in `[liveRegionStart, commitSafeEnd)` may commit even though they are
81
+ * technically live, because they will never change. Without it, a single live
82
+ * block that alone overflows the window would hold its scrolled-off head out
83
+ * of history until it finalizes. Volatile live blocks (tool previews that
84
+ * collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
85
+ * reports no seam at all commits everything that scrolls (shell semantics).
74
86
  */
75
87
  export interface NativeScrollbackLiveRegion {
76
88
  getNativeScrollbackLiveRegionStart(): number | undefined;
77
89
  getNativeScrollbackCommitSafeEnd?(): number | undefined;
78
90
  }
91
+ /**
92
+ * Opt-in stability report for components that mutate their returned render
93
+ * array in place across frames (instead of returning a fresh array per
94
+ * change). The engine reads it right after the component's `render()` returns:
95
+ * the report counts the leading rows of the just-returned array that are
96
+ * byte-identical to the array state the reader last observed. The engine uses
97
+ * it to reuse the composed frame's prefix — skipping marker extraction, line
98
+ * preparation, and the committed-prefix audit for those rows.
99
+ *
100
+ * Contract:
101
+ * - Reading CONSUMES the report: it re-bases the baseline to the current
102
+ * array state. The accumulated count therefore covers every render since
103
+ * the previous read, so out-of-band `render()` calls between engine frames
104
+ * (an exporter walking the tree) can only lower the report, never inflate
105
+ * it past what the engine actually has.
106
+ * - An implementer that cannot prove stability for a frame must lower the
107
+ * accumulated count to 0 for that render.
108
+ * - Rows at or beyond the report may have been mutated in place; rows before
109
+ * it must be the identical string values at the identical indices.
110
+ */
111
+ export interface RenderStablePrefix {
112
+ getRenderStablePrefixRows(): number;
113
+ }
79
114
  /**
80
115
  * Interface for components that can receive focus and display a cursor.
81
116
  * When focused, the component should emit CURSOR_MARKER at the cursor position
@@ -97,21 +132,6 @@ export interface Focusable {
97
132
  export interface RenderRequestOptions {
98
133
  /** Clear terminal scrollback for intentional transcript replacement. */
99
134
  clearScrollback?: boolean;
100
- /**
101
- * Allow a transient live-viewport repaint when the terminal cannot report
102
- * whether its native viewport is at the tail.
103
- *
104
- * This is **not** a settled transcript commit and must not be used for tool
105
- * completion, session replay, or other background/offscreen rewrites. On
106
- * ED3-risk terminals it may deliberately choose a viewport repaint/deferred
107
- * shrink without clearing native scrollback so autocomplete, IME, and focused
108
- * editor chrome stay responsive without yanking a scrolled reader.
109
- */
110
- allowUnknownViewportMutation?: boolean;
111
- }
112
- /** Options for deferred native scrollback rebuild checkpoints. Reserved for API stability. */
113
- export interface NativeScrollbackRefreshOptions {
114
- allowUnknownViewport?: boolean;
115
135
  }
116
136
  /** Type guard to check if a component implements Focusable */
117
137
  export declare function isFocusable(component: Component | null): component is Component & Focusable;
@@ -192,6 +212,7 @@ export interface OverlayHandle {
192
212
  * Container - a component that contains other components
193
213
  */
194
214
  export declare class Container implements Component {
215
+ #private;
195
216
  children: Component[];
196
217
  addChild(component: Component): void;
197
218
  removeChild(component: Component): void;
@@ -203,8 +224,28 @@ export declare class Container implements Component {
203
224
  * {@link clear} for that). Idempotent per child via each child's own dispose.
204
225
  */
205
226
  dispose(): void;
206
- render(width: number): string[];
227
+ render(width: number): readonly string[];
207
228
  }
229
+ /**
230
+ * Decide whether `frame` still aligns with the committed prefix, and where to
231
+ * re-anchor the commit index when it does not. Returns the resync row index,
232
+ * or -1 when no resync is needed.
233
+ *
234
+ * The detector exploits the asymmetry between the two mutation classes: an
235
+ * in-place edit or restyle of committed rows disturbs only the touched rows
236
+ * (alignment below them is intact — the stale copy in history is the
237
+ * long-accepted artifact), while any insertion or deletion shifts EVERY row
238
+ * below it, including the rows just above the commit boundary. So the prefix
239
+ * *tail* is sampled (up to 8 non-blank rows within the last 24, compared
240
+ * SGR-stripped so theme changes stay quiet, tolerating one mismatch for a
241
+ * legitimate single-row edit): aligned ⇒ no resync; misaligned ⇒ resync at
242
+ * the first non-equivalent row, recommitting from there — duplication, never
243
+ * loss. Highly repetitive tails (identical filler rows) can mask a shift, in
244
+ * which case the skipped rows are content-identical to the committed ones —
245
+ * observationally harmless. Exported for the render-stress harness, whose
246
+ * shadow commit ledger must mirror the engine's law exactly.
247
+ */
248
+ export declare function findCommittedPrefixResync(frame: readonly string[], prefix: readonly string[]): number;
208
249
  /**
209
250
  * TUI - Main class for managing terminal UI with differential rendering
210
251
  */
@@ -220,7 +261,7 @@ export declare class TUI extends Container {
220
261
  hidden: boolean;
221
262
  }[];
222
263
  constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: TUIOptions);
223
- render(width: number): string[];
264
+ render(width: number): readonly string[];
224
265
  get fullRedraws(): number;
225
266
  /** Shared budget that caps how many inline images render as live graphics. */
226
267
  get imageBudget(): ImageBudget;
@@ -232,13 +273,6 @@ export declare class TUI extends Container {
232
273
  setMaxInlineImages(cap: number): void;
233
274
  getShowHardwareCursor(): boolean;
234
275
  setShowHardwareCursor(enabled: boolean): void;
235
- getClearOnShrink(): boolean;
236
- /**
237
- * Set whether to trigger full re-render when content shrinks.
238
- * When true (default), empty rows are cleared when content shrinks.
239
- * When false, empty rows remain (reduces redraws on slower terminals).
240
- */
241
- setClearOnShrink(enabled: boolean): void;
242
276
  /**
243
277
  * Whether DEC 2026 synchronized-output wrappers are currently emitted around
244
278
  * paints. Starts from conservative terminal/env detection and is reconciled at
@@ -246,30 +280,6 @@ export declare class TUI extends Container {
246
280
  * positive report, disabled on a negative one.
247
281
  */
248
282
  get synchronizedOutput(): boolean;
249
- /**
250
- * When enabled, live render frames rebuild native scrollback on offscreen and
251
- * structural changes even when the viewport position is unobservable (POSIX,
252
- * where `isNativeViewportAtBottom()` is `undefined`), instead of deferring to a
253
- * non-destructive repaint. This trades the anti-yank guarantee for a clean,
254
- * duplicate-free history and is meant for windows where output above the fold
255
- * is actively re-rendering — e.g. a tool whose result is still streaming and
256
- * re-laying-out rows that have already scrolled into history. A terminal that
257
- * reports a *known*-scrolled viewport still defers, as does native Windows
258
- * (the viewport is never observable there and ConPTY hosts erase host
259
- * scrollback on ED3 — #1635/#1746); only the unknown POSIX case is forced to
260
- * rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
261
- * (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint
262
- * rebuilds are unaffected.
263
- *
264
- * Disabling stays active through one already-requested frame: the event batch
265
- * that ends a foreground stream both removes its UI rows (loader/status
266
- * teardown — a shrink) and clears this flag before the throttled render timer
267
- * fires. If the flag dropped immediately, that teardown frame would hit the
268
- * ED3-risk idle deferral and freeze on screen (stale spinner) until the next
269
- * keystroke. When no render is pending, disable immediately so a later
270
- * unrelated content mutation does not inherit foreground-stream privileges.
271
- */
272
- setEagerNativeScrollbackRebuild(enabled: boolean): void;
273
283
  setFocus(component: Component | null): void;
274
284
  /** Component currently receiving keyboard input, if any. */
275
285
  getFocused(): Component | null;
@@ -288,12 +298,6 @@ export declare class TUI extends Container {
288
298
  addInputListener(listener: InputListener): () => void;
289
299
  removeInputListener(listener: InputListener): void;
290
300
  stop(): void;
291
- /**
292
- * Rebuild native terminal scrollback if live rendering deferred a history rewrite.
293
- * Callers should only invoke this at checkpoints where the user is expected to be
294
- * at the terminal bottom, such as after submitting a new prompt.
295
- */
296
- refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean;
297
301
  /**
298
302
  * Force an immediate full replay of the current frame, including native
299
303
  * scrollback. This is the keyboard-accessible equivalent of the resize reset: