@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 +22 -0
- package/dist/types/components/box.d.ts +1 -0
- package/dist/types/terminal-capabilities.d.ts +23 -2
- package/dist/types/tui.d.ts +18 -2
- package/package.json +3 -3
- package/src/components/box.ts +6 -0
- package/src/terminal-capabilities.ts +60 -25
- package/src/tui.ts +101 -33
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
|
-
/**
|
|
55
|
-
|
|
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
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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
|
|
219
|
-
*
|
|
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.
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.
|
|
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
|
},
|
package/src/components/box.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
197
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
switch (
|
|
216
|
-
case "
|
|
217
|
-
case "
|
|
218
|
-
case "
|
|
219
|
-
case "
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
569
|
-
*
|
|
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
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
// the
|
|
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
|
|
740
|
-
|
|
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.
|
|
905
|
-
*
|
|
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-
|
|
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
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
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 (
|
|
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 } =
|
|
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 +=
|
|
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-
|
|
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.
|
|
2571
|
-
//
|
|
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 +=
|
|
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 +=
|
|
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
|
|
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 (
|
|
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 +=
|
|
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.
|