@oh-my-pi/pi-tui 3.37.0 → 4.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/package.json +1 -1
- package/src/components/input.ts +9 -2
- package/src/editor-component.ts +65 -0
- package/src/index.ts +9 -0
- package/src/keys.ts +116 -21
- package/src/stdin-buffer.ts +386 -0
- package/src/terminal.ts +146 -12
- package/src/tui.ts +133 -4
- package/src/utils.ts +167 -14
package/package.json
CHANGED
package/src/components/input.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getEditorKeybindings } from "../keybindings";
|
|
1
2
|
import {
|
|
2
3
|
isAltBackspace,
|
|
3
4
|
isAltLeft,
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
isCtrlU,
|
|
14
15
|
isCtrlW,
|
|
15
16
|
isDelete,
|
|
16
|
-
isEnter,
|
|
17
17
|
} from "../keys";
|
|
18
18
|
import type { Component } from "../tui";
|
|
19
19
|
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils";
|
|
@@ -27,6 +27,7 @@ export class Input implements Component {
|
|
|
27
27
|
private value: string = "";
|
|
28
28
|
private cursor: number = 0; // Cursor position in the value
|
|
29
29
|
public onSubmit?: (value: string) => void;
|
|
30
|
+
public onEscape?: () => void;
|
|
30
31
|
|
|
31
32
|
// Bracketed paste mode buffering
|
|
32
33
|
private pasteBuffer: string = "";
|
|
@@ -78,8 +79,14 @@ export class Input implements Component {
|
|
|
78
79
|
}
|
|
79
80
|
return;
|
|
80
81
|
}
|
|
82
|
+
const kb = getEditorKeybindings();
|
|
83
|
+
if (kb.matches(data, "selectCancel")) {
|
|
84
|
+
this.onEscape?.();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
81
88
|
// Handle special keys
|
|
82
|
-
if (
|
|
89
|
+
if (kb.matches(data, "submit") || data === "\n") {
|
|
83
90
|
// Enter - submit
|
|
84
91
|
if (this.onSubmit) {
|
|
85
92
|
this.onSubmit(this.value);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { AutocompleteProvider } from "./autocomplete";
|
|
2
|
+
import type { Component } from "./tui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Interface for custom editor components.
|
|
6
|
+
*
|
|
7
|
+
* This allows extensions to provide their own editor implementation
|
|
8
|
+
* (e.g., vim mode, emacs mode, custom keybindings) while maintaining
|
|
9
|
+
* compatibility with the core application.
|
|
10
|
+
*/
|
|
11
|
+
export interface EditorComponent extends Component {
|
|
12
|
+
// =========================================================================
|
|
13
|
+
// Core text access (required)
|
|
14
|
+
// =========================================================================
|
|
15
|
+
|
|
16
|
+
/** Get the current text content */
|
|
17
|
+
getText(): string;
|
|
18
|
+
|
|
19
|
+
/** Set the text content */
|
|
20
|
+
setText(text: string): void;
|
|
21
|
+
|
|
22
|
+
// =========================================================================
|
|
23
|
+
// Callbacks (required)
|
|
24
|
+
// =========================================================================
|
|
25
|
+
|
|
26
|
+
/** Called when user submits (e.g., Enter key) */
|
|
27
|
+
onSubmit?: (text: string) => void;
|
|
28
|
+
|
|
29
|
+
/** Called when text changes */
|
|
30
|
+
onChange?: (text: string) => void;
|
|
31
|
+
|
|
32
|
+
// =========================================================================
|
|
33
|
+
// History support (optional)
|
|
34
|
+
// =========================================================================
|
|
35
|
+
|
|
36
|
+
/** Add text to history for up/down navigation */
|
|
37
|
+
addToHistory?(text: string): void;
|
|
38
|
+
|
|
39
|
+
// =========================================================================
|
|
40
|
+
// Advanced text manipulation (optional)
|
|
41
|
+
// =========================================================================
|
|
42
|
+
|
|
43
|
+
/** Insert text at current cursor position */
|
|
44
|
+
insertTextAtCursor?(text: string): void;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get text with any markers expanded (e.g., paste markers).
|
|
48
|
+
* Falls back to getText() if not implemented.
|
|
49
|
+
*/
|
|
50
|
+
getExpandedText?(): string;
|
|
51
|
+
|
|
52
|
+
// =========================================================================
|
|
53
|
+
// Autocomplete support (optional)
|
|
54
|
+
// =========================================================================
|
|
55
|
+
|
|
56
|
+
/** Set the autocomplete provider */
|
|
57
|
+
setAutocompleteProvider?(provider: AutocompleteProvider): void;
|
|
58
|
+
|
|
59
|
+
// =========================================================================
|
|
60
|
+
// Appearance (optional)
|
|
61
|
+
// =========================================================================
|
|
62
|
+
|
|
63
|
+
/** Border color function */
|
|
64
|
+
borderColor?: (str: string) => string;
|
|
65
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,8 @@ export { Spacer } from "./components/spacer";
|
|
|
21
21
|
export { type Tab, TabBar, type TabBarTheme } from "./components/tab-bar";
|
|
22
22
|
export { Text } from "./components/text";
|
|
23
23
|
export { TruncatedText } from "./components/truncated-text";
|
|
24
|
+
// Editor component interface (for custom editors)
|
|
25
|
+
export type { EditorComponent } from "./editor-component";
|
|
24
26
|
// Keybindings
|
|
25
27
|
export {
|
|
26
28
|
DEFAULT_EDITOR_KEYBINDINGS,
|
|
@@ -64,6 +66,9 @@ export {
|
|
|
64
66
|
isEnter,
|
|
65
67
|
isEscape,
|
|
66
68
|
isHome,
|
|
69
|
+
isKeyRelease,
|
|
70
|
+
isKeyRepeat,
|
|
71
|
+
isKittyProtocolActive,
|
|
67
72
|
isShiftBackspace,
|
|
68
73
|
isShiftCtrlD,
|
|
69
74
|
isShiftCtrlO,
|
|
@@ -74,10 +79,14 @@ export {
|
|
|
74
79
|
isShiftTab,
|
|
75
80
|
isTab,
|
|
76
81
|
Key,
|
|
82
|
+
type KeyEventType,
|
|
77
83
|
type KeyId,
|
|
78
84
|
matchesKey,
|
|
79
85
|
parseKey,
|
|
86
|
+
setKittyProtocolActive,
|
|
80
87
|
} from "./keys";
|
|
88
|
+
// Input buffering for batch splitting
|
|
89
|
+
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer";
|
|
81
90
|
export type { BoxSymbols, SymbolTheme } from "./symbols";
|
|
82
91
|
// Terminal interface and implementations
|
|
83
92
|
export { emergencyTerminalRestore, ProcessTerminal, type Terminal } from "./terminal";
|
package/src/keys.ts
CHANGED
|
@@ -13,8 +13,31 @@
|
|
|
13
13
|
* - matchesKey(data, keyId) - Check if input matches a key identifier
|
|
14
14
|
* - parseKey(data) - Parse input and return the key identifier
|
|
15
15
|
* - Key - Helper object for creating typed key identifiers
|
|
16
|
+
* - setKittyProtocolActive(active) - Set global Kitty protocol state
|
|
17
|
+
* - isKittyProtocolActive() - Query global Kitty protocol state
|
|
16
18
|
*/
|
|
17
19
|
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Global Kitty Protocol State
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
let kittyProtocolActive = false;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Set the global Kitty keyboard protocol state.
|
|
28
|
+
* Called by ProcessTerminal after detecting protocol support.
|
|
29
|
+
*/
|
|
30
|
+
export function setKittyProtocolActive(active: boolean): void {
|
|
31
|
+
kittyProtocolActive = active;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Query whether Kitty keyboard protocol is currently active.
|
|
36
|
+
*/
|
|
37
|
+
export function isKittyProtocolActive(): boolean {
|
|
38
|
+
return kittyProtocolActive;
|
|
39
|
+
}
|
|
40
|
+
|
|
18
41
|
// =============================================================================
|
|
19
42
|
// Type-Safe Key Identifiers
|
|
20
43
|
// =============================================================================
|
|
@@ -271,33 +294,85 @@ const FUNCTIONAL_CODEPOINTS = {
|
|
|
271
294
|
// Kitty Protocol Parsing
|
|
272
295
|
// =============================================================================
|
|
273
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Event types from Kitty keyboard protocol (flag 2)
|
|
299
|
+
* 1 = key press, 2 = key repeat, 3 = key release
|
|
300
|
+
*/
|
|
301
|
+
export type KeyEventType = "press" | "repeat" | "release";
|
|
302
|
+
|
|
274
303
|
interface ParsedKittySequence {
|
|
275
304
|
codepoint: number;
|
|
276
305
|
modifier: number;
|
|
306
|
+
eventType: KeyEventType;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Check if the last parsed key event was a key release.
|
|
311
|
+
* Only meaningful when Kitty keyboard protocol with flag 2 is active.
|
|
312
|
+
*/
|
|
313
|
+
export function isKeyRelease(data: string): boolean {
|
|
314
|
+
return (
|
|
315
|
+
data.includes(":3u") ||
|
|
316
|
+
data.includes(":3~") ||
|
|
317
|
+
data.includes(":3A") ||
|
|
318
|
+
data.includes(":3B") ||
|
|
319
|
+
data.includes(":3C") ||
|
|
320
|
+
data.includes(":3D") ||
|
|
321
|
+
data.includes(":3H") ||
|
|
322
|
+
data.includes(":3F")
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Check if the last parsed key event was a key repeat.
|
|
328
|
+
* Only meaningful when Kitty keyboard protocol with flag 2 is active.
|
|
329
|
+
*/
|
|
330
|
+
export function isKeyRepeat(data: string): boolean {
|
|
331
|
+
return (
|
|
332
|
+
data.includes(":2u") ||
|
|
333
|
+
data.includes(":2~") ||
|
|
334
|
+
data.includes(":2A") ||
|
|
335
|
+
data.includes(":2B") ||
|
|
336
|
+
data.includes(":2C") ||
|
|
337
|
+
data.includes(":2D") ||
|
|
338
|
+
data.includes(":2H") ||
|
|
339
|
+
data.includes(":2F")
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function parseEventType(eventTypeStr: string | undefined): KeyEventType {
|
|
344
|
+
if (!eventTypeStr) return "press";
|
|
345
|
+
const eventType = parseInt(eventTypeStr, 10);
|
|
346
|
+
if (eventType === 2) return "repeat";
|
|
347
|
+
if (eventType === 3) return "release";
|
|
348
|
+
return "press";
|
|
277
349
|
}
|
|
278
350
|
|
|
279
351
|
function parseKittySequence(data: string): ParsedKittySequence | null {
|
|
280
|
-
// CSI u format: \x1b[<num>u or \x1b[<num>;<mod>u
|
|
281
|
-
const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?u$/);
|
|
352
|
+
// CSI u format: \x1b[<num>u or \x1b[<num>;<mod>u or \x1b[<num>;<mod>:<event>u
|
|
353
|
+
const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?u$/);
|
|
282
354
|
if (csiUMatch) {
|
|
283
355
|
const codepoint = parseInt(csiUMatch[1]!, 10);
|
|
284
356
|
const modValue = csiUMatch[2] ? parseInt(csiUMatch[2], 10) : 1;
|
|
285
|
-
|
|
357
|
+
const eventType = parseEventType(csiUMatch[3]);
|
|
358
|
+
return { codepoint, modifier: modValue - 1, eventType };
|
|
286
359
|
}
|
|
287
360
|
|
|
288
|
-
// Arrow keys with modifier: \x1b[1;<mod>A/B/C/D
|
|
289
|
-
const arrowMatch = data.match(/^\x1b\[1;(\d+)([ABCD])$/);
|
|
361
|
+
// Arrow keys with modifier: \x1b[1;<mod>A/B/C/D or \x1b[1;<mod>:<event>A/B/C/D
|
|
362
|
+
const arrowMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/);
|
|
290
363
|
if (arrowMatch) {
|
|
291
364
|
const modValue = parseInt(arrowMatch[1]!, 10);
|
|
365
|
+
const eventType = parseEventType(arrowMatch[2]);
|
|
292
366
|
const arrowCodes: Record<string, number> = { A: -1, B: -2, C: -3, D: -4 };
|
|
293
|
-
return { codepoint: arrowCodes[arrowMatch[
|
|
367
|
+
return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType };
|
|
294
368
|
}
|
|
295
369
|
|
|
296
|
-
// Functional keys: \x1b[<num>~ or \x1b[<num>;<mod>~
|
|
297
|
-
const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?~$/);
|
|
370
|
+
// Functional keys: \x1b[<num>~ or \x1b[<num>;<mod>~ or \x1b[<num>;<mod>:<event>~
|
|
371
|
+
const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/);
|
|
298
372
|
if (funcMatch) {
|
|
299
373
|
const keyNum = parseInt(funcMatch[1]!, 10);
|
|
300
374
|
const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1;
|
|
375
|
+
const eventType = parseEventType(funcMatch[3]);
|
|
301
376
|
const funcCodes: Record<number, number> = {
|
|
302
377
|
2: FUNCTIONAL_CODEPOINTS.insert,
|
|
303
378
|
3: FUNCTIONAL_CODEPOINTS.delete,
|
|
@@ -308,16 +383,17 @@ function parseKittySequence(data: string): ParsedKittySequence | null {
|
|
|
308
383
|
};
|
|
309
384
|
const codepoint = funcCodes[keyNum];
|
|
310
385
|
if (codepoint !== undefined) {
|
|
311
|
-
return { codepoint, modifier: modValue - 1 };
|
|
386
|
+
return { codepoint, modifier: modValue - 1, eventType };
|
|
312
387
|
}
|
|
313
388
|
}
|
|
314
389
|
|
|
315
|
-
// Home/End with modifier: \x1b[1;<mod>H/F
|
|
316
|
-
const homeEndMatch = data.match(/^\x1b\[1;(\d+)([HF])$/);
|
|
390
|
+
// Home/End with modifier: \x1b[1;<mod>H/F or \x1b[1;<mod>:<event>H/F
|
|
391
|
+
const homeEndMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/);
|
|
317
392
|
if (homeEndMatch) {
|
|
318
393
|
const modValue = parseInt(homeEndMatch[1]!, 10);
|
|
319
|
-
const
|
|
320
|
-
|
|
394
|
+
const eventType = parseEventType(homeEndMatch[2]);
|
|
395
|
+
const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end;
|
|
396
|
+
return { codepoint, modifier: modValue - 1, eventType };
|
|
321
397
|
}
|
|
322
398
|
|
|
323
399
|
return null;
|
|
@@ -402,17 +478,28 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
|
|
402
478
|
case "enter":
|
|
403
479
|
case "return":
|
|
404
480
|
if (shift && !ctrl && !alt) {
|
|
405
|
-
|
|
406
|
-
data === "\x1b\r" || // Legacy: some terminals send ESC+CR for shift+enter
|
|
481
|
+
if (
|
|
407
482
|
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) ||
|
|
408
483
|
matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift)
|
|
409
|
-
)
|
|
484
|
+
) {
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
if (kittyProtocolActive) {
|
|
488
|
+
return data === "\x1b\r" || data === "\n";
|
|
489
|
+
}
|
|
490
|
+
return false;
|
|
410
491
|
}
|
|
411
492
|
if (alt && !ctrl && !shift) {
|
|
412
|
-
|
|
493
|
+
if (
|
|
413
494
|
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) ||
|
|
414
495
|
matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt)
|
|
415
|
-
)
|
|
496
|
+
) {
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
if (!kittyProtocolActive) {
|
|
500
|
+
return data === "\x1b\r";
|
|
501
|
+
}
|
|
502
|
+
return false;
|
|
416
503
|
}
|
|
417
504
|
if (modifier === 0) {
|
|
418
505
|
return (
|
|
@@ -534,7 +621,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
|
|
534
621
|
return matchesKittySequence(data, codepoint, modifier);
|
|
535
622
|
}
|
|
536
623
|
|
|
537
|
-
return data === key;
|
|
624
|
+
return data === key || matchesKittySequence(data, codepoint, 0);
|
|
538
625
|
}
|
|
539
626
|
|
|
540
627
|
return false;
|
|
@@ -577,14 +664,22 @@ export function parseKey(data: string): string | undefined {
|
|
|
577
664
|
}
|
|
578
665
|
}
|
|
579
666
|
|
|
580
|
-
//
|
|
667
|
+
// Mode-aware legacy sequences
|
|
668
|
+
// When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings:
|
|
669
|
+
// - \x1b\r = shift+enter (Kitty mapping), not alt+enter
|
|
670
|
+
// - \n = shift+enter (Ghostty mapping)
|
|
671
|
+
if (kittyProtocolActive) {
|
|
672
|
+
if (data === "\x1b\r" || data === "\n") return "shift+enter";
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences)
|
|
581
676
|
if (data === "\x1b") return "escape";
|
|
582
677
|
if (data === "\t") return "tab";
|
|
583
678
|
if (data === "\r" || data === "\x1bOM") return "enter";
|
|
584
679
|
if (data === " ") return "space";
|
|
585
680
|
if (data === "\x7f" || data === "\x08") return "backspace";
|
|
586
681
|
if (data === "\x1b[Z") return "shift+tab";
|
|
587
|
-
if (data === "\x1b\r") return "
|
|
682
|
+
if (!kittyProtocolActive && data === "\x1b\r") return "alt+enter";
|
|
588
683
|
if (data === "\x1b\x7f") return "alt+backspace";
|
|
589
684
|
if (data === "\x1b[A") return "up";
|
|
590
685
|
if (data === "\x1b[B") return "down";
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StdinBuffer buffers input and emits complete sequences.
|
|
3
|
+
*
|
|
4
|
+
* This is necessary because stdin data events can arrive in partial chunks,
|
|
5
|
+
* especially for escape sequences like mouse events. Without buffering,
|
|
6
|
+
* partial sequences can be misinterpreted as regular keypresses.
|
|
7
|
+
*
|
|
8
|
+
* For example, the mouse SGR sequence `\x1b[<35;20;5m` might arrive as:
|
|
9
|
+
* - Event 1: `\x1b`
|
|
10
|
+
* - Event 2: `[<35`
|
|
11
|
+
* - Event 3: `;20;5m`
|
|
12
|
+
*
|
|
13
|
+
* The buffer accumulates these until a complete sequence is detected.
|
|
14
|
+
* Call the `process()` method to feed input data.
|
|
15
|
+
*
|
|
16
|
+
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
|
|
17
|
+
* MIT License - Copyright (c) 2025 opentui
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { EventEmitter } from "node:events";
|
|
21
|
+
|
|
22
|
+
const ESC = "\x1b";
|
|
23
|
+
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
24
|
+
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if a string is a complete escape sequence or needs more data
|
|
28
|
+
*/
|
|
29
|
+
function isCompleteSequence(data: string): "complete" | "incomplete" | "not-escape" {
|
|
30
|
+
if (!data.startsWith(ESC)) {
|
|
31
|
+
return "not-escape";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (data.length === 1) {
|
|
35
|
+
return "incomplete";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const afterEsc = data.slice(1);
|
|
39
|
+
|
|
40
|
+
// CSI sequences: ESC [
|
|
41
|
+
if (afterEsc.startsWith("[")) {
|
|
42
|
+
// Check for old-style mouse sequence: ESC[M + 3 bytes
|
|
43
|
+
if (afterEsc.startsWith("[M")) {
|
|
44
|
+
// Old-style mouse needs ESC[M + 3 bytes = 6 total
|
|
45
|
+
return data.length >= 6 ? "complete" : "incomplete";
|
|
46
|
+
}
|
|
47
|
+
return isCompleteCsiSequence(data);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// OSC sequences: ESC ]
|
|
51
|
+
if (afterEsc.startsWith("]")) {
|
|
52
|
+
return isCompleteOscSequence(data);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// DCS sequences: ESC P ... ESC \ (includes XTVersion responses)
|
|
56
|
+
if (afterEsc.startsWith("P")) {
|
|
57
|
+
return isCompleteDcsSequence(data);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses)
|
|
61
|
+
if (afterEsc.startsWith("_")) {
|
|
62
|
+
return isCompleteApcSequence(data);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// SS3 sequences: ESC O
|
|
66
|
+
if (afterEsc.startsWith("O")) {
|
|
67
|
+
// ESC O followed by a single character
|
|
68
|
+
return afterEsc.length >= 2 ? "complete" : "incomplete";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Meta key sequences: ESC followed by a single character
|
|
72
|
+
if (afterEsc.length === 1) {
|
|
73
|
+
return "complete";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Unknown escape sequence - treat as complete
|
|
77
|
+
return "complete";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if CSI sequence is complete
|
|
82
|
+
* CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E)
|
|
83
|
+
*/
|
|
84
|
+
function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
|
|
85
|
+
if (!data.startsWith(`${ESC}[`)) {
|
|
86
|
+
return "complete";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Need at least ESC [ and one more character
|
|
90
|
+
if (data.length < 3) {
|
|
91
|
+
return "incomplete";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const payload = data.slice(2);
|
|
95
|
+
|
|
96
|
+
// CSI sequences end with a byte in the range 0x40-0x7E (@-~)
|
|
97
|
+
// This includes all letters and several special characters
|
|
98
|
+
const lastChar = payload[payload.length - 1];
|
|
99
|
+
const lastCharCode = lastChar.charCodeAt(0);
|
|
100
|
+
|
|
101
|
+
if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) {
|
|
102
|
+
// Special handling for SGR mouse sequences
|
|
103
|
+
// Format: ESC[<B;X;Ym or ESC[<B;X;YM
|
|
104
|
+
if (payload.startsWith("<")) {
|
|
105
|
+
// Must have format: <digits;digits;digits[Mm]
|
|
106
|
+
const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
|
|
107
|
+
if (mouseMatch) {
|
|
108
|
+
return "complete";
|
|
109
|
+
}
|
|
110
|
+
// If it ends with M or m but doesn't match the pattern, still incomplete
|
|
111
|
+
if (lastChar === "M" || lastChar === "m") {
|
|
112
|
+
// Check if we have the right structure
|
|
113
|
+
const parts = payload.slice(1, -1).split(";");
|
|
114
|
+
if (parts.length === 3 && parts.every((p) => /^\d+$/.test(p))) {
|
|
115
|
+
return "complete";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return "incomplete";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return "complete";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return "incomplete";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if OSC sequence is complete
|
|
130
|
+
* OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL)
|
|
131
|
+
*/
|
|
132
|
+
function isCompleteOscSequence(data: string): "complete" | "incomplete" {
|
|
133
|
+
if (!data.startsWith(`${ESC}]`)) {
|
|
134
|
+
return "complete";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// OSC sequences end with ST (ESC \) or BEL (\x07)
|
|
138
|
+
if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) {
|
|
139
|
+
return "complete";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return "incomplete";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check if DCS (Device Control String) sequence is complete
|
|
147
|
+
* DCS sequences: ESC P ... ST (where ST is ESC \)
|
|
148
|
+
* Used for XTVersion responses like ESC P >| ... ESC \
|
|
149
|
+
*/
|
|
150
|
+
function isCompleteDcsSequence(data: string): "complete" | "incomplete" {
|
|
151
|
+
if (!data.startsWith(`${ESC}P`)) {
|
|
152
|
+
return "complete";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// DCS sequences end with ST (ESC \)
|
|
156
|
+
if (data.endsWith(`${ESC}\\`)) {
|
|
157
|
+
return "complete";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return "incomplete";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Check if APC (Application Program Command) sequence is complete
|
|
165
|
+
* APC sequences: ESC _ ... ST (where ST is ESC \)
|
|
166
|
+
* Used for Kitty graphics responses like ESC _ G ... ESC \
|
|
167
|
+
*/
|
|
168
|
+
function isCompleteApcSequence(data: string): "complete" | "incomplete" {
|
|
169
|
+
if (!data.startsWith(`${ESC}_`)) {
|
|
170
|
+
return "complete";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// APC sequences end with ST (ESC \)
|
|
174
|
+
if (data.endsWith(`${ESC}\\`)) {
|
|
175
|
+
return "complete";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return "incomplete";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Split accumulated buffer into complete sequences
|
|
183
|
+
*/
|
|
184
|
+
function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
|
|
185
|
+
const sequences: string[] = [];
|
|
186
|
+
let pos = 0;
|
|
187
|
+
|
|
188
|
+
while (pos < buffer.length) {
|
|
189
|
+
const remaining = buffer.slice(pos);
|
|
190
|
+
|
|
191
|
+
// Try to extract a sequence starting at this position
|
|
192
|
+
if (remaining.startsWith(ESC)) {
|
|
193
|
+
// Find the end of this escape sequence
|
|
194
|
+
let seqEnd = 1;
|
|
195
|
+
while (seqEnd <= remaining.length) {
|
|
196
|
+
const candidate = remaining.slice(0, seqEnd);
|
|
197
|
+
const status = isCompleteSequence(candidate);
|
|
198
|
+
|
|
199
|
+
if (status === "complete") {
|
|
200
|
+
sequences.push(candidate);
|
|
201
|
+
pos += seqEnd;
|
|
202
|
+
break;
|
|
203
|
+
} else if (status === "incomplete") {
|
|
204
|
+
seqEnd++;
|
|
205
|
+
} else {
|
|
206
|
+
// Should not happen when starting with ESC
|
|
207
|
+
sequences.push(candidate);
|
|
208
|
+
pos += seqEnd;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (seqEnd > remaining.length) {
|
|
214
|
+
return { sequences, remainder: remaining };
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
// Not an escape sequence - take a single character
|
|
218
|
+
sequences.push(remaining[0]!);
|
|
219
|
+
pos++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { sequences, remainder: "" };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type StdinBufferOptions = {
|
|
227
|
+
/**
|
|
228
|
+
* Maximum time to wait for sequence completion (default: 10ms)
|
|
229
|
+
* After this time, the buffer is flushed even if incomplete
|
|
230
|
+
*/
|
|
231
|
+
timeout?: number;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export type StdinBufferEventMap = {
|
|
235
|
+
data: [string];
|
|
236
|
+
paste: [string];
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Buffers stdin input and emits complete sequences via the 'data' event.
|
|
241
|
+
* Handles partial escape sequences that arrive across multiple chunks.
|
|
242
|
+
*/
|
|
243
|
+
export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
244
|
+
private buffer: string = "";
|
|
245
|
+
private timeout: ReturnType<typeof setTimeout> | null = null;
|
|
246
|
+
private readonly timeoutMs: number;
|
|
247
|
+
private pasteMode: boolean = false;
|
|
248
|
+
private pasteBuffer: string = "";
|
|
249
|
+
|
|
250
|
+
constructor(options: StdinBufferOptions = {}) {
|
|
251
|
+
super();
|
|
252
|
+
this.timeoutMs = options.timeout ?? 10;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
public process(data: string | Buffer): void {
|
|
256
|
+
// Clear any pending timeout
|
|
257
|
+
if (this.timeout) {
|
|
258
|
+
clearTimeout(this.timeout);
|
|
259
|
+
this.timeout = null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Handle high-byte conversion (for compatibility with parseKeypress)
|
|
263
|
+
// If buffer has single byte > 127, convert to ESC + (byte - 128)
|
|
264
|
+
let str: string;
|
|
265
|
+
if (Buffer.isBuffer(data)) {
|
|
266
|
+
if (data.length === 1 && data[0]! > 127) {
|
|
267
|
+
const byte = data[0]! - 128;
|
|
268
|
+
str = `\x1b${String.fromCharCode(byte)}`;
|
|
269
|
+
} else {
|
|
270
|
+
str = data.toString();
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
str = data;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (str.length === 0 && this.buffer.length === 0) {
|
|
277
|
+
this.emit("data", "");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
this.buffer += str;
|
|
282
|
+
|
|
283
|
+
if (this.pasteMode) {
|
|
284
|
+
this.pasteBuffer += this.buffer;
|
|
285
|
+
this.buffer = "";
|
|
286
|
+
|
|
287
|
+
const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
|
|
288
|
+
if (endIndex !== -1) {
|
|
289
|
+
const pastedContent = this.pasteBuffer.slice(0, endIndex);
|
|
290
|
+
const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
|
|
291
|
+
|
|
292
|
+
this.pasteMode = false;
|
|
293
|
+
this.pasteBuffer = "";
|
|
294
|
+
|
|
295
|
+
this.emit("paste", pastedContent);
|
|
296
|
+
|
|
297
|
+
if (remaining.length > 0) {
|
|
298
|
+
this.process(remaining);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START);
|
|
305
|
+
if (startIndex !== -1) {
|
|
306
|
+
if (startIndex > 0) {
|
|
307
|
+
const beforePaste = this.buffer.slice(0, startIndex);
|
|
308
|
+
const result = extractCompleteSequences(beforePaste);
|
|
309
|
+
for (const sequence of result.sequences) {
|
|
310
|
+
this.emit("data", sequence);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length);
|
|
315
|
+
this.pasteMode = true;
|
|
316
|
+
this.pasteBuffer = this.buffer;
|
|
317
|
+
this.buffer = "";
|
|
318
|
+
|
|
319
|
+
const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
|
|
320
|
+
if (endIndex !== -1) {
|
|
321
|
+
const pastedContent = this.pasteBuffer.slice(0, endIndex);
|
|
322
|
+
const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
|
|
323
|
+
|
|
324
|
+
this.pasteMode = false;
|
|
325
|
+
this.pasteBuffer = "";
|
|
326
|
+
|
|
327
|
+
this.emit("paste", pastedContent);
|
|
328
|
+
|
|
329
|
+
if (remaining.length > 0) {
|
|
330
|
+
this.process(remaining);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const result = extractCompleteSequences(this.buffer);
|
|
337
|
+
this.buffer = result.remainder;
|
|
338
|
+
|
|
339
|
+
for (const sequence of result.sequences) {
|
|
340
|
+
this.emit("data", sequence);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (this.buffer.length > 0) {
|
|
344
|
+
this.timeout = setTimeout(() => {
|
|
345
|
+
const flushed = this.flush();
|
|
346
|
+
|
|
347
|
+
for (const sequence of flushed) {
|
|
348
|
+
this.emit("data", sequence);
|
|
349
|
+
}
|
|
350
|
+
}, this.timeoutMs);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
flush(): string[] {
|
|
355
|
+
if (this.timeout) {
|
|
356
|
+
clearTimeout(this.timeout);
|
|
357
|
+
this.timeout = null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (this.buffer.length === 0) {
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const sequences = [this.buffer];
|
|
365
|
+
this.buffer = "";
|
|
366
|
+
return sequences;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
clear(): void {
|
|
370
|
+
if (this.timeout) {
|
|
371
|
+
clearTimeout(this.timeout);
|
|
372
|
+
this.timeout = null;
|
|
373
|
+
}
|
|
374
|
+
this.buffer = "";
|
|
375
|
+
this.pasteMode = false;
|
|
376
|
+
this.pasteBuffer = "";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
getBuffer(): string {
|
|
380
|
+
return this.buffer;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
destroy(): void {
|
|
384
|
+
this.clear();
|
|
385
|
+
}
|
|
386
|
+
}
|
package/src/terminal.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { setKittyProtocolActive } from "./keys";
|
|
2
|
+
import { StdinBuffer } from "./stdin-buffer";
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Minimal terminal interface for TUI
|
|
3
6
|
*/
|
|
@@ -40,6 +43,9 @@ export interface Terminal {
|
|
|
40
43
|
get columns(): number;
|
|
41
44
|
get rows(): number;
|
|
42
45
|
|
|
46
|
+
// Whether Kitty keyboard protocol is active
|
|
47
|
+
get kittyProtocolActive(): boolean;
|
|
48
|
+
|
|
43
49
|
// Cursor positioning (relative to current position)
|
|
44
50
|
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
|
|
45
51
|
|
|
@@ -63,6 +69,13 @@ export class ProcessTerminal implements Terminal {
|
|
|
63
69
|
private wasRaw = false;
|
|
64
70
|
private inputHandler?: (data: string) => void;
|
|
65
71
|
private resizeHandler?: () => void;
|
|
72
|
+
private _kittyProtocolActive = false;
|
|
73
|
+
private stdinBuffer?: StdinBuffer;
|
|
74
|
+
private stdinDataHandler?: (data: string) => void;
|
|
75
|
+
|
|
76
|
+
get kittyProtocolActive(): boolean {
|
|
77
|
+
return this._kittyProtocolActive;
|
|
78
|
+
}
|
|
66
79
|
|
|
67
80
|
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
68
81
|
this.inputHandler = onInput;
|
|
@@ -82,15 +95,125 @@ export class ProcessTerminal implements Terminal {
|
|
|
82
95
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
83
96
|
process.stdout.write("\x1b[?2004h");
|
|
84
97
|
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
98
|
+
// Set up resize handler immediately
|
|
99
|
+
process.stdout.on("resize", this.resizeHandler);
|
|
100
|
+
|
|
101
|
+
// Query and enable Kitty keyboard protocol
|
|
102
|
+
// The query handler intercepts input temporarily, then installs the user's handler
|
|
88
103
|
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
89
|
-
|
|
104
|
+
this.queryAndEnableKittyProtocol();
|
|
105
|
+
}
|
|
90
106
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Set up StdinBuffer to split batched input into individual sequences.
|
|
109
|
+
* This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
|
|
110
|
+
* Note: Does NOT register the stdin handler - that's done after the Kitty protocol query.
|
|
111
|
+
*/
|
|
112
|
+
private setupStdinBuffer(): void {
|
|
113
|
+
this.stdinBuffer = new StdinBuffer({ timeout: 10 });
|
|
114
|
+
|
|
115
|
+
// Forward individual sequences to the input handler
|
|
116
|
+
this.stdinBuffer.on("data", (sequence: string) => {
|
|
117
|
+
if (this.inputHandler) {
|
|
118
|
+
this.inputHandler(sequence);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Re-wrap paste content with bracketed paste markers for existing editor handling
|
|
123
|
+
this.stdinBuffer.on("paste", (content: string) => {
|
|
124
|
+
if (this.inputHandler) {
|
|
125
|
+
this.inputHandler(`\x1b[200~${content}\x1b[201~`);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Handler that pipes stdin data through the buffer
|
|
130
|
+
// Registration happens after Kitty protocol query completes
|
|
131
|
+
this.stdinDataHandler = (data: string) => {
|
|
132
|
+
this.stdinBuffer!.process(data);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Query terminal for Kitty keyboard protocol support and enable if available.
|
|
138
|
+
*
|
|
139
|
+
* Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
|
|
140
|
+
* it supports the protocol and we enable it with CSI > 1 u.
|
|
141
|
+
*
|
|
142
|
+
* Non-supporting terminals won't respond, so we use a timeout.
|
|
143
|
+
*/
|
|
144
|
+
private queryAndEnableKittyProtocol(): void {
|
|
145
|
+
const QUERY_TIMEOUT_MS = 100;
|
|
146
|
+
let resolved = false;
|
|
147
|
+
let buffer = "";
|
|
148
|
+
|
|
149
|
+
// Kitty protocol response pattern: \x1b[?<flags>u
|
|
150
|
+
const kittyResponsePattern = /\x1b\[\?(\d+)u/;
|
|
151
|
+
|
|
152
|
+
const queryHandler = (data: string) => {
|
|
153
|
+
if (resolved) {
|
|
154
|
+
// Query phase done, forward to StdinBuffer
|
|
155
|
+
if (this.stdinBuffer) {
|
|
156
|
+
this.stdinBuffer.process(data);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
buffer += data;
|
|
162
|
+
|
|
163
|
+
// Check if we have a Kitty protocol response
|
|
164
|
+
const match = buffer.match(kittyResponsePattern);
|
|
165
|
+
if (match) {
|
|
166
|
+
resolved = true;
|
|
167
|
+
this._kittyProtocolActive = true;
|
|
168
|
+
setKittyProtocolActive(true);
|
|
169
|
+
|
|
170
|
+
// Enable Kitty keyboard protocol (push flags)
|
|
171
|
+
// Flag 1 = disambiguate escape codes
|
|
172
|
+
// Flag 2 = report event types (press/repeat/release)
|
|
173
|
+
process.stdout.write("\x1b[>3u");
|
|
174
|
+
|
|
175
|
+
// Remove the response from buffer, forward any remaining input through StdinBuffer
|
|
176
|
+
const remaining = buffer.replace(kittyResponsePattern, "");
|
|
177
|
+
if (remaining && this.stdinBuffer) {
|
|
178
|
+
this.stdinBuffer.process(remaining);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Replace query handler with StdinBuffer handler
|
|
182
|
+
process.stdin.removeListener("data", queryHandler);
|
|
183
|
+
if (this.stdinDataHandler) {
|
|
184
|
+
process.stdin.on("data", this.stdinDataHandler);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Set up StdinBuffer before query (it will receive input after query completes)
|
|
190
|
+
this.setupStdinBuffer();
|
|
191
|
+
|
|
192
|
+
// Temporarily intercept input for the query (before StdinBuffer)
|
|
193
|
+
process.stdin.on("data", queryHandler);
|
|
194
|
+
|
|
195
|
+
// Send query
|
|
196
|
+
process.stdout.write("\x1b[?u");
|
|
197
|
+
|
|
198
|
+
// Timeout: if no response, terminal doesn't support Kitty protocol
|
|
199
|
+
setTimeout(() => {
|
|
200
|
+
if (!resolved) {
|
|
201
|
+
resolved = true;
|
|
202
|
+
this._kittyProtocolActive = false;
|
|
203
|
+
setKittyProtocolActive(false);
|
|
204
|
+
|
|
205
|
+
// Forward any buffered input that wasn't a Kitty response through StdinBuffer
|
|
206
|
+
if (buffer && this.stdinBuffer) {
|
|
207
|
+
this.stdinBuffer.process(buffer);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Replace query handler with StdinBuffer handler
|
|
211
|
+
process.stdin.removeListener("data", queryHandler);
|
|
212
|
+
if (this.stdinDataHandler) {
|
|
213
|
+
process.stdin.on("data", this.stdinDataHandler);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}, QUERY_TIMEOUT_MS);
|
|
94
217
|
}
|
|
95
218
|
|
|
96
219
|
stop(): void {
|
|
@@ -102,14 +225,25 @@ export class ProcessTerminal implements Terminal {
|
|
|
102
225
|
// Disable bracketed paste mode
|
|
103
226
|
process.stdout.write("\x1b[?2004l");
|
|
104
227
|
|
|
105
|
-
// Disable Kitty keyboard protocol (pop the flags we pushed)
|
|
106
|
-
|
|
228
|
+
// Disable Kitty keyboard protocol (pop the flags we pushed) - only if we enabled it
|
|
229
|
+
if (this._kittyProtocolActive) {
|
|
230
|
+
process.stdout.write("\x1b[<u");
|
|
231
|
+
this._kittyProtocolActive = false;
|
|
232
|
+
setKittyProtocolActive(false);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Clean up StdinBuffer
|
|
236
|
+
if (this.stdinBuffer) {
|
|
237
|
+
this.stdinBuffer.destroy();
|
|
238
|
+
this.stdinBuffer = undefined;
|
|
239
|
+
}
|
|
107
240
|
|
|
108
241
|
// Remove event handlers
|
|
109
|
-
if (this.
|
|
110
|
-
process.stdin.removeListener("data", this.
|
|
111
|
-
this.
|
|
242
|
+
if (this.stdinDataHandler) {
|
|
243
|
+
process.stdin.removeListener("data", this.stdinDataHandler);
|
|
244
|
+
this.stdinDataHandler = undefined;
|
|
112
245
|
}
|
|
246
|
+
this.inputHandler = undefined;
|
|
113
247
|
if (this.resizeHandler) {
|
|
114
248
|
process.stdout.removeListener("resize", this.resizeHandler);
|
|
115
249
|
this.resizeHandler = undefined;
|
package/src/tui.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
-
import { isShiftCtrlD } from "./keys";
|
|
8
|
+
import { isKeyRelease, isShiftCtrlD } from "./keys";
|
|
9
9
|
import type { Terminal } from "./terminal";
|
|
10
10
|
import { getCapabilities, setCellDimensions } from "./terminal-image";
|
|
11
|
-
import { visibleWidth } from "./utils";
|
|
11
|
+
import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Component interface - all components must implement this
|
|
@@ -26,6 +26,12 @@ export interface Component {
|
|
|
26
26
|
*/
|
|
27
27
|
handleInput?(data: string): void;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* If true, component receives key release events (Kitty protocol).
|
|
31
|
+
* Default is false - release events are filtered out.
|
|
32
|
+
*/
|
|
33
|
+
wantsKeyRelease?: boolean;
|
|
34
|
+
|
|
29
35
|
/**
|
|
30
36
|
* Optional cursor position within the rendered output (0-based row/col).
|
|
31
37
|
*/
|
|
@@ -105,6 +111,11 @@ export class TUI extends Container {
|
|
|
105
111
|
private previousCursor: { row: number; col: number } | null = null;
|
|
106
112
|
private inputBuffer = ""; // Buffer for parsing terminal responses
|
|
107
113
|
private cellSizeQueryPending = false;
|
|
114
|
+
private overlayStack: {
|
|
115
|
+
component: Component;
|
|
116
|
+
options?: { row?: number; col?: number; width?: number };
|
|
117
|
+
preFocus: Component | null;
|
|
118
|
+
}[] = [];
|
|
108
119
|
private inputQueue: string[] = []; // Queue input during cell size query to avoid interleaving
|
|
109
120
|
|
|
110
121
|
constructor(terminal: Terminal) {
|
|
@@ -116,6 +127,32 @@ export class TUI extends Container {
|
|
|
116
127
|
this.focusedComponent = component;
|
|
117
128
|
}
|
|
118
129
|
|
|
130
|
+
/** Show an overlay component centered (or at specified position). */
|
|
131
|
+
showOverlay(component: Component, options?: { row?: number; col?: number; width?: number }): void {
|
|
132
|
+
this.overlayStack.push({ component, options, preFocus: this.focusedComponent });
|
|
133
|
+
this.setFocus(component);
|
|
134
|
+
this.terminal.hideCursor();
|
|
135
|
+
this.requestRender();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Hide the topmost overlay and restore previous focus. */
|
|
139
|
+
hideOverlay(): void {
|
|
140
|
+
const overlay = this.overlayStack.pop();
|
|
141
|
+
if (!overlay) return;
|
|
142
|
+
this.setFocus(overlay.preFocus);
|
|
143
|
+
if (this.overlayStack.length === 0) this.terminal.hideCursor();
|
|
144
|
+
this.requestRender();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
hasOverlay(): boolean {
|
|
148
|
+
return this.overlayStack.length > 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
override invalidate(): void {
|
|
152
|
+
super.invalidate();
|
|
153
|
+
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
|
154
|
+
}
|
|
155
|
+
|
|
119
156
|
start(): void {
|
|
120
157
|
this.terminal.start(
|
|
121
158
|
(data) => this.handleInput(data),
|
|
@@ -230,6 +267,9 @@ export class TUI extends Container {
|
|
|
230
267
|
// Pass input to focused component (including Ctrl+C)
|
|
231
268
|
// The focused component can decide how to handle Ctrl+C
|
|
232
269
|
if (this.focusedComponent?.handleInput) {
|
|
270
|
+
if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
233
273
|
this.focusedComponent.handleInput(data);
|
|
234
274
|
this.requestRender();
|
|
235
275
|
}
|
|
@@ -282,6 +322,77 @@ export class TUI extends Container {
|
|
|
282
322
|
return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
|
|
283
323
|
}
|
|
284
324
|
|
|
325
|
+
/** Composite all overlays into content lines (in stack order, later = on top). */
|
|
326
|
+
private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
|
|
327
|
+
if (this.overlayStack.length === 0) return lines;
|
|
328
|
+
const result = [...lines];
|
|
329
|
+
const viewportStart = Math.max(0, result.length - termHeight);
|
|
330
|
+
|
|
331
|
+
for (const { component, options } of this.overlayStack) {
|
|
332
|
+
const w =
|
|
333
|
+
options?.width !== undefined
|
|
334
|
+
? Math.max(1, Math.min(options.width, termWidth - 4))
|
|
335
|
+
: Math.max(1, Math.min(80, termWidth - 4));
|
|
336
|
+
const overlayLines = component.render(w);
|
|
337
|
+
const h = overlayLines.length;
|
|
338
|
+
|
|
339
|
+
const row = Math.max(0, Math.min(options?.row ?? Math.floor((termHeight - h) / 2), termHeight - h));
|
|
340
|
+
const col = Math.max(0, Math.min(options?.col ?? Math.floor((termWidth - w) / 2), termWidth - w));
|
|
341
|
+
|
|
342
|
+
for (let i = 0; i < h; i++) {
|
|
343
|
+
const idx = viewportStart + row + i;
|
|
344
|
+
if (idx >= 0 && idx < result.length) {
|
|
345
|
+
result[idx] = this.compositeLineAt(result[idx], overlayLines[i], col, w, termWidth);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
|
|
353
|
+
|
|
354
|
+
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
|
|
355
|
+
private compositeLineAt(
|
|
356
|
+
baseLine: string,
|
|
357
|
+
overlayLine: string,
|
|
358
|
+
startCol: number,
|
|
359
|
+
overlayWidth: number,
|
|
360
|
+
totalWidth: number,
|
|
361
|
+
): string {
|
|
362
|
+
if (this.containsImage(baseLine)) return baseLine;
|
|
363
|
+
|
|
364
|
+
// Single pass through baseLine extracts both before and after segments
|
|
365
|
+
const afterStart = startCol + overlayWidth;
|
|
366
|
+
const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
|
|
367
|
+
|
|
368
|
+
// Extract overlay with width tracking
|
|
369
|
+
const overlay = sliceWithWidth(overlayLine, 0, overlayWidth);
|
|
370
|
+
|
|
371
|
+
// Pad segments to target widths
|
|
372
|
+
const beforePad = Math.max(0, startCol - base.beforeWidth);
|
|
373
|
+
const overlayPad = Math.max(0, overlayWidth - overlay.width);
|
|
374
|
+
const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
|
|
375
|
+
const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
|
|
376
|
+
const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
|
|
377
|
+
const afterPad = Math.max(0, afterTarget - base.afterWidth);
|
|
378
|
+
|
|
379
|
+
// Compose result - widths are tracked so no final visibleWidth check needed
|
|
380
|
+
const r = TUI.SEGMENT_RESET;
|
|
381
|
+
const result =
|
|
382
|
+
base.before +
|
|
383
|
+
" ".repeat(beforePad) +
|
|
384
|
+
r +
|
|
385
|
+
overlay.text +
|
|
386
|
+
" ".repeat(overlayPad) +
|
|
387
|
+
r +
|
|
388
|
+
base.after +
|
|
389
|
+
" ".repeat(afterPad);
|
|
390
|
+
|
|
391
|
+
// Only truncate if wide char at after boundary caused overflow (rare)
|
|
392
|
+
const resultWidth = actualBeforeWidth + actualOverlayWidth + Math.max(afterTarget, base.afterWidth);
|
|
393
|
+
return resultWidth <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
|
|
394
|
+
}
|
|
395
|
+
|
|
285
396
|
private doRender(): void {
|
|
286
397
|
// Capture terminal dimensions at start to ensure consistency throughout render
|
|
287
398
|
const width = this.terminal.columns;
|
|
@@ -290,7 +401,13 @@ export class TUI extends Container {
|
|
|
290
401
|
const currentCursorRow = this.cursorRow;
|
|
291
402
|
|
|
292
403
|
// Render all components to get new lines
|
|
293
|
-
|
|
404
|
+
let newLines = this.render(width);
|
|
405
|
+
|
|
406
|
+
// Composite overlays into the rendered lines (before differential compare)
|
|
407
|
+
if (this.overlayStack.length > 0) {
|
|
408
|
+
newLines = this.compositeOverlays(newLines, width, height);
|
|
409
|
+
}
|
|
410
|
+
|
|
294
411
|
const cursorInfo = this.getCursorPosition(width);
|
|
295
412
|
|
|
296
413
|
// Width changed - need full re-render
|
|
@@ -417,7 +534,19 @@ export class TUI extends Container {
|
|
|
417
534
|
} catch {
|
|
418
535
|
// Ignore - crash log is best-effort
|
|
419
536
|
}
|
|
420
|
-
|
|
537
|
+
|
|
538
|
+
// Clean up terminal state before throwing
|
|
539
|
+
this.stop();
|
|
540
|
+
|
|
541
|
+
const errorMsg = [
|
|
542
|
+
`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
|
|
543
|
+
"",
|
|
544
|
+
"This is likely caused by a custom TUI component not truncating its output.",
|
|
545
|
+
"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
|
|
546
|
+
"",
|
|
547
|
+
`Debug log written to: ${crashLogPath}`,
|
|
548
|
+
].join("\n");
|
|
549
|
+
throw new Error(errorMsg);
|
|
421
550
|
}
|
|
422
551
|
buffer += line;
|
|
423
552
|
}
|
package/src/utils.ts
CHANGED
|
@@ -135,21 +135,31 @@ export function visibleWidth(str: string): number {
|
|
|
135
135
|
/**
|
|
136
136
|
* Extract ANSI escape sequences from a string at the given position.
|
|
137
137
|
*/
|
|
138
|
-
function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
|
139
|
-
if (pos >= str.length || str[pos] !== "\x1b"
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
|
139
|
+
if (pos >= str.length || str[pos] !== "\x1b") return null;
|
|
140
|
+
|
|
141
|
+
const next = str[pos + 1];
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
j
|
|
143
|
+
// CSI sequence: ESC [ ... m/G/K/H/J
|
|
144
|
+
if (next === "[") {
|
|
145
|
+
let j = pos + 2;
|
|
146
|
+
while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++;
|
|
147
|
+
if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos };
|
|
148
|
+
return null;
|
|
146
149
|
}
|
|
147
150
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
// OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \)
|
|
152
|
+
// Used for hyperlinks (OSC 8), window titles, etc.
|
|
153
|
+
if (next === "]") {
|
|
154
|
+
let j = pos + 2;
|
|
155
|
+
while (j < str.length) {
|
|
156
|
+
if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos };
|
|
157
|
+
if (str[j] === "\x1b" && str[j + 1] === "\\") {
|
|
158
|
+
return { code: str.substring(pos, j + 2), length: j + 2 - pos };
|
|
159
|
+
}
|
|
160
|
+
j++;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
153
163
|
}
|
|
154
164
|
|
|
155
165
|
return null;
|
|
@@ -308,6 +318,11 @@ class AnsiCodeTracker {
|
|
|
308
318
|
this.bgColor = null;
|
|
309
319
|
}
|
|
310
320
|
|
|
321
|
+
/** Clear all state for reuse. */
|
|
322
|
+
clear(): void {
|
|
323
|
+
this.reset();
|
|
324
|
+
}
|
|
325
|
+
|
|
311
326
|
getActiveCodes(): string {
|
|
312
327
|
const codes: string[] = [];
|
|
313
328
|
if (this.bold) codes.push("1");
|
|
@@ -510,7 +525,7 @@ function wrapSingleLine(line: string, width: number): string[] {
|
|
|
510
525
|
const totalNeeded = currentVisibleLength + tokenVisibleLength;
|
|
511
526
|
|
|
512
527
|
if (totalNeeded > width && currentVisibleLength > 0) {
|
|
513
|
-
//
|
|
528
|
+
// Trim trailing whitespace, then add underline reset (not full reset, to preserve background)
|
|
514
529
|
let lineToWrap = currentLine.trimEnd();
|
|
515
530
|
const lineEndReset = tracker.getLineEndReset();
|
|
516
531
|
if (lineEndReset) {
|
|
@@ -539,7 +554,8 @@ function wrapSingleLine(line: string, width: number): string[] {
|
|
|
539
554
|
wrapped.push(currentLine);
|
|
540
555
|
}
|
|
541
556
|
|
|
542
|
-
|
|
557
|
+
// Trailing whitespace can cause lines to exceed the requested width
|
|
558
|
+
return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""];
|
|
543
559
|
}
|
|
544
560
|
|
|
545
561
|
const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
|
|
@@ -722,3 +738,140 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string
|
|
|
722
738
|
// Add reset code before ellipsis to prevent styling leaking into it
|
|
723
739
|
return `${result}\x1b[0m${ellipsis}`;
|
|
724
740
|
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
|
|
744
|
+
* @param strict - If true, exclude wide chars at boundary that would extend past the range
|
|
745
|
+
*/
|
|
746
|
+
export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string {
|
|
747
|
+
return sliceWithWidth(line, startCol, length, strict).text;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Like sliceByColumn but also returns the actual visible width of the result. */
|
|
751
|
+
export function sliceWithWidth(
|
|
752
|
+
line: string,
|
|
753
|
+
startCol: number,
|
|
754
|
+
length: number,
|
|
755
|
+
strict = false,
|
|
756
|
+
): { text: string; width: number } {
|
|
757
|
+
if (length <= 0) return { text: "", width: 0 };
|
|
758
|
+
const endCol = startCol + length;
|
|
759
|
+
let result = "",
|
|
760
|
+
resultWidth = 0,
|
|
761
|
+
currentCol = 0,
|
|
762
|
+
i = 0,
|
|
763
|
+
pendingAnsi = "";
|
|
764
|
+
|
|
765
|
+
while (i < line.length) {
|
|
766
|
+
const ansi = extractAnsiCode(line, i);
|
|
767
|
+
if (ansi) {
|
|
768
|
+
if (currentCol >= startCol && currentCol < endCol) result += ansi.code;
|
|
769
|
+
else if (currentCol < startCol) pendingAnsi += ansi.code;
|
|
770
|
+
i += ansi.length;
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
let textEnd = i;
|
|
775
|
+
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
|
|
776
|
+
|
|
777
|
+
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
|
|
778
|
+
const w = graphemeWidth(segment);
|
|
779
|
+
const inRange = currentCol >= startCol && currentCol < endCol;
|
|
780
|
+
const fits = !strict || currentCol + w <= endCol;
|
|
781
|
+
if (inRange && fits) {
|
|
782
|
+
if (pendingAnsi) {
|
|
783
|
+
result += pendingAnsi;
|
|
784
|
+
pendingAnsi = "";
|
|
785
|
+
}
|
|
786
|
+
result += segment;
|
|
787
|
+
resultWidth += w;
|
|
788
|
+
}
|
|
789
|
+
currentCol += w;
|
|
790
|
+
if (currentCol >= endCol) break;
|
|
791
|
+
}
|
|
792
|
+
i = textEnd;
|
|
793
|
+
if (currentCol >= endCol) break;
|
|
794
|
+
}
|
|
795
|
+
return { text: result, width: resultWidth };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Pooled tracker instance for extractSegments (avoids allocation per call)
|
|
799
|
+
const pooledStyleTracker = new AnsiCodeTracker();
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Extract "before" and "after" segments from a line in a single pass.
|
|
803
|
+
* Used for overlay compositing where we need content before and after the overlay region.
|
|
804
|
+
* Preserves styling from before the overlay that should affect content after it.
|
|
805
|
+
*/
|
|
806
|
+
export function extractSegments(
|
|
807
|
+
line: string,
|
|
808
|
+
beforeEnd: number,
|
|
809
|
+
afterStart: number,
|
|
810
|
+
afterLen: number,
|
|
811
|
+
strictAfter = false,
|
|
812
|
+
): { before: string; beforeWidth: number; after: string; afterWidth: number } {
|
|
813
|
+
let before = "",
|
|
814
|
+
beforeWidth = 0,
|
|
815
|
+
after = "",
|
|
816
|
+
afterWidth = 0;
|
|
817
|
+
let currentCol = 0,
|
|
818
|
+
i = 0;
|
|
819
|
+
let pendingAnsiBefore = "";
|
|
820
|
+
let afterStarted = false;
|
|
821
|
+
const afterEnd = afterStart + afterLen;
|
|
822
|
+
|
|
823
|
+
// Track styling state so "after" inherits styling from before the overlay
|
|
824
|
+
pooledStyleTracker.clear();
|
|
825
|
+
|
|
826
|
+
while (i < line.length) {
|
|
827
|
+
const ansi = extractAnsiCode(line, i);
|
|
828
|
+
if (ansi) {
|
|
829
|
+
// Track all SGR codes to know styling state at afterStart
|
|
830
|
+
pooledStyleTracker.process(ansi.code);
|
|
831
|
+
// Include ANSI codes in their respective segments
|
|
832
|
+
if (currentCol < beforeEnd) {
|
|
833
|
+
pendingAnsiBefore += ansi.code;
|
|
834
|
+
} else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) {
|
|
835
|
+
// Only include after we've started "after" (styling already prepended)
|
|
836
|
+
after += ansi.code;
|
|
837
|
+
}
|
|
838
|
+
i += ansi.length;
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
let textEnd = i;
|
|
843
|
+
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
|
|
844
|
+
|
|
845
|
+
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
|
|
846
|
+
const w = graphemeWidth(segment);
|
|
847
|
+
|
|
848
|
+
if (currentCol < beforeEnd) {
|
|
849
|
+
if (pendingAnsiBefore) {
|
|
850
|
+
before += pendingAnsiBefore;
|
|
851
|
+
pendingAnsiBefore = "";
|
|
852
|
+
}
|
|
853
|
+
before += segment;
|
|
854
|
+
beforeWidth += w;
|
|
855
|
+
} else if (currentCol >= afterStart && currentCol < afterEnd) {
|
|
856
|
+
const fits = !strictAfter || currentCol + w <= afterEnd;
|
|
857
|
+
if (fits) {
|
|
858
|
+
// On first "after" grapheme, prepend inherited styling from before overlay
|
|
859
|
+
if (!afterStarted) {
|
|
860
|
+
after += pooledStyleTracker.getActiveCodes();
|
|
861
|
+
afterStarted = true;
|
|
862
|
+
}
|
|
863
|
+
after += segment;
|
|
864
|
+
afterWidth += w;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
currentCol += w;
|
|
869
|
+
// Early exit: done with "before" only, or done with both segments
|
|
870
|
+
if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
|
|
871
|
+
}
|
|
872
|
+
i = textEnd;
|
|
873
|
+
if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return { before, beforeWidth, after, afterWidth };
|
|
877
|
+
}
|