Merge PR #8172: fix: make terminal Mermaid state diagrams readable (@shawnkoh)

This commit is contained in:
can1357
2026-08-11 15:06:16 +02:00
7 changed files with 84 additions and 17 deletions
+1
View File
@@ -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
@@ -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 };
})()
@@ -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);
}
});
});
+4
View File
@@ -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
@@ -370,6 +370,8 @@ const VERTICAL_FLIP_MAP: Record<string, string> = {
// 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.
+19 -12
View File
@@ -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)
}
}
}
+24
View File
@@ -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(
[