mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 15:44:56 +00:00
introduce opentui keymap as sole key/cmd engine (#26053)
This commit is contained in:
@@ -1,37 +1,89 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { RGBA, VignetteEffect } from "@opentui/core"
|
||||
import type {
|
||||
TuiKeybindSet,
|
||||
TuiPlugin,
|
||||
TuiPluginApi,
|
||||
TuiPluginMeta,
|
||||
TuiPluginModule,
|
||||
TuiSlotPlugin,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
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 BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras"
|
||||
import type { Binding } from "@opentui/keymap"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } 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 +95,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 +116,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 +132,25 @@ const names = (input: Cfg) => {
|
||||
}
|
||||
}
|
||||
|
||||
type Keys = TuiKeybindSet
|
||||
function createKeys(input: SmokeKeymap | undefined): { sections: ResolvedSections } {
|
||||
const sections = resolveBindingSections(
|
||||
{
|
||||
global: { ...defaultKeymap.global, ...input?.sections?.global },
|
||||
dialog: { ...defaultKeymap.dialog, ...input?.sections?.dialog },
|
||||
local: { ...defaultKeymap.local, ...input?.sections?.local },
|
||||
screen: { ...defaultKeymap.screen, ...input?.sections?.screen },
|
||||
modal: { ...defaultKeymap.modal, ...input?.sections?.modal },
|
||||
} satisfies BindingSectionsConfig<Renderable, KeyEvent>,
|
||||
{ sections: sectionNames },
|
||||
).sections
|
||||
|
||||
return {
|
||||
sections,
|
||||
}
|
||||
}
|
||||
|
||||
type Keys = ReturnType<typeof createKeys>
|
||||
|
||||
const ui = {
|
||||
panel: "#1d1d1d",
|
||||
border: "#4a4a4a",
|
||||
@@ -292,125 +357,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 +531,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 +578,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 +586,16 @@ 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 +648,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 +671,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 +710,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 +906,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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
31
bun.lock
31
bun.lock
@@ -379,6 +379,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:",
|
||||
@@ -477,6 +478,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
@@ -484,11 +486,13 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.2",
|
||||
"@opentui/solid": ">=0.2.2",
|
||||
"@opentui/core": ">=0.2.4",
|
||||
"@opentui/keymap": ">=0.2.4",
|
||||
"@opentui/solid": ">=0.2.4",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
"@opentui/keymap",
|
||||
"@opentui/solid",
|
||||
],
|
||||
},
|
||||
@@ -663,8 +667,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.2.4",
|
||||
"@opentui/keymap": "0.2.4",
|
||||
"@opentui/solid": "0.2.4",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1589,21 +1594,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.2.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.4", "@opentui/core-darwin-x64": "0.2.4", "@opentui/core-linux-arm64": "0.2.4", "@opentui/core-linux-x64": "0.2.4", "@opentui/core-win32-arm64": "0.2.4", "@opentui/core-win32-x64": "0.2.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6xRdxmSgCFsEIwUwv7Pr+XKS1gBOwYF0tS/DE4KxTNzuH39VQDot7blzm8UKl6okdurFAxkt2+1HJJStl+rICw=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2GlEndoBQkA8qSxr9RQEOgprdheCBRZvbUIfui5AUUmREZfgIQP+w399cJwmhlwSoNVNtfzLQGHxUFGfPhzMrQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-5V/Fcwg1rYTeKH9/bj3pMG1837APMIaYPfNWz0Ha87m5wcUKjodQOMf/xTzz2NJuLE/m5rydSuvY9uh5EJx3QA=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-yQoWlEH9sQ/OfpCYcoGxzV4mbPaMCbYIl3thD/vcvIqOSa386vjKZFdTeU5Lu1PHiz3MMU/8Fzej5pJ6ZFJFZA=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-OxhBUqFHNcIhiKzon3+HYo4T2Me0ooRJQJW8bDVfEc7gtcWGm3ix/+8o7feQAdcbQW63Chwzed2glvbrOrDCzg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-UWECe8y9vzdcotRf1ljOvWFOjNiGqAnmeC/SyylhvvoNhh/TqvbZawHVFifw/GZUlBEBdZcXF0f3XGGsW4M5Nw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-P7yBRAWwiMZXDnVzzXCNksjkCzAvQ5b2X32JzL3gACf+yobs4bvA9F47Ud+XgKZiqILf/c7fa0cud2E0cfWxlA=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.4", "", { "dependencies": { "@opentui/core": "0.2.4" }, "peerDependencies": { "@opentui/react": "0.2.4", "@opentui/solid": "0.2.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-TOEkPKlcfhP2Xqo6xtU6zYTsvwHv4syqZ2v89hjNLCuY476j4UMTxeszJCfuSABplwm0OfllV94rVFl0BheWVw=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-EKuUwcTElRW0jKrXNJrTiWVOBvok78wk8viVwsyy3h8sD9qcLyCQA+XGmOINapADNGvgBohW9dKOSTFsqjZlvA=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -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.2.4",
|
||||
"@opentui/keymap": "0.2.4",
|
||||
"@opentui/solid": "0.2.4",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
|
||||
@@ -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"
|
||||
@@ -125,6 +124,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:",
|
||||
|
||||
@@ -59,5 +59,5 @@ await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2))
|
||||
|
||||
if (tuiFile) {
|
||||
console.log(tuiFile)
|
||||
await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2))
|
||||
await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.JsonSchemaInfo), null, 2))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,3 +8,29 @@ Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Comm
|
||||
|
||||
_Why_
|
||||
Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right.
|
||||
|
||||
## OpenTUI Keymap Migration
|
||||
|
||||
The v2 TUI uses `@opentui/keymap` as the key/cmd engine. The remaining legacy compatibility is config-only and exists to migrate users from `keybinds` to `keymap`:
|
||||
|
||||
- `packages/opencode/src/config/keybinds.ts`: old `keybinds` schema, defaults, and legacy key names.
|
||||
- `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`: transforms parsed legacy `keybinds` into OpenTUI `keymap` sections.
|
||||
- `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`: migrates legacy TUI keys from `opencode.json` into `tui.json`, including `theme`, `keybinds`, and nested `tui`.
|
||||
- `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`: still accepts deprecated `keybinds` via `KeybindOverride` and marks it as deprecated. This file also contains the new `keymap` config schema.
|
||||
- `packages/opencode/src/cli/cmd/tui/config/tui.ts`: parses legacy `keybinds`, applies the Windows `terminal_suspend`/`input_undo` adjustment, and uses `LegacyKeymapTransform.create(...)` as the fallback when no `keymap` section is configured.
|
||||
- `packages/plugin/src/tui.ts`: plugin-facing `tuiConfig` still includes `keybinds` through `PluginConfig`; this should be removed when the public plugin API no longer exposes legacy config.
|
||||
|
||||
The transform must stay while users are migrating. It lets users upgrade without first rewriting their existing `keybinds` config. If `keymap` is configured, `keybinds` are ignored for keymap resolution. If `keymap` is missing, `legacy-keymap-transform.ts` turns legacy `keybinds` into the resolved `keymap` consumed by OpenTUI.
|
||||
|
||||
## Removing Legacy Later
|
||||
|
||||
When switching fully to the new config style, remove legacy support with these exact changes:
|
||||
|
||||
- Delete `packages/opencode/src/config/keybinds.ts`.
|
||||
- Delete `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`.
|
||||
- Delete `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`.
|
||||
- In `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`, remove the `ConfigKeybinds` import, remove `KeybindOverride`, and delete the deprecated `keybinds` field from `TuiInfo`.
|
||||
- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `migrateTuiConfig(...)`, remove `ConfigKeybinds`, remove the Windows legacy keybind adjustment, remove `LegacyKeymapTransform.create(...)`, and require/default `keymap` through the new config path instead.
|
||||
- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `keybinds` from `Resolved`; resolved TUI config should expose `keymap` only.
|
||||
- In `packages/plugin/src/tui.ts`, remove `keybinds` from plugin-facing `TuiConfigView`.
|
||||
- Remove or rewrite tests that write or assert `keybinds`, especially in `packages/opencode/test/config/tui.test.ts`, `packages/opencode/test/fixture/tui-runtime.ts`, and TUI plugin loader tests.
|
||||
|
||||
@@ -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,
|
||||
@@ -36,11 +38,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"
|
||||
@@ -60,15 +60,17 @@ 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"
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||
function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig {
|
||||
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
||||
|
||||
return {
|
||||
@@ -111,7 +113,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
|
||||
@@ -130,6 +132,7 @@ export function tui(input: {
|
||||
}
|
||||
|
||||
const onBeforeExit = async () => {
|
||||
offKeymap()
|
||||
await TuiPluginRuntime.dispose()
|
||||
}
|
||||
|
||||
@@ -138,6 +141,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
|
||||
@@ -145,37 +151,37 @@ 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>
|
||||
<SyncProviderV2>
|
||||
<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>
|
||||
<SyncProviderV2>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<CommandPaletteProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
@@ -185,22 +191,22 @@ export function tui(input: {
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</CommandProvider>
|
||||
</CommandPaletteProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProviderV2>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
@@ -209,14 +215,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()
|
||||
@@ -233,10 +242,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
}
|
||||
|
||||
const api = createTuiApi({
|
||||
command,
|
||||
tuiConfig,
|
||||
dialog,
|
||||
keybind,
|
||||
keymap,
|
||||
kv,
|
||||
route,
|
||||
routes,
|
||||
@@ -260,40 +268,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) => {
|
||||
@@ -410,379 +394,365 @@ 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()
|
||||
},
|
||||
},
|
||||
{
|
||||
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: process.platform !== "win32",
|
||||
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.global,
|
||||
}))
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
command.trigger(evt.properties.command)
|
||||
command.run(evt.properties.command)
|
||||
})
|
||||
|
||||
event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
getOwner,
|
||||
onCleanup,
|
||||
runWithOwner,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
|
||||
type Context = ReturnType<typeof init>
|
||||
const ctx = createContext<Context>()
|
||||
|
||||
export type Slash = {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
}
|
||||
|
||||
export type CommandOption = DialogSelectOption<string> & {
|
||||
keybind?: string
|
||||
suggested?: boolean
|
||||
slash?: Slash
|
||||
hidden?: boolean
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function init() {
|
||||
const root = getOwner()
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
|
||||
const entries = createMemo(() => {
|
||||
const all = registrations().flatMap((x) => x())
|
||||
return all.map((x) => ({
|
||||
...x,
|
||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
const isEnabled = (option: CommandOption) => option.enabled !== false
|
||||
const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden
|
||||
|
||||
const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option)))
|
||||
const suggestedOptions = createMemo(() =>
|
||||
visibleOptions()
|
||||
.filter((option) => option.suggested)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
)
|
||||
const suspended = () => suspendCount() > 0
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return
|
||||
if (dialog.stack.length > 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
for (const option of entries()) {
|
||||
if (!isEnabled(option)) continue
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault()
|
||||
option.onSelect?.(dialog)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
trigger(name: string) {
|
||||
for (const option of entries()) {
|
||||
if (option.value === name) {
|
||||
if (!isEnabled(option)) return
|
||||
option.onSelect?.(dialog)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
slashes() {
|
||||
return visibleOptions().flatMap((option) => {
|
||||
const slash = option.slash
|
||||
if (!slash) return []
|
||||
return {
|
||||
display: "/" + slash.name,
|
||||
description: option.description ?? option.title,
|
||||
aliases: slash.aliases?.map((alias) => "/" + alias),
|
||||
onSelect: () => result.trigger(option.value),
|
||||
}
|
||||
})
|
||||
},
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||
},
|
||||
suspended,
|
||||
show() {
|
||||
dialog.replace(() => <DialogCommand options={visibleOptions()} suggestedOptions={suggestedOptions()} />)
|
||||
},
|
||||
register(cb: () => CommandOption[]) {
|
||||
const owner = getOwner() ?? root
|
||||
if (!owner) return () => {}
|
||||
|
||||
let list: Accessor<CommandOption[]> | undefined
|
||||
|
||||
// TUI plugins now register commands via an async store that runs outside an active reactive scope.
|
||||
// runWithOwner attaches createMemo/onCleanup to this owner so plugin registrations stay reactive and dispose correctly.
|
||||
runWithOwner(owner, () => {
|
||||
list = createMemo(cb)
|
||||
const ref = list
|
||||
if (!ref) return
|
||||
setRegistrations((arr) => [ref, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||
})
|
||||
})
|
||||
|
||||
if (!list) return () => {}
|
||||
let done = false
|
||||
return () => {
|
||||
if (done) return
|
||||
done = true
|
||||
const ref = list
|
||||
if (!ref) return
|
||||
setRegistrations((arr) => arr.filter((x) => x !== ref))
|
||||
}
|
||||
},
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function useCommandDialog() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useCommandDialog must be used within a CommandProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function CommandProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (value.suspended()) return
|
||||
if (dialog.stack.length > 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
if (keybind.match("command_list", evt)) {
|
||||
evt.preventDefault()
|
||||
value.show()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) {
|
||||
let ref: DialogSelectRef<string>
|
||||
const list = () => {
|
||||
if (ref?.filter) return props.options
|
||||
return [...props.suggestedOptions, ...props.options]
|
||||
}
|
||||
return <DialogSelect ref={(r) => (ref = r)} title="Commands" options={list()} />
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { 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)}>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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"
|
||||
|
||||
@@ -45,9 +44,9 @@ export function DialogMcp() {
|
||||
)
|
||||
})
|
||||
|
||||
const keybinds = createMemo(() => [
|
||||
const actions = createMemo(() => [
|
||||
{
|
||||
keybind: Keybind.parse("space")[0],
|
||||
command: "dialog.action.toggle",
|
||||
title: "toggle",
|
||||
onTrigger: async (option: DialogSelectOption<string>) => {
|
||||
// Prevent toggling while an operation is already in progress
|
||||
@@ -77,7 +76,7 @@ export function DialogMcp() {
|
||||
ref={setRef}
|
||||
title="MCPs"
|
||||
options={options()}
|
||||
keybind={keybinds()}
|
||||
actions={actions()}
|
||||
onSelect={(_option) => {
|
||||
// Don't close on select, only on escape
|
||||
}}
|
||||
|
||||
@@ -6,15 +6,15 @@ import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { 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 tuiConfig = useTuiConfig()
|
||||
const [query, setQuery] = createSignal("")
|
||||
|
||||
const connected = useConnected()
|
||||
@@ -150,16 +150,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: "model.dialog.provider",
|
||||
title: connected() ? "Connect provider" : "View all providers",
|
||||
onTrigger() {
|
||||
dialog.replace(() => <DialogProvider />)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.model_favorite_toggle?.[0],
|
||||
command: "model.dialog.favorite",
|
||||
title: "Favorite",
|
||||
disabled: !connected(),
|
||||
onTrigger: (option) => {
|
||||
@@ -167,6 +167,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
},
|
||||
},
|
||||
]}
|
||||
bindings={tuiConfig.keymap.sections.model}
|
||||
onFilter={setQuery}
|
||||
flat={true}
|
||||
skipFilter={true}
|
||||
|
||||
@@ -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,
|
||||
@@ -239,14 +239,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({
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createResource, createSignal, onMount, type JSX } 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"
|
||||
@@ -17,18 +16,19 @@ import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
|
||||
import { WorkspaceLabel } from "./workspace-label"
|
||||
import { useCommandShortcut } from "../keymap"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const deleteHint = useCommandShortcut("dialog.action.delete")
|
||||
|
||||
const [searchResults, { refetch }] = createResource(
|
||||
() => ({ query: search(), filter: sync.session.query() }),
|
||||
@@ -156,7 +156,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,
|
||||
@@ -187,9 +187,9 @@ export function DialogSessionList() {
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
actions={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
command: "dialog.action.delete",
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
@@ -237,7 +237,7 @@ export function DialogSessionList() {
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.session_rename?.[0],
|
||||
command: "dialog.action.rename",
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
|
||||
@@ -3,8 +3,8 @@ 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 { useCommandShortcut } from "../keymap"
|
||||
|
||||
function getRelativeTime(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
@@ -30,9 +30,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
const dialog = useDialog()
|
||||
const stash = usePromptStash()
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<number>()
|
||||
const deleteHint = useCommandShortcut("dialog.action.delete")
|
||||
|
||||
const options = createMemo(() => {
|
||||
const entries = stash.list()
|
||||
@@ -42,7 +42,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 +68,9 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
}
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
actions={[
|
||||
{
|
||||
keybind: keybind.all.stash_delete?.[0],
|
||||
command: "dialog.action.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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.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("/")
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -16,14 +26,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"
|
||||
@@ -40,7 +48,6 @@ 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 {
|
||||
confirmWorkspaceFileChanges,
|
||||
@@ -51,7 +58,15 @@ import {
|
||||
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||
import { useArgs } from "@tui/context/args"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label"
|
||||
import { type WorkspaceStatus } from "../workspace-label"
|
||||
import { useCommandPalette } from "../../context/command-palette"
|
||||
import {
|
||||
useBindings,
|
||||
useCommandShortcut,
|
||||
useLeaderActive,
|
||||
useOpencodeKeymap,
|
||||
} from "../../keymap"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -124,9 +139,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()
|
||||
@@ -134,12 +149,17 @@ export function Prompt(props: PromptProps) {
|
||||
const route = useRoute()
|
||||
const project = useProject()
|
||||
const sync = useSync()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const keymapConfig = tuiConfig.keymap
|
||||
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()
|
||||
@@ -184,6 +204,7 @@ export function Prompt(props: PromptProps) {
|
||||
const [workspaceCreating, setWorkspaceCreating] = createSignal(false)
|
||||
const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3)
|
||||
const [warpNotice, setWarpNotice] = createSignal<string>()
|
||||
const [cursorVersion, setCursorVersion] = createSignal(0)
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
const defaultWorkspaceID = createMemo(() => props.workspaceID ?? project.workspace.current())
|
||||
@@ -287,9 +308,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")!
|
||||
@@ -391,26 +409,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
|
||||
@@ -420,21 +442,22 @@ 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>) => {
|
||||
ctx.event.preventDefault()
|
||||
ctx.event.stopPropagation()
|
||||
const content = await Clipboard.read()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
await pasteAttachment({
|
||||
@@ -442,18 +465,21 @@ export function Prompt(props: PromptProps) {
|
||||
mime: content.mime,
|
||||
content: content.data,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (content?.mime === "text/plain") {
|
||||
await pasteInputText(content.data)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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") {
|
||||
@@ -480,12 +506,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
|
||||
@@ -566,12 +589,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) => {
|
||||
@@ -588,14 +609,12 @@ export function Prompt(props: PromptProps) {
|
||||
},
|
||||
{
|
||||
title: "Warp",
|
||||
description: "Change the workspace for the session",
|
||||
value: "workspace.set",
|
||||
desc: "Change the workspace for the session",
|
||||
name: "workspace.set",
|
||||
category: "Session",
|
||||
enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
slash: {
|
||||
name: "warp",
|
||||
},
|
||||
onSelect: (dialog) => {
|
||||
slashName: "warp",
|
||||
run: () => {
|
||||
void openWorkspaceSelect({
|
||||
dialog,
|
||||
sdk,
|
||||
@@ -607,8 +626,29 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
].map((entry) => ({
|
||||
namespace: "palette",
|
||||
...entry,
|
||||
})),
|
||||
)
|
||||
|
||||
useBindings(() => ({
|
||||
commands: promptCommands(),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: command.matcher,
|
||||
bindings: keymapConfig.pick("prompt", [
|
||||
"prompt.submit",
|
||||
"prompt.editor",
|
||||
"prompt.editor_context.clear",
|
||||
"prompt.stash",
|
||||
"prompt.stash.pop",
|
||||
"prompt.stash.list",
|
||||
"session.interrupt",
|
||||
"workspace.set",
|
||||
]),
|
||||
}))
|
||||
|
||||
const ref: PromptRef = {
|
||||
get focused() {
|
||||
@@ -659,6 +699,7 @@ export function Prompt(props: PromptProps) {
|
||||
if (store.prompt.input) {
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
setInputTarget(undefined)
|
||||
props.ref?.(undefined)
|
||||
})
|
||||
|
||||
@@ -676,11 +717,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"]) {
|
||||
@@ -761,60 +805,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: keymapConfig.pick("prompt", ["prompt.paste"]),
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "",
|
||||
bindings: keymapConfig.pick("prompt", ["prompt.clear"]),
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && !props.disabled && store.mode === "normal" && !auto()?.visible && input?.visualCursor.offset === 0
|
||||
})(),
|
||||
bindings: [
|
||||
{
|
||||
key: "!",
|
||||
cmd: () => {
|
||||
setStore("placeholder", randomIndex(shell().length))
|
||||
setStore("mode", "shell")
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && store.mode === "shell",
|
||||
bindings: [{ key: "escape", cmd: () => setStore("mode", "normal") }],
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
|
||||
})(),
|
||||
bindings: [{ key: "backspace", cmd: () => setStore("mode", "normal") }],
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return (
|
||||
inputTarget() !== undefined &&
|
||||
!props.disabled &&
|
||||
!auto()?.visible &&
|
||||
input !== undefined &&
|
||||
(input.cursorOffset === 0 || input.visualCursor.visualRow === 0)
|
||||
)
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
name: "prompt.history.previous",
|
||||
run() {
|
||||
if (input.cursorOffset !== 0) {
|
||||
input.cursorOffset = 0
|
||||
return
|
||||
}
|
||||
|
||||
const item = history.move(-1, input.plainText)
|
||||
if (!item) return
|
||||
input.setText(item.input)
|
||||
setStore("prompt", item)
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
restoreExtmarksFromParts(item.parts)
|
||||
input.cursorOffset = 0
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]),
|
||||
}
|
||||
})
|
||||
|
||||
useBindings(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return (
|
||||
inputTarget() !== undefined &&
|
||||
!props.disabled &&
|
||||
!auto()?.visible &&
|
||||
input !== undefined &&
|
||||
(input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1)
|
||||
)
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
name: "prompt.history.next",
|
||||
run() {
|
||||
if (input.cursorOffset !== input.plainText.length) {
|
||||
input.cursorOffset = input.plainText.length
|
||||
return
|
||||
}
|
||||
|
||||
const item = history.move(1, input.plainText)
|
||||
if (!item) return
|
||||
input.setText(item.input)
|
||||
setStore("prompt", item)
|
||||
setStore("mode", item.mode ?? "normal")
|
||||
restoreExtmarksFromParts(item.parts)
|
||||
input.cursorOffset = input.plainText.length
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: keymapConfig.pick("prompt", ["prompt.history.next"]),
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
setWarpNotice(undefined)
|
||||
@@ -828,7 +1007,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
if (props.disabled) return false
|
||||
if (workspaceCreating()) 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
|
||||
@@ -1068,6 +1247,66 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
}
|
||||
|
||||
async function pasteInputText(text: string) {
|
||||
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
const pastedContent = normalizedText.trim()
|
||||
const filepath = iife(() => {
|
||||
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
|
||||
if (raw.startsWith("file://")) {
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
} catch {}
|
||||
}
|
||||
if (process.platform === "win32") return raw
|
||||
return raw.replace(/\\(.)/g, "$1")
|
||||
})
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
try {
|
||||
const mime = await Filesystem.mimeType(filepath)
|
||||
const filename = path.basename(filepath)
|
||||
if (mime === "image/svg+xml") {
|
||||
const content = await Filesystem.readText(filepath).catch(() => {})
|
||||
if (content) {
|
||||
pasteText(content, `[SVG: ${filename ?? "image"}]`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (mime.startsWith("image/") || mime === "application/pdf") {
|
||||
const content = await Filesystem.readArrayBuffer(filepath)
|
||||
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
||||
.catch(() => {})
|
||||
if (content) {
|
||||
await pasteAttachment({
|
||||
filename,
|
||||
filepath,
|
||||
mime,
|
||||
content,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (
|
||||
(lineCount >= 3 || pastedContent.length > 150) &&
|
||||
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
|
||||
) {
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
}
|
||||
|
||||
input.insertText(normalizedText)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.getLayoutNode().markDirty()
|
||||
renderer.requestRender()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
async function pasteAttachment(file: { filename?: string; filepath?: string; content: string; mime: string }) {
|
||||
const currentOffset = input.visualCursor.offset
|
||||
const extmarkStart = currentOffset
|
||||
@@ -1117,7 +1356,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
|
||||
@@ -1206,30 +1445,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()}
|
||||
@@ -1249,94 +1465,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
|
||||
@@ -1358,7 +1503,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
|
||||
}
|
||||
|
||||
@@ -1366,67 +1511,11 @@ export function Prompt(props: PromptProps) {
|
||||
// default paste unless we suppress it first and handle insertion ourselves.
|
||||
event.preventDefault()
|
||||
|
||||
const filepath = iife(() => {
|
||||
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
|
||||
if (raw.startsWith("file://")) {
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
} catch {}
|
||||
}
|
||||
if (process.platform === "win32") return raw
|
||||
return raw.replace(/\\(.)/g, "$1")
|
||||
})
|
||||
const isUrl = /^(https?):\/\//.test(filepath)
|
||||
if (!isUrl) {
|
||||
try {
|
||||
const mime = await Filesystem.mimeType(filepath)
|
||||
const filename = path.basename(filepath)
|
||||
// Handle SVG as raw text content, not as base64 image
|
||||
if (mime === "image/svg+xml") {
|
||||
const content = await Filesystem.readText(filepath).catch(() => {})
|
||||
if (content) {
|
||||
pasteText(content, `[SVG: ${filename ?? "image"}]`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (mime.startsWith("image/") || mime === "application/pdf") {
|
||||
const content = await Filesystem.readArrayBuffer(filepath)
|
||||
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
||||
.catch(() => {})
|
||||
if (content) {
|
||||
await pasteAttachment({
|
||||
filename,
|
||||
filepath,
|
||||
mime,
|
||||
content,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (
|
||||
(lineCount >= 3 || pastedContent.length > 150) &&
|
||||
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
|
||||
) {
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
}
|
||||
|
||||
input.insertText(normalizedText)
|
||||
|
||||
// Force layout update and render for the pasted content
|
||||
setTimeout(() => {
|
||||
// setTimeout is a workaround and needs to be addressed properly
|
||||
if (!input || input.isDestroyed) return
|
||||
input.getLayoutNode().markDirty()
|
||||
renderer.requestRender()
|
||||
}, 0)
|
||||
await pasteInputText(normalizedText)
|
||||
}}
|
||||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
setInputTarget(r)
|
||||
if (promptPartTypeId === 0) {
|
||||
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
||||
}
|
||||
@@ -1455,7 +1544,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>
|
||||
@@ -1646,12 +1735,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"}>
|
||||
@@ -1664,6 +1753,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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface PromptTraitsInput {
|
||||
autocompleteVisible: boolean
|
||||
}
|
||||
|
||||
export type PromptTraits = EditorTraits & {
|
||||
owner: "opencode"
|
||||
role: "prompt"
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the textarea editor traits for the prompt.
|
||||
*
|
||||
@@ -16,7 +21,7 @@ export interface PromptTraitsInput {
|
||||
* editing mode — only `disabled` should suspend the textarea, otherwise
|
||||
* users can type in shell mode but cannot delete or move the cursor.
|
||||
*/
|
||||
export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
|
||||
export function computePromptTraits(input: PromptTraitsInput): PromptTraits {
|
||||
const capture =
|
||||
input.mode === "normal"
|
||||
? input.autocompleteVisible
|
||||
@@ -27,5 +32,7 @@ export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
|
||||
capture,
|
||||
suspend: input.disabled,
|
||||
status: input.mode === "shell" ? "SHELL" : undefined,
|
||||
owner: "opencode",
|
||||
role: "prompt",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import type { KeyBinding } from "@opentui/core"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
|
||||
const TEXTAREA_ACTIONS = [
|
||||
"submit",
|
||||
"newline",
|
||||
"move-left",
|
||||
"move-right",
|
||||
"move-up",
|
||||
"move-down",
|
||||
"select-left",
|
||||
"select-right",
|
||||
"select-up",
|
||||
"select-down",
|
||||
"line-home",
|
||||
"line-end",
|
||||
"select-line-home",
|
||||
"select-line-end",
|
||||
"visual-line-home",
|
||||
"visual-line-end",
|
||||
"select-visual-line-home",
|
||||
"select-visual-line-end",
|
||||
"buffer-home",
|
||||
"buffer-end",
|
||||
"select-buffer-home",
|
||||
"select-buffer-end",
|
||||
"delete-line",
|
||||
"delete-to-line-end",
|
||||
"delete-to-line-start",
|
||||
"backspace",
|
||||
"delete",
|
||||
"undo",
|
||||
"redo",
|
||||
"word-forward",
|
||||
"word-backward",
|
||||
"select-word-forward",
|
||||
"select-word-backward",
|
||||
"delete-word-forward",
|
||||
"delete-word-backward",
|
||||
] as const
|
||||
|
||||
function mapTextareaKeybindings(
|
||||
keybinds: Record<string, Keybind.Info[]>,
|
||||
action: (typeof TEXTAREA_ACTIONS)[number],
|
||||
): KeyBinding[] {
|
||||
const configKey = `input_${action.replace(/-/g, "_")}`
|
||||
const bindings = keybinds[configKey]
|
||||
if (!bindings) return []
|
||||
return bindings.map((binding) => ({
|
||||
name: binding.name,
|
||||
ctrl: binding.ctrl || undefined,
|
||||
meta: binding.meta || undefined,
|
||||
shift: binding.shift || undefined,
|
||||
super: binding.super || undefined,
|
||||
action,
|
||||
}))
|
||||
}
|
||||
|
||||
export function useTextareaKeybindings() {
|
||||
const keybind = useKeybind()
|
||||
|
||||
return createMemo(() => {
|
||||
const keybinds = keybind.all
|
||||
|
||||
return [
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "return", meta: true, action: "newline" },
|
||||
...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)),
|
||||
] satisfies KeyBinding[]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { Binding } from "@opentui/keymap"
|
||||
import type { BindingValue } from "@opentui/keymap/extras"
|
||||
import { ConfigKeybinds } from "@/config/keybinds"
|
||||
import { type KeymapConfigInput, type KeymapSection } from "./tui-schema"
|
||||
|
||||
type LegacyKeybinds = Partial<ConfigKeybinds.Keybinds>
|
||||
type SectionsConfig = Record<string, Record<string, BindingValue<Renderable, KeyEvent>>>
|
||||
|
||||
const inputCommands = {
|
||||
input_submit: "input.submit",
|
||||
input_newline: "input.newline",
|
||||
input_move_left: "input.move.left",
|
||||
input_move_right: "input.move.right",
|
||||
input_move_up: "input.move.up",
|
||||
input_move_down: "input.move.down",
|
||||
input_select_left: "input.select.left",
|
||||
input_select_right: "input.select.right",
|
||||
input_select_up: "input.select.up",
|
||||
input_select_down: "input.select.down",
|
||||
input_line_home: "input.line.home",
|
||||
input_line_end: "input.line.end",
|
||||
input_select_line_home: "input.select.line.home",
|
||||
input_select_line_end: "input.select.line.end",
|
||||
input_visual_line_home: "input.visual.line.home",
|
||||
input_visual_line_end: "input.visual.line.end",
|
||||
input_select_visual_line_home: "input.select.visual.line.home",
|
||||
input_select_visual_line_end: "input.select.visual.line.end",
|
||||
input_buffer_home: "input.buffer.home",
|
||||
input_buffer_end: "input.buffer.end",
|
||||
input_select_buffer_home: "input.select.buffer.home",
|
||||
input_select_buffer_end: "input.select.buffer.end",
|
||||
input_delete_line: "input.delete.line",
|
||||
input_delete_to_line_end: "input.delete.to.line.end",
|
||||
input_delete_to_line_start: "input.delete.to.line.start",
|
||||
input_backspace: "input.backspace",
|
||||
input_delete: "input.delete",
|
||||
input_undo: "input.undo",
|
||||
input_redo: "input.redo",
|
||||
input_word_forward: "input.word.forward",
|
||||
input_word_backward: "input.word.backward",
|
||||
input_select_word_forward: "input.select.word.forward",
|
||||
input_select_word_backward: "input.select.word.backward",
|
||||
input_delete_word_forward: "input.delete.word.forward",
|
||||
input_delete_word_backward: "input.delete.word.backward",
|
||||
input_select_all: "input.select.all",
|
||||
} as const satisfies Partial<Record<keyof LegacyKeybinds, string>>
|
||||
|
||||
function add(config: SectionsConfig, section: KeymapSection, command: string, binding: BindingValue<Renderable, KeyEvent> | undefined) {
|
||||
if (binding === undefined) return
|
||||
config[section] ??= {}
|
||||
config[section][command] = binding
|
||||
}
|
||||
|
||||
function bindingWith(key: string | undefined, input: Omit<Binding<Renderable, KeyEvent>, "key" | "cmd">) {
|
||||
if (!key) return undefined
|
||||
if (key === "none") return "none"
|
||||
return { ...input, key }
|
||||
}
|
||||
|
||||
function combineBindings(...keys: (string | undefined)[]) {
|
||||
const result = Array.from(
|
||||
new Set(
|
||||
keys.flatMap((key) => {
|
||||
if (!key || key === "none") return []
|
||||
return key
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part && part !== "none")
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (result.length) return result.join(",")
|
||||
if (keys.some((key) => key === "none")) return "none"
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function create(keybinds: LegacyKeybinds): KeymapConfigInput {
|
||||
const config: SectionsConfig = {}
|
||||
|
||||
add(config, "global", "command.palette.show", keybinds.command_list)
|
||||
add(config, "global", "session.list", keybinds.session_list)
|
||||
add(config, "global", "session.new", keybinds.session_new)
|
||||
add(config, "global", "model.list", keybinds.model_list)
|
||||
add(config, "global", "model.cycle_recent", keybinds.model_cycle_recent)
|
||||
add(config, "global", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse)
|
||||
add(config, "global", "model.cycle_favorite", keybinds.model_cycle_favorite)
|
||||
add(config, "global", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse)
|
||||
add(config, "global", "agent.list", keybinds.agent_list)
|
||||
add(config, "global", "agent.cycle", keybinds.agent_cycle)
|
||||
add(config, "global", "agent.cycle.reverse", keybinds.agent_cycle_reverse)
|
||||
add(config, "global", "variant.cycle", keybinds.variant_cycle)
|
||||
add(config, "global", "variant.list", keybinds.variant_list)
|
||||
add(config, "prompt", "prompt.editor", keybinds.editor_open)
|
||||
add(config, "global", "opencode.status", keybinds.status_view)
|
||||
add(config, "global", "theme.switch", keybinds.theme_list)
|
||||
add(config, "global", "app.exit", keybinds.app_exit)
|
||||
add(config, "global", "terminal.suspend", keybinds.terminal_suspend)
|
||||
add(config, "global", "terminal.title.toggle", keybinds.terminal_title_toggle)
|
||||
|
||||
add(config, "session", "session.share", keybinds.session_share)
|
||||
add(config, "session", "session.rename", keybinds.session_rename)
|
||||
add(config, "session", "session.timeline", keybinds.session_timeline)
|
||||
add(config, "session", "session.fork", keybinds.session_fork)
|
||||
add(config, "session", "session.compact", keybinds.session_compact)
|
||||
add(config, "session", "session.unshare", keybinds.session_unshare)
|
||||
add(config, "session", "session.undo", keybinds.messages_undo)
|
||||
add(config, "session", "session.redo", keybinds.messages_redo)
|
||||
add(config, "session", "session.sidebar.toggle", keybinds.sidebar_toggle)
|
||||
add(config, "session", "session.toggle.conceal", keybinds.messages_toggle_conceal)
|
||||
add(config, "session", "session.toggle.thinking", keybinds.display_thinking)
|
||||
add(config, "session", "session.toggle.actions", keybinds.tool_details)
|
||||
add(config, "session", "session.toggle.scrollbar", keybinds.scrollbar_toggle)
|
||||
add(config, "session", "session.page.up", keybinds.messages_page_up)
|
||||
add(config, "session", "session.page.down", keybinds.messages_page_down)
|
||||
add(config, "session", "session.line.up", keybinds.messages_line_up)
|
||||
add(config, "session", "session.line.down", keybinds.messages_line_down)
|
||||
add(config, "session", "session.half.page.up", keybinds.messages_half_page_up)
|
||||
add(config, "session", "session.half.page.down", keybinds.messages_half_page_down)
|
||||
add(config, "session", "session.first", keybinds.messages_first)
|
||||
add(config, "session", "session.last", keybinds.messages_last)
|
||||
add(config, "session", "session.messages_last_user", keybinds.messages_last_user)
|
||||
add(config, "session", "session.message.next", keybinds.messages_next)
|
||||
add(config, "session", "session.message.previous", keybinds.messages_previous)
|
||||
add(config, "session", "messages.copy", keybinds.messages_copy)
|
||||
add(config, "session", "session.export", keybinds.session_export)
|
||||
add(config, "session", "session.child.first", keybinds.session_child_first)
|
||||
add(config, "session", "session.parent", keybinds.session_parent)
|
||||
add(config, "session", "session.child.next", keybinds.session_child_cycle)
|
||||
add(config, "session", "session.child.previous", keybinds.session_child_cycle_reverse)
|
||||
|
||||
add(config, "prompt", "session.interrupt", keybinds.session_interrupt)
|
||||
add(config, "prompt", "prompt.clear", keybinds.input_clear)
|
||||
add(config, "prompt", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false }))
|
||||
add(config, "prompt", "prompt.history.previous", keybinds.history_previous)
|
||||
add(config, "prompt", "prompt.history.next", keybinds.history_next)
|
||||
|
||||
add(config, "autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"])
|
||||
add(config, "autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"])
|
||||
add(config, "autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"])
|
||||
add(config, "autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"])
|
||||
add(config, "autocomplete", "prompt.autocomplete.complete", keybinds["prompt.autocomplete.complete"])
|
||||
|
||||
for (const [legacy, command] of Object.entries(inputCommands) as [keyof typeof inputCommands, string][]) {
|
||||
add(config, "input", command, keybinds[legacy])
|
||||
}
|
||||
|
||||
add(config, "dialog_select", "dialog.select.prev", keybinds["dialog.select.prev"])
|
||||
add(config, "dialog_select", "dialog.select.next", keybinds["dialog.select.next"])
|
||||
add(config, "dialog_select", "dialog.select.page_up", keybinds["dialog.select.page_up"])
|
||||
add(config, "dialog_select", "dialog.select.page_down", keybinds["dialog.select.page_down"])
|
||||
add(config, "dialog_select", "dialog.select.home", keybinds["dialog.select.home"])
|
||||
add(config, "dialog_select", "dialog.select.end", keybinds["dialog.select.end"])
|
||||
add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"])
|
||||
add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete))
|
||||
add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename)
|
||||
add(config, "dialog_actions", "dialog.action.toggle", combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"]))
|
||||
add(config, "model", "model.dialog.provider", keybinds.model_provider_list)
|
||||
add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle)
|
||||
|
||||
add(config, "permission", "permission.reject.cancel", keybinds.app_exit)
|
||||
add(config, "permission", "permission.prompt.escape", keybinds.app_exit)
|
||||
add(config, "permission", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"])
|
||||
add(config, "question", "question.reject", keybinds.app_exit)
|
||||
add(config, "question", "question.edit.clear", keybinds.input_clear)
|
||||
|
||||
add(config, "plugins", "plugins.list", keybinds.plugin_manager)
|
||||
add(config, "plugins", "plugin.dialog.install", keybinds["dialog.plugins.install"])
|
||||
add(config, "home_tips", "tips.toggle", keybinds.tips_toggle)
|
||||
|
||||
return {
|
||||
...(keybinds.leader && keybinds.leader !== "none" && { leader: keybinds.leader }),
|
||||
sections: config,
|
||||
}
|
||||
}
|
||||
|
||||
export * as LegacyKeymapTransform from "./legacy-keymap-transform"
|
||||
@@ -1,4 +1,7 @@
|
||||
import z from "zod"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { Binding } from "@opentui/keymap"
|
||||
import type { ResolvedBindingSections } from "@opentui/keymap/extras"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { ConfigKeybinds } from "@/config/keybinds"
|
||||
|
||||
@@ -11,6 +14,303 @@ const KeybindOverride = z
|
||||
)
|
||||
.strict()
|
||||
|
||||
const KeyStroke = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
ctrl: z.boolean().optional(),
|
||||
shift: z.boolean().optional(),
|
||||
meta: z.boolean().optional(),
|
||||
super: z.boolean().optional(),
|
||||
hyper: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
const KeymapBindingObject = z
|
||||
.object({
|
||||
key: z.union([z.string(), KeyStroke]),
|
||||
event: z.enum(["press", "release"]).optional(),
|
||||
preventDefault: z.boolean().optional(),
|
||||
fallthrough: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const KeymapBindingItem = z.union([z.string(), KeyStroke, KeymapBindingObject])
|
||||
const KeymapBindingValue = z.union([z.literal(false), z.literal("none"), KeymapBindingItem, z.array(KeymapBindingItem)])
|
||||
|
||||
const keymapBinding = (value: z.input<typeof KeymapBindingValue> | (() => z.input<typeof KeymapBindingValue>)) =>
|
||||
KeymapBindingValue.prefault(value)
|
||||
const keymapSection = <Shape extends z.ZodRawShape>(shape: Shape) => {
|
||||
const schema = z.object(shape).strict()
|
||||
return schema.prefault({} as z.input<typeof schema>)
|
||||
}
|
||||
const keymapSectionInput = <Shape extends z.ZodRawShape>(shape: Shape) =>
|
||||
z
|
||||
.object(
|
||||
Object.fromEntries(Object.keys(shape).map((key) => [key, KeymapBindingValue.optional()])) as {
|
||||
[Key in keyof Shape]: z.ZodOptional<typeof KeymapBindingValue>
|
||||
},
|
||||
)
|
||||
.strict()
|
||||
|
||||
const GlobalKeymapSection = {
|
||||
"command.palette.show": keymapBinding("ctrl+p"),
|
||||
"session.list": keymapBinding("<leader>l"),
|
||||
"session.new": keymapBinding("<leader>n"),
|
||||
"model.list": keymapBinding("<leader>m"),
|
||||
"model.cycle_recent": keymapBinding("f2"),
|
||||
"model.cycle_recent_reverse": keymapBinding("shift+f2"),
|
||||
"model.cycle_favorite": keymapBinding("none"),
|
||||
"model.cycle_favorite_reverse": keymapBinding("none"),
|
||||
"agent.list": keymapBinding("<leader>a"),
|
||||
"mcp.list": keymapBinding("none"),
|
||||
"agent.cycle": keymapBinding("tab"),
|
||||
"agent.cycle.reverse": keymapBinding("shift+tab"),
|
||||
"variant.cycle": keymapBinding("ctrl+t"),
|
||||
"variant.list": keymapBinding("none"),
|
||||
"provider.connect": keymapBinding("none"),
|
||||
"console.org.switch": keymapBinding("none"),
|
||||
"opencode.status": keymapBinding("<leader>s"),
|
||||
"theme.switch": keymapBinding("<leader>t"),
|
||||
"theme.switch_mode": keymapBinding("none"),
|
||||
"theme.mode.lock": keymapBinding("none"),
|
||||
"help.show": keymapBinding("none"),
|
||||
"docs.open": keymapBinding("none"),
|
||||
"app.exit": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
|
||||
"app.debug": keymapBinding("none"),
|
||||
"app.console": keymapBinding("none"),
|
||||
"app.heap_snapshot": keymapBinding("none"),
|
||||
"app.toggle.animations": keymapBinding("none"),
|
||||
"app.toggle.file_context": keymapBinding("none"),
|
||||
"app.toggle.diffwrap": keymapBinding("none"),
|
||||
"app.toggle.paste_summary": keymapBinding("none"),
|
||||
"app.toggle.session_directory_filter": keymapBinding("none"),
|
||||
"terminal.suspend": keymapBinding(() => (process.platform === "win32" ? "none" : "ctrl+z")),
|
||||
"terminal.title.toggle": keymapBinding("none"),
|
||||
}
|
||||
|
||||
const SessionKeymapSection = {
|
||||
"session.share": keymapBinding("none"),
|
||||
"session.rename": keymapBinding("ctrl+r"),
|
||||
"session.timeline": keymapBinding("<leader>g"),
|
||||
"session.fork": keymapBinding("none"),
|
||||
"session.compact": keymapBinding("<leader>c"),
|
||||
"session.unshare": keymapBinding("none"),
|
||||
"session.undo": keymapBinding("<leader>u"),
|
||||
"session.redo": keymapBinding("<leader>r"),
|
||||
"session.sidebar.toggle": keymapBinding("<leader>b"),
|
||||
"session.toggle.conceal": keymapBinding("<leader>h"),
|
||||
"session.toggle.timestamps": keymapBinding("none"),
|
||||
"session.toggle.thinking": keymapBinding("none"),
|
||||
"session.toggle.actions": keymapBinding("none"),
|
||||
"session.toggle.scrollbar": keymapBinding("none"),
|
||||
"session.toggle.generic_tool_output": keymapBinding("none"),
|
||||
"session.page.up": keymapBinding("pageup,ctrl+alt+b"),
|
||||
"session.page.down": keymapBinding("pagedown,ctrl+alt+f"),
|
||||
"session.line.up": keymapBinding("ctrl+alt+y"),
|
||||
"session.line.down": keymapBinding("ctrl+alt+e"),
|
||||
"session.half.page.up": keymapBinding("ctrl+alt+u"),
|
||||
"session.half.page.down": keymapBinding("ctrl+alt+d"),
|
||||
"session.first": keymapBinding("ctrl+g,home"),
|
||||
"session.last": keymapBinding("ctrl+alt+g,end"),
|
||||
"session.messages_last_user": keymapBinding("none"),
|
||||
"session.message.next": keymapBinding("none"),
|
||||
"session.message.previous": keymapBinding("none"),
|
||||
"messages.copy": keymapBinding("<leader>y"),
|
||||
"session.copy": keymapBinding("none"),
|
||||
"session.export": keymapBinding("<leader>x"),
|
||||
"session.child.first": keymapBinding("<leader>down"),
|
||||
"session.parent": keymapBinding("up"),
|
||||
"session.child.next": keymapBinding("right"),
|
||||
"session.child.previous": keymapBinding("left"),
|
||||
}
|
||||
|
||||
const PromptKeymapSection = {
|
||||
"prompt.submit": keymapBinding("none"),
|
||||
"prompt.editor": keymapBinding("<leader>e"),
|
||||
"prompt.editor_context.clear": keymapBinding("none"),
|
||||
"prompt.skills": keymapBinding("none"),
|
||||
"prompt.stash": keymapBinding("none"),
|
||||
"prompt.stash.pop": keymapBinding("none"),
|
||||
"prompt.stash.list": keymapBinding("none"),
|
||||
"workspace.set": keymapBinding("none"),
|
||||
"session.interrupt": keymapBinding("escape"),
|
||||
"prompt.clear": keymapBinding("ctrl+c"),
|
||||
"prompt.paste": keymapBinding({ key: "ctrl+v", preventDefault: false }),
|
||||
"prompt.history.previous": keymapBinding("up"),
|
||||
"prompt.history.next": keymapBinding("down"),
|
||||
}
|
||||
|
||||
const AutocompleteKeymapSection = {
|
||||
"prompt.autocomplete.prev": keymapBinding("up,ctrl+p"),
|
||||
"prompt.autocomplete.next": keymapBinding("down,ctrl+n"),
|
||||
"prompt.autocomplete.hide": keymapBinding("escape"),
|
||||
"prompt.autocomplete.select": keymapBinding("return"),
|
||||
"prompt.autocomplete.complete": keymapBinding("tab"),
|
||||
}
|
||||
|
||||
const InputKeymapSection = {
|
||||
"input.submit": keymapBinding("return"),
|
||||
"input.newline": keymapBinding("shift+return,ctrl+return,alt+return,ctrl+j"),
|
||||
"input.move.left": keymapBinding("left,ctrl+b"),
|
||||
"input.move.right": keymapBinding("right,ctrl+f"),
|
||||
"input.move.up": keymapBinding("up"),
|
||||
"input.move.down": keymapBinding("down"),
|
||||
"input.select.left": keymapBinding("shift+left"),
|
||||
"input.select.right": keymapBinding("shift+right"),
|
||||
"input.select.up": keymapBinding("shift+up"),
|
||||
"input.select.down": keymapBinding("shift+down"),
|
||||
"input.line.home": keymapBinding("ctrl+a"),
|
||||
"input.line.end": keymapBinding("ctrl+e"),
|
||||
"input.select.line.home": keymapBinding("ctrl+shift+a"),
|
||||
"input.select.line.end": keymapBinding("ctrl+shift+e"),
|
||||
"input.visual.line.home": keymapBinding("alt+a"),
|
||||
"input.visual.line.end": keymapBinding("alt+e"),
|
||||
"input.select.visual.line.home": keymapBinding("alt+shift+a"),
|
||||
"input.select.visual.line.end": keymapBinding("alt+shift+e"),
|
||||
"input.buffer.home": keymapBinding("home"),
|
||||
"input.buffer.end": keymapBinding("end"),
|
||||
"input.select.buffer.home": keymapBinding("shift+home"),
|
||||
"input.select.buffer.end": keymapBinding("shift+end"),
|
||||
"input.delete.line": keymapBinding("ctrl+shift+d"),
|
||||
"input.delete.to.line.end": keymapBinding("ctrl+k"),
|
||||
"input.delete.to.line.start": keymapBinding("ctrl+u"),
|
||||
"input.backspace": keymapBinding("backspace,shift+backspace"),
|
||||
"input.delete": keymapBinding("ctrl+d,delete,shift+delete"),
|
||||
"input.undo": keymapBinding(() => (process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")),
|
||||
"input.redo": keymapBinding("ctrl+.,super+shift+z"),
|
||||
"input.word.forward": keymapBinding("alt+f,alt+right,ctrl+right"),
|
||||
"input.word.backward": keymapBinding("alt+b,alt+left,ctrl+left"),
|
||||
"input.select.word.forward": keymapBinding("alt+shift+f,alt+shift+right"),
|
||||
"input.select.word.backward": keymapBinding("alt+shift+b,alt+shift+left"),
|
||||
"input.delete.word.forward": keymapBinding("alt+d,alt+delete,ctrl+delete"),
|
||||
"input.delete.word.backward": keymapBinding("ctrl+w,ctrl+backspace,alt+backspace"),
|
||||
"input.select.all": keymapBinding("super+a"),
|
||||
}
|
||||
|
||||
const DialogSelectKeymapSection = {
|
||||
"dialog.select.prev": keymapBinding("up,ctrl+p"),
|
||||
"dialog.select.next": keymapBinding("down,ctrl+n"),
|
||||
"dialog.select.page_up": keymapBinding("pageup"),
|
||||
"dialog.select.page_down": keymapBinding("pagedown"),
|
||||
"dialog.select.home": keymapBinding("home"),
|
||||
"dialog.select.end": keymapBinding("end"),
|
||||
"dialog.select.submit": keymapBinding("return"),
|
||||
}
|
||||
|
||||
const DialogActionsKeymapSection = {
|
||||
"dialog.action.toggle": keymapBinding("space"),
|
||||
"dialog.action.delete": keymapBinding("ctrl+d"),
|
||||
"dialog.action.rename": keymapBinding("ctrl+r"),
|
||||
}
|
||||
|
||||
const ModelKeymapSection = {
|
||||
"model.dialog.provider": keymapBinding("ctrl+a"),
|
||||
"model.dialog.favorite": keymapBinding("ctrl+f"),
|
||||
}
|
||||
|
||||
const PermissionKeymapSection = {
|
||||
"permission.reject.cancel": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
|
||||
"permission.prompt.escape": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
|
||||
"permission.prompt.fullscreen": keymapBinding("ctrl+f"),
|
||||
}
|
||||
|
||||
const QuestionKeymapSection = {
|
||||
"question.reject": keymapBinding("ctrl+c,ctrl+d,<leader>q"),
|
||||
"question.edit.clear": keymapBinding("ctrl+c"),
|
||||
}
|
||||
|
||||
const PluginsKeymapSection = {
|
||||
"plugins.list": keymapBinding("none"),
|
||||
"plugins.install": keymapBinding("none"),
|
||||
"plugin.dialog.install": keymapBinding("shift+i"),
|
||||
}
|
||||
|
||||
const HomeTipsKeymapSection = {
|
||||
"tips.toggle": keymapBinding("<leader>h"),
|
||||
}
|
||||
|
||||
const KeymapSectionsShape = {
|
||||
global: keymapSection(GlobalKeymapSection),
|
||||
session: keymapSection(SessionKeymapSection),
|
||||
prompt: keymapSection(PromptKeymapSection),
|
||||
autocomplete: keymapSection(AutocompleteKeymapSection),
|
||||
input: keymapSection(InputKeymapSection),
|
||||
dialog_select: keymapSection(DialogSelectKeymapSection),
|
||||
dialog_actions: keymapSection(DialogActionsKeymapSection),
|
||||
model: keymapSection(ModelKeymapSection),
|
||||
permission: keymapSection(PermissionKeymapSection),
|
||||
question: keymapSection(QuestionKeymapSection),
|
||||
plugins: keymapSection(PluginsKeymapSection),
|
||||
home_tips: keymapSection(HomeTipsKeymapSection),
|
||||
}
|
||||
|
||||
const KeymapSectionsInputShape = {
|
||||
global: keymapSectionInput(GlobalKeymapSection).optional(),
|
||||
session: keymapSectionInput(SessionKeymapSection).optional(),
|
||||
prompt: keymapSectionInput(PromptKeymapSection).optional(),
|
||||
autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(),
|
||||
input: keymapSectionInput(InputKeymapSection).optional(),
|
||||
dialog_select: keymapSectionInput(DialogSelectKeymapSection).optional(),
|
||||
dialog_actions: keymapSectionInput(DialogActionsKeymapSection).optional(),
|
||||
model: keymapSectionInput(ModelKeymapSection).optional(),
|
||||
permission: keymapSectionInput(PermissionKeymapSection).optional(),
|
||||
question: keymapSectionInput(QuestionKeymapSection).optional(),
|
||||
plugins: keymapSectionInput(PluginsKeymapSection).optional(),
|
||||
home_tips: keymapSectionInput(HomeTipsKeymapSection).optional(),
|
||||
}
|
||||
|
||||
export const KeymapSections = z.object(KeymapSectionsShape).strict().prefault({})
|
||||
export type KeymapSections = z.output<typeof KeymapSections>
|
||||
export type KeymapSection = keyof KeymapSections
|
||||
export const KeymapSectionNames = Object.keys(KeymapSectionsShape) as KeymapSection[]
|
||||
export const KeymapLeaderTimeoutDefault = 2000
|
||||
export type KeymapInfo = {
|
||||
leader: string
|
||||
leader_timeout: number
|
||||
} & ResolvedBindingSections<Renderable, KeyEvent, KeymapSection>
|
||||
|
||||
export const KeymapSectionGroups = {
|
||||
global: "Global",
|
||||
session: "Session",
|
||||
prompt: "Prompt",
|
||||
autocomplete: "Autocomplete",
|
||||
input: "Text Editing",
|
||||
dialog_select: "Dialog",
|
||||
dialog_actions: "Dialog",
|
||||
model: "Model",
|
||||
permission: "Permission",
|
||||
question: "Question",
|
||||
plugins: "Plugins",
|
||||
home_tips: "Home",
|
||||
} satisfies Record<KeymapSection, string>
|
||||
|
||||
export function keymapBindingDefaults(input: { section: string; binding: Readonly<Binding<Renderable, KeyEvent>> }) {
|
||||
if (input.binding.group !== undefined) return
|
||||
if (!Object.hasOwn(KeymapSectionGroups, input.section)) return
|
||||
return { group: KeymapSectionGroups[input.section as KeymapSection] }
|
||||
}
|
||||
|
||||
export const KeymapConfig = z
|
||||
.object({
|
||||
leader: z.string().prefault("ctrl+x"),
|
||||
leader_timeout: z.number().int().positive().prefault(KeymapLeaderTimeoutDefault).describe("Leader key timeout in milliseconds"),
|
||||
sections: KeymapSections,
|
||||
})
|
||||
.strict()
|
||||
.describe("TUI keymap configuration")
|
||||
export type KeymapConfig = z.output<typeof KeymapConfig>
|
||||
|
||||
const KeymapSectionsInput = z.object(KeymapSectionsInputShape).strict().optional()
|
||||
export const KeymapConfigInput = z
|
||||
.object({
|
||||
leader: z.string().optional(),
|
||||
leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"),
|
||||
sections: KeymapSectionsInput,
|
||||
})
|
||||
.strict()
|
||||
.describe("TUI keymap configuration")
|
||||
export type KeymapConfigInput = z.output<typeof KeymapConfigInput>
|
||||
|
||||
export const TuiOptions = z.object({
|
||||
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
|
||||
scroll_acceleration: z
|
||||
@@ -30,9 +330,17 @@ 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: KeymapConfigInput.optional(),
|
||||
plugin: ConfigPlugin.Spec.zod.array().optional(),
|
||||
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
||||
})
|
||||
.extend(TuiOptions.shape)
|
||||
.strict()
|
||||
|
||||
export const TuiJsonSchemaInfo = TuiInfo.extend({
|
||||
keymap: KeymapConfig.optional(),
|
||||
}).strict()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
export * as TuiConfig from "./tui"
|
||||
|
||||
import z from "zod"
|
||||
import type z from "zod"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Context, Effect, Fiber, Layer } from "effect"
|
||||
import { ConfigParse } from "@/config/parse"
|
||||
import * as ConfigPaths from "@/config/paths"
|
||||
import { migrateTuiConfig } from "./tui-migrate"
|
||||
import { TuiInfo } from "./tui-schema"
|
||||
import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -20,27 +22,34 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { LegacyKeymapTransform } from "./legacy-keymap-transform"
|
||||
import {
|
||||
KeymapSectionNames,
|
||||
keymapBindingDefaults,
|
||||
type KeymapInfo,
|
||||
type KeymapSection,
|
||||
} from "./tui-schema"
|
||||
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
export const Info = TuiInfo
|
||||
export const JsonSchemaInfo = TuiJsonSchemaInfo
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
type Acc = {
|
||||
result: Info
|
||||
plugin_origins: ConfigPlugin.Origin[]
|
||||
}
|
||||
|
||||
type State = {
|
||||
config: Info
|
||||
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
||||
}
|
||||
|
||||
export type Info = z.output<typeof Info> & {
|
||||
export type Resolved = Omit<Info, "keybinds" | "keymap"> & {
|
||||
keybinds: ConfigKeybinds.Keybinds
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -128,11 +137,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...acc.plugin_origins,
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
])
|
||||
acc.result.plugin = plugins.map((item) => item.spec)
|
||||
acc.result.plugin_origins = plugins
|
||||
acc.plugin_origins = plugins
|
||||
})
|
||||
|
||||
// Every config dir we may read from: global config dir, any `.opencode`
|
||||
@@ -144,6 +153,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
plugin_origins: [],
|
||||
}
|
||||
|
||||
// 1. Global tui config (lowest precedence).
|
||||
@@ -184,11 +194,33 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
|
||||
]).join(",")
|
||||
}
|
||||
acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
||||
const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
||||
const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {})
|
||||
const keymapConfig = KeymapConfig.parse(keymapInput)
|
||||
const keymap = {
|
||||
leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader,
|
||||
leader_timeout: keymapConfig.leader_timeout,
|
||||
...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
|
||||
keymapConfig.sections,
|
||||
{
|
||||
sections: KeymapSectionNames,
|
||||
bindingDefaults: keymapBindingDefaults,
|
||||
},
|
||||
),
|
||||
}
|
||||
const result: Resolved = {
|
||||
...acc.result,
|
||||
keybinds: parsedKeybinds,
|
||||
plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined,
|
||||
// `keybinds` is deprecated and will be removed in opencode v2.0. Keep it
|
||||
// only as the legacy fallback; once `keymap` is configured, ignore
|
||||
// `keybinds` for keymap resolution.
|
||||
keymap,
|
||||
}
|
||||
|
||||
return {
|
||||
config: acc.result,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
config: result,
|
||||
dirs: result.plugin?.length ? dirs : [],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
163
packages/opencode/src/cli/cmd/tui/context/command-palette.tsx
Normal file
163
packages/opencode/src/cli/cmd/tui/context/command-palette.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { createContext, createMemo, createSignal, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||
import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
||||
import {
|
||||
formatKeyBindings,
|
||||
reactiveMatcherFromSignal,
|
||||
type OpenTuiKeymap,
|
||||
useKeymapSelector,
|
||||
useOpencodeKeymap,
|
||||
} from "../keymap"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
|
||||
type SlashEntry = {
|
||||
display: string
|
||||
description?: string
|
||||
aliases?: string[]
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
type CommandPaletteContext = {
|
||||
run(command: string): void
|
||||
show(): void
|
||||
slashes: Accessor<readonly SlashEntry[]>
|
||||
suspend(enabled: boolean): void
|
||||
readonly suspended: boolean
|
||||
matcher: ReturnType<typeof reactiveMatcherFromSignal>
|
||||
}
|
||||
|
||||
const COMMAND_PALETTE_DIALOG = "command.palette.show"
|
||||
const ctx = createContext<CommandPaletteContext>()
|
||||
type PaletteCommandEntry = ReturnType<OpenTuiKeymap["getCommandEntries"]>[number]
|
||||
|
||||
function isVisiblePaletteCommand(entry: PaletteCommandEntry) {
|
||||
return entry.command.hidden !== true && entry.command.name !== COMMAND_PALETTE_DIALOG
|
||||
}
|
||||
|
||||
function isSuggestedPaletteCommand(entry: PaletteCommandEntry) {
|
||||
const suggested = entry.command.suggested
|
||||
if (typeof suggested === "boolean") return suggested
|
||||
if (typeof suggested === "function") return suggested() === true
|
||||
return false
|
||||
}
|
||||
|
||||
export function CommandPaletteProvider(props: ParentProps) {
|
||||
const dialog = useDialog()
|
||||
const keymap = useOpencodeKeymap()
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
|
||||
keymap
|
||||
.getCommandEntries({
|
||||
visibility: "reachable",
|
||||
namespace: "palette",
|
||||
})
|
||||
.filter(isVisiblePaletteCommand),
|
||||
)
|
||||
|
||||
const run = (command: string) => {
|
||||
keymap.dispatchCommand(command)
|
||||
}
|
||||
|
||||
const slashes = createMemo<SlashEntry[]>(() =>
|
||||
entries().flatMap((entry) => {
|
||||
const slashName = entry.command.slashName
|
||||
if (typeof slashName !== "string" || !slashName) return []
|
||||
const slashAliases = entry.command.slashAliases
|
||||
return {
|
||||
display: `/${slashName}`,
|
||||
description:
|
||||
typeof entry.command.desc === "string"
|
||||
? entry.command.desc
|
||||
: typeof entry.command.title === "string"
|
||||
? entry.command.title
|
||||
: undefined,
|
||||
aliases: Array.isArray(slashAliases)
|
||||
? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
|
||||
: undefined,
|
||||
onSelect: () => run(entry.command.name),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const value: CommandPaletteContext = {
|
||||
run,
|
||||
show() {
|
||||
dialog.replace(() => <CommandPaletteDialog run={run} />)
|
||||
},
|
||||
slashes,
|
||||
suspend(enabled: boolean) {
|
||||
setSuspendCount((count) => Math.max(0, count + (enabled ? 1 : -1)))
|
||||
},
|
||||
get suspended() {
|
||||
return suspendCount() > 0 || dialog.stack.length > 0
|
||||
},
|
||||
matcher: reactiveMatcherFromSignal(() => suspendCount() === 0 && dialog.stack.length === 0),
|
||||
}
|
||||
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useCommandPalette() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) throw new Error("CommandPalette context must be used within a CommandPaletteProvider")
|
||||
return value
|
||||
}
|
||||
|
||||
function CommandPaletteDialog(props: { run(command: string): void }) {
|
||||
const config = useTuiConfig()
|
||||
const entries = useKeymapSelector((keymap: OpenTuiKeymap) => {
|
||||
const query = {
|
||||
namespace: "palette",
|
||||
}
|
||||
const reachable = keymap
|
||||
.getCommandEntries({
|
||||
...query,
|
||||
visibility: "reachable",
|
||||
})
|
||||
.filter(isVisiblePaletteCommand)
|
||||
const registeredBindings = keymap.getCommandBindings({
|
||||
visibility: "registered",
|
||||
commands: reachable.map((entry) => entry.command.name),
|
||||
})
|
||||
|
||||
return reachable.map((entry) => ({
|
||||
...entry,
|
||||
bindings: registeredBindings.get(entry.command.name) ?? entry.bindings,
|
||||
}))
|
||||
})
|
||||
const options = createMemo(() =>
|
||||
entries().map((entry) => ({
|
||||
title: typeof entry.command.title === "string" ? entry.command.title : entry.command.name,
|
||||
description: typeof entry.command.desc === "string" ? entry.command.desc : undefined,
|
||||
category: typeof entry.command.category === "string" ? entry.command.category : undefined,
|
||||
footer: formatKeyBindings(entry.bindings, config),
|
||||
value: entry.command.name,
|
||||
suggested: isSuggestedPaletteCommand(entry),
|
||||
onSelect: (dialog: DialogContext) => {
|
||||
dialog.clear()
|
||||
props.run(entry.command.name)
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
let ref: DialogSelectRef<string>
|
||||
const list = () => {
|
||||
if (ref?.filter) return options()
|
||||
return [
|
||||
...options()
|
||||
.filter((option) => option.suggested)
|
||||
.map((option) => ({
|
||||
...option,
|
||||
value: `suggested:${option.value}`,
|
||||
category: "Suggested",
|
||||
})),
|
||||
...options(),
|
||||
]
|
||||
}
|
||||
|
||||
return <DialogSelect ref={(value) => (ref = value)} title="Commands" options={list()} />
|
||||
}
|
||||
|
||||
export function useCommandSlashes(): Accessor<readonly SlashEntry[]> {
|
||||
return useCommandPalette().slashes
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { pipe, mapValues } from "remeda"
|
||||
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
|
||||
export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
|
||||
|
||||
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
|
||||
name: "Keybind",
|
||||
init: () => {
|
||||
const config = useTuiConfig()
|
||||
const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
|
||||
return pipe(
|
||||
(config.keybinds ?? {}) as Record<string, string>,
|
||||
mapValues((value) => Keybind.parse(value)),
|
||||
)
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
leader: false,
|
||||
})
|
||||
const renderer = useRenderer()
|
||||
|
||||
let focus: Renderable | null
|
||||
let timeout: NodeJS.Timeout
|
||||
function leader(active: boolean) {
|
||||
if (active) {
|
||||
setStore("leader", true)
|
||||
focus = renderer.currentFocusedRenderable
|
||||
focus?.blur()
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
if (!store.leader) return
|
||||
leader(false)
|
||||
if (!focus || focus.isDestroyed) return
|
||||
focus.focus()
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
if (focus && !renderer.currentFocusedRenderable) {
|
||||
focus.focus()
|
||||
}
|
||||
setStore("leader", false)
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(async (evt) => {
|
||||
if (!store.leader && result.match("leader", evt)) {
|
||||
leader(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.leader && evt.name) {
|
||||
setImmediate(() => {
|
||||
if (focus && renderer.currentFocusedRenderable === focus) {
|
||||
focus.focus()
|
||||
}
|
||||
leader(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
get all() {
|
||||
return keybinds()
|
||||
},
|
||||
get leader() {
|
||||
return store.leader
|
||||
},
|
||||
parse(evt: ParsedKey): Keybind.Info {
|
||||
// Handle special case for Ctrl+Underscore (represented as \x1F)
|
||||
if (evt.name === "\x1F") {
|
||||
return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader)
|
||||
}
|
||||
return Keybind.fromParsedKey(evt, store.leader)
|
||||
},
|
||||
match(key: string, evt: ParsedKey) {
|
||||
const list = keybinds()[key] ?? Keybind.parse(key)
|
||||
if (!list.length) return false
|
||||
const parsed: Keybind.Info = result.parse(evt)
|
||||
for (const item of list) {
|
||||
if (Keybind.match(item, parsed)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
print(key: string) {
|
||||
const first = keybinds()[key]?.at(0) ?? Keybind.parse(key).at(0)
|
||||
if (!first) return ""
|
||||
const text = Keybind.toString(first)
|
||||
const lead = keybinds().leader?.[0]
|
||||
if (!lead) return text
|
||||
return text.replace("<leader>", Keybind.toString(lead))
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
|
||||
export type PluginKeybindMap = Record<string, string>
|
||||
|
||||
type Base = {
|
||||
match: (key: string, evt: ParsedKey) => boolean
|
||||
print: (key: string) => string
|
||||
}
|
||||
|
||||
export type PluginKeybind = {
|
||||
readonly all: PluginKeybindMap
|
||||
get: (name: string) => string
|
||||
match: (name: string, evt: ParsedKey) => boolean
|
||||
print: (name: string) => string
|
||||
}
|
||||
|
||||
const txt = (value: unknown) => {
|
||||
if (typeof value !== "string") return
|
||||
if (!value.trim()) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function createPluginKeybind(
|
||||
base: Base,
|
||||
defaults: PluginKeybindMap,
|
||||
overrides?: Record<string, unknown>,
|
||||
): PluginKeybind {
|
||||
const all = Object.freeze(
|
||||
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
|
||||
)
|
||||
const get = (name: string) => all[name] ?? name
|
||||
|
||||
return {
|
||||
get all() {
|
||||
return all
|
||||
},
|
||||
get,
|
||||
match: (name, evt) => base.match(get(name), evt),
|
||||
print: (name) => base.print(get(name)),
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
||||
name: "TuiConfig",
|
||||
init: (props: { config: TuiConfig.Info }) => {
|
||||
init: (props: { config: TuiConfig.Resolved }) => {
|
||||
return props.config
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Tips } from "./tips-view"
|
||||
import { useBindings } from "../../keymap"
|
||||
|
||||
const id = "internal:home-tips"
|
||||
|
||||
function View(props: { show: boolean; connected: boolean }) {
|
||||
function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connected: boolean }) {
|
||||
useBindings(() => ({
|
||||
commands: [
|
||||
{
|
||||
name: "tips.toggle",
|
||||
title: props.hidden ? "Show tips" : "Hide tips",
|
||||
category: "System",
|
||||
namespace: "palette",
|
||||
run() {
|
||||
props.api.kv.set("tips_hidden", !props.api.kv.get("tips_hidden", false))
|
||||
props.api.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: props.api.tuiConfig.keymap.sections.home_tips,
|
||||
}))
|
||||
|
||||
return (
|
||||
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
|
||||
<Show when={props.show}>
|
||||
@@ -15,20 +32,6 @@ 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.slots.register({
|
||||
order: 100,
|
||||
slots: {
|
||||
@@ -41,7 +44,7 @@ const tui: TuiPlugin = async (api) => {
|
||||
),
|
||||
)
|
||||
const show = createMemo(() => (!first() || !connected()) && !hidden())
|
||||
return <View show={show()} connected={connected()} />
|
||||
return <View api={api} hidden={hidden()} show={show()} connected={connected()} />
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiPluginStatus } from "@opencode-ai/plugin/tui"
|
||||
import { 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>
|
||||
)}
|
||||
@@ -209,10 +203,10 @@ function View(props: { api: TuiPluginApi }) {
|
||||
options={rows()}
|
||||
current={cur()}
|
||||
onMove={(item) => setCur(item.value)}
|
||||
keybind={[
|
||||
actions={[
|
||||
{
|
||||
title: "toggle",
|
||||
keybind: key,
|
||||
command: "dialog.action.toggle",
|
||||
disabled: lock(),
|
||||
onTrigger: (item) => {
|
||||
setCur(item.value)
|
||||
@@ -221,13 +215,14 @@ function View(props: { api: TuiPluginApi }) {
|
||||
},
|
||||
{
|
||||
title: "install",
|
||||
keybind: add,
|
||||
command: "plugin.dialog.install",
|
||||
disabled: lock(),
|
||||
onTrigger: () => {
|
||||
showInstall(props.api)
|
||||
},
|
||||
},
|
||||
]}
|
||||
bindings={props.api.tuiConfig.keymap.pick("plugins", ["plugin.dialog.install"])}
|
||||
onSelect={(item) => {
|
||||
setCur(item.value)
|
||||
flip(item.value)
|
||||
@@ -241,25 +236,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.omit("plugins", ["plugin.dialog.install"]),
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
|
||||
@@ -4,8 +4,9 @@ import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
|
||||
import { useBindings } from "../../keymap"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import path from "path"
|
||||
@@ -53,12 +54,16 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
|
||||
void sync.session.message.sync(props.sessionID)
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.name !== "escape") return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.api.route.navigate("session", { sessionID: props.sessionID })
|
||||
})
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "escape",
|
||||
cmd() {
|
||||
props.api.route.navigate("session", { sessionID: props.sessionID })
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
|
||||
@@ -1113,21 +1118,24 @@ const tui: TuiPlugin = async (api) => {
|
||||
},
|
||||
])
|
||||
|
||||
api.command.register(() => [
|
||||
{
|
||||
title: "View v2 session messages",
|
||||
value: route,
|
||||
category: "Debug",
|
||||
suggested: api.route.current.name === "session",
|
||||
enabled: api.route.current.name === "session",
|
||||
onSelect() {
|
||||
const sessionID = currentSessionID(api)
|
||||
if (!sessionID) return
|
||||
api.route.navigate(route, { sessionID })
|
||||
api.ui.dialog.clear()
|
||||
api.keymap.registerLayer({
|
||||
commands: [
|
||||
{
|
||||
name: route,
|
||||
title: "View v2 session messages",
|
||||
category: "Debug",
|
||||
namespace: "palette",
|
||||
suggested: () => api.route.current.name === "session",
|
||||
enabled: () => api.route.current.name === "session",
|
||||
run() {
|
||||
const sessionID = currentSessionID(api)
|
||||
if (!sessionID) return
|
||||
api.route.navigate(route, { sessionID })
|
||||
api.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: TuiPluginModule & { id: string } = {
|
||||
|
||||
91
packages/opencode/src/cli/cmd/tui/keymap.tsx
Normal file
91
packages/opencode/src/cli/cmd/tui/keymap.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { type CliRenderer } from "@opentui/core"
|
||||
import * as addons from "@opentui/keymap/addons/opentui"
|
||||
import {
|
||||
formatCommandBindings as formatCommandBindingsExtra,
|
||||
formatKeySequence as formatKeySequenceExtra,
|
||||
} from "@opentui/keymap/extras"
|
||||
import {
|
||||
KeymapProvider,
|
||||
reactiveMatcherFromSignal,
|
||||
useBindings,
|
||||
useKeymap,
|
||||
useKeymapSelector,
|
||||
} from "@opentui/keymap/solid"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { TuiConfig } from "./config/tui"
|
||||
import { useTuiConfig } from "./context/tui-config"
|
||||
|
||||
export const LEADER_TOKEN = "leader"
|
||||
|
||||
export const OpencodeKeymapProvider = KeymapProvider
|
||||
export const useOpencodeKeymap = useKeymap
|
||||
|
||||
export { reactiveMatcherFromSignal, useBindings, useKeymapSelector }
|
||||
|
||||
export type OpenTuiKeymap = ReturnType<typeof useKeymap>
|
||||
|
||||
function formatOptions(config: TuiConfig.Resolved) {
|
||||
return {
|
||||
tokenDisplay: {
|
||||
[LEADER_TOKEN]: config.keymap.leader,
|
||||
},
|
||||
keyNameAliases: {
|
||||
pageup: "pgup",
|
||||
pagedown: "pgdn",
|
||||
delete: "del",
|
||||
},
|
||||
modifierAliases: {
|
||||
meta: "alt",
|
||||
},
|
||||
} as const
|
||||
}
|
||||
|
||||
export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: TuiConfig.Resolved) {
|
||||
return formatKeySequenceExtra(parts, formatOptions(config))
|
||||
}
|
||||
|
||||
export function formatKeyBindings(
|
||||
bindings: Parameters<typeof formatCommandBindingsExtra>[0],
|
||||
config: TuiConfig.Resolved,
|
||||
) {
|
||||
return formatCommandBindingsExtra(bindings, formatOptions(config))
|
||||
}
|
||||
|
||||
export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) {
|
||||
const offCommaBindings = addons.registerCommaBindings(keymap)
|
||||
const offBaseLayout = addons.registerBaseLayoutFallback(keymap)
|
||||
const offLeader = addons.registerTimedLeader(keymap, {
|
||||
trigger: config.keymap.leader,
|
||||
name: LEADER_TOKEN,
|
||||
timeoutMs: config.keymap.leader_timeout,
|
||||
})
|
||||
const offEscape = addons.registerEscapeClearsPendingSequence(keymap)
|
||||
const offBackspace = addons.registerBackspacePopsPendingSequence(keymap)
|
||||
const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, {
|
||||
enabled: () => renderer.currentFocusedEditor !== null,
|
||||
bindings: config.keymap.sections.input,
|
||||
})
|
||||
|
||||
return () => {
|
||||
offInputBindings()
|
||||
offBackspace()
|
||||
offEscape()
|
||||
offLeader()
|
||||
offBaseLayout()
|
||||
offCommaBindings()
|
||||
}
|
||||
}
|
||||
|
||||
export function useCommandShortcut(command: string): Accessor<string> {
|
||||
const config = useTuiConfig()
|
||||
return useKeymapSelector((keymap) =>
|
||||
formatKeySequence(
|
||||
keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence,
|
||||
config,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function useLeaderActive(): Accessor<boolean> {
|
||||
return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||
import type { 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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -70,6 +73,36 @@ type PluginEntry = {
|
||||
scope?: PluginScope
|
||||
}
|
||||
|
||||
const ScopedKeymapMethods = new Set<PropertyKey>([
|
||||
"acquireResource",
|
||||
"registerLayer",
|
||||
"registerLayerFields",
|
||||
"prependLayerBindingsTransformer",
|
||||
"appendLayerBindingsTransformer",
|
||||
"prependBindingTransformer",
|
||||
"appendBindingTransformer",
|
||||
"prependBindingParser",
|
||||
"appendBindingParser",
|
||||
"registerToken",
|
||||
"registerSequencePattern",
|
||||
"prependBindingExpander",
|
||||
"appendBindingExpander",
|
||||
"registerBindingFields",
|
||||
"registerCommandFields",
|
||||
"prependCommandTransformer",
|
||||
"appendCommandTransformer",
|
||||
"prependCommandResolver",
|
||||
"appendCommandResolver",
|
||||
"prependLayerAnalyzer",
|
||||
"appendLayerAnalyzer",
|
||||
"intercept",
|
||||
"on",
|
||||
"prependEventMatchResolver",
|
||||
"appendEventMatchResolver",
|
||||
"prependDisambiguationResolver",
|
||||
"appendDisambiguationResolver",
|
||||
])
|
||||
|
||||
type RuntimeState = {
|
||||
directory: string
|
||||
api: Api
|
||||
@@ -104,6 +137,25 @@ function warn(message: string, data: Record<string, unknown>) {
|
||||
console.warn(`[tui.plugin] ${message}`, data)
|
||||
}
|
||||
|
||||
function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): TuiPluginApi["keymap"] {
|
||||
const cache = new Map<PropertyKey, unknown>()
|
||||
return new Proxy(keymap, {
|
||||
get(target, prop) {
|
||||
const value = Reflect.get(target, prop, target)
|
||||
if (typeof value !== "function") return value
|
||||
if (cache.has(prop)) return cache.get(prop)
|
||||
const fn = ScopedKeymapMethods.has(prop)
|
||||
? (...args: unknown[]) => {
|
||||
const dispose = (value as (...args: unknown[]) => unknown).apply(target, args)
|
||||
return scope.track(typeof dispose === "function" ? (dispose as () => void) : undefined)
|
||||
}
|
||||
: (...args: unknown[]) => (value as (...args: unknown[]) => unknown).apply(target, args)
|
||||
cache.set(prop, fn)
|
||||
return fn
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
||||
|
||||
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
||||
@@ -327,14 +379,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"] = {
|
||||
@@ -395,7 +449,7 @@ function readPluginEnabledMap(value: unknown) {
|
||||
)
|
||||
}
|
||||
|
||||
function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
|
||||
function pluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
|
||||
return {
|
||||
...readPluginEnabledMap(config.plugin_enabled),
|
||||
...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
|
||||
@@ -484,17 +538,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 +561,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
|
||||
},
|
||||
}
|
||||
|
||||
const keymap = createScopedKeymap(api.keymap, scope)
|
||||
|
||||
let count = 0
|
||||
|
||||
const slots: TuiPluginApi["slots"] = {
|
||||
@@ -531,10 +576,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,
|
||||
@@ -580,7 +625,7 @@ function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
|
||||
return true
|
||||
}
|
||||
|
||||
function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
|
||||
function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
|
||||
const map = pluginEnabledState(state, config)
|
||||
for (const plugin of state.plugins) {
|
||||
const enabled = map[plugin.id]
|
||||
@@ -923,7 +968,7 @@ let loaded: Promise<void> | undefined
|
||||
let runtime: RuntimeState | undefined
|
||||
export const Slot = View
|
||||
|
||||
export async function init(input: { api: HostPluginApi; config: TuiConfig.Info }) {
|
||||
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) {
|
||||
const cwd = process.cwd()
|
||||
if (loaded) {
|
||||
if (dir !== cwd) {
|
||||
@@ -972,7 +1017,7 @@ export async function dispose() {
|
||||
}
|
||||
}
|
||||
|
||||
async function load(input: { api: Api; config: TuiConfig.Info }) {
|
||||
async function load(input: { api: Api; config: TuiConfig.Resolved }) {
|
||||
const { api, config } = input
|
||||
const cwd = process.cwd()
|
||||
const slots = setupSlots(api)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,27 @@ 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 keymapConfig = tuiConfig.keymap
|
||||
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() },
|
||||
...keymapConfig.pick("permission", ["permission.reject.cancel"]),
|
||||
{ key: "return", cmd: () => props.onConfirm(input.plainText) },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -520,7 +520,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 +544,75 @@ function Prompt<const T extends Record<string, string>>(props: {
|
||||
onSelect: (option: keyof T) => void
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const keymapConfig = tuiConfig.keymap
|
||||
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 ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []),
|
||||
...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []),
|
||||
],
|
||||
}))
|
||||
|
||||
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
|
||||
useRenderer()
|
||||
@@ -661,7 +685,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}>
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
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 keymapConfig = tuiConfig.keymap
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
||||
@@ -122,131 +124,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]
|
||||
},
|
||||
},
|
||||
...keymapConfig.pick("question", ["question.edit.clear"]),
|
||||
{
|
||||
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 +389,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.primary}
|
||||
keyBindings={bindings()}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
@@ -24,13 +31,14 @@ export interface DialogSelectProps<T> {
|
||||
onSelect?: (option: DialogSelectOption<T>) => void
|
||||
skipFilter?: boolean
|
||||
renderFilter?: 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
|
||||
}
|
||||
|
||||
@@ -57,6 +65,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({
|
||||
@@ -81,6 +92,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 || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true)
|
||||
const needle = store.filter.toLowerCase()
|
||||
@@ -171,7 +201,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
|
||||
@@ -192,36 +222,86 @@ 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,
|
||||
...tuiConfig.keymap.pick(
|
||||
"dialog_actions",
|
||||
enabledActions.map((item) => item.command),
|
||||
),
|
||||
...(props.bindings ?? []).filter((binding) => {
|
||||
if (typeof binding.cmd !== "string") return true
|
||||
return enabledActions.some((item) => item.command === binding.cmd)
|
||||
}),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -236,9 +316,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
}
|
||||
props.ref?.(ref)
|
||||
|
||||
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}>
|
||||
@@ -365,7 +449,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}
|
||||
@@ -381,7 +465,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>
|
||||
@@ -393,7 +477,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ export class CustomSpeedScroll implements ScrollAcceleration {
|
||||
reset(): void {}
|
||||
}
|
||||
|
||||
export function getScrollAcceleration(tuiConfig?: TuiConfig.Info): ScrollAcceleration {
|
||||
export function getScrollAcceleration(
|
||||
tuiConfig?: Pick<TuiConfig.Info, "scroll_acceleration" | "scroll_speed">,
|
||||
): ScrollAcceleration {
|
||||
if (tuiConfig?.scroll_acceleration?.enabled) {
|
||||
return new MacOSScrollAccel()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,22 @@ 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.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
|
||||
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
|
||||
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
|
||||
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
|
||||
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
|
||||
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
|
||||
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
|
||||
"plugins.toggle": keybind("space", "Toggle plugin"),
|
||||
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
|
||||
agent_list: keybind("<leader>a", "List agents"),
|
||||
agent_cycle: keybind("tab", "Next agent"),
|
||||
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
|
||||
@@ -101,6 +116,7 @@ const KeybindsSchema = Schema.Struct({
|
||||
input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
|
||||
input_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"),
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { isDeepEqual } from "remeda"
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
|
||||
/**
|
||||
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
|
||||
* This ensures type compatibility and catches missing fields at compile time.
|
||||
*/
|
||||
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
|
||||
leader: boolean // our custom field
|
||||
}
|
||||
|
||||
export function match(a: Info | undefined, b: Info): boolean {
|
||||
if (!a) return false
|
||||
const normalizedA = { ...a, super: a.super ?? false }
|
||||
const normalizedB = { ...b, super: b.super ?? false }
|
||||
return isDeepEqual(normalizedA, normalizedB)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OpenTUI's ParsedKey to our Keybind.Info format.
|
||||
* This helper ensures all required fields are present and avoids manual object creation.
|
||||
*/
|
||||
export function fromParsedKey(key: ParsedKey, leader = false): Info {
|
||||
return {
|
||||
name: key.name === " " ? "space" : key.name,
|
||||
ctrl: key.ctrl,
|
||||
meta: key.meta,
|
||||
shift: key.shift,
|
||||
super: key.super ?? false,
|
||||
leader,
|
||||
}
|
||||
}
|
||||
|
||||
export function toString(info: Info | undefined): string {
|
||||
if (!info) return ""
|
||||
const parts: string[] = []
|
||||
|
||||
if (info.ctrl) parts.push("ctrl")
|
||||
if (info.meta) parts.push("alt")
|
||||
if (info.super) parts.push("super")
|
||||
if (info.shift) parts.push("shift")
|
||||
if (info.name) {
|
||||
if (info.name === "delete") parts.push("del")
|
||||
else parts.push(info.name)
|
||||
}
|
||||
|
||||
let result = parts.join("+")
|
||||
|
||||
if (info.leader) {
|
||||
result = result ? `<leader> ${result}` : `<leader>`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function parse(key: string): Info[] {
|
||||
if (key === "none") return []
|
||||
|
||||
return key.split(",").map((combo) => {
|
||||
// Handle <leader> syntax by replacing with leader+
|
||||
const normalized = combo.replace(/<leader>/g, "leader+")
|
||||
const parts = normalized.toLowerCase().split("+")
|
||||
const info: Info = {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
leader: false,
|
||||
name: "",
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
switch (part) {
|
||||
case "ctrl":
|
||||
info.ctrl = true
|
||||
break
|
||||
case "alt":
|
||||
case "meta":
|
||||
case "option":
|
||||
info.meta = true
|
||||
break
|
||||
case "super":
|
||||
info.super = true
|
||||
break
|
||||
case "shift":
|
||||
info.shift = true
|
||||
break
|
||||
case "leader":
|
||||
info.leader = true
|
||||
break
|
||||
case "esc":
|
||||
info.name = "escape"
|
||||
break
|
||||
default:
|
||||
info.name = part
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
})
|
||||
}
|
||||
|
||||
export * as Keybind from "./keybind"
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds"
|
||||
|
||||
describe("createPluginKeybind", () => {
|
||||
const defaults = {
|
||||
open: "ctrl+o",
|
||||
close: "escape",
|
||||
}
|
||||
|
||||
test("uses defaults when overrides are missing", () => {
|
||||
const api = {
|
||||
match: () => false,
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults)
|
||||
|
||||
expect(bind.all).toEqual(defaults)
|
||||
expect(bind.get("open")).toBe("ctrl+o")
|
||||
expect(bind.get("close")).toBe("escape")
|
||||
})
|
||||
|
||||
test("applies valid overrides", () => {
|
||||
const api = {
|
||||
match: () => false,
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
open: "ctrl+alt+o",
|
||||
close: "q",
|
||||
})
|
||||
|
||||
expect(bind.all).toEqual({
|
||||
open: "ctrl+alt+o",
|
||||
close: "q",
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores invalid overrides", () => {
|
||||
const api = {
|
||||
match: () => false,
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
open: " ",
|
||||
close: 1,
|
||||
extra: "ctrl+x",
|
||||
})
|
||||
|
||||
expect(bind.all).toEqual(defaults)
|
||||
expect(bind.get("extra")).toBe("extra")
|
||||
})
|
||||
|
||||
test("resolves names for match", () => {
|
||||
const list: string[] = []
|
||||
const api = {
|
||||
match: (key: string) => {
|
||||
list.push(key)
|
||||
return true
|
||||
},
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
open: "ctrl+shift+o",
|
||||
})
|
||||
|
||||
bind.match("open", { name: "x" } as ParsedKey)
|
||||
bind.match("ctrl+k", { name: "x" } as ParsedKey)
|
||||
|
||||
expect(list).toEqual(["ctrl+shift+o", "ctrl+k"])
|
||||
})
|
||||
|
||||
test("resolves names for print", () => {
|
||||
const list: string[] = []
|
||||
const api = {
|
||||
match: () => false,
|
||||
print: (key: string) => {
|
||||
list.push(key)
|
||||
return `print:${key}`
|
||||
},
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
close: "q",
|
||||
})
|
||||
|
||||
expect(bind.print("close")).toBe("print:q")
|
||||
expect(bind.print("ctrl+p")).toBe("print:ctrl+p")
|
||||
expect(list).toEqual(["q", "ctrl+p"])
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
@@ -31,10 +32,9 @@ test("adds tui plugin at runtime from spec", async () => {
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [],
|
||||
plugin_origins: undefined,
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
@@ -74,10 +74,9 @@ test("retries runtime add for file plugins after dependency wait", async () => {
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [],
|
||||
plugin_origins: undefined,
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
|
||||
await Bun.write(
|
||||
path.join(tmp.extra.mod, "index.ts"),
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
@@ -50,10 +51,9 @@ test("installs plugin without loading it", async () => {
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [],
|
||||
plugin_origins: undefined,
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const api = createTuiPluginApi({
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
@@ -44,7 +45,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_origins: [
|
||||
{
|
||||
@@ -53,7 +54,7 @@ test("loads npm tui plugin from package ./tui export", async () => {
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
@@ -105,7 +106,7 @@ test("does not use npm package exports dot for tui entry", async () => {
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
@@ -114,7 +115,7 @@ test("does not use npm package exports dot for tui entry", async () => {
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
@@ -167,7 +168,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
@@ -176,7 +177,7 @@ test("rejects npm tui export that resolves outside plugin directory", async () =
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
@@ -229,7 +230,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
@@ -238,7 +239,7 @@ test("rejects npm tui plugin that exports server and tui together", async () =>
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
@@ -287,7 +288,7 @@ test("does not use npm package main for tui entry", async () => {
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
@@ -296,7 +297,7 @@ test("does not use npm package main for tui entry", async () => {
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
@@ -352,7 +353,7 @@ test("does not use directory package main for tui entry", async () => {
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
@@ -361,7 +362,7 @@ test("does not use directory package main for tui entry", async () => {
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
@@ -399,7 +400,7 @@ test("uses directory index fallback for tui when package.json is missing", async
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
@@ -408,7 +409,7 @@ test("uses directory index fallback for tui when package.json is missing", async
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
@@ -456,7 +457,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_origins: [
|
||||
{
|
||||
@@ -465,7 +466,7 @@ test("uses npm package name when tui plugin id is omitted", async () => {
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
@@ -37,7 +38,7 @@ test("skips external tui plugins in pure mode", async () => {
|
||||
process.env.OPENCODE_PURE = "1"
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
|
||||
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_origins: [
|
||||
{
|
||||
@@ -46,7 +47,7 @@ test("skips external tui plugins in pure mode", async () => {
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import { beforeAll, describe, expect, spyOn, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { createTestKeymap } from "@opentui/keymap/testing"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -79,7 +81,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 +98,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 +148,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 +356,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,
|
||||
@@ -356,7 +382,7 @@ export default {
|
||||
theme_name: tmp.extra.globalThemeName,
|
||||
}
|
||||
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [
|
||||
[tmp.extra.localSpec, localOpts],
|
||||
[tmp.extra.invalidSpec, invalidOpts],
|
||||
@@ -373,7 +399,7 @@ export default {
|
||||
source: path.join(Global.Path.config, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
await TuiPluginRuntime.init({
|
||||
api: createTuiPluginApi({
|
||||
@@ -386,9 +412,6 @@ export default {
|
||||
input_submit: "ctrl+enter",
|
||||
},
|
||||
},
|
||||
keybind: {
|
||||
print: (key) => `print:${key}`,
|
||||
},
|
||||
state: {
|
||||
session: {
|
||||
diff(sessionID) {
|
||||
@@ -507,7 +530,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [
|
||||
[tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
|
||||
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
||||
@@ -525,7 +548,7 @@ test("continues loading when a plugin is missing config metadata", async () => {
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
@@ -606,13 +629,13 @@ export default {
|
||||
const b = path.join(tmp.path, "order-b.ts")
|
||||
const aSpec = pathToFileURL(a).href
|
||||
const bSpec = pathToFileURL(b).href
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [aSpec, bSpec],
|
||||
plugin_origins: [
|
||||
{ spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||
{ spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||
],
|
||||
}
|
||||
})
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
|
||||
expect(lines).toEqual(["a-start", "a-end", "b"])
|
||||
@@ -645,7 +668,10 @@ describe("tui.plugin.loader", () => {
|
||||
expect(data.local.key_modal).toBe("ctrl+alt+m")
|
||||
expect(data.local.key_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 +729,227 @@ describe("tui.plugin.loader", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("auto-disposes plugin keymap layers", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "keymap-cleanup-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.keymap.cleanup",
|
||||
tui: async (api) => {
|
||||
api.keymap.registerLayer({
|
||||
commands: [{ name: "demo.keymap.cleanup", run() {} }],
|
||||
bindings: [{ key: "ctrl+g", cmd: "demo.keymap.cleanup" }],
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec }
|
||||
},
|
||||
})
|
||||
|
||||
let command_add = 0
|
||||
let command_drop = 0
|
||||
const keymap = {
|
||||
registerLayer(layer: { commands?: Array<{ name: string }> }) {
|
||||
const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup") ?? false
|
||||
if (tracked) command_add += 1
|
||||
return () => {
|
||||
if (!tracked) return
|
||||
command_drop += 1
|
||||
}
|
||||
},
|
||||
} as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({
|
||||
api: createTuiPluginApi({ keymap }),
|
||||
config: createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(command_add).toBe(1)
|
||||
expect(command_drop).toBe(0)
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
expect(command_drop).toBe(1)
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("plugin keymap proxy preserves real keymap receiver", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "keymap-receiver-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
const marker = path.join(dir, "keymap-receiver.txt")
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.keymap.receiver",
|
||||
tui: async (api) => {
|
||||
api.keymap.setData("demo.receiver", "ok")
|
||||
await Bun.write(${JSON.stringify(marker)}, String(api.keymap.getData("demo.receiver")))
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec, marker }
|
||||
},
|
||||
})
|
||||
|
||||
const harness = createTestKeymap({ defaultKeys: true })
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({
|
||||
api: createTuiPluginApi({
|
||||
keymap: harness.keymap as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"],
|
||||
}),
|
||||
config: createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("ok")
|
||||
expect(harness.keymap.getData("demo.receiver")).toBe("ok")
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
harness.cleanup()
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("auto-disposes plugin keymap transformers", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "keymap-transformer-cleanup-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.keymap.transformer.cleanup",
|
||||
tui: async (api) => {
|
||||
api.keymap.prependLayerBindingsTransformer((bindings) => bindings)
|
||||
api.keymap.appendLayerBindingsTransformer((bindings) => bindings)
|
||||
api.keymap.prependCommandTransformer(() => {})
|
||||
api.keymap.appendCommandTransformer(() => {})
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec }
|
||||
},
|
||||
})
|
||||
|
||||
let add = 0
|
||||
let drop = 0
|
||||
const track = () => {
|
||||
add += 1
|
||||
return () => {
|
||||
drop += 1
|
||||
}
|
||||
}
|
||||
const keymap = {
|
||||
registerLayer: () => () => {},
|
||||
prependLayerBindingsTransformer: track,
|
||||
appendLayerBindingsTransformer: track,
|
||||
prependCommandTransformer: track,
|
||||
appendCommandTransformer: track,
|
||||
} as unknown as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({
|
||||
api: createTuiPluginApi({ keymap }),
|
||||
config: createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(add).toBe(4)
|
||||
expect(drop).toBe(0)
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
expect(drop).toBe(4)
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("manual onDispose for plugin keymap layers stays idempotent", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "keymap-cleanup-manual-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.keymap.cleanup.manual",
|
||||
tui: async (api) => {
|
||||
const off = api.keymap.registerLayer({
|
||||
commands: [{ name: "demo.keymap.cleanup.manual", run() {} }],
|
||||
bindings: [{ key: "ctrl+h", cmd: "demo.keymap.cleanup.manual" }],
|
||||
})
|
||||
api.lifecycle.onDispose(off)
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec }
|
||||
},
|
||||
})
|
||||
|
||||
let command_drop = 0
|
||||
const keymap = {
|
||||
registerLayer(layer: { commands?: Array<{ name: string }> }) {
|
||||
const tracked = layer.commands?.some((item) => item.name === "demo.keymap.cleanup.manual") ?? false
|
||||
return () => {
|
||||
if (!tracked) return
|
||||
command_drop += 1
|
||||
}
|
||||
},
|
||||
} as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["keymap"]
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({
|
||||
api: createTuiPluginApi({ keymap }),
|
||||
config: createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
||||
}),
|
||||
})
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
expect(command_drop).toBe(1)
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("updates installed theme when plugin metadata changes", async () => {
|
||||
await using tmp = await tmpdir<{
|
||||
spec: string
|
||||
@@ -766,16 +1013,17 @@ test("updates installed theme when plugin metadata changes", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
const mkConfig = (): TuiConfig.Info => ({
|
||||
plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
})
|
||||
const mkConfig = () =>
|
||||
createTuiResolvedConfig({
|
||||
plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
@@ -39,7 +40,7 @@ test("toggles plugin runtime state by exported id", async () => {
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_enabled: {
|
||||
"demo.toggle": false,
|
||||
@@ -51,7 +52,7 @@ test("toggles plugin runtime state by exported id", async () => {
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const api = createTuiPluginApi()
|
||||
@@ -116,7 +117,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_enabled: {
|
||||
"demo.startup": false,
|
||||
@@ -128,7 +129,7 @@ test("kv plugin_enabled overrides tui config on startup", async () => {
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const api = createTuiPluginApi()
|
||||
|
||||
@@ -30,6 +30,19 @@ const getTuiConfig = async (directory: string) =>
|
||||
),
|
||||
)
|
||||
|
||||
async function withPlatform<Value>(platform: typeof process.platform, fn: () => Promise<Value>) {
|
||||
const original = Object.getOwnPropertyDescriptor(process, "platform")
|
||||
Object.defineProperty(process, "platform", {
|
||||
...original,
|
||||
value: platform,
|
||||
})
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
if (original) Object.defineProperty(process, "platform", original)
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.OPENCODE_CONFIG
|
||||
delete process.env.OPENCODE_TUI_CONFIG
|
||||
@@ -389,6 +402,98 @@ test("merges keybind overrides across precedence layers", async () => {
|
||||
expect(config.keybinds?.theme_list).toBe("ctrl+k")
|
||||
})
|
||||
|
||||
test("resolves semantic keymap sections", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
keybinds: { command_list: "ctrl+z" },
|
||||
keymap: {
|
||||
sections: {
|
||||
global: { "command.palette.show": "alt+p" },
|
||||
prompt: { "prompt.editor": "ctrl+e" },
|
||||
autocomplete: { "prompt.autocomplete.next": "ctrl+j" },
|
||||
dialog_actions: { "dialog.action.toggle": "ctrl+t" },
|
||||
model: { "model.dialog.favorite": "ctrl+f" },
|
||||
plugins: { "plugin.dialog.install": "shift+i" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
|
||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("<leader>n")
|
||||
expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
|
||||
expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j")
|
||||
expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t")
|
||||
expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f")
|
||||
expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i")
|
||||
expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
|
||||
"plugin.dialog.install",
|
||||
])
|
||||
expect(
|
||||
(config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group,
|
||||
).toBe("Plugins")
|
||||
expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([])
|
||||
})
|
||||
|
||||
test("legacy keybinds transform into semantic keymap sections", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
keybinds: {
|
||||
command_list: "alt+p",
|
||||
editor_open: "ctrl+e",
|
||||
"prompt.autocomplete.next": "ctrl+j",
|
||||
"dialog.mcp.toggle": "ctrl+t",
|
||||
"dialog.plugins.install": "shift+i",
|
||||
plugin_manager: "ctrl+shift+p",
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(Object.keys(config.keymap.sections)).toEqual([
|
||||
"global",
|
||||
"session",
|
||||
"prompt",
|
||||
"autocomplete",
|
||||
"input",
|
||||
"dialog_select",
|
||||
"dialog_actions",
|
||||
"model",
|
||||
"permission",
|
||||
"question",
|
||||
"plugins",
|
||||
"home_tips",
|
||||
])
|
||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p")
|
||||
expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e")
|
||||
expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe("ctrl+j")
|
||||
expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe("ctrl+t")
|
||||
expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a")
|
||||
expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f")
|
||||
expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i")
|
||||
expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugins.list")?.key).toBe("ctrl+shift+p")
|
||||
expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
|
||||
"plugin.dialog.install",
|
||||
])
|
||||
expect(
|
||||
(config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group,
|
||||
).toBe("Plugins")
|
||||
expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([
|
||||
"plugins.list",
|
||||
])
|
||||
})
|
||||
|
||||
wintest("defaults Ctrl+Z to input undo on Windows", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
@@ -419,6 +524,62 @@ wintest("ignores terminal suspend bindings on Windows", async () => {
|
||||
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
||||
})
|
||||
|
||||
test("applies Windows keymap defaults", async () => {
|
||||
await withPlatform("win32", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")).toBeUndefined()
|
||||
expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe(
|
||||
"ctrl+z,ctrl+-,super+z",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps explicit configured keymap terminal suspend binding on Windows", async () => {
|
||||
await withPlatform("win32", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
keymap: {
|
||||
sections: {
|
||||
global: { "terminal.suspend": "alt+z" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z")
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps explicit configured keymap input undo on Windows", async () => {
|
||||
await withPlatform("win32", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "tui.json"),
|
||||
JSON.stringify({
|
||||
keymap: {
|
||||
sections: {
|
||||
input: { "input.undo": "ctrl+y" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const config = await getTuiConfig(tmp.path)
|
||||
expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y")
|
||||
})
|
||||
})
|
||||
|
||||
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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"
|
||||
import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform"
|
||||
import { ConfigKeybinds } from "../../src/config/keybinds"
|
||||
import { createTuiResolvedKeymap } from "./tui-runtime"
|
||||
|
||||
type Count = {
|
||||
event_add: number
|
||||
@@ -84,8 +86,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 +111,15 @@ type Opts = {
|
||||
}
|
||||
}
|
||||
|
||||
function tuiConfig(input?: Partial<HostPluginApi["tuiConfig"]>): HostPluginApi["tuiConfig"] {
|
||||
const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {})
|
||||
return {
|
||||
...input,
|
||||
keybinds,
|
||||
keymap: input?.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input?.keybinds ?? {})),
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
|
||||
const kv: Record<string, unknown> = {}
|
||||
const count = opts.count
|
||||
@@ -128,10 +139,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 +152,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 +187,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 +223,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 +268,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) {
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import { spyOn } from "bun:test"
|
||||
import path from "path"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras"
|
||||
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
|
||||
import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform"
|
||||
import { ConfigKeybinds } from "../../src/config/keybinds"
|
||||
import {
|
||||
KeymapConfig,
|
||||
KeymapSectionNames,
|
||||
keymapBindingDefaults,
|
||||
type KeymapConfigInput,
|
||||
type KeymapSection,
|
||||
} from "../../src/cli/cmd/tui/config/tui-schema"
|
||||
|
||||
type PluginSpec = string | [string, Record<string, unknown>]
|
||||
type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "keymap"> & {
|
||||
keybinds?: TuiConfig.Resolved["keybinds"]
|
||||
keymap?: TuiConfig.Resolved["keymap"]
|
||||
}
|
||||
|
||||
export function createTuiResolvedKeymap(input: KeymapConfigInput): TuiConfig.Resolved["keymap"] {
|
||||
const config = KeymapConfig.parse(input)
|
||||
return {
|
||||
leader: !config.leader || config.leader === "none" ? "ctrl+x" : config.leader,
|
||||
leader_timeout: config.leader_timeout,
|
||||
...resolveBindingSections<Renderable, KeyEvent, BindingSectionsConfig<Renderable, KeyEvent>, KeymapSection>(
|
||||
config.sections,
|
||||
{
|
||||
sections: KeymapSectionNames,
|
||||
bindingDefaults: keymapBindingDefaults,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved {
|
||||
const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({})
|
||||
return {
|
||||
...input,
|
||||
keybinds,
|
||||
keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})),
|
||||
}
|
||||
}
|
||||
|
||||
export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugin_enabled?: Record<string, boolean> }) {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
|
||||
@@ -14,11 +53,11 @@ export function mockTuiRuntime(dir: string, plugin: PluginSpec[], opts?: { plugi
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
|
||||
|
||||
const config: TuiConfig.Info = {
|
||||
const config = createTuiResolvedConfig({
|
||||
plugin,
|
||||
plugin_origins,
|
||||
...(opts?.plugin_enabled && { plugin_enabled: opts.plugin_enabled }),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
config,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -22,19 +22,24 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.2",
|
||||
"@opentui/solid": ">=0.2.2"
|
||||
"@opentui/core": ">=0.2.4",
|
||||
"@opentui/keymap": ">=0.2.4",
|
||||
"@opentui/solid": ">=0.2.4"
|
||||
},
|
||||
"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:",
|
||||
|
||||
@@ -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,14 @@ export type TuiState = {
|
||||
type TuiConfigView = Pick<PluginConfig, "$schema" | "theme" | "keybinds" | "plugin"> &
|
||||
NonNullable<PluginConfig["tui"]> & {
|
||||
plugin_enabled?: Record<string, boolean>
|
||||
keymap: {
|
||||
leader: string
|
||||
leader_timeout: number
|
||||
sections: Record<string, ReadonlyArray<Binding<Renderable, KeyEvent>>>
|
||||
get: (section: string, cmd: string) => ReadonlyArray<Binding<Renderable, KeyEvent>> | undefined
|
||||
pick: (section: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
|
||||
omit: (section: string, commands: readonly string[]) => Binding<Renderable, KeyEvent>[]
|
||||
}
|
||||
}
|
||||
|
||||
export type TuiApp = {
|
||||
@@ -448,11 +457,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 +475,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
|
||||
|
||||
@@ -525,17 +525,27 @@ You can also define commands using markdown files in `~/.config/opencode/command
|
||||
|
||||
---
|
||||
|
||||
### Keybinds
|
||||
### Keymap
|
||||
|
||||
Customize keybinds in `tui.json`.
|
||||
Customize TUI keyboard shortcuts in `tui.json` with `keymap`.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keybinds": {}
|
||||
"keymap": {
|
||||
"sections": {
|
||||
"global": {
|
||||
"command.palette.show": "ctrl+p"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
|
||||
|
||||
The older `keybinds` field is deprecated and only applies when `keymap` is not present.
|
||||
|
||||
[Learn more here](/docs/keybinds).
|
||||
|
||||
---
|
||||
|
||||
@@ -1,144 +1,317 @@
|
||||
---
|
||||
title: Keybinds
|
||||
description: Customize your keybinds.
|
||||
description: Customize your keyboard shortcuts.
|
||||
---
|
||||
|
||||
OpenCode has a list of keybinds that you can customize through `tui.json`.
|
||||
OpenCode customizes TUI keyboard shortcuts with `keymap` in `tui.json`.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keybinds": {
|
||||
"leader": "ctrl+x",
|
||||
"app_exit": "ctrl+c,ctrl+d,<leader>q",
|
||||
"editor_open": "<leader>e",
|
||||
"theme_list": "<leader>t",
|
||||
"sidebar_toggle": "<leader>b",
|
||||
"scrollbar_toggle": "none",
|
||||
"username_toggle": "none",
|
||||
"status_view": "<leader>s",
|
||||
"tool_details": "none",
|
||||
"session_export": "<leader>x",
|
||||
"session_new": "<leader>n",
|
||||
"session_list": "<leader>l",
|
||||
"session_timeline": "<leader>g",
|
||||
"session_fork": "none",
|
||||
"session_rename": "ctrl+r",
|
||||
"session_share": "none",
|
||||
"session_unshare": "none",
|
||||
"session_interrupt": "escape",
|
||||
"session_compact": "<leader>c",
|
||||
"session_child_first": "<leader>down",
|
||||
"session_child_cycle": "right",
|
||||
"session_child_cycle_reverse": "left",
|
||||
"session_parent": "up",
|
||||
"messages_page_up": "pageup,ctrl+alt+b",
|
||||
"messages_page_down": "pagedown,ctrl+alt+f",
|
||||
"messages_line_up": "ctrl+alt+y",
|
||||
"messages_line_down": "ctrl+alt+e",
|
||||
"messages_half_page_up": "ctrl+alt+u",
|
||||
"messages_half_page_down": "ctrl+alt+d",
|
||||
"messages_first": "ctrl+g,home",
|
||||
"messages_last": "ctrl+alt+g,end",
|
||||
"messages_next": "none",
|
||||
"messages_previous": "none",
|
||||
"messages_copy": "<leader>y",
|
||||
"messages_undo": "<leader>u",
|
||||
"messages_redo": "<leader>r",
|
||||
"messages_last_user": "none",
|
||||
"messages_toggle_conceal": "<leader>h",
|
||||
"model_list": "<leader>m",
|
||||
"model_cycle_recent": "f2",
|
||||
"model_cycle_recent_reverse": "shift+f2",
|
||||
"model_cycle_favorite": "none",
|
||||
"model_cycle_favorite_reverse": "none",
|
||||
"variant_cycle": "ctrl+t",
|
||||
"variant_list": "none",
|
||||
"command_list": "ctrl+p",
|
||||
"agent_list": "<leader>a",
|
||||
"agent_cycle": "tab",
|
||||
"agent_cycle_reverse": "shift+tab",
|
||||
"input_clear": "ctrl+c",
|
||||
"input_paste": "ctrl+v",
|
||||
"input_submit": "return",
|
||||
"input_newline": "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
"input_move_left": "left,ctrl+b",
|
||||
"input_move_right": "right,ctrl+f",
|
||||
"input_move_up": "up",
|
||||
"input_move_down": "down",
|
||||
"input_select_left": "shift+left",
|
||||
"input_select_right": "shift+right",
|
||||
"input_select_up": "shift+up",
|
||||
"input_select_down": "shift+down",
|
||||
"input_line_home": "ctrl+a",
|
||||
"input_line_end": "ctrl+e",
|
||||
"input_select_line_home": "ctrl+shift+a",
|
||||
"input_select_line_end": "ctrl+shift+e",
|
||||
"input_visual_line_home": "alt+a",
|
||||
"input_visual_line_end": "alt+e",
|
||||
"input_select_visual_line_home": "alt+shift+a",
|
||||
"input_select_visual_line_end": "alt+shift+e",
|
||||
"input_buffer_home": "home",
|
||||
"input_buffer_end": "end",
|
||||
"input_select_buffer_home": "shift+home",
|
||||
"input_select_buffer_end": "shift+end",
|
||||
"input_delete_line": "ctrl+shift+d",
|
||||
"input_delete_to_line_end": "ctrl+k",
|
||||
"input_delete_to_line_start": "ctrl+u",
|
||||
"input_backspace": "backspace,shift+backspace",
|
||||
"input_delete": "ctrl+d,delete,shift+delete",
|
||||
"input_undo": "ctrl+-,super+z",
|
||||
"input_redo": "ctrl+.,super+shift+z",
|
||||
"input_word_forward": "alt+f,alt+right,ctrl+right",
|
||||
"input_word_backward": "alt+b,alt+left,ctrl+left",
|
||||
"input_select_word_forward": "alt+shift+f,alt+shift+right",
|
||||
"input_select_word_backward": "alt+shift+b,alt+shift+left",
|
||||
"input_delete_word_forward": "alt+d,alt+delete,ctrl+delete",
|
||||
"input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace",
|
||||
"history_previous": "up",
|
||||
"history_next": "down",
|
||||
"terminal_suspend": "ctrl+z",
|
||||
"terminal_title_toggle": "none",
|
||||
"tips_toggle": "<leader>h",
|
||||
"display_thinking": "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
The older `keybinds` field is still accepted as a migration fallback, but it is deprecated and will be removed in OpenCode v2.0. If `keymap` is present, OpenCode ignores `keybinds` for shortcut resolution.
|
||||
|
||||
:::note
|
||||
On Windows, the defaults for `input_undo` and `terminal_suspend` are different:
|
||||
|
||||
- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend).
|
||||
- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend.
|
||||
:::
|
||||
`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
|
||||
|
||||
---
|
||||
|
||||
## Leader key
|
||||
|
||||
OpenCode uses a `leader` key for most keybinds. This avoids conflicts in your terminal.
|
||||
OpenCode uses a `leader` key for many shortcuts. This avoids conflicts in your terminal.
|
||||
|
||||
By default, `ctrl+x` is the leader key and most actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
|
||||
By default, `ctrl+x` is the leader key and leader shortcuts require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
|
||||
|
||||
You don't need to use a leader key for your keybinds but we recommend doing so.
|
||||
|
||||
Some navigation keybinds intentionally do not use the leader key by default. For subagent sessions, the defaults are `session_child_first` = `\<leader>down`, `session_child_cycle` = `right`, `session_child_cycle_reverse` = `left`, and `session_parent` = `up`.
|
||||
You do not need to use a leader key, but we recommend doing so.
|
||||
|
||||
---
|
||||
|
||||
## Disable keybind
|
||||
## Minimal example
|
||||
|
||||
You can disable a keybind by adding the key to `tui.json` with a value of "none".
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keymap": {
|
||||
"leader": "ctrl+x",
|
||||
"leader_timeout": 2000,
|
||||
"sections": {
|
||||
"global": {
|
||||
"command.palette.show": "ctrl+p",
|
||||
"session.new": "<leader>n",
|
||||
"session.list": "<leader>l"
|
||||
},
|
||||
"session": {
|
||||
"session.compact": "<leader>c",
|
||||
"session.undo": "<leader>u",
|
||||
"session.redo": "<leader>r"
|
||||
},
|
||||
"input": {
|
||||
"input.submit": "return",
|
||||
"input.newline": ["shift+return", "ctrl+return", "alt+return", "ctrl+j"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keymap structure
|
||||
|
||||
`keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them.
|
||||
|
||||
| Field | Description |
|
||||
| ----- | ----------- |
|
||||
| `leader` | The key used by `<leader>` sequences. Defaults to `ctrl+x`. |
|
||||
| `leader_timeout` | How long OpenCode waits for the next key after the leader key, in milliseconds. Defaults to `2000`. |
|
||||
| `sections` | A map of TUI areas to command bindings. |
|
||||
|
||||
---
|
||||
|
||||
## Binding values
|
||||
|
||||
A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts, or `"none"`/`false` to disable a command.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keymap": {
|
||||
"sections": {
|
||||
"session": {
|
||||
"session.compact": "none",
|
||||
"session.export": "<leader>x,ctrl+shift+x",
|
||||
"session.copy": ["<leader>y", "ctrl+shift+c"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keymap": {
|
||||
"sections": {
|
||||
"prompt": {
|
||||
"prompt.paste": {
|
||||
"key": "ctrl+v",
|
||||
"preventDefault": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Complete keymap reference
|
||||
|
||||
This example lists the built-in sections, command names, and default fallback bindings. Commands set to `"none"` are available to bind but disabled by default.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keymap": {
|
||||
"leader": "ctrl+x",
|
||||
"leader_timeout": 2000,
|
||||
"sections": {
|
||||
"global": {
|
||||
"command.palette.show": "ctrl+p",
|
||||
"session.list": "<leader>l",
|
||||
"session.new": "<leader>n",
|
||||
"model.list": "<leader>m",
|
||||
"model.cycle_recent": "f2",
|
||||
"model.cycle_recent_reverse": "shift+f2",
|
||||
"model.cycle_favorite": "none",
|
||||
"model.cycle_favorite_reverse": "none",
|
||||
"agent.list": "<leader>a",
|
||||
"mcp.list": "none",
|
||||
"agent.cycle": "tab",
|
||||
"agent.cycle.reverse": "shift+tab",
|
||||
"variant.cycle": "ctrl+t",
|
||||
"variant.list": "none",
|
||||
"provider.connect": "none",
|
||||
"console.org.switch": "none",
|
||||
"opencode.status": "<leader>s",
|
||||
"theme.switch": "<leader>t",
|
||||
"theme.switch_mode": "none",
|
||||
"theme.mode.lock": "none",
|
||||
"help.show": "none",
|
||||
"docs.open": "none",
|
||||
"app.exit": "ctrl+c,ctrl+d,<leader>q",
|
||||
"app.debug": "none",
|
||||
"app.console": "none",
|
||||
"app.heap_snapshot": "none",
|
||||
"app.toggle.animations": "none",
|
||||
"app.toggle.file_context": "none",
|
||||
"app.toggle.diffwrap": "none",
|
||||
"app.toggle.paste_summary": "none",
|
||||
"app.toggle.session_directory_filter": "none",
|
||||
"terminal.suspend": "ctrl+z",
|
||||
"terminal.title.toggle": "none"
|
||||
},
|
||||
"session": {
|
||||
"session.share": "none",
|
||||
"session.rename": "ctrl+r",
|
||||
"session.timeline": "<leader>g",
|
||||
"session.fork": "none",
|
||||
"session.compact": "<leader>c",
|
||||
"session.unshare": "none",
|
||||
"session.undo": "<leader>u",
|
||||
"session.redo": "<leader>r",
|
||||
"session.sidebar.toggle": "<leader>b",
|
||||
"session.toggle.conceal": "<leader>h",
|
||||
"session.toggle.timestamps": "none",
|
||||
"session.toggle.thinking": "none",
|
||||
"session.toggle.actions": "none",
|
||||
"session.toggle.scrollbar": "none",
|
||||
"session.toggle.generic_tool_output": "none",
|
||||
"session.page.up": "pageup,ctrl+alt+b",
|
||||
"session.page.down": "pagedown,ctrl+alt+f",
|
||||
"session.line.up": "ctrl+alt+y",
|
||||
"session.line.down": "ctrl+alt+e",
|
||||
"session.half.page.up": "ctrl+alt+u",
|
||||
"session.half.page.down": "ctrl+alt+d",
|
||||
"session.first": "ctrl+g,home",
|
||||
"session.last": "ctrl+alt+g,end",
|
||||
"session.messages_last_user": "none",
|
||||
"session.message.next": "none",
|
||||
"session.message.previous": "none",
|
||||
"messages.copy": "<leader>y",
|
||||
"session.copy": "none",
|
||||
"session.export": "<leader>x",
|
||||
"session.child.first": "<leader>down",
|
||||
"session.parent": "up",
|
||||
"session.child.next": "right",
|
||||
"session.child.previous": "left"
|
||||
},
|
||||
"prompt": {
|
||||
"prompt.submit": "none",
|
||||
"prompt.editor": "<leader>e",
|
||||
"prompt.editor_context.clear": "none",
|
||||
"prompt.skills": "none",
|
||||
"prompt.stash": "none",
|
||||
"prompt.stash.pop": "none",
|
||||
"prompt.stash.list": "none",
|
||||
"workspace.set": "none",
|
||||
"session.interrupt": "escape",
|
||||
"prompt.clear": "ctrl+c",
|
||||
"prompt.paste": {
|
||||
"key": "ctrl+v",
|
||||
"preventDefault": false
|
||||
},
|
||||
"prompt.history.previous": "up",
|
||||
"prompt.history.next": "down"
|
||||
},
|
||||
"autocomplete": {
|
||||
"prompt.autocomplete.prev": "up,ctrl+p",
|
||||
"prompt.autocomplete.next": "down,ctrl+n",
|
||||
"prompt.autocomplete.hide": "escape",
|
||||
"prompt.autocomplete.select": "return",
|
||||
"prompt.autocomplete.complete": "tab"
|
||||
},
|
||||
"input": {
|
||||
"input.submit": "return",
|
||||
"input.newline": "shift+return,ctrl+return,alt+return,ctrl+j",
|
||||
"input.move.left": "left,ctrl+b",
|
||||
"input.move.right": "right,ctrl+f",
|
||||
"input.move.up": "up",
|
||||
"input.move.down": "down",
|
||||
"input.select.left": "shift+left",
|
||||
"input.select.right": "shift+right",
|
||||
"input.select.up": "shift+up",
|
||||
"input.select.down": "shift+down",
|
||||
"input.line.home": "ctrl+a",
|
||||
"input.line.end": "ctrl+e",
|
||||
"input.select.line.home": "ctrl+shift+a",
|
||||
"input.select.line.end": "ctrl+shift+e",
|
||||
"input.visual.line.home": "alt+a",
|
||||
"input.visual.line.end": "alt+e",
|
||||
"input.select.visual.line.home": "alt+shift+a",
|
||||
"input.select.visual.line.end": "alt+shift+e",
|
||||
"input.buffer.home": "home",
|
||||
"input.buffer.end": "end",
|
||||
"input.select.buffer.home": "shift+home",
|
||||
"input.select.buffer.end": "shift+end",
|
||||
"input.delete.line": "ctrl+shift+d",
|
||||
"input.delete.to.line.end": "ctrl+k",
|
||||
"input.delete.to.line.start": "ctrl+u",
|
||||
"input.backspace": "backspace,shift+backspace",
|
||||
"input.delete": "ctrl+d,delete,shift+delete",
|
||||
"input.undo": "ctrl+-,super+z",
|
||||
"input.redo": "ctrl+.,super+shift+z",
|
||||
"input.word.forward": "alt+f,alt+right,ctrl+right",
|
||||
"input.word.backward": "alt+b,alt+left,ctrl+left",
|
||||
"input.select.word.forward": "alt+shift+f,alt+shift+right",
|
||||
"input.select.word.backward": "alt+shift+b,alt+shift+left",
|
||||
"input.delete.word.forward": "alt+d,alt+delete,ctrl+delete",
|
||||
"input.delete.word.backward": "ctrl+w,ctrl+backspace,alt+backspace",
|
||||
"input.select.all": "super+a"
|
||||
},
|
||||
"dialog_select": {
|
||||
"dialog.select.prev": "up,ctrl+p",
|
||||
"dialog.select.next": "down,ctrl+n",
|
||||
"dialog.select.page_up": "pageup",
|
||||
"dialog.select.page_down": "pagedown",
|
||||
"dialog.select.home": "home",
|
||||
"dialog.select.end": "end",
|
||||
"dialog.select.submit": "return"
|
||||
},
|
||||
"dialog_actions": {
|
||||
"dialog.action.toggle": "space",
|
||||
"dialog.action.delete": "ctrl+d",
|
||||
"dialog.action.rename": "ctrl+r"
|
||||
},
|
||||
"model": {
|
||||
"model.dialog.provider": "ctrl+a",
|
||||
"model.dialog.favorite": "ctrl+f"
|
||||
},
|
||||
"permission": {
|
||||
"permission.reject.cancel": "ctrl+c,ctrl+d,<leader>q",
|
||||
"permission.prompt.escape": "ctrl+c,ctrl+d,<leader>q",
|
||||
"permission.prompt.fullscreen": "ctrl+f"
|
||||
},
|
||||
"question": {
|
||||
"question.reject": "ctrl+c,ctrl+d,<leader>q",
|
||||
"question.edit.clear": "ctrl+c"
|
||||
},
|
||||
"plugins": {
|
||||
"plugins.list": "none",
|
||||
"plugins.install": "none",
|
||||
"plugin.dialog.install": "shift+i"
|
||||
},
|
||||
"home_tips": {
|
||||
"tips.toggle": "<leader>h"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Legacy keybinds
|
||||
|
||||
`keybinds` is deprecated. It is kept so existing configs continue to work while users migrate to `keymap`.
|
||||
|
||||
Only use `keybinds` when `keymap` is not present. If both fields are set, `keymap` wins and `keybinds` are ignored for shortcut resolution.
|
||||
|
||||
```json title="tui.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keybinds": {
|
||||
"session_compact": "none"
|
||||
"command_list": "ctrl+p",
|
||||
"session_new": "<leader>n",
|
||||
"session_compact": "<leader>c"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::note
|
||||
On native Windows, the defaults for undo and terminal suspend are different for both `keymap` and legacy `keybinds`:
|
||||
|
||||
- `input.undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend).
|
||||
- `terminal.suspend` is disabled because native Windows terminals do not support POSIX suspend.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Desktop prompt shortcuts
|
||||
|
||||
@@ -63,7 +63,7 @@ When using the OpenCode TUI, you can type `/` followed by a command name to quic
|
||||
/help
|
||||
```
|
||||
|
||||
Most commands also have keybind using `ctrl+x` as the leader key, where `ctrl+x` is the default leader key. [Learn more](/docs/keybinds).
|
||||
Most commands also have keyboard shortcuts using `ctrl+x` as the default leader key. [Learn more](/docs/keybinds).
|
||||
|
||||
Here are all available slash commands:
|
||||
|
||||
@@ -353,8 +353,14 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"theme": "opencode",
|
||||
"keybinds": {
|
||||
"leader": "ctrl+x"
|
||||
"keymap": {
|
||||
"leader": "ctrl+x",
|
||||
"leader_timeout": 2000,
|
||||
"sections": {
|
||||
"global": {
|
||||
"command.palette.show": "ctrl+p"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scroll_speed": 3,
|
||||
"scroll_acceleration": {
|
||||
@@ -367,10 +373,13 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
|
||||
|
||||
This is separate from `opencode.json`, which configures server/runtime behavior.
|
||||
|
||||
`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change.
|
||||
|
||||
### Options
|
||||
|
||||
- `theme` - Sets your UI theme. [Learn more](/docs/themes).
|
||||
- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
|
||||
- `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
|
||||
- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present.
|
||||
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
|
||||
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
|
||||
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user