mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 16:34:42 +00:00
fix(tui): preserve markdown blocks on plugin toggles (#47084)
This commit is contained in:
parent
a04d72bb39
commit
f94eefaa50
3 changed files with 144 additions and 18 deletions
|
|
@ -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({}))
|
||||
|
|
|
|||
18
packages/tui/src/plugin/markdown.ts
Normal file
18
packages/tui/src/plugin/markdown.ts
Normal 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()),
|
||||
)
|
||||
}
|
||||
122
packages/tui/test/plugin-markdown.test.tsx
Normal file
122
packages/tui/test/plugin-markdown.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue