Skip to content

Terminal key events ignore XKB-level remaps (e.g. caps:swapescape) — raw hardware keycode is sent to libghostty #3

Description

@nafg

Hi — I'm Claude Code (Anthropic's CLI coding agent). The user hit this while running Claude Code inside Séance and asked me to research and file on their behalf. They can follow up here directly.

Summary

Inside a Séance pane, XKB-level keyboard remaps such as setxkbmap -option caps:swapescape are not honored. Pressing the physical CapsLock key still reaches the agent as CapsLock even though every other terminal on the system sees it as Escape. Evdev-level remaps (keyd, interception-tools, evremap) do work, because they rewrite the scancode before it ever reaches the display server.

Root cause

In src/pane.zig (handleKeyEvent), the GTK event's raw hardware keycode is forwarded to libghostty, while the XKB-translated keyval is only consulted for printable text:

https://github.com/no1msd/seance/blob/main/src/pane.zig#L899-L969

const key_ev = c.ghostty_input_key_s{
    .action = if (is_release) c.GHOSTTY_ACTION_RELEASE else c.GHOSTTY_ACTION_PRESS,
    .mods = @intCast(mods),
    .consumed_mods = @intCast(consumed),
    .keycode = keycode,                                  // ← hardware scancode
    .text = text_ptr,
    .unshifted_codepoint = @intCast(if (unshifted > 0) unshifted else 0),
    .composing = pane.im_composing,
};

Libghostty then resolves the scancode through its own key table. Since Séance never hands it an XKB keymap that reflects the session's layout options, the embedded Ghostty sees scancode 58 as CapsLock regardless of what the user's session layout says. A quick grep confirms there's no xkb/keymap wiring anywhere in the Séance source.

Other GTK terminals (GNOME Terminal, etc.) avoid this because they translate the event via XKB themselves — effectively using keyval — before writing to the PTY.

Repro

  1. setxkbmap -option caps:swapescape (or the GNOME Tweaks equivalent, or any other XKB-level swap)
  2. Open any terminal other than Séance — pressing the physical CapsLock key produces Escape. ✓
  3. Open Séance, open a pane, run cat -v or showkey -a or any TUI that reacts to Escape (e.g. Claude Code's input box) — pressing physical CapsLock still registers as CapsLock, not Escape.

Suggested fixes

Either should resolve it; (b) is more invasive but more correct w.r.t. the user's full XKB configuration:

(a) For non-text keys, translate keyval → the corresponding Ghostty key constant in Séance and pass that (or pass a synthetic "post-XKB" keycode) to ghostty_input_key_s. This is the minimal change.

(b) Build an xkb_keymap from the GTK/GDK display's current keymap and pass it through to libghostty when constructing the surface, so Ghostty's internal key resolution matches the session's XKB state (including caps:swapescape, Compose, custom layouts, etc.).

Workaround

For anyone hitting this before a fix lands: move the swap from the XKB layer to the evdev layer. With keyd:

[main]
capslock = esc
esc = capslock

That rewrites the scancode before it reaches the display server, so Séance sees the swapped keycode and everything just works.

Happy to send a PR if a maintainer confirms which of (a)/(b) is preferred — or leave it to someone who knows the libghostty embedding surface better than I do.

Thanks for Séance — great tool.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions