fix(tui): prevent wrapped table link color leaks (#8363)

This commit is contained in:
Ramiz Wachtler 2026-08-20 15:58:47 +02:00 • committed by GitHub
parent cffe4d776c
commit 8f2ae3fadd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 118 additions and 19 deletions

View file

@ -6,6 +6,7 @@
- Fixed duplicate fullscreen right-click paste in VS Code-based terminals on Windows ([#8186](https://github.com/earendil-works/pi/issues/8186)).
- Fixed padded text exceeding narrow terminal widths ([#8252](https://github.com/earendil-works/pi/issues/8252)).
- Fixed wrapped Markdown table links leaking color into borders and neighboring cells, including tables inside blockquotes ([#8335](https://github.com/earendil-works/pi/issues/8335)).
## [0.84.2] - 2026-08-14

View file

@ -826,8 +826,13 @@ export class Markdown implements Component {
* Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
* consistently with the rest of the renderer.
*/
private wrapCellText(text: string, maxWidth: number): string[] {
return wrapTextWithAnsi(text, Math.max(1, maxWidth));
private wrapCellText(text: string, maxWidth: number, stylePrefix = ""): string[] {
const lines = wrapTextWithAnsi(text, Math.max(1, maxWidth));
return lines.map((line, index) => {
// Reset text styles after each non-final fragment, then restore the surrounding style before padding and borders.
const styleReset = index < lines.length - 1 ? "\x1b[22;23;24;25;27;28;29;39m" : "";
return `${line}${styleReset}${stylePrefix}`;
});
}
/**
@ -958,7 +963,7 @@ export class Markdown implements Component {
// Render header with wrapping
const headerCellLines: string[][] = token.header.map((cell, i) => {
const text = this.renderInlineTokens(cell.tokens || [], styleContext);
return this.wrapCellText(text, columnWidths[i]);
return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix);
});
const headerLineCount = Math.max(...headerCellLines.map((c) => c.length));
@ -981,7 +986,7 @@ export class Markdown implements Component {
const row = token.rows[rowIndex];
const rowCellLines: string[][] = row.map((cell, i) => {
const text = this.renderInlineTokens(cell.tokens || [], styleContext);
return this.wrapCellText(text, columnWidths[i]);
return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix);
});
const rowLineCount = Math.max(...rowCellLines.map((c) => c.length));

View file

@ -2,7 +2,7 @@ import assert from "node:assert";
import { afterEach, describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { Chalk } from "chalk";
import { Markdown } from "../src/components/markdown.ts";
import { Markdown, type MarkdownTheme } from "../src/components/markdown.ts";
import { resetCapabilitiesCache, setCapabilities } from "../src/terminal-image.ts";
import type { Component, TUI } from "../src/tui.ts";
import { TuiMainScreen } from "../src/tui-main-screen.ts";
@ -12,24 +12,14 @@ import { VirtualTerminal } from "./virtual-terminal.ts";
// Force full color in CI so ANSI assertions are deterministic
const chalk = new Chalk({ level: 3 });
function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number {
function getCell(terminal: VirtualTerminal, row: number, col: number) {
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
const buffer = xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
assert.ok(line, `Missing buffer line at row ${row}`);
const cell = line.getCell(col);
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
return cell.isItalic();
}
function getCellUnderline(terminal: VirtualTerminal, row: number, col: number): number {
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
const buffer = xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
assert.ok(line, `Missing buffer line at row ${row}`);
const cell = line.getCell(col);
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
return cell.isUnderline();
return cell;
}
function stripAnsi(line: string): string {
@ -479,6 +469,105 @@ describe("Markdown component", () => {
assert.ok(allText.includes("Install"), "Should contain 'Install'");
});
it("should not leak wrapped link styles into table borders or plain cells", async () => {
const source = `| Link | Plain |
| --- | --- |
| [**one two three four five six**](https://example.com) | normal text |`;
try {
for (const hyperlinks of [true, false]) {
setCapabilities({ images: null, trueColor: false, hyperlinks });
const terminal = new VirtualTerminal(24, 16);
const tui: TUI = new TuiMainScreen(terminal);
tui.addChild(new Markdown(source, 0, 0, defaultMarkdownTheme));
tui.start();
try {
await terminal.waitForRender();
const viewport = terminal.getViewport();
const row = viewport.findIndex((line) => line.includes("one") && line.includes("norm"));
assert.notStrictEqual(row, -1, `Missing wrapped table row: ${JSON.stringify(viewport)}`);
const line = viewport[row];
const linkCol = line.indexOf("one");
const separatorCol = line.indexOf("│", linkCol);
const plainCol = line.indexOf("norm");
assert.ok(linkCol >= 0 && separatorCol > linkCol && plainCol > separatorCol);
assert.strictEqual(getCell(terminal, row, linkCol).isFgDefault(), false);
assert.strictEqual(getCell(terminal, row, separatorCol).isFgDefault(), true);
assert.strictEqual(getCell(terminal, row, plainCol).isFgDefault(), true);
assert.notStrictEqual(getCell(terminal, row, linkCol).isBold(), 0);
assert.strictEqual(getCell(terminal, row, separatorCol).isBold(), 0);
assert.strictEqual(getCell(terminal, row, plainCol).isBold(), 0);
if (!hyperlinks) {
const urlRow = viewport.findIndex((viewportLine) => viewportLine.includes("https"));
assert.notStrictEqual(urlRow, -1, `Missing fallback URL row: ${JSON.stringify(viewport)}`);
const urlLine = viewport[urlRow];
const urlCol = urlLine.indexOf("https");
const urlSeparatorCol = urlLine.indexOf("│", urlCol);
const urlBorderCol = urlLine.lastIndexOf("│");
assert.ok(urlCol >= 0 && urlSeparatorCol > urlCol && urlBorderCol > urlSeparatorCol);
assert.notStrictEqual(getCell(terminal, urlRow, urlCol).isDim(), 0);
assert.strictEqual(getCell(terminal, urlRow, urlSeparatorCol).isDim(), 0);
assert.strictEqual(getCell(terminal, urlRow, urlBorderCol).isDim(), 0);
}
} finally {
tui.stop();
}
}
} finally {
resetCapabilitiesCache();
}
});
it("should restore the enclosing style after a wrapped table link", async () => {
const quoteColor = 0x123456;
const theme: MarkdownTheme = {
...defaultMarkdownTheme,
// Use a basic wrapper that does not automatically reopen itself after nested resets.
quote: (text) => `\x1b[38;2;18;52;86m${text}\x1b[39m`,
link: (text) => `\x1b[38;2;129;162;190m${text}\x1b[39m`,
};
const source = `> | Link | Plain |
> | --- | --- |
> | [one two three four five six](https://example.com) | normal text |`;
setCapabilities({ images: null, trueColor: true, hyperlinks: true });
const terminal = new VirtualTerminal(28, 10);
const tui: TUI = new TuiMainScreen(terminal);
tui.addChild(new Markdown(source, 0, 0, theme));
tui.start();
try {
await terminal.waitForRender();
const viewport = terminal.getViewport();
const row = viewport.findIndex((line) => line.includes("one") && line.includes("normal"));
assert.notStrictEqual(row, -1, `Missing wrapped blockquote table row: ${JSON.stringify(viewport)}`);
const line = viewport[row];
const linkCol = line.indexOf("one");
const separatorCol = line.indexOf("│", linkCol);
const plainCol = line.indexOf("normal");
assert.ok(linkCol >= 0 && separatorCol > linkCol && plainCol > separatorCol);
assert.notStrictEqual(getCell(terminal, row, linkCol).getFgColor(), quoteColor);
assert.strictEqual(getCell(terminal, row, separatorCol).getFgColor(), quoteColor);
assert.strictEqual(getCell(terminal, row, plainCol).getFgColor(), quoteColor);
const finalRow = viewport.findIndex((line) => line.includes("five six"));
assert.notStrictEqual(finalRow, -1, `Missing final wrapped link row: ${JSON.stringify(viewport)}`);
const finalLine = viewport[finalRow];
const finalLinkCol = finalLine.indexOf("five six");
const finalSeparatorCol = finalLine.indexOf("│", finalLinkCol);
const finalBorderCol = finalLine.lastIndexOf("│");
assert.ok(finalLinkCol >= 0 && finalSeparatorCol > finalLinkCol && finalBorderCol > finalSeparatorCol);
assert.strictEqual(getCell(terminal, finalRow, finalSeparatorCol).getFgColor(), quoteColor);
assert.strictEqual(getCell(terminal, finalRow, finalBorderCol).getFgColor(), quoteColor);
} finally {
tui.stop();
resetCapabilitiesCache();
}
});
it("should wrap long cell content to multiple lines", () => {
const markdown = new Markdown(
`| Header |
@ -988,7 +1077,7 @@ A=
assert.ok(component.markdownLineCount > 0);
const inputRow = component.markdownLineCount;
assert.strictEqual(getCellItalic(terminal, inputRow, 0), 0);
assert.strictEqual(getCell(terminal, inputRow, 0).isItalic(), 0);
tui.stop();
});
});
@ -1432,7 +1521,11 @@ bar`,
assert.ok(contentWidth > 0, "Should have visible heading content");
for (let col = contentWidth; col < 80; col++) {
assert.strictEqual(getCellUnderline(terminal, 0, col), 0, `Expected no underline in padding at col ${col}`);
assert.strictEqual(
getCell(terminal, 0, col).isUnderline(),
0,
`Expected no underline in padding at col ${col}`,
);
}
tui.stop();