@gajae-code/tui 0.18.8 → 0.18.10
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 +19 -0
- package/dist/types/tui.d.ts +2 -0
- package/dist/types/utils.d.ts +70 -0
- package/package.json +3 -3
- package/src/components/markdown.ts +484 -62
- package/src/tui.ts +503 -72
- package/src/utils.ts +196 -0
|
@@ -4,14 +4,126 @@ import type { SymbolTheme } from "../symbols";
|
|
|
4
4
|
import { TERMINAL } from "../terminal-capabilities";
|
|
5
5
|
import type { Component } from "../tui";
|
|
6
6
|
import {
|
|
7
|
+
annotateCopyOrigin,
|
|
8
|
+
annotateCopyRow,
|
|
7
9
|
applyBackgroundToLine,
|
|
10
|
+
copyAnnotationsEnabled,
|
|
11
|
+
getIndentation,
|
|
8
12
|
padding,
|
|
13
|
+
replaceCopyAnnotationSourceId,
|
|
9
14
|
replaceTabs,
|
|
10
15
|
type ViewportAnchorSpan,
|
|
11
16
|
visibleWidth,
|
|
12
17
|
wrapTextWithAnsi,
|
|
13
18
|
} from "../utils";
|
|
14
19
|
|
|
20
|
+
// Source text cannot supply renderer-owned decoration or wrap hints.
|
|
21
|
+
const copyHintNonce = crypto.randomUUID();
|
|
22
|
+
const COPY_CODE_ROW_PREFIX = `\x1b_AGJC_COPY_CODE:${copyHintNonce}:`;
|
|
23
|
+
const COPY_CODE_ROW_REGEX = new RegExp(COPY_CODE_ROW_PREFIX + String.raw`([a-z]+)\x1b\\`, "gu");
|
|
24
|
+
const copyCodeRow = (text: string): "open" | "content" | "close" | null => {
|
|
25
|
+
const match = COPY_CODE_ROW_REGEX.exec(text);
|
|
26
|
+
COPY_CODE_ROW_REGEX.lastIndex = 0;
|
|
27
|
+
return match?.[1] === "open" || match?.[1] === "content" || match?.[1] === "close" ? match[1] : null;
|
|
28
|
+
};
|
|
29
|
+
const stripCopyCodeRow = (text: string): string => text.replace(COPY_CODE_ROW_REGEX, "");
|
|
30
|
+
const COPY_TABLE_RANGES_PREFIX = `\x1b_AGJC_COPY_TABLE:${copyHintNonce}:`;
|
|
31
|
+
const COPY_TABLE_RANGES_REGEX = new RegExp(COPY_TABLE_RANGES_PREFIX + String.raw`([^\x1b]*)\x1b\\`, "gu");
|
|
32
|
+
const copyTableRanges = (text: string): Array<[number, number]> | undefined => {
|
|
33
|
+
const match = COPY_TABLE_RANGES_REGEX.exec(text);
|
|
34
|
+
COPY_TABLE_RANGES_REGEX.lastIndex = 0;
|
|
35
|
+
if (!match) return undefined;
|
|
36
|
+
if (match[1] === "-") return [];
|
|
37
|
+
if (!match[1]) return undefined;
|
|
38
|
+
const ranges = match[1].split(",").map(pair => pair.split("-").map(Number));
|
|
39
|
+
return ranges.every(range => range.length === 2 && Number.isSafeInteger(range[0]) && Number.isSafeInteger(range[1]))
|
|
40
|
+
? (ranges as Array<[number, number]>)
|
|
41
|
+
: undefined;
|
|
42
|
+
};
|
|
43
|
+
const stripCopyTableRanges = (text: string): string => text.replace(COPY_TABLE_RANGES_REGEX, "");
|
|
44
|
+
|
|
45
|
+
// A table row that continues the wrapped cells of the row above carries, per
|
|
46
|
+
// cell, the source text the soft wrap removed before that cell's fragment.
|
|
47
|
+
const COPY_TABLE_WRAP_PREFIX = `\x1b_AGJC_COPY_TABLE_WRAP:${copyHintNonce}:`;
|
|
48
|
+
const COPY_TABLE_WRAP_REGEX = new RegExp(COPY_TABLE_WRAP_PREFIX + String.raw`([^\x1b]*)\x1b\\`, "gu");
|
|
49
|
+
const copyTableWrap = (text: string): string[] | undefined => {
|
|
50
|
+
const match = COPY_TABLE_WRAP_REGEX.exec(text);
|
|
51
|
+
COPY_TABLE_WRAP_REGEX.lastIndex = 0;
|
|
52
|
+
if (!match) return undefined;
|
|
53
|
+
try {
|
|
54
|
+
const gaps: unknown = JSON.parse(decodeURIComponent(match[1]!));
|
|
55
|
+
return Array.isArray(gaps) && gaps.every(gap => typeof gap === "string") ? gaps : undefined;
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const stripCopyTableWrap = (text: string): string => text.replace(COPY_TABLE_WRAP_REGEX, "");
|
|
61
|
+
|
|
62
|
+
const COPY_QUOTE_DEPTH_PREFIX = `\x1b_AGJC_COPY_QUOTE_DEPTH:${copyHintNonce}:`;
|
|
63
|
+
const COPY_QUOTE_DEPTH_REGEX = new RegExp(COPY_QUOTE_DEPTH_PREFIX + String.raw`(\d+)\x1b\\`, "gu");
|
|
64
|
+
const copyQuoteDepth = (text: string): number => {
|
|
65
|
+
const match = COPY_QUOTE_DEPTH_REGEX.exec(text);
|
|
66
|
+
COPY_QUOTE_DEPTH_REGEX.lastIndex = 0;
|
|
67
|
+
return match ? Number(match[1]) : 0;
|
|
68
|
+
};
|
|
69
|
+
const stripCopyQuoteDepth = (text: string): string => text.replace(COPY_QUOTE_DEPTH_REGEX, "");
|
|
70
|
+
|
|
71
|
+
// Zero-width marker where a code or table row's own content begins. Its visible
|
|
72
|
+
// column on the final logical row measures every enclosing prefix (list indent,
|
|
73
|
+
// code indent) without each container having to report its width.
|
|
74
|
+
const COPY_ROW_START = `\x1b_AGJC_COPY_ROW_START:${copyHintNonce}\x1b\\`;
|
|
75
|
+
const copyRowStartColumn = (line: string): number => {
|
|
76
|
+
const at = line.indexOf(COPY_ROW_START);
|
|
77
|
+
return at < 0 ? 0 : visibleWidth(line.slice(0, at));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const COPY_QUOTE_CONTINUATION_PREFIX = `\x1b_AGJC_COPY_QUOTE_WRAP:${copyHintNonce}:`;
|
|
81
|
+
const COPY_QUOTE_CONTINUATION_REGEX = new RegExp(COPY_QUOTE_CONTINUATION_PREFIX + String.raw`([^\x1b]*)\x1b\\`, "gu");
|
|
82
|
+
const stripCopyQuoteContinuation = (text: string): string => text.replace(COPY_QUOTE_CONTINUATION_REGEX, "");
|
|
83
|
+
const stripCopyHints = (text: string): string =>
|
|
84
|
+
text.includes("\x1b_AGJC_COPY_")
|
|
85
|
+
? stripCopyTableWrap(
|
|
86
|
+
stripCopyTableRanges(
|
|
87
|
+
stripCopyCodeRow(stripCopyQuoteDepth(stripCopyQuoteContinuation(text))).replaceAll(COPY_ROW_START, ""),
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
: text;
|
|
91
|
+
/**
|
|
92
|
+
* Pair each rendered code token, at any nesting depth, with the original-spelling
|
|
93
|
+
* text of the code token at the same tree position. A pair is kept only when the
|
|
94
|
+
* texts agree once tabs are dropped: marked re-expands list-item tabs to its own
|
|
95
|
+
* stops, so exact tab-replaced equality would refuse every list code block, and a
|
|
96
|
+
* tab-induced structure change still cannot restore another block's text.
|
|
97
|
+
*/
|
|
98
|
+
const pairOriginalCode = (rendered: unknown, original: unknown, pairs: Map<Token, string>): void => {
|
|
99
|
+
if (Array.isArray(rendered)) {
|
|
100
|
+
if (!Array.isArray(original)) return;
|
|
101
|
+
for (const [index, child] of rendered.entries()) pairOriginalCode(child, original[index], pairs);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (typeof rendered !== "object" || rendered === null || typeof original !== "object" || original === null) return;
|
|
105
|
+
const token = rendered as Token;
|
|
106
|
+
const source = original as Token;
|
|
107
|
+
if (token.type !== source.type) return;
|
|
108
|
+
if (token.type === "code" && source.type === "code") {
|
|
109
|
+
const bare = (text: string): string => text.replace(/[\t ]+/gu, " ");
|
|
110
|
+
if (bare(source.text) === bare(token.text)) pairs.set(token, source.text);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if ("tokens" in token) pairOriginalCode(token.tokens, "tokens" in source ? source.tokens : undefined, pairs);
|
|
114
|
+
if ("items" in token) pairOriginalCode(token.items, "items" in source ? source.items : undefined, pairs);
|
|
115
|
+
};
|
|
116
|
+
const copyQuoteGap = (text: string): string => {
|
|
117
|
+
const match = COPY_QUOTE_CONTINUATION_REGEX.exec(text);
|
|
118
|
+
COPY_QUOTE_CONTINUATION_REGEX.lastIndex = 0;
|
|
119
|
+
if (!match) return "";
|
|
120
|
+
try {
|
|
121
|
+
return decodeURIComponent(match[1]!);
|
|
122
|
+
} catch {
|
|
123
|
+
return "";
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
15
127
|
const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
|
|
16
128
|
|
|
17
129
|
class StrictStrikethroughTokenizer extends Tokenizer {
|
|
@@ -48,6 +160,7 @@ markdownParser.setOptions({
|
|
|
48
160
|
const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
|
|
49
161
|
const DOCUMENT_CACHE_SIZE = 8 * 1024 * 1024;
|
|
50
162
|
const DOCUMENT_ENTRY_SIZE = 1024 * 1024;
|
|
163
|
+
const CACHED_COPY_SOURCE_ID = "cached";
|
|
51
164
|
|
|
52
165
|
/** Account retained payload, not live heap. Stop once admission is impossible. */
|
|
53
166
|
export function getMarkdownCacheEntryAccountedSize(key: string, value: unknown, cap: number): number {
|
|
@@ -364,6 +477,12 @@ export class Markdown implements Component {
|
|
|
364
477
|
#defaultStylePrefix?: string;
|
|
365
478
|
/** Number of spaces used to indent code block content. */
|
|
366
479
|
#codeBlockIndent: number;
|
|
480
|
+
#copySourceId = crypto.randomUUID();
|
|
481
|
+
#cachedCopyEnabled = false;
|
|
482
|
+
/** Original-spelling lex, reused while the text is unchanged (streaming re-renders). */
|
|
483
|
+
#originalParse?: { source: string; tokens: Token[] };
|
|
484
|
+
/** Code tokens emitted as code rows while rendering one top-level token, in row order. */
|
|
485
|
+
#emittedCode: Token[] = [];
|
|
367
486
|
|
|
368
487
|
// Cache for rendered output
|
|
369
488
|
#cachedText?: string;
|
|
@@ -483,35 +602,47 @@ export class Markdown implements Component {
|
|
|
483
602
|
return result;
|
|
484
603
|
}
|
|
485
604
|
|
|
486
|
-
|
|
487
|
-
|
|
605
|
+
/** `token` is the rendered code token; copy rows map it back to its original text. */
|
|
606
|
+
#emitCodeBlock(lines: string[], token: Token, code: string, lang: string, codeIndent: string): void {
|
|
607
|
+
this.#emittedCode.push(token);
|
|
608
|
+
lines.push(`${this.#theme.codeBlockBorder(`\`\`\`${lang}`)}${COPY_CODE_ROW_PREFIX}open\x1b\\`);
|
|
488
609
|
const highlighted = this.#highlightCodeBlock(code, lang);
|
|
489
610
|
if (highlighted) {
|
|
490
611
|
for (const hlLine of highlighted) {
|
|
491
|
-
lines.push(`${codeIndent}${hlLine}
|
|
612
|
+
lines.push(`${COPY_ROW_START}${codeIndent}${hlLine}${COPY_CODE_ROW_PREFIX}content\x1b\\`);
|
|
492
613
|
}
|
|
493
614
|
} else {
|
|
494
615
|
if (this.#theme.highlightCode && this.#exceedsHighlightCap(code)) {
|
|
495
616
|
lines.push(`${codeIndent}${this.#theme.codeBlock("[syntax highlighting skipped: code block too large]")}`);
|
|
496
617
|
}
|
|
497
618
|
for (const codeLine of code.split("\n")) {
|
|
498
|
-
lines.push(
|
|
619
|
+
lines.push(
|
|
620
|
+
`${COPY_ROW_START}${codeIndent}${this.#theme.codeBlock(codeLine)}${COPY_CODE_ROW_PREFIX}content\x1b\\`,
|
|
621
|
+
);
|
|
499
622
|
}
|
|
500
623
|
}
|
|
501
|
-
lines.push(this.#theme.codeBlockBorder("```"));
|
|
624
|
+
lines.push(`${this.#theme.codeBlockBorder("```")}${COPY_CODE_ROW_PREFIX}close\x1b\\`);
|
|
502
625
|
}
|
|
503
626
|
|
|
504
627
|
render(width: number): string[] {
|
|
505
628
|
return this.#render(width, false).lines;
|
|
506
629
|
}
|
|
507
630
|
|
|
631
|
+
/** Bind cached rows to this instance, or drop copy data when no copying TUI runs. */
|
|
632
|
+
#bindCopySource(lines: string[], copyEnabled: boolean): string[] {
|
|
633
|
+
this.#cachedCopyEnabled = copyEnabled;
|
|
634
|
+
return copyEnabled ? lines.map(line => replaceCopyAnnotationSourceId(line, this.#copySourceId)) : lines;
|
|
635
|
+
}
|
|
636
|
+
|
|
508
637
|
#render(width: number, includeAnchors: boolean): { lines: string[]; spans?: Array<ViewportAnchorSpan | null> } {
|
|
509
638
|
// L1: per-instance cache — fastest path for repeated renders of the same
|
|
510
639
|
// instance at the same width (e.g. resize debounce, repeated redraws).
|
|
640
|
+
const copyEnabled = copyAnnotationsEnabled();
|
|
511
641
|
if (
|
|
512
642
|
this.#cachedLines &&
|
|
513
643
|
this.#cachedText === this.#text &&
|
|
514
644
|
this.#cachedWidth === width &&
|
|
645
|
+
this.#cachedCopyEnabled === copyEnabled &&
|
|
515
646
|
(!includeAnchors || this.#cachedAnchorSpans !== undefined)
|
|
516
647
|
) {
|
|
517
648
|
return { lines: this.#cachedLines, spans: this.#cachedAnchorSpans };
|
|
@@ -524,6 +655,7 @@ export class Markdown implements Component {
|
|
|
524
655
|
this.#streaming &&
|
|
525
656
|
this.#cachedLines &&
|
|
526
657
|
this.#cachedWidth === width &&
|
|
658
|
+
this.#cachedCopyEnabled === copyEnabled &&
|
|
527
659
|
this.#lastFullParseAt > 0 &&
|
|
528
660
|
(!includeAnchors || this.#cachedAnchorSpans !== undefined)
|
|
529
661
|
) {
|
|
@@ -542,6 +674,7 @@ export class Markdown implements Component {
|
|
|
542
674
|
this.#cachedText = this.#text;
|
|
543
675
|
this.#cachedWidth = width;
|
|
544
676
|
this.#cachedLines = result;
|
|
677
|
+
this.#cachedCopyEnabled = copyEnabled;
|
|
545
678
|
this.#cachedAnchorSpans = includeAnchors ? [] : undefined;
|
|
546
679
|
return { lines: result, spans: this.#cachedAnchorSpans };
|
|
547
680
|
}
|
|
@@ -551,6 +684,15 @@ export class Markdown implements Component {
|
|
|
551
684
|
const normalizedText = replaceTabs(renderedText);
|
|
552
685
|
this.#clearStaleThrottleTimer();
|
|
553
686
|
const contentKey = markdownContentKey(normalizedText);
|
|
687
|
+
// Annotated rows carry original (tab-preserving) spelling, so they are keyed
|
|
688
|
+
// and verified by the original text and never shared with stock entries. The
|
|
689
|
+
// tab-expanded key stays in the copy key so a tab-width change misses.
|
|
690
|
+
const renderCacheSource = copyEnabled ? renderedText : normalizedText;
|
|
691
|
+
const renderContentKey = copyEnabled
|
|
692
|
+
? renderedText === normalizedText
|
|
693
|
+
? `copy:${contentKey}`
|
|
694
|
+
: `copy:${contentKey}:${markdownContentKey(renderedText)}`
|
|
695
|
+
: contentKey;
|
|
554
696
|
|
|
555
697
|
// L2: module-level LRU — survives component disposal/recreation across
|
|
556
698
|
// session-tree navigations. Key encodes every dimension that affects the
|
|
@@ -559,20 +701,21 @@ export class Markdown implements Component {
|
|
|
559
701
|
// and verify it on hit so hash collisions can never serve wrong output.
|
|
560
702
|
const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
|
|
561
703
|
const headingProbe = this.#theme.heading("");
|
|
562
|
-
const cacheKey = `${
|
|
704
|
+
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}`;
|
|
563
705
|
const cached = renderCache.get(cacheKey);
|
|
564
706
|
if (
|
|
565
707
|
cached !== undefined &&
|
|
566
|
-
cached.source ===
|
|
708
|
+
cached.source === renderCacheSource &&
|
|
567
709
|
(!includeAnchors || cached.anchorSpans !== undefined)
|
|
568
710
|
) {
|
|
569
711
|
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
|
|
570
712
|
this.#cachedText = renderedText;
|
|
571
713
|
this.#cachedWidth = width;
|
|
572
|
-
|
|
714
|
+
const lines = this.#bindCopySource(cached.lines, copyEnabled);
|
|
715
|
+
this.#cachedLines = lines;
|
|
573
716
|
this.#cachedAnchorSpans = cached.anchorSpans;
|
|
574
717
|
if (!this.#streaming || this.#currentParse?.source !== normalizedText) this.#currentParse = undefined;
|
|
575
|
-
return { lines
|
|
718
|
+
return { lines, spans: cached.anchorSpans };
|
|
576
719
|
}
|
|
577
720
|
|
|
578
721
|
// Parse markdown to marked tokens. Parse cache is width/theme independent,
|
|
@@ -592,6 +735,30 @@ export class Markdown implements Component {
|
|
|
592
735
|
if (this.#streaming && this.#text === renderedText) {
|
|
593
736
|
this.#currentParse = { source: normalizedText, tokens };
|
|
594
737
|
}
|
|
738
|
+
// Keep original token spelling (notably code tabs) separate from normalized
|
|
739
|
+
// render tokens. It is used only as local row source or a one-row token copy.
|
|
740
|
+
let originalTokens = tokens;
|
|
741
|
+
if (copyEnabled && renderedText !== normalizedText) {
|
|
742
|
+
if (this.#originalParse?.source !== renderedText) {
|
|
743
|
+
__markdownPerfCounters.lexerInvocations += 1;
|
|
744
|
+
__markdownPerfCounters.lexedBytes += renderedText.length;
|
|
745
|
+
this.#originalParse = { source: renderedText, tokens: markdownParser.lexer(renderedText) };
|
|
746
|
+
}
|
|
747
|
+
originalTokens = this.#originalParse.tokens;
|
|
748
|
+
}
|
|
749
|
+
// Tab expansion can change block structure (an indented-code tab becomes a
|
|
750
|
+
// paragraph), shifting token indexes. An original token is trusted only when
|
|
751
|
+
// it spells exactly the rendered token.
|
|
752
|
+
const originalAt = (i: number): Token | undefined => {
|
|
753
|
+
const original = originalTokens[i];
|
|
754
|
+
const raw = (tokens[i] as { raw?: unknown } | undefined)?.raw;
|
|
755
|
+
return original && typeof original.raw === "string" && replaceTabs(original.raw) === raw
|
|
756
|
+
? original
|
|
757
|
+
: undefined;
|
|
758
|
+
};
|
|
759
|
+
// Original code text for code blocks at any depth (inside quotes and lists too).
|
|
760
|
+
const originalCode = new Map<Token, string>();
|
|
761
|
+
if (copyEnabled) pairOriginalCode(tokens, originalTokens, originalCode);
|
|
595
762
|
|
|
596
763
|
// Convert tokens to styled terminal output. When anchoring, record each
|
|
597
764
|
// top-level token's rendered-line range plus a width-invariant source-text
|
|
@@ -599,7 +766,17 @@ export class Markdown implements Component {
|
|
|
599
766
|
// numbered over stable source identities instead of the rendered glyph
|
|
600
767
|
// stream (#2031).
|
|
601
768
|
const renderedLines: string[] = [];
|
|
602
|
-
const tokenBoundaries: Array<{
|
|
769
|
+
const tokenBoundaries: Array<{
|
|
770
|
+
start: number;
|
|
771
|
+
end: number;
|
|
772
|
+
srcLo: number;
|
|
773
|
+
srcHi: number;
|
|
774
|
+
units: number[];
|
|
775
|
+
token: number;
|
|
776
|
+
kind: string;
|
|
777
|
+
tokenSource: string;
|
|
778
|
+
codeSource?: string;
|
|
779
|
+
}> = [];
|
|
603
780
|
let srcOffset = 0;
|
|
604
781
|
|
|
605
782
|
for (let i = 0; i < tokens.length; i++) {
|
|
@@ -607,28 +784,92 @@ export class Markdown implements Component {
|
|
|
607
784
|
const nextToken = tokens[i + 1];
|
|
608
785
|
const tokenStart = renderedLines.length;
|
|
609
786
|
const units: number[] | undefined = includeAnchors ? [] : undefined;
|
|
787
|
+
this.#emittedCode = [];
|
|
610
788
|
const tokenLines = this.#renderToken(token, contentWidth, nextToken?.type, undefined, units);
|
|
789
|
+
const codeTexts = this.#emittedCode.map(code => originalCode.get(code));
|
|
790
|
+
this.#emittedCode = [];
|
|
611
791
|
renderedLines.push(...tokenLines);
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
792
|
+
const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
|
|
793
|
+
const srcLo = srcOffset;
|
|
794
|
+
srcOffset += Math.max(1, raw.length);
|
|
795
|
+
// Units must align 1:1 with rendered lines; semantic copy uses the same
|
|
796
|
+
// token ownership even when viewport anchors are not requested.
|
|
797
|
+
const lineUnits =
|
|
798
|
+
units && units.length === tokenLines.length ? units : new Array<number>(tokenLines.length).fill(0);
|
|
799
|
+
tokenBoundaries.push({
|
|
800
|
+
start: tokenStart,
|
|
801
|
+
end: renderedLines.length,
|
|
802
|
+
srcLo,
|
|
803
|
+
srcHi: srcOffset,
|
|
804
|
+
units: lineUnits,
|
|
805
|
+
token: i,
|
|
806
|
+
kind: token.type,
|
|
807
|
+
tokenSource: (copyEnabled ? originalAt(i)?.raw : undefined) ?? raw,
|
|
808
|
+
// Code rows of every emitted block map, in order, onto their original lines.
|
|
809
|
+
codeSource:
|
|
810
|
+
codeTexts.length > 0 && codeTexts.every(text => text !== undefined) ? codeTexts.join("\n") : undefined,
|
|
811
|
+
});
|
|
628
812
|
}
|
|
629
813
|
|
|
630
814
|
let wrappedLines: string[];
|
|
631
815
|
let wrappedSpans: Array<ViewportAnchorSpan | null> | undefined;
|
|
816
|
+
const wrappedCopyRows: Array<{
|
|
817
|
+
token: number;
|
|
818
|
+
kind: string;
|
|
819
|
+
/** Row-local source; filled from the fragment (or original code text) below. */
|
|
820
|
+
source: string;
|
|
821
|
+
fragment: string;
|
|
822
|
+
logical: string;
|
|
823
|
+
continuation: boolean;
|
|
824
|
+
joinGap: string;
|
|
825
|
+
tokenSource?: string;
|
|
826
|
+
/** Position of this row among its token's rendered rows. */
|
|
827
|
+
tokenRow: number;
|
|
828
|
+
/** The token's rendered row count, set with `tokenSource` on its last row. */
|
|
829
|
+
tokenRows?: number;
|
|
830
|
+
/** `source` is the row's original-spelling code slice. */
|
|
831
|
+
originalCode?: true;
|
|
832
|
+
codeRow: "open" | "content" | "close" | null;
|
|
833
|
+
ranges?: Array<[number, number]>;
|
|
834
|
+
cellGaps?: string[];
|
|
835
|
+
quoteDepth: number;
|
|
836
|
+
/** Visible column where a code/table row's own content begins on the logical row. */
|
|
837
|
+
rowStart: number;
|
|
838
|
+
} | null> = [];
|
|
839
|
+
// Copy rows exist only while a copying TUI runs; stock renders skip the work.
|
|
840
|
+
const pushCopyRows = (boundary: (typeof tokenBoundaries)[number], line: string, wrapped: string[]): void => {
|
|
841
|
+
if (!copyEnabled) return;
|
|
842
|
+
const logical = stripCopyHints(Bun.stripANSI(line));
|
|
843
|
+
const continued = line.includes(COPY_QUOTE_CONTINUATION_PREFIX);
|
|
844
|
+
const joinGap = copyQuoteGap(line);
|
|
845
|
+
const codeRow = copyCodeRow(line);
|
|
846
|
+
const rowStart = copyRowStartColumn(line);
|
|
847
|
+
// Table ranges are table-local; enclosing quote/list prefixes shift them.
|
|
848
|
+
const ranges = copyTableRanges(line)?.map(([start, end]): [number, number] => [
|
|
849
|
+
start + rowStart,
|
|
850
|
+
end + rowStart,
|
|
851
|
+
]);
|
|
852
|
+
const quoteDepth = copyQuoteDepth(line);
|
|
853
|
+
const cellGaps = copyTableWrap(line);
|
|
854
|
+
for (let wrapIndex = 0; wrapIndex < wrapped.length; wrapIndex++) {
|
|
855
|
+
wrappedCopyRows.push({
|
|
856
|
+
token: boundary.token,
|
|
857
|
+
kind: boundary.kind,
|
|
858
|
+
source: "",
|
|
859
|
+
fragment: stripCopyHints(Bun.stripANSI(wrapped[wrapIndex]!)),
|
|
860
|
+
logical,
|
|
861
|
+
continuation: wrapIndex > 0 || continued,
|
|
862
|
+
joinGap,
|
|
863
|
+
// Filled per token once all of its rows are known.
|
|
864
|
+
tokenRow: 0,
|
|
865
|
+
codeRow,
|
|
866
|
+
ranges,
|
|
867
|
+
cellGaps,
|
|
868
|
+
quoteDepth,
|
|
869
|
+
rowStart,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
};
|
|
632
873
|
if (includeAnchors) {
|
|
633
874
|
wrappedLines = [];
|
|
634
875
|
const spans: Array<ViewportAnchorSpan | null> = [];
|
|
@@ -659,6 +900,7 @@ export class Markdown implements Component {
|
|
|
659
900
|
const lineWrapped = TERMINAL.isImageLine(renderedLine)
|
|
660
901
|
? [renderedLine]
|
|
661
902
|
: wrapTextIfNeeded(renderedLine, contentWidth);
|
|
903
|
+
pushCopyRows(boundary, renderedLine, lineWrapped);
|
|
662
904
|
for (const w of lineWrapped) {
|
|
663
905
|
wrappedLines.push(w);
|
|
664
906
|
spans.push(null);
|
|
@@ -689,9 +931,94 @@ export class Markdown implements Component {
|
|
|
689
931
|
wrappedSpans = spans;
|
|
690
932
|
} else {
|
|
691
933
|
wrappedLines = [];
|
|
692
|
-
for (const
|
|
693
|
-
|
|
694
|
-
|
|
934
|
+
for (const boundary of tokenBoundaries) {
|
|
935
|
+
for (let lineIndex = boundary.start; lineIndex < boundary.end; lineIndex++) {
|
|
936
|
+
const line = renderedLines[lineIndex]!;
|
|
937
|
+
const lineWrapped = TERMINAL.isImageLine(line) ? [line] : wrapTextIfNeeded(line, contentWidth);
|
|
938
|
+
pushCopyRows(boundary, line, lineWrapped);
|
|
939
|
+
wrappedLines.push(...lineWrapped);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const copyRowsByToken = new Map<number, number[]>();
|
|
945
|
+
for (let index = 0; index < wrappedCopyRows.length; index++) {
|
|
946
|
+
const row = wrappedCopyRows[index];
|
|
947
|
+
if (!row) continue;
|
|
948
|
+
const indexes = copyRowsByToken.get(row.token) ?? [];
|
|
949
|
+
indexes.push(index);
|
|
950
|
+
copyRowsByToken.set(row.token, indexes);
|
|
951
|
+
}
|
|
952
|
+
for (const indexes of copyRowsByToken.values()) {
|
|
953
|
+
const first = wrappedCopyRows[indexes[0]!];
|
|
954
|
+
if (!first) continue;
|
|
955
|
+
const boundary = tokenBoundaries[first.token]!;
|
|
956
|
+
let logical = "";
|
|
957
|
+
let cursor = 0;
|
|
958
|
+
let codeLine = -1;
|
|
959
|
+
const originalCodeLines = (boundary.codeSource ?? "").split("\n");
|
|
960
|
+
let original = "";
|
|
961
|
+
let display = "";
|
|
962
|
+
let codeOriginalCursor = 0;
|
|
963
|
+
let codeDisplayCursor = 0;
|
|
964
|
+
for (const [position, index] of indexes.entries()) {
|
|
965
|
+
const row = wrappedCopyRows[index];
|
|
966
|
+
if (!row) continue;
|
|
967
|
+
row.tokenRow = position;
|
|
968
|
+
if (!row.continuation) {
|
|
969
|
+
if (row.codeRow === "content") {
|
|
970
|
+
codeLine += 1;
|
|
971
|
+
original = originalCodeLines[codeLine] ?? "";
|
|
972
|
+
display = replaceTabs(original);
|
|
973
|
+
codeOriginalCursor = 0;
|
|
974
|
+
codeDisplayCursor = 0;
|
|
975
|
+
}
|
|
976
|
+
logical = row.logical;
|
|
977
|
+
cursor = 0;
|
|
978
|
+
row.joinGap = "";
|
|
979
|
+
} else {
|
|
980
|
+
const at = logical.indexOf(row.fragment, cursor);
|
|
981
|
+
if (!row.joinGap) row.joinGap = at < 0 ? "" : logical.slice(cursor, at);
|
|
982
|
+
}
|
|
983
|
+
const at = logical.indexOf(row.fragment, cursor);
|
|
984
|
+
if (at >= 0) cursor = at + row.fragment.length;
|
|
985
|
+
// Row metadata stays row-local: a wrapped paragraph must not replicate
|
|
986
|
+
// its complete logical line into every APC payload, and a growing token
|
|
987
|
+
// must not change rows that are already painted.
|
|
988
|
+
row.source = row.fragment;
|
|
989
|
+
if (row.codeRow === "content" && boundary.codeSource !== undefined) {
|
|
990
|
+
// A wrapped nested code row repeats its quote borders before the code text.
|
|
991
|
+
const visible = row.continuation
|
|
992
|
+
? row.fragment.slice(row.quoteDepth * visibleWidth(`${this.#theme.symbols.quoteBorder} `))
|
|
993
|
+
: row.fragment.slice(row.rowStart + this.#codeBlockIndent);
|
|
994
|
+
const start = display.indexOf(visible, codeDisplayCursor);
|
|
995
|
+
if (start >= 0) {
|
|
996
|
+
let originalStart = codeOriginalCursor;
|
|
997
|
+
let displayOffset = codeDisplayCursor;
|
|
998
|
+
while (originalStart < original.length && displayOffset < start) {
|
|
999
|
+
displayOffset += original[originalStart] === "\t" ? getIndentation() : 1;
|
|
1000
|
+
originalStart++;
|
|
1001
|
+
}
|
|
1002
|
+
let originalEnd = originalStart;
|
|
1003
|
+
while (originalEnd < original.length && displayOffset < start + visible.length) {
|
|
1004
|
+
displayOffset += original[originalEnd] === "\t" ? getIndentation() : 1;
|
|
1005
|
+
originalEnd++;
|
|
1006
|
+
}
|
|
1007
|
+
if (row.continuation) row.joinGap = original.slice(codeOriginalCursor, originalStart);
|
|
1008
|
+
row.source = original.slice(originalStart, originalEnd);
|
|
1009
|
+
row.originalCode = true;
|
|
1010
|
+
codeOriginalCursor = originalEnd;
|
|
1011
|
+
codeDisplayCursor = displayOffset;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// The whole-token source rides on the token's last row. It is attached while
|
|
1015
|
+
// streaming too: only the growing tail token's last row changes per delta,
|
|
1016
|
+
// and ending the stream leaves already-painted rows byte-identical.
|
|
1017
|
+
// The row count rides with it, so earlier rows stay stable as the token grows.
|
|
1018
|
+
if (index === indexes[indexes.length - 1]) {
|
|
1019
|
+
row.tokenSource = boundary.tokenSource;
|
|
1020
|
+
row.tokenRows = indexes.length;
|
|
1021
|
+
}
|
|
695
1022
|
}
|
|
696
1023
|
}
|
|
697
1024
|
|
|
@@ -701,23 +1028,47 @@ export class Markdown implements Component {
|
|
|
701
1028
|
const bgFn = this.#defaultTextStyle?.bgColor;
|
|
702
1029
|
const contentLines: string[] = [];
|
|
703
1030
|
|
|
704
|
-
for (
|
|
705
|
-
|
|
1031
|
+
for (let index = 0; index < wrappedLines.length; index++) {
|
|
1032
|
+
const line = wrappedLines[index]!;
|
|
1033
|
+
// Image lines must be output raw - no margins or background.
|
|
706
1034
|
if (TERMINAL.isImageLine(line)) {
|
|
707
1035
|
contentLines.push(line);
|
|
708
1036
|
continue;
|
|
709
1037
|
}
|
|
710
1038
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
1039
|
+
// Renderer hints never leave the component; the row annotation sits
|
|
1040
|
+
// before the right margin/padding so callers' trimEnd() still trims it.
|
|
1041
|
+
const content = stripCopyHints(line);
|
|
1042
|
+
const copy = wrappedCopyRows[index];
|
|
1043
|
+
const annotated = copy
|
|
1044
|
+
? annotateCopyRow(annotateCopyOrigin(content), CACHED_COPY_SOURCE_ID, {
|
|
1045
|
+
token: copy.token,
|
|
1046
|
+
kind: copy.kind,
|
|
1047
|
+
source: copy.source,
|
|
1048
|
+
...(copy.tokenSource === undefined ? {} : { tokenSource: copy.tokenSource }),
|
|
1049
|
+
tokenRow: copy.tokenRow,
|
|
1050
|
+
...(copy.tokenRows === undefined ? {} : { tokenRows: copy.tokenRows }),
|
|
1051
|
+
...(copy.originalCode ? { originalCode: true } : {}),
|
|
1052
|
+
joinGap: copy.joinGap,
|
|
1053
|
+
contentStart: 0,
|
|
1054
|
+
contentEnd: visibleWidth(content),
|
|
1055
|
+
continuation: copy.continuation,
|
|
1056
|
+
prefixColumns:
|
|
1057
|
+
copy.codeRow === "content" && !copy.continuation
|
|
1058
|
+
? copy.rowStart + this.#codeBlockIndent
|
|
1059
|
+
: copy.quoteDepth * visibleWidth(`${this.#theme.symbols.quoteBorder} `),
|
|
1060
|
+
fence: copy.codeRow === "open" || copy.codeRow === "close",
|
|
1061
|
+
quoteDepth: copy.quoteDepth,
|
|
1062
|
+
...(copy.ranges === undefined ? {} : { ranges: copy.ranges }),
|
|
1063
|
+
...(copy.cellGaps === undefined ? {} : { cellGaps: copy.cellGaps }),
|
|
1064
|
+
})
|
|
1065
|
+
: content;
|
|
1066
|
+
const lineWithMargins = leftMargin + annotated + rightMargin;
|
|
1067
|
+
contentLines.push(
|
|
1068
|
+
bgFn
|
|
1069
|
+
? applyBackgroundToLine(lineWithMargins, width, bgFn)
|
|
1070
|
+
: lineWithMargins + padding(Math.max(0, width - visibleWidth(lineWithMargins))),
|
|
1071
|
+
);
|
|
721
1072
|
}
|
|
722
1073
|
|
|
723
1074
|
// Add top/bottom padding (empty lines)
|
|
@@ -756,17 +1107,22 @@ export class Markdown implements Component {
|
|
|
756
1107
|
// Update L1 per-instance cache
|
|
757
1108
|
this.#cachedText = renderedText;
|
|
758
1109
|
this.#cachedWidth = width;
|
|
759
|
-
|
|
1110
|
+
const copyBoundResult = this.#bindCopySource(result, copyEnabled);
|
|
1111
|
+
this.#cachedLines = copyBoundResult;
|
|
760
1112
|
this.#cachedAnchorSpans = anchorSpans;
|
|
761
1113
|
this.#lastFullParseAt = markdownNow();
|
|
762
1114
|
|
|
763
1115
|
// Update L2 module-level LRU so future instances with the same key skip
|
|
764
1116
|
// the marked.lexer + highlightCode (Rust FFI) work entirely.
|
|
765
1117
|
if (!this.#streaming) {
|
|
766
|
-
renderCache.set(cacheKey, {
|
|
1118
|
+
renderCache.set(cacheKey, {
|
|
1119
|
+
source: renderCacheSource,
|
|
1120
|
+
lines: result,
|
|
1121
|
+
...(anchorSpans ? { anchorSpans } : {}),
|
|
1122
|
+
});
|
|
767
1123
|
}
|
|
768
1124
|
|
|
769
|
-
return { lines:
|
|
1125
|
+
return { lines: copyBoundResult, spans: anchorSpans };
|
|
770
1126
|
}
|
|
771
1127
|
|
|
772
1128
|
renderWithViewportAnchorSource(
|
|
@@ -923,7 +1279,7 @@ export class Markdown implements Component {
|
|
|
923
1279
|
}
|
|
924
1280
|
|
|
925
1281
|
const codeIndent = padding(this.#codeBlockIndent);
|
|
926
|
-
this.#emitCodeBlock(lines, token.text, token.lang || "", codeIndent);
|
|
1282
|
+
this.#emitCodeBlock(lines, token, token.text, token.lang || "", codeIndent);
|
|
927
1283
|
if (nextTokenType && nextTokenType !== "space") {
|
|
928
1284
|
lines.push(""); // Add spacing after code blocks (unless space token follows)
|
|
929
1285
|
}
|
|
@@ -990,9 +1346,31 @@ export class Markdown implements Component {
|
|
|
990
1346
|
|
|
991
1347
|
for (let q = 0; q < renderedQuoteLines.length; q++) {
|
|
992
1348
|
const styledLine = applyQuoteStyle(renderedQuoteLines[q]);
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1349
|
+
// Every wrapped part of a code row is a code row; wrapping would leave
|
|
1350
|
+
// the trailing hint on the last part only.
|
|
1351
|
+
const codeRow = copyCodeRow(styledLine);
|
|
1352
|
+
const codeRowHint = codeRow ? `${COPY_CODE_ROW_PREFIX}${codeRow}\x1b\\` : "";
|
|
1353
|
+
const wrappedLines = wrapTextIfNeeded(
|
|
1354
|
+
codeRow ? stripCopyCodeRow(styledLine) : styledLine,
|
|
1355
|
+
quoteContentWidth,
|
|
1356
|
+
);
|
|
1357
|
+
const quoteLogical = Bun.stripANSI(styledLine);
|
|
1358
|
+
let quoteCursor = 0;
|
|
1359
|
+
for (let quoteWrapIndex = 0; quoteWrapIndex < wrappedLines.length; quoteWrapIndex++) {
|
|
1360
|
+
const wrappedLine = wrappedLines[quoteWrapIndex]!;
|
|
1361
|
+
const fragment = Bun.stripANSI(wrappedLine);
|
|
1362
|
+
const at = quoteLogical.indexOf(fragment, quoteCursor);
|
|
1363
|
+
const gap = at < 0 ? "" : quoteLogical.slice(quoteCursor, at);
|
|
1364
|
+
if (at >= 0) quoteCursor = at + fragment.length;
|
|
1365
|
+
lines.push(
|
|
1366
|
+
this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) +
|
|
1367
|
+
stripCopyQuoteDepth(wrappedLine) +
|
|
1368
|
+
codeRowHint +
|
|
1369
|
+
(quoteWrapIndex > 0
|
|
1370
|
+
? `${COPY_QUOTE_CONTINUATION_PREFIX}${encodeURIComponent(gap.toWellFormed())}\x1b\\`
|
|
1371
|
+
: "") +
|
|
1372
|
+
`${COPY_QUOTE_DEPTH_PREFIX}${copyQuoteDepth(styledLine) + 1}\x1b\\`,
|
|
1373
|
+
);
|
|
996
1374
|
units?.push(quoteLineUnit[q]);
|
|
997
1375
|
}
|
|
998
1376
|
}
|
|
@@ -1216,7 +1594,7 @@ export class Markdown implements Component {
|
|
|
1216
1594
|
} else if (token.type === "code") {
|
|
1217
1595
|
// Code block in list item
|
|
1218
1596
|
const codeIndent = padding(this.#codeBlockIndent);
|
|
1219
|
-
this.#emitCodeBlock(lines, token.text, token.lang || "", codeIndent);
|
|
1597
|
+
this.#emitCodeBlock(lines, token, token.text, token.lang || "", codeIndent);
|
|
1220
1598
|
} else {
|
|
1221
1599
|
// Other token types - try to render as inline
|
|
1222
1600
|
const text = this.#renderInlineTokens([token], styleContext);
|
|
@@ -1254,6 +1632,28 @@ export class Markdown implements Component {
|
|
|
1254
1632
|
return wrapTextIfNeeded(text, Math.max(1, maxWidth));
|
|
1255
1633
|
}
|
|
1256
1634
|
|
|
1635
|
+
/**
|
|
1636
|
+
* Copy hint for physical row `lineIdx` of a wrapped table row: per cell, the
|
|
1637
|
+
* cell text the soft wrap removed before this row's fragment. The first row
|
|
1638
|
+
* of a table row (and every row of a stock render) gets none.
|
|
1639
|
+
*/
|
|
1640
|
+
#tableWrapHint(cellTexts: string[], cellLines: string[][], lineIdx: number): string {
|
|
1641
|
+
if (lineIdx === 0 || !copyAnnotationsEnabled()) return "";
|
|
1642
|
+
const gaps = cellTexts.map((text, cell) => {
|
|
1643
|
+
const logical = Bun.stripANSI(text);
|
|
1644
|
+
let cursor = 0;
|
|
1645
|
+
let gap = "";
|
|
1646
|
+
for (const [index, line] of (cellLines[cell] ?? []).slice(0, lineIdx + 1).entries()) {
|
|
1647
|
+
const fragment = Bun.stripANSI(line);
|
|
1648
|
+
const at = logical.indexOf(fragment, cursor);
|
|
1649
|
+
if (index === lineIdx) gap = at < 0 ? "" : logical.slice(cursor, at);
|
|
1650
|
+
if (at >= 0) cursor = at + fragment.length;
|
|
1651
|
+
}
|
|
1652
|
+
return gap.toWellFormed();
|
|
1653
|
+
});
|
|
1654
|
+
return `${COPY_TABLE_WRAP_PREFIX}${encodeURIComponent(JSON.stringify(gaps))}\x1b\\`;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1257
1657
|
/**
|
|
1258
1658
|
* Render a table with width-aware cell wrapping.
|
|
1259
1659
|
* Cells that don't fit are wrapped to multiple lines.
|
|
@@ -1382,13 +1782,13 @@ export class Markdown implements Component {
|
|
|
1382
1782
|
|
|
1383
1783
|
// Render top border
|
|
1384
1784
|
const topBorderCells = columnWidths.map(w => h.repeat(w));
|
|
1385
|
-
lines.push(
|
|
1785
|
+
lines.push(
|
|
1786
|
+
`${t.topLeft}${h}${topBorderCells.join(`${h}${t.teeDown}${h}`)}${h}${t.topRight}${COPY_TABLE_RANGES_PREFIX}-\x1b\\`,
|
|
1787
|
+
);
|
|
1386
1788
|
|
|
1387
1789
|
// Render header with wrapping
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1390
|
-
return this.#wrapCellText(text, columnWidths[i]);
|
|
1391
|
-
});
|
|
1790
|
+
const headerCellTexts = token.header.map(cell => this.#renderInlineTokens(cell.tokens || [], styleContext));
|
|
1791
|
+
const headerCellLines: string[][] = headerCellTexts.map((text, i) => this.#wrapCellText(text, columnWidths[i]));
|
|
1392
1792
|
const headerLineCount = Math.max(...headerCellLines.map(c => c.length));
|
|
1393
1793
|
|
|
1394
1794
|
for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
|
|
@@ -1397,12 +1797,23 @@ export class Markdown implements Component {
|
|
|
1397
1797
|
const padded = text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
|
|
1398
1798
|
return this.#theme.bold(padded);
|
|
1399
1799
|
});
|
|
1400
|
-
|
|
1800
|
+
const tableLine = `${v} ${rowParts.join(` ${v} `)} ${v}`;
|
|
1801
|
+
let tableColumn = 2;
|
|
1802
|
+
const tableRanges = rowParts.map(part => {
|
|
1803
|
+
const start = tableColumn;
|
|
1804
|
+
tableColumn += visibleWidth(part);
|
|
1805
|
+
const range: [number, number] = [start, tableColumn];
|
|
1806
|
+
tableColumn += 3;
|
|
1807
|
+
return range;
|
|
1808
|
+
});
|
|
1809
|
+
lines.push(
|
|
1810
|
+
`${COPY_ROW_START}${tableLine}${COPY_TABLE_RANGES_PREFIX}${tableRanges.map(range => range.join("-")).join(",")}\x1b\\${this.#tableWrapHint(headerCellTexts, headerCellLines, lineIdx)}`,
|
|
1811
|
+
);
|
|
1401
1812
|
}
|
|
1402
1813
|
|
|
1403
1814
|
// Render separator
|
|
1404
1815
|
const separatorCells = columnWidths.map(w => h.repeat(w));
|
|
1405
|
-
const separatorLine = `${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}
|
|
1816
|
+
const separatorLine = `${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}${COPY_TABLE_RANGES_PREFIX}-\x1b\\`;
|
|
1406
1817
|
lines.push(separatorLine);
|
|
1407
1818
|
// Anchor units: the whole header block (top border + header rows + separator)
|
|
1408
1819
|
// is unit 0; each source data row is its own width-invariant unit below.
|
|
@@ -1411,10 +1822,8 @@ export class Markdown implements Component {
|
|
|
1411
1822
|
// Render rows with wrapping
|
|
1412
1823
|
for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
|
|
1413
1824
|
const row = token.rows[rowIndex];
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
return this.#wrapCellText(text, columnWidths[i]);
|
|
1417
|
-
});
|
|
1825
|
+
const rowCellTexts = row.map(cell => this.#renderInlineTokens(cell.tokens || [], styleContext));
|
|
1826
|
+
const rowCellLines: string[][] = rowCellTexts.map((text, i) => this.#wrapCellText(text, columnWidths[i]));
|
|
1418
1827
|
const rowLineCount = Math.max(...rowCellLines.map(c => c.length));
|
|
1419
1828
|
|
|
1420
1829
|
for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
|
|
@@ -1422,7 +1831,18 @@ export class Markdown implements Component {
|
|
|
1422
1831
|
const text = cellLines[lineIdx] || "";
|
|
1423
1832
|
return text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
|
|
1424
1833
|
});
|
|
1425
|
-
|
|
1834
|
+
const tableLine = `${v} ${rowParts.join(` ${v} `)} ${v}`;
|
|
1835
|
+
let tableColumn = 2;
|
|
1836
|
+
const tableRanges = rowParts.map(part => {
|
|
1837
|
+
const start = tableColumn;
|
|
1838
|
+
tableColumn += visibleWidth(part);
|
|
1839
|
+
const range: [number, number] = [start, tableColumn];
|
|
1840
|
+
tableColumn += 3;
|
|
1841
|
+
return range;
|
|
1842
|
+
});
|
|
1843
|
+
lines.push(
|
|
1844
|
+
`${COPY_ROW_START}${tableLine}${COPY_TABLE_RANGES_PREFIX}${tableRanges.map(range => range.join("-")).join(",")}\x1b\\${this.#tableWrapHint(rowCellTexts, rowCellLines, lineIdx)}`,
|
|
1845
|
+
);
|
|
1426
1846
|
}
|
|
1427
1847
|
|
|
1428
1848
|
if (rowIndex < token.rows.length - 1) {
|
|
@@ -1433,7 +1853,9 @@ export class Markdown implements Component {
|
|
|
1433
1853
|
|
|
1434
1854
|
// Render bottom border
|
|
1435
1855
|
const bottomBorderCells = columnWidths.map(w => h.repeat(w));
|
|
1436
|
-
lines.push(
|
|
1856
|
+
lines.push(
|
|
1857
|
+
`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}${COPY_TABLE_RANGES_PREFIX}-\x1b\\`,
|
|
1858
|
+
);
|
|
1437
1859
|
|
|
1438
1860
|
if (nextTokenType && nextTokenType !== "space") {
|
|
1439
1861
|
lines.push(""); // Add spacing after table
|