@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.
Files changed (39) hide show
  1. package/package.json +8 -9
  2. package/src/components/image.ts +79 -39
  3. package/src/components/markdown.ts +282 -37
  4. package/src/components/sayknow-pet.ts +435 -0
  5. package/src/components/text.ts +60 -36
  6. package/src/index.ts +1 -0
  7. package/src/terminal-capabilities.ts +42 -0
  8. package/src/tui.ts +471 -55
  9. package/src/utils.ts +144 -8
  10. package/dist/types/animation-scheduler.d.ts +0 -13
  11. package/dist/types/autocomplete.d.ts +0 -83
  12. package/dist/types/bracketed-paste.d.ts +0 -26
  13. package/dist/types/components/box.d.ts +0 -20
  14. package/dist/types/components/cancellable-loader.d.ts +0 -21
  15. package/dist/types/components/editor.d.ts +0 -126
  16. package/dist/types/components/image.d.ts +0 -16
  17. package/dist/types/components/input.d.ts +0 -16
  18. package/dist/types/components/loader.d.ts +0 -23
  19. package/dist/types/components/markdown.d.ts +0 -77
  20. package/dist/types/components/select-list.d.ts +0 -46
  21. package/dist/types/components/settings-list.d.ts +0 -39
  22. package/dist/types/components/spacer.d.ts +0 -11
  23. package/dist/types/components/tab-bar.d.ts +0 -56
  24. package/dist/types/components/text.d.ts +0 -13
  25. package/dist/types/components/truncated-text.d.ts +0 -10
  26. package/dist/types/editor-component.d.ts +0 -36
  27. package/dist/types/fuzzy.d.ts +0 -15
  28. package/dist/types/index.d.ts +0 -27
  29. package/dist/types/keybindings.d.ts +0 -201
  30. package/dist/types/keys.d.ts +0 -208
  31. package/dist/types/kill-ring.d.ts +0 -27
  32. package/dist/types/metrics.d.ts +0 -85
  33. package/dist/types/stdin-buffer.d.ts +0 -50
  34. package/dist/types/symbols.d.ts +0 -23
  35. package/dist/types/terminal-capabilities.d.ts +0 -143
  36. package/dist/types/terminal.d.ts +0 -90
  37. package/dist/types/ttyid.d.ts +0 -9
  38. package/dist/types/tui.d.ts +0 -215
  39. 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 { 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());
@@ -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 (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
319
- 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 };
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 (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
+ ) {
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
- return result;
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 (cached !== undefined && cached.source === normalizedText) {
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
- return cached.lines;
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 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);
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
- // Wrap lines (NO padding, NO background yet)
390
- const wrappedLines: string[] = [];
391
- for (const line of renderedLines) {
392
- if (TERMINAL.isImageLine(line)) {
393
- wrappedLines.push(line);
394
- } else {
395
- 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));
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(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[] {
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
- renderedQuoteLines.push(
629
- ...this.#renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext),
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 (const quoteLine of renderedQuoteLines) {
638
- const styledLine = applyQuoteStyle(quoteLine);
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
- // Render HTML as plain text (escaped for terminal)
659
- if ("raw" in token && typeof token.raw === "string") {
660
- lines.push(this.#applyDefaultStyle(token.raw.trim()));
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
- // Render inline HTML as plain text
750
- if ("raw" in token && typeof token.raw === "string") {
751
- result += applyTextWithNewlines(token.raw);
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);