@tuidom/terminal-backend 0.1.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/dist/nodeTerminalBackend.d.ts +83 -0
- package/dist/nodeTerminalBackend.js +378 -0
- package/dist/terminalEnv.d.ts +9 -0
- package/dist/terminalEnv.js +13 -0
- package/package.json +33 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ITerminalBackend } from "@tuidom/core/backend/iTerminalBackend";
|
|
2
|
+
import { Point, Size } from "@tuidom/core/common/geometryPromitives";
|
|
3
|
+
import type { KeyPressEvent } from "@tuidom/core/input/keyEvent";
|
|
4
|
+
import type { MouseToken } from "@tuidom/core/input/rawTerminalToken";
|
|
5
|
+
import { Grid } from "@tuidom/core/rendering/grid";
|
|
6
|
+
/**
|
|
7
|
+
* Real terminal backend: reads from process.stdin, writes to process.stdout.
|
|
8
|
+
* Handles alternate screen, raw mode, cursor visibility, signal cleanup.
|
|
9
|
+
* Enables the Kitty Keyboard Protocol; inside tmux the input-mode sequences are sent
|
|
10
|
+
* directly so tmux manages them per-pane (passthrough is reserved for OSC clipboard).
|
|
11
|
+
*/
|
|
12
|
+
export declare class NodeTerminalBackend implements ITerminalBackend {
|
|
13
|
+
private inputCallbacks;
|
|
14
|
+
private mouseCallbacks;
|
|
15
|
+
private pasteCallbacks;
|
|
16
|
+
private resizeCallbacks;
|
|
17
|
+
private oscResponseCallbacks;
|
|
18
|
+
private deviceReportCallbacks;
|
|
19
|
+
private stdin;
|
|
20
|
+
private stdout;
|
|
21
|
+
private onDataHandler;
|
|
22
|
+
private partialInputTimer;
|
|
23
|
+
private onResizeHandler;
|
|
24
|
+
private resizeThrottleTimer;
|
|
25
|
+
private resizeThrottleMs;
|
|
26
|
+
private lastEmittedSize;
|
|
27
|
+
private resizePending;
|
|
28
|
+
private cleanupHandlers;
|
|
29
|
+
private readonly isTmux;
|
|
30
|
+
private readonly inputParser;
|
|
31
|
+
private readonly renderer;
|
|
32
|
+
/**
|
|
33
|
+
* The cell-diff for the current frame is buffered here instead of going straight
|
|
34
|
+
* to stdout, so renderFrame() can decide whether the frame is empty before
|
|
35
|
+
* emitting the frame wrapper (sync markers + cursor hide/show). See renderFrame.
|
|
36
|
+
*/
|
|
37
|
+
private readonly frameBuffer;
|
|
38
|
+
private prevGrid;
|
|
39
|
+
private prevCursorPosition;
|
|
40
|
+
constructor(stdin?: NodeJS.ReadStream, stdout?: NodeJS.WriteStream, options?: {
|
|
41
|
+
resizeThrottleMs?: number;
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* Write a raw escape sequence to stdout, wrapping in TMUX passthrough if needed.
|
|
45
|
+
*
|
|
46
|
+
* ВАЖНО: passthrough применять ТОЛЬКО к последовательностям, которые tmux НЕ понимает
|
|
47
|
+
* и которые должны дойти до внешнего терминала напрямую (например, OSC 52 — буфер обмена).
|
|
48
|
+
*
|
|
49
|
+
* Последовательности режимов ВВОДА (Kitty keyboard protocol `CSI > u`/`CSI < u`/`CSI ? u`,
|
|
50
|
+
* mouse tracking `?1000/1002/1003/1006`) tmux понимает и отслеживает ПОПАНЕЛЬНО — их нужно
|
|
51
|
+
* слать напрямую (writeDirect), иначе passthrough включит режим на внешнем терминале глобально,
|
|
52
|
+
* и кодировка клавиш «протечёт» во все панели tmux (Ctrl+S перестаёт быть префиксом, а в других
|
|
53
|
+
* вкладках появляются `[115;5u`-подобные символы).
|
|
54
|
+
*/
|
|
55
|
+
private writePassthrough;
|
|
56
|
+
/**
|
|
57
|
+
* Write a sequence straight to stdout without TMUX passthrough. Use for input-mode
|
|
58
|
+
* sequences (Kitty keyboard protocol, mouse tracking) so tmux manages them per-pane.
|
|
59
|
+
*/
|
|
60
|
+
private writeDirect;
|
|
61
|
+
/**
|
|
62
|
+
* Write an OSC or other escape sequence to the terminal.
|
|
63
|
+
* TMUX passthrough is applied automatically when running inside TMUX.
|
|
64
|
+
*/
|
|
65
|
+
writeOscSequence(sequence: string): void;
|
|
66
|
+
onInput(callback: (event: KeyPressEvent) => void): void;
|
|
67
|
+
onMouse(callback: (event: MouseToken) => void): void;
|
|
68
|
+
onPaste(callback: (text: string) => void): void;
|
|
69
|
+
onResize(callback: (size: Size) => void): void;
|
|
70
|
+
onOscResponse(callback: (code: number, data: string) => void): void;
|
|
71
|
+
probeKeyboardProtocol(onResult: (supported: boolean) => void): void;
|
|
72
|
+
/** Fan a parsed input batch out to the registered callbacks. */
|
|
73
|
+
private dispatchInput;
|
|
74
|
+
/** If a partial sequence is buffered, flush it after a short grace period (the rest never came). */
|
|
75
|
+
private schedulePartialInputFlush;
|
|
76
|
+
private clearPartialInputTimer;
|
|
77
|
+
/** Emit resize only if dimensions actually changed */
|
|
78
|
+
private emitResize;
|
|
79
|
+
renderFrame(grid: Grid, cursorPosition: Point | null): void;
|
|
80
|
+
getSize(): Size;
|
|
81
|
+
setup(): void;
|
|
82
|
+
teardown(): void;
|
|
83
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { Point, Size } from "@tuidom/core/common/geometryPromitives";
|
|
2
|
+
import { KeyInputParser } from "@tuidom/core/input/keyInputParser";
|
|
3
|
+
import { MOUSE_TRACKING_ALL_ENABLE, MOUSE_TRACKING_DISABLE } from "@tuidom/core/input/mouseTracking";
|
|
4
|
+
import { Grid } from "@tuidom/core/rendering/grid";
|
|
5
|
+
import { TerminalRenderer } from "@tuidom/core/rendering/terminalRenderer";
|
|
6
|
+
import { isInsideTmux } from "./terminalEnv.js";
|
|
7
|
+
/**
|
|
8
|
+
* Kitty Keyboard Protocol escape sequences.
|
|
9
|
+
*
|
|
10
|
+
* Flags (push mode): disambiguate(1) + event types(2) + all keys as escapes(8) + alternate keys(4) = 15
|
|
11
|
+
* Alternate keys (flag 4) makes the terminal report baseLayoutKey — the physical key position in the
|
|
12
|
+
* base (US QWERTY) layout — as the third sub-parameter of the codepoint field. This lets us match
|
|
13
|
+
* shortcuts like Ctrl+S regardless of the active keyboard layout (e.g. Russian).
|
|
14
|
+
* See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
15
|
+
*/
|
|
16
|
+
const KITTY_ENABLE = "\x1b[>15u";
|
|
17
|
+
const KITTY_DISABLE = "\x1b[<u";
|
|
18
|
+
/**
|
|
19
|
+
* xterm modifyOtherKeys, level 2 (`CSI > 4 ; 2 m`) + reset (`CSI > 4 ; 0 m`).
|
|
20
|
+
*
|
|
21
|
+
* tmux does NOT speak the Kitty keyboard protocol push (`CSI > 15 u`) — it ignores it,
|
|
22
|
+
* so inside tmux modified keys like Ctrl+Tab / Ctrl+Shift+<letter> arrive as their plain
|
|
23
|
+
* legacy byte (Ctrl+Tab → `\t`) and the shortcut is lost. tmux DOES honor modifyOtherKeys
|
|
24
|
+
* and then emits those keys in CSI-u form (Ctrl+Tab → `\x1b[9;5u`), which our tokenizer
|
|
25
|
+
* already parses. Sending both enables is the standard belt-and-suspenders: real Kitty (no
|
|
26
|
+
* tmux) uses the Kitty protocol and ignores modifyOtherKeys; tmux uses modifyOtherKeys.
|
|
27
|
+
* Written per-pane (`writeDirect`, no passthrough) like the other input modes.
|
|
28
|
+
*/
|
|
29
|
+
const MODIFY_OTHER_KEYS_ENABLE = "\x1b[>4;2m";
|
|
30
|
+
const MODIFY_OTHER_KEYS_DISABLE = "\x1b[>4;0m";
|
|
31
|
+
/**
|
|
32
|
+
* Bracketed paste (DEC private mode ?2004). When enabled, the terminal wraps pasted
|
|
33
|
+
* clipboard content in `ESC[200~ … ESC[201~` so we insert it as one literal text block
|
|
34
|
+
* instead of replaying it as keystrokes (which loses newlines and runs special chars as
|
|
35
|
+
* shortcuts). Like the Kitty/mouse modes, tmux understands and tracks it per-pane, so it
|
|
36
|
+
* is written directly (no passthrough).
|
|
37
|
+
*/
|
|
38
|
+
const BRACKETED_PASTE_ENABLE = "\x1b[?2004h";
|
|
39
|
+
const BRACKETED_PASTE_DISABLE = "\x1b[?2004l";
|
|
40
|
+
/**
|
|
41
|
+
* Keyboard-protocol probe: query current Kitty flags (`CSI ? u`) immediately followed by
|
|
42
|
+
* Primary Device Attributes (`CSI c`). Only Kitty-capable terminals answer the first; every
|
|
43
|
+
* terminal answers DA1, so the DA1 reply is the "all replies are in" sentinel. The timeout
|
|
44
|
+
* bounds the negative case (no reply at all → not supported).
|
|
45
|
+
*/
|
|
46
|
+
const KITTY_FLAGS_QUERY = "\x1b[?u";
|
|
47
|
+
const DA1_QUERY = "\x1b[c";
|
|
48
|
+
const KEYBOARD_PROBE_TIMEOUT_MS = 200;
|
|
49
|
+
/**
|
|
50
|
+
* How long to hold a partial escape sequence that arrived at the end of a stdin read,
|
|
51
|
+
* waiting for the rest to follow (it does, on a split keypress over SSH/tmux). If nothing
|
|
52
|
+
* follows, the tail is flushed as-is — a lone ESC then resolves to the Escape key. Mirrors
|
|
53
|
+
* the classic terminal "escape timeout"; 50ms keeps Escape responsive while absorbing splits.
|
|
54
|
+
*/
|
|
55
|
+
const PARTIAL_INPUT_FLUSH_MS = 50;
|
|
56
|
+
/**
|
|
57
|
+
* Wrap an escape sequence for TMUX DCS passthrough.
|
|
58
|
+
*
|
|
59
|
+
* TMUX не понимает произвольные escape-последовательности и глотает их.
|
|
60
|
+
* Чтобы передать их терминалу напрямую, нужно обернуть в DCS passthrough:
|
|
61
|
+
* \x1bPtmux;\x1b<sequence>\x1b\\
|
|
62
|
+
*
|
|
63
|
+
* Внутри passthrough каждый ESC (\x1b) в оригинальной последовательности
|
|
64
|
+
* удваивается (\x1b → \x1b\x1b).
|
|
65
|
+
*
|
|
66
|
+
* See: https://github.com/tmux/tmux/wiki/FAQ#what-is-the-passthrough-escape-sequence-and-how-do-i-use-it
|
|
67
|
+
*/
|
|
68
|
+
function wrapForTmux(sequence) {
|
|
69
|
+
const escaped = sequence.replace(/\x1b/g, "\x1b\x1b");
|
|
70
|
+
return `\x1bPtmux;${escaped}\x1b\\`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Real terminal backend: reads from process.stdin, writes to process.stdout.
|
|
74
|
+
* Handles alternate screen, raw mode, cursor visibility, signal cleanup.
|
|
75
|
+
* Enables the Kitty Keyboard Protocol; inside tmux the input-mode sequences are sent
|
|
76
|
+
* directly so tmux manages them per-pane (passthrough is reserved for OSC clipboard).
|
|
77
|
+
*/
|
|
78
|
+
export class NodeTerminalBackend {
|
|
79
|
+
inputCallbacks = [];
|
|
80
|
+
mouseCallbacks = [];
|
|
81
|
+
pasteCallbacks = [];
|
|
82
|
+
resizeCallbacks = [];
|
|
83
|
+
oscResponseCallbacks = [];
|
|
84
|
+
deviceReportCallbacks = [];
|
|
85
|
+
stdin;
|
|
86
|
+
stdout;
|
|
87
|
+
onDataHandler = null;
|
|
88
|
+
partialInputTimer = null;
|
|
89
|
+
onResizeHandler = null;
|
|
90
|
+
resizeThrottleTimer = null;
|
|
91
|
+
resizeThrottleMs;
|
|
92
|
+
lastEmittedSize = null;
|
|
93
|
+
resizePending = false;
|
|
94
|
+
cleanupHandlers = [];
|
|
95
|
+
isTmux;
|
|
96
|
+
inputParser = new KeyInputParser();
|
|
97
|
+
renderer;
|
|
98
|
+
/**
|
|
99
|
+
* The cell-diff for the current frame is buffered here instead of going straight
|
|
100
|
+
* to stdout, so renderFrame() can decide whether the frame is empty before
|
|
101
|
+
* emitting the frame wrapper (sync markers + cursor hide/show). See renderFrame.
|
|
102
|
+
*/
|
|
103
|
+
frameBuffer = new StringSink();
|
|
104
|
+
prevGrid = null;
|
|
105
|
+
prevCursorPosition = null;
|
|
106
|
+
constructor(stdin = process.stdin, stdout = process.stdout, options) {
|
|
107
|
+
this.stdin = stdin;
|
|
108
|
+
this.stdout = stdout;
|
|
109
|
+
this.resizeThrottleMs = options?.resizeThrottleMs ?? 100;
|
|
110
|
+
this.isTmux = isInsideTmux();
|
|
111
|
+
this.renderer = new TerminalRenderer(this.frameBuffer);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Write a raw escape sequence to stdout, wrapping in TMUX passthrough if needed.
|
|
115
|
+
*
|
|
116
|
+
* ВАЖНО: passthrough применять ТОЛЬКО к последовательностям, которые tmux НЕ понимает
|
|
117
|
+
* и которые должны дойти до внешнего терминала напрямую (например, OSC 52 — буфер обмена).
|
|
118
|
+
*
|
|
119
|
+
* Последовательности режимов ВВОДА (Kitty keyboard protocol `CSI > u`/`CSI < u`/`CSI ? u`,
|
|
120
|
+
* mouse tracking `?1000/1002/1003/1006`) tmux понимает и отслеживает ПОПАНЕЛЬНО — их нужно
|
|
121
|
+
* слать напрямую (writeDirect), иначе passthrough включит режим на внешнем терминале глобально,
|
|
122
|
+
* и кодировка клавиш «протечёт» во все панели tmux (Ctrl+S перестаёт быть префиксом, а в других
|
|
123
|
+
* вкладках появляются `[115;5u`-подобные символы).
|
|
124
|
+
*/
|
|
125
|
+
writePassthrough(sequence) {
|
|
126
|
+
this.stdout.write(this.isTmux ? wrapForTmux(sequence) : sequence);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Write a sequence straight to stdout without TMUX passthrough. Use for input-mode
|
|
130
|
+
* sequences (Kitty keyboard protocol, mouse tracking) so tmux manages them per-pane.
|
|
131
|
+
*/
|
|
132
|
+
writeDirect(sequence) {
|
|
133
|
+
this.stdout.write(sequence);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Write an OSC or other escape sequence to the terminal.
|
|
137
|
+
* TMUX passthrough is applied automatically when running inside TMUX.
|
|
138
|
+
*/
|
|
139
|
+
writeOscSequence(sequence) {
|
|
140
|
+
this.writePassthrough(sequence);
|
|
141
|
+
}
|
|
142
|
+
onInput(callback) {
|
|
143
|
+
this.inputCallbacks.push(callback);
|
|
144
|
+
}
|
|
145
|
+
onMouse(callback) {
|
|
146
|
+
this.mouseCallbacks.push(callback);
|
|
147
|
+
}
|
|
148
|
+
onPaste(callback) {
|
|
149
|
+
this.pasteCallbacks.push(callback);
|
|
150
|
+
}
|
|
151
|
+
onResize(callback) {
|
|
152
|
+
this.resizeCallbacks.push(callback);
|
|
153
|
+
}
|
|
154
|
+
onOscResponse(callback) {
|
|
155
|
+
this.oscResponseCallbacks.push(callback);
|
|
156
|
+
}
|
|
157
|
+
probeKeyboardProtocol(onResult) {
|
|
158
|
+
let settled = false;
|
|
159
|
+
let supported = false;
|
|
160
|
+
let timer = null;
|
|
161
|
+
const finish = () => {
|
|
162
|
+
if (settled)
|
|
163
|
+
return;
|
|
164
|
+
settled = true;
|
|
165
|
+
if (timer !== null)
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
onResult(supported);
|
|
168
|
+
};
|
|
169
|
+
this.deviceReportCallbacks.push((report) => {
|
|
170
|
+
if (settled)
|
|
171
|
+
return;
|
|
172
|
+
if (report === "kitty-flags")
|
|
173
|
+
supported = true;
|
|
174
|
+
else
|
|
175
|
+
finish(); // DA1 reply = all replies are in
|
|
176
|
+
});
|
|
177
|
+
// Direct (not passthrough): the reply must reflect tmux's own Kitty support, per-pane.
|
|
178
|
+
this.writeDirect(KITTY_FLAGS_QUERY + DA1_QUERY);
|
|
179
|
+
timer = setTimeout(finish, KEYBOARD_PROBE_TIMEOUT_MS);
|
|
180
|
+
}
|
|
181
|
+
/** Fan a parsed input batch out to the registered callbacks. */
|
|
182
|
+
dispatchInput(result) {
|
|
183
|
+
for (const event of result.keys) {
|
|
184
|
+
for (const cb of this.inputCallbacks)
|
|
185
|
+
cb(event);
|
|
186
|
+
}
|
|
187
|
+
for (const mouseToken of result.mouse) {
|
|
188
|
+
for (const cb of this.mouseCallbacks)
|
|
189
|
+
cb(mouseToken);
|
|
190
|
+
}
|
|
191
|
+
for (const text of result.paste) {
|
|
192
|
+
for (const cb of this.pasteCallbacks)
|
|
193
|
+
cb(text);
|
|
194
|
+
}
|
|
195
|
+
for (const oscToken of result.osc) {
|
|
196
|
+
for (const cb of this.oscResponseCallbacks)
|
|
197
|
+
cb(oscToken.code, oscToken.data);
|
|
198
|
+
}
|
|
199
|
+
for (const report of result.deviceReports) {
|
|
200
|
+
for (const cb of this.deviceReportCallbacks)
|
|
201
|
+
cb(report.report, report.params);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** If a partial sequence is buffered, flush it after a short grace period (the rest never came). */
|
|
205
|
+
schedulePartialInputFlush() {
|
|
206
|
+
if (!this.inputParser.hasPending())
|
|
207
|
+
return;
|
|
208
|
+
this.partialInputTimer = setTimeout(() => {
|
|
209
|
+
this.partialInputTimer = null;
|
|
210
|
+
this.dispatchInput(this.inputParser.flush());
|
|
211
|
+
}, PARTIAL_INPUT_FLUSH_MS);
|
|
212
|
+
}
|
|
213
|
+
clearPartialInputTimer() {
|
|
214
|
+
if (this.partialInputTimer !== null) {
|
|
215
|
+
clearTimeout(this.partialInputTimer);
|
|
216
|
+
this.partialInputTimer = null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/** Emit resize only if dimensions actually changed */
|
|
220
|
+
emitResize() {
|
|
221
|
+
const size = this.getSize();
|
|
222
|
+
if (this.lastEmittedSize !== null &&
|
|
223
|
+
this.lastEmittedSize.width === size.width &&
|
|
224
|
+
this.lastEmittedSize.height === size.height) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
this.lastEmittedSize = size;
|
|
228
|
+
for (const cb of this.resizeCallbacks) {
|
|
229
|
+
cb(size);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
renderFrame(grid, cursorPosition) {
|
|
233
|
+
this.prevGrid ??= new Grid(grid.size);
|
|
234
|
+
const sizeChanged = this.prevGrid.width !== grid.width || this.prevGrid.height !== grid.height;
|
|
235
|
+
if (sizeChanged) {
|
|
236
|
+
this.prevGrid = new Grid(grid.size);
|
|
237
|
+
}
|
|
238
|
+
// Diff into a buffer (not straight to stdout) so we know whether anything changed.
|
|
239
|
+
this.frameBuffer.reset();
|
|
240
|
+
this.renderer.render(grid, this.prevGrid);
|
|
241
|
+
const body = this.frameBuffer.value;
|
|
242
|
+
const cursorChanged = !samePoint(cursorPosition, this.prevCursorPosition);
|
|
243
|
+
// Nothing to repaint and the cursor hasn't moved → emit absolutely nothing.
|
|
244
|
+
// Without this, every mouse-move event re-sent the frame wrapper (hide/show
|
|
245
|
+
// cursor + reposition), making the terminal cursor flicker on plain motion.
|
|
246
|
+
if (!sizeChanged && body.length === 0 && !cursorChanged) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
let out = "\x1b[?2026h"; // begin synchronized output
|
|
250
|
+
if (sizeChanged) {
|
|
251
|
+
out += "\x1b[2J"; // clear screen to avoid stale reflow artifacts
|
|
252
|
+
}
|
|
253
|
+
// Hide the cursor while we paint cells, or when it should be hidden outright.
|
|
254
|
+
if (body.length > 0 || cursorPosition === null) {
|
|
255
|
+
out += "\x1b[?25l"; // hide cursor
|
|
256
|
+
}
|
|
257
|
+
out += body;
|
|
258
|
+
if (cursorPosition !== null) {
|
|
259
|
+
out += `\x1b[${(cursorPosition.y + 1).toString()};${(cursorPosition.x + 1).toString()}H`; // position cursor
|
|
260
|
+
out += "\x1b[?25h"; // show cursor
|
|
261
|
+
}
|
|
262
|
+
out += "\x1b[?2026l"; // end synchronized output
|
|
263
|
+
this.stdout.write(out);
|
|
264
|
+
this.prevCursorPosition = cursorPosition;
|
|
265
|
+
}
|
|
266
|
+
getSize() {
|
|
267
|
+
return new Size(this.stdout.columns, this.stdout.rows);
|
|
268
|
+
}
|
|
269
|
+
setup() {
|
|
270
|
+
// Switch to alternate screen buffer
|
|
271
|
+
this.stdout.write("\x1b[?1049h");
|
|
272
|
+
// Show cursor (will be positioned by the focused element)
|
|
273
|
+
this.stdout.write("\x1b[?25h");
|
|
274
|
+
// Enable Kitty Keyboard Protocol — direct, so tmux tracks it per-pane (no passthrough)
|
|
275
|
+
this.writeDirect(KITTY_ENABLE);
|
|
276
|
+
// Also enable xterm modifyOtherKeys — tmux ignores the Kitty push but honors this,
|
|
277
|
+
// delivering Ctrl+Tab / Ctrl+Shift+<key> as CSI-u. Direct, per-pane.
|
|
278
|
+
this.writeDirect(MODIFY_OTHER_KEYS_ENABLE);
|
|
279
|
+
// Enable mouse tracking (all-motion mode for hover/enter/leave) — direct, per-pane
|
|
280
|
+
this.writeDirect(MOUSE_TRACKING_ALL_ENABLE);
|
|
281
|
+
// Enable bracketed paste so pastes arrive as one text block — direct, per-pane
|
|
282
|
+
this.writeDirect(BRACKETED_PASTE_ENABLE);
|
|
283
|
+
// Raw mode for character-by-character input
|
|
284
|
+
this.stdin.setRawMode(true);
|
|
285
|
+
this.stdin.setEncoding("utf8");
|
|
286
|
+
this.stdin.resume();
|
|
287
|
+
// Listen for input
|
|
288
|
+
this.onDataHandler = (chunk) => {
|
|
289
|
+
// A new chunk supersedes any buffered tail (it gets prepended inside the parser),
|
|
290
|
+
// so cancel a pending flush; reschedule below if a tail still remains afterward.
|
|
291
|
+
this.clearPartialInputTimer();
|
|
292
|
+
this.dispatchInput(this.inputParser.parseWithMouse(chunk));
|
|
293
|
+
this.schedulePartialInputFlush();
|
|
294
|
+
};
|
|
295
|
+
this.stdin.on("data", this.onDataHandler);
|
|
296
|
+
// Listen for terminal resize (throttled + deduplicated)
|
|
297
|
+
this.onResizeHandler = () => {
|
|
298
|
+
if (this.resizeThrottleTimer !== null) {
|
|
299
|
+
// Already throttling — just mark that a new resize arrived
|
|
300
|
+
this.resizePending = true;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
this.emitResize();
|
|
304
|
+
this.resizeThrottleTimer = setTimeout(() => {
|
|
305
|
+
this.resizeThrottleTimer = null;
|
|
306
|
+
if (this.resizePending) {
|
|
307
|
+
this.resizePending = false;
|
|
308
|
+
this.emitResize();
|
|
309
|
+
}
|
|
310
|
+
}, this.resizeThrottleMs);
|
|
311
|
+
};
|
|
312
|
+
this.stdout.on("resize", this.onResizeHandler);
|
|
313
|
+
// Cleanup on exit/SIGINT
|
|
314
|
+
const onExit = () => {
|
|
315
|
+
this.teardown();
|
|
316
|
+
};
|
|
317
|
+
const onSigint = () => {
|
|
318
|
+
this.teardown();
|
|
319
|
+
process.exit(0);
|
|
320
|
+
};
|
|
321
|
+
process.on("exit", onExit);
|
|
322
|
+
process.on("SIGINT", onSigint);
|
|
323
|
+
this.cleanupHandlers.push(() => {
|
|
324
|
+
process.removeListener("exit", onExit);
|
|
325
|
+
process.removeListener("SIGINT", onSigint);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
teardown() {
|
|
329
|
+
// Disable Kitty Keyboard Protocol — direct (matches the per-pane enable in setup)
|
|
330
|
+
this.writeDirect(KITTY_DISABLE);
|
|
331
|
+
// Reset modifyOtherKeys — direct (matches the per-pane enable in setup)
|
|
332
|
+
this.writeDirect(MODIFY_OTHER_KEYS_DISABLE);
|
|
333
|
+
// Disable mouse tracking — direct
|
|
334
|
+
this.writeDirect(MOUSE_TRACKING_DISABLE);
|
|
335
|
+
// Disable bracketed paste — direct (matches the per-pane enable in setup)
|
|
336
|
+
this.writeDirect(BRACKETED_PASTE_DISABLE);
|
|
337
|
+
// Restore cursor
|
|
338
|
+
this.stdout.write("\x1b[?25h");
|
|
339
|
+
// Restore normal screen buffer
|
|
340
|
+
this.stdout.write("\x1b[?1049l");
|
|
341
|
+
// Remove stdin listener
|
|
342
|
+
if (this.onDataHandler) {
|
|
343
|
+
this.stdin.removeListener("data", this.onDataHandler);
|
|
344
|
+
this.onDataHandler = null;
|
|
345
|
+
}
|
|
346
|
+
this.clearPartialInputTimer();
|
|
347
|
+
// Cancel pending throttle and remove resize listener
|
|
348
|
+
if (this.resizeThrottleTimer !== null) {
|
|
349
|
+
clearTimeout(this.resizeThrottleTimer);
|
|
350
|
+
this.resizeThrottleTimer = null;
|
|
351
|
+
}
|
|
352
|
+
this.resizePending = false;
|
|
353
|
+
if (this.onResizeHandler) {
|
|
354
|
+
this.stdout.removeListener("resize", this.onResizeHandler);
|
|
355
|
+
this.onResizeHandler = null;
|
|
356
|
+
}
|
|
357
|
+
// Remove process listeners
|
|
358
|
+
for (const cleanup of this.cleanupHandlers) {
|
|
359
|
+
cleanup();
|
|
360
|
+
}
|
|
361
|
+
this.cleanupHandlers = [];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** In-memory sink that accumulates writes into a string. Implements WritableOutput. */
|
|
365
|
+
class StringSink {
|
|
366
|
+
value = "";
|
|
367
|
+
write(data) {
|
|
368
|
+
this.value += data;
|
|
369
|
+
}
|
|
370
|
+
reset() {
|
|
371
|
+
this.value = "";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function samePoint(a, b) {
|
|
375
|
+
if (a === null || b === null)
|
|
376
|
+
return a === b;
|
|
377
|
+
return a.x === b.x && a.y === b.y;
|
|
378
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared predicates over the process environment, used by both the terminal
|
|
3
|
+
* backend and the terminal-environment detector. Single source of truth so the
|
|
4
|
+
* two layers can't disagree about "are we in tmux / over ssh".
|
|
5
|
+
*/
|
|
6
|
+
/** True when running inside a TMUX session. */
|
|
7
|
+
export declare function isInsideTmux(env?: NodeJS.ProcessEnv): boolean;
|
|
8
|
+
/** True when the session is reached over SSH. */
|
|
9
|
+
export declare function isSsh(env?: NodeJS.ProcessEnv): boolean;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared predicates over the process environment, used by both the terminal
|
|
3
|
+
* backend and the terminal-environment detector. Single source of truth so the
|
|
4
|
+
* two layers can't disagree about "are we in tmux / over ssh".
|
|
5
|
+
*/
|
|
6
|
+
/** True when running inside a TMUX session. */
|
|
7
|
+
export function isInsideTmux(env = process.env) {
|
|
8
|
+
return env.TMUX != null && env.TMUX !== "";
|
|
9
|
+
}
|
|
10
|
+
/** True when the session is reached over SSH. */
|
|
11
|
+
export function isSsh(env = process.env) {
|
|
12
|
+
return (env.SSH_CONNECTION != null && env.SSH_CONNECTION !== "") || (env.SSH_TTY != null && env.SSH_TTY !== "");
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tuidom/terminal-backend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Experimental terminal UI DOM — real terminal backend for Node (tty, ANSI diffing, Kitty protocol). API is unstable.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tuidom/tuidom.git",
|
|
10
|
+
"directory": "packages/terminal-backend"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24.0.0"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"tui",
|
|
17
|
+
"terminal",
|
|
18
|
+
"ui",
|
|
19
|
+
"dom",
|
|
20
|
+
"backend"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
"./package.json": "./package.json",
|
|
27
|
+
"./*.js": "./dist/*.js",
|
|
28
|
+
"./*": "./dist/*.js"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@tuidom/core": "0.1.0"
|
|
32
|
+
}
|
|
33
|
+
}
|