Skip to content

app: keybinds match the produced character, so Ctrl+F and other letter shortcuts fail on non-Latin layouts #55676

Open

Description

@belomord

Description

App keybinds are matched against event.key (the character produced by the active
layout) and only fall back to the physical event.code when Alt is held. On
non-Latin layouts (e.g. Russian) event.key is Cyrillic, so letter and punctuation
shortcuts silently do nothing unless Alt is involved.

Example: the session find bar added in #48470 is bound to mod+f. On an English
layout Ctrl+F opens it; on a Russian layout it does nothing. session.search is
registered with hidden: true, so the command palette cannot reach it either — the
shortcut is the only entry point, and on a Cyrillic layout it never fires.

Root cause — packages/app/src/shell/commands/command.tsx:

export function keyFromKeyboardEvent(event: KeyboardEvent) {
  const key = normalizeKey(event.key)

  if (!event.altKey || /^[a-z0-9]$/.test(key)) return key

  if (!event.code.startsWith("Key") || event.code.length !== 4) return key

  return event.code.slice(3).toLowerCase()
}

matchKeybind compares the configured key ("f") with keyFromKeyboardEvent(event).
With a Russian layout, Ctrl+F arrives as event.key === "а", event.code === "KeyF",
event.altKey === false, so the function returns "а" and "f" !== "а" — no match.
Alt+F does match, so the event.code fallback exists but is gated on Alt (it was
added for macOS Option). Ctrl/Meta/Shift on a non-Latin layout are not covered.

The same applies to mod+p, mod+t, mod+w, mod+u, mod+shift+a/e/l/o/r/s/t/x,
mod+shift+d, and the punctuation binds mod+comma, mod+., mod+;, mod+',
mod+[, mod+], mod+\\. Digit binds (mod+1..9) are fine because the digit row
matches across these layouts.

Suggested fix: remove the !event.altKey gate so the physical-key fallback applies to
every modifier (this keeps the macOS Option behavior), and additionally map event.code
for the punctuation keys used in keybinds (Comma, Period, Semicolon, Quote,
BracketLeft, BracketRight, Backslash, …).

Related, but not the same:

Plugins

None

OpenCode version

2.0.29 (desktop app)

Steps to reproduce

  1. Open the desktop app on Windows and switch the keyboard layout to Russian (ЙЦУКЕН).
  2. Open a session and press Ctrl+F on the physical F key. → Nothing happens.
  3. Switch to the English layout and press the same physical keys. → The find bar opens.
  4. Still in the Russian layout, Alt+F matches while Ctrl+F does not.

Screenshot and/or share link

N/A

Operating System

Windows 11 (10.0.22631)

Terminal

N/A — desktop app

Activity

  1. opencode-agent commented on Oct 15, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I can confirm this.

    I couldn't run the Windows desktop app here, so I tested the app's own keybind matcher (matchKeybind / parseKeybind in packages/app/src/shell/commands/command.tsx) with the keyboard events a Russian layout produces:

    • Ctrl+F on the US layout (key: "f", code: "KeyF") matches mod+f.
    • Ctrl+F on the Russian layout (key: "а", code: "KeyF") does not match.
    • The same goes for mod+p, mod+t, mod+w, mod+comma, mod+. and mod+[.
    • Alt+F on the Russian layout does match, through the existing event.code fallback.

    The results are the same on 2.0.29 and on the latest dev (4397ee44). The test script below reproduces it.

    Related: #40048 (same problem on v1, closed by the stale bot), plus the open PRs #54573 and #54708, which change the same function for other keyboard cases.

    Reproduction script (layout-keybind.test.ts)
    // Reproduces anomalyco/opencode#55676 against the app's real keybind matcher.
    // Usage (from a checkout of anomalyco/opencode, after `bun install`):
    //   cp layout-keybind.test.ts packages/app/src/shell/commands/
    //   cd packages/app && bun test src/shell/commands/layout-keybind.test.ts
    // Non-mac platform is assumed (bun has no navigator.platform => "mod" = ctrl).
    import { expect, test } from "bun:test"
    import { matchKeybind, parseKeybind } from "./command"
    
    // Minimal KeyboardEvent shape as Chromium/WebView2 delivers it on Windows.
    function ev(key: string, code: string, mods: { ctrl?: boolean; shift?: boolean; alt?: boolean; meta?: boolean } = {}) {
      return {
        key,
        code,
        ctrlKey: !!mods.ctrl,
        shiftKey: !!mods.shift,
        altKey: !!mods.alt,
        metaKey: !!mods.meta,
      } as KeyboardEvent
    }
    
    // Physical key -> [US layout key, Russian ЙЦУКЕН layout key]
    const cases: Array<[string, string, string, string]> = [
      // keybind, code, en key, ru key
      ["mod+f", "KeyF", "f", "а"], // session.search (find bar)
      ["mod+p", "KeyP", "p", "з"],
      ["mod+t", "KeyT", "t", "е"],
      ["mod+w", "KeyW", "w", "ц"],
      ["mod+comma", "Comma", ",", "б"],
      ["mod+.", "Period", ".", "ю"],
      ["mod+[", "BracketLeft", "[", "х"],
    ]
    
    for (const [bind, code, en, ru] of cases) {
      test(`${bind} on US layout (key=${en})`, () => {
        expect(matchKeybind(parseKeybind(bind), ev(en, code, { ctrl: true }))).toBe(true)
      })
      test(`${bind} on Russian layout, same physical key (key=${ru}, code=${code})`, () => {
        expect(matchKeybind(parseKeybind(bind), ev(ru, code, { ctrl: true }))).toBe(true)
      })
    }
    
    // Control: the event.code fallback already works when Alt is held.
    test("alt+f on Russian layout (key=а, code=KeyF) matches via code fallback", () => {
      expect(matchKeybind(parseKeybind("alt+f"), ev("а", "KeyF", { alt: true }))).toBe(true)
    })

    Run it: bun layout-keybind.test.ts

  2. ZXLyyds100 commented on Oct 15, 2026

    @ZXLyyds100

    I'd like to take this on. I'll cover Ctrl/Meta shortcuts whose key is a non-Latin character, including punctuation keys, while preserving Latin layout mappings and unmodified custom bindings. I'll extend the existing command matcher tests. I've checked #54573 and #54708; their Option and Dead/Unidentified cases are separate from this fix.

    Delivery update, October 29: #55765 at 4f0cf1cb98d3c8abded6e5497dcbee010f447f96 now normally merges current dev@726cf096, preserving recording, bracket/Option behavior and the new declined-command contract. This is AI-assisted implementation/testing/self-review, not independent human review.

    Local verification: 63 focused command tests, 630 randomized repetitions, 971 unit + 251 browser tests, 20 focused Chrome component repetitions, app TypeScript, root check/normal push hook, changed-file lint and formatting passed. Current upstream first fails 13 command assertions and both focused browser regressions.

    The PR describes the remaining baseline browser failures honestly: full components 205 passed / 2 failed; the selected built-mode keepers 3 passed / 4 failed, also reproduced on the same upstream production code. No assertions were skipped or weakened. Synthetic keyboard events on macOS/Chrome are not OS-level Russian-layout or hosted Linux/Windows verification. Maintainer review and fork-workflow approval are still separate gates.

  3. added a commit that references this issue on Oct 15, 2026
    35d45ac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions