introduce opentui keymap as sole key/cmd engine (#26053)
This commit is contained in:
@@ -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",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user