Skip to content

Commit 598f2e4

Browse files
committed
feat(plugin): allow release keybindings in TUI plugin layers
Widen KeymapCommand.bind to accept { key, event } so TUI plugins can register key-release bindings. The keybind config schema and the keymap library already support release events; the plugin API was the only gap. This unblocks hold-to-talk plugins (see #35219).
1 parent bcf23b6 commit 598f2e4

3 files changed

Lines changed: 42 additions & 3 deletions

File tree

‎packages/plugin/src/tui/context.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ export interface KeymapCommand {
416416
/** Enables or disables the command. */
417417
readonly enabled?: boolean | (() => boolean)
418418
/** Configures automatic binding, or disables it for a named command. */
419-
readonly bind?: false | string
419+
readonly bind?: false | string | { key: string; event?: "press" | "release" }
420420
/** Adds a named command to the command palette. */
421421
readonly palette?: true
422422
/** Adds a named command to prompt slash completion. */

‎packages/tui/src/context/keymap.tsx‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,16 @@ function createLayer(input: () => KeymapLayer) {
241241
if (command.bind === false) return []
242242
const configured = value.config.keybinds.get(command.id)
243243
if (configured.length) return configured
244-
if (typeof command.bind !== "string") return []
245-
return [{ key: command.bind, cmd: command.id }]
244+
if (typeof command.bind === "string") return [{ key: command.bind, cmd: command.id }]
245+
if (typeof command.bind === "object" && command.bind.key)
246+
return [
247+
{
248+
key: command.bind.key,
249+
cmd: command.id,
250+
...(command.bind.event === undefined ? {} : { event: command.bind.event }),
251+
},
252+
]
253+
return []
246254
}),
247255
...(bindings ?? []).flatMap((id) => value.config.keybinds.get(id)),
248256
],
@@ -257,6 +265,8 @@ function groupCommands(commands: readonly KeymapCommand[] = []) {
257265
if (!command.id) throw new Error("Keymap command IDs cannot be empty")
258266
if (typeof command.bind === "string" && !command.bind)
259267
throw new Error("Keymap command bindings cannot be empty")
268+
if (typeof command.bind === "object" && !command.bind.key)
269+
throw new Error("Keymap command bindings cannot be empty")
260270
result.named.push({ ...command, id: command.id })
261271
return result
262272
}

‎packages/tui/test/keymap.test.tsx‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @jsxImportSource @opentui/solid */
22
import { testRender } from "@opentui/solid"
3+
import { useKeymapSelector } from "@opentui/keymap/solid"
34
import { expect, test } from "bun:test"
45
import { ConfigProvider } from "../src/config"
56
import { Keymap } from "../src/context/keymap"
@@ -139,3 +140,31 @@ test("global commands stay reachable when the mode changes", async () => {
139140
app.renderer.destroy()
140141
}
141142
})
143+
144+
test("layer object bindings carry release events", async () => {
145+
let read = () => ""
146+
147+
function Harness() {
148+
const bindings = useKeymapSelector((keymap) =>
149+
keymap.getCommandBindings({ visibility: "registered", commands: ["voice.release"] }),
150+
)
151+
Keymap.createLayer(() => ({
152+
commands: [{ id: "voice.release", bind: { key: "f9", event: "release" }, run() {} }],
153+
}))
154+
read = () => bindings().get("voice.release")?.[0]?.event ?? ""
155+
return <box />
156+
}
157+
158+
const app = await testRender(() => (
159+
<ConfigProvider config={createTuiResolvedConfig()}>
160+
<Keymap.Provider>
161+
<Harness />
162+
</Keymap.Provider>
163+
</ConfigProvider>
164+
))
165+
try {
166+
expect(read()).toBe("release")
167+
} finally {
168+
app.renderer.destroy()
169+
}
170+
})

0 commit comments

Comments
 (0)