introduce opentui keymap as sole key/cmd engine (#26053)

This commit is contained in:
Sebastian
2026-05-07 20:35:31 +02:00
committed by GitHub
parent 474e311f6f
commit 98f5e6e713
67 changed files with 3858 additions and 2977 deletions
+381 -269
View File
@@ -1,37 +1,89 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" import { useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core" import { useBindings, useKeymapSelector } from "@opentui/keymap/solid"
import type { import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core"
TuiKeybindSet, import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras"
TuiPlugin, import type { Binding } from "@opentui/keymap"
TuiPluginApi, import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"] const tabs = ["overview", "counter", "help"]
const bind = { const command = {
modal: "ctrl+shift+m", modal: "plugin.smoke.modal",
screen: "ctrl+shift+o", screen: "plugin.smoke.screen",
home: "escape,ctrl+h", alert: "plugin.smoke.alert",
left: "left,h", confirm: "plugin.smoke.confirm",
right: "right,l", prompt: "plugin.smoke.prompt",
up: "up,k", select: "plugin.smoke.select",
down: "down,j", host: "plugin.smoke.host",
alert: "a", home: "plugin.smoke.home",
confirm: "c", toast: "plugin.smoke.toast",
prompt: "p", dialog_close: "plugin.smoke.dialog.close",
select: "s", local_push: "plugin.smoke.local.push",
modal_accept: "enter,return", local_pop: "plugin.smoke.local.pop",
modal_close: "escape", screen_home: "plugin.smoke.screen.home",
dialog_close: "escape", screen_left: "plugin.smoke.screen.left",
local: "x", screen_right: "plugin.smoke.screen.right",
local_push: "enter,return", screen_up: "plugin.smoke.screen.up",
local_close: "q,backspace", screen_down: "plugin.smoke.screen.down",
host: "z", screen_modal: "plugin.smoke.screen.modal",
screen_local: "plugin.smoke.screen.local",
screen_host: "plugin.smoke.screen.host",
screen_alert: "plugin.smoke.screen.alert",
screen_confirm: "plugin.smoke.screen.confirm",
screen_prompt: "plugin.smoke.screen.prompt",
screen_select: "plugin.smoke.screen.select",
modal_accept: "plugin.smoke.modal.accept",
modal_close: "plugin.smoke.modal.close",
} as const
const sectionNames = ["global", "dialog", "local", "screen", "modal"] as const
type SectionName = (typeof sectionNames)[number]
type SectionConfig = Record<string, BindingValue<Renderable, KeyEvent>>
type ResolvedSections = Record<SectionName, Binding<Renderable, KeyEvent>[]>
type SmokeKeymap = {
sections?: Partial<Record<SectionName, SectionConfig>>
} }
type SmokeOptions = {
enabled?: boolean
label?: unknown
route?: unknown
vignette?: unknown
keymap?: SmokeKeymap
}
const defaultKeymap = {
global: {
[command.modal]: "ctrl+shift+m",
[command.screen]: "ctrl+shift+o",
},
dialog: {
[command.dialog_close]: "escape",
},
local: {
[command.local_push]: "enter,return",
[command.local_pop]: "escape,q,backspace",
},
screen: {
[command.screen_home]: "escape,ctrl+h",
[command.screen_left]: "left,h",
[command.screen_right]: "right,l",
[command.screen_up]: "up,k",
[command.screen_down]: "down,j",
[command.screen_modal]: "ctrl+shift+m",
[command.screen_local]: "x",
[command.screen_host]: "z",
[command.screen_alert]: "a",
[command.screen_confirm]: "c",
[command.screen_prompt]: "p",
[command.screen_select]: "s",
},
modal: {
[command.modal_accept]: "enter,return",
[command.modal_close]: "escape",
},
} satisfies Record<SectionName, SectionConfig>
const pick = (value: unknown, fallback: string) => { const pick = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback if (typeof value !== "string") return fallback
if (!value.trim()) return fallback if (!value.trim()) return fallback
@@ -43,16 +95,11 @@ const num = (value: unknown, fallback: number) => {
return value return value
} }
const rec = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return
return Object.fromEntries(Object.entries(value))
}
type Cfg = { type Cfg = {
label: string label: string
route: string route: string
vignette: number vignette: number
keybinds: Record<string, unknown> | undefined keymap: SmokeKeymap | undefined
} }
type Route = { type Route = {
@@ -69,12 +116,12 @@ type State = {
local: number local: number
} }
const cfg = (options: Record<string, unknown> | undefined) => { const cfg = (options: SmokeOptions | undefined) => {
return { return {
label: pick(options?.label, "smoke"), label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"), route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)), vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: rec(options?.keybinds), keymap: options?.keymap,
} }
} }
@@ -85,7 +132,25 @@ const names = (input: Cfg) => {
} }
} }
type Keys = TuiKeybindSet function createKeys(input: SmokeKeymap | undefined): { sections: ResolvedSections } {
const sections = resolveBindingSections(
{
global: { ...defaultKeymap.global, ...input?.sections?.global },
dialog: { ...defaultKeymap.dialog, ...input?.sections?.dialog },
local: { ...defaultKeymap.local, ...input?.sections?.local },
screen: { ...defaultKeymap.screen, ...input?.sections?.screen },
modal: { ...defaultKeymap.modal, ...input?.sections?.modal },
} satisfies BindingSectionsConfig<Renderable, KeyEvent>,
{ sections: sectionNames },
).sections
return {
sections,
}
}
type Keys = ReturnType<typeof createKeys>
const ui = { const ui = {
panel: "#1d1d1d", panel: "#1d1d1d",
border: "#4a4a4a", border: "#4a4a4a",
@@ -292,125 +357,161 @@ const Screen = (props: {
} }
const pop = (base?: State) => { const pop = (base?: State) => {
const next = base ?? current(props.api, props.route) const next = base ?? current(props.api, props.route)
const local = Math.max(0, next.local - 1) set(Math.max(0, next.local - 1), next)
set(local, next)
} }
const show = () => { const show = () => {
setTimeout(() => { setTimeout(() => {
open() open()
}, 0) }, 0)
} }
useKeyboard((evt) => { const screenActive = () => props.api.route.current.name === props.route.screen
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (props.api.ui.dialog.open) {
if (props.keys.match("dialog_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.ui.dialog.clear()
return
}
return
}
if (next.local > 0) { useBindings(() => ({
if (evt.name === "escape" || props.keys.match("local_close", evt)) { enabled: () => screenActive() && props.api.ui.dialog.open,
evt.preventDefault() commands: [
evt.stopPropagation() {
pop(next) name: command.dialog_close,
return run() {
} props.api.ui.dialog.clear()
},
},
],
bindings: props.keys.sections.dialog,
}))
if (props.keys.match("local_push", evt)) { useBindings(() => ({
evt.preventDefault() enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0,
evt.stopPropagation() commands: [
push(next) {
return name: command.local_push,
} run() {
return push(current(props.api, props.route))
} },
},
{
name: command.local_pop,
run() {
pop(current(props.api, props.route))
},
},
],
bindings: props.keys.sections.local,
}))
if (props.keys.match("home", evt)) { useBindings(() => ({
evt.preventDefault() enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0,
evt.stopPropagation() commands: [
props.api.route.navigate("home") {
return name: command.screen_home,
} run() {
props.api.route.navigate("home")
},
},
{
name: command.screen_left,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
},
},
{
name: command.screen_right,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
},
},
{
name: command.screen_up,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
},
},
{
name: command.screen_down,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
},
},
{
name: command.screen_modal,
run() {
props.api.route.navigate(props.route.modal, current(props.api, props.route))
},
},
{
name: command.screen_local,
run() {
open()
},
},
{
name: command.screen_host,
run() {
host(props.api, props.input, skin)
},
},
{
name: command.screen_alert,
run() {
warn(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_confirm,
run() {
check(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_prompt,
run() {
entry(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_select,
run() {
picker(props.api, props.route, current(props.api, props.route))
},
},
],
bindings: props.keys.sections.screen,
}))
const shortcuts = useKeymapSelector((keymap) => {
const bindings = keymap.getCommandBindings({
visibility: "registered",
commands: [
command.screen_home,
command.screen_up,
command.screen_down,
command.screen_modal,
command.screen_alert,
command.screen_confirm,
command.screen_prompt,
command.screen_select,
command.screen_local,
command.screen_host,
command.local_push,
command.local_pop,
],
})
if (props.keys.match("left", evt)) { return {
evt.preventDefault() screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "",
evt.stopPropagation() screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "",
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length }) screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "",
return screen_modal: props.api.keys.formatBindings(bindings.get(command.screen_modal)) ?? "",
} screen_alert: props.api.keys.formatBindings(bindings.get(command.screen_alert)) ?? "",
screen_confirm: props.api.keys.formatBindings(bindings.get(command.screen_confirm)) ?? "",
if (props.keys.match("right", evt)) { screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "",
evt.preventDefault() screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "",
evt.stopPropagation() screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "",
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length }) screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "",
return local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "",
} local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "",
if (props.keys.match("up", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (props.keys.match("down", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (props.keys.match("modal", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (props.keys.match("local", evt)) {
evt.preventDefault()
evt.stopPropagation()
open()
return
}
if (props.keys.match("host", evt)) {
evt.preventDefault()
evt.stopPropagation()
host(props.api, props.input, skin)
return
}
if (props.keys.match("alert", evt)) {
evt.preventDefault()
evt.stopPropagation()
warn(props.api, props.route, next)
return
}
if (props.keys.match("confirm", evt)) {
evt.preventDefault()
evt.stopPropagation()
check(props.api, props.route, next)
return
}
if (props.keys.match("prompt", evt)) {
evt.preventDefault()
evt.stopPropagation()
entry(props.api, props.route, next)
return
}
if (props.keys.match("select", evt)) {
evt.preventDefault()
evt.stopPropagation()
picker(props.api, props.route, next)
} }
}) })
@@ -430,7 +531,7 @@ const Screen = (props: {
<b>{props.input.label} screen</b> <b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span> <span style={{ fg: skin.muted }}> plugin route</span>
</text> </text>
<text fg={skin.muted}>{props.keys.print("home")} home</text> <text fg={skin.muted}>{shortcuts().screen_home} home</text>
</box> </box>
<box flexDirection="row" gap={1} paddingBottom={1}> <box flexDirection="row" gap={1} paddingBottom={1}>
@@ -477,7 +578,7 @@ const Screen = (props: {
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text> <text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}> <text fg={skin.muted}>
{props.keys.print("up")} / {props.keys.print("down")} change value {shortcuts().screen_up} / {shortcuts().screen_down} change value
</text> </text>
</box> </box>
) : null} ) : null}
@@ -485,17 +586,16 @@ const Screen = (props: {
{value.tab === 2 ? ( {value.tab === 2 ? (
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text fg={skin.muted}> <text fg={skin.muted}>
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "} {shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm}{" "}
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select
</text> </text>
<text fg={skin.muted}> <text fg={skin.muted}>
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack {shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack
</text> </text>
<text fg={skin.muted}> <text fg={skin.muted}>
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "} local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close
close
</text> </text>
<text fg={skin.muted}>{props.keys.print("home")} returns home</text> <text fg={skin.muted}>{shortcuts().screen_home} returns home</text>
</box> </box>
) : null} ) : null}
</box> </box>
@@ -548,7 +648,7 @@ const Screen = (props: {
</text> </text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text> <text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<text fg={skin.muted}> <text fg={skin.muted}>
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close {shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close
</text> </text>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on /> <Btn txt="push" run={push} skin={skin} on />
@@ -571,20 +671,35 @@ const Modal = (props: {
const value = parse(props.params) const value = parse(props.params)
const skin = tone(props.api) const skin = tone(props.api)
useKeyboard((evt) => { useBindings(() => ({
if (props.api.route.current.name !== props.route.modal) return enabled: () => props.api.route.current.name === props.route.modal,
commands: [
{
name: command.modal_accept,
run() {
props.api.route.navigate(props.route.screen, { ...parse(props.params), source: "modal" })
},
},
{
name: command.modal_close,
run() {
props.api.route.navigate("home")
},
},
],
bindings: props.keys.sections.modal,
}))
const shortcuts = useKeymapSelector((keymap) => {
const bindings = keymap.getCommandBindings({
visibility: "registered",
commands: [command.modal, command.screen, command.modal_accept, command.modal_close],
})
if (props.keys.match("modal_accept", evt)) { return {
evt.preventDefault() modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "",
evt.stopPropagation() screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "",
props.api.route.navigate(props.route.screen, { ...value, source: "modal" }) modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "",
return modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "",
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
} }
}) })
@@ -595,10 +710,10 @@ const Modal = (props: {
<text fg={skin.text}> <text fg={skin.text}>
<b>{props.input.label} modal</b> <b>{props.input.label} modal</b>
</text> </text>
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text> <text fg={skin.muted}>{shortcuts().modal} modal command</text>
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text> <text fg={skin.muted}>{shortcuts().screen} screen command</text>
<text fg={skin.muted}> <text fg={skin.muted}>
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes {shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes
</text> </text>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<Btn <Btn
@@ -791,120 +906,117 @@ const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => { const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
const route = names(input) const route = names(input)
api.command.register(() => [ api.keymap.registerLayer({
{ commands: [
title: `${input.label} modal`, {
value: "plugin.smoke.modal", name: command.modal,
keybind: keys.get("modal"), title: `${input.label} modal`,
category: "Plugin", category: "Plugin",
slash: { namespace: "palette",
name: "smoke", slashName: "smoke",
run() {
api.route.navigate(route.modal, { source: "command" })
},
}, },
onSelect: () => { {
api.route.navigate(route.modal, { source: "command" }) name: command.screen,
title: `${input.label} screen`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-screen",
run() {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
},
}, },
}, {
{ name: command.alert,
title: `${input.label} screen`, title: `${input.label} alert dialog`,
value: "plugin.smoke.screen", category: "Plugin",
keybind: keys.get("screen"), namespace: "palette",
category: "Plugin", slashName: "smoke-alert",
slash: { run() {
name: "smoke-screen", warn(api, route, current(api, route))
},
}, },
onSelect: () => { {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 }) name: command.confirm,
title: `${input.label} confirm dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-confirm",
run() {
check(api, route, current(api, route))
},
}, },
}, {
{ name: command.prompt,
title: `${input.label} alert dialog`, title: `${input.label} prompt dialog`,
value: "plugin.smoke.alert", category: "Plugin",
category: "Plugin", namespace: "palette",
slash: { slashName: "smoke-prompt",
name: "smoke-alert", run() {
entry(api, route, current(api, route))
},
}, },
onSelect: () => { {
warn(api, route, current(api, route)) name: command.select,
title: `${input.label} select dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-select",
run() {
picker(api, route, current(api, route))
},
}, },
}, {
{ name: command.host,
title: `${input.label} confirm dialog`, title: `${input.label} host overlay`,
value: "plugin.smoke.confirm", category: "Plugin",
category: "Plugin", namespace: "palette",
slash: { slashName: "smoke-host",
name: "smoke-confirm", run() {
host(api, input, tone(api))
},
}, },
onSelect: () => { {
check(api, route, current(api, route)) name: command.home,
title: `${input.label} go home`,
category: "Plugin",
namespace: "palette",
enabled: () => api.route.current.name !== "home",
run() {
api.route.navigate("home")
},
}, },
}, {
{ name: command.toast,
title: `${input.label} prompt dialog`, title: `${input.label} toast`,
value: "plugin.smoke.prompt", category: "Plugin",
category: "Plugin", namespace: "palette",
slash: { run() {
name: "smoke-prompt", api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
}, },
onSelect: () => { ],
entry(api, route, current(api, route)) bindings: keys.sections.global,
}, })
},
{
title: `${input.label} select dialog`,
value: "plugin.smoke.select",
category: "Plugin",
slash: {
name: "smoke-select",
},
onSelect: () => {
picker(api, route, current(api, route))
},
},
{
title: `${input.label} host overlay`,
value: "plugin.smoke.host",
category: "Plugin",
slash: {
name: "smoke-host",
},
onSelect: () => {
host(api, input, tone(api))
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: api.route.current.name !== "home",
onSelect: () => {
api.route.navigate("home")
},
},
{
title: `${input.label} toast`,
value: "plugin.smoke.toast",
category: "Plugin",
onSelect: () => {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
])
} }
const tui: TuiPlugin = async (api, options, meta) => { const tui: TuiPlugin = async (api, options, meta) => {
if (options?.enabled === false) return const input = options as SmokeOptions | undefined
if (input?.enabled === false) return
await api.theme.install("./smoke-theme.json") await api.theme.install("./smoke-theme.json")
api.theme.set("smoke-theme") api.theme.set("smoke-theme")
const value = cfg(options ?? undefined) const value = cfg(input)
const route = names(value) const route = names(value)
const keys = api.keybind.create(bind, value.keybinds) const keys = createKeys(value.keymap)
const fx = new VignetteEffect(value.vignette) const fx = new VignetteEffect(value.vignette)
const post = fx.apply.bind(fx) const post = fx.apply.bind(fx)
api.renderer.addPostProcessFn(post) api.renderer.addPostProcessFn(post)
+14 -5
View File
@@ -6,11 +6,20 @@
{ {
"enabled": false, "enabled": false,
"label": "workspace", "label": "workspace",
"keybinds": { "keymap": {
"modal": "ctrl+alt+m", "sections": {
"screen": "ctrl+alt+o", "global": {
"home": "escape,ctrl+shift+h", "plugin.smoke.modal": "ctrl+alt+m",
"dialog_close": "escape,q" "plugin.smoke.screen": "ctrl+alt+o"
},
"screen": {
"plugin.smoke.screen.home": "escape,ctrl+shift+h",
"plugin.smoke.screen.modal": "ctrl+alt+m"
},
"dialog": {
"plugin.smoke.dialog.close": "escape,q"
}
}
} }
} }
] ]
+19 -12
View File
@@ -379,6 +379,7 @@
"@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
@@ -477,6 +478,7 @@
}, },
"devDependencies": { "devDependencies": {
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
@@ -484,11 +486,13 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.2.2", "@opentui/core": ">=0.2.4",
"@opentui/solid": ">=0.2.2", "@opentui/keymap": ">=0.2.4",
"@opentui/solid": ">=0.2.4",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
"@opentui/keymap",
"@opentui/solid", "@opentui/solid",
], ],
}, },
@@ -663,8 +667,9 @@
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.2", "@opentui/core": "0.2.4",
"@opentui/solid": "0.2.2", "@opentui/keymap": "0.2.4",
"@opentui/solid": "0.2.4",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@@ -1589,21 +1594,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="], "@opentui/core": ["@opentui/core@0.2.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.4", "@opentui/core-darwin-x64": "0.2.4", "@opentui/core-linux-arm64": "0.2.4", "@opentui/core-linux-x64": "0.2.4", "@opentui/core-win32-arm64": "0.2.4", "@opentui/core-win32-x64": "0.2.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6xRdxmSgCFsEIwUwv7Pr+XKS1gBOwYF0tS/DE4KxTNzuH39VQDot7blzm8UKl6okdurFAxkt2+1HJJStl+rICw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2GlEndoBQkA8qSxr9RQEOgprdheCBRZvbUIfui5AUUmREZfgIQP+w399cJwmhlwSoNVNtfzLQGHxUFGfPhzMrQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-5V/Fcwg1rYTeKH9/bj3pMG1837APMIaYPfNWz0Ha87m5wcUKjodQOMf/xTzz2NJuLE/m5rydSuvY9uh5EJx3QA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-yQoWlEH9sQ/OfpCYcoGxzV4mbPaMCbYIl3thD/vcvIqOSa386vjKZFdTeU5Lu1PHiz3MMU/8Fzej5pJ6ZFJFZA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-OxhBUqFHNcIhiKzon3+HYo4T2Me0ooRJQJW8bDVfEc7gtcWGm3ix/+8o7feQAdcbQW63Chwzed2glvbrOrDCzg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-UWECe8y9vzdcotRf1ljOvWFOjNiGqAnmeC/SyylhvvoNhh/TqvbZawHVFifw/GZUlBEBdZcXF0f3XGGsW4M5Nw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-P7yBRAWwiMZXDnVzzXCNksjkCzAvQ5b2X32JzL3gACf+yobs4bvA9F47Ud+XgKZiqILf/c7fa0cud2E0cfWxlA=="],
"@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="], "@opentui/keymap": ["@opentui/keymap@0.2.4", "", { "dependencies": { "@opentui/core": "0.2.4" }, "peerDependencies": { "@opentui/react": "0.2.4", "@opentui/solid": "0.2.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-TOEkPKlcfhP2Xqo6xtU6zYTsvwHv4syqZ2v89hjNLCuY476j4UMTxeszJCfuSABplwm0OfllV94rVFl0BheWVw=="],
"@opentui/solid": ["@opentui/solid@0.2.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-EKuUwcTElRW0jKrXNJrTiWVOBvok78wk8viVwsyy3h8sD9qcLyCQA+XGmOINapADNGvgBohW9dKOSTFsqjZlvA=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+4 -2
View File
@@ -13,6 +13,7 @@
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint", "lint": "oxlint",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/opencode fix-node-pty", "postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
@@ -34,8 +35,9 @@
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.2", "@opentui/core": "0.2.4",
"@opentui/solid": "0.2.2", "@opentui/keymap": "0.2.4",
"@opentui/solid": "0.2.4",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
+1 -1
View File
@@ -11,7 +11,6 @@
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"build": "bun run script/build.ts", "build": "bun run script/build.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts", "fix-node-pty": "bun run script/fix-node-pty.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts", "dev": "bun run --conditions=browser ./src/index.ts",
"dev:temporary": "bun run --conditions=browser ./src/temporary.ts", "dev:temporary": "bun run --conditions=browser ./src/temporary.ts",
"db": "bun drizzle-kit" "db": "bun drizzle-kit"
@@ -125,6 +124,7 @@
"@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
+1 -1
View File
@@ -59,5 +59,5 @@ await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2))
if (tuiFile) { if (tuiFile) {
console.log(tuiFile) console.log(tuiFile)
await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2)) await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.JsonSchemaInfo), null, 2))
} }
+31 -30
View File
@@ -53,13 +53,21 @@ Minimal module shape:
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
const tui: TuiPlugin = async (api, options, meta) => { const tui: TuiPlugin = async (api, options, meta) => {
api.command.register(() => [ api.keymap.registerLayer({
{ commands: [
title: "Demo", {
value: "demo.open", name: "demo.open",
onSelect: () => api.route.navigate("demo"), title: "Demo",
}, category: "Plugin",
]) namespace: "palette",
slashName: "demo",
run() {
api.route.navigate("demo")
},
},
],
bindings: [{ key: "ctrl+shift+m", cmd: "demo.open", desc: "Open demo" }],
})
api.route.register([ api.route.register([
{ {
@@ -194,10 +202,10 @@ That is what makes local config-scoped plugins able to import `@opencode-ai/plug
Top-level API groups exposed to `tui(api, options, meta)`: Top-level API groups exposed to `tui(api, options, meta)`:
- `api.app.version` - `api.app.version`
- `api.command.register(cb)` / `api.command.trigger(value)` / `api.command.show()` - `api.keys.formatSequence(parts)`, `formatBindings(bindings)`
- `api.keymap`
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current` - `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog` - `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog`
- `api.keybind.match`, `print`, `create`
- `api.tuiConfig` - `api.tuiConfig`
- `api.kv.get`, `set`, `ready` - `api.kv.get`, `set`, `ready`
- `api.state` - `api.state`
@@ -209,23 +217,23 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)` - `api.plugins.list()`, `activate(id)`, `deactivate(id)`, `add(spec)`, `install(spec, options?)`
- `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)` - `api.lifecycle.signal`, `api.lifecycle.onDispose(fn)`
### Commands ### Keymap
`api.command.register` returns an unregister function. Command rows support: - `api.keymap` exposes the raw `Keymap<Renderable, KeyEvent>` instance from the host.
- The host already installs the default OpenTUI bundle (`default keys`, metadata fields, and enabled fields) plus OpenCode's comma bindings, leader token, base layout fallback, pending-sequence helpers, and managed textarea layer.
- Register commands with `api.keymap.registerLayer({ commands: [...] })`.
- Register key bindings with `bindings: [{ key, cmd, desc }]` in the same layer or a separate layer.
- Use `api.keymap.acquireResource(...)` for shared plugin addon setup that should ref-count against the host keymap.
- To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command.
- Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution.
- Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself.
- `title`, `value` ### Keys
- `description`, `category`
- `keybind`
- `suggested`, `hidden`, `enabled`
- `slash: { name, aliases? }`
- `onSelect`
Command behavior: - `api.keys` exposes host-formatted shortcut display helpers for plugin UI.
- `formatSequence(parts)` formats parsed key sequence parts using the host's display policy.
- Registrations are reactive. - `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show.
- Later registrations win for duplicate `value` and for keybind handling. - For generic config-to-bindings helpers, import `resolveBindingSections` from `@opencode-ai/plugin/tui`.
- Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and `command.trigger(value)` if `enabled !== false`.
- `api.command.show()` opens the host command dialog directly.
### Routes ### Routes
@@ -252,13 +260,6 @@ Command behavior:
- `setSize("medium" | "large" | "xlarge")` - `setSize("medium" | "large" | "xlarge")`
- readonly `size`, `depth`, `open` - readonly `size`, `depth`, `open`
### Keybinds
- `api.keybind.match(key, evt)` and `print(key)` use the host keybind parser/printer.
- `api.keybind.create(defaults, overrides?)` builds a plugin-local keybind set.
- Only missing, blank, or non-string overrides are ignored. Key syntax is not validated.
- Returned keybind set exposes `all`, `get(name)`, `match(name, evt)`, `print(name)`.
### KV, state, client, events ### KV, state, client, events
- `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced. - `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced.
+26
View File
@@ -8,3 +8,29 @@ Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Comm
_Why_ _Why_
Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right. Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right.
## OpenTUI Keymap Migration
The v2 TUI uses `@opentui/keymap` as the key/cmd engine. The remaining legacy compatibility is config-only and exists to migrate users from `keybinds` to `keymap`:
- `packages/opencode/src/config/keybinds.ts`: old `keybinds` schema, defaults, and legacy key names.
- `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`: transforms parsed legacy `keybinds` into OpenTUI `keymap` sections.
- `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`: migrates legacy TUI keys from `opencode.json` into `tui.json`, including `theme`, `keybinds`, and nested `tui`.
- `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`: still accepts deprecated `keybinds` via `KeybindOverride` and marks it as deprecated. This file also contains the new `keymap` config schema.
- `packages/opencode/src/cli/cmd/tui/config/tui.ts`: parses legacy `keybinds`, applies the Windows `terminal_suspend`/`input_undo` adjustment, and uses `LegacyKeymapTransform.create(...)` as the fallback when no `keymap` section is configured.
- `packages/plugin/src/tui.ts`: plugin-facing `tuiConfig` still includes `keybinds` through `PluginConfig`; this should be removed when the public plugin API no longer exposes legacy config.
The transform must stay while users are migrating. It lets users upgrade without first rewriting their existing `keybinds` config. If `keymap` is configured, `keybinds` are ignored for keymap resolution. If `keymap` is missing, `legacy-keymap-transform.ts` turns legacy `keybinds` into the resolved `keymap` consumed by OpenTUI.
## Removing Legacy Later
When switching fully to the new config style, remove legacy support with these exact changes:
- Delete `packages/opencode/src/config/keybinds.ts`.
- Delete `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`.
- Delete `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`.
- In `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`, remove the `ConfigKeybinds` import, remove `KeybindOverride`, and delete the deprecated `keybinds` field from `TuiInfo`.
- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `migrateTuiConfig(...)`, remove `ConfigKeybinds`, remove the Windows legacy keybind adjustment, remove `LegacyKeymapTransform.create(...)`, and require/default `keymap` through the new config path instead.
- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `keybinds` from `Resolved`; resolved TUI config should expose `keymap` only.
- In `packages/plugin/src/tui.ts`, remove `keybinds` from plugin-facing `TuiConfigView`.
- Remove or rewrite tests that write or assert `keybinds`, especially in `packages/opencode/test/config/tui.test.ts`, `packages/opencode/test/fixture/tui-runtime.ts`, and TUI plugin loader tests.
+394 -424
View File
@@ -1,4 +1,5 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import * as Clipboard from "@tui/util/clipboard" import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection" import * as Selection from "@tui/util/selection"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
@@ -11,6 +12,7 @@ import {
ErrorBoundary, ErrorBoundary,
createSignal, createSignal,
onMount, onMount,
onCleanup,
batch, batch,
Show, Show,
on, on,
@@ -36,11 +38,9 @@ import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status" import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help" import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent" import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme" import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home" import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session" import { Session } from "@tui/routes/session"
@@ -60,15 +60,17 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt" import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
import type { RouteMap } from "@/cli/cmd/tui/plugin/api" import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
import { FormatError, FormatUnknownError } from "@/cli/error" import { FormatError, FormatUnknownError } from "@/cli/error"
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant" import { DialogVariant } from "./component/dialog-variant"
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true) const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
return { return {
@@ -111,7 +113,7 @@ function errorMessage(error: unknown) {
export function tui(input: { export function tui(input: {
url: string url: string
args: Args args: Args
config: TuiConfig.Info config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]> onSnapshot?: () => Promise<string[]>
directory?: string directory?: string
fetch?: typeof fetch fetch?: typeof fetch
@@ -130,6 +132,7 @@ export function tui(input: {
} }
const onBeforeExit = async () => { const onBeforeExit = async () => {
offKeymap()
await TuiPluginRuntime.dispose() await TuiPluginRuntime.dispose()
} }
@@ -138,6 +141,9 @@ export function tui(input: {
void renderer.getPalette({ size: 16 }).catch(() => undefined) void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
const keymap = createDefaultOpenTuiKeymap(renderer)
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
await render(() => { await render(() => {
return ( return (
<ErrorBoundary <ErrorBoundary
@@ -145,37 +151,37 @@ export function tui(input: {
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} /> <ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
)} )}
> >
<ArgsProvider {...input.args}> <OpencodeKeymapProvider keymap={keymap}>
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}> <ArgsProvider {...input.args}>
<KVProvider> <ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<ToastProvider> <KVProvider>
<RouteProvider <ToastProvider>
initialRoute={ <RouteProvider
input.args.continue initialRoute={
? { input.args.continue
type: "session", ? {
sessionID: "dummy", type: "session",
} sessionID: "dummy",
: undefined }
} : undefined
> }
<TuiConfigProvider config={input.config}> >
<SDKProvider <TuiConfigProvider config={input.config}>
url={input.url} <SDKProvider
directory={input.directory} url={input.url}
fetch={input.fetch} directory={input.directory}
headers={input.headers} fetch={input.fetch}
events={input.events} headers={input.headers}
> events={input.events}
<ProjectProvider> >
<SyncProvider> <ProjectProvider>
<SyncProviderV2> <SyncProvider>
<ThemeProvider mode={mode}> <SyncProviderV2>
<LocalProvider> <ThemeProvider mode={mode}>
<KeybindProvider> <LocalProvider>
<PromptStashProvider> <PromptStashProvider>
<DialogProvider> <DialogProvider>
<CommandProvider> <CommandPaletteProvider>
<FrecencyProvider> <FrecencyProvider>
<PromptHistoryProvider> <PromptHistoryProvider>
<PromptRefProvider> <PromptRefProvider>
@@ -185,22 +191,22 @@ export function tui(input: {
</PromptRefProvider> </PromptRefProvider>
</PromptHistoryProvider> </PromptHistoryProvider>
</FrecencyProvider> </FrecencyProvider>
</CommandProvider> </CommandPaletteProvider>
</DialogProvider> </DialogProvider>
</PromptStashProvider> </PromptStashProvider>
</KeybindProvider> </LocalProvider>
</LocalProvider> </ThemeProvider>
</ThemeProvider> </SyncProviderV2>
</SyncProviderV2> </SyncProvider>
</SyncProvider> </ProjectProvider>
</ProjectProvider> </SDKProvider>
</SDKProvider> </TuiConfigProvider>
</TuiConfigProvider> </RouteProvider>
</RouteProvider> </ToastProvider>
</ToastProvider> </KVProvider>
</KVProvider> </ExitProvider>
</ExitProvider> </ArgsProvider>
</ArgsProvider> </OpencodeKeymapProvider>
</ErrorBoundary> </ErrorBoundary>
) )
}, renderer) }, renderer)
@@ -209,14 +215,17 @@ export function tui(input: {
function App(props: { onSnapshot?: () => Promise<string[]> }) { function App(props: { onSnapshot?: () => Promise<string[]> }) {
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const route = useRoute() const route = useRoute()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const renderer = useRenderer() const renderer = useRenderer()
const dialog = useDialog() const dialog = useDialog()
const local = useLocal() const local = useLocal()
const kv = useKV() const kv = useKV()
const command = useCommandDialog() const command = useCommandPalette()
const keybind = useKeybind() const keymap = useOpencodeKeymap()
const event = useEvent() const event = useEvent()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
@@ -233,10 +242,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
} }
const api = createTuiApi({ const api = createTuiApi({
command,
tuiConfig, tuiConfig,
dialog, dialog,
keybind, keymap,
kv, kv,
route, route,
routes, routes,
@@ -260,40 +268,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
setReady(true) setReady(true)
}) })
useKeyboard((evt) => { // Let selection copy/dismiss win ahead of normal bindings when the feature flag is on.
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return const offSelectionKeys = keymap.intercept(
const sel = renderer.getSelection() "key",
if (!sel) return ({ event }) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
// Windows Terminal-like behavior: Selection.handleSelectionKey(renderer, toast, event)
// - Ctrl+C copies and dismisses selection },
// - Esc dismisses selection { priority: 1 },
// - Most other key input dismisses selection and is passed through )
if (evt.ctrl && evt.name === "c") { onCleanup(offSelectionKeys)
if (!Selection.copy(renderer, toast)) {
renderer.clearSelection()
return
}
evt.preventDefault()
evt.stopPropagation()
return
}
if (evt.name === "escape") {
renderer.clearSelection()
evt.preventDefault()
evt.stopPropagation()
return
}
const focus = renderer.currentFocusedRenderable
if (focus?.hasSelection() && sel.selectedRenderables.includes(focus)) {
return
}
renderer.clearSelection()
})
// Wire up console copy-to-clipboard via opentui's onCopySelection callback // Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => { renderer.console.onCopySelection = async (text: string) => {
@@ -410,379 +394,365 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
) )
const connected = useConnected() const connected = useConnected()
command.register(() => [ const appCommands = createMemo(() =>
{ [
title: "Switch session", {
value: "session.list", name: "command.palette.show",
keybind: "session_list", title: "Show command palette",
category: "Session", hidden: true,
suggested: sync.data.session.length > 0, run: () => {
slash: { command.show()
name: "sessions", },
aliases: ["resume", "continue"],
}, },
onSelect: () => { {
dialog.replace(() => <DialogSessionList />) name: "session.list",
title: "Switch session",
category: "Session",
suggested: sync.data.session.length > 0,
slashName: "sessions",
slashAliases: ["resume", "continue"],
run: () => {
dialog.replace(() => <DialogSessionList />)
},
}, },
}, {
{ name: "session.new",
title: "New session", title: "New session",
suggested: route.data.type === "session", suggested: route.data.type === "session",
value: "session.new", category: "Session",
keybind: "session_new", slashName: "new",
category: "Session", slashAliases: ["clear"],
slash: { run: () => {
name: "new", route.navigate({
aliases: ["clear"], type: "home",
})
dialog.clear()
},
}, },
onSelect: () => { {
route.navigate({ name: "model.list",
type: "home", title: "Switch model",
}) suggested: true,
dialog.clear() category: "Agent",
slashName: "models",
run: () => {
dialog.replace(() => <DialogModel />)
},
}, },
}, {
{ name: "model.cycle_recent",
title: "Switch model", title: "Model cycle",
value: "model.list", category: "Agent",
keybind: "model_list", hidden: true,
suggested: true, run: () => {
category: "Agent", local.model.cycle(1)
slash: { },
name: "models",
}, },
onSelect: () => { {
dialog.replace(() => <DialogModel />) name: "model.cycle_recent_reverse",
title: "Model cycle reverse",
category: "Agent",
hidden: true,
run: () => {
local.model.cycle(-1)
},
}, },
}, {
{ name: "model.cycle_favorite",
title: "Model cycle", title: "Favorite cycle",
value: "model.cycle_recent", category: "Agent",
keybind: "model_cycle_recent", hidden: true,
category: "Agent", run: () => {
hidden: true, local.model.cycleFavorite(1)
onSelect: () => { },
local.model.cycle(1)
}, },
}, {
{ name: "model.cycle_favorite_reverse",
title: "Model cycle reverse", title: "Favorite cycle reverse",
value: "model.cycle_recent_reverse", category: "Agent",
keybind: "model_cycle_recent_reverse", hidden: true,
category: "Agent", run: () => {
hidden: true, local.model.cycleFavorite(-1)
onSelect: () => { },
local.model.cycle(-1)
}, },
}, {
{ name: "agent.list",
title: "Favorite cycle", title: "Switch agent",
value: "model.cycle_favorite", category: "Agent",
keybind: "model_cycle_favorite", slashName: "agents",
category: "Agent", run: () => {
hidden: true, dialog.replace(() => <DialogAgent />)
onSelect: () => { },
local.model.cycleFavorite(1)
}, },
}, {
{ name: "mcp.list",
title: "Favorite cycle reverse", title: "Toggle MCPs",
value: "model.cycle_favorite_reverse", category: "Agent",
keybind: "model_cycle_favorite_reverse", slashName: "mcps",
category: "Agent", run: () => {
hidden: true, dialog.replace(() => <DialogMcp />)
onSelect: () => { },
local.model.cycleFavorite(-1)
}, },
}, {
{ name: "agent.cycle",
title: "Switch agent", title: "Agent cycle",
value: "agent.list", category: "Agent",
keybind: "agent_list", hidden: true,
category: "Agent", run: () => {
slash: { local.agent.move(1)
name: "agents", },
}, },
onSelect: () => { {
dialog.replace(() => <DialogAgent />) name: "variant.cycle",
title: "Variant cycle",
category: "Agent",
run: () => {
local.model.variant.cycle()
},
}, },
}, {
{ name: "variant.list",
title: "Toggle MCPs", title: "Switch model variant",
value: "mcp.list", category: "Agent",
category: "Agent", hidden: local.model.variant.list().length === 0,
slash: { slashName: "variants",
name: "mcps", run: () => {
dialog.replace(() => <DialogVariant />)
},
}, },
onSelect: () => { {
dialog.replace(() => <DialogMcp />) name: "agent.cycle.reverse",
title: "Agent cycle reverse",
category: "Agent",
hidden: true,
run: () => {
local.agent.move(-1)
},
}, },
}, {
{ name: "provider.connect",
title: "Agent cycle", title: "Connect provider",
value: "agent.cycle", suggested: !connected(),
keybind: "agent_cycle", slashName: "connect",
category: "Agent", run: () => {
hidden: true, dialog.replace(() => <DialogProviderList />)
onSelect: () => { },
local.agent.move(1) category: "Provider",
}, },
}, ...(sync.data.console_state.switchableOrgCount > 1
{ ? [
title: "Variant cycle", {
value: "variant.cycle", name: "console.org.switch",
keybind: "variant_cycle", title: "Switch org",
category: "Agent", suggested: Boolean(sync.data.console_state.activeOrgName),
onSelect: () => { slashName: "org",
local.model.variant.cycle() slashAliases: ["orgs", "switch-org"],
}, run: () => {
}, dialog.replace(() => <DialogConsoleOrg />)
{ },
title: "Switch model variant", category: "Provider",
value: "variant.list",
keybind: "variant_list",
category: "Agent",
hidden: local.model.variant.list().length === 0,
slash: {
name: "variants",
},
onSelect: () => {
dialog.replace(() => <DialogVariant />)
},
},
{
title: "Agent cycle reverse",
value: "agent.cycle.reverse",
keybind: "agent_cycle_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.agent.move(-1)
},
},
{
title: "Connect provider",
value: "provider.connect",
suggested: !connected(),
slash: {
name: "connect",
},
onSelect: () => {
dialog.replace(() => <DialogProviderList />)
},
category: "Provider",
},
...(sync.data.console_state.switchableOrgCount > 1
? [
{
title: "Switch org",
value: "console.org.switch",
suggested: Boolean(sync.data.console_state.activeOrgName),
slash: {
name: "org",
aliases: ["orgs", "switch-org"],
}, },
onSelect: () => { ]
dialog.replace(() => <DialogConsoleOrg />) : []),
}, {
category: "Provider", name: "opencode.status",
}, title: "View status",
] slashName: "status",
: []), run: () => {
{ dialog.replace(() => <DialogStatus />)
title: "View status", },
keybind: "status_view", category: "System",
value: "opencode.status",
slash: {
name: "status",
}, },
onSelect: () => { {
dialog.replace(() => <DialogStatus />) name: "theme.switch",
title: "Switch theme",
slashName: "themes",
run: () => {
dialog.replace(() => <DialogThemeList />)
},
category: "System",
}, },
category: "System", {
}, name: "theme.switch_mode",
{ title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
title: "Switch theme", run: () => {
value: "theme.switch", setMode(mode() === "dark" ? "light" : "dark")
keybind: "theme_list", dialog.clear()
slash: { },
name: "themes", category: "System",
}, },
onSelect: () => { {
dialog.replace(() => <DialogThemeList />) name: "theme.mode.lock",
title: locked() ? "Unlock theme mode" : "Lock theme mode",
run: () => {
if (locked()) unlock()
else lock()
dialog.clear()
},
category: "System",
}, },
category: "System", {
}, name: "help.show",
{ title: "Help",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode", slashName: "help",
value: "theme.switch_mode", run: () => {
onSelect: (dialog) => { dialog.replace(() => <DialogHelp />)
setMode(mode() === "dark" ? "light" : "dark") },
dialog.clear() category: "System",
}, },
category: "System", {
}, name: "docs.open",
{ title: "Open docs",
title: locked() ? "Unlock theme mode" : "Lock theme mode", run: () => {
value: "theme.mode.lock", open("https://opencode.ai/docs").catch(() => {})
onSelect: (dialog) => { dialog.clear()
if (locked()) unlock() },
else lock() category: "System",
dialog.clear()
}, },
category: "System", {
}, name: "app.exit",
{ title: "Exit the app",
title: "Help", slashName: "exit",
value: "help.show", slashAliases: ["quit", "q"],
slash: { enabled: () => {
name: "help", const current = promptRef.current
if (!current?.focused) return true
return current.current.input === ""
},
run: () => exit(),
category: "System",
}, },
onSelect: () => { {
dialog.replace(() => <DialogHelp />) name: "app.debug",
title: "Toggle debug panel",
category: "System",
run: () => {
renderer.toggleDebugOverlay()
dialog.clear()
},
}, },
category: "System", {
}, name: "app.console",
{ title: "Toggle console",
title: "Open docs", category: "System",
value: "docs.open", run: () => {
onSelect: () => { renderer.console.toggle()
open("https://opencode.ai/docs").catch(() => {}) dialog.clear()
dialog.clear() },
}, },
category: "System", {
}, name: "app.heap_snapshot",
{ title: "Write heap snapshot",
title: "Exit the app", category: "System",
value: "app.exit", run: async () => {
slash: { const files = await props.onSnapshot?.()
name: "exit", toast.show({
aliases: ["quit", "q"], variant: "info",
message: `Heap snapshot written to ${files?.join(", ")}`,
duration: 5000,
})
dialog.clear()
},
}, },
onSelect: () => exit(), {
category: "System", name: "terminal.suspend",
}, title: "Suspend terminal",
{ category: "System",
title: "Toggle debug panel", hidden: true,
category: "System", enabled: process.platform !== "win32",
value: "app.debug", run: () => {
onSelect: (dialog) => { process.once("SIGCONT", () => {
renderer.toggleDebugOverlay() renderer.resume()
dialog.clear() })
},
},
{
title: "Toggle console",
category: "System",
value: "app.console",
onSelect: (dialog) => {
renderer.console.toggle()
dialog.clear()
},
},
{
title: "Write heap snapshot",
category: "System",
value: "app.heap_snapshot",
onSelect: async (dialog) => {
const files = await props.onSnapshot?.()
toast.show({
variant: "info",
message: `Heap snapshot written to ${files?.join(", ")}`,
duration: 5000,
})
dialog.clear()
},
},
{
title: "Suspend terminal",
value: "terminal.suspend",
keybind: "terminal_suspend",
category: "System",
hidden: true,
enabled: tuiConfig.keybinds?.terminal_suspend !== "none",
onSelect: () => {
process.once("SIGCONT", () => {
renderer.resume()
})
renderer.suspend() renderer.suspend()
// pid=0 means send the signal to all processes in the process group process.kill(0, "SIGTSTP")
process.kill(0, "SIGTSTP") },
}, },
}, {
{ name: "terminal.title.toggle",
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle", category: "System",
keybind: "terminal_title_toggle", run: () => {
category: "System", setTerminalTitleEnabled((prev) => {
onSelect: (dialog) => { const next = !prev
setTerminalTitleEnabled((prev) => { kv.set("terminal_title_enabled", next)
const next = !prev if (!next) renderer.setTerminalTitle("")
kv.set("terminal_title_enabled", next) return next
if (!next) renderer.setTerminalTitle("") })
return next dialog.clear()
}) },
dialog.clear()
}, },
}, {
{ name: "app.toggle.animations",
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations", category: "System",
category: "System", run: () => {
onSelect: (dialog) => { kv.set("animations_enabled", !kv.get("animations_enabled", true))
kv.set("animations_enabled", !kv.get("animations_enabled", true)) dialog.clear()
dialog.clear() },
}, },
}, {
{ name: "app.toggle.file_context",
title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context", title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
value: "app.toggle.file_context", category: "System",
category: "System", run: () => {
onSelect: (dialog) => { kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
kv.set("file_context_enabled", !kv.get("file_context_enabled", true)) dialog.clear()
dialog.clear() },
}, },
}, {
{ name: "app.toggle.diffwrap",
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary", title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.paste_summary", category: "System",
category: "System", run: () => {
onSelect: (dialog) => { const current = kv.get("diff_wrap_mode", "word")
setPasteSummaryEnabled((prev) => { kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
const next = !prev dialog.clear()
kv.set("paste_summary_enabled", next) },
return next
})
dialog.clear()
}, },
}, {
{ name: "app.toggle.paste_summary",
title: kv.get("session_directory_filter_enabled", true) title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
? "Disable session directory filtering" category: "System",
: "Enable session directory filtering", run: () => {
value: "app.toggle.session_directory_filter", setPasteSummaryEnabled((prev) => {
category: "System", const next = !prev
onSelect: async (dialog) => { kv.set("paste_summary_enabled", next)
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true)) return next
await sync.session.refresh() })
dialog.clear() dialog.clear()
},
}, },
}, {
{ name: "app.toggle.session_directory_filter",
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", title: kv.get("session_directory_filter_enabled", true)
value: "app.toggle.diffwrap", ? "Disable session directory filtering"
category: "System", : "Enable session directory filtering",
onSelect: (dialog) => { category: "System",
const current = kv.get("diff_wrap_mode", "word") run: async () => {
kv.set("diff_wrap_mode", current === "word" ? "none" : "word") kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
dialog.clear() await sync.session.refresh()
dialog.clear()
},
}, },
}, ].map((command) => ({
]) namespace: "palette",
...command,
})),
)
useBindings(() => ({
commands: appCommands(),
}))
useBindings(() => ({
enabled: command.matcher,
bindings: sections.global,
}))
event.on(TuiEvent.CommandExecute.type, (evt) => { event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command) command.run(evt.properties.command)
}) })
event.on(TuiEvent.ToastShow.type, (evt) => { event.on(TuiEvent.ToastShow.type, (evt) => {
@@ -1,172 +0,0 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
import {
createContext,
createMemo,
createSignal,
getOwner,
onCleanup,
runWithOwner,
useContext,
type Accessor,
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
export type Slash = {
name: string
aliases?: string[]
}
export type CommandOption = DialogSelectOption<string> & {
keybind?: string
suggested?: boolean
slash?: Slash
hidden?: boolean
enabled?: boolean
}
function init() {
const root = getOwner()
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
const keybind = useKeybind()
const entries = createMemo(() => {
const all = registrations().flatMap((x) => x())
return all.map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}))
})
const isEnabled = (option: CommandOption) => option.enabled !== false
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
const suggestedOptions = createMemo(() =>
visibleOptions()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
)
const suspended = () => suspendCount() > 0
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
return
}
}
})
const result = {
trigger(name: string) {
for (const option of entries()) {
if (option.value === name) {
if (!isEnabled(option)) return
option.onSelect?.(dialog)
return
}
}
},
slashes() {
return visibleOptions().flatMap((option) => {
const slash = option.slash
if (!slash) return []
return {
display: "/" + slash.name,
description: option.description ?? option.title,
aliases: slash.aliases?.map((alias) => "/" + alias),
onSelect: () => result.trigger(option.value),
}
})
},
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
},
suspended,
show() {
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
},
register(cb: () => CommandOption[]) {
const owner = getOwner() ?? root
if (!owner) return () => {}
let list: Accessor<CommandOption[]> | undefined
// TUI plugins now register commands via an async store that runs outside an active reactive scope.
// runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
runWithOwner(owner, () => {
list = createMemo(cb)
const ref = list
if (!ref) return
setRegistrations((arr) => [ref, ...arr])
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== ref))
})
})
if (!list) return () => {}
let done = false
return () => {
if (done) return
done = true
const ref = list
if (!ref) return
setRegistrations((arr) => arr.filter((x) => x !== ref))
}
},
}
return result
}
export function useCommandDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider")
}
return value
}
export function CommandProvider(props: ParentProps) {
const value = init()
const dialog = useDialog()
const keybind = useKeybind()
useKeyboard((evt) => {
if (value.suspended()) return
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
if (keybind.match("command_list", evt)) {
evt.preventDefault()
value.show()
return
}
})
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
let ref: DialogSelectRef<string>
const list = () => {
if (ref?.filter) return props.options
return [...props.suggestedOptions, ...props.options]
}
return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
}
@@ -1,5 +1,4 @@
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core" import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import open from "open" import open from "open"
import { createSignal, onCleanup, onMount } from "solid-js" import { createSignal, onCleanup, onMount } from "solid-js"
import { selectedForeground, useTheme } from "@tui/context/theme" import { selectedForeground, useTheme } from "@tui/context/theme"
@@ -7,6 +6,7 @@ import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { Link } from "@tui/ui/link" import { Link } from "@tui/ui/link"
import { GoLogo } from "./logo" import { GoLogo } from "./logo"
import { BgPulse, type BgPulseMask } from "./bg-pulse" import { BgPulse, type BgPulseMask } from "./bg-pulse"
import { useBindings } from "../keymap"
const GO_URL = "https://opencode.ai/go" const GO_URL = "https://opencode.ai/go"
const PAD_X = 3 const PAD_X = 3
@@ -71,18 +71,29 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync) for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
}) })
useKeyboard((evt) => { useBindings(() => ({
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") { bindings: [
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe")) {
return key: "left",
} cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
if (evt.name === "return") { },
evt.preventDefault() {
evt.stopPropagation() key: "right",
if (selected() === "subscribe") subscribe(props, dialog) cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
else dismiss(props, dialog) },
} {
}) key: "tab",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
},
{
key: "return",
cmd: () => {
if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog)
},
},
],
}))
return ( return (
<box ref={(item: BoxRenderable) => (content = item)}> <box ref={(item: BoxRenderable) => (content = item)}>
@@ -4,7 +4,6 @@ import { useSync } from "@tui/context/sync"
import { map, pipe, entries, sortBy } from "remeda" import { map, pipe, entries, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select" import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { Keybind } from "@/util/keybind"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
@@ -45,9 +44,9 @@ export function DialogMcp() {
) )
}) })
const keybinds = createMemo(() => [ const actions = createMemo(() => [
{ {
keybind: Keybind.parse("space")[0], command: "dialog.action.toggle",
title: "toggle", title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => { onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress // Prevent toggling while an operation is already in progress
@@ -77,7 +76,7 @@ export function DialogMcp() {
ref={setRef} ref={setRef}
title="MCPs" title="MCPs"
options={options()} options={options()}
keybind={keybinds()} actions={actions()}
onSelect={(_option) => { onSelect={(_option) => {
// Don't close on select, only on escape // Don't close on select, only on escape
}} }}
@@ -6,15 +6,15 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog" import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant" import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort" import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected" import { useConnected } from "./use-connected"
import { useTuiConfig } from "../context/tui-config"
export function DialogModel(props: { providerID?: string }) { export function DialogModel(props: { providerID?: string }) {
const local = useLocal() const local = useLocal()
const sync = useSync() const sync = useSync()
const dialog = useDialog() const dialog = useDialog()
const keybind = useKeybind() const tuiConfig = useTuiConfig()
const [query, setQuery] = createSignal("") const [query, setQuery] = createSignal("")
const connected = useConnected() const connected = useConnected()
@@ -150,16 +150,16 @@ export function DialogModel(props: { providerID?: string }) {
return ( return (
<DialogSelect<ReturnType<typeof options>[number]["value"]> <DialogSelect<ReturnType<typeof options>[number]["value"]>
options={options()} options={options()}
keybind={[ actions={[
{ {
keybind: keybind.all.model_provider_list?.[0], command: "model.dialog.provider",
title: connected() ? "Connect provider" : "View all providers", title: connected() ? "Connect provider" : "View all providers",
onTrigger() { onTrigger() {
dialog.replace(() => <DialogProvider />) dialog.replace(() => <DialogProvider />)
}, },
}, },
{ {
keybind: keybind.all.model_favorite_toggle?.[0], command: "model.dialog.favorite",
title: "Favorite", title: "Favorite",
disabled: !connected(), disabled: !connected(),
onTrigger: (option) => { onTrigger: (option) => {
@@ -167,6 +167,7 @@ export function DialogModel(props: { providerID?: string }) {
}, },
}, },
]} ]}
bindings={tuiConfig.keymap.sections.model}
onFilter={setQuery} onFilter={setQuery}
flat={true} flat={true}
skipFilter={true} skipFilter={true}
@@ -10,11 +10,11 @@ import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2" import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
import { DialogModel } from "./dialog-model" import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard" import * as Clipboard from "@tui/util/clipboard"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "@tui/util/provider-origin" import { isConsoleManagedProvider } from "@tui/util/provider-origin"
import { useConnected } from "./use-connected" import { useConnected } from "./use-connected"
import { useBindings } from "../keymap"
const PROVIDER_PRIORITY: Record<string, number> = { const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0, opencode: 0,
@@ -239,14 +239,19 @@ function AutoMethod(props: AutoMethodProps) {
const sync = useSync() const sync = useSync()
const toast = useToast() const toast = useToast()
useKeyboard((evt) => { useBindings(() => ({
if (evt.name === "c" && !evt.ctrl && !evt.meta) { bindings: [
const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url {
Clipboard.copy(code) key: "c",
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) cmd: () => {
.catch(toast.error) const code = props.authorization.instructions.match(/[A-Z0-9]{4}-[A-Z0-9]{4,5}/)?.[0] ?? props.authorization.url
} Clipboard.copy(code)
}) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
},
},
],
}))
onMount(async () => { onMount(async () => {
const result = await sdk.client.provider.oauth.callback({ const result = await sdk.client.provider.oauth.callback({
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { For } from "solid-js" import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid" import { useBindings } from "../keymap"
export function DialogSessionDeleteFailed(props: { export function DialogSessionDeleteFailed(props: {
session: string session: string
@@ -40,19 +40,15 @@ export function DialogSessionDeleteFailed(props: {
if (!props.onDone) dialog.clear() if (!props.onDone) dialog.clear()
} }
useKeyboard((evt) => { useBindings(() => ({
if (evt.name === "return") { bindings: [
evt.preventDefault() { key: "return", cmd: () => void confirm() },
evt.stopPropagation() { key: "left", cmd: () => setStore("active", "delete") },
void confirm() { key: "up", cmd: () => setStore("active", "delete") },
} { key: "right", cmd: () => setStore("active", "restore") },
if (evt.name === "left" || evt.name === "up") { { key: "down", cmd: () => setStore("active", "restore") },
setStore("active", "delete") ],
} }))
if (evt.name === "right" || evt.name === "down") {
setStore("active", "restore")
}
})
return ( return (
<box paddingLeft={2} paddingRight={2} gap={1}> <box paddingLeft={2} paddingRight={2} gap={1}>
@@ -5,7 +5,6 @@ import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js" import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project" import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
@@ -17,18 +16,19 @@ import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { WorkspaceLabel } from "./workspace-label" import { WorkspaceLabel } from "./workspace-label"
import { useCommandShortcut } from "../keymap"
export function DialogSessionList() { export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
const route = useRoute() const route = useRoute()
const sync = useSync() const sync = useSync()
const project = useProject() const project = useProject()
const keybind = useKeybind()
const { theme } = useTheme() const { theme } = useTheme()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("dialog.action.delete")
const [searchResults, { refetch }] = createResource( const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }), () => ({ query: search(), filter: sync.session.query() }),
@@ -156,7 +156,7 @@ export function DialogSessionList() {
const status = sync.data.session_status?.[x.id] const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy" const isWorking = status?.type === "busy"
return { return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title, title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined, bg: isDeleting ? theme.error : undefined,
value: x.id, value: x.id,
category, category,
@@ -187,9 +187,9 @@ export function DialogSessionList() {
}) })
dialog.clear() dialog.clear()
}} }}
keybind={[ actions={[
{ {
keybind: keybind.all.session_delete?.[0], command: "dialog.action.delete",
title: "delete", title: "delete",
onTrigger: async (option) => { onTrigger: async (option) => {
if (toDelete() === option.value) { if (toDelete() === option.value) {
@@ -237,7 +237,7 @@ export function DialogSessionList() {
}, },
}, },
{ {
keybind: keybind.all.session_rename?.[0], command: "dialog.action.rename",
title: "rename", title: "rename",
onTrigger: async (option) => { onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />) dialog.replace(() => <DialogSessionRename session={option.value} />)
@@ -3,8 +3,8 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { createMemo, createSignal } from "solid-js" import { createMemo, createSignal } from "solid-js"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useKeybind } from "../context/keybind"
import { usePromptStash, type StashEntry } from "./prompt/stash" import { usePromptStash, type StashEntry } from "./prompt/stash"
import { useCommandShortcut } from "../keymap"
function getRelativeTime(timestamp: number): string { function getRelativeTime(timestamp: number): string {
const now = Date.now() const now = Date.now()
@@ -30,9 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog() const dialog = useDialog()
const stash = usePromptStash() const stash = usePromptStash()
const { theme } = useTheme() const { theme } = useTheme()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<number>() const [toDelete, setToDelete] = createSignal<number>()
const deleteHint = useCommandShortcut("dialog.action.delete")
const options = createMemo(() => { const options = createMemo(() => {
const entries = stash.list() const entries = stash.list()
@@ -42,7 +42,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const isDeleting = toDelete() === index const isDeleting = toDelete() === index
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1 const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
return { return {
title: isDeleting ? `Press ${keybind.print("stash_delete")} again to confirm` : getStashPreview(entry.input), title: isDeleting ? `Press ${deleteHint()} again to confirm` : getStashPreview(entry.input),
bg: isDeleting ? theme.error : undefined, bg: isDeleting ? theme.error : undefined,
value: index, value: index,
description: getRelativeTime(entry.timestamp), description: getRelativeTime(entry.timestamp),
@@ -68,9 +68,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
} }
dialog.clear() dialog.clear()
}} }}
keybind={[ actions={[
{ {
keybind: keybind.all.stash_delete?.[0], command: "dialog.action.delete",
title: "delete", title: "delete",
onTrigger: (option) => { onTrigger: (option) => {
if (toDelete() === option.value) { if (toDelete() === option.value) {
@@ -1,9 +1,9 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { For } from "solid-js" import { For } from "solid-js"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { useBindings } from "../keymap"
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) { export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
const dialog = useDialog() const dialog = useDialog()
@@ -23,25 +23,13 @@ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean |
if (result === false) return if (result === false) return
} }
useKeyboard((evt) => { useBindings(() => ({
if (evt.name === "return") { bindings: [
evt.preventDefault() { key: "return", cmd: () => void confirm() },
evt.stopPropagation() { key: "left", cmd: () => setStore("active", "cancel") },
void confirm() { key: "right", cmd: () => setStore("active", "restore") },
return ],
} }))
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "cancel")
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "restore")
}
})
return ( return (
<box paddingLeft={2} paddingRight={2} gap={1}> <box paddingLeft={2} paddingRight={2} gap={1}>
@@ -1,4 +1,4 @@
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core" import type { BoxRenderable, TextareaRenderable, ScrollBoxRenderable } from "@opentui/core"
import { pathToFileURL } from "bun" import { pathToFileURL } from "bun"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import path from "path" import path from "path"
@@ -12,11 +12,12 @@ import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config" import { useTuiConfig } from "../../context/tui-config"
import { useTheme, selectedForeground } from "@tui/context/theme" import { useTheme, selectedForeground } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border" import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command" import { useCommandPalette } from "../../context/command-palette"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import type { PromptInfo } from "./history" import type { PromptInfo } from "./history"
import { useFrecency } from "./frecency" import { useFrecency } from "./frecency"
import { useBindings } from "../../keymap"
function removeLineRange(input: string) { function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#") const hashIndex = input.lastIndexOf("#")
@@ -52,7 +53,6 @@ function extractLineRange(input: string) {
export type AutocompleteRef = { export type AutocompleteRef = {
onInput: (value: string) => void onInput: (value: string) => void
onKeyDown: (e: KeyEvent) => void
visible: false | "@" | "/" visible: false | "@" | "/"
} }
@@ -82,12 +82,14 @@ export function Autocomplete(props: {
const editor = useEditorContext() const editor = useEditorContext()
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const command = useCommandDialog() const command = useCommandPalette()
const { theme } = useTheme() const { theme } = useTheme()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const frecency = useFrecency() const frecency = useFrecency()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const [store, setStore] = createStore({ const [store, setStore] = createStore({
index: 0, index: 0,
selected: 0, selected: 0,
@@ -282,7 +284,7 @@ export function Autocomplete(props: {
const { filename, part } = createFilePart(item, lineRange) const { filename, part } = createFilePart(item, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset const index = store.visible === "@" ? store.index : props.input().cursorOffset
command.keybinds(true) command.suspend(false)
setStore("visible", false) setStore("visible", false)
setStore("index", index) setStore("index", index)
insertPart(filename, part) insertPart(filename, part)
@@ -520,8 +522,54 @@ export function Autocomplete(props: {
setStore("selected", 0) setStore("selected", 0)
} }
useBindings(() => ({
target: props.input,
enabled: () => Boolean(store.visible),
commands: [
{
name: "prompt.autocomplete.prev",
run() {
setStore("input", "keyboard")
move(-1)
},
},
{
name: "prompt.autocomplete.next",
run() {
setStore("input", "keyboard")
move(1)
},
},
{
name: "prompt.autocomplete.hide",
run() {
hide()
},
},
{
name: "prompt.autocomplete.select",
run() {
select()
},
},
{
name: "prompt.autocomplete.complete",
run() {
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
return
}
select()
},
},
],
bindings: sections.autocomplete,
}))
function show(mode: "@" | "/") { function show(mode: "@" | "/") {
command.keybinds(false) command.suspend(true)
setStore({ setStore({
visible: mode, visible: mode,
index: props.input().cursorOffset, index: props.input().cursorOffset,
@@ -538,7 +586,7 @@ export function Autocomplete(props: {
draft.input = props.input().plainText draft.input = props.input().plainText
}) })
} }
command.keybinds(true) command.suspend(false)
setStore("visible", false) setStore("visible", false)
} }
@@ -593,60 +641,6 @@ export function Autocomplete(props: {
setStore("index", idx) setStore("index", idx)
} }
}, },
onKeyDown(e: KeyEvent) {
if (store.visible) {
const name = e.name?.toLowerCase()
const ctrlOnly = e.ctrl && !e.meta && !e.shift
const isNavUp = name === "up" || (ctrlOnly && name === "p")
const isNavDown = name === "down" || (ctrlOnly && name === "n")
if (isNavUp) {
setStore("input", "keyboard")
move(-1)
e.preventDefault()
return
}
if (isNavDown) {
setStore("input", "keyboard")
move(1)
e.preventDefault()
return
}
if (name === "escape") {
hide()
e.preventDefault()
return
}
if (name === "return") {
select()
e.preventDefault()
return
}
if (name === "tab") {
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
} else {
select()
}
e.preventDefault()
return
}
}
if (!store.visible) {
if (e.name === "@") {
const cursorOffset = props.input().cursorOffset
const charBeforeCursor =
cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset)
const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor)
if (canTrigger) show("@")
}
if (e.name === "/") {
if (props.input().cursorOffset === 0) show("/")
}
}
},
}) })
}) })
@@ -1,4 +1,14 @@
import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core" import {
BoxRenderable,
RGBA,
TextareaRenderable,
MouseEvent,
PasteEvent,
decodePasteBytes,
type KeyEvent,
type Renderable,
} from "@opentui/core"
import type { CommandContext } from "@opentui/keymap"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid" import "opentui-spinner/solid"
import path from "path" import path from "path"
@@ -16,14 +26,12 @@ import { useEvent } from "@tui/context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor" import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema" import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store" import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history" import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits" import { computePromptTraits } from "./traits"
import { assign } from "./part" import { assign } from "./part"
import { usePromptStash } from "./stash" import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash" import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import * as Editor from "@tui/util/editor" import * as Editor from "@tui/util/editor"
import { useExit } from "../../context/exit" import { useExit } from "../../context/exit"
@@ -40,7 +48,6 @@ import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast" import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv" import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal" import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill" import { DialogSkill } from "../dialog-skill"
import { import {
confirmWorkspaceFileChanges, confirmWorkspaceFileChanges,
@@ -51,7 +58,15 @@ import {
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args" import { useArgs } from "@tui/context/args"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label" import { type WorkspaceStatus } from "../workspace-label"
import { useCommandPalette } from "../../context/command-palette"
import {
useBindings,
useCommandShortcut,
useLeaderActive,
useOpencodeKeymap,
} from "../../keymap"
import { useTuiConfig } from "../../context/tui-config"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@@ -124,9 +139,9 @@ let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) { export function Prompt(props: PromptProps) {
let input: TextareaRenderable let input: TextareaRenderable
let anchor: BoxRenderable let anchor: BoxRenderable
let autocomplete: AutocompleteRef const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const keybind = useKeybind() const leader = useLeaderActive()
const local = useLocal() const local = useLocal()
const args = useArgs() const args = useArgs()
const sdk = useSDK() const sdk = useSDK()
@@ -134,12 +149,17 @@ export function Prompt(props: PromptProps) {
const route = useRoute() const route = useRoute()
const project = useProject() const project = useProject()
const sync = useSync() const sync = useSync()
const tuiConfig = useTuiConfig()
const keymapConfig = tuiConfig.keymap
const dialog = useDialog() const dialog = useDialog()
const toast = useToast() const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory() const history = usePromptHistory()
const stash = usePromptStash() const stash = usePromptStash()
const command = useCommandDialog() const command = useCommandPalette()
const keymap = useOpencodeKeymap()
const agentShortcut = useCommandShortcut("agent.cycle")
const paletteShortcut = useCommandShortcut("command.palette.show")
const renderer = useRenderer() const renderer = useRenderer()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
@@ -184,6 +204,7 @@ export function Prompt(props: PromptProps) {
const [workspaceCreating, setWorkspaceCreating] = createSignal(false) const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3) const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
const [warpNotice, setWarpNotice] = createSignal<string>() const [warpNotice, setWarpNotice] = createSignal<string>()
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider) const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right)) const hasRightContent = createMemo(() => Boolean(props.right))
const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current()) const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current())
@@ -287,9 +308,6 @@ export function Prompt(props: PromptProps) {
setDismissedEditorSelectionKey(editorSelectionKey(editorContext())) setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
editor.clearSelection() editor.clearSelection()
} }
const textareaKeybindings = useTextareaKeybindings()
const fileStyleId = syntax().getStyleId("extmark.file")! const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")! const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")! const pasteStyleId = syntax().getStyleId("extmark.paste")!
@@ -391,26 +409,30 @@ export function Prompt(props: PromptProps) {
} }
}) })
command.register(() => { const promptCommands = createMemo(() =>
return [ [
{ {
title: "Clear prompt", title: "Clear prompt",
value: "prompt.clear", name: "prompt.clear",
category: "Prompt", category: "Prompt",
hidden: true, hidden: true,
onSelect: (dialog) => { run: () => {
input.extmarks.clear()
input.clear() input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
dialog.clear() dialog.clear()
}, },
}, },
{ {
title: "Submit prompt", title: "Submit prompt",
value: "prompt.submit", name: "prompt.submit",
keybind: "input_submit",
category: "Prompt", category: "Prompt",
hidden: true, hidden: true,
onSelect: async (dialog) => { run: async () => {
if (!input.focused) return if (!input.focused) return
const handled = await submit() const handled = await submit()
if (!handled) return if (!handled) return
@@ -420,21 +442,22 @@ export function Prompt(props: PromptProps) {
}, },
{ {
title: "Remove editor context", title: "Remove editor context",
value: "prompt.editor_context.clear", name: "prompt.editor_context.clear",
category: "Prompt", category: "Prompt",
enabled: Boolean(editorContext()), enabled: Boolean(editorContext()),
onSelect: (dialog) => { run: () => {
dismissEditorContext() dismissEditorContext()
dialog.clear() dialog.clear()
}, },
}, },
{ {
title: "Paste", title: "Paste",
value: "prompt.paste", name: "prompt.paste",
keybind: "input_paste",
category: "Prompt", category: "Prompt",
hidden: true, hidden: true,
onSelect: async () => { run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
ctx.event.preventDefault()
ctx.event.stopPropagation()
const content = await Clipboard.read() const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) { if (content?.mime.startsWith("image/")) {
await pasteAttachment({ await pasteAttachment({
@@ -442,18 +465,21 @@ export function Prompt(props: PromptProps) {
mime: content.mime, mime: content.mime,
content: content.data, content: content.data,
}) })
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data)
} }
}, },
}, },
{ {
title: "Interrupt session", title: "Interrupt session",
value: "session.interrupt", name: "session.interrupt",
keybind: "session_interrupt",
category: "Session", category: "Session",
hidden: true, hidden: true,
enabled: status().type !== "idle", enabled: status().type !== "idle",
onSelect: (dialog) => { run: () => {
if (autocomplete.visible) return if (auto()?.visible) return
if (!input.focused) return if (!input.focused) return
// TODO: this should be its own command // TODO: this should be its own command
if (store.mode === "shell") { if (store.mode === "shell") {
@@ -480,12 +506,9 @@ export function Prompt(props: PromptProps) {
{ {
title: "Open editor", title: "Open editor",
category: "Session", category: "Session",
keybind: "editor_open", name: "prompt.editor",
value: "prompt.editor", slashName: "editor",
slash: { run: async () => {
name: "editor",
},
onSelect: async (dialog) => {
dialog.clear() dialog.clear()
// replace summarized text parts with the actual text // replace summarized text parts with the actual text
@@ -566,12 +589,10 @@ export function Prompt(props: PromptProps) {
}, },
{ {
title: "Skills", title: "Skills",
value: "prompt.skills", name: "prompt.skills",
category: "Prompt", category: "Prompt",
slash: { slashName: "skills",
name: "skills", run: () => {
},
onSelect: () => {
dialog.replace(() => ( dialog.replace(() => (
<DialogSkill <DialogSkill
onSelect={(skill) => { onSelect={(skill) => {
@@ -588,14 +609,12 @@ export function Prompt(props: PromptProps) {
}, },
{ {
title: "Warp", title: "Warp",
description: "Change the workspace for the session", desc: "Change the workspace for the session",
value: "workspace.set", name: "workspace.set",
category: "Session", category: "Session",
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slash: { slashName: "warp",
name: "warp", run: () => {
},
onSelect: (dialog) => {
void openWorkspaceSelect({ void openWorkspaceSelect({
dialog, dialog,
sdk, sdk,
@@ -607,8 +626,29 @@ export function Prompt(props: PromptProps) {
}) })
}, },
}, },
] ].map((entry) => ({
}) namespace: "palette",
...entry,
})),
)
useBindings(() => ({
commands: promptCommands(),
}))
useBindings(() => ({
enabled: command.matcher,
bindings: keymapConfig.pick("prompt", [
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
"session.interrupt",
"workspace.set",
]),
}))
const ref: PromptRef = { const ref: PromptRef = {
get focused() { get focused() {
@@ -659,6 +699,7 @@ export function Prompt(props: PromptProps) {
if (store.prompt.input) { if (store.prompt.input) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
} }
setInputTarget(undefined)
props.ref?.(undefined) props.ref?.(undefined)
}) })
@@ -676,11 +717,14 @@ export function Prompt(props: PromptProps) {
createEffect(() => { createEffect(() => {
if (!input || input.isDestroyed) return if (!input || input.isDestroyed) return
input.traits = computePromptTraits({ input.traits = {
mode: store.mode, ...input.traits,
disabled: !!props.disabled, ...computePromptTraits({
autocompleteVisible: !!auto()?.visible, mode: store.mode,
}) disabled: !!props.disabled,
autocompleteVisible: !!auto()?.visible,
}),
}
}) })
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
@@ -761,60 +805,195 @@ export function Prompt(props: PromptProps) {
) )
} }
command.register(() => [ const stashCommands = createMemo(() =>
{ [
title: "Stash prompt", {
value: "prompt.stash", title: "Stash prompt",
category: "Prompt", name: "prompt.stash",
enabled: !!store.prompt.input, category: "Prompt",
onSelect: (dialog) => { enabled: !!store.prompt.input,
if (!store.prompt.input) return run: () => {
stash.push({ if (!store.prompt.input) return
input: store.prompt.input, stash.push({
parts: store.prompt.parts, input: store.prompt.input,
}) parts: store.prompt.parts,
input.extmarks.clear() })
input.clear() input.extmarks.clear()
setStore("prompt", { input: "", parts: [] }) input.clear()
setStore("extmarkToPartIndex", new Map()) setStore("prompt", { input: "", parts: [] })
dialog.clear() setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
}, },
}, {
{ title: "Stash pop",
title: "Stash pop", name: "prompt.stash.pop",
value: "prompt.stash.pop", category: "Prompt",
category: "Prompt", enabled: stash.list().length > 0,
enabled: stash.list().length > 0, run: () => {
onSelect: (dialog) => { const entry = stash.pop()
const entry = stash.pop() if (entry) {
if (entry) { input.setText(entry.input)
input.setText(entry.input) setStore("prompt", { input: entry.input, parts: entry.parts })
setStore("prompt", { input: entry.input, parts: entry.parts }) restoreExtmarksFromParts(entry.parts)
restoreExtmarksFromParts(entry.parts) input.gotoBufferEnd()
input.gotoBufferEnd() }
} dialog.clear()
dialog.clear() },
}, },
}, {
{ title: "Stash list",
title: "Stash list", name: "prompt.stash.list",
value: "prompt.stash.list", category: "Prompt",
category: "Prompt", enabled: stash.list().length > 0,
enabled: stash.list().length > 0, run: () => {
onSelect: (dialog) => { dialog.replace(() => (
dialog.replace(() => ( <DialogStash
<DialogStash onSelect={(entry) => {
onSelect={(entry) => { input.setText(entry.input)
input.setText(entry.input) setStore("prompt", { input: entry.input, parts: entry.parts })
setStore("prompt", { input: entry.input, parts: entry.parts }) restoreExtmarksFromParts(entry.parts)
restoreExtmarksFromParts(entry.parts) input.gotoBufferEnd()
input.gotoBufferEnd() }}
}} />
/> ))
)) },
}, },
}, ].map((entry) => ({
]) namespace: "palette",
...entry,
})),
)
useBindings(() => ({
commands: stashCommands(),
}))
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
bindings: keymapConfig.pick("prompt", ["prompt.paste"]),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
bindings: keymapConfig.pick("prompt", ["prompt.clear"]),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0
})(),
bindings: [
{
key: "!",
cmd: () => {
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
},
},
],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }],
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!auto()?.visible &&
input !== undefined &&
(input.cursorOffset === 0 || input.visualCursor.visualRow === 0)
)
})(),
commands: [
{
name: "prompt.history.previous",
run() {
if (input.cursorOffset !== 0) {
input.cursorOffset = 0
return
}
const item = history.move(-1, input.plainText)
if (!item) return
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
input.cursorOffset = 0
},
},
],
bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]),
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: (() => {
cursorVersion()
return (
inputTarget() !== undefined &&
!props.disabled &&
!auto()?.visible &&
input !== undefined &&
(input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1)
)
})(),
commands: [
{
name: "prompt.history.next",
run() {
if (input.cursorOffset !== input.plainText.length) {
input.cursorOffset = input.plainText.length
return
}
const item = history.move(1, input.plainText)
if (!item) return
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
input.cursorOffset = input.plainText.length
},
},
],
bindings: keymapConfig.pick("prompt", ["prompt.history.next"]),
}
})
async function submit() { async function submit() {
setWarpNotice(undefined) setWarpNotice(undefined)
@@ -828,7 +1007,7 @@ export function Prompt(props: PromptProps) {
} }
if (props.disabled) return false if (props.disabled) return false
if (workspaceCreating()) return false if (workspaceCreating()) return false
if (autocomplete?.visible) return false if (auto()?.visible) return false
if (!store.prompt.input) return false if (!store.prompt.input) return false
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return false if (!agent) return false
@@ -1068,6 +1247,66 @@ export function Prompt(props: PromptProps) {
) )
} }
async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = iife(() => {
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (process.platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
})
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const mime = await Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
if (mime === "image/svg+xml") {
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (mime.startsWith("image/") || mime === "application/pdf") {
const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteAttachment({
filename,
filepath,
mime,
content,
})
return
}
}
} catch {}
}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
input.insertText(normalizedText)
setTimeout(() => {
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
}
async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) { async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
const currentOffset = input.visualCursor.offset const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset const extmarkStart = currentOffset
@@ -1117,7 +1356,7 @@ export function Prompt(props: PromptProps) {
} }
const highlight = createMemo(() => { const highlight = createMemo(() => {
if (keybind.leader) return theme.border if (leader()) return theme.border
if (store.mode === "shell") return theme.primary if (store.mode === "shell") return theme.primary
const agent = local.agent.current() const agent = local.agent.current()
if (!agent) return theme.border if (!agent) return theme.border
@@ -1206,30 +1445,7 @@ export function Prompt(props: PromptProps) {
return ( return (
<> <>
<Autocomplete <box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false}>
sessionID={props.sessionID}
ref={(r) => {
autocomplete = r
setAuto(() => r)
}}
anchor={() => anchor}
input={() => input}
setPrompt={(cb) => {
setStore("prompt", produce(cb))
}}
setExtmark={(partIndex, extmarkId) => {
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
}}
value={store.prompt.input}
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box <box
border={["left"]} border={["left"]}
borderColor={borderHighlight()} borderColor={borderHighlight()}
@@ -1249,94 +1465,23 @@ export function Prompt(props: PromptProps) {
<textarea <textarea
placeholder={placeholderText()} placeholder={placeholderText()}
placeholderColor={theme.textMuted} placeholderColor={theme.textMuted}
textColor={keybind.leader ? theme.textMuted : theme.text} textColor={leader() ? theme.textMuted : theme.text}
focusedTextColor={keybind.leader ? theme.textMuted : theme.text} focusedTextColor={leader() ? theme.textMuted : theme.text}
minHeight={1} minHeight={1}
maxHeight={6} maxHeight={6}
onContentChange={() => { onContentChange={() => {
const value = input.plainText const value = input.plainText
setStore("prompt", "input", value) setStore("prompt", "input", value)
autocomplete.onInput(value) auto()?.onInput(value)
syncExtmarksWithPromptParts() syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}} }}
keyBindings={textareaKeybindings()} onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={async (e) => { onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) { if (props.disabled) {
e.preventDefault() e.preventDefault()
return return
} }
// Check clipboard for images before terminal-handled paste runs.
// This helps terminals that forward Ctrl+V to the app; Windows
// Terminal 1.25+ usually handles Ctrl+V before this path.
if (keybind.match("input_paste", e)) {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
e.preventDefault()
await pasteAttachment({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
return
}
// If no image, let the default paste behavior continue
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
return
}
if (keybind.match("app_exit", e)) {
if (store.prompt.input === "") {
await exit()
// Don't preventDefault - let textarea potentially handle the event
e.preventDefault()
return
}
}
if (e.name === "!" && input.visualCursor.offset === 0) {
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
e.preventDefault()
return
}
if (store.mode === "shell") {
if ((e.name === "backspace" && input.visualCursor.offset === 0) || e.name === "escape") {
setStore("mode", "normal")
e.preventDefault()
return
}
}
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (!autocomplete.visible) {
if (
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
(keybind.match("history_next", e) && input.cursorOffset === input.plainText.length)
) {
const direction = keybind.match("history_previous", e) ? -1 : 1
const item = history.move(direction, input.plainText)
if (item) {
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
e.preventDefault()
if (direction === -1) input.cursorOffset = 0
if (direction === 1) input.cursorOffset = input.plainText.length
}
return
}
if (keybind.match("history_previous", e) && input.visualCursor.visualRow === 0) input.cursorOffset = 0
if (keybind.match("history_next", e) && input.visualCursor.visualRow === input.height - 1)
input.cursorOffset = input.plainText.length
}
}} }}
onSubmit={() => { onSubmit={() => {
// IME: double-defer so the last composed character (e.g. Korean // IME: double-defer so the last composed character (e.g. Korean
@@ -1358,7 +1503,7 @@ export function Prompt(props: PromptProps) {
// Windows Terminal <1.25 can surface image-only clipboard as an // Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not. // empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) { if (!pastedContent) {
command.trigger("prompt.paste") keymap.dispatchCommand("prompt.paste")
return return
} }
@@ -1366,67 +1511,11 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves. // default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault() event.preventDefault()
const filepath = iife(() => { await pasteInputText(normalizedText)
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) {
try {
return fileURLToPath(raw)
} catch {}
}
if (process.platform === "win32") return raw
return raw.replace(/\\(.)/g, "$1")
})
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
try {
const mime = await Filesystem.mimeType(filepath)
const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") {
const content = await Filesystem.readText(filepath).catch(() => {})
if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`)
return
}
}
if (mime.startsWith("image/") || mime === "application/pdf") {
const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteAttachment({
filename,
filepath,
mime,
content,
})
return
}
}
} catch {}
}
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
input.insertText(normalizedText)
// Force layout update and render for the pasted content
setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly
if (!input || input.isDestroyed) return
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
}} }}
ref={(r: TextareaRenderable) => { ref={(r: TextareaRenderable) => {
input = r input = r
setInputTarget(r)
if (promptPartTypeId === 0) { if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part") promptPartTypeId = input.extmarks.registerType("prompt-part")
} }
@@ -1455,7 +1544,7 @@ export function Prompt(props: PromptProps) {
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text> <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
<text <text
flexShrink={0} flexShrink={0}
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())} fg={fadeColor(leader() ? theme.textMuted : theme.text, modelMetaAlpha())}
> >
{local.model.parsed().model} {local.model.parsed().model}
</text> </text>
@@ -1646,12 +1735,12 @@ export function Prompt(props: PromptProps) {
</Match> </Match>
<Match when={true}> <Match when={true}>
<text fg={theme.text}> <text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span> {agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text> </text>
</Match> </Match>
</Switch> </Switch>
<text fg={theme.text}> <text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span> {paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text> </text>
</Match> </Match>
<Match when={store.mode === "shell"}> <Match when={store.mode === "shell"}>
@@ -1664,6 +1753,28 @@ export function Prompt(props: PromptProps) {
</Show> </Show>
</box> </box>
</box> </box>
<Autocomplete
sessionID={props.sessionID}
ref={(r) => {
setAuto(() => r)
}}
anchor={() => anchor}
input={() => input}
setPrompt={(cb) => {
setStore("prompt", produce(cb))
}}
setExtmark={(partIndex, extmarkId) => {
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
const newMap = new Map(map)
newMap.set(extmarkId, partIndex)
return newMap
})
}}
value={store.prompt.input}
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
</> </>
) )
} }
@@ -8,6 +8,11 @@ export interface PromptTraitsInput {
autocompleteVisible: boolean autocompleteVisible: boolean
} }
export type PromptTraits = EditorTraits & {
owner: "opencode"
role: "prompt"
}
/** /**
* Compute the textarea editor traits for the prompt. * Compute the textarea editor traits for the prompt.
* *
@@ -16,7 +21,7 @@ export interface PromptTraitsInput {
* editing mode — only `disabled` should suspend the textarea, otherwise * editing mode — only `disabled` should suspend the textarea, otherwise
* users can type in shell mode but cannot delete or move the cursor. * users can type in shell mode but cannot delete or move the cursor.
*/ */
export function computePromptTraits(input: PromptTraitsInput): EditorTraits { export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
const capture = const capture =
input.mode === "normal" input.mode === "normal"
? input.autocompleteVisible ? input.autocompleteVisible
@@ -27,5 +32,7 @@ export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
capture, capture,
suspend: input.disabled, suspend: input.disabled,
status: input.mode === "shell" ? "SHELL" : undefined, status: input.mode === "shell" ? "SHELL" : undefined,
owner: "opencode",
role: "prompt",
} }
} }
@@ -1,73 +0,0 @@
import { createMemo } from "solid-js"
import type { KeyBinding } from "@opentui/core"
import { useKeybind } from "../context/keybind"
import { Keybind } from "@/util/keybind"
const TEXTAREA_ACTIONS = [
"submit",
"newline",
"move-left",
"move-right",
"move-up",
"move-down",
"select-left",
"select-right",
"select-up",
"select-down",
"line-home",
"line-end",
"select-line-home",
"select-line-end",
"visual-line-home",
"visual-line-end",
"select-visual-line-home",
"select-visual-line-end",
"buffer-home",
"buffer-end",
"select-buffer-home",
"select-buffer-end",
"delete-line",
"delete-to-line-end",
"delete-to-line-start",
"backspace",
"delete",
"undo",
"redo",
"word-forward",
"word-backward",
"select-word-forward",
"select-word-backward",
"delete-word-forward",
"delete-word-backward",
] as const
function mapTextareaKeybindings(
keybinds: Record<string, Keybind.Info[]>,
action: (typeof TEXTAREA_ACTIONS)[number],
): KeyBinding[] {
const configKey = `input_${action.replace(/-/g, "_")}`
const bindings = keybinds[configKey]
if (!bindings) return []
return bindings.map((binding) => ({
name: binding.name,
ctrl: binding.ctrl || undefined,
meta: binding.meta || undefined,
shift: binding.shift || undefined,
super: binding.super || undefined,
action,
}))
}
export function useTextareaKeybindings() {
const keybind = useKeybind()
return createMemo(() => {
const keybinds = keybind.all
return [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)),
] satisfies KeyBinding[]
})
}
@@ -0,0 +1,177 @@
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { BindingValue } from "@opentui/keymap/extras"
import { ConfigKeybinds } from "@/config/keybinds"
import { type KeymapConfigInput, type KeymapSection } from "./tui-schema"
type LegacyKeybinds = Partial<ConfigKeybinds.Keybinds>
type SectionsConfig = Record<string, Record<string, BindingValue<Renderable, KeyEvent>>>
const inputCommands = {
input_submit: "input.submit",
input_newline: "input.newline",
input_move_left: "input.move.left",
input_move_right: "input.move.right",
input_move_up: "input.move.up",
input_move_down: "input.move.down",
input_select_left: "input.select.left",
input_select_right: "input.select.right",
input_select_up: "input.select.up",
input_select_down: "input.select.down",
input_line_home: "input.line.home",
input_line_end: "input.line.end",
input_select_line_home: "input.select.line.home",
input_select_line_end: "input.select.line.end",
input_visual_line_home: "input.visual.line.home",
input_visual_line_end: "input.visual.line.end",
input_select_visual_line_home: "input.select.visual.line.home",
input_select_visual_line_end: "input.select.visual.line.end",
input_buffer_home: "input.buffer.home",
input_buffer_end: "input.buffer.end",
input_select_buffer_home: "input.select.buffer.home",
input_select_buffer_end: "input.select.buffer.end",
input_delete_line: "input.delete.line",
input_delete_to_line_end: "input.delete.to.line.end",
input_delete_to_line_start: "input.delete.to.line.start",
input_backspace: "input.backspace",
input_delete: "input.delete",
input_undo: "input.undo",
input_redo: "input.redo",
input_word_forward: "input.word.forward",
input_word_backward: "input.word.backward",
input_select_word_forward: "input.select.word.forward",
input_select_word_backward: "input.select.word.backward",
input_delete_word_forward: "input.delete.word.forward",
input_delete_word_backward: "input.delete.word.backward",
input_select_all: "input.select.all",
} as const satisfies Partial<Record<keyof LegacyKeybinds, string>>
function add(config: SectionsConfig, section: KeymapSection, command: string, binding: BindingValue<Renderable, KeyEvent> | undefined) {
if (binding === undefined) return
config[section] ??= {}
config[section][command] = binding
}
function bindingWith(key: string | undefined, input: Omit<Binding<Renderable, KeyEvent>, "key" | "cmd">) {
if (!key) return undefined
if (key === "none") return "none"
return { ...input, key }
}
function combineBindings(...keys: (string | undefined)[]) {
const result = Array.from(
new Set(
keys.flatMap((key) => {
if (!key || key === "none") return []
return key
.split(",")
.map((part) => part.trim())
.filter((part) => part && part !== "none")
}),
),
)
if (result.length) return result.join(",")
if (keys.some((key) => key === "none")) return "none"
return undefined
}
export function create(keybinds: LegacyKeybinds): KeymapConfigInput {
const config: SectionsConfig = {}
add(config, "global", "command.palette.show", keybinds.command_list)
add(config, "global", "session.list", keybinds.session_list)
add(config, "global", "session.new", keybinds.session_new)
add(config, "global", "model.list", keybinds.model_list)
add(config, "global", "model.cycle_recent", keybinds.model_cycle_recent)
add(config, "global", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse)
add(config, "global", "model.cycle_favorite", keybinds.model_cycle_favorite)
add(config, "global", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse)
add(config, "global", "agent.list", keybinds.agent_list)
add(config, "global", "agent.cycle", keybinds.agent_cycle)
add(config, "global", "agent.cycle.reverse", keybinds.agent_cycle_reverse)
add(config, "global", "variant.cycle", keybinds.variant_cycle)
add(config, "global", "variant.list", keybinds.variant_list)
add(config, "prompt", "prompt.editor", keybinds.editor_open)
add(config, "global", "opencode.status", keybinds.status_view)
add(config, "global", "theme.switch", keybinds.theme_list)
add(config, "global", "app.exit", keybinds.app_exit)
add(config, "global", "terminal.suspend", keybinds.terminal_suspend)
add(config, "global", "terminal.title.toggle", keybinds.terminal_title_toggle)
add(config, "session", "session.share", keybinds.session_share)
add(config, "session", "session.rename", keybinds.session_rename)
add(config, "session", "session.timeline", keybinds.session_timeline)
add(config, "session", "session.fork", keybinds.session_fork)
add(config, "session", "session.compact", keybinds.session_compact)
add(config, "session", "session.unshare", keybinds.session_unshare)
add(config, "session", "session.undo", keybinds.messages_undo)
add(config, "session", "session.redo", keybinds.messages_redo)
add(config, "session", "session.sidebar.toggle", keybinds.sidebar_toggle)
add(config, "session", "session.toggle.conceal", keybinds.messages_toggle_conceal)
add(config, "session", "session.toggle.thinking", keybinds.display_thinking)
add(config, "session", "session.toggle.actions", keybinds.tool_details)
add(config, "session", "session.toggle.scrollbar", keybinds.scrollbar_toggle)
add(config, "session", "session.page.up", keybinds.messages_page_up)
add(config, "session", "session.page.down", keybinds.messages_page_down)
add(config, "session", "session.line.up", keybinds.messages_line_up)
add(config, "session", "session.line.down", keybinds.messages_line_down)
add(config, "session", "session.half.page.up", keybinds.messages_half_page_up)
add(config, "session", "session.half.page.down", keybinds.messages_half_page_down)
add(config, "session", "session.first", keybinds.messages_first)
add(config, "session", "session.last", keybinds.messages_last)
add(config, "session", "session.messages_last_user", keybinds.messages_last_user)
add(config, "session", "session.message.next", keybinds.messages_next)
add(config, "session", "session.message.previous", keybinds.messages_previous)
add(config, "session", "messages.copy", keybinds.messages_copy)
add(config, "session", "session.export", keybinds.session_export)
add(config, "session", "session.child.first", keybinds.session_child_first)
add(config, "session", "session.parent", keybinds.session_parent)
add(config, "session", "session.child.next", keybinds.session_child_cycle)
add(config, "session", "session.child.previous", keybinds.session_child_cycle_reverse)
add(config, "prompt", "session.interrupt", keybinds.session_interrupt)
add(config, "prompt", "prompt.clear", keybinds.input_clear)
add(config, "prompt", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false }))
add(config, "prompt", "prompt.history.previous", keybinds.history_previous)
add(config, "prompt", "prompt.history.next", keybinds.history_next)
add(config, "autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"])
add(config, "autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"])
add(config, "autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"])
add(config, "autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"])
add(config, "autocomplete", "prompt.autocomplete.complete", keybinds["prompt.autocomplete.complete"])
for (const [legacy, command] of Object.entries(inputCommands) as [keyof typeof inputCommands, string][]) {
add(config, "input", command, keybinds[legacy])
}
add(config, "dialog_select", "dialog.select.prev", keybinds["dialog.select.prev"])
add(config, "dialog_select", "dialog.select.next", keybinds["dialog.select.next"])
add(config, "dialog_select", "dialog.select.page_up", keybinds["dialog.select.page_up"])
add(config, "dialog_select", "dialog.select.page_down", keybinds["dialog.select.page_down"])
add(config, "dialog_select", "dialog.select.home", keybinds["dialog.select.home"])
add(config, "dialog_select", "dialog.select.end", keybinds["dialog.select.end"])
add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"])
add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete))
add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename)
add(config, "dialog_actions", "dialog.action.toggle", combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"]))
add(config, "model", "model.dialog.provider", keybinds.model_provider_list)
add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle)
add(config, "permission", "permission.reject.cancel", keybinds.app_exit)
add(config, "permission", "permission.prompt.escape", keybinds.app_exit)
add(config, "permission", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"])
add(config, "question", "question.reject", keybinds.app_exit)
add(config, "question", "question.edit.clear", keybinds.input_clear)
add(config, "plugins", "plugins.list", keybinds.plugin_manager)
add(config, "plugins", "plugin.dialog.install", keybinds["dialog.plugins.install"])
add(config, "home_tips", "tips.toggle", keybinds.tips_toggle)
return {
...(keybinds.leader && keybinds.leader !== "none" && { leader: keybinds.leader }),
sections: config,
}
}
export * as LegacyKeymapTransform from "./legacy-keymap-transform"
@@ -1,4 +1,7 @@
import z from "zod" import z from "zod"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { ResolvedBindingSections } from "@opentui/keymap/extras"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds" import { ConfigKeybinds } from "@/config/keybinds"
@@ -11,6 +14,303 @@ const KeybindOverride = z
) )
.strict() .strict()
const KeyStroke = z
.object({
name: z.string(),
ctrl: z.boolean().optional(),
shift: z.boolean().optional(),
meta: z.boolean().optional(),
super: z.boolean().optional(),
hyper: z.boolean().optional(),
})
.strict()
const KeymapBindingObject = z
.object({
key: z.union([z.string(), KeyStroke]),
event: z.enum(["press", "release"]).optional(),
preventDefault: z.boolean().optional(),
fallthrough: z.boolean().optional(),
})
.passthrough()
const KeymapBindingItem = z.union([z.string(), KeyStroke, KeymapBindingObject])
const KeymapBindingValue = z.union([z.literal(false), z.literal("none"), KeymapBindingItem, z.array(KeymapBindingItem)])
const keymapBinding = (value: z.input<typeof KeymapBindingValue> | (() => z.input<typeof KeymapBindingValue>)) =>
KeymapBindingValue.prefault(value)
const keymapSection = <Shape extends z.ZodRawShape>(shape: Shape) => {
const schema = z.object(shape).strict()
return schema.prefault({} as z.input<typeof schema>)
}
const keymapSectionInput = <Shape extends z.ZodRawShape>(shape: Shape) =>
z
.object(
Object.fromEntries(Object.keys(shape).map((key) => [key, KeymapBindingValue.optional()])) as {
[Key in keyof Shape]: z.ZodOptional<typeof KeymapBindingValue>
},
)
.strict()
const GlobalKeymapSection = {
"command.palette.show": keymapBinding("ctrl+p"),
"session.list": keymapBinding("<leader>l"),
"session.new": keymapBinding("<leader>n"),
"model.list": keymapBinding("<leader>m"),
"model.cycle_recent": keymapBinding("f2"),
"model.cycle_recent_reverse": keymapBinding("shift+f2"),
"model.cycle_favorite": keymapBinding("none"),
"model.cycle_favorite_reverse": keymapBinding("none"),
"agent.list": keymapBinding("<leader>a"),
"mcp.list": keymapBinding("none"),
"agent.cycle": keymapBinding("tab"),
"agent.cycle.reverse": keymapBinding("shift+tab"),
"variant.cycle": keymapBinding("ctrl+t"),
"variant.list": keymapBinding("none"),
"provider.connect": keymapBinding("none"),
"console.org.switch": keymapBinding("none"),
"opencode.status": keymapBinding("<leader>s"),
"theme.switch": keymapBinding("<leader>t"),
"theme.switch_mode": keymapBinding("none"),
"theme.mode.lock": keymapBinding("none"),
"help.show": keymapBinding("none"),
"docs.open": keymapBinding("none"),
"app.exit": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
"app.debug": keymapBinding("none"),
"app.console": keymapBinding("none"),
"app.heap_snapshot": keymapBinding("none"),
"app.toggle.animations": keymapBinding("none"),
"app.toggle.file_context": keymapBinding("none"),
"app.toggle.diffwrap": keymapBinding("none"),
"app.toggle.paste_summary": keymapBinding("none"),
"app.toggle.session_directory_filter": keymapBinding("none"),
"terminal.suspend": keymapBinding(() => (process.platform === "win32" ? "none" : "ctrl+z")),
"terminal.title.toggle": keymapBinding("none"),
}
const SessionKeymapSection = {
"session.share": keymapBinding("none"),
"session.rename": keymapBinding("ctrl+r"),
"session.timeline": keymapBinding("<leader>g"),
"session.fork": keymapBinding("none"),
"session.compact": keymapBinding("<leader>c"),
"session.unshare": keymapBinding("none"),
"session.undo": keymapBinding("<leader>u"),
"session.redo": keymapBinding("<leader>r"),
"session.sidebar.toggle": keymapBinding("<leader>b"),
"session.toggle.conceal": keymapBinding("<leader>h"),
"session.toggle.timestamps": keymapBinding("none"),
"session.toggle.thinking": keymapBinding("none"),
"session.toggle.actions": keymapBinding("none"),
"session.toggle.scrollbar": keymapBinding("none"),
"session.toggle.generic_tool_output": keymapBinding("none"),
"session.page.up": keymapBinding("pageup,ctrl+alt+b"),
"session.page.down": keymapBinding("pagedown,ctrl+alt+f"),
"session.line.up": keymapBinding("ctrl+alt+y"),
"session.line.down": keymapBinding("ctrl+alt+e"),
"session.half.page.up": keymapBinding("ctrl+alt+u"),
"session.half.page.down": keymapBinding("ctrl+alt+d"),
"session.first": keymapBinding("ctrl+g,home"),
"session.last": keymapBinding("ctrl+alt+g,end"),
"session.messages_last_user": keymapBinding("none"),
"session.message.next": keymapBinding("none"),
"session.message.previous": keymapBinding("none"),
"messages.copy": keymapBinding("<leader>y"),
"session.copy": keymapBinding("none"),
"session.export": keymapBinding("<leader>x"),
"session.child.first": keymapBinding("<leader>down"),
"session.parent": keymapBinding("up"),
"session.child.next": keymapBinding("right"),
"session.child.previous": keymapBinding("left"),
}
const PromptKeymapSection = {
"prompt.submit": keymapBinding("none"),
"prompt.editor": keymapBinding("<leader>e"),
"prompt.editor_context.clear": keymapBinding("none"),
"prompt.skills": keymapBinding("none"),
"prompt.stash": keymapBinding("none"),
"prompt.stash.pop": keymapBinding("none"),
"prompt.stash.list": keymapBinding("none"),
"workspace.set": keymapBinding("none"),
"session.interrupt": keymapBinding("escape"),
"prompt.clear": keymapBinding("ctrl+c"),
"prompt.paste": keymapBinding({ key: "ctrl+v", preventDefault: false }),
"prompt.history.previous": keymapBinding("up"),
"prompt.history.next": keymapBinding("down"),
}
const AutocompleteKeymapSection = {
"prompt.autocomplete.prev": keymapBinding("up,ctrl+p"),
"prompt.autocomplete.next": keymapBinding("down,ctrl+n"),
"prompt.autocomplete.hide": keymapBinding("escape"),
"prompt.autocomplete.select": keymapBinding("return"),
"prompt.autocomplete.complete": keymapBinding("tab"),
}
const InputKeymapSection = {
"input.submit": keymapBinding("return"),
"input.newline": keymapBinding("shift+return,ctrl+return,alt+return,ctrl+j"),
"input.move.left": keymapBinding("left,ctrl+b"),
"input.move.right": keymapBinding("right,ctrl+f"),
"input.move.up": keymapBinding("up"),
"input.move.down": keymapBinding("down"),
"input.select.left": keymapBinding("shift+left"),
"input.select.right": keymapBinding("shift+right"),
"input.select.up": keymapBinding("shift+up"),
"input.select.down": keymapBinding("shift+down"),
"input.line.home": keymapBinding("ctrl+a"),
"input.line.end": keymapBinding("ctrl+e"),
"input.select.line.home": keymapBinding("ctrl+shift+a"),
"input.select.line.end": keymapBinding("ctrl+shift+e"),
"input.visual.line.home": keymapBinding("alt+a"),
"input.visual.line.end": keymapBinding("alt+e"),
"input.select.visual.line.home": keymapBinding("alt+shift+a"),
"input.select.visual.line.end": keymapBinding("alt+shift+e"),
"input.buffer.home": keymapBinding("home"),
"input.buffer.end": keymapBinding("end"),
"input.select.buffer.home": keymapBinding("shift+home"),
"input.select.buffer.end": keymapBinding("shift+end"),
"input.delete.line": keymapBinding("ctrl+shift+d"),
"input.delete.to.line.end": keymapBinding("ctrl+k"),
"input.delete.to.line.start": keymapBinding("ctrl+u"),
"input.backspace": keymapBinding("backspace,shift+backspace"),
"input.delete": keymapBinding("ctrl+d,delete,shift+delete"),
"input.undo": keymapBinding(() => (process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")),
"input.redo": keymapBinding("ctrl+.,super+shift+z"),
"input.word.forward": keymapBinding("alt+f,alt+right,ctrl+right"),
"input.word.backward": keymapBinding("alt+b,alt+left,ctrl+left"),
"input.select.word.forward": keymapBinding("alt+shift+f,alt+shift+right"),
"input.select.word.backward": keymapBinding("alt+shift+b,alt+shift+left"),
"input.delete.word.forward": keymapBinding("alt+d,alt+delete,ctrl+delete"),
"input.delete.word.backward": keymapBinding("ctrl+w,ctrl+backspace,alt+backspace"),
"input.select.all": keymapBinding("super+a"),
}
const DialogSelectKeymapSection = {
"dialog.select.prev": keymapBinding("up,ctrl+p"),
"dialog.select.next": keymapBinding("down,ctrl+n"),
"dialog.select.page_up": keymapBinding("pageup"),
"dialog.select.page_down": keymapBinding("pagedown"),
"dialog.select.home": keymapBinding("home"),
"dialog.select.end": keymapBinding("end"),
"dialog.select.submit": keymapBinding("return"),
}
const DialogActionsKeymapSection = {
"dialog.action.toggle": keymapBinding("space"),
"dialog.action.delete": keymapBinding("ctrl+d"),
"dialog.action.rename": keymapBinding("ctrl+r"),
}
const ModelKeymapSection = {
"model.dialog.provider": keymapBinding("ctrl+a"),
"model.dialog.favorite": keymapBinding("ctrl+f"),
}
const PermissionKeymapSection = {
"permission.reject.cancel": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
"permission.prompt.escape": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
"permission.prompt.fullscreen": keymapBinding("ctrl+f"),
}
const QuestionKeymapSection = {
"question.reject": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
"question.edit.clear": keymapBinding("ctrl+c"),
}
const PluginsKeymapSection = {
"plugins.list": keymapBinding("none"),
"plugins.install": keymapBinding("none"),
"plugin.dialog.install": keymapBinding("shift+i"),
}
const HomeTipsKeymapSection = {
"tips.toggle": keymapBinding("<leader>h"),
}
const KeymapSectionsShape = {
global: keymapSection(GlobalKeymapSection),
session: keymapSection(SessionKeymapSection),
prompt: keymapSection(PromptKeymapSection),
autocomplete: keymapSection(AutocompleteKeymapSection),
input: keymapSection(InputKeymapSection),
dialog_select: keymapSection(DialogSelectKeymapSection),
dialog_actions: keymapSection(DialogActionsKeymapSection),
model: keymapSection(ModelKeymapSection),
permission: keymapSection(PermissionKeymapSection),
question: keymapSection(QuestionKeymapSection),
plugins: keymapSection(PluginsKeymapSection),
home_tips: keymapSection(HomeTipsKeymapSection),
}
const KeymapSectionsInputShape = {
global: keymapSectionInput(GlobalKeymapSection).optional(),
session: keymapSectionInput(SessionKeymapSection).optional(),
prompt: keymapSectionInput(PromptKeymapSection).optional(),
autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(),
input: keymapSectionInput(InputKeymapSection).optional(),
dialog_select: keymapSectionInput(DialogSelectKeymapSection).optional(),
dialog_actions: keymapSectionInput(DialogActionsKeymapSection).optional(),
model: keymapSectionInput(ModelKeymapSection).optional(),
permission: keymapSectionInput(PermissionKeymapSection).optional(),
question: keymapSectionInput(QuestionKeymapSection).optional(),
plugins: keymapSectionInput(PluginsKeymapSection).optional(),
home_tips: keymapSectionInput(HomeTipsKeymapSection).optional(),
}
export const KeymapSections = z.object(KeymapSectionsShape).strict().prefault({})
export type KeymapSections = z.output<typeof KeymapSections>
export type KeymapSection = keyof KeymapSections
export const KeymapSectionNames = Object.keys(KeymapSectionsShape) as KeymapSection[]
export const KeymapLeaderTimeoutDefault = 2000
export type KeymapInfo = {
leader: string
leader_timeout: number
} & ResolvedBindingSections<Renderable, KeyEvent, KeymapSection>
export const KeymapSectionGroups = {
global: "Global",
session: "Session",
prompt: "Prompt",
autocomplete: "Autocomplete",
input: "Text Editing",
dialog_select: "Dialog",
dialog_actions: "Dialog",
model: "Model",
permission: "Permission",
question: "Question",
plugins: "Plugins",
home_tips: "Home",
} satisfies Record<KeymapSection, string>
export function keymapBindingDefaults(input: { section: string; binding: Readonly<Binding<Renderable, KeyEvent>> }) {
if (input.binding.group !== undefined) return
if (!Object.hasOwn(KeymapSectionGroups, input.section)) return
return { group: KeymapSectionGroups[input.section as KeymapSection] }
}
export const KeymapConfig = z
.object({
leader: z.string().prefault("ctrl+x"),
leader_timeout: z.number().int().positive().prefault(KeymapLeaderTimeoutDefault).describe("Leader key timeout in milliseconds"),
sections: KeymapSections,
})
.strict()
.describe("TUI keymap configuration")
export type KeymapConfig = z.output<typeof KeymapConfig>
const KeymapSectionsInput = z.object(KeymapSectionsInputShape).strict().optional()
export const KeymapConfigInput = z
.object({
leader: z.string().optional(),
leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"),
sections: KeymapSectionsInput,
})
.strict()
.describe("TUI keymap configuration")
export type KeymapConfigInput = z.output<typeof KeymapConfigInput>
export const TuiOptions = z.object({ export const TuiOptions = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z scroll_acceleration: z
@@ -30,9 +330,17 @@ export const TuiInfo = z
.object({ .object({
$schema: z.string().optional(), $schema: z.string().optional(),
theme: z.string().optional(), theme: z.string().optional(),
keybinds: KeybindOverride.optional(), keybinds: KeybindOverride.optional().meta({
deprecated: true,
description: "Use keymap instead. This will be removed in opencode v2.0.",
}),
keymap: KeymapConfigInput.optional(),
plugin: ConfigPlugin.Spec.zod.array().optional(), plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(),
}) })
.extend(TuiOptions.shape) .extend(TuiOptions.shape)
.strict() .strict()
export const TuiJsonSchemaInfo = TuiInfo.extend({
keymap: KeymapConfig.optional(),
}).strict()
+46 -14
View File
@@ -1,12 +1,14 @@
export * as TuiConfig from "./tui" export * as TuiConfig from "./tui"
import z from "zod" import type z from "zod"
import type { KeyEvent, Renderable } from "@opentui/core"
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
import { mergeDeep, unique } from "remeda" import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect" import { Context, Effect, Fiber, Layer } from "effect"
import { ConfigParse } from "@/config/parse" import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths" import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate" import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema" import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
@@ -20,27 +22,34 @@ import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { ConfigVariable } from "@/config/variable" import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { LegacyKeymapTransform } from "./legacy-keymap-transform"
import {
KeymapSectionNames,
keymapBindingDefaults,
type KeymapInfo,
type KeymapSection,
} from "./tui-schema"
const log = Log.create({ service: "tui.config" }) const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo export const Info = TuiInfo
export const JsonSchemaInfo = TuiJsonSchemaInfo
export type Info = z.output<typeof Info>
type Acc = { type Acc = {
result: Info result: Info
plugin_origins: ConfigPlugin.Origin[]
} }
type State = { export type Resolved = Omit<Info, "keybinds" | "keymap"> & {
config: Info keybinds: ConfigKeybinds.Keybinds
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>> keymap: KeymapInfo
}
export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading. // Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[] plugin_origins?: ConfigPlugin.Origin[]
} }
export interface Interface { export interface Interface {
readonly get: () => Effect.Effect<Info> readonly get: () => Effect.Effect<Resolved>
readonly waitForDependencies: () => Effect.Effect<void> readonly waitForDependencies: () => Effect.Effect<void>
} }
@@ -128,11 +137,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const scope = pluginScope(file, ctx) const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([ const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []), ...acc.plugin_origins,
...data.plugin.map((spec) => ({ spec, scope, source: file })), ...data.plugin.map((spec) => ({ spec, scope, source: file })),
]) ])
acc.result.plugin = plugins.map((item) => item.spec) acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins acc.plugin_origins = plugins
}) })
// Every config dir we may read from: global config dir, any `.opencode` // Every config dir we may read from: global config dir, any `.opencode`
@@ -144,6 +153,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const acc: Acc = { const acc: Acc = {
result: {}, result: {},
plugin_origins: [],
} }
// 1. Global tui config (lowest precedence). // 1. Global tui config (lowest precedence).
@@ -184,11 +194,33 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",") ]).join(",")
} }
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds) const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds)
const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {})
const keymapConfig = KeymapConfig.parse(keymapInput)
const keymap = {
leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader,
leader_timeout: keymapConfig.leader_timeout,
...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
keymapConfig.sections,
{
sections: KeymapSectionNames,
bindingDefaults: keymapBindingDefaults,
},
),
}
const result: Resolved = {
...acc.result,
keybinds: parsedKeybinds,
plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined,
// `keybinds` is deprecated and will be removed in opencode v2.0. Keep it
// only as the legacy fallback; once `keymap` is configured, ignore
// `keybinds` for keymap resolution.
keymap,
}
return { return {
config: acc.result, config: result,
dirs: acc.result.plugin?.length ? dirs : [], dirs: result.plugin?.length ? dirs : [],
} }
}) })
@@ -0,0 +1,163 @@
import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps } from "solid-js"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import {
formatKeyBindings,
reactiveMatcherFromSignal,
type OpenTuiKeymap,
useKeymapSelector,
useOpencodeKeymap,
} from "../keymap"
import { useTuiConfig } from "./tui-config"
type SlashEntry = {
display: string
description?: string
aliases?: string[]
onSelect: () => void
}
type CommandPaletteContext = {
run(command: string): void
show(): void
slashes: Accessor<readonly SlashEntry[]>
suspend(enabled: boolean): void
readonly suspended: boolean
matcher: ReturnType<typeof reactiveMatcherFromSignal>
}
const COMMAND_PALETTE_DIALOG = "command.palette.show"
const ctx = createContext<CommandPaletteContext>()
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
function isVisiblePaletteCommand(entry: PaletteCommandEntry) {
return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_DIALOG
}
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
const suggested = entry.command.suggested
if (typeof suggested === "boolean") return suggested
if (typeof suggested === "function") return suggested() === true
return false
}
export function CommandPaletteProvider(props: ParentProps) {
const dialog = useDialog()
const keymap = useOpencodeKeymap()
const [suspendCount, setSuspendCount] = createSignal(0)
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
keymap
.getCommandEntries({
visibility: "reachable",
namespace: "palette",
})
.filter(isVisiblePaletteCommand),
)
const run = (command: string) => {
keymap.dispatchCommand(command)
}
const slashes = createMemo<SlashEntry[]>(() =>
entries().flatMap((entry) => {
const slashName = entry.command.slashName
if (typeof slashName !== "string" || !slashName) return []
const slashAliases = entry.command.slashAliases
return {
display: `/${slashName}`,
description:
typeof entry.command.desc === "string"
? entry.command.desc
: typeof entry.command.title === "string"
? entry.command.title
: undefined,
aliases: Array.isArray(slashAliases)
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
: undefined,
onSelect: () => run(entry.command.name),
}
}),
)
const value: CommandPaletteContext = {
run,
show() {
dialog.replace(() => <CommandPaletteDialog run={run} />)
},
slashes,
suspend(enabled: boolean) {
setSuspendCount((count) => Math.max(0, count + (enabled ? 1 : -1)))
},
get suspended() {
return suspendCount() > 0 || dialog.stack.length > 0
},
matcher: reactiveMatcherFromSignal(() => suspendCount() === 0 && dialog.stack.length === 0),
}
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useCommandPalette() {
const value = useContext(ctx)
if (!value) throw new Error("CommandPalette context must be used within a CommandPaletteProvider")
return value
}
function CommandPaletteDialog(props: { run(command: string): void }) {
const config = useTuiConfig()
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
const query = {
namespace: "palette",
}
const reachable = keymap
.getCommandEntries({
...query,
visibility: "reachable",
})
.filter(isVisiblePaletteCommand)
const registeredBindings = keymap.getCommandBindings({
visibility: "registered",
commands: reachable.map((entry) => entry.command.name),
})
return reachable.map((entry) => ({
...entry,
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
}))
})
const options = createMemo(() =>
entries().map((entry) => ({
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
footer: formatKeyBindings(entry.bindings, config),
value: entry.command.name,
suggested: isSuggestedPaletteCommand(entry),
onSelect: (dialog: DialogContext) => {
dialog.clear()
props.run(entry.command.name)
},
})),
)
let ref: DialogSelectRef<string>
const list = () => {
if (ref?.filter) return options()
return [
...options()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
...options(),
]
}
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
}
export function useCommandSlashes(): Accessor<readonly SlashEntry[]> {
return useCommandPalette().slashes
}
@@ -1,105 +0,0 @@
import { createMemo } from "solid-js"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { useTuiConfig } from "./tui-config"
export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: () => {
const config = useTuiConfig()
const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
return pipe(
(config.keybinds ?? {}) as Record<string, string>,
mapValues((value) => Keybind.parse(value)),
)
})
const [store, setStore] = createStore({
leader: false,
})
const renderer = useRenderer()
let focus: Renderable | null
let timeout: NodeJS.Timeout
function leader(active: boolean) {
if (active) {
setStore("leader", true)
focus = renderer.currentFocusedRenderable
focus?.blur()
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
if (!store.leader) return
leader(false)
if (!focus || focus.isDestroyed) return
focus.focus()
}, 2000)
return
}
if (!active) {
if (focus && !renderer.currentFocusedRenderable) {
focus.focus()
}
setStore("leader", false)
}
}
useKeyboard(async (evt) => {
if (!store.leader && result.match("leader", evt)) {
leader(true)
return
}
if (store.leader && evt.name) {
setImmediate(() => {
if (focus && renderer.currentFocusedRenderable === focus) {
focus.focus()
}
leader(false)
})
}
})
const result = {
get all() {
return keybinds()
},
get leader() {
return store.leader
},
parse(evt: ParsedKey): Keybind.Info {
// Handle special case for Ctrl+Underscore (represented as \x1F)
if (evt.name === "\x1F") {
return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader)
}
return Keybind.fromParsedKey(evt, store.leader)
},
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 item of list) {
if (Keybind.match(item, parsed)) {
return true
}
}
return false
},
print(key: string) {
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
if (!first) return ""
const text = Keybind.toString(first)
const lead = keybinds().leader?.[0]
if (!lead) return text
return text.replace("<leader>", Keybind.toString(lead))
},
}
return result
},
})
@@ -1,41 +0,0 @@
import type { ParsedKey } from "@opentui/core"
export type PluginKeybindMap = Record<string, string>
type Base = {
match: (key: string, evt: ParsedKey) => boolean
print: (key: string) => string
}
export type PluginKeybind = {
readonly all: PluginKeybindMap
get: (name: string) => string
match: (name: string, evt: ParsedKey) => boolean
print: (name: string) => string
}
const txt = (value: unknown) => {
if (typeof value !== "string") return
if (!value.trim()) return
return value
}
export function createPluginKeybind(
base: Base,
defaults: PluginKeybindMap,
overrides?: Record<string, unknown>,
): PluginKeybind {
const all = Object.freeze(
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
)
const get = (name: string) => all[name] ?? name
return {
get all() {
return all
},
get,
match: (name, evt) => base.match(get(name), evt),
print: (name) => base.print(get(name)),
}
}
@@ -3,7 +3,7 @@ import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({ export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
name: "TuiConfig", name: "TuiConfig",
init: (props: { config: TuiConfig.Info }) => { init: (props: { config: TuiConfig.Resolved }) => {
return props.config return props.config
}, },
}) })
@@ -1,10 +1,27 @@
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { Tips } from "./tips-view" import { Tips } from "./tips-view"
import { useBindings } from "../../keymap"
const id = "internal:home-tips" const id = "internal:home-tips"
function View(props: { show: boolean; connected: boolean }) { function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) {
useBindings(() => ({
commands: [
{
name: "tips.toggle",
title: props.hidden ? "Show tips" : "Hide tips",
category: "System",
namespace: "palette",
run() {
props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false))
props.api.ui.dialog.clear()
},
},
],
bindings: props.api.tuiConfig.keymap.sections.home_tips,
}))
return ( return (
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}> <box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={props.show}> <Show when={props.show}>
@@ -15,20 +32,6 @@ function View(props: { show: boolean; connected: boolean }) {
} }
const tui: TuiPlugin = async (api) => { const tui: TuiPlugin = async (api) => {
api.command.register(() => [
{
title: api.kv.get("tips_hidden", false) ? "Show tips" : "Hide tips",
value: "tips.toggle",
keybind: "tips_toggle",
category: "System",
hidden: api.route.current.name !== "home",
onSelect() {
api.kv.set("tips_hidden", !api.kv.get("tips_hidden", false))
api.ui.dialog.clear()
},
},
])
api.slots.register({ api.slots.register({
order: 100, order: 100,
slots: { slots: {
@@ -41,7 +44,7 @@ const tui: TuiPlugin = async (api) => {
), ),
) )
const show = createMemo(() => (!first() || !connected()) && !hidden()) const show = createMemo(() => (!first() || !connected()) && !hidden())
return <View show={show()} connected={connected()} /> return <View api={api} hidden={hidden()} show={show()} connected={connected()} />
}, },
}, },
}) })
@@ -1,14 +1,11 @@
import { Keybind } from "@/util/keybind"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { Show, createEffect, createMemo, createSignal } from "solid-js" import { Show, createEffect, createMemo, createSignal } from "solid-js"
import { useBindings } from "../../keymap"
const id = "internal:plugin-manager" const id = "internal:plugin-manager"
const key = Keybind.parse("space").at(0)
const add = Keybind.parse("shift+i").at(0)
const tab = Keybind.parse("tab").at(0)
function state(api: TuiPluginApi, item: TuiPluginStatus) { function state(api: TuiPluginApi, item: TuiPluginStatus) {
if (!item.enabled) { if (!item.enabled) {
@@ -41,13 +38,10 @@ function Install(props: { api: TuiPluginApi }) {
const [global, setGlobal] = createSignal(false) const [global, setGlobal] = createSignal(false)
const [busy, setBusy] = createSignal(false) const [busy, setBusy] = createSignal(false)
useKeyboard((evt) => { useBindings(() => ({
if (evt.name !== "tab") return enabled: !busy(),
evt.preventDefault() bindings: [{ key: "tab", cmd: () => setGlobal((value) => !value) }],
evt.stopPropagation() }))
if (busy()) return
setGlobal((x) => !x)
})
return ( return (
<props.api.ui.DialogPrompt <props.api.ui.DialogPrompt
@@ -62,7 +56,7 @@ function Install(props: { api: TuiPluginApi }) {
{global() ? "global" : "local"} {global() ? "global" : "local"}
</text> </text>
<Show when={!busy()}> <Show when={!busy()}>
<text fg={props.api.theme.current.textMuted}>({Keybind.toString(tab)} toggle)</text> <text fg={props.api.theme.current.textMuted}>(tab toggle)</text>
</Show> </Show>
</box> </box>
)} )}
@@ -209,10 +203,10 @@ function View(props: { api: TuiPluginApi }) {
options={rows()} options={rows()}
current={cur()} current={cur()}
onMove={(item) => setCur(item.value)} onMove={(item) => setCur(item.value)}
keybind={[ actions={[
{ {
title: "toggle", title: "toggle",
keybind: key, command: "dialog.action.toggle",
disabled: lock(), disabled: lock(),
onTrigger: (item) => { onTrigger: (item) => {
setCur(item.value) setCur(item.value)
@@ -221,13 +215,14 @@ function View(props: { api: TuiPluginApi }) {
}, },
{ {
title: "install", title: "install",
keybind: add, command: "plugin.dialog.install",
disabled: lock(), disabled: lock(),
onTrigger: () => { onTrigger: () => {
showInstall(props.api) showInstall(props.api)
}, },
}, },
]} ]}
bindings={props.api.tuiConfig.keymap.pick("plugins", ["plugin.dialog.install"])}
onSelect={(item) => { onSelect={(item) => {
setCur(item.value) setCur(item.value)
flip(item.value) flip(item.value)
@@ -241,25 +236,29 @@ function show(api: TuiPluginApi) {
} }
const tui: TuiPlugin = async (api) => { const tui: TuiPlugin = async (api) => {
api.command.register(() => [ api.keymap.registerLayer({
{ commands: [
title: "Plugins", {
value: "plugins.list", name: "plugins.list",
keybind: "plugin_manager", title: "Plugins",
category: "System", category: "System",
onSelect() { namespace: "palette",
show(api) run() {
show(api)
},
}, },
}, {
{ name: "plugins.install",
title: "Install plugin", title: "Install plugin",
value: "plugins.install", category: "System",
category: "System", namespace: "palette",
onSelect() { run() {
showInstall(api) showInstall(api)
},
}, },
}, ],
]) bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]),
})
} }
const plugin: TuiPluginModule & { id: string } = { const plugin: TuiPluginModule & { id: string } = {
@@ -4,8 +4,9 @@ import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner" import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { useBindings } from "../../keymap"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import path from "path" import path from "path"
@@ -53,12 +54,16 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
void sync.session.message.sync(props.sessionID) void sync.session.message.sync(props.sessionID)
}) })
useKeyboard((event) => { useBindings(() => ({
if (event.name !== "escape") return bindings: [
event.preventDefault() {
event.stopPropagation() key: "escape",
props.api.route.navigate("session", { sessionID: props.sessionID }) cmd() {
}) props.api.route.navigate("session", { sessionID: props.sessionID })
},
},
],
}))
return ( return (
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}> <box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
@@ -1113,21 +1118,24 @@ const tui: TuiPlugin = async (api) => {
}, },
]) ])
api.command.register(() => [ api.keymap.registerLayer({
{ commands: [
title: "View v2 session messages", {
value: route, name: route,
category: "Debug", title: "View v2 session messages",
suggested: api.route.current.name === "session", category: "Debug",
enabled: api.route.current.name === "session", namespace: "palette",
onSelect() { suggested: () => api.route.current.name === "session",
const sessionID = currentSessionID(api) enabled: () => api.route.current.name === "session",
if (!sessionID) return run() {
api.route.navigate(route, { sessionID }) const sessionID = currentSessionID(api)
api.ui.dialog.clear() if (!sessionID) return
api.route.navigate(route, { sessionID })
api.ui.dialog.clear()
},
}, },
}, ],
]) })
} }
const plugin: TuiPluginModule & { id: string } = { const plugin: TuiPluginModule & { id: string } = {
@@ -0,0 +1,91 @@
import { type CliRenderer } from "@opentui/core"
import * as addons from "@opentui/keymap/addons/opentui"
import {
formatCommandBindings as formatCommandBindingsExtra,
formatKeySequence as formatKeySequenceExtra,
} from "@opentui/keymap/extras"
import {
KeymapProvider,
reactiveMatcherFromSignal,
useBindings,
useKeymap,
useKeymapSelector,
} from "@opentui/keymap/solid"
import type { Accessor } from "solid-js"
import type { TuiConfig } from "./config/tui"
import { useTuiConfig } from "./context/tui-config"
export const LEADER_TOKEN = "leader"
export const OpencodeKeymapProvider = KeymapProvider
export const useOpencodeKeymap = useKeymap
export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
function formatOptions(config: TuiConfig.Resolved) {
return {
tokenDisplay: {
[LEADER_TOKEN]: config.keymap.leader,
},
keyNameAliases: {
pageup: "pgup",
pagedown: "pgdn",
delete: "del",
},
modifierAliases: {
meta: "alt",
},
} as const
}
export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: TuiConfig.Resolved) {
return formatKeySequenceExtra(parts, formatOptions(config))
}
export function formatKeyBindings(
bindings: Parameters<typeof formatCommandBindingsExtra>[0],
config: TuiConfig.Resolved,
) {
return formatCommandBindingsExtra(bindings, formatOptions(config))
}
export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) {
const offCommaBindings = addons.registerCommaBindings(keymap)
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
const offLeader = addons.registerTimedLeader(keymap, {
trigger: config.keymap.leader,
name: LEADER_TOKEN,
timeoutMs: config.keymap.leader_timeout,
})
const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
enabled: () => renderer.currentFocusedEditor !== null,
bindings: config.keymap.sections.input,
})
return () => {
offInputBindings()
offBackspace()
offEscape()
offLeader()
offBaseLayout()
offCommaBindings()
}
}
export function useCommandShortcut(command: string): Accessor<string> {
const config = useTuiConfig()
return useKeymapSelector((keymap) =>
formatKeySequence(
keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence,
config,
),
)
}
export function useLeaderActive(): Accessor<boolean> {
return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
}
@@ -1,15 +1,12 @@
import type { ParsedKey } from "@opentui/core"
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { useCommandDialog } from "@tui/component/dialog-command"
import type { useEvent } from "@tui/context/event" import type { useEvent } from "@tui/context/event"
import type { useKeybind } from "@tui/context/keybind"
import type { useRoute } from "@tui/context/route" import type { useRoute } from "@tui/context/route"
import type { useSDK } from "@tui/context/sdk" import type { useSDK } from "@tui/context/sdk"
import type { useSync } from "@tui/context/sync" import type { useSync } from "@tui/context/sync"
import type { useTheme } from "@tui/context/theme" import type { useTheme } from "@tui/context/theme"
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog" import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
import type { TuiConfig } from "@/cli/cmd/tui/config/tui" import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createPluginKeybind } from "../context/plugin-keybinds" import type { useOpencodeKeymap } from "../keymap"
import type { useKV } from "../context/kv" import type { useKV } from "../context/kv"
import { DialogAlert } from "../ui/dialog-alert" import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm" import { DialogConfirm } from "../ui/dialog-confirm"
@@ -19,6 +16,7 @@ import { Prompt } from "../component/prompt"
import { Slot as HostSlot } from "./slots" import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast" import type { useToast } from "../ui/toast"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Keymap from "../keymap"
type RouteEntry = { type RouteEntry = {
key: symbol key: symbol
@@ -28,10 +26,9 @@ type RouteEntry = {
export type RouteMap = Map<string, RouteEntry[]> export type RouteMap = Map<string, RouteEntry[]>
type Input = { type Input = {
command: ReturnType<typeof useCommandDialog> tuiConfig: TuiConfig.Resolved
tuiConfig: TuiConfig.Info
dialog: ReturnType<typeof useDialog> dialog: ReturnType<typeof useDialog>
keybind: ReturnType<typeof useKeybind> keymap: ReturnType<typeof useOpencodeKeymap>
kv: ReturnType<typeof useKV> kv: ReturnType<typeof useKV>
route: ReturnType<typeof useRoute> route: ReturnType<typeof useRoute>
routes: RouteMap routes: RouteMap
@@ -201,20 +198,17 @@ export function createTuiApi(input: Input): TuiPluginApi {
return () => {} return () => {}
}, },
} }
return { return {
app: appApi(), app: appApi(),
command: { keys: {
register(cb) { formatSequence(parts) {
return input.command.register(() => cb()) return Keymap.formatKeySequence(parts, input.tuiConfig)
}, },
trigger(value) { formatBindings(bindings) {
input.command.trigger(value) return Keymap.formatKeyBindings(bindings, input.tuiConfig)
},
show() {
input.command.show()
}, },
}, },
keymap: input.keymap,
route: { route: {
register(list) { register(list) {
return routeRegister(input.routes, list, input.bump) return routeRegister(input.routes, list, input.bump)
@@ -306,17 +300,6 @@ export function createTuiApi(input: Input): TuiPluginApi {
}, },
}, },
}, },
keybind: {
match(key, evt: ParsedKey) {
return input.keybind.match(key, evt)
},
print(key) {
return input.keybind.print(key)
},
create(defaults, overrides) {
return createPluginKeybind(input.keybind, defaults, overrides)
},
},
get tuiConfig() { get tuiConfig() {
return input.tuiConfig return input.tuiConfig
}, },
@@ -1,4 +1,5 @@
import "@opentui/solid/runtime-plugin-support" import { runtimeModules as keymapRuntimeModules } from "@opentui/keymap/runtime-modules"
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
import { import {
type TuiDispose, type TuiDispose,
type TuiPlugin, type TuiPlugin,
@@ -39,6 +40,8 @@ import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots" import type { HostPluginApi, HostSlots } from "./slots"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
type PluginLoad = { type PluginLoad = {
options: ConfigPlugin.Options | undefined options: ConfigPlugin.Options | undefined
spec: string spec: string
@@ -70,6 +73,36 @@ type PluginEntry = {
scope?: PluginScope scope?: PluginScope
} }
const ScopedKeymapMethods = new Set<PropertyKey>([
"acquireResource",
"registerLayer",
"registerLayerFields",
"prependLayerBindingsTransformer",
"appendLayerBindingsTransformer",
"prependBindingTransformer",
"appendBindingTransformer",
"prependBindingParser",
"appendBindingParser",
"registerToken",
"registerSequencePattern",
"prependBindingExpander",
"appendBindingExpander",
"registerBindingFields",
"registerCommandFields",
"prependCommandTransformer",
"appendCommandTransformer",
"prependCommandResolver",
"appendCommandResolver",
"prependLayerAnalyzer",
"appendLayerAnalyzer",
"intercept",
"on",
"prependEventMatchResolver",
"appendEventMatchResolver",
"prependDisambiguationResolver",
"appendDisambiguationResolver",
])
type RuntimeState = { type RuntimeState = {
directory: string directory: string
api: Api api: Api
@@ -104,6 +137,25 @@ function warn(message: string, data: Record<string, unknown>) {
console.warn(`[tui.plugin] ${message}`, data) console.warn(`[tui.plugin] ${message}`, data)
} }
function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): TuiPluginApi["keymap"] {
const cache = new Map<PropertyKey, unknown>()
return new Proxy(keymap, {
get(target, prop) {
const value = Reflect.get(target, prop, target)
if (typeof value !== "function") return value
if (cache.has(prop)) return cache.get(prop)
const fn = ScopedKeymapMethods.has(prop)
? (...args: unknown[]) => {
const dispose = (value as (...args: unknown[]) => unknown).apply(target, args)
return scope.track(typeof dispose === "function" ? (dispose as () => void) : undefined)
}
: (...args: unknown[]) => (value as (...args: unknown[]) => unknown).apply(target, args)
cache.set(prop, fn)
return fn
},
})
}
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" } type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> { function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
@@ -327,14 +379,16 @@ function createPluginScope(load: PluginLoad, id: string) {
const track = (fn: (() => void) | undefined) => { const track = (fn: (() => void) | undefined) => {
if (!fn) return () => {} if (!fn) return () => {}
const off = onDispose(fn)
let drop = false let drop = false
return () => { let off = () => {}
const wrapped = () => {
if (drop) return if (drop) return
drop = true drop = true
off() off()
fn() fn()
} }
off = onDispose(wrapped)
return wrapped
} }
const lifecycle: TuiPluginApi["lifecycle"] = { const lifecycle: TuiPluginApi["lifecycle"] = {
@@ -395,7 +449,7 @@ function readPluginEnabledMap(value: unknown) {
) )
} }
function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) { function pluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
return { return {
...readPluginEnabledMap(config.plugin_enabled), ...readPluginEnabledMap(config.plugin_enabled),
...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})), ...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
@@ -484,17 +538,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
const api = runtime.api const api = runtime.api
const host = runtime.slots const host = runtime.slots
const load = plugin.load const load = plugin.load
const command: TuiPluginApi["command"] = {
register(cb) {
return scope.track(api.command.register(cb))
},
trigger(value) {
api.command.trigger(value)
},
show() {
api.command.show()
},
}
const route: TuiPluginApi["route"] = { const route: TuiPluginApi["route"] = {
register(list) { register(list) {
@@ -518,6 +561,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
}, },
} }
const keymap = createScopedKeymap(api.keymap, scope)
let count = 0 let count = 0
const slots: TuiPluginApi["slots"] = { const slots: TuiPluginApi["slots"] = {
@@ -531,10 +576,10 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
return { return {
app: api.app, app: api.app,
command, keys: api.keys,
keymap,
route, route,
ui: api.ui, ui: api.ui,
keybind: api.keybind,
tuiConfig: api.tuiConfig, tuiConfig: api.tuiConfig,
kv: api.kv, kv: api.kv,
state: api.state, state: api.state,
@@ -580,7 +625,7 @@ function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
return true return true
} }
function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) { function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
const map = pluginEnabledState(state, config) const map = pluginEnabledState(state, config)
for (const plugin of state.plugins) { for (const plugin of state.plugins) {
const enabled = map[plugin.id] const enabled = map[plugin.id]
@@ -923,7 +968,7 @@ let loaded: Promise<void> | undefined
let runtime: RuntimeState | undefined let runtime: RuntimeState | undefined
export const Slot = View export const Slot = View
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) { export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) {
const cwd = process.cwd() const cwd = process.cwd()
if (loaded) { if (loaded) {
if (dir !== cwd) { if (dir !== cwd) {
@@ -972,7 +1017,7 @@ export async function dispose() {
} }
} }
async function load(input: { api: Api; config: TuiConfig.Info }) { async function load(input: { api: Api; config: TuiConfig.Resolved }) {
const { api, config } = input const { api, config } = input
const cwd = process.cwd() const cwd = process.cwd()
const slots = setupSlots(api) const slots = setupSlots(api)
@@ -49,12 +49,10 @@ import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task" import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question" import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill" import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor" import { useEditorContext } from "@tui/context/editor"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog" import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { TodoItem } from "../../component/todo-item" import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message" import { DialogMessage } from "./dialog-message"
@@ -90,6 +88,8 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { DialogGoUpsell } from "../../component/dialog-go-upsell" import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry" import { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff" import { getRevertDiffFiles } from "../../util/revert-diff"
import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut } from "../../keymap"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -124,6 +124,9 @@ export function Session() {
const event = useEvent() const event = useEvent()
const project = useProject() const project = useProject()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const kv = useKV() const kv = useKV()
const { theme } = useTheme() const { theme } = useTheme()
const promptRef = usePromptRef() const promptRef = usePromptRef()
@@ -250,7 +253,7 @@ export function Session() {
seeded = true seeded = true
r.set(route.prompt) r.set(route.prompt)
} }
const keybind = useKeybind() const command = useCommandPalette()
const dialog = useDialog() const dialog = useDialog()
const renderer = useRenderer() const renderer = useRenderer()
@@ -271,7 +274,6 @@ export function Session() {
}) })
}) })
// Allow exit when in child session (prompt is hidden)
const exit = useExit() const exit = useExit()
createEffect(() => { createEffect(() => {
@@ -293,13 +295,6 @@ export function Session() {
) )
}) })
useKeyboard((evt) => {
if (!session()?.parentID) return
if (keybind.match("app_exit", evt)) {
void exit()
}
})
// Helper: Find next visible message boundary in direction // Helper: Find next visible message boundary in direction
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
const children = scroll.getChildren() const children = scroll.getChildren()
@@ -382,26 +377,24 @@ export function Session() {
} }
} }
function childSessionHandler(func: (dialog: DialogContext) => void) { function childSessionHandler(func: () => void) {
return (dialog: DialogContext) => { return () => {
if (!session()?.parentID || dialog.stack.length > 0) return if (!session()?.parentID || dialog.stack.length > 0) return
func(dialog) func()
} }
} }
const command = useCommandDialog() const sessionCommandList = createMemo(() => [
command.register(() => [
{ {
title: session()?.share?.url ? "Copy share link" : "Share session", title: session()?.share?.url ? "Copy share link" : "Share session",
value: "session.share", value: "session.share",
suggested: route.type === "session", suggested: route.type === "session",
keybind: "session_share",
category: "Session", category: "Session",
enabled: sync.data.config.share !== "disabled", enabled: sync.data.config.share !== "disabled",
slash: { slash: {
name: "share", name: "share",
}, },
onSelect: async (dialog) => { run: async () => {
const copy = (url: string) => const copy = (url: string) =>
Clipboard.copy(url) Clipboard.copy(url)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
@@ -434,24 +427,22 @@ export function Session() {
{ {
title: "Rename session", title: "Rename session",
value: "session.rename", value: "session.rename",
keybind: "session_rename",
category: "Session", category: "Session",
slash: { slash: {
name: "rename", name: "rename",
}, },
onSelect: (dialog) => { run: () => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />) dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
}, },
}, },
{ {
title: "Jump to message", title: "Jump to message",
value: "session.timeline", value: "session.timeline",
keybind: "session_timeline",
category: "Session", category: "Session",
slash: { slash: {
name: "timeline", name: "timeline",
}, },
onSelect: (dialog) => { run: () => {
dialog.replace(() => ( dialog.replace(() => (
<DialogTimeline <DialogTimeline
onMove={(messageID) => { onMove={(messageID) => {
@@ -469,12 +460,11 @@ export function Session() {
{ {
title: "Fork session", title: "Fork session",
value: "session.fork", value: "session.fork",
keybind: "session_fork",
category: "Session", category: "Session",
slash: { slash: {
name: "fork", name: "fork",
}, },
onSelect: (dialog) => { run: () => {
dialog.replace(() => ( dialog.replace(() => (
<DialogForkFromTimeline <DialogForkFromTimeline
onMove={(messageID) => { onMove={(messageID) => {
@@ -492,13 +482,12 @@ export function Session() {
{ {
title: "Compact session", title: "Compact session",
value: "session.compact", value: "session.compact",
keybind: "session_compact",
category: "Session", category: "Session",
slash: { slash: {
name: "compact", name: "compact",
aliases: ["summarize"], aliases: ["summarize"],
}, },
onSelect: (dialog) => { run: () => {
const selectedModel = local.model.current() const selectedModel = local.model.current()
if (!selectedModel) { if (!selectedModel) {
toast.show({ toast.show({
@@ -519,13 +508,12 @@ export function Session() {
{ {
title: "Unshare session", title: "Unshare session",
value: "session.unshare", value: "session.unshare",
keybind: "session_unshare",
category: "Session", category: "Session",
enabled: !!session()?.share?.url, enabled: !!session()?.share?.url,
slash: { slash: {
name: "unshare", name: "unshare",
}, },
onSelect: async (dialog) => { run: async () => {
await sdk.client.session await sdk.client.session
.unshare({ .unshare({
sessionID: route.sessionID, sessionID: route.sessionID,
@@ -543,12 +531,11 @@ export function Session() {
{ {
title: "Undo previous message", title: "Undo previous message",
value: "session.undo", value: "session.undo",
keybind: "messages_undo",
category: "Session", category: "Session",
slash: { slash: {
name: "undo", name: "undo",
}, },
onSelect: async (dialog) => { run: async () => {
const status = sync.data.session_status?.[route.sessionID] const status = sync.data.session_status?.[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
const revert = session()?.revert?.messageID const revert = session()?.revert?.messageID
@@ -581,13 +568,12 @@ export function Session() {
{ {
title: "Redo", title: "Redo",
value: "session.redo", value: "session.redo",
keybind: "messages_redo",
category: "Session", category: "Session",
enabled: !!session()?.revert?.messageID, enabled: !!session()?.revert?.messageID,
slash: { slash: {
name: "redo", name: "redo",
}, },
onSelect: (dialog) => { run: () => {
dialog.clear() dialog.clear()
const messageID = session()?.revert?.messageID const messageID = session()?.revert?.messageID
if (!messageID) return if (!messageID) return
@@ -608,9 +594,8 @@ export function Session() {
{ {
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle", value: "session.sidebar.toggle",
keybind: "sidebar_toggle",
category: "Session", category: "Session",
onSelect: (dialog) => { run: () => {
batch(() => { batch(() => {
const isVisible = sidebarVisible() const isVisible = sidebarVisible()
setSidebar(() => (isVisible ? "hide" : "auto")) setSidebar(() => (isVisible ? "hide" : "auto"))
@@ -622,9 +607,8 @@ export function Session() {
{ {
title: conceal() ? "Disable code concealment" : "Enable code concealment", title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal", value: "session.toggle.conceal",
keybind: "messages_toggle_conceal",
category: "Session", category: "Session",
onSelect: (dialog) => { run: () => {
setConceal((prev) => !prev) setConceal((prev) => !prev)
dialog.clear() dialog.clear()
}, },
@@ -637,7 +621,7 @@ export function Session() {
name: "timestamps", name: "timestamps",
aliases: ["toggle-timestamps"], aliases: ["toggle-timestamps"],
}, },
onSelect: (dialog) => { run: () => {
setTimestamps((prev) => (prev === "show" ? "hide" : "show")) setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
dialog.clear() dialog.clear()
}, },
@@ -645,13 +629,12 @@ export function Session() {
{ {
title: showThinking() ? "Hide thinking" : "Show thinking", title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking", value: "session.toggle.thinking",
keybind: "display_thinking",
category: "Session", category: "Session",
slash: { slash: {
name: "thinking", name: "thinking",
aliases: ["toggle-thinking"], aliases: ["toggle-thinking"],
}, },
onSelect: (dialog) => { run: () => {
setShowThinking((prev) => !prev) setShowThinking((prev) => !prev)
dialog.clear() dialog.clear()
}, },
@@ -659,9 +642,8 @@ export function Session() {
{ {
title: showDetails() ? "Hide tool details" : "Show tool details", title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions", value: "session.toggle.actions",
keybind: "tool_details",
category: "Session", category: "Session",
onSelect: (dialog) => { run: () => {
setShowDetails((prev) => !prev) setShowDetails((prev) => !prev)
dialog.clear() dialog.clear()
}, },
@@ -669,9 +651,8 @@ export function Session() {
{ {
title: "Toggle session scrollbar", title: "Toggle session scrollbar",
value: "session.toggle.scrollbar", value: "session.toggle.scrollbar",
keybind: "scrollbar_toggle",
category: "Session", category: "Session",
onSelect: (dialog) => { run: () => {
setShowScrollbar((prev) => !prev) setShowScrollbar((prev) => !prev)
dialog.clear() dialog.clear()
}, },
@@ -680,7 +661,7 @@ export function Session() {
title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output", title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output",
value: "session.toggle.generic_tool_output", value: "session.toggle.generic_tool_output",
category: "Session", category: "Session",
onSelect: (dialog) => { run: () => {
setShowGenericToolOutput((prev) => !prev) setShowGenericToolOutput((prev) => !prev)
dialog.clear() dialog.clear()
}, },
@@ -688,10 +669,9 @@ export function Session() {
{ {
title: "Page up", title: "Page up",
value: "session.page.up", value: "session.page.up",
keybind: "messages_page_up",
category: "Session", category: "Session",
hidden: true, hidden: true,
onSelect: (dialog) => { run: () => {
scroll.scrollBy(-scroll.height / 2) scroll.scrollBy(-scroll.height / 2)
dialog.clear() dialog.clear()
}, },
@@ -699,10 +679,9 @@ export function Session() {
{ {
title: "Page down", title: "Page down",
value: "session.page.down", value: "session.page.down",
keybind: "messages_page_down",
category: "Session", category: "Session",
hidden: true, hidden: true,
onSelect: (dialog) => { run: () => {
scroll.scrollBy(scroll.height / 2) scroll.scrollBy(scroll.height / 2)
dialog.clear() dialog.clear()
}, },
@@ -710,10 +689,9 @@ export function Session() {
{ {
title: "Line up", title: "Line up",
value: "session.line.up", value: "session.line.up",
keybind: "messages_line_up",
category: "Session", category: "Session",
disabled: true, enabled: false,
onSelect: (dialog) => { run: () => {
scroll.scrollBy(-1) scroll.scrollBy(-1)
dialog.clear() dialog.clear()
}, },
@@ -721,10 +699,9 @@ export function Session() {
{ {
title: "Line down", title: "Line down",
value: "session.line.down", value: "session.line.down",
keybind: "messages_line_down",
category: "Session", category: "Session",
disabled: true, enabled: false,
onSelect: (dialog) => { run: () => {
scroll.scrollBy(1) scroll.scrollBy(1)
dialog.clear() dialog.clear()
}, },
@@ -732,10 +709,9 @@ export function Session() {
{ {
title: "Half page up", title: "Half page up",
value: "session.half.page.up", value: "session.half.page.up",
keybind: "messages_half_page_up",
category: "Session", category: "Session",
hidden: true, hidden: true,
onSelect: (dialog) => { run: () => {
scroll.scrollBy(-scroll.height / 4) scroll.scrollBy(-scroll.height / 4)
dialog.clear() dialog.clear()
}, },
@@ -743,10 +719,9 @@ export function Session() {
{ {
title: "Half page down", title: "Half page down",
value: "session.half.page.down", value: "session.half.page.down",
keybind: "messages_half_page_down",
category: "Session", category: "Session",
hidden: true, hidden: true,
onSelect: (dialog) => { run: () => {
scroll.scrollBy(scroll.height / 4) scroll.scrollBy(scroll.height / 4)
dialog.clear() dialog.clear()
}, },
@@ -754,10 +729,9 @@ export function Session() {
{ {
title: "First message", title: "First message",
value: "session.first", value: "session.first",
keybind: "messages_first",
category: "Session", category: "Session",
hidden: true, hidden: true,
onSelect: (dialog) => { run: () => {
scroll.scrollTo(0) scroll.scrollTo(0)
dialog.clear() dialog.clear()
}, },
@@ -765,10 +739,9 @@ export function Session() {
{ {
title: "Last message", title: "Last message",
value: "session.last", value: "session.last",
keybind: "messages_last",
category: "Session", category: "Session",
hidden: true, hidden: true,
onSelect: (dialog) => { run: () => {
scroll.scrollTo(scroll.scrollHeight) scroll.scrollTo(scroll.scrollHeight)
dialog.clear() dialog.clear()
}, },
@@ -776,10 +749,9 @@ export function Session() {
{ {
title: "Jump to last user message", title: "Jump to last user message",
value: "session.messages_last_user", value: "session.messages_last_user",
keybind: "messages_last_user",
category: "Session", category: "Session",
hidden: true, hidden: true,
onSelect: () => { run: () => {
const messages = sync.data.message[route.sessionID] const messages = sync.data.message[route.sessionID]
if (!messages || !messages.length) return if (!messages || !messages.length) return
@@ -808,25 +780,22 @@ export function Session() {
{ {
title: "Next message", title: "Next message",
value: "session.message.next", value: "session.message.next",
keybind: "messages_next",
category: "Session", category: "Session",
hidden: true, hidden: true,
onSelect: (dialog) => scrollToMessage("next", dialog), run: () => scrollToMessage("next", dialog),
}, },
{ {
title: "Previous message", title: "Previous message",
value: "session.message.previous", value: "session.message.previous",
keybind: "messages_previous",
category: "Session", category: "Session",
hidden: true, hidden: true,
onSelect: (dialog) => scrollToMessage("prev", dialog), run: () => scrollToMessage("prev", dialog),
}, },
{ {
title: "Copy last assistant message", title: "Copy last assistant message",
value: "messages.copy", value: "messages.copy",
keybind: "messages_copy",
category: "Session", category: "Session",
onSelect: (dialog) => { run: () => {
const revertID = session()?.revert?.messageID const revertID = session()?.revert?.messageID
const lastAssistantMessage = messages().findLast( const lastAssistantMessage = messages().findLast(
(msg) => msg.role === "assistant" && (!revertID || msg.id < revertID), (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID),
@@ -871,7 +840,7 @@ export function Session() {
slash: { slash: {
name: "copy", name: "copy",
}, },
onSelect: async (dialog) => { run: async () => {
try { try {
const sessionData = session() const sessionData = session()
if (!sessionData) return if (!sessionData) return
@@ -897,12 +866,11 @@ export function Session() {
{ {
title: "Export session transcript", title: "Export session transcript",
value: "session.export", value: "session.export",
keybind: "session_export",
category: "Session", category: "Session",
slash: { slash: {
name: "export", name: "export",
}, },
onSelect: async (dialog) => { run: async () => {
try { try {
const sessionData = session() const sessionData = session()
if (!sessionData) return if (!sessionData) return
@@ -959,10 +927,9 @@ export function Session() {
{ {
title: "Go to child session", title: "Go to child session",
value: "session.child.first", value: "session.child.first",
keybind: "session_child_first",
category: "Session", category: "Session",
hidden: true, hidden: true,
onSelect: (dialog) => { run: () => {
moveFirstChild() moveFirstChild()
dialog.clear() dialog.clear()
}, },
@@ -970,11 +937,10 @@ export function Session() {
{ {
title: "Go to parent session", title: "Go to parent session",
value: "session.parent", value: "session.parent",
keybind: "session_parent",
category: "Session", category: "Session",
hidden: true, hidden: true,
enabled: !!session()?.parentID, enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => { run: childSessionHandler(() => {
const parentID = session()?.parentID const parentID = session()?.parentID
if (parentID) { if (parentID) {
navigate({ navigate({
@@ -988,11 +954,10 @@ export function Session() {
{ {
title: "Next child session", title: "Next child session",
value: "session.child.next", value: "session.child.next",
keybind: "session_child_cycle",
category: "Session", category: "Session",
hidden: true, hidden: true,
enabled: !!session()?.parentID, enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => { run: childSessionHandler(() => {
moveChild(1) moveChild(1)
dialog.clear() dialog.clear()
}), }),
@@ -1000,17 +965,36 @@ export function Session() {
{ {
title: "Previous child session", title: "Previous child session",
value: "session.child.previous", value: "session.child.previous",
keybind: "session_child_cycle_reverse",
category: "Session", category: "Session",
hidden: true, hidden: true,
enabled: !!session()?.parentID, enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => { run: childSessionHandler(() => {
moveChild(-1) moveChild(-1)
dialog.clear() dialog.clear()
}), }),
}, },
]) ])
const sessionCommands = createMemo(() =>
sessionCommandList().map((command) => ({
namespace: "palette",
name: command.value,
desc: "description" in command ? command.description : undefined,
slashName: "slash" in command ? command.slash?.name : undefined,
slashAliases: "slash" in command ? command.slash?.aliases : undefined,
...command,
})),
)
useBindings(() => ({
commands: sessionCommands(),
}))
useBindings(() => ({
enabled: command.matcher,
bindings: sections.session,
}))
const revertInfo = createMemo(() => session()?.revert) const revertInfo = createMemo(() => session()?.revert)
const revertMessageID = createMemo(() => revertInfo()?.messageID) const revertMessageID = createMemo(() => revertInfo()?.messageID)
@@ -1082,7 +1066,8 @@ export function Session() {
<Switch> <Switch>
<Match when={message.id === revert()?.messageID}> <Match when={message.id === revert()?.messageID}>
{(function () { {(function () {
const command = useCommandDialog() const command = useCommandPalette()
const redoShortcut = useCommandShortcut("session.redo")
const [hover, setHover] = createSignal(false) const [hover, setHover] = createSignal(false)
const dialog = useDialog() const dialog = useDialog()
@@ -1093,7 +1078,7 @@ export function Session() {
"Are you sure you want to restore the reverted messages?", "Are you sure you want to restore the reverted messages?",
) )
if (confirmed) { if (confirmed) {
command.trigger("session.redo") command.run("session.redo")
} }
} }
@@ -1116,7 +1101,7 @@ export function Session() {
> >
<text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text> <text fg={theme.textMuted}>{revert()!.reverted.length} message reverted</text>
<text fg={theme.textMuted}> <text fg={theme.textMuted}>
<span style={{ fg: theme.text }}>{keybind.print("messages_redo")}</span> or /redo to <span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to
restore restore
</text> </text>
<Show when={revert()!.diffFiles?.length}> <Show when={revert()!.diffFiles?.length}>
@@ -1370,7 +1355,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return props.message.time.completed - user.time.created return props.message.time.completed - user.time.created
}) })
const keybind = useKeybind() const childShortcut = useCommandShortcut("session.child.first")
return ( return (
<> <>
@@ -1392,7 +1377,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
<Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}> <Show when={props.parts.some((x) => x.type === "tool" && x.tool === "task")}>
<box paddingTop={1} paddingLeft={3}> <box paddingTop={1} paddingLeft={3}>
<text fg={theme.text}> <text fg={theme.text}>
{keybind.print("session_child_first")} {childShortcut()}
<span style={{ fg: theme.textMuted }}> view subagents</span> <span style={{ fg: theme.textMuted }}> view subagents</span>
</text> </text>
</box> </box>
@@ -1,24 +1,22 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js" import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core" import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme, selectedForeground } from "../../context/theme" import { useTheme, selectedForeground } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2" import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk" import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border" import { SplitBorder } from "../../component/border"
import { useSync } from "../../context/sync" import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useProject } from "../../context/project" import { useProject } from "../../context/project"
import path from "path" import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { ShellID } from "@/tool/shell/id" import { ShellID } from "@/tool/shell/id"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config" import { useTuiConfig } from "../../context/tui-config"
import { useBindings, useCommandShortcut } from "../../keymap"
type PermissionStage = "permission" | "always" | "reject" type PermissionStage = "permission" | "always" | "reject"
@@ -463,25 +461,27 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) { function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable let input: TextareaRenderable
const { theme } = useTheme() const { theme } = useTheme()
const keybind = useKeybind() const tuiConfig = useTuiConfig()
const textareaKeybindings = useTextareaKeybindings() const keymapConfig = tuiConfig.keymap
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80) const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog() const dialog = useDialog()
useBindings(() => ({
useKeyboard((evt) => { enabled: dialog.stack.length === 0,
if (dialog.stack.length > 0) return commands: [
{
if (evt.name === "escape" || keybind.match("app_exit", evt)) { name: "permission.reject.cancel",
evt.preventDefault() run() {
props.onCancel() props.onCancel()
return },
} },
if (evt.name === "return") { ],
evt.preventDefault() bindings: [
props.onConfirm(input.plainText) { key: "escape", cmd: () => props.onCancel() },
} ...keymapConfig.pick("permission", ["permission.reject.cancel"]),
}) { key: "return", cmd: () => props.onConfirm(input.plainText) },
],
}))
return ( return (
<box <box
@@ -520,7 +520,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
textColor={theme.text} textColor={theme.text}
focusedTextColor={theme.text} focusedTextColor={theme.text}
cursorColor={theme.primary} cursorColor={theme.primary}
keyBindings={textareaKeybindings()}
/> />
<box flexDirection="row" gap={2} flexShrink={0}> <box flexDirection="row" gap={2} flexShrink={0}>
<text fg={theme.text}> <text fg={theme.text}>
@@ -545,50 +544,75 @@ function Prompt<const T extends Record<string, string>>(props: {
onSelect: (option: keyof T) => void onSelect: (option: keyof T) => void
}) { }) {
const { theme } = useTheme() const { theme } = useTheme()
const keybind = useKeybind() const tuiConfig = useTuiConfig()
const keymapConfig = tuiConfig.keymap
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[] const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({ const [store, setStore] = createStore({
selected: keys[0], selected: keys[0],
expanded: false, expanded: false,
}) })
const diffKey = Keybind.parse("ctrl+f")[0]
const narrow = createMemo(() => dimensions().width < 80) const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog() const dialog = useDialog()
const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen")
useKeyboard((evt) => { useBindings(() => ({
if (dialog.stack.length > 0) return enabled: dialog.stack.length === 0,
commands: [
if (evt.name === "left" || evt.name == "h") { {
evt.preventDefault() name: "permission.prompt.escape",
const idx = keys.indexOf(store.selected) run() {
const next = keys[(idx - 1 + keys.length) % keys.length] if (!props.escapeKey) return
setStore("selected", next) props.onSelect(props.escapeKey)
} },
},
if (evt.name === "right" || evt.name == "l") { {
evt.preventDefault() name: "permission.prompt.fullscreen",
const idx = keys.indexOf(store.selected) run() {
const next = keys[(idx + 1) % keys.length] if (!props.fullscreen) return
setStore("selected", next) setStore("expanded", (v) => !v)
} },
},
if (evt.name === "return") { ],
evt.preventDefault() bindings: [
props.onSelect(store.selected) {
} key: "left",
cmd: () => {
if (props.escapeKey && (evt.name === "escape" || keybind.match("app_exit", evt))) { const idx = keys.indexOf(store.selected)
evt.preventDefault() const next = keys[(idx - 1 + keys.length) % keys.length]
props.onSelect(props.escapeKey) setStore("selected", next)
} },
},
if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) { {
evt.preventDefault() key: "h",
evt.stopPropagation() cmd: () => {
setStore("expanded", (v) => !v) const idx = keys.indexOf(store.selected)
} const next = keys[(idx - 1 + keys.length) % keys.length]
}) setStore("selected", next)
},
},
{
key: "right",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{
key: "l",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
},
},
{ key: "return", cmd: () => props.onSelect(store.selected) },
...(props.escapeKey ? [{ key: "escape", cmd: () => props.onSelect(props.escapeKey!) }] : []),
...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []),
...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []),
],
}))
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
useRenderer() useRenderer()
@@ -661,7 +685,7 @@ function Prompt<const T extends Record<string, string>>(props: {
<box flexDirection="row" gap={2} flexShrink={0}> <box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}> <Show when={props.fullscreen}>
<text fg={theme.text}> <text fg={theme.text}>
{"ctrl+f"} <span style={{ fg: theme.textMuted }}>{hint()}</span> {fullscreenHint()} <span style={{ fg: theme.textMuted }}>{hint()}</span>
</text> </text>
</Show> </Show>
<text fg={theme.text}> <text fg={theme.text}>
@@ -1,20 +1,22 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, Show } from "solid-js" import { createMemo, createSignal, For, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core" import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { selectedForeground, tint, useTheme } from "../../context/theme" import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk" import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border" import { SplitBorder } from "../../component/border"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings } from "../../keymap"
export function QuestionPrompt(props: { request: QuestionRequest }) { export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK() const sdk = useSDK()
const { theme } = useTheme() const { theme } = useTheme()
const keybind = useKeybind() const tuiConfig = useTuiConfig()
const bindings = useTextareaKeybindings() const {
keymap: { sections },
} = tuiConfig
const keymapConfig = tuiConfig.keymap
const questions = createMemo(() => props.request.questions) const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
@@ -122,131 +124,124 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const dialog = useDialog() const dialog = useDialog()
useKeyboard((evt) => { useBindings(() => ({
// Skip processing if a dialog (e.g., command palette) is open enabled: store.editing && !confirm(),
if (dialog.stack.length > 0) return commands: [
{
// When editing custom answer textarea name: "question.edit.clear",
if (store.editing && !confirm()) { run() {
if (evt.name === "escape") { const text = textarea?.plainText ?? ""
evt.preventDefault() if (!text) {
setStore("editing", false) setStore("editing", false)
return return
} }
if (keybind.match("input_clear", evt)) { textarea?.setText("")
evt.preventDefault() },
const text = textarea?.plainText ?? "" },
if (!text) { ],
bindings: [
{
key: "escape",
cmd: () => {
setStore("editing", false) setStore("editing", false)
return },
} },
textarea?.setText("") ...keymapConfig.pick("question", ["question.edit.clear"]),
return {
} key: "return",
if (evt.name === "return") { cmd: () => {
evt.preventDefault() const text = textarea?.plainText?.trim() ?? ""
const text = textarea?.plainText?.trim() ?? "" const prev = store.custom[store.tab]
const prev = store.custom[store.tab]
if (!text) { if (!text) {
if (prev) { if (prev) {
const inputs = [...store.custom]
inputs[store.tab] = ""
setStore("custom", inputs)
const answers = [...store.answers]
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
setStore("answers", answers)
}
setStore("editing", false)
return
}
if (multi()) {
const inputs = [...store.custom] const inputs = [...store.custom]
inputs[store.tab] = "" inputs[store.tab] = text
setStore("custom", inputs) setStore("custom", inputs)
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (prev) {
const index = next.indexOf(prev)
if (index !== -1) next.splice(index, 1)
}
if (!next.includes(text)) next.push(text)
const answers = [...store.answers] const answers = [...store.answers]
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev) answers[store.tab] = next
setStore("answers", answers) setStore("answers", answers)
setStore("editing", false)
return
} }
pick(text, true)
setStore("editing", false) setStore("editing", false)
return },
} },
],
}))
if (multi()) { useBindings(() => {
const inputs = [...store.custom] const opts = options()
inputs[store.tab] = text const total = opts.length + (custom() ? 1 : 0)
setStore("custom", inputs) const max = Math.min(total, 9)
const existing = store.answers[store.tab] ?? [] return {
const next = [...existing] enabled: dialog.stack.length === 0 && !store.editing,
if (prev) { commands: [
const index = next.indexOf(prev) {
if (index !== -1) next.splice(index, 1) name: "question.reject",
} run() {
if (!next.includes(text)) next.push(text) reject()
const answers = [...store.answers] },
answers[store.tab] = next },
setStore("answers", answers) ],
setStore("editing", false) bindings: [
return { key: "left", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
} { key: "h", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
{ key: "right", cmd: () => selectTab((store.tab + 1) % tabs()) },
pick(text, true) { key: "l", cmd: () => selectTab((store.tab + 1) % tabs()) },
setStore("editing", false) {
return key: "tab",
} cmd: ({ event }: { event: { shift: boolean } }) => {
// Let textarea handle all other keys selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
return },
} },
...(confirm()
if (evt.name === "left" || evt.name === "h") { ? [
evt.preventDefault() { key: "return", cmd: () => submit() },
selectTab((store.tab - 1 + tabs()) % tabs()) { key: "escape", cmd: () => reject() },
} ...sections.question,
]
if (evt.name === "right" || evt.name === "l") { : [
evt.preventDefault() ...Array.from({ length: max }, (_, index) => ({
selectTab((store.tab + 1) % tabs()) key: String(index + 1),
} cmd: () => {
moveTo(index)
if (evt.name === "tab") { selectOption()
evt.preventDefault() },
const direction = evt.shift ? -1 : 1 })),
selectTab((store.tab + direction + tabs()) % tabs()) { key: "up", cmd: () => moveTo((store.selected - 1 + total) % total) },
} { key: "k", cmd: () => moveTo((store.selected - 1 + total) % total) },
{ key: "down", cmd: () => moveTo((store.selected + 1) % total) },
if (confirm()) { { key: "j", cmd: () => moveTo((store.selected + 1) % total) },
if (evt.name === "return") { { key: "return", cmd: () => selectOption() },
evt.preventDefault() { key: "escape", cmd: () => reject() },
submit() ...sections.question,
} ]),
if (evt.name === "escape" || keybind.match("app_exit", evt)) { ],
evt.preventDefault()
reject()
}
} else {
const opts = options()
const total = opts.length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
const digit = Number(evt.name)
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
evt.preventDefault()
const index = digit - 1
moveTo(index)
selectOption()
return
}
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
moveTo((store.selected - 1 + total) % total)
}
if (evt.name === "down" || evt.name === "j") {
evt.preventDefault()
moveTo((store.selected + 1) % total)
}
if (evt.name === "return") {
evt.preventDefault()
selectOption()
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
reject()
}
} }
}) })
@@ -394,7 +389,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
textColor={theme.text} textColor={theme.text}
focusedTextColor={theme.text} focusedTextColor={theme.text}
cursorColor={theme.primary} cursorColor={theme.primary}
keyBindings={bindings()}
/> />
</box> </box>
</Show> </Show>
@@ -4,10 +4,10 @@ import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border" import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import { useCommandPalette } from "../../context/command-palette"
import { useCommandShortcut } from "../../keymap"
export function SubagentFooter() { export function SubagentFooter() {
const route = useRouteData("session") const route = useRouteData("session")
@@ -56,8 +56,10 @@ export function SubagentFooter() {
}) })
const { theme } = useTheme() const { theme } = useTheme()
const keybind = useKeybind() const command = useCommandPalette()
const command = useCommandDialog() const parentShortcut = useCommandShortcut("session.parent")
const previousShortcut = useCommandShortcut("session.child.previous")
const nextShortcut = useCommandShortcut("session.child.next")
const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null)
useTerminalDimensions() useTerminalDimensions()
@@ -96,31 +98,31 @@ export function SubagentFooter() {
<box <box
onMouseOver={() => setHover("parent")} onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)} onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.parent")} onMouseUp={() => command.run("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
> >
<text fg={theme.text}> <text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span> Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
</text> </text>
</box> </box>
<box <box
onMouseOver={() => setHover("prev")} onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)} onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.previous")} onMouseUp={() => command.run("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
> >
<text fg={theme.text}> <text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span> Prev <span style={{ fg: theme.textMuted }}>{previousShortcut()}</span>
</text> </text>
</box> </box>
<box <box
onMouseOver={() => setHover("next")} onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)} onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.next")} onMouseUp={() => command.run("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
> >
<text fg={theme.text}> <text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span> Next <span style={{ fg: theme.textMuted }}>{nextShortcut()}</span>
</text> </text>
</box> </box>
</box> </box>
@@ -1,7 +1,7 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { useKeyboard } from "@opentui/solid" import { useBindings } from "../keymap"
export type DialogAlertProps = { export type DialogAlertProps = {
title: string title: string
@@ -13,14 +13,17 @@ export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
useKeyboard((evt) => { useBindings(() => ({
if (evt.name === "return") { bindings: [
evt.preventDefault() {
evt.stopPropagation() key: "return",
props.onConfirm?.() cmd: () => {
dialog.clear() props.onConfirm?.()
} dialog.clear()
}) },
},
],
}))
return ( return (
<box paddingLeft={2} paddingRight={2} gap={1}> <box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
@@ -3,8 +3,8 @@ import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { For } from "solid-js" import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { useBindings } from "../keymap"
export type DialogConfirmProps = { export type DialogConfirmProps = {
title: string title: string
@@ -23,19 +23,30 @@ export function DialogConfirm(props: DialogConfirmProps) {
active: "confirm" as "confirm" | "cancel", active: "confirm" as "confirm" | "cancel",
}) })
useKeyboard((evt) => { useBindings(() => ({
if (evt.name === "return") { bindings: [
evt.preventDefault() {
evt.stopPropagation() key: "return",
if (store.active === "confirm") props.onConfirm?.() cmd: () => {
if (store.active === "cancel") props.onCancel?.() if (store.active === "confirm") props.onConfirm?.()
dialog.clear() if (store.active === "cancel") props.onCancel?.()
} dialog.clear()
},
if (evt.name === "left" || evt.name === "right") { },
setStore("active", store.active === "confirm" ? "cancel" : "confirm") {
} key: "left",
}) cmd: () => {
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
},
},
{
key: "right",
cmd: () => {
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
},
},
],
}))
return ( return (
<box paddingLeft={2} paddingRight={2} gap={1}> <box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between"> <box flexDirection="row" justifyContent="space-between">
@@ -56,7 +67,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={key === store.active ? theme.primary : undefined} backgroundColor={key === store.active ? theme.primary : undefined}
onMouseUp={(_evt) => { onMouseUp={() => {
if (key === "confirm") props.onConfirm?.() if (key === "confirm") props.onConfirm?.()
if (key === "cancel") props.onCancel?.() if (key === "cancel") props.onCancel?.()
dialog.clear() dialog.clear()
@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { onMount, Show } from "solid-js" import { onMount, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid" import { useBindings } from "../keymap"
export type DialogExportOptionsProps = { export type DialogExportOptionsProps = {
defaultFilename: string defaultFilename: string
@@ -33,39 +33,40 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving", active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
}) })
useKeyboard((evt) => { useBindings(() => ({
if (evt.name === "return") { bindings: [
evt.preventDefault() {
evt.stopPropagation() key: "tab",
props.onConfirm?.({ cmd: () => {
filename: textarea.plainText, const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
thinking: store.thinking, "filename",
toolDetails: store.toolDetails, "thinking",
assistantMetadata: store.assistantMetadata, "toolDetails",
openWithoutSaving: store.openWithoutSaving, "assistantMetadata",
}) "openWithoutSaving",
} ]
if (evt.name === "tab") { const currentIndex = order.indexOf(store.active)
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [ const nextIndex = (currentIndex + 1) % order.length
"filename", setStore("active", order[nextIndex])
"thinking", },
"toolDetails", },
"assistantMetadata", ],
"openWithoutSaving", }))
]
const currentIndex = order.indexOf(store.active) useBindings(() => ({
const nextIndex = (currentIndex + 1) % order.length enabled: store.active !== "filename",
setStore("active", order[nextIndex]) bindings: [
evt.preventDefault() {
} key: "space",
if (evt.name === "space" || evt.name === " ") { cmd: () => {
if (store.active === "thinking") setStore("thinking", !store.thinking) if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails) if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata) if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving) if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
evt.preventDefault() },
} },
}) ],
}))
onMount(() => { onMount(() => {
dialog.setSize("medium") dialog.setSize("medium")
@@ -101,7 +102,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
}) })
}} }}
height={3} height={3}
keyBindings={[{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => { ref={(val: TextareaRenderable) => {
textarea = val textarea = val
val.traits = { status: "FILENAME" } val.traits = { status: "FILENAME" }
@@ -1,21 +1,19 @@
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { useDialog } from "./dialog" import { useDialog } from "./dialog"
import { useKeyboard } from "@opentui/solid" import { useBindings, useCommandShortcut } from "../keymap"
import { useKeybind } from "@tui/context/keybind"
export function DialogHelp() { export function DialogHelp() {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
const keybind = useKeybind() const commandShortcut = useCommandShortcut("command.palette.show")
useKeyboard((evt) => { useBindings(() => ({
if (evt.name === "return" || evt.name === "escape") { bindings: [
evt.preventDefault() { key: "return", cmd: () => dialog.clear() },
evt.stopPropagation() { key: "escape", cmd: () => dialog.clear() },
dialog.clear() ],
} }))
})
return ( return (
<box paddingLeft={2} paddingRight={2} gap={1}> <box paddingLeft={2} paddingRight={2} gap={1}>
@@ -29,7 +27,7 @@ export function DialogHelp() {
</box> </box>
<box paddingBottom={1}> <box paddingBottom={1}>
<text fg={theme.textMuted}> <text fg={theme.textMuted}>
Press {keybind.print("command_list")} to see all available actions and commands in any context. Press {commandShortcut()} to see all available actions and commands in any context.
</text> </text>
</box> </box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}> <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
@@ -2,7 +2,6 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog" import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, onMount, type JSX } from "solid-js" import { Show, createEffect, onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Spinner } from "../component/spinner" import { Spinner } from "../component/spinner"
export type DialogPromptProps = { export type DialogPromptProps = {
@@ -21,20 +20,6 @@ export function DialogPrompt(props: DialogPromptProps) {
const { theme } = useTheme() const { theme } = useTheme()
let textarea: TextareaRenderable let textarea: TextareaRenderable
useKeyboard((evt) => {
if (props.busy) {
if (evt.name === "escape") return
evt.preventDefault()
evt.stopPropagation()
return
}
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
props.onConfirm?.(textarea.plainText)
}
})
onMount(() => { onMount(() => {
dialog.setSize("medium") dialog.setSize("medium")
setTimeout(() => { setTimeout(() => {
@@ -79,7 +64,6 @@ export function DialogPrompt(props: DialogPromptProps) {
props.onConfirm?.(textarea.plainText) props.onConfirm?.(textarea.plainText)
}} }}
height={3} height={3}
keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => { ref={(val: TextareaRenderable) => {
textarea = val textarea = val
}} }}
@@ -1,17 +1,24 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import {
InputRenderable,
RGBA,
ScrollBoxRenderable,
TextAttributes,
type KeyEvent,
type Renderable,
} from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import { useTheme, selectedForeground } from "@tui/context/theme" import { useTheme, selectedForeground } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort" import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda" import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "@tui/ui/dialog" import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { getScrollAcceleration } from "../util/scroll" import { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../context/tui-config" import { useTuiConfig } from "../context/tui-config"
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
export interface DialogSelectProps<T> { export interface DialogSelectProps<T> {
title: string title: string
@@ -24,13 +31,14 @@ export interface DialogSelectProps<T> {
onSelect?: (option: DialogSelectOption<T>) => void onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean skipFilter?: boolean
renderFilter?: boolean renderFilter?: boolean
keybind?: { actions?: {
keybind?: Keybind.Info command: string
title: string title: string
side?: "left" | "right" side?: "left" | "right"
disabled?: boolean disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void onTrigger: (option: DialogSelectOption<T>) => void
}[] }[]
bindings?: readonly Binding<Renderable, KeyEvent>[]
current?: T current?: T
} }
@@ -57,6 +65,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@@ -81,6 +92,25 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
let input: InputRenderable let input: InputRenderable
const actions = createMemo(() => props.actions ?? [])
const actionBindings = useKeymapSelector((keymap) =>
keymap.getCommandBindings({
visibility: "registered",
commands: actions().map((item) => item.command),
}),
)
const actionLabels = createMemo(() => {
const labels = new Map<string, string>()
for (const action of actions()) {
const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig)
if (label) labels.set(action.command, label)
}
return labels
})
const filtered = createMemo(() => { const filtered = createMemo(() => {
if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase() const needle = store.filter.toLowerCase()
@@ -171,7 +201,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const option = selected() const option = selected()
if (option) props.onMove?.(option) if (option) props.onMove?.(option)
if (!scroll) return if (!scroll) return
const target = scroll.getChildren().find((child) => { const target = scroll.getChildren().find((child: { id?: string }) => {
return child.id === JSON.stringify(selected()?.value) return child.id === JSON.stringify(selected()?.value)
}) })
if (!target) return if (!target) return
@@ -192,36 +222,86 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
} }
} }
const keybind = useKeybind() function submit() {
useKeyboard((evt) => {
setStore("input", "keyboard") setStore("input", "keyboard")
const option = selected()
if (!option) return
option.onSelect?.(dialog)
props.onSelect?.(option)
}
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) useBindings(() => {
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) const enabledActions = actions().filter((item) => !item.disabled)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)
if (evt.name === "home") moveTo(0)
if (evt.name === "end") moveTo(flat().length - 1)
if (evt.name === "return") { return {
const option = selected() commands: [
if (option) { {
evt.preventDefault() name: "dialog.select.prev",
evt.stopPropagation() run() {
if (option.onSelect) option.onSelect(dialog) setStore("input", "keyboard")
props.onSelect?.(option) move(-1)
} },
} },
{
for (const item of props.keybind ?? []) { name: "dialog.select.next",
if (item.disabled || !item.keybind) continue run() {
if (Keybind.match(item.keybind, keybind.parse(evt))) { setStore("input", "keyboard")
const s = selected() move(1)
if (s) { },
evt.preventDefault() },
item.onTrigger(s) {
} name: "dialog.select.page_up",
} run() {
setStore("input", "keyboard")
move(-10)
},
},
{
name: "dialog.select.page_down",
run() {
setStore("input", "keyboard")
move(10)
},
},
{
name: "dialog.select.home",
run() {
setStore("input", "keyboard")
moveTo(0)
},
},
{
name: "dialog.select.end",
run() {
setStore("input", "keyboard")
moveTo(flat().length - 1)
},
},
{
name: "dialog.select.submit",
run: submit,
},
...enabledActions.map((item) => ({
name: item.command,
run() {
setStore("input", "keyboard")
const option = selected()
if (!option) return
item.onTrigger(option)
},
})),
],
bindings: [
...sections.dialog_select,
...tuiConfig.keymap.pick(
"dialog_actions",
enabledActions.map((item) => item.command),
),
...(props.bindings ?? []).filter((binding) => {
if (typeof binding.cmd !== "string") return true
return enabledActions.some((item) => item.command === binding.cmd)
}),
],
} }
}) })
@@ -236,9 +316,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
} }
props.ref?.(ref) props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) const visibleActions = createMemo(() =>
const left = createMemo(() => keybinds().filter((item) => item.side !== "right")) actions()
const right = createMemo(() => keybinds().filter((item) => item.side === "right")) .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
.filter((item) => !item.disabled && item.label),
)
const left = createMemo(() => visibleActions().filter((item) => item.side !== "right"))
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
return ( return (
<box gap={1} paddingBottom={1}> <box gap={1} paddingBottom={1}>
@@ -365,7 +449,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</For> </For>
</scrollbox> </scrollbox>
</Show> </Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}> <Show when={visibleActions().length} fallback={<box flexShrink={0} />}>
<box <box
paddingRight={2} paddingRight={2}
paddingLeft={4} paddingLeft={4}
@@ -381,7 +465,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<span style={{ fg: theme.text }}> <span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "} <b>{item.title}</b>{" "}
</span> </span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span> <span style={{ fg: theme.textMuted }}>{item.label}</span>
</text> </text>
)} )}
</For> </For>
@@ -393,7 +477,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<span style={{ fg: theme.text }}> <span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "} <b>{item.title}</b>{" "}
</span> </span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span> <span style={{ fg: theme.textMuted }}>{item.label}</span>
</text> </text>
)} )}
</For> </For>
+35 -20
View File
@@ -1,4 +1,4 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js" import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core" import { MouseButton, Renderable, RGBA } from "@opentui/core"
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
import { useToast } from "./toast" import { useToast } from "./toast"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import * as Selection from "@tui/util/selection" import * as Selection from "@tui/util/selection"
import { useBindings } from "../keymap"
export function Dialog( export function Dialog(
props: ParentProps<{ props: ParentProps<{
@@ -47,7 +48,7 @@ export function Dialog(
backgroundColor={RGBA.fromInts(0, 0, 0, 150)} backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
> >
<box <box
onMouseUp={(e) => { onMouseUp={(e: { stopPropagation(): void }) => {
dismiss = false dismiss = false
e.stopPropagation() e.stopPropagation()
}} }}
@@ -73,23 +74,6 @@ function init() {
const renderer = useRenderer() const renderer = useRenderer()
useKeyboard((evt) => {
if (store.stack.length === 0) return
if (evt.defaultPrevented) return
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
evt.preventDefault()
evt.stopPropagation()
refocus()
}
})
let focus: Renderable | null let focus: Renderable | null
function refocus() { function refocus() {
setTimeout(() => { setTimeout(() => {
@@ -108,6 +92,36 @@ function init() {
}, 1) }, 1)
} }
useBindings(() => ({
enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(),
bindings: [
{
key: "escape",
cmd: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)
current?.onClose?.()
setStore("stack", store.stack.slice(0, -1))
refocus()
},
},
{
key: "ctrl+c",
cmd: () => {
if (renderer.getSelection()) {
renderer.clearSelection()
}
const current = store.stack.at(-1)
current?.onClose?.()
setStore("stack", store.stack.slice(0, -1))
refocus()
},
},
],
}))
return { return {
clear() { clear() {
for (const item of store.stack) { for (const item of store.stack) {
@@ -155,13 +169,14 @@ export function DialogProvider(props: ParentProps) {
const value = init() const value = init()
const renderer = useRenderer() const renderer = useRenderer()
const toast = useToast() const toast = useToast()
return ( return (
<ctx.Provider value={value}> <ctx.Provider value={value}>
{props.children} {props.children}
<box <box
position="absolute" position="absolute"
zIndex={3000} zIndex={3000}
onMouseDown={(evt) => { onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return if (evt.button !== MouseButton.RIGHT) return
@@ -11,7 +11,9 @@ export class CustomSpeedScroll implements ScrollAcceleration {
reset(): void {} reset(): void {}
} }
export function getScrollAcceleration(tuiConfig?: TuiConfig.Info): ScrollAcceleration { export function getScrollAcceleration(
tuiConfig?: Pick<TuiConfig.Info, "scroll_acceleration" | "scroll_speed">,
): ScrollAcceleration {
if (tuiConfig?.scroll_acceleration?.enabled) { if (tuiConfig?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel() return new MacOSScrollAccel()
} }
@@ -5,9 +5,21 @@ type Toast = {
error: (err: unknown) => void error: (err: unknown) => void
} }
type FocusableSelectionTarget = {
hasSelection: () => boolean
}
type Renderer = { type Renderer = {
getSelection: () => { getSelectedText: () => string } | null getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null
clearSelection: () => void clearSelection: () => void
currentFocusedRenderable?: FocusableSelectionTarget | null
}
type SelectionKeyEvent = {
ctrl?: boolean
name: string
preventDefault: () => void
stopPropagation: () => void
} }
export function copy(renderer: Renderer, toast: Toast): boolean { export function copy(renderer: Renderer, toast: Toast): boolean {
@@ -22,4 +34,32 @@ export function copy(renderer: Renderer, toast: Toast): boolean {
return true return true
} }
export function handleSelectionKey(renderer: Renderer, toast: Toast, event: SelectionKeyEvent) {
const selection = renderer.getSelection()
if (!selection) return
if (event.ctrl && event.name === "c") {
if (!copy(renderer, toast)) {
renderer.clearSelection()
return
}
event.preventDefault()
event.stopPropagation()
return
}
if (event.name === "escape") {
renderer.clearSelection()
event.preventDefault()
event.stopPropagation()
return
}
const focus = renderer.currentFocusedRenderable
if (focus?.hasSelection() && selection.selectedRenderables.includes(focus)) return
renderer.clearSelection()
}
export * as Selection from "./selection" export * as Selection from "./selection"
+17 -1
View File
@@ -21,7 +21,6 @@ const KeybindsSchema = Schema.Struct({
theme_list: keybind("<leader>t", "List available themes"), theme_list: keybind("<leader>t", "List available themes"),
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"), sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"), scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
username_toggle: keybind("none", "Toggle username visibility"),
status_view: keybind("<leader>s", "View status"), status_view: keybind("<leader>s", "View status"),
session_export: keybind("<leader>x", "Export session to editor"), session_export: keybind("<leader>x", "Export session to editor"),
session_new: keybind("<leader>n", "Create a new session"), session_new: keybind("<leader>n", "Create a new session"),
@@ -59,6 +58,22 @@ const KeybindsSchema = Schema.Struct({
model_cycle_favorite: keybind("none", "Next favorite model"), model_cycle_favorite: keybind("none", "Next favorite model"),
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
command_list: keybind("ctrl+p", "List available commands"), command_list: keybind("ctrl+p", "List available commands"),
"dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
"dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),
"dialog.select.page_up": keybind("pageup", "Move up one page in dialog"),
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
agent_list: keybind("<leader>a", "List agents"), agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"), agent_cycle: keybind("tab", "Next agent"),
agent_cycle_reverse: keybind("shift+tab", "Previous agent"), agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
@@ -101,6 +116,7 @@ const KeybindsSchema = Schema.Struct({
input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"), input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"), input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"), input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
input_select_all: keybind("super+a", "Select all in input"),
history_previous: keybind("up", "Previous history item"), history_previous: keybind("up", "Previous history item"),
history_next: keybind("down", "Next history item"), history_next: keybind("down", "Next history item"),
session_child_first: keybind("<leader>down", "Go to first child session"), session_child_first: keybind("<leader>down", "Go to first child session"),
-103
View File
@@ -1,103 +0,0 @@
import { isDeepEqual } from "remeda"
import type { ParsedKey } from "@opentui/core"
/**
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
* This ensures type compatibility and catches missing fields at compile time.
*/
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
leader: boolean // our custom field
}
export function match(a: Info | undefined, b: Info): boolean {
if (!a) return false
const normalizedA = { ...a, super: a.super ?? false }
const normalizedB = { ...b, super: b.super ?? false }
return isDeepEqual(normalizedA, normalizedB)
}
/**
* Convert OpenTUI's ParsedKey to our Keybind.Info format.
* This helper ensures all required fields are present and avoids manual object creation.
*/
export function fromParsedKey(key: ParsedKey, leader = false): Info {
return {
name: key.name === " " ? "space" : key.name,
ctrl: key.ctrl,
meta: key.meta,
shift: key.shift,
super: key.super ?? false,
leader,
}
}
export function toString(info: Info | undefined): string {
if (!info) return ""
const parts: string[] = []
if (info.ctrl) parts.push("ctrl")
if (info.meta) parts.push("alt")
if (info.super) parts.push("super")
if (info.shift) parts.push("shift")
if (info.name) {
if (info.name === "delete") parts.push("del")
else parts.push(info.name)
}
let result = parts.join("+")
if (info.leader) {
result = result ? `<leader> ${result}` : `<leader>`
}
return result
}
export function parse(key: string): Info[] {
if (key === "none") return []
return key.split(",").map((combo) => {
// Handle <leader> syntax by replacing with leader+
const normalized = combo.replace(/<leader>/g, "leader+")
const parts = normalized.toLowerCase().split("+")
const info: Info = {
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "",
}
for (const part of parts) {
switch (part) {
case "ctrl":
info.ctrl = true
break
case "alt":
case "meta":
case "option":
info.meta = true
break
case "super":
info.super = true
break
case "shift":
info.shift = true
break
case "leader":
info.leader = true
break
case "esc":
info.name = "escape"
break
default:
info.name = part
break
}
}
return info
})
}
export * as Keybind from "./keybind"
@@ -1,90 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { ParsedKey } from "@opentui/core"
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds"
describe("createPluginKeybind", () => {
const defaults = {
open: "ctrl+o",
close: "escape",
}
test("uses defaults when overrides are missing", () => {
const api = {
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults)
expect(bind.all).toEqual(defaults)
expect(bind.get("open")).toBe("ctrl+o")
expect(bind.get("close")).toBe("escape")
})
test("applies valid overrides", () => {
const api = {
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: "ctrl+alt+o",
close: "q",
})
expect(bind.all).toEqual({
open: "ctrl+alt+o",
close: "q",
})
})
test("ignores invalid overrides", () => {
const api = {
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: " ",
close: 1,
extra: "ctrl+x",
})
expect(bind.all).toEqual(defaults)
expect(bind.get("extra")).toBe("extra")
})
test("resolves names for match", () => {
const list: string[] = []
const api = {
match: (key: string) => {
list.push(key)
return true
},
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: "ctrl+shift+o",
})
bind.match("open", { name: "x" } as ParsedKey)
bind.match("ctrl+k", { name: "x" } as ParsedKey)
expect(list).toEqual(["ctrl+shift+o", "ctrl+k"])
})
test("resolves names for print", () => {
const list: string[] = []
const api = {
match: () => false,
print: (key: string) => {
list.push(key)
return `print:${key}`
},
}
const bind = createPluginKeybind(api, defaults, {
close: "q",
})
expect(bind.print("close")).toBe("print:q")
expect(bind.print("ctrl+p")).toBe("print:ctrl+p")
expect(list).toEqual(["q", "ctrl+p"])
})
})
@@ -4,6 +4,7 @@ import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -31,10 +32,9 @@ test("adds tui plugin at runtime from spec", async () => {
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [], plugin: [],
plugin_origins: undefined, })
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -74,10 +74,9 @@ test("retries runtime add for file plugins after dependency wait", async () => {
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [], plugin: [],
plugin_origins: undefined, })
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => { const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
await Bun.write( await Bun.write(
path.join(tmp.extra.mod, "index.ts"), path.join(tmp.extra.mod, "index.ts"),
@@ -4,6 +4,7 @@ import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -50,10 +51,9 @@ test("installs plugin without loading it", async () => {
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [], plugin: [],
plugin_origins: undefined, })
}
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi({ const api = createTuiPluginApi({
@@ -4,6 +4,7 @@ import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
@@ -44,7 +45,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [ plugin_origins: [
{ {
@@ -53,7 +54,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -105,7 +106,7 @@ test("does not use npm package exports dot for tui entry", async () => {
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_origins: [ plugin_origins: [
{ {
@@ -114,7 +115,7 @@ test("does not use npm package exports dot for tui entry", async () => {
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -167,7 +168,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_origins: [ plugin_origins: [
{ {
@@ -176,7 +177,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -229,7 +230,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_origins: [ plugin_origins: [
{ {
@@ -238,7 +239,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -287,7 +288,7 @@ test("does not use npm package main for tui entry", async () => {
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_origins: [ plugin_origins: [
{ {
@@ -296,7 +297,7 @@ test("does not use npm package main for tui entry", async () => {
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -352,7 +353,7 @@ test("does not use directory package main for tui entry", async () => {
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_origins: [ plugin_origins: [
{ {
@@ -361,7 +362,7 @@ test("does not use directory package main for tui entry", async () => {
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -399,7 +400,7 @@ test("uses directory index fallback for tui when package.json is missing", async
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [tmp.extra.spec], plugin: [tmp.extra.spec],
plugin_origins: [ plugin_origins: [
{ {
@@ -408,7 +409,7 @@ test("uses directory index fallback for tui when package.json is missing", async
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -456,7 +457,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [ plugin_origins: [
{ {
@@ -465,7 +466,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
@@ -4,6 +4,7 @@ import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -37,7 +38,7 @@ test("skips external tui plugins in pure mode", async () => {
process.env.OPENCODE_PURE = "1" process.env.OPENCODE_PURE = "1"
process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_origins: [ plugin_origins: [
{ {
@@ -46,7 +47,7 @@ test("skips external tui plugins in pure mode", async () => {
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -2,8 +2,10 @@ import { beforeAll, describe, expect, spyOn, test } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { createTestKeymap } from "@opentui/keymap/testing"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
@@ -79,7 +81,10 @@ async function load(): Promise<Data> {
await Bun.write( await Bun.write(
localPluginPath, localPluginPath,
`export const ignored = async (_input, options) => { `import { resolveBindingSections } from "@opentui/keymap/extras"
import { useBindings } from "@opentui/keymap/solid"
export const ignored = async (_input, options) => {
if (!options?.fn_marker) return if (!options?.fn_marker) return
await Bun.write(options.fn_marker, "called") await Bun.write(options.fn_marker, "called")
} }
@@ -93,10 +98,21 @@ export default {
const cfg_speed = api.tuiConfig.scroll_speed const cfg_speed = api.tuiConfig.scroll_speed
const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
const cfg_submit = api.tuiConfig.keybinds?.input_submit const cfg_submit = api.tuiConfig.keybinds?.input_submit
const key = api.keybind.create( const has_keys = typeof api.keys.formatBindings === "function"
{ modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" }, const keymap = resolveBindingSections(options.keymap?.sections ?? {
options.keybinds, main: {
) "plugin.loader.local": "ctrl+shift+m",
"plugin.loader.close": "escape",
},
}, { sections: ["main"] }).sections
const key_modal = keymap.main.find((item) => item.cmd === "plugin.loader.local")?.key
const key_close = keymap.main.find((item) => item.cmd === "plugin.loader.close")?.key
const key_unknown = "ctrl+k"
const off = api.keymap.registerLayer({
commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }],
bindings: keymap.main,
})
off()
const kv_before = api.kv.get(options.kv_key, "missing") const kv_before = api.kv.get(options.kv_key, "missing")
api.kv.set(options.kv_key, "stored") api.kv.set(options.kv_key, "stored")
const kv_after = api.kv.get(options.kv_key, "missing") const kv_after = api.kv.get(options.kv_key, "missing")
@@ -132,10 +148,13 @@ export default {
set_installed, set_installed,
selected: api.theme.selected, selected: api.theme.selected,
same: first === second, same: first === second,
key_modal: key.get("modal"), key_modal,
key_close: key.get("close"), key_close,
key_unknown: key.get("ctrl+k"), key_unknown,
key_print: key.print("modal"), has_keys,
has_keymap: typeof api.keymap.registerLayer === "function",
has_resolve_binding_sections: typeof resolveBindingSections === "function",
has_keymap_solid: typeof useBindings === "function",
kv_before, kv_before,
kv_after, kv_after,
kv_ready: api.kv.ready, kv_ready: api.kv.ready,
@@ -337,7 +356,14 @@ export default {
theme_name: tmp.extra.localThemeName, theme_name: tmp.extra.localThemeName,
kv_key: "plugin_state_key", kv_key: "plugin_state_key",
session_id: "ses_test", session_id: "ses_test",
keybinds: { modal: "ctrl+alt+m", close: "q" }, keymap: {
sections: {
main: {
"plugin.loader.local": "ctrl+alt+m",
"plugin.loader.close": "q",
},
},
},
} }
const invalidOpts = { const invalidOpts = {
marker: tmp.extra.invalidMarker, marker: tmp.extra.invalidMarker,
@@ -356,7 +382,7 @@ export default {
theme_name: tmp.extra.globalThemeName, theme_name: tmp.extra.globalThemeName,
} }
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [ plugin: [
[tmp.extra.localSpec, localOpts], [tmp.extra.localSpec, localOpts],
[tmp.extra.invalidSpec, invalidOpts], [tmp.extra.invalidSpec, invalidOpts],
@@ -373,7 +399,7 @@ export default {
source: path.join(Global.Path.config, "tui.json"), source: path.join(Global.Path.config, "tui.json"),
}, },
], ],
} })
await TuiPluginRuntime.init({ await TuiPluginRuntime.init({
api: createTuiPluginApi({ api: createTuiPluginApi({
@@ -386,9 +412,6 @@ export default {
input_submit: "ctrl+enter", input_submit: "ctrl+enter",
}, },
}, },
keybind: {
print: (key) => `print:${key}`,
},
state: { state: {
session: { session: {
diff(sessionID) { diff(sessionID) {
@@ -507,7 +530,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [ plugin: [
[tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }], [tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }], [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
@@ -525,7 +548,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
@@ -606,13 +629,13 @@ export default {
const b = path.join(tmp.path, "order-b.ts") const b = path.join(tmp.path, "order-b.ts")
const aSpec = pathToFileURL(a).href const aSpec = pathToFileURL(a).href
const bSpec = pathToFileURL(b).href const bSpec = pathToFileURL(b).href
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [aSpec, bSpec], plugin: [aSpec, bSpec],
plugin_origins: [ plugin_origins: [
{ spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, { spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
{ spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") }, { spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
], ],
} })
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config }) await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n") const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
expect(lines).toEqual(["a-start", "a-end", "b"]) expect(lines).toEqual(["a-start", "a-end", "b"])
@@ -645,7 +668,10 @@ describe("tui.plugin.loader", () => {
expect(data.local.key_modal).toBe("ctrl+alt+m") expect(data.local.key_modal).toBe("ctrl+alt+m")
expect(data.local.key_close).toBe("q") expect(data.local.key_close).toBe("q")
expect(data.local.key_unknown).toBe("ctrl+k") expect(data.local.key_unknown).toBe("ctrl+k")
expect(data.local.key_print).toBe("print:ctrl+alt+m") expect(data.local.has_keys).toBe(true)
expect(data.local.has_keymap).toBe(true)
expect(data.local.has_resolve_binding_sections).toBe(true)
expect(data.local.has_keymap_solid).toBe(true)
expect(data.local.kv_before).toBe("missing") expect(data.local.kv_before).toBe("missing")
expect(data.local.kv_after).toBe("stored") expect(data.local.kv_after).toBe("stored")
expect(data.local.kv_ready).toBe(true) expect(data.local.kv_ready).toBe(true)
@@ -703,6 +729,227 @@ describe("tui.plugin.loader", () => {
}) })
}) })
test("auto-disposes plugin keymap layers", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "keymap-cleanup-plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(
file,
`export default {
id: "demo.keymap.cleanup",
tui: async (api) => {
api.keymap.registerLayer({
commands: [{ name: "demo.keymap.cleanup", run() {} }],
bindings: [{ key: "ctrl+g", cmd: "demo.keymap.cleanup" }],
})
},
}
`,
)
return { spec }
},
})
let command_add = 0
let command_drop = 0
const keymap = {
registerLayer(layer: { commands?: Array<{ name: string }> }) {
const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup") ?? false
if (tracked) command_add += 1
return () => {
if (!tracked) return
command_drop += 1
}
},
} as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({
api: createTuiPluginApi({ keymap }),
config: createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
}),
})
expect(command_add).toBe(1)
expect(command_drop).toBe(0)
} finally {
await TuiPluginRuntime.dispose()
expect(command_drop).toBe(1)
cwd.mockRestore()
wait.mockRestore()
}
})
test("plugin keymap proxy preserves real keymap receiver", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "keymap-receiver-plugin.ts")
const spec = pathToFileURL(file).href
const marker = path.join(dir, "keymap-receiver.txt")
await Bun.write(
file,
`export default {
id: "demo.keymap.receiver",
tui: async (api) => {
api.keymap.setData("demo.receiver", "ok")
await Bun.write(${JSON.stringify(marker)}, String(api.keymap.getData("demo.receiver")))
},
}
`,
)
return { spec, marker }
},
})
const harness = createTestKeymap({ defaultKeys: true })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({
api: createTuiPluginApi({
keymap: harness.keymap as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"],
}),
config: createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
}),
})
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("ok")
expect(harness.keymap.getData("demo.receiver")).toBe("ok")
} finally {
await TuiPluginRuntime.dispose()
harness.cleanup()
cwd.mockRestore()
wait.mockRestore()
}
})
test("auto-disposes plugin keymap transformers", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "keymap-transformer-cleanup-plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(
file,
`export default {
id: "demo.keymap.transformer.cleanup",
tui: async (api) => {
api.keymap.prependLayerBindingsTransformer((bindings) => bindings)
api.keymap.appendLayerBindingsTransformer((bindings) => bindings)
api.keymap.prependCommandTransformer(() => {})
api.keymap.appendCommandTransformer(() => {})
},
}
`,
)
return { spec }
},
})
let add = 0
let drop = 0
const track = () => {
add += 1
return () => {
drop += 1
}
}
const keymap = {
registerLayer: () => () => {},
prependLayerBindingsTransformer: track,
appendLayerBindingsTransformer: track,
prependCommandTransformer: track,
appendCommandTransformer: track,
} as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({
api: createTuiPluginApi({ keymap }),
config: createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
}),
})
expect(add).toBe(4)
expect(drop).toBe(0)
} finally {
await TuiPluginRuntime.dispose()
expect(drop).toBe(4)
cwd.mockRestore()
wait.mockRestore()
}
})
test("manual onDispose for plugin keymap layers stays idempotent", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "keymap-cleanup-manual-plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(
file,
`export default {
id: "demo.keymap.cleanup.manual",
tui: async (api) => {
const off = api.keymap.registerLayer({
commands: [{ name: "demo.keymap.cleanup.manual", run() {} }],
bindings: [{ key: "ctrl+h", cmd: "demo.keymap.cleanup.manual" }],
})
api.lifecycle.onDispose(off)
},
}
`,
)
return { spec }
},
})
let command_drop = 0
const keymap = {
registerLayer(layer: { commands?: Array<{ name: string }> }) {
const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup.manual") ?? false
return () => {
if (!tracked) return
command_drop += 1
}
},
} as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
try {
await TuiPluginRuntime.init({
api: createTuiPluginApi({ keymap }),
config: createTuiResolvedConfig({
plugin: [tmp.extra.spec],
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
}),
})
} finally {
await TuiPluginRuntime.dispose()
expect(command_drop).toBe(1)
cwd.mockRestore()
wait.mockRestore()
}
})
test("updates installed theme when plugin metadata changes", async () => { test("updates installed theme when plugin metadata changes", async () => {
await using tmp = await tmpdir<{ await using tmp = await tmpdir<{
spec: string spec: string
@@ -766,16 +1013,17 @@ test("updates installed theme when plugin metadata changes", async () => {
}, },
}) })
const mkConfig = (): TuiConfig.Info => ({ const mkConfig = () =>
plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]], createTuiResolvedConfig({
plugin_origins: [ plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
{ plugin_origins: [
spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }], {
scope: "local", spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
source: path.join(tmp.path, "tui.json"), scope: "local",
}, source: path.join(tmp.path, "tui.json"),
], },
}) ],
})
try { try {
await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() }) await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
@@ -4,6 +4,7 @@ import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { tmpdir } from "../../fixture/fixture" import { tmpdir } from "../../fixture/fixture"
import { createTuiPluginApi } from "../../fixture/tui-plugin" import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime") const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
@@ -39,7 +40,7 @@ test("toggles plugin runtime state by exported id", async () => {
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_enabled: { plugin_enabled: {
"demo.toggle": false, "demo.toggle": false,
@@ -51,7 +52,7 @@ test("toggles plugin runtime state by exported id", async () => {
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi() const api = createTuiPluginApi()
@@ -116,7 +117,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
}) })
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]], plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
plugin_enabled: { plugin_enabled: {
"demo.startup": false, "demo.startup": false,
@@ -128,7 +129,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
source: path.join(tmp.path, "tui.json"), source: path.join(tmp.path, "tui.json"),
}, },
], ],
} })
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
const api = createTuiPluginApi() const api = createTuiPluginApi()
+161
View File
@@ -30,6 +30,19 @@ const getTuiConfig = async (directory: string) =>
), ),
) )
async function withPlatform<Value>(platform: typeof process.platform, fn: () => Promise<Value>) {
const original = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", {
...original,
value: platform,
})
try {
return await fn()
} finally {
if (original) Object.defineProperty(process, "platform", original)
}
}
afterEach(async () => { afterEach(async () => {
delete process.env.OPENCODE_CONFIG delete process.env.OPENCODE_CONFIG
delete process.env.OPENCODE_TUI_CONFIG delete process.env.OPENCODE_TUI_CONFIG
@@ -389,6 +402,98 @@ test("merges keybind overrides across precedence layers", async () => {
expect(config.keybinds?.theme_list).toBe("ctrl+k") expect(config.keybinds?.theme_list).toBe("ctrl+k")
}) })
test("resolves semantic keymap sections", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
keybinds: { command_list: "ctrl+z" },
keymap: {
sections: {
global: { "command.palette.show": "alt+p" },
prompt: { "prompt.editor": "ctrl+e" },
autocomplete: { "prompt.autocomplete.next": "ctrl+j" },
dialog_actions: { "dialog.action.toggle": "ctrl+t" },
model: { "model.dialog.favorite": "ctrl+f" },
plugins: { "plugin.dialog.install": "shift+i" },
},
},
}),
)
},
})
const config = await getTuiConfig(tmp.path)
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("<leader>n")
expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j")
expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t")
expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f")
expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i")
expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
"plugin.dialog.install",
])
expect(
(config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group,
).toBe("Plugins")
expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([])
})
test("legacy keybinds transform into semantic keymap sections", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
keybinds: {
command_list: "alt+p",
editor_open: "ctrl+e",
"prompt.autocomplete.next": "ctrl+j",
"dialog.mcp.toggle": "ctrl+t",
"dialog.plugins.install": "shift+i",
plugin_manager: "ctrl+shift+p",
},
}),
)
},
})
const config = await getTuiConfig(tmp.path)
expect(Object.keys(config.keymap.sections)).toEqual([
"global",
"session",
"prompt",
"autocomplete",
"input",
"dialog_select",
"dialog_actions",
"model",
"permission",
"question",
"plugins",
"home_tips",
])
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j")
expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t")
expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a")
expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f")
expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i")
expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugins.list")?.key).toBe("ctrl+shift+p")
expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
"plugin.dialog.install",
])
expect(
(config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group,
).toBe("Plugins")
expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
"plugins.list",
])
})
wintest("defaults Ctrl+Z to input undo on Windows", async () => { wintest("defaults Ctrl+Z to input undo on Windows", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const config = await getTuiConfig(tmp.path) const config = await getTuiConfig(tmp.path)
@@ -419,6 +524,62 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
}) })
test("applies Windows keymap defaults", async () => {
await withPlatform("win32", async () => {
await using tmp = await tmpdir()
const config = await getTuiConfig(tmp.path)
expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")).toBeUndefined()
expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe(
"ctrl+z,ctrl+-,super+z",
)
})
})
test("keeps explicit configured keymap terminal suspend binding on Windows", async () => {
await withPlatform("win32", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
keymap: {
sections: {
global: { "terminal.suspend": "alt+z" },
},
},
}),
)
},
})
const config = await getTuiConfig(tmp.path)
expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z")
})
})
test("keeps explicit configured keymap input undo on Windows", async () => {
await withPlatform("win32", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
keymap: {
sections: {
input: { "input.undo": "ctrl+y" },
},
},
}),
)
},
})
const config = await getTuiConfig(tmp.path)
expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y")
})
})
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => { test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
init: async (dir) => { init: async (dir) => {
+40 -27
View File
@@ -1,7 +1,9 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { RGBA, type CliRenderer } from "@opentui/core" import { RGBA, type CliRenderer } from "@opentui/core"
import { createPluginKeybind } from "../../src/cli/cmd/tui/context/plugin-keybinds"
import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots" import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots"
import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform"
import { ConfigKeybinds } from "../../src/config/keybinds"
import { createTuiResolvedKeymap } from "./tui-runtime"
type Count = { type Count = {
event_add: number event_add: number
@@ -84,8 +86,8 @@ type Opts = {
client?: HostPluginApi["client"] | (() => HostPluginApi["client"]) client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
renderer?: HostPluginApi["renderer"] renderer?: HostPluginApi["renderer"]
count?: Count count?: Count
keybind?: Partial<HostPluginApi["keybind"]> keymap?: HostPluginApi["keymap"]
tuiConfig?: HostPluginApi["tuiConfig"] tuiConfig?: Partial<HostPluginApi["tuiConfig"]>
app?: Partial<HostPluginApi["app"]> app?: Partial<HostPluginApi["app"]>
state?: { state?: {
ready?: HostPluginApi["state"]["ready"] ready?: HostPluginApi["state"]["ready"]
@@ -109,6 +111,15 @@ type Opts = {
} }
} }
function tuiConfig(input?: Partial<HostPluginApi["tuiConfig"]>): HostPluginApi["tuiConfig"] {
const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {})
return {
...input,
keybinds,
keymap: input?.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input?.keybinds ?? {})),
}
}
export function createTuiPluginApi(opts: Opts = {}): HostPluginApi { export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
const kv: Record<string, unknown> = {} const kv: Record<string, unknown> = {}
const count = opts.count const count = opts.count
@@ -128,10 +139,6 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
let size: "medium" | "large" | "xlarge" = "medium" let size: "medium" | "large" | "xlarge" = "medium"
const has = opts.theme?.has ?? (() => false) const has = opts.theme?.has ?? (() => false)
let selected = opts.theme?.selected ?? "opencode" let selected = opts.theme?.selected ?? "opencode"
const key = {
match: opts.keybind?.match ?? (() => false),
print: opts.keybind?.print ?? ((name: string) => name),
}
const set = const set =
opts.theme?.set ?? opts.theme?.set ??
((name: string) => { ((name: string) => {
@@ -145,6 +152,26 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
return this return this
}, },
} }
const keymap =
opts.keymap ??
({
acquireResource(_key: symbol, setup: () => () => void) {
const dispose = setup()
return () => {
dispose()
}
},
registerLayer() {
if (count) count.command_add += 1
return () => {
if (!count) return
count.command_drop += 1
}
},
runCommand() {
return { ok: true } as const
},
} as unknown as HostPluginApi["keymap"])
function kvGet(name: string): unknown function kvGet(name: string): unknown
function kvGet<Value>(name: string, fallback: Value): Value function kvGet<Value>(name: string, fallback: Value): Value
@@ -160,6 +187,10 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
return opts.app?.version ?? "0.0.0-test" return opts.app?.version ?? "0.0.0-test"
}, },
}, },
keys: {
formatSequence: () => "",
formatBindings: () => undefined,
},
get client() { get client() {
return client() return client()
}, },
@@ -192,17 +223,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
return () => {} return () => {}
}, },
}, },
command: { keymap,
register: () => {
if (count) count.command_add += 1
return () => {
if (!count) return
count.command_drop += 1
}
},
trigger: () => {},
show: () => {},
},
route: { route: {
register: () => { register: () => {
if (count) count.route_add += 1 if (count) count.route_add += 1
@@ -247,15 +268,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
}, },
}, },
}, },
keybind: { tuiConfig: tuiConfig(opts.tuiConfig),
...key,
create:
opts.keybind?.create ??
((defaults, over) => {
return createPluginKeybind(key, defaults, over)
}),
},
tuiConfig: opts.tuiConfig ?? {},
kv: { kv: {
get: kvGet, get: kvGet,
set(name, value) { set(name, value) {
+41 -2
View File
@@ -1,8 +1,47 @@
import { spyOn } from "bun:test" import { spyOn } from "bun:test"
import path from "path" import path from "path"
import type { KeyEvent, Renderable } from "@opentui/core"
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform"
import { ConfigKeybinds } from "../../src/config/keybinds"
import {
KeymapConfig,
KeymapSectionNames,
keymapBindingDefaults,
type KeymapConfigInput,
type KeymapSection,
} from "../../src/cli/cmd/tui/config/tui-schema"
type PluginSpec = string | [string, Record<string, unknown>] type PluginSpec = string | [string, Record<string, unknown>]
type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "keymap"> & {
keybinds?: TuiConfig.Resolved["keybinds"]
keymap?: TuiConfig.Resolved["keymap"]
}
export function createTuiResolvedKeymap(input: KeymapConfigInput): TuiConfig.Resolved["keymap"] {
const config = KeymapConfig.parse(input)
return {
leader: !config.leader || config.leader === "none" ? "ctrl+x" : config.leader,
leader_timeout: config.leader_timeout,
...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
config.sections,
{
sections: KeymapSectionNames,
bindingDefaults: keymapBindingDefaults,
},
),
}
}
export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved {
const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({})
return {
...input,
keybinds,
keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})),
}
}
export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record<string, boolean> }) { export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record<string, boolean> }) {
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json") process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
@@ -14,11 +53,11 @@ export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugi
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
const cwd = spyOn(process, "cwd").mockImplementation(() => dir) const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
const config: TuiConfig.Info = { const config = createTuiResolvedConfig({
plugin, plugin,
plugin_origins, plugin_origins,
...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }), ...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }),
} })
return { return {
config, config,
-421
View File
@@ -1,421 +0,0 @@
import { describe, test, expect } from "bun:test"
import { Keybind } from "@/util/keybind"
describe("Keybind.toString", () => {
test("should convert simple key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" }
expect(Keybind.toString(info)).toBe("f")
})
test("should convert ctrl modifier to string", () => {
const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
expect(Keybind.toString(info)).toBe("ctrl+x")
})
test("should convert leader key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
expect(Keybind.toString(info)).toBe("<leader> f")
})
test("should convert multiple modifiers to string", () => {
const info: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
expect(Keybind.toString(info)).toBe("ctrl+alt+g")
})
test("should convert all modifiers to string", () => {
const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "h" }
expect(Keybind.toString(info)).toBe("<leader> ctrl+alt+shift+h")
})
test("should convert shift modifier to string", () => {
const info: Keybind.Info = {
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "return",
}
expect(Keybind.toString(info)).toBe("shift+return")
})
test("should convert function key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f2" }
expect(Keybind.toString(info)).toBe("f2")
})
test("should convert special key to string", () => {
const info: Keybind.Info = {
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "pgup",
}
expect(Keybind.toString(info)).toBe("pgup")
})
test("should handle empty name", () => {
const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "" }
expect(Keybind.toString(info)).toBe("ctrl")
})
test("should handle only modifiers", () => {
const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "" }
expect(Keybind.toString(info)).toBe("<leader> ctrl+alt+shift")
})
test("should handle only leader with no other parts", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "" }
expect(Keybind.toString(info)).toBe("<leader>")
})
test("should convert super modifier to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
expect(Keybind.toString(info)).toBe("super+z")
})
test("should convert super+shift modifier to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
expect(Keybind.toString(info)).toBe("super+shift+z")
})
test("should handle super with ctrl modifier", () => {
const info: Keybind.Info = { ctrl: true, meta: false, shift: false, super: true, leader: false, name: "a" }
expect(Keybind.toString(info)).toBe("ctrl+super+a")
})
test("should handle super with all modifiers", () => {
const info: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "x" }
expect(Keybind.toString(info)).toBe("ctrl+alt+super+shift+x")
})
test("should handle undefined super field (omitted)", () => {
const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" }
expect(Keybind.toString(info)).toBe("ctrl+c")
})
})
describe("Keybind.match", () => {
test("should match identical keybinds", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match different key names", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "y" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should not match different modifiers", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "x" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match leader keybinds", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match leader vs non-leader", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match complex keybinds", () => {
const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
const b: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match with one modifier different", () => {
const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
const b: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: false, name: "g" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match simple key without modifiers", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should match super modifier keybinds", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match super vs non-super", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: false, leader: false, name: "z" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match undefined super with false super", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, super: false, leader: false, name: "c" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should match super+shift combination", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match when only super differs", () => {
const a: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "a" }
const b: Keybind.Info = { ctrl: true, meta: true, shift: true, super: false, leader: false, name: "a" }
expect(Keybind.match(a, b)).toBe(false)
})
})
describe("Keybind.parse", () => {
test("should parse simple key", () => {
const result = Keybind.parse("f")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "f",
},
])
})
test("should parse leader key syntax", () => {
const result = Keybind.parse("<leader>f")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: true,
name: "f",
},
])
})
test("should parse ctrl modifier", () => {
const result = Keybind.parse("ctrl+x")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "x",
},
])
})
test("should parse multiple modifiers", () => {
const result = Keybind.parse("ctrl+alt+u")
expect(result).toEqual([
{
ctrl: true,
meta: true,
shift: false,
leader: false,
name: "u",
},
])
})
test("should parse shift modifier", () => {
const result = Keybind.parse("shift+f2")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "f2",
},
])
})
test("should parse meta/alt modifier", () => {
const result = Keybind.parse("meta+g")
expect(result).toEqual([
{
ctrl: false,
meta: true,
shift: false,
leader: false,
name: "g",
},
])
})
test("should parse leader with modifier", () => {
const result = Keybind.parse("<leader>h")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: true,
name: "h",
},
])
})
test("should parse multiple keybinds separated by comma", () => {
const result = Keybind.parse("ctrl+c,<leader>q")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "c",
},
{
ctrl: false,
meta: false,
shift: false,
leader: true,
name: "q",
},
])
})
test("should parse shift+return combination", () => {
const result = Keybind.parse("shift+return")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "return",
},
])
})
test("should parse ctrl+j combination", () => {
const result = Keybind.parse("ctrl+j")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "j",
},
])
})
test("should handle 'none' value", () => {
const result = Keybind.parse("none")
expect(result).toEqual([])
})
test("should handle special keys", () => {
const result = Keybind.parse("pgup")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "pgup",
},
])
})
test("should handle function keys", () => {
const result = Keybind.parse("f2")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "f2",
},
])
})
test("should handle complex multi-modifier combination", () => {
const result = Keybind.parse("ctrl+alt+g")
expect(result).toEqual([
{
ctrl: true,
meta: true,
shift: false,
leader: false,
name: "g",
},
])
})
test("should be case insensitive", () => {
const result = Keybind.parse("CTRL+X")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "x",
},
])
})
test("should parse super modifier", () => {
const result = Keybind.parse("super+z")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
super: true,
leader: false,
name: "z",
},
])
})
test("should parse super with shift modifier", () => {
const result = Keybind.parse("super+shift+z")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: true,
super: true,
leader: false,
name: "z",
},
])
})
test("should parse multiple keybinds with super", () => {
const result = Keybind.parse("ctrl+-,super+z")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "-",
},
{
ctrl: false,
meta: false,
shift: false,
super: true,
leader: false,
name: "z",
},
])
})
})
+7 -2
View File
@@ -22,19 +22,24 @@
"zod": "catalog:" "zod": "catalog:"
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.2.2", "@opentui/core": ">=0.2.4",
"@opentui/solid": ">=0.2.2" "@opentui/keymap": ">=0.2.4",
"@opentui/solid": ">=0.2.4"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@opentui/core": { "@opentui/core": {
"optional": true "optional": true
}, },
"@opentui/keymap": {
"optional": true
},
"@opentui/solid": { "@opentui/solid": {
"optional": true "optional": true
} }
}, },
"devDependencies": { "devDependencies": {
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
+44 -43
View File
@@ -15,11 +15,39 @@ import type {
TextPart, TextPart,
Config as SdkConfig, Config as SdkConfig,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import type { CliRenderer, ParsedKey, RGBA, SlotMode } from "@opentui/core" import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core"
import type { Binding, Keymap } from "@opentui/keymap"
import {
resolveBindingSections as resolveKeymapBindingSections,
type BindingSectionsConfig,
type KeySequenceFormatPart,
type SequenceBindingLike,
} from "@opentui/keymap/extras"
import type { JSX, SolidPlugin } from "@opentui/solid" import type { JSX, SolidPlugin } from "@opentui/solid"
import type { Config as PluginConfig, PluginOptions } from "./index.js" import type { Config as PluginConfig, PluginOptions } from "./index.js"
export type { CliRenderer, SlotMode } from "@opentui/core" export type { CliRenderer, KeyEvent, Renderable, SlotMode } from "@opentui/core"
export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap"
export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap"
export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras"
export type {
BindingSectionsConfig,
BindingValue,
FormatCommandBindingsOptions,
FormatKeySequenceOptions,
KeySequenceFormatPart,
SequenceBindingLike,
} from "@opentui/keymap/extras"
export function resolveBindingSections<Section extends string>(
config: BindingSectionsConfig<Renderable, KeyEvent> | undefined,
options: { sections: readonly Section[] },
) {
return resolveKeymapBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, Section>(
config ?? {},
options,
)
}
export type TuiRouteCurrent = export type TuiRouteCurrent =
| { | {
@@ -42,39 +70,12 @@ export type TuiRouteDefinition = {
render: (input: { params?: Record<string, unknown> }) => JSX.Element render: (input: { params?: Record<string, unknown> }) => JSX.Element
} }
export type TuiCommand = { export type TuiKeys = {
title: string formatSequence: (parts: readonly KeySequenceFormatPart[] | undefined) => string
value: string formatBindings: (bindings: readonly SequenceBindingLike[] | undefined) => string | undefined
description?: string
category?: string
keybind?: string
suggested?: boolean
hidden?: boolean
enabled?: boolean
slash?: {
name: string
aliases?: string[]
}
onSelect?: () => void
} }
export type TuiKeybind = { export type TuiKeymap = Keymap<Renderable, KeyEvent>
name: string
ctrl: boolean
meta: boolean
shift: boolean
super?: boolean
leader: boolean
}
export type TuiKeybindMap = Record<string, string>
export type TuiKeybindSet = {
readonly all: TuiKeybindMap
get: (name: string) => string
match: (name: string, evt: ParsedKey) => boolean
print: (name: string) => string
}
export type TuiDialogProps = { export type TuiDialogProps = {
size?: "medium" | "large" | "xlarge" size?: "medium" | "large" | "xlarge"
@@ -288,6 +289,14 @@ export type TuiState = {
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plugin"> & type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plugin"> &
NonNullable<PluginConfig["tui"]> & { NonNullable<PluginConfig["tui"]> & {
plugin_enabled?: Record<string, boolean> plugin_enabled?: Record<string, boolean>
keymap: {
leader: string
leader_timeout: number
sections: Record<string, ReadonlyArray<Binding<Renderable, KeyEvent>>>
get: (section: string, cmd: string) => ReadonlyArray<Binding<Renderable, KeyEvent>> | undefined
pick: (section: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
omit: (section: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
}
} }
export type TuiApp = { export type TuiApp = {
@@ -448,11 +457,8 @@ export type TuiWorkspace = {
export type TuiPluginApi = { export type TuiPluginApi = {
app: TuiApp app: TuiApp
command: { keys: TuiKeys
register: (cb: () => TuiCommand[]) => () => void keymap: TuiKeymap
trigger: (value: string) => void
show: () => void
}
route: { route: {
register: (routes: TuiRouteDefinition[]) => () => void register: (routes: TuiRouteDefinition[]) => () => void
navigate: (name: string, params?: Record<string, unknown>) => void navigate: (name: string, params?: Record<string, unknown>) => void
@@ -469,11 +475,6 @@ export type TuiPluginApi = {
toast: (input: TuiToast) => void toast: (input: TuiToast) => void
dialog: TuiDialogStack dialog: TuiDialogStack
} }
keybind: {
match: (key: string, evt: ParsedKey) => boolean
print: (key: string) => string
create: (defaults: TuiKeybindMap, overrides?: Record<string, unknown>) => TuiKeybindSet
}
readonly tuiConfig: Frozen<TuiConfigView> readonly tuiConfig: Frozen<TuiConfigView>
kv: TuiKV kv: TuiKV
state: TuiState state: TuiState
+13 -3
View File
@@ -525,17 +525,27 @@ You can also define commands using markdown files in `~/.config/opencode/command
--- ---
### Keybinds ### Keymap
Customize keybinds in `tui.json`. Customize TUI keyboard shortcuts in `tui.json` with `keymap`.
```json title="tui.json" ```json title="tui.json"
{ {
"$schema": "https://opencode.ai/tui.json", "$schema": "https://opencode.ai/tui.json",
"keybinds": {} "keymap": {
"sections": {
"global": {
"command.palette.show": "ctrl+p"
}
}
}
} }
``` ```
`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
The older `keybinds` field is deprecated and only applies when `keymap` is not present.
[Learn more here](/docs/keybinds). [Learn more here](/docs/keybinds).
--- ---
+288 -115
View File
@@ -1,144 +1,317 @@
--- ---
title: Keybinds title: Keybinds
description: Customize your keybinds. description: Customize your keyboard shortcuts.
--- ---
OpenCode has a list of keybinds that you can customize through `tui.json`. OpenCode customizes TUI keyboard shortcuts with `keymap` in `tui.json`.
```json title="tui.json" The older `keybinds` field is still accepted as a migration fallback, but it is deprecated and will be removed in OpenCode v2.0. If `keymap` is present, OpenCode ignores `keybinds` for shortcut resolution.
{
"$schema": "https://opencode.ai/tui.json",
"keybinds": {
"leader": "ctrl+x",
"app_exit": "ctrl+c,ctrl+d,<leader>q",
"editor_open": "<leader>e",
"theme_list": "<leader>t",
"sidebar_toggle": "<leader>b",
"scrollbar_toggle": "none",
"username_toggle": "none",
"status_view": "<leader>s",
"tool_details": "none",
"session_export": "<leader>x",
"session_new": "<leader>n",
"session_list": "<leader>l",
"session_timeline": "<leader>g",
"session_fork": "none",
"session_rename": "ctrl+r",
"session_share": "none",
"session_unshare": "none",
"session_interrupt": "escape",
"session_compact": "<leader>c",
"session_child_first": "<leader>down",
"session_child_cycle": "right",
"session_child_cycle_reverse": "left",
"session_parent": "up",
"messages_page_up": "pageup,ctrl+alt+b",
"messages_page_down": "pagedown,ctrl+alt+f",
"messages_line_up": "ctrl+alt+y",
"messages_line_down": "ctrl+alt+e",
"messages_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d",
"messages_first": "ctrl+g,home",
"messages_last": "ctrl+alt+g,end",
"messages_next": "none",
"messages_previous": "none",
"messages_copy": "<leader>y",
"messages_undo": "<leader>u",
"messages_redo": "<leader>r",
"messages_last_user": "none",
"messages_toggle_conceal": "<leader>h",
"model_list": "<leader>m",
"model_cycle_recent": "f2",
"model_cycle_recent_reverse": "shift+f2",
"model_cycle_favorite": "none",
"model_cycle_favorite_reverse": "none",
"variant_cycle": "ctrl+t",
"variant_list": "none",
"command_list": "ctrl+p",
"agent_list": "<leader>a",
"agent_cycle": "tab",
"agent_cycle_reverse": "shift+tab",
"input_clear": "ctrl+c",
"input_paste": "ctrl+v",
"input_submit": "return",
"input_newline": "shift+return,ctrl+return,alt+return,ctrl+j",
"input_move_left": "left,ctrl+b",
"input_move_right": "right,ctrl+f",
"input_move_up": "up",
"input_move_down": "down",
"input_select_left": "shift+left",
"input_select_right": "shift+right",
"input_select_up": "shift+up",
"input_select_down": "shift+down",
"input_line_home": "ctrl+a",
"input_line_end": "ctrl+e",
"input_select_line_home": "ctrl+shift+a",
"input_select_line_end": "ctrl+shift+e",
"input_visual_line_home": "alt+a",
"input_visual_line_end": "alt+e",
"input_select_visual_line_home": "alt+shift+a",
"input_select_visual_line_end": "alt+shift+e",
"input_buffer_home": "home",
"input_buffer_end": "end",
"input_select_buffer_home": "shift+home",
"input_select_buffer_end": "shift+end",
"input_delete_line": "ctrl+shift+d",
"input_delete_to_line_end": "ctrl+k",
"input_delete_to_line_start": "ctrl+u",
"input_backspace": "backspace,shift+backspace",
"input_delete": "ctrl+d,delete,shift+delete",
"input_undo": "ctrl+-,super+z",
"input_redo": "ctrl+.,super+shift+z",
"input_word_forward": "alt+f,alt+right,ctrl+right",
"input_word_backward": "alt+b,alt+left,ctrl+left",
"input_select_word_forward": "alt+shift+f,alt+shift+right",
"input_select_word_backward": "alt+shift+b,alt+shift+left",
"input_delete_word_forward": "alt+d,alt+delete,ctrl+delete",
"input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace",
"history_previous": "up",
"history_next": "down",
"terminal_suspend": "ctrl+z",
"terminal_title_toggle": "none",
"tips_toggle": "<leader>h",
"display_thinking": "none"
}
}
```
:::note `keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
On Windows, the defaults for `input_undo` and `terminal_suspend` are different:
- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend).
- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend.
:::
--- ---
## Leader key ## Leader key
OpenCode uses a `leader` key for most keybinds. This avoids conflicts in your terminal. OpenCode uses a `leader` key for many shortcuts. This avoids conflicts in your terminal.
By default, `ctrl+x` is the leader key and most actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. By default, `ctrl+x` is the leader key and leader shortcuts require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
You don't need to use a leader key for your keybinds but we recommend doing so. You do not need to use a leader key, but we recommend doing so.
Some navigation keybinds intentionally do not use the leader key by default. For subagent sessions, the defaults are `session_child_first` = `\<leader>down`, `session_child_cycle` = `right`, `session_child_cycle_reverse` = `left`, and `session_parent` = `up`.
--- ---
## Disable keybind ## Minimal example
You can disable a keybind by adding the key to `tui.json` with a value of "none". ```json title="tui.json"
{
"$schema": "https://opencode.ai/tui.json",
"keymap": {
"leader": "ctrl+x",
"leader_timeout": 2000,
"sections": {
"global": {
"command.palette.show": "ctrl+p",
"session.new": "<leader>n",
"session.list": "<leader>l"
},
"session": {
"session.compact": "<leader>c",
"session.undo": "<leader>u",
"session.redo": "<leader>r"
},
"input": {
"input.submit": "return",
"input.newline": ["shift+return", "ctrl+return", "alt+return", "ctrl+j"]
}
}
}
}
```
---
## Keymap structure
`keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them.
| Field | Description |
| ----- | ----------- |
| `leader` | The key used by `<leader>` sequences. Defaults to `ctrl+x`. |
| `leader_timeout` | How long OpenCode waits for the next key after the leader key, in milliseconds. Defaults to `2000`. |
| `sections` | A map of TUI areas to command bindings. |
---
## Binding values
A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts, or `"none"`/`false` to disable a command.
```json title="tui.json"
{
"$schema": "https://opencode.ai/tui.json",
"keymap": {
"sections": {
"session": {
"session.compact": "none",
"session.export": "<leader>x,ctrl+shift+x",
"session.copy": ["<leader>y", "ctrl+shift+c"]
}
}
}
}
```
For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`.
```json title="tui.json"
{
"$schema": "https://opencode.ai/tui.json",
"keymap": {
"sections": {
"prompt": {
"prompt.paste": {
"key": "ctrl+v",
"preventDefault": false
}
}
}
}
}
```
---
## Complete keymap reference
This example lists the built-in sections, command names, and default fallback bindings. Commands set to `"none"` are available to bind but disabled by default.
```json title="tui.json"
{
"$schema": "https://opencode.ai/tui.json",
"keymap": {
"leader": "ctrl+x",
"leader_timeout": 2000,
"sections": {
"global": {
"command.palette.show": "ctrl+p",
"session.list": "<leader>l",
"session.new": "<leader>n",
"model.list": "<leader>m",
"model.cycle_recent": "f2",
"model.cycle_recent_reverse": "shift+f2",
"model.cycle_favorite": "none",
"model.cycle_favorite_reverse": "none",
"agent.list": "<leader>a",
"mcp.list": "none",
"agent.cycle": "tab",
"agent.cycle.reverse": "shift+tab",
"variant.cycle": "ctrl+t",
"variant.list": "none",
"provider.connect": "none",
"console.org.switch": "none",
"opencode.status": "<leader>s",
"theme.switch": "<leader>t",
"theme.switch_mode": "none",
"theme.mode.lock": "none",
"help.show": "none",
"docs.open": "none",
"app.exit": "ctrl+c,ctrl+d,<leader>q",
"app.debug": "none",
"app.console": "none",
"app.heap_snapshot": "none",
"app.toggle.animations": "none",
"app.toggle.file_context": "none",
"app.toggle.diffwrap": "none",
"app.toggle.paste_summary": "none",
"app.toggle.session_directory_filter": "none",
"terminal.suspend": "ctrl+z",
"terminal.title.toggle": "none"
},
"session": {
"session.share": "none",
"session.rename": "ctrl+r",
"session.timeline": "<leader>g",
"session.fork": "none",
"session.compact": "<leader>c",
"session.unshare": "none",
"session.undo": "<leader>u",
"session.redo": "<leader>r",
"session.sidebar.toggle": "<leader>b",
"session.toggle.conceal": "<leader>h",
"session.toggle.timestamps": "none",
"session.toggle.thinking": "none",
"session.toggle.actions": "none",
"session.toggle.scrollbar": "none",
"session.toggle.generic_tool_output": "none",
"session.page.up": "pageup,ctrl+alt+b",
"session.page.down": "pagedown,ctrl+alt+f",
"session.line.up": "ctrl+alt+y",
"session.line.down": "ctrl+alt+e",
"session.half.page.up": "ctrl+alt+u",
"session.half.page.down": "ctrl+alt+d",
"session.first": "ctrl+g,home",
"session.last": "ctrl+alt+g,end",
"session.messages_last_user": "none",
"session.message.next": "none",
"session.message.previous": "none",
"messages.copy": "<leader>y",
"session.copy": "none",
"session.export": "<leader>x",
"session.child.first": "<leader>down",
"session.parent": "up",
"session.child.next": "right",
"session.child.previous": "left"
},
"prompt": {
"prompt.submit": "none",
"prompt.editor": "<leader>e",
"prompt.editor_context.clear": "none",
"prompt.skills": "none",
"prompt.stash": "none",
"prompt.stash.pop": "none",
"prompt.stash.list": "none",
"workspace.set": "none",
"session.interrupt": "escape",
"prompt.clear": "ctrl+c",
"prompt.paste": {
"key": "ctrl+v",
"preventDefault": false
},
"prompt.history.previous": "up",
"prompt.history.next": "down"
},
"autocomplete": {
"prompt.autocomplete.prev": "up,ctrl+p",
"prompt.autocomplete.next": "down,ctrl+n",
"prompt.autocomplete.hide": "escape",
"prompt.autocomplete.select": "return",
"prompt.autocomplete.complete": "tab"
},
"input": {
"input.submit": "return",
"input.newline": "shift+return,ctrl+return,alt+return,ctrl+j",
"input.move.left": "left,ctrl+b",
"input.move.right": "right,ctrl+f",
"input.move.up": "up",
"input.move.down": "down",
"input.select.left": "shift+left",
"input.select.right": "shift+right",
"input.select.up": "shift+up",
"input.select.down": "shift+down",
"input.line.home": "ctrl+a",
"input.line.end": "ctrl+e",
"input.select.line.home": "ctrl+shift+a",
"input.select.line.end": "ctrl+shift+e",
"input.visual.line.home": "alt+a",
"input.visual.line.end": "alt+e",
"input.select.visual.line.home": "alt+shift+a",
"input.select.visual.line.end": "alt+shift+e",
"input.buffer.home": "home",
"input.buffer.end": "end",
"input.select.buffer.home": "shift+home",
"input.select.buffer.end": "shift+end",
"input.delete.line": "ctrl+shift+d",
"input.delete.to.line.end": "ctrl+k",
"input.delete.to.line.start": "ctrl+u",
"input.backspace": "backspace,shift+backspace",
"input.delete": "ctrl+d,delete,shift+delete",
"input.undo": "ctrl+-,super+z",
"input.redo": "ctrl+.,super+shift+z",
"input.word.forward": "alt+f,alt+right,ctrl+right",
"input.word.backward": "alt+b,alt+left,ctrl+left",
"input.select.word.forward": "alt+shift+f,alt+shift+right",
"input.select.word.backward": "alt+shift+b,alt+shift+left",
"input.delete.word.forward": "alt+d,alt+delete,ctrl+delete",
"input.delete.word.backward": "ctrl+w,ctrl+backspace,alt+backspace",
"input.select.all": "super+a"
},
"dialog_select": {
"dialog.select.prev": "up,ctrl+p",
"dialog.select.next": "down,ctrl+n",
"dialog.select.page_up": "pageup",
"dialog.select.page_down": "pagedown",
"dialog.select.home": "home",
"dialog.select.end": "end",
"dialog.select.submit": "return"
},
"dialog_actions": {
"dialog.action.toggle": "space",
"dialog.action.delete": "ctrl+d",
"dialog.action.rename": "ctrl+r"
},
"model": {
"model.dialog.provider": "ctrl+a",
"model.dialog.favorite": "ctrl+f"
},
"permission": {
"permission.reject.cancel": "ctrl+c,ctrl+d,<leader>q",
"permission.prompt.escape": "ctrl+c,ctrl+d,<leader>q",
"permission.prompt.fullscreen": "ctrl+f"
},
"question": {
"question.reject": "ctrl+c,ctrl+d,<leader>q",
"question.edit.clear": "ctrl+c"
},
"plugins": {
"plugins.list": "none",
"plugins.install": "none",
"plugin.dialog.install": "shift+i"
},
"home_tips": {
"tips.toggle": "<leader>h"
}
}
}
}
```
---
## Legacy keybinds
`keybinds` is deprecated. It is kept so existing configs continue to work while users migrate to `keymap`.
Only use `keybinds` when `keymap` is not present. If both fields are set, `keymap` wins and `keybinds` are ignored for shortcut resolution.
```json title="tui.json" ```json title="tui.json"
{ {
"$schema": "https://opencode.ai/tui.json", "$schema": "https://opencode.ai/tui.json",
"keybinds": { "keybinds": {
"session_compact": "none" "command_list": "ctrl+p",
"session_new": "<leader>n",
"session_compact": "<leader>c"
} }
} }
``` ```
:::note
On native Windows, the defaults for undo and terminal suspend are different for both `keymap` and legacy `keybinds`:
- `input.undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend).
- `terminal.suspend` is disabled because native Windows terminals do not support POSIX suspend.
:::
--- ---
## Desktop prompt shortcuts ## Desktop prompt shortcuts
+13 -4
View File
@@ -63,7 +63,7 @@ When using the OpenCode TUI, you can type `/` followed by a command name to quic
/help /help
``` ```
Most commands also have keybind using `ctrl+x` as the leader key, where `ctrl+x` is the default leader key. [Learn more](/docs/keybinds). Most commands also have keyboard shortcuts using `ctrl+x` as the default leader key. [Learn more](/docs/keybinds).
Here are all available slash commands: Here are all available slash commands:
@@ -353,8 +353,14 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
{ {
"$schema": "https://opencode.ai/tui.json", "$schema": "https://opencode.ai/tui.json",
"theme": "opencode", "theme": "opencode",
"keybinds": { "keymap": {
"leader": "ctrl+x" "leader": "ctrl+x",
"leader_timeout": 2000,
"sections": {
"global": {
"command.palette.show": "ctrl+p"
}
}
}, },
"scroll_speed": 3, "scroll_speed": 3,
"scroll_acceleration": { "scroll_acceleration": {
@@ -367,10 +373,13 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
This is separate from `opencode.json`, which configures server/runtime behavior. This is separate from `opencode.json`, which configures server/runtime behavior.
`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
### Options ### Options
- `theme` - Sets your UI theme. [Learn more](/docs/themes). - `theme` - Sets your UI theme. [Learn more](/docs/themes).
- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). - `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present.
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
@@ -9,29 +9,30 @@ if (!raw) {
} }
const ver = raw.replace(/^v/, "") const ver = raw.replace(/^v/, "")
const root = path.resolve(import.meta.dir, "../../..") const root = path.resolve(import.meta.dir, "..")
const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"]) const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"])
const keys = ["@opentui/core", "@opentui/solid"] as const const keys = ["@opentui/core", "@opentui/keymap", "@opentui/solid"] as const
const files = (await Array.fromAsync(new Bun.Glob("**/package.json").scan({ cwd: root }))).filter( const files = (await Array.fromAsync(new Bun.Glob("**/package.json").scan({ cwd: root }))).filter(
(file) => !file.split("/").some((part) => skip.has(part)), (file) => !file.split("/").some((part) => skip.has(part)),
) )
const set = (cur: string) => { const setVersion = (cur: string) => {
if (cur === "catalog:" || cur.startsWith("workspace:")) return cur
if (cur.startsWith(">=")) return `>=${ver}` if (cur.startsWith(">=")) return `>=${ver}`
if (cur.startsWith("^")) return `^${ver}` if (cur.startsWith("^")) return `^${ver}`
if (cur.startsWith("~")) return `~${ver}` if (cur.startsWith("~")) return `~${ver}`
return ver return ver
} }
const edit = (obj: unknown) => { const editDeps = (obj: unknown) => {
if (!obj || typeof obj !== "object") return false if (!obj || typeof obj !== "object") return false
const map = obj as Record<string, unknown> const map = obj as Record<string, unknown>
return keys return keys
.map((key) => { .map((key) => {
const cur = map[key] const cur = map[key]
if (typeof cur !== "string") return false if (typeof cur !== "string") return false
const next = set(cur) const next = setVersion(cur)
if (next === cur) return false if (next === cur) return false
map[key] = next map[key] = next
return true return true
@@ -39,13 +40,31 @@ const edit = (obj: unknown) => {
.some(Boolean) .some(Boolean)
} }
const editCatalog = (obj: unknown) => {
if (!obj || typeof obj !== "object") return false
const map = obj as Record<string, unknown>
return keys
.map((key) => {
const cur = map[key]
if (typeof cur !== "string" || cur === ver) return false
map[key] = ver
return true
})
.some(Boolean)
}
const out = ( const out = (
await Promise.all( await Promise.all(
files.map(async (rel) => { files.map(async (rel) => {
const file = path.join(root, rel) const file = path.join(root, rel)
const txt = await Bun.file(file).text() const txt = await Bun.file(file).text()
const json = JSON.parse(txt) const json = JSON.parse(txt)
const hit = [json.dependencies, json.devDependencies, json.peerDependencies].map(edit).some(Boolean) const hit = [
editCatalog(json.workspaces?.catalog),
editDeps(json.dependencies),
editDeps(json.devDependencies),
editDeps(json.peerDependencies),
].some(Boolean)
if (!hit) return null if (!hit) return null
await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`) await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`)
return rel return rel