@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.
- package/dist/types/components/markdown.d.ts +9 -0
- package/dist/types/components/sayknow-pet.d.ts +128 -0
- package/dist/types/components/text.d.ts +9 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/terminal-capabilities.d.ts +44 -0
- package/dist/types/tui.d.ts +55 -1
- package/dist/types/utils.d.ts +23 -0
- package/package.json +3 -3
- package/src/components/image.ts +23 -4
- package/src/components/markdown.ts +216 -29
- package/src/components/sayknow-pet.ts +435 -0
- package/src/components/text.ts +60 -36
- package/src/index.ts +1 -0
- package/src/terminal-capabilities.ts +119 -4
- package/src/tui.ts +503 -67
- package/src/utils.ts +144 -8
|
@@ -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 {
|
|
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<
|
|
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 (
|
|
360
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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(
|
|
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
|
-
|
|
670
|
-
|
|
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 (
|
|
679
|
-
const styledLine = applyQuoteStyle(
|
|
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
|
}
|