diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index ebc6f634598..9e99c0e2ae0 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -1,6 +1,6 @@ import type { PluginInfo } from "@opencode-ai/client" import type { Plugin } from "@opencode-ai/plugin/tui" -import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core" +import type { MarkdownCodeBlockRenderer, MarkdownOptions } from "@opentui/core" import { batch, createContext, @@ -33,6 +33,7 @@ import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, import { createSourceWatcher } from "./watch" import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery" import { isMissingPath } from "../util/config-directories" +import { createMarkdownRenderer } from "./markdown" export interface PackageSource { readonly prepare: (spec: string, install?: boolean) => Promise @@ -83,17 +84,6 @@ type Desired = Pick() let sourceVersion = Date.now() -export function combineMarkdownRenderers( - sources: ReadonlyArray>>, -): MarkdownOptions["renderNode"] { - const renderers = new Map() - for (const source of sources) { - for (const [language, render] of Object.entries(source)) renderers.set(language, render) - } - if (renderers.size === 0) return undefined - return createMarkdownCodeBlockRenderer(renderers) -} - export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) { const host = usePluginHost() const config = useConfig() @@ -125,12 +115,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir sourceVersions.set(entrypoint, { digest, generation }) return generation } - const markdown = createMemo(() => - combineMarkdownRenderers( - Object.values(store.registrations).flatMap((registration) => - registration.active ? [registration.markdown] : [], - ), - ), + const markdown = createMarkdownRenderer(() => + Object.values(store.registrations).flatMap((registration) => (registration.active ? [registration.markdown] : [])), ) const clearContributions = (id: string) => { setStore("registrations", id, "routes", reconcileStore({})) diff --git a/packages/tui/src/plugin/markdown.ts b/packages/tui/src/plugin/markdown.ts new file mode 100644 index 00000000000..a0420cb7cf7 --- /dev/null +++ b/packages/tui/src/plugin/markdown.ts @@ -0,0 +1,18 @@ +import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer } from "@opentui/core" +import { isShallowEqual } from "remeda" +import { createMemo } from "solid-js" + +export function createMarkdownRenderer( + sources: () => ReadonlyArray>>, +) { + // Changing renderNode makes OpenTUI destroy and rebuild every Markdown block. + // Only invalidate it when the effective last-wins language handlers change. + const renderers = createMemo( + () => Object.fromEntries(sources().flatMap((source) => Object.entries(source))), + undefined, + { equals: isShallowEqual }, + ) + return createMemo(() => + Object.keys(renderers()).length === 0 ? undefined : createMarkdownCodeBlockRenderer(renderers()), + ) +} diff --git a/packages/tui/test/plugin-markdown.test.tsx b/packages/tui/test/plugin-markdown.test.tsx new file mode 100644 index 00000000000..2e9faedd141 --- /dev/null +++ b/packages/tui/test/plugin-markdown.test.tsx @@ -0,0 +1,122 @@ +import { expect, test } from "bun:test" +import { + CodeRenderable, + MarkdownRenderable, + SyntaxStyle, + TextRenderable, + type MarkdownCodeBlockRenderer, +} from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { render } from "@opentui/solid" +import { createRoot, createSignal } from "solid-js" +import { createMarkdownRenderer } from "../src/plugin/markdown" + +test("unrelated plugin toggles preserve mounted Markdown blocks", async () => { + const output = await createTestRenderer({ width: 80, height: 12, remote: true, useThread: false }) + const handler: MarkdownCodeBlockRenderer = () => + new TextRenderable(output.renderer, { content: "Custom fence", height: 1 }) + const [sources, setSources] = createSignal>>>([ + { example: handler }, + {}, + ]) + await render(() => { + const renderNode = createMarkdownRenderer(sources) + return ( + + ) + }, output.renderer) + try { + output.renderer.start() + await output.waitForFrame((frame) => frame.includes("Custom fence")) + const markdown = output.renderer.root.getChildren()[0] + if (!(markdown instanceof MarkdownRenderable)) throw new Error("Expected Markdown") + const initial = markdown.getChildren() + expect(initial).toHaveLength(2) + expect(output.captureCharFrame()).toContain("Custom fence") + + for (const active of [false, true, false, true]) { + setSources([{ example: handler }, ...(active ? [{}] : [])]) + await output.renderOnce() + expect(markdown.getChildren()[0] === initial[0]).toBe(true) + expect(markdown.getChildren()[1] === initial[1]).toBe(true) + expect(initial.every((block) => !block.isDestroyed)).toBe(true) + } + } finally { + output.renderer.destroy() + } +}) + +test("effective mappings preserve identity through reordered and shadowed contributions", () => { + createRoot((dispose) => { + try { + const first: MarkdownCodeBlockRenderer = () => undefined + const second: MarkdownCodeBlockRenderer = () => undefined + const [sources, setSources] = createSignal>>>([ + { example: first }, + { example: second, other: first }, + ]) + const renderNode = createMarkdownRenderer(sources) + const initial = renderNode() + + setSources([{ other: first, example: second }]) + expect(renderNode()).toBe(initial) + setSources([{ example: first }, { other: first, example: second }]) + expect(renderNode()).toBe(initial) + + setSources([{ example: first, other: first }]) + expect(renderNode()).not.toBe(initial) + setSources([]) + expect(renderNode()).toBeUndefined() + } finally { + dispose() + } + }) +}) + +test("changing and removing a Markdown handler refreshes existing messages", async () => { + const output = await createTestRenderer({ width: 80, height: 12, remote: true, useThread: false }) + const first: MarkdownCodeBlockRenderer = () => + new TextRenderable(output.renderer, { content: "First renderer", height: 1 }) + const second: MarkdownCodeBlockRenderer = () => + new TextRenderable(output.renderer, { content: "Second renderer", height: 1 }) + const [sources, setSources] = createSignal>>>([ + { example: first }, + ]) + await render(() => { + const renderNode = createMarkdownRenderer(sources) + return ( + + ) + }, output.renderer) + try { + output.renderer.start() + await output.waitForFrame((frame) => frame.includes("First renderer")) + + setSources([{ example: first }, { example: second }]) + await output.waitForFrame((frame) => frame.includes("Second renderer")) + + setSources([{ example: first }]) + await output.waitForFrame((frame) => frame.includes("First renderer")) + + setSources([]) + await output.waitForFrame((frame) => frame.includes("Fence content")) + expect(output.renderer.root.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(CodeRenderable) + + setSources([{ example: second }]) + await output.waitForFrame((frame) => frame.includes("Second renderer")) + } finally { + output.renderer.destroy() + } +})