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

This commit is contained in:
Sebastian
2026-05-07 20:35:31 +02:00
committed by GitHub
parent 474e311f6f
commit 98f5e6e713
67 changed files with 3858 additions and 2977 deletions

View File

@@ -1,90 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { ParsedKey } from "@opentui/core"
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds"
describe("createPluginKeybind", () => {
const defaults = {
open: "ctrl+o",
close: "escape",
}
test("uses defaults when overrides are missing", () => {
const api = {
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults)
expect(bind.all).toEqual(defaults)
expect(bind.get("open")).toBe("ctrl+o")
expect(bind.get("close")).toBe("escape")
})
test("applies valid overrides", () => {
const api = {
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: "ctrl+alt+o",
close: "q",
})
expect(bind.all).toEqual({
open: "ctrl+alt+o",
close: "q",
})
})
test("ignores invalid overrides", () => {
const api = {
match: () => false,
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: " ",
close: 1,
extra: "ctrl+x",
})
expect(bind.all).toEqual(defaults)
expect(bind.get("extra")).toBe("extra")
})
test("resolves names for match", () => {
const list: string[] = []
const api = {
match: (key: string) => {
list.push(key)
return true
},
print: (key: string) => key,
}
const bind = createPluginKeybind(api, defaults, {
open: "ctrl+shift+o",
})
bind.match("open", { name: "x" } as ParsedKey)
bind.match("ctrl+k", { name: "x" } as ParsedKey)
expect(list).toEqual(["ctrl+shift+o", "ctrl+k"])
})
test("resolves names for print", () => {
const list: string[] = []
const api = {
match: () => false,
print: (key: string) => {
list.push(key)
return `print:${key}`
},
}
const bind = createPluginKeybind(api, defaults, {
close: "q",
})
expect(bind.print("close")).toBe("print:q")
expect(bind.print("ctrl+p")).toBe("print:ctrl+p")
expect(list).toEqual(["q", "ctrl+p"])
})
})

View File

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

View File

@@ -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({

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,19 @@ const getTuiConfig = async (directory: string) =>
),
)
async function withPlatform<Value>(platform: typeof process.platform, fn: () => Promise<Value>) {
const original = Object.getOwnPropertyDescriptor(process, "platform")
Object.defineProperty(process, "platform", {
...original,
value: platform,
})
try {
return await fn()
} finally {
if (original) Object.defineProperty(process, "platform", original)
}
}
afterEach(async () => {
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) => {

View File

@@ -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) {

View File

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

View File

@@ -1,421 +0,0 @@
import { describe, test, expect } from "bun:test"
import { Keybind } from "@/util/keybind"
describe("Keybind.toString", () => {
test("should convert simple key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" }
expect(Keybind.toString(info)).toBe("f")
})
test("should convert ctrl modifier to string", () => {
const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
expect(Keybind.toString(info)).toBe("ctrl+x")
})
test("should convert leader key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
expect(Keybind.toString(info)).toBe("<leader> f")
})
test("should convert multiple modifiers to string", () => {
const info: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
expect(Keybind.toString(info)).toBe("ctrl+alt+g")
})
test("should convert all modifiers to string", () => {
const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "h" }
expect(Keybind.toString(info)).toBe("<leader> ctrl+alt+shift+h")
})
test("should convert shift modifier to string", () => {
const info: Keybind.Info = {
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "return",
}
expect(Keybind.toString(info)).toBe("shift+return")
})
test("should convert function key to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f2" }
expect(Keybind.toString(info)).toBe("f2")
})
test("should convert special key to string", () => {
const info: Keybind.Info = {
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "pgup",
}
expect(Keybind.toString(info)).toBe("pgup")
})
test("should handle empty name", () => {
const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "" }
expect(Keybind.toString(info)).toBe("ctrl")
})
test("should handle only modifiers", () => {
const info: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: true, name: "" }
expect(Keybind.toString(info)).toBe("<leader> ctrl+alt+shift")
})
test("should handle only leader with no other parts", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "" }
expect(Keybind.toString(info)).toBe("<leader>")
})
test("should convert super modifier to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
expect(Keybind.toString(info)).toBe("super+z")
})
test("should convert super+shift modifier to string", () => {
const info: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
expect(Keybind.toString(info)).toBe("super+shift+z")
})
test("should handle super with ctrl modifier", () => {
const info: Keybind.Info = { ctrl: true, meta: false, shift: false, super: true, leader: false, name: "a" }
expect(Keybind.toString(info)).toBe("ctrl+super+a")
})
test("should handle super with all modifiers", () => {
const info: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "x" }
expect(Keybind.toString(info)).toBe("ctrl+alt+super+shift+x")
})
test("should handle undefined super field (omitted)", () => {
const info: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" }
expect(Keybind.toString(info)).toBe("ctrl+c")
})
})
describe("Keybind.match", () => {
test("should match identical keybinds", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match different key names", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "y" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should not match different modifiers", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "x" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match leader keybinds", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match leader vs non-leader", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: true, name: "f" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match complex keybinds", () => {
const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
const b: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match with one modifier different", () => {
const a: Keybind.Info = { ctrl: true, meta: true, shift: false, leader: false, name: "g" }
const b: Keybind.Info = { ctrl: true, meta: true, shift: true, leader: false, name: "g" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match simple key without modifiers", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "a" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should match super modifier keybinds", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match super vs non-super", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: false, super: false, leader: false, name: "z" }
expect(Keybind.match(a, b)).toBe(false)
})
test("should match undefined super with false super", () => {
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" }
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, super: false, leader: false, name: "c" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should match super+shift combination", () => {
const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
expect(Keybind.match(a, b)).toBe(true)
})
test("should not match when only super differs", () => {
const a: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "a" }
const b: Keybind.Info = { ctrl: true, meta: true, shift: true, super: false, leader: false, name: "a" }
expect(Keybind.match(a, b)).toBe(false)
})
})
describe("Keybind.parse", () => {
test("should parse simple key", () => {
const result = Keybind.parse("f")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "f",
},
])
})
test("should parse leader key syntax", () => {
const result = Keybind.parse("<leader>f")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: true,
name: "f",
},
])
})
test("should parse ctrl modifier", () => {
const result = Keybind.parse("ctrl+x")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "x",
},
])
})
test("should parse multiple modifiers", () => {
const result = Keybind.parse("ctrl+alt+u")
expect(result).toEqual([
{
ctrl: true,
meta: true,
shift: false,
leader: false,
name: "u",
},
])
})
test("should parse shift modifier", () => {
const result = Keybind.parse("shift+f2")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "f2",
},
])
})
test("should parse meta/alt modifier", () => {
const result = Keybind.parse("meta+g")
expect(result).toEqual([
{
ctrl: false,
meta: true,
shift: false,
leader: false,
name: "g",
},
])
})
test("should parse leader with modifier", () => {
const result = Keybind.parse("<leader>h")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: true,
name: "h",
},
])
})
test("should parse multiple keybinds separated by comma", () => {
const result = Keybind.parse("ctrl+c,<leader>q")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "c",
},
{
ctrl: false,
meta: false,
shift: false,
leader: true,
name: "q",
},
])
})
test("should parse shift+return combination", () => {
const result = Keybind.parse("shift+return")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: true,
leader: false,
name: "return",
},
])
})
test("should parse ctrl+j combination", () => {
const result = Keybind.parse("ctrl+j")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "j",
},
])
})
test("should handle 'none' value", () => {
const result = Keybind.parse("none")
expect(result).toEqual([])
})
test("should handle special keys", () => {
const result = Keybind.parse("pgup")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "pgup",
},
])
})
test("should handle function keys", () => {
const result = Keybind.parse("f2")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
leader: false,
name: "f2",
},
])
})
test("should handle complex multi-modifier combination", () => {
const result = Keybind.parse("ctrl+alt+g")
expect(result).toEqual([
{
ctrl: true,
meta: true,
shift: false,
leader: false,
name: "g",
},
])
})
test("should be case insensitive", () => {
const result = Keybind.parse("CTRL+X")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "x",
},
])
})
test("should parse super modifier", () => {
const result = Keybind.parse("super+z")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: false,
super: true,
leader: false,
name: "z",
},
])
})
test("should parse super with shift modifier", () => {
const result = Keybind.parse("super+shift+z")
expect(result).toEqual([
{
ctrl: false,
meta: false,
shift: true,
super: true,
leader: false,
name: "z",
},
])
})
test("should parse multiple keybinds with super", () => {
const result = Keybind.parse("ctrl+-,super+z")
expect(result).toEqual([
{
ctrl: true,
meta: false,
shift: false,
leader: false,
name: "-",
},
{
ctrl: false,
meta: false,
shift: false,
super: true,
leader: false,
name: "z",
},
])
})
})