@gajae-code/tui 0.18.11 → 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.
- package/CHANGELOG.md +14 -0
- package/dist/types/chat/svg-figure.d.ts +26 -0
- package/dist/types/chat/svg-source.d.ts +32 -0
- package/dist/types/components/markdown.d.ts +20 -0
- package/package.json +3 -3
- package/src/chat/svg-figure.ts +405 -0
- package/src/chat/svg-source.ts +837 -0
- package/src/components/markdown.ts +160 -7
- package/src/tui.ts +58 -5
|
@@ -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
|
-
//
|
|
1108
|
-
this
|
|
1109
|
-
|
|
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.#
|
|
1112
|
-
|
|
1113
|
-
|
|
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 {
|
|
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);
|
|
@@ -6360,9 +6416,6 @@ export class TUI extends Container {
|
|
|
6360
6416
|
buffer = this.#frameSynchronizedOutput(buffer);
|
|
6361
6417
|
|
|
6362
6418
|
if ($pickflag("GJC_TUI_DEBUG", "PI_TUI_DEBUG")) {
|
|
6363
|
-
const debugDir = "/tmp/tui";
|
|
6364
|
-
fs.mkdirSync(debugDir, { recursive: true });
|
|
6365
|
-
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
6366
6419
|
const debugData = [
|
|
6367
6420
|
`firstChanged: ${firstChanged}`,
|
|
6368
6421
|
`viewportTop: ${viewportTop}`,
|
|
@@ -6386,7 +6439,7 @@ export class TUI extends Container {
|
|
|
6386
6439
|
"=== buffer ===",
|
|
6387
6440
|
JSON.stringify(buffer),
|
|
6388
6441
|
].join("\n");
|
|
6389
|
-
|
|
6442
|
+
this.#writeRenderDebugDump(debugData);
|
|
6390
6443
|
}
|
|
6391
6444
|
|
|
6392
6445
|
// Write entire buffer at once. Once those bytes are accepted, the painted
|