@gajae-code/tui 0.12.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/types/stdin-buffer.d.ts +6 -0
- package/dist/types/terminal-capabilities.d.ts +9 -0
- package/dist/types/terminal.d.ts +16 -0
- package/package.json +3 -3
- package/src/autocomplete.ts +4 -0
- package/src/keys.ts +2 -18
- package/src/stdin-buffer.ts +99 -11
- package/src/terminal-capabilities.ts +63 -1
- package/src/terminal.ts +98 -11
- package/src/tui.ts +406 -90
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.12.2] - 2026-07-30
|
|
6
|
+
|
|
7
|
+
## [0.12.1] - 2026-07-29
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Kitty/Ghostty inline images no longer remain visually pinned when sticky or semantic viewport repaints move their anchors into application scrollback. The renderer now soft-deletes only the named placement from the old viewport, retains uploaded pixels for history replay, and keeps placement tracking aligned across unresolved-anchor and follow-live transitions.
|
|
12
|
+
|
|
5
13
|
## [0.12.0] - 2026-07-28
|
|
6
14
|
|
|
7
15
|
### Changed
|
|
@@ -13,6 +21,10 @@
|
|
|
13
21
|
|
|
14
22
|
### Fixed
|
|
15
23
|
|
|
24
|
+
- Slash-command autocomplete no longer treats the final segment of a nested filesystem path or URL as a command token, preventing accepted skill suggestions from rewriting literal paths.
|
|
25
|
+
|
|
26
|
+
- Terminal capability-probe replies no longer leak into the prompt as text (`^[]11;rgb:0000/0000/0000^G^[[?62;22;52c` appearing in the editor after a long-running foreground command). Three separate paths fed them to the input handler: replies whose pending-query counters had already been reset (`stop()`/`start()` around an editor handoff, Ctrl+Z, or a session resume) failed the `#pendingDa1Sentinels`/`#osc11Pending` gates and were forwarded; a reply split across stdin reads with a gap larger than `StdinBuffer`'s 10ms completion timeout was flushed as individual characters; and an unterminated sequence kept absorbing the following ESC. Probe replies are now consumed by shape, incomplete probe-reply prefixes are held at the stdin decoding boundary (bounded by 500ms/256 bytes, and only 150ms for a bare ESC inside a probe window), an ESC cuts the sequence in progress unless it is an OSC/DCS/APC string terminator, and an unsolicited reply is dropped by an explicit backstop. A dropped or mangled reply can no longer latch `#osc11Pending` either: a 1s watchdog and a 64-byte reassembly cap resolve the query cycle instead of swallowing keystrokes. DA1 and XTSMGRAPHICS replies stay owned by the sixel probe and are dropped in `Tui` once that probe has finished, so an orphaned device report is no longer typed into the focused component.
|
|
27
|
+
|
|
16
28
|
- `waitForRenderCommit` / generation-scoped render tokens resolve only after a successful buffer write (or fail open on stopped/unavailable terminals), enabling awaitable progress frames for interactive resume without hanging (#2914).
|
|
17
29
|
- Streaming layout contraction followed by regrowth no longer re-admits an already committed logical row into native terminal scrollback, preventing occasional duplicated assistant lines after Markdown reflow.
|
|
18
30
|
- Repeated clearing of an already-clear viewport output source is now a render-request no-op, matching identical non-null source updates.
|
|
@@ -45,6 +45,12 @@ export declare class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
45
45
|
#private;
|
|
46
46
|
constructor(options?: StdinBufferOptions);
|
|
47
47
|
process(data: string | Buffer): void;
|
|
48
|
+
/**
|
|
49
|
+
* ProcessTerminal calls this right after writing a capability probe so a lone
|
|
50
|
+
* ESC arriving inside the reply window is treated as a possible reply fragment
|
|
51
|
+
* rather than a keypress.
|
|
52
|
+
*/
|
|
53
|
+
noteProbeIssued(windowMs?: number): void;
|
|
48
54
|
flush(): string[];
|
|
49
55
|
clear(): void;
|
|
50
56
|
getBuffer(): string;
|
|
@@ -158,6 +158,15 @@ export declare function encodeKittyPlacement(options: {
|
|
|
158
158
|
columns: number;
|
|
159
159
|
rows: number;
|
|
160
160
|
}): string;
|
|
161
|
+
export interface KittyPlacementReference {
|
|
162
|
+
imageId: number;
|
|
163
|
+
placementId: number;
|
|
164
|
+
rows: number;
|
|
165
|
+
}
|
|
166
|
+
/** Extract bounded, named kitty placements from a rendered line. */
|
|
167
|
+
export declare function extractKittyPlacementReferences(line: string): KittyPlacementReference[];
|
|
168
|
+
/** Soft-delete one named kitty placement while retaining its transmitted pixels. */
|
|
169
|
+
export declare function encodeKittyPlacementDelete(reference: KittyPlacementReference): string;
|
|
161
170
|
export declare function encodeITerm2(base64Data: string, options?: {
|
|
162
171
|
width?: number | string;
|
|
163
172
|
height?: number | string;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability-probe reply shapes that only this layer solicits (OSC 11 background
|
|
3
|
+
* color, the Mode 2031 appearance DSR, and the Kitty keyboard-flags report).
|
|
4
|
+
* These are terminal-to-host replies and are NEVER legitimate user input, so a
|
|
5
|
+
* reply that arrives outside its pending-query window is dropped defensively.
|
|
6
|
+
*
|
|
7
|
+
* DA1 is deliberately absent: `Tui` issues its own DA1 request for the sixel
|
|
8
|
+
* probe and consumes that reply downstream.
|
|
9
|
+
*/
|
|
10
|
+
export declare const PROBE_REPLY_PATTERNS: ReadonlyArray<{
|
|
11
|
+
name: string;
|
|
12
|
+
issuedProbe: string;
|
|
13
|
+
pattern: RegExp;
|
|
14
|
+
}>;
|
|
15
|
+
/** True when `sequence` is one of the probe replies above. */
|
|
16
|
+
export declare function isUnsolicitedProbeReply(sequence: string): boolean;
|
|
1
17
|
/**
|
|
2
18
|
* Whether GJC may reprogram the keyboard with enhanced input protocols
|
|
3
19
|
* (the Kitty keyboard protocol and the xterm modifyOtherKeys fallback).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.12.
|
|
4
|
+
"version": "0.12.2",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"fmt": "biome format --write ."
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@gajae-code/natives": "0.12.
|
|
40
|
-
"@gajae-code/utils": "0.12.
|
|
39
|
+
"@gajae-code/natives": "0.12.2",
|
|
40
|
+
"@gajae-code/utils": "0.12.2",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
package/src/autocomplete.ts
CHANGED
|
@@ -229,6 +229,10 @@ export function extractSlashCommandTokenPrefix(text: string): string | null {
|
|
|
229
229
|
const charBeforeSlash = text[slashIndex - 1];
|
|
230
230
|
if (charBeforeSlash && NON_COMMAND_SLASH_PREFIX_PRECEDERS.has(charBeforeSlash)) return null;
|
|
231
231
|
|
|
232
|
+
let tokenStart = slashIndex;
|
|
233
|
+
while (tokenStart > 0 && !/\s/.test(text[tokenStart - 1] ?? "")) tokenStart -= 1;
|
|
234
|
+
if (text.slice(tokenStart, slashIndex).includes("/")) return null;
|
|
235
|
+
|
|
232
236
|
return token;
|
|
233
237
|
}
|
|
234
238
|
export interface AutocompleteItem {
|
package/src/keys.ts
CHANGED
|
@@ -423,7 +423,6 @@ const KITTY_MOD_SUPER = 8;
|
|
|
423
423
|
const KITTY_MOD_NUM_LOCK = 128;
|
|
424
424
|
const KITTY_LOCK_MASK = 64 + 128; // Caps Lock + Num Lock
|
|
425
425
|
const MODIFY_OTHER_KEYS_PATTERN = /^\x1b\[27;(\d+);(\d+)~$/;
|
|
426
|
-
const PSMUX_MODIFIED_ENTER_PATTERN = /^\x1b\[13;(2|6)~$/;
|
|
427
426
|
const KITTY_KEYPAD_OPERATOR_TEXT: Record<number, string> = {
|
|
428
427
|
57410: "/",
|
|
429
428
|
57411: "*",
|
|
@@ -496,21 +495,6 @@ export function parseKittySequence(data: string): ParsedKittySequence | null {
|
|
|
496
495
|
eventType: result.eventType,
|
|
497
496
|
};
|
|
498
497
|
}
|
|
499
|
-
|
|
500
|
-
function parsePsmuxModifiedEnter(data: string): string | undefined {
|
|
501
|
-
const match = data.match(PSMUX_MODIFIED_ENTER_PATTERN);
|
|
502
|
-
if (!match) return undefined;
|
|
503
|
-
return match[1] === "6" ? "shift+ctrl+enter" : "shift+enter";
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
function matchesPsmuxModifiedEnter(data: string, keyId: KeyId): boolean {
|
|
507
|
-
const parsed = parsePsmuxModifiedEnter(data);
|
|
508
|
-
if (!parsed) return false;
|
|
509
|
-
const expected = String(keyId);
|
|
510
|
-
if (parsed === expected) return true;
|
|
511
|
-
return parsed === "shift+ctrl+enter" && expected === "ctrl+shift+enter";
|
|
512
|
-
}
|
|
513
|
-
|
|
514
498
|
function hasControlChars(data: string): boolean {
|
|
515
499
|
return [...data].some(ch => {
|
|
516
500
|
const code = ch.charCodeAt(0);
|
|
@@ -654,7 +638,7 @@ export function decodePrintableKey(data: string): string | undefined {
|
|
|
654
638
|
* @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
|
|
655
639
|
*/
|
|
656
640
|
export function matchesKey(data: string, keyId: KeyId): boolean {
|
|
657
|
-
return
|
|
641
|
+
return matchesKeyNative(data, keyId, kittyProtocolActive);
|
|
658
642
|
}
|
|
659
643
|
|
|
660
644
|
/**
|
|
@@ -666,5 +650,5 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
|
|
666
650
|
* @param data - Raw input data from terminal
|
|
667
651
|
*/
|
|
668
652
|
export function parseKey(data: string): string | undefined {
|
|
669
|
-
return
|
|
653
|
+
return parseKeyNative(data, kittyProtocolActive) ?? undefined;
|
|
670
654
|
}
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -26,6 +26,25 @@ const BRACKETED_PASTE_END = "\x1b[201~";
|
|
|
26
26
|
const SGR_QUARANTINE_MAX_BYTES = 256;
|
|
27
27
|
const SGR_QUARANTINE_TIMEOUT_MS = 100;
|
|
28
28
|
|
|
29
|
+
// Bounds for holding an incomplete terminal capability-probe reply instead of
|
|
30
|
+
// flushing its fragments into the input stream.
|
|
31
|
+
const PROBE_FRAGMENT_HOLD_MAX_MS = 500;
|
|
32
|
+
const PROBE_FRAGMENT_MAX_BYTES = 256;
|
|
33
|
+
const PROBE_ESCAPE_HOLD_MAX_MS = 150;
|
|
34
|
+
const PROBE_REPLY_WINDOW_MS = 2500;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* True when the buffer is an escape sequence that only a terminal reply can
|
|
38
|
+
* complete: an OSC without its BEL/ST terminator (OSC 11 background color) or a
|
|
39
|
+
* private CSI without its final byte (DA1, kitty flags, Mode 2031 DSR).
|
|
40
|
+
* SGR mouse prefixes are excluded; they have their own quarantine.
|
|
41
|
+
*/
|
|
42
|
+
function isIncompleteProbeReplyPrefix(buffer: string): boolean {
|
|
43
|
+
if (buffer.length > PROBE_FRAGMENT_MAX_BYTES) return false;
|
|
44
|
+
if (/^\x1b\][^\x07\x1b]*$/.test(buffer)) return true;
|
|
45
|
+
return /^\x1b\[\??[\d;]*$/.test(buffer);
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
/** True for complete SGR mouse CSI reports. These remain control input, never text. */
|
|
30
49
|
export function isSgrMouseSequence(sequence: string): boolean {
|
|
31
50
|
return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(sequence);
|
|
@@ -238,6 +257,19 @@ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | unde
|
|
|
238
257
|
return codepoint >= 32 ? codepoint : undefined;
|
|
239
258
|
}
|
|
240
259
|
|
|
260
|
+
/**
|
|
261
|
+
* True when the ESC at `index` can still continue the escape sequence that
|
|
262
|
+
* started at offset 0, i.e. it is (or may become) the ST terminator `ESC \` of
|
|
263
|
+
* an OSC/DCS/APC string. Anywhere else an ESC cancels the sequence in progress.
|
|
264
|
+
*/
|
|
265
|
+
function continuesAsStringTerminator(remaining: string, index: number): boolean {
|
|
266
|
+
const introducer = remaining[1];
|
|
267
|
+
if (introducer !== "]" && introducer !== "P" && introducer !== "_") return false;
|
|
268
|
+
const afterEsc = remaining[index + 1];
|
|
269
|
+
// Terminator not fully delivered yet: keep buffering rather than guessing.
|
|
270
|
+
return afterEsc === undefined || afterEsc === "\\";
|
|
271
|
+
}
|
|
272
|
+
|
|
241
273
|
function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
|
|
242
274
|
const sequences: string[] = [];
|
|
243
275
|
let pos = 0;
|
|
@@ -270,6 +302,15 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
|
|
270
302
|
pos += seqEnd;
|
|
271
303
|
break;
|
|
272
304
|
} else if (status === "incomplete") {
|
|
305
|
+
// An ESC cancels an escape sequence already in progress; it can only
|
|
306
|
+
// continue one as the ST terminator of an OSC/DCS/APC string. Cutting
|
|
307
|
+
// here keeps an unterminated sequence from swallowing the next key.
|
|
308
|
+
// seqEnd === 1 is excluded so Meta sequences (ESC ESC) still parse.
|
|
309
|
+
if (remaining[seqEnd] === ESC && seqEnd >= 2 && !continuesAsStringTerminator(remaining, seqEnd)) {
|
|
310
|
+
sequences.push(candidate);
|
|
311
|
+
pos += seqEnd;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
273
314
|
seqEnd++;
|
|
274
315
|
} else {
|
|
275
316
|
// Should not happen when starting with ESC
|
|
@@ -346,6 +387,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
346
387
|
#sgrQuarantineBytes = 0;
|
|
347
388
|
#sgrQuarantineSemicolons = 0;
|
|
348
389
|
#sgrQuarantineHasDigit = false;
|
|
390
|
+
// Probe-reply fragment hold.
|
|
391
|
+
#probeHoldStartedAt: number | undefined;
|
|
392
|
+
#probeHoldBuffer = "";
|
|
393
|
+
#probeReplyWindowUntil = 0;
|
|
349
394
|
|
|
350
395
|
constructor(options: StdinBufferOptions = {}) {
|
|
351
396
|
super();
|
|
@@ -497,17 +542,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
497
542
|
this.#emitDataSequence(sequence);
|
|
498
543
|
}
|
|
499
544
|
|
|
500
|
-
if (this.#buffer.length
|
|
501
|
-
this.#
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
return;
|
|
505
|
-
}
|
|
506
|
-
const flushed = this.flush();
|
|
507
|
-
for (const sequence of flushed) {
|
|
508
|
-
this.#emitDataSequence(sequence);
|
|
509
|
-
}
|
|
510
|
-
}, this.#timeoutMs);
|
|
545
|
+
if (this.#buffer.length === 0) {
|
|
546
|
+
this.#probeHoldStartedAt = undefined;
|
|
547
|
+
} else {
|
|
548
|
+
this.#timeout = setTimeout(() => this.#onFlushTimeout(), this.#timeoutMs);
|
|
511
549
|
}
|
|
512
550
|
}
|
|
513
551
|
|
|
@@ -599,12 +637,61 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
599
637
|
this.emit("data", sequence);
|
|
600
638
|
}
|
|
601
639
|
|
|
640
|
+
/**
|
|
641
|
+
* ProcessTerminal calls this right after writing a capability probe so a lone
|
|
642
|
+
* ESC arriving inside the reply window is treated as a possible reply fragment
|
|
643
|
+
* rather than a keypress.
|
|
644
|
+
*/
|
|
645
|
+
noteProbeIssued(windowMs: number = PROBE_REPLY_WINDOW_MS): void {
|
|
646
|
+
this.#probeReplyWindowUntil = Date.now() + windowMs;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
#onFlushTimeout(): void {
|
|
650
|
+
if (isSgrMousePrefix(this.#buffer)) {
|
|
651
|
+
this.#beginSgrQuarantine();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (this.#shouldHoldProbeFragment()) {
|
|
655
|
+
this.#timeout = setTimeout(() => this.#onFlushTimeout(), this.#timeoutMs);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
this.#probeHoldStartedAt = undefined;
|
|
659
|
+
const flushed = this.flush();
|
|
660
|
+
for (const sequence of flushed) {
|
|
661
|
+
this.#emitDataSequence(sequence);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
#shouldHoldProbeFragment(): boolean {
|
|
666
|
+
const buffer = this.#buffer;
|
|
667
|
+
if (buffer.length === 0) return false;
|
|
668
|
+
const now = Date.now();
|
|
669
|
+
const bareEscape = buffer === "\x1b";
|
|
670
|
+
if (bareEscape) {
|
|
671
|
+
// A lone ESC is a real key press: only hold it while a probe reply is
|
|
672
|
+
// still expected, and only briefly.
|
|
673
|
+
if (now >= this.#probeReplyWindowUntil) return false;
|
|
674
|
+
} else if (!isIncompleteProbeReplyPrefix(buffer)) {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
if (this.#probeHoldStartedAt === undefined || this.#probeHoldBuffer !== buffer) {
|
|
678
|
+
// Restart the clock whenever the fragment makes progress. A start stamp left
|
|
679
|
+
// over from an earlier fragment expired every later hold instantly, so a
|
|
680
|
+
// reply split across many reads still leaked character by character.
|
|
681
|
+
this.#probeHoldStartedAt = now;
|
|
682
|
+
this.#probeHoldBuffer = buffer;
|
|
683
|
+
}
|
|
684
|
+
const limit = bareEscape ? PROBE_ESCAPE_HOLD_MAX_MS : PROBE_FRAGMENT_HOLD_MAX_MS;
|
|
685
|
+
return now - this.#probeHoldStartedAt < limit;
|
|
686
|
+
}
|
|
687
|
+
|
|
602
688
|
flush(): string[] {
|
|
603
689
|
if (this.#timeout) {
|
|
604
690
|
clearTimeout(this.#timeout);
|
|
605
691
|
this.#timeout = undefined;
|
|
606
692
|
}
|
|
607
693
|
if (this.#sgrQuarantine) this.#endSgrQuarantine();
|
|
694
|
+
this.#probeHoldStartedAt = undefined;
|
|
608
695
|
|
|
609
696
|
const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta();
|
|
610
697
|
|
|
@@ -637,6 +724,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
637
724
|
this.#sgrQuarantineBytes = 0;
|
|
638
725
|
this.#sgrQuarantineSemicolons = 0;
|
|
639
726
|
this.#sgrQuarantineHasDigit = false;
|
|
727
|
+
this.#probeHoldStartedAt = undefined;
|
|
640
728
|
// Drop any incomplete multi-byte sequence the decoder is holding so a
|
|
641
729
|
// stale partial prefix cannot combine with future input. destroy()
|
|
642
730
|
// resets the decoder by calling clear().
|
|
@@ -514,6 +514,68 @@ export function encodeKittyPlacement(options: {
|
|
|
514
514
|
return `\x1b_Ga=p,i=${options.imageId},p=${options.placementId},c=${options.columns},r=${options.rows},C=1,q=2\x1b\\`;
|
|
515
515
|
}
|
|
516
516
|
|
|
517
|
+
export interface KittyPlacementReference {
|
|
518
|
+
imageId: number;
|
|
519
|
+
placementId: number;
|
|
520
|
+
rows: number;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const MAX_KITTY_CONTROL_CHARS = 4096;
|
|
524
|
+
const MAX_KITTY_PLACEMENTS_PER_LINE = 1024;
|
|
525
|
+
const MAX_KITTY_PLACEMENT_SCAN_CHARS = 256 * 1024;
|
|
526
|
+
const MAX_KITTY_PLACEMENT_SCAN_BYTES = 256 * 1024;
|
|
527
|
+
const MAX_KITTY_UINT32 = 0xffff_ffff;
|
|
528
|
+
const MAX_KITTY_CONTROL_FIELDS = 64;
|
|
529
|
+
|
|
530
|
+
function parseKittyUint32(raw: string | undefined): number | null {
|
|
531
|
+
if (raw === undefined || raw.length === 0 || raw.length > 10 || !/^\d+$/u.test(raw)) return null;
|
|
532
|
+
const value = Number(raw);
|
|
533
|
+
return Number.isInteger(value) && value > 0 && value <= MAX_KITTY_UINT32 ? value : null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Extract bounded, named kitty placements from a rendered line. */
|
|
537
|
+
export function extractKittyPlacementReferences(line: string): KittyPlacementReference[] {
|
|
538
|
+
if (line.length > MAX_KITTY_PLACEMENT_SCAN_CHARS) return [];
|
|
539
|
+
if (!line.includes(ImageProtocol.Kitty) || Buffer.byteLength(line) > MAX_KITTY_PLACEMENT_SCAN_BYTES) return [];
|
|
540
|
+
const placements: KittyPlacementReference[] = [];
|
|
541
|
+
for (const match of line.matchAll(/\x1b_G([^;\x1b]*)(?:;([^\x1b]*))?\x1b\\/gu)) {
|
|
542
|
+
const control = match[1] ?? "";
|
|
543
|
+
if (control.length === 0 || control.length > MAX_KITTY_CONTROL_CHARS || match[2] !== undefined) continue;
|
|
544
|
+
const parts = control.split(",");
|
|
545
|
+
if (parts.length > MAX_KITTY_CONTROL_FIELDS) continue;
|
|
546
|
+
|
|
547
|
+
const params = new Map<string, string>();
|
|
548
|
+
let valid = true;
|
|
549
|
+
for (const part of parts) {
|
|
550
|
+
const separator = part.indexOf("=");
|
|
551
|
+
if (separator !== 1 || part.length === 2) {
|
|
552
|
+
valid = false;
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
const key = part[0];
|
|
556
|
+
if (!/[A-Za-z]/u.test(key) || params.has(key)) {
|
|
557
|
+
valid = false;
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
params.set(key, part.slice(2));
|
|
561
|
+
}
|
|
562
|
+
if (!valid || params.get("a") !== "p" || params.get("C") !== "1" || params.has("m")) continue;
|
|
563
|
+
|
|
564
|
+
const imageId = parseKittyUint32(params.get("i"));
|
|
565
|
+
const placementId = parseKittyUint32(params.get("p"));
|
|
566
|
+
const rows = parseKittyUint32(params.get("r"));
|
|
567
|
+
if (imageId === null || placementId === null || rows === null) continue;
|
|
568
|
+
placements.push({ imageId, placementId, rows });
|
|
569
|
+
if (placements.length > MAX_KITTY_PLACEMENTS_PER_LINE) return [];
|
|
570
|
+
}
|
|
571
|
+
return placements;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Soft-delete one named kitty placement while retaining its transmitted pixels. */
|
|
575
|
+
export function encodeKittyPlacementDelete(reference: KittyPlacementReference): string {
|
|
576
|
+
return `\x1b_Ga=d,d=i,i=${reference.imageId},p=${reference.placementId},q=2\x1b\\`;
|
|
577
|
+
}
|
|
578
|
+
|
|
517
579
|
export function encodeITerm2(
|
|
518
580
|
base64Data: string,
|
|
519
581
|
options: {
|
|
@@ -755,8 +817,8 @@ export function renderImage(
|
|
|
755
817
|
// and ALL of its placements (breaking sibling components showing the
|
|
756
818
|
// same content) and would re-send multi-MB payloads on every repaint.
|
|
757
819
|
if (!transmittedKittyImageIds.has(imageId)) {
|
|
758
|
-
transmittedKittyImageIds.add(imageId);
|
|
759
820
|
(options.onTransmit ?? kittyTransmitWriter)(encodeKittyTransmit(base64Data, imageId));
|
|
821
|
+
transmittedKittyImageIds.add(imageId);
|
|
760
822
|
}
|
|
761
823
|
const sequence = encodeKittyPlacement({ imageId, placementId, columns: fit.columns, rows: fit.rows });
|
|
762
824
|
return { sequence, rows: fit.rows, cursorNeutral: true };
|
package/src/terminal.ts
CHANGED
|
@@ -8,6 +8,33 @@ const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
|
|
8
8
|
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
|
9
9
|
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Capability-probe reply shapes that only this layer solicits (OSC 11 background
|
|
13
|
+
* color, the Mode 2031 appearance DSR, and the Kitty keyboard-flags report).
|
|
14
|
+
* These are terminal-to-host replies and are NEVER legitimate user input, so a
|
|
15
|
+
* reply that arrives outside its pending-query window is dropped defensively.
|
|
16
|
+
*
|
|
17
|
+
* DA1 is deliberately absent: `Tui` issues its own DA1 request for the sixel
|
|
18
|
+
* probe and consumes that reply downstream.
|
|
19
|
+
*/
|
|
20
|
+
export const PROBE_REPLY_PATTERNS: ReadonlyArray<{ name: string; issuedProbe: string; pattern: RegExp }> = [
|
|
21
|
+
{
|
|
22
|
+
name: "osc11-background",
|
|
23
|
+
issuedProbe: "\x1b]11;?\x07",
|
|
24
|
+
pattern: /^\x1b\]11;rgba?:[0-9a-fA-F]{1,4}\/[0-9a-fA-F]{1,4}\/[0-9a-fA-F]{1,4}(?:\x07|\x1b\\)$/,
|
|
25
|
+
},
|
|
26
|
+
{ name: "mode2031-dsr", issuedProbe: "\x1b[?2031h", pattern: /^\x1b\[\?997;[12]n$/ },
|
|
27
|
+
{ name: "kitty-flags", issuedProbe: "\x1b[?u", pattern: /^\x1b\[\?\d+u$/ },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** True when `sequence` is one of the probe replies above. */
|
|
31
|
+
export function isUnsolicitedProbeReply(sequence: string): boolean {
|
|
32
|
+
for (const entry of PROBE_REPLY_PATTERNS) {
|
|
33
|
+
if (entry.pattern.test(sequence)) return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
11
38
|
/**
|
|
12
39
|
* Whether GJC may reprogram the keyboard with enhanced input protocols
|
|
13
40
|
* (the Kitty keyboard protocol and the xterm modifyOtherKeys fallback).
|
|
@@ -222,6 +249,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
222
249
|
#privateCsiResponseBuffer = "";
|
|
223
250
|
#pendingDa1Sentinels = 0;
|
|
224
251
|
#osc11PollTimer?: Timer;
|
|
252
|
+
// Bounds the OSC 11 / DA1 pending-query window so a dropped or mangled reply
|
|
253
|
+
// (multiplexer, TERM=dumb host) cannot latch #osc11Pending forever and freeze
|
|
254
|
+
// stdin.
|
|
255
|
+
#osc11QueryWatchdog?: Timer;
|
|
225
256
|
#mode2031DebounceTimer?: Timer;
|
|
226
257
|
#progressTimer?: ReturnType<typeof setInterval>;
|
|
227
258
|
#mouseEnabled = false;
|
|
@@ -318,6 +349,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
318
349
|
// When the terminal reports a change, we re-query OSC 11 to get the
|
|
319
350
|
// actual background color (following Neovim convention) with 100ms debounce.
|
|
320
351
|
this.#safeWrite("\x1b[?2031h");
|
|
352
|
+
this.#stdinBuffer?.noteProbeIssued();
|
|
321
353
|
|
|
322
354
|
// Start periodic OSC 11 re-query for terminals without Mode 2031
|
|
323
355
|
// (Warp, Alacritty, WezTerm, iTerm2). Self-disables once Mode 2031 fires.
|
|
@@ -415,10 +447,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
415
447
|
// flush timeout elapses mid-sequence, the prefix `\x1b[?<digits>` arrives as
|
|
416
448
|
// one event and the tail `;...<terminator>` arrives as individual character
|
|
417
449
|
// events that would otherwise leak into the prompt as keystrokes. See #1238.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
450
|
+
// Reassembly is keyed on the reply's shape, not on `#pendingDa1Sentinels`:
|
|
451
|
+
// replies the terminal still owed after a counter reset (stop()/start()
|
|
452
|
+
// around a foreground command) otherwise leaked into the editor one
|
|
453
|
+
// character at a time. No keystroke can produce this prefix.
|
|
454
|
+
if (this.#privateCsiResponseBuffer || privateCsiPartialPattern.test(sequence)) {
|
|
422
455
|
if (this.#privateCsiResponseBuffer && sequence.startsWith("\x1b")) {
|
|
423
456
|
// New escape arrived mid-reassembly — abandon partial and re-process the new sequence.
|
|
424
457
|
this.#privateCsiResponseBuffer = "";
|
|
@@ -491,7 +524,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
491
524
|
// Accumulate fragments until the BEL/ST terminator arrives, then parse once.
|
|
492
525
|
// If a new escape sequence arrives (not the ST terminator), abort buffering
|
|
493
526
|
// and forward it as normal input so user keystrokes are never swallowed.
|
|
494
|
-
if (this.#
|
|
527
|
+
if (this.#osc11ResponseBuffer || sequence.startsWith("\x1b]11;")) {
|
|
495
528
|
if (this.#osc11ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
|
|
496
529
|
// New escape sequence arrived mid-buffer — not an OSC 11 continuation.
|
|
497
530
|
this.#osc11ResponseBuffer = "";
|
|
@@ -499,12 +532,25 @@ export class ProcessTerminal implements Terminal {
|
|
|
499
532
|
} else {
|
|
500
533
|
this.#osc11ResponseBuffer += sequence;
|
|
501
534
|
const osc11Match = this.#osc11ResponseBuffer.match(osc11ResponsePattern);
|
|
502
|
-
if (
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
535
|
+
if (osc11Match) {
|
|
536
|
+
const [, rHex, gHex, bHex] = osc11Match;
|
|
537
|
+
this.#osc11Pending = false;
|
|
538
|
+
this.#osc11ResponseBuffer = "";
|
|
539
|
+
this.#clearOsc11QueryWatchdog();
|
|
540
|
+
this.#handleOsc11Response(rHex!, gHex!, bHex!);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
// Bound the reassembly buffer. A real reply is <= ~25 bytes; if the
|
|
544
|
+
// terminator is dropped or mangled (multiplexer, TERM=dumb) an unbounded
|
|
545
|
+
// buffer swallows every following keystroke and freezes input. Past the
|
|
546
|
+
// cap, abandon reassembly and let the sequence fall through as input.
|
|
547
|
+
if (this.#osc11ResponseBuffer.length > 64) {
|
|
548
|
+
this.#osc11Pending = false;
|
|
549
|
+
this.#osc11ResponseBuffer = "";
|
|
550
|
+
this.#clearOsc11QueryWatchdog();
|
|
551
|
+
} else {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
508
554
|
}
|
|
509
555
|
}
|
|
510
556
|
|
|
@@ -520,6 +566,13 @@ export class ProcessTerminal implements Terminal {
|
|
|
520
566
|
}, 100);
|
|
521
567
|
return;
|
|
522
568
|
}
|
|
569
|
+
// Defensive backstop. A capability-probe reply reaching this point arrived
|
|
570
|
+
// outside its pending-query window, so none of the handlers above consumed
|
|
571
|
+
// it. These shapes are never user input, and paste content never reaches
|
|
572
|
+
// this handler, so dropping is always safe.
|
|
573
|
+
if (isUnsolicitedProbeReply(sequence)) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
523
576
|
if (this.#inputHandler) {
|
|
524
577
|
this.#inputHandler(sequence);
|
|
525
578
|
}
|
|
@@ -562,6 +615,39 @@ export class ProcessTerminal implements Terminal {
|
|
|
562
615
|
this.#pendingDa1Sentinels++;
|
|
563
616
|
this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated)
|
|
564
617
|
this.#safeWrite("\x1b[c"); // DA1 sentinel
|
|
618
|
+
this.#stdinBuffer?.noteProbeIssued();
|
|
619
|
+
this.#armOsc11QueryWatchdog();
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* OSC 11 pending-query watchdog. If neither the OSC 11 reply nor its DA1
|
|
624
|
+
* sentinel comes back (dropped by a multiplexer or a TERM=dumb host),
|
|
625
|
+
* #osc11Pending / #pendingDa1Sentinels latch forever: #queryBackgroundColor
|
|
626
|
+
* stops re-querying and the reassembly branch swallows keystrokes.
|
|
627
|
+
* Force-resolve the cycle after a bounded wait so the state machine self-heals.
|
|
628
|
+
*/
|
|
629
|
+
#armOsc11QueryWatchdog(): void {
|
|
630
|
+
this.#clearOsc11QueryWatchdog();
|
|
631
|
+
this.#osc11QueryWatchdog = setTimeout(() => {
|
|
632
|
+
this.#osc11QueryWatchdog = undefined;
|
|
633
|
+
if (this.#dead) return;
|
|
634
|
+
if (!this.#osc11Pending && this.#pendingDa1Sentinels === 0) return;
|
|
635
|
+
this.#osc11Pending = false;
|
|
636
|
+
this.#osc11ResponseBuffer = "";
|
|
637
|
+
this.#pendingDa1Sentinels = 0;
|
|
638
|
+
if (this.#osc11QueryQueued && !this.#dead) {
|
|
639
|
+
this.#osc11QueryQueued = false;
|
|
640
|
+
this.#startOsc11Query();
|
|
641
|
+
}
|
|
642
|
+
}, 1000);
|
|
643
|
+
this.#osc11QueryWatchdog.unref?.();
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
#clearOsc11QueryWatchdog(): void {
|
|
647
|
+
if (this.#osc11QueryWatchdog) {
|
|
648
|
+
clearTimeout(this.#osc11QueryWatchdog);
|
|
649
|
+
this.#osc11QueryWatchdog = undefined;
|
|
650
|
+
}
|
|
565
651
|
}
|
|
566
652
|
/**
|
|
567
653
|
* Parse an OSC 11 background color response and compute BT.601 luminance.
|
|
@@ -631,6 +717,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
631
717
|
return;
|
|
632
718
|
}
|
|
633
719
|
this.#safeWrite("\x1b[?u");
|
|
720
|
+
this.#stdinBuffer?.noteProbeIssued();
|
|
634
721
|
// Windows Terminal and conhost do not implement the Kitty keyboard
|
|
635
722
|
// protocol, so the query above never activates it there. They do honor the
|
|
636
723
|
// modifyOtherKeys fallback below — but that mode breaks Windows CJK/Hangul
|