@sayknow-cli/tui 0.3.7 → 0.3.9
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 +1 -0
- package/dist/types/components/editor.d.ts +11 -2
- package/dist/types/components/loader.d.ts +10 -1
- package/dist/types/components/markdown.d.ts +14 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/terminal-capabilities.d.ts +70 -2
- package/dist/types/terminal.d.ts +2 -0
- package/dist/types/tui.d.ts +22 -4
- package/dist/types/utils.d.ts +12 -0
- package/package.json +4 -4
- package/src/animation-scheduler.ts +99 -0
- package/src/autocomplete.ts +119 -96
- package/src/components/editor.ts +315 -175
- package/src/components/image.ts +43 -9
- package/src/components/loader.ts +36 -37
- package/src/components/markdown.ts +79 -2
- package/src/components/settings-list.ts +4 -2
- package/src/index.ts +1 -0
- package/src/stdin-buffer.ts +89 -11
- package/src/terminal-capabilities.ts +149 -7
- package/src/terminal.ts +7 -0
- package/src/tui.ts +274 -96
- package/src/utils.ts +77 -11
package/src/components/image.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getImageDimensions,
|
|
3
3
|
type ImageDimensions,
|
|
4
|
+
ImageProtocol,
|
|
4
5
|
imageFallback,
|
|
6
|
+
kittyImageId,
|
|
5
7
|
renderImage,
|
|
6
8
|
TERMINAL,
|
|
7
9
|
} from "../terminal-capabilities";
|
|
8
10
|
import type { Component } from "../tui";
|
|
9
11
|
|
|
12
|
+
// Monotonic placement id allocator (kitty `p=`). Each Image instance keeps a
|
|
13
|
+
// stable placement id so diff-renderer repaints replace its own placement
|
|
14
|
+
// instead of stacking new copies, while two components showing identical
|
|
15
|
+
// content (same image id) still coexist as distinct placements.
|
|
16
|
+
let nextPlacementId = 1;
|
|
17
|
+
function allocatePlacementId(): number {
|
|
18
|
+
const id = nextPlacementId;
|
|
19
|
+
nextPlacementId = nextPlacementId >= 0x7fffffff ? 1 : nextPlacementId + 1;
|
|
20
|
+
return id;
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
export interface ImageTheme {
|
|
11
24
|
fallbackColor: (str: string) => string;
|
|
12
25
|
}
|
|
@@ -26,6 +39,10 @@ export class Image implements Component {
|
|
|
26
39
|
|
|
27
40
|
#cachedLines?: string[];
|
|
28
41
|
#cachedWidth?: number;
|
|
42
|
+
// Kitty graphics: content-derived image id + per-instance placement id.
|
|
43
|
+
// Computed lazily so non-kitty terminals never pay the hash cost.
|
|
44
|
+
#kittyImageId?: number;
|
|
45
|
+
readonly #kittyPlacementId = allocatePlacementId();
|
|
29
46
|
|
|
30
47
|
constructor(
|
|
31
48
|
base64Data: string,
|
|
@@ -57,22 +74,39 @@ export class Image implements Component {
|
|
|
57
74
|
let lines: string[];
|
|
58
75
|
|
|
59
76
|
if (TERMINAL.imageProtocol) {
|
|
77
|
+
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
78
|
+
this.#kittyImageId ??= kittyImageId(this.#base64Data);
|
|
79
|
+
}
|
|
60
80
|
const result = renderImage(this.#base64Data, this.#dimensions, {
|
|
61
81
|
maxWidthCells: maxWidth,
|
|
62
82
|
maxHeightCells: this.#options.maxHeightCells,
|
|
83
|
+
imageId: this.#kittyImageId,
|
|
84
|
+
placementId: this.#kittyPlacementId,
|
|
63
85
|
});
|
|
64
86
|
|
|
65
87
|
if (result) {
|
|
66
|
-
// Return `rows` lines so TUI accounts for image height
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
88
|
+
// Return `rows` lines so the TUI accounts for the image height.
|
|
89
|
+
if (result.cursorNeutral) {
|
|
90
|
+
// Kitty a=p,C=1 placements neither move the cursor nor carry
|
|
91
|
+
// pixel data, so the escape lives on the FIRST row — the image
|
|
92
|
+
// anchors to that cell and no cursor-up trick is needed (the
|
|
93
|
+
// old CUU approach clamped at the viewport top edge and placed
|
|
94
|
+
// the image over transcript text when partially scrolled out).
|
|
95
|
+
lines = [result.sequence];
|
|
96
|
+
for (let i = 0; i < result.rows - 1; i++) {
|
|
97
|
+
lines.push("");
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
// iTerm2/SIXEL draw at the cursor and advance it: reserve
|
|
101
|
+
// rows-1 blank lines (TUI clears them), then move the cursor
|
|
102
|
+
// up and draw from the last line.
|
|
103
|
+
lines = [];
|
|
104
|
+
for (let i = 0; i < result.rows - 1; i++) {
|
|
105
|
+
lines.push("");
|
|
106
|
+
}
|
|
107
|
+
const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
|
|
108
|
+
lines.push(moveUp + result.sequence);
|
|
72
109
|
}
|
|
73
|
-
// Move cursor up to first row, then output image
|
|
74
|
-
const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
|
|
75
|
-
lines.push(moveUp + result.sequence);
|
|
76
110
|
} else {
|
|
77
111
|
const fallback = imageFallback(this.#mimeType, this.#dimensions, this.#options.filename);
|
|
78
112
|
lines = [this.#theme.fallbackColor(fallback)];
|
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.
|
|
@@ -213,9 +213,11 @@ export class SettingsList implements Component {
|
|
|
213
213
|
|
|
214
214
|
#closeSubmenu(): void {
|
|
215
215
|
this.#submenuComponent = null;
|
|
216
|
-
// Restore selection to the item that opened the submenu
|
|
216
|
+
// Restore selection to the item that opened the submenu, clamped against
|
|
217
|
+
// the current list because callers may replace items while the submenu is open.
|
|
217
218
|
if (this.#submenuItemIndex !== null) {
|
|
218
|
-
this.#selectedIndex =
|
|
219
|
+
this.#selectedIndex =
|
|
220
|
+
this.#items.length === 0 ? 0 : Math.min(this.#submenuItemIndex, Math.max(0, this.#items.length - 1));
|
|
219
221
|
this.#submenuItemIndex = null;
|
|
220
222
|
this.#notifySelectionChange();
|
|
221
223
|
}
|
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 {
|