feat(tui): expand v2 plugin context

This commit is contained in:
Dax Raad 2026-07-28 11:03:40 -04:00
commit 010133f6df
14 changed files with 545 additions and 204 deletions

View file

@ -4,6 +4,7 @@ import SidebarFooter from "../feature-plugins/sidebar/footer"
import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
import DiffViewer from "../feature-plugins/system/diff-viewer"
import Notifications from "../feature-plugins/system/notifications"
import Scrap from "../feature-plugins/system/scrap"
export const builtins = [
@ -12,6 +13,7 @@ export const builtins = [
SidebarMcp,
SidebarLsp,
SidebarFooter,
Notifications,
Scrap,
DiffViewer,
]

View file

@ -13,8 +13,9 @@ import {
import path from "path"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Page, Slot } from "@opencode-ai/plugin/tui/context"
import type { Context, Dialog, Page, Slot, Toast } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { useRenderer } from "@opentui/solid"
import { useConfig } from "../config"
import { useClient } from "../context/client"
import { useData } from "../context/data"
@ -22,6 +23,14 @@ import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiLifecycle } from "../context/runtime"
import { useLocation } from "../context/location"
import { useTheme } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention"
import { builtins } from "./builtins"
export interface PackageResolver {
@ -57,14 +66,20 @@ type Registration = {
const PluginContext = createContext<Value>()
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
const renderer = useRenderer()
const client = useClient()
const data = useData()
const route = useRoute()
const config = useConfig()
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
const keymapState = Keymap.useState()
const lifecycle = useTuiLifecycle()
const location = useLocation()
const theme = useTheme()
const dialog = useDialog()
const toast = useToast()
const attention = useAttention()
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
@ -82,20 +97,144 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
setStore("registrations", id, "cleanups", [])
})
const owned: Dispose[] = []
let activeDialog: symbol | undefined
const dialogApi: Dialog = {
show(render, onClose) {
const token = Symbol()
let closed = false
activeDialog = token
dialog.replace(render, () => {
if (closed) return
closed = true
if (activeDialog === token) activeDialog = undefined
onClose?.()
})
return () => {
if (closed || activeDialog !== token) return
dialog.clear()
}
},
set(options) {
if (!activeDialog) return
dialog.setSize(options.size ?? "medium")
dialog.setCentered(options.centered ?? false)
},
clear() {
if (!activeDialog) return
dialog.clear()
},
alert(options) {
return new Promise<void>((resolve) => {
let settled = false
const done = () => {
if (settled) return
settled = true
resolve()
}
dialogApi.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
})
},
confirm(options) {
return new Promise<boolean | undefined>((resolve) => {
let settled = false
const done = (result: boolean | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogConfirm
title={options.title}
message={options.message}
label={options.label}
onConfirm={() => done(true)}
onCancel={() => done(false)}
/>
),
() => done(undefined),
)
})
},
prompt(options) {
return new Promise<string | undefined>((resolve) => {
let settled = false
const done = (result: string | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogPrompt
title={options.title}
description={options.description ? () => <text>{options.description}</text> : undefined}
placeholder={options.placeholder}
value={options.value}
onConfirm={(value) => {
done(value)
dialogApi.clear()
}}
/>
),
() => done(undefined),
)
})
},
select(options) {
return new Promise((resolve) => {
let settled = false
const done = (result: (typeof options.options)[number]["value"] | undefined) => {
if (settled) return
settled = true
resolve(result)
}
dialogApi.show(
() => (
<DialogSelect
title={options.title}
placeholder={options.placeholder}
options={options.options.map((option) => ({ ...option }))}
current={options.current}
onSelect={(option) => {
done(option.value)
dialogApi.clear()
}}
/>
),
() => done(undefined),
)
})
},
}
const toastApi: Toast = {
show(options) {
toast.show({ ...options, variant: options.variant ?? "info" })
},
}
owned.push(async () => dialogApi.clear())
const context: Context = {
options: item.options ?? {},
get location() {
return location.current
},
renderer,
client: client.api,
data,
attention,
theme: theme.themeV2,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
shortcut: shortcuts.get,
shortcuts: shortcuts.list,
commands: keymapState.commands,
pending: keymapState.pending,
active: keymapState.active,
mode: keymap.mode,
},
ui: {
dialog: dialogApi,
toast: toastApi,
router: {
register(page) {
if (store.registrations[item.plugin.id]?.routes[page.name])