@oh-my-pi/pi-tui 16.5.2 → 17.0.1
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 +27 -0
- package/dist/types/components/editor.d.ts +2 -0
- package/dist/types/components/markdown.d.ts +10 -2
- package/dist/types/terminal.d.ts +5 -3
- package/dist/types/tui.d.ts +11 -1
- package/package.json +3 -3
- package/src/autocomplete.ts +14 -16
- package/src/components/editor.ts +26 -3
- package/src/components/markdown.ts +268 -31
- package/src/latex-block.ts +110 -2
- package/src/terminal-capabilities.ts +50 -3
- package/src/terminal.ts +29 -22
- package/src/tui.ts +66 -50
|
@@ -36,6 +36,38 @@ export type TerminalId =
|
|
|
36
36
|
| "base"
|
|
37
37
|
| "trueColor";
|
|
38
38
|
|
|
39
|
+
const CMUX_NOTIFICATION_TITLE = "Oh My Pi";
|
|
40
|
+
const CMUX_SURFACE_ID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Route a notification through cmux when the process belongs to a concrete
|
|
44
|
+
* surface. Workspace/socket state alone is not enough: only the injected
|
|
45
|
+
* surface UUID identifies the pane that should receive the notification.
|
|
46
|
+
* Returns whether cmux owns delivery so the caller can preserve every existing
|
|
47
|
+
* terminal fallback unchanged when no valid surface is present.
|
|
48
|
+
*/
|
|
49
|
+
function sendCmuxNotification(message: string | TerminalNotification, env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
50
|
+
const surfaceId = env.CMUX_SURFACE_ID?.trim();
|
|
51
|
+
if (!surfaceId || !CMUX_SURFACE_ID_PATTERN.test(surfaceId)) return false;
|
|
52
|
+
|
|
53
|
+
const title =
|
|
54
|
+
typeof message === "string" ? CMUX_NOTIFICATION_TITLE : message.title?.trim() || CMUX_NOTIFICATION_TITLE;
|
|
55
|
+
const body = typeof message === "string" ? message : (message.body ?? "");
|
|
56
|
+
try {
|
|
57
|
+
const child = Bun.spawn({
|
|
58
|
+
cmd: ["cmux", "notify", "--surface", surfaceId, "--title", title, "--body", body],
|
|
59
|
+
stdin: "ignore",
|
|
60
|
+
stdout: "ignore",
|
|
61
|
+
stderr: "ignore",
|
|
62
|
+
});
|
|
63
|
+
child.unref();
|
|
64
|
+
} catch {
|
|
65
|
+
// A missing cmux binary leaves delivery to the existing terminal fallback.
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
39
71
|
function hasNeedleBefore(line: string, needle: string, limit: number): boolean {
|
|
40
72
|
const index = line.indexOf(needle);
|
|
41
73
|
return index !== -1 && index + needle.length <= limit;
|
|
@@ -111,6 +143,7 @@ export class TerminalInfo {
|
|
|
111
143
|
|
|
112
144
|
sendNotification(message: string | TerminalNotification): void {
|
|
113
145
|
if (isNotificationSuppressed() || isTerminalHeadless()) return;
|
|
146
|
+
if (sendCmuxNotification(message)) return;
|
|
114
147
|
const formatted = this.formatNotification(message);
|
|
115
148
|
// Under tmux, terminals whose notify protocol is OSC 9 / OSC 99 would
|
|
116
149
|
// otherwise lose the notification entirely: tmux does not forward bare
|
|
@@ -952,11 +985,25 @@ export function renderImage(
|
|
|
952
985
|
|
|
953
986
|
if (TERMINAL.imageProtocol === ImageProtocol.Sixel) {
|
|
954
987
|
try {
|
|
955
|
-
|
|
956
|
-
|
|
988
|
+
// SIXEL encodes in 6-pixel vertical bands. A height that is not a
|
|
989
|
+
// multiple of 6 is padded with transparent rows, but the terminal
|
|
990
|
+
// still allocates cell rows for the padded height. When the padded
|
|
991
|
+
// height crosses a cell boundary the terminal uses one more row
|
|
992
|
+
// than fit.rows, so the next line of content overwrites the bottom
|
|
993
|
+
// of the image — a visible slice stripped from the image. Round the
|
|
994
|
+
// encode height DOWN to the largest multiple of 6 that fits within
|
|
995
|
+
// the requested row budget, so the band boundary aligns without
|
|
996
|
+
// padding and the reserved row count never exceeds fit.rows. Scale
|
|
997
|
+
// the width by the same ratio so resize_exact preserves the aspect
|
|
998
|
+
// ratio instead of squashing the image vertically.
|
|
999
|
+
const rawHeightPx = Math.max(1, fit.rows * cellDims.heightPx);
|
|
1000
|
+
const targetHeightPx = Math.max(6, Math.floor(rawHeightPx / 6) * 6);
|
|
1001
|
+
const heightScale = targetHeightPx / rawHeightPx;
|
|
1002
|
+
const targetWidthPx = Math.max(1, Math.round(fit.columns * cellDims.widthPx * heightScale));
|
|
1003
|
+
const rows = Math.max(1, Math.ceil(targetHeightPx / cellDims.heightPx));
|
|
957
1004
|
const decoded = new Uint8Array(Buffer.from(base64Data, "base64"));
|
|
958
1005
|
const sequence = encodeSixel(decoded, targetWidthPx, targetHeightPx);
|
|
959
|
-
return { sequence, rows
|
|
1006
|
+
return { sequence, rows };
|
|
960
1007
|
} catch {
|
|
961
1008
|
return null;
|
|
962
1009
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -12,12 +12,11 @@ import {
|
|
|
12
12
|
import { setKittyProtocolActive } from "./keys";
|
|
13
13
|
import { StdinBuffer } from "./stdin-buffer";
|
|
14
14
|
import {
|
|
15
|
-
|
|
15
|
+
isInsideTerminalMultiplexer,
|
|
16
16
|
NotifyProtocol,
|
|
17
17
|
setCellDimensions,
|
|
18
18
|
setOsc99Supported,
|
|
19
19
|
TERMINAL,
|
|
20
|
-
wrapTmuxPassthrough,
|
|
21
20
|
} from "./terminal-capabilities";
|
|
22
21
|
import { type HangulCompatibilityJamoWidth, setHangulCompatibilityJamoWidth } from "./utils";
|
|
23
22
|
|
|
@@ -45,7 +44,6 @@ export function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(
|
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
function shouldEnableModifyOtherKeysFallback(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
48
|
-
if (isInsideTmux(env)) return false;
|
|
49
47
|
if (!env.SSH_CONNECTION && !env.SSH_TTY && !env.SSH_CLIENT) return true;
|
|
50
48
|
return TERMINAL.id !== "base" && TERMINAL.id !== "trueColor";
|
|
51
49
|
}
|
|
@@ -408,9 +406,11 @@ export interface Terminal {
|
|
|
408
406
|
get appearance(): TerminalAppearance | undefined;
|
|
409
407
|
/**
|
|
410
408
|
* Register a callback fired once per DEC private mode when its DECRQM support
|
|
411
|
-
* status resolves.
|
|
409
|
+
* status resolves. `confirmed` is false when the terminal answered the DA1
|
|
410
|
+
* sentinel without answering DECRQM, which proves only that querying support
|
|
411
|
+
* is unavailable — not that the private mode itself is unsupported.
|
|
412
412
|
*/
|
|
413
|
-
onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void;
|
|
413
|
+
onPrivateModeReport?(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
|
|
414
414
|
}
|
|
415
415
|
|
|
416
416
|
/**
|
|
@@ -498,7 +498,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
498
498
|
#da1SentinelOwners: Da1SentinelOwner[] = [];
|
|
499
499
|
/** Resolved DECRQM support per private mode (mode → supported). */
|
|
500
500
|
#privateModeSupport = new Map<number, boolean>();
|
|
501
|
-
#privateModeCallbacks: Array<(mode: number, supported: boolean) => void> = [];
|
|
501
|
+
#privateModeCallbacks: Array<(mode: number, supported: boolean, confirmed: boolean) => void> = [];
|
|
502
502
|
/** Whether DEC 2048 in-band resize notifications are currently enabled. */
|
|
503
503
|
#inBandResizeActive = false;
|
|
504
504
|
/** Reassembly buffer for a DEC 2048 in-band resize report split across stdin reads. */
|
|
@@ -550,7 +550,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
550
550
|
}
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
-
onPrivateModeReport(callback: (mode: number, supported: boolean) => void): void {
|
|
553
|
+
onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void {
|
|
554
554
|
this.#privateModeCallbacks.push(callback);
|
|
555
555
|
}
|
|
556
556
|
|
|
@@ -891,8 +891,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
891
891
|
break;
|
|
892
892
|
}
|
|
893
893
|
case "privateMode": {
|
|
894
|
-
// DA1 beat the DECRPM reply
|
|
895
|
-
|
|
894
|
+
// DA1 beat the DECRPM reply. The terminal cannot report this
|
|
895
|
+
// capability, but may still implement it; keep that distinction
|
|
896
|
+
// so static terminal detection is not incorrectly downgraded.
|
|
897
|
+
this.#resolvePrivateMode(owner.mode, false, false);
|
|
896
898
|
break;
|
|
897
899
|
}
|
|
898
900
|
case "keyboard": {
|
|
@@ -1042,6 +1044,15 @@ export class ProcessTerminal implements Terminal {
|
|
|
1042
1044
|
|
|
1043
1045
|
#shouldQueryOsc99Support(): boolean {
|
|
1044
1046
|
if (TERMINAL.notifyProtocol !== NotifyProtocol.Osc99) return false;
|
|
1047
|
+
// Never probe inside a terminal multiplexer. tmux/screen forward the
|
|
1048
|
+
// passthrough-wrapped `p=?` query to the outer terminal, but cannot route
|
|
1049
|
+
// the capability reply back to the pane that sent it (tmux/tmux#4386,
|
|
1050
|
+
// tmux/tmux#3964), so the reply leaks into the pane as literal text and
|
|
1051
|
+
// its bytes perturb input (issue #5582 — the notification sibling of the
|
|
1052
|
+
// graphics-probe leak #5381). Rich notifications fall back to the
|
|
1053
|
+
// single-line OSC 99 form until confirmation, and delivery still uses the
|
|
1054
|
+
// passthrough/BEL path (#3395).
|
|
1055
|
+
if (isInsideTerminalMultiplexer($env)) return false;
|
|
1045
1056
|
return !isBunTestRuntime() || $env.PI_TUI_OSC99_PROBE === "1";
|
|
1046
1057
|
}
|
|
1047
1058
|
|
|
@@ -1055,14 +1066,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
1055
1066
|
const id = `omp-probe-${nextOsc99ProbeId++}`;
|
|
1056
1067
|
this.#osc99PendingId = id;
|
|
1057
1068
|
this.#da1SentinelOwners.push({ kind: "osc99Probe", id });
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
|
|
1061
|
-
// inside tmux even when the outer terminal speaks OSC 99, and rich
|
|
1062
|
-
// notifications stay permanently downgraded to the single-line fallback.
|
|
1063
|
-
const probe = `\x1b]99;i=${id}:p=?;\x1b\\`;
|
|
1064
|
-
const sequence = isInsideTmux() ? wrapTmuxPassthrough(probe) : probe;
|
|
1065
|
-
this.#safeWrite(`${sequence}\x1b[c`);
|
|
1069
|
+
// The probe never runs under a multiplexer (see #shouldQueryOsc99Support),
|
|
1070
|
+
// so it is always sent directly to the terminal.
|
|
1071
|
+
this.#safeWrite(`\x1b]99;i=${id}:p=?;\x1b\\\x1b[c`);
|
|
1066
1072
|
}
|
|
1067
1073
|
|
|
1068
1074
|
#handleOsc99CapabilityResponse(metaRaw: string, payload: string): boolean {
|
|
@@ -1154,7 +1160,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1154
1160
|
}
|
|
1155
1161
|
|
|
1156
1162
|
#handlePrivateModeReport(mode: number, status: string): void {
|
|
1157
|
-
this.#resolvePrivateMode(mode, isPrivateModeSupported(status));
|
|
1163
|
+
this.#resolvePrivateMode(mode, isPrivateModeSupported(status), true);
|
|
1158
1164
|
if (isXtermScrollToBottomMode(mode) && isPrivateModeSet(status)) {
|
|
1159
1165
|
this.#disableXtermScrollToBottomMode(mode);
|
|
1160
1166
|
}
|
|
@@ -1162,15 +1168,16 @@ export class ProcessTerminal implements Terminal {
|
|
|
1162
1168
|
|
|
1163
1169
|
/**
|
|
1164
1170
|
* Record DECRQM support for a private mode (idempotent — first result wins)
|
|
1165
|
-
* and notify subscribers.
|
|
1166
|
-
*
|
|
1171
|
+
* and notify subscribers. `confirmed` distinguishes an explicit DECRPM
|
|
1172
|
+
* unsupported response from an absent response followed by the DA1 sentinel.
|
|
1173
|
+
* Enables DEC 2048 in-band resize only after positive confirmation.
|
|
1167
1174
|
*/
|
|
1168
|
-
#resolvePrivateMode(mode: number, supported: boolean): void {
|
|
1175
|
+
#resolvePrivateMode(mode: number, supported: boolean, confirmed: boolean): void {
|
|
1169
1176
|
if (this.#privateModeSupport.has(mode)) return;
|
|
1170
1177
|
this.#privateModeSupport.set(mode, supported);
|
|
1171
1178
|
for (const cb of this.#privateModeCallbacks) {
|
|
1172
1179
|
try {
|
|
1173
|
-
cb(mode, supported);
|
|
1180
|
+
cb(mode, supported, confirmed);
|
|
1174
1181
|
} catch {
|
|
1175
1182
|
// Ignore subscriber errors — capability reporting must not crash input.
|
|
1176
1183
|
}
|
package/src/tui.ts
CHANGED
|
@@ -87,8 +87,6 @@ const CURSOR_END_NO_SYNC = "";
|
|
|
87
87
|
// coordinates so columns/rows past 223 are reported.
|
|
88
88
|
const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
|
|
89
89
|
const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
|
|
90
|
-
const ALT_SCREEN_ENTER = "\x1b[?1049h";
|
|
91
|
-
const ALT_SCREEN_EXIT = "\x1b[?1049l";
|
|
92
90
|
|
|
93
91
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
94
92
|
type InputListener = (data: string) => InputListenerResult;
|
|
@@ -473,7 +471,7 @@ export interface OverlayHandle {
|
|
|
473
471
|
/**
|
|
474
472
|
* Container - a component that contains other components
|
|
475
473
|
*/
|
|
476
|
-
export class Container implements Component {
|
|
474
|
+
export class Container implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
|
|
477
475
|
children: Component[] = [];
|
|
478
476
|
|
|
479
477
|
// Memoized concatenation of the children's latest renders. Children are
|
|
@@ -543,6 +541,34 @@ export class Container implements Component {
|
|
|
543
541
|
}
|
|
544
542
|
}
|
|
545
543
|
|
|
544
|
+
/**
|
|
545
|
+
* Split the committed prefix from the container's most recently rendered
|
|
546
|
+
* rows across its children. The memoized child arrays are the exact geometry
|
|
547
|
+
* that produced that frame; when the child list was invalidated or rebuilt,
|
|
548
|
+
* there is no safe old-to-new coordinate mapping, so propagation waits for
|
|
549
|
+
* the next render/post-emit publication.
|
|
550
|
+
*/
|
|
551
|
+
setNativeScrollbackCommittedRows(rows: number): void {
|
|
552
|
+
const refs = this.#memoChildLines;
|
|
553
|
+
if (this.#memoLines === undefined || refs.length !== this.children.length) return;
|
|
554
|
+
const committed = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
|
|
555
|
+
let offset = 0;
|
|
556
|
+
for (let i = 0; i < this.children.length; i++) {
|
|
557
|
+
const childRows = refs[i];
|
|
558
|
+
if (childRows === undefined) return;
|
|
559
|
+
setNativeScrollbackCommittedRows(
|
|
560
|
+
this.children[i]!,
|
|
561
|
+
Math.min(childRows.length, Math.max(0, committed - offset)),
|
|
562
|
+
);
|
|
563
|
+
offset += childRows.length;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Recursively discard layout locks that are meaningful only to the old tape. */
|
|
568
|
+
prepareNativeScrollbackReplay(): void {
|
|
569
|
+
for (const child of this.children) prepareNativeScrollbackReplay(child);
|
|
570
|
+
}
|
|
571
|
+
|
|
546
572
|
render(width: number): readonly string[] {
|
|
547
573
|
width = Math.max(1, width);
|
|
548
574
|
const children = this.children;
|
|
@@ -1048,11 +1074,6 @@ export class TUI extends Container {
|
|
|
1048
1074
|
// `#fullRedrawCount`: these never enter native scrollback and exist only for
|
|
1049
1075
|
// the lifetime of the drag. Exposed for tests/diagnostics.
|
|
1050
1076
|
#resizeViewportPaintCount = 0;
|
|
1051
|
-
// During a live resize drag the terminal's normal buffer may reflow full-width
|
|
1052
|
-
// rows before our repaint lands. Borrow the alternate screen for throwaway
|
|
1053
|
-
// resize frames so width changes truncate the transient viewport instead of
|
|
1054
|
-
// pushing wrapped fragments into native scrollback.
|
|
1055
|
-
#resizeAltActive = false;
|
|
1056
1077
|
#stopped = false;
|
|
1057
1078
|
// Always-on event-loop lag probe. The high default threshold keeps it quiet;
|
|
1058
1079
|
// it only logs `ui.loop-blocked` (with the current loop phase) when a frame
|
|
@@ -1068,6 +1089,9 @@ export class TUI extends Container {
|
|
|
1068
1089
|
#altPreviousLines: string[] = [];
|
|
1069
1090
|
#altEnterWidth = 0;
|
|
1070
1091
|
#altEnterHeight = 0;
|
|
1092
|
+
// Holds an alternate-screen exit until its replacement full paint can emit it
|
|
1093
|
+
// atomically. It must survive a deferred Ghostty image frame.
|
|
1094
|
+
#pendingAltExit = "";
|
|
1071
1095
|
|
|
1072
1096
|
// Persistent composed frame. The render override splices only rows at/after
|
|
1073
1097
|
// the stable prefix each frame; cursor markers are stripped at ingestion so
|
|
@@ -1491,13 +1515,15 @@ export class TUI extends Container {
|
|
|
1491
1515
|
this.#watchdog.start();
|
|
1492
1516
|
this.#ghosttyInitialImageDelayDone = false;
|
|
1493
1517
|
this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
|
|
1494
|
-
// A
|
|
1495
|
-
// output when the terminal reports support
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
|
|
1500
|
-
|
|
1518
|
+
// A confirmed DECRPM report for mode 2026 is authoritative: enable
|
|
1519
|
+
// synchronized output when the terminal reports support and disable it for
|
|
1520
|
+
// an explicit unsupported status. A DA1 sentinel without a DECRPM reply is
|
|
1521
|
+
// inconclusive: many terminals implement synchronized output without
|
|
1522
|
+
// implementing DECRQM, so retain the statically detected default instead of
|
|
1523
|
+
// exposing destructive full paints. An explicit user opt-out/force still
|
|
1524
|
+
// wins, so skip every probe result in that case.
|
|
1525
|
+
this.terminal.onPrivateModeReport?.((mode, supported, confirmed = true) => {
|
|
1526
|
+
if (mode !== 2026 || !confirmed) return;
|
|
1501
1527
|
if (synchronizedOutputUserOverride() !== null) return;
|
|
1502
1528
|
this.#setSynchronizedOutput(supported);
|
|
1503
1529
|
});
|
|
@@ -1715,17 +1741,14 @@ export class TUI extends Container {
|
|
|
1715
1741
|
}
|
|
1716
1742
|
|
|
1717
1743
|
stop(): void {
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
this.terminal.write(
|
|
1722
|
-
}
|
|
1723
|
-
if (this.#altActive) {
|
|
1724
|
-
const enhancementExit = this.#keyboardEnhancementExit();
|
|
1725
|
-
this.terminal.write(`${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`);
|
|
1744
|
+
if (this.#altActive || this.#pendingAltExit) {
|
|
1745
|
+
const exitSequence =
|
|
1746
|
+
this.#pendingAltExit || `${MOUSE_TRACKING_OFF}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
|
|
1747
|
+
this.terminal.write(exitSequence);
|
|
1726
1748
|
setAltScreenActive(false);
|
|
1727
1749
|
this.#altActive = false;
|
|
1728
1750
|
this.#altPreviousLines = [];
|
|
1751
|
+
this.#pendingAltExit = "";
|
|
1729
1752
|
}
|
|
1730
1753
|
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
1731
1754
|
for (const id of this.#imageBudget.takeAllTransmittedIds()) {
|
|
@@ -2681,6 +2704,7 @@ export class TUI extends Container {
|
|
|
2681
2704
|
// Fullscreen alt-screen short-circuit. While the topmost visible overlay
|
|
2682
2705
|
// requests it, borrow the terminal's alternate buffer and paint only the
|
|
2683
2706
|
// modal there; the normal screen and all accounting stay untouched.
|
|
2707
|
+
let deferredAltExit = this.#pendingAltExit;
|
|
2684
2708
|
const wantAlt = this.#wantsAltScreen();
|
|
2685
2709
|
if (wantAlt && !this.#altActive) {
|
|
2686
2710
|
// Enhanced keyboard modes can be buffer-local: re-push the active
|
|
@@ -2698,7 +2722,15 @@ export class TUI extends Container {
|
|
|
2698
2722
|
this.#altEnterHeight = height;
|
|
2699
2723
|
} else if (!wantAlt && this.#altActive) {
|
|
2700
2724
|
const enhancementExit = this.#keyboardEnhancementExit();
|
|
2701
|
-
|
|
2725
|
+
const exitSequence = `${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`;
|
|
2726
|
+
// Session replacement can finish while a fullscreen selector is still
|
|
2727
|
+
// covering the old normal buffer. Keep the overlay visible until the
|
|
2728
|
+
// replacement is ready, then fuse the buffer restore into that full paint;
|
|
2729
|
+
// a standalone exit exposes the stale session for one terminal frame.
|
|
2730
|
+
if (this.#clearScrollbackOnNextRender) {
|
|
2731
|
+
this.#pendingAltExit = exitSequence;
|
|
2732
|
+
deferredAltExit = exitSequence;
|
|
2733
|
+
} else this.terminal.write(exitSequence);
|
|
2702
2734
|
setAltScreenActive(false);
|
|
2703
2735
|
this.#forgetHardwareCursorState();
|
|
2704
2736
|
this.#altActive = false;
|
|
@@ -3027,7 +3059,9 @@ export class TUI extends Container {
|
|
|
3027
3059
|
chunkTo,
|
|
3028
3060
|
windowTop,
|
|
3029
3061
|
cursorTrackingLineCount,
|
|
3062
|
+
leadingSequence: deferredAltExit,
|
|
3030
3063
|
});
|
|
3064
|
+
this.#pendingAltExit = "";
|
|
3031
3065
|
this.#committedPrefix = rawFrame.slice(0, chunkTo);
|
|
3032
3066
|
this.#committedPrefixAuditRows = Math.min(chunkTo, finalBoundary);
|
|
3033
3067
|
this.#clearScrollbackOnNextRender = false;
|
|
@@ -3406,6 +3440,7 @@ export class TUI extends Container {
|
|
|
3406
3440
|
chunkTo: number;
|
|
3407
3441
|
windowTop: number;
|
|
3408
3442
|
cursorTrackingLineCount: number;
|
|
3443
|
+
leadingSequence: string;
|
|
3409
3444
|
},
|
|
3410
3445
|
): void {
|
|
3411
3446
|
this.#fullRedrawCount += 1;
|
|
@@ -3443,7 +3478,7 @@ export class TUI extends Container {
|
|
|
3443
3478
|
paintCursorPos = paint.cursorPos;
|
|
3444
3479
|
}
|
|
3445
3480
|
}
|
|
3446
|
-
let buffer = this.#paintBeginSequence +
|
|
3481
|
+
let buffer = this.#paintBeginSequence + options.leadingSequence + purgeSequence;
|
|
3447
3482
|
if (options.clearScrollback) {
|
|
3448
3483
|
// Clear native history without blanking the live viewport first. The
|
|
3449
3484
|
// replay below rewrites every visible row from home, including blanks,
|
|
@@ -3643,34 +3678,15 @@ export class TUI extends Container {
|
|
|
3643
3678
|
return this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
|
|
3644
3679
|
}
|
|
3645
3680
|
|
|
3646
|
-
#enterResizeAltSequence(): string {
|
|
3647
|
-
if (this.#resizeAltActive || this.#altActive) return "";
|
|
3648
|
-
this.#resizeAltActive = true;
|
|
3649
|
-
setAltScreenActive(true);
|
|
3650
|
-
this.#forgetHardwareCursorState();
|
|
3651
|
-
this.#recordHardwareCursorHidden();
|
|
3652
|
-
return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementEnter()}`;
|
|
3653
|
-
}
|
|
3654
|
-
|
|
3655
|
-
#leaveResizeAltSequence(): string {
|
|
3656
|
-
if (!this.#resizeAltActive) return "";
|
|
3657
|
-
const enhancementExit = this.#keyboardEnhancementExit();
|
|
3658
|
-
this.#resizeAltActive = false;
|
|
3659
|
-
setAltScreenActive(false);
|
|
3660
|
-
this.#forgetHardwareCursorState();
|
|
3661
|
-
return `${enhancementExit}${ALT_SCREEN_EXIT}`;
|
|
3662
|
-
}
|
|
3663
|
-
|
|
3664
3681
|
/**
|
|
3665
|
-
* Emit a throwaway viewport repaint for the resize fast path as an
|
|
3666
|
-
*
|
|
3667
|
-
*
|
|
3668
|
-
*
|
|
3669
|
-
*
|
|
3670
|
-
* settle via `#emitFullPaint`.
|
|
3682
|
+
* Emit a throwaway viewport repaint for the resize fast path as an in-place
|
|
3683
|
+
* per-row overwrite. Switching buffers exposes the saved normal screen before
|
|
3684
|
+
* the authoritative settle paint on terminals without effective DEC 2026,
|
|
3685
|
+
* which is the resize flicker this fast path exists to avoid. The normal
|
|
3686
|
+
* screen is therefore rewritten directly; history is rebuilt once at settle.
|
|
3671
3687
|
*/
|
|
3672
3688
|
#emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void {
|
|
3673
|
-
let buffer = `${this.#paintBeginSequence
|
|
3689
|
+
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
|
3674
3690
|
for (let r = 0; r < height; r++) {
|
|
3675
3691
|
if (r > 0) buffer += "\r\n";
|
|
3676
3692
|
buffer += this.#lineRewriteSequence(window[r] ?? "", width);
|