fix(tui): preserve markdown blocks on plugin toggles (#47084)

This commit is contained in:
Kit Langton 2026-09-03 11:37:41 -04:00 committed by GitHub
parent a04d72bb39
commit f94eefaa50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 144 additions and 18 deletions

View file

@ -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<Host.Target>
@ -83,17 +84,6 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
const PluginContext = createContext<Value>()
let sourceVersion = Date.now()
export function combineMarkdownRenderers(
sources: ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
): MarkdownOptions["renderNode"] {
const renderers = new Map<string, MarkdownCodeBlockRenderer>()
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({}))

View file

@ -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<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
) {
// 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()),
)
}

View file

@ -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<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
{ example: handler },
{},
])
await render(() => {
const renderNode = createMarkdownRenderer(sources)
return (
<markdown
syntaxStyle={SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })}
renderNode={renderNode()}
content={"A plain paragraph.\n\n```example\nFence content\n```"}
streaming={false}
internalBlockMode="top-level"
/>
)
}, 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<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
{ 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<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
{ example: first },
])
await render(() => {
const renderNode = createMarkdownRenderer(sources)
return (
<markdown
syntaxStyle={SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })}
renderNode={renderNode()}
content={"```example\nFence content\n```"}
streaming={false}
internalBlockMode="top-level"
/>
)
}, 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()
}
})