@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.
@@ -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
- // First (rows-1) lines are empty (TUI clears them)
68
- // Last line: move cursor back up, then output image sequence
69
- lines = [];
70
- for (let i = 0; i < result.rows - 1; i++) {
71
- lines.push("");
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)];
@@ -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
- #intervalId?: NodeJS.Timeout;
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
- this.#intervalId = setInterval(() => {
63
- const now = performance.now();
64
- if (now - this.#lastSpinnerTick >= SPINNER_ADVANCE_MS) {
65
- this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length;
66
- this.#lastSpinnerTick = now;
67
- }
68
- this.#updateDisplay();
69
- }, RENDER_INTERVAL_MS);
70
- // Don't let the animation timer keep the event loop alive on its own.
71
- this.#intervalId?.unref?.();
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.#intervalId) {
76
- clearInterval(this.#intervalId);
77
- this.#intervalId = undefined;
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
- setText(text: string): void {
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 = this.#submenuItemIndex;
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
@@ -1,5 +1,6 @@
1
1
  // Core TUI interfaces and classes
2
2
 
3
+ export * from "./animation-scheduler";
3
4
  // Autocomplete support
4
5
  export * from "./autocomplete";
5
6
  // Components
@@ -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
- // Legacy 8-bit meta: an isolated high byte (0x80-0xFF) is treated
290
- // as ESC + (byte - 128) for Alt/meta compatibility, BEFORE UTF-8
291
- // decoding. This is the one documented exception to UTF-8 boundary
292
- // decoding a lone high byte that is also a valid UTF-8 lead byte
293
- // is still read as meta — so such a byte is never fed to the decoder.
294
- if (data.length === 1 && data[0]! > 127) {
295
- const byte = data[0]! - 128;
296
- str = `\x1b${String.fromCharCode(byte)}`;
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(data);
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 {