@sayknow-cli/tui 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/animation-scheduler.d.ts +13 -0
- package/dist/types/autocomplete.d.ts +83 -0
- package/dist/types/bracketed-paste.d.ts +26 -0
- package/dist/types/components/box.d.ts +20 -0
- package/dist/types/components/cancellable-loader.d.ts +21 -0
- package/dist/types/components/editor.d.ts +126 -0
- package/dist/types/components/image.d.ts +16 -0
- package/dist/types/components/input.d.ts +16 -0
- package/dist/types/components/loader.d.ts +23 -0
- package/dist/types/components/markdown.d.ts +77 -0
- package/dist/types/components/select-list.d.ts +46 -0
- package/dist/types/components/settings-list.d.ts +39 -0
- package/dist/types/components/spacer.d.ts +11 -0
- package/dist/types/components/tab-bar.d.ts +56 -0
- package/dist/types/components/text.d.ts +13 -0
- package/dist/types/components/truncated-text.d.ts +10 -0
- package/dist/types/editor-component.d.ts +36 -0
- package/dist/types/fuzzy.d.ts +15 -0
- package/dist/types/index.d.ts +27 -0
- package/dist/types/keybindings.d.ts +201 -0
- package/dist/types/keys.d.ts +208 -0
- package/dist/types/kill-ring.d.ts +27 -0
- package/dist/types/metrics.d.ts +85 -0
- package/dist/types/stdin-buffer.d.ts +50 -0
- package/dist/types/symbols.d.ts +23 -0
- package/dist/types/terminal-capabilities.d.ts +75 -0
- package/dist/types/terminal.d.ts +88 -0
- package/dist/types/ttyid.d.ts +9 -0
- package/dist/types/tui.d.ts +206 -0
- package/dist/types/utils.d.ts +87 -0
- package/package.json +10 -9
- package/src/animation-scheduler.ts +99 -0
- package/src/autocomplete.ts +119 -96
- package/src/components/editor.ts +310 -128
- package/src/components/input.ts +2 -1
- package/src/components/loader.ts +36 -37
- package/src/components/markdown.ts +79 -2
- package/src/components/select-list.ts +8 -1
- package/src/index.ts +1 -0
- package/src/stdin-buffer.ts +89 -11
- package/src/terminal.ts +44 -8
- package/src/tui.ts +362 -64
- package/src/utils.ts +77 -11
package/src/components/input.ts
CHANGED
|
@@ -58,7 +58,8 @@ export class Input implements Component, Focusable {
|
|
|
58
58
|
setValue(value: string): void {
|
|
59
59
|
const normalized = value.normalize("NFC");
|
|
60
60
|
this.#value = normalized;
|
|
61
|
-
this.#cursor =
|
|
61
|
+
this.#cursor = normalized.length;
|
|
62
|
+
this.#lastAction = null;
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
handleInput(data: string): void {
|
package/src/components/loader.ts
CHANGED
|
@@ -1,34 +1,32 @@
|
|
|
1
|
+
import { type AnimationRegistration, registerAnimationCallback } from "../animation-scheduler";
|
|
1
2
|
import type { TUI } from "../tui";
|
|
2
3
|
import { sliceByColumn, visibleWidth } from "../utils";
|
|
3
4
|
import { Text } from "./text";
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
-
* Loader component that drives display refresh at ~60fps so callers whose
|
|
7
|
-
* message colorizer is time-dependent (e.g. shimmer/KITT) animate smoothly.
|
|
8
|
-
*
|
|
9
|
-
* Two cadences are interleaved on a single timer:
|
|
10
|
-
* - **Recompute tick** (every `RENDER_INTERVAL_MS`) → recomposes the spinner +
|
|
11
|
-
* colorized message every 16ms. A redraw is requested only when that composed
|
|
12
|
-
* text actually changed since the last tick (`#lastDisplayed`), so animated
|
|
13
|
-
* colorizers (shimmer/KITT) and spinner-frame advances still repaint, while
|
|
14
|
-
* static loaders skip the redundant no-op render requests between advances.
|
|
15
|
-
* - **Spinner advance** (every `SPINNER_ADVANCE_MS`) → bumps the spinner
|
|
16
|
-
* frame index. Decoupled from the recompute cadence so the spinner keeps
|
|
17
|
-
* its classic ~12.5fps step pace regardless of shimmer state.
|
|
18
|
-
*
|
|
19
|
-
* The animation timer is `unref`'d so an active loader never keeps the event
|
|
20
|
-
* loop alive on its own.
|
|
21
|
-
*/
|
|
22
|
-
const RENDER_INTERVAL_MS = 16;
|
|
23
6
|
const SPINNER_ADVANCE_MS = 80;
|
|
24
7
|
|
|
8
|
+
export interface LoaderOptions {
|
|
9
|
+
timeDependentColor?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Test-only performance counters for advisory baseline tests. */
|
|
13
|
+
export const __loaderPerfCounters = {
|
|
14
|
+
liveIntervals: 0,
|
|
15
|
+
startedIntervals: 0,
|
|
16
|
+
reset(): void {
|
|
17
|
+
this.liveIntervals = 0;
|
|
18
|
+
this.startedIntervals = 0;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
25
22
|
export class Loader extends Text {
|
|
26
23
|
#frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
27
24
|
#currentFrame = 0;
|
|
28
|
-
#
|
|
25
|
+
#animation?: AnimationRegistration;
|
|
29
26
|
#ui: TUI | null = null;
|
|
30
27
|
#lastSpinnerTick = 0;
|
|
31
28
|
#lastDisplayed?: string;
|
|
29
|
+
#timeDependentColor: boolean;
|
|
32
30
|
|
|
33
31
|
constructor(
|
|
34
32
|
ui: TUI,
|
|
@@ -36,9 +34,11 @@ export class Loader extends Text {
|
|
|
36
34
|
private messageColorFn: (str: string) => string,
|
|
37
35
|
private message: string = "Loading...",
|
|
38
36
|
spinnerFrames?: string[],
|
|
37
|
+
options: LoaderOptions = {},
|
|
39
38
|
) {
|
|
40
39
|
super("", 1, 0);
|
|
41
40
|
this.#ui = ui;
|
|
41
|
+
this.#timeDependentColor = options.timeDependentColor ?? false;
|
|
42
42
|
if (spinnerFrames && spinnerFrames.length > 0) {
|
|
43
43
|
this.#frames = spinnerFrames;
|
|
44
44
|
}
|
|
@@ -57,24 +57,28 @@ export class Loader extends Text {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
start() {
|
|
60
|
+
if (this.#animation) return;
|
|
60
61
|
this.#lastSpinnerTick = performance.now();
|
|
61
62
|
this.#updateDisplay();
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
this.#lastSpinnerTick
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
63
|
+
__loaderPerfCounters.liveIntervals += 1;
|
|
64
|
+
__loaderPerfCounters.startedIntervals += 1;
|
|
65
|
+
this.#animation = registerAnimationCallback(
|
|
66
|
+
now => {
|
|
67
|
+
if (now - this.#lastSpinnerTick >= SPINNER_ADVANCE_MS) {
|
|
68
|
+
this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length;
|
|
69
|
+
this.#lastSpinnerTick = now;
|
|
70
|
+
}
|
|
71
|
+
this.#updateDisplay();
|
|
72
|
+
},
|
|
73
|
+
this.#timeDependentColor ? 16 : 80,
|
|
74
|
+
);
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
stop() {
|
|
75
|
-
if (this.#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
if (this.#animation) {
|
|
79
|
+
this.#animation.unregister();
|
|
80
|
+
__loaderPerfCounters.liveIntervals = Math.max(0, __loaderPerfCounters.liveIntervals - 1);
|
|
81
|
+
this.#animation = undefined;
|
|
78
82
|
}
|
|
79
83
|
}
|
|
80
84
|
|
|
@@ -90,11 +94,6 @@ export class Loader extends Text {
|
|
|
90
94
|
#updateDisplay() {
|
|
91
95
|
const frame = this.#frames[this.#currentFrame];
|
|
92
96
|
const next = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
|
|
93
|
-
// Only touch the component and ask the TUI to repaint when the rendered
|
|
94
|
-
// text actually changed. Time-dependent colorizers (shimmer/KITT) produce
|
|
95
|
-
// new text every tick and still animate; static loaders skip the ~16ms
|
|
96
|
-
// no-op render requests between 80ms spinner advances. Output is unchanged
|
|
97
|
-
// because a suppressed frame would have produced a no-op write anyway.
|
|
98
97
|
if (next === this.#lastDisplayed) return;
|
|
99
98
|
this.#lastDisplayed = next;
|
|
100
99
|
this.setText(next);
|
|
@@ -42,6 +42,13 @@ const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
|
|
|
42
42
|
const renderCache = new LRUCache<string, { source: string; lines: string[] }>({ max: RENDER_CACHE_MAX });
|
|
43
43
|
const PARSE_CACHE_MAX = 128;
|
|
44
44
|
const parseCache = new LRUCache<string, { source: string; tokens: Token[] }>({ max: PARSE_CACHE_MAX });
|
|
45
|
+
const MARKDOWN_STREAM_THROTTLE_MS = 64;
|
|
46
|
+
let markdownNow = (): number => performance.now();
|
|
47
|
+
|
|
48
|
+
/** Test-only clock seam for streaming throttle tests. */
|
|
49
|
+
export function __setMarkdownNowForTest(now: (() => number) | undefined): void {
|
|
50
|
+
markdownNow = now ?? (() => performance.now());
|
|
51
|
+
}
|
|
45
52
|
|
|
46
53
|
// Per-code-block highlight cache (F3): keyed by theme + lang + code so streaming
|
|
47
54
|
// appends only highlight new/changed blocks instead of re-highlighting the whole
|
|
@@ -61,6 +68,16 @@ export function resetMarkdownHighlightCallCount(): void {
|
|
|
61
68
|
highlightCallCount = 0;
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
/** Test-only performance counters for advisory baseline tests. */
|
|
72
|
+
export const __markdownPerfCounters = {
|
|
73
|
+
lexerInvocations: 0,
|
|
74
|
+
lexedBytes: 0,
|
|
75
|
+
reset(): void {
|
|
76
|
+
this.lexerInvocations = 0;
|
|
77
|
+
this.lexedBytes = 0;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
64
81
|
// Full-content 64-bit wyhash over every byte (no lossy sampling). Cache hits
|
|
65
82
|
// additionally verify entry.source against the normalized text, so even a
|
|
66
83
|
// hash collision can never return another message's render.
|
|
@@ -178,6 +195,11 @@ export class Markdown implements Component {
|
|
|
178
195
|
#cachedWidth?: number;
|
|
179
196
|
#cachedLines?: string[];
|
|
180
197
|
|
|
198
|
+
#streaming = false;
|
|
199
|
+
#lastFullParseAt = 0;
|
|
200
|
+
#onStaleThrottle?: () => void;
|
|
201
|
+
#staleThrottleTimer?: ReturnType<typeof setTimeout>;
|
|
202
|
+
|
|
181
203
|
constructor(
|
|
182
204
|
text: string,
|
|
183
205
|
paddingX: number,
|
|
@@ -194,11 +216,55 @@ export class Markdown implements Component {
|
|
|
194
216
|
this.#codeBlockIndent = Math.max(0, Math.floor(codeBlockIndent));
|
|
195
217
|
}
|
|
196
218
|
|
|
197
|
-
|
|
219
|
+
setOnStaleThrottle(callback: (() => void) | undefined): void {
|
|
220
|
+
this.#onStaleThrottle = callback;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
setText(text: string, options?: { streaming?: boolean }): void {
|
|
224
|
+
if (options?.streaming !== undefined) {
|
|
225
|
+
this.setStreaming(options.streaming);
|
|
226
|
+
}
|
|
198
227
|
this.#text = text;
|
|
228
|
+
if (this.#streaming) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
199
231
|
this.invalidate();
|
|
200
232
|
}
|
|
201
233
|
|
|
234
|
+
setStreaming(streaming: boolean): void {
|
|
235
|
+
if (this.#streaming === streaming) return;
|
|
236
|
+
this.#streaming = streaming;
|
|
237
|
+
if (!streaming) {
|
|
238
|
+
this.#clearStaleThrottleTimer();
|
|
239
|
+
this.#lastFullParseAt = 0;
|
|
240
|
+
this.invalidate();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
#clearStaleThrottleTimer(): void {
|
|
245
|
+
if (!this.#staleThrottleTimer) return;
|
|
246
|
+
clearTimeout(this.#staleThrottleTimer);
|
|
247
|
+
this.#staleThrottleTimer = undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#armStaleThrottleTimer(remainingMs: number): void {
|
|
251
|
+
if (!this.#onStaleThrottle || this.#staleThrottleTimer || this.#cachedText === this.#text) return;
|
|
252
|
+
this.#staleThrottleTimer = setTimeout(
|
|
253
|
+
() => {
|
|
254
|
+
this.#staleThrottleTimer = undefined;
|
|
255
|
+
if (this.#streaming && this.#cachedText !== this.#text) {
|
|
256
|
+
this.#onStaleThrottle?.();
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
Math.max(0, remainingMs),
|
|
260
|
+
);
|
|
261
|
+
this.#staleThrottleTimer.unref?.();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
dispose(): void {
|
|
265
|
+
this.#clearStaleThrottleTimer();
|
|
266
|
+
}
|
|
267
|
+
|
|
202
268
|
invalidate(): void {
|
|
203
269
|
this.#cachedText = undefined;
|
|
204
270
|
this.#cachedWidth = undefined;
|
|
@@ -256,6 +322,14 @@ export class Markdown implements Component {
|
|
|
256
322
|
// Calculate available width for content (subtract horizontal padding)
|
|
257
323
|
const contentWidth = Math.max(1, width - this.#paddingX * 2);
|
|
258
324
|
|
|
325
|
+
if (this.#streaming && this.#cachedLines && this.#cachedWidth === width && this.#lastFullParseAt > 0) {
|
|
326
|
+
const elapsedMs = markdownNow() - this.#lastFullParseAt;
|
|
327
|
+
if (elapsedMs < MARKDOWN_STREAM_THROTTLE_MS) {
|
|
328
|
+
this.#armStaleThrottleTimer(MARKDOWN_STREAM_THROTTLE_MS - elapsedMs);
|
|
329
|
+
return this.#cachedLines;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
259
333
|
// Don't render anything if there's no actual text
|
|
260
334
|
if (!this.#text || this.#text.trim() === "") {
|
|
261
335
|
const result: string[] = [];
|
|
@@ -268,7 +342,7 @@ export class Markdown implements Component {
|
|
|
268
342
|
|
|
269
343
|
// Replace tabs with 3 spaces for consistent rendering
|
|
270
344
|
const normalizedText = replaceTabs(this.#text);
|
|
271
|
-
|
|
345
|
+
this.#clearStaleThrottleTimer();
|
|
272
346
|
const contentKey = markdownContentKey(normalizedText);
|
|
273
347
|
|
|
274
348
|
// L2: module-level LRU — survives component disposal/recreation across
|
|
@@ -296,6 +370,8 @@ export class Markdown implements Component {
|
|
|
296
370
|
if (cachedParse !== undefined && cachedParse.source === normalizedText) {
|
|
297
371
|
tokens = cachedParse.tokens;
|
|
298
372
|
} else {
|
|
373
|
+
__markdownPerfCounters.lexerInvocations += 1;
|
|
374
|
+
__markdownPerfCounters.lexedBytes += normalizedText.length;
|
|
299
375
|
tokens = markdownParser.lexer(normalizedText);
|
|
300
376
|
parseCache.set(contentKey, { source: normalizedText, tokens });
|
|
301
377
|
}
|
|
@@ -361,6 +437,7 @@ export class Markdown implements Component {
|
|
|
361
437
|
this.#cachedText = this.#text;
|
|
362
438
|
this.#cachedWidth = width;
|
|
363
439
|
this.#cachedLines = result;
|
|
440
|
+
this.#lastFullParseAt = markdownNow();
|
|
364
441
|
|
|
365
442
|
// Update L2 module-level LRU so future instances with the same key skip
|
|
366
443
|
// the marked.lexer + highlightCode (Rust FFI) work entirely.
|
|
@@ -117,8 +117,15 @@ export class SelectList implements Component {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
handleInput(keyData: string): void {
|
|
120
|
-
if (this.#filteredItems.length === 0) return;
|
|
121
120
|
const kb = getKeybindings();
|
|
121
|
+
if (this.#filteredItems.length === 0) {
|
|
122
|
+
if (kb.matches(keyData, "tui.select.cancel")) {
|
|
123
|
+
if (this.onCancel) {
|
|
124
|
+
this.onCancel();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
122
129
|
// Up arrow - wrap to bottom when at top
|
|
123
130
|
if (kb.matches(keyData, "tui.select.up")) {
|
|
124
131
|
this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
|
package/src/index.ts
CHANGED
package/src/stdin-buffer.ts
CHANGED
|
@@ -24,6 +24,39 @@ const ESC = "\x1b";
|
|
|
24
24
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
25
25
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
26
26
|
|
|
27
|
+
function isUtf8LeadByte(byte: number): boolean {
|
|
28
|
+
return byte >= 0xc2 && byte <= 0xf4;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function endsWithIncompleteUtf8Sequence(data: Buffer): boolean {
|
|
32
|
+
if (data.length === 0) return false;
|
|
33
|
+
|
|
34
|
+
let index = data.length - 1;
|
|
35
|
+
let continuationCount = 0;
|
|
36
|
+
while (index >= 0) {
|
|
37
|
+
const byte = data[index]!;
|
|
38
|
+
if (byte < 0x80 || byte > 0xbf) break;
|
|
39
|
+
continuationCount++;
|
|
40
|
+
index--;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (index < 0) {
|
|
44
|
+
return continuationCount > 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const lead = data[index]!;
|
|
48
|
+
let expectedLength = 0;
|
|
49
|
+
if (lead >= 0xc2 && lead <= 0xdf) expectedLength = 2;
|
|
50
|
+
else if (lead >= 0xe0 && lead <= 0xef) expectedLength = 3;
|
|
51
|
+
else if (lead >= 0xf0 && lead <= 0xf4) expectedLength = 4;
|
|
52
|
+
|
|
53
|
+
return expectedLength > 0 && continuationCount + 1 < expectedLength;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function legacyMetaSequence(byte: number): string {
|
|
57
|
+
return `\x1b${String.fromCharCode(byte - 128)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
27
60
|
/**
|
|
28
61
|
* Check if a string is a complete escape sequence or needs more data
|
|
29
62
|
*/
|
|
@@ -268,6 +301,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
268
301
|
// split across two stdin events) reassemble correctly instead of emitting
|
|
269
302
|
// U+FFFD. Reset on clear()/destroy(); never finalized on normal flush.
|
|
270
303
|
#decoder = new StringDecoder("utf8");
|
|
304
|
+
#decoderHasPendingUtf8 = false;
|
|
305
|
+
#pendingSingleUtf8LeadByte: number | undefined;
|
|
271
306
|
|
|
272
307
|
constructor(options: StdinBufferOptions = {}) {
|
|
273
308
|
super();
|
|
@@ -286,22 +321,51 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
286
321
|
let str: string;
|
|
287
322
|
let decodedFromBuffer = false;
|
|
288
323
|
if (Buffer.isBuffer(data)) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
324
|
+
let bytes = data;
|
|
325
|
+
const hadPendingUtf8 = this.#decoderHasPendingUtf8 || this.#pendingSingleUtf8LeadByte !== undefined;
|
|
326
|
+
|
|
327
|
+
if (this.#pendingSingleUtf8LeadByte !== undefined) {
|
|
328
|
+
const nextByte = data[0];
|
|
329
|
+
if (nextByte !== undefined && nextByte >= 0x80 && nextByte <= 0xbf) {
|
|
330
|
+
bytes = Buffer.concat([Buffer.from([this.#pendingSingleUtf8LeadByte]), data]);
|
|
331
|
+
this.#pendingSingleUtf8LeadByte = undefined;
|
|
332
|
+
} else {
|
|
333
|
+
const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta();
|
|
334
|
+
if (pendingMeta !== undefined) {
|
|
335
|
+
this.#emitDataSequence(pendingMeta);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (bytes.length === 1 && bytes[0]! > 127 && !this.#decoderHasPendingUtf8) {
|
|
341
|
+
const byte = bytes[0]!;
|
|
342
|
+
if (isUtf8LeadByte(byte)) {
|
|
343
|
+
this.#pendingSingleUtf8LeadByte = byte;
|
|
344
|
+
this.#decoderHasPendingUtf8 = true;
|
|
345
|
+
this.#timeout = setTimeout(() => {
|
|
346
|
+
const sequence = this.#consumePendingSingleUtf8LeadAsMeta();
|
|
347
|
+
if (sequence !== undefined) {
|
|
348
|
+
this.#emitDataSequence(sequence);
|
|
349
|
+
}
|
|
350
|
+
}, this.#timeoutMs);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
str = legacyMetaSequence(byte);
|
|
297
354
|
} else {
|
|
298
355
|
// Decode through the persistent StringDecoder so a multi-byte
|
|
299
356
|
// sequence split across chunks (e.g. a 3-byte Korean syllable)
|
|
300
357
|
// is reassembled instead of emitting U+FFFD.
|
|
301
|
-
str = this.#decoder.write(
|
|
358
|
+
str = this.#decoder.write(bytes);
|
|
302
359
|
decodedFromBuffer = true;
|
|
360
|
+
const allContinuationBytes = bytes.every(byte => byte >= 0x80 && byte <= 0xbf);
|
|
361
|
+
this.#decoderHasPendingUtf8 =
|
|
362
|
+
endsWithIncompleteUtf8Sequence(bytes) && !(hadPendingUtf8 && str.length > 0 && allContinuationBytes);
|
|
303
363
|
}
|
|
304
364
|
} else {
|
|
365
|
+
const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta();
|
|
366
|
+
if (pendingMeta !== undefined) {
|
|
367
|
+
this.#emitDataSequence(pendingMeta);
|
|
368
|
+
}
|
|
305
369
|
str = data;
|
|
306
370
|
}
|
|
307
371
|
|
|
@@ -348,6 +412,9 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
348
412
|
for (const sequence of result.sequences) {
|
|
349
413
|
this.#emitDataSequence(sequence);
|
|
350
414
|
}
|
|
415
|
+
if (result.remainder.length > 0) {
|
|
416
|
+
this.#emitDataSequence(result.remainder);
|
|
417
|
+
}
|
|
351
418
|
}
|
|
352
419
|
|
|
353
420
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
@@ -392,6 +459,13 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
392
459
|
}
|
|
393
460
|
}
|
|
394
461
|
|
|
462
|
+
#consumePendingSingleUtf8LeadAsMeta(): string | undefined {
|
|
463
|
+
const byte = this.#pendingSingleUtf8LeadByte;
|
|
464
|
+
if (byte === undefined) return undefined;
|
|
465
|
+
this.#pendingSingleUtf8LeadByte = undefined;
|
|
466
|
+
this.#decoderHasPendingUtf8 = false;
|
|
467
|
+
return legacyMetaSequence(byte);
|
|
468
|
+
}
|
|
395
469
|
#emitDataSequence(sequence: string): void {
|
|
396
470
|
const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
|
|
397
471
|
if (rawCodepoint !== undefined && rawCodepoint === this.#pendingKittyPrintableCodepoint) {
|
|
@@ -409,11 +483,13 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
409
483
|
this.#timeout = undefined;
|
|
410
484
|
}
|
|
411
485
|
|
|
486
|
+
const pendingMeta = this.#consumePendingSingleUtf8LeadAsMeta();
|
|
487
|
+
|
|
412
488
|
if (this.#buffer.length === 0) {
|
|
413
|
-
return [];
|
|
489
|
+
return pendingMeta === undefined ? [] : [pendingMeta];
|
|
414
490
|
}
|
|
415
491
|
|
|
416
|
-
const sequences = [this.#buffer];
|
|
492
|
+
const sequences = pendingMeta === undefined ? [this.#buffer] : [pendingMeta, this.#buffer];
|
|
417
493
|
this.#buffer = "";
|
|
418
494
|
this.#pendingKittyPrintableCodepoint = undefined;
|
|
419
495
|
return sequences;
|
|
@@ -432,6 +508,8 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
432
508
|
// stale partial prefix cannot combine with future input. destroy()
|
|
433
509
|
// resets the decoder by calling clear().
|
|
434
510
|
this.#decoder = new StringDecoder("utf8");
|
|
511
|
+
this.#decoderHasPendingUtf8 = false;
|
|
512
|
+
this.#pendingSingleUtf8LeadByte = undefined;
|
|
435
513
|
}
|
|
436
514
|
|
|
437
515
|
getBuffer(): string {
|
package/src/terminal.ts
CHANGED
|
@@ -207,6 +207,7 @@ export function resolveTerminalRows(
|
|
|
207
207
|
function isWindowsSubsystemForLinux(): boolean {
|
|
208
208
|
return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
|
|
209
209
|
}
|
|
210
|
+
const STDOUT_ERROR_HANDLER_GRACE_MS = 250;
|
|
210
211
|
|
|
211
212
|
/**
|
|
212
213
|
* Real terminal using process.stdin/stdout
|
|
@@ -225,6 +226,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
225
226
|
#detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
|
|
226
227
|
#windowsVTInputRestore?: () => void;
|
|
227
228
|
#stdoutErrorHandler?: (err: Error) => void;
|
|
229
|
+
#stdoutErrorHandlerCleanupTimer?: Timer;
|
|
228
230
|
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
229
231
|
#appearance: TerminalAppearance | undefined;
|
|
230
232
|
#osc11Pending = false;
|
|
@@ -277,10 +279,16 @@ export class ProcessTerminal implements Terminal {
|
|
|
277
279
|
|
|
278
280
|
// Set up resize handler immediately
|
|
279
281
|
process.stdout.on("resize", this.#resizeHandler);
|
|
280
|
-
this.#
|
|
281
|
-
this.#
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
if (this.#stdoutErrorHandlerCleanupTimer) {
|
|
283
|
+
clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
|
|
284
|
+
this.#stdoutErrorHandlerCleanupTimer = undefined;
|
|
285
|
+
}
|
|
286
|
+
if (!this.#stdoutErrorHandler) {
|
|
287
|
+
this.#stdoutErrorHandler = (err: Error) => {
|
|
288
|
+
this.#markUnavailable(err, "stdout-error");
|
|
289
|
+
};
|
|
290
|
+
process.stdout.on("error", this.#stdoutErrorHandler);
|
|
291
|
+
}
|
|
284
292
|
|
|
285
293
|
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
286
294
|
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
@@ -622,6 +630,20 @@ export class ProcessTerminal implements Terminal {
|
|
|
622
630
|
return;
|
|
623
631
|
}
|
|
624
632
|
this.#safeWrite("\x1b[?u");
|
|
633
|
+
// Windows Terminal and conhost do not implement the Kitty keyboard
|
|
634
|
+
// protocol, so the query above never activates it there. They do honor the
|
|
635
|
+
// modifyOtherKeys fallback below — but that mode breaks Windows CJK/Hangul
|
|
636
|
+
// IME composition: Alt+Enter (and other chords) bypass the IME commit, so
|
|
637
|
+
// the syllable still being composed is never delivered to the app and the
|
|
638
|
+
// action fires on empty text (e.g. queue-message no-ops unless the user
|
|
639
|
+
// types a trailing space to force a commit first). Skip the fallback on
|
|
640
|
+
// win32; legacy encodings still deliver Alt+Enter (ESC CR) and the newline
|
|
641
|
+
// chords, and IME composition works again. Opt back in with
|
|
642
|
+
// SKC_TUI_KEYBOARD_PROTOCOL=0 disabling all enhancement, or force-enable
|
|
643
|
+
// elsewhere if a Kitty-capable Windows terminal appears.
|
|
644
|
+
if (process.platform === "win32") {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
625
647
|
this.#modifyOtherKeysTimeout = setTimeout(() => {
|
|
626
648
|
this.#modifyOtherKeysTimeout = undefined;
|
|
627
649
|
if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
|
|
@@ -736,10 +758,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
736
758
|
process.stdout.removeListener("resize", this.#resizeHandler);
|
|
737
759
|
this.#resizeHandler = undefined;
|
|
738
760
|
}
|
|
739
|
-
|
|
740
|
-
process.stdout.removeListener("error", this.#stdoutErrorHandler);
|
|
741
|
-
this.#stdoutErrorHandler = undefined;
|
|
742
|
-
}
|
|
761
|
+
this.#scheduleStdoutErrorHandlerCleanup();
|
|
743
762
|
|
|
744
763
|
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
745
764
|
// re-interpreted after raw mode is disabled. This fixes a race condition
|
|
@@ -752,6 +771,23 @@ export class ProcessTerminal implements Terminal {
|
|
|
752
771
|
}
|
|
753
772
|
}
|
|
754
773
|
|
|
774
|
+
#scheduleStdoutErrorHandlerCleanup(): void {
|
|
775
|
+
if (!this.#stdoutErrorHandler) return;
|
|
776
|
+
if (this.#stdoutErrorHandlerCleanupTimer) clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
|
|
777
|
+
// Terminal restore writes above can fail asynchronously after stop() returns
|
|
778
|
+
// when an SSH/Windows Terminal PTY disappears. Keep the stdout error listener
|
|
779
|
+
// armed briefly so late EIO/EPIPE events mark the terminal unavailable instead
|
|
780
|
+
// of surfacing as uncaught exceptions that kill the tmux pane.
|
|
781
|
+
this.#stdoutErrorHandlerCleanupTimer = setTimeout(() => {
|
|
782
|
+
if (this.#stdoutErrorHandler) {
|
|
783
|
+
process.stdout.removeListener("error", this.#stdoutErrorHandler);
|
|
784
|
+
this.#stdoutErrorHandler = undefined;
|
|
785
|
+
}
|
|
786
|
+
this.#stdoutErrorHandlerCleanupTimer = undefined;
|
|
787
|
+
}, STDOUT_ERROR_HANDLER_GRACE_MS);
|
|
788
|
+
this.#stdoutErrorHandlerCleanupTimer.unref?.();
|
|
789
|
+
}
|
|
790
|
+
|
|
755
791
|
write(data: string): void {
|
|
756
792
|
this.#safeWrite(data);
|
|
757
793
|
if (this.#writeLogPath) {
|