@sayknow-cli/tui 0.3.11 → 0.3.13
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/package.json +8 -9
- package/src/components/image.ts +79 -39
- package/src/components/markdown.ts +282 -37
- 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 +42 -0
- package/src/tui.ts +471 -55
- package/src/utils.ts +144 -8
- package/dist/types/animation-scheduler.d.ts +0 -13
- package/dist/types/autocomplete.d.ts +0 -83
- package/dist/types/bracketed-paste.d.ts +0 -26
- package/dist/types/components/box.d.ts +0 -20
- package/dist/types/components/cancellable-loader.d.ts +0 -21
- package/dist/types/components/editor.d.ts +0 -126
- package/dist/types/components/image.d.ts +0 -16
- package/dist/types/components/input.d.ts +0 -16
- package/dist/types/components/loader.d.ts +0 -23
- package/dist/types/components/markdown.d.ts +0 -77
- package/dist/types/components/select-list.d.ts +0 -46
- package/dist/types/components/settings-list.d.ts +0 -39
- package/dist/types/components/spacer.d.ts +0 -11
- package/dist/types/components/tab-bar.d.ts +0 -56
- package/dist/types/components/text.d.ts +0 -13
- package/dist/types/components/truncated-text.d.ts +0 -10
- package/dist/types/editor-component.d.ts +0 -36
- package/dist/types/fuzzy.d.ts +0 -15
- package/dist/types/index.d.ts +0 -27
- package/dist/types/keybindings.d.ts +0 -201
- package/dist/types/keys.d.ts +0 -208
- package/dist/types/kill-ring.d.ts +0 -27
- package/dist/types/metrics.d.ts +0 -85
- package/dist/types/stdin-buffer.d.ts +0 -50
- package/dist/types/symbols.d.ts +0 -23
- package/dist/types/terminal-capabilities.d.ts +0 -143
- package/dist/types/terminal.d.ts +0 -90
- package/dist/types/ttyid.d.ts +0 -9
- package/dist/types/tui.d.ts +0 -215
- package/dist/types/utils.d.ts +0 -87
|
@@ -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());
|
|
@@ -55,6 +77,21 @@ export function __setMarkdownNowForTest(now: (() => number) | undefined): void {
|
|
|
55
77
|
// prefix on every chunk. Bounded LRU; cleared on theme change via clearRenderCache().
|
|
56
78
|
const HIGHLIGHT_CACHE_MAX = 512;
|
|
57
79
|
const highlightCache = new LRUCache<string, string[]>({ max: HIGHLIGHT_CACHE_MAX });
|
|
80
|
+
|
|
81
|
+
function renderedLinesBytes(lines: readonly string[]): number {
|
|
82
|
+
let bytes = 0;
|
|
83
|
+
for (const line of lines) bytes += Buffer.byteLength(line, "utf8");
|
|
84
|
+
return bytes;
|
|
85
|
+
}
|
|
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
|
+
|
|
58
95
|
// F18: cap synchronous (Rust FFI) syntax highlighting so a single huge fenced block
|
|
59
96
|
// cannot stall the UI thread; oversized blocks render plain with a sanitized marker.
|
|
60
97
|
const MAX_HIGHLIGHT_BYTES = 200_000;
|
|
@@ -96,6 +133,18 @@ export function clearRenderCache(): void {
|
|
|
96
133
|
highlightCache.clear();
|
|
97
134
|
}
|
|
98
135
|
|
|
136
|
+
export function getRenderCacheRetainedBytes(): number {
|
|
137
|
+
let bytes = 0;
|
|
138
|
+
for (const entry of renderCache.values()) {
|
|
139
|
+
bytes += Buffer.byteLength(entry.source, "utf8");
|
|
140
|
+
bytes += renderedLinesBytes(entry.lines);
|
|
141
|
+
if (entry.anchorSpans) bytes += anchorSpansBytes(entry.anchorSpans);
|
|
142
|
+
}
|
|
143
|
+
for (const entry of parseCache.values()) bytes += Buffer.byteLength(entry.source, "utf8");
|
|
144
|
+
for (const lines of highlightCache.values()) bytes += renderedLinesBytes(lines);
|
|
145
|
+
return bytes;
|
|
146
|
+
}
|
|
147
|
+
|
|
99
148
|
// Stable numeric IDs for structural theme/style objects (no ID field on type).
|
|
100
149
|
// Symbol-keyed so the id travels with the object and is invisible to consumers.
|
|
101
150
|
const kObjectId = Symbol("markdown.objectId");
|
|
@@ -180,6 +229,29 @@ function formatHyperlink(text: string, target: string): string {
|
|
|
180
229
|
return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`;
|
|
181
230
|
}
|
|
182
231
|
|
|
232
|
+
function stripHtmlComments(raw: string): string {
|
|
233
|
+
let result = "";
|
|
234
|
+
let index = 0;
|
|
235
|
+
|
|
236
|
+
while (index < raw.length) {
|
|
237
|
+
const start = raw.indexOf("<!--", index);
|
|
238
|
+
if (start < 0) {
|
|
239
|
+
result += raw.slice(index);
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
result += raw.slice(index, start);
|
|
244
|
+
const end = raw.indexOf("-->", start + 4);
|
|
245
|
+
if (end < 0) {
|
|
246
|
+
result += raw.slice(start);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
index = end + 3;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
|
|
183
255
|
export class Markdown implements Component {
|
|
184
256
|
#text: string;
|
|
185
257
|
#paddingX: number; // Left/right padding
|
|
@@ -194,6 +266,7 @@ export class Markdown implements Component {
|
|
|
194
266
|
#cachedText?: string;
|
|
195
267
|
#cachedWidth?: number;
|
|
196
268
|
#cachedLines?: string[];
|
|
269
|
+
#cachedAnchorSpans?: Array<ViewportAnchorSpan | null>;
|
|
197
270
|
|
|
198
271
|
#streaming = false;
|
|
199
272
|
#lastFullParseAt = 0;
|
|
@@ -269,6 +342,7 @@ export class Markdown implements Component {
|
|
|
269
342
|
this.#cachedText = undefined;
|
|
270
343
|
this.#cachedWidth = undefined;
|
|
271
344
|
this.#cachedLines = undefined;
|
|
345
|
+
this.#cachedAnchorSpans = undefined;
|
|
272
346
|
}
|
|
273
347
|
|
|
274
348
|
#exceedsHighlightCap(code: string): boolean {
|
|
@@ -313,20 +387,35 @@ export class Markdown implements Component {
|
|
|
313
387
|
}
|
|
314
388
|
|
|
315
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> } {
|
|
316
394
|
// L1: per-instance cache — fastest path for repeated renders of the same
|
|
317
395
|
// instance at the same width (e.g. resize debounce, repeated redraws).
|
|
318
|
-
if (
|
|
319
|
-
|
|
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 };
|
|
320
403
|
}
|
|
321
404
|
|
|
322
405
|
// Calculate available width for content (subtract horizontal padding)
|
|
323
406
|
const contentWidth = Math.max(1, width - this.#paddingX * 2);
|
|
324
407
|
|
|
325
|
-
if (
|
|
408
|
+
if (
|
|
409
|
+
this.#streaming &&
|
|
410
|
+
this.#cachedLines &&
|
|
411
|
+
this.#cachedWidth === width &&
|
|
412
|
+
this.#lastFullParseAt > 0 &&
|
|
413
|
+
(!includeAnchors || this.#cachedAnchorSpans !== undefined)
|
|
414
|
+
) {
|
|
326
415
|
const elapsedMs = markdownNow() - this.#lastFullParseAt;
|
|
327
416
|
if (elapsedMs < MARKDOWN_STREAM_THROTTLE_MS) {
|
|
328
417
|
this.#armStaleThrottleTimer(MARKDOWN_STREAM_THROTTLE_MS - elapsedMs);
|
|
329
|
-
return this.#cachedLines;
|
|
418
|
+
return { lines: this.#cachedLines, spans: this.#cachedAnchorSpans };
|
|
330
419
|
}
|
|
331
420
|
}
|
|
332
421
|
|
|
@@ -337,7 +426,8 @@ export class Markdown implements Component {
|
|
|
337
426
|
this.#cachedText = this.#text;
|
|
338
427
|
this.#cachedWidth = width;
|
|
339
428
|
this.#cachedLines = result;
|
|
340
|
-
|
|
429
|
+
this.#cachedAnchorSpans = includeAnchors ? [] : undefined;
|
|
430
|
+
return { lines: result, spans: this.#cachedAnchorSpans };
|
|
341
431
|
}
|
|
342
432
|
|
|
343
433
|
// Replace tabs with 3 spaces for consistent rendering
|
|
@@ -354,12 +444,17 @@ export class Markdown implements Component {
|
|
|
354
444
|
const headingProbe = this.#theme.heading("");
|
|
355
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}`;
|
|
356
446
|
const cached = renderCache.get(cacheKey);
|
|
357
|
-
if (
|
|
447
|
+
if (
|
|
448
|
+
cached !== undefined &&
|
|
449
|
+
cached.source === normalizedText &&
|
|
450
|
+
(!includeAnchors || cached.anchorSpans !== undefined)
|
|
451
|
+
) {
|
|
358
452
|
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
|
|
359
453
|
this.#cachedText = this.#text;
|
|
360
454
|
this.#cachedWidth = width;
|
|
361
455
|
this.#cachedLines = cached.lines;
|
|
362
|
-
|
|
456
|
+
this.#cachedAnchorSpans = cached.anchorSpans;
|
|
457
|
+
return { lines: cached.lines, spans: cached.anchorSpans };
|
|
363
458
|
}
|
|
364
459
|
|
|
365
460
|
// Parse markdown to marked tokens. Parse cache is width/theme independent,
|
|
@@ -376,23 +471,105 @@ export class Markdown implements Component {
|
|
|
376
471
|
parseCache.set(contentKey, { source: normalizedText, tokens });
|
|
377
472
|
}
|
|
378
473
|
|
|
379
|
-
// 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).
|
|
380
479
|
const renderedLines: string[] = [];
|
|
480
|
+
const tokenBoundaries: Array<{ start: number; end: number; srcLo: number; srcHi: number; units: number[] }> = [];
|
|
481
|
+
let srcOffset = 0;
|
|
381
482
|
|
|
382
483
|
for (let i = 0; i < tokens.length; i++) {
|
|
383
484
|
const token = tokens[i];
|
|
384
485
|
const nextToken = tokens[i + 1];
|
|
385
|
-
const
|
|
486
|
+
const tokenStart = renderedLines.length;
|
|
487
|
+
const units: number[] | undefined = includeAnchors ? [] : undefined;
|
|
488
|
+
const tokenLines = this.#renderToken(token, contentWidth, nextToken?.type, undefined, units);
|
|
386
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
|
+
}
|
|
387
506
|
}
|
|
388
507
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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));
|
|
396
573
|
}
|
|
397
574
|
}
|
|
398
575
|
|
|
@@ -429,21 +606,42 @@ export class Markdown implements Component {
|
|
|
429
606
|
emptyLines.push(line);
|
|
430
607
|
}
|
|
431
608
|
|
|
432
|
-
// Combine top padding, content, and bottom padding
|
|
433
609
|
const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
|
|
610
|
+
const rawAnchorSpans = wrappedSpans && [
|
|
611
|
+
...emptyLines.map(() => null),
|
|
612
|
+
...wrappedSpans,
|
|
613
|
+
...emptyLines.map(() => null),
|
|
614
|
+
];
|
|
434
615
|
const result = rawResult.length > 0 ? rawResult : [""];
|
|
616
|
+
const anchorSpans = rawResult.length > 0 ? rawAnchorSpans : wrappedSpans ? [null] : undefined;
|
|
435
617
|
|
|
436
618
|
// Update L1 per-instance cache
|
|
437
619
|
this.#cachedText = this.#text;
|
|
438
620
|
this.#cachedWidth = width;
|
|
439
621
|
this.#cachedLines = result;
|
|
622
|
+
this.#cachedAnchorSpans = anchorSpans;
|
|
440
623
|
this.#lastFullParseAt = markdownNow();
|
|
441
624
|
|
|
442
625
|
// Update L2 module-level LRU so future instances with the same key skip
|
|
443
626
|
// the marked.lexer + highlightCode (Rust FFI) work entirely.
|
|
444
|
-
renderCache.set(cacheKey, { source: normalizedText, lines: result });
|
|
627
|
+
renderCache.set(cacheKey, { source: normalizedText, lines: result, ...(anchorSpans ? { anchorSpans } : {}) });
|
|
445
628
|
|
|
446
|
-
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
|
+
};
|
|
447
645
|
}
|
|
448
646
|
|
|
449
647
|
/**
|
|
@@ -529,7 +727,13 @@ export class Markdown implements Component {
|
|
|
529
727
|
};
|
|
530
728
|
}
|
|
531
729
|
|
|
532
|
-
#renderToken(
|
|
730
|
+
#renderToken(
|
|
731
|
+
token: Token,
|
|
732
|
+
width: number,
|
|
733
|
+
nextTokenType?: string,
|
|
734
|
+
styleContext?: InlineStyleContext,
|
|
735
|
+
units?: number[],
|
|
736
|
+
): string[] {
|
|
533
737
|
const lines: string[] = [];
|
|
534
738
|
|
|
535
739
|
switch (token.type) {
|
|
@@ -587,7 +791,7 @@ export class Markdown implements Component {
|
|
|
587
791
|
}
|
|
588
792
|
|
|
589
793
|
case "list": {
|
|
590
|
-
const listLines = this.#renderList(token as ListToken, 0, styleContext);
|
|
794
|
+
const listLines = this.#renderList(token as ListToken, 0, styleContext, units);
|
|
591
795
|
lines.push(...listLines);
|
|
592
796
|
// Don't add spacing after lists if a space token follows
|
|
593
797
|
// (the space token will handle it)
|
|
@@ -595,7 +799,7 @@ export class Markdown implements Component {
|
|
|
595
799
|
}
|
|
596
800
|
|
|
597
801
|
case "table": {
|
|
598
|
-
const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
|
|
802
|
+
const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext, units);
|
|
599
803
|
lines.push(...tableLines);
|
|
600
804
|
break;
|
|
601
805
|
}
|
|
@@ -621,28 +825,40 @@ export class Markdown implements Component {
|
|
|
621
825
|
const quoteContentWidth = Math.max(1, width - 2);
|
|
622
826
|
const quoteTokens = token.tokens || [];
|
|
623
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[] = [];
|
|
624
832
|
|
|
625
833
|
for (let i = 0; i < quoteTokens.length; i++) {
|
|
626
834
|
const quoteToken = quoteTokens[i];
|
|
627
835
|
const nextQuoteToken = quoteTokens[i + 1];
|
|
628
|
-
|
|
629
|
-
|
|
836
|
+
const childLines = this.#renderToken(
|
|
837
|
+
quoteToken,
|
|
838
|
+
quoteContentWidth,
|
|
839
|
+
nextQuoteToken?.type,
|
|
840
|
+
quoteInlineStyleContext,
|
|
630
841
|
);
|
|
842
|
+
for (let c = 0; c < childLines.length; c++) quoteLineUnit.push(i);
|
|
843
|
+
renderedQuoteLines.push(...childLines);
|
|
631
844
|
}
|
|
632
845
|
|
|
633
846
|
while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
|
|
634
847
|
renderedQuoteLines.pop();
|
|
848
|
+
quoteLineUnit.pop();
|
|
635
849
|
}
|
|
636
850
|
|
|
637
|
-
for (
|
|
638
|
-
const styledLine = applyQuoteStyle(
|
|
851
|
+
for (let q = 0; q < renderedQuoteLines.length; q++) {
|
|
852
|
+
const styledLine = applyQuoteStyle(renderedQuoteLines[q]);
|
|
639
853
|
const wrappedLines = wrapTextIfNeeded(styledLine, quoteContentWidth);
|
|
640
854
|
for (const wrappedLine of wrappedLines) {
|
|
641
855
|
lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
|
|
856
|
+
units?.push(quoteLineUnit[q]);
|
|
642
857
|
}
|
|
643
858
|
}
|
|
644
859
|
if (nextTokenType && nextTokenType !== "space") {
|
|
645
860
|
lines.push(""); // Add spacing after blockquotes (unless space token follows)
|
|
861
|
+
units?.push(quoteTokens.length > 0 ? quoteTokens.length - 1 : 0);
|
|
646
862
|
}
|
|
647
863
|
break;
|
|
648
864
|
}
|
|
@@ -654,12 +870,15 @@ export class Markdown implements Component {
|
|
|
654
870
|
}
|
|
655
871
|
break;
|
|
656
872
|
|
|
657
|
-
case "html":
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
|
|
873
|
+
case "html": {
|
|
874
|
+
// HTML comments are invisible markup (React/SSR text separators often emit "<!-- -->").
|
|
875
|
+
// Keep other HTML-like model text visible as plain terminal text.
|
|
876
|
+
const visibleHtml = "raw" in token && typeof token.raw === "string" ? stripHtmlComments(token.raw) : "";
|
|
877
|
+
if (visibleHtml.trim().length > 0) {
|
|
878
|
+
lines.push(this.#applyDefaultStyle(visibleHtml.trim()));
|
|
661
879
|
}
|
|
662
880
|
break;
|
|
881
|
+
}
|
|
663
882
|
|
|
664
883
|
case "space":
|
|
665
884
|
// Space tokens represent blank lines in markdown
|
|
@@ -673,6 +892,9 @@ export class Markdown implements Component {
|
|
|
673
892
|
}
|
|
674
893
|
}
|
|
675
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);
|
|
676
898
|
return lines;
|
|
677
899
|
}
|
|
678
900
|
|
|
@@ -745,12 +967,14 @@ export class Markdown implements Component {
|
|
|
745
967
|
break;
|
|
746
968
|
}
|
|
747
969
|
|
|
748
|
-
case "html":
|
|
749
|
-
//
|
|
750
|
-
|
|
751
|
-
|
|
970
|
+
case "html": {
|
|
971
|
+
// HTML comments are markup-only separators; keep other inline HTML visible as text.
|
|
972
|
+
const visibleHtml = "raw" in token && typeof token.raw === "string" ? stripHtmlComments(token.raw) : "";
|
|
973
|
+
if (visibleHtml.trim().length > 0) {
|
|
974
|
+
result += applyTextWithNewlines(visibleHtml);
|
|
752
975
|
}
|
|
753
976
|
break;
|
|
977
|
+
}
|
|
754
978
|
|
|
755
979
|
default:
|
|
756
980
|
// Handle any other inline token types as plain text
|
|
@@ -774,7 +998,7 @@ export class Markdown implements Component {
|
|
|
774
998
|
/**
|
|
775
999
|
* Render a list with proper nesting support
|
|
776
1000
|
*/
|
|
777
|
-
#renderList(token: ListToken, depth: number, styleContext?: InlineStyleContext): string[] {
|
|
1001
|
+
#renderList(token: ListToken, depth: number, styleContext?: InlineStyleContext, units?: number[]): string[] {
|
|
778
1002
|
const lines: string[] = [];
|
|
779
1003
|
const indent = " ".repeat(depth);
|
|
780
1004
|
// Use the list's start property (defaults to 1 for ordered lists)
|
|
@@ -817,6 +1041,9 @@ export class Markdown implements Component {
|
|
|
817
1041
|
} else {
|
|
818
1042
|
lines.push(indent + this.#theme.listBullet(bullet));
|
|
819
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);
|
|
820
1047
|
}
|
|
821
1048
|
|
|
822
1049
|
return lines;
|
|
@@ -896,6 +1123,7 @@ export class Markdown implements Component {
|
|
|
896
1123
|
availableWidth: number,
|
|
897
1124
|
nextTokenType?: string,
|
|
898
1125
|
styleContext?: InlineStyleContext,
|
|
1126
|
+
units?: number[],
|
|
899
1127
|
): string[] {
|
|
900
1128
|
const lines: string[] = [];
|
|
901
1129
|
const numCols = token.header.length;
|
|
@@ -1036,6 +1264,9 @@ export class Markdown implements Component {
|
|
|
1036
1264
|
const separatorCells = columnWidths.map(w => h.repeat(w));
|
|
1037
1265
|
const separatorLine = `${t.teeRight}${h}${separatorCells.join(`${h}${t.cross}${h}`)}${h}${t.teeLeft}`;
|
|
1038
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);
|
|
1039
1270
|
|
|
1040
1271
|
// Render rows with wrapping
|
|
1041
1272
|
for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
|
|
@@ -1057,6 +1288,7 @@ export class Markdown implements Component {
|
|
|
1057
1288
|
if (rowIndex < token.rows.length - 1) {
|
|
1058
1289
|
lines.push(separatorLine);
|
|
1059
1290
|
}
|
|
1291
|
+
if (units) while (units.length < lines.length) units.push(rowIndex + 1);
|
|
1060
1292
|
}
|
|
1061
1293
|
|
|
1062
1294
|
// Render bottom border
|
|
@@ -1066,6 +1298,7 @@ export class Markdown implements Component {
|
|
|
1066
1298
|
if (nextTokenType && nextTokenType !== "space") {
|
|
1067
1299
|
lines.push(""); // Add spacing after table
|
|
1068
1300
|
}
|
|
1301
|
+
if (units) while (units.length < lines.length) units.push(token.rows.length);
|
|
1069
1302
|
return lines;
|
|
1070
1303
|
}
|
|
1071
1304
|
}
|
|
@@ -1091,6 +1324,11 @@ export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseC
|
|
|
1091
1324
|
return `${applyText(prefix)}${content}`;
|
|
1092
1325
|
})
|
|
1093
1326
|
.join(applyText(" "));
|
|
1327
|
+
} else if (token.type === "html" && "raw" in token && typeof token.raw === "string") {
|
|
1328
|
+
const visibleHtml = stripHtmlComments(token.raw);
|
|
1329
|
+
if (visibleHtml.trim().length > 0) {
|
|
1330
|
+
result += applyText(visibleHtml);
|
|
1331
|
+
}
|
|
1094
1332
|
} else if ("text" in token && typeof token.text === "string") {
|
|
1095
1333
|
result += applyText(token.text);
|
|
1096
1334
|
}
|
|
@@ -1127,6 +1365,13 @@ function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText:
|
|
|
1127
1365
|
result += mdTheme.link(mdTheme.underline(linkText)) + styleReset;
|
|
1128
1366
|
break;
|
|
1129
1367
|
}
|
|
1368
|
+
case "html": {
|
|
1369
|
+
const visibleHtml = "raw" in token && typeof token.raw === "string" ? stripHtmlComments(token.raw) : "";
|
|
1370
|
+
if (visibleHtml.trim().length > 0) {
|
|
1371
|
+
result += applyText(visibleHtml);
|
|
1372
|
+
}
|
|
1373
|
+
break;
|
|
1374
|
+
}
|
|
1130
1375
|
default:
|
|
1131
1376
|
if ("text" in token && typeof token.text === "string") {
|
|
1132
1377
|
result += applyText(token.text);
|