diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8c4f596fd3..5d5caa34f3 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -59,6 +59,7 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfig } from "@/config/tui" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { FormatError, FormatUnknownError } from "@/cli/error" +import { Keybind } from "@/util/keybind" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -310,7 +311,7 @@ function App(props: { onSnapshot?: () => Promise }) { // - Ctrl+C copies and dismisses selection // - Esc dismisses selection // - Most other key input dismisses selection and is passed through - if (evt.ctrl && evt.name === "c") { + if (Keybind.matchParsedKey("ctrl+c", evt)) { if (!Selection.copy(renderer, toast)) { renderer.clearSelection() return diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index 9cfa30d4df..80ac5df31f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -47,7 +47,7 @@ export function DialogMcp() { const keybinds = createMemo(() => [ { - keybind: Keybind.parse("space")[0], + keybind: Keybind.parseOne("space"), title: "toggle", onTrigger: async (option: DialogSelectOption) => { // Prevent toggling while an operation is already in progress diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 9ecb21e82a..bf0a7b1bf8 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -162,7 +162,7 @@ export function DialogSessionList() { }, }, { - keybind: Keybind.parse("ctrl+w")[0], + keybind: Keybind.parseOne("ctrl+w"), title: "new workspace", side: "right", disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, diff --git a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx index b22163902e..ff359cfc60 100644 --- a/packages/opencode/src/cli/cmd/tui/component/error-component.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/error-component.tsx @@ -5,6 +5,7 @@ import { createSignal } from "solid-js" import { Installation } from "@/installation" import { win32FlushInputBuffer } from "../win32" import { getScrollAcceleration } from "../util/scroll" +import { Keybind } from "@/util/keybind" export function ErrorComponent(props: { error: Error @@ -25,7 +26,7 @@ export function ErrorComponent(props: { } useKeyboard((evt) => { - if (evt.ctrl && evt.name === "c") { + if (Keybind.matchParsedKey("ctrl+c", evt)) { handleExit() } }) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 109b5f2f11..45ea34079e 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -195,8 +195,8 @@ export function DialogSelect(props: DialogSelectProps) { useKeyboard((evt) => { setStore("input", "keyboard") - if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) - if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) + if (evt.name === "up" || Keybind.matchParsedKey("ctrl+p", evt)) move(-1) + if (evt.name === "down" || Keybind.matchParsedKey("ctrl+n", evt)) move(1) if (evt.name === "pageup") move(-10) if (evt.name === "pagedown") move(10) if (evt.name === "home") moveTo(0) diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 11c43fe24c..d7384e213f 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -6,6 +6,7 @@ import { createStore } from "solid-js/store" import { useToast } from "./toast" import { Flag } from "@/flag/flag" import { Selection } from "@tui/util/selection" +import { Keybind } from "@/util/keybind" export function Dialog( props: ParentProps<{ @@ -72,12 +73,13 @@ function init() { }) const renderer = useRenderer() - useKeyboard((evt) => { if (store.stack.length === 0) return if (evt.defaultPrevented) return - if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return - if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { + const isCtrlC = Keybind.matchParsedKey("ctrl+c", evt) + + if ((evt.name === "escape" || isCtrlC) && renderer.getSelection()?.getSelectedText()) return + if (evt.name === "escape" || isCtrlC) { if (renderer.getSelection()) { renderer.clearSelection() } diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts index 83c7945ae1..f1e0a8200c 100644 --- a/packages/opencode/src/util/keybind.ts +++ b/packages/opencode/src/util/keybind.ts @@ -6,15 +6,70 @@ export namespace Keybind { * Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field. * This ensures type compatibility and catches missing fields at compile time. */ - export type Info = Pick & { + export type Info = Pick & { leader: boolean // our custom field } + function getBaseCodeName(baseCode: number | undefined): string | undefined { + if (baseCode === undefined || baseCode < 32 || baseCode === 127) { + return undefined + } + + try { + const name = String.fromCodePoint(baseCode) + + if (name.length === 1 && name >= "A" && name <= "Z") { + return name.toLowerCase() + } + + return name + } catch { + return undefined + } + } + export function match(a: Info | undefined, b: Info): boolean { if (!a) return false const normalizedA = { ...a, super: a.super ?? false } const normalizedB = { ...b, super: b.super ?? false } - return isDeepEqual(normalizedA, normalizedB) + if (isDeepEqual(normalizedA, normalizedB)) { + return true + } + + const modifiersA = { + ctrl: normalizedA.ctrl, + meta: normalizedA.meta, + shift: normalizedA.shift, + super: normalizedA.super, + leader: normalizedA.leader, + } + const modifiersB = { + ctrl: normalizedB.ctrl, + meta: normalizedB.meta, + shift: normalizedB.shift, + super: normalizedB.super, + leader: normalizedB.leader, + } + + if (!isDeepEqual(modifiersA, modifiersB)) { + return false + } + + return ( + normalizedA.name === normalizedB.name || + getBaseCodeName(normalizedA.baseCode) === normalizedB.name || + getBaseCodeName(normalizedB.baseCode) === normalizedA.name + ) + } + + export function parseOne(key: string): Info { + const parsed = parse(key) + + if (parsed.length !== 1) { + throw new Error(`Expected exactly one keybind, got ${parsed.length}: ${key}`) + } + + return parsed[0]! } /** @@ -28,10 +83,23 @@ export namespace Keybind { meta: key.meta, shift: key.shift, super: key.super ?? false, + baseCode: key.baseCode, leader, } } + export function matchParsedKey(binding: Info | string | undefined, key: ParsedKey, leader = false): boolean { + const bindings = typeof binding === "string" ? parse(binding) : binding ? [binding] : [] + + if (!bindings.length) { + return false + } + + const parsed = fromParsedKey(key, leader) + + return bindings.some((item) => match(item, parsed)) + } + export function toString(info: Info | undefined): string { if (!info) return "" const parts: string[] = [] diff --git a/packages/opencode/test/keybind.test.ts b/packages/opencode/test/keybind.test.ts index 4ca1f1697e..4e04bcff6d 100644 --- a/packages/opencode/test/keybind.test.ts +++ b/packages/opencode/test/keybind.test.ts @@ -162,6 +162,24 @@ describe("Keybind.match", () => { expect(Keybind.match(a, b)).toBe(true) }) + test("should match ctrl shortcuts by baseCode from alternate layouts", () => { + const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" } + const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 } + expect(Keybind.match(a, b)).toBe(true) + }) + + test("should still match the reported character when baseCode is also present", () => { + const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ" } + const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 } + expect(Keybind.match(a, b)).toBe(true) + }) + + test("should not match a different shortcut just because baseCode exists", () => { + 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: "ㅊ", baseCode: 99 } + expect(Keybind.match(a, b)).toBe(false) + }) + 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" } @@ -419,3 +437,68 @@ describe("Keybind.parse", () => { ]) }) }) + +describe("Keybind.parseOne", () => { + test("should parse a single keybind", () => { + expect(Keybind.parseOne("ctrl+x")).toEqual({ + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "x", + }) + }) + + test("should reject multiple keybinds", () => { + expect(() => Keybind.parseOne("ctrl+x,ctrl+y")).toThrow("Expected exactly one keybind") + }) +}) + +describe("Keybind.fromParsedKey", () => { + test("should preserve baseCode from ParsedKey", () => { + const result = Keybind.fromParsedKey({ + name: "ㅊ", + ctrl: true, + meta: false, + shift: false, + option: false, + number: false, + sequence: "ㅊ", + raw: "\x1b[12618::99;5u", + eventType: "press", + source: "kitty", + baseCode: 99, + }) + + expect(result).toEqual({ + name: "ㅊ", + ctrl: true, + meta: false, + shift: false, + super: false, + leader: false, + baseCode: 99, + }) + }) + + test("should ignore leader unless explicitly requested", () => { + const key = { + name: "ㅊ", + ctrl: true, + meta: false, + shift: false, + option: false, + number: false, + sequence: "ㅊ", + raw: "\x1b[12618::99;5u", + eventType: "press" as const, + source: "kitty" as const, + baseCode: 99, + } + + expect(Keybind.matchParsedKey("ctrl+c", key)).toBe(true) + expect(Keybind.matchParsedKey("ctrl+x,ctrl+c", key)).toBe(true) + expect(Keybind.matchParsedKey("ctrl+x,ctrl+y", key)).toBe(false) + expect(Keybind.matchParsedKey("ctrl+c", key, true)).toBe(false) + }) +})