Merge PR #8172: fix: make terminal Mermaid state diagrams readable (@shawnkoh)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user