Windows: Shift+Enter indistinguishable from Enter (modifiers dropped by libuv console-to-VT translation)

Status Open
Reported on v2.1.263
Maintainer reply None cached
Activity 6 comments · opened Sep 8, 2026

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:

  1. 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.

  1. 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

  1. 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.
  2. Alternative: read INPUT_RECORD via ReadConsoleInputW directly and handle modifiers explicitly, as crossterm does.
  3. Related: switch kitty protocol enablement from the hardcoded allowlist to the runtime query whose parser already exists, so any conforming terminal benefits.

Reproduction

  1. Launch Claude Code on Windows in a terminal that supports the kitty keyboard protocol (reproduced in Netcatty)
  2. Bind shift+enter to chat:newline in ~/.claude/keybindings.json
  3. 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.

View original on GitHub ↗

4 Comments

yasuyuki · 29 days ago

Still reproducing on 2.1.273 (native claude.exe), on a different terminal from the one
in 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_MODE is now a named constant — but only the disable is wired up

This 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:

var gg = {
  CURSOR_VISIBLE:25, ALT_SCREEN:47, ALT_SCREEN_CLEAR:1049,
  MOUSE_NORMAL:1000, MOUSE_BUTTON:1002, MOUSE_ANY:1003, MOUSE_SGR:1006,
  FOCUS_EVENTS:1004, BRACKETED_PASTE:2004, THEME_NOTIFY:2031,
  SYNCHRONIZED_UPDATE:2026, WIN32_INPUT_MODE:9001
};
function ID(E){ return zi(`?${E}h`) }   // enable
function i3(E){ return zi(`?${E}l`) }   // disable

// every other mode gets both; 9001 gets only:
var lEt = i3(gg.WIN32_INPUT_MODE);       // ESC[?9001l

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
_/t and P9. WIN32_INPUT_MODE is the only one with a disable and no enable:
ID(gg.WIN32_INPUT_MODE) occurs zero times in the binary. (ALT_SCREEN:47 has
neither, i.e. it is simply unused.)
So the app still emits ESC[?9001l on teardown for a mode it never turned on — 49 releases
after 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, or ReadConsoleInputW with explicit modifier
handling), 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

  • #77311 — "Windows: Shift+Enter does not insert newline — terminal protocol limitation

requires Win32 input support", opened 2026-06, bug / platform:windows / area:tui /
keybindings. It carries @deganii's win32-input-mode probe (raw ESC[13;28;13;1;48;1_
captures proving WT transmits SHIFT_PRESSED), @anton-ubi's confirmation, and my own
2.1.224 analysis. It was closed 2026-09-10 as stale by github-actions, with
*"Closing for now — inactive for too long. Please open a new issue if this is still
relevant."*

  • #80817 — same bug reported against WT, closed as duplicate of #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:

  • The CSI-u decode path is already complete; only the negotiation is missing. Keycode

13 maps to return and 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's
settings.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.

  • The sendInput workaround costs more than PSReadLine. Sending "\n" (or CSI-u) from

WT 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 te array is byte-identical in this build:

var eTn = ["iTerm.app","kitty","WezTerm","ghostty","tmux","windows-terminal","WarpTerminal"];

The same windows-terminal entry also appears in two further tables that drive
user-visible text, which is why this bug is so hard to self-diagnose:

// /terminal-setup
var Gxr = { ghostty:"Ghostty", kitty:"Kitty", WarpTerminal:"Warp", WezTerm:"WezTerm",
            "windows-terminal":"Windows Terminal" };
// -> "Check terminal setup (Shift+Enter is natively supported in Windows Terminal)"

// newline hint in the composer
var Me  = { ghostty:"Ghostty", kitty:"Kitty", "iTerm.app":"iTerm2", WezTerm:"WezTerm",
            WarpTerminal:"Warp", "windows-terminal":"Windows Terminal" };

Terminal detection resolves WT via if (process.env.WT_SESSION) return "windows-terminal",
so on every Windows Terminal install /terminal-setup reports success, the UI advertises
shift+enter as the newline key, and no workaround is ever offered — for a key that cannot
work. Dropping windows-terminal from Gxr and Me is a one-line change that would stop
the 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_PRESSED arriving intact, and a diagnosis
that ctrl+enter (Uc=10) is flattened onto ctrl+j before the matcher sees it.

bobbybasketball1999 · 27 days ago

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.

AntaresFeng · 24 days ago

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 ?9001h alone 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

ReadConsoleInputW can observe Shift+Enter as VK_RETURN, UnicodeChar=0x0D, and SHIFT_PRESSED. WT also defaults compatibility.input.forceVT to false, its non-legacy input path.

Claude's stdin console mode is 0x0208 (ENABLE_WINDOW_INPUT | ENABLE_VIRTUAL_TERMINAL_INPUT), and the binary contains uv_process_tty_read_raw_req, uv_tty_set_mode, and ReadConsoleInputW.

In libuv's Windows uv_tty reader, a key with UnicodeChar != 0 is emitted as that character. dwControlKeyState is 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 WriteConsoleInputW to 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

WriteConsoleInputW bypasses terminal/PTY negotiation and places a complete KEY_EVENT directly 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[?9001h cannot 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 0x0A survives, so it is interpreted as the existing ctrl+j: chat:newline binding rather than ctrl+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:

ESC[>5u       x2
ESC[>4;2m     x2
ESC[?9001l    x1
ESC[?9001h    x0

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;2u without ESC | inserted as text |

No keybindings.json entry is required. The decoders are functional when Claude enables its extendedKeys path. In an unrecognized environment such as TERM=dumb, Claude neither pushes these modes nor decodes the same input as Shift+Enter.

The startup ESC[?9001l is the defensive reset in the extendedKeys off sequence; the binary has no matching enable path or win32-input-mode record parser.

Built-in Chat bindings in 2.1.278 include:

enter              chat:submit
ctrl+j             chat:newline
ctrl+enter         chat:sendNow
ctrl+x ctrl+s      chat:sendNow
ctrl+x enter       chat:queueSubmit

What works now

  • Zero configuration: Ctrl+J for newline; Ctrl+X Ctrl+S for send-now.
  • WT sendInput: map Shift+Enter to \^[[13;2u and Ctrl+Enter to \^[[13;5u. Claude decodes both on its WT extendedKeys path. WT mappings are terminal-wide and may affect other programs in the same window.
  • Merely changing terminals does not help when input still reaches Claude through the same Win32 console/libuv uv_tty path. 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 with ReadConsoleInputW and preserve dwControlKeyState, instead of relying on libuv's lossy character-key translation. Claude already ships a win32-kernel32 bun:ffi module 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 WriteConsoleInputW injection. 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.

GOUKI9999 · 24 days ago

@C:/Users/gouki/AppData/Local/Temp/cc-92771-comment.md

Showing cached comments. Read the full discussion on GitHub ↗