From 59642a436ff14165f30ac7933facda12e9ad5f01 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 13:19:55 -0400 Subject: [PATCH] fix(tui): restore content lane rhythm --- packages/merman/src/markdown.ts | 20 ++++++++--- packages/merman/src/test/markdown.test.ts | 35 ++++++++++++++++--- packages/tui/src/routes/session/index.tsx | 24 +++++++------ .../tui/src/routes/session/markdown-lanes.ts | 5 +++ .../tui/test/cli/tui/markdown-lanes.test.ts | 9 ++++- 5 files changed, 73 insertions(+), 20 deletions(-) diff --git a/packages/merman/src/markdown.ts b/packages/merman/src/markdown.ts index a629b98b23..e9e51e8c77 100644 --- a/packages/merman/src/markdown.ts +++ b/packages/merman/src/markdown.ts @@ -1,5 +1,6 @@ import { TextRenderable, + BoxRenderable, RenderableEvents, createMarkdownCodeBlockRenderer, parseColor, @@ -33,6 +34,7 @@ interface PreparedDiagram { readonly source: string readonly text: StyledText readonly height: number + readonly width: number } export interface MermaidMarkdownRendererOptions { @@ -55,16 +57,23 @@ function color(value: ColorInput | undefined): RGBA | undefined { return value === undefined ? undefined : parseColor(value) } -class StaticDiagramRenderable extends TextRenderable { +class StaticDiagramRenderable extends BoxRenderable { constructor(ctx: RenderContext, prepared: PreparedDiagram) { super(ctx, { - content: prepared.text, width: "100%", + alignItems: "center", + flexShrink: 0, + marginTop: 1, + }) + const diagram = new TextRenderable(ctx, { + content: prepared.text, + width: prepared.width, + maxWidth: "100%", height: prepared.height, wrapMode: "none", selectable: false, - marginTop: 1, }) + this.add(diagram) let dragX: number | undefined this.onMouseDown = (event: MouseEvent) => { if (event.button !== 0) return @@ -79,7 +88,7 @@ class StaticDiagramRenderable extends TextRenderable { if (dragX === undefined) return const dx = event.x - dragX dragX = event.x - if (dx) this.scrollX -= dx + if (dx) diagram.scrollX -= dx } this.onMouseDragEnd = (event: MouseEvent) => { dragX = undefined @@ -121,6 +130,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd }), ), height: size.height, + width: size.width, } } case "sequence": { @@ -144,6 +154,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd }), ), height: size.height, + width: size.width, } } case "state": { @@ -168,6 +179,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd }), ), height: size.height, + width: size.width, } } } diff --git a/packages/merman/src/test/markdown.test.ts b/packages/merman/src/test/markdown.test.ts index 468af3546f..b10a366614 100644 --- a/packages/merman/src/test/markdown.test.ts +++ b/packages/merman/src/test/markdown.test.ts @@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test" import { mkdir } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TreeSitterClient } from "@opentui/core" +import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TextRenderable, TreeSitterClient } from "@opentui/core" import { createTestRenderer } from "@opentui/core/testing" import { createMermaidMarkdownRenderer } from "../markdown.js" @@ -78,6 +78,31 @@ flowchart LR expect(markdown.getChildren()[0]?.marginTop).toBe(1) }) +test("centers a Mermaid diagram narrower than its canvas", async () => { + const testRenderer = await createTestRenderer({ width: 80, height: 14 }) + renderer = testRenderer.renderer + const markdown = new MarkdownRenderable(renderer, { + id: "markdown-centered-mermaid", + content: `\`\`\`mermaid +flowchart LR + A[Start] --> B[Done] +\`\`\``, + syntaxStyle, + treeSitterClient, + renderNode: createMermaidMarkdownRenderer(renderer), + }) + + renderer.root.add(markdown) + await renderMarkdown(markdown, testRenderer.renderOnce) + + const line = testRenderer + .captureCharFrame() + .split("\n") + .find((value) => value.includes("Start")) + if (!line) throw new Error("Expected the rendered diagram") + expect(line.indexOf("Start")).toBeGreaterThan(10) +}) + test("recognizes normalized Mermaid fence info strings", async () => { const testRenderer = await createTestRenderer({ width: 80, height: 14 }) renderer = testRenderer.renderer @@ -235,17 +260,19 @@ sequenceDiagram renderer.root.add(markdown) await renderMarkdown(markdown, testRenderer.renderOnce) - const diagram = markdown.getChildren()[0] as CodeRenderable + const wrapper = markdown.getChildren()[0] + if (!wrapper) throw new Error("Expected the rendered diagram wrapper") + const diagram = wrapper.getChildren()[0] as TextRenderable expect(diagram.scrollWidth).toBeGreaterThan(diagram.width) expect(diagram.scrollX).toBe(0) - await testRenderer.mockMouse.drag(diagram.x + 20, diagram.y + 2, diagram.x + 5, diagram.y + 2) + await testRenderer.mockMouse.drag(wrapper.x + 20, wrapper.y + 2, wrapper.x + 5, wrapper.y + 2) await testRenderer.renderOnce() expect(diagram.scrollX).toBeGreaterThan(0) expect(diagram.hasSelection()).toBe(false) diagram.scrollX = 0 - await testRenderer.mockMouse.scroll(diagram.x + 20, diagram.y + 2, "right") + await testRenderer.mockMouse.scroll(wrapper.x + 20, wrapper.y + 2, "right") await testRenderer.renderOnce() expect(diagram.scrollX).toBeGreaterThan(0) }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index eab0165d9b..22656ce2f8 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -108,7 +108,7 @@ import { createSingleFlight } from "../../util/single-flight" import type { SessionPending } from "@opencode-ai/schema/session-pending" import { generateThinkingSyntax } from "./thinking-syntax" import { createDelayedPresence } from "../../util/delayed-presence" -import { markdownLanes } from "./markdown-lanes" +import { markdownLaneMarginTop, markdownLanes } from "./markdown-lanes" addDefaultParsers(parsers.parsers) @@ -2244,7 +2244,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) { return ( - {(segment) => { + {(segment, index) => { const content = ( ) return ( - - {content} - - {content} - - - {content} - - + + + {content} + + {content} + + + {content} + + + ) }} diff --git a/packages/tui/src/routes/session/markdown-lanes.ts b/packages/tui/src/routes/session/markdown-lanes.ts index 701218faca..402baaa552 100644 --- a/packages/tui/src/routes/session/markdown-lanes.ts +++ b/packages/tui/src/routes/session/markdown-lanes.ts @@ -38,3 +38,8 @@ export function markdownLanes(content: string): MarkdownLane[] { return result } + +export function markdownLaneMarginTop(index: number, width: MarkdownLane["width"]) { + if (index === 0 || width === "wide") return 0 + return 1 +} diff --git a/packages/tui/test/cli/tui/markdown-lanes.test.ts b/packages/tui/test/cli/tui/markdown-lanes.test.ts index 4d65e0e743..0cf06e3e43 100644 --- a/packages/tui/test/cli/tui/markdown-lanes.test.ts +++ b/packages/tui/test/cli/tui/markdown-lanes.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { markdownLanes } from "../../../src/routes/session/markdown-lanes" +import { markdownLaneMarginTop, markdownLanes } from "../../../src/routes/session/markdown-lanes" test("keeps prose in the readable lane", () => { expect(markdownLanes("Before\n\nAfter")).toEqual([{ content: "Before\n\nAfter", width: "readable" }]) @@ -47,3 +47,10 @@ test("gives ordinary fenced code an intermediate lane", () => { { content: "```ts\nexport const value = true\n```", width: "code" }, ]) }) + +test("restores spacing between separately rendered blocks", () => { + expect(markdownLaneMarginTop(0, "readable")).toBe(0) + expect(markdownLaneMarginTop(1, "code")).toBe(1) + expect(markdownLaneMarginTop(2, "readable")).toBe(1) + expect(markdownLaneMarginTop(1, "wide")).toBe(0) +})