Windows: Shift+Enter indistinguishable from Enter (modifiers dropped by libuv console-to-VT translation)
Summary
On Windows, Shift+Enter is indistinguishable from Enter inside Claude Code, so a shift+enter -> chat:newline binding in keybindings.json can never fire. The Windows console event carries the Shift modifier correctly all the way to the process — it is dropped by libuv's console-event-to-VT translation layer, which forwards only the character and discards dwControlKeyState.
This is not terminal-specific. It affects any Node/Bun CLI on Windows and does not affect Rust CLIs running in the very same terminal session.
Environment
- Claude Code 2.1.263 (native binary, Bun v1.4.1)
- Windows 11 Pro 10.0.26200
- Terminal: Netcatty (Electron + xterm.js, local shell over ConPTY), full kitty keyboard protocol support
TERM=xterm-256color, shell: PowerShell
Runtime comparison (same terminal, same ConPTY session)
| CLI | Runtime | Shift+Enter |
|---|---|---|
| Codex | Rust (crossterm) | works |
| Grok CLI | Rust/Go | works |
| Claude Code | Bun / libuv | broken |
| Antigravity CLI (agy) | Node / libuv | broken |
The split follows the runtime, not the terminal.
Root cause evidence
Windows console delivers the modifier intact
Reading console events directly via .NET ReadKey (the same path crossterm uses):
while($true){$k=[Console]::ReadKey($true); "$($k.Key) | mods=$($k.Modifiers) | char=$([int]$k.KeyChar)"; if($k.Key -eq 'Escape'){break}}
Enter -> Enter | mods=0 | char=13
Shift+Enter -> Enter | mods=Shift | char=13
Alt+Enter -> Enter | mods=Alt | char=13
Ctrl+Enter -> Enter | mods=Control | char=10
INPUT_RECORD carries Shift, Alt and Control correctly. ConPTY and the terminal are both fine.
The Node/Bun path loses it
Reading the same keypresses through process.stdin in raw mode (libuv TTY path, identical to Claude Code's):
Enter -> 0d
Shift+Enter -> 0d <- collides with Enter
Ctrl+Enter -> 0a
libuv's uv_tty raw reader calls ReadConsoleInputW, then translates INPUT_RECORD into VT sequences using its own table. For VK_RETURN it always emits \r and never consults dwControlKeyState. Per-key comparison:
| Key | console event | char | delivered to app |
|---|---|---|---|
| Enter | mods=0 | 13 | 0d |
| Shift+Enter | mods=Shift | 13 | 0d (collision) |
| Alt+Enter | mods=Alt | 13 | 0d (collision) |
| Ctrl+Enter | mods=Control | 10 | 0a (distinguishable) |
Ctrl+Enter survives only because the console assigns it char 10 rather than 13 — not because the modifier made it through. The Claude Code binary imports ReadConsoleInputW from KERNEL32 and contains a bun-conpty- marker, consistent with this path.
The kitty keyboard protocol does not help here, and is separately gated
The terminal answers the capability query correctly — sending CSI ? u returns 1b 5b 3f 30 75 (ESC[?0u), and that reply travels through ConPTY and libuv to the app. But after pushing kitty mode with CSI > 1 u, Shift+Enter still arrives as 0d, because the modifier is already gone before the app ever sees a byte.
Two related observations while investigating:
- Enablement is a hardcoded allowlist. In 2.1.260 / 2.1.261 / 2.1.263 (identical across all three):
``js``
var te = ["iTerm.app","kitty","WezTerm","ghostty","tmux","windows-terminal","WarpTerminal"];
function KDt(t){ return te.includes(t ?? a.terminal ?? "") }
function UPe(t){ return KDt() ? KK + (t?.legacyKitty?Flr:$lr) + Ulr : "" }
Terminal detection falls back to return process.env.TERM, so this terminal resolves to "xterm-256color" and the enable sequence is never sent. Setting KITTY_WINDOW_ID=1 makes detection return "kitty" and the allowlist match, but Shift+Enter still fails — confirming the allowlist is a separate, secondary issue.
- The capability reply is parsed but never consumed. The binary contains
``js``
if(s=i1.exec(t)) return {type:"kittyKeyboard", flags:parseInt(s[1],10)};
kittyKeyboard appears nowhere else in the binary — the terminal's answer is parsed and then discarded, so enablement is decided purely by the allowlist. crossterm, by contrast, queries first (supports_keyboard_enhancement) and enables based on the reply.
Suggested fixes
- Primary: on Windows, set
ENABLE_VIRTUAL_TERMINAL_INPUT(0x0200) on the stdin console mode so the raw VT byte stream reaches the app and bypasses libuv's translation. This is the ConPTY-recommended approach and would make kitty protocol sequences usable. - Alternative: read
INPUT_RECORDviaReadConsoleInputWdirectly and handle modifiers explicitly, as crossterm does. - Related: switch kitty protocol enablement from the hardcoded allowlist to the runtime query whose parser already exists, so any conforming terminal benefits.
Reproduction
- Launch Claude Code on Windows in a terminal that supports the kitty keyboard protocol (reproduced in Netcatty)
- Bind
shift+entertochat:newlinein~/.claude/keybindings.json - Press Shift+Enter — the message is submitted instead of a newline being inserted
Workaround
Ctrl+Enter emits 0a and is distinguishable from Enter's 0d, so it works on Windows today without any keyboard protocol.
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
Still reproducing on 2.1.273 (native
claude.exe), on a different terminal from the onein the issue body: Windows 11 Pro 26200 + Windows Terminal 1.24.11911.0 + PowerShell 7,
stock WT settings. That supports the "not terminal-specific, follows the runtime" framing
here — Codex CLI (Rust/crossterm) handles Shift+Enter in this same WT tab with no terminal
configuration at all, while Claude Code does not.
Adding three things: one new piece of evidence from the 2.1.273 binary, the history of the
two older issues this replaces, and confirmation that the allowlist you quoted is unchanged.
1.
WIN32_INPUT_MODEis now a named constant — but only the disable is wired upThis is a bit more damning than the earlier "no enable exists anywhere" finding, because
in 2.1.273 the mode is explicitly present in the DECSET table and deliberately generated
in one direction only:
Every mode the app actually uses is instantiated in both directions —
l7e/PRe,Aar/sEt,iEt/J1e,Rar/aEt,JT/QT,Q1e/Z1e, and the mouse modes via_/tandP9.WIN32_INPUT_MODEis the only one with a disable and no enable:ID(gg.WIN32_INPUT_MODE)occurs zero times in the binary. (ALT_SCREEN:47hasneither, i.e. it is simply unused.)
So the app still emits
ESC[?9001lon teardown for a mode it never turned on — 49 releasesafter this was first reported against 2.1.216.
That matters for suggested fix #1 in the issue body: whichever route is taken
(
ENABLE_VIRTUAL_TERMINAL_INPUT+9001h, orReadConsoleInputWwith explicit modifierhandling), the teardown half and the mode constant are already in place, so the missing
piece is the negotiation, not the plumbing.
2. Two older issues covered this and both are now closed — please keep this one open
requires Win32 input support", opened 2026-06,
bug/platform:windows/area:tui/keybindings. It carries @deganii's win32-input-mode probe (rawESC[13;28;13;1;48;1_captures proving WT transmits
SHIFT_PRESSED), @anton-ubi's confirmation, and my own2.1.224 analysis. It was closed 2026-09-10 as
staleby github-actions, with*"Closing for now — inactive for too long. Please open a new issue if this is still
relevant."*
duplicateof #77311 on 2026-08-15.So the duplicate was consolidated into an issue that was then auto-closed for inactivity,
and the bug survived both. This issue (#92771) is now the only open place where the root
cause is written down, hence piling the evidence here rather than opening a third ticket.
Two findings from #77311 worth carrying over, since they bear directly on the fixes
proposed here:
13maps toreturnand the modifier parameter is decoded as(param - 1)bitmask →shift / meta / ctrl / super. You can verify end-to-end today by hand-feeding CSI-u:bind Shift+Enter to
{"action":"sendInput","input":"\^[[13;2u"}in WT'ssettings.json, with"shift+enter": "chat:newline"in~/.claude/keybindings.json—it works. Both halves are required, which is a common way to conclude the keybinding
system is broken when it isn't.
sendInputworkaround costs more than PSReadLine. Sending"\n"(or CSI-u) fromWT replaces the real key event with injected bytes for every app in the terminal, so it
regresses Codex CLI, which handles Shift+Enter correctly today. Anyone running both
agents in one WT has to choose which one gets a working Shift+Enter.
3. The allowlist is unchanged in 2.1.273, and there are two more copies of it
Your
tearray is byte-identical in this build:The same
windows-terminalentry also appears in two further tables that driveuser-visible text, which is why this bug is so hard to self-diagnose:
Terminal detection resolves WT via
if (process.env.WT_SESSION) return "windows-terminal",so on every Windows Terminal install
/terminal-setupreports success, the UI advertisesshift+enteras the newline key, and no workaround is ever offered — for a key that cannotwork. Dropping
windows-terminalfromGxrandMeis a one-line change that would stopthe tool from reporting success while the key does nothing, independently of the harder
input-path fix. (The same claim is in the
terminal-config docs.)
Related: #87888 reaches the same conclusion from the Windows Terminal side, with
win32-input-mode captures showing
Cs=16 SHIFT_PRESSEDarriving intact, and a diagnosisthat
ctrl+enter(Uc=10) is flattened ontoctrl+jbefore the matcher sees it.Shift+Enter is still broken for me on Claude Code 2.1.274 / Windows Terminal 1.24.11911.0 / Windows 11 26200.
It works in Codex in the same terminal.
Reproduced on Claude Code 2.1.278 + Windows Terminal 1.24. The modifier is lost in the Bun/libuv console-input path, not in Claude Code's CSI-u decoder. Enabling
?9001halone would not fix this path.Environment: Claude Code 2.1.278 native (
claude.exe) · Windows 10 19045 · Windows Terminal 1.24.11911.0 · PowerShell 7.1. A full key event reaches the console, then libuv flattens it
ReadConsoleInputWcan observe Shift+Enter asVK_RETURN,UnicodeChar=0x0D, andSHIFT_PRESSED. WT also defaultscompatibility.input.forceVTtofalse, its non-legacy input path.Claude's stdin console mode is
0x0208(ENABLE_WINDOW_INPUT | ENABLE_VIRTUAL_TERMINAL_INPUT), and the binary containsuv_process_tty_read_raw_req,uv_tty_set_mode, andReadConsoleInputW.In libuv's Windows
uv_ttyreader, a key withUnicodeChar != 0is emitted as that character.dwControlKeyStateis used for Alt handling, but Shift/Control are not encoded. Those modifiers are considered only on the function-key path (UnicodeChar == 0).Control test with stock Node, using
WriteConsoleInputWto inject directly into its console:| injected
KEY_EVENT|process.stdin||---|---|
| Enter:
Uc=0x0D, no modifier |0d|| Shift+Enter:
Uc=0x0D,SHIFT_PRESSED|0d|The two complete output streams were byte-identical.
2. The same loss occurs end to end in Claude Code
WriteConsoleInputWbypasses terminal/PTY negotiation and places a completeKEY_EVENTdirectly in Claude's console input buffer.| injected input | observed result |
|---|---|
| Enter:
VK=0x0D, Uc=0x0D, no modifier | normal Enter || Shift+Enter:
VK=0x0D, Uc=0x0D, SHIFT_PRESSED| byte-identical to normal Enter || Ctrl+Enter:
VK=0x0D, Uc=0x0A, CTRL_PRESSED| newline |This isolates the loss to the console-buffer → Bun/libuv → key matcher path. On this observed path, emitting
ESC[?9001hcannot restore the modifier: even a full-fidelity event already in the buffer is flattened before Claude sees it.The Ctrl+Enter result is also expected: only
0x0Asurvives, so it is interpreted as the existingctrl+j: chat:newlinebinding rather thanctrl+enter.3. Claude's extended-key decoders already work
With a Windows Terminal-like environment (
WT_SESSION,TERM=xterm-256color), Claude 2.1.278 emits:After startup, injecting literal characters gives:
| literal input | result |
|---|---|
|
ESC[13;2u(kitty CSI-u) | Shift+Enter → newline ||
ESC[27;2;13~(modifyOtherKeys) | Shift+Enter → newline ||
[13;2uwithout ESC | inserted as text |No
keybindings.jsonentry is required. The decoders are functional when Claude enables itsextendedKeyspath. In an unrecognized environment such asTERM=dumb, Claude neither pushes these modes nor decodes the same input as Shift+Enter.The startup
ESC[?9001lis the defensive reset in theextendedKeysoff sequence; the binary has no matching enable path or win32-input-mode record parser.Built-in Chat bindings in 2.1.278 include:
What works now
Ctrl+Jfor newline;Ctrl+X Ctrl+Sfor send-now.sendInput: map Shift+Enter to\^[[13;2uand Ctrl+Enter to\^[[13;5u. Claude decodes both on its WTextendedKeyspath. WT mappings are terminal-wide and may affect other programs in the same window.uv_ttypath. The tested Windows 10 system-ConPTY path also consumed literal CSI-u instead of passing it through as bytes.Suggested fix
One concrete Windows fix is to read Enter-family
KEY_EVENTs directly withReadConsoleInputWand preservedwControlKeyState, instead of relying on libuv's lossy character-key translation. Claude already ships awin32-kernel32bun:ffimodule and the required kernel32 symbols.This needs coordination with the existing stdin reader so two consumers do not race on the console input buffer. An equivalent runtime-level fix that preserves modifiers before the key matcher would also solve the problem.
Method note: the console-buffer and decoder tests used a self-built ConPTY plus direct
WriteConsoleInputWinjection. They prove the loss after the buffer and the application-side decoding behavior; they do not claim that every terminal or PTY backend has identical transport behavior.@C:/Users/gouki/AppData/Local/Temp/cc-92771-comment.md