From a42868f5501841f065216a568b23e18b51c5e8a3 Mon Sep 17 00:00:00 2001 From: Shawn Koh Date: Tue, 11 Aug 2026 01:00:35 +0800 Subject: [PATCH 1/3] fix: make terminal state diagrams readable --- .../src/modes/theme/tui-adapters.ts | 9 +++--- .../modes/theme/mermaid-rendering.test.ts | 21 +++++++++++++ .../src/vendor/mermaid-ascii/ascii/canvas.ts | 31 ++++++++++++++++--- .../src/vendor/mermaid-ascii/ascii/draw.ts | 24 +++++++------- .../src/vendor/mermaid-ascii/text-metrics.ts | 11 +++++++ packages/utils/test/mermaid-ascii.test.ts | 25 +++++++++++++++ 6 files changed, 100 insertions(+), 21 deletions(-) diff --git a/packages/coding-agent/src/modes/theme/tui-adapters.ts b/packages/coding-agent/src/modes/theme/tui-adapters.ts index f0058dd95..571854191 100644 --- a/packages/coding-agent/src/modes/theme/tui-adapters.ts +++ b/packages/coding-agent/src/modes/theme/tui-adapters.ts @@ -148,18 +148,17 @@ export function getMarkdownTheme(): MarkdownTheme { } const mermaid = markdownMermaidRendering ? (() => { - // Mermaid ASCII diagrams render with the active palette so they read as - // content rather than raw monochrome. Roles mirror the SVG renderer's - // mapping; `text`/`muted`/`border`/`borderMuted`/`accent` exist in every theme. + // Diagram geometry is content, so keep every structural stroke on the + // theme's readable muted foreground instead of subtle UI chrome borders. const mermaidColorMode = theme.getColorMode() === "truecolor" ? ("truecolor" as const) : ("ansi256" as const); const mermaidTheme = { fg: theme.getColorHex("text"), - border: theme.getColorHex("border"), + border: theme.getColorHex("muted"), line: theme.getColorHex("muted"), arrow: theme.getColorHex("accent"), corner: theme.getColorHex("muted"), - junction: theme.getColorHex("borderMuted"), + junction: theme.getColorHex("muted"), }; return { mermaidColorMode, mermaidTheme }; })() diff --git a/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts b/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts index 373491bc7..dde2878d3 100644 --- a/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts +++ b/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeAll, describe, expect, it } from "bun:test"; import { Markdown } from "@oh-my-pi/pi-tui"; import { Settings } from "../../../src/config/settings"; +import { createTheme, getBuiltinThemes } from "../../../src/modes/theme/loader"; import { getMarkdownTheme, getThemeByName, @@ -55,4 +56,24 @@ describe("Mermaid rendering setting", () => { expect(lines).toContain("graph TD"); expect(lines).toContain("-->"); }); + + it("uses content-visible Titanium colors for Mermaid structure", async () => { + const dark = await getThemeByName("dark"); + if (!dark) throw new Error("fallback theme unavailable"); + const titaniumJson = getBuiltinThemes().titanium; + if (!titaniumJson) throw new Error("Titanium theme unavailable"); + + try { + setThemeInstance(createTheme(titaniumJson, { mode: "truecolor" })); + const renderer = getMarkdownTheme().resolveMermaidAscii; + if (!renderer) throw new Error("Mermaid renderer unavailable"); + const rendered = renderer("flowchart TD\n A[Capture] --> B[Act]", 80); + + expect(rendered).toContain("\x1b[38;2;156;163;176m"); + expect(rendered).not.toContain("\x1b[38;2;42;48;56m"); + expect(rendered).not.toContain("\x1b[38;2;31;37;45m"); + } finally { + setThemeInstance(dark); + } + }); }); diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts index 2db2d19c6..9cd613386 100644 --- a/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts @@ -8,7 +8,7 @@ import type { Canvas, DrawingCoord, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types' import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi' -import { displayWidth, toCells, WIDE_PAD } from '../text-metrics' +import { displayWidth, toCells, LABEL_CELL_PREFIX, OPAQUE_SPACE, WIDE_PAD } from '../text-metrics' /** * Create a blank canvas filled with spaces. @@ -189,7 +189,7 @@ export function isJunctionChar(c: string): boolean { * letter/digit test misses. */ function isLabelChar(c: string): boolean { - return c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c) + return c === OPAQUE_SPACE || c.startsWith(LABEL_CELL_PREFIX) || c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c) } /** @@ -327,8 +327,9 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): let line = '' for (let x = 0; x <= maxX; x++) { const c = canvas[x]![y]! - // Skip wide-glyph continuation cells: the glyph itself spans 2 columns - if (c !== WIDE_PAD) line += c + if (c !== WIDE_PAD) { + line += c === OPAQUE_SPACE ? ' ' : c.startsWith(LABEL_CELL_PREFIX) ? c.slice(LABEL_CELL_PREFIX.length) : c + } } lines.push(line) } else { @@ -338,7 +339,7 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): for (let x = 0; x <= maxX; x++) { const c = canvas[x]![y]! if (c === WIDE_PAD) continue - chars.push(c) + chars.push(c === OPAQUE_SPACE ? ' ' : c.startsWith(LABEL_CELL_PREFIX) ? c.slice(LABEL_CELL_PREFIX.length) : c) roles.push(roleCanvas[x]?.[y] ?? null) } lines.push(colorizeLine(chars, roles, theme, colorMode)) @@ -443,6 +444,26 @@ export function drawText( } } +/** + * Draw edge-label text with ownership markers so later canvas merges preserve + * punctuation and spaces while allowing the label to clear connector strokes. + */ +export function drawLabelText(canvas: Canvas, start: DrawingCoord, text: string): void { + const cells = toCells(text) + increaseSize(canvas, start.x + cells.length, start.y) + for (let i = 0; i < cells.length; i++) { + const cell = cells[i]! + if (cell === WIDE_PAD) continue + const x = start.x + i + if (cells[i + 1] === WIDE_PAD) { + writeCell(canvas, x, start.y, cell) + writeCell(canvas, x + 1, start.y, WIDE_PAD) + } else { + writeCell(canvas, x, start.y, cell === ' ' ? OPAQUE_SPACE : LABEL_CELL_PREFIX + cell) + } + } +} + /** * Set the canvas size to fit all grid columns and rows. * Called after layout to ensure the canvas covers the full drawing area. diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts index 7d7fdc821..33ad571f6 100644 --- a/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts @@ -14,7 +14,7 @@ import { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle, drawingCoordEquals, } from './types' -import { mkCanvas, copyCanvas, getCanvasSize, mergeCanvases, drawText, mkRoleCanvas, setRole, mergeRoleCanvases } from './canvas' +import { mkCanvas, copyCanvas, getCanvasSize, mergeCanvases, drawText, drawLabelText, mkRoleCanvas, setRole, mergeRoleCanvases } from './canvas' import type { RoleCanvas, CharRole } from './types' import { determineDirection, dirEquals } from './edge-routing' import { gridToDrawingCoord, lineToDrawing } from './grid' @@ -76,16 +76,18 @@ function drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas { // Get corner characters for this shape type const corners = getCorners(node.shape, useAscii) - // State-end uses double border to differentiate from state-start - const isDoubleBox = node.shape === 'state-end' - const hChar = useAscii ? (isDoubleBox ? '=' : '-') : (isDoubleBox ? '═' : '─') - const vChar = useAscii ? (isDoubleBox ? '‖' : '|') : (isDoubleBox ? '║' : '│') + const isStateStart = node.shape === 'state-start' + const isStateEnd = node.shape === 'state-end' + const hChar = useAscii ? (isStateEnd ? '=' : '-') : (isStateEnd ? '═' : '─') + const vChar = useAscii ? (isStateEnd ? '‖' : '|') : (isStateEnd ? '║' : '│') - // Double-box corners (for state-end) - const doubleCorners = useAscii + const stateStartCorners = useAscii + ? { tl: '.', tr: '.', bl: "'", br: "'" } + : { tl: '╭', tr: '╮', bl: '╰', br: '╯' } + const stateEndCorners = useAscii ? { tl: '#', tr: '#', bl: '#', br: '#' } : { tl: '╔', tr: '╗', bl: '╚', br: '╝' } - const effectiveCorners = isDoubleBox ? doubleCorners : corners + const effectiveCorners = isStateEnd ? stateEndCorners : isStateStart ? stateStartCorners : corners // Draw box border with shape-specific corners for (let x = from.x + 1; x < to.x; x++) box[x]![from.y] = hChar @@ -97,8 +99,8 @@ function drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas { box[from.x]![to.y] = effectiveCorners.bl box[to.x]![to.y] = effectiveCorners.br - // Center the multi-line display label inside the box - const label = node.displayLabel + // Pseudostates have no source label; restore their UML marker explicitly. + const label = node.displayLabel || (isStateStart ? (useAscii ? '*' : '●') : isStateEnd ? (useAscii ? '*' : '◎') : '') const lines = splitLines(label) const textCenterY = from.y + Math.floor(h / 2) const startY = textCenterY - Math.floor((lines.length - 1) / 2) @@ -677,7 +679,7 @@ function drawTextOnLine(canvas: Canvas, line: DrawingCoord[], label: string, isU for (let i = 0; i < lines.length; i++) { const lineText = lines[i]! const startX = middleX - Math.floor(displayWidth(lineText) / 2) - drawText(canvas, { x: startX, y: startY + i }, lineText) + drawLabelText(canvas, { x: startX, y: startY + i }, lineText) } } diff --git a/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts b/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts index 77c89e7b0..206e799cf 100644 --- a/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts +++ b/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts @@ -29,6 +29,17 @@ */ export const WIDE_PAD = '\u0000' +/** + * Space owned by a text label rather than unused canvas. Merge layers treat + * ordinary spaces as transparent, so labels use this sentinel to clear edge + * lines between words. Serialization converts it back to a regular space. + */ +export const OPAQUE_SPACE = '\u0001' + +/** Prefix marking a one-column cell as label-owned through canvas merges. */ +export const LABEL_CELL_PREFIX = '\u0002' + + const graphemeSegmenter = new Intl.Segmenter() /** diff --git a/packages/utils/test/mermaid-ascii.test.ts b/packages/utils/test/mermaid-ascii.test.ts index ef9d44f7c..a4852c6d4 100644 --- a/packages/utils/test/mermaid-ascii.test.ts +++ b/packages/utils/test/mermaid-ascii.test.ts @@ -13,6 +13,31 @@ describe("renderMermaidAscii", () => { expect(rendered).not.toContain("──A─"); }); + it("renders state pseudostates with their UML markers", () => { + const rendered = renderMermaidAscii(["stateDiagram-v2", " [*] --> Created", " Created --> [*]"].join("\n"), { + colorMode: "none", + }); + + expect(rendered).toMatch(/│\s+●\s+│/); + expect(rendered).toMatch(/║\s+◎\s+║/); + }); + + it("keeps dense transition labels intact above connector lines", () => { + const rendered = renderMermaidAscii( + [ + "stateDiagram-v2", + " Working --> Working: sessions die and respawn freely", + " Working --> Archived: cheap exit, branches kept", + " Archived --> Working: resume rebuilds substrate", + ].join("\n"), + { colorMode: "none" }, + ); + + expect(rendered).toContain("sessions die and respawn freely"); + expect(rendered).toContain("cheap exit, branches kept"); + expect(rendered).toContain("resume rebuilds substrate"); + }); + it("returns a bounded fallback for declaration orders that make a clean route unreachable", () => { const rendered = renderMermaidAsciiSafe( [ From e658d9f7be7a77386a4e3dcd01ef77a6436ac35e Mon Sep 17 00:00:00 2001 From: Shawn Koh Date: Tue, 11 Aug 2026 01:14:29 +0800 Subject: [PATCH 2/3] fix: address Mermaid rendering review --- packages/coding-agent/CHANGELOG.md | 4 ++ .../modes/theme/mermaid-rendering.test.ts | 8 +++- packages/utils/CHANGELOG.md | 4 ++ .../src/vendor/mermaid-ascii/ascii/canvas.ts | 32 ++++------------ .../src/vendor/mermaid-ascii/ascii/draw.ts | 8 ++-- .../src/vendor/mermaid-ascii/text-metrics.ts | 11 ------ packages/utils/test/mermaid-ascii.test.ts | 37 +++++++++---------- 7 files changed, 43 insertions(+), 61 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a4d66e8d2..91f3fa2d6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed terminal Mermaid borders and junctions using low-contrast UI chrome colors instead of the active theme's readable content color. + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts b/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts index dde2878d3..25173ab3e 100644 --- a/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts +++ b/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts @@ -67,9 +67,13 @@ describe("Mermaid rendering setting", () => { setThemeInstance(createTheme(titaniumJson, { mode: "truecolor" })); const renderer = getMarkdownTheme().resolveMermaidAscii; if (!renderer) throw new Error("Mermaid renderer unavailable"); - const rendered = renderer("flowchart TD\n A[Capture] --> B[Act]", 80); + const rendered = renderer("stateDiagram-v2\n [*] --> Capture\n Capture --> [*]", 80); + const muted = "\x1b[38;2;156;163;176m"; - expect(rendered).toContain("\x1b[38;2;156;163;176m"); + expect(rendered).toContain(`${muted}╔`); + expect(rendered).toContain(`${muted}║`); + expect(rendered).toContain(`${muted}╚`); + expect(rendered).not.toMatch(/\x1b\[38;2;229;229;231m[╔═╗║╚╝]/); expect(rendered).not.toContain("\x1b[38;2;42;48;56m"); expect(rendered).not.toContain("\x1b[38;2;31;37;45m"); } finally { diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 498d25ff2..88f79df6d 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Mermaid ASCII state pseudostates rendering empty boxes, miscoloring final-state borders, and inverting rounded corners in bottom-to-top diagrams. + ## [17.2.11] - 2026-08-07 ### Added diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts index 9cd613386..589a4ba39 100644 --- a/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts @@ -8,7 +8,7 @@ import type { Canvas, DrawingCoord, RoleCanvas, CharRole, AsciiTheme, ColorMode } from './types' import { colorizeLine, DEFAULT_ASCII_THEME } from './ansi' -import { displayWidth, toCells, LABEL_CELL_PREFIX, OPAQUE_SPACE, WIDE_PAD } from '../text-metrics' +import { displayWidth, toCells, WIDE_PAD } from '../text-metrics' /** * Create a blank canvas filled with spaces. @@ -189,7 +189,7 @@ export function isJunctionChar(c: string): boolean { * letter/digit test misses. */ function isLabelChar(c: string): boolean { - return c === OPAQUE_SPACE || c.startsWith(LABEL_CELL_PREFIX) || c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c) + return c === WIDE_PAD || displayWidth(c) === 2 || /[\p{L}\p{N}]/u.test(c) } /** @@ -327,9 +327,8 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): let line = '' for (let x = 0; x <= maxX; x++) { const c = canvas[x]![y]! - if (c !== WIDE_PAD) { - line += c === OPAQUE_SPACE ? ' ' : c.startsWith(LABEL_CELL_PREFIX) ? c.slice(LABEL_CELL_PREFIX.length) : c - } + // Skip wide-glyph continuation cells: the glyph itself spans 2 columns + if (c !== WIDE_PAD) line += c } lines.push(line) } else { @@ -339,7 +338,7 @@ export function canvasToString(canvas: Canvas, options?: CanvasToStringOptions): for (let x = 0; x <= maxX; x++) { const c = canvas[x]![y]! if (c === WIDE_PAD) continue - chars.push(c === OPAQUE_SPACE ? ' ' : c.startsWith(LABEL_CELL_PREFIX) ? c.slice(LABEL_CELL_PREFIX.length) : c) + chars.push(c) roles.push(roleCanvas[x]?.[y] ?? null) } lines.push(colorizeLine(chars, roles, theme, colorMode)) @@ -371,6 +370,8 @@ const VERTICAL_FLIP_MAP: Record = { // Unicode corners '┌': '└', '└': '┌', '┐': '┘', '┘': '┐', + '╭': '╰', '╰': '╭', + '╮': '╯', '╯': '╮', // Unicode junctions (T-pieces flip vertically) '┬': '┴', '┴': '┬', // Box-start junctions (exit points from node boxes) @@ -444,25 +445,6 @@ export function drawText( } } -/** - * Draw edge-label text with ownership markers so later canvas merges preserve - * punctuation and spaces while allowing the label to clear connector strokes. - */ -export function drawLabelText(canvas: Canvas, start: DrawingCoord, text: string): void { - const cells = toCells(text) - increaseSize(canvas, start.x + cells.length, start.y) - for (let i = 0; i < cells.length; i++) { - const cell = cells[i]! - if (cell === WIDE_PAD) continue - const x = start.x + i - if (cells[i + 1] === WIDE_PAD) { - writeCell(canvas, x, start.y, cell) - writeCell(canvas, x + 1, start.y, WIDE_PAD) - } else { - writeCell(canvas, x, start.y, cell === ' ' ? OPAQUE_SPACE : LABEL_CELL_PREFIX + cell) - } - } -} /** * Set the canvas size to fit all grid columns and rows. diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts index 33ad571f6..d849d3670 100644 --- a/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts @@ -14,7 +14,7 @@ import { Up, Down, Left, Right, UpperLeft, UpperRight, LowerLeft, LowerRight, Middle, drawingCoordEquals, } from './types' -import { mkCanvas, copyCanvas, getCanvasSize, mergeCanvases, drawText, drawLabelText, mkRoleCanvas, setRole, mergeRoleCanvases } from './canvas' +import { mkCanvas, copyCanvas, getCanvasSize, mergeCanvases, drawText, mkRoleCanvas, setRole, mergeRoleCanvases } from './canvas' import type { RoleCanvas, CharRole } from './types' import { determineDirection, dirEquals } from './edge-routing' import { gridToDrawingCoord, lineToDrawing } from './grid' @@ -82,7 +82,7 @@ function drawBoxWithGridDimensions(node: AsciiNode, graph: AsciiGraph): Canvas { const vChar = useAscii ? (isStateEnd ? '‖' : '|') : (isStateEnd ? '║' : '│') const stateStartCorners = useAscii - ? { tl: '.', tr: '.', bl: "'", br: "'" } + ? { tl: '+', tr: '+', bl: '+', br: '+' } : { tl: '╭', tr: '╮', bl: '╰', br: '╯' } const stateEndCorners = useAscii ? { tl: '#', tr: '#', bl: '#', br: '#' } @@ -679,7 +679,7 @@ function drawTextOnLine(canvas: Canvas, line: DrawingCoord[], label: string, isU for (let i = 0; i < lines.length; i++) { const lineText = lines[i]! const startX = middleX - Math.floor(displayWidth(lineText) / 2) - drawLabelText(canvas, { x: startX, y: startY + i }, lineText) + drawText(canvas, { x: startX, y: startY + i }, lineText) } } @@ -1232,7 +1232,7 @@ function fillRolesForNodeBox( canvas: Canvas, offset: DrawingCoord, ): void { - const isBorderChar = (c: string) => /^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|.':]$/.test(c) + const isBorderChar = (c: string) => /^[┌┐└┘├┤┬┴┼│─╭╮╰╯╔╗╚╝═║+\-|.':#=‖]$/.test(c) for (let x = 0; x < canvas.length; x++) { for (let y = 0; y < (canvas[0]?.length ?? 0); y++) { diff --git a/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts b/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts index 206e799cf..77c89e7b0 100644 --- a/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts +++ b/packages/utils/src/vendor/mermaid-ascii/text-metrics.ts @@ -29,17 +29,6 @@ */ export const WIDE_PAD = '\u0000' -/** - * Space owned by a text label rather than unused canvas. Merge layers treat - * ordinary spaces as transparent, so labels use this sentinel to clear edge - * lines between words. Serialization converts it back to a regular space. - */ -export const OPAQUE_SPACE = '\u0001' - -/** Prefix marking a one-column cell as label-owned through canvas merges. */ -export const LABEL_CELL_PREFIX = '\u0002' - - const graphemeSegmenter = new Intl.Segmenter() /** diff --git a/packages/utils/test/mermaid-ascii.test.ts b/packages/utils/test/mermaid-ascii.test.ts index a4852c6d4..1486b3e17 100644 --- a/packages/utils/test/mermaid-ascii.test.ts +++ b/packages/utils/test/mermaid-ascii.test.ts @@ -13,29 +13,28 @@ describe("renderMermaidAscii", () => { expect(rendered).not.toContain("──A─"); }); - it("renders state pseudostates with their UML markers", () => { - const rendered = renderMermaidAscii(["stateDiagram-v2", " [*] --> Created", " Created --> [*]"].join("\n"), { - colorMode: "none", - }); + it("renders Unicode and ASCII state pseudostates with distinct UML markers", () => { + const source = ["stateDiagram-v2", " [*] --> Created", " Created --> [*]"].join("\n"); + const unicode = renderMermaidAscii(source, { colorMode: "none" }); + const ascii = renderMermaidAscii(source, { colorMode: "none", useAscii: true }); - expect(rendered).toMatch(/│\s+●\s+│/); - expect(rendered).toMatch(/║\s+◎\s+║/); + expect(unicode).toMatch(/│\s+●\s+│/); + expect(unicode).toMatch(/║\s+◎\s+║/); + expect(ascii).toMatch(/\|\s+\*\s+\|/); + expect(ascii).toMatch(/‖\s+\*\s+‖/); + expect(ascii).toMatch(/#=+#/); }); - it("keeps dense transition labels intact above connector lines", () => { - const rendered = renderMermaidAscii( - [ - "stateDiagram-v2", - " Working --> Working: sessions die and respawn freely", - " Working --> Archived: cheap exit, branches kept", - " Archived --> Working: resume rebuilds substrate", - ].join("\n"), - { colorMode: "none" }, - ); + it("keeps rounded pseudostate corners upright in bottom-to-top diagrams", () => { + const rendered = renderMermaidAscii(["stateDiagram-v2", " direction BT", " [*] --> Created"].join("\n"), { + colorMode: "none", + }); + const rows = rendered.split("\n"); + const markerRow = rows.findIndex(row => row.includes("●")); - expect(rendered).toContain("sessions die and respawn freely"); - expect(rendered).toContain("cheap exit, branches kept"); - expect(rendered).toContain("resume rebuilds substrate"); + expect(markerRow).toBeGreaterThan(0); + expect(rows[markerRow - 1]).toMatch(/╭─+╮/); + expect(rows[markerRow + 1]).toMatch(/╰─+╯/); }); it("returns a bounded fallback for declaration orders that make a clean route unreachable", () => { From cfbd567628e2a9dbbf3f89f85fc445a5ff4590ad Mon Sep 17 00:00:00 2001 From: Shawn Koh Date: Tue, 11 Aug 2026 01:19:54 +0800 Subject: [PATCH 3/3] fix: preserve punctuation label colors --- .../test/modes/theme/mermaid-rendering.test.ts | 4 ++++ .../utils/src/vendor/mermaid-ascii/ascii/draw.ts | 13 +++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts b/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts index 25173ab3e..d86bb48f5 100644 --- a/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts +++ b/packages/coding-agent/test/modes/theme/mermaid-rendering.test.ts @@ -76,6 +76,10 @@ describe("Mermaid rendering setting", () => { expect(rendered).not.toMatch(/\x1b\[38;2;229;229;231m[╔═╗║╚╝]/); expect(rendered).not.toContain("\x1b[38;2;42;48;56m"); expect(rendered).not.toContain("\x1b[38;2;31;37;45m"); + const labels = renderer("flowchart TD\n A[x=y]\n B[status=#1]", 80); + const text = "\x1b[38;2;229;229;231m"; + expect(labels).toContain(`${text}x=y`); + expect(labels).toContain(`${text}status=#1`); } finally { setThemeInstance(dark); } diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts index d849d3670..1cefc6c49 100644 --- a/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts @@ -1225,15 +1225,18 @@ function fillRolesFromCanvases( /** * Special handling for node boxes: border chars get 'border' role, text gets 'text' role. - * Detects text by checking if character is alphanumeric or common punctuation. + * Common final-state border characters (`#` and `=`) count only on the outer edge, + * so identical characters inside ordinary node labels retain the text role. */ function fillRolesForNodeBox( roleCanvas: RoleCanvas, canvas: Canvas, offset: DrawingCoord, ): void { - const isBorderChar = (c: string) => /^[┌┐└┘├┤┬┴┼│─╭╮╰╯╔╗╚╝═║+\-|.':#=‖]$/.test(c) - + const isBorderChar = (c: string) => /^[┌┐└┘├┤┬┴┼│─╭╮╰╯+\-|.':]$/.test(c) + const isStateEndBorderChar = (c: string) => /^[╔╗╚╝═║#=‖]$/.test(c) + const maxX = canvas.length - 1 + const maxY = (canvas[0]?.length ?? 1) - 1 for (let x = 0; x < canvas.length; x++) { for (let y = 0; y < (canvas[0]?.length ?? 0); y++) { const char = canvas[x]?.[y] @@ -1242,7 +1245,9 @@ function fillRolesForNodeBox( const ry = y + offset.y // Use setRole which auto-expands the role canvas if needed if (rx >= 0 && ry >= 0) { - setRole(roleCanvas, rx, ry, isBorderChar(char) ? 'border' : 'text') + const isOuterEdge = x === 0 || x === maxX || y === 0 || y === maxY + const role = isBorderChar(char) || (isOuterEdge && isStateEndBorderChar(char)) ? 'border' : 'text' + setRole(roleCanvas, rx, ry, role) } } }