STASH COMPLEX PLUGIN

actual-tui-plugins
Sebastian Herrlinger 2026-03-05 01:39:32 +01:00
parent 3ac74b51f5
commit 836e0dbc55
8 changed files with 480 additions and 41 deletions

View File

@ -1,29 +1,238 @@
/** @jsxImportSource @opentui/solid */
import mytheme from "../themes/mytheme.json" with { type: "json" }
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import type { TuiApi, TuiPluginInput } from "@opencode-ai/plugin/tui"
const slot = (label) => ({
const pick = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback
if (!value.trim()) return fallback
return value
}
const cfg = (options: Record<string, unknown> | undefined) => {
return {
label: pick(options?.label, "smoke"),
modal: pick(options?.modal, "ctrl+shift+m"),
screen: pick(options?.screen, "ctrl+shift+o"),
route: pick(options?.route, "workspace-smoke"),
}
}
const ui = {
panel: "#1d1d1d",
border: "#4a4a4a",
text: "#f0f0f0",
muted: "#a5a5a5",
accent: "#5f87ff",
}
const active = (api: TuiApi, id: string) => {
const route = api.route.data
return route.type === "plugin" && route.id === id
}
const open = (api: TuiApi, input: ReturnType<typeof cfg>, source: string) => {
console.log("[smoke] open", { route: input.route, source })
api.route.plugin(input.route, { source })
api.dialog.clear()
}
const Modal = (props: { api: TuiApi; input: ReturnType<typeof cfg> }) => {
const dim = useTerminalDimensions()
useKeyboard((evt) => {
if (evt.defaultPrevented) return
if (evt.name !== "return") return
console.log("[smoke] modal key", { key: evt.name })
evt.preventDefault()
evt.stopPropagation()
open(props.api, props.input, "modal")
})
return (
<box
position="absolute"
top={0}
left={0}
width={dim().width}
height={dim().height}
alignItems="center"
paddingTop={Math.max(3, Math.floor(dim().height / 4))}
backgroundColor="#000000"
>
<box width={64} maxWidth={dim().width - 4} backgroundColor={ui.panel} border borderColor={ui.border}>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<text fg={ui.text}>
<b>{props.input.label} modal</b>
</text>
<text fg={ui.muted}>Plugin commands and keybinds work without host internals</text>
<text fg={ui.muted}>
{props.api.keybind.print(props.input.modal)} open modal · {props.api.keybind.print(props.input.screen)} open
screen
</text>
<text fg={ui.muted}>enter opens screen · esc closes</text>
<box flexDirection="row" gap={1}>
<box
onMouseUp={() => open(props.api, props.input, "modal")}
backgroundColor={ui.accent}
paddingLeft={1}
paddingRight={1}
>
<text fg={ui.text}>open screen</text>
</box>
<box
onMouseUp={() => props.api.dialog.clear()}
backgroundColor={ui.border}
paddingLeft={1}
paddingRight={1}
>
<text fg={ui.text}>cancel</text>
</box>
</box>
</box>
</box>
</box>
)
}
const Screen = (props: { api: TuiApi; input: ReturnType<typeof cfg>; data?: Record<string, unknown> }) => {
const dim = useTerminalDimensions()
useKeyboard((evt) => {
if (evt.defaultPrevented) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "h")) {
console.log("[smoke] screen key", { key: evt.name, ctrl: evt.ctrl })
evt.preventDefault()
evt.stopPropagation()
props.api.route.home()
return
}
if (evt.ctrl && evt.name === "m") {
console.log("[smoke] screen key", { key: evt.name, ctrl: evt.ctrl })
evt.preventDefault()
evt.stopPropagation()
props.api.dialog.replace(() => <Modal api={props.api} input={props.input} />)
}
})
return (
<box width={dim().width} height={dim().height} backgroundColor={ui.panel}>
<box
flexDirection="column"
width="100%"
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
>
<text fg={ui.text}>
<b>{props.input.label} screen</b>
<span style={{ fg: ui.muted }}> plugin route</span>
</text>
<text fg={ui.text}>Route id: {props.input.route}</text>
<text fg={ui.muted}>source: {String(props.data?.source ?? "unknown")}</text>
<text fg={ui.muted}>esc or ctrl+h go home · ctrl+m opens modal</text>
<box flexDirection="row" gap={1}>
<box onMouseUp={() => props.api.route.home()} backgroundColor={ui.border} paddingLeft={1} paddingRight={1}>
<text fg={ui.text}>go home</text>
</box>
<box
onMouseUp={() => props.api.dialog.replace(() => <Modal api={props.api} input={props.input} />)}
backgroundColor={ui.accent}
paddingLeft={1}
paddingRight={1}
>
<text fg={ui.text}>open modal</text>
</box>
</box>
</box>
</box>
)
}
const slot = (api: TuiApi, input: ReturnType<typeof cfg>) => ({
id: "workspace-smoke",
slots: {
home_logo() {
return <text>plugin logo:{label}</text>
route(_ctx, value) {
if (value.route_id !== input.route) return null
console.log("[smoke] route render", { route: value.route_id, data: value.data })
return <Screen api={api} input={input} data={value.data} />
},
sidebar_top(_ctx, props) {
home_logo() {
return <text>plugin logo:{input.label}</text>
},
sidebar_top(_ctx, value) {
return (
<text>
plugin:{label} session:{props.session_id.slice(0, 8)}
plugin:{input.label} session:{value.session_id.slice(0, 8)}
</text>
)
},
},
})
const reg = (api: TuiApi, input: ReturnType<typeof cfg>) => {
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: input.modal,
category: "Plugin",
slash: {
name: "smoke",
},
onSelect: () => {
console.log("[smoke] command", { value: "plugin.smoke.modal" })
api.dialog.replace(() => <Modal api={api} input={input} />)
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: input.screen,
category: "Plugin",
slash: {
name: "smoke-screen",
},
onSelect: () => {
console.log("[smoke] command", { value: "plugin.smoke.screen" })
open(api, input, "command")
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: active(api, input.route),
onSelect: () => {
console.log("[smoke] command", { value: "plugin.smoke.home" })
api.route.home()
api.dialog.clear()
},
},
])
}
const themes = {
"workspace-plugin-smoke": mytheme,
}
const tui = async (input, options) => {
const tui = async (input: TuiPluginInput, options?: Record<string, unknown>) => {
if (options?.enabled === false) return
const label = typeof options?.label === "string" ? options.label : "smoke"
input.slots.register(slot(label))
const value = cfg(options)
console.log("[smoke] init", {
label: value.label,
modal: value.modal,
screen: value.screen,
route: value.route,
})
reg(input.api, value)
input.slots.register(slot(input.api, value))
}
export default {

View File

@ -0,0 +1,34 @@
[01:37:30] [LOG] 'bootstrapping'
[01:37:30] [LOG] 'resolveSystemTheme'
[01:37:30] [LOG] '[smoke] init' {
label: 'workspace',
modal: 'ctrl+shift+m',
screen: 'ctrl+shift+o',
route: 'workspace-smoke'
}
[01:37:30] [LOG] [
'#45475a', '#f38ba8',
'#a6e3a1', '#f9e2af',
'#89b4fa', '#f5c2e7',
'#94e2d5', '#a6adc8',
'#585b70', '#f37799',
'#89d88b', '#ebd391',
'#74a8fc', '#f2aede',
'#6bd7ca', '#bac2de'
]
[01:37:40] [LOG] '[smoke] command' { value: 'plugin.smoke.modal' }
[01:37:46] [LOG] '[smoke] command' { value: 'plugin.smoke.modal' }
[01:37:54] [LOG] '[smoke] command' { value: 'plugin.smoke.screen' }
[01:37:54] [LOG] '[smoke] open' { route: 'workspace-smoke', source: 'command' }
[01:37:54] [LOG] '[smoke] route render' { route: 'workspace-smoke', data: { source: 'command' } }
[01:37:54] [LOG] '[smoke] route render' { route: 'workspace-smoke', data: { source: 'command' } }
[01:38:04] [LOG] '[smoke] command' { value: 'plugin.smoke.screen' }
[01:38:04] [LOG] '[smoke] open' { route: 'workspace-smoke', source: 'command' }
[01:38:04] [LOG] '[smoke] route render' { route: 'workspace-smoke', data: { source: 'command' } }
[01:38:04] [LOG] '[smoke] route render' { route: 'workspace-smoke', data: { source: 'command' } }
[01:38:08] [LOG] '[smoke] screen key' { key: 'escape', ctrl: false }
[01:38:30] [LOG] '[smoke] command' { value: 'plugin.smoke.modal' }
[01:38:33] [LOG] '[smoke] command' { value: 'plugin.smoke.modal' }
[01:38:34] [LOG] '[smoke] open' { route: 'workspace-smoke', source: 'modal' }
[01:38:34] [LOG] '[smoke] route render' { route: 'workspace-smoke', data: { source: 'modal' } }
[01:38:34] [LOG] '[smoke] route render' { route: 'workspace-smoke', data: { source: 'modal' } }

View File

@ -1,7 +1,7 @@
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { Selection } from "@tui/util/selection"
import { createCliRenderer, MouseButton, TextAttributes, type CliRendererConfig } from "@opentui/core"
import { createCliRenderer, MouseButton, TextAttributes, type CliRendererConfig, type ParsedKey } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
@ -21,7 +21,7 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { KeybindProvider } from "@tui/context/keybind"
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
@ -41,6 +41,7 @@ import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
import type { TuiApi, TuiRoute } from "@opencode-ai/plugin/tui"
import { TuiPlugin } from "./plugin"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
@ -212,19 +213,72 @@ function App() {
const local = useLocal()
const kv = useKV()
const command = useCommandDialog()
const keybind = useKeybind()
const sdk = useSDK()
TuiPlugin.init({
client: sdk.client,
event: sdk.event,
renderer,
}).catch((error) => {
console.error("Failed to load TUI plugins", error)
})
const toast = useToast()
const { theme, mode, setMode } = useTheme()
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
const api: TuiApi = {
command: {
register(cb) {
command.register(() => cb())
},
trigger(value) {
command.trigger(value)
},
},
dialog: {
clear() {
dialog.clear()
},
replace(input, onClose) {
dialog.replace(input, onClose)
},
get depth() {
return dialog.stack.length
},
},
route: {
get data() {
return route.data
},
navigate(next: TuiRoute) {
route.navigate(next)
},
home() {
route.navigate({ type: "home" })
},
plugin(id, data) {
route.navigate({ type: "plugin", id, data })
},
},
keybind: {
parse(evt: ParsedKey) {
return keybind.parse(evt)
},
match(key, evt: ParsedKey) {
return keybind.match(key, evt)
},
print(key) {
return keybind.print(key)
},
},
theme: {
get current() {
return theme
},
},
}
TuiPlugin.init({
client: sdk.client,
event: sdk.event,
renderer,
api,
}).catch((error) => {
console.error("Failed to load TUI plugins", error)
})
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
@ -267,10 +321,6 @@ function App() {
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
createEffect(() => {
console.log(JSON.stringify(route.data))
})
// Update terminal window title based on current route and session
createEffect(() => {
if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
@ -287,9 +337,13 @@ function App() {
return
}
// Truncate title to 40 chars max
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
renderer.setTerminalTitle(`OC | ${title}`)
return
}
if (route.data.type === "plugin") {
renderer.setTerminalTitle(`OC | ${route.data.id}`)
}
})
@ -760,6 +814,11 @@ function App() {
})
})
const plugin = () => {
if (route.data.type !== "plugin") return
return route.data
}
return (
<box
width={dimensions().width}
@ -783,6 +842,27 @@ function App() {
<Session />
</Match>
</Switch>
<Show when={plugin()}>
{(item) => (
<TuiPlugin.Slot name="route" mode="replace" route_id={item().id} data={item().data}>
<PluginRouteMissing id={item().id} onHome={() => route.navigate({ type: "home" })} />
</TuiPlugin.Slot>
)}
</Show>
<TuiPlugin.Slot name="app" />
</box>
)
}
function PluginRouteMissing(props: { id: string; onHome: () => void }) {
const { theme } = useTheme()
return (
<box width="100%" height="100%" alignItems="center" justifyContent="center" flexDirection="column" gap={1}>
<text fg={theme.warning}>Unknown plugin route: {props.id}</text>
<box onMouseUp={props.onHome} backgroundColor={theme.backgroundElement} paddingLeft={1} paddingRight={1}>
<text fg={theme.text}>go home</text>
</box>
</box>
)
}

View File

@ -10,7 +10,7 @@ import {
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { type KeybindKey, useKeybind } from "@tui/context/keybind"
import { useKeybind } from "@tui/context/keybind"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
@ -21,7 +21,7 @@ export type Slash = {
}
export type CommandOption = DialogSelectOption<string> & {
keybind?: KeybindKey
keybind?: string
suggested?: boolean
slash?: Slash
hidden?: boolean

View File

@ -80,21 +80,24 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
}
return Keybind.fromParsedKey(evt, store.leader)
},
match(key: KeybindKey, evt: ParsedKey) {
const keybind = keybinds()[key]
if (!keybind) return false
match(key: string, evt: ParsedKey) {
const list = keybinds()[key] ?? Keybind.parse(key)
if (!list.length) return false
const parsed: Keybind.Info = result.parse(evt)
for (const key of keybind) {
if (Keybind.match(key, parsed)) {
for (const item of list) {
if (Keybind.match(item, parsed)) {
return true
}
}
return false
},
print(key: KeybindKey) {
const first = keybinds()[key]?.at(0)
print(key: string) {
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
if (!first) return ""
const result = Keybind.toString(first)
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
const text = Keybind.toString(first)
const lead = keybinds().leader?.[0]
if (!lead) return text
return text.replace("<leader>", Keybind.toString(lead))
},
}
return result

View File

@ -13,7 +13,13 @@ export type SessionRoute = {
initialPrompt?: PromptInfo
}
export type Route = HomeRoute | SessionRoute
export type PluginRoute = {
type: "plugin"
id: string
data?: Record<string, unknown>
}
export type Route = HomeRoute | SessionRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
@ -31,7 +37,6 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store
},
navigate(route: Route) {
console.log("navigate", route)
setStore(route)
},
}

View File

@ -97,6 +97,43 @@ test("ignores function-only tui exports and loads object exports", async () => {
on: () => () => {},
},
renderer,
api: {
command: {
register: () => {},
trigger: () => {},
},
dialog: {
clear: () => {},
replace: () => {},
get depth() {
return 0
},
},
route: {
get data() {
return { type: "home" as const }
},
navigate: () => {},
home: () => {},
plugin: () => {},
},
keybind: {
parse: () => ({
name: "",
ctrl: false,
meta: false,
shift: false,
leader: false,
}),
match: () => false,
print: () => "",
},
theme: {
get current() {
return {}
},
},
},
})
expect(await fs.readFile(tmp.extra.objMarker, "utf8")).toBe("called")

View File

@ -1,5 +1,5 @@
import type { createOpencodeClient as createOpencodeClientV2, Event as TuiEvent } from "@opencode-ai/sdk/v2"
import type { CliRenderer, Plugin as CorePlugin } from "@opentui/core"
import type { CliRenderer, ParsedKey, Plugin as CorePlugin } from "@opentui/core"
import type { Plugin as ServerPlugin, PluginOptions } from "./index"
export type { CliRenderer, SlotMode } from "@opentui/core"
@ -22,7 +22,77 @@ export type ThemeJson = {
}
}
export type TuiRoute =
| {
type: "home"
}
| {
type: "session"
sessionID: string
}
| {
type: "plugin"
id: string
data?: Record<string, unknown>
}
export type TuiCommand = {
title: string
value: string
description?: string
category?: string
keybind?: string
suggested?: boolean
hidden?: boolean
enabled?: boolean
slash?: {
name: string
aliases?: string[]
}
onSelect?: () => void
}
export type TuiKeybind = {
name: string
ctrl: boolean
meta: boolean
shift: boolean
super?: boolean
leader: boolean
}
export type TuiApi<Node = unknown> = {
command: {
register: (cb: () => TuiCommand[]) => void
trigger: (value: string) => void
}
dialog: {
clear: () => void
replace: (input: Node | (() => Node), onClose?: () => void) => void
readonly depth: number
}
route: {
readonly data: TuiRoute
navigate: (route: TuiRoute) => void
home: () => void
plugin: (id: string, data?: Record<string, unknown>) => void
}
keybind: {
parse: (evt: ParsedKey) => TuiKeybind
match: (key: string, evt: ParsedKey) => boolean
print: (key: string) => string
}
theme: {
readonly current: Record<string, unknown>
}
}
export type TuiSlotMap = {
app: {}
route: {
route_id: string
data?: Record<string, unknown>
}
home_logo: {}
sidebar_top: {
session_id: string
@ -44,21 +114,22 @@ export type TuiEventBus = {
) => () => void
}
export type TuiPluginInput<Renderer = CliRenderer> = {
export type TuiPluginInput<Renderer = CliRenderer, Node = unknown> = {
client: ReturnType<typeof createOpencodeClientV2>
event: TuiEventBus
renderer: Renderer
slots: TuiSlots
api: TuiApi<Node>
}
export type TuiPlugin<Renderer = CliRenderer> = (
input: TuiPluginInput<Renderer>,
export type TuiPlugin<Renderer = CliRenderer, Node = unknown> = (
input: TuiPluginInput<Renderer, Node>,
options?: PluginOptions,
) => Promise<void>
export type TuiPluginModule<Renderer = CliRenderer> = {
export type TuiPluginModule<Renderer = CliRenderer, Node = unknown> = {
server?: ServerPlugin
tui?: TuiPlugin<Renderer>
tui?: TuiPlugin<Renderer, Node>
slots?: TuiSlotPlugin
themes?: Record<string, ThemeJson>
}