Skip to content

Kitty Keyboard Protocol Support

Silvery fully supports the Kitty keyboard protocol for unambiguous key identification, modifier detection, and key event types.

What is the Kitty Keyboard Protocol?

The Kitty keyboard protocol is a modern terminal keyboard protocol that solves fundamental limitations of traditional terminal input handling. It was created by Kovid Goyal for the Kitty terminal emulator and has since been adopted by many other terminals.

The Problem with Traditional Terminal Input

Traditional terminals encode keypresses using ASCII control codes and escape sequences. This creates ambiguities:

KeysBoth SendWhy?
Ctrl+I / Tab0x09Tab is ASCII character 9, same as Ctrl+I
Ctrl+M / Enter0x0DCarriage return is ASCII 13, same as Ctrl+M
Ctrl+[ / Escape0x1BEscape is ASCII 27, same as Ctrl+[
Ctrl+H / Backspace0x08Backspace is ASCII 8, same as Ctrl+H

Additionally, many key combinations are simply undetectable:

  • Ctrl+Shift+<letter> - Shift state is lost
  • Ctrl+<number> - Most produce no output
  • Key release events - Not reported at all
  • Super/Hyper modifiers - Not transmitted

Progressive Enhancement Mode

The Kitty protocol uses progressive enhancement - applications opt-in to enhanced features using a flags bitmask. This allows:

  1. Applications to request only the features they need
  2. Graceful fallback when features aren't supported
  3. Backward compatibility with legacy applications

Enhancement flags (binary bitmask):

BitValueFeature
0b11Disambiguate escape codes - All keys use unambiguous CSI u format
0b102Report event types - Distinguish press, repeat, and release
0b1004Report alternate keys - Include shifted/alternate key variants
0b10008Report all keys as escape codes - Even plain letters
0b1000016Report associated text - Include Unicode text for the key

For Silvery, flags 1 (disambiguate) and 2 (event types) are the most valuable.

Key Encoding Format

Keys are encoded as:

CSI unicode-key-code : alternates ; modifiers : event-type ; text u

Only unicode-key-code is mandatory. For example:

  • a key: CSI 97 u (97 = Unicode for 'a')
  • Ctrl+a: CSI 97 ; 5 u (5 = 1 + ctrl modifier)
  • Tab: CSI 9 u (9 = tab key code)
  • Ctrl+i: CSI 105 ; 5 u (105 = 'i', with ctrl - DISTINGUISHABLE from Tab!)

Modifier Encoding

Modifiers use a bitmask with an offset of +1:

Value = 1 + modifiers

where modifiers bits are:
  shift     = 0b1       (1)
  alt       = 0b10      (2)
  ctrl      = 0b100     (4)
  super     = 0b1000    (8)
  hyper     = 0b10000   (16)
  meta      = 0b100000  (32)
  caps_lock = 0b1000000 (64)
  num_lock  = 0b10000000 (128)

For example, Ctrl+Shift = 1 + 1 + 4 = 6.

Event Types

When flag 2 is enabled, event types are reported:

TypeCodeDescription
Press1Key pressed (default, often omitted)
Repeat2Key held down, repeating
Release3Key released

Example: CSI 97 ; 1 : 3 u = 'a' key released.

Terminal Support

Terminals with Full Support

TerminalPlatformNotes
KittyLinux, macOSThe reference implementation
WezTermLinux, macOS, WindowsFull support
footLinux (Wayland)Full support
GhosttymacOS, LinuxFull support
AlacrittyCross-platformFull support (added 2024)
iTerm2macOSFull support
rioCross-platformFull support

Terminals Without Support

TerminalPlatformNotes
macOS Terminal.appmacOSNo plans for support
GNOME TerminalLinuxUses VTE, no support yet
KonsoleLinuxNo support
Windows TerminalWindowsNo support (may add in future)

Terminal Multiplexers

MultiplexerSupportNotes
tmuxPartialMust enable passthrough mode
screenNoLegacy protocol only
ZellijYesFull passthrough support

Applications Using the Protocol

Major applications that have adopted the protocol:

  • Editors: Neovim, Vim, Helix, Kakoune, dte
  • Shells: fish, nushell
  • File managers: Yazi, far2l
  • Libraries: notcurses, crossterm, textual, bubbletea, vaxis

Detecting Terminal Support

Query-based Detection

Send the query sequence and check for a response:

typescript
// Query current keyboard mode
stdout.write("\x1b[?u")

// Terminal will respond with:
// CSI ? flags u   (if supported)
// Nothing         (if not supported, will show garbage or be ignored)

Robust Detection Pattern

typescript
async function detectKittyProtocol(stdin, stdout): Promise<boolean> {
  return new Promise((resolve) => {
    const timeout = setTimeout(() => {
      cleanup()
      resolve(false)
    }, 100)

    function onData(data: Buffer) {
      const str = data.toString()
      // Look for CSI ? <number> u response
      if (/\x1b\[\?\d+u/.test(str)) {
        cleanup()
        resolve(true)
      }
    }

    function cleanup() {
      clearTimeout(timeout)
      stdin.removeListener("data", onData)
    }

    stdin.on("data", onData)

    // Query current mode, then query device attributes (fallback)
    stdout.write("\x1b[?u\x1b[c")
  })
}

Using Primary Device Attributes as Fallback

If the terminal doesn't respond to CSI ? u, it will respond to CSI c (device attributes). By sending both, you can detect support with a timeout:

  1. Send \x1b[?u\x1b[c
  2. If you get CSI ? <n> u before CSI ? <attrs> c, protocol is supported
  3. If you only get device attributes, protocol is not supported

Usage in Silvery

Enabling the Protocol

run() auto-detects Kitty protocol support and enables it by default on supported terminals (Ghostty, Kitty, WezTerm, foot):

tsx
import { run } from "@silvery/term/runtime"

// Auto-enabled — ⌘ and ✦ modifiers just work
await run(<App />)

// Opt out if needed
await run(<App />, { kitty: false })

// Specific flags for advanced features:
import { KittyFlags } from "silvery"
await run(<App />, { kitty: KittyFlags.DISAMBIGUATE | KittyFlags.REPORT_EVENTS })

Silvery handles the full lifecycle: detect support, enable on startup, disable on exit (including crash/SIGINT).

Enhanced Key Fields

When the protocol is active, the Key object includes additional fields:

FieldTypeDescription
superbooleanCmd/Super modifier (Kitty bit 3)
hyperbooleanHyper modifier (Kitty bit 4)
eventType1 | 2 | 3Press (1), repeat (2), release (3). Requires REPORT_EVENTS flag.
capsLockbooleanCapsLock is active
numLockbooleanNumLock is active
shiftedKeystringCharacter produced when Shift is held
baseLayoutKeystringKey on standard US layout (for non-Latin keyboards)
associatedTextstringDecoded text from REPORT_TEXT mode

Protocol Control Functions

typescript
import { enableKittyKeyboard, disableKittyKeyboard, queryKittyKeyboard, KittyFlags } from "silvery"

enableKittyKeyboard(KittyFlags.DISAMBIGUATE) // CSI > flags u
disableKittyKeyboard() // CSI < u (pop stack)
queryKittyKeyboard() // CSI ? u (detect support)

Detection

typescript
import { detectKittySupport, detectKittyFromStdio } from "silvery"

// Low-level: send query, parse response
const supported = await detectKittySupport(write, read, timeout)

// Convenience: detect using real stdio
const supported = await detectKittyFromStdio(stdout, stdin, timeout)

API Design Examples

Basic Usage (Auto-detection)

tsx
import { render, useInput } from "silvery"

function App() {
  useInput((input, key) => {
    // With Kitty protocol, these are now distinguishable!
    if (key.tab && !key.ctrl) {
      // User pressed Tab
    }
    if (key.ctrl && input === "i") {
      // User pressed Ctrl+I (NOT Tab!)
    }
  })

  return <Text>Tab and Ctrl+I are now different!</Text>
}

// Enable Kitty protocol (falls back gracefully)
using term = createTerm()
await render(<App />, { kittyKeyboard: true })

Key Release Events

tsx
function Game() {
  const [isJumping, setIsJumping] = useState(false)

  useInput((input, key) => {
    if (input === " ") {
      if (key.eventType === "press") {
        setIsJumping(true)
      } else if (key.eventType === "release") {
        setIsJumping(false)
      }
    }
  })

  return <Text>{isJumping ? "Jumping!" : "On ground"}</Text>
}

using term = createTerm()
await render(<Game />, {
  kittyKeyboard: { reportRelease: true },
})

Checking Protocol Support

tsx
function App() {
  const rt = useRuntime()
  // Kitty support is auto-detected at startup — run() enables it by default
  // on supported terminals (Ghostty, Kitty, WezTerm, foot)

  return (
    <Box flexDirection="column">
      <Text>Kitty protocol: check terminal capabilities</Text>
      <Text dimColor>Tip: Use Kitty, WezTerm, or iTerm2 for enhanced keyboard support</Text>
    </Box>
  )
}

Backward Compatibility

Graceful Degradation

When Kitty protocol is requested but not supported:

  1. Detection returns false
  2. kittyProtocolEnabled context value is false
  3. Input parsing uses legacy escape sequences
  4. All existing code continues to work

API Stability

The existing Key interface properties remain unchanged. New properties are optional and only populated when the protocol is active:

typescript
// Existing code continues to work
useInput((input, key) => {
  if (key.tab) {
    // Still works - might be Tab or Ctrl+I
  }
})

// Enhanced code can check for disambiguation
useInput((input, key) => {
  if (key.kittyProtocol) {
    // Can trust that Tab and Ctrl+I are distinct
  }
})

Before/After Comparison

Before (Legacy Protocol)

tsx
useInput((input, key) => {
  // PROBLEM: Cannot distinguish these
  if (key.tab) {
    // Could be Tab OR Ctrl+I - no way to know
    handleIndent() // User wanted Ctrl+I for something else!
  }

  // PROBLEM: Cannot detect key release
  if (input === "w") {
    moveForward() // Keeps triggering on repeat
    // No way to know when user lifts finger
  }

  // PROBLEM: No Super/Hyper modifiers
  if (key.meta && input === "s") {
    // This is Alt+S, but user pressed Cmd+S
    // Cmd is intercepted by terminal
  }
})

After (Kitty Protocol)

tsx
useInput((input, key) => {
  // SOLVED: Tab and Ctrl+I are distinct
  if (key.tab && !key.ctrl) {
    handleIndent()
  }
  if (key.ctrl && input === "i") {
    showInfo() // Separate action!
  }

  // SOLVED: Key release detection
  if (input === "w") {
    if (key.eventType === "press") {
      startMovingForward()
    } else if (key.eventType === "release") {
      stopMovingForward()
    }
  }

  // SOLVED: Super modifier available (if terminal supports)
  if (key.super && input === "s") {
    save() // Actually Cmd+S on macOS
  }
})

Testing

Use kittyMode: true on createRenderer to route press() through Kitty encoding, and keyToKittyAnsi() to generate raw sequences:

tsx
import { createRenderer, keyToKittyAnsi } from "@silvery/test"

const render = createRenderer({ cols: 80, rows: 24, kittyMode: true })

test("Super+j triggers action", async () => {
  const app = render(<App />)
  await app.press("Super+j")
  expect(app.text).toContain("action triggered")
})

// Generate raw sequences for direct stdin writing
keyToKittyAnsi("Super+j") // '\x1b[106;9u'
keyToKittyAnsi("Meta+Enter") // '\x1b[13;3u'

References

Released under the MIT License.