mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user