mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-19 09:23:23 +00:00
fix(tui): harden Mermaid rendering limits (#42123)
This commit is contained in:
parent
c7bee09632
commit
c6a86acb0b
8 changed files with 70 additions and 6 deletions
|
|
@ -4,7 +4,7 @@ import { DiagramCanvas, DiagramCanvasSizeError, type DiagramCanvasCell } from ".
|
|||
|
||||
describe("DiagramCanvas", () => {
|
||||
test("rejects canvases that exceed the rendering budget", () => {
|
||||
expect(() => new DiagramCanvas(2_000, 1_000)).toThrow(DiagramCanvasSizeError)
|
||||
expect(() => new DiagramCanvas(1_000, 251)).toThrow(DiagramCanvasSizeError)
|
||||
})
|
||||
|
||||
test("rejects invalid canvas dimensions", () => {
|
||||
|
|
@ -38,6 +38,12 @@ describe("DiagramCanvas", () => {
|
|||
expect(stringWidth(canvas.toString())).toBe(4)
|
||||
})
|
||||
|
||||
test("does not emit partial wide graphemes", () => {
|
||||
const clipped = new DiagramCanvas<"label">(1, 1)
|
||||
clipped.setText(0, 0, "界", "label")
|
||||
expect(clipped.toString()).toBe("")
|
||||
})
|
||||
|
||||
test("keeps custom measurement for ASCII text", () => {
|
||||
let measurements = 0
|
||||
const canvas = new DiagramCanvas<"label">(5, 1, {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export interface DiagramCanvasRunOptions<Style extends string, Metadata extends
|
|||
trimBottom?: boolean
|
||||
}
|
||||
|
||||
const MAX_DIAGRAM_CELLS = 1_000_000
|
||||
const MAX_DIAGRAM_CELLS = 250_000
|
||||
|
||||
export class DiagramCanvasSizeError extends Error {
|
||||
constructor(
|
||||
|
|
@ -122,7 +122,6 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
|
|||
merge: boolean,
|
||||
): void {
|
||||
if (y < 0 || y >= this.cells.length || x < 0 || x >= this.cells[y]!.length) return
|
||||
|
||||
const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
|
||||
const cell = merge ? (this.mergeCell?.(this.cells[y]![x]!, incoming) ?? incoming) : incoming
|
||||
this.cells[y]![x] = cell
|
||||
|
|
@ -151,6 +150,10 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
|
|||
let offset = 0
|
||||
for (const grapheme of diagramTextGraphemes(text)) {
|
||||
const width = Math.max(1, this.measure(grapheme))
|
||||
if (x + offset < 0 || x + offset + width > this.width) {
|
||||
offset += width
|
||||
continue
|
||||
}
|
||||
this.setCell(x + offset, y, grapheme, style, metadataAt(x + offset))
|
||||
for (let continuation = 1; continuation < width; continuation++) {
|
||||
this.setCell(x + offset + continuation, y, "", style, metadataAt(x + offset + continuation))
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const BOX_NODE_RE = new RegExp(`^(${ID_RE})\\[(.+)\\]$`)
|
|||
const ID_ONLY_RE = new RegExp(`^${ID_RE}$`)
|
||||
const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`)
|
||||
const CIRCLE_NODE_RE = new RegExp(`^${ID_RE}\\(\\(.+\\)\\)$`)
|
||||
const MAX_FLOWCHART_LINE_LENGTH = 10_000
|
||||
const EDGE_OPERATOR_RE =
|
||||
/(-\.(?!->)(.+?)\.(?:->|-))|(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->|\.-)|(<-->|-->|==>|-\.->|---|~~~)\s*(?:\|([^|]*)\|\s*)?/dg
|
||||
|
||||
|
|
@ -252,6 +253,9 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
|
|||
|
||||
for (const source of meaningfulNumberedMermaidLines(content)) {
|
||||
const line = source.text
|
||||
if (line.length > MAX_FLOWCHART_LINE_LENGTH) {
|
||||
throw new MermaidSyntaxError("flowchart", source.lineNumber, line, "Flowchart statement is too long")
|
||||
}
|
||||
if (hasInternalStatementSeparator(line)) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
|
||||
const header = line.match(FLOWCHART_HEADER_RE)
|
||||
if (header) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ interface PreparedDiagram {
|
|||
|
||||
export interface MermaidMarkdownRendererOptions {
|
||||
compact?: boolean
|
||||
/** Fold horizontal flowcharts that exceed this width. Defaults to 120 columns. */
|
||||
layoutMaxWidth?: number
|
||||
colors?: {
|
||||
text?: ColorInput
|
||||
primary?: ColorInput
|
||||
|
|
@ -101,11 +103,19 @@ class StaticDiagramRenderable extends TextRenderable {
|
|||
}
|
||||
}
|
||||
|
||||
function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkdownRendererOptions): PreparedDiagram {
|
||||
function prepareDiagram(
|
||||
kind: DiagramKind,
|
||||
source: string,
|
||||
options: MermaidMarkdownRendererOptions,
|
||||
layoutMaxWidth: number,
|
||||
): PreparedDiagram {
|
||||
const colors = options.colors ?? {}
|
||||
switch (kind) {
|
||||
case "flowchart": {
|
||||
const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), { compact: options.compact })
|
||||
const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), {
|
||||
compact: options.compact,
|
||||
layoutMaxWidth,
|
||||
})
|
||||
const size = grid.getTextSize({ trimTop: true, trimBottom: true })
|
||||
return {
|
||||
kind,
|
||||
|
|
@ -192,9 +202,11 @@ export function createMermaidCodeBlockRenderer(
|
|||
// OpenTUI's default block ID is the stable identity available for this fence across streaming updates.
|
||||
const key = context.defaultRender()?.id
|
||||
const options = typeof input === "function" ? input() : input
|
||||
const configuredMaxWidth = options.layoutMaxWidth === undefined ? 120 : Math.max(1, Math.trunc(options.layoutMaxWidth))
|
||||
const layoutMaxWidth = Math.min(configuredMaxWidth, Math.max(1, Math.trunc(ctx.width)))
|
||||
|
||||
try {
|
||||
const prepared = prepareDiagram(kind, token.text, options)
|
||||
const prepared = prepareDiagram(kind, token.text, options, layoutMaxWidth)
|
||||
const diagram = new StaticDiagramRenderable(ctx, prepared)
|
||||
if (key) claimLastGood(key, prepared, diagram, lastGood)
|
||||
return diagram
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const ELSE_RE = /^else(?:\s+(.+))?$/i
|
|||
const LOOP_RE = /^loop\s+(.+)$/i
|
||||
const AUTONUMBER_RE = /^autonumber(?:\s+(\d+)(?:\s+(\d+))?)?$/i
|
||||
const UNSUPPORTED_BIDIRECTIONAL_MESSAGE_RE = /<<-{1,2}>>/
|
||||
const MESSAGE_OPERATOR_RE = /-->>|->>|--x|-x|--\)|-\)|-->|->/
|
||||
const CSS_COLOR_NAMES = new Set([
|
||||
"black",
|
||||
"white",
|
||||
|
|
@ -237,6 +238,9 @@ export function parseMermaidSequenceDiagram(content: string): SequenceDiagram {
|
|||
const arrow = messageMatch[2]!
|
||||
const activationMarker = messageMatch[3]!
|
||||
const to = stripQuotes(messageMatch[4]!)
|
||||
if (MESSAGE_OPERATOR_RE.test(from) || MESSAGE_OPERATOR_RE.test(to)) {
|
||||
throw new MermaidSyntaxError("sequence", source.lineNumber, line)
|
||||
}
|
||||
const message: SequenceMessage = {
|
||||
from,
|
||||
to,
|
||||
|
|
|
|||
|
|
@ -176,6 +176,9 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
|
|||
if (transitionMatch) {
|
||||
const rawFrom = transitionMatch[1]!
|
||||
const rawTo = transitionMatch[2]!
|
||||
if (rawFrom.includes("-->") || rawTo.includes("-->")) {
|
||||
throw new MermaidSyntaxError("state", source.lineNumber, line)
|
||||
}
|
||||
const from = normalizeStateDiagramEndpoint(rawFrom, "from", parentId)
|
||||
const to = normalizeStateDiagramEndpoint(rawTo, "to", parentId)
|
||||
ensureState(states, from, rawFrom === "[*]" ? "●" : from, rawFrom === "[*]" ? "start" : "state", parentId)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ describe("parser diagnostics", () => {
|
|||
expect(diagram.edges).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("rejects pathological flowchart statements before parsing edge operators", () => {
|
||||
const statement = "-.a".repeat(4_000)
|
||||
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n${statement}`)).toThrow("Flowchart statement is too long")
|
||||
})
|
||||
|
||||
test("exposes structured syntax errors through top-level rendering", () => {
|
||||
try {
|
||||
renderSequenceDiagram(`sequenceDiagram
|
||||
|
|
@ -62,6 +67,11 @@ describe("parser diagnostics", () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("rejects chained sequence and state transitions instead of creating phantom endpoints", () => {
|
||||
expect(() => parseMermaidSequenceDiagram("sequenceDiagram\n A->>B->>C: hello")).toThrow(MermaidSyntaxError)
|
||||
expect(() => parseMermaidStateDiagram("stateDiagram-v2\n A-->B-->C")).toThrow(MermaidSyntaxError)
|
||||
})
|
||||
|
||||
test("reports unclosed state constructs at their opening line", () => {
|
||||
expect(() =>
|
||||
parseMermaidStateDiagram(`stateDiagram-v2
|
||||
|
|
|
|||
|
|
@ -289,6 +289,28 @@ flowchart TB
|
|||
expect(frame).not.toMatch(/<\/?i>|<br|events persist|mcp stdio/)
|
||||
})
|
||||
|
||||
test("folds a horizontal flowchart to fit the Markdown viewport", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 160, height: 30 })
|
||||
renderer = testRenderer.renderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-horizontal-flowchart",
|
||||
content: `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A["per TURN<br/>fresh sandbox each turn"] --- B["per SESSION/thread<br/>one sandbox per Slack thread"] --- C["per REPO<br/>threads share a sandbox"] --- D["GLOBAL registry<br/>(upstream today: hardwired at boot)"]
|
||||
\`\`\``,
|
||||
syntaxStyle,
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
const diagram = markdown.getChildren()[0] as CodeRenderable
|
||||
expect(diagram.scrollWidth).toBeLessThanOrEqual(diagram.width)
|
||||
expect(diagram.scrollWidth).toBeLessThanOrEqual(120)
|
||||
expect(testRenderer.captureCharFrame()).toContain("GLOBAL registry")
|
||||
})
|
||||
|
||||
test("renders a Mermaid state fence inside MarkdownRenderable", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue