@gajae-code/tui 0.18.8 → 0.18.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.
@@ -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
- #emitCodeBlock(lines: string[], code: string, lang: string, codeIndent: string): void {
487
- lines.push(this.#theme.codeBlockBorder(`\`\`\`${lang}`));
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(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
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 = `${contentKey}\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}`;
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 === normalizedText &&
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
- this.#cachedLines = cached.lines;
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: cached.lines, spans: cached.anchorSpans };
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<{ start: number; end: number; srcLo: number; srcHi: number; units: number[] }> = [];
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
- if (includeAnchors) {
613
- const rawLength = "raw" in token && typeof token.raw === "string" ? token.raw.length : 0;
614
- const srcLo = srcOffset;
615
- srcOffset += Math.max(1, rawLength);
616
- // units must align 1:1 with the token's rendered lines; if a renderer
617
- // path emitted none, treat the whole token as a single unit.
618
- const lineUnits =
619
- units && units.length === tokenLines.length ? units : new Array<number>(tokenLines.length).fill(0);
620
- tokenBoundaries.push({
621
- start: tokenStart,
622
- end: renderedLines.length,
623
- srcLo,
624
- srcHi: srcOffset,
625
- units: lineUnits,
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 line of renderedLines) {
693
- if (TERMINAL.isImageLine(line)) wrappedLines.push(line);
694
- else wrappedLines.push(...wrapTextIfNeeded(line, contentWidth));
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 (const line of wrappedLines) {
705
- // Image lines must be output raw - no margins or background
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
- const lineWithMargins = leftMargin + line + rightMargin;
712
-
713
- if (bgFn) {
714
- contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
715
- } else {
716
- // No background - just pad to width
717
- const visibleLen = visibleWidth(lineWithMargins);
718
- const paddingNeeded = Math.max(0, width - visibleLen);
719
- contentLines.push(lineWithMargins + padding(paddingNeeded));
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
- this.#cachedLines = result;
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, { source: normalizedText, lines: result, ...(anchorSpans ? { anchorSpans } : {}) });
1118
+ renderCache.set(cacheKey, {
1119
+ source: renderCacheSource,
1120
+ lines: result,
1121
+ ...(anchorSpans ? { anchorSpans } : {}),
1122
+ });
767
1123
  }
768
1124
 
769
- return { lines: result, spans: anchorSpans };
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
- const wrappedLines = wrapTextIfNeeded(styledLine, quoteContentWidth);
994
- for (const wrappedLine of wrappedLines) {
995
- lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
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(`${t.topLeft}${h}${topBorderCells.join(`${h}${t.teeDown}${h}`)}${h}${t.topRight}`);
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 headerCellLines: string[][] = token.header.map((cell, i) => {
1389
- const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
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
- lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
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 rowCellLines: string[][] = row.map((cell, i) => {
1415
- const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
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
- lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`);
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(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
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