mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 14:55:19 +00:00
keybinds
This commit is contained in:
@@ -5,6 +5,22 @@ import { ThreeRenderable, THREE } from "@opentui/core/3d"
|
||||
import type { TuiApi, TuiPluginInput } 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",
|
||||
}
|
||||
|
||||
const pick = (value: unknown, fallback: string) => {
|
||||
if (typeof value !== "string") return fallback
|
||||
@@ -17,13 +33,17 @@ const num = (value: unknown, fallback: number) => {
|
||||
return value
|
||||
}
|
||||
|
||||
const rec = (value: unknown) => {
|
||||
if (!value || typeof value !== "object") return
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
const cfg = (options: Record<string, unknown> | undefined) => {
|
||||
return {
|
||||
label: pick(options?.label, "smoke"),
|
||||
modal: pick(options?.modal, "ctrl+shift+m"),
|
||||
screen: pick(options?.screen, "ctrl+shift+o"),
|
||||
route: pick(options?.route, "workspace-smoke"),
|
||||
vignette: Math.max(0, num(options?.vignette, 0.35)),
|
||||
keybinds: rec(options?.keybinds),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +58,7 @@ const names = (input: ReturnType<typeof cfg>) => {
|
||||
}
|
||||
}
|
||||
|
||||
type Keys = ReturnType<TuiApi["keybind"]["create"]>
|
||||
const ui = {
|
||||
panel: "#1d1d1d",
|
||||
border: "#4a4a4a",
|
||||
@@ -214,6 +235,7 @@ const Screen = (props: {
|
||||
api: TuiApi
|
||||
input: ReturnType<typeof cfg>
|
||||
route: ReturnType<typeof names>
|
||||
keys: Keys
|
||||
params?: Record<string, unknown>
|
||||
}) => {
|
||||
const dim = useTerminalDimensions()
|
||||
@@ -225,70 +247,70 @@ const Screen = (props: {
|
||||
|
||||
const next = current(props.api, props.route)
|
||||
|
||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "h")) {
|
||||
if (props.keys.match("home", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate("home")
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "left" || evt.name === "h") {
|
||||
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 (evt.name === "right" || evt.name === "l") {
|
||||
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 (evt.name === "up" || evt.name === "k") {
|
||||
if (props.keys.match("up", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "down" || evt.name === "j") {
|
||||
if (props.keys.match("down", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.ctrl && evt.name === "m") {
|
||||
if (props.keys.match("modal", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.modal, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "a") {
|
||||
if (props.keys.match("alert", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.alert, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "c") {
|
||||
if (props.keys.match("confirm", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.confirm, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "p") {
|
||||
if (props.keys.match("prompt", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.prompt, next)
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "s") {
|
||||
if (props.keys.match("select", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.select, next)
|
||||
@@ -311,7 +333,7 @@ const Screen = (props: {
|
||||
<b>{props.input.label} screen</b>
|
||||
<span style={{ fg: skin.muted }}> plugin route</span>
|
||||
</text>
|
||||
<text fg={skin.muted}>esc or ctrl+h home</text>
|
||||
<text fg={skin.muted}>{props.keys.print("home")} home</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} paddingBottom={1}>
|
||||
@@ -349,14 +371,19 @@ const Screen = (props: {
|
||||
{value.tab === 1 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.text}>Counter: {value.count}</text>
|
||||
<text fg={skin.muted}>up/down or j/k change value</text>
|
||||
<text fg={skin.muted}>
|
||||
{props.keys.print("up")} / {props.keys.print("down")} change value
|
||||
</text>
|
||||
</box>
|
||||
) : null}
|
||||
|
||||
{value.tab === 2 ? (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={skin.muted}>ctrl+m modal | a alert | c confirm | p prompt | s select</text>
|
||||
<text fg={skin.muted}>esc or ctrl+h returns home</text>
|
||||
<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
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
|
||||
</box>
|
||||
) : null}
|
||||
</box>
|
||||
@@ -378,6 +405,7 @@ const Modal = (props: {
|
||||
api: TuiApi
|
||||
input: ReturnType<typeof cfg>
|
||||
route: ReturnType<typeof names>
|
||||
keys: Keys
|
||||
params?: Record<string, unknown>
|
||||
}) => {
|
||||
const Dialog = props.api.ui.Dialog
|
||||
@@ -387,14 +415,14 @@ const Modal = (props: {
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.modal) return
|
||||
|
||||
if (evt.name === "return" || evt.name === "enter") {
|
||||
if (props.keys.match("modal_accept", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.name === "escape") {
|
||||
if (props.keys.match("modal_close", evt)) {
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate("home")
|
||||
@@ -408,9 +436,11 @@ const Modal = (props: {
|
||||
<text fg={skin.text}>
|
||||
<b>{props.input.label} modal</b>
|
||||
</text>
|
||||
<text fg={skin.muted}>{props.api.keybind.print(props.input.modal)} modal command</text>
|
||||
<text fg={skin.muted}>{props.api.keybind.print(props.input.screen)} screen command</text>
|
||||
<text fg={skin.muted}>enter opens screen · esc closes</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}>
|
||||
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Btn
|
||||
txt="open screen"
|
||||
@@ -426,7 +456,12 @@ const Modal = (props: {
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; params?: Record<string, unknown> }) => {
|
||||
const AlertDialog = (props: {
|
||||
api: TuiApi
|
||||
route: ReturnType<typeof names>
|
||||
keys: Keys
|
||||
params?: Record<string, unknown>
|
||||
}) => {
|
||||
const Dialog = props.api.ui.Dialog
|
||||
const DialogAlert = props.api.ui.DialogAlert
|
||||
const value = parse(props.params)
|
||||
@@ -434,7 +469,7 @@ const AlertDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; para
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.alert) return
|
||||
if (evt.name !== "escape") return
|
||||
if (!props.keys.match("dialog_close", evt)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, value)
|
||||
@@ -453,7 +488,12 @@ const AlertDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; para
|
||||
)
|
||||
}
|
||||
|
||||
const ConfirmDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; params?: Record<string, unknown> }) => {
|
||||
const ConfirmDialog = (props: {
|
||||
api: TuiApi
|
||||
route: ReturnType<typeof names>
|
||||
keys: Keys
|
||||
params?: Record<string, unknown>
|
||||
}) => {
|
||||
const Dialog = props.api.ui.Dialog
|
||||
const DialogConfirm = props.api.ui.DialogConfirm
|
||||
const value = parse(props.params)
|
||||
@@ -461,7 +501,7 @@ const ConfirmDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; pa
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.confirm) return
|
||||
if (evt.name !== "escape") return
|
||||
if (!props.keys.match("dialog_close", evt)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, value)
|
||||
@@ -483,7 +523,12 @@ const ConfirmDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; pa
|
||||
)
|
||||
}
|
||||
|
||||
const PromptDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; params?: Record<string, unknown> }) => {
|
||||
const PromptDialog = (props: {
|
||||
api: TuiApi
|
||||
route: ReturnType<typeof names>
|
||||
keys: Keys
|
||||
params?: Record<string, unknown>
|
||||
}) => {
|
||||
const Dialog = props.api.ui.Dialog
|
||||
const DialogPrompt = props.api.ui.DialogPrompt
|
||||
const value = parse(props.params)
|
||||
@@ -491,7 +536,7 @@ const PromptDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; par
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.prompt) return
|
||||
if (evt.name !== "escape") return
|
||||
if (!props.keys.match("dialog_close", evt)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, value)
|
||||
@@ -512,7 +557,12 @@ const PromptDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; par
|
||||
)
|
||||
}
|
||||
|
||||
const SelectDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; params?: Record<string, unknown> }) => {
|
||||
const SelectDialog = (props: {
|
||||
api: TuiApi
|
||||
route: ReturnType<typeof names>
|
||||
keys: Keys
|
||||
params?: Record<string, unknown>
|
||||
}) => {
|
||||
const Dialog = props.api.ui.Dialog
|
||||
const DialogSelect = props.api.ui.DialogSelect
|
||||
const value = parse(props.params)
|
||||
@@ -537,7 +587,7 @@ const SelectDialog = (props: { api: TuiApi; route: ReturnType<typeof names>; par
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (props.api.route.current.name !== props.route.select) return
|
||||
if (evt.name !== "escape") return
|
||||
if (!props.keys.match("dialog_close", evt)) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
props.api.route.navigate(props.route.screen, value)
|
||||
@@ -629,13 +679,13 @@ const slot = (input: ReturnType<typeof cfg>) => ({
|
||||
},
|
||||
})
|
||||
|
||||
const reg = (api: TuiApi, input: ReturnType<typeof cfg>) => {
|
||||
const reg = (api: TuiApi, input: ReturnType<typeof cfg>, keys: Keys) => {
|
||||
const route = names(input)
|
||||
api.command.register(() => [
|
||||
{
|
||||
title: `${input.label} modal`,
|
||||
value: "plugin.smoke.modal",
|
||||
keybind: input.modal,
|
||||
keybind: keys.get("modal"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke",
|
||||
@@ -647,7 +697,7 @@ const reg = (api: TuiApi, input: ReturnType<typeof cfg>) => {
|
||||
{
|
||||
title: `${input.label} screen`,
|
||||
value: "plugin.smoke.screen",
|
||||
keybind: input.screen,
|
||||
keybind: keys.get("screen"),
|
||||
category: "Plugin",
|
||||
slash: {
|
||||
name: "smoke-screen",
|
||||
@@ -733,37 +783,38 @@ const tui = async (input: TuiPluginInput, options?: Record<string, unknown>) =>
|
||||
|
||||
const value = cfg(options)
|
||||
const route = names(value)
|
||||
const keys = input.api.keybind.create(bind, value.keybinds)
|
||||
const fx = new VignetteEffect(value.vignette)
|
||||
input.renderer.addPostProcessFn(fx.apply.bind(fx))
|
||||
|
||||
input.api.route.register([
|
||||
{
|
||||
name: route.screen,
|
||||
render: ({ params }) => <Screen api={input.api} input={value} route={route} params={params} />,
|
||||
render: ({ params }) => <Screen api={input.api} input={value} route={route} keys={keys} params={params} />,
|
||||
},
|
||||
{
|
||||
name: route.modal,
|
||||
render: ({ params }) => <Modal api={input.api} input={value} route={route} params={params} />,
|
||||
render: ({ params }) => <Modal api={input.api} input={value} route={route} keys={keys} params={params} />,
|
||||
},
|
||||
{
|
||||
name: route.alert,
|
||||
render: ({ params }) => <AlertDialog api={input.api} route={route} params={params} />,
|
||||
render: ({ params }) => <AlertDialog api={input.api} route={route} keys={keys} params={params} />,
|
||||
},
|
||||
{
|
||||
name: route.confirm,
|
||||
render: ({ params }) => <ConfirmDialog api={input.api} route={route} params={params} />,
|
||||
render: ({ params }) => <ConfirmDialog api={input.api} route={route} keys={keys} params={params} />,
|
||||
},
|
||||
{
|
||||
name: route.prompt,
|
||||
render: ({ params }) => <PromptDialog api={input.api} route={route} params={params} />,
|
||||
render: ({ params }) => <PromptDialog api={input.api} route={route} keys={keys} params={params} />,
|
||||
},
|
||||
{
|
||||
name: route.select,
|
||||
render: ({ params }) => <SelectDialog api={input.api} route={route} params={params} />,
|
||||
render: ({ params }) => <SelectDialog api={input.api} route={route} keys={keys} params={params} />,
|
||||
},
|
||||
])
|
||||
|
||||
reg(input.api, value)
|
||||
reg(input.api, value, keys)
|
||||
input.slots.register(slot(value))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
"./plugins/tui-smoke.tsx",
|
||||
{
|
||||
"enabled": true,
|
||||
"label": "workspace"
|
||||
"label": "workspace",
|
||||
"keybinds": {
|
||||
"modal": "ctrl+alt+m",
|
||||
"screen": "ctrl+alt+o",
|
||||
"home": "escape,ctrl+shift+h",
|
||||
"dialog_close": "escape,q"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -387,6 +387,9 @@ function App() {
|
||||
print(key) {
|
||||
return keybind.print(key)
|
||||
},
|
||||
create(defaults, overrides) {
|
||||
return keybind.create(defaults, overrides)
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
get current() {
|
||||
|
||||
44
packages/opencode/src/cli/cmd/tui/context/keybind-plugin.ts
Normal file
44
packages/opencode/src/cli/cmd/tui/context/keybind-plugin.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
|
||||
export type PluginKeybindMap = Record<string, string>
|
||||
|
||||
type Base<Info> = {
|
||||
parse: (evt: ParsedKey) => Info
|
||||
match: (key: string, evt: ParsedKey) => boolean
|
||||
print: (key: string) => string
|
||||
}
|
||||
|
||||
export type PluginKeybind<Info> = {
|
||||
readonly all: PluginKeybindMap
|
||||
get: (name: string) => string
|
||||
parse: (evt: ParsedKey) => Info
|
||||
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<Info>(
|
||||
base: Base<Info>,
|
||||
defaults: PluginKeybindMap,
|
||||
overrides?: Record<string, unknown>,
|
||||
): PluginKeybind<Info> {
|
||||
const all = Object.freeze(
|
||||
Object.fromEntries(Object.entries(defaults).map(([name, value]) => [name, txt(overrides?.[name]) ?? value])),
|
||||
) as PluginKeybindMap
|
||||
const get = (name: string) => all[name] ?? name
|
||||
|
||||
return {
|
||||
get all() {
|
||||
return all
|
||||
},
|
||||
get,
|
||||
parse: (evt) => base.parse(evt),
|
||||
match: (name, evt) => base.match(get(name), evt),
|
||||
print: (name) => base.print(get(name)),
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { ParsedKey, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createPluginKeybind, type PluginKeybindMap } from "./keybind-plugin"
|
||||
import { useTuiConfig } from "./tui-config"
|
||||
|
||||
export type KeybindKey = keyof NonNullable<TuiConfig.Info["keybinds"]> & string
|
||||
@@ -99,6 +100,9 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex
|
||||
if (!lead) return text
|
||||
return text.replace("<leader>", Keybind.toString(lead))
|
||||
},
|
||||
create(defaults: PluginKeybindMap, overrides?: Record<string, unknown>) {
|
||||
return createPluginKeybind(result, defaults, overrides)
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
107
packages/opencode/test/cli/tui/keybind-plugin.test.ts
Normal file
107
packages/opencode/test/cli/tui/keybind-plugin.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/keybind-plugin"
|
||||
|
||||
describe("createPluginKeybind", () => {
|
||||
const defaults = {
|
||||
open: "ctrl+o",
|
||||
close: "escape",
|
||||
}
|
||||
|
||||
test("uses defaults when overrides are missing", () => {
|
||||
const api = {
|
||||
parse: () => "parsed",
|
||||
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 = {
|
||||
parse: () => "parsed",
|
||||
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 = {
|
||||
parse: () => "parsed",
|
||||
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("delegates parse", () => {
|
||||
const evt = { name: "x" } as ParsedKey
|
||||
const api = {
|
||||
parse: (value: ParsedKey) => value,
|
||||
match: () => false,
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults)
|
||||
|
||||
expect(bind.parse(evt)).toBe(evt)
|
||||
})
|
||||
|
||||
test("resolves names for match", () => {
|
||||
const list: string[] = []
|
||||
const api = {
|
||||
parse: () => "parsed",
|
||||
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 = {
|
||||
parse: () => "parsed",
|
||||
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"])
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import type { CliRenderer } from "@opentui/core"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { Log } from "../../../src/util/log"
|
||||
import { Global } from "../../../src/global"
|
||||
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/keybind-plugin"
|
||||
|
||||
mock.module("@opentui/solid/preload", () => ({}))
|
||||
mock.module("@opentui/solid/jsx-runtime", () => ({
|
||||
@@ -35,7 +36,7 @@ async function waitForLog(text: string, timeout = 1000) {
|
||||
.catch(() => "")
|
||||
}
|
||||
|
||||
test("loads plugin theme API with scoped theme installation", async () => {
|
||||
test("loads plugin theme and keybind APIs with scoped theme installation", async () => {
|
||||
const stamp = Date.now()
|
||||
const globalConfigPath = path.join(Global.Path.config, "tui.json")
|
||||
const backup = await Bun.file(globalConfigPath)
|
||||
@@ -80,6 +81,10 @@ test("loads plugin theme API with scoped theme installation", async () => {
|
||||
export const object_plugin = {
|
||||
tui: async (input, options) => {
|
||||
if (!options?.marker) return
|
||||
const key = input.api.keybind.create(
|
||||
{ modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
|
||||
options.keybinds,
|
||||
)
|
||||
const before = input.api.theme.has(options.theme_name)
|
||||
const set_missing = input.api.theme.set(options.theme_name)
|
||||
await input.api.theme.install(options.theme_path)
|
||||
@@ -91,7 +96,18 @@ export const object_plugin = {
|
||||
const second = await Bun.file(options.dest).text()
|
||||
await Bun.write(
|
||||
options.marker,
|
||||
JSON.stringify({ before, set_missing, after, set_installed, selected: input.api.theme.selected, same: first === second }),
|
||||
JSON.stringify({
|
||||
before,
|
||||
set_missing,
|
||||
after,
|
||||
set_installed,
|
||||
selected: input.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"),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -157,6 +173,10 @@ export const object_plugin = {
|
||||
dest: localDest,
|
||||
theme_path: `./${localThemeFile}`,
|
||||
theme_name: localThemeName,
|
||||
keybinds: {
|
||||
modal: "ctrl+alt+m",
|
||||
close: "q",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -202,6 +222,18 @@ export const object_plugin = {
|
||||
return this
|
||||
},
|
||||
} satisfies CliRenderer
|
||||
const keybind = {
|
||||
parse: (evt: { name?: string; ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean }) => ({
|
||||
name: evt.name ?? "",
|
||||
ctrl: evt.ctrl ?? false,
|
||||
meta: evt.meta ?? false,
|
||||
shift: evt.shift ?? false,
|
||||
super: evt.super,
|
||||
leader: false,
|
||||
}),
|
||||
match: () => false,
|
||||
print: (key: string) => `print:${key}`,
|
||||
}
|
||||
|
||||
try {
|
||||
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
|
||||
@@ -235,15 +267,10 @@ export const object_plugin = {
|
||||
toast: () => {},
|
||||
},
|
||||
keybind: {
|
||||
parse: () => ({
|
||||
name: "",
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
leader: false,
|
||||
}),
|
||||
match: () => false,
|
||||
print: () => "",
|
||||
...keybind,
|
||||
create(defaults, overrides) {
|
||||
return createPluginKeybind(keybind, defaults, overrides)
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
get current() {
|
||||
@@ -280,6 +307,10 @@ export const object_plugin = {
|
||||
expect(local.set_installed).toBe(true)
|
||||
expect(local.selected).toBe(tmp.extra.localThemeName)
|
||||
expect(local.same).toBe(true)
|
||||
expect(local.key_modal).toBe("ctrl+alt+m")
|
||||
expect(local.key_close).toBe("q")
|
||||
expect(local.key_unknown).toBe("ctrl+k")
|
||||
expect(local.key_print).toBe("print:ctrl+alt+m")
|
||||
|
||||
const global = JSON.parse(await fs.readFile(tmp.extra.globalMarker, "utf8"))
|
||||
expect(global.has).toBe(true)
|
||||
|
||||
@@ -50,6 +50,16 @@ export type TuiKeybind = {
|
||||
leader: boolean
|
||||
}
|
||||
|
||||
export type TuiKeybindMap = Record<string, string>
|
||||
|
||||
export type TuiKeybindSet = {
|
||||
readonly all: TuiKeybindMap
|
||||
get: (name: string) => string
|
||||
parse: (evt: ParsedKey) => TuiKeybind
|
||||
match: (name: string, evt: ParsedKey) => boolean
|
||||
print: (name: string) => string
|
||||
}
|
||||
|
||||
export type TuiDialogProps<Node = unknown> = {
|
||||
size?: "medium" | "large"
|
||||
onClose: () => void
|
||||
@@ -139,6 +149,7 @@ export type TuiApi<Node = unknown> = {
|
||||
parse: (evt: ParsedKey) => TuiKeybind
|
||||
match: (key: string, evt: ParsedKey) => boolean
|
||||
print: (key: string) => string
|
||||
create: (defaults: TuiKeybindMap, overrides?: Record<string, unknown>) => TuiKeybindSet
|
||||
}
|
||||
theme: TuiTheme
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user