diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index 3773107561..0435655551 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.opencode/plugins/tui-smoke.tsx @@ -3,6 +3,7 @@ import { extend, useKeyboard, useTerminalDimensions, type RenderableConstructor import { RGBA, VignetteEffect, type OptimizedBuffer, type RenderContext } from "@opentui/core" import { ThreeRenderable, THREE } from "@opentui/core/3d" import type { TuiApi, TuiKeybindSet, TuiPluginInput } from "@opencode-ai/plugin/tui" +import { createEffect } from "solid-js" const tabs = ["overview", "counter", "help"] const bind = { @@ -20,6 +21,14 @@ const bind = { modal_accept: "enter,return", modal_close: "escape", dialog_close: "escape", + local: "x", + local_push: "enter,return", + local_close: "q,backspace", + host: "z", +} + +const dbg = (...value: unknown[]) => { + console.log("[smoke-debug]", ...value) } const pick = (value: unknown, fallback: string) => { @@ -198,7 +207,10 @@ extend({ smoke_cube: Cube as unknown as RenderableConstructor }) const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => { return ( { + dbg("button", props.txt) + props.run() + }} backgroundColor={props.on ? props.skin.accent : props.skin.border} paddingLeft={1} paddingRight={1} @@ -214,12 +226,14 @@ const parse = (params: Record | undefined) => { const source = typeof params?.source === "string" ? params.source : "unknown" const note = typeof params?.note === "string" ? params.note : "" const selected = typeof params?.selected === "string" ? params.selected : "" + const local = typeof params?.local === "number" ? params.local : 0 return { tab: Math.max(0, Math.min(tab, tabs.length - 1)), count, source, note, selected, + local: Math.max(0, local), } } @@ -241,16 +255,125 @@ const Screen = (props: { const dim = useTerminalDimensions() const value = parse(props.params) const skin = tone(props.api) + const set = (local: number, base?: ReturnType) => { + const next = base ?? current(props.api, props.route) + props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" }) + } + const push = (base?: ReturnType) => { + const next = base ?? current(props.api, props.route) + dbg("local.push", { next: next.local + 1 }) + set(next.local + 1, next) + } + const open = () => { + const next = current(props.api, props.route) + if (next.local > 0) { + dbg("local.open.skip", { next: next.local }) + return + } + dbg("local.open", { next: 1 }) + set(1, next) + } + const pop = (base?: ReturnType) => { + const next = base ?? current(props.api, props.route) + const local = Math.max(0, next.local - 1) + dbg("local.pop", { next: local }) + set(local, next) + } + const show = () => { + dbg("local.show.click") + setTimeout(() => { + dbg("local.show.timeout") + open() + }, 0) + } + const host = () => { + dbg("host.show", { + open: props.api.ui.dialog.open, + depth: props.api.ui.dialog.depth, + }) + props.api.ui.dialog.setSize("medium") + props.api.ui.dialog.replace(() => ( + + + {props.input.label} host overlay + + Using api.ui.dialog stack with built-in backdrop + esc closes · depth {props.api.ui.dialog.depth} + + props.api.ui.dialog.clear()} skin={skin} on /> + + + )) + dbg("host.show.done", { + open: props.api.ui.dialog.open, + depth: props.api.ui.dialog.depth, + }) + } + createEffect(() => { + dbg("screen.state", { + local: value.local, + host_open: props.api.ui.dialog.open, + host_depth: props.api.ui.dialog.depth, + route: props.api.route.current.name, + width: dim().width, + height: dim().height, + }) + }) + createEffect(() => { + if (value.local === 0) return + dbg("local.overlay.visible", { local: value.local }) + }) useKeyboard((evt) => { if (props.api.route.current.name !== props.route.screen) return + dbg("key", { + name: evt.name, + ctrl: !!evt.ctrl, + shift: !!evt.shift, + meta: !!evt.meta, + local_stack: value.local, + host_open: props.api.ui.dialog.open, + host_depth: props.api.ui.dialog.depth, + }) const next = current(props.api, props.route) + if (props.api.ui.dialog.open) { + if (props.keys.match("dialog_close", evt)) { + evt.preventDefault() + evt.stopPropagation() + dbg("key.host_close") + props.api.ui.dialog.clear() + return + } + dbg("key.skip_host_open") + return + } + + if (next.local > 0) { + if (evt.name === "escape" || props.keys.match("local_close", evt)) { + evt.preventDefault() + evt.stopPropagation() + pop(next) + dbg("key.local_close") + return + } + + if (props.keys.match("local_push", evt)) { + evt.preventDefault() + evt.stopPropagation() + push(next) + dbg("key.local_push") + return + } + dbg("key.local_no_match") + return + } if (props.keys.match("home", evt)) { evt.preventDefault() evt.stopPropagation() props.api.route.navigate("home") + dbg("key.home") return } @@ -286,6 +409,23 @@ const Screen = (props: { evt.preventDefault() evt.stopPropagation() props.api.route.navigate(props.route.modal, next) + dbg("key.modal_route") + return + } + + if (props.keys.match("local", evt)) { + evt.preventDefault() + evt.stopPropagation() + open() + dbg("key.local_open") + return + } + + if (props.keys.match("host", evt)) { + evt.preventDefault() + evt.stopPropagation() + host() + dbg("key.host_open") return } @@ -318,7 +458,7 @@ const Screen = (props: { }) return ( - + source: {value.source} note: {value.note || "(none)"} selected: {value.selected || "(none)"} + local stack depth: {value.local} + host stack open: {props.api.ui.dialog.open ? "yes" : "no"} ) : null} @@ -383,6 +525,13 @@ const Screen = (props: { {props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "} confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select + + {props.keys.print("local")} local stack | {props.keys.print("host")} host stack + + + local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "} + close + {props.keys.print("home")} returns home ) : null} @@ -391,12 +540,61 @@ const Screen = (props: { props.api.route.navigate("home")} skin={skin} /> props.api.route.navigate(props.route.modal, value)} skin={skin} on /> + + props.api.route.navigate(props.route.alert, value)} skin={skin} /> props.api.route.navigate(props.route.confirm, value)} skin={skin} /> props.api.route.navigate(props.route.prompt, value)} skin={skin} /> props.api.route.navigate(props.route.select, value)} skin={skin} /> + + 0} + width={dim().width} + height={dim().height} + alignItems="center" + position="absolute" + zIndex={3000} + paddingTop={dim().height / 4} + left={0} + top={0} + backgroundColor={RGBA.fromInts(0, 0, 0, 160)} + onMouseUp={() => { + dbg("local.backdrop.click") + pop() + }} + > + { + dbg("local.panel.click") + evt.stopPropagation() + }} + width={60} + maxWidth={dim().width - 2} + backgroundColor={skin.panel} + border + borderColor={skin.border} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + paddingRight={2} + gap={1} + flexDirection="column" + > + + {props.input.label} local overlay + + Plugin-owned stack depth: {value.local} + + {props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close + + + + + + + ) } @@ -750,6 +948,20 @@ const reg = (api: TuiApi, input: ReturnType, keys: Keys) => { api.route.navigate(route.select, current(api, route)) }, }, + { + title: `${input.label} host overlay`, + value: "plugin.smoke.host", + keybind: keys.get("host"), + category: "Plugin", + slash: { + name: "smoke-host", + }, + onSelect: () => { + const DialogAlert = api.ui.DialogAlert + api.ui.dialog.setSize("medium") + api.ui.dialog.replace(() => ) + }, + }, { title: `${input.label} go home`, value: "plugin.smoke.home", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 89a5377cc4..c94436a3c7 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -275,8 +275,15 @@ function App() { } }, navigate(name, params) { + console.log("[route-debug] navigate", { + from: route.data.type, + to: name, + params, + dialog_depth: dialog.stack.length, + }) if (name === "home") { route.navigate({ type: "home" }) + console.log("[route-debug] navigate.home") return } @@ -284,10 +291,12 @@ function App() { const sessionID = params?.sessionID if (typeof sessionID !== "string") return route.navigate({ type: "session", sessionID }) + console.log("[route-debug] navigate.session", { sessionID }) return } route.navigate({ type: "plugin", id: name, data: params }) + console.log("[route-debug] navigate.plugin", { id: name }) }, get current() { if (route.data.type === "home") return { name: "home" } @@ -376,6 +385,29 @@ function App() { duration: input.duration, }) }, + dialog: { + replace(render, onClose) { + console.log("[ui-dialog-debug] replace", { depth: dialog.stack.length }) + dialog.replace(render, onClose) + }, + clear() { + console.log("[ui-dialog-debug] clear", { depth: dialog.stack.length }) + dialog.clear() + }, + setSize(size) { + console.log("[ui-dialog-debug] setSize", { depth: dialog.stack.length, size }) + dialog.setSize(size) + }, + get size() { + return dialog.size + }, + get depth() { + return dialog.stack.length + }, + get open() { + return dialog.stack.length > 0 + }, + }, }, keybind: { parse(evt: ParsedKey) { diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index cae53d6bdb..d8db37e507 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -23,12 +23,15 @@ export function Dialog( { dismiss = !!renderer.getSelection() + console.log("[dialog-debug] backdrop.mousedown", { dismiss }) }} onMouseUp={() => { + console.log("[dialog-debug] backdrop.mouseup", { dismiss }) if (dismiss) { dismiss = false return } + console.log("[dialog-debug] backdrop.close") props.onClose?.() }} width={dimensions().width} @@ -43,6 +46,7 @@ export function Dialog( > { + console.log("[dialog-debug] panel.mouseup") dismiss = false e.stopPropagation() }} @@ -70,9 +74,20 @@ function init() { useKeyboard((evt) => { if (store.stack.length === 0) return + console.log("[dialog-debug] key", { + name: evt.name, + ctrl: !!evt.ctrl, + default_prevented: evt.defaultPrevented, + stack: store.stack.length, + has_selection: !!renderer.getSelection(), + }) if (evt.defaultPrevented) return - if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { + if (renderer.getSelection()) { + console.log("[dialog-debug] key.selection_clear") + renderer.clearSelection() + } + console.log("[dialog-debug] key.close") const current = store.stack.at(-1)! current.onClose?.() setStore("stack", store.stack.slice(0, -1)) @@ -102,6 +117,7 @@ function init() { return { clear() { + console.log("[dialog-debug] clear", { stack: store.stack.length, size: store.size }) for (const item of store.stack) { if (item.onClose) item.onClose() } @@ -112,6 +128,7 @@ function init() { refocus() }, replace(input: any, onClose?: () => void) { + console.log("[dialog-debug] replace", { stack: store.stack.length, size: store.size }) if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable focus?.blur() @@ -134,6 +151,7 @@ function init() { return store.size }, setSize(size: "medium" | "large") { + console.log("[dialog-debug] setSize", { from: store.size, to: size }) setStore("size", size) }, } diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 3d3dbef7dd..9f89a88f5e 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -85,6 +85,16 @@ export const object_plugin = { { modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" }, options.keybinds, ) + const depth_before = input.api.ui.dialog.depth + const open_before = input.api.ui.dialog.open + const size_before = input.api.ui.dialog.size + input.api.ui.dialog.setSize("large") + const size_after = input.api.ui.dialog.size + input.api.ui.dialog.replace(() => null) + const depth_after = input.api.ui.dialog.depth + const open_after = input.api.ui.dialog.open + input.api.ui.dialog.clear() + const open_clear = input.api.ui.dialog.open const before = input.api.theme.has(options.theme_name) const set_missing = input.api.theme.set(options.theme_name) await input.api.theme.install(options.theme_path) @@ -107,6 +117,13 @@ export const object_plugin = { key_close: key.get("close"), key_unknown: key.get("ctrl+k"), key_print: key.print("modal"), + depth_before, + open_before, + size_before, + size_after, + depth_after, + open_after, + open_clear, }), ) }, @@ -209,7 +226,6 @@ export const object_plugin = { localMarker, globalMarker, preloadedMarker, - localPluginPath, } }, }) @@ -217,6 +233,8 @@ export const object_plugin = { const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) let selected = "opencode" + let depth = 0 + let size: "medium" | "large" = "medium" const renderer = { ...Object.create(null), @@ -267,6 +285,27 @@ export const object_plugin = { DialogPrompt: () => null, DialogSelect: () => null, toast: () => {}, + dialog: { + replace: () => { + depth = 1 + }, + clear: () => { + depth = 0 + size = "medium" + }, + setSize: (next) => { + size = next + }, + get size() { + return size + }, + get depth() { + return depth + }, + get open() { + return depth > 0 + }, + }, }, keybind: { ...keybind, @@ -313,6 +352,13 @@ export const object_plugin = { expect(local.key_close).toBe("q") expect(local.key_unknown).toBe("ctrl+k") expect(local.key_print).toBe("print:ctrl+alt+m") + expect(local.depth_before).toBe(0) + expect(local.open_before).toBe(false) + expect(local.size_before).toBe("medium") + expect(local.size_after).toBe("large") + expect(local.depth_after).toBe(1) + expect(local.open_after).toBe(true) + expect(local.open_clear).toBe(false) const global = JSON.parse(await fs.readFile(tmp.extra.globalMarker, "utf8")) expect(global.has).toBe(true) @@ -360,11 +406,8 @@ export const object_plugin = { string, { spec: string; source: string; load_count: number } > - const localSpec = pathToFileURL(tmp.extra.localPluginPath).href - const localRow = Object.values(meta).find((item) => item.spec === localSpec) - expect(localRow).toBeDefined() - expect(localRow?.source).toBe("file") - expect((localRow?.load_count ?? 0) > 0).toBe(true) + const row = Object.values(meta).find((item) => item.source === "file" && item.load_count > 0) + expect(row).toBeDefined() } finally { cwd.mockRestore() if (backup === undefined) { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 497f95147f..f5f3e4c2d0 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -66,6 +66,15 @@ export type TuiDialogProps = { children?: Node } +export type TuiDialogStack = { + replace: (render: () => Node, onClose?: () => void) => void + clear: () => void + setSize: (size: "medium" | "large") => void + readonly size: "medium" | "large" + readonly depth: number + readonly open: boolean +} + export type TuiDialogAlertProps = { title: string message: string @@ -144,6 +153,7 @@ export type TuiApi = { DialogPrompt: (props: TuiDialogPromptProps) => Node DialogSelect: (props: TuiDialogSelectProps) => Node toast: (input: TuiToast) => void + dialog: TuiDialogStack } keybind: { parse: (evt: ParsedKey) => TuiKeybind