From 9ed3896cd439b0d27b25198fc3613df3f7c9a233 Mon Sep 17 00:00:00 2001 From: Filipe Forattini Date: Fri, 25 Sep 2026 09:52:38 -0300 Subject: [PATCH] fix: write Alt+Enter through zellij as CSI-u With the kitty keyboard protocol off, zellij forwards Alt+Enter as a bare ESC CR that pane apps cannot tell from Escape+Enter or from the ESC CR some terminals send for Shift+Enter. Bind it to ESC[13;3u in the locked and normal modes, map the sequence to a newline in readline (the same newline ble.sh inserts for M-RET), and recognise the 1.0.169 base so untouched configs upgrade. --- README.md | 5 +++- config/bash/inputrc.conf | 7 +++++ config/zellij/config.kdl | 8 ++++++ src/alt-enter.test.ts | 48 +++++++++++++++++++++++++++++++ src/dotfiles.ts | 3 ++ src/zellij-config-upgrade.test.ts | 38 +++++++++++++++++++++++- 6 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 src/alt-enter.test.ts diff --git a/README.md b/README.md index 56e6ab1f..8025de2c 100644 --- a/README.md +++ b/README.md @@ -518,7 +518,10 @@ rather than pinning the whole converge indefinitely. Convergence makes the input gestures a workstation contract rather than an agent-specific surprise. Shift+Enter is emitted as CSI-u by Alacritty and Windows Terminal, mapped to a newline in Claude Code, and stated explicitly in -RedCode's OpenCode-compatible `tui.json`; Codex receives the same terminal sequence. Alt+V sends +RedCode's OpenCode-compatible `tui.json`; Codex receives the same terminal sequence. Inside +zellij, Alt+Enter is written through as CSI-u (`ESC[13;3u`) rather than a bare +`ESC CR`, so agents can tell it apart from Escape and Enter; bash turns it into +a newline, as ble.sh already does for `M-RET`. Alt+V sends the raw image-paste gesture on Alacritty and Windows Terminal, while Ctrl+Shift+V remains text paste. Plain Shift+V cannot be used because it is the ordinary uppercase `V`. Every config merge is non-destructive: malformed JSON diff --git a/config/bash/inputrc.conf b/config/bash/inputrc.conf index a8d41d71..ed5d2712 100644 --- a/config/bash/inputrc.conf +++ b/config/bash/inputrc.conf @@ -42,6 +42,13 @@ set show-all-if-unmodified on "\e[13;2u": "\C-q\C-j" "\e[27;2;13~": "\C-q\C-j" +# Alt+Enter, as ESC[13;3u. zellij rewrites the key to that sequence +# (config/zellij/config.kdl) so agents can tell it from ESC CR. ble.sh, +# which owns the prompt inside zellij, decodes it natively as M-RET and +# inserts a newline; this is the same newline for plain readline, so the +# shell never echoes the raw bytes. +"\e[13;3u": "\C-q\C-j" + # Add a trailing slash immediately when completing a symlink to a directory. set mark-symlinked-directories on diff --git a/config/zellij/config.kdl b/config/zellij/config.kdl index cbad0059..6d2b0e1a 100644 --- a/config/zellij/config.kdl +++ b/config/zellij/config.kdl @@ -77,9 +77,17 @@ keybinds clear-defaults=true { // downgrade, so the bytes arrive intact; bash panes are covered // too, because the inputrc maps the same sequence to a newline. // Only in the two modes where typing reaches the pane at all. + // + // Alt+Enter gets the same treatment for the same reason. With the + // kitty protocol off, zellij forwards it as ESC CR, which a pane app + // cannot tell apart from a lone Escape followed by Enter, nor from + // the ESC CR some terminals send for Shift+Enter. ESC[13;3u is its + // CSI-u spelling (3 is 1 plus the alt bit); ble.sh already reads it + // as M-RET, and the inputrc maps it for plain readline. shared_among "locked" "normal" { bind "Shift Enter" { Write 27 91 49 51 59 50 117; } bind "Ctrl Enter" { Write 27 91 49 51 59 50 117; } + bind "Alt Enter" { Write 27 91 49 51 59 51 117; } } locked { bind "Ctrl g" { SwitchToMode "normal"; } diff --git a/src/alt-enter.test.ts b/src/alt-enter.test.ts new file mode 100644 index 00000000..c78a579d --- /dev/null +++ b/src/alt-enter.test.ts @@ -0,0 +1,48 @@ +/** + * Alt+Enter, kept distinguishable through zellij. + * + * red-dev runs zellij with the kitty keyboard protocol off, so zellij + * forwards Alt+Enter as ESC CR. A pane app cannot tell those two bytes + * from a lone Escape followed by Enter, nor from the ESC CR some + * terminals send for Shift+Enter. The zellij keybind writes the key's + * CSI-u spelling instead — ESC[13;3u, 13 for Enter and 3 for 1 plus the + * alt bit — and readline maps that sequence to the newline ble.sh + * already inserts for M-RET, so the shell never echoes raw bytes. + */ + +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { composeZellijConfig } from "./zellij-layer.ts"; + +const ALT_ENTER = "\u001b[13;3u"; + +const root = `${import.meta.dir}/..`; +const inputrc = readFileSync(`${root}/config/bash/inputrc.conf`, "utf8"); +const zellij = readFileSync(`${root}/config/zellij/config.kdl`, "utf8"); + +const decimalBytes = [...ALT_ENTER].map((c) => c.codePointAt(0)).join(" "); +const binding = `bind "Alt Enter" { Write ${decimalBytes}; }`; + +describe("Alt+Enter through zellij", () => { + test("is written through as ESC[13;3u in the modes typing reaches", () => { + const shared = zellij.indexOf('shared_among "locked" "normal" {'); + const close = zellij.indexOf("}\n locked {", shared); + expect(shared).toBeGreaterThan(-1); + expect(zellij.slice(shared, close)).toContain(binding); + }); + + test("survives a user layer that binds other keys in the same modes", () => { + const composed = composeZellijConfig( + zellij, + 'keybinds {\n shared_among "locked" "normal" {\n bind "Ctrl Tab" { GoToNextTab; }\n }\n}\n', + ); + expect(composed).toContain(binding); + expect(composed).toContain('bind "Ctrl Tab" { GoToNextTab; }'); + }); +}); + +describe("bash turns it into a newline", () => { + test("readline inserts a newline instead of printing the bytes", () => { + expect(inputrc).toContain(`"${ALT_ENTER.replace("\u001b", "\\e")}": "\\C-q\\C-j"`); + }); +}); diff --git a/src/dotfiles.ts b/src/dotfiles.ts index 552033ce..b6dd8539 100644 --- a/src/dotfiles.ts +++ b/src/dotfiles.ts @@ -347,6 +347,9 @@ const SHIPPED_ZELLIJ_CONFIGS = new Set([ "f8611722c0b6cbcd30981736bb29ee29b483e80913cf7d3c0462865a2ad704cb", // The input-stack repair on top of 1.0.145's tab navigation. "dc5ea3895fd0a711d06147871b1e16de3d8daa8231eba4c2f818ff201afffd0b", + // Alt+Enter written through as ESC[13;3u, so agents can tell it from + // the bare ESC CR zellij forwards with the kitty protocol off. + "d72b8187bbd75243bc81836c83a0f59b844b09f26026dcbc5e541e0d940c995e", ]); /** diff --git a/src/zellij-config-upgrade.test.ts b/src/zellij-config-upgrade.test.ts index 8e96c364..50fec93d 100644 --- a/src/zellij-config-upgrade.test.ts +++ b/src/zellij-config-upgrade.test.ts @@ -33,6 +33,20 @@ function previouslyShipped(): string { return legacy; } +/** + * Today's fixture as it was before Alt+Enter was bound: the binding and + * the comment paragraph that explains it removed, nothing else. + */ +function withoutAltEnter(config: string): string { + const comment = config.indexOf(" //\n // Alt+Enter gets the same treatment"); + const end = config.indexOf(" shared_among \"locked\" \"normal\" {", comment); + expect(comment).toBeGreaterThan(-1); + return (config.slice(0, comment) + config.slice(end)).replace( + ' bind "Alt Enter" { Write 27 91 49 51 59 51 117; }\n', + "", + ); +} + describe("zellij config upgrade", () => { test("writes when there is nothing there", () => { expect(zellijConfigAction(null, shipped)).toBe("write"); @@ -80,7 +94,7 @@ describe("zellij config upgrade", () => { // exact generated tail an arbitrary Ubuntu user would have received. const inputSettings = shipped.indexOf("// Zellij's startup tips"); const keys = shipped.indexOf("// ------------------------------------------------------------- keys"); - const oldBase = (shipped.slice(0, inputSettings) + shipped.slice(keys)).replace( + const oldBase = withoutAltEnter(shipped.slice(0, inputSettings) + shipped.slice(keys)).replace( ' bind "Ctrl Enter" { Write 27 91 49 51 59 50 117; }\n', "", ); @@ -96,6 +110,28 @@ copy_command "bash /home/someone/.local/share/red-dev/config/bash/linux-clipboar expect(zellijConfigAction(generated, shipped)).toBe("upgrade"); }); + test("upgrades the 1.0.169 config that forwarded Alt+Enter as ESC CR", () => { + // The config every machine had before Alt+Enter was written through. + // Recognised both bare and under a Linux desktop's generated tail, + // or those machines would never receive the ESC[13;3u binding. + const previous = withoutAltEnter(shipped); + expect(previous).not.toContain('bind "Alt Enter"'); + expect(new Bun.CryptoHasher("sha256").update(previous).digest("hex")).toBe( + "dc5ea3895fd0a711d06147871b1e16de3d8daa8231eba4c2f818ff201afffd0b", + ); + expect(zellijConfigAction(previous, shipped)).toBe("upgrade"); + const desktop = `${previous} +// Generated for this target by red-dev. +// +// The clipboard zellij should write to. Without it zellij uses OSC 52, +// which asks the terminal to do the copying and cannot tell whether it +// did — so a selection reports success and the clipboard keeps whatever +// was in it. +copy_command "bash /home/someone/.local/share/red-dev/config/bash/linux-clipboard.sh" +`; + expect(zellijConfigAction(desktop, shipped)).toBe("upgrade"); + }); + test("upgrades the stub whatever comment sits above it", () => { // Recognised by what it contains rather than by its hash: the // comment applyZellij writes has changed before and would again.