Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/keybind.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<leader>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({
Expand Down
52 changes: 48 additions & 4 deletions packages/opencode/src/util/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super" | "baseCode"> & {
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 {
Expand All @@ -28,6 +62,7 @@ export namespace Keybind {
meta: key.meta,
shift: key.shift,
super: key.super ?? false,
baseCode: key.baseCode,
leader,
}
}
Expand Down Expand Up @@ -66,6 +101,7 @@ export namespace Keybind {
shift: false,
leader: false,
name: "",
baseCode: undefined,
}

for (const part of parts) {
Expand All @@ -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
}
}
Expand Down
Loading