Created
July 23, 2026 02:45
-
-
Save Wowfunhappy/002009cf9c410ed2f751bde8e47db483 to your computer and use it in GitHub Desktop.
Claude Code: Enter creates newlines instead of submitting your message
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Claude Code: tap Enter for a newline, Option+Enter for submit. | |
| // | |
| // Tap Enter -> insert a newline | |
| // Option+Enter -> submit (convenient locally) | |
| // | |
| // Alternately, you can also hold enter to submit. Does not work in /tui fullscreen. | |
| // | |
| // Usage — Claude Code is a Bun-compiled binary, so it honors Bun's --preload: | |
| // | |
| // BUN_OPTIONS="--preload=/path/to/claude-code-enter-newline.js" claude | |
| // | |
| // (Put that in a shell alias or wrapper to make it permanent.) | |
| // | |
| // How it works: Claude submits only on a bare Return (0x0d) with no meta; a | |
| // Return carrying meta inserts a newline instead. So this rewrites stdin bytes | |
| // before Claude's key parser sees them: a tapped 0x0d becomes 0x1b 0x0d | |
| // (meta+Return = newline), and Option+Enter's 0x1b 0x0d becomes a bare 0x0d. | |
| // | |
| // Interception point: Claude (Ink on Bun) reads stdin in PAUSED mode — it waits | |
| // for 'readable' and pulls bytes with stdin.read(), and never registers a 'data' | |
| // listener. So we wrap stdin.read(). A 'data' hook is kept as a fallback for any | |
| // flowing-mode consumer, with its own state so the two can't interfere. | |
| // | |
| // Verified against Claude Code 2.1.215 / 2.1.216 on macOS 10.9 Mavericks. | |
| // Public domain / CC0 — do whatever you like with it. | |
| // This code was, naturally, written by Claude, specifically Opus 4.8. | |
| (function () { | |
| "use strict"; | |
| var ESC = 0x1b, CR = 0x0d, LB = 0x5b, BS = 0x7f; // ESC, '\r', '[', DEL/backspace | |
| // Timing thresholds (ms), env-tunable for live adjustment. Auto-repeat over | |
| // VNC measured ~110ms between bytes; local repeat is faster still. A tap or a | |
| // deliberate double-tap is slower than FAST_GAP, so it never looks like a hold. | |
| function envInt(name, dflt) { var v = parseInt(process.env[name], 10); return (v > 0) ? v : dflt; } | |
| var FAST_GAP = envInt("MF_KB_FASTGAP", 180); // gap <= this => part of an auto-repeat burst | |
| var NEED_FAST = envInt("MF_KB_NEEDFAST", 5); // this many consecutive fast gaps confirms a HOLD | |
| var RUN_END = envInt("MF_KB_RUNEND", 700); // gap > this ends a run / the post-submit swallow | |
| function now() { return Date.now(); } | |
| function createRemapper(log) { | |
| var PASTE_START = Buffer.from("\x1b[200~"); | |
| var PASTE_END = Buffer.from("\x1b[201~"); | |
| var inPaste = false; | |
| // Hold-detection state. | |
| var lastCR = 0; // timestamp of previous Return | |
| var runNewlines = 0; // newlines emitted in the current contiguous Return run | |
| var fastCount = 0; // consecutive fast gaps seen | |
| var swallowing = false; // after a hold-submit, eat the rest of the held burst | |
| function marker(buf, i, m) { | |
| if (i + m.length > buf.length) return false; | |
| for (var k = 0; k < m.length; k++) if (buf[i + k] !== m[k]) return false; | |
| return true; | |
| } | |
| function transform(buf) { | |
| var out = []; | |
| var i = 0, n = buf.length; | |
| while (i < n) { | |
| // Bracketed paste: pass through untouched, don't let it affect hold state. | |
| if (buf[i] === ESC && buf[i + 1] === LB) { | |
| if (marker(buf, i, PASTE_START)) { inPaste = true; for (var a = 0; a < PASTE_START.length; a++) out.push(buf[i + a]); i += PASTE_START.length; continue; } | |
| if (marker(buf, i, PASTE_END)) { inPaste = false; for (var b = 0; b < PASTE_END.length; b++) out.push(buf[i + b]); i += PASTE_END.length; continue; } | |
| } | |
| if (inPaste) { out.push(buf[i]); i += 1; continue; } | |
| // Option+Enter (ESC+CR in one keystroke) -> submit. Resets hold state. | |
| if (buf[i] === ESC && buf[i + 1] === CR) { | |
| out.push(CR); | |
| runNewlines = 0; fastCount = 0; swallowing = false; | |
| if (log) log("optEnter -> submit"); | |
| i += 2; continue; | |
| } | |
| if (buf[i] === CR) { | |
| var t = now(), gap = t - lastCR; lastCR = t; | |
| if (swallowing) { | |
| if (gap <= RUN_END) { if (log) log("swallow held CR gap=" + gap); i += 1; continue; } | |
| swallowing = false; runNewlines = 0; fastCount = 0; // burst ended; fresh Return | |
| } | |
| if (gap > RUN_END) { runNewlines = 0; fastCount = 0; } | |
| else if (gap <= FAST_GAP) fastCount += 1; | |
| else fastCount = 0; | |
| if (fastCount >= NEED_FAST) { | |
| // HOLD confirmed: erase this run's newlines, then submit once. | |
| for (var u = 0; u < runNewlines; u++) out.push(BS); | |
| out.push(CR); | |
| if (log) log("HOLD -> undo " + runNewlines + " nl + submit (gap=" + gap + ")"); | |
| runNewlines = 0; fastCount = 0; swallowing = true; | |
| } else { | |
| out.push(ESC, CR); // tap (so far) -> newline | |
| runNewlines += 1; | |
| if (log) log("CR -> newline gap=" + gap + " fast=" + fastCount + " run=" + runNewlines); | |
| } | |
| i += 1; continue; | |
| } | |
| // Any other byte breaks the pure-newline run so we never undo across it. | |
| runNewlines = 0; fastCount = 0; | |
| out.push(buf[i]); i += 1; | |
| } | |
| return Buffer.from(out); | |
| } | |
| return { transform: transform }; | |
| } | |
| var stdin = process.stdin; | |
| if (!stdin || !stdin.isTTY) return; | |
| if (process.env.MF_KEYBIND_DISABLE) return; // escape hatch: behave like stock Claude | |
| var dbg = null; | |
| if (process.env.MF_KEYBIND_DEBUG) { | |
| try { | |
| var fs = require("fs"), logPath = process.env.MF_KEYBIND_DEBUG; | |
| dbg = function (m) { try { fs.appendFileSync(logPath, m + "\n"); } catch (e) {} }; | |
| dbg("[enter-newline] installed on tty"); | |
| } catch (e) {} | |
| } | |
| function hexOf(b) { return Array.prototype.map.call(b, function (x) { return ("0" + x.toString(16)).slice(-2); }).join(" "); } | |
| function coerce(chunk, remap) { | |
| var wasString = typeof chunk === "string"; | |
| var b = wasString ? Buffer.from(chunk, "utf8") : chunk; | |
| if (!Buffer.isBuffer(b)) return chunk; | |
| b = remap.transform(b); | |
| return wasString ? b.toString("utf8") : b; | |
| } | |
| // Primary path: Claude pulls input via stdin.read() in paused mode. | |
| var readRemap = createRemapper(dbg); | |
| var realRead = stdin.read; | |
| if (typeof realRead === "function") { | |
| stdin.read = function (size) { | |
| var chunk = realRead.apply(this, arguments); | |
| if (chunk == null || size != null) return chunk; | |
| return coerce(chunk, readRemap); | |
| }; | |
| } | |
| // Fallback: any flowing-mode consumer using 'data' events (own remapper state). | |
| var emitRemap = createRemapper(null); | |
| var realEmit = stdin.emit; | |
| stdin.emit = function (event, chunk) { | |
| if (event === "data" && this.listenerCount("data") > 0) { | |
| return realEmit.call(this, event, coerce(chunk, emitRemap)); | |
| } | |
| return realEmit.apply(this, arguments); | |
| }; | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment