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