From 836e0dbc55bd7c2872a505d31f67bc01c0e09f5f Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Thu, 5 Mar 2026 01:39:32 +0100 Subject: [PATCH] STASH COMPLEX PLUGIN --- .opencode/plugins/tui-smoke.tsx | 225 +++++++++++++++++- packages/opencode/_console_1772671132875.log | 34 +++ packages/opencode/src/cli/cmd/tui/app.tsx | 108 +++++++-- .../cli/cmd/tui/component/dialog-command.tsx | 4 +- .../src/cli/cmd/tui/context/keybind.tsx | 21 +- .../src/cli/cmd/tui/context/route.tsx | 9 +- .../test/cli/tui/plugin-loader.test.ts | 37 +++ packages/plugin/src/tui.ts | 83 ++++++- 8 files changed, 480 insertions(+), 41 deletions(-) create mode 100644 packages/opencode/_console_1772671132875.log diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index cac60db45c..85d12343a3 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.opencode/plugins/tui-smoke.tsx @@ -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 | 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, 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 }) => { + 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 ( + + + + + {props.input.label} modal + + Plugin commands and keybinds work without host internals + + {props.api.keybind.print(props.input.modal)} open modal · {props.api.keybind.print(props.input.screen)} open + screen + + enter opens screen · esc closes + + open(props.api, props.input, "modal")} + backgroundColor={ui.accent} + paddingLeft={1} + paddingRight={1} + > + open screen + + props.api.dialog.clear()} + backgroundColor={ui.border} + paddingLeft={1} + paddingRight={1} + > + cancel + + + + + + ) +} + +const Screen = (props: { api: TuiApi; input: ReturnType; data?: Record }) => { + 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(() => ) + } + }) + + return ( + + + + {props.input.label} screen + plugin route + + Route id: {props.input.route} + source: {String(props.data?.source ?? "unknown")} + esc or ctrl+h go home · ctrl+m opens modal + + props.api.route.home()} backgroundColor={ui.border} paddingLeft={1} paddingRight={1}> + go home + + props.api.dialog.replace(() => )} + backgroundColor={ui.accent} + paddingLeft={1} + paddingRight={1} + > + open modal + + + + + ) +} + +const slot = (api: TuiApi, input: ReturnType) => ({ id: "workspace-smoke", slots: { - home_logo() { - return plugin logo:{label} + route(_ctx, value) { + if (value.route_id !== input.route) return null + console.log("[smoke] route render", { route: value.route_id, data: value.data }) + return }, - sidebar_top(_ctx, props) { + home_logo() { + return plugin logo:{input.label} + }, + sidebar_top(_ctx, value) { return ( - plugin:{label} session:{props.session_id.slice(0, 8)} + plugin:{input.label} session:{value.session_id.slice(0, 8)} ) }, }, }) +const reg = (api: TuiApi, input: ReturnType) => { + 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(() => ) + }, + }, + { + 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) => { 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 { diff --git a/packages/opencode/_console_1772671132875.log b/packages/opencode/_console_1772671132875.log new file mode 100644 index 0000000000..3b612e4e0d --- /dev/null +++ b/packages/opencode/_console_1772671132875.log @@ -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' } } \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 9cfda07852..06ea84868c 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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 ( + + {(item) => ( + + route.navigate({ type: "home" })} /> + + )} + + + + ) +} + +function PluginRouteMissing(props: { id: string; onHome: () => void }) { + const { theme } = useTheme() + + return ( + + Unknown plugin route: {props.id} + + go home + ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index be031296e9..ea6fb30984 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -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 const ctx = createContext() @@ -21,7 +21,7 @@ export type Slash = { } export type CommandOption = DialogSelectOption & { - keybind?: KeybindKey + keybind?: string suggested?: boolean slash?: Slash hidden?: boolean diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 566d66ade5..8d3fe487d1 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -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("", Keybind.toString(keybinds().leader![0]!)) + const text = Keybind.toString(first) + const lead = keybinds().leader?.[0] + if (!lead) return text + return text.replace("", Keybind.toString(lead)) }, } return result diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index 358461921b..e9f463a13f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -13,7 +13,13 @@ export type SessionRoute = { initialPrompt?: PromptInfo } -export type Route = HomeRoute | SessionRoute +export type PluginRoute = { + type: "plugin" + id: string + data?: Record +} + +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) }, } diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index c425a91c42..3cd0654786 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -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") diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 13716a846b..3703a231ed 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -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 + } + +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 = { + 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) => void + } + keybind: { + parse: (evt: ParsedKey) => TuiKeybind + match: (key: string, evt: ParsedKey) => boolean + print: (key: string) => string + } + theme: { + readonly current: Record + } +} + export type TuiSlotMap = { + app: {} + route: { + route_id: string + data?: Record + } home_logo: {} sidebar_top: { session_id: string @@ -44,21 +114,22 @@ export type TuiEventBus = { ) => () => void } -export type TuiPluginInput = { +export type TuiPluginInput = { client: ReturnType event: TuiEventBus renderer: Renderer slots: TuiSlots + api: TuiApi } -export type TuiPlugin = ( - input: TuiPluginInput, +export type TuiPlugin = ( + input: TuiPluginInput, options?: PluginOptions, ) => Promise -export type TuiPluginModule = { +export type TuiPluginModule = { server?: ServerPlugin - tui?: TuiPlugin + tui?: TuiPlugin slots?: TuiSlotPlugin themes?: Record }