diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index 29c4f2c6c2..9bc0459cd8 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.opencode/plugins/tui-smoke.tsx @@ -20,6 +20,13 @@ const cfg = (options: Record | undefined) => { } } +const names = (input: ReturnType) => { + return { + modal: `${input.route}.modal`, + screen: `${input.route}.screen`, + } +} + const ui = { panel: "#1d1d1d", border: "#4a4a4a", @@ -28,132 +35,118 @@ const ui = { accent: "#5f87ff", } -const parse = (data: Record | undefined) => { - const tab = typeof data?.tab === "number" ? data.tab : 0 - const count = typeof data?.count === "number" ? data.count : 0 - const source = typeof data?.source === "string" ? data.source : "unknown" +const parse = (params: Record | undefined) => { + const tab = typeof params?.tab === "number" ? params.tab : 0 + const count = typeof params?.count === "number" ? params.count : 0 + const source = typeof params?.source === "string" ? params.source : "unknown" return { - tab, + tab: Math.max(0, Math.min(tab, tabs.length - 1)), count, source, } } -const active = (api: TuiApi, id: string) => { - const route = api.route.data - return route.type === "plugin" && route.id === id +const current = (api: TuiApi, route: ReturnType) => { + const value = api.route.current + if (value.name !== route.screen && value.name !== route.modal) return parse(undefined) + if (!("params" in value)) return parse(undefined) + return parse(value.params) } -const merge = (api: TuiApi, patch: Record) => { - const route = api.route.data - if (route.type !== "plugin") return patch - return { ...(route.data ?? {}), ...patch } +const nav = (api: TuiApi, name: string, params: Record | undefined, from: string) => { + console.log("[smoke] nav", { + from, + to: name, + params, + current: api.route.current, + }) + api.route.navigate(name, params) } -const open = (api: TuiApi, input: ReturnType, source: string) => { - console.log("[smoke] open", { route: input.route, source }) - api.route.plugin(input.route, merge(api, { source })) - api.dialog.clear() +const key = (api: TuiApi, where: string, evt: any) => { + console.log("[smoke] key", { + where, + current: api.route.current.name, + name: evt.name, + ctrl: evt.ctrl, + meta: evt.meta, + shift: evt.shift, + leader: evt.leader, + defaultPrevented: evt.defaultPrevented, + eventType: evt.eventType, + }) } -const patch = (api: TuiApi, input: ReturnType, value: Record) => { - api.route.plugin(input.route, merge(api, value)) -} - -const Modal = (props: { api: TuiApi; input: ReturnType }) => { +const Probe = (props: { api: TuiApi; route: ReturnType }) => { 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") + const name = props.api.route.current.name + if (name !== props.route.screen && name !== props.route.modal) return + key(props.api, "probe", evt) }) - 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 - - - - ) + return null } -const Screen = (props: { api: TuiApi; input: ReturnType; data?: Record }) => { +const Screen = (props: { + api: TuiApi + input: ReturnType + route: ReturnType + params?: Record +}) => { const dim = useTerminalDimensions() - const value = parse(props.data) + const value = parse(props.params) + + console.log("[smoke] render", { + view: "screen", + current: props.api.route.current, + params: props.params, + }) useKeyboard((evt) => { - if (evt.defaultPrevented) return - if (!active(props.api, props.input.route)) return + key(props.api, "screen", evt) + if (props.api.route.current.name !== props.route.screen) return - const state = parse(props.api.route.data.type === "plugin" ? props.api.route.data.data : undefined) + const next = current(props.api, props.route) 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() + nav(props.api, "home", undefined, "screen:escape") return } - if (evt.name === "left") { - console.log("[smoke] screen key", { key: evt.name }) + if (evt.name === "left" || evt.name === "h") { evt.preventDefault() evt.stopPropagation() - patch(props.api, props.input, { tab: (state.tab - 1 + tabs.length) % tabs.length }) + nav(props.api, props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length }, "screen:left") return } - if (evt.name === "right") { - console.log("[smoke] screen key", { key: evt.name }) + if (evt.name === "right" || evt.name === "l") { evt.preventDefault() evt.stopPropagation() - patch(props.api, props.input, { tab: (state.tab + 1) % tabs.length }) + nav(props.api, props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length }, "screen:right") return } - if (evt.name === "up" || (evt.ctrl && evt.name === "up")) { - console.log("[smoke] screen key", { key: evt.name, ctrl: evt.ctrl }) + if (evt.name === "up" || evt.name === "k") { evt.preventDefault() evt.stopPropagation() - patch(props.api, props.input, { count: state.count + 1 }) + nav(props.api, props.route.screen, { ...next, count: next.count + 1 }, "screen:up") return } - if (evt.name === "down" || (evt.ctrl && evt.name === "down")) { - console.log("[smoke] screen key", { key: evt.name, ctrl: evt.ctrl }) + if (evt.name === "down" || evt.name === "j") { evt.preventDefault() evt.stopPropagation() - patch(props.api, props.input, { count: state.count - 1 }) + nav(props.api, props.route.screen, { ...next, count: next.count - 1 }, "screen:down") 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(() => ) + nav(props.api, props.route.modal, next, "screen:ctrl+m") } }) @@ -181,7 +174,7 @@ const Screen = (props: { api: TuiApi; input: ReturnType; data?: Reco const on = value.tab === i return ( patch(props.api, props.input, { tab: i })} + onMouseUp={() => nav(props.api, props.route.screen, { ...value, tab: i }, "screen:click-tab")} backgroundColor={on ? ui.accent : ui.border} paddingLeft={1} paddingRight={1} @@ -203,32 +196,16 @@ const Screen = (props: { api: TuiApi; input: ReturnType; data?: Reco > {value.tab === 0 ? ( - Route id: {props.input.route} + Route: {props.route.screen} source: {value.source} - left/right switch tabs + left/right or h/l switch tabs ) : null} {value.tab === 1 ? ( Counter: {value.count} - ctrl+up and ctrl+down change value - - patch(props.api, props.input, { count: value.count + 1 })} - backgroundColor={ui.border} - paddingLeft={1} - > - +1 - - patch(props.api, props.input, { count: value.count - 1 })} - backgroundColor={ui.border} - paddingLeft={1} - > - -1 - - + up/down or j/k change value ) : null} @@ -241,11 +218,16 @@ const Screen = (props: { api: TuiApi; input: ReturnType; data?: Reco - props.api.route.home()} backgroundColor={ui.border} paddingLeft={1} paddingRight={1}> + nav(props.api, "home", undefined, "screen:click-home")} + backgroundColor={ui.border} + paddingLeft={1} + paddingRight={1} + > go home props.api.dialog.replace(() => )} + onMouseUp={() => nav(props.api, props.route.modal, value, "screen:click-modal")} backgroundColor={ui.accent} paddingLeft={1} paddingRight={1} @@ -258,13 +240,78 @@ const Screen = (props: { api: TuiApi; input: ReturnType; data?: Reco ) } -const slot = (api: TuiApi, input: ReturnType) => ({ +const Modal = (props: { + api: TuiApi + input: ReturnType + route: ReturnType + params?: Record +}) => { + const Dialog = props.api.ui.Dialog + const value = parse(props.params) + + console.log("[smoke] render", { + view: "modal", + current: props.api.route.current, + params: props.params, + }) + + useKeyboard((evt) => { + key(props.api, "modal", evt) + if (props.api.route.current.name !== props.route.modal) return + + if (evt.name === "return" || evt.name === "enter") { + evt.preventDefault() + evt.stopPropagation() + nav(props.api, props.route.screen, { ...value, source: "modal" }, "modal:enter") + return + } + + if (evt.name === "escape") { + evt.preventDefault() + evt.stopPropagation() + nav(props.api, "home", undefined, "modal:escape") + } + }) + + return ( + + nav(props.api, "home", undefined, "modal:onClose")}> + + + {props.input.label} modal + + {props.api.keybind.print(props.input.modal)} modal command + {props.api.keybind.print(props.input.screen)} screen command + enter opens screen · esc closes + + nav(props.api, props.route.screen, { ...value, source: "modal" }, "modal:click-open")} + backgroundColor={ui.accent} + paddingLeft={1} + paddingRight={1} + > + open screen + + nav(props.api, "home", undefined, "modal:click-cancel")} + backgroundColor={ui.border} + paddingLeft={1} + paddingRight={1} + > + cancel + + + + + + ) +} + +const slot = (api: TuiApi, input: ReturnType, route: ReturnType) => ({ id: "workspace-smoke", slots: { - route(_ctx, value) { - if (value.route_id !== input.route) return null - console.log("[smoke] route render", { route: value.route_id, data: value.data }) - return + app() { + return }, home_logo() { return plugin logo:{input.label} @@ -280,6 +327,7 @@ const slot = (api: TuiApi, input: ReturnType) => ({ }) const reg = (api: TuiApi, input: ReturnType) => { + const route = names(input) api.command.register(() => [ { title: `${input.label} modal`, @@ -290,8 +338,8 @@ const reg = (api: TuiApi, input: ReturnType) => { name: "smoke", }, onSelect: () => { - console.log("[smoke] command", { value: "plugin.smoke.modal" }) - api.dialog.replace(() => ) + console.log("[smoke] command", { value: "plugin.smoke.modal", current: api.route.current }) + nav(api, route.modal, { source: "command" }, "command:modal") }, }, { @@ -303,19 +351,32 @@ const reg = (api: TuiApi, input: ReturnType) => { name: "smoke-screen", }, onSelect: () => { - console.log("[smoke] command", { value: "plugin.smoke.screen" }) - open(api, input, "command") + console.log("[smoke] command", { value: "plugin.smoke.screen", current: api.route.current }) + nav(api, route.screen, { source: "command", tab: 0, count: 0 }, "command:screen") }, }, { title: `${input.label} go home`, value: "plugin.smoke.home", category: "Plugin", - enabled: active(api, input.route), + enabled: api.route.current.name !== "home", onSelect: () => { - console.log("[smoke] command", { value: "plugin.smoke.home" }) - api.route.home() - api.dialog.clear() + console.log("[smoke] command", { value: "plugin.smoke.home", current: api.route.current }) + nav(api, "home", undefined, "command:home") + }, + }, + { + title: `${input.label} toast`, + value: "plugin.smoke.toast", + category: "Plugin", + onSelect: () => { + console.log("[smoke] command", { value: "plugin.smoke.toast", current: api.route.current }) + api.ui.toast({ + variant: "info", + title: "Smoke", + message: "Plugin toast works", + duration: 2000, + }) }, }, ]) @@ -329,14 +390,31 @@ const tui = async (input: TuiPluginInput, options?: Record) => if (options?.enabled === false) return const value = cfg(options) + const route = names(value) + console.log("[smoke] init", { - label: value.label, - modal: value.modal, - screen: value.screen, - route: value.route, + route, + keybind: { + modal: value.modal, + screen: value.screen, + }, }) + + input.api.route.register([ + { + name: route.screen, + render: ({ params }) => , + }, + { + name: route.modal, + render: ({ params }) => , + }, + ]) + + console.log("[smoke] routes registered", route) + reg(input.api, value) - input.slots.register(slot(input.api, value)) + input.slots.register(slot(input.api, value, route)) } export default { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 06ea84868c..b9d2da5ad2 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,13 +1,25 @@ -import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { render, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { Selection } from "@tui/util/selection" 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 { + Switch, + Match, + createEffect, + createMemo, + untrack, + ErrorBoundary, + createSignal, + onMount, + batch, + Show, + on, +} from "solid-js" import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" import { Installation } from "@/installation" import { Flag } from "@/flag/flag" -import { DialogProvider, useDialog } from "@tui/ui/dialog" +import { Dialog as DialogUI, DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { SDKProvider, useSDK } from "@tui/context/sdk" import { SyncProvider, useSync } from "@tui/context/sync" @@ -41,7 +53,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 type { TuiApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui" import { TuiPlugin } from "./plugin" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { @@ -220,6 +232,13 @@ function App() { const sync = useSync() const exit = useExit() const promptRef = usePromptRef() + const routes = new Map() + const [rev, setRev] = createSignal(0) + const view = (name: string) => { + rev() + return routes.get(name)?.at(-1)?.render + } + const api: TuiApi = { command: { register(cb) { @@ -229,29 +248,76 @@ function App() { command.trigger(value) }, }, - dialog: { - clear() { - dialog.clear() + route: { + register(input) { + const key = Symbol() + for (const item of input) { + const list = routes.get(item.name) ?? [] + list.push({ key, render: item.render }) + routes.set(item.name, list) + } + setRev((x) => x + 1) + return () => { + for (const item of input) { + const list = routes.get(item.name) + if (!list) continue + routes.set( + item.name, + list.filter((x) => x.key !== key), + ) + if (!routes.get(item.name)?.length) routes.delete(item.name) + } + setRev((x) => x + 1) + } }, - replace(input, onClose) { - dialog.replace(input, onClose) + navigate(name, params) { + if (name === "home") { + route.navigate({ type: "home" }) + return + } + + if (name === "session") { + const sessionID = params?.sessionID + if (typeof sessionID !== "string") return + route.navigate({ type: "session", sessionID }) + return + } + + route.navigate({ type: "plugin", id: name, data: params }) }, - get depth() { - return dialog.stack.length + get current() { + if (route.data.type === "home") return { name: "home" } + if (route.data.type === "session") { + return { + name: "session", + params: { + sessionID: route.data.sessionID, + initialPrompt: route.data.initialPrompt, + }, + } + } + + return { + name: route.data.id, + params: route.data.data, + } }, }, - route: { - get data() { - return route.data + ui: { + Dialog(props) { + return ( + + {props.children as JSX.Element} + + ) }, - navigate(next: TuiRoute) { - route.navigate(next) - }, - home() { - route.navigate({ type: "home" }) - }, - plugin(id, data) { - route.navigate({ type: "plugin", id, data }) + toast(input) { + toast.show({ + title: input.title, + message: input.message, + variant: input.variant ?? "info", + duration: input.duration, + }) }, }, keybind: { @@ -814,10 +880,12 @@ function App() { }) }) - const plugin = () => { + const plugin = createMemo(() => { if (route.data.type !== "plugin") return - return route.data - } + const render = view(route.data.id) + if (!render) return route.navigate({ type: "home" })} /> + return render({ params: route.data.data }) + }) return ( - - {(item) => ( - - route.navigate({ type: "home" })} /> - - )} - + {plugin()} ) diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 3cd0654786..e45545c286 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -102,20 +102,16 @@ test("ignores function-only tui exports and loads object exports", async () => { register: () => {}, trigger: () => {}, }, - dialog: { - clear: () => {}, - replace: () => {}, - get depth() { - return 0 + route: { + register: () => () => {}, + navigate: () => {}, + get current() { + return { name: "home" as const } }, }, - route: { - get data() { - return { type: "home" as const } - }, - navigate: () => {}, - home: () => {}, - plugin: () => {}, + ui: { + Dialog: () => null, + toast: () => {}, }, keybind: { parse: () => ({ diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 3703a231ed..56a4195afa 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -22,20 +22,27 @@ export type ThemeJson = { } } -export type TuiRoute = +export type TuiRouteCurrent = | { - type: "home" + name: "home" } | { - type: "session" - sessionID: string + name: "session" + params: { + sessionID: string + initialPrompt?: unknown + } } | { - type: "plugin" - id: string - data?: Record + name: string + params?: Record } +export type TuiRouteDefinition = { + name: string + render: (input: { params?: Record }) => Node +} + export type TuiCommand = { title: string value: string @@ -61,21 +68,32 @@ export type TuiKeybind = { leader: boolean } +export type TuiDialogProps = { + size?: "medium" | "large" + onClose: () => void + children?: Node +} + +export type TuiToast = { + variant?: "info" | "success" | "warning" | "error" + title?: string + message: string + duration?: number +} + 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 + register: (routes: TuiRouteDefinition[]) => () => void + navigate: (name: string, params?: Record) => void + readonly current: TuiRouteCurrent + } + ui: { + Dialog: (props: TuiDialogProps) => Node + toast: (input: TuiToast) => void } keybind: { parse: (evt: ParsedKey) => TuiKeybind @@ -89,10 +107,6 @@ export type TuiApi = { export type TuiSlotMap = { app: {} - route: { - route_id: string - data?: Record - } home_logo: {} sidebar_top: { session_id: string