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( [