Skip to content

Commit 15151de

Browse files
committed
perf(tui): stop rejoining streaming markdown on every retext
Frame retext was rebuilding the frozen prefix, walking every code block, and copying the transcript child list even when the row only grew at the tail.
1 parent f5e3cd2 commit 15151de

7 files changed

Lines changed: 418 additions & 10 deletions

File tree

‎src/tui/code-links.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,13 +406,29 @@ export function armMarkdownLinks(
406406
};
407407
}
408408

409+
const armedChildCounts = new WeakMap<Renderable, number>();
410+
let codeLinkWalkCount = 0;
411+
412+
/** Subtree walks `armCodeLinks` actually performed. Equal child counts do not count. */
413+
export function codeLinkArmWalks(): number {
414+
return codeLinkWalkCount;
415+
}
416+
409417
/**
410418
* Fenced code is a Code renderable without markdown's link callbacks.
411419
* `onHighlight` keeps the tree-sitter groups; `onChunks` is `detectLinks`,
412420
* so a bare URL becomes an OSC-8 span and survives native wrap. Markdown
413421
* prose already sets both — do not replace those, or conceal regresses.
422+
* A retext whose child count did not grow has no new code block to arm.
414423
*/
415424
export function armCodeLinks(node: Renderable): void {
425+
const count = node.getChildrenCount();
426+
const previous = armedChildCounts.get(node);
427+
if (previous !== undefined && count <= previous) {
428+
if (count < previous) armedChildCounts.set(node, count);
429+
return;
430+
}
431+
codeLinkWalkCount += 1;
416432
const walk = (current: Renderable): void => {
417433
if (current instanceof CodeRenderable) {
418434
if (current.onHighlight === undefined) {
@@ -425,4 +441,5 @@ export function armCodeLinks(node: Renderable): void {
425441
for (const child of current.getChildren()) walk(child);
426442
};
427443
walk(node);
444+
armedChildCounts.set(node, count);
428445
}

‎src/tui/markdown-parser.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { describe, expect, test } from "bun:test";
22
import {
3+
clearMarkdownParseInputCache,
34
nextStreamMarkdownState,
45
parseMarkdown,
6+
settledBlockFullSplits,
57
splitAtSettledBlock,
68
splitAtSettledHeading,
79
withholdIncompleteHeading,
810
type StyledSegment,
911
} from "./markdown-parser.js";
12+
import { setTheme } from "./theme.js";
13+
import "./stream.js";
1014

1115
function firstLine(text: string): StyledSegment[] {
1216
return parseMarkdown(text)[0] ?? [];
@@ -579,6 +583,78 @@ describe("splitAtSettledBlock", () => {
579583
expect(splitAtSettledBlock(text)).toEqual(splitAtSettledHeading(text));
580584
}
581585
});
586+
587+
test("an unchanged body does not split or join again", () => {
588+
clearMarkdownParseInputCache();
589+
const text = "### Title\n\n```bash\necho hi\n```\n\ntail";
590+
const before = settledBlockFullSplits();
591+
const first = splitAtSettledBlock(text, 40);
592+
const second = splitAtSettledBlock(text, 40);
593+
expect(second).toBe(first);
594+
expect(second?.frozen).toBe(first?.frozen);
595+
expect(settledBlockFullSplits() - before).toBe(1);
596+
});
597+
598+
test("an append keeps the frozen string and skips another full-body join", () => {
599+
clearMarkdownParseInputCache();
600+
const base = "### Title\n\n```bash\necho hi\n```\n\ntail";
601+
const first = splitAtSettledBlock(base, 40);
602+
const before = settledBlockFullSplits();
603+
const next = splitAtSettledBlock(`${base} more`, 40);
604+
expect(next?.frozen).toBe(first?.frozen);
605+
expect(next?.live).toBe("tail more");
606+
expect(next?.gapRows).toBe(1);
607+
expect(settledBlockFullSplits() - before).toBe(0);
608+
});
609+
610+
test("appended chunks match a full split at every prefix", () => {
611+
clearMarkdownParseInputCache();
612+
const chunks = [
613+
"### Title",
614+
"\n\n",
615+
"hello",
616+
" world",
617+
"\n\n```bash\n",
618+
"echo hi\n",
619+
"```\n\n",
620+
"tail",
621+
" more",
622+
"\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n",
623+
"after",
624+
"\n\n## Next\n\n",
625+
"done",
626+
];
627+
let body = "";
628+
chunks.forEach((chunk, index) => {
629+
body += chunk;
630+
const fast = splitAtSettledBlock(body, 48);
631+
// A fresh width has no append chain, so this walk is the full split.
632+
const full = splitAtSettledBlock(body, -(index + 1));
633+
expect(fast).toEqual(full);
634+
});
635+
clearMarkdownParseInputCache();
636+
const rewritten = "### Title\n\nrewritten tail";
637+
expect(splitAtSettledBlock(rewritten, 48)).toEqual(
638+
splitAtSettledBlock(rewritten, -2),
639+
);
640+
});
641+
642+
test("a theme change drops the parse-input cache", () => {
643+
const text = "### T\n\nhello";
644+
try {
645+
setTheme("corbits-dark");
646+
clearMarkdownParseInputCache();
647+
splitAtSettledBlock(text, 12);
648+
const before = settledBlockFullSplits();
649+
splitAtSettledBlock(text, 12);
650+
expect(settledBlockFullSplits() - before).toBe(0);
651+
setTheme("corbits-light");
652+
splitAtSettledBlock(text, 12);
653+
expect(settledBlockFullSplits() - before).toBe(1);
654+
} finally {
655+
setTheme("corbits-dark");
656+
}
657+
});
582658
});
583659

584660
describe("nextStreamMarkdownState", () => {

‎src/tui/markdown-parser.ts‎

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { highlightCode } from "./syntax-highlight.js";
2+
import { onThemeChange } from "./theme.js";
23
import { wrapRanges, stringWidth } from "./view/height.js";
34

45
export interface StyledSegment {
@@ -742,8 +743,106 @@ export function splitAtSettledHeading(text: string): MarkdownSplit | null {
742743
* heading checks stay in that order so a settled block can freeze while the
743744
* tail is still streaming. An unclosed fence consumes to the end; blank lines
744745
* before the tail collapse to one.
746+
*
747+
* Repeat calls keep the previous frozen string object. An unchanged body
748+
* returns the cached split; an append reuses that frozen reference and only
749+
* rebuilds the live tail. The cache stores those parse inputs (text, width),
750+
* not painted nodes, and drops them when the theme changes.
745751
*/
746-
export function splitAtSettledBlock(withheld: string): MarkdownSplit | null {
752+
const PARSE_INPUT_CACHE_LIMIT = 32;
753+
754+
interface SettledSplitEntry {
755+
input: string;
756+
width: number;
757+
split: MarkdownSplit | null;
758+
restStart: number;
759+
}
760+
761+
const parseInputCache: SettledSplitEntry[] = [];
762+
let settledBlockFullSplitCount = 0;
763+
764+
/** Full-body split walks since process start. Appends and cache hits do not count. */
765+
export function settledBlockFullSplits(): number {
766+
return settledBlockFullSplitCount;
767+
}
768+
769+
/** Drop cached parse inputs. Theme changes call this; the strings are not styled. */
770+
export function clearMarkdownParseInputCache(): void {
771+
parseInputCache.length = 0;
772+
}
773+
774+
onThemeChange(() => {
775+
clearMarkdownParseInputCache();
776+
});
777+
778+
function rememberSplit(
779+
entry: SettledSplitEntry,
780+
drop?: SettledSplitEntry,
781+
): void {
782+
if (drop !== undefined) {
783+
const index = parseInputCache.indexOf(drop);
784+
if (index >= 0) parseInputCache.splice(index, 1);
785+
}
786+
parseInputCache.push(entry);
787+
if (parseInputCache.length > PARSE_INPUT_CACHE_LIMIT) parseInputCache.shift();
788+
}
789+
790+
function findCachedSplit(
791+
text: string,
792+
width: number,
793+
): SettledSplitEntry | undefined {
794+
for (let i = parseInputCache.length - 1; i >= 0; i--) {
795+
const entry = parseInputCache[i];
796+
if (entry === undefined || entry.width !== width || entry.input !== text) {
797+
continue;
798+
}
799+
if (i !== parseInputCache.length - 1) {
800+
parseInputCache.splice(i, 1);
801+
parseInputCache.push(entry);
802+
}
803+
return entry;
804+
}
805+
return undefined;
806+
}
807+
808+
function findAppendBase(
809+
text: string,
810+
width: number,
811+
): SettledSplitEntry | undefined {
812+
for (let i = parseInputCache.length - 1; i >= 0; i--) {
813+
const entry = parseInputCache[i];
814+
if (entry === undefined || entry.width !== width || entry.split === null) {
815+
continue;
816+
}
817+
if (text.length <= entry.input.length || !text.startsWith(entry.input)) {
818+
continue;
819+
}
820+
return entry;
821+
}
822+
return undefined;
823+
}
824+
825+
function restStartFor(withheld: string, split: MarkdownSplit | null): number {
826+
if (split === null || !withheld.startsWith(split.frozen)) return 0;
827+
const end = split.frozen.length;
828+
return end < withheld.length && withheld[end] === "\n" ? end + 1 : end;
829+
}
830+
831+
function firstContentOffset(text: string): number {
832+
let start = 0;
833+
for (let i = 0; i <= text.length; i++) {
834+
if (i !== text.length && text[i] !== "\n") continue;
835+
if (text.slice(start, i).trim().length > 0) return start;
836+
start = i + 1;
837+
}
838+
return text.length;
839+
}
840+
841+
function computeSettledSplit(
842+
withheld: string,
843+
account: boolean,
844+
): { split: MarkdownSplit | null; restStart: number } {
845+
if (account) settledBlockFullSplitCount += 1;
747846
const lines = withheld.split("\n");
748847
const boundaries = new Set<number>();
749848
let i = 0;
@@ -803,13 +902,78 @@ export function splitAtSettledBlock(withheld: string): MarkdownSplit | null {
803902
const rest = lines.slice(boundary + 1);
804903
const firstContent = rest.findIndex((entry) => entry.trim().length > 0);
805904
if (firstContent === -1) continue;
806-
return settledSplit(
905+
const split = settledSplit(
807906
lines.slice(0, boundary + 1).join("\n"),
808907
rest,
809908
firstContent,
810909
);
910+
return { split, restStart: restStartFor(withheld, split) };
911+
}
912+
return { split: null, restStart: 0 };
913+
}
914+
915+
function tryExtendSplit(
916+
prev: SettledSplitEntry,
917+
withheld: string,
918+
width: number,
919+
): SettledSplitEntry | undefined {
920+
const split = prev.split;
921+
if (split === null || !withheld.startsWith(split.frozen)) return undefined;
922+
const candidateLive = split.live + withheld.slice(prev.input.length);
923+
const moved = computeSettledSplit(candidateLive, false);
924+
if (moved.split === null) {
925+
return {
926+
input: withheld,
927+
width,
928+
split: {
929+
frozen: split.frozen,
930+
live: candidateLive,
931+
gapRows: split.gapRows,
932+
},
933+
restStart: prev.restStart,
934+
};
935+
}
936+
const tail = withheld.slice(prev.restStart);
937+
const at = firstContentOffset(tail);
938+
const marker = moved.split.frozen;
939+
if (!tail.startsWith(marker, at)) return undefined;
940+
const frozenEnd = prev.restStart + at + marker.length;
941+
const frozen = withheld.slice(0, frozenEnd);
942+
const next: MarkdownSplit = {
943+
frozen,
944+
live: moved.split.live,
945+
gapRows: moved.split.gapRows,
946+
};
947+
return {
948+
input: withheld,
949+
width,
950+
split: next,
951+
restStart: restStartFor(withheld, next),
952+
};
953+
}
954+
955+
export function splitAtSettledBlock(
956+
withheld: string,
957+
width = 0,
958+
): MarkdownSplit | null {
959+
const cached = findCachedSplit(withheld, width);
960+
if (cached) return cached.split;
961+
const base = findAppendBase(withheld, width);
962+
if (base) {
963+
const extended = tryExtendSplit(base, withheld, width);
964+
if (extended) {
965+
rememberSplit(extended, base);
966+
return extended.split;
967+
}
811968
}
812-
return null;
969+
const computed = computeSettledSplit(withheld, true);
970+
rememberSplit({
971+
input: withheld,
972+
width,
973+
split: computed.split,
974+
restStart: computed.restStart,
975+
});
976+
return computed.split;
813977
}
814978

815979
/** Paint state for one streaming markdown row across repaints. */
@@ -833,6 +997,8 @@ export function nextStreamMarkdownState(
833997
): { state: StreamMarkdownSnapshot; paintFrozen: boolean } {
834998
const state: StreamMarkdownSnapshot = { content, frozen, width, streaming };
835999
if (prev === null) return { state, paintFrozen: true };
1000+
// Frozen text is the cached string object, so an unchanged boundary does
1001+
// not compare a freshly joined copy of the prefix.
8361002
const paintFrozen =
8371003
prev.frozen !== frozen ||
8381004
prev.width !== width ||

‎src/tui/shell/chrome.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ import {
139139
labelBefore,
140140
noteAgentVoice,
141141
retextStreamRow,
142+
transcriptLaidOutRow,
143+
transcriptLaidOutRowCount,
142144
transcriptMarker,
143145
transcriptRowChildren,
144146
transcriptRowLayout,
@@ -1353,16 +1355,17 @@ export function replaceStreamRowAt(
13531355
if (local < 0 || local >= shell.streamLog.length) return;
13541356
shell.streamLog[local] = row;
13551357

1356-
const children = transcriptRowChildren(shell);
1357-
// A raw appendTranscript line breaks the 1:1 node↔row mapping; fall back to
1358-
// a full repaint, which derives every node from the log.
1359-
if (children.length !== shell.streamLog.length) {
1358+
// Index the content list directly. `getChildren()` copies every row to
1359+
// find this one, and an in-place retext never needs that copy. A raw
1360+
// appendTranscript line breaks the 1:1 node-row mapping; fall back to a
1361+
// full repaint, which derives every node from the log.
1362+
if (transcriptLaidOutRowCount(shell) !== shell.streamLog.length) {
13601363
repaintTranscriptWindow(shell);
13611364
paintChrome(shell);
13621365
return;
13631366
}
13641367

1365-
const stale = children[local];
1368+
const stale = transcriptLaidOutRow(shell, local);
13661369
if (stale && retextStreamRow(shell, stale, row, labelBefore(shell, local))) {
13671370
paintChrome(shell);
13681371
return;

0 commit comments

Comments
 (0)