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

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,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.10] - 2026-06-09
6
+ ### Fixed
7
+
8
+ - 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.
9
+ - 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.
10
+ - 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.
11
+
12
+ ### Changed
13
+
14
+ - 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.
15
+ - 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).
16
+ - 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.
17
+ - 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.
18
+ - 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).
19
+
20
+ ### Removed
21
+
22
+ - 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.
23
+ - 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).
24
+
5
25
  ## [15.10.9] - 2026-06-09
6
26
 
7
27
  ### Added
@@ -1228,4 +1248,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
1228
1248
 
1229
1249
  ### Fixed
1230
1250
 
1231
- - **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
1251
+ - **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
@@ -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.
@@ -55,22 +55,22 @@ export interface Component {
55
55
  dispose?(): void;
56
56
  }
57
57
  /**
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.
58
+ * Component seam for append-only native-scrollback commits. A component that
59
+ * renders a finalized prefix followed by a live/mutating suffix reports the
60
+ * local line index where that suffix begins after each render. The engine
61
+ * commits rows to native scrollback only up to that boundary; everything
62
+ * below repaints in place inside the visible window and never enters history
63
+ * until it finalizes.
63
64
  *
64
65
  * `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.
66
+ * inside the live suffix: the line index up to which the live region is
67
+ * append-only (earlier rows never re-layout a streaming assistant message).
68
+ * Rows in `[liveRegionStart, commitSafeEnd)` may commit even though they are
69
+ * technically live, because they will never change. Without it, a single live
70
+ * block that alone overflows the window would hold its scrolled-off head out
71
+ * of history until it finalizes. Volatile live blocks (tool previews that
72
+ * collapse) omit it. Defaults to `liveRegionStart` when absent; a root that
73
+ * reports no seam at all commits everything that scrolls (shell semantics).
74
74
  */
75
75
  export interface NativeScrollbackLiveRegion {
76
76
  getNativeScrollbackLiveRegionStart(): number | undefined;
@@ -97,21 +97,6 @@ export interface Focusable {
97
97
  export interface RenderRequestOptions {
98
98
  /** Clear terminal scrollback for intentional transcript replacement. */
99
99
  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
100
  }
116
101
  /** Type guard to check if a component implements Focusable */
117
102
  export declare function isFocusable(component: Component | null): component is Component & Focusable;
@@ -205,6 +190,26 @@ export declare class Container implements Component {
205
190
  dispose(): void;
206
191
  render(width: number): string[];
207
192
  }
193
+ /**
194
+ * Decide whether `frame` still aligns with the committed prefix, and where to
195
+ * re-anchor the commit index when it does not. Returns the resync row index,
196
+ * or -1 when no resync is needed.
197
+ *
198
+ * The detector exploits the asymmetry between the two mutation classes: an
199
+ * in-place edit or restyle of committed rows disturbs only the touched rows
200
+ * (alignment below them is intact — the stale copy in history is the
201
+ * long-accepted artifact), while any insertion or deletion shifts EVERY row
202
+ * below it, including the rows just above the commit boundary. So the prefix
203
+ * *tail* is sampled (up to 8 non-blank rows within the last 24, compared
204
+ * SGR-stripped so theme changes stay quiet, tolerating one mismatch for a
205
+ * legitimate single-row edit): aligned ⇒ no resync; misaligned ⇒ resync at
206
+ * the first non-equivalent row, recommitting from there — duplication, never
207
+ * loss. Highly repetitive tails (identical filler rows) can mask a shift, in
208
+ * which case the skipped rows are content-identical to the committed ones —
209
+ * observationally harmless. Exported for the render-stress harness, whose
210
+ * shadow commit ledger must mirror the engine's law exactly.
211
+ */
212
+ export declare function findCommittedPrefixResync(frame: readonly string[], prefix: readonly string[]): number;
208
213
  /**
209
214
  * TUI - Main class for managing terminal UI with differential rendering
210
215
  */
@@ -232,13 +237,6 @@ export declare class TUI extends Container {
232
237
  setMaxInlineImages(cap: number): void;
233
238
  getShowHardwareCursor(): boolean;
234
239
  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
240
  /**
243
241
  * Whether DEC 2026 synchronized-output wrappers are currently emitted around
244
242
  * paints. Starts from conservative terminal/env detection and is reconciled at
@@ -246,30 +244,6 @@ export declare class TUI extends Container {
246
244
  * positive report, disabled on a negative one.
247
245
  */
248
246
  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
247
  setFocus(component: Component | null): void;
274
248
  /** Component currently receiving keyboard input, if any. */
275
249
  getFocused(): Component | null;
@@ -288,12 +262,6 @@ export declare class TUI extends Container {
288
262
  addInputListener(listener: InputListener): () => void;
289
263
  removeInputListener(listener: InputListener): void;
290
264
  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
265
  /**
298
266
  * Force an immediate full replay of the current frame, including native
299
267
  * scrollback. This is the keyboard-accessible equivalent of the resize reset:
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.10.9",
4
+ "version": "15.10.10",
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.10.9",
41
- "@oh-my-pi/pi-utils": "15.10.9",
40
+ "@oh-my-pi/pi-natives": "15.10.10",
41
+ "@oh-my-pi/pi-utils": "15.10.10",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -240,6 +240,10 @@ export class Image implements Component {
240
240
  #cachedLines?: string[];
241
241
  #cachedWidth?: number;
242
242
  #cachedSuppressed = false;
243
+ // Tallest graphic placement this image has rendered. The text fallback
244
+ // pads itself to this height so a budget demotion never shrinks the block
245
+ // (its rows may already be committed to native scrollback).
246
+ #renderedGraphicRows = 0;
243
247
 
244
248
  constructor(
245
249
  base64Data: string,
@@ -309,12 +313,11 @@ export class Image implements Component {
309
313
  const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
310
314
  lines.push(moveUp + (result.sequence ?? ""));
311
315
  } else {
312
- lines = [
313
- this.#theme.fallbackColor(imageFallback(this.#mimeType, this.#dimensions, this.#options.filename)),
314
- ];
316
+ lines = this.#fallbackLines();
315
317
  }
318
+ this.#renderedGraphicRows = Math.max(this.#renderedGraphicRows, lines.length);
316
319
  } else {
317
- lines = [this.#theme.fallbackColor(imageFallback(this.#mimeType, this.#dimensions, this.#options.filename))];
320
+ lines = this.#fallbackLines();
318
321
  }
319
322
 
320
323
  this.#cachedLines = lines;
@@ -323,4 +326,25 @@ export class Image implements Component {
323
326
 
324
327
  return lines;
325
328
  }
329
+
330
+ /**
331
+ * Text fallback, height-preserving once a graphic has rendered: a demoted
332
+ * image must keep occupying the rows its placement used, because those
333
+ * rows may already be committed to native scrollback — shrinking the block
334
+ * would shift everything below it and force the renderer's commit-resync
335
+ * (stale band + recommit). Reserved rows stay non-plain so blank-edge
336
+ * trimming cannot collapse the block either.
337
+ */
338
+ #fallbackLines(): string[] {
339
+ const fallback = this.#theme.fallbackColor(
340
+ imageFallback(this.#mimeType, this.#dimensions, this.#options.filename),
341
+ );
342
+ if (this.#renderedGraphicRows <= 1) return [fallback];
343
+ const lines: string[] = [];
344
+ for (let i = 0; i < this.#renderedGraphicRows - 1; i++) {
345
+ lines.push(RESERVED_IMAGE_ROW);
346
+ }
347
+ lines.push(fallback);
348
+ return lines;
349
+ }
326
350
  }
@@ -56,22 +56,12 @@ export class TerminalInfo {
56
56
  public readonly trueColor: boolean,
57
57
  public readonly hyperlinks: boolean,
58
58
  public readonly notifyProtocol: NotifyProtocol = NotifyProtocol.Bell,
59
- public readonly eagerEraseScrollbackRisk: boolean = false,
60
59
  public readonly deccara: boolean = false,
61
60
  readonly supportsScreenToScrollback: boolean = false,
62
61
  /** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
63
62
  public readonly textSizing: boolean = false,
64
63
  ) {}
65
64
 
66
- /**
67
- * Whether a prompt-submit keystroke scrolls this host to its tail, so the
68
- * native-scrollback reconciliation checkpoint may ED3-rebuild even when the
69
- * viewport position is unprobeable. Assigned by the TERMINAL builder from
70
- * {@link detectSubmitPinsViewportToTail}; readonly but tests opt in via the
71
- * {@link setTerminalSubmitPinsViewportToTail} mutable-cast setter.
72
- */
73
- readonly submitPinsViewportToTail: boolean = false;
74
-
75
65
  /**
76
66
  * Mutable clone for the {@link TERMINAL} singleton: copies every field and
77
67
  * keeps the prototype methods, so the builder and runtime setters flip
@@ -157,128 +147,6 @@ export function isWindowsTerminalPreviewSixelSupported(
157
147
  return version.major > 1 || (version.major === 1 && version.minor >= 22);
158
148
  }
159
149
 
160
- /**
161
- * Whether live-frame native scrollback rebuilds are unsafe when the terminal
162
- * viewport position is unobservable.
163
- *
164
- * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). Many
165
- * terminals either clamp a scrolled reader back to the active tail or erase host
166
- * scrollback when ED3 lands. The important property is not the brand name — it
167
- * is that an unknown viewport position cannot be proven safe. Environment
168
- * markers are therefore only used to prove *risk* or a strongly-known profile;
169
- * unknown POSIX/remote/multiplexer shapes default to risky for passive renders.
170
- *
171
- * Native win32 is excluded here because the renderer has dedicated ConPTY
172
- * deferral paths; a `WT_SESSION` sighting on POSIX means Windows Terminal is the
173
- * outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
174
- */
175
- export function detectTerminalEagerEraseScrollbackRisk(
176
- env: NodeJS.ProcessEnv = Bun.env,
177
- platform: NodeJS.Platform = process.platform,
178
- ): boolean {
179
- if (platform === "win32") return false;
180
-
181
- const term = env.TERM?.toLowerCase() ?? "";
182
- const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
183
- const colorTerm = env.COLORTERM?.toLowerCase() ?? "";
184
-
185
- if (env.PI_TUI_ED3_SAFE === "1") return false;
186
- if (env.WT_SESSION) return true;
187
- if (
188
- env.SSH_CONNECTION ||
189
- env.SSH_CLIENT ||
190
- env.SSH_TTY ||
191
- env.TMUX ||
192
- env.STY ||
193
- env.ZELLIJ ||
194
- term.startsWith("tmux") ||
195
- term.startsWith("screen")
196
- ) {
197
- return true;
198
- }
199
- if (
200
- env.WEZTERM_PANE ||
201
- env.KITTY_WINDOW_ID ||
202
- env.GHOSTTY_RESOURCES_DIR ||
203
- env.ALACRITTY_WINDOW_ID ||
204
- env.VTE_VERSION ||
205
- env.ITERM_SESSION_ID
206
- ) {
207
- return true;
208
- }
209
- switch (termProgram) {
210
- case "alacritty":
211
- case "apple_terminal":
212
- case "ghostty":
213
- case "gnome-terminal":
214
- case "iterm.app":
215
- case "kgx":
216
- case "kitty":
217
- case "ptyxis":
218
- case "wezterm":
219
- case "xfce4-terminal":
220
- return true;
221
- default:
222
- break;
223
- }
224
- if (platform === "linux" && (colorTerm === "truecolor" || colorTerm === "24bit")) return true;
225
- // Unknown POSIX terminals have no scroll-position oracle. Treat them as risky
226
- // for passive ED3 until a positive terminal-specific integration proves safe.
227
- return true;
228
- }
229
-
230
- /**
231
- * Whether a prompt-submit keystroke scrolls this terminal to its tail, making the
232
- * native-scrollback reconciliation checkpoint (`refreshNativeScrollbackIfDirty`)
233
- * safe to ED3-rebuild even when the viewport position cannot be probed.
234
- *
235
- * True only for recognized genuine *local* terminals where typing into the prompt
236
- * brings the host viewport to the bottom. False — the checkpoint keeps deferring
237
- * until a positive at-tail probe — for hosts whose scrollback a keystroke does not
238
- * move: Windows consoles/ConPTY, Windows Terminal (incl. WSL), SSH, multiplexers,
239
- * and unrecognized profiles. This is the per-terminal counterpart to the blanket
240
- * block from #1610/#1682/#1746: those hosts genuinely cannot treat a submit as
241
- * proof of at-tail, but genuine local terminals can.
242
- */
243
- export function detectSubmitPinsViewportToTail(
244
- env: NodeJS.ProcessEnv = Bun.env,
245
- platform: NodeJS.Platform = process.platform,
246
- ): boolean {
247
- if (env.PI_TUI_ED3_SAFE === "1") return true;
248
- if (platform === "win32") return false;
249
- if (env.WT_SESSION) return false;
250
- if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return false;
251
- const term = env.TERM?.toLowerCase() ?? "";
252
- if (env.TMUX || env.STY || env.ZELLIJ || term.startsWith("tmux") || term.startsWith("screen")) {
253
- return false;
254
- }
255
- if (
256
- env.WEZTERM_PANE ||
257
- env.KITTY_WINDOW_ID ||
258
- env.GHOSTTY_RESOURCES_DIR ||
259
- env.ALACRITTY_WINDOW_ID ||
260
- env.ITERM_SESSION_ID ||
261
- env.VTE_VERSION
262
- ) {
263
- return true;
264
- }
265
- switch (env.TERM_PROGRAM?.toLowerCase() ?? "") {
266
- case "alacritty":
267
- case "apple_terminal":
268
- case "ghostty":
269
- case "gnome-terminal":
270
- case "iterm.app":
271
- case "kgx":
272
- case "kitty":
273
- case "ptyxis":
274
- case "wezterm":
275
- case "xfce4-terminal":
276
- return true;
277
- default:
278
- return false;
279
- }
280
- }
281
-
282
150
  /**
283
151
  * Resolve an explicit user override for DEC 2026 synchronized output. Returns
284
152
  * `false` for an opt-out, `true` for a force-on, or `null` when the user has
@@ -395,12 +263,12 @@ const KNOWN_TERMINALS = Object.freeze({
395
263
  base: new TerminalInfo("base", null, false, false, NotifyProtocol.Bell),
396
264
  trueColor: new TerminalInfo("trueColor", null, true, false, NotifyProtocol.Bell),
397
265
  // Recognized terminals
398
- kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99, true, true, true, true),
399
- ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, true),
400
- wezterm: new TerminalInfo("wezterm", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, true),
401
- iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9, true),
266
+ kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99, true, true, true),
267
+ ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9),
268
+ wezterm: new TerminalInfo("wezterm", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9),
269
+ iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9),
402
270
  vscode: new TerminalInfo("vscode", null, true, true, NotifyProtocol.Bell),
403
- alacritty: new TerminalInfo("alacritty", null, true, true, NotifyProtocol.Bell, true),
271
+ alacritty: new TerminalInfo("alacritty", null, true, true, NotifyProtocol.Bell),
404
272
  });
405
273
 
406
274
  export const TERMINAL_ID: TerminalId = (() => {
@@ -453,16 +321,13 @@ export const TERMINAL_ID: TerminalId = (() => {
453
321
  export interface RuntimeTerminal extends TerminalInfo {
454
322
  imageProtocol: ImageProtocol | null;
455
323
  hyperlinks: boolean;
456
- eagerEraseScrollbackRisk: boolean;
457
324
  deccara: boolean;
458
325
  supportsScreenToScrollback: boolean;
459
326
  textSizing: boolean;
460
- submitPinsViewportToTail: boolean;
461
327
  }
462
328
 
463
329
  export const TERMINAL: RuntimeTerminal = (() => {
464
330
  const resolved = getTerminalInfo(TERMINAL_ID).clone();
465
- resolved.eagerEraseScrollbackRisk = detectTerminalEagerEraseScrollbackRisk(Bun.env, process.platform);
466
331
 
467
332
  const forcedImageProtocol = getForcedImageProtocol();
468
333
  if (forcedImageProtocol !== undefined) {
@@ -484,11 +349,6 @@ export const TERMINAL: RuntimeTerminal = (() => {
484
349
  // ignores DECCARA) exercises the padded-string fallback. Integration tests opt
485
350
  // in explicitly through setTerminalDeccara.
486
351
  resolved.deccara = detectRectangularSgrSupport(resolved.id, Bun.env) && !isBunTestRuntime();
487
- // A genuine local terminal scrolls to its tail on the submit keystroke, so the
488
- // reconciliation checkpoint may ED3-rebuild on an unprobeable viewport there.
489
- // Forced off under the test runtime (like deccara) so checkpoint tests stay
490
- // deterministic and opt in through setTerminalSubmitPinsViewportToTail.
491
- resolved.submitPinsViewportToTail = detectSubmitPinsViewportToTail(Bun.env, process.platform) && !isBunTestRuntime();
492
352
  return resolved;
493
353
  })();
494
354
 
@@ -519,11 +379,6 @@ export function setTerminalScreenToScrollback(enabled: boolean): void {
519
379
  TERMINAL.supportsScreenToScrollback = enabled;
520
380
  }
521
381
 
522
- /** Override submit-pins-viewport-to-tail for checkpoint reconciliation tests. */
523
- export function setTerminalSubmitPinsViewportToTail(enabled: boolean): void {
524
- TERMINAL.submitPinsViewportToTail = enabled;
525
- }
526
-
527
382
  /**
528
383
  * Enable/disable OSC 66 text-sizing at runtime. The coding-agent calls this from
529
384
  * the `tui.textSizing` setting (gated on the terminal's static `textSizing`
package/src/terminal.ts CHANGED
@@ -202,44 +202,6 @@ export interface Terminal {
202
202
  // Progress indicator (OSC 9;4)
203
203
  setProgress(active: boolean): void;
204
204
 
205
- /**
206
- * Returns whether the native terminal viewport is at the scrollback tail when
207
- * the host exposes that state. `undefined` means the terminal cannot report it.
208
- *
209
- * `ProcessTerminal` deliberately does not implement this — no real terminal
210
- * can answer it truthfully:
211
- *
212
- * - POSIX terminals expose no scrollback-position API at all.
213
- * - Every modern Windows terminal host (Windows Terminal, VS Code, Tabby,
214
- * Hyper, Alacritty, WezTerm, JetBrains, …) fronts console apps through
215
- * ConPTY, where kernel32's `GetConsoleScreenBufferInfo` describes the
216
- * pseudo-console buffer. That buffer is pinned to the visible grid —
217
- * scrollback lives in the host UI, invisible to console APIs
218
- * (microsoft/terminal#10191) — so a probe reads "at bottom" no matter
219
- * where the user scrolled. Trusting it let streaming-time rebuilds emit
220
- * `\x1b[3J` and yank scrolled readers: #1635 (Windows Terminal), #1746
221
- * (Tabby and other ConPTY hosts). No env var distinguishes these hosts
222
- * (Tabby sets none), so trust cannot be conditional on the environment.
223
- * - Legacy conhost (the only non-ConPTY host) keeps a real scrollback
224
- * buffer, but its window follows the output cursor: a probe comparing
225
- * `srWindow.Bottom` against `dwSize.Y - 1` reads "scrolled up" for a user
226
- * following live output until all ~9001 buffer rows fill, permanently
227
- * blocking checkpoint scrollback reconciliation.
228
- *
229
- * The renderer treats a missing implementation / `undefined` as "unknown":
230
- * live mutations defer destructive rebuilds and reconcile native scrollback
231
- * at explicit checkpoints (prompt submit), where the user's keystroke has
232
- * already pinned the host viewport to the bottom. Only test terminals
233
- * (xterm.js-backed) implement this with a real answer.
234
- */
235
- isNativeViewportAtBottom?(): boolean | undefined;
236
-
237
- /**
238
- * Override the global terminal-profile ED3 risk decision for custom/test
239
- * terminals. `undefined` falls back to the resolved `TERMINAL` profile.
240
- */
241
- hasEagerEraseScrollbackRisk?(): boolean | undefined;
242
-
243
205
  /**
244
206
  * Register a callback for terminal appearance (dark/light) changes.
245
207
  * Detection uses OSC 11 background color query with Mode 2031 as a change trigger.