feat(input): route key encoding to the backend, and honor DECCKM - #182
Draft
Ayman Bagabas (aymanbagabas) wants to merge 5 commits into
Draft
feat(input): route key encoding to the backend, and honor DECCKM#182Ayman Bagabas (aymanbagabas) wants to merge 5 commits into
Ayman Bagabas (aymanbagabas) wants to merge 5 commits into
Conversation
Two fixes to key encoding, one caused by looking into the other. `keys.rs` modelled no DECCKM at all. `CSI ?1h` moves the cursor keys onto `SS3`, and readline, vim, and less all set it, so `key press Up` was sending `CSI A` to children that had asked for `SS3 A`. It reaches exactly the keys `CSI ... A-D`, `H`, and `F` spell, and only for an unmodified press, which is already what `ss3_unmodified` means for F1-F4, so it reuses that rather than adding a second rule that could drift from it. A modified cursor key carries a parameter that `SS3` has nowhere to put, so it stays `CSI`, and the Kitty protocol replaces the legacy encoding outright so it outranks the mode. Encoding now prefers the backend's own encoder, per token, falling back to the shared one. That matters because encoding depends on more terminal state than the shared encoder models: ghostty's applies keypad application mode, `modifyOtherKeys`, and the alt-escape prefix, and reads all of it straight off the live terminal through `set_options_from_terminal`. Only ghostty ships an encoder. alacritty and rio keep theirs in their GUI crates rather than their VT libraries, and the xterm.js headless bundle omits key handling entirely, so those three answer `None` and fall back. `None` is also the answer for an event ghostty cannot express: its modifier bitmask has no Hyper or Meta, which the Kitty protocol does define, and declining is the only way to avoid sending a key with a modifier silently dropped. Fallback is all-or-nothing per token, because a token half-encoded by each would interleave two encodings of the same keypress. The two halves meet rather than overlap: ghostty encodes its own DECCKM, and the shared encoder's DECCKM is what the other three backends now get, so all four agree instead of only the one with an encoder being right. `InputModes` groups the state encoding branches on. Public entry points take `impl Into<InputModes>`, so callers holding only Kitty flags are unchanged. Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
A backend encoder needs to know what a key produces, not just which key it is, and three bugs came from not telling it. Found by differential-testing the shared encoder against ghostty's. `key press A` sent `a`. Tokens were lowercased wholesale before reaching the backend, which is right for a named key and wrong for a character. `key press Space` sent nothing. Ghostty encodes a text-bearing key with no `utf8` set as the empty string, and only single-character tokens were setting it, so the one named key that produces text set nothing. `key press Alt+a` sent `a`. `set_options_from_terminal` resets `macos_option_as_alt` to `False`, which is a macOS GUI question rather than a terminal one: with it off, Alt is Option and composes text instead of prefixing ESC. A headless session has no keyboard and no compose behavior. `KeyPress` now carries `key` as the *unshifted* key plus the `text` it produces, so `!` arrives as `"1"` with shift and text `"!"`. The text is carried rather than derived because deriving it needs a keyboard layout, and `Shift+1` is `!` on a US layout and `"` on a UK one. `token_to_presses` now classifies a token exactly as `token_to_seq_for_action_with_mode` does, so both encoders are handed the same events instead of two readings of one token. Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
…ey-encoding # Conflicts: # README.md # SKILL.md # crates/tui-test/src/terminal/ghostty/core.rs # crates/tui-test/src/terminal/ghostty/mod.rs
`kitty_keyboard = false` stopped `keyboard_mode` reporting flags but did not stop ghostty encoding with them. `set_options_from_terminal` reads the Kitty flags straight off the live terminal, which is the reason to route encoding there at all, and it goes around the profile: a disabled session reported no flags and then encoded `Escape` as `CSI 27u` anyway. The modes are still tracked, as they are for rio, so they are cleared out of the encoder after the terminal has had its say rather than being kept from the terminal. Only reachable with both halves present, which is why neither branch found it alone and why this one now sits on top of the Kitty profile work. Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
Ayman Bagabas (aymanbagabas)
changed the base branch from
main
to
aymanbagabas/kitty-keyboard-config
August 26, 2026 18:17
Shift that went into producing a character is spent: it made `A` out of `a` and is not a modifier to report alongside it. Without saying so, a plain `Shift+a` encoded as `CSI 97;2u` where the Kitty spec has a text-producing key still send its text when only disambiguation is on. Found by the differential test against ghostty's encoder. Signed-off-by: Ayman Bagabas <ayman.bagabas@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Routes key encoding to a backend's own encoder where one exists, and fixes a bug that finding out about surfaced.
The bug: no DECCKM at all
keys.rsmodelled the Kitty protocol flags and nothing else.CSI ?1h(DECCKM) moves the cursor keys fromCSI AontoSS3 A, and readline, vim, and less all set it — sokey press Uphas been sendingCSI Ato children that asked forSS3 A.It reaches exactly the keys
CSI ... A-D,H, andFspell: the four arrows plus Home and End. Only an unmodified press changes, which is already whatss3_unmodifiedmeans for F1-F4, so it reuses that path rather than adding a second rule that could drift from it.Two cases deliberately do not change:
CSI.Ctrl+UpisCSI 1;5A, andSS3has nowhere to put the modifier parameter.CSIwith a parameter, so DECCKM does not apply.The routing
Emulator::encode_key(&KeyPress) -> Option<Vec<u8>>, defaulting toNone. A backend that has an encoder uses it;Nonefalls back to the shared encoder.ghostty_vt::key::EncoderalacrittyGUI crate, notalacritty_terminalrio-vthas only hex and graphics encodersevaluateKeyboardEventis inbrowser/, absent from the headless bundlePreferring the backend matters because encoding depends on more terminal state than the shared encoder models. Ghostty's
set_options_from_terminalreads the live terminal and applies keypad application mode,modifyOtherKeys, and the alt-escape prefix — none of which are visible through theEmulatortrait, and none of whichkeys.rshas ever handled.Declining is a feature. Ghostty's modifier bitmask has no Hyper or Meta, which the Kitty protocol does define. Rather than encode without them,
encode_keyreturnsNoneand the event falls back, so a modifier is never silently dropped. Same for a key ghostty has no physical code for.Fallback is all-or-nothing per token. A token half-encoded by the backend and half by the fallback would interleave two encodings of the same keypress.
How the two halves fit
They meet rather than overlap. Ghostty encodes its own DECCKM inside its encoder; the shared encoder's new DECCKM is what the other three backends get. Without the second half, this PR would have made ghostty correct in application cursor mode and left the other three wrong — a divergence introduced by a fix. With both, all four agree.
Shape of the change
InputModesgroups the terminal state encoding branches on:Public entry points take
impl Into<InputModes>withFrom<KeyboardMode>, so all existing callers and the 32 existing test call sites compile untouched.Emulator::cursor_key_application()is new and implemented by all four backends:TermMode::APP_CURSOR(alacritty),Mode::APP_CURSOR(rio),Mode::DECCKM(ghostty), andmodes.applicationCursorKeysMode(xterm.js, public API).Tests
Five DECCKM cases in
keys.rs: application mode usesSS3, normal mode is unchanged, a modified cursor key staysCSI, Kitty outranks the mode, and the mode reaches only the cursor keys (F1 staysSS3either way, F5 and PageUp are untouched).Four routing cases against a real ghostty terminal, which is the only way to show the routing is live rather than merely wired: the encoder follows
CSI ?1handCSI ?1l, it follows negotiated Kitty flags, Hyper/Meta decline, and an unmapped key declines.cargo test --workspace --features tui-test-rs/ghostty,tui-test-rs/rio,tui-test-rs/xtermjsgives 419 passed, 0 failed.Related
Stacked on #180. It started out independent, but the two features interact:
set_options_from_terminalreads ghostty's Kitty flags straight off the live terminal, which is the reason to route encoding there at all and also goes around the profile. A session withkitty_keyboard = falsereported no flags and then encodedEscapeasCSI 27uanyway. Neither branch could show that alone.fix(ghostty): honor a disabled Kitty profile in the backend encoderclears the flags out of the encoder after the terminal has had its say, with a regression test.Independent of #181. #183 stacks on this one.
Update: four bugs found by the follow-up oracle
The differential test in #183 was written against this branch and immediately found four bugs in it, now fixed here in
fix(input): carry the text a key produces to the backend encoder:key press Asentakey press Spacesent nothingutf8set as the empty string, and only single-character tokens were setting itkey press Alt+asentaset_options_from_terminalresetsmacos_option_as_alttoFalse; with it off Alt is Option and composes text instead of prefixing ESCShift+asentCSI 97;2uinstead ofAconsumed_modswas never set, so ghostty reported a Shift that had been spent producing the textKeyPressnow carrieskeyas the unshifted key plus thetextit produces, so!arrives as"1"with shift and text"!". Text is carried rather than derived because deriving it needs a keyboard layout —Shift+1is!on a US layout and"on a UK one.token_to_pressesnow classifies a token exactly astoken_to_seq_for_action_with_modedoes, so both encoders receive the same events rather than two readings of one token.These are a good argument for reviewing #183 alongside this PR: without it, the first three would have shipped.
Update 2:
Shift+areported a spent modifierShift that goes into producing a character is consumed: it made
Aout ofaand is not a modifier to report alongside it. Without saying so viaset_consumed_mods,Shift+aencoded asCSI 97;2uwhere the Kitty spec has a text-producing key still send its text when only disambiguation is on. Also found by #183.