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:
chan1103
2026-07-05 19:17:03 +09:00
parent cae677702a
commit 1055d4e0d5
3 changed files with 469 additions and 1 deletions
+1
View File
@@ -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
+150 -1
View File
@@ -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");
});
});
});