feat(tui): refine plugin context slots

This commit is contained in:
Dax Raad 2026-07-28 14:19:12 -04:00
commit 44cd984589
10 changed files with 65 additions and 48 deletions

View file

@ -113,7 +113,22 @@ export interface Page {
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
} }
export type Slot = (props: Record<string, any>) => JSX.Element export interface SlotMap {
readonly app: Readonly<Record<string, never>>
readonly "home.footer": Readonly<Record<string, never>>
readonly "sidebar.content": {
readonly sessionID: string
}
readonly "sidebar.footer": Readonly<Record<string, never>>
}
export type SlotName = keyof SlotMap
export type Slot<Name extends SlotName = SlotName> = (props: SlotMap[Name]) => JSX.Element
export interface App {
readonly version: string
readonly channel: string
}
export type ToastVariant = "info" | "success" | "warning" | "error" export type ToastVariant = "info" | "success" | "warning" | "error"
@ -308,17 +323,21 @@ export interface Keymap {
export interface UI { export interface UI {
readonly dialog: Dialog readonly dialog: Dialog
readonly toast: Toast readonly toast: Toast
readonly format: {
path(value: string): string
}
readonly router: { readonly router: {
register(page: Page): () => void register(page: Page): () => void
navigate(destination: Destination): void navigate(destination: Destination): void
current(): Route current(): Route
} }
readonly slot: (name: string, render: Slot) => () => void readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
} }
export interface Context { export interface Context {
readonly options: Readonly<Record<string, any>> readonly options: Readonly<Record<string, any>>
readonly location: LocationRef | undefined readonly location: LocationRef | undefined
readonly app: App
readonly renderer: CliRenderer readonly renderer: CliRenderer
readonly client: OpenCodeClient readonly client: OpenCodeClient
readonly data: Data readonly data: Data

View file

@ -1127,10 +1127,7 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match> </Match>
</Switch> </Switch>
</box> </box>
<box flexShrink={0}> <PluginSlot name="app" input={{}} mode="all" />
<PluginSlot name="app.bottom" />
</box>
<PluginSlot name="app" />
</Show> </Show>
</box> </box>
</box> </box>

View file

@ -1,20 +1,17 @@
import { Plugin } from "@opencode-ai/plugin/tui" import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js" import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { useTuiApp, useTuiPaths } from "../../context/runtime"
import { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path" import { FilePath } from "../../ui/file-path"
import { stringWidth } from "../../util/string-width" import { stringWidth } from "../../util/string-width"
function Directory(props: { context: Plugin.Context; maxWidth: number }) { function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const paths = useTuiPaths()
const directory = createMemo(() => const directory = createMemo(() =>
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined, props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
) )
return ( return (
<Show when={directory()}> <Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.text.subdued} />} {(value) => <FilePath value={value()} maxWidth={props.maxWidth} fg={props.context.theme.themeV2.text.subdued} />}
</Show> </Show>
) )
} }
@ -27,16 +24,18 @@ function Mcp(props: { context: Plugin.Context }) {
return ( return (
<Show when={list().length}> <Show when={list().length}>
<box gap={1} flexDirection="row" flexShrink={0}> <box gap={1} flexDirection="row" flexShrink={0}>
<text fg={props.context.theme.text.default}> <text fg={props.context.theme.themeV2.text.default}>
<Switch> <Switch>
<Match when={failed()}> <Match when={failed()}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span> <span style={{ fg: props.context.theme.themeV2.text.feedback.error.default }}> </span>
</Match> </Match>
<Match when={true}> <Match when={true}>
<span <span
style={{ style={{
fg: fg:
count() > 0 ? props.context.theme.text.feedback.success.default : props.context.theme.text.subdued, count() > 0
? props.context.theme.themeV2.text.feedback.success.default
: props.context.theme.themeV2.text.subdued,
}} }}
> >
{" "} {" "}
@ -45,14 +44,13 @@ function Mcp(props: { context: Plugin.Context }) {
</Switch> </Switch>
{count()} MCP {count()} MCP
</text> </text>
<text fg={props.context.theme.text.subdued}>/status</text> <text fg={props.context.theme.themeV2.text.subdued}>/status</text>
</box> </box>
</Show> </Show>
) )
} }
function View(props: { context: Plugin.Context }) { function View(props: { context: Plugin.Context }) {
const app = useTuiApp()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const mcpWidth = createMemo(() => { const mcpWidth = createMemo(() => {
const list = props.context.data.location.mcp.server.list(props.context.location) ?? [] const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
@ -74,12 +72,12 @@ function View(props: { context: Plugin.Context }) {
> >
<Directory <Directory
context={props.context} context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(app.version) - mcpWidth())} maxWidth={Math.max(2, dimensions().width - 8 - stringWidth(props.context.app.version) - mcpWidth())}
/> />
<Mcp context={props.context} /> <Mcp context={props.context} />
<box flexGrow={1} /> <box flexGrow={1} />
<box flexShrink={0}> <box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{app.version}</text> <text fg={props.context.theme.themeV2.text.subdued}>{props.context.app.version}</text>
</box> </box>
</box> </box>
) )

View file

@ -1,18 +1,15 @@
import { Plugin } from "@opencode-ai/plugin/tui" import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { useTuiPaths } from "../../context/runtime"
import { useTheme } from "../../context/theme"
import { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path" import { FilePath } from "../../ui/file-path"
function View(props: { context: Plugin.Context }) { function View(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme()
const paths = useTuiPaths()
const directory = createMemo(() => const directory = createMemo(() =>
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined, props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
) )
return ( return (
<Show when={directory()}>{(value) => <FilePath value={value()} maxWidth={38} fg={themeV2.text.subdued} />}</Show> <Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.themeV2.text.subdued} />}
</Show>
) )
} }

View file

@ -16,7 +16,6 @@ import path from "path"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { DiffViewerFileTree } from "./diff-viewer-file-tree" import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui" import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select" import { DialogSelect } from "../../ui/dialog-select"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config" import { useConfig } from "../../config"
@ -1051,7 +1050,6 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
} }
function Commands(props: { context: Plugin.Context }) { function Commands(props: { context: Plugin.Context }) {
const dialog = useDialog()
props.context.keymap.layer(() => ({ props.context.keymap.layer(() => ({
mode: "global", mode: "global",
commands: [ commands: [
@ -1083,7 +1081,7 @@ function Commands(props: { context: Plugin.Context }) {
returnRoute, returnRoute,
}, },
}) })
dialog.clear() props.context.ui.dialog.clear()
}, },
}, },
], ],

View file

@ -1,12 +1,9 @@
import { Plugin } from "@opencode-ai/plugin/tui" import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { Keymap } from "../../context/keymap"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
import { useDialog } from "../../ui/dialog"
function Commands(props: { context: Plugin.Context }) { function Commands(props: { context: Plugin.Context }) {
const dialog = useDialog() props.context.keymap.layer(() => ({
Keymap.createLayer(() => ({
mode: "global", mode: "global",
commands: [ commands: [
{ {
@ -16,7 +13,7 @@ function Commands(props: { context: Plugin.Context }) {
palette: true, palette: true,
run() { run() {
props.context.ui.router.navigate({ type: "plugin", name: "scrap" }) props.context.ui.router.navigate({ type: "plugin", name: "scrap" })
dialog.clear() props.context.ui.dialog.clear()
}, },
}, },
], ],
@ -29,7 +26,7 @@ function Scrap(props: { context: Plugin.Context }) {
const { themeV2 } = useTheme() const { themeV2 } = useTheme()
const { themeV2: elevatedTheme } = useTheme().contextual("elevated") const { themeV2: elevatedTheme } = useTheme().contextual("elevated")
Keymap.createLayer(() => ({ props.context.keymap.layer(() => ({
commands: [ commands: [
{ {
bind: "escape", bind: "escape",

View file

@ -13,7 +13,7 @@ import {
import path from "path" import path from "path"
import { stat } from "fs/promises" import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url" import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Dialog, Page, Slot, Toast } from "@opencode-ai/plugin/tui/context" import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store" import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { useRenderer } from "@opentui/solid" import { useRenderer } from "@opentui/solid"
import { useConfig } from "../config" import { useConfig } from "../config"
@ -21,7 +21,7 @@ import { useClient } from "../context/client"
import { useData } from "../context/data" import { useData } from "../context/data"
import { Keymap } from "../context/keymap" import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route" import { useRoute } from "../context/route"
import { useTuiLifecycle } from "../context/runtime" import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
import { useLocation } from "../context/location" import { useLocation } from "../context/location"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { DialogAlert } from "../ui/dialog-alert" import { DialogAlert } from "../ui/dialog-alert"
@ -31,6 +31,7 @@ import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { useAttention } from "../context/attention" import { useAttention } from "../context/attention"
import { abbreviateHome } from "../util/path-format"
import { builtins } from "./builtins" import { builtins } from "./builtins"
export interface PackageResolver { export interface PackageResolver {
@ -47,7 +48,7 @@ type Value = {
readonly ready: () => boolean readonly ready: () => boolean
readonly list: () => ReadonlyArray<State> readonly list: () => ReadonlyArray<State>
readonly route: (id: string, name: string) => Page["render"] | undefined readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: (name: string) => ReadonlyArray<Slot> readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
readonly activate: (id: string) => Promise<boolean> readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean> readonly deactivate: (id: string) => Promise<boolean>
} }
@ -75,6 +76,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const shortcuts = Keymap.useShortcuts() const shortcuts = Keymap.useShortcuts()
const keymapState = Keymap.useState() const keymapState = Keymap.useState()
const lifecycle = useTuiLifecycle() const lifecycle = useTuiLifecycle()
const app = useTuiApp()
const paths = useTuiPaths()
const location = useLocation() const location = useLocation()
const theme = useTheme() const theme = useTheme()
const dialog = useDialog() const dialog = useDialog()
@ -120,7 +123,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
dialog.setCentered(options.centered ?? false) dialog.setCentered(options.centered ?? false)
}, },
clear() { clear() {
if (!activeDialog) return
dialog.clear() dialog.clear()
}, },
alert(options) { alert(options) {
@ -218,11 +220,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
get location() { get location() {
return location.current return location.current
}, },
app: { version: app.version, channel: app.channel },
renderer, renderer,
client: client.api, client: client.api,
data, data,
attention, attention,
theme: theme.themeV2, theme,
keymap: { keymap: {
layer: Keymap.createLayer, layer: Keymap.createLayer,
dispatch: keymap.dispatch, dispatch: keymap.dispatch,
@ -235,6 +238,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
ui: { ui: {
dialog: dialogApi, dialog: dialogApi,
toast: toastApi, toast: toastApi,
format: {
path: (value) => abbreviateHome(value, paths.home),
},
router: { router: {
register(page) { register(page) {
if (store.registrations[item.plugin.id]?.routes[page.name]) if (store.registrations[item.plugin.id]?.routes[page.name])
@ -530,7 +536,16 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
return <>{content()}</> return <>{content()}</>
} }
export function PluginSlot(props: { readonly name: string; readonly input?: Record<string, any> }) { export function PluginSlot<Name extends SlotName>(props: {
readonly name: Name
readonly input: SlotMap[Name]
readonly mode: "all" | "replace"
}) {
const plugins = usePlugin() const plugins = usePlugin()
return <For each={plugins.slot(props.name)}>{(render) => render(props.input ?? {})}</For> const renderers = createMemo(() => {
const items = plugins.slot(props.name)
if (props.mode === "replace") return items.slice(-1)
return items
})
return <For each={renderers()}>{(render) => render(props.input)}</For>
} }

View file

@ -85,11 +85,10 @@ export function Home() {
/> />
</pluginRuntime.Slot> </pluginRuntime.Slot>
</box> </box>
<PluginSlot name="home.bottom" />
<box flexGrow={1} minHeight={0} /> <box flexGrow={1} minHeight={0} />
</box> </box>
<box width="100%" flexShrink={0}> <box width="100%" flexShrink={0}>
<PluginSlot name="home.footer" /> <PluginSlot name="home.footer" input={{}} mode="replace" />
</box> </box>
<Show when={forms()[0]?.id} keyed> <Show when={forms()[0]?.id} keyed>
{(_) => { {(_) => {

View file

@ -77,7 +77,6 @@ import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../con
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { collapseToolOutput } from "../../util/collapse-tool-output" import { collapseToolOutput } from "../../util/collapse-tool-output"
import { usePluginRuntime } from "../../plugin/runtime" import { usePluginRuntime } from "../../plugin/runtime"
import { PluginSlot } from "../../plugin/context"
import { Keymap, type KeymapCommand } from "../../context/keymap" import { Keymap, type KeymapCommand } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format" import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location" import { useLocation } from "../../context/location"
@ -915,7 +914,6 @@ export function Session() {
> >
<box flexDirection="row" flexGrow={1} minHeight={0}> <box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}> <box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<PluginSlot name="session.header" input={{ sessionID: route.sessionID }} />
<Show when={session()}> <Show when={session()}>
<scrollbox <scrollbox
ref={(r) => (scroll = r)} ref={(r) => (scroll = r)}
@ -960,7 +958,6 @@ export function Session() {
</Show> </Show>
</scrollbox> </scrollbox>
<box flexShrink={0}> <box flexShrink={0}>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} />
<Composer <Composer
sessionID={route.sessionID} sessionID={route.sessionID}
open={composer.open || (!!session()?.parentID && forms().length === 0)} open={composer.open || (!!session()?.parentID && forms().length === 0)}

View file

@ -53,12 +53,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</Show> </Show>
</box> </box>
</pluginRuntime.Slot> </pluginRuntime.Slot>
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} /> <PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
</box> </box>
</scrollbox> </scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}> <box flexShrink={0} gap={1} paddingTop={1}>
<PluginSlot name="sidebar.footer" /> <PluginSlot name="sidebar.footer" input={{}} mode="replace" />
</box> </box>
</box> </box>
</Show> </Show>