From 8f2ae3faddb9f4eea565bbb67190ea126fac0691 Mon Sep 17 00:00:00 2001 From: Ramiz Wachtler Date: Thu, 20 Aug 2026 15:58:47 +0200 Subject: [PATCH] fix(tui): prevent wrapped table link color leaks (#8363) --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/components/markdown.ts | 13 ++- packages/tui/test/markdown.test.ts | 123 +++++++++++++++++++++--- 3 files changed, 118 insertions(+), 19 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 33dfa5812..3bd2a2fda 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 608936c7b..f66666ea9 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -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)); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index d45b7716d..6347aa5d7 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -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();