diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 6071daba..823f6808 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -18,6 +18,7 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows- - [Getting Started](#getting-started) - [Installation](#installation) - [Windows Setup](#windows-setup) + - [Terminal Setup](#terminal-setup) - [API Keys & OAuth](#api-keys--oauth) - [Quick Start](#quick-start) - [Usage](#usage) @@ -115,6 +116,28 @@ For most users, [Git for Windows](https://git-scm.com/download/win) is sufficien } ``` +### Terminal Setup + +Pi uses the [Kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) for reliable modifier key detection. Most modern terminals support this protocol, but some require configuration. + +**Kitty, iTerm2:** Work out of the box. + +**Ghostty:** Add to your Ghostty config (`~/.config/ghostty/config`): + +``` +keybind = alt+backspace=text:\x1b\x7f +keybind = shift+enter=text:\n +``` + +**wezterm:** Create `~/.wezterm.lua`: + +```lua +local wezterm = require 'wezterm' +local config = wezterm.config_builder() +config.enable_kitty_keyboard = true +return config +``` + ### API Keys & OAuth **Option 1: Auth file** (recommended) diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 23f9262e..2e1b51fb 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -30,7 +30,7 @@ export { setEditorKeybindings, } from "./keybindings.js"; // Keyboard input handling -export { Key, type KeyId, matchesKey, parseKey } from "./keys.js"; +export { isKittyProtocolActive, Key, type KeyId, matchesKey, parseKey, setKittyProtocolActive } from "./keys.js"; // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.js"; // Terminal image support diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 70dfeed3..1c3e3d87 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -13,8 +13,31 @@ * - matchesKey(data, keyId) - Check if input matches a key identifier * - parseKey(data) - Parse input and return the key identifier * - Key - Helper object for creating typed key identifiers + * - setKittyProtocolActive(active) - Set global Kitty protocol state + * - isKittyProtocolActive() - Query global Kitty protocol state */ +// ============================================================================= +// Global Kitty Protocol State +// ============================================================================= + +let _kittyProtocolActive = false; + +/** + * Set the global Kitty keyboard protocol state. + * Called by ProcessTerminal after detecting protocol support. + */ +export function setKittyProtocolActive(active: boolean): void { + _kittyProtocolActive = active; +} + +/** + * Query whether Kitty keyboard protocol is currently active. + */ +export function isKittyProtocolActive(): boolean { + return _kittyProtocolActive; +} + // ============================================================================= // Type-Safe Key Identifiers // ============================================================================= @@ -402,17 +425,35 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "enter": case "return": if (shift && !ctrl && !alt) { - return ( + // CSI u sequences (standard Kitty protocol) + if ( matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) || matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift) - ); + ) { + return true; + } + // When Kitty protocol is active, legacy sequences are custom terminal mappings + // \x1b\r = Kitty's "map shift+enter send_text all \e\r" + // \n = Ghostty's "keybind = shift+enter=text:\n" + if (_kittyProtocolActive) { + return data === "\x1b\r" || data === "\n"; + } + return false; } if (alt && !ctrl && !shift) { - return ( - data === "\x1b\r" || + // CSI u sequences (standard Kitty protocol) + if ( matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) || matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt) - ); + ) { + return true; + } + // \x1b\r is alt+enter only in legacy mode (no Kitty protocol) + // When Kitty protocol is active, alt+enter comes as CSI u sequence + if (!_kittyProtocolActive) { + return data === "\x1b\r"; + } + return false; } if (modifier === 0) { return ( @@ -577,14 +618,22 @@ export function parseKey(data: string): string | undefined { } } - // Legacy sequences + // Mode-aware legacy sequences + // When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings: + // - \x1b\r = shift+enter (Kitty mapping), not alt+enter + // - \n = shift+enter (Ghostty mapping) + if (_kittyProtocolActive) { + if (data === "\x1b\r" || data === "\n") return "shift+enter"; + } + + // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences) if (data === "\x1b") return "escape"; if (data === "\t") return "tab"; if (data === "\r" || data === "\x1bOM") return "enter"; if (data === " ") return "space"; if (data === "\x7f" || data === "\x08") return "backspace"; if (data === "\x1b[Z") return "shift+tab"; - if (data === "\x1b\r") return "alt+enter"; + if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter"; if (data === "\x1b\x7f") return "alt+backspace"; if (data === "\x1b[A") return "up"; if (data === "\x1b[B") return "down"; diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index e484e2a5..8cda0350 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,3 +1,5 @@ +import { setKittyProtocolActive } from "./keys.js"; + /** * Minimal terminal interface for TUI */ @@ -15,6 +17,9 @@ export interface Terminal { get columns(): number; get rows(): number; + // Whether Kitty keyboard protocol is active + get kittyProtocolActive(): boolean; + // Cursor positioning (relative to current position) moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines @@ -38,6 +43,11 @@ export class ProcessTerminal implements Terminal { private wasRaw = false; private inputHandler?: (data: string) => void; private resizeHandler?: () => void; + private _kittyProtocolActive = false; + + get kittyProtocolActive(): boolean { + return this._kittyProtocolActive; + } start(onInput: (data: string) => void, onResize: () => void): void { this.inputHandler = onInput; @@ -54,23 +64,104 @@ export class ProcessTerminal implements Terminal { // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~ process.stdout.write("\x1b[?2004h"); - // Enable Kitty keyboard protocol (disambiguate escape codes) - // This makes terminals like Ghostty, Kitty, WezTerm send enhanced key sequences - // e.g., Shift+Enter becomes \x1b[13;2u instead of just \r - // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ - process.stdout.write("\x1b[>1u"); - - // Set up event handlers - process.stdin.on("data", this.inputHandler); + // Set up resize handler immediately process.stdout.on("resize", this.resizeHandler); + + // Query and enable Kitty keyboard protocol + // The query handler intercepts input temporarily, then installs the user's handler + // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + this.queryAndEnableKittyProtocol(); + } + + /** + * Query terminal for Kitty keyboard protocol support and enable if available. + * + * Sends CSI ? u to query current flags. If terminal responds with CSI ? u, + * it supports the protocol and we enable it with CSI > 1 u. + * + * Non-supporting terminals won't respond, so we use a timeout. + */ + private queryAndEnableKittyProtocol(): void { + const QUERY_TIMEOUT_MS = 100; + let resolved = false; + let buffer = ""; + + // Kitty protocol response pattern: \x1b[?u + const kittyResponsePattern = /\x1b\[\?(\d+)u/; + + const queryHandler = (data: string) => { + if (resolved) { + // Query phase done, forward to user handler + if (this.inputHandler) { + this.inputHandler(data); + } + return; + } + + buffer += data; + + // Check if we have a Kitty protocol response + const match = buffer.match(kittyResponsePattern); + if (match) { + resolved = true; + this._kittyProtocolActive = true; + setKittyProtocolActive(true); + + // Enable Kitty keyboard protocol (push flags) + // Flag 1 = disambiguate escape codes + process.stdout.write("\x1b[>1u"); + + // Remove the response from buffer, forward any remaining input + const remaining = buffer.replace(kittyResponsePattern, ""); + if (remaining && this.inputHandler) { + this.inputHandler(remaining); + } + + // Replace with user handler + process.stdin.removeListener("data", queryHandler); + if (this.inputHandler) { + process.stdin.on("data", this.inputHandler); + } + } + }; + + // Temporarily intercept input for the query + process.stdin.on("data", queryHandler); + + // Send query + process.stdout.write("\x1b[?u"); + + // Timeout: if no response, terminal doesn't support Kitty protocol + setTimeout(() => { + if (!resolved) { + resolved = true; + this._kittyProtocolActive = false; + setKittyProtocolActive(false); + + // Forward any buffered input that wasn't a Kitty response + if (buffer && this.inputHandler) { + this.inputHandler(buffer); + } + + // Replace with user handler + process.stdin.removeListener("data", queryHandler); + if (this.inputHandler) { + process.stdin.on("data", this.inputHandler); + } + } + }, QUERY_TIMEOUT_MS); } stop(): void { // Disable bracketed paste mode process.stdout.write("\x1b[?2004l"); - // Disable Kitty keyboard protocol (pop the flags we pushed) - process.stdout.write("\x1b[ 0) { // Move down