Skip to content

Commit 0d780ee

Browse files
Merge pull request #1540 from corbitsdev/cl-10819-streaming-retext-on-main
perf(tui): stop rejoining streaming markdown on every retext
2 parents 2ae5137 + b7a3118 commit 0d780ee

7 files changed

Lines changed: 553 additions & 10 deletions

File tree

‎src/tui/code-links.ts‎

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

409+
const armedChildIdentity = new WeakMap<Renderable, readonly Renderable[]>();
410+
let codeLinkWalkCount = 0;
411+
412+
/** Subtree walks `armCodeLinks` actually performed. Same child nodes do not count. */
413+
export function codeLinkArmWalks(): number {
414+
return codeLinkWalkCount;
415+
}
416+
417+
function sameChildIdentity(
418+
previous: readonly Renderable[],
419+
next: readonly Renderable[],
420+
): boolean {
421+
if (previous.length !== next.length) return false;
422+
for (let i = 0; i < previous.length; i += 1) {
423+
if (previous[i] !== next[i]) return false;
424+
}
425+
return true;
426+
}
427+
409428
/**
410429
* Fenced code is a Code renderable without markdown's link callbacks.
411430
* `onHighlight` keeps the tree-sitter groups; `onChunks` is `detectLinks`,
412431
* so a bare URL becomes an OSC-8 span and survives native wrap. Markdown
413432
* prose already sets both — do not replace those, or conceal regresses.
433+
* OpenTUI can swap the only block for a new CodeRenderable without growing
434+
* the child count, so the walk follows child identity, not the count. A
435+
* list box can also stay the same object while a fence is inserted under
436+
* it; that new code node still needs the walk.
414437
*/
415438
export function armCodeLinks(node: Renderable): void {
439+
const children = node.getChildren();
440+
const previous = armedChildIdentity.get(node);
441+
if (
442+
previous !== undefined &&
443+
sameChildIdentity(previous, children) &&
444+
!hasUnarmedCode(node)
445+
) {
446+
return;
447+
}
448+
codeLinkWalkCount += 1;
416449
const walk = (current: Renderable): void => {
417450
if (current instanceof CodeRenderable) {
418451
if (current.onHighlight === undefined) {
@@ -425,4 +458,19 @@ export function armCodeLinks(node: Renderable): void {
425458
for (const child of current.getChildren()) walk(child);
426459
};
427460
walk(node);
461+
armedChildIdentity.set(node, children);
462+
}
463+
464+
/** True when a code node under `node` was created without link callbacks. */
465+
function hasUnarmedCode(node: Renderable): boolean {
466+
if (
467+
node instanceof CodeRenderable &&
468+
(node.onHighlight === undefined || node.onChunks === undefined)
469+
) {
470+
return true;
471+
}
472+
for (const child of node.getChildren()) {
473+
if (hasUnarmedCode(child)) return true;
474+
}
475+
return false;
428476
}

‎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)