@f5-sales-demo/pi-tui 19.51.2
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 +702 -0
- package/README.md +704 -0
- package/package.json +71 -0
- package/src/autocomplete.ts +780 -0
- package/src/bracketed-paste.ts +47 -0
- package/src/chord-dispatcher.ts +90 -0
- package/src/chord-parser.ts +66 -0
- package/src/components/box.ts +155 -0
- package/src/components/cancellable-loader.ts +40 -0
- package/src/components/editor.ts +2610 -0
- package/src/components/image.ts +90 -0
- package/src/components/input.ts +439 -0
- package/src/components/loader.ts +67 -0
- package/src/components/markdown.ts +940 -0
- package/src/components/select-list.ts +249 -0
- package/src/components/settings-list.ts +195 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +175 -0
- package/src/components/text.ts +110 -0
- package/src/components/truncated-text.ts +61 -0
- package/src/editor-component.ts +71 -0
- package/src/events.ts +32 -0
- package/src/fuzzy.ts +143 -0
- package/src/horizontal-split.ts +186 -0
- package/src/index.ts +64 -0
- package/src/keybindings.ts +340 -0
- package/src/keys.ts +408 -0
- package/src/kill-ring.ts +46 -0
- package/src/stdin-buffer.ts +481 -0
- package/src/symbols.ts +24 -0
- package/src/terminal-capabilities.ts +533 -0
- package/src/terminal.ts +687 -0
- package/src/ttyid.ts +66 -0
- package/src/tui.ts +1341 -0
- package/src/utils.ts +345 -0
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { $env, logger } from "@f5xc-salesdemos/pi-utils";
|
|
4
|
+
import { setKittyProtocolActive } from "./keys";
|
|
5
|
+
import { StdinBuffer } from "./stdin-buffer";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimal terminal interface for TUI
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Track active terminal for emergency cleanup on crash
|
|
12
|
+
let activeTerminal: ProcessTerminal | null = null;
|
|
13
|
+
// Track if a terminal was ever started (for emergency restore logic)
|
|
14
|
+
let terminalEverStarted = false;
|
|
15
|
+
|
|
16
|
+
const STD_INPUT_HANDLE = -10;
|
|
17
|
+
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
|
|
18
|
+
/**
|
|
19
|
+
* Emergency terminal restore - call this from signal/crash handlers
|
|
20
|
+
* Resets terminal state without requiring access to the ProcessTerminal instance
|
|
21
|
+
*/
|
|
22
|
+
export function emergencyTerminalRestore(): void {
|
|
23
|
+
try {
|
|
24
|
+
const terminal = activeTerminal;
|
|
25
|
+
if (terminal) {
|
|
26
|
+
terminal.stop();
|
|
27
|
+
terminal.showCursor();
|
|
28
|
+
} else if (terminalEverStarted) {
|
|
29
|
+
// Blind restore only if we know a terminal was started but lost track of it
|
|
30
|
+
// This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
|
|
31
|
+
process.stdout.write(
|
|
32
|
+
"\x1b[?2004l" + // Disable bracketed paste
|
|
33
|
+
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
|
|
34
|
+
"\x1b[<u" + // Pop kitty keyboard protocol
|
|
35
|
+
"\x1b[>4;0m" + // Disable modifyOtherKeys fallback
|
|
36
|
+
"\x1b[?25h", // Show cursor
|
|
37
|
+
);
|
|
38
|
+
if (process.stdin.setRawMode) {
|
|
39
|
+
process.stdin.setRawMode(false);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Terminal may already be dead during crash cleanup - ignore errors
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Terminal-reported appearance (dark/light mode). */
|
|
47
|
+
export type TerminalAppearance = "dark" | "light";
|
|
48
|
+
export interface Terminal {
|
|
49
|
+
// Start the terminal with input and resize handlers
|
|
50
|
+
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
51
|
+
|
|
52
|
+
// Stop the terminal and restore state
|
|
53
|
+
stop(): void;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Drain stdin before exiting to prevent Kitty key release events from
|
|
57
|
+
* leaking to the parent shell over slow SSH connections.
|
|
58
|
+
* @param maxMs - Maximum time to drain (default: 1000ms)
|
|
59
|
+
* @param idleMs - Exit early if no input arrives within this time (default: 50ms)
|
|
60
|
+
*/
|
|
61
|
+
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
|
62
|
+
|
|
63
|
+
// Write output to terminal
|
|
64
|
+
write(data: string): void;
|
|
65
|
+
|
|
66
|
+
// Get terminal dimensions
|
|
67
|
+
get columns(): number;
|
|
68
|
+
get rows(): number;
|
|
69
|
+
|
|
70
|
+
// Whether Kitty keyboard protocol is active
|
|
71
|
+
get kittyProtocolActive(): boolean;
|
|
72
|
+
|
|
73
|
+
// Cursor positioning (relative to current position)
|
|
74
|
+
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
|
|
75
|
+
|
|
76
|
+
// Cursor visibility
|
|
77
|
+
hideCursor(): void; // Hide the cursor
|
|
78
|
+
showCursor(): void; // Show the cursor
|
|
79
|
+
|
|
80
|
+
// Clear operations
|
|
81
|
+
clearLine(): void; // Clear current line
|
|
82
|
+
clearFromCursor(): void; // Clear from cursor to end of screen
|
|
83
|
+
clearScreen(): void; // Clear entire screen and move cursor to (0,0)
|
|
84
|
+
|
|
85
|
+
// Title operations
|
|
86
|
+
setTitle(title: string): void; // Set terminal window title
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Register a callback for terminal appearance (dark/light) changes.
|
|
90
|
+
* Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
|
|
91
|
+
* Fires when the detected appearance changes, including the initial detection.
|
|
92
|
+
*/
|
|
93
|
+
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
|
|
94
|
+
|
|
95
|
+
/** The last detected terminal appearance, or undefined if not yet known. */
|
|
96
|
+
get appearance(): TerminalAppearance | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Real terminal using process.stdin/stdout
|
|
101
|
+
*/
|
|
102
|
+
export class ProcessTerminal implements Terminal {
|
|
103
|
+
#wasRaw = false;
|
|
104
|
+
#inputHandler?: (data: string) => void;
|
|
105
|
+
#resizeHandler?: () => void;
|
|
106
|
+
#kittyProtocolActive = false;
|
|
107
|
+
#modifyOtherKeysActive = false;
|
|
108
|
+
#modifyOtherKeysTimeout?: ReturnType<typeof setTimeout>;
|
|
109
|
+
#stdinBuffer?: StdinBuffer;
|
|
110
|
+
#stdinDataHandler?: (data: string) => void;
|
|
111
|
+
#dead = false;
|
|
112
|
+
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
|
|
113
|
+
#windowsVTInputRestore?: () => void;
|
|
114
|
+
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
115
|
+
#appearance: TerminalAppearance | undefined;
|
|
116
|
+
#osc11Pending = false;
|
|
117
|
+
#osc11QueryQueued = false;
|
|
118
|
+
#osc11ResponseBuffer = "";
|
|
119
|
+
#pendingDa1Sentinels = 0;
|
|
120
|
+
#osc11PollTimer?: Timer;
|
|
121
|
+
#mode2031Active = false;
|
|
122
|
+
#mode2031DebounceTimer?: Timer;
|
|
123
|
+
// Capability-probe results that persist across stop()/start() cycles. After
|
|
124
|
+
// the first handshake, a restart (e.g. the credential-fix ui.stop()→cooked
|
|
125
|
+
// subprocess→ui.start() cycle) must re-enable protocols WITHOUT re-querying:
|
|
126
|
+
// a re-query's response lands during the cooked-mode window and the terminal
|
|
127
|
+
// echoes it as visible "gibberish". These flags are deliberately NOT reset in
|
|
128
|
+
// stop() — they remember what we already learned about the terminal.
|
|
129
|
+
#hasProbedCapabilities = false;
|
|
130
|
+
#kittySupported = false;
|
|
131
|
+
#modifyOtherKeysSupported = false;
|
|
132
|
+
|
|
133
|
+
get kittyProtocolActive(): boolean {
|
|
134
|
+
return this.#kittyProtocolActive;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get appearance(): TerminalAppearance | undefined {
|
|
138
|
+
return this.#appearance;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void {
|
|
142
|
+
this.#appearanceCallbacks.push(callback);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
146
|
+
this.#resizeHandler = onResize;
|
|
147
|
+
|
|
148
|
+
// Register for emergency cleanup
|
|
149
|
+
activeTerminal = this;
|
|
150
|
+
terminalEverStarted = true;
|
|
151
|
+
|
|
152
|
+
// Save previous state and enable raw mode
|
|
153
|
+
this.#wasRaw = process.stdin.isRaw || false;
|
|
154
|
+
if (process.stdin.setRawMode) {
|
|
155
|
+
process.stdin.setRawMode(true);
|
|
156
|
+
}
|
|
157
|
+
process.stdin.setEncoding("utf8");
|
|
158
|
+
process.stdin.resume();
|
|
159
|
+
|
|
160
|
+
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
161
|
+
this.#safeWrite("\x1b[?2004h");
|
|
162
|
+
|
|
163
|
+
// Set up resize handler immediately
|
|
164
|
+
process.stdout.on("resize", this.#resizeHandler);
|
|
165
|
+
|
|
166
|
+
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
167
|
+
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
168
|
+
if (process.platform !== "win32") {
|
|
169
|
+
process.kill(process.pid, "SIGWINCH");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
|
|
173
|
+
// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
|
|
174
|
+
// events that lose modifier information. Must run after setRawMode(true)
|
|
175
|
+
// since that resets console mode flags.
|
|
176
|
+
this.#enableWindowsVTInput();
|
|
177
|
+
// Query and enable Kitty keyboard protocol.
|
|
178
|
+
// The query handler intercepts input temporarily, then installs the user's handler.
|
|
179
|
+
// On a restart this re-enables the protocol directly from the cached result
|
|
180
|
+
// instead of re-querying (a re-query response would echo as gibberish during
|
|
181
|
+
// a stop/start cooked-mode window).
|
|
182
|
+
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
183
|
+
this.#queryAndEnableKittyProtocol();
|
|
184
|
+
|
|
185
|
+
// Query terminal background color via OSC 11 for dark/light detection.
|
|
186
|
+
// Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
|
|
187
|
+
// sequences in order, so if DA1 arrives before OSC 11 response,
|
|
188
|
+
// the terminal does not support OSC 11. This avoids indefinite hangs.
|
|
189
|
+
// Technique used by Neovim, bat, fish, and terminal-colorsaurus.
|
|
190
|
+
//
|
|
191
|
+
// Only on the FIRST start: on a restart we keep the cached appearance and
|
|
192
|
+
// rely on Mode 2031 notifications / the OSC 11 poll to pick up any change,
|
|
193
|
+
// so the query response can never leak during a stop/start cycle.
|
|
194
|
+
if (!this.#hasProbedCapabilities) {
|
|
195
|
+
this.#queryBackgroundColor();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Subscribe to Mode 2031 appearance change notifications.
|
|
199
|
+
// When the terminal reports a change, we re-query OSC 11 to get the
|
|
200
|
+
// actual background color (following Neovim convention) with 100ms debounce.
|
|
201
|
+
this.#safeWrite("\x1b[?2031h");
|
|
202
|
+
|
|
203
|
+
// Start periodic OSC 11 re-query for terminals without Mode 2031
|
|
204
|
+
// (Warp, Alacritty, WezTerm, iTerm2). Self-disables once Mode 2031 fires.
|
|
205
|
+
this.#startOsc11Poll();
|
|
206
|
+
|
|
207
|
+
// Capabilities are now probed; subsequent start() calls re-enable from cache.
|
|
208
|
+
this.#hasProbedCapabilities = true;
|
|
209
|
+
|
|
210
|
+
// Defer activating the user input handler until terminal capability
|
|
211
|
+
// queries have settled. Without this, query responses (Kitty, DA1,
|
|
212
|
+
// OSC 11) race into the editor as typed text.
|
|
213
|
+
setTimeout(() => {
|
|
214
|
+
this.#inputHandler = onInput;
|
|
215
|
+
}, 50);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT to the stdin console mode
|
|
220
|
+
* so modified keys (for example Shift+Tab) arrive as VT escape sequences.
|
|
221
|
+
*/
|
|
222
|
+
#enableWindowsVTInput(): void {
|
|
223
|
+
if (process.platform !== "win32") return;
|
|
224
|
+
this.#restoreWindowsVTInput();
|
|
225
|
+
try {
|
|
226
|
+
const kernel32 = dlopen("kernel32.dll", {
|
|
227
|
+
GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
|
|
228
|
+
GetConsoleMode: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
|
|
229
|
+
SetConsoleMode: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.bool },
|
|
230
|
+
});
|
|
231
|
+
const handle = kernel32.symbols.GetStdHandle(STD_INPUT_HANDLE);
|
|
232
|
+
const mode = new Uint32Array(1);
|
|
233
|
+
const modePtr = ptr(mode);
|
|
234
|
+
if (!modePtr || !kernel32.symbols.GetConsoleMode(handle, modePtr)) {
|
|
235
|
+
kernel32.close();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const originalMode = mode[0]!;
|
|
239
|
+
const vtMode = originalMode | ENABLE_VIRTUAL_TERMINAL_INPUT;
|
|
240
|
+
if (vtMode !== originalMode && !kernel32.symbols.SetConsoleMode(handle, vtMode)) {
|
|
241
|
+
kernel32.close();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.#windowsVTInputRestore = () => {
|
|
245
|
+
try {
|
|
246
|
+
kernel32.symbols.SetConsoleMode(handle, originalMode);
|
|
247
|
+
} finally {
|
|
248
|
+
kernel32.close();
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
} catch {
|
|
252
|
+
// bun:ffi unavailable or console API unsupported; keep startup non-fatal.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#restoreWindowsVTInput(): void {
|
|
257
|
+
if (process.platform !== "win32") return;
|
|
258
|
+
const restore = this.#windowsVTInputRestore;
|
|
259
|
+
this.#windowsVTInputRestore = undefined;
|
|
260
|
+
if (!restore) return;
|
|
261
|
+
try {
|
|
262
|
+
restore();
|
|
263
|
+
} catch {
|
|
264
|
+
// Ignore restore errors during terminal teardown.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Set up StdinBuffer to split batched input into individual sequences.
|
|
270
|
+
* This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
|
|
271
|
+
*
|
|
272
|
+
* Also watches for Kitty protocol response and enables it when detected.
|
|
273
|
+
* This is done here (after stdinBuffer parsing) rather than on raw stdin
|
|
274
|
+
* to handle the case where the response arrives split across multiple events.
|
|
275
|
+
*/
|
|
276
|
+
#setupStdinBuffer(): void {
|
|
277
|
+
// Use the default ESC-hold window (50ms) so escape sequences fragmented
|
|
278
|
+
// across stdin reads under render load reassemble instead of leaking their
|
|
279
|
+
// tail as editor text (e.g. modifyOtherKeys Ctrl+C \x1b[27;5;99~).
|
|
280
|
+
this.#stdinBuffer = new StdinBuffer();
|
|
281
|
+
|
|
282
|
+
// Kitty protocol response pattern: \x1b[?<flags>u
|
|
283
|
+
const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
|
|
284
|
+
|
|
285
|
+
// Mode 2031 DSR response: \x1b[?997;{1=dark,2=light}n
|
|
286
|
+
const appearanceDsrPattern = /^\x1b\[\?997;([12])n$/;
|
|
287
|
+
|
|
288
|
+
// OSC 11 response: \x1b]11;rgb:RR/GG/BB or rgba:RR/GG/BB, terminated by BEL or ST.
|
|
289
|
+
const osc11ResponsePattern =
|
|
290
|
+
/^\x1b\]11;rgba?:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(?:\x07|\x1b\\)$/;
|
|
291
|
+
|
|
292
|
+
// DA1 (Primary Device Attributes) response: \x1b[?...c
|
|
293
|
+
const da1ResponsePattern = /^\x1b\[\?[\d;]*c$/;
|
|
294
|
+
|
|
295
|
+
// Forward individual sequences to the input handler
|
|
296
|
+
this.#stdinBuffer.on("data", (sequence: string) => {
|
|
297
|
+
// Kitty protocol response — always swallow, enable protocol on first match
|
|
298
|
+
const kittyMatch = sequence.match(kittyResponsePattern);
|
|
299
|
+
if (kittyMatch) {
|
|
300
|
+
// Remember support so a restart can re-enable without re-querying.
|
|
301
|
+
this.#kittySupported = true;
|
|
302
|
+
if (!this.#kittyProtocolActive) {
|
|
303
|
+
if (this.#modifyOtherKeysTimeout) {
|
|
304
|
+
clearTimeout(this.#modifyOtherKeysTimeout);
|
|
305
|
+
this.#modifyOtherKeysTimeout = undefined;
|
|
306
|
+
}
|
|
307
|
+
this.#kittyProtocolActive = true;
|
|
308
|
+
setKittyProtocolActive(true);
|
|
309
|
+
this.#safeWrite("\x1b[>7u");
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// DA1 response — always swallow; manage sentinel count if pending
|
|
315
|
+
if (da1ResponsePattern.test(sequence)) {
|
|
316
|
+
if (this.#pendingDa1Sentinels > 0) {
|
|
317
|
+
this.#pendingDa1Sentinels--;
|
|
318
|
+
if (this.#osc11Pending) {
|
|
319
|
+
this.#osc11Pending = false;
|
|
320
|
+
this.#osc11ResponseBuffer = "";
|
|
321
|
+
}
|
|
322
|
+
if (this.#osc11QueryQueued && !this.#dead) {
|
|
323
|
+
this.#osc11QueryQueued = false;
|
|
324
|
+
this.#startOsc11Query();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// OSC 11 replies can be split if the stdin buffer flushes a partial sequence.
|
|
331
|
+
// Accumulate fragments until the BEL/ST terminator arrives, then parse once.
|
|
332
|
+
// If a new escape sequence arrives (not the ST terminator), abort buffering
|
|
333
|
+
// and forward it as normal input so user keystrokes are never swallowed.
|
|
334
|
+
if (this.#osc11Pending && (this.#osc11ResponseBuffer || sequence.startsWith("\x1b]11;"))) {
|
|
335
|
+
if (this.#osc11ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
|
|
336
|
+
// New escape sequence arrived mid-buffer — not an OSC 11 continuation.
|
|
337
|
+
this.#osc11ResponseBuffer = "";
|
|
338
|
+
// Fall through to normal input handling below.
|
|
339
|
+
} else {
|
|
340
|
+
this.#osc11ResponseBuffer += sequence;
|
|
341
|
+
const osc11Match = this.#osc11ResponseBuffer.match(osc11ResponsePattern);
|
|
342
|
+
if (!osc11Match) return;
|
|
343
|
+
const [, rHex, gHex, bHex] = osc11Match;
|
|
344
|
+
this.#osc11Pending = false;
|
|
345
|
+
this.#osc11ResponseBuffer = "";
|
|
346
|
+
this.#handleOsc11Response(rHex!, gHex!, bHex!);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Mode 2031 change notification: re-query OSC 11 with 100ms debounce
|
|
352
|
+
// (Neovim convention — coalesces rapid notifications during transitions)
|
|
353
|
+
const appearanceMatch = sequence.match(appearanceDsrPattern);
|
|
354
|
+
if (appearanceMatch) {
|
|
355
|
+
if (!this.#mode2031Active) {
|
|
356
|
+
this.#mode2031Active = true;
|
|
357
|
+
this.#stopOsc11Poll();
|
|
358
|
+
}
|
|
359
|
+
if (this.#mode2031DebounceTimer) clearTimeout(this.#mode2031DebounceTimer);
|
|
360
|
+
this.#mode2031DebounceTimer = setTimeout(() => {
|
|
361
|
+
this.#mode2031DebounceTimer = undefined;
|
|
362
|
+
this.#queryBackgroundColor();
|
|
363
|
+
}, 100);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// Drop stale terminal responses that leaked through the stdin buffer
|
|
367
|
+
// timeout flush. These are incomplete DA1/DA2 replies, OSC 11
|
|
368
|
+
// fragments, or Kitty protocol responses that arrived split across
|
|
369
|
+
// data events and were flushed before their terminator arrived.
|
|
370
|
+
if (
|
|
371
|
+
/^\x1b\[\?[\d;]*[a-z]?$/i.test(sequence) || // DA1/Kitty responses: ESC[?...
|
|
372
|
+
/^\x1b\](?:11|10|4);/.test(sequence) || // OSC color responses without terminator
|
|
373
|
+
/^\x1b\[>[\d;]*[a-z]?$/i.test(sequence) // DA2 responses: ESC[>...
|
|
374
|
+
) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (this.#inputHandler) {
|
|
379
|
+
this.#inputHandler(sequence);
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Re-wrap paste content with bracketed paste markers for existing editor handling
|
|
384
|
+
this.#stdinBuffer.on("paste", (content: string) => {
|
|
385
|
+
if (this.#inputHandler) {
|
|
386
|
+
this.#inputHandler(`\x1b[200~${content}\x1b[201~`);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Handler that pipes stdin data through the buffer
|
|
391
|
+
this.#stdinDataHandler = (data: string) => {
|
|
392
|
+
this.#stdinBuffer!.process(data);
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Send OSC 11 background color query followed by DA1 sentinel.
|
|
398
|
+
* DA1 avoids indefinite hangs: if DA1 response arrives before OSC 11,
|
|
399
|
+
* the terminal does not support OSC 11.
|
|
400
|
+
*/
|
|
401
|
+
#queryBackgroundColor(): void {
|
|
402
|
+
if (this.#dead) return;
|
|
403
|
+
// Queue if an OSC 11 query is in flight or its DA1 sentinel hasn't been
|
|
404
|
+
// consumed yet. Starting a new query while a DA1 is outstanding would
|
|
405
|
+
// increment the sentinel counter, and the old DA1 arrival would then
|
|
406
|
+
// prematurely clear the new query's pending state.
|
|
407
|
+
if (this.#osc11Pending || this.#pendingDa1Sentinels > 0) {
|
|
408
|
+
this.#osc11QueryQueued = true;
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
this.#startOsc11Query();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
#startOsc11Query(): void {
|
|
415
|
+
this.#osc11Pending = true;
|
|
416
|
+
this.#osc11ResponseBuffer = "";
|
|
417
|
+
this.#pendingDa1Sentinels++;
|
|
418
|
+
this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated)
|
|
419
|
+
this.#safeWrite("\x1b[c"); // DA1 sentinel
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Parse an OSC 11 background color response and compute BT.601 luminance.
|
|
423
|
+
* Handles 1-, 2-, 3-, and 4-digit XParseColor hex components.
|
|
424
|
+
*/
|
|
425
|
+
#handleOsc11Response(rHex: string, gHex: string, bHex: string): void {
|
|
426
|
+
const normalize = (hex: string): number => {
|
|
427
|
+
const value = parseInt(hex, 16);
|
|
428
|
+
if (Number.isNaN(value)) return 0;
|
|
429
|
+
const max = 16 ** hex.length - 1;
|
|
430
|
+
return max > 0 ? value / max : 0;
|
|
431
|
+
};
|
|
432
|
+
const luminance = 0.299 * normalize(rHex) + 0.587 * normalize(gHex) + 0.114 * normalize(bHex);
|
|
433
|
+
const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light";
|
|
434
|
+
if (mode === this.#appearance) return;
|
|
435
|
+
this.#appearance = mode;
|
|
436
|
+
for (const cb of this.#appearanceCallbacks) {
|
|
437
|
+
try {
|
|
438
|
+
cb(mode);
|
|
439
|
+
} catch {
|
|
440
|
+
/* ignore callback errors */
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Start periodic OSC 11 re-queries for terminals without Mode 2031 (Warp, Alacritty, WezTerm).
|
|
447
|
+
* Self-disables once Mode 2031 fires (push-based is better than polling).
|
|
448
|
+
*/
|
|
449
|
+
#startOsc11Poll(): void {
|
|
450
|
+
this.#stopOsc11Poll();
|
|
451
|
+
this.#osc11PollTimer = setInterval(() => {
|
|
452
|
+
if (this.#dead) {
|
|
453
|
+
this.#stopOsc11Poll();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
this.#queryBackgroundColor();
|
|
457
|
+
}, 2_000);
|
|
458
|
+
this.#osc11PollTimer.unref();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#stopOsc11Poll(): void {
|
|
462
|
+
if (this.#osc11PollTimer) {
|
|
463
|
+
clearInterval(this.#osc11PollTimer);
|
|
464
|
+
this.#osc11PollTimer = undefined;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Query terminal for Kitty keyboard protocol support and enable if available.
|
|
470
|
+
*
|
|
471
|
+
* Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
|
|
472
|
+
* it supports the protocol and we enable it with CSI > 1 u.
|
|
473
|
+
*
|
|
474
|
+
* The response is detected in setupStdinBuffer's data handler, which properly
|
|
475
|
+
* handles the case where the response arrives split across multiple stdin events.
|
|
476
|
+
*/
|
|
477
|
+
#queryAndEnableKittyProtocol(): void {
|
|
478
|
+
this.#setupStdinBuffer();
|
|
479
|
+
process.stdin.on("data", this.#stdinDataHandler!);
|
|
480
|
+
|
|
481
|
+
// Restart path: capabilities are already known. Re-enable the keyboard
|
|
482
|
+
// protocol directly — no query means no response means nothing to echo as
|
|
483
|
+
// gibberish during the cooked-mode window of a stop/start cycle.
|
|
484
|
+
if (this.#hasProbedCapabilities) {
|
|
485
|
+
if (this.#kittySupported) {
|
|
486
|
+
this.#kittyProtocolActive = true;
|
|
487
|
+
setKittyProtocolActive(true);
|
|
488
|
+
this.#safeWrite("\x1b[>7u");
|
|
489
|
+
} else if (this.#modifyOtherKeysSupported) {
|
|
490
|
+
this.#safeWrite("\x1b[>4;2m");
|
|
491
|
+
this.#modifyOtherKeysActive = true;
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
this.#safeWrite("\x1b[?u");
|
|
497
|
+
this.#modifyOtherKeysTimeout = setTimeout(() => {
|
|
498
|
+
this.#modifyOtherKeysTimeout = undefined;
|
|
499
|
+
if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
this.#safeWrite("\x1b[>4;2m");
|
|
503
|
+
this.#modifyOtherKeysActive = true;
|
|
504
|
+
this.#modifyOtherKeysSupported = true;
|
|
505
|
+
}, 150);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
|
|
509
|
+
// Stop the periodic OSC 11 poll first. drainInput precedes relinquishing
|
|
510
|
+
// the TTY (exit, or a cooked-mode subprocess); a poll query fired now would
|
|
511
|
+
// have its response echoed as gibberish once raw mode is gone.
|
|
512
|
+
this.#stopOsc11Poll();
|
|
513
|
+
if (this.#kittyProtocolActive) {
|
|
514
|
+
// Disable Kitty keyboard protocol first so any late key releases
|
|
515
|
+
// do not generate new Kitty escape sequences.
|
|
516
|
+
this.#safeWrite("\x1b[<u");
|
|
517
|
+
this.#kittyProtocolActive = false;
|
|
518
|
+
setKittyProtocolActive(false);
|
|
519
|
+
}
|
|
520
|
+
if (this.#modifyOtherKeysTimeout) {
|
|
521
|
+
clearTimeout(this.#modifyOtherKeysTimeout);
|
|
522
|
+
this.#modifyOtherKeysTimeout = undefined;
|
|
523
|
+
}
|
|
524
|
+
if (this.#modifyOtherKeysActive) {
|
|
525
|
+
this.#safeWrite("\x1b[>4;0m");
|
|
526
|
+
this.#modifyOtherKeysActive = false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const previousHandler = this.#inputHandler;
|
|
530
|
+
this.#inputHandler = undefined;
|
|
531
|
+
|
|
532
|
+
let lastDataTime = Date.now();
|
|
533
|
+
const onData = () => {
|
|
534
|
+
lastDataTime = Date.now();
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
process.stdin.on("data", onData);
|
|
538
|
+
const endTime = Date.now() + maxMs;
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
while (true) {
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
const timeLeft = endTime - now;
|
|
544
|
+
if (timeLeft <= 0) break;
|
|
545
|
+
if (now - lastDataTime >= idleMs) break;
|
|
546
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(idleMs, timeLeft)));
|
|
547
|
+
}
|
|
548
|
+
} finally {
|
|
549
|
+
process.stdin.removeListener("data", onData);
|
|
550
|
+
this.#inputHandler = previousHandler;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
stop(): void {
|
|
555
|
+
// Unregister from emergency cleanup
|
|
556
|
+
if (activeTerminal === this) {
|
|
557
|
+
activeTerminal = null;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Disable bracketed paste mode
|
|
561
|
+
this.#safeWrite("\x1b[?2004l");
|
|
562
|
+
|
|
563
|
+
// Disable Mode 2031 appearance change notifications
|
|
564
|
+
this.#safeWrite("\x1b[?2031l");
|
|
565
|
+
this.#stopOsc11Poll();
|
|
566
|
+
if (this.#mode2031DebounceTimer) {
|
|
567
|
+
clearTimeout(this.#mode2031DebounceTimer);
|
|
568
|
+
this.#mode2031DebounceTimer = undefined;
|
|
569
|
+
}
|
|
570
|
+
this.#appearanceCallbacks = [];
|
|
571
|
+
this.#osc11Pending = false;
|
|
572
|
+
this.#osc11QueryQueued = false;
|
|
573
|
+
this.#osc11ResponseBuffer = "";
|
|
574
|
+
this.#pendingDa1Sentinels = 0;
|
|
575
|
+
this.#mode2031Active = false;
|
|
576
|
+
|
|
577
|
+
// Disable Kitty keyboard protocol if not already done by drainInput()
|
|
578
|
+
if (this.#kittyProtocolActive) {
|
|
579
|
+
this.#safeWrite("\x1b[<u");
|
|
580
|
+
this.#kittyProtocolActive = false;
|
|
581
|
+
setKittyProtocolActive(false);
|
|
582
|
+
}
|
|
583
|
+
if (this.#modifyOtherKeysTimeout) {
|
|
584
|
+
clearTimeout(this.#modifyOtherKeysTimeout);
|
|
585
|
+
this.#modifyOtherKeysTimeout = undefined;
|
|
586
|
+
}
|
|
587
|
+
if (this.#modifyOtherKeysActive) {
|
|
588
|
+
this.#safeWrite("\x1b[>4;0m");
|
|
589
|
+
this.#modifyOtherKeysActive = false;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
this.#restoreWindowsVTInput();
|
|
593
|
+
// Clean up StdinBuffer
|
|
594
|
+
if (this.#stdinBuffer) {
|
|
595
|
+
this.#stdinBuffer.destroy();
|
|
596
|
+
this.#stdinBuffer = undefined;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Remove event handlers
|
|
600
|
+
if (this.#stdinDataHandler) {
|
|
601
|
+
process.stdin.removeListener("data", this.#stdinDataHandler);
|
|
602
|
+
this.#stdinDataHandler = undefined;
|
|
603
|
+
}
|
|
604
|
+
this.#inputHandler = undefined;
|
|
605
|
+
this.#appearance = undefined;
|
|
606
|
+
if (this.#resizeHandler) {
|
|
607
|
+
process.stdout.removeListener("resize", this.#resizeHandler);
|
|
608
|
+
this.#resizeHandler = undefined;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
612
|
+
// re-interpreted after raw mode is disabled. This fixes a race condition
|
|
613
|
+
// where Ctrl+D could close the parent shell over SSH.
|
|
614
|
+
process.stdin.pause();
|
|
615
|
+
|
|
616
|
+
// Restore raw mode state
|
|
617
|
+
if (process.stdin.setRawMode) {
|
|
618
|
+
process.stdin.setRawMode(this.#wasRaw);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
write(data: string): void {
|
|
623
|
+
this.#safeWrite(data);
|
|
624
|
+
if (this.#writeLogPath) {
|
|
625
|
+
try {
|
|
626
|
+
fs.appendFileSync(this.#writeLogPath, data, { encoding: "utf8" });
|
|
627
|
+
} catch {
|
|
628
|
+
// Ignore logging errors
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
#safeWrite(data: string): void {
|
|
634
|
+
if (this.#dead) return;
|
|
635
|
+
try {
|
|
636
|
+
process.stdout.write(data);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
// Any write failure means terminal is dead - no recovery possible
|
|
639
|
+
this.#dead = true;
|
|
640
|
+
logger.warn("terminal is dead - no recovery possible", { error: err, data });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
get columns(): number {
|
|
645
|
+
return process.stdout.columns || 80;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
get rows(): number {
|
|
649
|
+
return process.stdout.rows || 24;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
moveBy(lines: number): void {
|
|
653
|
+
if (lines > 0) {
|
|
654
|
+
// Move down
|
|
655
|
+
this.#safeWrite(`\x1b[${lines}B`);
|
|
656
|
+
} else if (lines < 0) {
|
|
657
|
+
// Move up
|
|
658
|
+
this.#safeWrite(`\x1b[${-lines}A`);
|
|
659
|
+
}
|
|
660
|
+
// lines === 0: no movement
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
hideCursor(): void {
|
|
664
|
+
this.#safeWrite("\x1b[?25l");
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
showCursor(): void {
|
|
668
|
+
this.#safeWrite("\x1b[?25h");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
clearLine(): void {
|
|
672
|
+
this.#safeWrite("\x1b[K");
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
clearFromCursor(): void {
|
|
676
|
+
this.#safeWrite("\x1b[J");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
clearScreen(): void {
|
|
680
|
+
this.#safeWrite("\x1b[H\x1b[0J"); // Move to home (1,1) and clear from cursor to end
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
setTitle(title: string): void {
|
|
684
|
+
// OSC 0;title BEL - set terminal window title
|
|
685
|
+
this.#safeWrite(`\x1b]0;${title}\x07`);
|
|
686
|
+
}
|
|
687
|
+
}
|