fix(tui): preserve streamed table coordinates

This commit is contained in:
pppobear
2026-07-15 21:24:01 +08:00
parent f9459bf462
commit 8570d80f25
3 changed files with 146 additions and 27 deletions
+70 -27
View File
@@ -1309,7 +1309,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
try {
contentLines = this.transientRenderCache
? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
: this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
: this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
} finally {
this.#activeRenderSignature = undefined;
this.#activeTableRenderSpecs = undefined;
@@ -1374,16 +1374,18 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
const frozenText = this.#streamPrefixText;
const frozenTokenCount = this.#streamPrefixTokens?.length ?? 0;
if (frozenText === undefined || frozenTokenCount === 0 || !normalizedText.startsWith(frozenText)) {
return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
}
const contentLines: string[] = [];
const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, frozenText, signature);
let renderedUntil = 0;
let renderedSourceOffset = 0;
if (reusablePrefix && reusablePrefix.tokenCount <= frozenTokenCount) {
contentLines.push(...reusablePrefix.lines);
this.#activeTableRenderSpecs?.push(...reusablePrefix.tables);
renderedUntil = reusablePrefix.tokenCount;
renderedSourceOffset = reusablePrefix.text.length;
}
if (renderedUntil < frozenTokenCount) {
@@ -1399,6 +1401,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
contentWidth,
signature,
contentLines.length,
renderedSourceOffset,
),
);
} finally {
@@ -1437,6 +1440,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
contentWidth,
signature,
contentLines.length,
frozenText.length,
),
);
}
@@ -1472,16 +1476,17 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
end: number,
contentWidth: number,
signature: RenderSignature,
rowOffset = 0,
rowOffset: number,
startingSourceOffset: number,
): string[] {
const wrappedLines: string[] = [];
let sourceOffset = 0;
for (let i = 0; i < start; i++) sourceOffset += tokens[i]!.raw.length;
let sourceOffset = startingSourceOffset;
for (let i = start; i < end; i++) {
const token = tokens[i];
const nextToken = tokens[i + 1];
const tableSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
const tokenRowStart = rowOffset + wrappedLines.length;
const tokenWrappedRowStart = wrappedLines.length;
const tokenRowStart = rowOffset + tokenWrappedRowStart;
const renderedTokenLines = this.#renderToken(
token,
contentWidth,
@@ -1489,6 +1494,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
undefined,
`offset:${sourceOffset}`,
);
const tokenLineOffsets = [0];
for (const line of renderedTokenLines) {
// Skip wrapping for image protocol lines and OSC 66 sized headings
// (would corrupt escape sequences / split the indivisible sized span).
@@ -1497,25 +1503,27 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
} else {
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
}
tokenLineOffsets.push(wrappedLines.length - tokenWrappedRowStart);
}
const tokenRowEnd = rowOffset + wrappedLines.length;
const tableSpecs = this.#activeTableRenderSpecs;
if (tableSpecs !== undefined) {
for (let specIndex = tableSpecStart; specIndex < tableSpecs.length; specIndex++) {
const spec = tableSpecs[specIndex]!;
let relativeStart: number;
let relativeEnd: number;
if (token.type === "table") {
// A top-level table's own rows are already width-bounded, so none
// wrap here. Exclude the optional inter-block blank from its span.
spec.startRow = tokenRowStart;
spec.endRow = Math.min(tokenRowEnd, tokenRowStart + spec.lineCount);
// Exclude the optional inter-block blank from a top-level table's span.
relativeStart = 0;
relativeEnd = Math.min(renderedTokenLines.length, spec.lineCount);
} else {
// Tables nested in a blockquote inherit the enclosing token's span.
// This is conservative (it may lock within the quote's prose head)
// but remains structural and can never confuse unrelated text for
// a table border.
spec.startRow = tokenRowStart;
spec.endRow = tokenRowEnd;
// Container renderers express nested table spans relative to their
// returned lines. Preserve that exact span through this final wrap.
if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
relativeStart = Math.min(renderedTokenLines.length, spec.startRow);
relativeEnd = Math.min(renderedTokenLines.length, spec.endRow);
}
spec.startRow = tokenRowStart + tokenLineOffsets[relativeStart]!;
spec.endRow = tokenRowStart + tokenLineOffsets[relativeEnd]!;
}
}
sourceOffset += token.raw.length;
@@ -1908,26 +1916,59 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
const quoteContentWidth = Math.max(1, width - 2);
const quoteTokens = token.tokens || [];
const renderedQuoteLines: string[] = [];
const blockquoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
for (let i = 0; i < quoteTokens.length; i++) {
const quoteToken = quoteTokens[i];
const nextQuoteToken = quoteTokens[i + 1];
renderedQuoteLines.push(
...this.#renderToken(
quoteToken,
quoteContentWidth,
nextQuoteToken?.type,
quoteInlineStyleContext,
`${tokenKey}/quote:${i}`,
),
const quoteTokenRowStart = renderedQuoteLines.length;
const quoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
const quoteTokenLines = this.#renderToken(
quoteToken,
quoteContentWidth,
nextQuoteToken?.type,
quoteInlineStyleContext,
`${tokenKey}/quote:${i}`,
);
renderedQuoteLines.push(...quoteTokenLines);
const tableSpecs = this.#activeTableRenderSpecs;
if (tableSpecs !== undefined) {
for (let specIndex = quoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
const spec = tableSpecs[specIndex]!;
if (spec.startRow < 0) {
// Direct child tables initially have no row coordinates. Their
// structural line count excludes any inter-block blank.
spec.startRow = quoteTokenRowStart;
spec.endRow = quoteTokenRowStart + Math.min(quoteTokenLines.length, spec.lineCount);
} else {
// A nested blockquote already mapped the table into its own
// returned rows; translate those rows into this quote's input.
spec.startRow += quoteTokenRowStart;
spec.endRow += quoteTokenRowStart;
}
}
}
}
while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
renderedQuoteLines.pop();
}
lines.push(...this.#applyQuoteBorder(renderedQuoteLines, width));
const quoteRowOffsets: number[] = [];
const borderedQuoteLines = this.#applyQuoteBorder(renderedQuoteLines, width, quoteRowOffsets);
const tableSpecs = this.#activeTableRenderSpecs;
if (tableSpecs !== undefined) {
for (let specIndex = blockquoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
const spec = tableSpecs[specIndex]!;
if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
const relativeStart = Math.min(renderedQuoteLines.length, spec.startRow);
const relativeEnd = Math.min(renderedQuoteLines.length, spec.endRow);
spec.startRow = quoteRowOffsets[relativeStart]!;
spec.endRow = quoteRowOffsets[relativeEnd]!;
}
}
lines.push(...borderedQuoteLines);
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after blockquotes (unless space token follows)
}
@@ -1974,7 +2015,7 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
* Wrap already-rendered lines in the blockquote border and quote styling.
* `width` is the full content width; the border reserves two cells.
*/
#applyQuoteBorder(renderedLines: string[], width: number): string[] {
#applyQuoteBorder(renderedLines: string[], width: number, sourceRowOffsets?: number[]): string[] {
const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
const applyQuoteStyle = (line: string): string => {
@@ -1986,11 +2027,13 @@ export class Markdown implements Component, NativeScrollbackCommittedRows, Nativ
};
const quoteContentWidth = Math.max(1, width - 2);
const lines: string[] = [];
sourceRowOffsets?.push(0);
for (const quoteLine of renderedLines) {
const styledLine = applyQuoteStyle(quoteLine);
for (const wrappedLine of wrapTextWithAnsi(styledLine, quoteContentWidth)) {
lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
}
sourceRowOffsets?.push(lines.length);
}
return lines;
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from "bun:test";
import { stripVTControlCharacters } from "node:util";
import { clearRenderCache, Markdown, type MarkdownTheme } from "@oh-my-pi/pi-tui/components/markdown";
import { defaultMarkdownTheme } from "./test-themes.js";
@@ -90,4 +91,41 @@ describe("Markdown streaming prefix render cache", () => {
expect(streamingLines).toEqual(renderCold(prefix, defaultMarkdownTheme));
});
it("keeps table layout keys stable when rendering after a cached prefix", () => {
const prefix = `| Archived entry | Code |
| --- | --- |
| prefix-column-is-deliberately-wide | P000 |
`;
const initialText = `${prefix}| Live entry | Value |
| --- | --- |
| short | R000 |`;
const expandedText = `${initialText}
| tail-column-is-even-longer-than-before | R001 |`;
const md = new Markdown(initialText, 0, 0, defaultMarkdownTheme);
md.transientRenderCache = true;
const tableAt = (lines: readonly string[], marker: string): { startRow: number; border: string } => {
const plain = lines.map(line => stripVTControlCharacters(line).trimEnd());
const headerRow = plain.findIndex(line => line.includes(marker));
expect(headerRow).toBeGreaterThan(0);
return { startRow: headerRow - 1, border: plain[headerRow - 1]! };
};
const initialLines = md.render(WIDTH);
const prefixTable = tableAt(initialLines, "Archived entry");
const tailTable = tableAt(initialLines, "Live entry");
expect(prefixTable.border).not.toBe(tailTable.border);
md.setNativeScrollbackCommittedRows(tailTable.startRow + 1);
md.setText(expandedText);
const expandedLines = md.render(WIDTH);
const expandedPrefix = tableAt(expandedLines, "Archived entry");
const expandedTail = tableAt(expandedLines, "Live entry");
expect(expandedPrefix.border).toBe(prefixTable.border);
expect(expandedTail.border).toBe(tailTable.border);
expect(expandedTail.border).not.toBe(expandedPrefix.border);
expect(expandedLines.some(line => stripVTControlCharacters(line).includes("R001"))).toBe(true);
});
});
+38
View File
@@ -526,6 +526,44 @@ describe("Markdown component", () => {
expect(widenedBorders[3]).toBe(borders[3]);
});
it("does not lock a quoted table until the table itself enters native scrollback", () => {
const initial = `> > Intro sentence deliberately long enough to wrap across several physical quote rows before the table.
> >
> > | Entry | Value |
> > | --- | --- |
> > | short | R000 |`;
const beforeCommit = `${initial}
> > | medium-width-entry | R001 |`;
const afterCommit = `${beforeCommit}
> > | entry-that-is-even-wider-than-the-locked-layout | R002 |`;
const markdown = new Markdown(initial, 0, 0, defaultMarkdownTheme);
markdown.transientRenderCache = true;
const tableGeometry = (lines: readonly string[]): { start: number; border: string } => {
const plain = lines.map(line => stripVTControlCharacters(line).trimEnd());
const header = plain.findIndex(line => line.includes("Entry") && line.includes("Value"));
expect(header).toBeGreaterThan(0);
return { start: header - 1, border: plain[header - 1]! };
};
const initialLines = markdown.render(48);
const initialTable = tableGeometry(initialLines);
expect(initialTable.start).toBeGreaterThan(2);
// Commit only the quote prose; the nested table remains wholly live.
markdown.setNativeScrollbackCommittedRows(initialTable.start);
markdown.setText(beforeCommit);
const growingLines = markdown.render(48);
const growingTable = tableGeometry(growingLines);
expect(growingTable.border).not.toBe(initialTable.border);
markdown.setNativeScrollbackCommittedRows(growingTable.start + 1);
markdown.setText(afterCommit);
const lockedLines = markdown.render(48);
expect(tableGeometry(lockedLines).border).toBe(growingTable.border);
expect(lockedLines.some(line => stripVTControlCharacters(line).includes("R002"))).toBe(true);
});
it("recomputes a locked streamed table after resize or non-append replacement", () => {
const short = `| Entry | Value |
| --- | --- |