Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton bd406b9619 fix(tui): share runtime with external TSX plugins 2026-07-31 21:40:14 -04:00
10 changed files with 187 additions and 131 deletions
+5
View File
@@ -77,6 +77,11 @@
"bun": "./src/plugin/runtime-plugin-support.bun.ts",
"node": "./src/plugin/runtime-plugin-support.node.ts",
"default": "./src/plugin/runtime-plugin-support.node.ts"
},
"#plugin-loader": {
"bun": "./src/plugin/loader.bun.ts",
"node": "./src/plugin/loader.node.ts",
"default": "./src/plugin/loader.node.ts"
}
},
"dependencies": {
+4 -10
View File
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => {
const provider = providers().get(model.providerID)
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
return {
value: { providerID: model.providerID, modelID: model.id },
providerID: model.providerID,
providerName: provider?.name ?? model.providerID,
title: model.name,
releaseDate: model.time.released,
favorite,
description: favorite ? "(Favorite)" : undefined,
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
? "(Favorite)"
: undefined,
category: connected() ? (provider?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
onSelect() {
@@ -96,9 +96,7 @@ export function DialogModel(props: { providerID?: string }) {
)
if (needle) {
return prioritizeFavorites(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
)
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
}
return [...favoriteOptions, ...recentOptions, ...modelOptions]
@@ -162,10 +160,6 @@ export function DialogModel(props: { providerID?: string }) {
)
}
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
}
export function sortModelOptions<
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
>(options: T[]) {
+11 -43
View File
@@ -130,7 +130,7 @@ function formatEditorContext(selection: EditorSelection) {
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
}
const drafts = new Map<string, { prompt: PromptInfo; cursor: number }>()
let stashed: { prompt: PromptInfo; cursor: number } | undefined
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
const head = parseSlashHead(input, /\s/)
@@ -172,20 +172,6 @@ export function Prompt(props: PromptProps) {
const exit = useExit()
const dimensions = useTerminalDimensions()
const theme = useTheme()
const draftKey = (sessionID?: string) => sessionID ?? "new"
const saveDraft = (sessionID?: string) => {
const key = draftKey(sessionID)
if (
!store.prompt.text &&
store.prompt.pasted.length === 0 &&
(store.prompt.files?.length ?? 0) === 0 &&
(store.prompt.agents?.length ?? 0) === 0
) {
drafts.delete(key)
return
}
drafts.set(key, { prompt: structuredClone(unwrap(store.prompt)), cursor: input.cursorOffset })
}
const { currentSyntax: syntax } = useThemes()
const animationsEnabled = createMemo(() => config.animations ?? true)
const list = createMemo(() => props.placeholders?.normal ?? [])
@@ -575,7 +561,6 @@ export function Prompt(props: PromptProps) {
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
drafts.delete(draftKey(props.sessionID))
},
submit() {
void submit()
@@ -583,10 +568,10 @@ export function Prompt(props: PromptProps) {
}
onMount(() => {
void history.load(props.sessionID)
const saved = drafts.get(draftKey(props.sessionID))
const saved = stashed
stashed = undefined
if (store.prompt.text) return
if (saved) {
if (saved && saved.prompt.text) {
input.setText(saved.prompt.text)
setStore("prompt", saved.prompt)
restoreExtmarksFromPrompt(saved.prompt)
@@ -594,25 +579,10 @@ export function Prompt(props: PromptProps) {
}
})
createEffect(
on(
() => props.sessionID,
(sessionID, previous) => {
saveDraft(previous)
const saved = drafts.get(draftKey(sessionID))
input.clear()
input.extmarks.clear()
setStore("prompt", saved?.prompt ?? emptyPrompt())
restoreExtmarksFromPrompt(saved?.prompt ?? emptyPrompt())
input.cursorOffset = saved?.cursor ?? 0
void history.load(sessionID)
},
{ defer: true },
),
)
onCleanup(() => {
saveDraft(props.sessionID)
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
setInputTarget(undefined)
props.ref?.(undefined)
})
@@ -884,7 +854,7 @@ export function Prompt(props: PromptProps) {
return
}
const item = history.move(props.sessionID, -1, input.plainText)
const item = history.move(-1, input.plainText)
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
@@ -923,7 +893,7 @@ export function Prompt(props: PromptProps) {
return
}
const item = history.move(props.sessionID, 1, input.plainText)
const item = history.move(1, input.plainText)
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
@@ -1147,14 +1117,13 @@ export function Prompt(props: PromptProps) {
}
if (pendingEditorSelection) editor.markSelectionSent()
}
history.append(sessionID, {
history.append({
...store.prompt,
mode: currentMode,
})
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
drafts.delete(draftKey(sessionID))
props.onSubmit?.()
// temporary hack to make sure the message is sent
@@ -1304,7 +1273,7 @@ export function Prompt(props: PromptProps) {
(store.prompt.files?.length ?? 0) > 0 ||
(store.prompt.agents?.length ?? 0) > 0
) {
history.append(props.sessionID, {
history.append({
...store.prompt,
mode: store.mode,
})
@@ -1313,7 +1282,6 @@ export function Prompt(props: PromptProps) {
input.extmarks.clear()
setStore("prompt", emptyPrompt())
setStore("extmarkToPart", new Map())
drafts.delete(draftKey(props.sessionID))
}
const highlight = createMemo(() => {
+4 -2
View File
@@ -7,6 +7,7 @@ import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { isDeepEqual } from "remeda"
import "#runtime-plugin-support"
import { preparePlugin } from "#plugin-loader"
import { useConfig } from "../config"
import { useTuiLifecycle } from "../context/runtime"
import { errorMessage } from "../util/error"
@@ -215,7 +216,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const memo = local ? undefined : npmFailures.get(target)
const resolved = memo
? { status: "failed" as const, error: memo }
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
: await resolvePlugin(target, local, options, previous, props.packages, host.paths.state).catch((error) => ({
status: "failed" as const,
error: errorMessage(error),
}))
@@ -439,6 +440,7 @@ async function resolvePlugin(
options: Readonly<Record<string, any>> | undefined,
previous: Registration | undefined,
packages: PackageResolver,
state: string,
) {
// Package entrypoints never change within a session, so a loaded previous
// version needs no re-resolution (which could otherwise hit npm).
@@ -451,7 +453,7 @@ async function resolvePlugin(
const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(version)
const mod: { readonly default?: unknown } = await import(await preparePlugin(entrypoint, version, state))
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
}
+44
View File
@@ -0,0 +1,44 @@
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import { isCoreRuntimeModuleSpecifier, runtimeModuleIdForSpecifier } from "@opentui/core/runtime-plugin"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const runtime = new Set([
"@opentui/solid",
"@opentui/solid/components",
"@opentui/solid/jsx-runtime",
"@opentui/solid/jsx-dev-runtime",
"solid-js",
"solid-js/store",
])
export async function preparePlugin(entrypoint: string, version: string, state: string) {
const source = fileURLToPath(entrypoint)
if (!source.endsWith(".tsx") && !source.endsWith(".jsx")) return version
const result = await Bun.build({
entrypoints: [source],
target: "bun",
format: "esm",
sourcemap: "inline",
plugins: [
createSolidTransformPlugin({
moduleName: runtimeModuleIdForSpecifier("@opentui/solid"),
resolvePath(specifier) {
if (!runtime.has(specifier) && !isCoreRuntimeModuleSpecifier(specifier)) return null
return runtimeModuleIdForSpecifier(specifier)
},
}),
],
external: [...runtime, "@opentui/core", "@opentui/core/testing"].flatMap((specifier) => [
specifier,
runtimeModuleIdForSpecifier(specifier),
]),
})
if (!result.success) throw new Error(result.logs.join("\n"))
const directory = path.join(state, "tui-plugin-cache")
const output = path.join(directory, `${Bun.hash(version).toString(16)}.mjs`)
await mkdir(directory, { recursive: true })
await Bun.write(output, result.outputs[0]!)
return output
}
+3
View File
@@ -0,0 +1,3 @@
export async function preparePlugin(_entrypoint: string, version: string, _state: string) {
return version
}
+39 -49
View File
@@ -1,5 +1,6 @@
import path from "path"
import { unwrap } from "solid-js/store"
import { onMount } from "solid-js"
import { createStore, produce, unwrap } from "solid-js/store"
import type { SessionPromptInput } from "@opencode-ai/client"
import type { Types } from "effect"
import { createSimpleContext } from "../context/helper"
@@ -60,67 +61,56 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
name: "PromptHistory",
init: () => {
const paths = useTuiPaths()
const stores = new Map<string, { index: number; history: PromptInfo[] }>()
const loaded = new Set<string>()
const key = (sessionID?: string) => sessionID ?? "new"
const historyPath = (sessionID?: string) =>
path.join(paths.state, "prompt-history", encodeURIComponent(key(sessionID)) + ".jsonl")
const store = (sessionID?: string) => {
const id = key(sessionID)
const current = stores.get(id)
if (current) return current
const next = { index: 0, history: [] as PromptInfo[] }
stores.set(id, next)
return next
}
const historyPath = path.join(paths.state, "prompt-history.jsonl")
onMount(async () => {
const lines = parsePromptHistory(await readText(historyPath).catch(() => ""))
setStore("history", lines)
// Rewrite valid retained entries to self-heal corruption and enforce the limit.
if (lines.length > 0)
writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
})
const [store, setStore] = createStore({
index: 0,
history: [] as PromptInfo[],
})
return {
async load(sessionID?: string) {
const id = key(sessionID)
if (loaded.has(id)) return
loaded.add(id)
const lines = parsePromptHistory(await readText(historyPath(sessionID)).catch(() => ""))
const current = stores.get(id)
const history = [...lines, ...(current?.history ?? [])]
.filter((entry, index, entries) => !isDuplicateEntry(entries[index - 1], entry))
.slice(-MAX_HISTORY_ENTRIES)
stores.set(id, { index: current?.index ?? 0, history })
if (lines.length > 0)
writeText(historyPath(sessionID), history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(
() => {},
)
},
move(sessionID: string | undefined, direction: 1 | -1, input: string) {
const state = store(sessionID)
if (!state.history.length) return undefined
const current = state.history.at(state.index)
move(direction: 1 | -1, input: string) {
if (!store.history.length) return undefined
const current = store.history.at(store.index)
if (!current) return undefined
if (current.text !== input && input.length) return
const next = state.index + direction
if (Math.abs(next) > state.history.length || next > 0) return
state.index = next
const next = store.index + direction
if (Math.abs(next) > store.history.length || next > 0) return
setStore("index", next)
if (next === 0) return emptyPrompt()
return state.history.at(next)
return store.history.at(next)
},
append(sessionID: string | undefined, item: PromptInfo) {
const state = store(sessionID)
append(item: PromptInfo) {
const entry = structuredClone(unwrap(item))
if (isDuplicateEntry(state.history.at(-1), entry)) {
state.index = 0
if (isDuplicateEntry(store.history.at(-1), entry)) {
setStore("index", 0)
return
}
state.history.push(entry)
const trimmed = state.history.length > MAX_HISTORY_ENTRIES
if (trimmed) state.history = state.history.slice(-MAX_HISTORY_ENTRIES)
state.index = 0
let trimmed = false
setStore(
produce((draft) => {
draft.history.push(entry)
if (draft.history.length > MAX_HISTORY_ENTRIES) {
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
trimmed = true
}
draft.index = 0
}),
)
if (trimmed) {
writeText(historyPath(sessionID), state.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(
() => {},
)
writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
return
}
appendText(historyPath(sessionID), JSON.stringify(entry) + "\n").catch(() => {})
appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {})
},
}
},
@@ -1,23 +1,5 @@
import { describe, expect, test } from "bun:test"
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
describe("prioritizeFavorites", () => {
test("moves favorites first while preserving fuzzy result order", () => {
const prioritized = prioritizeFavorites([
{ title: "Best match", favorite: false },
{ title: "Favorite match", favorite: true },
{ title: "Second best match", favorite: false },
{ title: "Second favorite match", favorite: true },
])
expect(prioritized.map((model) => model.title)).toEqual([
"Favorite match",
"Second favorite match",
"Best match",
"Second best match",
])
})
})
import { sortModelOptions } from "../../../../src/component/dialog-model"
describe("sortModelOptions", () => {
test("orders opencode models before other providers", () => {
@@ -0,0 +1,72 @@
import { expect, test } from "bun:test"
import { createComponent, createSignal, type JSX } from "solid-js"
import { testRender } from "@opentui/solid"
import { mkdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { preparePlugin } from "../src/plugin/loader.bun"
import "../src/plugin/runtime-plugin-support.bun"
import { tmpdir } from "./fixture/fixture"
test("an external TSX plugin uses the host Solid runtime", async () => {
await using tmp = await tmpdir()
const root = path.join(tmp.path, ".opencode")
const source = path.join(root, "plugins", "tui", "reactive.tsx")
const helper = path.join(root, "plugins", "tui", "signal.ts")
const localSolid = path.join(root, "node_modules", "solid-js")
const localOpenTui = path.join(root, "node_modules", "@opentui", "solid")
await Promise.all([
mkdir(path.dirname(source), { recursive: true }),
mkdir(localSolid, { recursive: true }),
mkdir(localOpenTui, { recursive: true }),
])
await Promise.all([
writeFile(
path.join(localSolid, "package.json"),
JSON.stringify({ name: "solid-js", type: "module", main: "index.js" }),
),
writeFile(
path.join(localSolid, "index.js"),
"export const createSignal = () => { throw new Error('local Solid used') }\n",
),
writeFile(
path.join(localOpenTui, "package.json"),
JSON.stringify({ name: "@opentui/solid", type: "module", main: "index.js" }),
),
writeFile(path.join(localOpenTui, "index.js"), "throw new Error('local OpenTUI used')\n"),
writeFile(helper, 'export { createSignal, onCleanup } from "solid-js"\n'),
writeFile(
source,
`
import { createSignal, onCleanup } from "./signal"
export const signal = createSignal
export default {
id: "test.reactive",
setup(context: any) {
context.ui.slot("home.footer", () => {
const [count, setCount] = createSignal(0)
const timer = setTimeout(() => setCount(1), 10)
onCleanup(() => clearTimeout(timer))
return <box><text>count:{count()}</text></box>
})
},
}
`,
),
])
const plugin = await import(await preparePlugin(new URL(`file://${source}`).href, `${source}?mtime=1`, tmp.path))
expect(plugin.signal).toBe(createSignal)
let slot: ((input: object) => JSX.Element) | undefined
await plugin.default.setup({ ui: { slot: (_name: string, render: typeof slot) => (slot = render) } })
if (!slot) throw new Error("Plugin did not register its slot")
const setup = await testRender(() => createComponent(slot!, {}), { width: 20, height: 2 })
try {
expect(await setup.waitForFrame((frame) => frame.includes("count:0"))).toContain("count:0")
expect(await setup.waitForFrame((frame) => frame.includes("count:1"))).toContain("count:1")
} finally {
setup.renderer.destroy()
}
})
@@ -27,15 +27,11 @@ test("down rejects at the newest history item with an empty prompt", async () =>
))
try {
await app.renderOnce()
history!.append("ses_one", { text: "previous", files: [], agents: [], pasted: [] })
history!.append({ text: "previous", files: [], agents: [], pasted: [] })
expect(history!.move("ses_one", 1, "")).toBeUndefined()
expect(history!.move("ses_one", -1, "")?.text).toBe("previous")
expect(history!.move("ses_one", 1, "previous")?.text).toBe("")
history!.append("ses_two", { text: "other", files: [], agents: [], pasted: [] })
expect(history!.move("ses_two", -1, "")?.text).toBe("other")
expect(history!.move("ses_one", -1, "")?.text).toBe("previous")
expect(history!.move(1, "")).toBeUndefined()
expect(history!.move(-1, "")?.text).toBe("previous")
expect(history!.move(1, "previous")?.text).toBe("")
} finally {
app.renderer.destroy()
}