diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 4c82e594c3e..b8af074e8e3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -83,8 +83,9 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex const keybind = keybinds()[key] if (!keybind) return false const parsed: Keybind.Info = result.parse(evt) + const usePhysicalKeys = sync.data.config.keybinds?.usePhysicalKeys ?? false for (const key of keybind) { - if (Keybind.match(key, parsed)) { + if (Keybind.match(key, parsed, { usePhysicalKeys })) { return true } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f62581db369..0cac795693d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -644,6 +644,13 @@ export namespace Config { terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), tips_toggle: z.string().optional().default("h").describe("Toggle tips on home screen"), + usePhysicalKeys: z + .boolean() + .optional() + .default(false) + .describe( + "Use physical key positions instead of character names for keybind matching. Enables keybindings to work correctly with non-English keyboard layouts (Korean, Japanese, Chinese, AZERTY, Dvorak, etc.). Requires terminal support for Kitty keyboard protocol.", + ), }) .strict() .meta({ diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts index 69fef28f0d9..6c51624ee41 100644 --- a/packages/opencode/src/util/keybind.ts +++ b/packages/opencode/src/util/keybind.ts @@ -5,21 +5,55 @@ 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. + * Now includes optional `baseCode` for physical key matching. */ - export type Info = Pick & { + export type Info = Pick & { leader: boolean // our custom field } - export function match(a: Info, b: Info): boolean { + /** + * Match options for keybind comparison + */ + export interface MatchOptions { + /** + * Use physical key position (baseCode) instead of character name. + * Enables keybindings to work correctly with non-English keyboard layouts + * (Korean, Japanese, Chinese, AZERTY, Dvorak, etc.) + */ + usePhysicalKeys?: boolean + } + + /** + * Match two keybinds. + * When usePhysicalKeys is true and both have baseCode, matches by physical key position. + * Otherwise falls back to character-based matching. + */ + export function match(a: Info, b: Info, options: MatchOptions = {}): boolean { + const { usePhysicalKeys = false } = options + + // Physical key matching: use baseCode when available + if (usePhysicalKeys && a.baseCode !== undefined && b.baseCode !== undefined) { + return ( + a.baseCode === b.baseCode && + a.ctrl === b.ctrl && + a.meta === b.meta && + a.shift === b.shift && + (a.super ?? false) === (b.super ?? false) && + a.leader === b.leader + ) + } + + // Fallback: character-based matching (current behavior) // Normalize super field (undefined and false are equivalent) - const normalizedA = { ...a, super: a.super ?? false } - const normalizedB = { ...b, super: b.super ?? false } + const normalizedA = { ...a, super: a.super ?? false, baseCode: undefined } + const normalizedB = { ...b, super: b.super ?? false, baseCode: undefined } return isDeepEqual(normalizedA, normalizedB) } /** * Convert OpenTUI's ParsedKey to our Keybind.Info format. * This helper ensures all required fields are present and avoids manual object creation. + * Now preserves baseCode for physical key matching. */ export function fromParsedKey(key: ParsedKey, leader = false): Info { return { @@ -28,6 +62,7 @@ export namespace Keybind { meta: key.meta, shift: key.shift, super: key.super ?? false, + baseCode: key.baseCode, leader, } } @@ -66,6 +101,7 @@ export namespace Keybind { shift: false, leader: false, name: "", + baseCode: undefined, } for (const part of parts) { @@ -92,6 +128,14 @@ export namespace Keybind { break default: info.name = part + // Generate baseCode for single ASCII characters (a-z, 0-9, punctuation) + if (part.length === 1) { + const code = part.charCodeAt(0) + // ASCII printable characters (32-126) + if (code >= 32 && code <= 126) { + info.baseCode = code + } + } break } } diff --git a/packages/opencode/test/keybind.test.ts b/packages/opencode/test/keybind.test.ts index 4ca1f1697e2..f68255281bf 100644 --- a/packages/opencode/test/keybind.test.ts +++ b/packages/opencode/test/keybind.test.ts @@ -185,6 +185,7 @@ describe("Keybind.parse", () => { shift: false, leader: false, name: "f", + baseCode: 102, }, ]) }) @@ -198,6 +199,7 @@ describe("Keybind.parse", () => { shift: false, leader: true, name: "f", + baseCode: 102, }, ]) }) @@ -211,6 +213,7 @@ describe("Keybind.parse", () => { shift: false, leader: false, name: "x", + baseCode: 120, }, ]) }) @@ -224,6 +227,7 @@ describe("Keybind.parse", () => { shift: false, leader: false, name: "u", + baseCode: 117, }, ]) }) @@ -237,6 +241,7 @@ describe("Keybind.parse", () => { shift: true, leader: false, name: "f2", + baseCode: undefined, }, ]) }) @@ -250,6 +255,7 @@ describe("Keybind.parse", () => { shift: false, leader: false, name: "g", + baseCode: 103, }, ]) }) @@ -263,6 +269,7 @@ describe("Keybind.parse", () => { shift: false, leader: true, name: "h", + baseCode: 104, }, ]) }) @@ -276,6 +283,7 @@ describe("Keybind.parse", () => { shift: false, leader: false, name: "c", + baseCode: 99, }, { ctrl: false, @@ -283,6 +291,7 @@ describe("Keybind.parse", () => { shift: false, leader: true, name: "q", + baseCode: 113, }, ]) }) @@ -296,6 +305,7 @@ describe("Keybind.parse", () => { shift: true, leader: false, name: "return", + baseCode: undefined, }, ]) }) @@ -309,6 +319,7 @@ describe("Keybind.parse", () => { shift: false, leader: false, name: "j", + baseCode: 106, }, ]) }) @@ -327,6 +338,7 @@ describe("Keybind.parse", () => { shift: false, leader: false, name: "pgup", + baseCode: undefined, }, ]) }) @@ -340,6 +352,7 @@ describe("Keybind.parse", () => { shift: false, leader: false, name: "f2", + baseCode: undefined, }, ]) }) @@ -353,6 +366,7 @@ describe("Keybind.parse", () => { shift: false, leader: false, name: "g", + baseCode: 103, }, ]) }) @@ -366,6 +380,7 @@ describe("Keybind.parse", () => { shift: false, leader: false, name: "x", + baseCode: 120, }, ]) }) @@ -380,6 +395,7 @@ describe("Keybind.parse", () => { super: true, leader: false, name: "z", + baseCode: 122, }, ]) }) @@ -394,6 +410,7 @@ describe("Keybind.parse", () => { super: true, leader: false, name: "z", + baseCode: 122, }, ]) }) @@ -407,6 +424,7 @@ describe("Keybind.parse", () => { shift: false, leader: false, name: "-", + baseCode: 45, }, { ctrl: false, @@ -415,7 +433,212 @@ describe("Keybind.parse", () => { super: true, leader: false, name: "z", + baseCode: 122, }, ]) }) + + test("should generate baseCode for single ASCII characters", () => { + const result = Keybind.parse("ctrl+x") + expect(result[0].baseCode).toBe(120) + }) + + test("should not generate baseCode for special keys", () => { + const result = Keybind.parse("ctrl+return") + expect(result[0].baseCode).toBeUndefined() + }) + + test("should not generate baseCode for function keys", () => { + const result = Keybind.parse("f2") + expect(result[0].baseCode).toBeUndefined() + }) +}) + +describe("Keybind.match with physical keys", () => { + test("should match by baseCode when usePhysicalKeys is true", () => { + const config: Keybind.Info = { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "x", + baseCode: 120, + } + const input: Keybind.Info = { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "ㅌ", + baseCode: 120, + } + expect(Keybind.match(config, input, { usePhysicalKeys: true })).toBe(true) + }) + + test("should not match different baseCode with usePhysicalKeys", () => { + const a: Keybind.Info = { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "x", + baseCode: 120, + } + const b: Keybind.Info = { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "y", + baseCode: 121, + } + expect(Keybind.match(a, b, { usePhysicalKeys: true })).toBe(false) + }) + + test("should fallback to name matching when baseCode unavailable", () => { + const a: Keybind.Info = { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "x", + baseCode: undefined, + } + const b: Keybind.Info = { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "x", + baseCode: undefined, + } + expect(Keybind.match(a, b, { usePhysicalKeys: true })).toBe(true) + }) + + test("should use character matching when usePhysicalKeys is false", () => { + const config: Keybind.Info = { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "x", + baseCode: 120, + } + const input: Keybind.Info = { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "ㅌ", + baseCode: 120, + } + expect(Keybind.match(config, input, { usePhysicalKeys: false })).toBe(false) + }) + + test("should match AZERTY layout (Q key produces 'a')", () => { + const config: Keybind.Info = { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "q", + baseCode: 113, + } + const input: Keybind.Info = { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "a", + baseCode: 113, + } + expect(Keybind.match(config, input, { usePhysicalKeys: true })).toBe(true) + }) + + test("should match modifiers correctly with physical keys", () => { + const a: Keybind.Info = { + ctrl: true, + meta: true, + shift: false, + leader: false, + name: "x", + baseCode: 120, + } + const b: Keybind.Info = { + ctrl: true, + meta: false, + shift: false, + leader: false, + name: "ㅌ", + baseCode: 120, + } + expect(Keybind.match(a, b, { usePhysicalKeys: true })).toBe(false) + }) + + test("should match leader key with physical keys", () => { + const a: Keybind.Info = { + ctrl: false, + meta: false, + shift: false, + leader: true, + name: "n", + baseCode: 110, + } + const b: Keybind.Info = { + ctrl: false, + meta: false, + shift: false, + leader: true, + name: "ㅜ", + baseCode: 110, + } + expect(Keybind.match(a, b, { usePhysicalKeys: true })).toBe(true) + }) + + test("should not match when leader differs with physical keys", () => { + const a: Keybind.Info = { + ctrl: false, + meta: false, + shift: false, + leader: true, + name: "n", + baseCode: 110, + } + const b: Keybind.Info = { + ctrl: false, + meta: false, + shift: false, + leader: false, + name: "ㅜ", + baseCode: 110, + } + expect(Keybind.match(a, b, { usePhysicalKeys: true })).toBe(false) + }) +}) + +describe("Keybind.fromParsedKey with baseCode", () => { + test("should preserve baseCode from ParsedKey", () => { + const parsedKey = { + name: "ㅌ", + ctrl: true, + meta: false, + shift: false, + baseCode: 120, + } as any + + const info = Keybind.fromParsedKey(parsedKey) + expect(info.baseCode).toBe(120) + }) + + test("should handle undefined baseCode", () => { + const parsedKey = { + name: "x", + ctrl: true, + meta: false, + shift: false, + } as any + + const info = Keybind.fromParsedKey(parsedKey) + expect(info.baseCode).toBeUndefined() + }) })