Keybinding Best Practices
Your app is never the only program listening to the keyboard. A keypress travels through several layers before it reaches your handlers, and any layer can consume it first. Designing good keybindings for a TUI is mostly about understanding that stack and staying inside the set of keys you can actually rely on.
This page is about the concepts and conventions. For the vue-termui API — onKeyDown, KeyEvent, focus routing — see Handling Input and Focus Management.
The input stack
An input event passes through a chain, and each stage may handle and swallow it before the next one sees it:
┌────────────┐ ┌──────────────────┐ ┌───────────────┐ ┌──────────────┐
│ OS / WM │ → │ terminal emulator │ → │ multiplexer │ → │ your app │
│ │ │ │ │ (tmux/screen)│ │ │
│ global │ │ menu shortcuts, │ │ prefix key, │ │ only sees │
│ shortcuts │ │ selection, mouse │ │ copy-mode, │ │ what │
│ │ │ protocol │ │ its keytables │ │ survived │
└────────────┘ └──────────────────┘ └───────────────┘ └──────────────┘The multiplexer layer is optional (not everyone runs tmux), but the other three are always there. The practical consequence: some key combinations will never reach your app, no matter how you bind them, because something upstream claims them first.
Why some keys never arrive
There are three separate reasons a combo might not make it to your handler.
1. It's a shortcut for an upper layer
The OS, the terminal emulator, and tmux all bind their own shortcuts, and those win. Common examples:
- The OS / terminal app: fullscreen, new tab/window, copy/paste, font size, tab switching — usually on the platform "command" modifier (Cmd on macOS, often Ctrl/Ctrl+Shift elsewhere).
- tmux: the prefix key (default Ctrl+b) plus whatever follows it, and everything in copy-mode.
These are user-configurable, but you don't control them — so don't build core functionality on a combo the user's terminal is likely to eat.
2. Legacy terminal input can't encode it
The classic terminal input model predates modern keyboards: a key press is just a handful of bytes on a stream. Many combinations have no byte sequence of their own, so they either collapse into another key or vanish entirely:
| Can't be distinguished | Because they send the same bytes |
|---|---|
| Ctrl+I and Tab | both 0x09 |
| Ctrl+M and Enter | both 0x0D |
| Ctrl+[ and Esc | both 0x1B |
| Ctrl+H and Backspace | both 0x08 (on some terminals) |
| Ctrl+letter and Ctrl+Shift+letter | Shift is dropped when Ctrl is held |
| Esc and Alt+key | Alt is encoded as an Esc prefix — telling them apart is a timing guess |
And some things are simply not there in the legacy model:
- The platform "command" modifier (Cmd / Super / Win) has no encoding at all — even if no upper layer grabbed it, the terminal has no bytes to send. Cmd+Enter doesn't exist as a key event.
- Key releases aren't reported. The stream only carries presses, so "hold to act" interactions can't be built on legacy input.
3. Decades of muscle memory already claim it
A few combos have long-standing, load-bearing meanings. In a canonical (cooked-mode) shell the TTY itself turns them into signals; a raw-mode TUI like yours receives most of them as ordinary key events instead. That does not free them up — the meaning lives in your users' reflexes:
| Input | Users expect |
|---|---|
| Ctrl+C | Interrupt — in raw mode it reaches the app as a key event, so honoring it is the app's job (see below) |
| Ctrl+Z | Suspend to the shell |
| Ctrl+D | End-of-file / "I'm done here" |
| Ctrl+S / Ctrl+Q | Flow control (XOFF/XON) — on some setups Ctrl+S still freezes the terminal |
| Ctrl+\ | Quit (SIGQUIT) |
| Shift+click | Manual text selection — terminals reserve Shift to override mouse reporting so users can still copy |
Shift+click deserves a special note: when your app enables mouse reporting, the terminal normally forwards clicks to you — except while Shift is held, which is deliberately reserved so users can select and copy text. This is a convention people rely on; don't try to reclaim it.
Ctrl+C is a contract, not a suggestion
Because a raw-mode TUI intercepts Ctrl+C instead of dying to SIGINT, keeping it working is the app's responsibility. OpenTUI upholds the contract for you: by default the renderer exits when it sees Ctrl+C (the exitOnCtrlC renderer option). If you turn that off — say, to confirm before quitting or run custom teardown — you must provide another way out, or your app becomes unquittable by the one key everyone tries first.
Getting more keys: the Kitty keyboard protocol
Modern terminals implement the Kitty keyboard protocol, which fixes most of the legacy limitations:
- It disambiguates the collisions above (Ctrl+I ≠ Tab, Shift survives alongside Ctrl, no more Esc-vs-Alt guessing).
- It can report key releases and repeats, not just presses.
- It adds the missing modifiers — including Super/Cmd — so combos like Cmd+Enter can finally reach your app.
You don't need to enable anything for the basics: OpenTUI requests the protocol's disambiguation and alternate-keys enhancements by default on every start, and terminals that don't support it simply ignore the request. Key releases are the exception — the protocol's report event types enhancement is off by default, so onKeyUp requires opting in through the renderer config's useKittyKeyboard object:
// main.ts
const app = await createApp(App, null, {
useKittyKeyboard: {
// disambiguate: true, (default)
// alternateKeys: true, (default)
events: true, // report press/repeat/release → onKeyUp works
},
})Two caveats worth internalizing:
- Support isn't universal. Kitty, WezTerm, Ghostty, foot, Alacritty, recent iTerm2 and the VS Code terminal support it; older/simpler terminals don't. tmux forwards the encoded keys but doesn't implement the full protocol. On unsupported setups you silently fall back to legacy behavior.
- It doesn't override upper layers. If the terminal still binds Cmd+Enter to fullscreen, that shortcut wins before the protocol ever encodes the key. The user has to free the binding in their terminal first.
Treat enhanced keys as a bonus, not a requirement
Because support varies, never make a core action reachable only through a Kitty-only combo (a Super chord, a key-release, a disambiguated Ctrl+I). Use them to enhance, and always provide a legacy-safe fallback.
Choosing good keybindings
The reliable core is smaller than a keyboard suggests, but it's plenty: letters, digits, arrows, Enter, Esc, Tab, Space, Backspace, Home/End/PgUp/PgDn, function keys, and most single Ctrl+letter combos (minus the reserved ones above). Within that set, lean on the vocabulary users already know:
| Key | Established meaning |
|---|---|
| q, Ctrl+C | quit |
| ? | help |
| / | search / filter |
| arrows, h j k l | move |
| Enter | confirm / open |
| Esc | cancel / back |
| Tab | next field or pane |
| Space | toggle / select |
| g / G | jump to top / bottom |
Beyond conventions:
- Don't depend on distinctions the terminal can't make unless Kitty is on: Ctrl+I vs Tab, Shift combined with Ctrl, Esc vs Alt chords.
- Use a leader key for large command sets — one namespace key followed by a letter scales much further than exotic chords, and it's how tmux, vim and helix users already think.
- Make bindings discoverable. A persistent hint bar or a ? overlay listing active keys turns your keymap from trivia into UI. A keybinding nobody can find is a keybinding nobody uses.
- Scope keys with focus. Route typing to the focused widget and keep global shortcuts (quit, help) at the top level — see Focus Management. Call
key.preventDefault()when a widget consumes a key so global handlers don't also react. - Test on more than one terminal, ideally with and without tmux, before committing to a binding.
A leader key with a hint bar
The two ideas reinforce each other: while the leader is pending, the hint bar becomes the menu of what can follow. All of it is plain state plus onKeyDown:
<script setup lang="ts">
import { onKeyDown, ref, useExit } from 'vue-termui'
const exit = useExit()
const leader = ref(false)
onKeyDown((key) => {
if (leader.value) {
leader.value = false
if (key.name === 's') save()
else if (key.name === 'r') rename()
else if (key.name === 'x') remove()
return // unknown follow-up: leader silently cancels
}
if (key.name === 'g') leader.value = true
else if (key.name === 'q' || (key.ctrl && key.name === 'c')) exit()
})
</script>
<template>
<MainView />
<Text dim>
{{
leader
? 'g + s save · r rename · x delete · any other key cancels'
: 'q quit · ? help · g commands'
}}
</Text>
</template>Debugging what actually reaches your app
When a binding "doesn't work," first find out whether the key even reaches the process — the problem is often an upper layer.
Inspect the raw bytes. Run
cat -v(orsed -n l) in the same terminal and press the combo. If you see an escape sequence, your app can receive it; if you see nothing, something upstream trapped it.showkey -aworks on Linux too.Show the
KeyEventin your UI. Every event carries the rawsequenceplus the parsednameand modifier booleans. Rendering the last event beats logging — stdout is busy drawing your app:vue<script setup lang="ts"> import { onKeyDown, ref } from 'vue-termui' const last = ref('press something…') onKeyDown((key) => { const mods = [ key.ctrl && 'ctrl', key.option && 'option', key.shift && 'shift', key.meta && 'meta', ] .filter(Boolean) .join('+') last.value = `${mods && mods + '+'}${key.name} seq=${JSON.stringify(key.sequence)} (${key.eventType})` }) </script> <template> <Text>{{ last }}</Text> </template>Check the upper layers' config when a key never arrives: your terminal's shortcut/keybinding preferences,
tmux list-keys, and your OS keyboard settings each enumerate what that layer claims.
No universal "trapped keys" list
There's no single API that reports every shortcut the OS, terminal, and tmux have claimed — the state is spread across each layer's own configuration. The raw-byte check above is the fastest way to answer "can my app even see this key?" for a specific combo on a specific setup.