@oh-my-pi/pi-tui 15.9.67 → 15.10.0

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,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.10.0] - 2026-06-06
6
+
7
+ ### Changed
8
+
9
+ - Reworked the DEC 2026 synchronized-output default policy: a positive DECRQM mode-2026 report now **enables** sync (previously a report could only disable it), so conservatively defaulted-off hosts that actually support it — current Zellij, tmux master, foot, contour, mintty — are upgraded at runtime. The static allowlist also covers Alacritty and the VS Code terminal, honors a `TERM_FEATURES` `Sy` advertisement and `WT_SESSION` (Windows Terminal / WSL), and no longer blanket-disables SSH (DEC 2026 passes through to the outer terminal). Risky multiplexers still start off and rely on the probe. Added `synchronizedOutputUserOverride()` as the shared opt-out/force resolver.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed WSL/Windows Terminal row flicker while typing by repainting changed text rows before clearing only their stale suffix ([#2011](https://github.com/can1357/oh-my-pi/issues/2011)).
14
+ - Fixed terminals that support DEC 2026 still tearing/flickering because the renderer ignored a positive DECRQM capability report and kept synchronized output off — most visibly WSL + Windows Terminal, Alacritty (≥0.13), and the VS Code terminal (≥1.108), which were detected yet refused sync.
15
+
16
+ ## [15.9.69] - 2026-06-06
17
+
18
+ ### Added
19
+
20
+ - Added `TUI.resetDisplay()` to force an immediate full-frame replay, including native scrollback when the host can safely clear it.
21
+ - Added `setPaddingY` to `Box` so vertical padding can be updated programmatically after creation.
22
+
23
+ ### Fixed
24
+
25
+ - Fixed DECCARA background-fill optimization running when synchronized output is disabled, which could expose default-background gaps during rapidly updating tool-use panels ([#2000](https://github.com/can1357/oh-my-pi/issues/2000)).
26
+
5
27
  ## [15.9.67] - 2026-06-06
6
28
  ### Added
7
29
 
@@ -10,6 +10,7 @@ export declare class Box implements Component {
10
10
  removeChild(component: Component): void;
11
11
  clear(): void;
12
12
  setPaddingX(paddingX: number): void;
13
+ setPaddingY(paddingY: number): void;
13
14
  setBgFn(bgFn?: (text: string) => string): void;
14
15
  invalidate(): void;
15
16
  render(width: number): string[];
@@ -51,8 +51,29 @@ export declare function isWindowsTerminalPreviewSixelSupported(env?: NodeJS.Proc
51
51
  * outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
52
52
  */
53
53
  export declare function detectTerminalEagerEraseScrollbackRisk(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
54
- /** Whether DEC 2026 synchronized-output wrappers should be enabled by default. */
55
- export declare function shouldEnableSynchronizedOutputByDefault(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, terminalId?: TerminalId): boolean;
54
+ /**
55
+ * Resolve an explicit user override for DEC 2026 synchronized output. Returns
56
+ * `false` for an opt-out, `true` for a force-on, or `null` when the user has
57
+ * expressed no preference. Shared by the static default and the runtime DECRQM
58
+ * probe so both honor the same precedence — an opt-out beats a force-on.
59
+ */
60
+ export declare function synchronizedOutputUserOverride(env?: NodeJS.ProcessEnv): boolean | null;
61
+ /**
62
+ * Whether DEC 2026 synchronized-output wrappers should be enabled by default.
63
+ *
64
+ * Policy (highest precedence first):
65
+ * 1. Explicit user override (`PI_NO_SYNC_OUTPUT`/`PI_TUI_SYNC_OUTPUT=0` off,
66
+ * `PI_FORCE_SYNC_OUTPUT=1`/`PI_TUI_SYNC_OUTPUT=1` on).
67
+ * 2. Positive `TERM_FEATURES` advertisement (`Sy`) — survives SSH/mux wrapping.
68
+ * 3. Windows Terminal (1.24+) via `WT_SESSION`, on native win32 and the
69
+ * WSL/SSH-fronted host alike.
70
+ * 4. Known direct terminals with confirmed support. SSH does *not* disable —
71
+ * DEC 2026 passes through SSH when the outer terminal honors it.
72
+ * 5. Everything else starts off, including risky multiplexers; the runtime
73
+ * DECRQM probe upgrades any of them when the terminal actually reports
74
+ * `?2026` supported (current zellij, tmux master, foot, contour, mintty…).
75
+ */
76
+ export declare function shouldEnableSynchronizedOutputByDefault(env?: NodeJS.ProcessEnv, terminalId?: TerminalId): boolean;
56
77
  /**
57
78
  * Whether the terminal applies Kitty-style DECCARA rectangular SGR changes
58
79
  * (`CSI Pt ; Pl ; Pb ; Pr ; <sgr> $ r`) extended to background color, so large
@@ -215,8 +215,9 @@ export declare class TUI extends Container {
215
215
  setClearOnShrink(enabled: boolean): void;
216
216
  /**
217
217
  * Whether DEC 2026 synchronized-output wrappers are currently emitted around
218
- * paints. Starts from conservative terminal/env detection and is force-disabled
219
- * at runtime if the terminal reports mode 2026 unsupported via DECRQM.
218
+ * paints. Starts from conservative terminal/env detection and is reconciled at
219
+ * runtime against the terminal's DECRQM mode-2026 report enabled on a
220
+ * positive report, disabled on a negative one.
220
221
  */
221
222
  get synchronizedOutput(): boolean;
222
223
  /**
@@ -264,5 +265,20 @@ export declare class TUI extends Container {
264
265
  * at the terminal bottom, such as after submitting a new prompt.
265
266
  */
266
267
  refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean;
268
+ /**
269
+ * Force an immediate full replay of the current frame, including native
270
+ * scrollback. This is the keyboard-accessible equivalent of the resize reset:
271
+ * no queued diff frame or terminal scrollback probe can downgrade it to a
272
+ * viewport-only repaint.
273
+ *
274
+ * Invalidates every component first so the replay reflects current state. A
275
+ * geometry-driven reset thaws frozen scrollback snapshots implicitly (the new
276
+ * width misses every cached snapshot), but a same-width reset would otherwise
277
+ * replay stale snapshots — leaving host-frozen blocks (e.g. a transcript whose
278
+ * committed rows are immutable on ED3-risk terminals) showing pre-mutation
279
+ * content. Invalidation is the generic signal those containers use to retire
280
+ * their snapshots, which is exactly what a user-driven display reset wants.
281
+ */
282
+ resetDisplay(): void;
267
283
  requestRender(force?: boolean, options?: RenderRequestOptions): void;
268
284
  }
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.9.67",
4
+ "version": "15.10.0",
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.9.67",
41
- "@oh-my-pi/pi-utils": "15.9.67",
40
+ "@oh-my-pi/pi-natives": "15.10.0",
41
+ "@oh-my-pi/pi-utils": "15.10.0",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -48,6 +48,12 @@ export class Box implements Component {
48
48
  this.#invalidateCache();
49
49
  }
50
50
 
51
+ setPaddingY(paddingY: number): void {
52
+ if (this.#paddingY === paddingY) return;
53
+ this.#paddingY = paddingY;
54
+ this.#invalidateCache();
55
+ }
56
+
51
57
  setBgFn(bgFn?: (text: string) => string): void {
52
58
  this.#bgFn = bgFn;
53
59
  // Don't invalidate here - we'll detect bgFn changes by sampling output
@@ -187,41 +187,76 @@ export function detectTerminalEagerEraseScrollbackRisk(
187
187
  return true;
188
188
  }
189
189
 
190
- /** Whether DEC 2026 synchronized-output wrappers should be enabled by default. */
190
+ /**
191
+ * Resolve an explicit user override for DEC 2026 synchronized output. Returns
192
+ * `false` for an opt-out, `true` for a force-on, or `null` when the user has
193
+ * expressed no preference. Shared by the static default and the runtime DECRQM
194
+ * probe so both honor the same precedence — an opt-out beats a force-on.
195
+ */
196
+ export function synchronizedOutputUserOverride(env: NodeJS.ProcessEnv = Bun.env): boolean | null {
197
+ if (env.PI_NO_SYNC_OUTPUT || env.PI_TUI_SYNC_OUTPUT === "0") return false;
198
+ if (env.PI_FORCE_SYNC_OUTPUT === "1" || env.PI_TUI_SYNC_OUTPUT === "1") return true;
199
+ return null;
200
+ }
201
+
202
+ /**
203
+ * Whether `TERM_FEATURES` advertises DEC 2026 synchronized output via the `Sy`
204
+ * capability token. `TERM_FEATURES` is a run of capitalized two-letter codes
205
+ * (e.g. `…Sy…`), so a case-sensitive substring match is unambiguous: `Sy`
206
+ * cannot straddle a code boundary because those are always lowercase→uppercase.
207
+ */
208
+ function advertisesSynchronizedOutput(termFeatures: string | undefined): boolean {
209
+ return termFeatures?.includes("Sy") ?? false;
210
+ }
211
+
212
+ /**
213
+ * Whether DEC 2026 synchronized-output wrappers should be enabled by default.
214
+ *
215
+ * Policy (highest precedence first):
216
+ * 1. Explicit user override (`PI_NO_SYNC_OUTPUT`/`PI_TUI_SYNC_OUTPUT=0` off,
217
+ * `PI_FORCE_SYNC_OUTPUT=1`/`PI_TUI_SYNC_OUTPUT=1` on).
218
+ * 2. Positive `TERM_FEATURES` advertisement (`Sy`) — survives SSH/mux wrapping.
219
+ * 3. Windows Terminal (1.24+) via `WT_SESSION`, on native win32 and the
220
+ * WSL/SSH-fronted host alike.
221
+ * 4. Known direct terminals with confirmed support. SSH does *not* disable —
222
+ * DEC 2026 passes through SSH when the outer terminal honors it.
223
+ * 5. Everything else starts off, including risky multiplexers; the runtime
224
+ * DECRQM probe upgrades any of them when the terminal actually reports
225
+ * `?2026` supported (current zellij, tmux master, foot, contour, mintty…).
226
+ */
191
227
  export function shouldEnableSynchronizedOutputByDefault(
192
228
  env: NodeJS.ProcessEnv = Bun.env,
193
- platform: NodeJS.Platform = process.platform,
194
229
  terminalId: TerminalId = TERMINAL_ID,
195
230
  ): boolean {
196
- if (env.PI_NO_SYNC_OUTPUT || env.PI_TUI_SYNC_OUTPUT === "0") return false;
197
- if (env.PI_FORCE_SYNC_OUTPUT === "1" || env.PI_TUI_SYNC_OUTPUT === "1") return true;
198
- if (platform === "win32") return false;
231
+ const override = synchronizedOutputUserOverride(env);
232
+ if (override !== null) return override;
199
233
 
234
+ if (advertisesSynchronizedOutput(env.TERM_FEATURES)) return true;
235
+ if (env.WT_SESSION) return true;
236
+
237
+ // Risky multiplexers start off even when an inner terminal id leaks through:
238
+ // older tmux/screen synchronized-output handling is flaky and a mux may not
239
+ // pass DEC 2026 to the outer host. The DECRQM probe re-enables sync when the
240
+ // mux reports `?2026` supported.
200
241
  const term = env.TERM?.toLowerCase() ?? "";
201
- const termProgram = env.TERM_PROGRAM?.toLowerCase() ?? "";
202
- if (
203
- env.SSH_CONNECTION ||
204
- env.SSH_CLIENT ||
205
- env.SSH_TTY ||
206
- env.TMUX ||
207
- env.STY ||
208
- env.ZELLIJ ||
209
- term.startsWith("tmux") ||
210
- term.startsWith("screen")
211
- ) {
242
+ if (env.TMUX || env.STY || env.ZELLIJ || term.startsWith("tmux") || term.startsWith("screen")) {
212
243
  return false;
213
244
  }
214
- if (env.VTE_VERSION) return false;
215
- switch (termProgram) {
216
- case "gnome-terminal":
217
- case "kgx":
218
- case "ptyxis":
219
- case "xfce4-terminal":
220
- return false;
245
+
246
+ switch (terminalId) {
247
+ case "kitty":
248
+ case "ghostty":
249
+ case "wezterm":
250
+ case "iterm2":
251
+ case "alacritty":
252
+ case "vscode":
253
+ return true;
221
254
  default:
222
- break;
255
+ // VTE family, GNU screen, Apple Terminal, legacy native console host
256
+ // (no WT_SESSION), and bare/unknown xterm profiles stay off until the
257
+ // DECRQM probe proves support.
258
+ return false;
223
259
  }
224
- return terminalId === "kitty" || terminalId === "ghostty" || terminalId === "wezterm" || terminalId === "iterm2";
225
260
  }
226
261
 
227
262
  /**
package/src/tui.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  setCellDimensions,
25
25
  setTerminalImageProtocol,
26
26
  shouldEnableSynchronizedOutputByDefault,
27
+ synchronizedOutputUserOverride,
27
28
  TERMINAL,
28
29
  } from "./terminal-capabilities";
29
30
  import {
@@ -44,6 +45,8 @@ const SEGMENT_RESET = "\x1b[0m";
44
45
  * diffing so `#previousLines` mirrors what was actually written.
45
46
  */
46
47
  const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
48
+ const ERASE_LINE = "\x1b[2K";
49
+ const ERASE_TO_END_OF_LINE = "\x1b[K";
47
50
  // Hide the hardware cursor before each paint/move write. Ghostty-style bar
48
51
  // cursors can otherwise leave visual afterimages while the TUI repaints the
49
52
  // row under a visible cursor. Paint writes also disable terminal autowrap:
@@ -340,7 +343,8 @@ export class Container implements Component {
340
343
  width = Math.max(1, width);
341
344
  const lines: string[] = [];
342
345
  for (const child of this.children) {
343
- lines.push(...child.render(width));
346
+ const childLines = child.render(width);
347
+ for (let i = 0; i < childLines.length; i++) lines.push(childLines[i]);
344
348
  }
345
349
  return lines;
346
350
  }
@@ -395,6 +399,11 @@ type RenderIntent =
395
399
  export class TUI extends Container {
396
400
  terminal: Terminal;
397
401
  #previousLines: string[] = [];
402
+ // Per-frame cache of #fitLineToWidth results. Cleared at the top of every
403
+ // #doRender (where the frame width is fixed), so it only ever holds entries
404
+ // for one width. Eliminates the duplicate fit work between the compose pass
405
+ // and the emitters, plus repeated fits of identical blank padding rows.
406
+ #fitLineCache = new Map<string, string>();
398
407
  #previousWidth = 0;
399
408
  #previousHeight = 0;
400
409
  #focusedComponent: Component | null = null;
@@ -507,7 +516,7 @@ export class TUI extends Container {
507
516
  this.#nativeScrollbackCommitSafeEnd = offset + boundedEnd;
508
517
  }
509
518
  }
510
- lines.push(...childLines);
519
+ for (let i = 0; i < childLines.length; i++) lines.push(childLines[i]);
511
520
  }
512
521
  return lines;
513
522
  }
@@ -565,12 +574,18 @@ export class TUI extends Container {
565
574
 
566
575
  /**
567
576
  * Whether DEC 2026 synchronized-output wrappers are currently emitted around
568
- * paints. Starts from conservative terminal/env detection and is force-disabled
569
- * at runtime if the terminal reports mode 2026 unsupported via DECRQM.
577
+ * paints. Starts from conservative terminal/env detection and is reconciled at
578
+ * runtime against the terminal's DECRQM mode-2026 report enabled on a
579
+ * positive report, disabled on a negative one.
570
580
  */
571
581
  get synchronizedOutput(): boolean {
572
582
  return this.#synchronizedOutputEnabled;
573
583
  }
584
+ #deccaraFillsEnabled(): boolean {
585
+ // DECCARA fill rectangles arrive after shortened row text; synchronized
586
+ // output hides that intermediate default-background state from users.
587
+ return TERMINAL.deccara && this.#synchronizedOutputEnabled;
588
+ }
574
589
 
575
590
  /**
576
591
  * When enabled, live render frames rebuild native scrollback on offscreen and
@@ -731,14 +746,15 @@ export class TUI extends Container {
731
746
 
732
747
  start(): void {
733
748
  this.#stopped = false;
734
- // Disable synchronized output if the terminal reports DEC 2026 unsupported
735
- // via DECRQM. PI_NO_SYNC_OUTPUT already forces it off at construction, so
736
- // only react when the user has not already opted out. Future paints drop
737
- // the begin/end markers; the autowrap guards stay (see #1765).
749
+ // A DECRQM report for mode 2026 is authoritative: enable synchronized
750
+ // output when the terminal reports support (upgrading conservatively
751
+ // defaulted-off hosts like zellij/tmux-master/foot) and disable it when
752
+ // the terminal reports it unsupported. An explicit user opt-out/force
753
+ // (resolved at construction) still wins, so skip the probe in that case.
738
754
  this.terminal.onPrivateModeReport?.((mode, supported) => {
739
- if (mode === 2026 && !supported && !$flag("PI_NO_SYNC_OUTPUT")) {
740
- this.#setSynchronizedOutput(false);
741
- }
755
+ if (mode !== 2026) return;
756
+ if (synchronizedOutputUserOverride() !== null) return;
757
+ this.#setSynchronizedOutput(supported);
742
758
  });
743
759
  this.terminal.start(
744
760
  data => this.#handleInput(data),
@@ -901,8 +917,8 @@ export class TUI extends Container {
901
917
 
902
918
  /**
903
919
  * Toggle synchronized-output (DEC 2026) wrappers on paint/cursor writes and
904
- * recompute the cached begin/end sequences. Honors a DECRQM report that the
905
- * terminal does not support 2026 (#1765 covers the static env opt-out).
920
+ * recompute the cached begin/end sequences. Driven by the terminal's DECRQM
921
+ * mode-2026 report (#1765 covers the static env opt-out).
906
922
  */
907
923
  #setSynchronizedOutput(enabled: boolean): void {
908
924
  if (this.#synchronizedOutputEnabled === enabled) return;
@@ -974,6 +990,30 @@ export class TUI extends Container {
974
990
  return true;
975
991
  }
976
992
 
993
+ /**
994
+ * Force an immediate full replay of the current frame, including native
995
+ * scrollback. This is the keyboard-accessible equivalent of the resize reset:
996
+ * no queued diff frame or terminal scrollback probe can downgrade it to a
997
+ * viewport-only repaint.
998
+ *
999
+ * Invalidates every component first so the replay reflects current state. A
1000
+ * geometry-driven reset thaws frozen scrollback snapshots implicitly (the new
1001
+ * width misses every cached snapshot), but a same-width reset would otherwise
1002
+ * replay stale snapshots — leaving host-frozen blocks (e.g. a transcript whose
1003
+ * committed rows are immutable on ED3-risk terminals) showing pre-mutation
1004
+ * content. Invalidation is the generic signal those containers use to retire
1005
+ * their snapshots, which is exactly what a user-driven display reset wants.
1006
+ */
1007
+ resetDisplay(): void {
1008
+ if (this.#stopped) return;
1009
+ this.invalidate();
1010
+ this.#prepareForcedRender(!isMultiplexerSession(), true);
1011
+ this.#resizeEventPending = true;
1012
+ this.#renderRequested = false;
1013
+ this.#lastRenderAt = this.#renderScheduler.now();
1014
+ this.#doRender();
1015
+ }
1016
+
977
1017
  requestRender(force = false, options?: RenderRequestOptions): void {
978
1018
  const allowUnknownViewportMutation = options?.allowUnknownViewportMutation === true;
979
1019
  this.#allowUnknownViewportMutationOnNextRender ||= allowUnknownViewportMutation;
@@ -1439,6 +1479,9 @@ export class TUI extends Container {
1439
1479
  if (this.#stopped) return;
1440
1480
  const width = this.terminal.columns;
1441
1481
  const height = this.terminal.rows;
1482
+ // Reset the per-frame fit memo: width is fixed for this frame, so cached
1483
+ // fit results stay valid across the compose pass and every emitter re-fit.
1484
+ this.#fitLineCache.clear();
1442
1485
 
1443
1486
  // 1. Compose the frame. Bracket the transcript render so the image budget
1444
1487
  // observes every inline image in display order (overlays carry none).
@@ -2256,8 +2299,8 @@ export class TUI extends Container {
2256
2299
  // Multiplexers (tmux/screen/zellij) cannot erase pane history with `\x1b[3J`
2257
2300
  // and cannot answer a viewport-position probe, so the destructive checkpoint
2258
2301
  // rebuild path is forever unavailable. The pinned emitter is built from the
2259
- // opposite primitives — relative cursor moves, per-line `\x1b[2K`, and
2260
- // `\r\n` to scroll sealed rows past the viewport bottom — which are exactly
2302
+ // opposite primitives — relative cursor moves, per-row rewrite/suffix-clear,
2303
+ // and `\r\n` to scroll sealed rows past the viewport bottom — which are exactly
2261
2304
  // what tmux pane history accepts. Without this commit-as-you-go path, the
2262
2305
  // streaming cap below clipped every frame to the visible tail and the
2263
2306
  // scrolled-off head was committed nowhere (issue #1974).
@@ -2323,10 +2366,31 @@ export class TUI extends Container {
2323
2366
  }
2324
2367
 
2325
2368
  #fitLineToWidth(line: string, width: number): string {
2326
- if (TERMINAL.isImageLine(line)) return line;
2327
- if (visibleWidth(line) <= width) return line;
2328
- const truncated = truncateToWidth(line, width, Ellipsis.Omit);
2329
- return truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
2369
+ // Frame-scoped memo: #doRender clears this each frame after reading the
2370
+ // terminal width, so within a frame `width` is constant and this map is
2371
+ // keyed by line alone. The compose/fit pass (#fitLinesToWidth) and every
2372
+ // emitter re-fit the same lines (and many repeated blank rows); the result
2373
+ // is pure for a fixed width, so caching it is byte-identical and skips the
2374
+ // redundant native visibleWidth/truncate work.
2375
+ const cached = this.#fitLineCache.get(line);
2376
+ if (cached !== undefined) return cached;
2377
+ let result: string;
2378
+ if (TERMINAL.isImageLine(line)) {
2379
+ result = line;
2380
+ } else if (visibleWidth(line) <= width) {
2381
+ result = line;
2382
+ } else {
2383
+ const truncated = truncateToWidth(line, width, Ellipsis.Omit);
2384
+ result = truncated + (truncated.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
2385
+ }
2386
+ this.#fitLineCache.set(line, result);
2387
+ return result;
2388
+ }
2389
+
2390
+ #lineRewriteSequence(line: string, width: number): string {
2391
+ const fitted = this.#fitLineToWidth(line, width);
2392
+ if (TERMINAL.isImageLine(fitted)) return ERASE_LINE + fitted;
2393
+ return visibleWidth(fitted) >= width ? fitted : fitted + ERASE_TO_END_OF_LINE;
2330
2394
  }
2331
2395
 
2332
2396
  /**
@@ -2389,7 +2453,7 @@ export class TUI extends Container {
2389
2453
  const visibleStart = Math.max(0, lines.length - height);
2390
2454
  let fillSequence = "";
2391
2455
  let visibleTexts: string[] | null = null;
2392
- if (TERMINAL.deccara && visibleStart < lines.length) {
2456
+ if (this.#deccaraFillsEnabled() && visibleStart < lines.length) {
2393
2457
  const visible: string[] = new Array(lines.length - visibleStart);
2394
2458
  for (let k = 0; k < visible.length; k++) {
2395
2459
  visible[k] = this.#fitLineToWidth(lines[visibleStart + k], width);
@@ -2489,14 +2553,13 @@ export class TUI extends Container {
2489
2553
  for (let screenRow = 0; screenRow < height; screenRow++) {
2490
2554
  visible[screenRow] = this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width);
2491
2555
  }
2492
- const { texts, sequence } = TERMINAL.deccara
2556
+ const { texts, sequence } = this.#deccaraFillsEnabled()
2493
2557
  ? planDeccaraFills(visible, width)
2494
2558
  : { texts: visible, sequence: "" };
2495
2559
  let buffer = `${this.#paintBeginSequence}\x1b[H`;
2496
2560
  for (let screenRow = 0; screenRow < height; screenRow++) {
2497
2561
  if (screenRow > 0) buffer += "\r\n";
2498
- buffer += "\x1b[2K";
2499
- buffer += texts[screenRow];
2562
+ buffer += this.#lineRewriteSequence(texts[screenRow], width);
2500
2563
  }
2501
2564
  // DECCARA rectangles paint the visible fills before cursor positioning;
2502
2565
  // the cleared cells written above are what the rectangles repaint.
@@ -2534,8 +2597,8 @@ export class TUI extends Container {
2534
2597
  * leaving the transient live region out of saved lines.
2535
2598
  *
2536
2599
  * Uses only the no-scroll-snap vocabulary of {@link #emitDiff}: relative
2537
- * cursor moves, per-line `\x1b[2K`, and `\r\n` to push the sealed chunk into
2538
- * history. It deliberately avoids a full-screen erase (`\x1b[2J`) and absolute
2600
+ * cursor moves, per-row rewrite/suffix-clear, and `\r\n` to push the sealed
2601
+ * chunk into history. It deliberately avoids a full-screen erase (`\x1b[2J`) and absolute
2539
2602
  * cursor home (`\x1b[H`): on Ghostty those snap a reader scrolled into history
2540
2603
  * back to the bottom on every frame.
2541
2604
  */
@@ -2567,17 +2630,18 @@ export class TUI extends Container {
2567
2630
 
2568
2631
  // Write the sealed chunk followed by the full viewport from the top row.
2569
2632
  // The first (boundedAppendTo - boundedAppendFrom) rows scroll into native
2570
- // history; the trailing `height` rows fill the viewport. Each row clears
2571
- // itself with `\x1b[2K` instead of relying on a screen-wide erase.
2633
+ // history; the trailing `height` rows fill the viewport. Text rows overwrite
2634
+ // first and clear only the suffix so non-synchronized hosts do not visibly
2635
+ // blank stable content before repainting it.
2572
2636
  let wroteLine = false;
2573
2637
  for (let i = boundedAppendFrom; i < boundedAppendTo; i++) {
2574
2638
  if (wroteLine) buffer += "\r\n";
2575
- buffer += `\x1b[2K${this.#fitLineToWidth(lines[i] ?? "", width)}`;
2639
+ buffer += this.#lineRewriteSequence(lines[i] ?? "", width);
2576
2640
  wroteLine = true;
2577
2641
  }
2578
2642
  for (let screenRow = 0; screenRow < height; screenRow++) {
2579
2643
  if (wroteLine) buffer += "\r\n";
2580
- buffer += `\x1b[2K${this.#fitLineToWidth(lines[viewportTop + screenRow] ?? "", width)}`;
2644
+ buffer += this.#lineRewriteSequence(lines[viewportTop + screenRow] ?? "", width);
2581
2645
  wroteLine = true;
2582
2646
  }
2583
2647
 
@@ -2656,7 +2720,7 @@ export class TUI extends Container {
2656
2720
  const currentScreenRow = Math.max(0, Math.min(height - 1, clampedCursor - prevViewportTop));
2657
2721
  const moveDown = height - 1 - currentScreenRow;
2658
2722
  if (moveDown > 0) buffer += `\x1b[${moveDown}B`;
2659
- buffer += `\r\x1b[2K${this.#fitLineToWidth(line, width)}\x1b[?25l`;
2723
+ buffer += `\r${this.#lineRewriteSequence(line, width)}\x1b[?25l`;
2660
2724
  buffer += this.#paintEndSequence;
2661
2725
  this.terminal.write(buffer);
2662
2726
 
@@ -2799,7 +2863,12 @@ export class TUI extends Container {
2799
2863
  const fillStart = Math.max(firstChanged, fillViewportTop);
2800
2864
  let fillSequence = "";
2801
2865
  let fillTexts: string[] | null = null;
2802
- if (TERMINAL.deccara && !appendStart && moveTargetRow <= prevViewportBottom && renderEnd >= fillStart) {
2866
+ if (
2867
+ this.#deccaraFillsEnabled() &&
2868
+ !appendStart &&
2869
+ moveTargetRow <= prevViewportBottom &&
2870
+ renderEnd >= fillStart
2871
+ ) {
2803
2872
  const slice: string[] = new Array(renderEnd - fillStart + 1);
2804
2873
  for (let i = fillStart; i <= renderEnd; i++) {
2805
2874
  slice[i - fillStart] = this.#fitLineToWidth(lines[i], width);
@@ -2810,8 +2879,7 @@ export class TUI extends Container {
2810
2879
  }
2811
2880
  for (let i = firstChanged; i <= renderEnd; i++) {
2812
2881
  if (i > firstChanged) buffer += "\r\n";
2813
- buffer += "\x1b[2K";
2814
- buffer += fillTexts && i >= fillStart ? fillTexts[i - fillStart] : this.#fitLineToWidth(lines[i], width);
2882
+ buffer += this.#lineRewriteSequence(fillTexts && i >= fillStart ? fillTexts[i - fillStart] : lines[i], width);
2815
2883
  }
2816
2884
 
2817
2885
  // If the prior frame was taller, clear the trailing rows.