diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1aba9ea9f..9d8b2d057 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,6 +21,7 @@ - Fixed message conversion caching strongly retaining the last session transcript and converted output after session disposal ([#8119](https://github.com/can1357/oh-my-pi/issues/8119)). - Fixed timed-out LSP requests continuing to consume server CPU and block queued requests by sending `$/cancelRequest` ([#8116](https://github.com/can1357/oh-my-pi/issues/8116)). - Fixed `shutdownAll()` leaving the configured LSP idle checker alive and preventing short-lived SDK hosts from exiting ([#8115](https://github.com/can1357/oh-my-pi/issues/8115)). +- 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 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..d86bb48f5 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,32 @@ 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("stateDiagram-v2\n [*] --> Capture\n Capture --> [*]", 80); + const muted = "\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"); + 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/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 2db2d19c6..589a4ba39 100644 --- a/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/canvas.ts @@ -370,6 +370,8 @@ const VERTICAL_FLIP_MAP: Record = { // Unicode corners '┌': '└', '└': '┌', '┐': '┘', '┘': '┐', + '╭': '╰', '╰': '╭', + '╮': '╯', '╯': '╮', // Unicode junctions (T-pieces flip vertically) '┬': '┴', '┴': '┬', // Box-start junctions (exit points from node boxes) @@ -443,6 +445,7 @@ export function drawText( } } + /** * 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..1cefc6c49 100644 --- a/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/draw.ts @@ -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) @@ -1223,7 +1225,8 @@ 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, @@ -1231,7 +1234,9 @@ function fillRolesForNodeBox( offset: DrawingCoord, ): void { 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] @@ -1240,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) } } } diff --git a/packages/utils/test/mermaid-ascii.test.ts b/packages/utils/test/mermaid-ascii.test.ts index ef9d44f7c..1486b3e17 100644 --- a/packages/utils/test/mermaid-ascii.test.ts +++ b/packages/utils/test/mermaid-ascii.test.ts @@ -13,6 +13,30 @@ describe("renderMermaidAscii", () => { expect(rendered).not.toContain("──A─"); }); + 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(unicode).toMatch(/│\s+●\s+│/); + expect(unicode).toMatch(/║\s+◎\s+║/); + expect(ascii).toMatch(/\|\s+\*\s+\|/); + expect(ascii).toMatch(/‖\s+\*\s+‖/); + expect(ascii).toMatch(/#=+#/); + }); + + 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(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", () => { const rendered = renderMermaidAsciiSafe( [