fix(core): preserve keyboard layouts in embedded terminals (#1477)

## Why

In OpenCode's embedded terminal, Dvorak Ctrl+U and Ctrl+D can reach
Neovim as Ctrl+F and Ctrl+H when the child uses Kitty keyboard encoding.
Paging up instead pages down, and paging down moves the cursor left.

The incoming Kitty event correctly separates the active-layout character
from its base-layout alternative. `EmbeddedTerminalRenderable` currently
passes the alternative as the native encoder's unshifted character,
changing the key's identity.

## What Changes

Keep the active-layout character as the primary key, and use the
base-layout alternative only to infer the physical key when an explicit
physical code is unavailable.

| Incoming Dvorak input | Before | After |
| --- | --- | --- |
| Plain u, base-layout F | u (117), byte `75` hex | Unchanged |
| Ctrl+U, base-layout F | Ctrl+F | Ctrl+U |
| Ctrl+D, base-layout H | Ctrl+H | Ctrl+D |
| Ctrl+Shift+U, base-layout F | Ctrl+Shift+F | Ctrl+Shift+U |

If the child requests alternate-key reporting, the original base-layout
alternative remains available separately. Legacy control-byte output
stays unchanged.

This explains why ordinary typing can work while control shortcuts fail:
in the reproduced Kitty mode, plain letters use the translated text,
while control shortcuts use the incorrectly selected key codepoint.
Ctrl+U becomes `ESC[102;5u` instead of `ESC[117;5u`; Ctrl+D becomes
`ESC[104;5u` instead of `ESC[100;5u`.

Regression coverage parses real Kitty sequences and exercises the native
encoder. One table covers plain Dvorak u/d, control shortcuts, Shift,
Cyrillic input, a QWERTY control case, legacy child mode, and
alternate-key reporting. A focused-delivery test covers
press/repeat/release events.

## Demo

Real Neovim 0.12.4 running in the existing `embedded-terminal-demo.ts`
example with the production `EmbeddedTerminalRenderable`. The inspector
shows:

1. The received key, active-layout and base-layout codepoints, and raw
input sequence.
2. The actual `onData` output forwarded unchanged to the PTY, in escaped
and hexadecimal form. The inspector does not re-encode the key.
3. Neovim's independently observed key via `vim.on_key`, plus its
resulting cursor position.

Each case starts at line 80, column 8, with `scroll=8`. Plain u is typed
in Insert mode; Ctrl+D/U are pressed in Normal mode. The clean Neovim
fixture explicitly enables Kitty mode with `CSI > 1 u`; Dvorak Kitty
input sequences are injected at the outer terminal boundary rather than
generated by an OS keyboard layout.

Before uses the unchanged `EmbeddedTerminal.ts` from `fe547ebe`, loaded
through a test-only module override. After uses `03b84c9a`. The three
independent cases are reordered to show plain typing first, slowed to
0.1×, and held for four seconds each; setup and resets are omitted. Both
processes returned cleanly after the check.

**Before:** Plain u stays 117. Ctrl+D changes 100 → 104 and Neovim sees
`<C-H>`; Ctrl+U changes 117 → 102 and Neovim sees `<C-F>`.


https://github.com/user-attachments/assets/6e1b469f-e4d2-4298-a095-8f422e10a8d0

**After:** Plain u still works. Ctrl+D stays 100 and Neovim sees
`<C-D>`; Ctrl+U stays 117 and Neovim sees `<C-U>`.


https://github.com/user-attachments/assets/b825e1f0-b7e6-4f1b-a112-e780a9f34fe8

## Scope

This fixes OpenTUI's embedded-terminal key adapter, without
application-specific remapping or a public API change. OpenCode will
need to adopt an OpenTUI release containing the fix.

## Verification

```sh
cd packages/core
bun run test src/renderables/EmbeddedTerminal.test.ts
bun run typecheck
bun run test:js:node
bun run build:lib
bun run test
cd ../..
bun run fmt:check
bun run lint
```

- Focused suite: seven regressions fail with the baseline encoder, while
plain typing passes; all 31 tests pass afterward. Rechecked after
consolidating the repeated test setup.
- Full Bun suite after building the parser assets: 5,587 passed, 26
skipped, zero failures. The initial unbuilt-worktree run failed only
because `parser.worker.js` was absent.
- Node suite: 4,842 passed, seven skipped, zero failures.
- Typechecking, formatting, and lint pass.
- Real PTY before/after verification shown above.
This commit is contained in:
Kit Langton
2026-09-03 20:37:56 -04:00
committed by GitHub
parent fe547ebed1
commit aef16edbac
2 changed files with 48 additions and 4 deletions
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "../testing/test-renderer.js"
import { KeyEvent } from "../lib/KeyHandler.js"
import { parseKeypress } from "../lib/parse.keypress.js"
import { RGBA } from "../lib/RGBA.js"
import { resolveRenderLib } from "../zig.js"
import { EmbeddedTerminalRenderable } from "./EmbeddedTerminal.js"
@@ -276,6 +277,45 @@ describe("EmbeddedTerminalRenderable", () => {
).toBe("\x1b[27u")
})
test.each([
["plain Dvorak u", 1, "\x1b[117::102;1u", "u"],
["plain Dvorak d", 1, "\x1b[100::104;1u", "d"],
["Dvorak Ctrl+U", 1, "\x1b[117::102;5u", "\x1b[117;5u"],
["Dvorak Ctrl+D", 1, "\x1b[100::104;5u", "\x1b[100;5u"],
["Dvorak Ctrl+Shift+U", 1, "\x1b[117:85:102;6u", "\x1b[117;6u"],
["Cyrillic Ctrl+ф", 1, "\x1b[1092::97;5u", "\x1b[1092;5u"],
["QWERTY Ctrl+U", 1, "\x1b[117;5u", "\x1b[117;5u"],
["Dvorak Ctrl+U in legacy mode", 0, "\x1b[117::102;5u", "\x15"],
["Dvorak Ctrl+D in legacy mode", 0, "\x1b[100::104;5u", "\x04"],
["Dvorak Ctrl+U with its base-layout alternative", 5, "\x1b[117::102;5u", "\x1b[117::102;5u"],
["Dvorak Ctrl+D with its base-layout alternative", 5, "\x1b[100::104;5u", "\x1b[100::104;5u"],
])("preserves %s", (_label, flags, raw, expected) => {
const terminal = new EmbeddedTerminalRenderable(setup.renderer, { width: 20, height: 4 })
setup.renderer.root.add(terminal)
terminal.write(`\x1b[>${flags}u`)
const parsed = parseKeypress(raw, { useKittyKeyboard: true })!
expect(new TextDecoder().decode(terminal.encodeKey(new KeyEvent(parsed)))).toBe(expected)
})
test("forwards Dvorak press, repeat, and release with the same active-layout key", () => {
const output: string[] = []
const terminal = new EmbeddedTerminalRenderable(setup.renderer, {
width: 20,
height: 4,
onData: (data) => output.push(new TextDecoder().decode(data)),
})
setup.renderer.root.add(terminal)
terminal.write("\x1b[>3u")
terminal.focus()
for (const raw of ["\x1b[117::102;5u", "\x1b[117::102;5:2u", "\x1b[117::102;5:3u"]) {
setup.renderer.keyInput.processParsedKey(parseKeypress(raw, { useKittyKeyboard: true })!)
}
expect(output).toEqual(["\x1b[117;5u", "\x1b[117;5:2u", "\x1b[117;5:3u"])
})
test("drains the preserved response prefix after overflow", () => {
const lib = resolveRenderLib()
const handle = lib.createEmbeddedTerminal({ cols: 20, rows: 4 })
@@ -148,7 +148,7 @@ export class EmbeddedTerminalRenderable extends Renderable {
key: physical,
mods: modifiers(key),
text,
unshiftedCodepoint: key.baseCode ?? physicalUnshiftedCodepoint(physical),
unshiftedCodepoint: unshiftedCodepoint(key, physical),
})
}
@@ -382,8 +382,9 @@ function modifiers(input: {
function physicalKey(key: KeyEvent) {
if (key.code && !key.code.startsWith("[")) return key.code
if (/^[a-z]$/i.test(key.name)) return `Key${key.name.toUpperCase()}`
if (/^[0-9]$/.test(key.name)) return `Digit${key.name}`
const name = key.baseCode === undefined ? key.name : String.fromCodePoint(key.baseCode)
if (/^[a-z]$/i.test(name)) return `Key${name.toUpperCase()}`
if (/^[0-9]$/.test(name)) return `Digit${name}`
return (
{
backspace: "Backspace",
@@ -413,7 +414,10 @@ function textualKey(key: KeyEvent) {
if ([...key.name].length === 1 || /[^\x00-\x7f]/.test(key.name)) return key.name
}
function physicalUnshiftedCodepoint(code: string | undefined) {
function unshiftedCodepoint(key: KeyEvent, code: string | undefined) {
// Kitty's baseCode is a physical-layout alternative, not the active layout's character.
if (key.name === "space") return 32
if ([...key.name].length === 1) return key.name.codePointAt(0)!
if (code?.startsWith("Key") && code.length === 4) return code.charCodeAt(3) + 32
if (code?.startsWith("Digit") && code.length === 6) return code.charCodeAt(5)
return 0