Skip to content

Commit f94eefa

Browse files
authored
fix(tui): preserve markdown blocks on plugin toggles (#47084)
1 parent a04d72b commit f94eefa

3 files changed

Lines changed: 144 additions & 18 deletions

File tree

‎packages/tui/src/plugin/context.tsx‎

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { PluginInfo } from "@opencode-ai/client"
22
import type { Plugin } from "@opencode-ai/plugin/tui"
3-
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
3+
import type { MarkdownCodeBlockRenderer, MarkdownOptions } from "@opentui/core"
44
import {
55
batch,
66
createContext,
@@ -33,6 +33,7 @@ import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot,
3333
import { createSourceWatcher } from "./watch"
3434
import { discoverPluginTargets, freshSpecifier, localSource } from "./discovery"
3535
import { isMissingPath } from "../util/config-directories"
36+
import { createMarkdownRenderer } from "./markdown"
3637

3738
export interface PackageSource {
3839
readonly prepare: (spec: string, install?: boolean) => Promise<Host.Target>
@@ -83,17 +84,6 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
8384
const PluginContext = createContext<Value>()
8485
let sourceVersion = Date.now()
8586

86-
export function combineMarkdownRenderers(
87-
sources: ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
88-
): MarkdownOptions["renderNode"] {
89-
const renderers = new Map<string, MarkdownCodeBlockRenderer>()
90-
for (const source of sources) {
91-
for (const [language, render] of Object.entries(source)) renderers.set(language, render)
92-
}
93-
if (renderers.size === 0) return undefined
94-
return createMarkdownCodeBlockRenderer(renderers)
95-
}
96-
9787
export function PluginProvider(props: ParentProps<{ packages: PackageSource; directories: string[] }>) {
9888
const host = usePluginHost()
9989
const config = useConfig()
@@ -125,12 +115,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
125115
sourceVersions.set(entrypoint, { digest, generation })
126116
return generation
127117
}
128-
const markdown = createMemo(() =>
129-
combineMarkdownRenderers(
130-
Object.values(store.registrations).flatMap((registration) =>
131-
registration.active ? [registration.markdown] : [],
132-
),
133-
),
118+
const markdown = createMarkdownRenderer(() =>
119+
Object.values(store.registrations).flatMap((registration) => (registration.active ? [registration.markdown] : [])),
134120
)
135121
const clearContributions = (id: string) => {
136122
setStore("registrations", id, "routes", reconcileStore({}))
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer } from "@opentui/core"
2+
import { isShallowEqual } from "remeda"
3+
import { createMemo } from "solid-js"
4+
5+
export function createMarkdownRenderer(
6+
sources: () => ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>,
7+
) {
8+
// Changing renderNode makes OpenTUI destroy and rebuild every Markdown block.
9+
// Only invalidate it when the effective last-wins language handlers change.
10+
const renderers = createMemo(
11+
() => Object.fromEntries(sources().flatMap((source) => Object.entries(source))),
12+
undefined,
13+
{ equals: isShallowEqual },
14+
)
15+
return createMemo(() =>
16+
Object.keys(renderers()).length === 0 ? undefined : createMarkdownCodeBlockRenderer(renderers()),
17+
)
18+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { expect, test } from "bun:test"
2+
import {
3+
CodeRenderable,
4+
MarkdownRenderable,
5+
SyntaxStyle,
6+
TextRenderable,
7+
type MarkdownCodeBlockRenderer,
8+
} from "@opentui/core"
9+
import { createTestRenderer } from "@opentui/core/testing"
10+
import { render } from "@opentui/solid"
11+
import { createRoot, createSignal } from "solid-js"
12+
import { createMarkdownRenderer } from "../src/plugin/markdown"
13+
14+
test("unrelated plugin toggles preserve mounted Markdown blocks", async () => {
15+
const output = await createTestRenderer({ width: 80, height: 12, remote: true, useThread: false })
16+
const handler: MarkdownCodeBlockRenderer = () =>
17+
new TextRenderable(output.renderer, { content: "Custom fence", height: 1 })
18+
const [sources, setSources] = createSignal<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
19+
{ example: handler },
20+
{},
21+
])
22+
await render(() => {
23+
const renderNode = createMarkdownRenderer(sources)
24+
return (
25+
<markdown
26+
syntaxStyle={SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })}
27+
renderNode={renderNode()}
28+
content={"A plain paragraph.\n\n```example\nFence content\n```"}
29+
streaming={false}
30+
internalBlockMode="top-level"
31+
/>
32+
)
33+
}, output.renderer)
34+
try {
35+
output.renderer.start()
36+
await output.waitForFrame((frame) => frame.includes("Custom fence"))
37+
const markdown = output.renderer.root.getChildren()[0]
38+
if (!(markdown instanceof MarkdownRenderable)) throw new Error("Expected Markdown")
39+
const initial = markdown.getChildren()
40+
expect(initial).toHaveLength(2)
41+
expect(output.captureCharFrame()).toContain("Custom fence")
42+
43+
for (const active of [false, true, false, true]) {
44+
setSources([{ example: handler }, ...(active ? [{}] : [])])
45+
await output.renderOnce()
46+
expect(markdown.getChildren()[0] === initial[0]).toBe(true)
47+
expect(markdown.getChildren()[1] === initial[1]).toBe(true)
48+
expect(initial.every((block) => !block.isDestroyed)).toBe(true)
49+
}
50+
} finally {
51+
output.renderer.destroy()
52+
}
53+
})
54+
55+
test("effective mappings preserve identity through reordered and shadowed contributions", () => {
56+
createRoot((dispose) => {
57+
try {
58+
const first: MarkdownCodeBlockRenderer = () => undefined
59+
const second: MarkdownCodeBlockRenderer = () => undefined
60+
const [sources, setSources] = createSignal<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
61+
{ example: first },
62+
{ example: second, other: first },
63+
])
64+
const renderNode = createMarkdownRenderer(sources)
65+
const initial = renderNode()
66+
67+
setSources([{ other: first, example: second }])
68+
expect(renderNode()).toBe(initial)
69+
setSources([{ example: first }, { other: first, example: second }])
70+
expect(renderNode()).toBe(initial)
71+
72+
setSources([{ example: first, other: first }])
73+
expect(renderNode()).not.toBe(initial)
74+
setSources([])
75+
expect(renderNode()).toBeUndefined()
76+
} finally {
77+
dispose()
78+
}
79+
})
80+
})
81+
82+
test("changing and removing a Markdown handler refreshes existing messages", async () => {
83+
const output = await createTestRenderer({ width: 80, height: 12, remote: true, useThread: false })
84+
const first: MarkdownCodeBlockRenderer = () =>
85+
new TextRenderable(output.renderer, { content: "First renderer", height: 1 })
86+
const second: MarkdownCodeBlockRenderer = () =>
87+
new TextRenderable(output.renderer, { content: "Second renderer", height: 1 })
88+
const [sources, setSources] = createSignal<ReadonlyArray<Readonly<Record<string, MarkdownCodeBlockRenderer>>>>([
89+
{ example: first },
90+
])
91+
await render(() => {
92+
const renderNode = createMarkdownRenderer(sources)
93+
return (
94+
<markdown
95+
syntaxStyle={SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } })}
96+
renderNode={renderNode()}
97+
content={"```example\nFence content\n```"}
98+
streaming={false}
99+
internalBlockMode="top-level"
100+
/>
101+
)
102+
}, output.renderer)
103+
try {
104+
output.renderer.start()
105+
await output.waitForFrame((frame) => frame.includes("First renderer"))
106+
107+
setSources([{ example: first }, { example: second }])
108+
await output.waitForFrame((frame) => frame.includes("Second renderer"))
109+
110+
setSources([{ example: first }])
111+
await output.waitForFrame((frame) => frame.includes("First renderer"))
112+
113+
setSources([])
114+
await output.waitForFrame((frame) => frame.includes("Fence content"))
115+
expect(output.renderer.root.getChildren()[0]?.getChildren()[0]).toBeInstanceOf(CodeRenderable)
116+
117+
setSources([{ example: second }])
118+
await output.waitForFrame((frame) => frame.includes("Second renderer"))
119+
} finally {
120+
output.renderer.destroy()
121+
}
122+
})

0 commit comments

Comments
 (0)