@gajae-code/tui 0.18.10 → 0.18.12

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,5 +1,6 @@
1
1
  import { LRUCache } from "lru-cache/raw";
2
2
  import { Marked, marked, type Token, Tokenizer, type Tokens } from "marked";
3
+ import { hasSvgFence, isClosedSvgFence } from "../chat/svg-source";
3
4
  import type { SymbolTheme } from "../symbols";
4
5
  import { TERMINAL } from "../terminal-capabilities";
5
6
  import type { Component } from "../tui";
@@ -327,10 +328,12 @@ export function resetMarkdownHighlightCallCount(): void {
327
328
  export const __markdownPerfCounters = {
328
329
  lexerInvocations: 0,
329
330
  lexedBytes: 0,
331
+ svgFenceDetectionCalls: 0,
330
332
  highlightAdmissionScans: 0,
331
333
  reset(): void {
332
334
  this.lexerInvocations = 0;
333
335
  this.lexedBytes = 0;
336
+ this.svgFenceDetectionCalls = 0;
334
337
  this.highlightAdmissionScans = 0;
335
338
  },
336
339
  };
@@ -420,9 +423,29 @@ export interface MarkdownTheme {
420
423
  * Return null to fall back to fenced code rendering.
421
424
  */
422
425
  resolveMermaidAscii?: (source: string) => string | null;
426
+ /**
427
+ * Resolve an SVG figure component by fenced block source text. Components
428
+ * are retained by fence order. Return null for a stable fenced-code fallback
429
+ * that disposes a previous component; return undefined for a fallback that
430
+ * preserves the previous component and should be retried on the next render.
431
+ */
432
+ resolveSvgFigure?: (source: string, context: SvgFigureResolveContext) => Component | null | undefined;
423
433
  symbols: SymbolTheme;
424
434
  }
425
435
 
436
+ export interface SvgFigureResolveContext {
437
+ /** Whether the containing message is still streaming. */
438
+ streaming: boolean;
439
+ /** The individual code fence has received its closing marker. */
440
+ closed: boolean;
441
+ /** Request a repaint after the figure's asynchronous output changes. */
442
+ onChange: () => void;
443
+ /** Available render width in terminal cells. */
444
+ width: number;
445
+ /** The component previously resolved for this fence position, if any. */
446
+ previous?: Component;
447
+ }
448
+
426
449
  interface InlineStyleContext {
427
450
  applyText: (text: string) => string;
428
451
  stylePrefix: string;
@@ -496,6 +519,15 @@ export class Markdown implements Component {
496
519
  #lastFullParseAt = 0;
497
520
  #onStaleThrottle?: () => void;
498
521
  #staleThrottleTimer?: ReturnType<typeof setTimeout>;
522
+ #mayHaveDynamicSvgFigures: boolean;
523
+ #svgFenceDetectionPending = false;
524
+ #svgResolverAvailable: boolean;
525
+ #hasActiveDynamicSvgFigures = false;
526
+ #hasRetryableSvgFigureFallback = false;
527
+ #cachedImageProtocol: typeof TERMINAL.imageProtocol | undefined;
528
+ #dynamicFigures = new Map<number, Component>();
529
+ #nextDynamicFigureIndex = 0;
530
+ #disposed = false;
499
531
 
500
532
  constructor(
501
533
  text: string,
@@ -506,6 +538,9 @@ export class Markdown implements Component {
506
538
  codeBlockIndent: number = 2,
507
539
  ) {
508
540
  this.#text = text;
541
+ this.#svgResolverAvailable = theme.resolveSvgFigure !== undefined;
542
+ if (this.#svgResolverAvailable) __markdownPerfCounters.svgFenceDetectionCalls++;
543
+ this.#mayHaveDynamicSvgFigures = this.#svgResolverAvailable && hasSvgFence(text);
509
544
  this.#paddingX = paddingX;
510
545
  this.#paddingY = paddingY;
511
546
  this.#theme = theme;
@@ -522,7 +557,23 @@ export class Markdown implements Component {
522
557
  this.setStreaming(options.streaming);
523
558
  }
524
559
  if (this.#text !== text) this.#rejectedParseKey = undefined;
560
+ const textChanged = this.#text !== text;
525
561
  this.#text = text;
562
+ const svgResolverAvailable = this.#theme.resolveSvgFigure !== undefined;
563
+ const svgResolverChanged = svgResolverAvailable !== this.#svgResolverAvailable;
564
+ this.#svgResolverAvailable = svgResolverAvailable;
565
+ if (!svgResolverAvailable) {
566
+ this.#mayHaveDynamicSvgFigures = false;
567
+ this.#svgFenceDetectionPending = false;
568
+ } else if (this.#streaming) {
569
+ // Streaming writes already feed the throttled lexer; don't rescan the growing
570
+ // document here before that parse budget gets a chance to run.
571
+ if (textChanged || svgResolverChanged) this.#svgFenceDetectionPending = true;
572
+ } else {
573
+ __markdownPerfCounters.svgFenceDetectionCalls++;
574
+ this.#mayHaveDynamicSvgFigures = hasSvgFence(text);
575
+ this.#svgFenceDetectionPending = false;
576
+ }
526
577
  if (this.#streaming) {
527
578
  return;
528
579
  }
@@ -560,9 +611,11 @@ export class Markdown implements Component {
560
611
  }
561
612
 
562
613
  dispose(): void {
614
+ this.#disposed = true;
563
615
  this.#clearStaleThrottleTimer();
564
616
  this.#currentParse = undefined;
565
617
  this.#rejectedParseKey = undefined;
618
+ this.#disposeDynamicFiguresFrom(0);
566
619
  }
567
620
 
568
621
  invalidate(): void {
@@ -570,6 +623,48 @@ export class Markdown implements Component {
570
623
  this.#cachedWidth = undefined;
571
624
  this.#cachedLines = undefined;
572
625
  this.#cachedAnchorSpans = undefined;
626
+ this.#lastFullParseAt = 0;
627
+ this.#clearStaleThrottleTimer();
628
+ }
629
+
630
+ #disposeDynamicFiguresFrom(startIndex: number): void {
631
+ for (const [index, component] of this.#dynamicFigures) {
632
+ if (index < startIndex) continue;
633
+ this.#dynamicFigures.delete(index);
634
+ component.dispose?.();
635
+ }
636
+ }
637
+
638
+ #resolveDynamicFigure(source: string, closed: boolean, width: number): Component | undefined {
639
+ const index = this.#nextDynamicFigureIndex++;
640
+ const previous = this.#dynamicFigures.get(index);
641
+ const component = this.#theme.resolveSvgFigure?.(source, {
642
+ streaming: this.#streaming,
643
+ closed,
644
+ width,
645
+ onChange: () => {
646
+ if (this.#disposed) return;
647
+ this.invalidate();
648
+ this.#onStaleThrottle?.();
649
+ },
650
+ previous,
651
+ });
652
+ if (component) {
653
+ if (previous && previous !== component) previous.dispose?.();
654
+ this.#dynamicFigures.set(index, component);
655
+ this.#hasActiveDynamicSvgFigures = true;
656
+ return component;
657
+ }
658
+ if (component === undefined) {
659
+ this.#hasRetryableSvgFigureFallback = true;
660
+ if (previous) this.#dynamicFigures.set(index, previous);
661
+ } else if (previous) {
662
+ // A stable fallback no longer needs the component or any raster data it
663
+ // retains. A future source change can create a fresh component.
664
+ this.#dynamicFigures.delete(index);
665
+ previous.dispose?.();
666
+ }
667
+ return undefined;
573
668
  }
574
669
 
575
670
  #exceedsHighlightCap(code: string): boolean {
@@ -635,13 +730,19 @@ export class Markdown implements Component {
635
730
  }
636
731
 
637
732
  #render(width: number, includeAnchors: boolean): { lines: string[]; spans?: Array<ViewportAnchorSpan | null> } {
733
+ if (!this.#mayHaveDynamicSvgFigures) this.#disposeDynamicFiguresFrom(0);
734
+
638
735
  // L1: per-instance cache — fastest path for repeated renders of the same
639
736
  // instance at the same width (e.g. resize debounce, repeated redraws).
640
737
  const copyEnabled = copyAnnotationsEnabled();
641
738
  if (
739
+ !this.#hasActiveDynamicSvgFigures &&
740
+ !this.#hasRetryableSvgFigureFallback &&
741
+ !this.#svgFenceDetectionPending &&
642
742
  this.#cachedLines &&
643
743
  this.#cachedText === this.#text &&
644
744
  this.#cachedWidth === width &&
745
+ this.#cachedImageProtocol === TERMINAL.imageProtocol &&
645
746
  this.#cachedCopyEnabled === copyEnabled &&
646
747
  (!includeAnchors || this.#cachedAnchorSpans !== undefined)
647
748
  ) {
@@ -652,9 +753,12 @@ export class Markdown implements Component {
652
753
  const contentWidth = Math.max(1, width - this.#paddingX * 2);
653
754
 
654
755
  if (
756
+ !this.#hasActiveDynamicSvgFigures &&
757
+ !this.#hasRetryableSvgFigureFallback &&
655
758
  this.#streaming &&
656
759
  this.#cachedLines &&
657
760
  this.#cachedWidth === width &&
761
+ this.#cachedImageProtocol === TERMINAL.imageProtocol &&
658
762
  this.#cachedCopyEnabled === copyEnabled &&
659
763
  this.#lastFullParseAt > 0 &&
660
764
  (!includeAnchors || this.#cachedAnchorSpans !== undefined)
@@ -666,9 +770,15 @@ export class Markdown implements Component {
666
770
  }
667
771
  }
668
772
 
773
+ this.#hasActiveDynamicSvgFigures = false;
774
+ this.#hasRetryableSvgFigureFallback = false;
775
+
669
776
  // Don't render anything if there's no actual text
670
777
  if (!this.#text || this.#text.trim() === "") {
671
778
  this.#currentParse = undefined;
779
+ this.#mayHaveDynamicSvgFigures = false;
780
+ this.#svgFenceDetectionPending = false;
781
+ this.#disposeDynamicFiguresFrom(0);
672
782
  const result: string[] = [];
673
783
  // Update per-instance cache
674
784
  this.#cachedText = this.#text;
@@ -676,6 +786,7 @@ export class Markdown implements Component {
676
786
  this.#cachedLines = result;
677
787
  this.#cachedCopyEnabled = copyEnabled;
678
788
  this.#cachedAnchorSpans = includeAnchors ? [] : undefined;
789
+ this.#cachedImageProtocol = TERMINAL.imageProtocol;
679
790
  return { lines: result, spans: this.#cachedAnchorSpans };
680
791
  }
681
792
 
@@ -704,6 +815,8 @@ export class Markdown implements Component {
704
815
  const cacheKey = `${renderContentKey}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
705
816
  const cached = renderCache.get(cacheKey);
706
817
  if (
818
+ !this.#mayHaveDynamicSvgFigures &&
819
+ !this.#svgFenceDetectionPending &&
707
820
  cached !== undefined &&
708
821
  cached.source === renderCacheSource &&
709
822
  (!includeAnchors || cached.anchorSpans !== undefined)
@@ -714,6 +827,7 @@ export class Markdown implements Component {
714
827
  const lines = this.#bindCopySource(cached.lines, copyEnabled);
715
828
  this.#cachedLines = lines;
716
829
  this.#cachedAnchorSpans = cached.anchorSpans;
830
+ this.#cachedImageProtocol = TERMINAL.imageProtocol;
717
831
  if (!this.#streaming || this.#currentParse?.source !== normalizedText) this.#currentParse = undefined;
718
832
  return { lines, spans: cached.anchorSpans };
719
833
  }
@@ -732,6 +846,13 @@ export class Markdown implements Component {
732
846
  __markdownPerfCounters.lexedBytes += normalizedText.length;
733
847
  tokens = markdownParser.lexer(normalizedText);
734
848
  }
849
+ this.#mayHaveDynamicSvgFigures =
850
+ this.#theme.resolveSvgFigure !== undefined &&
851
+ tokens.some(token => {
852
+ if (token.type !== "code") return false;
853
+ return token.lang?.trim().split(/[ \t]/, 1)[0]?.toLowerCase() === "svg";
854
+ });
855
+ this.#svgFenceDetectionPending = false;
735
856
  if (this.#streaming && this.#text === renderedText) {
736
857
  this.#currentParse = { source: normalizedText, tokens };
737
858
  }
@@ -778,6 +899,7 @@ export class Markdown implements Component {
778
899
  codeSource?: string;
779
900
  }> = [];
780
901
  let srcOffset = 0;
902
+ this.#nextDynamicFigureIndex = 0;
781
903
 
782
904
  for (let i = 0; i < tokens.length; i++) {
783
905
  const token = tokens[i];
@@ -810,6 +932,7 @@ export class Markdown implements Component {
810
932
  codeTexts.length > 0 && codeTexts.every(text => text !== undefined) ? codeTexts.join("\n") : undefined,
811
933
  });
812
934
  }
935
+ this.#disposeDynamicFiguresFrom(this.#nextDynamicFigureIndex);
813
936
 
814
937
  let wrappedLines: string[];
815
938
  let wrappedSpans: Array<ViewportAnchorSpan | null> | undefined;
@@ -1104,17 +1227,28 @@ export class Markdown implements Component {
1104
1227
  }
1105
1228
  if (!this.#streaming || this.#text !== renderedText) this.#currentParse = undefined;
1106
1229
 
1107
- // Update L1 per-instance cache
1108
- this.#cachedText = renderedText;
1109
- this.#cachedWidth = width;
1230
+ // Active figures and retryable fallbacks must render again. A stable
1231
+ // fenced-code fallback is safe in this instance's L1 cache, but possible
1232
+ // SVG fences stay out of shared L2 because resolver state can vary by instance.
1110
1233
  const copyBoundResult = this.#bindCopySource(result, copyEnabled);
1111
- this.#cachedLines = copyBoundResult;
1112
- this.#cachedAnchorSpans = anchorSpans;
1113
- this.#lastFullParseAt = markdownNow();
1234
+ if (this.#hasActiveDynamicSvgFigures || this.#hasRetryableSvgFigureFallback) {
1235
+ this.#cachedText = undefined;
1236
+ this.#cachedWidth = undefined;
1237
+ this.#cachedLines = undefined;
1238
+ this.#cachedAnchorSpans = undefined;
1239
+ this.#cachedImageProtocol = undefined;
1240
+ } else {
1241
+ this.#cachedText = renderedText;
1242
+ this.#cachedWidth = width;
1243
+ this.#cachedLines = copyBoundResult;
1244
+ this.#cachedAnchorSpans = anchorSpans;
1245
+ this.#cachedImageProtocol = TERMINAL.imageProtocol;
1246
+ this.#lastFullParseAt = markdownNow();
1247
+ }
1114
1248
 
1115
1249
  // Update L2 module-level LRU so future instances with the same key skip
1116
1250
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
1117
- if (!this.#streaming) {
1251
+ if (!this.#streaming && !this.#mayHaveDynamicSvgFigures) {
1118
1252
  renderCache.set(cacheKey, {
1119
1253
  source: renderCacheSource,
1120
1254
  lines: result,
@@ -1229,6 +1363,7 @@ export class Markdown implements Component {
1229
1363
  nextTokenType?: string,
1230
1364
  styleContext?: InlineStyleContext,
1231
1365
  units?: number[],
1366
+ allowSvgFigure = true,
1232
1367
  ): string[] {
1233
1368
  const lines: string[] = [];
1234
1369
 
@@ -1278,6 +1413,22 @@ export class Markdown implements Component {
1278
1413
  }
1279
1414
  }
1280
1415
 
1416
+ // Handle SVG figures when available. Marked keeps the complete info
1417
+ // string in token.lang, so match its normalized language token.
1418
+ const language = token.lang?.trim().split(/[ \t]/, 1)[0]?.toLowerCase();
1419
+ if (allowSvgFigure && language === "svg" && this.#theme.resolveSvgFigure) {
1420
+ const figure = this.#resolveDynamicFigure(token.text, isClosedSvgFence(token.raw), width);
1421
+ if (figure) {
1422
+ // SVG figures are components that render directly
1423
+ const figureLines = figure.render(width);
1424
+ lines.push(...figureLines);
1425
+ if (nextTokenType && nextTokenType !== "space") {
1426
+ lines.push("");
1427
+ }
1428
+ break;
1429
+ }
1430
+ }
1431
+
1281
1432
  const codeIndent = padding(this.#codeBlockIndent);
1282
1433
  this.#emitCodeBlock(lines, token, token.text, token.lang || "", codeIndent);
1283
1434
  if (nextTokenType && nextTokenType !== "space") {
@@ -1334,6 +1485,8 @@ export class Markdown implements Component {
1334
1485
  quoteContentWidth,
1335
1486
  nextQuoteToken?.type,
1336
1487
  quoteInlineStyleContext,
1488
+ undefined,
1489
+ false,
1337
1490
  );
1338
1491
  for (let c = 0; c < childLines.length; c++) quoteLineUnit.push(i);
1339
1492
  renderedQuoteLines.push(...childLines);
package/src/tui.ts CHANGED
@@ -4,7 +4,14 @@
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import { performance } from "node:perf_hooks";
7
- import { $flag, $pickflag, getDebugLogPath, logger, onDefaultTabWidthChange } from "@gajae-code/utils";
7
+ import {
8
+ $flag,
9
+ $pickflag,
10
+ getDebugLogPath,
11
+ logger,
12
+ onDefaultTabWidthChange,
13
+ redactCrashSecrets,
14
+ } from "@gajae-code/utils";
8
15
  import { getKeybindings } from "./keybindings";
9
16
  import { isKeyRelease } from "./keys";
10
17
  import { renderMetrics } from "./metrics";
@@ -999,6 +1006,23 @@ function createPreparationCancellation(entry: RenderPreparation): () => void {
999
1006
 
1000
1007
  function cancelNoPreparation(): void {}
1001
1008
 
1009
+ function filesystemErrorCode(error: unknown): string | undefined {
1010
+ if (error === null || typeof error !== "object" || !("code" in error)) return undefined;
1011
+ return typeof error.code === "string" ? error.code : undefined;
1012
+ }
1013
+
1014
+ // JSON.stringify writes ESC as "\\u001b", and a CSI final is a letter, so a
1015
+ // following "Bearer" has no word boundary. Split that join, scrub, then restore.
1016
+ function redactTuiRenderDump(debugData: string): string {
1017
+ const separateCsiFinal = (match: string) => `${match}\u0000`;
1018
+ const separated = debugData
1019
+ .replace(/\u001b\[[0-9;?]*[A-Za-z]/g, separateCsiFinal)
1020
+ .replace(/\\u001[bB]\[[0-9;?]*[A-Za-z]/g, separateCsiFinal);
1021
+ return redactCrashSecrets(separated)
1022
+ .replace(/\u001b\[[0-9;?]*[A-Za-z]\u0000/g, match => match.slice(0, -1))
1023
+ .replace(/\\u001[bB]\[[0-9;?]*[A-Za-z]\u0000/g, match => match.slice(0, -1));
1024
+ }
1025
+
1002
1026
  /**
1003
1027
  * TUI - Main class for managing terminal UI with differential rendering
1004
1028
  */
@@ -1274,6 +1298,38 @@ export class TUI extends Container {
1274
1298
  fs.appendFileSync(getDebugLogPath(), message);
1275
1299
  }
1276
1300
 
1301
+ // Sibling of the debug log. Create the parent only when it is missing.
1302
+ // The dump file is opened exclusive and no-follow, then written through that fd.
1303
+ #writeRenderDebugDump(debugData: string): void {
1304
+ const debugDir = path.dirname(getDebugLogPath());
1305
+ if (!fs.existsSync(debugDir)) {
1306
+ fs.mkdirSync(debugDir, { recursive: true, mode: 0o700 });
1307
+ fs.chmodSync(debugDir, 0o700);
1308
+ }
1309
+ // stat follows a symlink to the real agent directory; a directory we do
1310
+ // not own is not a place for a session frame.
1311
+ const dirStat = fs.statSync(debugDir);
1312
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
1313
+ if (!dirStat.isDirectory() || (uid !== undefined && dirStat.uid !== uid)) return;
1314
+
1315
+ const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
1316
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW ?? 0);
1317
+ let fd: number;
1318
+ try {
1319
+ fd = fs.openSync(debugPath, flags, 0o600);
1320
+ } catch (error) {
1321
+ const code = filesystemErrorCode(error);
1322
+ if (code === "EEXIST" || code === "ELOOP") return;
1323
+ throw error;
1324
+ }
1325
+ try {
1326
+ fs.writeFileSync(fd, redactTuiRenderDump(debugData));
1327
+ fs.fchmodSync(fd, 0o600);
1328
+ } finally {
1329
+ fs.closeSync(fd);
1330
+ }
1331
+ }
1332
+
1277
1333
  #visibleWidthForDifferentialGuard(line: string): number {
1278
1334
  if (line.length === 0) return 0;
1279
1335
  const cached = this.#lineEmitWidthCache.get(line);
@@ -5240,6 +5296,7 @@ export class TUI extends Container {
5240
5296
  let usedWindowNormalize = false;
5241
5297
  let stitched = false;
5242
5298
  let frontierSpacerLineCount = 0;
5299
+ let strandedContractionReplay = false;
5243
5300
  if (reusedAnchor !== null && layoutPrefixEligible && !layoutPrefixBlocked) {
5244
5301
  const reused = this.#reuseCachedLayoutPrefix(
5245
5302
  width,
@@ -5288,7 +5345,24 @@ export class TUI extends Container {
5288
5345
  !transcriptRebuildPending &&
5289
5346
  frontierPreviousWidth === width &&
5290
5347
  (frontierPreviousHeight === height || this.#viewportRepaintHost());
5291
- const minimumLineCount = preserveLiveFrontier ? prevViewportTop + height : height;
5348
+ // Padding to the committed frontier keeps scrollback rows from being
5349
+ // repainted, but when the contraction ends the transcript at or above that
5350
+ // frontier the padded viewport holds nothing but spacer rows. Replay the
5351
+ // frame instead of leaving a blank screen above the pinned component. A
5352
+ // pinned suffix that already fills the viewport hides the spacers, so it
5353
+ // keeps the frontier and its scrollback.
5354
+ strandedContractionReplay =
5355
+ preserveLiveFrontier &&
5356
+ !heightChanged &&
5357
+ frontierPreviousHeight === height &&
5358
+ this.overlayStack.length === 0 &&
5359
+ prevViewportTop > 0 &&
5360
+ sourceTranscriptLineCount > 0 &&
5361
+ sourceTranscriptLineCount <= prevViewportTop &&
5362
+ newLines.length < prevViewportTop + height &&
5363
+ newLines.length - sourceTranscriptLineCount < height;
5364
+ const minimumLineCount =
5365
+ preserveLiveFrontier && !strandedContractionReplay ? prevViewportTop + height : height;
5292
5366
  const padded = this.#padBeforeBottomPinnedComponent(
5293
5367
  newLines,
5294
5368
  minimumLineCount,
@@ -5873,6 +5947,14 @@ export class TUI extends Container {
5873
5947
  fullRender(true, "clearOnShrink", true);
5874
5948
  return;
5875
5949
  }
5950
+ // Default (clearOnShrink off) still replays when frontier padding would leave the
5951
+ // viewport with no transcript row, e.g. a tall live block collapsing back into
5952
+ // history. Same scrollback erase as above, for the same duplication reason.
5953
+ if (strandedContractionReplay) {
5954
+ logRedraw(`stranded contraction (prev=${this.#previousLines.length}, new=${newLines.length})`);
5955
+ fullRender(true, "stranded contraction", true);
5956
+ return;
5957
+ }
5876
5958
 
5877
5959
  // Find first and last changed lines
5878
5960
  let firstChanged = -1;
@@ -6334,9 +6416,6 @@ export class TUI extends Container {
6334
6416
  buffer = this.#frameSynchronizedOutput(buffer);
6335
6417
 
6336
6418
  if ($pickflag("GJC_TUI_DEBUG", "PI_TUI_DEBUG")) {
6337
- const debugDir = "/tmp/tui";
6338
- fs.mkdirSync(debugDir, { recursive: true });
6339
- const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
6340
6419
  const debugData = [
6341
6420
  `firstChanged: ${firstChanged}`,
6342
6421
  `viewportTop: ${viewportTop}`,
@@ -6360,7 +6439,7 @@ export class TUI extends Container {
6360
6439
  "=== buffer ===",
6361
6440
  JSON.stringify(buffer),
6362
6441
  ].join("\n");
6363
- fs.writeFileSync(debugPath, debugData);
6442
+ this.#writeRenderDebugDump(debugData);
6364
6443
  }
6365
6444
 
6366
6445
  // Write entire buffer at once. Once those bytes are accepted, the painted