Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions config/bash/inputrc.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions config/zellij/config.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -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"; }
Expand Down
48 changes: 48 additions & 0 deletions src/alt-enter.test.ts
Original file line number Diff line number Diff line change
@@ -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"`);
});
});
3 changes: 3 additions & 0 deletions src/dotfiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);

/**
Expand Down
38 changes: 37 additions & 1 deletion src/zellij-config-upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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',
"",
);
Expand All @@ -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.
Expand Down