diff --git a/crates/pi-natives/src/highlight.rs b/crates/pi-natives/src/highlight.rs index c779a684a..2646efb83 100644 --- a/crates/pi-natives/src/highlight.rs +++ b/crates/pi-natives/src/highlight.rs @@ -25,6 +25,7 @@ thread_local! { const EXTRA_SYNTAXES: &[&str] = &[ include_str!("syntaxes/Julia.sublime-syntax"), include_str!("syntaxes/Nix.sublime-syntax"), + include_str!("syntaxes/Mermaid.sublime-syntax"), ]; fn get_syntax_set() -> &'static SyntaxSet { @@ -179,6 +180,7 @@ const LANG_ALIASES: &[(&[&str], &str)] = &[ (&["rb", "ruby"], "Ruby"), (&["jl", "julia"], "Julia"), (&["nix"], "Nix"), + (&["mermaid", "mmd"], "Mermaid"), (&["rs", "rust"], "Rust"), (&["go", "golang"], "Go"), (&["java"], "Java"), diff --git a/crates/pi-natives/src/syntaxes/Mermaid.sublime-syntax b/crates/pi-natives/src/syntaxes/Mermaid.sublime-syntax new file mode 100644 index 000000000..33fe96247 --- /dev/null +++ b/crates/pi-natives/src/syntaxes/Mermaid.sublime-syntax @@ -0,0 +1,94 @@ +# Vendored from https://raw.githubusercontent.com/SublimeText/Mermaid/master/mermaid.sublime-syntax +# Source: SublimeText/Mermaid, MIT license, copyright (c) 2017 Peng Wang. +%YAML 1.2 +--- +# See https://www.sublimetext.com/docs/syntax.html +name: Mermaid +file_extensions: + - mermaid + - mmd +scope: source.mermaid +version: 2 +contexts: + prototype: + - include: line-comment + + main: + # don't include the prototype so we can have preprocesor comments take precedence over normal ones + - meta_include_prototype: false + - include: preprocessor-comments + - include: line-comment + + - match: < + push: + - meta_scope: punctuation.definition.comment.mermaid.html + - match: $ + pop: true + + - match: (?-i)^\s*(sequenceDiagram)\s+ + captures: + 1: keyword.declaration.trait.diagram.mermaid + push: + - meta_scope: meta.declaration.diagram.mermaid + - match: $ + set: scope:source.mermaid.sequence + - match: ^\s*(graph|flowchart)\s+ + captures: + 1: keyword.declaration.graph.mermaid + scope: keyword.declaration.graph.mermaid + - include: quoted-string + - match: (?:<-->|<--|-->|---|-.->|-\.-|==>|===|->>|-->>|<<-|<<--|-) + scope: keyword.operator.mermaid + - include: graph-direction + + graph-direction: + - match: \b(?:T[BD]|BT|LR|RL)\b + scope: constant.language.graph-direction.mermaid + + preprocessor-comments: + - match: '^\s*(%%)(?={)' + captures: + 1: punctuation.definition.comment.mermaid + push: inside-preprocessor-comment + + inside-preprocessor-comment: + - meta_scope: comment.line.percentage.mermaid + - match: '' + embed: scope:source.js + escape: (%%)?$ + escape_captures: + 1: punctuation.definition.comment.end.mermaid + pop: 1 + #- match: '' # basis, bumpX, bumpY, cardinal, catmullRom, linear, monotoneX, monotoneY, natural, step, stepAfter, and stepBefore. + + line-comment: + - match: '^\s*(%%)' + captures: + 1: punctuation.definition.comment.mermaid + push: inside-line-comment + + inside-line-comment: + - meta_scope: comment.line.percentage.mermaid + - match: $\n? + pop: true + + quoted-string: + - match: '"' + scope: punctuation.definition.string.begin.mermaid + push: inside-quoted-string + + inside-quoted-string: + - meta_scope: string.quoted.double.mermaid + - match: '"' + scope: punctuation.definition.string.end.mermaid + pop: true + - match: (#)[0-9]{1,7}(;) + scope: constant.character.entity.decimal.mermaid + captures: + 1: punctuation.definition.entity.mermaid + 2: punctuation.terminator.entity.mermaid + - match: (#)[a-zA-Z0-9]+(;) + scope: constant.character.entity.named.mermaid + captures: + 1: punctuation.definition.entity.mermaid + 2: punctuation.terminator.entity.mermaid diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 007b8c39a..d132d5c89 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `tui.renderMermaid` to control Mermaid fenced-block ASCII rendering; disabling it also removes the Mermaid diagram hint from the generated system prompt so Mermaid blocks fall back to ordinary highlighted code fences. + ## [16.1.22] - 2026-06-26 ### Fixed diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index a4cdd78a7..24cbe50c6 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -790,6 +790,17 @@ export const SETTINGS_SCHEMA = { }, }, + "tui.renderMermaid": { + type: "boolean", + default: true, + ui: { + tab: "appearance", + group: "Display", + label: "Render Mermaid Diagrams", + description: "Render Mermaid fenced code blocks as ASCII diagrams", + }, + }, + "tui.hyperlinks": { type: "enum", values: ["off", "auto", "always"] as const, diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 5ef346fe0..d3993137a 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -22,6 +22,7 @@ import { getSymbolTheme, previewTheme, setColorBlindMode, + setMarkdownMermaidRendering, setSymbolPreset, setTheme, theme, @@ -374,6 +375,15 @@ export class SelectorController { this.ctx.ui.requestRender(); break; + case "tui.renderMermaid": + setMarkdownMermaidRendering(value as boolean); + this.ctx.session.refreshBaseSystemPrompt().catch(err => { + this.ctx.showError(`Failed to apply Mermaid rendering setting: ${err}`); + }); + this.ctx.rebuildChatFromMessages(); + this.ctx.ui.resetDisplay(); + break; + case "theme": { setTheme(value as string, true).then(result => { this.ctx.statusLine.invalidate(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index ab1fab100..6eafb4d33 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -167,6 +167,7 @@ import { getSymbolTheme, onTerminalAppearanceChange, onThemeChange, + setMarkdownMermaidRendering, theme, } from "./theme/theme"; import type { @@ -588,6 +589,7 @@ export class InteractiveMode implements InteractiveModeContext { } setTuiTight(settings.get("tui.tight")); + setMarkdownMermaidRendering(settings.get("tui.renderMermaid")); this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor")); this.ui.setMaxInlineImages(settings.get("tui.maxInlineImages")); // OSC 66 text-sizing is Kitty-only; resolve the setting against the terminal's diff --git a/packages/coding-agent/src/modes/theme/mermaid-rendering.test.ts b/packages/coding-agent/src/modes/theme/mermaid-rendering.test.ts new file mode 100644 index 000000000..715b40844 --- /dev/null +++ b/packages/coding-agent/src/modes/theme/mermaid-rendering.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeAll, describe, expect, it } from "bun:test"; +import { Markdown } from "@oh-my-pi/pi-tui"; +import { Settings } from "../../config/settings"; +import { buildSystemPrompt } from "../../system-prompt"; +import { getMarkdownTheme, getThemeByName, setMarkdownMermaidRendering, setThemeInstance } from "./theme"; + +const workspaceTree = { + rootPath: "/tmp/project", + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], +}; + +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*m/g, ""); +} + +beforeAll(async () => { + await Settings.init({ inMemory: true }); + const theme = await getThemeByName("dark"); + if (!theme) throw new Error("theme unavailable"); + setThemeInstance(theme); +}); + +afterEach(() => { + setMarkdownMermaidRendering(true); +}); + +describe("Mermaid rendering setting", () => { + it("removes the Mermaid prompt note when rendering is disabled", async () => { + const { systemPrompt } = await buildSystemPrompt({ + renderMermaid: false, + contextFiles: [], + skills: [], + toolNames: [], + workspaceTree, + }); + + expect(systemPrompt.join("\n")).not.toContain("```mermaid"); + }); + + it("falls back to a highlighted code fence when rendering is disabled", () => { + setMarkdownMermaidRendering(false); + + const markdown = new Markdown("```mermaid\ngraph TD\n A --> B\n```", 0, 0, getMarkdownTheme()); + const lines = stripAnsi(markdown.render(80).join("\n")); + + expect(lines).toContain("```mermaid"); + expect(lines).toContain("graph TD"); + expect(lines).toContain("-->"); + }); +}); diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index ca5879223..e4f40873f 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -2824,23 +2824,36 @@ export function getSymbolTheme(): SymbolTheme { let cachedMarkdownTheme: MarkdownTheme | undefined; let cachedMarkdownThemeRef: Theme | undefined; +let markdownMermaidRendering = true; + +export function setMarkdownMermaidRendering(enabled: boolean): void { + if (markdownMermaidRendering === enabled) return; + markdownMermaidRendering = enabled; + cachedMarkdownTheme = undefined; +} export function getMarkdownTheme(): MarkdownTheme { if (cachedMarkdownTheme !== undefined && cachedMarkdownThemeRef === theme) { return cachedMarkdownTheme; } - // 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. - const mermaidColorMode = theme.getColorMode() === "truecolor" ? "truecolor" : "ansi256"; - const mermaidTheme = { - fg: theme.getColorHex("text"), - border: theme.getColorHex("border"), - line: theme.getColorHex("muted"), - arrow: theme.getColorHex("accent"), - corner: theme.getColorHex("muted"), - junction: theme.getColorHex("borderMuted"), - }; + 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. + const mermaidColorMode = + theme.getColorMode() === "truecolor" ? ("truecolor" as const) : ("ansi256" as const); + const mermaidTheme = { + fg: theme.getColorHex("text"), + border: theme.getColorHex("border"), + line: theme.getColorHex("muted"), + arrow: theme.getColorHex("accent"), + corner: theme.getColorHex("muted"), + junction: theme.getColorHex("borderMuted"), + }; + return { mermaidColorMode, mermaidTheme }; + })() + : undefined; const markdownTheme: MarkdownTheme = { heading: (text: string) => theme.fg("mdHeading", text), link: (text: string) => theme.fg("mdLink", text), @@ -2857,8 +2870,14 @@ export function getMarkdownTheme(): MarkdownTheme { underline: (text: string) => theme.underline(text), strikethrough: (text: string) => chalk.strikethrough(text), symbols: getSymbolTheme(), - resolveMermaidAscii: (source, maxWidth) => - resolveMermaidAscii(source, { maxWidth, theme: mermaidTheme, colorMode: mermaidColorMode }), + resolveMermaidAscii: mermaid + ? (source, maxWidth) => + resolveMermaidAscii(source, { + maxWidth, + theme: mermaid.mermaidTheme, + colorMode: mermaid.mermaidColorMode, + }) + : undefined, highlightCode: (code: string, lang?: string): string[] => { const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined; const highlighted = highlightCached(code, validLang, theme); diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index 10ba2774d..4d8eb9c08 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -16,7 +16,9 @@ You are a helpful assistant the team trusts with load-bearing changes, operating - Consider what code compiles to. NEVER allocate avoidably; no needless copies or computation. - You are not alone in this repo. Treat unexpected changes as the user's work and adapt. - In terminal prose and final chat, you MAY use LaTeX math (`$`, `$$`, `\text`, `\times`) and color (`\textcolor`, `\colorbox`, `\fcolorbox`). +{{#if renderMermaid}} - To show a diagram, you MAY emit a ` ```mermaid ` block — the terminal renders it as ASCII. Use it for genuine structure or flow, not trivia. +{{/if}} RUNTIME ============== diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 6ebf2b17e..b36d30502 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -2221,6 +2221,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} memoryRootEnabled: memoryBackend.id === "local", model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined, personality: agentKind === "sub" ? "none" : settings.get("personality"), + renderMermaid: settings.get("tui.renderMermaid"), }); if (options.systemPrompt === undefined) { diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 3d803af72..7961aa7cd 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -423,6 +423,8 @@ export interface BuildSystemPromptOptions { personality?: Personality; /** Whether to include the workspace directory tree in the system prompt. Default: false */ includeWorkspaceTree?: boolean; + /** Whether Mermaid fenced blocks render as terminal ASCII diagrams. Default: true */ + renderMermaid?: boolean; } /** Result of building provider-facing system prompt messages. */ @@ -464,6 +466,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): model, personality = "default", includeWorkspaceTree = false, + renderMermaid = true, } = options; const inlineToolDescriptors = providedInlineToolDescriptors ?? false; const resolvedCwd = cwd ?? getProjectDir(); @@ -682,6 +685,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): hasMemoryRoot: memoryRootEnabled, hasObsidian: hasObsidian(), includeWorkspaceTree, + renderMermaid, }; const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data); const systemPrompt = [rendered]; diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 3e6dce743..86c08a39c 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added Mermaid syntax highlighting support to `highlightCode`/`supportsLanguage` via a vendored `Mermaid.sublime-syntax` and `mermaid`/`mmd` aliases. + ## [16.1.17] - 2026-06-24 ### Added diff --git a/packages/natives/test/native.test.ts b/packages/natives/test/native.test.ts index b33db5a50..db483b9d7 100644 --- a/packages/natives/test/native.test.ts +++ b/packages/natives/test/native.test.ts @@ -251,6 +251,29 @@ describe("pi-natives", () => { expect(out).toContain("hello"); expect(out).toContain("# greeting"); }); + + it("highlights Mermaid via the vendored syntax", () => { + expect(getSupportedLanguages()).toContain("Mermaid"); + expect(supportsLanguage("mermaid")).toBe(true); + expect(supportsLanguage("mmd")).toBe(true); + + const colors = { + comment: "", + keyword: "", + function: "", + variable: "", + string: "", + number: "", + type: "", + operator: "", + punctuation: "

", + }; + const out = highlightCode('graph TD\n A["Start"] --> B\n %% note\n', "mermaid", colors); + expect(out).toContain("graph"); + expect(out).toContain("Start"); + expect(out).toContain("-->"); + expect(out).toContain(" note"); + }); }); describe("keys", () => {