Compare commits

...

4 Commits

Author SHA1 Message Date
Sebastian Herrlinger
c657428f4b fix 2026-05-03 02:30:28 +02:00
Sebastian Herrlinger
4ce335c75d update 2026-05-03 02:15:23 +02:00
Sebastian Herrlinger
c8f29442a4 opentui snapshot 2026-05-03 02:02:12 +02:00
Sebastian Herrlinger
2a56dd52f6 squash for rebase 2026-05-03 01:55:12 +02:00
52 changed files with 2884 additions and 2698 deletions

View File

@@ -1,8 +1,12 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type {
TuiKeybindSet,
import { useTerminalDimensions, type JSX } from "@opentui/solid"
import { useBindings, useKeymapSelector } from "@opentui/keymap/solid"
import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core"
import {
resolveBindingSections,
type Binding,
type BindingSectionsConfig,
type BindingValue,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
@@ -11,27 +15,83 @@ import type {
} from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
modal: "ctrl+shift+m",
screen: "ctrl+shift+o",
home: "escape,ctrl+h",
left: "left,h",
right: "right,l",
up: "up,k",
down: "down,j",
alert: "a",
confirm: "c",
prompt: "p",
select: "s",
modal_accept: "enter,return",
modal_close: "escape",
dialog_close: "escape",
local: "x",
local_push: "enter,return",
local_close: "q,backspace",
host: "z",
const command = {
modal: "plugin.smoke.modal",
screen: "plugin.smoke.screen",
alert: "plugin.smoke.alert",
confirm: "plugin.smoke.confirm",
prompt: "plugin.smoke.prompt",
select: "plugin.smoke.select",
host: "plugin.smoke.host",
home: "plugin.smoke.home",
toast: "plugin.smoke.toast",
dialog_close: "plugin.smoke.dialog.close",
local_push: "plugin.smoke.local.push",
local_pop: "plugin.smoke.local.pop",
screen_home: "plugin.smoke.screen.home",
screen_left: "plugin.smoke.screen.left",
screen_right: "plugin.smoke.screen.right",
screen_up: "plugin.smoke.screen.up",
screen_down: "plugin.smoke.screen.down",
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) => {
if (typeof value !== "string") return fallback
if (!value.trim()) return fallback
@@ -43,16 +103,11 @@ const num = (value: unknown, fallback: number) => {
return value
}
const rec = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return
return Object.fromEntries(Object.entries(value))
}
type Cfg = {
label: string
route: string
vignette: number
keybinds: Record<string, unknown> | undefined
keymap: SmokeKeymap | undefined
}
type Route = {
@@ -69,12 +124,12 @@ type State = {
local: number
}
const cfg = (options: Record<string, unknown> | undefined) => {
const cfg = (options: SmokeOptions | undefined) => {
return {
label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: rec(options?.keybinds),
keymap: options?.keymap,
}
}
@@ -85,7 +140,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 = {
panel: "#1d1d1d",
border: "#4a4a4a",
@@ -292,125 +365,161 @@ const Screen = (props: {
}
const pop = (base?: State) => {
const next = base ?? current(props.api, props.route)
const local = Math.max(0, next.local - 1)
set(local, next)
set(Math.max(0, next.local - 1), next)
}
const show = () => {
setTimeout(() => {
open()
}, 0)
}
useKeyboard((evt) => {
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
}
const screenActive = () => props.api.route.current.name === props.route.screen
if (next.local > 0) {
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
pop(next)
return
}
useBindings(() => ({
enabled: () => screenActive() && props.api.ui.dialog.open,
commands: [
{
name: command.dialog_close,
run() {
props.api.ui.dialog.clear()
},
},
],
bindings: props.keys.sections.dialog,
}))
if (props.keys.match("local_push", evt)) {
evt.preventDefault()
evt.stopPropagation()
push(next)
return
}
return
}
useBindings(() => ({
enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0,
commands: [
{
name: command.local_push,
run() {
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)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return
}
useBindings(() => ({
enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0,
commands: [
{
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)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
return
}
if (props.keys.match("right", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
return
}
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)
return {
screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "",
screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "",
screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "",
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)) ?? "",
screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "",
screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "",
screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "",
screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "",
local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "",
local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "",
}
})
@@ -430,7 +539,7 @@ const Screen = (props: {
<b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span>
</text>
<text fg={skin.muted}>{props.keys.print("home")} home</text>
<text fg={skin.muted}>{shortcuts().screen_home} home</text>
</box>
<box flexDirection="row" gap={1} paddingBottom={1}>
@@ -477,7 +586,7 @@ const Screen = (props: {
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}>
{props.keys.print("up")} / {props.keys.print("down")} change value
{shortcuts().screen_up} / {shortcuts().screen_down} change value
</text>
</box>
) : null}
@@ -485,17 +594,15 @@ const Screen = (props: {
{value.tab === 2 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.muted}>
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
{shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm} confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select
</text>
<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 fg={skin.muted}>
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
close
local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close
</text>
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
<text fg={skin.muted}>{shortcuts().screen_home} returns home</text>
</box>
) : null}
</box>
@@ -548,7 +655,7 @@ const Screen = (props: {
</text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<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>
<box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on />
@@ -571,20 +678,35 @@ const Modal = (props: {
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.modal) return
useBindings(() => ({
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)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
return
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return {
modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "",
screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "",
modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "",
modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "",
}
})
@@ -595,10 +717,10 @@ const Modal = (props: {
<text fg={skin.text}>
<b>{props.input.label} modal</b>
</text>
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
<text fg={skin.muted}>{shortcuts().modal} modal command</text>
<text fg={skin.muted}>{shortcuts().screen} screen command</text>
<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>
<box flexDirection="row" gap={1}>
<Btn
@@ -791,120 +913,117 @@ const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
const route = names(input)
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: keys.get("modal"),
category: "Plugin",
slash: {
name: "smoke",
api.keymap.registerLayer({
commands: [
{
name: command.modal,
title: `${input.label} modal`,
category: "Plugin",
namespace: "palette",
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 })
},
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: keys.get("screen"),
category: "Plugin",
slash: {
name: "smoke-screen",
{
name: command.alert,
title: `${input.label} alert dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-alert",
run() {
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))
},
},
},
{
title: `${input.label} alert dialog`,
value: "plugin.smoke.alert",
category: "Plugin",
slash: {
name: "smoke-alert",
{
name: command.prompt,
title: `${input.label} prompt dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-prompt",
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))
},
},
},
{
title: `${input.label} confirm dialog`,
value: "plugin.smoke.confirm",
category: "Plugin",
slash: {
name: "smoke-confirm",
{
name: command.host,
title: `${input.label} host overlay`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-host",
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")
},
},
},
{
title: `${input.label} prompt dialog`,
value: "plugin.smoke.prompt",
category: "Plugin",
slash: {
name: "smoke-prompt",
{
name: command.toast,
title: `${input.label} toast`,
category: "Plugin",
namespace: "palette",
run() {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
onSelect: () => {
entry(api, route, current(api, route))
},
},
{
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,
})
},
},
])
],
bindings: keys.sections.global,
})
}
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")
api.theme.set("smoke-theme")
const value = cfg(options ?? undefined)
const value = cfg(input)
const route = names(value)
const keys = api.keybind.create(bind, value.keybinds)
const keys = createKeys(value.keymap)
const fx = new VignetteEffect(value.vignette)
const post = fx.apply.bind(fx)
api.renderer.addPostProcessFn(post)

View File

@@ -6,11 +6,20 @@
{
"enabled": false,
"label": "workspace",
"keybinds": {
"modal": "ctrl+alt+m",
"screen": "ctrl+alt+o",
"home": "escape,ctrl+shift+h",
"dialog_close": "escape,q"
"keymap": {
"sections": {
"global": {
"plugin.smoke.modal": "ctrl+alt+m",
"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"
}
}
}
}
]

View File

@@ -406,6 +406,7 @@
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
@@ -504,6 +505,7 @@
},
"devDependencies": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
@@ -511,11 +513,13 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.2",
"@opentui/solid": ">=0.2.2",
"@opentui/core": ">=0.0.0-20260502-5091230e",
"@opentui/keymap": ">=0.0.0-20260502-5091230e",
"@opentui/solid": ">=0.0.0-20260502-5091230e",
},
"optionalPeers": [
"@opentui/core",
"@opentui/keymap",
"@opentui/solid",
],
},
@@ -690,8 +694,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"@opentui/core": "0.0.0-20260502-5091230e",
"@opentui/keymap": "0.0.0-20260502-5091230e",
"@opentui/solid": "0.0.0-20260502-5091230e",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1618,21 +1623,23 @@
"@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.0.0-20260502-5091230e", "", { "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.0.0-20260502-5091230e", "@opentui/core-darwin-x64": "0.0.0-20260502-5091230e", "@opentui/core-linux-arm64": "0.0.0-20260502-5091230e", "@opentui/core-linux-x64": "0.0.0-20260502-5091230e", "@opentui/core-win32-arm64": "0.0.0-20260502-5091230e", "@opentui/core-win32-x64": "0.0.0-20260502-5091230e" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-56XgLmV4uf7Lg6/gJQnyJnbTvWgPbKJJRNStjYf1xCTqHsYznSH5vLtvdD1cgNN8vmIETywsEnPDIpdFwJ+3Gw=="],
"@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.0.0-20260502-5091230e", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lP1xVb8hR+8LgHX4Z9xrI7nhTrDt/boSEMjbHtPz9fojcDhgTRbL5Xn2NNHz+20NEip8qjb3ch6KjK4IfbB+vw=="],
"@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.0.0-20260502-5091230e", "", { "os": "darwin", "cpu": "x64" }, "sha512-6DenKt3ZhkoDeyYsuaCsHo2URq4gQe2KiW5tQSiOYcGdRniCvBYei+cs97VMT2b53xAry0dYJi1Y19ZGZjiUeg=="],
"@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.0.0-20260502-5091230e", "", { "os": "linux", "cpu": "arm64" }, "sha512-ULAm+w2P7mRbQUvr55hFU//Y4aBE4iOzUxRIXEHGEfp2YftxO7dygUI1n/KnKFjdnMwuyOm37rAZlgd4f7nArw=="],
"@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.0.0-20260502-5091230e", "", { "os": "linux", "cpu": "x64" }, "sha512-QCgPhyk7lJhpdKyUuc4ZfnYsDM4a4EZqGPM94Udk8mh9l8CNGso7Bc5X9P9HciT6dX2cdk4m5hFjZKTnjmnddA=="],
"@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.0.0-20260502-5091230e", "", { "os": "win32", "cpu": "arm64" }, "sha512-xn7OeHzwZUmUDbv6Tc+jsqTE6zNlG+1nk2JTk8+6VIn201PwnGztcLxGrYdljinVYabcV9Xkj33KO+1gxyp5QQ=="],
"@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.0.0-20260502-5091230e", "", { "os": "win32", "cpu": "x64" }, "sha512-fIcgR0CA68Ezh5CKaoozF8QYhoubufILNdCxNLKDverUgTLvIhfHe7f4YisNwjcwF4MsxjsGzNk6pnJw/2F5kQ=="],
"@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.0.0-20260502-5091230e", "", { "dependencies": { "@opentui/core": "0.0.0-20260502-5091230e" }, "peerDependencies": { "@opentui/react": "0.0.0-20260502-5091230e", "@opentui/solid": "0.0.0-20260502-5091230e", "react": ">=19.0.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-1NQuDnvC0T+nhMydEegLIL9wUWmbnmgqU1vXAwz/bKz+t0VoPL+DWvRExzpaQ9nMHBCH4To1FzUDWOewYhUhTA=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20260502-5091230e", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260502-5091230e", "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-GHIFJqMDHsB/YRaeA4xcZD+blPuQ3rNkcxGtYXjI7xFFgvScJCLi/1rg9jkkLM6YxzfAWftkj6ZoZ0x0+6Bb6g=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],

View File

@@ -13,6 +13,7 @@
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
@@ -34,8 +35,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"@opentui/core": "0.0.0-20260502-5091230e",
"@opentui/keymap": "0.0.0-20260502-5091230e",
"@opentui/solid": "0.0.0-20260502-5091230e",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.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",
"build": "bun run script/build.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:temporary": "bun run --conditions=browser ./src/temporary.ts",
"db": "bun drizzle-kit"
@@ -120,6 +119,7 @@
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",

View File

@@ -53,13 +53,21 @@ Minimal module shape:
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
const tui: TuiPlugin = async (api, options, meta) => {
api.command.register(() => [
{
title: "Demo",
value: "demo.open",
onSelect: () => api.route.navigate("demo"),
},
])
api.keymap.registerLayer({
commands: [
{
name: "demo.open",
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([
{
@@ -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)`:
- `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.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Slot`, `Prompt`, `ui.toast`, `ui.dialog`
- `api.keybind.match`, `print`, `create`
- `api.tuiConfig`
- `api.kv.get`, `set`, `ready`
- `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.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`
- `description`, `category`
- `keybind`
- `suggested`, `hidden`, `enabled`
- `slash: { name, aliases? }`
- `onSelect`
### Keys
Command behavior:
- Registrations are reactive.
- Later registrations win for duplicate `value` and for keybind handling.
- 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.
- `api.keys` exposes host-formatted shortcut display helpers for plugin UI.
- `formatSequence(parts)` formats parsed key sequence parts using the host's display policy.
- `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show.
- For generic config-to-bindings helpers, import `resolveBindingSections` from `@opencode-ai/plugin/tui`.
### Routes
@@ -252,13 +260,6 @@ Command behavior:
- `setSize("medium" | "large" | "xlarge")`
- 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
- `api.kv` is the shared app KV store backed by `state/kv.json`. It is not plugin-namespaced.

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 Selection from "@tui/util/selection"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
@@ -11,6 +12,7 @@ import {
ErrorBoundary,
createSignal,
onMount,
onCleanup,
batch,
Show,
on,
@@ -35,11 +37,9 @@ import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
@@ -59,10 +59,12 @@ import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
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 { createTuiApi } from "@/cli/cmd/tui/plugin/api"
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
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 { DialogVariant } from "./component/dialog-variant"
@@ -110,7 +112,7 @@ function errorMessage(error: unknown) {
export function tui(input: {
url: string
args: Args
config: TuiConfig.Info
config: TuiConfig.Resolved
onSnapshot?: () => Promise<string[]>
directory?: string
fetch?: typeof fetch
@@ -129,6 +131,7 @@ export function tui(input: {
}
const onBeforeExit = async () => {
offKeymap()
await TuiPluginRuntime.dispose()
}
@@ -137,6 +140,9 @@ export function tui(input: {
void renderer.getPalette({ size: 16 }).catch(() => undefined)
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
const keymap = createDefaultOpenTuiKeymap(renderer)
const offKeymap = registerOpencodeKeymap(keymap, renderer, input.config)
await render(() => {
return (
<ErrorBoundary
@@ -144,36 +150,36 @@ export function tui(input: {
<ErrorComponent error={error} reset={reset} onBeforeExit={onBeforeExit} onExit={onExit} mode={mode} />
)}
>
<ArgsProvider {...input.args}>
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<OpencodeKeymapProvider keymap={keymap}>
<ArgsProvider {...input.args}>
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<ProjectProvider>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<CommandPaletteProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
@@ -183,21 +189,21 @@ export function tui(input: {
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</CommandPaletteProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</ProjectProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</OpencodeKeymapProvider>
</ErrorBoundary>
)
}, renderer)
@@ -206,14 +212,17 @@ export function tui(input: {
function App(props: { onSnapshot?: () => Promise<string[]> }) {
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
const dialog = useDialog()
const local = useLocal()
const kv = useKV()
const command = useCommandDialog()
const keybind = useKeybind()
const command = useCommandPalette()
const keymap = useOpencodeKeymap()
const event = useEvent()
const sdk = useSDK()
const toast = useToast()
@@ -230,10 +239,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}
const api = createTuiApi({
command,
tuiConfig,
dialog,
keybind,
keymap,
kv,
route,
routes,
@@ -257,40 +265,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
setReady(true)
})
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
const sel = renderer.getSelection()
if (!sel) return
// Windows Terminal-like behavior:
// - Ctrl+C copies and dismisses selection
// - Esc dismisses selection
// - Most other key input dismisses selection and is passed through
if (evt.ctrl && evt.name === "c") {
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()
})
// Let selection copy/dismiss win ahead of normal bindings when the feature flag is on.
const offSelectionKeys = keymap.intercept(
"key",
({ event }) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
Selection.handleSelectionKey(renderer, toast, event)
},
{ priority: 1 },
)
onCleanup(offSelectionKeys)
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
@@ -407,379 +391,374 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
)
const connected = useConnected()
command.register(() => [
{
title: "Switch session",
value: "session.list",
keybind: "session_list",
category: "Session",
suggested: sync.data.session.length > 0,
slash: {
name: "sessions",
aliases: ["resume", "continue"],
const appCommands = createMemo(() =>
[
{
name: "command.palette.show",
title: "Show command palette",
hidden: true,
run: () => {
command.show()
},
},
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 />)
},
},
},
{
title: "New session",
suggested: route.data.type === "session",
value: "session.new",
keybind: "session_new",
category: "Session",
slash: {
name: "new",
aliases: ["clear"],
{
name: "session.new",
title: "New session",
suggested: route.data.type === "session",
category: "Session",
slashName: "new",
slashAliases: ["clear"],
run: () => {
route.navigate({
type: "home",
})
dialog.clear()
},
},
onSelect: () => {
route.navigate({
type: "home",
})
dialog.clear()
{
name: "model.list",
title: "Switch model",
suggested: true,
category: "Agent",
slashName: "models",
run: () => {
dialog.replace(() => <DialogModel />)
},
},
},
{
title: "Switch model",
value: "model.list",
keybind: "model_list",
suggested: true,
category: "Agent",
slash: {
name: "models",
{
name: "model.cycle_recent",
title: "Model cycle",
category: "Agent",
hidden: true,
run: () => {
local.model.cycle(1)
},
},
onSelect: () => {
dialog.replace(() => <DialogModel />)
{
name: "model.cycle_recent_reverse",
title: "Model cycle reverse",
category: "Agent",
hidden: true,
run: () => {
local.model.cycle(-1)
},
},
},
{
title: "Model cycle",
value: "model.cycle_recent",
keybind: "model_cycle_recent",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycle(1)
{
name: "model.cycle_favorite",
title: "Favorite cycle",
category: "Agent",
hidden: true,
run: () => {
local.model.cycleFavorite(1)
},
},
},
{
title: "Model cycle reverse",
value: "model.cycle_recent_reverse",
keybind: "model_cycle_recent_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycle(-1)
{
name: "model.cycle_favorite_reverse",
title: "Favorite cycle reverse",
category: "Agent",
hidden: true,
run: () => {
local.model.cycleFavorite(-1)
},
},
},
{
title: "Favorite cycle",
value: "model.cycle_favorite",
keybind: "model_cycle_favorite",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycleFavorite(1)
{
name: "agent.list",
title: "Switch agent",
category: "Agent",
slashName: "agents",
run: () => {
dialog.replace(() => <DialogAgent />)
},
},
},
{
title: "Favorite cycle reverse",
value: "model.cycle_favorite_reverse",
keybind: "model_cycle_favorite_reverse",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.cycleFavorite(-1)
{
name: "mcp.list",
title: "Toggle MCPs",
category: "Agent",
slashName: "mcps",
run: () => {
dialog.replace(() => <DialogMcp />)
},
},
},
{
title: "Switch agent",
value: "agent.list",
keybind: "agent_list",
category: "Agent",
slash: {
name: "agents",
{
name: "agent.cycle",
title: "Agent cycle",
category: "Agent",
hidden: true,
run: () => {
local.agent.move(1)
},
},
onSelect: () => {
dialog.replace(() => <DialogAgent />)
{
name: "variant.cycle",
title: "Variant cycle",
category: "Agent",
run: () => {
local.model.variant.cycle()
},
},
},
{
title: "Toggle MCPs",
value: "mcp.list",
category: "Agent",
slash: {
name: "mcps",
{
name: "variant.list",
title: "Switch model variant",
category: "Agent",
hidden: local.model.variant.list().length === 0,
slashName: "variants",
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)
},
},
},
{
title: "Agent cycle",
value: "agent.cycle",
keybind: "agent_cycle",
category: "Agent",
hidden: true,
onSelect: () => {
local.agent.move(1)
{
name: "provider.connect",
title: "Connect provider",
suggested: !connected(),
slashName: "connect",
run: () => {
dialog.replace(() => <DialogProviderList />)
},
category: "Provider",
},
},
{
title: "Variant cycle",
value: "variant.cycle",
keybind: "variant_cycle",
category: "Agent",
onSelect: () => {
local.model.variant.cycle()
{
name: "prompt.editor.shortcut",
title: "Open editor shortcut",
category: "Session",
hidden: true,
run: () => {
command.run("prompt.editor")
},
},
},
{
title: "Switch model variant",
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"],
...(sync.data.console_state.switchableOrgCount > 1
? [
{
name: "console.org.switch",
title: "Switch org",
suggested: Boolean(sync.data.console_state.activeOrgName),
slashName: "org",
slashAliases: ["orgs", "switch-org"],
run: () => {
dialog.replace(() => <DialogConsoleOrg />)
},
category: "Provider",
},
onSelect: () => {
dialog.replace(() => <DialogConsoleOrg />)
},
category: "Provider",
},
]
: []),
{
title: "View status",
keybind: "status_view",
value: "opencode.status",
slash: {
name: "status",
]
: []),
{
name: "opencode.status",
title: "View status",
slashName: "status",
run: () => {
dialog.replace(() => <DialogStatus />)
},
category: "System",
},
onSelect: () => {
dialog.replace(() => <DialogStatus />)
{
name: "theme.switch",
title: "Switch theme",
slashName: "themes",
run: () => {
dialog.replace(() => <DialogThemeList />)
},
category: "System",
},
category: "System",
},
{
title: "Switch theme",
value: "theme.switch",
keybind: "theme_list",
slash: {
name: "themes",
{
name: "theme.switch_mode",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
run: () => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
},
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",
},
{
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
value: "theme.switch_mode",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
{
name: "help.show",
title: "Help",
slashName: "help",
run: () => {
dialog.replace(() => <DialogHelp />)
},
category: "System",
},
category: "System",
},
{
title: locked() ? "Unlock theme mode" : "Lock theme mode",
value: "theme.mode.lock",
onSelect: (dialog) => {
if (locked()) unlock()
else lock()
dialog.clear()
{
name: "docs.open",
title: "Open docs",
run: () => {
open("https://opencode.ai/docs").catch(() => {})
dialog.clear()
},
category: "System",
},
category: "System",
},
{
title: "Help",
value: "help.show",
slash: {
name: "help",
{
name: "app.exit",
title: "Exit the app",
slashName: "exit",
slashAliases: ["quit", "q"],
enabled: () => {
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",
},
{
title: "Open docs",
value: "docs.open",
onSelect: () => {
open("https://opencode.ai/docs").catch(() => {})
dialog.clear()
{
name: "app.console",
title: "Toggle console",
category: "System",
run: () => {
renderer.console.toggle()
dialog.clear()
},
},
category: "System",
},
{
title: "Exit the app",
value: "app.exit",
slash: {
name: "exit",
aliases: ["quit", "q"],
{
name: "app.heap_snapshot",
title: "Write heap snapshot",
category: "System",
run: async () => {
const files = await props.onSnapshot?.()
toast.show({
variant: "info",
message: `Heap snapshot written to ${files?.join(", ")}`,
duration: 5000,
})
dialog.clear()
},
},
onSelect: () => exit(),
category: "System",
},
{
title: "Toggle debug panel",
category: "System",
value: "app.debug",
onSelect: (dialog) => {
renderer.toggleDebugOverlay()
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()
})
{
name: "terminal.suspend",
title: "Suspend terminal",
category: "System",
hidden: true,
enabled: sections.app.some((binding) => binding.cmd === "terminal.suspend"),
run: () => {
process.once("SIGCONT", () => {
renderer.resume()
})
renderer.suspend()
// pid=0 means send the signal to all processes in the process group
process.kill(0, "SIGTSTP")
renderer.suspend()
process.kill(0, "SIGTSTP")
},
},
},
{
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle",
keybind: "terminal_title_toggle",
category: "System",
onSelect: (dialog) => {
setTerminalTitleEnabled((prev) => {
const next = !prev
kv.set("terminal_title_enabled", next)
if (!next) renderer.setTerminalTitle("")
return next
})
dialog.clear()
{
name: "terminal.title.toggle",
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
category: "System",
run: () => {
setTerminalTitleEnabled((prev) => {
const next = !prev
kv.set("terminal_title_enabled", next)
if (!next) renderer.setTerminalTitle("")
return next
})
dialog.clear()
},
},
},
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
dialog.clear()
{
name: "app.toggle.animations",
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
category: "System",
run: () => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
dialog.clear()
},
},
},
{
title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
value: "app.toggle.file_context",
category: "System",
onSelect: (dialog) => {
kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
dialog.clear()
{
name: "app.toggle.file_context",
title: kv.get("file_context_enabled", true) ? "Disable file context" : "Enable file context",
category: "System",
run: () => {
kv.set("file_context_enabled", !kv.get("file_context_enabled", true))
dialog.clear()
},
},
},
{
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
value: "app.toggle.paste_summary",
category: "System",
onSelect: (dialog) => {
setPasteSummaryEnabled((prev) => {
const next = !prev
kv.set("paste_summary_enabled", next)
return next
})
dialog.clear()
{
name: "app.toggle.diffwrap",
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
category: "System",
run: () => {
const current = kv.get("diff_wrap_mode", "word")
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
dialog.clear()
},
},
},
{
title: kv.get("session_directory_filter_enabled", true)
? "Disable session directory filtering"
: "Enable session directory filtering",
value: "app.toggle.session_directory_filter",
category: "System",
onSelect: async (dialog) => {
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
await sync.session.refresh()
dialog.clear()
{
name: "app.toggle.paste_summary",
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
category: "System",
run: () => {
setPasteSummaryEnabled((prev) => {
const next = !prev
kv.set("paste_summary_enabled", next)
return next
})
dialog.clear()
},
},
},
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")
kv.set("diff_wrap_mode", current === "word" ? "none" : "word")
dialog.clear()
{
name: "app.toggle.session_directory_filter",
title: kv.get("session_directory_filter_enabled", true)
? "Disable session directory filtering"
: "Enable session directory filtering",
category: "System",
run: async () => {
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
await sync.session.refresh()
dialog.clear()
},
},
},
])
].map((command) => ({
namespace: "palette",
...command,
})),
)
useBindings(() => ({
commands: appCommands(),
}))
useBindings(() => ({
enabled: command.matcher,
bindings: sections.app,
}))
event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command)
command.run(evt.properties.command)
})
event.on(TuiEvent.ToastShow.type, (evt) => {

View File

@@ -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()} />
}

View File

@@ -1,5 +1,4 @@
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import open from "open"
import { createSignal, onCleanup, onMount } from "solid-js"
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 { GoLogo } from "./logo"
import { BgPulse, type BgPulseMask } from "./bg-pulse"
import { useBindings } from "../keymap"
const GO_URL = "https://opencode.ai/go"
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)
})
useKeyboard((evt) => {
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
return
}
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog)
}
})
useBindings(() => ({
bindings: [
{
key: "left",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
},
{
key: "right",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
},
{
key: "tab",
cmd: () => setSelected((value) => (value === "subscribe" ? "dismiss" : "subscribe")),
},
{
key: "return",
cmd: () => {
if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog)
},
},
],
}))
return (
<box ref={(item: BoxRenderable) => (content = item)}>

View File

@@ -4,9 +4,9 @@ import { useSync } from "@tui/context/sync"
import { map, pipe, entries, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useTheme } from "../context/theme"
import { Keybind } from "@/util/keybind"
import { TextAttributes } from "@opentui/core"
import { useSDK } from "@tui/context/sdk"
import { useTuiConfig } from "../context/tui-config"
function Status(props: { enabled: boolean; loading: boolean }) {
const { theme } = useTheme()
@@ -23,6 +23,9 @@ export function DialogMcp() {
const local = useLocal()
const sync = useSync()
const sdk = useSDK()
const {
keymap: { sections },
} = useTuiConfig()
const [, setRef] = createSignal<DialogSelectRef<unknown>>()
const [loading, setLoading] = createSignal<string | null>(null)
@@ -45,9 +48,9 @@ export function DialogMcp() {
)
})
const keybinds = createMemo(() => [
const actions = createMemo(() => [
{
keybind: Keybind.parse("space")[0],
command: "dialog.mcp.toggle",
title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress
@@ -77,7 +80,8 @@ export function DialogMcp() {
ref={setRef}
title="MCPs"
options={options()}
keybind={keybinds()}
actions={actions()}
bindings={sections.dialog_mcp}
onSelect={(_option) => {
// Don't close on select, only on escape
}}

View File

@@ -6,15 +6,17 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useTuiConfig } from "../context/tui-config"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const sync = useSync()
const dialog = useDialog()
const keybind = useKeybind()
const {
keymap: { sections },
} = useTuiConfig()
const [query, setQuery] = createSignal("")
const connected = useConnected()
@@ -150,16 +152,16 @@ export function DialogModel(props: { providerID?: string }) {
return (
<DialogSelect<ReturnType<typeof options>[number]["value"]>
options={options()}
keybind={[
actions={[
{
keybind: keybind.all.model_provider_list?.[0],
command: "dialog.model.provider.list",
title: connected() ? "Connect provider" : "View all providers",
onTrigger() {
dialog.replace(() => <DialogProvider />)
},
},
{
keybind: keybind.all.model_favorite_toggle?.[0],
command: "dialog.model.favorite.toggle",
title: "Favorite",
disabled: !connected(),
onTrigger: (option) => {
@@ -167,6 +169,7 @@ export function DialogModel(props: { providerID?: string }) {
},
},
]}
bindings={sections.dialog_model}
onFilter={setQuery}
flat={true}
skipFilter={true}

View File

@@ -10,11 +10,11 @@ import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2"
import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
import { useConnected } from "./use-connected"
import { useBindings } from "../keymap"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@@ -163,14 +163,19 @@ function AutoMethod(props: AutoMethodProps) {
const sync = useSync()
const toast = useToast()
useKeyboard((evt) => {
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
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)
}
})
useBindings(() => ({
bindings: [
{
key: "c",
cmd: () => {
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 () => {
const result = await sdk.client.provider.oauth.callback({

View File

@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useBindings } from "../keymap"
export function DialogSessionDeleteFailed(props: {
session: string
@@ -40,19 +40,15 @@ export function DialogSessionDeleteFailed(props: {
if (!props.onDone) dialog.clear()
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
}
if (evt.name === "left" || evt.name === "up") {
setStore("active", "delete")
}
if (evt.name === "right" || evt.name === "down") {
setStore("active", "restore")
}
})
useBindings(() => ({
bindings: [
{ key: "return", cmd: () => void confirm() },
{ key: "left", cmd: () => setStore("active", "delete") },
{ key: "up", cmd: () => setStore("active", "delete") },
{ key: "right", cmd: () => setStore("active", "restore") },
{ key: "down", cmd: () => setStore("active", "restore") },
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>

View File

@@ -5,18 +5,18 @@ import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind"
import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { useTuiConfig } from "../context/tui-config"
import { useCommandShortcut } from "../keymap"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
@@ -25,12 +25,16 @@ export function DialogSessionList() {
const route = useRoute()
const sync = useSync()
const project = useProject()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const toast = useToast()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("dialog.session.delete")
const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }),
@@ -163,7 +167,7 @@ export function DialogSessionList() {
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
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,
value: x.id,
category,
@@ -194,9 +198,9 @@ export function DialogSessionList() {
})
dialog.clear()
}}
keybind={[
actions={[
{
keybind: keybind.all.session_delete?.[0],
command: "dialog.session.delete",
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
@@ -244,14 +248,14 @@ export function DialogSessionList() {
},
},
{
keybind: keybind.all.session_rename?.[0],
command: "dialog.session.rename",
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
{
keybind: Keybind.parse("ctrl+w")[0],
command: "dialog.session.workspace.new",
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
@@ -260,6 +264,7 @@ export function DialogSessionList() {
},
},
]}
bindings={sections.dialog_session_list}
/>
)
}

View File

@@ -3,8 +3,9 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "@/util/locale"
import { useTheme } from "../context/theme"
import { useKeybind } from "../context/keybind"
import { usePromptStash, type StashEntry } from "./prompt/stash"
import { useTuiConfig } from "../context/tui-config"
import { useCommandShortcut } from "../keymap"
function getRelativeTime(timestamp: number): string {
const now = Date.now()
@@ -30,9 +31,13 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const { theme } = useTheme()
const keybind = useKeybind()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const [toDelete, setToDelete] = createSignal<number>()
const deleteHint = useCommandShortcut("dialog.stash.delete")
const options = createMemo(() => {
const entries = stash.list()
@@ -42,7 +47,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const isDeleting = toDelete() === index
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
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,
value: index,
description: getRelativeTime(entry.timestamp),
@@ -68,9 +73,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
}
dialog.clear()
}}
keybind={[
actions={[
{
keybind: keybind.all.stash_delete?.[0],
command: "dialog.stash.delete",
title: "delete",
onTrigger: (option) => {
if (toDelete() === option.value) {
@@ -82,6 +87,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
},
},
]}
bindings={sections.dialog_stash}
/>
)
}

View File

@@ -1,9 +1,9 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useBindings } from "../keymap"
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
const dialog = useDialog()
@@ -23,25 +23,13 @@ export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean |
if (result === false) return
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "cancel")
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "restore")
}
})
useBindings(() => ({
bindings: [
{ key: "return", cmd: () => void confirm() },
{ key: "left", cmd: () => setStore("active", "cancel") },
{ key: "right", cmd: () => setStore("active", "restore") },
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>

View File

@@ -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 fuzzysort from "fuzzysort"
import path from "path"
@@ -12,11 +12,12 @@ import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useTheme, selectedForeground } from "@tui/context/theme"
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 { Locale } from "@/util/locale"
import type { PromptInfo } from "./history"
import { useFrecency } from "./frecency"
import { useBindings } from "../../keymap"
function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
@@ -52,7 +53,6 @@ function extractLineRange(input: string) {
export type AutocompleteRef = {
onInput: (value: string) => void
onKeyDown: (e: KeyEvent) => void
visible: false | "@" | "/"
}
@@ -82,12 +82,14 @@ export function Autocomplete(props: {
const editor = useEditorContext()
const sdk = useSDK()
const sync = useSync()
const command = useCommandDialog()
const command = useCommandPalette()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const [store, setStore] = createStore({
index: 0,
selected: 0,
@@ -282,7 +284,7 @@ export function Autocomplete(props: {
const { filename, part } = createFilePart(item, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset
command.keybinds(true)
command.suspend(false)
setStore("visible", false)
setStore("index", index)
insertPart(filename, part)
@@ -520,8 +522,54 @@ export function Autocomplete(props: {
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.prompt_autocomplete,
}))
function show(mode: "@" | "/") {
command.keybinds(false)
command.suspend(true)
setStore({
visible: mode,
index: props.input().cursorOffset,
@@ -538,7 +586,7 @@ export function Autocomplete(props: {
draft.input = props.input().plainText
})
}
command.keybinds(true)
command.suspend(false)
setStore("visible", false)
}
@@ -593,60 +641,6 @@ export function Autocomplete(props: {
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("/")
}
}
},
})
})

View File

@@ -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 "opentui-spinner/solid"
import path from "path"
@@ -15,14 +25,12 @@ import { useEvent } from "@tui/context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { computePromptTraits } from "./traits"
import { assign } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import * as Editor from "@tui/util/editor"
import { useExit } from "../../context/exit"
@@ -39,11 +47,18 @@ import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
import { useCommandPalette } from "../../context/command-palette"
import {
useBindings,
useCommandShortcut,
useLeaderActive,
useOpencodeKeymap,
} from "../../keymap"
import { useTuiConfig } from "../../context/tui-config"
export type PromptProps = {
sessionID?: string
@@ -116,9 +131,9 @@ let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
let autocomplete: AutocompleteRef
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const keybind = useKeybind()
const leader = useLeaderActive()
const local = useLocal()
const args = useArgs()
const sdk = useSDK()
@@ -126,12 +141,19 @@ export function Prompt(props: PromptProps) {
const route = useRoute()
const project = useProject()
const sync = useSync()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
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 dimensions = useTerminalDimensions()
const { theme, syntax } = useTheme()
@@ -173,6 +195,7 @@ export function Prompt(props: PromptProps) {
const [editorContextHover, setEditorContextHover] = createSignal(false)
let lastSubmittedEditorSelectionKey: string | undefined
const [auto, setAuto] = createSignal<AutocompleteRef>()
const [cursorVersion, setCursorVersion] = createSignal(0)
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
@@ -191,9 +214,6 @@ export function Prompt(props: PromptProps) {
setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
editor.clearSelection()
}
const textareaKeybindings = useTextareaKeybindings()
const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
@@ -294,26 +314,30 @@ export function Prompt(props: PromptProps) {
}
})
command.register(() => {
return [
const promptCommands = createMemo(() =>
[
{
title: "Clear prompt",
value: "prompt.clear",
name: "prompt.clear",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
input.extmarks.clear()
run: () => {
input.clear()
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
},
{
title: "Submit prompt",
value: "prompt.submit",
keybind: "input_submit",
name: "prompt.submit",
category: "Prompt",
hidden: true,
onSelect: async (dialog) => {
run: async () => {
if (!input.focused) return
const handled = await submit()
if (!handled) return
@@ -323,23 +347,24 @@ export function Prompt(props: PromptProps) {
},
{
title: "Remove editor context",
value: "prompt.editor_context.clear",
name: "prompt.editor_context.clear",
category: "Prompt",
enabled: Boolean(editorContext()),
onSelect: (dialog) => {
run: () => {
dismissEditorContext()
dialog.clear()
},
},
{
title: "Paste",
value: "prompt.paste",
keybind: "input_paste",
name: "prompt.paste",
category: "Prompt",
hidden: true,
onSelect: async () => {
run: async (ctx: CommandContext<Renderable, KeyEvent>) => {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
ctx.event.preventDefault()
ctx.event.stopPropagation()
await pasteAttachment({
filename: "clipboard",
mime: content.mime,
@@ -350,13 +375,12 @@ export function Prompt(props: PromptProps) {
},
{
title: "Interrupt session",
value: "session.interrupt",
keybind: "session_interrupt",
name: "session.interrupt",
category: "Session",
hidden: true,
enabled: status().type !== "idle",
onSelect: (dialog) => {
if (autocomplete.visible) return
run: () => {
if (auto()?.visible) return
if (!input.focused) return
// TODO: this should be its own command
if (store.mode === "shell") {
@@ -383,12 +407,9 @@ export function Prompt(props: PromptProps) {
{
title: "Open editor",
category: "Session",
keybind: "editor_open",
value: "prompt.editor",
slash: {
name: "editor",
},
onSelect: async (dialog) => {
name: "prompt.editor",
slashName: "editor",
run: async () => {
dialog.clear()
// replace summarized text parts with the actual text
@@ -469,12 +490,10 @@ export function Prompt(props: PromptProps) {
},
{
title: "Skills",
value: "prompt.skills",
name: "prompt.skills",
category: "Prompt",
slash: {
name: "skills",
},
onSelect: () => {
slashName: "skills",
run: () => {
dialog.replace(() => (
<DialogSkill
onSelect={(skill) => {
@@ -489,8 +508,20 @@ export function Prompt(props: PromptProps) {
))
},
},
]
})
].map((entry) => ({
namespace: "palette",
...entry,
})),
)
useBindings(() => ({
commands: promptCommands(),
}))
useBindings(() => ({
enabled: command.matcher,
bindings: sections.prompt,
}))
const ref: PromptRef = {
get focused() {
@@ -541,6 +572,7 @@ export function Prompt(props: PromptProps) {
if (store.prompt.input) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
setInputTarget(undefined)
props.ref?.(undefined)
})
@@ -558,11 +590,14 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
input.traits = computePromptTraits({
mode: store.mode,
disabled: !!props.disabled,
autocompleteVisible: !!auto()?.visible,
})
input.traits = {
...input.traits,
...computePromptTraits({
mode: store.mode,
disabled: !!props.disabled,
autocompleteVisible: !!auto()?.visible,
}),
}
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
@@ -643,60 +678,195 @@ export function Prompt(props: PromptProps) {
)
}
command.register(() => [
{
title: "Stash prompt",
value: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.input,
onSelect: (dialog) => {
if (!store.prompt.input) return
stash.push({
input: store.prompt.input,
parts: store.prompt.parts,
})
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
dialog.clear()
const stashCommands = createMemo(() =>
[
{
title: "Stash prompt",
name: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.input,
run: () => {
if (!store.prompt.input) return
stash.push({
input: store.prompt.input,
parts: store.prompt.parts,
})
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
},
},
{
title: "Stash pop",
value: "prompt.stash.pop",
category: "Prompt",
enabled: stash.list().length > 0,
onSelect: (dialog) => {
const entry = stash.pop()
if (entry) {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}
dialog.clear()
{
title: "Stash pop",
name: "prompt.stash.pop",
category: "Prompt",
enabled: stash.list().length > 0,
run: () => {
const entry = stash.pop()
if (entry) {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}
dialog.clear()
},
},
},
{
title: "Stash list",
value: "prompt.stash.list",
category: "Prompt",
enabled: stash.list().length > 0,
onSelect: (dialog) => {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}}
/>
))
{
title: "Stash list",
name: "prompt.stash.list",
category: "Prompt",
enabled: stash.list().length > 0,
run: () => {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}}
/>
))
},
},
},
])
].map((entry) => ({
namespace: "palette",
...entry,
})),
)
useBindings(() => ({
commands: stashCommands(),
}))
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled,
bindings: sections.prompt_paste,
}
})
useBindings(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
bindings: sections.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: sections.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: sections.prompt_history_next,
}
})
async function submit() {
// IME: double-defer may fire before onContentChange flushes the last
@@ -707,7 +877,7 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
if (autocomplete?.visible) return false
if (auto()?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
if (!agent) return false
@@ -984,7 +1154,7 @@ export function Prompt(props: PromptProps) {
}
const highlight = createMemo(() => {
if (keybind.leader) return theme.border
if (leader()) return theme.border
if (store.mode === "shell") return theme.primary
const agent = local.agent.current()
if (!agent) return theme.border
@@ -1040,30 +1210,7 @@ export function Prompt(props: PromptProps) {
return (
<>
<Autocomplete
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 ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={borderHighlight()}
@@ -1083,94 +1230,23 @@ export function Prompt(props: PromptProps) {
<textarea
placeholder={placeholderText()}
placeholderColor={theme.textMuted}
textColor={keybind.leader ? theme.textMuted : theme.text}
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
textColor={leader() ? theme.textMuted : theme.text}
focusedTextColor={leader() ? theme.textMuted : theme.text}
minHeight={1}
maxHeight={6}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "input", value)
autocomplete.onInput(value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
keyBindings={textareaKeybindings()}
onKeyDown={async (e) => {
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
e.preventDefault()
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={() => {
// IME: double-defer so the last composed character (e.g. Korean
@@ -1192,7 +1268,7 @@ export function Prompt(props: PromptProps) {
// Windows Terminal <1.25 can surface image-only clipboard as an
// empty bracketed paste. Windows Terminal 1.25+ does not.
if (!pastedContent) {
command.trigger("prompt.paste")
keymap.dispatchCommand("prompt.paste")
return
}
@@ -1261,6 +1337,7 @@ export function Prompt(props: PromptProps) {
}}
ref={(r: TextareaRenderable) => {
input = r
setInputTarget(r)
if (promptPartTypeId === 0) {
promptPartTypeId = input.extmarks.registerType("prompt-part")
}
@@ -1289,7 +1366,7 @@ export function Prompt(props: PromptProps) {
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
fg={fadeColor(leader() ? theme.textMuted : theme.text, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
@@ -1449,12 +1526,12 @@ export function Prompt(props: PromptProps) {
</Match>
<Match when={true}>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
@@ -1467,6 +1544,28 @@ export function Prompt(props: PromptProps) {
</Show>
</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}
/>
</>
)
}

View File

@@ -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[]
})
}

View File

@@ -0,0 +1,164 @@
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras"
import { ConfigKeybinds } from "@/config/keybinds"
import { KeymapSectionNames, type KeymapInfo, type KeymapSection } from "./tui-schema"
type LegacyKeybinds = 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) {
config[section] ??= {}
config[section][command] = binding ?? "none"
}
function bindingWith(key: string | undefined, input: Omit<Binding<Renderable, KeyEvent>, "key" | "cmd">) {
if (!key || key === "none") return "none"
return { ...input, key }
}
export function create(keybinds: LegacyKeybinds): KeymapInfo {
const config: SectionsConfig = {}
add(config, "app", "command.palette.show", keybinds.command_list)
add(config, "app", "session.list", keybinds.session_list)
add(config, "app", "session.new", keybinds.session_new)
add(config, "app", "model.list", keybinds.model_list)
add(config, "app", "model.cycle_recent", keybinds.model_cycle_recent)
add(config, "app", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse)
add(config, "app", "model.cycle_favorite", keybinds.model_cycle_favorite)
add(config, "app", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse)
add(config, "app", "agent.list", keybinds.agent_list)
add(config, "app", "agent.cycle", keybinds.agent_cycle)
add(config, "app", "agent.cycle.reverse", keybinds.agent_cycle_reverse)
add(config, "app", "variant.cycle", keybinds.variant_cycle)
add(config, "app", "variant.list", keybinds.variant_list)
add(config, "app", "prompt.editor.shortcut", keybinds.editor_open)
add(config, "app", "opencode.status", keybinds.status_view)
add(config, "app", "theme.switch", keybinds.theme_list)
add(config, "app", "app.exit", keybinds.app_exit)
add(config, "app", "terminal.suspend", keybinds.terminal_suspend)
add(config, "app", "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_clear", "prompt.clear", keybinds.input_clear)
add(config, "prompt_paste", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false }))
add(config, "prompt_history_previous", "prompt.history.previous", keybinds.history_previous)
add(config, "prompt_history_next", "prompt.history.next", keybinds.history_next)
add(config, "prompt_autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"])
add(config, "prompt_autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"])
add(config, "prompt_autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"])
add(config, "prompt_autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"])
add(config, "prompt_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_stash", "dialog.stash.delete", keybinds.stash_delete)
add(config, "dialog_session_list", "dialog.session.delete", keybinds.session_delete)
add(config, "dialog_session_list", "dialog.session.rename", keybinds.session_rename)
add(config, "dialog_session_list", "dialog.session.workspace.new", keybinds["dialog.session.workspace.new"])
add(config, "dialog_model", "dialog.model.provider.list", keybinds.model_provider_list)
add(config, "dialog_model", "dialog.model.favorite.toggle", keybinds.model_favorite_toggle)
add(config, "dialog_mcp", "dialog.mcp.toggle", keybinds["dialog.mcp.toggle"])
add(config, "permission_reject", "permission.reject.cancel", keybinds.app_exit)
add(config, "permission_prompt_escape", "permission.prompt.escape", keybinds.app_exit)
add(config, "permission_prompt_fullscreen", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"])
add(config, "question", "question.reject", keybinds.app_exit)
add(config, "question_edit", "question.edit.clear", keybinds.input_clear)
add(config, "plugins", "plugins.list", keybinds.plugin_manager)
add(config, "dialog_plugins", "plugins.toggle", keybinds["plugins.toggle"])
add(config, "dialog_plugins", "plugins.install", keybinds["plugins.install"])
add(config, "home_tips", "tips.toggle", keybinds.tips_toggle)
return {
leader: !keybinds.leader || keybinds.leader === "none" ? "ctrl+x" : keybinds.leader,
sections: resolveBindingSections<Renderable, KeyEvent, SectionsConfig, KeymapSection>(config, {
sections: KeymapSectionNames,
}).sections,
}
}
export * as LegacyKeymapTransform from "./legacy-keymap-transform"

View File

@@ -1,4 +1,7 @@
import z from "zod"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { BindingSectionsConfig, BindingValue } from "@opentui/keymap/extras"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
@@ -11,6 +14,74 @@ const KeybindOverride = z
)
.strict()
export const KeymapSectionNames = [
"app",
"session",
"prompt",
"prompt_clear",
"prompt_paste",
"prompt_history_previous",
"prompt_history_next",
"prompt_autocomplete",
"input",
"dialog_select",
"dialog_stash",
"dialog_session_list",
"dialog_model",
"dialog_mcp",
"permission_reject",
"permission_prompt_escape",
"permission_prompt_fullscreen",
"question",
"question_edit",
"plugins",
"dialog_plugins",
"home_tips",
] as const
export type KeymapSection = (typeof KeymapSectionNames)[number]
export type KeymapSections = Record<KeymapSection, Binding<Renderable, KeyEvent>[]>
export type KeymapInfo = {
leader: string
sections: KeymapSections
}
export type KeymapConfig = {
leader?: string
sections?: BindingSectionsConfig<Renderable, KeyEvent>
}
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 KeymapSectionsConfig = z.record(z.string(), z.record(z.string(), KeymapBindingValue))
export const KeymapConfig = z
.object({
leader: z.string().optional(),
sections: KeymapSectionsConfig.optional(),
})
.strict()
.describe("TUI keymap configuration")
export const TuiOptions = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
@@ -30,7 +101,11 @@ export const TuiInfo = z
.object({
$schema: 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: KeymapConfig.optional(),
plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})

View File

@@ -1,6 +1,8 @@
export * as TuiConfig from "./tui"
import z from "zod"
import type { KeyEvent, Renderable } from "@opentui/core"
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
import { ConfigParse } from "@/config/parse"
@@ -20,27 +22,39 @@ import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm"
import { LegacyKeymapTransform } from "./legacy-keymap-transform"
import { KeymapSectionNames, type KeymapConfig, type KeymapInfo, type KeymapSection } from "./tui-schema"
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
type FileInfo = Omit<z.output<typeof Info>, "keymap"> & {
keymap?: KeymapConfig
plugin_origins?: ConfigPlugin.Origin[]
}
type Acc = {
result: Info
result: FileInfo
}
type State = {
config: Info
config: Resolved
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
}
export type Info = z.output<typeof Info> & {
export type Info = Omit<FileInfo, "keymap"> & {
keymap?: KeymapConfig | KeymapInfo
}
export type Resolved = Omit<FileInfo, "keymap"> & {
keymap: KeymapInfo
// Internal resolved plugin list used by runtime loading.
plugin_origins?: ConfigPlugin.Origin[]
}
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly get: () => Effect.Effect<Resolved>
readonly waitForDependencies: () => Effect.Effect<void>
}
@@ -68,7 +82,7 @@ function normalize(raw: Record<string, unknown>) {
}
}
async function resolvePlugins(config: Info, configFilepath: string) {
async function resolvePlugins(config: FileInfo, configFilepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
@@ -140,11 +154,30 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
]).join(",")
}
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds)
const configuredKeymap = acc.result.keymap
const result: Resolved = {
...acc.result,
keybinds: parsedKeybinds,
// `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: configuredKeymap
? {
leader: !configuredKeymap.leader || configuredKeymap.leader === "none" ? "ctrl+x" : configuredKeymap.leader,
sections: resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
configuredKeymap.sections ?? {},
{
sections: KeymapSectionNames,
},
).sections,
}
: LegacyKeymapTransform.create(parsedKeybinds),
}
return {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
config: result,
dirs: result.plugin?.length ? dirs : [],
}
})
@@ -193,7 +226,7 @@ export async function get() {
return runPromise((svc) => svc.get())
}
async function loadFile(filepath: string): Promise<Info> {
async function loadFile(filepath: string): Promise<FileInfo> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
@@ -202,7 +235,7 @@ async function loadFile(filepath: string): Promise<Info> {
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
async function load(text: string, configFilepath: string): Promise<FileInfo> {
return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
.then((data) => {

View File

@@ -0,0 +1,156 @@
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
}
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: entry.command.suggested === true,
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
}

View File

@@ -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
},
})

View File

@@ -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)),
}
}

View File

@@ -3,7 +3,7 @@ import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
name: "TuiConfig",
init: (props: { config: TuiConfig.Info }) => {
init: (props: { config: TuiConfig.Resolved }) => {
return props.config
},
})

View File

@@ -15,19 +15,22 @@ function View(props: { show: boolean; connected: boolean }) {
}
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.keymap.registerLayer({
commands: [
{
name: "tips.toggle",
title: "Toggle tips",
category: "System",
namespace: "palette",
enabled: () => api.route.current.name === "home",
run() {
api.kv.set("tips_hidden", !api.kv.get("tips_hidden", false))
api.ui.dialog.clear()
},
},
},
])
],
bindings: api.tuiConfig.keymap.sections.home_tips,
})
api.slots.register({
order: 100,

View File

@@ -1,14 +1,11 @@
import { Keybind } from "@/util/keybind"
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 { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { Show, createEffect, createMemo, createSignal } from "solid-js"
import { useBindings } from "../../keymap"
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) {
if (!item.enabled) {
@@ -41,13 +38,10 @@ function Install(props: { api: TuiPluginApi }) {
const [global, setGlobal] = createSignal(false)
const [busy, setBusy] = createSignal(false)
useKeyboard((evt) => {
if (evt.name !== "tab") return
evt.preventDefault()
evt.stopPropagation()
if (busy()) return
setGlobal((x) => !x)
})
useBindings(() => ({
enabled: !busy(),
bindings: [{ key: "tab", cmd: () => setGlobal((value) => !value) }],
}))
return (
<props.api.ui.DialogPrompt
@@ -62,7 +56,7 @@ function Install(props: { api: TuiPluginApi }) {
{global() ? "global" : "local"}
</text>
<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>
</box>
)}
@@ -154,6 +148,7 @@ function showInstall(api: TuiPluginApi) {
function View(props: { api: TuiPluginApi }) {
const size = useTerminalDimensions()
const sections = props.api.tuiConfig.keymap.sections
const [list, setList] = createSignal(props.api.plugins.list())
const [cur, setCur] = createSignal<string | undefined>()
const [lock, setLock] = createSignal(false)
@@ -209,10 +204,10 @@ function View(props: { api: TuiPluginApi }) {
options={rows()}
current={cur()}
onMove={(item) => setCur(item.value)}
keybind={[
actions={[
{
title: "toggle",
keybind: key,
command: "plugins.toggle",
disabled: lock(),
onTrigger: (item) => {
setCur(item.value)
@@ -221,13 +216,14 @@ function View(props: { api: TuiPluginApi }) {
},
{
title: "install",
keybind: add,
command: "plugins.install",
disabled: lock(),
onTrigger: () => {
showInstall(props.api)
},
},
]}
bindings={sections.dialog_plugins}
onSelect={(item) => {
setCur(item.value)
flip(item.value)
@@ -241,25 +237,29 @@ function show(api: TuiPluginApi) {
}
const tui: TuiPlugin = async (api) => {
api.command.register(() => [
{
title: "Plugins",
value: "plugins.list",
keybind: "plugin_manager",
category: "System",
onSelect() {
show(api)
api.keymap.registerLayer({
commands: [
{
name: "plugins.list",
title: "Plugins",
category: "System",
namespace: "palette",
run() {
show(api)
},
},
},
{
title: "Install plugin",
value: "plugins.install",
category: "System",
onSelect() {
showInstall(api)
{
name: "plugins.install",
title: "Install plugin",
category: "System",
namespace: "palette",
run() {
showInstall(api)
},
},
},
])
],
bindings: api.tuiConfig.keymap.sections.plugins,
})
}
const plugin: TuiPluginModule & { id: string } = {

View File

@@ -0,0 +1,89 @@
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"
const LEADER_TIMEOUT_MS = 2000
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: LEADER_TIMEOUT_MS,
})
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)
}

View File

@@ -1,15 +1,12 @@
import type { ParsedKey } from "@opentui/core"
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 { useKeybind } from "@tui/context/keybind"
import type { useRoute } from "@tui/context/route"
import type { useSDK } from "@tui/context/sdk"
import type { useSync } from "@tui/context/sync"
import type { useTheme } from "@tui/context/theme"
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
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 { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
@@ -19,6 +16,7 @@ import { Prompt } from "../component/prompt"
import { Slot as HostSlot } from "./slots"
import type { useToast } from "../ui/toast"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Keymap from "../keymap"
type RouteEntry = {
key: symbol
@@ -28,10 +26,9 @@ type RouteEntry = {
export type RouteMap = Map<string, RouteEntry[]>
type Input = {
command: ReturnType<typeof useCommandDialog>
tuiConfig: TuiConfig.Info
tuiConfig: TuiConfig.Resolved
dialog: ReturnType<typeof useDialog>
keybind: ReturnType<typeof useKeybind>
keymap: ReturnType<typeof useOpencodeKeymap>
kv: ReturnType<typeof useKV>
route: ReturnType<typeof useRoute>
routes: RouteMap
@@ -201,20 +198,17 @@ export function createTuiApi(input: Input): TuiPluginApi {
return () => {}
},
}
return {
app: appApi(),
command: {
register(cb) {
return input.command.register(() => cb())
keys: {
formatSequence(parts) {
return Keymap.formatKeySequence(parts, input.tuiConfig)
},
trigger(value) {
input.command.trigger(value)
},
show() {
input.command.show()
formatBindings(bindings) {
return Keymap.formatKeyBindings(bindings, input.tuiConfig)
},
},
keymap: input.keymap,
route: {
register(list) {
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() {
return input.tuiConfig
},

View File

@@ -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 {
type TuiDispose,
type TuiPlugin,
@@ -39,6 +40,8 @@ import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots"
import { ConfigPlugin } from "@/config/plugin"
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
type PluginLoad = {
options: ConfigPlugin.Options | undefined
spec: string
@@ -327,14 +330,16 @@ function createPluginScope(load: PluginLoad, id: string) {
const track = (fn: (() => void) | undefined) => {
if (!fn) return () => {}
const off = onDispose(fn)
let drop = false
return () => {
let off = () => {}
const wrapped = () => {
if (drop) return
drop = true
off()
fn()
}
off = onDispose(wrapped)
return wrapped
}
const lifecycle: TuiPluginApi["lifecycle"] = {
@@ -484,17 +489,6 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
const api = runtime.api
const host = runtime.slots
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"] = {
register(list) {
@@ -518,6 +512,87 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
},
}
const keymap: TuiPluginApi["keymap"] = Object.assign(Object.create(api.keymap), {
acquireResource(...args: Parameters<TuiPluginApi["keymap"]["acquireResource"]>) {
return scope.track(api.keymap.acquireResource(...args))
},
registerLayer(...args: Parameters<TuiPluginApi["keymap"]["registerLayer"]>) {
return scope.track(api.keymap.registerLayer(...args))
},
registerLayerFields(...args: Parameters<TuiPluginApi["keymap"]["registerLayerFields"]>) {
return scope.track(api.keymap.registerLayerFields(...args))
},
prependLayerBindingsTransformer(...args: Parameters<TuiPluginApi["keymap"]["prependLayerBindingsTransformer"]>) {
return scope.track(api.keymap.prependLayerBindingsTransformer(...args))
},
appendLayerBindingsTransformer(...args: Parameters<TuiPluginApi["keymap"]["appendLayerBindingsTransformer"]>) {
return scope.track(api.keymap.appendLayerBindingsTransformer(...args))
},
prependBindingTransformer(...args: Parameters<TuiPluginApi["keymap"]["prependBindingTransformer"]>) {
return scope.track(api.keymap.prependBindingTransformer(...args))
},
appendBindingTransformer(...args: Parameters<TuiPluginApi["keymap"]["appendBindingTransformer"]>) {
return scope.track(api.keymap.appendBindingTransformer(...args))
},
prependBindingParser(...args: Parameters<TuiPluginApi["keymap"]["prependBindingParser"]>) {
return scope.track(api.keymap.prependBindingParser(...args))
},
appendBindingParser(...args: Parameters<TuiPluginApi["keymap"]["appendBindingParser"]>) {
return scope.track(api.keymap.appendBindingParser(...args))
},
registerToken(...args: Parameters<TuiPluginApi["keymap"]["registerToken"]>) {
return scope.track(api.keymap.registerToken(...args))
},
prependBindingExpander(...args: Parameters<TuiPluginApi["keymap"]["prependBindingExpander"]>) {
return scope.track(api.keymap.prependBindingExpander(...args))
},
appendBindingExpander(...args: Parameters<TuiPluginApi["keymap"]["appendBindingExpander"]>) {
return scope.track(api.keymap.appendBindingExpander(...args))
},
registerBindingFields(...args: Parameters<TuiPluginApi["keymap"]["registerBindingFields"]>) {
return scope.track(api.keymap.registerBindingFields(...args))
},
registerCommandFields(...args: Parameters<TuiPluginApi["keymap"]["registerCommandFields"]>) {
return scope.track(api.keymap.registerCommandFields(...args))
},
prependCommandTransformer(...args: Parameters<TuiPluginApi["keymap"]["prependCommandTransformer"]>) {
return scope.track(api.keymap.prependCommandTransformer(...args))
},
appendCommandTransformer(...args: Parameters<TuiPluginApi["keymap"]["appendCommandTransformer"]>) {
return scope.track(api.keymap.appendCommandTransformer(...args))
},
prependCommandResolver(...args: Parameters<TuiPluginApi["keymap"]["prependCommandResolver"]>) {
return scope.track(api.keymap.prependCommandResolver(...args))
},
appendCommandResolver(...args: Parameters<TuiPluginApi["keymap"]["appendCommandResolver"]>) {
return scope.track(api.keymap.appendCommandResolver(...args))
},
prependLayerAnalyzer(...args: Parameters<TuiPluginApi["keymap"]["prependLayerAnalyzer"]>) {
return scope.track(api.keymap.prependLayerAnalyzer(...args))
},
appendLayerAnalyzer(...args: Parameters<TuiPluginApi["keymap"]["appendLayerAnalyzer"]>) {
return scope.track(api.keymap.appendLayerAnalyzer(...args))
},
intercept(...args: Parameters<TuiPluginApi["keymap"]["intercept"]>) {
return scope.track(api.keymap.intercept(...args))
},
on(...args: Parameters<TuiPluginApi["keymap"]["on"]>) {
return scope.track(api.keymap.on(...args))
},
prependEventMatchResolver(...args: Parameters<TuiPluginApi["keymap"]["prependEventMatchResolver"]>) {
return scope.track(api.keymap.prependEventMatchResolver(...args))
},
appendEventMatchResolver(...args: Parameters<TuiPluginApi["keymap"]["appendEventMatchResolver"]>) {
return scope.track(api.keymap.appendEventMatchResolver(...args))
},
prependDisambiguationResolver(...args: Parameters<TuiPluginApi["keymap"]["prependDisambiguationResolver"]>) {
return scope.track(api.keymap.prependDisambiguationResolver(...args))
},
appendDisambiguationResolver(...args: Parameters<TuiPluginApi["keymap"]["appendDisambiguationResolver"]>) {
return scope.track(api.keymap.appendDisambiguationResolver(...args))
},
})
let count = 0
const slots: TuiPluginApi["slots"] = {
@@ -531,10 +606,10 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
return {
app: api.app,
command,
keys: api.keys,
keymap,
route,
ui: api.ui,
keybind: api.keybind,
tuiConfig: api.tuiConfig,
kv: api.kv,
state: api.state,

View File

@@ -49,12 +49,10 @@ import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
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 { useEditorContext } from "@tui/context/editor"
import { useCommandDialog } from "@tui/component/dialog-command"
import type { DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { useDialog } from "../../ui/dialog"
import { TodoItem } from "../../component/todo-item"
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 { SessionRetry } from "@/session/retry"
import { getRevertDiffFiles } from "../../util/revert-diff"
import { useCommandPalette } from "../../context/command-palette"
import { useBindings, useCommandShortcut } from "../../keymap"
addDefaultParsers(parsers.parsers)
@@ -124,6 +124,9 @@ export function Session() {
const event = useEvent()
const project = useProject()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const kv = useKV()
const { theme } = useTheme()
const promptRef = usePromptRef()
@@ -250,7 +253,7 @@ export function Session() {
seeded = true
r.set(route.prompt)
}
const keybind = useKeybind()
const command = useCommandPalette()
const dialog = useDialog()
const renderer = useRenderer()
@@ -271,7 +274,6 @@ export function Session() {
})
})
// Allow exit when in child session (prompt is hidden)
const exit = useExit()
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
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
const children = scroll.getChildren()
@@ -382,26 +377,24 @@ export function Session() {
}
}
function childSessionHandler(func: (dialog: DialogContext) => void) {
return (dialog: DialogContext) => {
function childSessionHandler(func: () => void) {
return () => {
if (!session()?.parentID || dialog.stack.length > 0) return
func(dialog)
func()
}
}
const command = useCommandDialog()
command.register(() => [
const sessionCommandList = createMemo(() => [
{
title: session()?.share?.url ? "Copy share link" : "Share session",
value: "session.share",
suggested: route.type === "session",
keybind: "session_share",
category: "Session",
enabled: sync.data.config.share !== "disabled",
slash: {
name: "share",
},
onSelect: async (dialog) => {
run: async () => {
const copy = (url: string) =>
Clipboard.copy(url)
.then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" }))
@@ -434,24 +427,22 @@ export function Session() {
{
title: "Rename session",
value: "session.rename",
keybind: "session_rename",
category: "Session",
slash: {
name: "rename",
},
onSelect: (dialog) => {
run: () => {
dialog.replace(() => <DialogSessionRename session={route.sessionID} />)
},
},
{
title: "Jump to message",
value: "session.timeline",
keybind: "session_timeline",
category: "Session",
slash: {
name: "timeline",
},
onSelect: (dialog) => {
run: () => {
dialog.replace(() => (
<DialogTimeline
onMove={(messageID) => {
@@ -469,12 +460,11 @@ export function Session() {
{
title: "Fork session",
value: "session.fork",
keybind: "session_fork",
category: "Session",
slash: {
name: "fork",
},
onSelect: (dialog) => {
run: () => {
dialog.replace(() => (
<DialogForkFromTimeline
onMove={(messageID) => {
@@ -492,13 +482,12 @@ export function Session() {
{
title: "Compact session",
value: "session.compact",
keybind: "session_compact",
category: "Session",
slash: {
name: "compact",
aliases: ["summarize"],
},
onSelect: (dialog) => {
run: () => {
const selectedModel = local.model.current()
if (!selectedModel) {
toast.show({
@@ -519,13 +508,12 @@ export function Session() {
{
title: "Unshare session",
value: "session.unshare",
keybind: "session_unshare",
category: "Session",
enabled: !!session()?.share?.url,
slash: {
name: "unshare",
},
onSelect: async (dialog) => {
run: async () => {
await sdk.client.session
.unshare({
sessionID: route.sessionID,
@@ -543,12 +531,11 @@ export function Session() {
{
title: "Undo previous message",
value: "session.undo",
keybind: "messages_undo",
category: "Session",
slash: {
name: "undo",
},
onSelect: async (dialog) => {
run: async () => {
const status = sync.data.session_status?.[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
const revert = session()?.revert?.messageID
@@ -581,13 +568,12 @@ export function Session() {
{
title: "Redo",
value: "session.redo",
keybind: "messages_redo",
category: "Session",
enabled: !!session()?.revert?.messageID,
slash: {
name: "redo",
},
onSelect: (dialog) => {
run: () => {
dialog.clear()
const messageID = session()?.revert?.messageID
if (!messageID) return
@@ -608,9 +594,8 @@ export function Session() {
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle",
keybind: "sidebar_toggle",
category: "Session",
onSelect: (dialog) => {
run: () => {
batch(() => {
const isVisible = sidebarVisible()
setSidebar(() => (isVisible ? "hide" : "auto"))
@@ -622,9 +607,8 @@ export function Session() {
{
title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal",
keybind: "messages_toggle_conceal",
category: "Session",
onSelect: (dialog) => {
run: () => {
setConceal((prev) => !prev)
dialog.clear()
},
@@ -637,7 +621,7 @@ export function Session() {
name: "timestamps",
aliases: ["toggle-timestamps"],
},
onSelect: (dialog) => {
run: () => {
setTimestamps((prev) => (prev === "show" ? "hide" : "show"))
dialog.clear()
},
@@ -645,13 +629,12 @@ export function Session() {
{
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
keybind: "display_thinking",
category: "Session",
slash: {
name: "thinking",
aliases: ["toggle-thinking"],
},
onSelect: (dialog) => {
run: () => {
setShowThinking((prev) => !prev)
dialog.clear()
},
@@ -659,9 +642,8 @@ export function Session() {
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
keybind: "tool_details",
category: "Session",
onSelect: (dialog) => {
run: () => {
setShowDetails((prev) => !prev)
dialog.clear()
},
@@ -669,9 +651,8 @@ export function Session() {
{
title: "Toggle session scrollbar",
value: "session.toggle.scrollbar",
keybind: "scrollbar_toggle",
category: "Session",
onSelect: (dialog) => {
run: () => {
setShowScrollbar((prev) => !prev)
dialog.clear()
},
@@ -680,7 +661,7 @@ export function Session() {
title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output",
value: "session.toggle.generic_tool_output",
category: "Session",
onSelect: (dialog) => {
run: () => {
setShowGenericToolOutput((prev) => !prev)
dialog.clear()
},
@@ -688,10 +669,9 @@ export function Session() {
{
title: "Page up",
value: "session.page.up",
keybind: "messages_page_up",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(-scroll.height / 2)
dialog.clear()
},
@@ -699,10 +679,9 @@ export function Session() {
{
title: "Page down",
value: "session.page.down",
keybind: "messages_page_down",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(scroll.height / 2)
dialog.clear()
},
@@ -710,10 +689,9 @@ export function Session() {
{
title: "Line up",
value: "session.line.up",
keybind: "messages_line_up",
category: "Session",
disabled: true,
onSelect: (dialog) => {
enabled: false,
run: () => {
scroll.scrollBy(-1)
dialog.clear()
},
@@ -721,10 +699,9 @@ export function Session() {
{
title: "Line down",
value: "session.line.down",
keybind: "messages_line_down",
category: "Session",
disabled: true,
onSelect: (dialog) => {
enabled: false,
run: () => {
scroll.scrollBy(1)
dialog.clear()
},
@@ -732,10 +709,9 @@ export function Session() {
{
title: "Half page up",
value: "session.half.page.up",
keybind: "messages_half_page_up",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(-scroll.height / 4)
dialog.clear()
},
@@ -743,10 +719,9 @@ export function Session() {
{
title: "Half page down",
value: "session.half.page.down",
keybind: "messages_half_page_down",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollBy(scroll.height / 4)
dialog.clear()
},
@@ -754,10 +729,9 @@ export function Session() {
{
title: "First message",
value: "session.first",
keybind: "messages_first",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollTo(0)
dialog.clear()
},
@@ -765,10 +739,9 @@ export function Session() {
{
title: "Last message",
value: "session.last",
keybind: "messages_last",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
scroll.scrollTo(scroll.scrollHeight)
dialog.clear()
},
@@ -776,10 +749,9 @@ export function Session() {
{
title: "Jump to last user message",
value: "session.messages_last_user",
keybind: "messages_last_user",
category: "Session",
hidden: true,
onSelect: () => {
run: () => {
const messages = sync.data.message[route.sessionID]
if (!messages || !messages.length) return
@@ -808,25 +780,22 @@ export function Session() {
{
title: "Next message",
value: "session.message.next",
keybind: "messages_next",
category: "Session",
hidden: true,
onSelect: (dialog) => scrollToMessage("next", dialog),
run: () => scrollToMessage("next", dialog),
},
{
title: "Previous message",
value: "session.message.previous",
keybind: "messages_previous",
category: "Session",
hidden: true,
onSelect: (dialog) => scrollToMessage("prev", dialog),
run: () => scrollToMessage("prev", dialog),
},
{
title: "Copy last assistant message",
value: "messages.copy",
keybind: "messages_copy",
category: "Session",
onSelect: (dialog) => {
run: () => {
const revertID = session()?.revert?.messageID
const lastAssistantMessage = messages().findLast(
(msg) => msg.role === "assistant" && (!revertID || msg.id < revertID),
@@ -871,7 +840,7 @@ export function Session() {
slash: {
name: "copy",
},
onSelect: async (dialog) => {
run: async () => {
try {
const sessionData = session()
if (!sessionData) return
@@ -897,12 +866,11 @@ export function Session() {
{
title: "Export session transcript",
value: "session.export",
keybind: "session_export",
category: "Session",
slash: {
name: "export",
},
onSelect: async (dialog) => {
run: async () => {
try {
const sessionData = session()
if (!sessionData) return
@@ -959,10 +927,9 @@ export function Session() {
{
title: "Go to child session",
value: "session.child.first",
keybind: "session_child_first",
category: "Session",
hidden: true,
onSelect: (dialog) => {
run: () => {
moveFirstChild()
dialog.clear()
},
@@ -970,11 +937,10 @@ export function Session() {
{
title: "Go to parent session",
value: "session.parent",
keybind: "session_parent",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => {
run: childSessionHandler(() => {
const parentID = session()?.parentID
if (parentID) {
navigate({
@@ -988,11 +954,10 @@ export function Session() {
{
title: "Next child session",
value: "session.child.next",
keybind: "session_child_cycle",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => {
run: childSessionHandler(() => {
moveChild(1)
dialog.clear()
}),
@@ -1000,17 +965,36 @@ export function Session() {
{
title: "Previous child session",
value: "session.child.previous",
keybind: "session_child_cycle_reverse",
category: "Session",
hidden: true,
enabled: !!session()?.parentID,
onSelect: childSessionHandler((dialog) => {
run: childSessionHandler(() => {
moveChild(-1)
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 revertMessageID = createMemo(() => revertInfo()?.messageID)
@@ -1082,7 +1066,8 @@ export function Session() {
<Switch>
<Match when={message.id === revert()?.messageID}>
{(function () {
const command = useCommandDialog()
const command = useCommandPalette()
const redoShortcut = useCommandShortcut("session.redo")
const [hover, setHover] = createSignal(false)
const dialog = useDialog()
@@ -1093,7 +1078,7 @@ export function Session() {
"Are you sure you want to restore the reverted messages?",
)
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}>
<span style={{ fg: theme.text }}>{keybind.print("messages_redo")}</span> or /redo to
<span style={{ fg: theme.text }}>{redoShortcut()}</span> or /redo to
restore
</text>
<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
})
const keybind = useKeybind()
const childShortcut = useCommandShortcut("session.child.first")
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")}>
<box paddingTop={1} paddingLeft={3}>
<text fg={theme.text}>
{keybind.print("session_child_first")}
{childShortcut()}
<span style={{ fg: theme.textMuted }}> view subagents</span>
</text>
</box>

View File

@@ -1,24 +1,22 @@
import { createStore } from "solid-js/store"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { useTheme, selectedForeground } from "../../context/theme"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useSync } from "../../context/sync"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useProject } from "../../context/project"
import path from "path"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@opencode-ai/core/global"
import { ShellID } from "@/tool/shell/id"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings, useCommandShortcut } from "../../keymap"
type PermissionStage = "permission" | "always" | "reject"
@@ -463,25 +461,29 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) {
let input: TextareaRenderable
const { theme } = useTheme()
const keybind = useKeybind()
const textareaKeybindings = useTextareaKeybindings()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const dimensions = useTerminalDimensions()
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
props.onCancel()
return
}
if (evt.name === "return") {
evt.preventDefault()
props.onConfirm(input.plainText)
}
})
useBindings(() => ({
enabled: dialog.stack.length === 0,
commands: [
{
name: "permission.reject.cancel",
run() {
props.onCancel()
},
},
],
bindings: [
{ key: "escape", cmd: () => props.onCancel() },
...sections.permission_reject,
{ key: "return", cmd: () => props.onConfirm(input.plainText) },
],
}))
return (
<box
@@ -520,7 +522,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: (
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={textareaKeybindings()}
/>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={theme.text}>
@@ -545,50 +546,77 @@ function Prompt<const T extends Record<string, string>>(props: {
onSelect: (option: keyof T) => void
}) {
const { theme } = useTheme()
const keybind = useKeybind()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const dimensions = useTerminalDimensions()
const keys = Object.keys(props.options) as (keyof T)[]
const [store, setStore] = createStore({
selected: keys[0],
expanded: false,
})
const diffKey = Keybind.parse("ctrl+f")[0]
const narrow = createMemo(() => dimensions().width < 80)
const dialog = useDialog()
const fullscreenHint = useCommandShortcut("permission.prompt.fullscreen")
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "left" || evt.name == "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
}
if (evt.name === "right" || evt.name == "l") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
}
if (evt.name === "return") {
evt.preventDefault()
props.onSelect(store.selected)
}
if (props.escapeKey && (evt.name === "escape" || keybind.match("app_exit", evt))) {
evt.preventDefault()
props.onSelect(props.escapeKey)
}
if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) {
evt.preventDefault()
evt.stopPropagation()
setStore("expanded", (v) => !v)
}
})
useBindings(() => ({
enabled: dialog.stack.length === 0,
commands: [
{
name: "permission.prompt.escape",
run() {
if (!props.escapeKey) return
props.onSelect(props.escapeKey)
},
},
{
name: "permission.prompt.fullscreen",
run() {
if (!props.fullscreen) return
setStore("expanded", (v) => !v)
},
},
],
bindings: [
{
key: "left",
cmd: () => {
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
},
},
{
key: "h",
cmd: () => {
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 ? sections.permission_prompt_escape : []),
...(props.fullscreen ? sections.permission_prompt_fullscreen : []),
],
}))
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
useRenderer()
@@ -661,7 +689,7 @@ function Prompt<const T extends Record<string, string>>(props: {
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={theme.text}>
{"ctrl+f"} <span style={{ fg: theme.textMuted }}>{hint()}</span>
{fullscreenHint()} <span style={{ fg: theme.textMuted }}>{hint()}</span>
</text>
</Show>
<text fg={theme.text}>

View File

@@ -1,20 +1,21 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import type { TextareaRenderable } from "@opentui/core"
import { useKeybind } from "../../context/keybind"
import { selectedForeground, tint, useTheme } from "../../context/theme"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
import { useDialog } from "../../ui/dialog"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings } from "../../keymap"
export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const bindings = useTextareaKeybindings()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
@@ -122,131 +123,124 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const dialog = useDialog()
useKeyboard((evt) => {
// Skip processing if a dialog (e.g., command palette) is open
if (dialog.stack.length > 0) return
// When editing custom answer textarea
if (store.editing && !confirm()) {
if (evt.name === "escape") {
evt.preventDefault()
setStore("editing", false)
return
}
if (keybind.match("input_clear", evt)) {
evt.preventDefault()
const text = textarea?.plainText ?? ""
if (!text) {
useBindings(() => ({
enabled: store.editing && !confirm(),
commands: [
{
name: "question.edit.clear",
run() {
const text = textarea?.plainText ?? ""
if (!text) {
setStore("editing", false)
return
}
textarea?.setText("")
},
},
],
bindings: [
{
key: "escape",
cmd: () => {
setStore("editing", false)
return
}
textarea?.setText("")
return
}
if (evt.name === "return") {
evt.preventDefault()
const text = textarea?.plainText?.trim() ?? ""
const prev = store.custom[store.tab]
},
},
...sections.question_edit,
{
key: "return",
cmd: () => {
const text = textarea?.plainText?.trim() ?? ""
const prev = store.custom[store.tab]
if (!text) {
if (prev) {
if (!text) {
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]
inputs[store.tab] = ""
inputs[store.tab] = text
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]
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(text, true)
setStore("editing", false)
return
}
},
},
],
}))
if (multi()) {
const inputs = [...store.custom]
inputs[store.tab] = text
setStore("custom", inputs)
useBindings(() => {
const opts = options()
const total = opts.length + (custom() ? 1 : 0)
const max = Math.min(total, 9)
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]
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(text, true)
setStore("editing", false)
return
}
// Let textarea handle all other keys
return
}
if (evt.name === "left" || evt.name === "h") {
evt.preventDefault()
selectTab((store.tab - 1 + tabs()) % tabs())
}
if (evt.name === "right" || evt.name === "l") {
evt.preventDefault()
selectTab((store.tab + 1) % tabs())
}
if (evt.name === "tab") {
evt.preventDefault()
const direction = evt.shift ? -1 : 1
selectTab((store.tab + direction + tabs()) % tabs())
}
if (confirm()) {
if (evt.name === "return") {
evt.preventDefault()
submit()
}
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()
}
return {
enabled: dialog.stack.length === 0 && !store.editing,
commands: [
{
name: "question.reject",
run() {
reject()
},
},
],
bindings: [
{ 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()) },
{ key: "l", cmd: () => selectTab((store.tab + 1) % tabs()) },
{
key: "tab",
cmd: ({ event }: { event: { shift: boolean } }) => {
selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
},
},
...(confirm()
? [
{ key: "return", cmd: () => submit() },
{ key: "escape", cmd: () => reject() },
...sections.question,
]
: [
...Array.from({ length: max }, (_, index) => ({
key: String(index + 1),
cmd: () => {
moveTo(index)
selectOption()
},
})),
{ 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) },
{ key: "j", cmd: () => moveTo((store.selected + 1) % total) },
{ key: "return", cmd: () => selectOption() },
{ key: "escape", cmd: () => reject() },
...sections.question,
]),
],
}
})
@@ -394,7 +388,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.primary}
keyBindings={bindings()}
/>
</box>
</Show>

View File

@@ -4,10 +4,10 @@ import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
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 { useTerminalDimensions } from "@opentui/solid"
import { useCommandPalette } from "../../context/command-palette"
import { useCommandShortcut } from "../../keymap"
export function SubagentFooter() {
const route = useRouteData("session")
@@ -56,8 +56,10 @@ export function SubagentFooter() {
})
const { theme } = useTheme()
const keybind = useKeybind()
const command = useCommandDialog()
const command = useCommandPalette()
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)
useTerminalDimensions()
@@ -96,31 +98,31 @@ export function SubagentFooter() {
<box
onMouseOver={() => setHover("parent")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.parent")}
onMouseUp={() => command.run("session.parent")}
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
</text>
</box>
<box
onMouseOver={() => setHover("prev")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.previous")}
onMouseUp={() => command.run("session.child.previous")}
backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel}
>
<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>
</box>
<box
onMouseOver={() => setHover("next")}
onMouseOut={() => setHover(null)}
onMouseUp={() => command.trigger("session.child.next")}
onMouseUp={() => command.run("session.child.next")}
backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel}
>
<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>
</box>
</box>

View File

@@ -1,7 +1,7 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { useKeyboard } from "@opentui/solid"
import { useBindings } from "../keymap"
export type DialogAlertProps = {
title: string
@@ -13,14 +13,17 @@ export function DialogAlert(props: DialogAlertProps) {
const dialog = useDialog()
const { theme } = useTheme()
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
props.onConfirm?.()
dialog.clear()
}
})
useBindings(() => ({
bindings: [
{
key: "return",
cmd: () => {
props.onConfirm?.()
dialog.clear()
},
},
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">

View File

@@ -3,8 +3,8 @@ import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Locale } from "@/util/locale"
import { useBindings } from "../keymap"
export type DialogConfirmProps = {
title: string
@@ -23,19 +23,30 @@ export function DialogConfirm(props: DialogConfirmProps) {
active: "confirm" as "confirm" | "cancel",
})
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
if (store.active === "confirm") props.onConfirm?.()
if (store.active === "cancel") props.onCancel?.()
dialog.clear()
}
if (evt.name === "left" || evt.name === "right") {
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
}
})
useBindings(() => ({
bindings: [
{
key: "return",
cmd: () => {
if (store.active === "confirm") props.onConfirm?.()
if (store.active === "cancel") props.onCancel?.()
dialog.clear()
},
},
{
key: "left",
cmd: () => {
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
},
},
{
key: "right",
cmd: () => {
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
},
},
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -56,7 +67,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
paddingLeft={1}
paddingRight={1}
backgroundColor={key === store.active ? theme.primary : undefined}
onMouseUp={(_evt) => {
onMouseUp={() => {
if (key === "confirm") props.onConfirm?.()
if (key === "cancel") props.onCancel?.()
dialog.clear()

View File

@@ -3,7 +3,7 @@ import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { onMount, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useBindings } from "../keymap"
export type DialogExportOptionsProps = {
defaultFilename: string
@@ -33,39 +33,40 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
active: "filename" as "filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving",
})
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
props.onConfirm?.({
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
assistantMetadata: store.assistantMetadata,
openWithoutSaving: store.openWithoutSaving,
})
}
if (evt.name === "tab") {
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
"filename",
"thinking",
"toolDetails",
"assistantMetadata",
"openWithoutSaving",
]
const currentIndex = order.indexOf(store.active)
const nextIndex = (currentIndex + 1) % order.length
setStore("active", order[nextIndex])
evt.preventDefault()
}
if (evt.name === "space" || evt.name === " ") {
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
evt.preventDefault()
}
})
useBindings(() => ({
bindings: [
{
key: "tab",
cmd: () => {
const order: Array<"filename" | "thinking" | "toolDetails" | "assistantMetadata" | "openWithoutSaving"> = [
"filename",
"thinking",
"toolDetails",
"assistantMetadata",
"openWithoutSaving",
]
const currentIndex = order.indexOf(store.active)
const nextIndex = (currentIndex + 1) % order.length
setStore("active", order[nextIndex])
},
},
],
}))
useBindings(() => ({
enabled: store.active !== "filename",
bindings: [
{
key: "space",
cmd: () => {
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
if (store.active === "assistantMetadata") setStore("assistantMetadata", !store.assistantMetadata)
if (store.active === "openWithoutSaving") setStore("openWithoutSaving", !store.openWithoutSaving)
},
},
],
}))
onMount(() => {
dialog.setSize("medium")
@@ -101,7 +102,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
})
}}
height={3}
keyBindings={[{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => {
textarea = val
val.traits = { status: "FILENAME" }

View File

@@ -1,21 +1,19 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "@tui/context/theme"
import { useDialog } from "./dialog"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
import { useBindings, useCommandShortcut } from "../keymap"
export function DialogHelp() {
const dialog = useDialog()
const { theme } = useTheme()
const keybind = useKeybind()
const commandShortcut = useCommandShortcut("command.palette.show")
useKeyboard((evt) => {
if (evt.name === "return" || evt.name === "escape") {
evt.preventDefault()
evt.stopPropagation()
dialog.clear()
}
})
useBindings(() => ({
bindings: [
{ key: "return", cmd: () => dialog.clear() },
{ key: "escape", cmd: () => dialog.clear() },
],
}))
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
@@ -29,7 +27,7 @@ export function DialogHelp() {
</box>
<box paddingBottom={1}>
<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>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>

View File

@@ -2,7 +2,6 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { Show, createEffect, onMount, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { Spinner } from "../component/spinner"
export type DialogPromptProps = {
@@ -21,20 +20,6 @@ export function DialogPrompt(props: DialogPromptProps) {
const { theme } = useTheme()
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(() => {
dialog.setSize("medium")
setTimeout(() => {
@@ -79,7 +64,6 @@ export function DialogPrompt(props: DialogPromptProps) {
props.onConfirm?.(textarea.plainText)
}}
height={3}
keyBindings={props.busy ? [] : [{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => {
textarea = val
}}

View File

@@ -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 { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
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 { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../context/tui-config"
import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
export interface DialogSelectProps<T> {
title: string
@@ -23,13 +30,14 @@ export interface DialogSelectProps<T> {
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean
keybind?: {
keybind?: Keybind.Info
actions?: {
command: string
title: string
side?: "left" | "right"
disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void
}[]
bindings?: readonly Binding<Renderable, KeyEvent>[]
current?: T
}
@@ -56,6 +64,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const {
keymap: { sections },
} = tuiConfig
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({
@@ -80,6 +91,25 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
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(() => {
if (props.skipFilter) return props.options.filter((x) => x.disabled !== true)
const needle = store.filter.toLowerCase()
@@ -170,7 +200,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const option = selected()
if (option) props.onMove?.(option)
if (!scroll) return
const target = scroll.getChildren().find((child) => {
const target = scroll.getChildren().find((child: { id?: string }) => {
return child.id === JSON.stringify(selected()?.value)
})
if (!target) return
@@ -191,36 +221,82 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
}
const keybind = useKeybind()
useKeyboard((evt) => {
function submit() {
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)
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
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)
useBindings(() => {
const enabledActions = actions().filter((item) => !item.disabled)
if (evt.name === "return") {
const option = selected()
if (option) {
evt.preventDefault()
evt.stopPropagation()
if (option.onSelect) option.onSelect(dialog)
props.onSelect?.(option)
}
}
for (const item of props.keybind ?? []) {
if (item.disabled || !item.keybind) continue
if (Keybind.match(item.keybind, keybind.parse(evt))) {
const s = selected()
if (s) {
evt.preventDefault()
item.onTrigger(s)
}
}
return {
commands: [
{
name: "dialog.select.prev",
run() {
setStore("input", "keyboard")
move(-1)
},
},
{
name: "dialog.select.next",
run() {
setStore("input", "keyboard")
move(1)
},
},
{
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,
...(props.bindings ?? []).filter((binding) => {
if (typeof binding.cmd !== "string") return true
return enabledActions.some((item) => item.command === binding.cmd)
}),
],
}
})
@@ -235,9 +311,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
const visibleActions = createMemo(() =>
actions()
.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 (
<box gap={1} paddingBottom={1}>
@@ -252,7 +332,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</box>
<box paddingTop={1}>
<input
onInput={(e) => {
onInput={(e: string) => {
batch(() => {
setStore("filter", e)
props.onFilter?.(e)
@@ -261,7 +341,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
focusedBackgroundColor={theme.backgroundPanel}
cursorColor={theme.primary}
focusedTextColor={theme.textMuted}
ref={(r) => {
ref={(r: InputRenderable) => {
input = r
input.traits = { status: "FILTER" }
setTimeout(() => {
@@ -362,7 +442,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</For>
</scrollbox>
</Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
<Show when={visibleActions().length} fallback={<box flexShrink={0} />}>
<box
paddingRight={2}
paddingLeft={4}
@@ -378,7 +458,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
<span style={{ fg: theme.textMuted }}>{item.label}</span>
</text>
)}
</For>
@@ -390,7 +470,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
<span style={{ fg: theme.textMuted }}>{item.label}</span>
</text>
)}
</For>

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 { useTheme } from "@tui/context/theme"
import { MouseButton, Renderable, RGBA } from "@opentui/core"
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Selection from "@tui/util/selection"
import { useBindings } from "../keymap"
export function Dialog(
props: ParentProps<{
@@ -47,7 +48,7 @@ export function Dialog(
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={(e) => {
onMouseUp={(e: { stopPropagation(): void }) => {
dismiss = false
e.stopPropagation()
}}
@@ -73,23 +74,6 @@ function init() {
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
function refocus() {
setTimeout(() => {
@@ -108,6 +92,36 @@ function init() {
}, 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 {
clear() {
for (const item of store.stack) {
@@ -155,13 +169,14 @@ export function DialogProvider(props: ParentProps) {
const value = init()
const renderer = useRenderer()
const toast = useToast()
return (
<ctx.Provider value={value}>
{props.children}
<box
position="absolute"
zIndex={3000}
onMouseDown={(evt) => {
onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (evt.button !== MouseButton.RIGHT) return

View File

@@ -5,9 +5,21 @@ type Toast = {
error: (err: unknown) => void
}
type FocusableSelectionTarget = {
hasSelection: () => boolean
}
type Renderer = {
getSelection: () => { getSelectedText: () => string } | null
getSelection: () => { getSelectedText: () => string; selectedRenderables: FocusableSelectionTarget[] } | null
clearSelection: () => void
currentFocusedRenderable?: FocusableSelectionTarget | null
}
type SelectionKeyEvent = {
ctrl?: boolean
name: string
preventDefault: () => void
stopPropagation: () => void
}
export function copy(renderer: Renderer, toast: Toast): boolean {
@@ -22,4 +34,32 @@ export function copy(renderer: Renderer, toast: Toast): boolean {
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"

View File

@@ -21,7 +21,6 @@ const KeybindsSchema = Schema.Struct({
theme_list: keybind("<leader>t", "List available themes"),
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
username_toggle: keybind("none", "Toggle username visibility"),
status_view: keybind("<leader>s", "View status"),
session_export: keybind("<leader>x", "Export session to editor"),
session_new: keybind("<leader>n", "Create a new session"),
@@ -59,6 +58,23 @@ const KeybindsSchema = Schema.Struct({
model_cycle_favorite: keybind("none", "Next favorite model"),
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
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.session.workspace.new": keybind("ctrl+w", "Create workspace from session dialog"),
"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"),
"plugins.install": keybind("shift+i", "Install plugin"),
agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"),
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
@@ -101,6 +117,7 @@ const KeybindsSchema = Schema.Struct({
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_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_next: keybind("down", "Next history item"),
session_child_first: keybind("<leader>down", "Go to first child session"),

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"

View File

@@ -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"])
})
})

View File

@@ -79,7 +79,10 @@ async function load(): Promise<Data> {
await Bun.write(
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
await Bun.write(options.fn_marker, "called")
}
@@ -93,10 +96,21 @@ export default {
const cfg_speed = api.tuiConfig.scroll_speed
const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
const cfg_submit = api.tuiConfig.keybinds?.input_submit
const key = api.keybind.create(
{ modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
options.keybinds,
)
const has_keys = typeof api.keys.formatBindings === "function"
const keymap = resolveBindingSections(options.keymap?.sections ?? {
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")
api.kv.set(options.kv_key, "stored")
const kv_after = api.kv.get(options.kv_key, "missing")
@@ -132,10 +146,13 @@ export default {
set_installed,
selected: api.theme.selected,
same: first === second,
key_modal: key.get("modal"),
key_close: key.get("close"),
key_unknown: key.get("ctrl+k"),
key_print: key.print("modal"),
key_modal,
key_close,
key_unknown,
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_after,
kv_ready: api.kv.ready,
@@ -337,7 +354,14 @@ export default {
theme_name: tmp.extra.localThemeName,
kv_key: "plugin_state_key",
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 = {
marker: tmp.extra.invalidMarker,
@@ -386,9 +410,6 @@ export default {
input_submit: "ctrl+enter",
},
},
keybind: {
print: (key) => `print:${key}`,
},
state: {
session: {
diff(sessionID) {
@@ -645,7 +666,10 @@ describe("tui.plugin.loader", () => {
expect(data.local.key_modal).toBe("ctrl+alt+m")
expect(data.local.key_close).toBe("q")
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_after).toBe("stored")
expect(data.local.kv_ready).toBe(true)
@@ -703,6 +727,179 @@ 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: {
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("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: {
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: {
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 () => {
await using tmp = await tmpdir<{
spec: string

View File

@@ -1,6 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
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"
type Count = {
@@ -84,8 +83,8 @@ type Opts = {
client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
renderer?: HostPluginApi["renderer"]
count?: Count
keybind?: Partial<HostPluginApi["keybind"]>
tuiConfig?: HostPluginApi["tuiConfig"]
keymap?: HostPluginApi["keymap"]
tuiConfig?: Partial<HostPluginApi["tuiConfig"]>
app?: Partial<HostPluginApi["app"]>
state?: {
ready?: HostPluginApi["state"]["ready"]
@@ -109,6 +108,16 @@ type Opts = {
}
}
function tuiConfig(input?: Partial<HostPluginApi["tuiConfig"]>): HostPluginApi["tuiConfig"] {
return {
...input,
keymap: input?.keymap ?? {
leader: "ctrl+x",
sections: {},
},
}
}
export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
const kv: Record<string, unknown> = {}
const count = opts.count
@@ -128,10 +137,6 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
let size: "medium" | "large" | "xlarge" = "medium"
const has = opts.theme?.has ?? (() => false)
let selected = opts.theme?.selected ?? "opencode"
const key = {
match: opts.keybind?.match ?? (() => false),
print: opts.keybind?.print ?? ((name: string) => name),
}
const set =
opts.theme?.set ??
((name: string) => {
@@ -145,6 +150,26 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
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<Value>(name: string, fallback: Value): Value
@@ -160,6 +185,10 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
return opts.app?.version ?? "0.0.0-test"
},
},
keys: {
formatSequence: () => "",
formatBindings: () => undefined,
},
get client() {
return client()
},
@@ -192,17 +221,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
return () => {}
},
},
command: {
register: () => {
if (count) count.command_add += 1
return () => {
if (!count) return
count.command_drop += 1
}
},
trigger: () => {},
show: () => {},
},
keymap,
route: {
register: () => {
if (count) count.route_add += 1
@@ -247,15 +266,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
},
},
},
keybind: {
...key,
create:
opts.keybind?.create ??
((defaults, over) => {
return createPluginKeybind(key, defaults, over)
}),
},
tuiConfig: opts.tuiConfig ?? {},
tuiConfig: tuiConfig(opts.tuiConfig),
kv: {
get: kvGet,
set(name, value) {

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",
},
])
})
})

View File

@@ -22,19 +22,24 @@
"zod": "catalog:"
},
"peerDependencies": {
"@opentui/core": ">=0.2.2",
"@opentui/solid": ">=0.2.2"
"@opentui/core": ">=0.0.0-20260502-5091230e",
"@opentui/keymap": ">=0.0.0-20260502-5091230e",
"@opentui/solid": ">=0.0.0-20260502-5091230e"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/keymap": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
},
"devDependencies": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",

View File

@@ -15,11 +15,39 @@ import type {
TextPart,
Config as SdkConfig,
} 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 { 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 =
| {
@@ -42,39 +70,12 @@ export type TuiRouteDefinition = {
render: (input: { params?: Record<string, unknown> }) => JSX.Element
}
export type TuiCommand = {
title: string
value: string
description?: string
category?: string
keybind?: string
suggested?: boolean
hidden?: boolean
enabled?: boolean
slash?: {
name: string
aliases?: string[]
}
onSelect?: () => void
export type TuiKeys = {
formatSequence: (parts: readonly KeySequenceFormatPart[] | undefined) => string
formatBindings: (bindings: readonly SequenceBindingLike[] | undefined) => string | undefined
}
export type TuiKeybind = {
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 TuiKeymap = Keymap<Renderable, KeyEvent>
export type TuiDialogProps = {
size?: "medium" | "large" | "xlarge"
@@ -288,6 +289,10 @@ export type TuiState = {
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plugin"> &
NonNullable<PluginConfig["tui"]> & {
plugin_enabled?: Record<string, boolean>
keymap: {
leader: string
sections: Record<string, ReadonlyArray<Binding<Renderable, KeyEvent>>>
}
}
export type TuiApp = {
@@ -448,11 +453,8 @@ export type TuiWorkspace = {
export type TuiPluginApi = {
app: TuiApp
command: {
register: (cb: () => TuiCommand[]) => () => void
trigger: (value: string) => void
show: () => void
}
keys: TuiKeys
keymap: TuiKeymap
route: {
register: (routes: TuiRouteDefinition[]) => () => void
navigate: (name: string, params?: Record<string, unknown>) => void
@@ -469,11 +471,6 @@ export type TuiPluginApi = {
toast: (input: TuiToast) => void
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>
kv: TuiKV
state: TuiState

View File

@@ -9,29 +9,30 @@ if (!raw) {
}
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 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(
(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}`
return ver
}
const edit = (obj: unknown) => {
const editDeps = (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") return false
const next = set(cur)
const next = setVersion(cur)
if (next === cur) return false
map[key] = next
return true
@@ -39,13 +40,31 @@ const edit = (obj: unknown) => {
.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 = (
await Promise.all(
files.map(async (rel) => {
const file = path.join(root, rel)
const txt = await Bun.file(file).text()
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
await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`)
return rel