Browse Source

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
Simon Klee 1 tuần trước cách đây
mục cha
commit
73a4f5a654

+ 2 - 1
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<string[]> }) {
     // - 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

+ 1 - 1
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<string>) => {
         // Prevent toggling while an operation is already in progress

+ 1 - 1
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,

+ 2 - 1
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()
     }
   })

+ 2 - 2
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -195,8 +195,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   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)

+ 5 - 3
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()
       }

+ 70 - 2
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<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
+  export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super" | "baseCode"> & {
     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[] = []

+ 83 - 0
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)
+  })
+})