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", () => {