Skip to content

Commit 98899d9

Browse files
committed
refactor(tui): keep markdown stream settling beside native render
Drop the unused fenced-code painter and parse memoizer. Native MarkdownRenderable already paints fences. Keep parseMarkdown for the empty-link and narrow-table cases native still paints differently.
1 parent e0e31cd commit 98899d9

2 files changed

Lines changed: 14 additions & 213 deletions

File tree

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

Lines changed: 0 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2-
import { defined } from "../../testkit/defined.js";
32
import {
4-
createMemoizedParseMarkdown,
53
nextStreamMarkdownState,
64
parseMarkdown,
75
splitAtSettledBlock,
@@ -116,74 +114,6 @@ describe("block elements", () => {
116114
expect(segs[0]?.rule).toBe(true);
117115
expect(segs[0]?.text).not.toContain("-");
118116
});
119-
120-
test("fenced code block renders inner lines as code without inline parsing", () => {
121-
const lines = parseMarkdown("```\nconst x = **not bold**\n```");
122-
const body = lines.flat().find((s) => s.text.includes("const x"));
123-
expect(body?.code).toBe(true);
124-
expect(body?.text).toContain("const x = **not bold**");
125-
expect(lines[0]?.[0]?.text).toContain("╭");
126-
});
127-
128-
test("a fenced block keeps its language cap with a plain body", () => {
129-
const lines = parseMarkdown('```js\nconst x = "hi";\n```');
130-
const body = lines.flat().find((s) => s.text.includes("const x"));
131-
expect(body?.code).toBe(true);
132-
expect(body?.color).toBeUndefined();
133-
expect(lines[0]?.map((s) => s.text).join("")).toContain("js");
134-
});
135-
136-
test("an unclosed streaming fence keeps a plain body", () => {
137-
const lines = parseMarkdown("```js\nconst x = 1;");
138-
const body = lines.flat().find((s) => s.text.includes("const x"));
139-
expect(body?.code).toBe(true);
140-
expect(body?.color).toBeUndefined();
141-
});
142-
143-
test("drops a half-typed closing fence from the streaming tail", () => {
144-
const lines = parseMarkdown("```js\nconst x = 1;\n``");
145-
const rendered = lines
146-
.flat()
147-
.map((s) => s.text)
148-
.join("");
149-
expect(rendered).not.toContain("``");
150-
expect(rendered).toContain("const");
151-
});
152-
153-
test("closing fence streamed one character at a time never shrinks the block", () => {
154-
// The newline after the body starts a fresh, empty line that could become
155-
// the closing fence. Streaming ` then `` then ``` across it must never
156-
// remove an already-visible line (shrink reads as flicker) — only hold
157-
// steady or grow as the fence completes.
158-
const base = "```js\nconst x = 1;\n";
159-
const steps = [base, `${base}\``, `${base}\`\``, `${base}\`\`\``];
160-
const lineCounts = steps.map((content) => parseMarkdown(content).length);
161-
for (let i = 1; i < lineCounts.length; i++) {
162-
expect(lineCounts[i]).toBeGreaterThanOrEqual(defined(lineCounts[i - 1]));
163-
}
164-
});
165-
166-
test("blank lines inside a fenced block keep a continuous gutter", () => {
167-
const lines = parseMarkdown("```ts\nconst a = 1;\n\nconst b = 2;\n```");
168-
// Cap, body, blank, body, foot — every body row carries the gutter so the
169-
// frame does not fragment.
170-
const gutterLines = lines.filter((line) =>
171-
line.some((s) => s.text.includes("▏")),
172-
);
173-
expect(gutterLines.length).toBeGreaterThanOrEqual(3);
174-
// The blank body line is not an empty segment array — it still paints ▏.
175-
const blankBody = lines.find(
176-
(line) =>
177-
line.length === 1 &&
178-
line[0]?.text === "▏ " &&
179-
line[0]?.codeFence === true,
180-
);
181-
expect(blankBody).toBeDefined();
182-
expect(lines[0]?.some((s) => s.text.includes("╭"))).toBe(true);
183-
expect(lines[lines.length - 1]?.some((s) => s.text.includes("╰"))).toBe(
184-
true,
185-
);
186-
});
187117
});
188118

189119
describe("bullets", () => {
@@ -495,54 +425,6 @@ test("link with an empty URL still renders as styled text, not raw characters",
495425
expect(segs.some((s) => s.text.includes("("))).toBe(false);
496426
});
497427

498-
describe("createMemoizedParseMarkdown", () => {
499-
test("returns the same array reference for a repeated (text, width) call", () => {
500-
const memoized = createMemoizedParseMarkdown();
501-
const first = memoized("hello **world**", 80);
502-
const second = memoized("hello **world**", 80);
503-
expect(second).toBe(first);
504-
});
505-
506-
test("misses on a different width even with identical text", () => {
507-
const memoized = createMemoizedParseMarkdown();
508-
const at80 = memoized("some prose", 80);
509-
const at40 = memoized("some prose", 40);
510-
expect(at40).not.toBe(at80);
511-
expect(at40).toEqual(parseMarkdown("some prose", 40));
512-
});
513-
514-
test("misses on different text at the same width", () => {
515-
const memoized = createMemoizedParseMarkdown();
516-
const a = memoized("first", 80);
517-
const b = memoized("second", 80);
518-
expect(b).not.toBe(a);
519-
});
520-
521-
test("evicts the least recently used entry once over capacity", () => {
522-
const memoized = createMemoizedParseMarkdown(2);
523-
const a = memoized("a", 80);
524-
const b = memoized("b", 80);
525-
// Touch "a" again so "b" becomes the least recently used entry.
526-
expect(memoized("a", 80)).toBe(a);
527-
const c = memoized("c", 80); // over capacity: evicts "b"
528-
529-
// "b" was evicted: same content, but freshly parsed (not identical).
530-
expect(memoized("b", 80)).not.toBe(b);
531-
// "c" is still warm — inserted more recently than "a", which the re-fetch
532-
// of "b" evicted.
533-
expect(memoized("c", 80)).toBe(c);
534-
});
535-
536-
test("clear() drops every cached entry", () => {
537-
const memoized = createMemoizedParseMarkdown();
538-
const first = memoized("hello", 80);
539-
memoized.clear();
540-
const second = memoized("hello", 80);
541-
expect(second).not.toBe(first);
542-
expect(second).toEqual(first);
543-
});
544-
});
545-
546428
describe("withholdIncompleteHeading", () => {
547429
test("strips a trailing bare heading marker while streaming", () => {
548430
expect(withholdIncompleteHeading("hello\n####")).toBe("hello\n");

‎src/tui/markdown-parser.ts‎

Lines changed: 14 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,6 @@ function parseLine(line: string): StyledSegment[] {
200200

201201
const FENCE_OPEN_RE = /^\s*(```+|~~~+)/;
202202
const FENCE_CLOSE_RE = /^\s*(```+|~~~+)\s*$/;
203-
// A partially-typed closing fence: zero to two lone fence chars on a line
204-
// (zero = the empty line right after the body). Stripped from the streaming
205-
// tail so the block does not flicker while the fence is typed.
206-
const PARTIAL_FENCE_RE = /^\s*[`~]{0,2}\s*$/;
207203
const INDENTED_CODE_RE = /^(?: {4}|\t)(.*)$/;
208204
const CODE_GUTTER = "▏ ";
209205

@@ -232,48 +228,6 @@ function fencedFoot(): StyledSegment[] {
232228
return [{ text: "╰", dim: true, codeFence: true }];
233229
}
234230

235-
// Collect a fenced block from `start`: highlight the body by the fence's
236-
// language token and frame it with cap/gutter glyphs. An unclosed streaming
237-
// block drops a partial closing fence so the tail re-highlights cleanly.
238-
function parseFencedBlock(
239-
input: string[],
240-
start: number,
241-
width: number,
242-
): FencedBlock {
243-
const openerLine = input[start];
244-
if (openerLine == null) throw new Error("fence opener missing");
245-
const language =
246-
openerLine.match(/^\s*(?:```+|~~~+)\s*([^\s`]*)/)?.[1] || undefined;
247-
const body: string[] = [];
248-
let i = start + 1;
249-
let closed = false;
250-
for (; i < input.length; i++) {
251-
const line = input[i];
252-
if (line == null) throw new Error("fence line missing");
253-
if (FENCE_CLOSE_RE.test(line)) {
254-
closed = true;
255-
break;
256-
}
257-
body.push(line);
258-
}
259-
const consumed = closed ? i - start + 1 : i - start;
260-
261-
const last = body[body.length - 1];
262-
if (!closed && last != null && PARTIAL_FENCE_RE.test(last)) {
263-
body.pop();
264-
}
265-
266-
const capLabel = language && language.length > 0 ? language : "code";
267-
const lines: StyledSegment[][] = [fencedCap(capLabel)];
268-
if (body.length > 0) {
269-
lines.push(
270-
...highlightCode(body.join("\n"), language, width).map(codeGutterPrefix),
271-
);
272-
}
273-
if (closed) lines.push(fencedFoot());
274-
return { lines, consumed };
275-
}
276-
277231
function parseIndentedCodeBlock(
278232
input: string[],
279233
start: number,
@@ -298,47 +252,6 @@ function parseIndentedCodeBlock(
298252
return { lines, consumed: i - start };
299253
}
300254

301-
export type MemoizedParseMarkdown = ((
302-
text: string,
303-
width?: number,
304-
) => StyledSegment[][]) & {
305-
clear: () => void;
306-
};
307-
308-
const DEFAULT_MARKDOWN_CACHE_ENTRIES = 32;
309-
310-
// Segments never change for a fixed (text, width) pair and resize-free
311-
// re-renders re-ask for the same pair, so a small bounded LRU makes those
312-
// hits. Callers clear() it with the per-block line cache (event-log.tsx /
313-
// app.tsx) so it does not grow across a session.
314-
export function createMemoizedParseMarkdown(
315-
maxEntries = DEFAULT_MARKDOWN_CACHE_ENTRIES,
316-
): MemoizedParseMarkdown {
317-
const cache = new Map<string, StyledSegment[][]>();
318-
319-
const memoized = (text: string, width = Infinity): StyledSegment[][] => {
320-
const key = `${width}\x1f${text}`;
321-
const cached = cache.get(key);
322-
if (cached !== undefined) {
323-
// Bump recency by re-inserting at the end of Map's iteration order.
324-
cache.delete(key);
325-
cache.set(key, cached);
326-
return cached;
327-
}
328-
329-
const result = parseMarkdown(text, width);
330-
cache.set(key, result);
331-
if (cache.size > maxEntries) {
332-
const oldestKey = cache.keys().next().value;
333-
if (oldestKey !== undefined) cache.delete(oldestKey);
334-
}
335-
return result;
336-
};
337-
338-
memoized.clear = () => cache.clear();
339-
return memoized;
340-
}
341-
342255
// `width` is the column budget the event log wraps to; tables lay out to it
343256
// (default Infinity = natural width).
344257
export function parseMarkdown(
@@ -352,12 +265,17 @@ export function parseMarkdown(
352265
const line = input[i];
353266
if (line == null) throw new Error("markdown line missing");
354267

355-
// Fenced code block (``` or ~~~): collect it whole so the body can be
356-
// syntax-highlighted by the fence's language token.
268+
// Fenced code (``` / ~~~) paints through MarkdownRenderable. This walker
269+
// still has to skip the body so a fence is not re-read as a table or list.
357270
if (FENCE_OPEN_RE.test(line)) {
358-
const block = parseFencedBlock(input, i, width);
359-
lines.push(...block.lines);
360-
i += block.consumed - 1;
271+
let closedAt = -1;
272+
for (let j = i + 1; j < input.length; j++) {
273+
if (FENCE_CLOSE_RE.test(input[j] ?? "")) {
274+
closedAt = j;
275+
break;
276+
}
277+
}
278+
i = closedAt === -1 ? input.length - 1 : closedAt;
361279
continue;
362280
}
363281

@@ -820,9 +738,10 @@ export function splitAtSettledHeading(text: string): MarkdownSplit | null {
820738

821739
/**
822740
* Split at the last settled block boundary: heading, fence closer, or table
823-
* row. Null when none has content behind it. The walk mirrors parseMarkdown's
824-
* dispatch order, so halves render the same apart as together. An unclosed
825-
* fence consumes to the end; blank lines before the tail collapse to one.
741+
* row. Null when none has content behind it. Fence, indented-code, table, and
742+
* heading checks stay in that order so a settled block can freeze while the
743+
* tail is still streaming. An unclosed fence consumes to the end; blank lines
744+
* before the tail collapse to one.
826745
*/
827746
export function splitAtSettledBlock(withheld: string): MarkdownSplit | null {
828747
const lines = withheld.split("\n");

0 commit comments

Comments
 (0)