fix(tui): hang wrapped box-drawing tree lines under their node text
Prose lines shaped like tree guides ("├── item") wrapped by restarting
at column 0, shearing the tree apart — doubly fast for CJK text. Wrap
the node text within the cells remaining after the guide prefix and
re-emit the prefix on continuation rows in pass-through form (├ → │,
└ → blank), so ancestor rails stay joined. Paragraph/text blocks only;
fitting lines, non-tree prose, and code blocks are byte-for-byte
unchanged.
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
### Fixed
|
||||
|
||||
- Fixed submitted slash-command arguments treating `@` file-reference tokens as prompt-composer autocomplete triggers when the command does not define argument completions. ([#4600](https://github.com/can1357/oh-my-pi/issues/4600))
|
||||
- Fixed box-drawing tree lines (`├── item` — directory layouts, decision trees) in prose shearing apart when they wrap: continuation rows now hang under the node text with ancestor rails carried through (`├` → `│`, `└` → blank) instead of restarting at column 0. Applies to prose paragraphs (including inside blockquotes) only when a line with a branch-connector prefix (`├──`, `└─`, …) actually overflows; fitting lines, non-tree prose, and code blocks render byte-for-byte as before.
|
||||
|
||||
## [16.3.6] - 2026-07-04
|
||||
|
||||
|
||||
@@ -251,6 +251,155 @@ function splitTerminalLines(text: string): string[] {
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree-guide hanging wrap
|
||||
//
|
||||
// Models routinely emit box-drawing trees ("├── item") inside plain
|
||||
// paragraphs — directory layouts, decision trees. The lexer sees those lines
|
||||
// as ordinary prose, so the generic wrap pass restarts wrapped continuations
|
||||
// at column 0 and visually shears the tree apart (doubly fast for CJK text,
|
||||
// where every glyph is two cells wide). Mirror the guide semantics of
|
||||
// `tree(1)` / rich.tree instead: wrap the node text within the cells that
|
||||
// remain after the guide prefix, and indent every continuation row under the
|
||||
// node text — branch glyphs swap to their pass-through form (`├` → `│`,
|
||||
// `└` → blank) so the rails of still-open ancestors stay visually joined.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Continuation glyph for each guide character a tree prefix may contain. */
|
||||
const TREE_GUIDE_CONTINUATION: Record<string, string> = {
|
||||
"│": "│",
|
||||
"┃": "┃",
|
||||
"║": "║",
|
||||
"├": "│",
|
||||
"┣": "┃",
|
||||
"╠": "║",
|
||||
"└": " ",
|
||||
"┗": " ",
|
||||
"╚": " ",
|
||||
"╰": " ",
|
||||
"─": " ",
|
||||
"━": " ",
|
||||
"═": " ",
|
||||
" ": " ",
|
||||
};
|
||||
|
||||
/** Cheap pre-gate: any guide glyph at all. The structural test is TREE_BRANCH_CONNECTOR_RE. */
|
||||
const TREE_GUIDE_ANCHOR_RE = /[│┃║├┣╠└┗╚╰]/;
|
||||
|
||||
/**
|
||||
* A prefix qualifies as tree-shaped only when a branch/corner glyph is
|
||||
* immediately followed by a horizontal connector (`├──`, `└─`, `╰──`, …).
|
||||
* A lone rail or branch glyph used as prose ("│ is the Unicode vertical box
|
||||
* drawing glyph…") never qualifies, so such paragraphs keep the plain wrap.
|
||||
*/
|
||||
const TREE_BRANCH_CONNECTOR_RE = /[├┣╠└┗╚╰][─━═]/;
|
||||
|
||||
/** Below this many content cells a hanging wrap degenerates; keep the plain wrap. */
|
||||
const MIN_TREE_CONTENT_WIDTH = 8;
|
||||
|
||||
const SGR_SEQUENCE_STICKY = /\x1b\[[0-9;:]*m/y;
|
||||
const SGR_SEQUENCE_GLOBAL = /\x1b\[[0-9;:]*m/g;
|
||||
|
||||
/**
|
||||
* Everything before the last full SGR reset is dead state — drop it so the
|
||||
* re-played `carry` stays bounded by the paragraph's live style run instead
|
||||
* of its whole code history.
|
||||
*/
|
||||
function compactSgrCarry(carry: string): string {
|
||||
const shortReset = carry.lastIndexOf("\x1b[m");
|
||||
const longReset = carry.lastIndexOf("\x1b[0m");
|
||||
const cut = Math.max(shortReset === -1 ? -1 : shortReset + 3, longReset === -1 ? -1 : longReset + 4);
|
||||
return cut === -1 ? carry : carry.slice(cut);
|
||||
}
|
||||
|
||||
interface TreeGuidePrefix {
|
||||
/** Index of the first char past the guide run (start of the node text). */
|
||||
end: number;
|
||||
/** SGR sequences interleaved with the guides, in order (zero visible width). */
|
||||
codes: string;
|
||||
/** Guide characters with SGR stripped, exactly as they appear on screen. */
|
||||
guides: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the leading box-drawing guide run of a rendered line (e.g. `│ ├── `),
|
||||
* tolerating interleaved SGR styling. Returns undefined unless the run
|
||||
* contains a branch glyph joined to a horizontal connector and node text
|
||||
* follows, so dash art, indented prose, and lone glyphs used as prose are
|
||||
* never treated as a tree.
|
||||
*/
|
||||
function matchTreeGuidePrefix(line: string): TreeGuidePrefix | undefined {
|
||||
let codes = "";
|
||||
let guides = "";
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
if (line.charCodeAt(i) === 0x1b) {
|
||||
SGR_SEQUENCE_STICKY.lastIndex = i;
|
||||
const match = SGR_SEQUENCE_STICKY.exec(line);
|
||||
if (!match) break;
|
||||
codes += match[0];
|
||||
i = SGR_SEQUENCE_STICKY.lastIndex;
|
||||
continue;
|
||||
}
|
||||
const char = line[i]!;
|
||||
if (!(char in TREE_GUIDE_CONTINUATION)) break;
|
||||
guides += char;
|
||||
i++;
|
||||
}
|
||||
if (i >= line.length || !TREE_BRANCH_CONNECTOR_RE.test(guides)) return undefined;
|
||||
return { end: i, codes, guides };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hanging wrap for box-drawing tree lines inside prose block text.
|
||||
*
|
||||
* Returns undefined when no line needs the treatment, so paragraphs without
|
||||
* overflowing tree lines keep their exact current render. When a paragraph
|
||||
* does hang, its lines are returned pre-split and style-self-contained: the
|
||||
* SGR state open at each line start is re-played onto that line (`carry`),
|
||||
* because the caller's wrap pass — which normally carries SGR state across
|
||||
* the newlines of a single entry — no longer sees them as one entry.
|
||||
*/
|
||||
function hangWrapTreeGuideLines(text: string, width: number): string[] | undefined {
|
||||
if (width < MIN_TREE_CONTENT_WIDTH || !TREE_GUIDE_ANCHOR_RE.test(text)) return undefined;
|
||||
|
||||
const sourceLines = text.split("\n");
|
||||
const hangs = (line: string): TreeGuidePrefix | undefined => {
|
||||
if (visibleWidth(line) <= width) return undefined;
|
||||
const prefix = matchTreeGuidePrefix(line);
|
||||
if (!prefix) return undefined;
|
||||
if (width - visibleWidth(prefix.guides) < MIN_TREE_CONTENT_WIDTH) return undefined;
|
||||
return prefix;
|
||||
};
|
||||
if (!sourceLines.some(line => hangs(line) !== undefined)) return undefined;
|
||||
|
||||
const out: string[] = [];
|
||||
let carry = "";
|
||||
for (const line of sourceLines) {
|
||||
const prefix = hangs(line);
|
||||
if (!prefix) {
|
||||
out.push(carry ? carry + line : line);
|
||||
carry = compactSgrCarry(carry + (line.match(SGR_SEQUENCE_GLOBAL)?.join("") ?? ""));
|
||||
continue;
|
||||
}
|
||||
// Re-play the SGR state ahead of the node text so the wrapper carries
|
||||
// it onto every continuation row; the codes are zero-width, so measured
|
||||
// row widths are unaffected.
|
||||
const activeCodes = carry + prefix.codes;
|
||||
const rows = wrapTextWithAnsi(activeCodes + line.slice(prefix.end), width - visibleWidth(prefix.guides));
|
||||
let hang = "";
|
||||
for (const guide of prefix.guides) hang += TREE_GUIDE_CONTINUATION[guide] ?? " ";
|
||||
const hangShortfall = visibleWidth(prefix.guides) - visibleWidth(hang);
|
||||
if (hangShortfall > 0) hang += padding(hangShortfall);
|
||||
out.push(carry + line.slice(0, prefix.end) + rows[0]!.slice(activeCodes.length));
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
out.push(activeCodes + hang + rows[i]!);
|
||||
}
|
||||
carry = compactSgrCarry(carry + (line.match(SGR_SEQUENCE_GLOBAL)?.join("") ?? ""));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
class StrictStrikethroughTokenizer extends Tokenizer {
|
||||
override del(src: string): Tokens.Del | undefined {
|
||||
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
|
||||
@@ -1383,7 +1532,7 @@ export class Markdown implements Component {
|
||||
break;
|
||||
}
|
||||
const paragraphText = this.#renderInlineTokens(token.tokens || [], styleContext);
|
||||
lines.push(paragraphText);
|
||||
lines.push(...(hangWrapTreeGuideLines(paragraphText, width) ?? [paragraphText]));
|
||||
// Don't add spacing if next token is space or list
|
||||
if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
|
||||
lines.push("");
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { stripVTControlCharacters } from "node:util";
|
||||
import { Markdown } from "@oh-my-pi/pi-tui/components/markdown";
|
||||
import { visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui/utils";
|
||||
import { Chalk } from "chalk";
|
||||
import { defaultMarkdownTheme } from "./test-themes.js";
|
||||
|
||||
const WIDTH = 40;
|
||||
|
||||
function renderRaw(text: string, width = WIDTH): readonly string[] {
|
||||
return new Markdown(text, 0, 0, defaultMarkdownTheme).render(width);
|
||||
}
|
||||
|
||||
/** Rendered rows as plain text, right-padding stripped (rows are padded to full width). */
|
||||
function renderPlain(text: string, width = WIDTH): string[] {
|
||||
return renderRaw(text, width).map(line => stripVTControlCharacters(line).trimEnd());
|
||||
}
|
||||
|
||||
describe("Markdown tree-guide hanging wrap", () => {
|
||||
it("hangs an overflowing '├── ' node under the node-text column with double-width Korean text", () => {
|
||||
const node = "가나다라 마바사아 자차카타 파하가나 다라마바 사자차카"; // 6 words x 8 cells
|
||||
const raw = renderRaw(`├── ${node}`);
|
||||
const plain = raw.map(line => stripVTControlCharacters(line).trimEnd());
|
||||
|
||||
expect(plain.length).toBeGreaterThanOrEqual(2);
|
||||
expect(plain[0]!.startsWith("├── 가나다라")).toBeTruthy();
|
||||
|
||||
for (const line of plain.slice(1)) {
|
||||
// Exactly `│` + 3 spaces: the node text column is cell 4, so the
|
||||
// continuation text must begin right there — not a cell earlier or later.
|
||||
expect(line.startsWith("│ ")).toBeTruthy();
|
||||
expect(line[4]).not.toBe(" ");
|
||||
}
|
||||
for (const line of raw) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(WIDTH);
|
||||
}
|
||||
|
||||
// No glyph may be lost or duplicated by the wrap (spaces are consumed at
|
||||
// break points, so compare with spaces removed).
|
||||
const rejoined = [plain[0]!.slice("├── ".length), ...plain.slice(1).map(line => line.slice("│ ".length))]
|
||||
.join("")
|
||||
.replace(/ /g, "");
|
||||
expect(rejoined).toBe(node.replace(/ /g, ""));
|
||||
});
|
||||
|
||||
it("keeps the outer rail and releases the corner for a nested '│ └── ' node", () => {
|
||||
const plain = renderPlain("│ └── delta echo foxtrot golf hotel india juliet");
|
||||
|
||||
expect(plain.length).toBeGreaterThanOrEqual(2);
|
||||
expect(plain[0]!.startsWith("│ └── delta")).toBeTruthy();
|
||||
for (const line of plain.slice(1)) {
|
||||
// `│` stays (outer level still open), `└──` releases to spaces.
|
||||
expect(line.startsWith("│ ")).toBeTruthy();
|
||||
expect(line[8]).not.toBe(" ");
|
||||
}
|
||||
});
|
||||
|
||||
it("releases a last-child '└── ' node to pure spaces with no rail on continuations", () => {
|
||||
const plain = renderPlain("└── alpha bravo charlie delta echo foxtrot golf hotel");
|
||||
|
||||
expect(plain.length).toBeGreaterThanOrEqual(2);
|
||||
expect(plain[0]!.startsWith("└── alpha")).toBeTruthy();
|
||||
for (const line of plain.slice(1)) {
|
||||
expect(line.startsWith(" ")).toBeTruthy();
|
||||
expect(line[4]).not.toBe(" ");
|
||||
expect(line.includes("│")).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it("treats the rounded corner '╰── ' like '└── '", () => {
|
||||
const plain = renderPlain("╰── alpha bravo charlie delta echo foxtrot golf hotel");
|
||||
|
||||
expect(plain.length).toBeGreaterThanOrEqual(2);
|
||||
expect(plain[0]!.startsWith("╰── alpha")).toBeTruthy();
|
||||
for (const line of plain.slice(1)) {
|
||||
expect(line.startsWith(" ")).toBeTruthy();
|
||||
expect(line[4]).not.toBe(" ");
|
||||
expect(line.includes("│")).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a non-tree paragraph byte-identical to the plain wrap", () => {
|
||||
const text = "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima";
|
||||
const plain = renderPlain(text);
|
||||
|
||||
// Differential against the generic wrapper: the tree feature must not
|
||||
// have touched this paragraph at all.
|
||||
const expected = wrapTextWithAnsi(text, WIDTH).map(line => line.trimEnd());
|
||||
expect(plain).toEqual(expected);
|
||||
|
||||
expect(plain.length).toBeGreaterThanOrEqual(2);
|
||||
for (const line of plain.slice(1)) {
|
||||
expect(line[0]).not.toBe(" ");
|
||||
expect(line[0]).not.toBe("│");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not treat a dash-only '── ' start as a tree", () => {
|
||||
const text = "── alpha bravo charlie delta echo foxtrot golf hotel india";
|
||||
const plain = renderPlain(text);
|
||||
|
||||
const expected = wrapTextWithAnsi(text, WIDTH).map(line => line.trimEnd());
|
||||
expect(plain).toEqual(expected);
|
||||
|
||||
expect(plain.length).toBeGreaterThanOrEqual(2);
|
||||
for (const line of plain.slice(1)) {
|
||||
// Flush at column 0: no injected hang, no rail.
|
||||
expect(line[0]).not.toBe(" ");
|
||||
expect(line[0]).not.toBe("│");
|
||||
}
|
||||
});
|
||||
|
||||
it("renders a fitting tree line-for-line unchanged", () => {
|
||||
const plain = renderPlain("├── alpha\n│ └── beta\n└── gamma");
|
||||
|
||||
expect(plain).toEqual(["├── alpha", "│ └── beta", "└── gamma"]);
|
||||
});
|
||||
|
||||
it("keeps the old column-0 wrap for '├── ' lines inside fenced code blocks", () => {
|
||||
const codeLine = "├── alpha bravo charlie delta echo foxtrot golf hotel india";
|
||||
const raw = renderRaw(`\`\`\`\n${codeLine}\n\`\`\``);
|
||||
const plain = raw.map(line => stripVTControlCharacters(line).trimEnd());
|
||||
|
||||
expect(plain[0]).toBe("```");
|
||||
expect(plain[plain.length - 1]).toBe("```");
|
||||
|
||||
const treeRow = plain.findIndex(line => line.includes("├──"));
|
||||
expect(treeRow).toBeGreaterThan(0);
|
||||
// The code line overflows, so a continuation row exists before the
|
||||
// closing fence — and it starts flush at column 0, no hanging prefix.
|
||||
const continuation = plain[treeRow + 1]!;
|
||||
expect(treeRow + 1).toBeLessThan(plain.length - 1);
|
||||
expect(continuation.length).toBeGreaterThan(0);
|
||||
expect(continuation[0]).not.toBe(" ");
|
||||
expect(continuation[0]).not.toBe("│");
|
||||
|
||||
for (const line of raw) {
|
||||
expect(visibleWidth(line)).toBeLessThanOrEqual(WIDTH);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries an open bold span onto the continuation row", () => {
|
||||
const raw = renderRaw("├── aaaa bbbb **cccc dddd eeee ffff gggg hhhh**");
|
||||
const plain = raw.map(line => stripVTControlCharacters(line).trimEnd());
|
||||
|
||||
// Row 0 holds 36 node cells ("aaaa bbbb cccc dddd eeee ffff gggg"),
|
||||
// so "hhhh" — inside the bold span — lands on the continuation row.
|
||||
expect(plain.length).toBeGreaterThanOrEqual(2);
|
||||
expect(plain[1]!.startsWith("│ hhhh")).toBeTruthy();
|
||||
|
||||
const continuation = raw[1]!;
|
||||
const boldOpen = continuation.indexOf("\x1b[1m");
|
||||
expect(boldOpen).toBeGreaterThanOrEqual(0);
|
||||
expect(boldOpen).toBeLessThan(continuation.indexOf("hhhh"));
|
||||
});
|
||||
|
||||
it("hangs inside a blockquote, after the quote border", () => {
|
||||
const plain = renderPlain("> ├── alpha bravo charlie delta echo foxtrot golf hotel india");
|
||||
|
||||
expect(plain.length).toBeGreaterThanOrEqual(2);
|
||||
// Quote border symbol, border gap, then the tree prefix.
|
||||
expect(plain[0]!.startsWith("│ ├── alpha")).toBeTruthy();
|
||||
const continuations = plain.slice(1).filter(line => line !== ""); // drop trailing spacing rows
|
||||
expect(continuations.length).toBeGreaterThanOrEqual(1);
|
||||
for (const line of continuations) {
|
||||
expect(line.startsWith("│ │ ")).toBeTruthy();
|
||||
expect(line[6]).not.toBe(" ");
|
||||
}
|
||||
});
|
||||
|
||||
describe("detection strictness and carry edge cases", () => {
|
||||
const SGR_RE = /\x1b\[[0-9;:]*m/g;
|
||||
|
||||
it("keeps plain wrap for prose starting with a lone '│ ' rail glyph", () => {
|
||||
const text = "│ is the Unicode vertical box drawing glyph used for rails in terminal trees";
|
||||
const raw = renderRaw(text);
|
||||
|
||||
// Byte-identical to the generic wrapper: no branch+connector pair,
|
||||
// so the tree feature must not inject rails or indent.
|
||||
expect(raw.map(line => line.trimEnd())).toEqual(wrapTextWithAnsi(text, WIDTH).map(line => line.trimEnd()));
|
||||
|
||||
const plain = renderPlain(text);
|
||||
expect(plain.length).toBeGreaterThanOrEqual(2); // the paragraph really overflowed
|
||||
for (const line of plain.slice(1)) {
|
||||
expect(line[0]).not.toBe(" ");
|
||||
expect(line[0]).not.toBe("│");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps plain wrap for prose starting with '├ ' without a horizontal connector", () => {
|
||||
const text = "├ marks a branch point in a tree diagram and has no horizontal connector here";
|
||||
const raw = renderRaw(text);
|
||||
|
||||
expect(raw.map(line => line.trimEnd())).toEqual(wrapTextWithAnsi(text, WIDTH).map(line => line.trimEnd()));
|
||||
|
||||
const plain = renderPlain(text);
|
||||
expect(plain.length).toBeGreaterThanOrEqual(2);
|
||||
for (const line of plain.slice(1)) {
|
||||
expect(line[0]).not.toBe(" ");
|
||||
expect(line[0]).not.toBe("│");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to plain wrap when fewer than 8 content cells remain after the prefix", () => {
|
||||
const text = "├── alpha bravo charlie delta echo foxtrot golf hotel india";
|
||||
|
||||
// Width 10 leaves 10 - 4 = 6 content cells after the '├── ' prefix:
|
||||
// below the minimum, so the hang degenerates and plain wrap wins.
|
||||
const raw = renderRaw(text, 10);
|
||||
expect(raw.map(line => line.trimEnd())).toEqual(wrapTextWithAnsi(text, 10).map(line => line.trimEnd()));
|
||||
const plain = renderPlain(text, 10);
|
||||
expect(plain.length).toBeGreaterThanOrEqual(2);
|
||||
for (const line of plain.slice(1)) {
|
||||
expect(line[0]).not.toBe(" ");
|
||||
expect(line[0]).not.toBe("│");
|
||||
}
|
||||
|
||||
// Width 12 leaves exactly 8 content cells — the boundary where the
|
||||
// hanging wrap applies again.
|
||||
const hung = renderPlain(text, 12);
|
||||
expect(hung[0]).toBe("├── alpha");
|
||||
expect(hung.length).toBeGreaterThanOrEqual(2);
|
||||
for (const line of hung.slice(1)) {
|
||||
expect(line.startsWith("│ ")).toBeTruthy();
|
||||
expect(line[4]).not.toBe(" ");
|
||||
}
|
||||
});
|
||||
|
||||
it("replays SGR state opened on an earlier line onto a later hung line's continuation rows", () => {
|
||||
// With a default text style, the renderer re-opens the default color
|
||||
// after `**bold**` (the style prefix) and the soft break leaves that
|
||||
// re-open unclosed — a style opened on line 1 that is still active
|
||||
// when line 2 hangs. Line 1's bold open/close pair exists nowhere on
|
||||
// line 2, so finding it ahead of the continuation text proves the
|
||||
// carry was re-played rather than line 2's own codes.
|
||||
const chalk = new Chalk({ level: 3 });
|
||||
const raw = new Markdown(
|
||||
"aaa **bold**\n├── alpha bravo charlie delta echo foxtrot golf hotel india",
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
{ color: text => chalk.red(text) },
|
||||
).render(WIDTH);
|
||||
const plain = raw.map(line => stripVTControlCharacters(line).trimEnd());
|
||||
|
||||
expect(plain.length).toBe(3);
|
||||
expect(plain[1]!.startsWith("├── alpha")).toBeTruthy();
|
||||
expect(plain[2]!.startsWith("│ foxtrot")).toBeTruthy();
|
||||
|
||||
// Line 1 genuinely ends with an unclosed style: its last SGR is the
|
||||
// default-color re-open, not a close.
|
||||
const row0Codes = raw[0]!.trimEnd().match(SGR_RE)!;
|
||||
expect(row0Codes[row0Codes.length - 1]).toBe("\x1b[31m");
|
||||
|
||||
// The continuation row starts with a zero-width SGR run that replays
|
||||
// line 1's history (the carried bold pair) and nets out to the
|
||||
// default color being open ahead of the visible text.
|
||||
const continuation = raw[2]!;
|
||||
const hangAt = continuation.indexOf("│ ");
|
||||
expect(hangAt).toBeGreaterThan(0);
|
||||
const replayed = continuation.slice(0, hangAt);
|
||||
expect(replayed.replace(SGR_RE, "")).toBe("");
|
||||
expect(replayed).toContain("\x1b[1m");
|
||||
const replayedCodes = replayed.match(SGR_RE)!;
|
||||
expect(replayedCodes[replayedCodes.length - 1]).toBe("\x1b[31m");
|
||||
});
|
||||
|
||||
it("re-renders byte-identically after a width round-trip (44 → 80 → 44)", () => {
|
||||
const doc = "├── alpha bravo charlie delta echo foxtrot golf\n└── hotel india juliet kilo lima mike november";
|
||||
const md = new Markdown(doc, 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const first = [...md.render(44)];
|
||||
// Narrow render actually hung — the round-trip below is not vacuous.
|
||||
expect(first.map(line => stripVTControlCharacters(line).trimEnd())).toEqual([
|
||||
"├── alpha bravo charlie delta echo foxtrot",
|
||||
"│ golf",
|
||||
"└── hotel india juliet kilo lima mike",
|
||||
" november",
|
||||
]);
|
||||
|
||||
// Wide render fits line-for-line: a genuinely different layout.
|
||||
const wide = md.render(80).map(line => stripVTControlCharacters(line).trimEnd());
|
||||
expect(wide).toEqual([
|
||||
"├── alpha bravo charlie delta echo foxtrot golf",
|
||||
"└── hotel india juliet kilo lima mike november",
|
||||
]);
|
||||
|
||||
expect([...md.render(44)]).toEqual(first);
|
||||
expect([...new Markdown(doc, 0, 0, defaultMarkdownTheme).render(44)]).toEqual(first);
|
||||
});
|
||||
|
||||
it("does not leak styles opened before a full SGR reset onto later hung rows", () => {
|
||||
// Raw ANSI in component input passes through marked byte-exact: line 1
|
||||
// opens bold, fully resets, then opens italic. Only the italic — the
|
||||
// live style after the reset — may carry onto the hung line.
|
||||
const raw = renderRaw(
|
||||
"aaa \x1b[1mbold\x1b[0m\x1b[3mrest and filler\n├── alpha bravo charlie delta echo foxtrot golf hotel india",
|
||||
);
|
||||
const plain = raw.map(line => stripVTControlCharacters(line).trimEnd());
|
||||
|
||||
expect(plain.length).toBe(3);
|
||||
expect(plain[1]!.startsWith("├── alpha")).toBeTruthy();
|
||||
expect(plain[2]!.startsWith("│ foxtrot")).toBeTruthy();
|
||||
|
||||
for (const row of raw.slice(1)) {
|
||||
expect(row).not.toContain("\x1b[1m"); // dead pre-reset style must not re-play
|
||||
expect(row).not.toContain("\x1b[0m");
|
||||
}
|
||||
// The live post-reset style carries onto the hung line and is the
|
||||
// entire replayed run ahead of the continuation's hang glyphs.
|
||||
expect(raw[1]!.startsWith("\x1b[3m├── ")).toBeTruthy();
|
||||
const continuation = raw[2]!;
|
||||
const hangAt = continuation.indexOf("│ ");
|
||||
expect(hangAt).toBeGreaterThan(0);
|
||||
expect(continuation.slice(0, hangAt)).toBe("\x1b[3m");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user