@linxiraos/pi-tui 1.0.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 +2219 -0
- package/README.md +705 -0
- package/dist/types/autocomplete.d.ts +116 -0
- package/dist/types/bracketed-paste.d.ts +51 -0
- package/dist/types/components/box.d.ts +31 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +162 -0
- package/dist/types/components/image.d.ts +112 -0
- package/dist/types/components/input.d.ts +25 -0
- package/dist/types/components/loader.d.ts +25 -0
- package/dist/types/components/markdown.d.ts +88 -0
- package/dist/types/components/scroll-view.d.ts +62 -0
- package/dist/types/components/select-list.d.ts +69 -0
- package/dist/types/components/settings-list.d.ts +123 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +89 -0
- package/dist/types/components/text.d.ts +27 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/deccara.d.ts +49 -0
- package/dist/types/desktop-notify.d.ts +52 -0
- package/dist/types/editor-component.d.ts +38 -0
- package/dist/types/fuzzy.d.ts +48 -0
- package/dist/types/index.d.ts +32 -0
- package/dist/types/keybindings.d.ts +197 -0
- package/dist/types/keys.d.ts +210 -0
- package/dist/types/kill-ring.d.ts +20 -0
- package/dist/types/kitty-graphics.d.ts +76 -0
- package/dist/types/latex-block.d.ts +8 -0
- package/dist/types/latex-to-unicode.d.ts +50 -0
- package/dist/types/loop-watchdog.d.ts +44 -0
- package/dist/types/mouse.d.ts +67 -0
- package/dist/types/stdin-buffer.d.ts +60 -0
- package/dist/types/symbols.d.ts +25 -0
- package/dist/types/terminal-capabilities.d.ts +285 -0
- package/dist/types/terminal.d.ts +175 -0
- package/dist/types/tmux.d.ts +6 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +457 -0
- package/dist/types/utils.d.ts +100 -0
- package/package.json +70 -0
- package/src/autocomplete.ts +1079 -0
- package/src/bracketed-paste.ts +123 -0
- package/src/components/box.ts +236 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +3301 -0
- package/src/components/image.ts +460 -0
- package/src/components/input.ts +482 -0
- package/src/components/loader.ts +174 -0
- package/src/components/markdown.ts +3119 -0
- package/src/components/scroll-view.ts +227 -0
- package/src/components/select-list.ts +539 -0
- package/src/components/settings-list.ts +793 -0
- package/src/components/spacer.ts +32 -0
- package/src/components/tab-bar.ts +300 -0
- package/src/components/text.ts +173 -0
- package/src/components/truncated-text.ts +69 -0
- package/src/deccara.ts +314 -0
- package/src/desktop-notify.ts +192 -0
- package/src/editor-component.ts +74 -0
- package/src/fuzzy.ts +384 -0
- package/src/index.ts +51 -0
- package/src/keybindings.ts +346 -0
- package/src/keys.ts +566 -0
- package/src/kill-ring.ts +51 -0
- package/src/kitty-graphics.ts +171 -0
- package/src/latex-block.ts +1338 -0
- package/src/latex-to-unicode.ts +2017 -0
- package/src/loop-watchdog.ts +115 -0
- package/src/mouse.ts +105 -0
- package/src/stdin-buffer.ts +781 -0
- package/src/symbols.ts +26 -0
- package/src/terminal-capabilities.ts +1211 -0
- package/src/terminal.ts +1854 -0
- package/src/tmux.ts +14 -0
- package/src/ttyid.ts +84 -0
- package/src/tui.ts +4275 -0
- package/src/utils.ts +619 -0
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,1854 @@
|
|
|
1
|
+
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
$env,
|
|
5
|
+
isBunTestRuntime,
|
|
6
|
+
isTerminalHeadless,
|
|
7
|
+
logger,
|
|
8
|
+
postmortem,
|
|
9
|
+
restoreTerminalStderr,
|
|
10
|
+
suppressTerminalStderr,
|
|
11
|
+
} from "@linxiraos/pi-utils";
|
|
12
|
+
import { setKittyProtocolActive } from "./keys";
|
|
13
|
+
import { StdinBuffer } from "./stdin-buffer";
|
|
14
|
+
import {
|
|
15
|
+
isInsideTerminalMultiplexer,
|
|
16
|
+
NotifyProtocol,
|
|
17
|
+
setCellDimensions,
|
|
18
|
+
setOsc99Supported,
|
|
19
|
+
TERMINAL,
|
|
20
|
+
} from "./terminal-capabilities";
|
|
21
|
+
import { isInsideTmux, wrapTmuxPassthrough } from "./tmux";
|
|
22
|
+
import { setHangulCompatibilityJamoWidth } from "./utils";
|
|
23
|
+
|
|
24
|
+
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
|
25
|
+
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
|
26
|
+
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
|
|
27
|
+
const WINDOWS_TERMINAL_OSC11_POLL_MS = 30_000;
|
|
28
|
+
function shouldEnableModifyOtherKeysFallback(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
29
|
+
if (!env.SSH_CONNECTION && !env.SSH_TTY && !env.SSH_CLIENT) return true;
|
|
30
|
+
return TERMINAL.id !== "base" && TERMINAL.id !== "trueColor";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function shouldPollWindowsTerminalAppearance(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
34
|
+
if (process.platform !== "win32") return false;
|
|
35
|
+
if (!env.WT_SESSION) return false;
|
|
36
|
+
return !env.TERM_PROGRAM || env.TERM_PROGRAM.toLowerCase() === "windows_terminal";
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Maximum encoded UTF-8 bytes per `process.stdout.write` call on Windows.
|
|
40
|
+
*
|
|
41
|
+
* Windows ConPTY ties viewport tracking to per-`WriteFile` boundaries: when a
|
|
42
|
+
* single write exceeds ~32-64 KB, the pseudo-console stops following the
|
|
43
|
+
* cursor and the host UI's viewport stays parked at whatever scroll position
|
|
44
|
+
* the write started from. The visible symptom is that a full-paint of a long
|
|
45
|
+
* session (resume, history rebuild, large permission dialog) shows only the
|
|
46
|
+
* first ~30 lines until any focus event forces the host to re-query the
|
|
47
|
+
* cursor. The data is delivered correctly — it's purely a viewport-sync bug.
|
|
48
|
+
*
|
|
49
|
+
* The cap is on **encoded UTF-8 bytes**, not JS code units, because
|
|
50
|
+
* `process.stdout.write(string)` UTF-8-encodes before handing off to
|
|
51
|
+
* `WriteFile`. A pure-CJK transcript row encodes to ~3 bytes per BMP code
|
|
52
|
+
* unit, so a code-unit-based cap of 16 KiB could land at ~48 KiB of actual
|
|
53
|
+
* `WriteFile` traffic and reintroduce the #2034 parked-viewport bug for
|
|
54
|
+
* non-ASCII content.
|
|
55
|
+
*
|
|
56
|
+
* 16 KiB is half the smallest observed Windows Terminal threshold (32 KiB),
|
|
57
|
+
* which keeps the per-write parked-viewport bug fixed by #2034 while halving
|
|
58
|
+
* the WriteFile count on multi-megabyte paints (a 3 MB session resume splits
|
|
59
|
+
* into ~192 chunks instead of ~384). Fewer WriteFiles means fewer chances for
|
|
60
|
+
* WT's viewport-following logic to lose track of the cursor during the burst,
|
|
61
|
+
* which mitigates the residual mid-paint drift the original 8 KiB cap left
|
|
62
|
+
* behind (#2095). Still well clear of the threshold so the other ConPTY hosts
|
|
63
|
+
* (Tabby, Hyper, VS Code) — where the exact limit is undocumented — keep
|
|
64
|
+
* their safety margin.
|
|
65
|
+
*/
|
|
66
|
+
const MAX_CONPTY_WRITE_CHUNK_BYTES = 16 * 1024;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Split `data` into chunks whose encoded UTF-8 byte length is no greater than
|
|
70
|
+
* `maxChunkBytes`, preferring a line boundary (`\n`) as the cut point so
|
|
71
|
+
* escape sequences (which never contain `\n`) stay intact. The TUI's
|
|
72
|
+
* full-paint buffers are line-structured (`buffer += "\r\n"` between rows),
|
|
73
|
+
* so a newline almost always exists within the window. The fallback for a
|
|
74
|
+
* buffer with no newline in range is a hard cut at the last UTF-8 code-point
|
|
75
|
+
* boundary that still fits — the ConPTY viewport bug from a single oversized
|
|
76
|
+
* write is strictly worse than a one-frame escape-sequence glitch on a
|
|
77
|
+
* buffer the renderer effectively never produces.
|
|
78
|
+
*
|
|
79
|
+
* UTF-16 code units are walked manually rather than measuring with
|
|
80
|
+
* `Buffer.byteLength` per slice candidate: each code unit's UTF-8 width is
|
|
81
|
+
* known from its value (BMP `<0x80` → 1, `<0x800` → 2, surrogate pair → 4
|
|
82
|
+
* bytes across two units, other BMP → 3), and surrogate pairs are kept
|
|
83
|
+
* together so the chunker never splits a non-BMP character.
|
|
84
|
+
*
|
|
85
|
+
* Exported for unit testing of the chunking contract; `#safeWrite` is the
|
|
86
|
+
* sole production caller.
|
|
87
|
+
*/
|
|
88
|
+
export function chunkForConPTY(data: string, maxChunkBytes: number = MAX_CONPTY_WRITE_CHUNK_BYTES): string[] {
|
|
89
|
+
// Fast path: whole buffer fits in one write.
|
|
90
|
+
if (Buffer.byteLength(data, "utf8") <= maxChunkBytes) return [data];
|
|
91
|
+
const chunks: string[] = [];
|
|
92
|
+
const len = data.length;
|
|
93
|
+
let pos = 0;
|
|
94
|
+
while (pos < len) {
|
|
95
|
+
let bytes = 0;
|
|
96
|
+
// Index just past the most recent `\n` we've consumed inside [pos, i):
|
|
97
|
+
// the natural cut point that leaves escape sequences intact.
|
|
98
|
+
let lastNewlineEnd = -1;
|
|
99
|
+
let i = pos;
|
|
100
|
+
while (i < len) {
|
|
101
|
+
const cu = data.charCodeAt(i);
|
|
102
|
+
let cuLen = 1;
|
|
103
|
+
let cuBytes: number;
|
|
104
|
+
if (cu < 0x80) {
|
|
105
|
+
cuBytes = 1;
|
|
106
|
+
} else if (cu < 0x800) {
|
|
107
|
+
cuBytes = 2;
|
|
108
|
+
} else if (cu >= 0xd800 && cu < 0xdc00) {
|
|
109
|
+
// High surrogate: pair with the following low surrogate (4 bytes
|
|
110
|
+
// across two code units); an unpaired surrogate UTF-8-encodes as
|
|
111
|
+
// the 3-byte U+FFFD replacement character.
|
|
112
|
+
const next = i + 1 < len ? data.charCodeAt(i + 1) : 0;
|
|
113
|
+
if (next >= 0xdc00 && next < 0xe000) {
|
|
114
|
+
cuBytes = 4;
|
|
115
|
+
cuLen = 2;
|
|
116
|
+
} else {
|
|
117
|
+
cuBytes = 3;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
// BMP non-surrogate or unpaired low surrogate → 3 bytes.
|
|
121
|
+
cuBytes = 3;
|
|
122
|
+
}
|
|
123
|
+
if (bytes + cuBytes > maxChunkBytes && i > pos) {
|
|
124
|
+
// Would overflow the cap. Cut at the last newline if we found one,
|
|
125
|
+
// otherwise hard-cut at the current code-point boundary.
|
|
126
|
+
const cut = lastNewlineEnd > pos ? lastNewlineEnd : i;
|
|
127
|
+
chunks.push(data.slice(pos, cut));
|
|
128
|
+
pos = cut;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
bytes += cuBytes;
|
|
132
|
+
i += cuLen;
|
|
133
|
+
if (cu === 0x0a) lastNewlineEnd = i;
|
|
134
|
+
}
|
|
135
|
+
if (i >= len) {
|
|
136
|
+
chunks.push(data.slice(pos));
|
|
137
|
+
pos = len;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return chunks;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Hard cap on bytes queued to a stalled stdout before its consumer is declared
|
|
145
|
+
* gone. A live terminal drains within milliseconds, so a backlog this large —
|
|
146
|
+
* far above any legitimate paint (a full session resume is a few MiB) — means
|
|
147
|
+
* the PTY reader has stopped consuming entirely. Without the cap, `#safeWrite`
|
|
148
|
+
* keeps handing cosmetic frames (the `hub wait` spinner, 500 ms progress
|
|
149
|
+
* snapshots) to a writable buffer that never drains, growing RSS without bound
|
|
150
|
+
* until the host runs out of memory. See #6854.
|
|
151
|
+
*/
|
|
152
|
+
const MAX_STDOUT_BACKLOG_BYTES = 64 * 1024 * 1024;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Turns an unbounded, never-draining stdout writable buffer into a bounded
|
|
156
|
+
* disconnect signal.
|
|
157
|
+
*
|
|
158
|
+
* `process.stdout.write()` returns `false` once its buffer exceeds the stream
|
|
159
|
+
* high-water mark; the bytes stay queued and are only freed when the consumer
|
|
160
|
+
* drains (the `drain` event). While the consumer keeps up, writes are accepted
|
|
161
|
+
* and nothing accumulates. When it stalls, every subsequent write piles onto
|
|
162
|
+
* the buffer — a stalled-but-alive PTY reader never throws, so the write path
|
|
163
|
+
* has no other signal that output is going nowhere. This guard sums the bytes
|
|
164
|
+
* queued since backpressure began and reports when that backlog crosses the
|
|
165
|
+
* cap, at which point the caller treats the terminal as disconnected.
|
|
166
|
+
*
|
|
167
|
+
* Exported for unit testing; `ProcessTerminal` is the sole production user.
|
|
168
|
+
*/
|
|
169
|
+
export class OutputBacklogGuard {
|
|
170
|
+
#bytes = 0;
|
|
171
|
+
#tracking = false;
|
|
172
|
+
|
|
173
|
+
constructor(private readonly capBytes: number = MAX_STDOUT_BACKLOG_BYTES) {}
|
|
174
|
+
|
|
175
|
+
/** True once a refused write started a backlog that has not yet drained. */
|
|
176
|
+
get tracking(): boolean {
|
|
177
|
+
return this.#tracking;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Record one `stdout.write()`: `accepted` is that call's return value and
|
|
182
|
+
* `bytes` its encoded size. Returns true when the pending backlog now
|
|
183
|
+
* exceeds the cap and the terminal should be treated as disconnected.
|
|
184
|
+
*/
|
|
185
|
+
record(accepted: boolean, bytes: number): boolean {
|
|
186
|
+
if (!this.#tracking) {
|
|
187
|
+
// Consumer is keeping up; nothing is queued.
|
|
188
|
+
if (accepted) return false;
|
|
189
|
+
// First refused write: backpressure has begun.
|
|
190
|
+
this.#tracking = true;
|
|
191
|
+
}
|
|
192
|
+
this.#bytes += bytes;
|
|
193
|
+
return this.#bytes > this.capBytes;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Called on the stdout `drain` event: the buffer emptied, backlog cleared. */
|
|
197
|
+
reset(): void {
|
|
198
|
+
this.#bytes = 0;
|
|
199
|
+
this.#tracking = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Minimal terminal interface for TUI
|
|
205
|
+
*/
|
|
206
|
+
|
|
207
|
+
// Track active terminal for emergency cleanup on crash
|
|
208
|
+
let activeTerminal: ProcessTerminal | null = null;
|
|
209
|
+
// Track if a terminal was ever started (for emergency restore logic)
|
|
210
|
+
let terminalEverStarted = false;
|
|
211
|
+
// Whether the alternate screen buffer is currently active (mirrors the TUI's
|
|
212
|
+
// overlay enter/leave writes). Consulted by emergencyTerminalRestore: DECRST
|
|
213
|
+
// 1049 must never be written blindly, because Windows' shared VT dispatcher
|
|
214
|
+
// (conhost and Windows Terminal both use AdaptDispatch) executes an
|
|
215
|
+
// unconditional cursor restore on it — with no prior DECSC save the cursor
|
|
216
|
+
// jumps to the viewport home, dropping the parent shell prompt on top of the
|
|
217
|
+
// dead frame after exit.
|
|
218
|
+
let altScreenActive = false;
|
|
219
|
+
let terminalRestoreRegistered = false;
|
|
220
|
+
|
|
221
|
+
function registerPostmortemTerminalRestore(): void {
|
|
222
|
+
if (terminalRestoreRegistered) return;
|
|
223
|
+
terminalRestoreRegistered = true;
|
|
224
|
+
postmortem.register("terminal-restore", () => {
|
|
225
|
+
emergencyTerminalRestore();
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Record alternate-screen state (called by the TUI on `?1049h`/`?1049l` writes). */
|
|
230
|
+
export function setAltScreenActive(active: boolean): void {
|
|
231
|
+
altScreenActive = active;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const stdoutErrorHandlers = new Set<(err: Error) => void>();
|
|
235
|
+
let stdoutErrorListenerInstalled = false;
|
|
236
|
+
|
|
237
|
+
function onStdoutError(err: Error): void {
|
|
238
|
+
for (const handler of stdoutErrorHandlers) handler(err);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function registerStdoutErrorHandler(handler: (err: Error) => void): () => void {
|
|
242
|
+
stdoutErrorHandlers.add(handler);
|
|
243
|
+
if (!stdoutErrorListenerInstalled) {
|
|
244
|
+
process.stdout.on("error", onStdoutError);
|
|
245
|
+
stdoutErrorListenerInstalled = true;
|
|
246
|
+
}
|
|
247
|
+
return () => {
|
|
248
|
+
stdoutErrorHandlers.delete(handler);
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const STD_INPUT_HANDLE = -10;
|
|
253
|
+
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
|
|
254
|
+
/** UTF-8 codepage id for SetConsoleCP/SetConsoleOutputCP. */
|
|
255
|
+
const CP_UTF8 = 65001;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Lazily-initialized closure re-asserting the UTF-8 console codepage, or
|
|
259
|
+
* `null` when unavailable (non-win32, FFI failure, console detached).
|
|
260
|
+
*/
|
|
261
|
+
let consoleCodepageGuard: (() => void) | null | undefined;
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Re-assert the UTF-8 console codepage before writing (win32 only).
|
|
265
|
+
*
|
|
266
|
+
* Bun sets both console codepages to UTF-8 (65001) at startup, and
|
|
267
|
+
* `process.stdout.write(string)` hands UTF-8 bytes to `WriteFile`, which
|
|
268
|
+
* conhost translates using the *current* console output codepage. Child
|
|
269
|
+
* processes spawned by tools (bash commands, MCP/LSP servers, eval kernels)
|
|
270
|
+
* share this console, and some flip the codepage behind our back: PHP >=7.1
|
|
271
|
+
* CLI issues the equivalent of `chcp` whenever `internal_encoding` mismatches
|
|
272
|
+
* the console codepage (php.net request #73716) and skips the restore when
|
|
273
|
+
* killed — and two PHP processes in a pipeline race their restores. Once the
|
|
274
|
+
* codepage falls back to an OEM page (437/850), every non-ASCII glyph the TUI
|
|
275
|
+
* paints is mis-translated: box-drawing borders degrade into `Γöé`/`ΓöÇ`
|
|
276
|
+
* mojibake on the next full repaint (most visibly ctrl+o expand, which
|
|
277
|
+
* rewrites every row).
|
|
278
|
+
*
|
|
279
|
+
* `GetConsoleOutputCP` is one cheap console call per `#safeWrite`; the setter
|
|
280
|
+
* only runs after a foreign flip. A reading of 0 means "no console" — leave
|
|
281
|
+
* that alone. Guarding the write chokepoint (rather than per-spawn cleanup)
|
|
282
|
+
* covers every console-sharing child and long-running processes that flip
|
|
283
|
+
* the codepage mid-session.
|
|
284
|
+
*/
|
|
285
|
+
function ensureWindowsConsoleUtf8(): void {
|
|
286
|
+
if (consoleCodepageGuard === undefined) consoleCodepageGuard = createConsoleCodepageGuard();
|
|
287
|
+
consoleCodepageGuard?.();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let lastWarnedCodepage = 0;
|
|
291
|
+
|
|
292
|
+
function createConsoleCodepageGuard(): (() => void) | null {
|
|
293
|
+
if (process.platform !== "win32") return null;
|
|
294
|
+
try {
|
|
295
|
+
const kernel32 = dlopen("kernel32.dll", {
|
|
296
|
+
GetConsoleOutputCP: { args: [], returns: FFIType.u32 },
|
|
297
|
+
SetConsoleOutputCP: { args: [FFIType.u32], returns: FFIType.bool },
|
|
298
|
+
GetConsoleCP: { args: [], returns: FFIType.u32 },
|
|
299
|
+
SetConsoleCP: { args: [FFIType.u32], returns: FFIType.bool },
|
|
300
|
+
});
|
|
301
|
+
return () => {
|
|
302
|
+
try {
|
|
303
|
+
const outCp = kernel32.symbols.GetConsoleOutputCP();
|
|
304
|
+
if (outCp !== 0 && outCp !== CP_UTF8) {
|
|
305
|
+
kernel32.symbols.SetConsoleOutputCP(CP_UTF8);
|
|
306
|
+
if (outCp !== lastWarnedCodepage) {
|
|
307
|
+
lastWarnedCodepage = outCp;
|
|
308
|
+
logger.warn("console output codepage changed by a child process; restoring UTF-8", {
|
|
309
|
+
codepage: outCp,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const inCp = kernel32.symbols.GetConsoleCP();
|
|
314
|
+
if (inCp !== 0 && inCp !== CP_UTF8) {
|
|
315
|
+
kernel32.symbols.SetConsoleCP(CP_UTF8);
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// Console APIs failed (console detached mid-session); disable the guard.
|
|
319
|
+
consoleCodepageGuard = null;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
} catch {
|
|
323
|
+
// bun:ffi unavailable; rendering proceeds without the guard.
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Emergency terminal restore - call this from signal/crash handlers
|
|
329
|
+
* Resets terminal state without requiring access to the ProcessTerminal instance
|
|
330
|
+
*/
|
|
331
|
+
export function emergencyTerminalRestore(): void {
|
|
332
|
+
try {
|
|
333
|
+
// Crash paths must surface subsequent stderr (fatal reports) on the
|
|
334
|
+
// real terminal; no-op when the stderr guard is inactive.
|
|
335
|
+
restoreTerminalStderr();
|
|
336
|
+
const terminal = activeTerminal;
|
|
337
|
+
if (terminal) {
|
|
338
|
+
// Keyboard enhancement state is screen-local: pop the alt-screen
|
|
339
|
+
// frame before leaving it, then let stop() pop omp's main-screen frame.
|
|
340
|
+
if (altScreenActive) {
|
|
341
|
+
const keyboardExit =
|
|
342
|
+
terminal.keyboardEnhancementExitSequence ?? (terminal.kittyEnableSequence ? "\x1b[<u" : "");
|
|
343
|
+
terminal.write(`${keyboardExit}\x1b[?1049l`);
|
|
344
|
+
altScreenActive = false;
|
|
345
|
+
}
|
|
346
|
+
terminal.stop();
|
|
347
|
+
terminal.showCursor(true);
|
|
348
|
+
} else if (terminalEverStarted && !isTerminalHeadless()) {
|
|
349
|
+
// Blind restore only if we know a terminal was started but lost track of it
|
|
350
|
+
// This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
|
|
351
|
+
process.stdout.write(
|
|
352
|
+
"\x1b[?2026l" + // End synchronized output
|
|
353
|
+
"\x1b[?7h" + // Restore autowrap
|
|
354
|
+
"\x1b[?1l\x1b>" + // Restore normal cursor-key + keypad mode (rmkx, #6374)
|
|
355
|
+
"\x1b[?2004l" + // Disable bracketed paste
|
|
356
|
+
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
|
|
357
|
+
"\x1b[?2048l" + // Disable in-band resize notifications
|
|
358
|
+
"\x1b[?5522l" + // Disable enhanced paste notifications
|
|
359
|
+
"\x1b[<u" + // Pop kitty keyboard protocol
|
|
360
|
+
"\x1b[>4;0m" + // Disable modifyOtherKeys fallback
|
|
361
|
+
"\x1b[?1006l\x1b[?1003l\x1b[?1000l" + // Disable mouse tracking (fullscreen overlays)
|
|
362
|
+
// Leave the alternate screen only when a fullscreen overlay
|
|
363
|
+
// actually holds it — on Windows, DECRST 1049 on the main
|
|
364
|
+
// buffer homes the cursor (unconditional CursorRestoreState
|
|
365
|
+
// with no prior save), corrupting the shell handoff on exit.
|
|
366
|
+
(altScreenActive ? "\x1b[?1049l\x1b[?1l\x1b>\x1b[<u" : "") + // Leave alt; reset main keyboard
|
|
367
|
+
"\x1b[?25h", // Show cursor
|
|
368
|
+
);
|
|
369
|
+
altScreenActive = false;
|
|
370
|
+
if (process.stdin.setRawMode) {
|
|
371
|
+
process.stdin.setRawMode(false);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
// Terminal may already be dead during crash cleanup - ignore errors
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** Terminal-reported appearance (dark/light mode). */
|
|
379
|
+
export type TerminalAppearance = "dark" | "light";
|
|
380
|
+
/** Identity of an accepted explicit terminal appearance refresh request. */
|
|
381
|
+
export type TerminalAppearanceRequestToken = number;
|
|
382
|
+
export interface Terminal {
|
|
383
|
+
// Start the terminal with input, resize, and host-disconnect handlers.
|
|
384
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
|
|
385
|
+
|
|
386
|
+
// Stop the terminal and restore state
|
|
387
|
+
stop(): void;
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Drain stdin before exiting to prevent Kitty key release events from
|
|
391
|
+
* leaking to the parent shell over slow SSH connections.
|
|
392
|
+
* @param maxMs - Maximum time to drain (default: 1000ms)
|
|
393
|
+
* @param idleMs - Exit early if no input arrives within this time (default: 50ms)
|
|
394
|
+
*/
|
|
395
|
+
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
|
396
|
+
|
|
397
|
+
// Write output to terminal
|
|
398
|
+
write(data: string): void;
|
|
399
|
+
|
|
400
|
+
// Get terminal dimensions
|
|
401
|
+
get columns(): number;
|
|
402
|
+
get rows(): number;
|
|
403
|
+
|
|
404
|
+
// Whether Kitty keyboard protocol is active
|
|
405
|
+
get kittyProtocolActive(): boolean;
|
|
406
|
+
|
|
407
|
+
// The exact kitty keyboard push sequence in effect ("\x1b[>5u" or "\x1b[>7u"),
|
|
408
|
+
// or null when the protocol is not active. Kitty keyboard flags are per-screen,
|
|
409
|
+
// so the TUI re-pushes this after entering the alternate screen.
|
|
410
|
+
get kittyEnableSequence(): string | null;
|
|
411
|
+
|
|
412
|
+
// The active modified-key reporting sequence to reassert on alternate-screen
|
|
413
|
+
// entry, or null when no enhanced keyboard mode is active. Optional so custom
|
|
414
|
+
// Terminals built against older pi-tui versions keep working.
|
|
415
|
+
readonly keyboardEnhancementEnterSequence?: string | null;
|
|
416
|
+
|
|
417
|
+
// The sequence that cleanly disables the active enhanced keyboard mode on
|
|
418
|
+
// alternate-screen exit, or null when no exit handshake is required. Optional
|
|
419
|
+
// so custom Terminals built against older pi-tui versions keep working.
|
|
420
|
+
readonly keyboardEnhancementExitSequence?: string | null;
|
|
421
|
+
|
|
422
|
+
// Cursor positioning (relative to current position)
|
|
423
|
+
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
|
|
424
|
+
|
|
425
|
+
// Cursor visibility. Same-state calls are deduped against the visibility
|
|
426
|
+
// last written to the terminal; pass force=true to write unconditionally
|
|
427
|
+
// (crash/exit restore paths).
|
|
428
|
+
hideCursor(force?: boolean): void; // Hide the cursor
|
|
429
|
+
showCursor(force?: boolean): void; // Show the cursor
|
|
430
|
+
|
|
431
|
+
// Clear operations
|
|
432
|
+
clearLine(): void; // Clear current line
|
|
433
|
+
clearFromCursor(): void; // Clear from cursor to end of screen
|
|
434
|
+
clearScreen(): void; // Clear entire screen and move cursor to (0,0)
|
|
435
|
+
|
|
436
|
+
// Title operations
|
|
437
|
+
setTitle(title: string): void; // Set terminal window title
|
|
438
|
+
|
|
439
|
+
// Progress indicator (OSC 9;4)
|
|
440
|
+
setProgress(active: boolean): void;
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Register a callback for terminal appearance (dark/light) changes.
|
|
444
|
+
* Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
|
|
445
|
+
* Fires when the detected appearance changes, including the initial detection.
|
|
446
|
+
* Subscribers registered after detection are invoked immediately with the
|
|
447
|
+
* already-detected appearance so late subscribers never miss it.
|
|
448
|
+
*/
|
|
449
|
+
onAppearanceChange(
|
|
450
|
+
callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void,
|
|
451
|
+
): void;
|
|
452
|
+
/**
|
|
453
|
+
* Register a callback fired for every valid OSC 11 appearance report,
|
|
454
|
+
* including reports whose classification matches the current appearance.
|
|
455
|
+
* Unlike onAppearanceChange, this does not replay an earlier report.
|
|
456
|
+
* Optional so custom Terminals built against older pi-tui versions keep working.
|
|
457
|
+
*/
|
|
458
|
+
onAppearanceReport?(
|
|
459
|
+
callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void,
|
|
460
|
+
): (() => void) | void;
|
|
461
|
+
/**
|
|
462
|
+
* Start a bounded OSC 11 background-color refresh cycle, driving appearance
|
|
463
|
+
* callbacks through the same parse/dedup pipeline used at startup and on Mode
|
|
464
|
+
* 2031 notifications. Direct terminals need one query; tmux needs a
|
|
465
|
+
* passthrough query to update its cache followed by one delayed direct cache
|
|
466
|
+
* read. Invoked on the user's explicit display-reset gesture so terminals
|
|
467
|
+
* without end-to-end Mode 2031 notifications pick up a light/dark switch
|
|
468
|
+
* without a restart. No periodic probes are armed.
|
|
469
|
+
*
|
|
470
|
+
* A caller-provided token must be propagated unchanged to callbacks and
|
|
471
|
+
* returned when the request is accepted. This lets callers establish ownership
|
|
472
|
+
* before implementations synchronously dispatch a cached response. Optional so
|
|
473
|
+
* custom Terminals built against older pi-tui versions keep working.
|
|
474
|
+
*/
|
|
475
|
+
refreshAppearance?(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void;
|
|
476
|
+
/** The last detected terminal appearance, or undefined if not yet known. */
|
|
477
|
+
get appearance(): TerminalAppearance | undefined;
|
|
478
|
+
/**
|
|
479
|
+
* Register a callback fired once per DEC private mode when its DECRQM support
|
|
480
|
+
* status resolves. `confirmed` is false when the terminal answered the DA1
|
|
481
|
+
* sentinel without answering DECRQM, which proves only that querying support
|
|
482
|
+
* is unavailable — not that the private mode itself is unsupported.
|
|
483
|
+
*/
|
|
484
|
+
onPrivateModeReport?(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* True when stdout flows through a ConPTY pseudo-console (native win32, or
|
|
489
|
+
* Linux running under WSL where stdout still crosses into ConPTY at the
|
|
490
|
+
* `wslhost` boundary). ConPTY hosts share the per-WriteFile viewport-tracking
|
|
491
|
+
* quirks documented above and on {@link MAX_CONPTY_WRITE_CHUNK_BYTES}, so both
|
|
492
|
+
* `#safeWrite` and the renderer's post-big-paint settle gate hang off this
|
|
493
|
+
* single predicate.
|
|
494
|
+
*/
|
|
495
|
+
export function isConPTYHosted(): boolean {
|
|
496
|
+
if (process.platform === "win32") return true;
|
|
497
|
+
// WSL: stdout still crosses into ConPTY at the `wslhost` boundary.
|
|
498
|
+
return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Discriminated owner of an outstanding DA1 sentinel in the unified probe FIFO. */
|
|
502
|
+
type Da1SentinelOwner =
|
|
503
|
+
| { kind: "keyboard" }
|
|
504
|
+
| { kind: "osc11" }
|
|
505
|
+
| { kind: "privateMode"; mode: number }
|
|
506
|
+
| { kind: "osc99Probe"; id: string };
|
|
507
|
+
|
|
508
|
+
let nextOsc99ProbeId = 1;
|
|
509
|
+
|
|
510
|
+
function parseOsc99KeyValues(section: string): Map<string, string> {
|
|
511
|
+
const values = new Map<string, string>();
|
|
512
|
+
for (const part of section.split(":")) {
|
|
513
|
+
const eq = part.indexOf("=");
|
|
514
|
+
if (eq !== 1) continue;
|
|
515
|
+
values.set(part.slice(0, eq), part.slice(eq + 1));
|
|
516
|
+
}
|
|
517
|
+
return values;
|
|
518
|
+
}
|
|
519
|
+
const XTERM_SCROLL_TO_BOTTOM_MODES = [1010, 1011] as const;
|
|
520
|
+
type Osc11QueryRoute = "direct" | "tmux";
|
|
521
|
+
const TMUX_OSC11_CACHE_REFRESH_DELAY_MS = 100;
|
|
522
|
+
|
|
523
|
+
function isXtermScrollToBottomMode(mode: number): boolean {
|
|
524
|
+
return mode === 1010 || mode === 1011;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function isPrivateModeSet(status: string): boolean {
|
|
528
|
+
return status === "1" || status === "3";
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function isPrivateModeSupported(status: string): boolean {
|
|
532
|
+
return status !== "0" && status !== "4";
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Real terminal using process.stdin/stdout
|
|
537
|
+
*/
|
|
538
|
+
export class ProcessTerminal implements Terminal {
|
|
539
|
+
#wasRaw = false;
|
|
540
|
+
#inputHandler?: (data: string) => void;
|
|
541
|
+
#resizeHandler?: () => void;
|
|
542
|
+
#stdoutResizeListener?: () => void;
|
|
543
|
+
#kittyProtocolActive = false;
|
|
544
|
+
#kittyEnableSeq: string | null = null;
|
|
545
|
+
#modifyOtherKeysActive = false;
|
|
546
|
+
#modifyOtherKeysTimeout?: Timer;
|
|
547
|
+
#stdinBuffer?: StdinBuffer;
|
|
548
|
+
#stdinDataHandler?: (data: string) => void;
|
|
549
|
+
#disconnectHandler?: () => void;
|
|
550
|
+
#stdinEndHandler = () => {
|
|
551
|
+
this.#markTerminalDisconnected("stdin ended");
|
|
552
|
+
};
|
|
553
|
+
#stdinCloseHandler = () => {
|
|
554
|
+
this.#markTerminalDisconnected("stdin closed");
|
|
555
|
+
};
|
|
556
|
+
#stdinErrorHandler = (err: Error) => {
|
|
557
|
+
this.#markTerminalDisconnected("stdin failed", err);
|
|
558
|
+
};
|
|
559
|
+
#dead = false;
|
|
560
|
+
#active = false;
|
|
561
|
+
// Last cursor visibility written to the terminal, sniffed from every
|
|
562
|
+
// outgoing sequence (frame buffers embed their own ?25h/?25l), so
|
|
563
|
+
// hideCursor()/showCursor() can skip same-state writes. `undefined` =
|
|
564
|
+
// unknown (fresh start, resize, or an alt-screen switch newer than the
|
|
565
|
+
// last cursor sequence — some hosts keep DECTCEM per buffer).
|
|
566
|
+
#cursorVisible: boolean | undefined;
|
|
567
|
+
// Captured at construction and re-read at start(): when true, every real
|
|
568
|
+
// terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is
|
|
569
|
+
// suppressed. Defaults on under `bun test` — see isTerminalHeadless().
|
|
570
|
+
#headless = isTerminalHeadless();
|
|
571
|
+
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
|
|
572
|
+
#stdoutErrorCleanup?: () => void;
|
|
573
|
+
#stdoutErrorHandler = (err: Error) => {
|
|
574
|
+
this.#markTerminalDisconnected("stdout failed", err);
|
|
575
|
+
};
|
|
576
|
+
// Bounds the stdout writable buffer against a stalled PTY consumer: a
|
|
577
|
+
// stalled-but-alive reader never throws, so #safeWrite has no error to catch
|
|
578
|
+
// and the writable buffer grows without bound as cosmetic frames pile up.
|
|
579
|
+
// See OutputBacklogGuard and #6854.
|
|
580
|
+
#stdoutBacklog = new OutputBacklogGuard();
|
|
581
|
+
#stdoutDrainArmed = false;
|
|
582
|
+
#stdoutDrainHandler = () => {
|
|
583
|
+
this.#stdoutDrainArmed = false;
|
|
584
|
+
this.#stdoutBacklog.reset();
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
#windowsVTInputRestore?: () => void;
|
|
588
|
+
#xtermScrollToBottomRestoreModes = new Set<number>();
|
|
589
|
+
#appearanceCallbacks: Array<
|
|
590
|
+
(appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void
|
|
591
|
+
> = [];
|
|
592
|
+
#appearanceReportCallbacks: Array<
|
|
593
|
+
(appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void
|
|
594
|
+
> = [];
|
|
595
|
+
#appearance: TerminalAppearance | undefined;
|
|
596
|
+
#osc11Pending = false;
|
|
597
|
+
#osc11ActiveToken?: TerminalAppearanceRequestToken;
|
|
598
|
+
#osc11QueuedQuery?: { route: Osc11QueryRoute; token?: TerminalAppearanceRequestToken };
|
|
599
|
+
#nextAppearanceRequestToken = 1;
|
|
600
|
+
#osc11ResponseBuffer = "";
|
|
601
|
+
#osc11TmuxRefreshTimer?: Timer;
|
|
602
|
+
#osc99PendingId: string | undefined;
|
|
603
|
+
#osc99ResponseBuffer = "";
|
|
604
|
+
#osc99Capabilities = new Map<string, string>();
|
|
605
|
+
#privateCsiResponseBuffer = "";
|
|
606
|
+
#da1SentinelOwners: Da1SentinelOwner[] = [];
|
|
607
|
+
/** Resolved DECRQM support per private mode (mode → supported). */
|
|
608
|
+
#privateModeSupport = new Map<number, boolean>();
|
|
609
|
+
#privateModeCallbacks: Array<(mode: number, supported: boolean, confirmed: boolean) => void> = [];
|
|
610
|
+
/** Whether DEC 2048 in-band resize notifications are currently enabled. */
|
|
611
|
+
#inBandResizeActive = false;
|
|
612
|
+
/** Reassembly buffer for a DEC 2048 in-band resize report split across stdin reads. */
|
|
613
|
+
#inBandResizeBuffer = "";
|
|
614
|
+
#reportedColumns?: number;
|
|
615
|
+
#reportedRows?: number;
|
|
616
|
+
#mode2031DebounceTimer?: Timer;
|
|
617
|
+
#windowsTerminalAppearancePollTimer?: Timer;
|
|
618
|
+
#progressTimer?: Timer;
|
|
619
|
+
|
|
620
|
+
get kittyProtocolActive(): boolean {
|
|
621
|
+
return this.#kittyProtocolActive;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
get kittyEnableSequence(): string | null {
|
|
625
|
+
return this.#kittyProtocolActive ? this.#kittyEnableSeq : null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
get keyboardEnhancementEnterSequence(): string | null {
|
|
629
|
+
if (this.#kittyProtocolActive) return this.#kittyEnableSeq;
|
|
630
|
+
return this.#modifyOtherKeysActive ? "\x1b[>4;2m" : null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
get keyboardEnhancementExitSequence(): string | null {
|
|
634
|
+
// kitty is a stack push (per-screen), so the matching pop balances alt-screen
|
|
635
|
+
// entry. xterm modifyOtherKeys is a single global flag with no per-screen
|
|
636
|
+
// stack — emitting `>4;0m` here would clear it on the normal screen too,
|
|
637
|
+
// breaking the composer between overlays. terminal.stop() still disables it
|
|
638
|
+
// globally on graceful exit; the emergency-restore path mirrors that.
|
|
639
|
+
return this.#kittyProtocolActive ? "\x1b[<u" : null;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
get appearance(): TerminalAppearance | undefined {
|
|
643
|
+
return this.#appearance;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
onAppearanceChange(
|
|
647
|
+
callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void,
|
|
648
|
+
): void {
|
|
649
|
+
this.#appearanceCallbacks.push(callback);
|
|
650
|
+
// Replay an already-detected appearance: the startup OSC 11 response can
|
|
651
|
+
// arrive before consumers (e.g. the theme bridge) subscribe, and the
|
|
652
|
+
// dedup in #handleOsc11Response would otherwise suppress the value for
|
|
653
|
+
// them forever (#4731).
|
|
654
|
+
if (this.#appearance) {
|
|
655
|
+
try {
|
|
656
|
+
callback(this.#appearance);
|
|
657
|
+
} catch {
|
|
658
|
+
/* ignore callback errors */
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
onAppearanceReport(
|
|
664
|
+
callback: (appearance: TerminalAppearance, requestToken?: TerminalAppearanceRequestToken) => void,
|
|
665
|
+
): () => void {
|
|
666
|
+
this.#appearanceReportCallbacks.push(callback);
|
|
667
|
+
let subscribed = true;
|
|
668
|
+
return () => {
|
|
669
|
+
if (!subscribed) return;
|
|
670
|
+
subscribed = false;
|
|
671
|
+
const index = this.#appearanceReportCallbacks.indexOf(callback);
|
|
672
|
+
if (index !== -1) this.#appearanceReportCallbacks.splice(index, 1);
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Re-query the terminal background through the startup DA1-sentinel FIFO,
|
|
678
|
+
* pending/queued gating, parsing, dedup, and appearance callbacks. Inside
|
|
679
|
+
* tmux, only this explicit path first passes an OSC 11 query to the outer
|
|
680
|
+
* terminal, waits briefly for tmux to consume the response into its cache,
|
|
681
|
+
* then reads that cache with a direct query. The outer query deliberately has
|
|
682
|
+
* no DA1 sentinel: multiplexers can decode a fragmented DA1 response as a key
|
|
683
|
+
* sequence and leak the remaining bytes into the editor. Startup and Mode 2031
|
|
684
|
+
* probes remain direct. Suppressed while inactive, headless, or after teardown.
|
|
685
|
+
*/
|
|
686
|
+
refreshAppearance(requestToken?: TerminalAppearanceRequestToken): TerminalAppearanceRequestToken | void {
|
|
687
|
+
if (!this.#active || this.#headless || this.#dead) return;
|
|
688
|
+
const token = requestToken ?? this.#nextAppearanceRequestToken++;
|
|
689
|
+
if (token >= this.#nextAppearanceRequestToken) {
|
|
690
|
+
this.#nextAppearanceRequestToken = token + 1;
|
|
691
|
+
}
|
|
692
|
+
this.#queryBackgroundColor(isInsideTmux() ? "tmux" : "direct", token);
|
|
693
|
+
return token;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void {
|
|
697
|
+
this.#privateModeCallbacks.push(callback);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void {
|
|
701
|
+
this.#inputHandler = onInput;
|
|
702
|
+
this.#resizeHandler = onResize;
|
|
703
|
+
this.#disconnectHandler = onDisconnect;
|
|
704
|
+
// The host terminal's cursor visibility is unknown until we write it.
|
|
705
|
+
this.#cursorVisible = undefined;
|
|
706
|
+
|
|
707
|
+
// Headless (tests): suppress every real-terminal side effect. Skip raw
|
|
708
|
+
// mode, stdin listeners, capability probes, SIGWINCH, and emergency-restore
|
|
709
|
+
// ownership; #safeWrite is also a no-op, so frame paints and teardown
|
|
710
|
+
// escapes never reach the developer's terminal during `bun test`.
|
|
711
|
+
this.#headless = isTerminalHeadless();
|
|
712
|
+
if (this.#headless) return;
|
|
713
|
+
registerPostmortemTerminalRestore();
|
|
714
|
+
|
|
715
|
+
// Register for emergency cleanup
|
|
716
|
+
activeTerminal = this;
|
|
717
|
+
terminalEverStarted = true;
|
|
718
|
+
|
|
719
|
+
// Keep unmanaged fd-2 writes (macOS libmalloc/framework diagnostics) off
|
|
720
|
+
// the viewport while we own the terminal; released in stop(). See
|
|
721
|
+
// stderr-guard in pi-utils (mirrors openai/codex#24459).
|
|
722
|
+
suppressTerminalStderr();
|
|
723
|
+
|
|
724
|
+
// A multiplexer or SSH disconnect can leave isTTY true after its pty has
|
|
725
|
+
// been revoked. Raw mode is then impossible, so take the normal terminal
|
|
726
|
+
// disconnect path rather than letting Bun abort startup with EIO.
|
|
727
|
+
this.#wasRaw = process.stdin.isRaw || false;
|
|
728
|
+
if (process.stdin.setRawMode) {
|
|
729
|
+
try {
|
|
730
|
+
process.stdin.setRawMode(true);
|
|
731
|
+
} catch (err) {
|
|
732
|
+
this.#markTerminalDisconnected("stdin raw mode setup failed", err);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
process.stdin.setEncoding("utf8");
|
|
737
|
+
process.stdin.on("end", this.#stdinEndHandler);
|
|
738
|
+
process.stdin.on("close", this.#stdinCloseHandler);
|
|
739
|
+
process.stdin.on("error", this.#stdinErrorHandler);
|
|
740
|
+
process.stdin.resume();
|
|
741
|
+
|
|
742
|
+
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
743
|
+
this.#safeWrite("\x1b[?2004h");
|
|
744
|
+
|
|
745
|
+
// Force normal cursor-key (DECCKM) and numeric-keypad mode (terminfo
|
|
746
|
+
// `rmkx` = "\x1b[?1l\x1b>"). omp decodes both CSI ("\x1b[A") and SS3
|
|
747
|
+
// ("\x1bOA") arrow encodings, so it never enables application mode
|
|
748
|
+
// itself — but a prior program that left the TTY in application-cursor-
|
|
749
|
+
// keys mode makes arrows arrive as SS3. Normalizing on entry keeps input
|
|
750
|
+
// in the predictable default state; stop() restores the same on exit.
|
|
751
|
+
// See #6374.
|
|
752
|
+
this.#safeWrite("\x1b[?1l\x1b>");
|
|
753
|
+
|
|
754
|
+
// Set up resize handler immediately. The OS refreshes process.stdout
|
|
755
|
+
// dimensions before firing `resize`, so it is authoritative for geometry:
|
|
756
|
+
// reconcile any stale cached DEC 2048 report before notifying the renderer.
|
|
757
|
+
this.#stdoutResizeListener = () => {
|
|
758
|
+
// Conservative: some hosts reset modes across a resize/reattach, so
|
|
759
|
+
// re-establish cursor visibility on the next explicit call.
|
|
760
|
+
this.#cursorVisible = undefined;
|
|
761
|
+
this.#reconcileInBandGeometryOnResize();
|
|
762
|
+
this.#resizeHandler?.();
|
|
763
|
+
};
|
|
764
|
+
process.stdout.on("resize", this.#stdoutResizeListener);
|
|
765
|
+
|
|
766
|
+
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
767
|
+
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
768
|
+
if (process.platform !== "win32") {
|
|
769
|
+
process.kill(process.pid, "SIGWINCH");
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
|
|
773
|
+
// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
|
|
774
|
+
// events that lose modifier information. Must run after setRawMode(true)
|
|
775
|
+
// since that resets console mode flags.
|
|
776
|
+
this.#enableWindowsVTInput();
|
|
777
|
+
// Query and enable Kitty keyboard protocol
|
|
778
|
+
// The query handler intercepts input temporarily, then installs the user's handler
|
|
779
|
+
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
780
|
+
this.#queryAndEnableKittyProtocol();
|
|
781
|
+
// Explicit probes are safe only after their response parser and stdin
|
|
782
|
+
// data handler are installed. Keep this false throughout temporary stops.
|
|
783
|
+
this.#active = true;
|
|
784
|
+
setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
|
|
785
|
+
|
|
786
|
+
// Query terminal background color via OSC 11 for dark/light detection.
|
|
787
|
+
// Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
|
|
788
|
+
// sequences in order, so if DA1 arrives before OSC 11 response,
|
|
789
|
+
// the terminal does not support OSC 11. This avoids indefinite hangs.
|
|
790
|
+
// Technique used by Neovim, bat, fish, and terminal-colorsaurus.
|
|
791
|
+
this.#queryBackgroundColor();
|
|
792
|
+
|
|
793
|
+
// Query OSC 99 notification capabilities for Kitty. The query uses the
|
|
794
|
+
// same DA1 sentinel FIFO as OSC 11/DECRQM so unsupported terminals resolve
|
|
795
|
+
// without leaking probe bytes to application input.
|
|
796
|
+
this.#queryOsc99Support();
|
|
797
|
+
|
|
798
|
+
// Subscribe to Mode 2031 appearance change notifications.
|
|
799
|
+
// When the terminal reports a change, we re-query OSC 11 to get the
|
|
800
|
+
// actual background color (following Neovim convention) with 100ms debounce.
|
|
801
|
+
this.#safeWrite("\x1b[?2031h");
|
|
802
|
+
|
|
803
|
+
// Theme detection relies on (1) the startup OSC 11 probe above and
|
|
804
|
+
// (2) DEC Mode 2031 push notifications. Terminals without Mode 2031
|
|
805
|
+
// (macOS Terminal.app, Warp, VS Code's built-in, older Alacritty/
|
|
806
|
+
// WezTerm) detect the appearance once at startup and pick up later OS
|
|
807
|
+
// theme changes on next launch. Earlier builds polled OSC 11 every 30 s
|
|
808
|
+
// here for those terminals, but each poll's OSC 11/DA1 write wiped the
|
|
809
|
+
// user's active text selection on several of them (#3297). Native Windows
|
|
810
|
+
// Terminal gets a scoped fallback after DECRQM confirms 2031 is unsupported.
|
|
811
|
+
|
|
812
|
+
// Probe DEC private-mode support via DECRQM. 2026 (synchronized output)
|
|
813
|
+
// gates the renderer's begin/end markers; 2048 (in-band resize) is enabled
|
|
814
|
+
// only after the terminal confirms support; 2031 (appearance change
|
|
815
|
+
// notifications) drives mid-session theme tracking. Xterm ?1010/?1011
|
|
816
|
+
// are disabled while OMP owns the TTY so typing in the editor does not
|
|
817
|
+
// force a reader scrolled into native history back to the tail. Each probe
|
|
818
|
+
// rides the shared DA1 sentinel, so terminals that ignore DECRQM resolve as
|
|
819
|
+
// unsupported when the DA1 reply arrives.
|
|
820
|
+
this.#queryPrivateMode(2026);
|
|
821
|
+
this.#queryPrivateMode(2048);
|
|
822
|
+
this.#queryPrivateMode(2031);
|
|
823
|
+
for (const mode of XTERM_SCROLL_TO_BOTTOM_MODES) {
|
|
824
|
+
this.#queryPrivateMode(mode);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT to the stdin console mode
|
|
830
|
+
* so modified keys (for example Shift+Tab) arrive as VT escape sequences.
|
|
831
|
+
*/
|
|
832
|
+
#enableWindowsVTInput(): void {
|
|
833
|
+
if (process.platform !== "win32") return;
|
|
834
|
+
this.#restoreWindowsVTInput();
|
|
835
|
+
try {
|
|
836
|
+
const kernel32 = dlopen("kernel32.dll", {
|
|
837
|
+
GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
|
|
838
|
+
GetConsoleMode: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
|
|
839
|
+
SetConsoleMode: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.bool },
|
|
840
|
+
});
|
|
841
|
+
const handle = kernel32.symbols.GetStdHandle(STD_INPUT_HANDLE);
|
|
842
|
+
const mode = new Uint32Array(1);
|
|
843
|
+
const modePtr = ptr(mode);
|
|
844
|
+
if (!modePtr || !kernel32.symbols.GetConsoleMode(handle, modePtr)) {
|
|
845
|
+
kernel32.close();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const originalMode = mode[0]!;
|
|
849
|
+
const vtMode = originalMode | ENABLE_VIRTUAL_TERMINAL_INPUT;
|
|
850
|
+
if (vtMode !== originalMode && !kernel32.symbols.SetConsoleMode(handle, vtMode)) {
|
|
851
|
+
kernel32.close();
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
this.#windowsVTInputRestore = () => {
|
|
855
|
+
try {
|
|
856
|
+
kernel32.symbols.SetConsoleMode(handle, originalMode);
|
|
857
|
+
} finally {
|
|
858
|
+
kernel32.close();
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
} catch {
|
|
862
|
+
// bun:ffi unavailable or console API unsupported; keep startup non-fatal.
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
#restoreWindowsVTInput(): void {
|
|
867
|
+
if (process.platform !== "win32") return;
|
|
868
|
+
const restore = this.#windowsVTInputRestore;
|
|
869
|
+
this.#windowsVTInputRestore = undefined;
|
|
870
|
+
if (!restore) return;
|
|
871
|
+
try {
|
|
872
|
+
restore();
|
|
873
|
+
} catch {
|
|
874
|
+
// Ignore restore errors during terminal teardown.
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Set up StdinBuffer to split batched input into individual sequences.
|
|
880
|
+
* This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
|
|
881
|
+
*
|
|
882
|
+
* Also watches for Kitty protocol response and enables it when detected.
|
|
883
|
+
* This is done here (after stdinBuffer parsing) rather than on raw stdin
|
|
884
|
+
* to handle the case where the response arrives split across multiple events.
|
|
885
|
+
*/
|
|
886
|
+
#setupStdinBuffer(): void {
|
|
887
|
+
// 50ms balances two failure modes: a bare ESC keypress on legacy
|
|
888
|
+
// terminals waits this long before it is delivered, while a CSI key
|
|
889
|
+
// escape split across stdin reads (laggy ssh/tmux links) leaks as
|
|
890
|
+
// literal typed text if the flush fires between the fragments. 10ms
|
|
891
|
+
// proved too tight for split escapes (#1238 covered only probe replies).
|
|
892
|
+
this.#stdinBuffer = new StdinBuffer({ timeout: 50 });
|
|
893
|
+
|
|
894
|
+
// Kitty protocol response pattern: \x1b[?<flags>u
|
|
895
|
+
const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
|
|
896
|
+
|
|
897
|
+
// Mode 2031 DSR response: \x1b[?997;{1=dark,2=light}n
|
|
898
|
+
const appearanceDsrPattern = /^\x1b\[\?997;([12])n$/;
|
|
899
|
+
|
|
900
|
+
// OSC 11 response: \x1b]11;rgb:RR/GG/BB or rgba:RR/GG/BB, terminated by BEL or ST.
|
|
901
|
+
const osc11ResponsePattern =
|
|
902
|
+
/^\x1b\]11;rgba?:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(?:\x07|\x1b\\)$/;
|
|
903
|
+
|
|
904
|
+
// DA1 (Primary Device Attributes) response: \x1b[?...c
|
|
905
|
+
const da1ResponsePattern = /^\x1b\[\?[\d;]*c$/;
|
|
906
|
+
|
|
907
|
+
// Private CSI partial: \x1b[?<digits/semicolons>... — incomplete probe response
|
|
908
|
+
// that the StdinBuffer flushed before the terminator arrived (split across
|
|
909
|
+
// stdin reads). Used to reassemble DA1, kitty, and Mode 2031 replies.
|
|
910
|
+
const privateCsiPartialPattern = /^\x1b\[\?[\d;]*[\x20-\x2f]*$/;
|
|
911
|
+
|
|
912
|
+
// DECRPM private-mode report (DECRQM reply): \x1b[?<mode>;<status>$y
|
|
913
|
+
const decrpmResponsePattern = /^\x1b\[\?(\d+);(\d+)\$y$/;
|
|
914
|
+
|
|
915
|
+
// In-band resize report (DEC mode 2048): \x1b[48;rows;cols;yPixels;xPixels t
|
|
916
|
+
// Any field may carry `:`-separated subparameters, which clients MUST
|
|
917
|
+
// ignore per spec (#4748): capture the leading digits of each field and
|
|
918
|
+
// skip the subparameter tail instead of dropping the whole report.
|
|
919
|
+
const inBandResizePattern = /^\x1b\[48;(\d+)(?::[\d:]*)?;(\d+)(?::[\d:]*)?;(\d+)(?::[\d:]*)?;(\d+)(?::[\d:]*)?t$/;
|
|
920
|
+
|
|
921
|
+
this.#stdinBuffer.on("data", (sequence: string) => {
|
|
922
|
+
// Fast path for plain-text bytes: every escape-probe regex below
|
|
923
|
+
// anchors on `^\x1b…`, so a byte that is not ESC can never match. A
|
|
924
|
+
// non-bracketed paste of N printable chars arrives as N per-scalar
|
|
925
|
+
// `data` events; running the full probe suite per event turns a
|
|
926
|
+
// 100 KB paste into ~600K regex executions and blocks the event
|
|
927
|
+
// loop. Skip straight to the input handler when no reassembly
|
|
928
|
+
// buffer is holding state that a non-ESC continuation could feed
|
|
929
|
+
// (issue #4073 case C).
|
|
930
|
+
if (
|
|
931
|
+
(sequence.length === 0 || sequence.charCodeAt(0) !== 0x1b) &&
|
|
932
|
+
this.#privateCsiResponseBuffer.length === 0 &&
|
|
933
|
+
this.#inBandResizeBuffer.length === 0 &&
|
|
934
|
+
this.#osc11ResponseBuffer.length === 0 &&
|
|
935
|
+
this.#osc99ResponseBuffer.length === 0
|
|
936
|
+
) {
|
|
937
|
+
if (this.#inputHandler) {
|
|
938
|
+
this.#inputHandler(sequence);
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Reassemble split private CSI responses (DA1, kitty keyboard, Mode 2031).
|
|
944
|
+
// When the terminal writes the response slowly enough that the StdinBuffer's
|
|
945
|
+
// flush timeout elapses mid-sequence, the prefix `\x1b[?<digits>` arrives as
|
|
946
|
+
// one event and the tail `;...<terminator>` arrives as individual character
|
|
947
|
+
// events that would otherwise leak into the prompt as keystrokes. See #1238.
|
|
948
|
+
if (
|
|
949
|
+
this.#privateCsiResponseBuffer ||
|
|
950
|
+
(privateCsiPartialPattern.test(sequence) && this.#da1SentinelOwners.length > 0)
|
|
951
|
+
) {
|
|
952
|
+
if (this.#privateCsiResponseBuffer && sequence.startsWith("\x1b")) {
|
|
953
|
+
// New escape arrived mid-reassembly — abandon partial and re-process the new sequence.
|
|
954
|
+
this.#privateCsiResponseBuffer = "";
|
|
955
|
+
} else {
|
|
956
|
+
this.#privateCsiResponseBuffer += sequence;
|
|
957
|
+
// Cap accumulator to defend against runaway partials if the terminator never arrives.
|
|
958
|
+
if (this.#privateCsiResponseBuffer.length > 256) {
|
|
959
|
+
this.#privateCsiResponseBuffer = "";
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const lastChar = this.#privateCsiResponseBuffer.at(-1)!;
|
|
963
|
+
const lastCode = lastChar.charCodeAt(0);
|
|
964
|
+
if (lastCode >= 0x40 && lastCode <= 0x7e) {
|
|
965
|
+
// Terminator byte arrived. Fall through to the pattern checks with the
|
|
966
|
+
// reassembled sequence so the existing DA1/kitty/Mode 2031 handlers run.
|
|
967
|
+
sequence = this.#privateCsiResponseBuffer;
|
|
968
|
+
this.#privateCsiResponseBuffer = "";
|
|
969
|
+
} else if (!privateCsiPartialPattern.test(this.#privateCsiResponseBuffer)) {
|
|
970
|
+
// Diverged from a valid private CSI prefix (unexpected byte). Drop the
|
|
971
|
+
// probe noise we ate; do not forward to the input handler.
|
|
972
|
+
this.#privateCsiResponseBuffer = "";
|
|
973
|
+
return;
|
|
974
|
+
} else {
|
|
975
|
+
// Still accumulating.
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// In-band resize report (DEC 2048) split across stdin reads. The report
|
|
982
|
+
// is `\x1b[48;rows;cols;yPx;xPx t`; when the StdinBuffer flush timeout
|
|
983
|
+
// elapses mid-sequence — common during a rapid resize that keeps the
|
|
984
|
+
// event loop busy — the `\x1b[48;…` prefix arrives as one event and the
|
|
985
|
+
// tail (`…;xPx t`) arrives as bare character events that would otherwise
|
|
986
|
+
// leak into the prompt as literal keystrokes. Reassemble until the
|
|
987
|
+
// terminator, then fall through to the resize handler below. A
|
|
988
|
+
// reassembled sequence that turns out not to be a resize report (e.g. a
|
|
989
|
+
// split kitty `\x1b[48;…u` for a digit key) is forwarded to the input
|
|
990
|
+
// handler rather than dropped.
|
|
991
|
+
const inBandResizePartialPattern = /^\x1b\[4[\d;:]*$/;
|
|
992
|
+
const isInBandResizePartial = this.#inBandResizeActive && inBandResizePartialPattern.test(sequence);
|
|
993
|
+
if (this.#inBandResizeBuffer && sequence.startsWith("\x1b")) {
|
|
994
|
+
// A new escape interrupted the partial; the stale partial is
|
|
995
|
+
// unrecoverable. If the new escape is itself an in-band prefix,
|
|
996
|
+
// restart reassembly with it; otherwise let it flow through below.
|
|
997
|
+
this.#inBandResizeBuffer = isInBandResizePartial ? sequence : "";
|
|
998
|
+
if (isInBandResizePartial) return;
|
|
999
|
+
} else if (this.#inBandResizeBuffer || isInBandResizePartial) {
|
|
1000
|
+
this.#inBandResizeBuffer += sequence;
|
|
1001
|
+
if (this.#inBandResizeBuffer.length > 256) {
|
|
1002
|
+
this.#inBandResizeBuffer = "";
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const lastCode = this.#inBandResizeBuffer.charCodeAt(this.#inBandResizeBuffer.length - 1);
|
|
1006
|
+
if (lastCode >= 0x40 && lastCode <= 0x7e) {
|
|
1007
|
+
// Terminator arrived: let the resize handler below claim it, or
|
|
1008
|
+
// fall through to the input handler if it is not a resize report.
|
|
1009
|
+
sequence = this.#inBandResizeBuffer;
|
|
1010
|
+
this.#inBandResizeBuffer = "";
|
|
1011
|
+
} else if (!inBandResizePartialPattern.test(this.#inBandResizeBuffer)) {
|
|
1012
|
+
// Diverged from a valid in-band prefix — drop the garbled report.
|
|
1013
|
+
this.#inBandResizeBuffer = "";
|
|
1014
|
+
return;
|
|
1015
|
+
} else {
|
|
1016
|
+
// Still accumulating the report.
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// In-band resize report (DEC mode 2048). Unsolicited and not tied to a
|
|
1022
|
+
// sentinel: update reported geometry + cell size, then drive the resize
|
|
1023
|
+
// handler so the renderer reflows.
|
|
1024
|
+
const resizeMatch = sequence.match(inBandResizePattern);
|
|
1025
|
+
if (resizeMatch) {
|
|
1026
|
+
this.#handleInBandResizeReport(resizeMatch[1]!, resizeMatch[2]!, resizeMatch[3]!, resizeMatch[4]!);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// DECRPM private-mode report. Resolves the matching probe by mode; the
|
|
1031
|
+
// owner stays in the FIFO and is drained by its DA1 sentinel (a no-op
|
|
1032
|
+
// once resolved). Per DECRPM, status 0 = unrecognized, 1/2 =
|
|
1033
|
+
// set/reset, 3 = permanently set, and 4 = permanently reset.
|
|
1034
|
+
const decrpmMatch = sequence.match(decrpmResponsePattern);
|
|
1035
|
+
if (decrpmMatch) {
|
|
1036
|
+
this.#handlePrivateModeReport(parseInt(decrpmMatch[1]!, 10), decrpmMatch[2]!);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// DA1 response: swallow our sentinel reply regardless of whether an
|
|
1041
|
+
// earlier capability-specific response already succeeded. Other terminal
|
|
1042
|
+
// probes should never see these replies.
|
|
1043
|
+
if (da1ResponsePattern.test(sequence) && this.#da1SentinelOwners.length > 0) {
|
|
1044
|
+
const owner = this.#da1SentinelOwners.shift()!;
|
|
1045
|
+
switch (owner.kind) {
|
|
1046
|
+
case "osc11": {
|
|
1047
|
+
if (this.#osc11Pending) {
|
|
1048
|
+
// DA1 arrived before OSC 11 response: terminal doesn't support OSC 11.
|
|
1049
|
+
this.#osc11Pending = false;
|
|
1050
|
+
this.#osc11ActiveToken = undefined;
|
|
1051
|
+
this.#osc11ResponseBuffer = "";
|
|
1052
|
+
}
|
|
1053
|
+
// Start a queued OSC 11 query once the prior cycle is fully drained.
|
|
1054
|
+
if (
|
|
1055
|
+
this.#osc11QueuedQuery !== undefined &&
|
|
1056
|
+
!this.#osc11Pending &&
|
|
1057
|
+
!this.#da1SentinelOwners.some(o => o.kind === "osc11") &&
|
|
1058
|
+
!this.#dead
|
|
1059
|
+
) {
|
|
1060
|
+
const query = this.#osc11QueuedQuery;
|
|
1061
|
+
this.#osc11QueuedQuery = undefined;
|
|
1062
|
+
this.#startOsc11Query(query.route, query.token);
|
|
1063
|
+
}
|
|
1064
|
+
break;
|
|
1065
|
+
}
|
|
1066
|
+
case "privateMode": {
|
|
1067
|
+
// DA1 beat the DECRPM reply. The terminal cannot report this
|
|
1068
|
+
// capability, but may still implement it; keep that distinction
|
|
1069
|
+
// so static terminal detection is not incorrectly downgraded.
|
|
1070
|
+
this.#resolvePrivateMode(owner.mode, false, false);
|
|
1071
|
+
break;
|
|
1072
|
+
}
|
|
1073
|
+
case "keyboard": {
|
|
1074
|
+
// Keyboard probe sentinel: kitty reply never arrived → fall back to modifyOtherKeys
|
|
1075
|
+
// only where the resolved terminal is known enough to tolerate it.
|
|
1076
|
+
if (this.#modifyOtherKeysTimeout) {
|
|
1077
|
+
clearTimeout(this.#modifyOtherKeysTimeout);
|
|
1078
|
+
this.#modifyOtherKeysTimeout = undefined;
|
|
1079
|
+
}
|
|
1080
|
+
this.#enableModifyOtherKeysFallback();
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
case "osc99Probe": {
|
|
1084
|
+
this.#resolveOsc99Support(owner.id, false);
|
|
1085
|
+
break;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const match = sequence.match(kittyResponsePattern);
|
|
1092
|
+
if (match) {
|
|
1093
|
+
if (this.#modifyOtherKeysTimeout) {
|
|
1094
|
+
clearTimeout(this.#modifyOtherKeysTimeout);
|
|
1095
|
+
this.#modifyOtherKeysTimeout = undefined;
|
|
1096
|
+
}
|
|
1097
|
+
// A DA1 sentinel that beat the kitty reply may have already
|
|
1098
|
+
// engaged the modifyOtherKeys fallback (terminals such as
|
|
1099
|
+
// Superset/xterm-on-Electron answer DA1 before `\x1b[?u`).
|
|
1100
|
+
// Kitty is strictly preferred — undo the fallback so the two
|
|
1101
|
+
// modes do not stack. See #2042.
|
|
1102
|
+
if (this.#modifyOtherKeysActive) {
|
|
1103
|
+
this.#safeWrite("\x1b[>4;0m");
|
|
1104
|
+
this.#modifyOtherKeysActive = false;
|
|
1105
|
+
}
|
|
1106
|
+
// Any reply to `\x1b[?u` means the terminal speaks the kitty keyboard
|
|
1107
|
+
// protocol. The reported flag value is the *current* stack-top — fresh
|
|
1108
|
+
// terminals report 0 — so support is implied by the reply itself, not by
|
|
1109
|
+
// the flag value. Pick the level we want; `\x1b[>Nu` pushes one frame
|
|
1110
|
+
// that shutdown's single `\x1b[<u` pop balances.
|
|
1111
|
+
const reportedFlags = parseInt(match[1]!, 10);
|
|
1112
|
+
this.#kittyProtocolActive = true;
|
|
1113
|
+
setKittyProtocolActive(true);
|
|
1114
|
+
if (isConPTYHosted()) {
|
|
1115
|
+
// ConPTY (native Windows and WSL) drops Shift+letter keypresses
|
|
1116
|
+
// entirely when flag 4 (report alternate keys) is set. Use flag 1
|
|
1117
|
+
// (disambiguate only), preserving flag 2 if already active.
|
|
1118
|
+
this.#kittyEnableSeq = (reportedFlags & 2) !== 0 ? "\x1b[>3u" : "\x1b[>1u";
|
|
1119
|
+
this.#safeWrite(this.#kittyEnableSeq);
|
|
1120
|
+
} else if ((reportedFlags & 2) !== 0) {
|
|
1121
|
+
// Preserve event-type reporting already enabled by a parent app.
|
|
1122
|
+
// Push level-2 to keep its shortcuts reporting consistently.
|
|
1123
|
+
this.#kittyEnableSeq = "\x1b[>7u";
|
|
1124
|
+
this.#safeWrite(this.#kittyEnableSeq);
|
|
1125
|
+
} else {
|
|
1126
|
+
// Disambiguate escape codes and report base-layout keys for physical
|
|
1127
|
+
// shortcut matching, without event reporting that caused regression #3259.
|
|
1128
|
+
this.#kittyEnableSeq = "\x1b[>5u";
|
|
1129
|
+
this.#safeWrite(this.#kittyEnableSeq);
|
|
1130
|
+
}
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// OSC 11 replies can be split if the stdin buffer flushes a partial sequence.
|
|
1135
|
+
// Accumulate fragments until the BEL/ST terminator arrives, then parse once.
|
|
1136
|
+
// If a new escape sequence arrives (not the ST terminator), abort buffering
|
|
1137
|
+
// and forward it as normal input so user keystrokes are never swallowed.
|
|
1138
|
+
if (this.#osc11Pending && (this.#osc11ResponseBuffer || sequence.startsWith("\x1b]11;"))) {
|
|
1139
|
+
if (this.#osc11ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
|
|
1140
|
+
// New escape sequence arrived mid-buffer — not an OSC 11 continuation.
|
|
1141
|
+
this.#osc11ResponseBuffer = "";
|
|
1142
|
+
// Fall through to normal input handling below.
|
|
1143
|
+
} else {
|
|
1144
|
+
this.#osc11ResponseBuffer += sequence;
|
|
1145
|
+
const osc11Match = this.#osc11ResponseBuffer.match(osc11ResponsePattern);
|
|
1146
|
+
if (!osc11Match) return;
|
|
1147
|
+
const [, rHex, gHex, bHex] = osc11Match;
|
|
1148
|
+
this.#osc11Pending = false;
|
|
1149
|
+
const requestToken = this.#osc11ActiveToken;
|
|
1150
|
+
this.#osc11ActiveToken = undefined;
|
|
1151
|
+
this.#osc11ResponseBuffer = "";
|
|
1152
|
+
this.#handleOsc11Response(rHex!, gHex!, bHex!, requestToken);
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (this.#osc99PendingId && (this.#osc99ResponseBuffer || sequence.startsWith("\x1b]99;"))) {
|
|
1158
|
+
if (this.#osc99ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
|
|
1159
|
+
this.#osc99ResponseBuffer = "";
|
|
1160
|
+
} else {
|
|
1161
|
+
this.#osc99ResponseBuffer += sequence;
|
|
1162
|
+
const osc99Match = this.#osc99ResponseBuffer.match(/^\x1b\]99;([^;]*);([\s\S]*?)(?:\x07|\x1b\\)$/u);
|
|
1163
|
+
if (!osc99Match) return;
|
|
1164
|
+
const [, meta, payload] = osc99Match;
|
|
1165
|
+
this.#osc99ResponseBuffer = "";
|
|
1166
|
+
this.#handleOsc99CapabilityResponse(meta!, payload!);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Mode 2031 change notification: re-query OSC 11 with 100ms debounce
|
|
1172
|
+
// (Neovim convention — coalesces rapid notifications during transitions)
|
|
1173
|
+
const appearanceMatch = sequence.match(appearanceDsrPattern);
|
|
1174
|
+
if (appearanceMatch) {
|
|
1175
|
+
if (this.#mode2031DebounceTimer) clearTimeout(this.#mode2031DebounceTimer);
|
|
1176
|
+
this.#mode2031DebounceTimer = setTimeout(() => {
|
|
1177
|
+
this.#mode2031DebounceTimer = undefined;
|
|
1178
|
+
this.#queryBackgroundColor();
|
|
1179
|
+
}, 100);
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
if (this.#inputHandler) {
|
|
1183
|
+
this.#inputHandler(sequence);
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
// Re-wrap paste content with bracketed paste markers for existing editor handling
|
|
1188
|
+
this.#stdinBuffer.on("paste", (content: string) => {
|
|
1189
|
+
if (this.#inputHandler) {
|
|
1190
|
+
this.#inputHandler(`\x1b[200~${content}\x1b[201~`);
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
// Handler that pipes stdin data through the buffer
|
|
1195
|
+
this.#stdinDataHandler = (data: string) => {
|
|
1196
|
+
this.#stdinBuffer!.process(data);
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Send OSC 11 background color query followed by DA1 sentinel.
|
|
1202
|
+
* DA1 avoids indefinite hangs: if DA1 response arrives before OSC 11,
|
|
1203
|
+
* the terminal does not support OSC 11.
|
|
1204
|
+
*/
|
|
1205
|
+
#queryBackgroundColor(route: Osc11QueryRoute = "direct", token?: TerminalAppearanceRequestToken): void {
|
|
1206
|
+
if (this.#dead) return;
|
|
1207
|
+
// Queue if an OSC 11 query is in flight or its DA1 sentinel hasn't been
|
|
1208
|
+
// consumed yet. Starting a new query while a DA1 is outstanding would
|
|
1209
|
+
// increment the sentinel counter, and the old DA1 arrival would then
|
|
1210
|
+
// prematurely clear the new query's pending state. Preserve a requested
|
|
1211
|
+
// tmux passthrough route when coalescing direct and explicit queries, and
|
|
1212
|
+
// retain the latest explicit request identity across automatic queries.
|
|
1213
|
+
if (this.#osc11Pending || this.#da1SentinelOwners.some(o => o.kind === "osc11")) {
|
|
1214
|
+
const queued = this.#osc11QueuedQuery;
|
|
1215
|
+
this.#osc11QueuedQuery = {
|
|
1216
|
+
route: queued?.route === "tmux" || route === "tmux" ? "tmux" : "direct",
|
|
1217
|
+
token: token ?? queued?.token,
|
|
1218
|
+
};
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
this.#startOsc11Query(route, token);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
#startOsc11Query(route: Osc11QueryRoute, token?: TerminalAppearanceRequestToken): void {
|
|
1225
|
+
this.#osc11Pending = true;
|
|
1226
|
+
this.#osc11ActiveToken = token;
|
|
1227
|
+
this.#osc11ResponseBuffer = "";
|
|
1228
|
+
if (route === "tmux") {
|
|
1229
|
+
this.#safeWrite(wrapTmuxPassthrough("\x1b]11;?\x07"));
|
|
1230
|
+
this.#osc11TmuxRefreshTimer = setTimeout(() => {
|
|
1231
|
+
this.#osc11TmuxRefreshTimer = undefined;
|
|
1232
|
+
if (this.#dead || !this.#osc11Pending) return;
|
|
1233
|
+
this.#startDirectOsc11Query();
|
|
1234
|
+
}, TMUX_OSC11_CACHE_REFRESH_DELAY_MS);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
this.#startDirectOsc11Query();
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
#startDirectOsc11Query(): void {
|
|
1241
|
+
this.#da1SentinelOwners.push({ kind: "osc11" });
|
|
1242
|
+
this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated)
|
|
1243
|
+
this.#safeWrite("\x1b[c"); // DA1 sentinel
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
#shouldQueryOsc99Support(): boolean {
|
|
1247
|
+
if (TERMINAL.notifyProtocol !== NotifyProtocol.Osc99) return false;
|
|
1248
|
+
// Never probe inside a terminal multiplexer. tmux/screen forward the
|
|
1249
|
+
// passthrough-wrapped `p=?` query to the outer terminal, but cannot route
|
|
1250
|
+
// the capability reply back to the pane that sent it (tmux/tmux#4386,
|
|
1251
|
+
// tmux/tmux#3964), so the reply leaks into the pane as literal text and
|
|
1252
|
+
// its bytes perturb input (issue #5582 — the notification sibling of the
|
|
1253
|
+
// graphics-probe leak #5381). Rich notifications fall back to the
|
|
1254
|
+
// single-line OSC 99 form until confirmation, and delivery still uses the
|
|
1255
|
+
// passthrough/BEL path (#3395).
|
|
1256
|
+
if (isInsideTerminalMultiplexer($env)) return false;
|
|
1257
|
+
return !isBunTestRuntime() || $env.PI_TUI_OSC99_PROBE === "1";
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
#queryOsc99Support(): void {
|
|
1261
|
+
setOsc99Supported(false);
|
|
1262
|
+
this.#osc99Capabilities.clear();
|
|
1263
|
+
this.#osc99PendingId = undefined;
|
|
1264
|
+
this.#osc99ResponseBuffer = "";
|
|
1265
|
+
if (this.#dead || !this.#shouldQueryOsc99Support()) return;
|
|
1266
|
+
|
|
1267
|
+
const id = `zeta-probe-${nextOsc99ProbeId++}`;
|
|
1268
|
+
this.#osc99PendingId = id;
|
|
1269
|
+
this.#da1SentinelOwners.push({ kind: "osc99Probe", id });
|
|
1270
|
+
// The probe never runs under a multiplexer (see #shouldQueryOsc99Support),
|
|
1271
|
+
// so it is always sent directly to the terminal.
|
|
1272
|
+
this.#safeWrite(`\x1b]99;i=${id}:p=?;\x1b\\\x1b[c`);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
#handleOsc99CapabilityResponse(metaRaw: string, payload: string): boolean {
|
|
1276
|
+
const pendingId = this.#osc99PendingId;
|
|
1277
|
+
if (!pendingId) return false;
|
|
1278
|
+
const meta = parseOsc99KeyValues(metaRaw);
|
|
1279
|
+
if (meta.get("i") !== pendingId || meta.get("p") !== "?") return false;
|
|
1280
|
+
|
|
1281
|
+
const capabilities = parseOsc99KeyValues(payload);
|
|
1282
|
+
this.#osc99Capabilities = capabilities;
|
|
1283
|
+
const payloadTypes = capabilities.get("p")?.split(",") ?? [];
|
|
1284
|
+
this.#resolveOsc99Support(pendingId, payloadTypes.includes("title"));
|
|
1285
|
+
return true;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
#resolveOsc99Support(id: string, supported: boolean): void {
|
|
1289
|
+
if (this.#osc99PendingId !== id) return;
|
|
1290
|
+
this.#osc99PendingId = undefined;
|
|
1291
|
+
this.#osc99ResponseBuffer = "";
|
|
1292
|
+
if (!supported) this.#osc99Capabilities.clear();
|
|
1293
|
+
setOsc99Supported(supported);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Parse an OSC 11 background color response and compute BT.601 luminance.
|
|
1298
|
+
* Handles 1-, 2-, 3-, and 4-digit XParseColor hex components.
|
|
1299
|
+
*/
|
|
1300
|
+
#handleOsc11Response(rHex: string, gHex: string, bHex: string, requestToken?: TerminalAppearanceRequestToken): void {
|
|
1301
|
+
const normalize = (hex: string): number => {
|
|
1302
|
+
const value = parseInt(hex, 16);
|
|
1303
|
+
if (Number.isNaN(value)) return 0;
|
|
1304
|
+
const max = 16 ** hex.length - 1;
|
|
1305
|
+
return max > 0 ? value / max : 0;
|
|
1306
|
+
};
|
|
1307
|
+
const luminance = 0.299 * normalize(rHex) + 0.587 * normalize(gHex) + 0.114 * normalize(bHex);
|
|
1308
|
+
const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light";
|
|
1309
|
+
const changed = mode !== this.#appearance;
|
|
1310
|
+
this.#appearance = mode;
|
|
1311
|
+
for (const cb of [...this.#appearanceReportCallbacks]) {
|
|
1312
|
+
try {
|
|
1313
|
+
cb(mode, requestToken);
|
|
1314
|
+
} catch {
|
|
1315
|
+
/* ignore callback errors */
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
if (!changed) return;
|
|
1319
|
+
for (const cb of this.#appearanceCallbacks) {
|
|
1320
|
+
try {
|
|
1321
|
+
cb(mode, requestToken);
|
|
1322
|
+
} catch {
|
|
1323
|
+
/* ignore callback errors */
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
#enableModifyOtherKeysFallback(): void {
|
|
1329
|
+
if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) return;
|
|
1330
|
+
if (!shouldEnableModifyOtherKeysFallback()) return;
|
|
1331
|
+
this.#safeWrite("\x1b[>4;2m");
|
|
1332
|
+
this.#modifyOtherKeysActive = true;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Query terminal for Kitty keyboard protocol support and enable if available.
|
|
1337
|
+
*
|
|
1338
|
+
* Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
|
|
1339
|
+
* it supports the protocol and we enable it with CSI > 1 u.
|
|
1340
|
+
*
|
|
1341
|
+
* The response is detected in setupStdinBuffer's data handler, which properly
|
|
1342
|
+
* handles the case where the response arrives split across multiple stdin events.
|
|
1343
|
+
*/
|
|
1344
|
+
#queryAndEnableKittyProtocol(): void {
|
|
1345
|
+
this.#setupStdinBuffer();
|
|
1346
|
+
process.stdin.on("data", this.#stdinDataHandler!);
|
|
1347
|
+
// Progressive enhancement query: CSI ?u asks the terminal for its current
|
|
1348
|
+
// kitty keyboard flags (no side effect on the stack); the DA1 sentinel
|
|
1349
|
+
// guarantees a reply even from terminals that ignore CSI ?u.
|
|
1350
|
+
this.#da1SentinelOwners.push({ kind: "keyboard" });
|
|
1351
|
+
this.#safeWrite("\x1b[?u\x1b[c");
|
|
1352
|
+
this.#modifyOtherKeysTimeout = setTimeout(() => {
|
|
1353
|
+
this.#modifyOtherKeysTimeout = undefined;
|
|
1354
|
+
this.#enableModifyOtherKeysFallback();
|
|
1355
|
+
}, 150);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
1359
|
+
* Probe a DEC private mode via DECRQM (`CSI ? mode $ p`) plus a DA1 sentinel.
|
|
1360
|
+
* The sentinel guarantees resolution even from terminals that ignore DECRQM.
|
|
1361
|
+
* Query and sentinel are fused into one write so the bare-`CSI c` sentinel
|
|
1362
|
+
* accounting used elsewhere stays accurate.
|
|
1363
|
+
*/
|
|
1364
|
+
#queryPrivateMode(mode: number): void {
|
|
1365
|
+
if (this.#dead) return;
|
|
1366
|
+
if (this.#privateModeSupport.has(mode)) return;
|
|
1367
|
+
this.#da1SentinelOwners.push({ kind: "privateMode", mode });
|
|
1368
|
+
this.#safeWrite(`\x1b[?${mode}$p\x1b[c`);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
#handlePrivateModeReport(mode: number, status: string): void {
|
|
1372
|
+
this.#resolvePrivateMode(mode, isPrivateModeSupported(status), true);
|
|
1373
|
+
if (isXtermScrollToBottomMode(mode) && isPrivateModeSet(status)) {
|
|
1374
|
+
this.#disableXtermScrollToBottomMode(mode);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* Record DECRQM support for a private mode (idempotent — first result wins)
|
|
1380
|
+
* and notify subscribers. `confirmed` distinguishes an explicit DECRPM
|
|
1381
|
+
* unsupported response from an absent response followed by the DA1 sentinel.
|
|
1382
|
+
* Enables DEC 2048 in-band resize only after positive confirmation.
|
|
1383
|
+
*/
|
|
1384
|
+
#resolvePrivateMode(mode: number, supported: boolean, confirmed: boolean): void {
|
|
1385
|
+
if (this.#privateModeSupport.has(mode)) return;
|
|
1386
|
+
this.#privateModeSupport.set(mode, supported);
|
|
1387
|
+
for (const cb of this.#privateModeCallbacks) {
|
|
1388
|
+
try {
|
|
1389
|
+
cb(mode, supported, confirmed);
|
|
1390
|
+
} catch {
|
|
1391
|
+
// Ignore subscriber errors — capability reporting must not crash input.
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
if (mode === 2048 && supported) this.#enableInBandResize();
|
|
1395
|
+
if (mode === 2031) this.#syncWindowsTerminalAppearancePolling(supported);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
#syncWindowsTerminalAppearancePolling(mode2031Supported: boolean): void {
|
|
1399
|
+
if (mode2031Supported || !shouldPollWindowsTerminalAppearance() || this.#dead) {
|
|
1400
|
+
this.#clearWindowsTerminalAppearancePoll();
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
if (this.#windowsTerminalAppearancePollTimer) return;
|
|
1404
|
+
this.#windowsTerminalAppearancePollTimer = setInterval(() => {
|
|
1405
|
+
this.#queryBackgroundColor();
|
|
1406
|
+
}, WINDOWS_TERMINAL_OSC11_POLL_MS);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
#clearWindowsTerminalAppearancePoll(): void {
|
|
1410
|
+
if (!this.#windowsTerminalAppearancePollTimer) return;
|
|
1411
|
+
clearInterval(this.#windowsTerminalAppearancePollTimer);
|
|
1412
|
+
this.#windowsTerminalAppearancePollTimer = undefined;
|
|
1413
|
+
}
|
|
1414
|
+
#disableXtermScrollToBottomMode(mode: number): void {
|
|
1415
|
+
if (this.#xtermScrollToBottomRestoreModes.has(mode) || this.#dead) return;
|
|
1416
|
+
this.#xtermScrollToBottomRestoreModes.add(mode);
|
|
1417
|
+
this.#safeWrite(`\x1b[?${mode}l`);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Enable DEC 2048 in-band resize notifications. The terminal emits an initial
|
|
1422
|
+
* report immediately, seeding reported geometry and cell dimensions.
|
|
1423
|
+
*/
|
|
1424
|
+
#enableInBandResize(): void {
|
|
1425
|
+
if (this.#inBandResizeActive || this.#dead) return;
|
|
1426
|
+
this.#inBandResizeActive = true;
|
|
1427
|
+
this.#safeWrite("\x1b[?2048h");
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* Apply an in-band resize report. Stores reported geometry so `rows`/`columns`
|
|
1432
|
+
* reflect in-band values, derives cell pixel size, and drives the resize
|
|
1433
|
+
* handler only when the report changes the effective row/column geometry.
|
|
1434
|
+
*/
|
|
1435
|
+
#handleInBandResizeReport(rowsRaw: string, colsRaw: string, yPixelsRaw: string, xPixelsRaw: string): void {
|
|
1436
|
+
const previousRows = this.rows;
|
|
1437
|
+
const previousColumns = this.columns;
|
|
1438
|
+
const rows = parseInt(rowsRaw, 10);
|
|
1439
|
+
const cols = parseInt(colsRaw, 10);
|
|
1440
|
+
const yPixels = parseInt(yPixelsRaw, 10);
|
|
1441
|
+
const xPixels = parseInt(xPixelsRaw, 10);
|
|
1442
|
+
if (rows > 0) this.#reportedRows = rows;
|
|
1443
|
+
if (cols > 0) this.#reportedColumns = cols;
|
|
1444
|
+
if (cols > 0 && xPixels > 0 && rows > 0 && yPixels > 0) {
|
|
1445
|
+
setCellDimensions({
|
|
1446
|
+
widthPx: Math.max(1, Math.round(xPixels / cols)),
|
|
1447
|
+
heightPx: Math.max(1, Math.round(yPixels / rows)),
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
if (rows > 0 && cols > 0 && (rows !== previousRows || cols !== previousColumns)) {
|
|
1451
|
+
this.#resizeHandler?.();
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* Reconcile cached in-band geometry with the OS on an OS-level resize.
|
|
1457
|
+
*
|
|
1458
|
+
* SIGWINCH (POSIX) and ConPTY (Windows) refresh `process.stdout.columns`/
|
|
1459
|
+
* `rows` before the `resize` event fires, so they are authoritative for the
|
|
1460
|
+
* new cell geometry. A cached DEC 2048 report can be stale: the matching
|
|
1461
|
+
* post-resize report may be dropped (split across stdin reads past the flush
|
|
1462
|
+
* window, or interrupted by another escape mid-reassembly), leaving the
|
|
1463
|
+
* getters pinned to the old size — which freezes the rendered width because
|
|
1464
|
+
* the renderer reflows against {@link columns}/{@link rows}, not the live OS
|
|
1465
|
+
* value. Drop a cached dimension that disagrees with the live OS value; the
|
|
1466
|
+
* terminal's next valid in-band report re-seeds pixel sizing.
|
|
1467
|
+
*/
|
|
1468
|
+
#reconcileInBandGeometryOnResize(): void {
|
|
1469
|
+
if (!this.#inBandResizeActive) return;
|
|
1470
|
+
const osColumns = process.stdout.columns;
|
|
1471
|
+
const osRows = process.stdout.rows;
|
|
1472
|
+
if (this.#reportedColumns !== undefined && osColumns > 0 && this.#reportedColumns !== osColumns) {
|
|
1473
|
+
this.#reportedColumns = undefined;
|
|
1474
|
+
}
|
|
1475
|
+
if (this.#reportedRows !== undefined && osRows > 0 && this.#reportedRows !== osRows) {
|
|
1476
|
+
this.#reportedRows = undefined;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
|
|
1481
|
+
if (this.#headless) return;
|
|
1482
|
+
if (this.#kittyProtocolActive) {
|
|
1483
|
+
// Disable Kitty keyboard protocol first so any late key releases
|
|
1484
|
+
// do not generate new Kitty escape sequences.
|
|
1485
|
+
this.#safeWrite("\x1b[<u");
|
|
1486
|
+
this.#kittyProtocolActive = false;
|
|
1487
|
+
setKittyProtocolActive(false);
|
|
1488
|
+
}
|
|
1489
|
+
if (this.#modifyOtherKeysTimeout) {
|
|
1490
|
+
clearTimeout(this.#modifyOtherKeysTimeout);
|
|
1491
|
+
this.#modifyOtherKeysTimeout = undefined;
|
|
1492
|
+
}
|
|
1493
|
+
if (this.#modifyOtherKeysActive) {
|
|
1494
|
+
this.#safeWrite("\x1b[>4;0m");
|
|
1495
|
+
this.#modifyOtherKeysActive = false;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const previousHandler = this.#inputHandler;
|
|
1499
|
+
this.#inputHandler = undefined;
|
|
1500
|
+
|
|
1501
|
+
let lastDataTime = Date.now();
|
|
1502
|
+
const onData = () => {
|
|
1503
|
+
lastDataTime = Date.now();
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
process.stdin.on("data", onData);
|
|
1507
|
+
const endTime = Date.now() + maxMs;
|
|
1508
|
+
|
|
1509
|
+
try {
|
|
1510
|
+
while (true) {
|
|
1511
|
+
const now = Date.now();
|
|
1512
|
+
const timeLeft = endTime - now;
|
|
1513
|
+
if (timeLeft <= 0) break;
|
|
1514
|
+
if (now - lastDataTime >= idleMs) break;
|
|
1515
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(idleMs, timeLeft)));
|
|
1516
|
+
}
|
|
1517
|
+
} finally {
|
|
1518
|
+
process.stdin.removeListener("data", onData);
|
|
1519
|
+
this.#inputHandler = previousHandler;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
stop(): void {
|
|
1524
|
+
// Suppress observer/timer callbacks before any teardown can yield or throw.
|
|
1525
|
+
this.#active = false;
|
|
1526
|
+
if (this.#headless) return;
|
|
1527
|
+
// Unregister from emergency cleanup
|
|
1528
|
+
if (activeTerminal === this) {
|
|
1529
|
+
activeTerminal = null;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// Release terminal ownership of fd 2 first so external programs,
|
|
1533
|
+
// suspend, and shutdown see the real stderr even if a later teardown
|
|
1534
|
+
// step throws.
|
|
1535
|
+
restoreTerminalStderr();
|
|
1536
|
+
|
|
1537
|
+
if (this.#clearProgressTimer()) {
|
|
1538
|
+
this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// Leave paint-time terminal modes even if the process exits between the
|
|
1542
|
+
// begin/end halves of a frame. Safe no-ops on terminals that ignored them.
|
|
1543
|
+
this.#safeWrite("\x1b[?2026l\x1b[?7h");
|
|
1544
|
+
|
|
1545
|
+
// Restore normal cursor-key (DECCKM) and numeric-keypad mode (terminfo
|
|
1546
|
+
// `rmkx`). Symmetric with the normalize in start(): a TTY-sharing child
|
|
1547
|
+
// can leave the terminal in application-cursor-keys mode, and without
|
|
1548
|
+
// this reset the parent shell inherits SS3 arrows so Up/Down history
|
|
1549
|
+
// navigation stays broken after omp exits (#6374).
|
|
1550
|
+
this.#safeWrite("\x1b[?1l\x1b>");
|
|
1551
|
+
|
|
1552
|
+
// Disable bracketed paste mode
|
|
1553
|
+
this.#safeWrite("\x1b[?2004l");
|
|
1554
|
+
this.#safeWrite("\x1b[?5522l");
|
|
1555
|
+
|
|
1556
|
+
// Disable mouse tracking (enabled only by fullscreen overlays; safe
|
|
1557
|
+
// no-ops otherwise). Covers crash paths that reach stop() without the
|
|
1558
|
+
// TUI's own overlay teardown running.
|
|
1559
|
+
this.#safeWrite("\x1b[?1006l\x1b[?1003l\x1b[?1000l");
|
|
1560
|
+
|
|
1561
|
+
// Disable Mode 2031 appearance change notifications
|
|
1562
|
+
this.#safeWrite("\x1b[?2031l");
|
|
1563
|
+
|
|
1564
|
+
// Restore xterm scroll-to-bottom modes that were set before startup.
|
|
1565
|
+
for (const mode of this.#xtermScrollToBottomRestoreModes) {
|
|
1566
|
+
this.#safeWrite(`\x1b[?${mode}h`);
|
|
1567
|
+
}
|
|
1568
|
+
this.#xtermScrollToBottomRestoreModes.clear();
|
|
1569
|
+
|
|
1570
|
+
if (this.#inBandResizeActive) {
|
|
1571
|
+
this.#safeWrite("\x1b[?2048l");
|
|
1572
|
+
this.#inBandResizeActive = false;
|
|
1573
|
+
}
|
|
1574
|
+
if (this.#mode2031DebounceTimer) {
|
|
1575
|
+
clearTimeout(this.#mode2031DebounceTimer);
|
|
1576
|
+
this.#mode2031DebounceTimer = undefined;
|
|
1577
|
+
}
|
|
1578
|
+
if (this.#osc11TmuxRefreshTimer) {
|
|
1579
|
+
clearTimeout(this.#osc11TmuxRefreshTimer);
|
|
1580
|
+
this.#osc11TmuxRefreshTimer = undefined;
|
|
1581
|
+
}
|
|
1582
|
+
this.#appearanceCallbacks = [];
|
|
1583
|
+
this.#appearanceReportCallbacks = [];
|
|
1584
|
+
this.#osc11Pending = false;
|
|
1585
|
+
this.#osc11ActiveToken = undefined;
|
|
1586
|
+
this.#clearWindowsTerminalAppearancePoll();
|
|
1587
|
+
this.#osc11QueuedQuery = undefined;
|
|
1588
|
+
this.#osc11ResponseBuffer = "";
|
|
1589
|
+
this.#osc99PendingId = undefined;
|
|
1590
|
+
this.#osc99ResponseBuffer = "";
|
|
1591
|
+
this.#osc99Capabilities.clear();
|
|
1592
|
+
setOsc99Supported(false);
|
|
1593
|
+
this.#privateCsiResponseBuffer = "";
|
|
1594
|
+
this.#inBandResizeBuffer = "";
|
|
1595
|
+
this.#da1SentinelOwners.length = 0;
|
|
1596
|
+
this.#privateModeCallbacks = [];
|
|
1597
|
+
this.#privateModeSupport.clear();
|
|
1598
|
+
this.#xtermScrollToBottomRestoreModes.clear();
|
|
1599
|
+
this.#reportedColumns = undefined;
|
|
1600
|
+
this.#reportedRows = undefined;
|
|
1601
|
+
|
|
1602
|
+
// Disable Kitty keyboard protocol if not already done by drainInput()
|
|
1603
|
+
if (this.#kittyProtocolActive) {
|
|
1604
|
+
this.#safeWrite("\x1b[<u");
|
|
1605
|
+
this.#kittyProtocolActive = false;
|
|
1606
|
+
setKittyProtocolActive(false);
|
|
1607
|
+
}
|
|
1608
|
+
if (this.#modifyOtherKeysTimeout) {
|
|
1609
|
+
clearTimeout(this.#modifyOtherKeysTimeout);
|
|
1610
|
+
this.#modifyOtherKeysTimeout = undefined;
|
|
1611
|
+
}
|
|
1612
|
+
if (this.#modifyOtherKeysActive) {
|
|
1613
|
+
this.#safeWrite("\x1b[>4;0m");
|
|
1614
|
+
this.#modifyOtherKeysActive = false;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
this.#restoreWindowsVTInput();
|
|
1618
|
+
// Clean up StdinBuffer
|
|
1619
|
+
if (this.#stdinBuffer) {
|
|
1620
|
+
this.#stdinBuffer.destroy();
|
|
1621
|
+
this.#stdinBuffer = undefined;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// Remove event handlers
|
|
1625
|
+
if (this.#stdinDataHandler) {
|
|
1626
|
+
process.stdin.removeListener("data", this.#stdinDataHandler);
|
|
1627
|
+
this.#stdinDataHandler = undefined;
|
|
1628
|
+
}
|
|
1629
|
+
process.stdin.removeListener("end", this.#stdinEndHandler);
|
|
1630
|
+
process.stdin.removeListener("close", this.#stdinCloseHandler);
|
|
1631
|
+
process.stdin.removeListener("error", this.#stdinErrorHandler);
|
|
1632
|
+
this.#disconnectHandler = undefined;
|
|
1633
|
+
this.#inputHandler = undefined;
|
|
1634
|
+
this.#appearance = undefined;
|
|
1635
|
+
if (this.#stdoutResizeListener) {
|
|
1636
|
+
process.stdout.removeListener("resize", this.#stdoutResizeListener);
|
|
1637
|
+
this.#stdoutResizeListener = undefined;
|
|
1638
|
+
}
|
|
1639
|
+
if (this.#stdoutDrainArmed) {
|
|
1640
|
+
process.stdout.removeListener("drain", this.#stdoutDrainHandler);
|
|
1641
|
+
this.#stdoutDrainArmed = false;
|
|
1642
|
+
}
|
|
1643
|
+
this.#stdoutBacklog.reset();
|
|
1644
|
+
this.#resizeHandler = undefined;
|
|
1645
|
+
|
|
1646
|
+
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
1647
|
+
// re-interpreted after raw mode is disabled. This fixes a race condition
|
|
1648
|
+
// where Ctrl+D could close the parent shell over SSH.
|
|
1649
|
+
process.stdin.pause();
|
|
1650
|
+
|
|
1651
|
+
// Restore raw mode state. On a disconnected terminal (pane recycled, ssh
|
|
1652
|
+
// dropped) the fd is no longer a tty and Bun's node:tty shim throws; there
|
|
1653
|
+
// is nothing left to restore, and throwing would abort the caller. On a
|
|
1654
|
+
// live terminal the failure still surfaces - swallowing it would silently
|
|
1655
|
+
// leave stdin in raw mode.
|
|
1656
|
+
try {
|
|
1657
|
+
process.stdin.setRawMode?.(this.#wasRaw);
|
|
1658
|
+
} catch (err) {
|
|
1659
|
+
if (!this.#dead) throw err;
|
|
1660
|
+
}
|
|
1661
|
+
this.#stdoutErrorCleanup?.();
|
|
1662
|
+
this.#stdoutErrorCleanup = undefined;
|
|
1663
|
+
// After stop() the terminal is shared with other writers; visibility
|
|
1664
|
+
// tracking is only meaningful while this instance owns the TTY.
|
|
1665
|
+
this.#cursorVisible = undefined;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
#ensureStdoutErrorHandler(): void {
|
|
1669
|
+
this.#stdoutErrorCleanup ??= registerStdoutErrorHandler(this.#stdoutErrorHandler);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
#markTerminalDisconnected(reason: string, err?: unknown): void {
|
|
1673
|
+
if (this.#dead) return;
|
|
1674
|
+
this.#dead = true;
|
|
1675
|
+
logger.warn("terminal disconnected; stopping interactive rendering", { reason, err });
|
|
1676
|
+
|
|
1677
|
+
const disconnectHandler = this.#disconnectHandler;
|
|
1678
|
+
this.#disconnectHandler = undefined;
|
|
1679
|
+
if (!disconnectHandler) return;
|
|
1680
|
+
// The handler tears the TUI down against a terminal that is already gone,
|
|
1681
|
+
// so any step in it can fail. Swallow that: the exit below is the whole
|
|
1682
|
+
// point of this method and must not be preempted by teardown noise.
|
|
1683
|
+
try {
|
|
1684
|
+
disconnectHandler();
|
|
1685
|
+
} catch (handlerErr) {
|
|
1686
|
+
logger.error("Terminal disconnect handler failed; exiting anyway", { err: handlerErr });
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
if (process.platform === "win32") {
|
|
1690
|
+
void postmortem.quit(129, { drainStdout: false });
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
try {
|
|
1694
|
+
process.kill(process.pid, "SIGHUP");
|
|
1695
|
+
} catch (signalErr) {
|
|
1696
|
+
logger.error("Failed to deliver terminal disconnect signal; exiting directly", { err: signalErr });
|
|
1697
|
+
void postmortem.quit(129);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
write(data: string): void {
|
|
1702
|
+
this.#safeWrite(data);
|
|
1703
|
+
if (this.#writeLogPath) {
|
|
1704
|
+
try {
|
|
1705
|
+
fs.appendFileSync(this.#writeLogPath, data, { encoding: "utf8" });
|
|
1706
|
+
} catch {
|
|
1707
|
+
// Ignore logging errors
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
#safeWrite(data: string): void {
|
|
1713
|
+
if (this.#headless) return;
|
|
1714
|
+
if (this.#dead) return;
|
|
1715
|
+
// Skip control sequences when stdout isn't a TTY (piped output, tests, log
|
|
1716
|
+
// files). They serve no purpose there and would surface as visible noise.
|
|
1717
|
+
if (!process.stdout.isTTY) return;
|
|
1718
|
+
this.#ensureStdoutErrorHandler();
|
|
1719
|
+
this.#trackCursorVisibility(data);
|
|
1720
|
+
// A console-sharing child process may have flipped the console codepage
|
|
1721
|
+
// away from UTF-8; repair it before any bytes hit WriteFile so no frame
|
|
1722
|
+
// is ever translated through an OEM codepage. See ensureWindowsConsoleUtf8.
|
|
1723
|
+
if (process.platform === "win32") ensureWindowsConsoleUtf8();
|
|
1724
|
+
try {
|
|
1725
|
+
// Windows ConPTY drops viewport tracking when a single write exceeds
|
|
1726
|
+
// ~32-64 KB: the host UI's scroll position stays parked at wherever
|
|
1727
|
+
// the write began, even though every byte landed in scrollback. Split
|
|
1728
|
+
// large paints into newline-aligned chunks so each underlying
|
|
1729
|
+
// `WriteFile` stays well below the threshold. The gate also covers
|
|
1730
|
+
// WSL — `process.platform === "linux"` there, but stdout still
|
|
1731
|
+
// crosses into ConPTY at the `wslhost` boundary, so the same per-
|
|
1732
|
+
// WriteFile cap applies. Non-ConPTY PTYs keep the single-write fast
|
|
1733
|
+
// path. The cap is on encoded UTF-8 bytes, not JS code units, because
|
|
1734
|
+
// `process.stdout.write(string)` UTF-8-encodes before `WriteFile`,
|
|
1735
|
+
// and a code-unit cap would let CJK transcript rows expand past the
|
|
1736
|
+
// threshold. See #2034 and #2095.
|
|
1737
|
+
const bytes = Buffer.byteLength(data, "utf8");
|
|
1738
|
+
let accepted: boolean;
|
|
1739
|
+
if (isConPTYHosted() && bytes > MAX_CONPTY_WRITE_CHUNK_BYTES) {
|
|
1740
|
+
accepted = true;
|
|
1741
|
+
for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) {
|
|
1742
|
+
if (this.#dead) break;
|
|
1743
|
+
accepted = process.stdout.write(chunk);
|
|
1744
|
+
}
|
|
1745
|
+
} else {
|
|
1746
|
+
accepted = process.stdout.write(data);
|
|
1747
|
+
}
|
|
1748
|
+
// A stalled-but-alive PTY consumer never throws: write() just returns
|
|
1749
|
+
// false and queues the bytes. Bound that never-draining backlog by
|
|
1750
|
+
// declaring the terminal disconnected once it crosses the cap — the
|
|
1751
|
+
// same clean-exit path a dead terminal takes (#6854).
|
|
1752
|
+
if (this.#stdoutBacklog.record(accepted, bytes)) {
|
|
1753
|
+
this.#markTerminalDisconnected("stdout backlog exceeded cap; PTY consumer stalled");
|
|
1754
|
+
} else if (this.#stdoutBacklog.tracking && !this.#stdoutDrainArmed) {
|
|
1755
|
+
this.#stdoutDrainArmed = true;
|
|
1756
|
+
process.stdout.once("drain", this.#stdoutDrainHandler);
|
|
1757
|
+
}
|
|
1758
|
+
} catch (err) {
|
|
1759
|
+
this.#markTerminalDisconnected("stdout failed", err);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
get columns(): number {
|
|
1764
|
+
if (this.#inBandResizeActive && this.#reportedColumns) return this.#reportedColumns;
|
|
1765
|
+
return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
get rows(): number {
|
|
1769
|
+
if (this.#inBandResizeActive && this.#reportedRows) return this.#reportedRows;
|
|
1770
|
+
return process.stdout.rows || Number(Bun.env.LINES) || 24;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
moveBy(lines: number): void {
|
|
1774
|
+
if (lines > 0) {
|
|
1775
|
+
// Move down
|
|
1776
|
+
this.#safeWrite(`\x1b[${lines}B`);
|
|
1777
|
+
} else if (lines < 0) {
|
|
1778
|
+
// Move up
|
|
1779
|
+
this.#safeWrite(`\x1b[${-lines}A`);
|
|
1780
|
+
}
|
|
1781
|
+
// lines === 0: no movement
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
hideCursor(force = false): void {
|
|
1785
|
+
if (!force && this.#cursorVisible === false) return;
|
|
1786
|
+
this.#safeWrite("\x1b[?25l");
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
showCursor(force = false): void {
|
|
1790
|
+
if (!force && this.#cursorVisible === true) return;
|
|
1791
|
+
this.#safeWrite("\x1b[?25h");
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* Sniff outgoing data for the last cursor-visibility change so the tracked
|
|
1796
|
+
* state stays correct for sequences embedded in frame buffers
|
|
1797
|
+
* (TUI#cursorControlSequence appends ?25h/?25l inside the paint write). An
|
|
1798
|
+
* alt-screen switch (DECSET/DECRST 1049) newer than the last cursor
|
|
1799
|
+
* sequence resets tracking to unknown: some hosts keep DECTCEM per buffer.
|
|
1800
|
+
*/
|
|
1801
|
+
#trackCursorVisibility(data: string): void {
|
|
1802
|
+
let idx = data.lastIndexOf("\x1b[?25");
|
|
1803
|
+
while (idx !== -1) {
|
|
1804
|
+
const final = data.charCodeAt(idx + 5);
|
|
1805
|
+
if (final === 0x68 /* h */ || final === 0x6c /* l */) break;
|
|
1806
|
+
idx = idx === 0 ? -1 : data.lastIndexOf("\x1b[?25", idx - 1);
|
|
1807
|
+
}
|
|
1808
|
+
if (data.lastIndexOf("\x1b[?1049") > idx) {
|
|
1809
|
+
this.#cursorVisible = undefined;
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
if (idx !== -1) this.#cursorVisible = data.charCodeAt(idx + 5) === 0x68;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
clearLine(): void {
|
|
1816
|
+
this.#safeWrite("\x1b[K");
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
clearFromCursor(): void {
|
|
1820
|
+
this.#safeWrite("\x1b[J");
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
clearScreen(): void {
|
|
1824
|
+
this.#safeWrite("\x1b[H\x1b[0J"); // Move to home (1,1) and clear from cursor to end
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
setTitle(title: string): void {
|
|
1828
|
+
// OSC 0;title BEL - set terminal window title
|
|
1829
|
+
this.#safeWrite(`\x1b]0;${title}\x07`);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
setProgress(active: boolean): void {
|
|
1833
|
+
if (this.#headless) return;
|
|
1834
|
+
if (active) {
|
|
1835
|
+
this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
|
|
1836
|
+
if (!this.#progressTimer) {
|
|
1837
|
+
this.#progressTimer = setInterval(() => {
|
|
1838
|
+
this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
|
|
1839
|
+
}, TERMINAL_PROGRESS_KEEPALIVE_MS);
|
|
1840
|
+
this.#progressTimer.unref?.();
|
|
1841
|
+
}
|
|
1842
|
+
} else {
|
|
1843
|
+
this.#clearProgressTimer();
|
|
1844
|
+
this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
#clearProgressTimer(): boolean {
|
|
1849
|
+
if (!this.#progressTimer) return false;
|
|
1850
|
+
clearInterval(this.#progressTimer);
|
|
1851
|
+
this.#progressTimer = undefined;
|
|
1852
|
+
return true;
|
|
1853
|
+
}
|
|
1854
|
+
}
|