From 73a4f5a65463652e6544d78f2fd112132d1bf474 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 12 Apr 2026 19:15:23 +0200 Subject: [PATCH] keybind: match by baseCode for non-Latin layouts Keyboard shortcuts like Ctrl+C fail on non-Latin input layouts because the terminal reports the layout-specific character name instead of the Latin one. Fall back to the baseCode field from the Kitty keyboard protocol to identify the physical key when names differ. Consolidate inline modifier checks in TUI components behind the new matchParsedKey helper. Issue #21163 --- packages/opencode/src/cli/cmd/tui/app.tsx | 3 +- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 2 +- .../cmd/tui/component/dialog-session-list.tsx | 2 +- .../cli/cmd/tui/component/error-component.tsx | 3 +- .../src/cli/cmd/tui/ui/dialog-select.tsx | 4 +- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 8 +- packages/opencode/src/util/keybind.ts | 72 +++++++++++++++- packages/opencode/test/keybind.test.ts | 83 +++++++++++++++++++ 8 files changed, 166 insertions(+), 11 deletions(-) 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) + }) +})