Skip to content

Commit a5466a1

Browse files
Merge pull request #1290 from corbitsdev/cl-9011-streaming-markdown-tail
perf(tui): avoid reparsing frozen streaming markdown
2 parents 814d06c + f2bfcd9 commit a5466a1

4 files changed

Lines changed: 617 additions & 18 deletions

File tree

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

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { describe, expect, test } from "bun:test";
22
import { defined } from "../../testkit/defined.js";
33
import {
44
createMemoizedParseMarkdown,
5+
nextStreamMarkdownState,
56
parseMarkdown,
7+
splitAtSettledBlock,
68
splitAtSettledHeading,
79
withholdIncompleteHeading,
810
type StyledSegment,
@@ -567,3 +569,230 @@ describe("splitAtSettledHeading", () => {
567569
expect(splitAtSettledHeading("# Title")).toBeNull();
568570
});
569571
});
572+
573+
describe("splitAtSettledBlock", () => {
574+
test("freezes through the latest settled block (heading, fence, table)", () => {
575+
const lines = [
576+
"### Title",
577+
"",
578+
"```bash",
579+
"echo hi",
580+
"```",
581+
"",
582+
"| a | b |",
583+
"|---|---|",
584+
"| 1 | 2 |",
585+
"",
586+
"tail",
587+
];
588+
expect(splitAtSettledBlock(lines.join("\n"))).toEqual({
589+
frozen: lines.slice(0, 9).join("\n"),
590+
live: "tail",
591+
gapRows: 1,
592+
});
593+
});
594+
595+
test("freezes a closed fence with prose after the closer", () => {
596+
expect(splitAtSettledBlock("```bash\necho hi\n```\n\ntail")).toEqual({
597+
frozen: "```bash\necho hi\n```",
598+
live: "tail",
599+
gapRows: 1,
600+
});
601+
});
602+
603+
test("supports tilde fences and indented openers up to three spaces", () => {
604+
expect(splitAtSettledBlock("~~~\ncode\n~~~\n\ntail")).toEqual({
605+
frozen: "~~~\ncode\n~~~",
606+
live: "tail",
607+
gapRows: 1,
608+
});
609+
expect(splitAtSettledBlock(" ```js\ncode\n ```\n\ntail")).toEqual({
610+
frozen: " ```js\ncode\n ```",
611+
live: "tail",
612+
gapRows: 1,
613+
});
614+
});
615+
616+
test("a four-space indented fence is not a fence, so earlier text can settle", () => {
617+
expect(splitAtSettledBlock("## T\n\n ```\nx")).toEqual({
618+
frozen: "## T",
619+
live: " ```\nx",
620+
gapRows: 1,
621+
});
622+
});
623+
624+
test("an unclosed fence after settled text still leaves the settled split in place", () => {
625+
expect(splitAtSettledBlock("### T\n\n```bash\necho hi")).toEqual({
626+
frozen: "### T",
627+
live: "```bash\necho hi",
628+
gapRows: 1,
629+
});
630+
expect(splitAtSettledBlock("```bash\necho hi")).toBeNull();
631+
});
632+
633+
test("a closed block at the last line falls back to the earlier split", () => {
634+
expect(splitAtSettledBlock("## T\n\n```\ncode\n```")).toEqual({
635+
frozen: "## T",
636+
live: "```\ncode\n```",
637+
gapRows: 1,
638+
});
639+
expect(splitAtSettledBlock("## T\n\n| h |\n|---|\n| r |")).toEqual({
640+
frozen: "## T",
641+
live: "| h |\n|---|\n| r |",
642+
gapRows: 1,
643+
});
644+
expect(splitAtSettledBlock("```\ncode\n```")).toBeNull();
645+
expect(splitAtSettledBlock("| h |\n|---|\n| r |")).toBeNull();
646+
});
647+
648+
test("freezes through a completed separator table, not just the heading", () => {
649+
const lines = [
650+
"## T",
651+
"",
652+
"Name | Age",
653+
"--- | ---",
654+
"Ada | 36",
655+
"",
656+
"tail",
657+
];
658+
expect(splitAtSettledBlock(lines.join("\n"))).toEqual({
659+
frozen: lines.slice(0, 5).join("\n"),
660+
live: "tail",
661+
gapRows: 1,
662+
});
663+
});
664+
665+
test("freezes through a completed borderless table", () => {
666+
const lines = [
667+
"## T",
668+
"",
669+
"| h1 | h2 |",
670+
"| r1 | r2 |",
671+
"| r3 | r4 |",
672+
"",
673+
"tail",
674+
];
675+
expect(splitAtSettledBlock(lines.join("\n"))).toEqual({
676+
frozen: lines.slice(0, 5).join("\n"),
677+
live: "tail",
678+
gapRows: 1,
679+
});
680+
});
681+
682+
test("a table that reaches the last line keeps the row live", () => {
683+
expect(splitAtSettledBlock("| h |\n|---|\n| r1 |\n| r2")).toBeNull();
684+
expect(splitAtSettledBlock("## T\n\n| h |\n|---|\n| r1 |\n| r2")).toEqual({
685+
frozen: "## T",
686+
live: "| h |\n|---|\n| r1 |\n| r2",
687+
gapRows: 1,
688+
});
689+
});
690+
691+
test("a table with no separator is not a table", () => {
692+
expect(splitAtSettledBlock("a | b\n\ntail")).toBeNull();
693+
});
694+
695+
test("agrees with splitAtSettledHeading on heading-only content", () => {
696+
for (const text of ["# Title\nbody", "# Title", "plain"]) {
697+
expect(splitAtSettledBlock(text)).toEqual(splitAtSettledHeading(text));
698+
}
699+
});
700+
});
701+
702+
describe("nextStreamMarkdownState", () => {
703+
const frozen = "### T";
704+
const base = {
705+
content: `${frozen}\n\ntail`,
706+
frozen,
707+
width: 100,
708+
streaming: true,
709+
};
710+
711+
test("a first paint always repaints", () => {
712+
expect(
713+
nextStreamMarkdownState(
714+
null,
715+
base.content,
716+
base.frozen,
717+
base.width,
718+
true,
719+
),
720+
).toEqual({
721+
state: base,
722+
paintFrozen: true,
723+
});
724+
});
725+
726+
test("tail-only growth skips the frozen repaint", () => {
727+
const grown = { ...base, content: `${frozen}\n\ntail grows` };
728+
const next = nextStreamMarkdownState(
729+
base,
730+
grown.content,
731+
grown.frozen,
732+
grown.width,
733+
true,
734+
);
735+
expect(next.paintFrozen).toBe(false);
736+
expect(next.state.content).toBe(grown.content);
737+
});
738+
739+
test("a moved boundary repaints the frozen node", () => {
740+
const moved = { ...base, frozen: `${frozen}\n\nsettled` };
741+
expect(
742+
nextStreamMarkdownState(
743+
base,
744+
base.content,
745+
moved.frozen,
746+
base.width,
747+
true,
748+
).paintFrozen,
749+
).toBe(true);
750+
});
751+
752+
test("width and streaming flips repaint the frozen node", () => {
753+
expect(
754+
nextStreamMarkdownState(
755+
base,
756+
base.content,
757+
base.frozen,
758+
base.width + 1,
759+
true,
760+
).paintFrozen,
761+
).toBe(true);
762+
expect(
763+
nextStreamMarkdownState(
764+
base,
765+
base.content,
766+
base.frozen,
767+
base.width,
768+
false,
769+
).paintFrozen,
770+
).toBe(true);
771+
});
772+
773+
test("mid-stream edits repaint the frozen node", () => {
774+
const edited = `${frozen}\n\nrewritten`;
775+
const prev = { ...base, content: `${frozen}\n\ntail` };
776+
const next = nextStreamMarkdownState(
777+
prev,
778+
edited,
779+
frozen,
780+
base.width,
781+
true,
782+
);
783+
expect(next.paintFrozen).toBe(true);
784+
expect(next.state).toBeDefined();
785+
});
786+
787+
test("identical content still skips the frozen repaint", () => {
788+
const next = nextStreamMarkdownState(
789+
base,
790+
base.content,
791+
base.frozen,
792+
base.width,
793+
true,
794+
);
795+
expect(next.paintFrozen).toBe(false);
796+
expect(next.state).toEqual(base);
797+
});
798+
});

‎src/tui/markdown-parser.ts‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -854,3 +854,119 @@ export function splitAtSettledHeading(text: string): MarkdownSplit | null {
854854
gapRows: firstContent > 0 ? 1 : 0,
855855
};
856856
}
857+
858+
/**
859+
* A markdown body split at the last settled block boundary: a settled ATX
860+
* heading line, the closing fence of a completed fenced block, or the last
861+
* row of a table the renderer would consume. Returns null when no boundary
862+
* has settled yet (or when the only boundary is the last line, leaving no
863+
* live tail).
864+
*
865+
* The walk below mirrors parseMarkdown's dispatch order line for line —
866+
* loose fence pairing (`FENCE_OPEN_RE`/`FENCE_CLOSE_RE`), indented-code runs,
867+
* then `parseTableBlock`'s start/consume/shape rules — so every recorded
868+
* boundary is a block boundary in the one-shot parse, and the frozen prefix
869+
* plus the live suffix render the same apart as they do together. An
870+
* unclosed fence consumes to the end of the row, so nothing after its opener
871+
* can settle. A single non-blank line between a boundary and the live tail
872+
* still yields `gapRows: 0`; blank lines collapse to one gap row, as before.
873+
*/
874+
export function splitAtSettledBlock(withheld: string): MarkdownSplit | null {
875+
const lines = withheld.split("\n");
876+
const boundaries = new Set<number>();
877+
let i = 0;
878+
while (i < lines.length) {
879+
const line = lines[i] ?? "";
880+
if (FENCE_OPEN_RE.test(line)) {
881+
let closer = -1;
882+
for (let j = i + 1; j < lines.length; j += 1) {
883+
if (FENCE_CLOSE_RE.test(lines[j] ?? "")) {
884+
closer = j;
885+
break;
886+
}
887+
}
888+
if (closer === -1) break;
889+
boundaries.add(closer);
890+
i = closer + 1;
891+
continue;
892+
}
893+
if (INDENTED_CODE_RE.test(line)) {
894+
i += 1;
895+
while (i < lines.length && INDENTED_CODE_RE.test(lines[i] ?? "")) i += 1;
896+
continue;
897+
}
898+
if (looksLikeTableRow(line)) {
899+
const next = lines[i + 1];
900+
const hasSeparator = next !== undefined && isTableSeparator(next);
901+
const rawRows: string[][] = [extractTableCells(line)];
902+
let consumed = 1;
903+
if (hasSeparator) {
904+
rawRows.push(extractTableCells(next ?? ""));
905+
consumed = 2;
906+
}
907+
while (i + consumed < lines.length) {
908+
const rowLine = lines[i + consumed] ?? "";
909+
if (!looksLikeTableRow(rowLine) || isTableSeparator(rowLine)) break;
910+
rawRows.push(extractTableCells(rowLine));
911+
consumed += 1;
912+
}
913+
const firstWidth = rawRows[0]?.length ?? 0;
914+
const valid =
915+
hasSeparator ||
916+
(rawRows.length >= 2 &&
917+
firstWidth >= 2 &&
918+
rawRows.every((row) => row.length === firstWidth));
919+
if (valid) {
920+
boundaries.add(i + consumed - 1);
921+
i += consumed;
922+
continue;
923+
}
924+
}
925+
if (ATX_HEADING_LINE_RE.test(line)) boundaries.add(i);
926+
i += 1;
927+
}
928+
const ordered = [...boundaries].sort((a, b) => b - a);
929+
for (const boundary of ordered) {
930+
if (boundary >= lines.length - 1) continue;
931+
const rest = lines.slice(boundary + 1);
932+
const firstContent = rest.findIndex((entry) => entry.trim().length > 0);
933+
if (firstContent === -1) continue;
934+
return {
935+
frozen: lines.slice(0, boundary + 1).join("\n"),
936+
live: rest.slice(firstContent).join("\n"),
937+
gapRows: firstContent > 0 ? 1 : 0,
938+
};
939+
}
940+
return null;
941+
}
942+
943+
/** Paint state for one streaming markdown row across repaints. */
944+
export interface StreamMarkdownSnapshot {
945+
readonly content: string;
946+
readonly frozen: string | null;
947+
readonly width: number;
948+
readonly streaming: boolean;
949+
}
950+
951+
/**
952+
* Decide whether a streaming markdown repaint must repaint the frozen node.
953+
* The same frozen text at the same width, still streaming a pure append,
954+
* needs no frozen repaint; a moved boundary, a resize, a streaming-flag
955+
* flip, or a non-append edit (rollback, rewrite) repaints it.
956+
*/
957+
export function nextStreamMarkdownState(
958+
prev: StreamMarkdownSnapshot | null,
959+
content: string,
960+
frozen: string | null,
961+
width: number,
962+
streaming: boolean,
963+
): { state: StreamMarkdownSnapshot; paintFrozen: boolean } {
964+
const state: StreamMarkdownSnapshot = { content, frozen, width, streaming };
965+
if (prev === null) return { state, paintFrozen: true };
966+
const paintFrozen =
967+
prev.frozen !== frozen ||
968+
prev.width !== width ||
969+
prev.streaming !== streaming ||
970+
!content.startsWith(prev.content);
971+
return { state, paintFrozen };
972+
}

0 commit comments

Comments
 (0)