From 9a8bc6130035c0df0ee6daa5289fdfff3e4e19bc Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 7 Mar 2026 23:01:08 +0100 Subject: [PATCH] fix(tui,coding-agent): handle tmux xterm extended keys and warn on tmux setup fixes #1872 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/terminal-setup.md | 19 ++- packages/coding-agent/docs/tmux.md | 38 ++++-- .../src/modes/interactive/interactive-mode.ts | 51 +++++++ packages/tui/CHANGELOG.md | 1 + packages/tui/src/keys.ts | 129 +++++++++++------- packages/tui/test/keys.test.ts | 36 +++++ 7 files changed, 214 insertions(+), 61 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dfdab2073..0c239482e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Fixed custom tool collapsed/expanded rendering in HTML exports. Custom tools that define different collapsed vs expanded displays now render correctly in exported HTML, with expandable sections when both states differ and direct display when only expanded exists ([#1934](https://github.com/badlogic/pi-mono/pull/1934) by [@aliou](https://github.com/aliou)) +- Fixed tmux startup guidance and keyboard setup warnings for modified key handling, including Ghostty `shift+enter=text:\n` remap guidance and tmux `extended-keys-format` detection ([#1872](https://github.com/badlogic/pi-mono/issues/1872)) ## [0.57.0] - 2026-03-07 diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index ea7376a08..2e68287d2 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -8,13 +8,30 @@ Work out of the box. ## Ghostty -Add to your Ghostty config (`~/.config/ghostty/config`): +Add to your Ghostty config (`~/Library/Application Support/com.mitchellh.ghostty/config` on macOS, `~/.config/ghostty/config` on Linux): ``` keybind = alt+backspace=text:\x1b\x7f +``` + +Older Claude Code versions may have added this Ghostty mapping: + +``` keybind = shift+enter=text:\n ``` +That mapping sends a raw linefeed byte. Inside pi, that is indistinguishable from `Ctrl+J`, so tmux and pi no longer see a real `shift+enter` key event. + +If Claude Code 2.x or newer is the only reason you added that mapping, you can remove it, unless you want to use Claude Code in tmux, where it still requires that Ghostty mapping. + +If you want `Shift+Enter` to keep working in tmux via that remap, add `ctrl+j` to your pi `newLine` keybinding in `~/.pi/agent/keybindings.json`: + +```json +{ + "newLine": ["shift+enter", "ctrl+j"] +} +``` + ## WezTerm Create `~/.wezterm.lua`: diff --git a/packages/coding-agent/docs/tmux.md b/packages/coding-agent/docs/tmux.md index 804ab8276..efe6ab571 100644 --- a/packages/coding-agent/docs/tmux.md +++ b/packages/coding-agent/docs/tmux.md @@ -1,8 +1,8 @@ # tmux Setup -Pi works inside tmux, but tmux strips modifier information from certain keys by default. Without configuration, `Shift+Enter` and `Ctrl+Enter` are indistinguishable from plain `Enter`. +Pi works inside tmux, but tmux strips modifier information from certain keys by default. Without configuration, `Shift+Enter` and `Ctrl+Enter` are usually indistinguishable from plain `Enter`. -## Required Configuration +## Recommended Configuration Add to `~/.tmux.conf`: @@ -11,27 +11,49 @@ set -g extended-keys on set -g extended-keys-format csi-u ``` -Then restart tmux (not just reload): +Then restart tmux fully: ```bash tmux kill-server tmux ``` -This tells tmux to forward modified key sequences in CSI-u format when an application requests extended key reporting. Pi requests this automatically when Kitty keyboard protocol is not available. +Pi requests extended key reporting automatically when Kitty keyboard protocol is not available. With `extended-keys-format csi-u`, tmux forwards modified keys in CSI-u format, which is the most reliable configuration. + +## Why `csi-u` Is Recommended + +With only: + +```tmux +set -g extended-keys on +``` + +tmux defaults to `extended-keys-format xterm`. When an application requests extended key reporting, modified keys are forwarded in xterm `modifyOtherKeys` format such as: + +- `Ctrl+C` → `\x1b[27;5;99~` +- `Ctrl+D` → `\x1b[27;5;100~` +- `Ctrl+Enter` → `\x1b[27;5;13~` + +With `extended-keys-format csi-u`, the same keys are forwarded as: + +- `Ctrl+C` → `\x1b[99;5u` +- `Ctrl+D` → `\x1b[100;5u` +- `Ctrl+Enter` → `\x1b[13;5u` + +Pi supports both formats, but `csi-u` is the recommended tmux setup. ## What This Fixes -Without this config, tmux collapses modified enter keys to plain `\r`: +Without tmux extended keys, modified Enter keys collapse to legacy sequences: -| Key | Without config | With config | -|-----|---------------|-------------| +| Key | Without extkeys | With `csi-u` | +|-----|-----------------|--------------| | Enter | `\r` | `\r` | | Shift+Enter | `\r` | `\x1b[13;2u` | | Ctrl+Enter | `\r` | `\x1b[13;5u` | | Alt/Option+Enter | `\x1b\r` | `\x1b[13;3u` | -This affects the default keybindings (`Enter` to submit, `Shift+Enter` for newline) and any custom keybindings using modified enter keys. +This affects the default keybindings (`Enter` to submit, `Shift+Enter` for newline) and any custom keybindings using modified Enter. ## Requirements diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index bc1955d4f..1528525a8 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -512,6 +512,13 @@ export class InteractiveMode { } }); + // Check tmux keyboard setup asynchronously + this.checkTmuxKeyboardSetup().then((warning) => { + if (warning) { + this.showWarning(warning); + } + }); + // Show startup warnings const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options; @@ -586,6 +593,50 @@ export class InteractiveMode { } } + private async checkTmuxKeyboardSetup(): Promise { + if (!process.env.TMUX) return undefined; + + const runTmuxShow = (option: string): Promise => { + return new Promise((resolve) => { + const proc = spawn("tmux", ["show", "-gv", option], { + stdio: ["ignore", "pipe", "ignore"], + }); + let stdout = ""; + const timer = setTimeout(() => { + proc.kill(); + resolve(undefined); + }, 2000); + + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + proc.on("error", () => { + clearTimeout(timer); + resolve(undefined); + }); + proc.on("close", (code) => { + clearTimeout(timer); + resolve(code === 0 ? stdout.trim() : undefined); + }); + }); + }; + + const [extendedKeys, extendedKeysFormat] = await Promise.all([ + runTmuxShow("extended-keys"), + runTmuxShow("extended-keys-format"), + ]); + + if (extendedKeys !== "on" && extendedKeys !== "always") { + return "tmux extended-keys is off. Modified Enter keys may not work. Add `set -g extended-keys on` to ~/.tmux.conf and restart tmux."; + } + + if (extendedKeysFormat === "xterm") { + return "tmux extended-keys-format is xterm. Pi works best with csi-u. Add `set -g extended-keys-format csi-u` to ~/.tmux.conf and restart tmux."; + } + + return undefined; + } + /** * Get changelog entries to display on startup. * Only shows new entries since last seen version, skips for resumed sessions. diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5b6c2687b..6b62af7e7 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed autocomplete selection ignoring typed text: highlight now follows the first prefix match as the user types, and exact matches are always selected on Enter ([#1931](https://github.com/badlogic/pi-mono/pull/1931) by [@aliou](https://github.com/aliou)) +- Fixed xterm `modifyOtherKeys` parsing in `matchesKey()` and `parseKey()`, restoring Ctrl-based keybindings and modified Enter keys in tmux when `extended-keys-format` is left at the default `xterm` ([#1872](https://github.com/badlogic/pi-mono/issues/1872)) ## [0.57.0] - 2026-03-07 diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 185c6e3f2..6dcb4c01d 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -470,6 +470,11 @@ interface ParsedKittySequence { eventType: KeyEventType; } +interface ParsedModifyOtherKeysSequence { + codepoint: number; + modifier: number; +} + // Store the last parsed event type for isKeyRelease() to query let _lastEventType: KeyEventType = "press"; @@ -637,19 +642,23 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM return false; } +function parseModifyOtherKeysSequence(data: string): ParsedModifyOtherKeysSequence | null { + const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/); + if (!match) return null; + const modValue = parseInt(match[1]!, 10); + const codepoint = parseInt(match[2]!, 10); + return { codepoint, modifier: modValue - 1 }; +} + /** * Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~ * This is used by terminals when Kitty protocol is not enabled. * Modifier values are 1-indexed: 2=shift, 3=alt, 5=ctrl, etc. */ function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { - const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/); - if (!match) return false; - const modValue = parseInt(match[1]!, 10); - const keycode = parseInt(match[2]!, 10); - // Convert from 1-indexed xterm format to our 0-indexed format - const actualMod = modValue - 1; - return keycode === expectedKeycode && actualMod === expectedModifier; + const parsed = parseModifyOtherKeysSequence(data); + if (!parsed) return false; + return parsed.codepoint === expectedKeycode && parsed.modifier === expectedModifier; } // ============================================================================= @@ -797,7 +806,8 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } return ( matchesKittySequence(data, CODEPOINTS.enter, modifier) || - matchesKittySequence(data, CODEPOINTS.kpEnter, modifier) + matchesKittySequence(data, CODEPOINTS.kpEnter, modifier) || + matchesModifyOtherKeys(data, CODEPOINTS.enter, modifier) ); case "backspace": @@ -1012,21 +1022,30 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (ctrl && !shift && !alt) { // Legacy: ctrl+key sends the control character if (rawCtrl && data === rawCtrl) return true; - return matchesKittySequence(data, codepoint, MODIFIERS.ctrl); + return ( + matchesKittySequence(data, codepoint, MODIFIERS.ctrl) || + matchesModifyOtherKeys(data, codepoint, MODIFIERS.ctrl) + ); } if (ctrl && shift && !alt) { - return matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl); + return ( + matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) || + matchesModifyOtherKeys(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) + ); } if (shift && !ctrl && !alt) { // Legacy: shift+letter produces uppercase if (data === key.toUpperCase()) return true; - return matchesKittySequence(data, codepoint, MODIFIERS.shift); + return ( + matchesKittySequence(data, codepoint, MODIFIERS.shift) || + matchesModifyOtherKeys(data, codepoint, MODIFIERS.shift) + ); } if (modifier !== 0) { - return matchesKittySequence(data, codepoint, modifier); + return matchesKittySequence(data, codepoint, modifier) || matchesModifyOtherKeys(data, codepoint, modifier); } // Check both raw char and Kitty sequence (needed for release events) @@ -1042,50 +1061,56 @@ export function matchesKey(data: string, keyId: KeyId): boolean { * @param data - Raw input data from terminal * @returns Key identifier string (e.g., "ctrl+c") or undefined */ +function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: number): string | undefined { + const mods: string[] = []; + const effectiveMod = modifier & ~LOCK_MASK; + const supportedModifierMask = MODIFIERS.shift | MODIFIERS.ctrl | MODIFIERS.alt; + if ((effectiveMod & ~supportedModifierMask) !== 0) return undefined; + if (effectiveMod & MODIFIERS.shift) mods.push("shift"); + if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl"); + if (effectiveMod & MODIFIERS.alt) mods.push("alt"); + + // Use base layout key only when codepoint is not a recognized Latin + // letter (a-z) or symbol (/, -, [, ;, etc.). For those, the codepoint + // is authoritative regardless of physical key position. This prevents + // remapped layouts (Dvorak, Colemak, xremap, etc.) from reporting the + // wrong key name based on the QWERTY physical position. + const isLatinLetter = codepoint >= 97 && codepoint <= 122; // a-z + const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(codepoint)); + const effectiveCodepoint = isLatinLetter || isKnownSymbol ? codepoint : (baseLayoutKey ?? codepoint); + + let keyName: string | undefined; + if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape"; + else if (effectiveCodepoint === CODEPOINTS.tab) keyName = "tab"; + else if (effectiveCodepoint === CODEPOINTS.enter || effectiveCodepoint === CODEPOINTS.kpEnter) keyName = "enter"; + else if (effectiveCodepoint === CODEPOINTS.space) keyName = "space"; + else if (effectiveCodepoint === CODEPOINTS.backspace) keyName = "backspace"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.insert) keyName = "insert"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp"; + else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = "down"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left"; + else if (effectiveCodepoint === ARROW_CODEPOINTS.right) keyName = "right"; + else if (effectiveCodepoint >= 97 && effectiveCodepoint <= 122) keyName = String.fromCharCode(effectiveCodepoint); + else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint))) keyName = String.fromCharCode(effectiveCodepoint); + + if (!keyName) return undefined; + return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; +} + export function parseKey(data: string): string | undefined { const kitty = parseKittySequence(data); if (kitty) { - const { codepoint, baseLayoutKey, modifier } = kitty; - const mods: string[] = []; - const effectiveMod = modifier & ~LOCK_MASK; - const supportedModifierMask = MODIFIERS.shift | MODIFIERS.ctrl | MODIFIERS.alt; - if ((effectiveMod & ~supportedModifierMask) !== 0) return undefined; - if (effectiveMod & MODIFIERS.shift) mods.push("shift"); - if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl"); - if (effectiveMod & MODIFIERS.alt) mods.push("alt"); + return formatParsedKey(kitty.codepoint, kitty.modifier, kitty.baseLayoutKey); + } - // Use base layout key only when codepoint is not a recognized Latin - // letter (a-z) or symbol (/, -, [, ;, etc.). For those, the codepoint - // is authoritative regardless of physical key position. This prevents - // remapped layouts (Dvorak, Colemak, xremap, etc.) from reporting the - // wrong key name based on the QWERTY physical position. - const isLatinLetter = codepoint >= 97 && codepoint <= 122; // a-z - const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(codepoint)); - const effectiveCodepoint = isLatinLetter || isKnownSymbol ? codepoint : (baseLayoutKey ?? codepoint); - - let keyName: string | undefined; - if (effectiveCodepoint === CODEPOINTS.escape) keyName = "escape"; - else if (effectiveCodepoint === CODEPOINTS.tab) keyName = "tab"; - else if (effectiveCodepoint === CODEPOINTS.enter || effectiveCodepoint === CODEPOINTS.kpEnter) keyName = "enter"; - else if (effectiveCodepoint === CODEPOINTS.space) keyName = "space"; - else if (effectiveCodepoint === CODEPOINTS.backspace) keyName = "backspace"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.insert) keyName = "insert"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp"; - else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown"; - else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = "up"; - else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = "down"; - else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = "left"; - else if (effectiveCodepoint === ARROW_CODEPOINTS.right) keyName = "right"; - else if (effectiveCodepoint >= 97 && effectiveCodepoint <= 122) keyName = String.fromCharCode(effectiveCodepoint); - else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint))) - keyName = String.fromCharCode(effectiveCodepoint); - - if (keyName) { - return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName; - } + const modifyOtherKeys = parseModifyOtherKeysSequence(data); + if (modifyOtherKeys) { + return formatParsedKey(modifyOtherKeys.codepoint, modifyOtherKeys.modifier); } // Mode-aware legacy sequences diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index d31fa7f4b..38096a4b9 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -118,6 +118,42 @@ describe("matchesKey", () => { }); }); + describe("modifyOtherKeys matching", () => { + it("should match xterm modifyOtherKeys Ctrl+c", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;99~", "ctrl+c"), true); + assert.strictEqual(parseKey("\x1b[27;5;99~"), "ctrl+c"); + }); + + it("should match xterm modifyOtherKeys Ctrl+d", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;100~", "ctrl+d"), true); + assert.strictEqual(parseKey("\x1b[27;5;100~"), "ctrl+d"); + }); + + it("should match xterm modifyOtherKeys Ctrl+z", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;122~", "ctrl+z"), true); + assert.strictEqual(parseKey("\x1b[27;5;122~"), "ctrl+z"); + }); + + it("should match xterm modifyOtherKeys Enter variants", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;13~", "ctrl+enter"), true); + assert.strictEqual(matchesKey("\x1b[27;2;13~", "shift+enter"), true); + assert.strictEqual(matchesKey("\x1b[27;3;13~", "alt+enter"), true); + assert.strictEqual(parseKey("\x1b[27;5;13~"), "ctrl+enter"); + assert.strictEqual(parseKey("\x1b[27;2;13~"), "shift+enter"); + assert.strictEqual(parseKey("\x1b[27;3;13~"), "alt+enter"); + }); + + it("should match xterm modifyOtherKeys symbol combos", () => { + setKittyProtocolActive(false); + assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true); + assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/"); + }); + }); + describe("Legacy key matching", () => { it("should match legacy Ctrl+c", () => { setKittyProtocolActive(false);