@nghyane/arcane-tui 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/CHANGELOG.md +3 -0
- package/README.md +704 -0
- package/package.json +72 -0
- package/src/autocomplete.ts +772 -0
- package/src/buffer/ansi-parser.ts +349 -0
- package/src/buffer/buffer.ts +120 -0
- package/src/buffer/cell.ts +103 -0
- package/src/buffer/index.ts +16 -0
- package/src/buffer/render.ts +149 -0
- package/src/components/box.ts +144 -0
- package/src/components/cancellable-loader.ts +39 -0
- package/src/components/editor.ts +2289 -0
- package/src/components/image.ts +86 -0
- package/src/components/input.ts +531 -0
- package/src/components/loader.ts +59 -0
- package/src/components/markdown.ts +858 -0
- package/src/components/select-list.ts +198 -0
- package/src/components/settings-list.ts +194 -0
- package/src/components/spacer.ts +28 -0
- package/src/components/tab-bar.ts +142 -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/fuzzy.ts +143 -0
- package/src/index.ts +69 -0
- package/src/keybindings.ts +197 -0
- package/src/keys.ts +270 -0
- package/src/kill-ring.ts +46 -0
- package/src/mermaid.ts +140 -0
- package/src/stdin-buffer.ts +385 -0
- package/src/symbols.ts +24 -0
- package/src/terminal-capabilities.ts +393 -0
- package/src/terminal.ts +467 -0
- package/src/ttyid.ts +66 -0
- package/src/tui.ts +1134 -0
- package/src/utils.ts +149 -0
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { $env, logger } from "@nghyane/arcane-utils";
|
|
4
|
+
import { setKittyProtocolActive } from "./keys";
|
|
5
|
+
import { StdinBuffer } from "./stdin-buffer";
|
|
6
|
+
|
|
7
|
+
export type MouseEventType = "press" | "drag" | "release" | "scroll";
|
|
8
|
+
|
|
9
|
+
export interface MouseEvent {
|
|
10
|
+
type: MouseEventType;
|
|
11
|
+
button: number;
|
|
12
|
+
col: number;
|
|
13
|
+
row: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Internal scroll sequences emitted by mouse wheel events
|
|
17
|
+
export const SCROLL_UP = "\x1b[<64~";
|
|
18
|
+
export const SCROLL_DOWN = "\x1b[<65~";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Minimal terminal interface for TUI
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Track active terminal for emergency cleanup on crash
|
|
25
|
+
let activeTerminal: ProcessTerminal | null = null;
|
|
26
|
+
// Track if a terminal was ever started (for emergency restore logic)
|
|
27
|
+
let terminalEverStarted = false;
|
|
28
|
+
|
|
29
|
+
const STD_INPUT_HANDLE = -10;
|
|
30
|
+
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
|
|
31
|
+
/**
|
|
32
|
+
* Emergency terminal restore - call this from signal/crash handlers
|
|
33
|
+
* Resets terminal state without requiring access to the ProcessTerminal instance
|
|
34
|
+
*/
|
|
35
|
+
export function emergencyTerminalRestore(): void {
|
|
36
|
+
try {
|
|
37
|
+
const terminal = activeTerminal;
|
|
38
|
+
if (terminal) {
|
|
39
|
+
terminal.stop();
|
|
40
|
+
terminal.showCursor();
|
|
41
|
+
} else if (terminalEverStarted) {
|
|
42
|
+
// Blind restore only if we know a terminal was started but lost track of it
|
|
43
|
+
// This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
|
|
44
|
+
process.stdout.write(
|
|
45
|
+
"\x1b[?2004l" + // Disable bracketed paste
|
|
46
|
+
"\x1b[?1006l\x1b[?1002l" + // Disable mouse tracking
|
|
47
|
+
"\x1b[<u" + // Pop kitty keyboard protocol
|
|
48
|
+
"\x1b[?1049l" + // Leave alternate screen buffer
|
|
49
|
+
"\x1b[?25h", // Show cursor
|
|
50
|
+
);
|
|
51
|
+
if (process.stdin.setRawMode) {
|
|
52
|
+
process.stdin.setRawMode(false);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Terminal may already be dead during crash cleanup - ignore errors
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export interface Terminal {
|
|
60
|
+
// Start the terminal with input and resize handlers
|
|
61
|
+
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
62
|
+
|
|
63
|
+
// Set mouse event handler
|
|
64
|
+
onMouse(handler: (event: MouseEvent) => void): void;
|
|
65
|
+
|
|
66
|
+
// Stop the terminal and restore state
|
|
67
|
+
stop(): void;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Drain stdin before exiting to prevent Kitty key release events from
|
|
71
|
+
* leaking to the parent shell over slow SSH connections.
|
|
72
|
+
* @param maxMs - Maximum time to drain (default: 1000ms)
|
|
73
|
+
* @param idleMs - Exit early if no input arrives within this time (default: 50ms)
|
|
74
|
+
*/
|
|
75
|
+
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
|
76
|
+
|
|
77
|
+
// Write output to terminal
|
|
78
|
+
write(data: string): void;
|
|
79
|
+
|
|
80
|
+
// Get terminal dimensions
|
|
81
|
+
get columns(): number;
|
|
82
|
+
get rows(): number;
|
|
83
|
+
|
|
84
|
+
// Whether Kitty keyboard protocol is active
|
|
85
|
+
get kittyProtocolActive(): boolean;
|
|
86
|
+
|
|
87
|
+
// Cursor positioning (relative to current position)
|
|
88
|
+
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
|
|
89
|
+
|
|
90
|
+
// Cursor visibility
|
|
91
|
+
hideCursor(): void; // Hide the cursor
|
|
92
|
+
showCursor(): void; // Show the cursor
|
|
93
|
+
|
|
94
|
+
// Clear operations
|
|
95
|
+
clearLine(): void; // Clear current line
|
|
96
|
+
clearFromCursor(): void; // Clear from cursor to end of screen
|
|
97
|
+
clearScreen(): void; // Clear entire screen and move cursor to (0,0)
|
|
98
|
+
|
|
99
|
+
// Title operations
|
|
100
|
+
setTitle(title: string): void; // Set terminal window title
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Real terminal using process.stdin/stdout
|
|
105
|
+
*/
|
|
106
|
+
export class ProcessTerminal implements Terminal {
|
|
107
|
+
#wasRaw = false;
|
|
108
|
+
#inputHandler?: (data: string) => void;
|
|
109
|
+
#resizeHandler?: () => void;
|
|
110
|
+
#mouseHandler?: (event: MouseEvent) => void;
|
|
111
|
+
#kittyProtocolActive = false;
|
|
112
|
+
#stdinBuffer?: StdinBuffer;
|
|
113
|
+
#stdinDataHandler?: (data: string) => void;
|
|
114
|
+
#dead = false;
|
|
115
|
+
#writeLogPath = $env.ARCANE_TUI_WRITE_LOG || "";
|
|
116
|
+
#windowsVTInputRestore?: () => void;
|
|
117
|
+
|
|
118
|
+
get kittyProtocolActive(): boolean {
|
|
119
|
+
return this.#kittyProtocolActive;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
onMouse(handler: (event: MouseEvent) => void): void {
|
|
123
|
+
this.#mouseHandler = handler;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
127
|
+
this.#inputHandler = onInput;
|
|
128
|
+
this.#resizeHandler = onResize;
|
|
129
|
+
|
|
130
|
+
// Register for emergency cleanup
|
|
131
|
+
activeTerminal = this;
|
|
132
|
+
terminalEverStarted = true;
|
|
133
|
+
|
|
134
|
+
// Save previous state and enable raw mode
|
|
135
|
+
this.#wasRaw = process.stdin.isRaw || false;
|
|
136
|
+
if (process.stdin.setRawMode) {
|
|
137
|
+
process.stdin.setRawMode(true);
|
|
138
|
+
}
|
|
139
|
+
process.stdin.setEncoding("utf8");
|
|
140
|
+
process.stdin.resume();
|
|
141
|
+
|
|
142
|
+
// Enter alternate screen buffer — keeps TUI output out of scrollback
|
|
143
|
+
this.#safeWrite("\x1b[?1049h");
|
|
144
|
+
|
|
145
|
+
// Enable mouse tracking (button-motion + SGR encoding) for scroll and selection
|
|
146
|
+
this.#safeWrite("\x1b[?1002h\x1b[?1006h");
|
|
147
|
+
|
|
148
|
+
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
149
|
+
this.#safeWrite("\x1b[?2004h");
|
|
150
|
+
|
|
151
|
+
// Set up resize handler immediately
|
|
152
|
+
process.stdout.on("resize", this.#resizeHandler);
|
|
153
|
+
|
|
154
|
+
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
155
|
+
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
156
|
+
if (process.platform !== "win32") {
|
|
157
|
+
process.kill(process.pid, "SIGWINCH");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
|
|
161
|
+
// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
|
|
162
|
+
// events that lose modifier information. Must run after setRawMode(true)
|
|
163
|
+
// since that resets console mode flags.
|
|
164
|
+
this.#enableWindowsVTInput();
|
|
165
|
+
// Query and enable Kitty keyboard protocol
|
|
166
|
+
// The query handler intercepts input temporarily, then installs the user's handler
|
|
167
|
+
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
168
|
+
this.#queryAndEnableKittyProtocol();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT to the stdin console mode
|
|
173
|
+
* so modified keys (for example Shift+Tab) arrive as VT escape sequences.
|
|
174
|
+
*/
|
|
175
|
+
#enableWindowsVTInput(): void {
|
|
176
|
+
if (process.platform !== "win32") return;
|
|
177
|
+
this.#restoreWindowsVTInput();
|
|
178
|
+
try {
|
|
179
|
+
const kernel32 = dlopen("kernel32.dll", {
|
|
180
|
+
GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
|
|
181
|
+
GetConsoleMode: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
|
|
182
|
+
SetConsoleMode: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.bool },
|
|
183
|
+
});
|
|
184
|
+
const handle = kernel32.symbols.GetStdHandle(STD_INPUT_HANDLE);
|
|
185
|
+
const mode = new Uint32Array(1);
|
|
186
|
+
const modePtr = ptr(mode);
|
|
187
|
+
if (!modePtr || !kernel32.symbols.GetConsoleMode(handle, modePtr)) {
|
|
188
|
+
kernel32.close();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const originalMode = mode[0]!;
|
|
192
|
+
const vtMode = originalMode | ENABLE_VIRTUAL_TERMINAL_INPUT;
|
|
193
|
+
if (vtMode !== originalMode && !kernel32.symbols.SetConsoleMode(handle, vtMode)) {
|
|
194
|
+
kernel32.close();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.#windowsVTInputRestore = () => {
|
|
198
|
+
try {
|
|
199
|
+
kernel32.symbols.SetConsoleMode(handle, originalMode);
|
|
200
|
+
} finally {
|
|
201
|
+
kernel32.close();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
} catch {
|
|
205
|
+
// bun:ffi unavailable or console API unsupported; keep startup non-fatal.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
#restoreWindowsVTInput(): void {
|
|
210
|
+
if (process.platform !== "win32") return;
|
|
211
|
+
const restore = this.#windowsVTInputRestore;
|
|
212
|
+
this.#windowsVTInputRestore = undefined;
|
|
213
|
+
if (!restore) return;
|
|
214
|
+
try {
|
|
215
|
+
restore();
|
|
216
|
+
} catch {
|
|
217
|
+
// Ignore restore errors during terminal teardown.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Set up StdinBuffer to split batched input into individual sequences.
|
|
223
|
+
* This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
|
|
224
|
+
*
|
|
225
|
+
* Also watches for Kitty protocol response and enables it when detected.
|
|
226
|
+
* This is done here (after stdinBuffer parsing) rather than on raw stdin
|
|
227
|
+
* to handle the case where the response arrives split across multiple events.
|
|
228
|
+
*/
|
|
229
|
+
#setupStdinBuffer(): void {
|
|
230
|
+
this.#stdinBuffer = new StdinBuffer({ timeout: 10 });
|
|
231
|
+
|
|
232
|
+
// Kitty protocol response pattern: \x1b[?<flags>u
|
|
233
|
+
const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
|
|
234
|
+
|
|
235
|
+
// SGR mouse sequence pattern: \x1b[<button;col;rowM or \x1b[<button;col;rowm
|
|
236
|
+
const sgrMousePattern = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
|
237
|
+
|
|
238
|
+
// Forward individual sequences to the input handler
|
|
239
|
+
this.#stdinBuffer.on("data", (sequence: string) => {
|
|
240
|
+
// Check for Kitty protocol response (only if not already enabled)
|
|
241
|
+
if (!this.#kittyProtocolActive) {
|
|
242
|
+
const match = sequence.match(kittyResponsePattern);
|
|
243
|
+
if (match) {
|
|
244
|
+
this.#kittyProtocolActive = true;
|
|
245
|
+
setKittyProtocolActive(true);
|
|
246
|
+
|
|
247
|
+
// Enable Kitty keyboard protocol (push flags)
|
|
248
|
+
// Flag 1 = disambiguate escape codes
|
|
249
|
+
// Flag 2 = report event types (press/repeat/release)
|
|
250
|
+
// Flag 4 = report alternate keys
|
|
251
|
+
this.#safeWrite("\x1b[>7u");
|
|
252
|
+
return; // Don't forward protocol response to TUI
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Handle mouse events
|
|
257
|
+
const sgrMatch = sequence.match(sgrMousePattern);
|
|
258
|
+
if (sgrMatch) {
|
|
259
|
+
const rawButton = Number.parseInt(sgrMatch[1], 10);
|
|
260
|
+
const col = Number.parseInt(sgrMatch[2], 10);
|
|
261
|
+
const row = Number.parseInt(sgrMatch[3], 10);
|
|
262
|
+
const isRelease = sgrMatch[4] === "m";
|
|
263
|
+
|
|
264
|
+
// Scroll wheel: button 64 = up, 65 = down
|
|
265
|
+
if ((rawButton === 64 || rawButton === 65) && this.#inputHandler) {
|
|
266
|
+
this.#inputHandler(rawButton === 64 ? SCROLL_UP : SCROLL_DOWN);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Emit structured mouse event
|
|
271
|
+
if (this.#mouseHandler) {
|
|
272
|
+
const button = rawButton & 0x03; // low 2 bits = button number
|
|
273
|
+
const isDrag = (rawButton & 0x20) !== 0; // bit 5 = motion
|
|
274
|
+
const type: MouseEventType = isRelease ? "release" : isDrag ? "drag" : "press";
|
|
275
|
+
this.#mouseHandler({ type, button, col, row });
|
|
276
|
+
}
|
|
277
|
+
return; // Don't forward mouse events as keyboard input
|
|
278
|
+
}
|
|
279
|
+
// Drop legacy X10 mouse sequences
|
|
280
|
+
if (sequence.startsWith("\x1b[M") && sequence.length === 6) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (this.#inputHandler) {
|
|
285
|
+
this.#inputHandler(sequence);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Re-wrap paste content with bracketed paste markers for existing editor handling
|
|
290
|
+
this.#stdinBuffer.on("paste", (content: string) => {
|
|
291
|
+
if (this.#inputHandler) {
|
|
292
|
+
this.#inputHandler(`\x1b[200~${content}\x1b[201~`);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Handler that pipes stdin data through the buffer
|
|
297
|
+
this.#stdinDataHandler = (data: string) => {
|
|
298
|
+
this.#stdinBuffer!.process(data);
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Query terminal for Kitty keyboard protocol support and enable if available.
|
|
304
|
+
*
|
|
305
|
+
* Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
|
|
306
|
+
* it supports the protocol and we enable it with CSI > 1 u.
|
|
307
|
+
*
|
|
308
|
+
* The response is detected in setupStdinBuffer's data handler, which properly
|
|
309
|
+
* handles the case where the response arrives split across multiple stdin events.
|
|
310
|
+
*/
|
|
311
|
+
#queryAndEnableKittyProtocol(): void {
|
|
312
|
+
this.#setupStdinBuffer();
|
|
313
|
+
process.stdin.on("data", this.#stdinDataHandler!);
|
|
314
|
+
this.#safeWrite("\x1b[?u");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
|
|
318
|
+
if (this.#kittyProtocolActive) {
|
|
319
|
+
// Disable Kitty keyboard protocol first so any late key releases
|
|
320
|
+
// do not generate new Kitty escape sequences.
|
|
321
|
+
this.#safeWrite("\x1b[<u");
|
|
322
|
+
this.#kittyProtocolActive = false;
|
|
323
|
+
setKittyProtocolActive(false);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const previousHandler = this.#inputHandler;
|
|
327
|
+
this.#inputHandler = undefined;
|
|
328
|
+
|
|
329
|
+
let lastDataTime = Date.now();
|
|
330
|
+
const onData = () => {
|
|
331
|
+
lastDataTime = Date.now();
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
process.stdin.on("data", onData);
|
|
335
|
+
const endTime = Date.now() + maxMs;
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
while (true) {
|
|
339
|
+
const now = Date.now();
|
|
340
|
+
const timeLeft = endTime - now;
|
|
341
|
+
if (timeLeft <= 0) break;
|
|
342
|
+
if (now - lastDataTime >= idleMs) break;
|
|
343
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(idleMs, timeLeft)));
|
|
344
|
+
}
|
|
345
|
+
} finally {
|
|
346
|
+
process.stdin.removeListener("data", onData);
|
|
347
|
+
this.#inputHandler = previousHandler;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
stop(): void {
|
|
352
|
+
// Unregister from emergency cleanup
|
|
353
|
+
if (activeTerminal === this) {
|
|
354
|
+
activeTerminal = null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Disable bracketed paste mode
|
|
358
|
+
this.#safeWrite("\x1b[?2004l");
|
|
359
|
+
|
|
360
|
+
// Disable mouse tracking
|
|
361
|
+
this.#safeWrite("\x1b[?1006l\x1b[?1002l");
|
|
362
|
+
|
|
363
|
+
// Disable Kitty keyboard protocol if not already done by drainInput()
|
|
364
|
+
if (this.#kittyProtocolActive) {
|
|
365
|
+
this.#safeWrite("\x1b[<u");
|
|
366
|
+
this.#kittyProtocolActive = false;
|
|
367
|
+
setKittyProtocolActive(false);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
this.#restoreWindowsVTInput();
|
|
371
|
+
// Clean up StdinBuffer
|
|
372
|
+
if (this.#stdinBuffer) {
|
|
373
|
+
this.#stdinBuffer.destroy();
|
|
374
|
+
this.#stdinBuffer = undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Remove event handlers
|
|
378
|
+
if (this.#stdinDataHandler) {
|
|
379
|
+
process.stdin.removeListener("data", this.#stdinDataHandler);
|
|
380
|
+
this.#stdinDataHandler = undefined;
|
|
381
|
+
}
|
|
382
|
+
this.#inputHandler = undefined;
|
|
383
|
+
if (this.#resizeHandler) {
|
|
384
|
+
process.stdout.removeListener("resize", this.#resizeHandler);
|
|
385
|
+
this.#resizeHandler = undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
389
|
+
// re-interpreted after raw mode is disabled. This fixes a race condition
|
|
390
|
+
// where Ctrl+D could close the parent shell over SSH.
|
|
391
|
+
process.stdin.pause();
|
|
392
|
+
|
|
393
|
+
// Restore raw mode state
|
|
394
|
+
if (process.stdin.setRawMode) {
|
|
395
|
+
process.stdin.setRawMode(this.#wasRaw);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Leave alternate screen buffer and show cursor (must be last — restores normal screen)
|
|
399
|
+
this.#safeWrite("\x1b[?1049l\x1b[?25h");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
write(data: string): void {
|
|
403
|
+
this.#safeWrite(data);
|
|
404
|
+
if (this.#writeLogPath) {
|
|
405
|
+
try {
|
|
406
|
+
fs.appendFileSync(this.#writeLogPath, data, { encoding: "utf8" });
|
|
407
|
+
} catch {
|
|
408
|
+
// Ignore logging errors
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#safeWrite(data: string): void {
|
|
414
|
+
if (this.#dead) return;
|
|
415
|
+
try {
|
|
416
|
+
process.stdout.write(data);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
// Any write failure means terminal is dead - no recovery possible
|
|
419
|
+
this.#dead = true;
|
|
420
|
+
logger.warn("terminal is dead - no recovery possible", { error: err, data });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
get columns(): number {
|
|
425
|
+
return process.stdout.columns || 80;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
get rows(): number {
|
|
429
|
+
return process.stdout.rows || 24;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
moveBy(lines: number): void {
|
|
433
|
+
if (lines > 0) {
|
|
434
|
+
// Move down
|
|
435
|
+
this.#safeWrite(`\x1b[${lines}B`);
|
|
436
|
+
} else if (lines < 0) {
|
|
437
|
+
// Move up
|
|
438
|
+
this.#safeWrite(`\x1b[${-lines}A`);
|
|
439
|
+
}
|
|
440
|
+
// lines === 0: no movement
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
hideCursor(): void {
|
|
444
|
+
this.#safeWrite("\x1b[?25l");
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
showCursor(): void {
|
|
448
|
+
this.#safeWrite("\x1b[?25h");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
clearLine(): void {
|
|
452
|
+
this.#safeWrite("\x1b[K");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
clearFromCursor(): void {
|
|
456
|
+
this.#safeWrite("\x1b[J");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
clearScreen(): void {
|
|
460
|
+
this.#safeWrite("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
setTitle(title: string): void {
|
|
464
|
+
// OSC 0;title BEL - set terminal window title
|
|
465
|
+
this.#safeWrite(`\x1b]0;${title}\x07`);
|
|
466
|
+
}
|
|
467
|
+
}
|
package/src/ttyid.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { CString, dlopen, FFIType } from "bun:ffi";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
|
|
5
|
+
/** Resolve the TTY device path for stdin (fd 0) via POSIX `ttyname(3)`. */
|
|
6
|
+
export function getTtyPath(): string | null {
|
|
7
|
+
if (os.platform() === "linux") {
|
|
8
|
+
// Linux: /proc/self/fd/0 is a symlink to /dev/pts/N
|
|
9
|
+
try {
|
|
10
|
+
const ttyPath = fs.readlinkSync("/proc/self/fd/0");
|
|
11
|
+
if (ttyPath.startsWith("/dev/")) {
|
|
12
|
+
return ttyPath;
|
|
13
|
+
}
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
} else if (os.platform() !== "win32") {
|
|
18
|
+
try {
|
|
19
|
+
const libName = os.platform() === "darwin" ? "libSystem.B.dylib" : "libc.so.6";
|
|
20
|
+
const lib = dlopen(libName, {
|
|
21
|
+
ttyname: { args: [FFIType.i32], returns: FFIType.ptr },
|
|
22
|
+
});
|
|
23
|
+
try {
|
|
24
|
+
const result = lib.symbols.ttyname(0);
|
|
25
|
+
return result ? new CString(result).toString() : null;
|
|
26
|
+
} finally {
|
|
27
|
+
lib.close();
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Get a stable identifier for the current terminal.
|
|
37
|
+
* Uses the TTY device path (e.g., /dev/pts/3), falling back to environment
|
|
38
|
+
* variables for terminal multiplexers or terminal emulators.
|
|
39
|
+
* Returns null if no terminal can be identified (e.g., piped input).
|
|
40
|
+
*/
|
|
41
|
+
export function getTerminalId(): string | null {
|
|
42
|
+
// TTY device path — most reliable, unique per terminal tab
|
|
43
|
+
if (process.stdin.isTTY) {
|
|
44
|
+
try {
|
|
45
|
+
const ttyPath = getTtyPath();
|
|
46
|
+
if (ttyPath?.startsWith("/dev/")) {
|
|
47
|
+
return ttyPath.slice(5).replace(/\//g, "-"); // /dev/pts/3 -> pts-3
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Fallback to terminal-specific env vars
|
|
53
|
+
const kittyId = process.env.KITTY_WINDOW_ID;
|
|
54
|
+
if (kittyId) return `kitty-${kittyId}`;
|
|
55
|
+
|
|
56
|
+
const tmuxPane = process.env.TMUX_PANE;
|
|
57
|
+
if (tmuxPane) return `tmux-${tmuxPane}`;
|
|
58
|
+
|
|
59
|
+
const terminalSessionId = process.env.TERM_SESSION_ID; // macOS Terminal.app
|
|
60
|
+
if (terminalSessionId) return `apple-${terminalSessionId}`;
|
|
61
|
+
|
|
62
|
+
const wtSession = process.env.WT_SESSION; // Windows Terminal
|
|
63
|
+
if (wtSession) return `wt-${wtSession}`;
|
|
64
|
+
|
|
65
|
+
return null;
|
|
66
|
+
}
|