@sayknow-cli/tui 0.3.12 → 0.3.15

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.
@@ -3,7 +3,14 @@ import { Marked, marked, type Token, Tokenizer, type Tokens } from "marked";
3
3
  import type { SymbolTheme } from "../symbols";
4
4
  import { TERMINAL } from "../terminal-capabilities";
5
5
  import type { Component } from "../tui";
6
- import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
6
+ import {
7
+ applyBackgroundToLine,
8
+ padding,
9
+ replaceTabs,
10
+ type ViewportAnchorSpan,
11
+ visibleWidth,
12
+ wrapTextWithAnsi,
13
+ } from "../utils";
7
14
 
8
15
  const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
9
16
 
@@ -39,12 +46,27 @@ markdownParser.setOptions({
39
46
  // (Rust FFI) work for content/layout combinations already seen this session.
40
47
 
41
48
  const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
42
- const renderCache = new LRUCache<string, { source: string; lines: string[] }>({ max: RENDER_CACHE_MAX });
49
+ const renderCache = new LRUCache<
50
+ string,
51
+ { source: string; lines: string[]; anchorSpans?: Array<ViewportAnchorSpan | null> }
52
+ >({
53
+ max: RENDER_CACHE_MAX,
54
+ });
43
55
  const PARSE_CACHE_MAX = 128;
44
56
  const parseCache = new LRUCache<string, { source: string; tokens: Token[] }>({ max: PARSE_CACHE_MAX });
45
57
  const MARKDOWN_STREAM_THROTTLE_MS = 64;
46
58
  let markdownNow = (): number => performance.now();
47
59
 
60
+ // Viewport anchor offsets are numbered over each top-level token's width-invariant
61
+ // source-text span (scaled to leave room for content rows) instead of the
62
+ // width-dependent rendered glyph stream. HR fill length, per-row blockquote
63
+ // prefixes, and boxed/raw table topology all change the rendered glyph count with
64
+ // width; source-text spans do not, so anchors resolve to the same content after a
65
+ // topology-changing reflow (#2031). The scale guarantees each content row a
66
+ // positive, non-overlapping sub-span even when a token wraps into more rows than
67
+ // its source has characters.
68
+ const ANCHOR_SOURCE_SCALE = 1 << 16;
69
+
48
70
  /** Test-only clock seam for streaming throttle tests. */
49
71
  export function __setMarkdownNowForTest(now: (() => number) | undefined): void {
50
72
  markdownNow = now ?? (() => performance.now());
@@ -62,6 +84,14 @@ function renderedLinesBytes(lines: readonly string[]): number {
62
84
  return bytes;
63
85
  }
64
86
 
87
+ function anchorSpansBytes(spans: readonly (ViewportAnchorSpan | null)[]): number {
88
+ let bytes = spans.length * 8; // Array element references
89
+ for (const span of spans) {
90
+ if (span) bytes += 4 * 8; // Four numeric offsets
91
+ }
92
+ return bytes;
93
+ }
94
+
65
95
  // F18: cap synchronous (Rust FFI) syntax highlighting so a single huge fenced block
66
96
  // cannot stall the UI thread; oversized blocks render plain with a sanitized marker.
67
97
  const MAX_HIGHLIGHT_BYTES = 200_000;
@@ -108,6 +138,7 @@ export function getRenderCacheRetainedBytes(): number {
108
138
  for (const entry of renderCache.values()) {
109
139
  bytes += Buffer.byteLength(entry.source, "utf8");
110
140
  bytes += renderedLinesBytes(entry.lines);
141
+ if (entry.anchorSpans) bytes += anchorSpansBytes(entry.anchorSpans);
111
142
  }
112
143
  for (const entry of parseCache.values()) bytes += Buffer.byteLength(entry.source, "utf8");
113
144
  for (const lines of highlightCache.values()) bytes += renderedLinesBytes(lines);
@@ -235,6 +266,7 @@ export class Markdown implements Component {
235
266
  #cachedText?: string;
236
267
  #cachedWidth?: number;
237
268
  #cachedLines?: string[];
269
+ #cachedAnchorSpans?: Array<ViewportAnchorSpan | null>;
238
270
 
239
271
  #streaming = false;
240
272
  #lastFullParseAt = 0;
@@ -310,6 +342,7 @@ export class Markdown implements Component {
310
342
  this.#cachedText = undefined;
311
343
  this.#cachedWidth = undefined;
312
344
  this.#cachedLines = undefined;
345
+ this.#cachedAnchorSpans = undefined;
313
346
  }
314
347
 
315
348
  #exceedsHighlightCap(code: string): boolean {
@@ -354,20 +387,35 @@ export class Markdown implements Component {
354
387
  }
355
388
 
356
389
  render(width: number): string[] {
390
+ return this.#render(width, false).lines;
391
+ }
392
+
393
+ #render(width: number, includeAnchors: boolean): { lines: string[]; spans?: Array<ViewportAnchorSpan | null> } {
357
394
  // L1: per-instance cache — fastest path for repeated renders of the same
358
395
  // instance at the same width (e.g. resize debounce, repeated redraws).
359
- if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
360
- return this.#cachedLines;
396
+ if (
397
+ this.#cachedLines &&
398
+ this.#cachedText === this.#text &&
399
+ this.#cachedWidth === width &&
400
+ (!includeAnchors || this.#cachedAnchorSpans !== undefined)
401
+ ) {
402
+ return { lines: this.#cachedLines, spans: this.#cachedAnchorSpans };
361
403
  }
362
404
 
363
405
  // Calculate available width for content (subtract horizontal padding)
364
406
  const contentWidth = Math.max(1, width - this.#paddingX * 2);
365
407
 
366
- if (this.#streaming && this.#cachedLines && this.#cachedWidth === width && this.#lastFullParseAt > 0) {
408
+ if (
409
+ this.#streaming &&
410
+ this.#cachedLines &&
411
+ this.#cachedWidth === width &&
412
+ this.#lastFullParseAt > 0 &&
413
+ (!includeAnchors || this.#cachedAnchorSpans !== undefined)
414
+ ) {
367
415
  const elapsedMs = markdownNow() - this.#lastFullParseAt;
368
416
  if (elapsedMs < MARKDOWN_STREAM_THROTTLE_MS) {
369
417
  this.#armStaleThrottleTimer(MARKDOWN_STREAM_THROTTLE_MS - elapsedMs);
370
- return this.#cachedLines;
418
+ return { lines: this.#cachedLines, spans: this.#cachedAnchorSpans };
371
419
  }
372
420
  }
373
421
 
@@ -378,7 +426,8 @@ export class Markdown implements Component {
378
426
  this.#cachedText = this.#text;
379
427
  this.#cachedWidth = width;
380
428
  this.#cachedLines = result;
381
- return result;
429
+ this.#cachedAnchorSpans = includeAnchors ? [] : undefined;
430
+ return { lines: result, spans: this.#cachedAnchorSpans };
382
431
  }
383
432
 
384
433
  // Replace tabs with 3 spaces for consistent rendering
@@ -395,12 +444,17 @@ export class Markdown implements Component {
395
444
  const headingProbe = this.#theme.heading("");
396
445
  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}`;
397
446
  const cached = renderCache.get(cacheKey);
398
- if (cached !== undefined && cached.source === normalizedText) {
447
+ if (
448
+ cached !== undefined &&
449
+ cached.source === normalizedText &&
450
+ (!includeAnchors || cached.anchorSpans !== undefined)
451
+ ) {
399
452
  // Populate L1 so subsequent calls from this instance are O(1) map lookup.
400
453
  this.#cachedText = this.#text;
401
454
  this.#cachedWidth = width;
402
455
  this.#cachedLines = cached.lines;
403
- return cached.lines;
456
+ this.#cachedAnchorSpans = cached.anchorSpans;
457
+ return { lines: cached.lines, spans: cached.anchorSpans };
404
458
  }
405
459
 
406
460
  // Parse markdown to marked tokens. Parse cache is width/theme independent,
@@ -417,23 +471,105 @@ export class Markdown implements Component {
417
471
  parseCache.set(contentKey, { source: normalizedText, tokens });
418
472
  }
419
473
 
420
- // Convert tokens to styled terminal output
474
+ // Convert tokens to styled terminal output. When anchoring, record each
475
+ // top-level token's rendered-line range plus a width-invariant source-text
476
+ // span (cumulative marked-token `raw` lengths) so viewport anchors are
477
+ // numbered over stable source identities instead of the rendered glyph
478
+ // stream (#2031).
421
479
  const renderedLines: string[] = [];
480
+ const tokenBoundaries: Array<{ start: number; end: number; srcLo: number; srcHi: number; units: number[] }> = [];
481
+ let srcOffset = 0;
422
482
 
423
483
  for (let i = 0; i < tokens.length; i++) {
424
484
  const token = tokens[i];
425
485
  const nextToken = tokens[i + 1];
426
- const tokenLines = this.#renderToken(token, contentWidth, nextToken?.type);
486
+ const tokenStart = renderedLines.length;
487
+ const units: number[] | undefined = includeAnchors ? [] : undefined;
488
+ const tokenLines = this.#renderToken(token, contentWidth, nextToken?.type, undefined, units);
427
489
  renderedLines.push(...tokenLines);
490
+ if (includeAnchors) {
491
+ const rawLength = "raw" in token && typeof token.raw === "string" ? token.raw.length : 0;
492
+ const srcLo = srcOffset;
493
+ srcOffset += Math.max(1, rawLength);
494
+ // units must align 1:1 with the token's rendered lines; if a renderer
495
+ // path emitted none, treat the whole token as a single unit.
496
+ const lineUnits =
497
+ units && units.length === tokenLines.length ? units : new Array<number>(tokenLines.length).fill(0);
498
+ tokenBoundaries.push({
499
+ start: tokenStart,
500
+ end: renderedLines.length,
501
+ srcLo,
502
+ srcHi: srcOffset,
503
+ units: lineUnits,
504
+ });
505
+ }
428
506
  }
429
507
 
430
- // Wrap lines (NO padding, NO background yet)
431
- const wrappedLines: string[] = [];
432
- for (const line of renderedLines) {
433
- if (TERMINAL.isImageLine(line)) {
434
- wrappedLines.push(line);
435
- } else {
436
- wrappedLines.push(...wrapTextIfNeeded(line, contentWidth));
508
+ let wrappedLines: string[];
509
+ let wrappedSpans: Array<ViewportAnchorSpan | null> | undefined;
510
+ if (includeAnchors) {
511
+ wrappedLines = [];
512
+ const spans: Array<ViewportAnchorSpan | null> = [];
513
+ for (const boundary of tokenBoundaries) {
514
+ // Number this token's width-invariant source-text band [lo, hi) over its
515
+ // source units — width-invariant structural spans (a list item, a table
516
+ // row, a blockquote child block; a simple token is one unit) — rather than
517
+ // over the width-dependent count of final content rows. Each unit reserves
518
+ // a fixed sub-band, so an earlier unit's rows no longer move when a *later*
519
+ // unit of the same top-level token rewraps into more rows. That
520
+ // within-token drift was the P2 the first #2031 pass reintroduced: a
521
+ // page-down anchor pinned to list item 1 (or a table header) slid into item
522
+ // 2 (or a data row) once the later unit rewrapped, because every unit shared
523
+ // one content-row count the later unit inflated.
524
+ const lo = boundary.srcLo * ANCHOR_SOURCE_SCALE;
525
+ const hi = boundary.srcHi * ANCHOR_SOURCE_SCALE;
526
+ const lineCount = boundary.end - boundary.start;
527
+ // Wrap the token's rendered lines exactly as the plain path does (so the
528
+ // emitted `lines` stay byte-identical to render()), recording each wrapped
529
+ // row's source unit and its position in `wrappedLines`.
530
+ const firstWrapped = wrappedLines.length;
531
+ const rowUnit: number[] = [];
532
+ let unitCount = 1;
533
+ for (let li = 0; li < lineCount; li++) {
534
+ const renderedLine = renderedLines[boundary.start + li];
535
+ const unit = boundary.units[li];
536
+ if (unit + 1 > unitCount) unitCount = unit + 1;
537
+ const lineWrapped = TERMINAL.isImageLine(renderedLine)
538
+ ? [renderedLine]
539
+ : wrapTextIfNeeded(renderedLine, contentWidth);
540
+ for (const w of lineWrapped) {
541
+ wrappedLines.push(w);
542
+ spans.push(null);
543
+ rowUnit.push(unit);
544
+ }
545
+ }
546
+ // Blank, decoration-only whitespace, and image rows carry no anchor.
547
+ const contentRowsByUnit: number[][] = Array.from({ length: unitCount }, () => []);
548
+ for (let j = 0; j < rowUnit.length; j++) {
549
+ const globalIdx = firstWrapped + j;
550
+ const line = wrappedLines[globalIdx];
551
+ if (!TERMINAL.isImageLine(line) && Bun.stripANSI(line).trim().length > 0) {
552
+ contentRowsByUnit[rowUnit[j]].push(globalIdx);
553
+ }
554
+ }
555
+ for (let u = 0; u < unitCount; u++) {
556
+ const unitLo = lo + Math.floor((u * (hi - lo)) / unitCount);
557
+ const unitHi = u === unitCount - 1 ? hi : lo + Math.floor(((u + 1) * (hi - lo)) / unitCount);
558
+ const rows = contentRowsByUnit[u];
559
+ const count = rows.length;
560
+ for (let c = 0; c < count; c++) {
561
+ const start = unitLo + Math.floor((c * (unitHi - unitLo)) / count);
562
+ const end = c === count - 1 ? unitHi : unitLo + Math.floor(((c + 1) * (unitHi - unitLo)) / count);
563
+ spans[rows[c]] = { graphemeStart: start, graphemeEnd: end, cellStart: start, cellEnd: end };
564
+ }
565
+ }
566
+ }
567
+ wrappedSpans = spans;
568
+ } else {
569
+ wrappedLines = [];
570
+ for (const line of renderedLines) {
571
+ if (TERMINAL.isImageLine(line)) wrappedLines.push(line);
572
+ else wrappedLines.push(...wrapTextIfNeeded(line, contentWidth));
437
573
  }
438
574
  }
439
575
 
@@ -470,21 +606,42 @@ export class Markdown implements Component {
470
606
  emptyLines.push(line);
471
607
  }
472
608
 
473
- // Combine top padding, content, and bottom padding
474
609
  const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
610
+ const rawAnchorSpans = wrappedSpans && [
611
+ ...emptyLines.map(() => null),
612
+ ...wrappedSpans,
613
+ ...emptyLines.map(() => null),
614
+ ];
475
615
  const result = rawResult.length > 0 ? rawResult : [""];
616
+ const anchorSpans = rawResult.length > 0 ? rawAnchorSpans : wrappedSpans ? [null] : undefined;
476
617
 
477
618
  // Update L1 per-instance cache
478
619
  this.#cachedText = this.#text;
479
620
  this.#cachedWidth = width;
480
621
  this.#cachedLines = result;
622
+ this.#cachedAnchorSpans = anchorSpans;
481
623
  this.#lastFullParseAt = markdownNow();
482
624
 
483
625
  // Update L2 module-level LRU so future instances with the same key skip
484
626
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
485
- renderCache.set(cacheKey, { source: normalizedText, lines: result });
627
+ renderCache.set(cacheKey, { source: normalizedText, lines: result, ...(anchorSpans ? { anchorSpans } : {}) });
486
628
 
487
- return result;
629
+ return { lines: result, spans: anchorSpans };
630
+ }
631
+
632
+ renderWithViewportAnchorSource(
633
+ width: number,
634
+ source: { id: string },
635
+ ): {
636
+ lines: string[];
637
+ anchors: Array<({ id: string } & ViewportAnchorSpan) | null>;
638
+ } {
639
+ const { lines, spans } = this.#render(width, true);
640
+ if (!spans) throw new Error("Viewport anchor source render completed without row spans");
641
+ return {
642
+ lines,
643
+ anchors: spans.map(span => (span ? { id: source.id, ...span } : null)),
644
+ };
488
645
  }
489
646
 
490
647
  /**
@@ -570,7 +727,13 @@ export class Markdown implements Component {
570
727
  };
571
728
  }
572
729
 
573
- #renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: InlineStyleContext): string[] {
730
+ #renderToken(
731
+ token: Token,
732
+ width: number,
733
+ nextTokenType?: string,
734
+ styleContext?: InlineStyleContext,
735
+ units?: number[],
736
+ ): string[] {
574
737
  const lines: string[] = [];
575
738
 
576
739
  switch (token.type) {
@@ -628,7 +791,7 @@ export class Markdown implements Component {
628
791
  }
629
792
 
630
793
  case "list": {
631
- const listLines = this.#renderList(token as ListToken, 0, styleContext);
794
+ const listLines = this.#renderList(token as ListToken, 0, styleContext, units);
632
795
  lines.push(...listLines);
633
796
  // Don't add spacing after lists if a space token follows
634
797
  // (the space token will handle it)
@@ -636,7 +799,7 @@ export class Markdown implements Component {
636
799
  }
637
800
 
638
801
  case "table": {
639
- const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
802
+ const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext, units);
640
803
  lines.push(...tableLines);
641
804
  break;
642
805
  }
@@ -662,28 +825,40 @@ export class Markdown implements Component {
662
825
  const quoteContentWidth = Math.max(1, width - 2);
663
826
  const quoteTokens = token.tokens || [];
664
827
  const renderedQuoteLines: string[] = [];
828
+ // Track which source child block produced each rendered quote line so the
829
+ // anchor band can be numbered over width-invariant child units rather than
830
+ // the width-dependent count of wrapped quote lines.
831
+ const quoteLineUnit: number[] = [];
665
832
 
666
833
  for (let i = 0; i < quoteTokens.length; i++) {
667
834
  const quoteToken = quoteTokens[i];
668
835
  const nextQuoteToken = quoteTokens[i + 1];
669
- renderedQuoteLines.push(
670
- ...this.#renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext),
836
+ const childLines = this.#renderToken(
837
+ quoteToken,
838
+ quoteContentWidth,
839
+ nextQuoteToken?.type,
840
+ quoteInlineStyleContext,
671
841
  );
842
+ for (let c = 0; c < childLines.length; c++) quoteLineUnit.push(i);
843
+ renderedQuoteLines.push(...childLines);
672
844
  }
673
845
 
674
846
  while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
675
847
  renderedQuoteLines.pop();
848
+ quoteLineUnit.pop();
676
849
  }
677
850
 
678
- for (const quoteLine of renderedQuoteLines) {
679
- const styledLine = applyQuoteStyle(quoteLine);
851
+ for (let q = 0; q < renderedQuoteLines.length; q++) {
852
+ const styledLine = applyQuoteStyle(renderedQuoteLines[q]);
680
853
  const wrappedLines = wrapTextIfNeeded(styledLine, quoteContentWidth);
681
854
  for (const wrappedLine of wrappedLines) {
682
855
  lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
856
+ units?.push(quoteLineUnit[q]);
683
857
  }
684
858
  }
685
859
  if (nextTokenType && nextTokenType !== "space") {
686
860
  lines.push(""); // Add spacing after blockquotes (unless space token follows)
861
+ units?.push(quoteTokens.length > 0 ? quoteTokens.length - 1 : 0);
687
862
  }
688
863
  break;
689
864
  }
@@ -717,6 +892,9 @@ export class Markdown implements Component {
717
892
  }
718
893
  }
719
894
 
895
+ // Anchor units: lines a token emitted without an explicit unit (simple
896
+ // single-unit tokens, trailing spacing) inherit the last unit, default 0.
897
+ if (units) while (units.length < lines.length) units.push(units.length ? units[units.length - 1] : 0);
720
898
  return lines;
721
899
  }
722
900
 
@@ -820,7 +998,7 @@ export class Markdown implements Component {
820
998
  /**
821
999
  * Render a list with proper nesting support
822
1000
  */
823
- #renderList(token: ListToken, depth: number, styleContext?: InlineStyleContext): string[] {
1001
+ #renderList(token: ListToken, depth: number, styleContext?: InlineStyleContext, units?: number[]): string[] {
824
1002
  const lines: string[] = [];
825
1003
  const indent = " ".repeat(depth);
826
1004
  // Use the list's start property (defaults to 1 for ordered lists)
@@ -863,6 +1041,9 @@ export class Markdown implements Component {
863
1041
  } else {
864
1042
  lines.push(indent + this.#theme.listBullet(bullet));
865
1043
  }
1044
+ // Anchor units: every rendered line of this top-level item (including any
1045
+ // nested list lines) belongs to one width-invariant unit — the item index.
1046
+ if (units) while (units.length < lines.length) units.push(i);
866
1047
  }
867
1048
 
868
1049
  return lines;
@@ -942,6 +1123,7 @@ export class Markdown implements Component {
942
1123
  availableWidth: number,
943
1124
  nextTokenType?: string,
944
1125
  styleContext?: InlineStyleContext,
1126
+ units?: number[],
945
1127
  ): string[] {
946
1128
  const lines: string[] = [];
947
1129
  const numCols = token.header.length;
@@ -1082,6 +1264,9 @@ export class Markdown implements Component {
1082
1264
  const separatorCells = columnWidths.map(w => h.repeat(w));
1083
1265
  const separatorLine = `${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`;
1084
1266
  lines.push(separatorLine);
1267
+ // Anchor units: the whole header block (top border + header rows + separator)
1268
+ // is unit 0; each source data row is its own width-invariant unit below.
1269
+ if (units) while (units.length < lines.length) units.push(0);
1085
1270
 
1086
1271
  // Render rows with wrapping
1087
1272
  for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
@@ -1103,6 +1288,7 @@ export class Markdown implements Component {
1103
1288
  if (rowIndex < token.rows.length - 1) {
1104
1289
  lines.push(separatorLine);
1105
1290
  }
1291
+ if (units) while (units.length < lines.length) units.push(rowIndex + 1);
1106
1292
  }
1107
1293
 
1108
1294
  // Render bottom border
@@ -1112,6 +1298,7 @@ export class Markdown implements Component {
1112
1298
  if (nextTokenType && nextTokenType !== "space") {
1113
1299
  lines.push(""); // Add spacing after table
1114
1300
  }
1301
+ if (units) while (units.length < lines.length) units.push(token.rows.length);
1115
1302
  return lines;
1116
1303
  }
1117
1304
  }