Skip to content

fix: ignore keyboard lock state when matching shortcuts (#327) - #331

Open
kbwo wants to merge 4 commits into
mainfrom
test/issue-327-e2e
Open

fix: ignore keyboard lock state when matching shortcuts (#327)#331
kbwo wants to merge 4 commits into
mainfrom
test/issue-327-e2e

Conversation

@kbwo

@kbwo kbwo commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Fixes #327.

Problem

Ctrl+E (the returnToMenu shortcut) silently did nothing inside an attached session whenever Num Lock or Caps Lock was on. The key was forwarded to the child process with no error and no visual feedback, which is very hard to diagnose from the user side.

Extended keyboard protocols report the modifiers of a keypress as a single number that also carries the lock state of the keyboard (+64 Caps Lock, +128 Num Lock) alongside the modifiers actually being held. shortcutManager compared the incoming bytes against pre-built strings whose modifier field was hardcoded to 5 (ctrl), so a Ctrl+E arriving as ESC[101;133u matched nothing and fell through to session.process.write(data) in Session.tsx.

Laptops commonly boot with Num Lock on internally even without a numpad, so this hits every Ctrl shortcut for those users.

Approach

The three sequences that carry the modifiers as a number — kitty's CSI-u, xterm/tmux modifyOtherKeys, and the ESC[1;<mod><letter> form reported in #82/#107 — are now matched by parsing the modifier field and masking out the lock bits, instead of by string comparison against a fixed list. The pre-built list keeps only the forms that have no modifier field (the ASCII control code and bare escape), so there is a single place that decides which extended sequence means which shortcut.

Key release events (...:3u) are excluded, so a release cannot fire the shortcut a second time.

Verification

Developed test-first; both test levels were confirmed failing before the fix.

  • End-to-end (src/e2e/): a new test boots the real CLI in a pseudo terminal against a throwaway git repo, attaches a session backed by a fake command, writes one key sequence, and checks whether the menu comes back. It covers the ASCII control code, CSI-u without lock bits, and the Num Lock and Caps Lock variants. The two lock variants reproduced the issue on the first commit of this branch and pass on the second.

    The harness runs as a separate bun process because the pseudo terminal needs Bun's Bun.Terminal API while Vitest runs under node; it is skipped where bun or a Unix pseudo terminal is unavailable.

  • Unit (shortcutManager.test.ts): both lock bits, their combination, the uppercase code point, modifyOtherKeys, and a sequence embedded in a larger chunk — plus the cases that must still not match, so the masking cannot become too permissive: a different key carrying a lock bit, an added real modifier (shift), ctrl missing, and alt instead of ctrl.

bun run lint, bun run typecheck and bun run test all pass.

Review notes

The two commits are meant to be read in order: the first one only adds the end-to-end test and pins down the broken behaviour, the second one fixes it and flips those expectations.

🤖 Generated with Claude Code

https://claude.ai/code/session_01P7fASJhs75iXWmgndrabpv

kbwo and others added 4 commits August 30, 2026 22:21
Ctrl+E (returnToMenu) is reported to do nothing while a session is
attached when Num Lock or Caps Lock is on: the kitty keyboard protocol
adds a lock-state bit to the CSI-u modifier mask, and shortcutManager
only matches the mask without those bits.

Add an end-to-end test that runs the real CLI in a pseudo terminal
against a throwaway git repo, attaches a session backed by a fake
command, writes one key sequence, and checks whether the menu comes
back. It covers the ASCII control code, the CSI-u form without lock
bits (both work) and the Num Lock / Caps Lock variants (both are
swallowed), so the reported behaviour is pinned down.

The lock-bit expectations assert the current broken behaviour and have
to be flipped to `true` when the issue is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7fASJhs75iXWmgndrabpv
Ctrl+E (returnToMenu) did nothing inside a session whenever Num Lock or
Caps Lock was on. Extended keyboard protocols report the modifiers of a
keypress as a number that also carries the keyboard's lock state (+64
Caps Lock, +128 Num Lock), and shortcutManager only compared the
incoming bytes against pre-built strings whose modifier field was
hardcoded to 5 (ctrl). A Ctrl+E arriving as ESC[101;133u matched none of
them and was forwarded to the child process instead, with no feedback.

Match the three parameterized sequences (CSI u, modifyOtherKeys and
CSI 1;<mod><letter>) by parsing the modifier field and masking out the
lock bits, rather than by string comparison against a fixed list. The
pre-built list now only holds the forms that have no modifier field: the
ASCII control code and bare escape. Key release events are ignored so a
release cannot trigger the shortcut a second time.

The end-to-end expectations for the Num Lock and Caps Lock variants are
flipped from the reproduction to the fixed behaviour, and the unit tests
cover both lock bits, their combination, an embedded sequence, and the
cases that must still not match (another key, an added real modifier,
ctrl missing, alt instead of ctrl).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7fASJhs75iXWmgndrabpv
The end-to-end test failed on GitHub Actions with "menu never rendered"
while passing locally. Ink does not paint frames to stdout when it
believes it runs in CI: it keeps the frame in memory and writes it only
on unmount (the isInCi branch in ink/build/ink.js, fed by the is-in-ci
package, which checks CI and CONTINUOUS_INTEGRATION). The child
inherited CI=true from the runner, so the menu never appeared and the
harness timed out before it could send a key.

Drop those two variables from the environment handed to the child. The
harness drives a real pseudo terminal and has to see the menu the way a
user does, which is exactly what Ink's CI mode suppresses.

Reproduced locally with `CI=true bun run test` before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7fASJhs75iXWmgndrabpv
`bun install` runs the build through the `prepare` script, so the test
suite also picks up the compiled copies under dist/. The compiled test
still pointed at `returnToMenuPtyHarness.ts`, which does not exist
there, and failed in CI with "Module not found" while the src copy
passed.

Resolve the harness next to the test file by the extension that is
actually present, the same way the harness resolves the CLI entry
point. The dist copy then drives the built dist/cli.js, so the run also
covers the build output.

Reproduced locally with `bun run build && CI=true bun run test`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P7fASJhs75iXWmgndrabpv
@kbwo
kbwo marked this pull request as ready for review September 2, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ctrl+E (returnToMenu) silently fails when Num Lock is on, modifier byte mismatch in CSI-u parsing

1 participant