From 4a4531f887a9559289bbf6d6205ff9d9a5eb3ed4 Mon Sep 17 00:00:00 2001 From: Ahmed Kamal Date: Thu, 18 Dec 2025 19:20:30 +0200 Subject: [PATCH 1/5] Add Kitty keyboard protocol support for Shift+Enter and other modifier keys Enable the Kitty keyboard protocol on terminal start to receive enhanced key sequences that include modifier information. This fixes Shift+Enter not working in Ghostty, Kitty, WezTerm, and other modern terminals. Changes: - Enable Kitty protocol on start (\x1b[>1u), disable on stop (\x1b[ void; handleInput(data: string): void { - // Intercept Ctrl+T for thinking block visibility toggle - if (data === "\x14" && this.onCtrlT) { + // Intercept Ctrl+T for thinking block visibility toggle (raw byte or Kitty protocol) + if ((data === "\x14" || data === Keys.CTRL_T) && this.onCtrlT) { this.onCtrlT(); return; } - // Intercept Ctrl+O for tool output expansion - if (data === "\x0f" && this.onCtrlO) { + // Intercept Ctrl+O for tool output expansion (raw byte or Kitty protocol) + if ((data === "\x0f" || data === Keys.CTRL_O) && this.onCtrlO) { this.onCtrlO(); return; } - // Intercept Ctrl+P for model cycling - if (data === "\x10" && this.onCtrlP) { + // Intercept Ctrl+P for model cycling (raw byte or Kitty protocol) + if ((data === "\x10" || data === Keys.CTRL_P) && this.onCtrlP) { this.onCtrlP(); return; } - // Intercept Shift+Tab for thinking level cycling - if (data === "\x1b[Z" && this.onShiftTab) { + // Intercept Shift+Tab for thinking level cycling (legacy or Kitty protocol) + if (isShiftTab(data) && this.onShiftTab) { this.onShiftTab(); return; } @@ -43,8 +43,8 @@ export class CustomEditor extends Editor { return; } - // Intercept Ctrl+C - if (data === "\x03" && this.onCtrlC) { + // Intercept Ctrl+C (raw byte or Kitty keyboard protocol) + if ((data === "\x03" || data === Keys.CTRL_C) && this.onCtrlC) { this.onCtrlC(); return; } diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 3db54b8a..27a2e21d 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -1,4 +1,4 @@ -import { type Component, Container, Input, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { type Component, Container, Input, Keys, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; import type { SessionManager } from "../../../core/session-manager.js"; import { fuzzyFilter } from "../../../utils/fuzzy.js"; import { theme } from "../theme/theme.js"; @@ -144,8 +144,8 @@ class SessionList implements Component { this.onCancel(); } } - // Ctrl+C - exit process - else if (keyData === "\x03") { + // Ctrl+C - exit process (raw byte or Kitty keyboard protocol) + else if (keyData === "\x03" || keyData === Keys.CTRL_C) { process.exit(0); } // Pass everything else to search input diff --git a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts index 4c2816ab..4f34130b 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -1,4 +1,4 @@ -import { type Component, Container, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { type Component, Container, Keys, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -99,8 +99,8 @@ class UserMessageList implements Component { this.onCancel(); } } - // Ctrl+C - cancel - else if (keyData === "\x03") { + // Ctrl+C - cancel (raw byte or Kitty keyboard protocol) + else if (keyData === "\x03" || keyData === Keys.CTRL_C) { if (this.onCancel) { this.onCancel(); } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 522f2025..309aed0c 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,4 +1,5 @@ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; +import { Keys } from "../keys.js"; import type { Component } from "../tui.js"; import { visibleWidth } from "../utils.js"; import { SelectList, type SelectListTheme } from "./select-list.js"; @@ -259,7 +260,8 @@ export class Editor implements Component { // Handle special key combinations first // Ctrl+C - Exit (let parent handle this) - if (data.charCodeAt(0) === 3) { + // Handle both raw byte (\x03) and Kitty keyboard protocol + if (data.charCodeAt(0) === 3 || data === Keys.CTRL_C) { return; } @@ -358,35 +360,37 @@ export class Editor implements Component { } // Continue with rest of input handling - // Ctrl+K - Delete to end of line - if (data.charCodeAt(0) === 11) { + // Ctrl+K - Delete to end of line (raw byte or Kitty protocol) + if (data.charCodeAt(0) === 11 || data === Keys.CTRL_K) { this.deleteToEndOfLine(); } - // Ctrl+U - Delete to start of line - else if (data.charCodeAt(0) === 21) { + // Ctrl+U - Delete to start of line (raw byte or Kitty protocol) + else if (data.charCodeAt(0) === 21 || data === Keys.CTRL_U) { this.deleteToStartOfLine(); } - // Ctrl+W - Delete word backwards - else if (data.charCodeAt(0) === 23) { + // Ctrl+W - Delete word backwards (raw byte or Kitty protocol) + else if (data.charCodeAt(0) === 23 || data === Keys.CTRL_W) { this.deleteWordBackwards(); } - // Option/Alt+Backspace (e.g. Ghostty sends ESC + DEL) - else if (data === "\x1b\x7f") { + // Option/Alt+Backspace (e.g. Ghostty sends ESC + DEL, or Kitty protocol) + else if (data === "\x1b\x7f" || data === Keys.ALT_BACKSPACE) { this.deleteWordBackwards(); } - // Ctrl+A - Move to start of line - else if (data.charCodeAt(0) === 1) { + // Ctrl+A - Move to start of line (raw byte or Kitty protocol) + else if (data.charCodeAt(0) === 1 || data === Keys.CTRL_A) { this.moveToLineStart(); } - // Ctrl+E - Move to end of line - else if (data.charCodeAt(0) === 5) { + // Ctrl+E - Move to end of line (raw byte or Kitty protocol) + else if (data.charCodeAt(0) === 5 || data === Keys.CTRL_E) { this.moveToLineEnd(); } // New line shortcuts (but not plain LF/CR which should be submit) else if ( (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers - data === "\x1b\r" || // Option+Enter in some terminals - data === "\x1b[13;2~" || // Shift+Enter in some terminals + data === "\x1b\r" || // Option+Enter in some terminals (legacy) + data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format) + data === Keys.SHIFT_ENTER || // Shift+Enter in Kitty keyboard protocol + data === Keys.ALT_ENTER || // Alt+Enter in Kitty keyboard protocol (data.length > 1 && data.includes("\x1b") && data.includes("\r")) || (data === "\n" && data.length === 1) || // Shift+Enter from iTerm2 mapping data === "\\\r" // Shift+Enter in VS Code terminal diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index dbf8c801..18e12496 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,3 +1,4 @@ +import { Keys } from "../keys.js"; import type { Component } from "../tui.js"; import { visibleWidth } from "../utils.js"; @@ -101,39 +102,39 @@ export class Input implements Component { return; } - if (data === "\x01") { - // Ctrl+A - beginning of line + if (data === "\x01" || data === Keys.CTRL_A) { + // Ctrl+A - beginning of line (raw byte or Kitty protocol) this.cursor = 0; return; } - if (data === "\x05") { - // Ctrl+E - end of line + if (data === "\x05" || data === Keys.CTRL_E) { + // Ctrl+E - end of line (raw byte or Kitty protocol) this.cursor = this.value.length; return; } - if (data.charCodeAt(0) === 23) { - // Ctrl+W - delete word backwards + if (data.charCodeAt(0) === 23 || data === Keys.CTRL_W) { + // Ctrl+W - delete word backwards (raw byte or Kitty protocol) this.deleteWordBackwards(); return; } - if (data === "\x1b\x7f") { - // Option/Alt+Backspace - delete word backwards + if (data === "\x1b\x7f" || data === Keys.ALT_BACKSPACE) { + // Option/Alt+Backspace - delete word backwards (legacy or Kitty protocol) this.deleteWordBackwards(); return; } - if (data.charCodeAt(0) === 21) { - // Ctrl+U - delete from cursor to start of line + if (data.charCodeAt(0) === 21 || data === Keys.CTRL_U) { + // Ctrl+U - delete from cursor to start of line (raw byte or Kitty protocol) this.value = this.value.slice(this.cursor); this.cursor = 0; return; } - if (data.charCodeAt(0) === 11) { - // Ctrl+K - delete from cursor to end of line + if (data.charCodeAt(0) === 11 || data === Keys.CTRL_K) { + // Ctrl+K - delete from cursor to end of line (raw byte or Kitty protocol) this.value = this.value.slice(0, this.cursor); return; } diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 357a4fa7..5be6fe60 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,3 +1,4 @@ +import { Keys } from "../keys.js"; import type { Component } from "../tui.js"; import { truncateToWidth } from "../utils.js"; @@ -161,8 +162,8 @@ export class SelectList implements Component { this.onSelect(selectedItem); } } - // Escape or Ctrl+C - else if (keyData === "\x1b" || keyData === "\x03") { + // Escape or Ctrl+C (raw byte or Kitty keyboard protocol) + else if (keyData === "\x1b" || keyData === "\x03" || keyData === Keys.CTRL_C) { if (this.onCancel) { this.onCancel(); } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index ed3daab7..8f692749 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -18,6 +18,8 @@ export { type SelectItem, SelectList, type SelectListTheme } from "./components/ export { Spacer } from "./components/spacer.js"; export { Text } from "./components/text.js"; export { TruncatedText } from "./components/truncated-text.js"; +// Kitty keyboard protocol helpers +export { isCtrlC, isKittyCtrl, isKittyKey, isShiftTab, Keys } 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 new file mode 100644 index 00000000..eb1289ba --- /dev/null +++ b/packages/tui/src/keys.ts @@ -0,0 +1,108 @@ +/** + * Kitty keyboard protocol key sequence helpers. + * + * The Kitty keyboard protocol sends enhanced escape sequences in the format: + * \x1b[;u + * + * Modifier values (added to 1): + * - Shift: 1 (value 2) + * - Alt: 2 (value 3) + * - Ctrl: 4 (value 5) + * - Super: 8 (value 9) + * + * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + */ + +// Common codepoints +const CODEPOINTS = { + // Letters (lowercase ASCII) + a: 97, + c: 99, + e: 101, + k: 107, + o: 111, + p: 112, + t: 116, + u: 117, + w: 119, + + // Special keys + tab: 9, + enter: 13, + backspace: 127, +} as const; + +// Modifier bits (before adding 1) +const MODIFIERS = { + shift: 1, + alt: 2, + ctrl: 4, + super: 8, +} as const; + +/** + * Build a Kitty keyboard protocol sequence for a key with modifier. + */ +function kittySequence(codepoint: number, modifier: number): string { + return `\x1b[${codepoint};${modifier + 1}u`; +} + +// Pre-built sequences for common key combinations +export const Keys = { + // Ctrl+ combinations + CTRL_A: kittySequence(CODEPOINTS.a, MODIFIERS.ctrl), + CTRL_C: kittySequence(CODEPOINTS.c, MODIFIERS.ctrl), + CTRL_E: kittySequence(CODEPOINTS.e, MODIFIERS.ctrl), + CTRL_K: kittySequence(CODEPOINTS.k, MODIFIERS.ctrl), + CTRL_O: kittySequence(CODEPOINTS.o, MODIFIERS.ctrl), + CTRL_P: kittySequence(CODEPOINTS.p, MODIFIERS.ctrl), + CTRL_T: kittySequence(CODEPOINTS.t, MODIFIERS.ctrl), + CTRL_U: kittySequence(CODEPOINTS.u, MODIFIERS.ctrl), + CTRL_W: kittySequence(CODEPOINTS.w, MODIFIERS.ctrl), + + // Enter combinations + SHIFT_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.shift), + ALT_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.alt), + CTRL_ENTER: kittySequence(CODEPOINTS.enter, MODIFIERS.ctrl), + + // Tab combinations + SHIFT_TAB: kittySequence(CODEPOINTS.tab, MODIFIERS.shift), + + // Backspace combinations + ALT_BACKSPACE: kittySequence(CODEPOINTS.backspace, MODIFIERS.alt), +} as const; + +/** + * Check if input matches a Kitty protocol Ctrl+ sequence. + * @param data - The input data to check + * @param key - Single lowercase letter (e.g., 'c' for Ctrl+C) + */ +export function isKittyCtrl(data: string, key: string): boolean { + if (key.length !== 1) return false; + const codepoint = key.charCodeAt(0); + return data === kittySequence(codepoint, MODIFIERS.ctrl); +} + +/** + * Check if input matches a Kitty protocol key sequence with specific modifier. + * @param data - The input data to check + * @param codepoint - ASCII codepoint of the key + * @param modifier - Modifier value (use MODIFIERS constants) + */ +export function isKittyKey(data: string, codepoint: number, modifier: number): boolean { + return data === kittySequence(codepoint, modifier); +} + +/** + * Check if input matches Ctrl+C (raw byte or Kitty protocol). + */ +export function isCtrlC(data: string): boolean { + return data === "\x03" || data === Keys.CTRL_C; +} + +/** + * Check if input matches Shift+Tab (legacy or Kitty protocol). + */ +export function isShiftTab(data: string): boolean { + return data === "\x1b[Z" || data === Keys.SHIFT_TAB; +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 68c4d4e7..9b27c12f 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -51,6 +51,12 @@ 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); process.stdout.on("resize", this.resizeHandler); @@ -60,6 +66,9 @@ export class ProcessTerminal implements Terminal { // Disable bracketed paste mode process.stdout.write("\x1b[?2004l"); + // Disable Kitty keyboard protocol (pop the flags we pushed) + process.stdout.write("\x1b[ { tui.stop(); console.log("\nExiting..."); From c3c2bffc6847baccb7831c7009fce8c9de5a7442 Mon Sep 17 00:00:00 2001 From: Ahmed Kamal Date: Thu, 18 Dec 2025 19:28:43 +0200 Subject: [PATCH 2/5] Add helper functions for key detection and update usage - Add isCtrlA/C/E/K/O/P/T/U/W helper functions that check both raw and Kitty formats - Add isAltBackspace helper function - Refactor editor.ts, input.ts, select-list.ts to use helper functions - Refactor custom-editor.ts, session-selector.ts, user-message-selector.ts - Add CHANGELOG entry for the Shift+Enter fix --- packages/coding-agent/CHANGELOG.md | 4 + .../interactive/components/custom-editor.ts | 20 ++--- .../components/session-selector.ts | 6 +- .../components/user-message-selector.ts | 6 +- packages/tui/src/components/editor.ts | 29 ++++--- packages/tui/src/components/input.ts | 26 +++--- packages/tui/src/components/select-list.ts | 6 +- packages/tui/src/index.ts | 17 +++- packages/tui/src/keys.ts | 82 ++++++++++++++++++- 9 files changed, 146 insertions(+), 50 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 63a725bb..c5624e4d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **Shift+Enter for newlines**: Fixed Shift+Enter not working for newlines in Ghostty, Kitty, WezTerm, and other terminals supporting the Kitty keyboard protocol. Also fixed Alt+Enter, Shift+Tab, and all Ctrl+key combinations (Ctrl+A/C/E/K/O/P/T/U/W). (by [@kim0](https://github.com/kim0)) + ## [0.23.4] - 2025-12-18 ### Added diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 4283c9de..74989d42 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,4 +1,4 @@ -import { Editor, isShiftTab, Keys } from "@mariozechner/pi-tui"; +import { Editor, isCtrlC, isCtrlO, isCtrlP, isCtrlT, isShiftTab } from "@mariozechner/pi-tui"; /** * Custom editor that handles Escape and Ctrl+C keys for coding-agent @@ -12,25 +12,25 @@ export class CustomEditor extends Editor { public onCtrlT?: () => void; handleInput(data: string): void { - // Intercept Ctrl+T for thinking block visibility toggle (raw byte or Kitty protocol) - if ((data === "\x14" || data === Keys.CTRL_T) && this.onCtrlT) { + // Intercept Ctrl+T for thinking block visibility toggle + if (isCtrlT(data) && this.onCtrlT) { this.onCtrlT(); return; } - // Intercept Ctrl+O for tool output expansion (raw byte or Kitty protocol) - if ((data === "\x0f" || data === Keys.CTRL_O) && this.onCtrlO) { + // Intercept Ctrl+O for tool output expansion + if (isCtrlO(data) && this.onCtrlO) { this.onCtrlO(); return; } - // Intercept Ctrl+P for model cycling (raw byte or Kitty protocol) - if ((data === "\x10" || data === Keys.CTRL_P) && this.onCtrlP) { + // Intercept Ctrl+P for model cycling + if (isCtrlP(data) && this.onCtrlP) { this.onCtrlP(); return; } - // Intercept Shift+Tab for thinking level cycling (legacy or Kitty protocol) + // Intercept Shift+Tab for thinking level cycling if (isShiftTab(data) && this.onShiftTab) { this.onShiftTab(); return; @@ -43,8 +43,8 @@ export class CustomEditor extends Editor { return; } - // Intercept Ctrl+C (raw byte or Kitty keyboard protocol) - if ((data === "\x03" || data === Keys.CTRL_C) && this.onCtrlC) { + // Intercept Ctrl+C + if (isCtrlC(data) && this.onCtrlC) { this.onCtrlC(); return; } diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts index 27a2e21d..cbc96696 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -1,4 +1,4 @@ -import { type Component, Container, Input, Keys, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { type Component, Container, Input, isCtrlC, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; import type { SessionManager } from "../../../core/session-manager.js"; import { fuzzyFilter } from "../../../utils/fuzzy.js"; import { theme } from "../theme/theme.js"; @@ -144,8 +144,8 @@ class SessionList implements Component { this.onCancel(); } } - // Ctrl+C - exit process (raw byte or Kitty keyboard protocol) - else if (keyData === "\x03" || keyData === Keys.CTRL_C) { + // Ctrl+C - exit process + else if (isCtrlC(keyData)) { process.exit(0); } // Pass everything else to search input diff --git a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts index 4f34130b..d66a8ca1 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -1,4 +1,4 @@ -import { type Component, Container, Keys, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { type Component, Container, isCtrlC, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -99,8 +99,8 @@ class UserMessageList implements Component { this.onCancel(); } } - // Ctrl+C - cancel (raw byte or Kitty keyboard protocol) - else if (keyData === "\x03" || keyData === Keys.CTRL_C) { + // Ctrl+C - cancel + else if (isCtrlC(keyData)) { if (this.onCancel) { this.onCancel(); } diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 309aed0c..da56d285 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,5 +1,5 @@ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; -import { Keys } from "../keys.js"; +import { isAltBackspace, isCtrlA, isCtrlC, isCtrlE, isCtrlK, isCtrlU, isCtrlW, Keys } from "../keys.js"; import type { Component } from "../tui.js"; import { visibleWidth } from "../utils.js"; import { SelectList, type SelectListTheme } from "./select-list.js"; @@ -260,8 +260,7 @@ export class Editor implements Component { // Handle special key combinations first // Ctrl+C - Exit (let parent handle this) - // Handle both raw byte (\x03) and Kitty keyboard protocol - if (data.charCodeAt(0) === 3 || data === Keys.CTRL_C) { + if (isCtrlC(data)) { return; } @@ -360,28 +359,28 @@ export class Editor implements Component { } // Continue with rest of input handling - // Ctrl+K - Delete to end of line (raw byte or Kitty protocol) - if (data.charCodeAt(0) === 11 || data === Keys.CTRL_K) { + // Ctrl+K - Delete to end of line + if (isCtrlK(data)) { this.deleteToEndOfLine(); } - // Ctrl+U - Delete to start of line (raw byte or Kitty protocol) - else if (data.charCodeAt(0) === 21 || data === Keys.CTRL_U) { + // Ctrl+U - Delete to start of line + else if (isCtrlU(data)) { this.deleteToStartOfLine(); } - // Ctrl+W - Delete word backwards (raw byte or Kitty protocol) - else if (data.charCodeAt(0) === 23 || data === Keys.CTRL_W) { + // Ctrl+W - Delete word backwards + else if (isCtrlW(data)) { this.deleteWordBackwards(); } - // Option/Alt+Backspace (e.g. Ghostty sends ESC + DEL, or Kitty protocol) - else if (data === "\x1b\x7f" || data === Keys.ALT_BACKSPACE) { + // Option/Alt+Backspace - Delete word backwards + else if (isAltBackspace(data)) { this.deleteWordBackwards(); } - // Ctrl+A - Move to start of line (raw byte or Kitty protocol) - else if (data.charCodeAt(0) === 1 || data === Keys.CTRL_A) { + // Ctrl+A - Move to start of line + else if (isCtrlA(data)) { this.moveToLineStart(); } - // Ctrl+E - Move to end of line (raw byte or Kitty protocol) - else if (data.charCodeAt(0) === 5 || data === Keys.CTRL_E) { + // Ctrl+E - Move to end of line + else if (isCtrlE(data)) { this.moveToLineEnd(); } // New line shortcuts (but not plain LF/CR which should be submit) diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 18e12496..8a60e11b 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,4 +1,4 @@ -import { Keys } from "../keys.js"; +import { isAltBackspace, isCtrlA, isCtrlE, isCtrlK, isCtrlU, isCtrlW } from "../keys.js"; import type { Component } from "../tui.js"; import { visibleWidth } from "../utils.js"; @@ -102,39 +102,39 @@ export class Input implements Component { return; } - if (data === "\x01" || data === Keys.CTRL_A) { - // Ctrl+A - beginning of line (raw byte or Kitty protocol) + if (isCtrlA(data)) { + // Ctrl+A - beginning of line this.cursor = 0; return; } - if (data === "\x05" || data === Keys.CTRL_E) { - // Ctrl+E - end of line (raw byte or Kitty protocol) + if (isCtrlE(data)) { + // Ctrl+E - end of line this.cursor = this.value.length; return; } - if (data.charCodeAt(0) === 23 || data === Keys.CTRL_W) { - // Ctrl+W - delete word backwards (raw byte or Kitty protocol) + if (isCtrlW(data)) { + // Ctrl+W - delete word backwards this.deleteWordBackwards(); return; } - if (data === "\x1b\x7f" || data === Keys.ALT_BACKSPACE) { - // Option/Alt+Backspace - delete word backwards (legacy or Kitty protocol) + if (isAltBackspace(data)) { + // Option/Alt+Backspace - delete word backwards this.deleteWordBackwards(); return; } - if (data.charCodeAt(0) === 21 || data === Keys.CTRL_U) { - // Ctrl+U - delete from cursor to start of line (raw byte or Kitty protocol) + if (isCtrlU(data)) { + // Ctrl+U - delete from cursor to start of line this.value = this.value.slice(this.cursor); this.cursor = 0; return; } - if (data.charCodeAt(0) === 11 || data === Keys.CTRL_K) { - // Ctrl+K - delete from cursor to end of line (raw byte or Kitty protocol) + if (isCtrlK(data)) { + // Ctrl+K - delete from cursor to end of line this.value = this.value.slice(0, this.cursor); return; } diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 5be6fe60..ab2f5e33 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,4 +1,4 @@ -import { Keys } from "../keys.js"; +import { isCtrlC } from "../keys.js"; import type { Component } from "../tui.js"; import { truncateToWidth } from "../utils.js"; @@ -162,8 +162,8 @@ export class SelectList implements Component { this.onSelect(selectedItem); } } - // Escape or Ctrl+C (raw byte or Kitty keyboard protocol) - else if (keyData === "\x1b" || keyData === "\x03" || keyData === Keys.CTRL_C) { + // Escape or Ctrl+C + else if (keyData === "\x1b" || isCtrlC(keyData)) { if (this.onCancel) { this.onCancel(); } diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 8f692749..185f23b4 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -19,7 +19,22 @@ export { Spacer } from "./components/spacer.js"; export { Text } from "./components/text.js"; export { TruncatedText } from "./components/truncated-text.js"; // Kitty keyboard protocol helpers -export { isCtrlC, isKittyCtrl, isKittyKey, isShiftTab, Keys } from "./keys.js"; +export { + isAltBackspace, + isCtrlA, + isCtrlC, + isCtrlE, + isCtrlK, + isCtrlO, + isCtrlP, + isCtrlT, + isCtrlU, + isCtrlW, + isKittyCtrl, + isKittyKey, + isShiftTab, + Keys, +} 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 eb1289ba..0c4b7c38 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -93,16 +93,94 @@ export function isKittyKey(data: string, codepoint: number, modifier: number): b return data === kittySequence(codepoint, modifier); } +// Raw control character codes +const RAW = { + CTRL_A: "\x01", + CTRL_C: "\x03", + CTRL_E: "\x05", + CTRL_K: "\x0b", + CTRL_O: "\x0f", + CTRL_P: "\x10", + CTRL_T: "\x14", + CTRL_U: "\x15", + CTRL_W: "\x17", + ALT_BACKSPACE: "\x1b\x7f", + SHIFT_TAB: "\x1b[Z", +} as const; + +/** + * Check if input matches Ctrl+A (raw byte or Kitty protocol). + */ +export function isCtrlA(data: string): boolean { + return data === RAW.CTRL_A || data === Keys.CTRL_A; +} + /** * Check if input matches Ctrl+C (raw byte or Kitty protocol). */ export function isCtrlC(data: string): boolean { - return data === "\x03" || data === Keys.CTRL_C; + return data === RAW.CTRL_C || data === Keys.CTRL_C; +} + +/** + * Check if input matches Ctrl+E (raw byte or Kitty protocol). + */ +export function isCtrlE(data: string): boolean { + return data === RAW.CTRL_E || data === Keys.CTRL_E; +} + +/** + * Check if input matches Ctrl+K (raw byte or Kitty protocol). + */ +export function isCtrlK(data: string): boolean { + return data === RAW.CTRL_K || data === Keys.CTRL_K; +} + +/** + * Check if input matches Ctrl+O (raw byte or Kitty protocol). + */ +export function isCtrlO(data: string): boolean { + return data === RAW.CTRL_O || data === Keys.CTRL_O; +} + +/** + * Check if input matches Ctrl+P (raw byte or Kitty protocol). + */ +export function isCtrlP(data: string): boolean { + return data === RAW.CTRL_P || data === Keys.CTRL_P; +} + +/** + * Check if input matches Ctrl+T (raw byte or Kitty protocol). + */ +export function isCtrlT(data: string): boolean { + return data === RAW.CTRL_T || data === Keys.CTRL_T; +} + +/** + * Check if input matches Ctrl+U (raw byte or Kitty protocol). + */ +export function isCtrlU(data: string): boolean { + return data === RAW.CTRL_U || data === Keys.CTRL_U; +} + +/** + * Check if input matches Ctrl+W (raw byte or Kitty protocol). + */ +export function isCtrlW(data: string): boolean { + return data === RAW.CTRL_W || data === Keys.CTRL_W; +} + +/** + * Check if input matches Alt+Backspace (legacy or Kitty protocol). + */ +export function isAltBackspace(data: string): boolean { + return data === RAW.ALT_BACKSPACE || data === Keys.ALT_BACKSPACE; } /** * Check if input matches Shift+Tab (legacy or Kitty protocol). */ export function isShiftTab(data: string): boolean { - return data === "\x1b[Z" || data === Keys.SHIFT_TAB; + return data === RAW.SHIFT_TAB || data === Keys.SHIFT_TAB; } From 727a7ab018f712e221b9d8a29fd743720b1db3c7 Mon Sep 17 00:00:00 2001 From: Ahmed Kamal Date: Thu, 18 Dec 2025 19:39:41 +0200 Subject: [PATCH 3/5] Add Ctrl+D to exit when editor is empty - Add isCtrlD helper to keys.ts - CustomEditor intercepts Ctrl+D and only triggers callback when editor is empty - Single Ctrl+D with empty input exits immediately - Update CHANGELOG to frame as feature (Kitty protocol support) not fix --- packages/coding-agent/CHANGELOG.md | 4 ++-- .../modes/interactive/components/custom-editor.ts | 12 +++++++++++- .../src/modes/interactive/interactive-mode.ts | 7 +++++++ packages/tui/src/index.ts | 1 + packages/tui/src/keys.ts | 10 ++++++++++ 5 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c5624e4d..ad986930 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,9 +2,9 @@ ## [Unreleased] -### Fixed +### Added -- **Shift+Enter for newlines**: Fixed Shift+Enter not working for newlines in Ghostty, Kitty, WezTerm, and other terminals supporting the Kitty keyboard protocol. Also fixed Alt+Enter, Shift+Tab, and all Ctrl+key combinations (Ctrl+A/C/E/K/O/P/T/U/W). (by [@kim0](https://github.com/kim0)) +- **Kitty keyboard protocol support**: Added support for the Kitty keyboard protocol, enabling Shift+Enter, Alt+Enter, Shift+Tab, Ctrl+D to exit, and all Ctrl+key combinations to work in Ghostty, Kitty, WezTerm, and other modern terminals. (by [@kim0](https://github.com/kim0)) ## [0.23.4] - 2025-12-18 diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 74989d42..92b8ab0c 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,4 +1,4 @@ -import { Editor, isCtrlC, isCtrlO, isCtrlP, isCtrlT, isShiftTab } from "@mariozechner/pi-tui"; +import { Editor, isCtrlC, isCtrlD, isCtrlO, isCtrlP, isCtrlT, isShiftTab } from "@mariozechner/pi-tui"; /** * Custom editor that handles Escape and Ctrl+C keys for coding-agent @@ -6,6 +6,7 @@ import { Editor, isCtrlC, isCtrlO, isCtrlP, isCtrlT, isShiftTab } from "@marioze export class CustomEditor extends Editor { public onEscape?: () => void; public onCtrlC?: () => void; + public onCtrlD?: () => void; public onShiftTab?: () => void; public onCtrlP?: () => void; public onCtrlO?: () => void; @@ -49,6 +50,15 @@ export class CustomEditor extends Editor { return; } + // Intercept Ctrl+D (only when editor is empty) + if (isCtrlD(data)) { + if (this.getText().length === 0 && this.onCtrlD) { + this.onCtrlD(); + } + // Always consume Ctrl+D (don't pass to parent) + return; + } + // Pass to parent for normal handling super.handleInput(data); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 35023b58..ed0e4ef4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -562,6 +562,7 @@ export class InteractiveMode { }; this.editor.onCtrlC = () => this.handleCtrlC(); + this.editor.onCtrlD = () => this.handleCtrlD(); this.editor.onShiftTab = () => this.cycleThinkingLevel(); this.editor.onCtrlP = () => this.cycleModel(); this.editor.onCtrlO = () => this.toggleToolOutputExpansion(); @@ -1128,6 +1129,12 @@ export class InteractiveMode { } } + private handleCtrlD(): void { + // Only called when editor is empty (enforced by CustomEditor) + this.stop(); + process.exit(0); + } + private updateEditorBorderColor(): void { if (this.isBashMode) { this.editor.borderColor = theme.getBashModeBorderColor(); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 185f23b4..439c2925 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -23,6 +23,7 @@ export { isAltBackspace, isCtrlA, isCtrlC, + isCtrlD, isCtrlE, isCtrlK, isCtrlO, diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 0c4b7c38..bac4557d 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -18,6 +18,7 @@ const CODEPOINTS = { // Letters (lowercase ASCII) a: 97, c: 99, + d: 100, e: 101, k: 107, o: 111, @@ -52,6 +53,7 @@ export const Keys = { // Ctrl+ combinations CTRL_A: kittySequence(CODEPOINTS.a, MODIFIERS.ctrl), CTRL_C: kittySequence(CODEPOINTS.c, MODIFIERS.ctrl), + CTRL_D: kittySequence(CODEPOINTS.d, MODIFIERS.ctrl), CTRL_E: kittySequence(CODEPOINTS.e, MODIFIERS.ctrl), CTRL_K: kittySequence(CODEPOINTS.k, MODIFIERS.ctrl), CTRL_O: kittySequence(CODEPOINTS.o, MODIFIERS.ctrl), @@ -97,6 +99,7 @@ export function isKittyKey(data: string, codepoint: number, modifier: number): b const RAW = { CTRL_A: "\x01", CTRL_C: "\x03", + CTRL_D: "\x04", CTRL_E: "\x05", CTRL_K: "\x0b", CTRL_O: "\x0f", @@ -122,6 +125,13 @@ export function isCtrlC(data: string): boolean { return data === RAW.CTRL_C || data === Keys.CTRL_C; } +/** + * Check if input matches Ctrl+D (raw byte or Kitty protocol). + */ +export function isCtrlD(data: string): boolean { + return data === RAW.CTRL_D || data === Keys.CTRL_D; +} + /** * Check if input matches Ctrl+E (raw byte or Kitty protocol). */ From d569f0353e02f88b073269686276b08f64926d65 Mon Sep 17 00:00:00 2001 From: Ahmed Kamal Date: Thu, 18 Dec 2025 19:51:04 +0200 Subject: [PATCH 4/5] Remove isKittyCtrl and isKittyKey from public exports These generic helpers aren't used externally and would require exporting MODIFIERS constants to be ergonomic. The specific isCtrl* helpers cover all practical use cases. --- packages/tui/src/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 439c2925..b03c0cf3 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -31,8 +31,6 @@ export { isCtrlT, isCtrlU, isCtrlW, - isKittyCtrl, - isKittyKey, isShiftTab, Keys, } from "./keys.js"; From 4f2bc9b617dfbcbf8c1882eed65405acef200054 Mon Sep 17 00:00:00 2001 From: Ahmed Kamal Date: Thu, 18 Dec 2025 19:56:23 +0200 Subject: [PATCH 5/5] Add Ctrl+D to README keyboard shortcuts --- packages/coding-agent/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index b4b8155e..665aa0a8 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -219,6 +219,7 @@ The agent reads, writes, and edits files, and executes commands via bash. | Tab | Path completion / accept autocomplete | | Escape | Cancel autocomplete / abort streaming | | Ctrl+C | Clear editor (first) / exit (second) | +| Ctrl+D | Exit (when editor is empty) | | Shift+Tab | Cycle thinking level | | Ctrl+P | Cycle models (scoped by `--models`) | | Ctrl+O | Toggle tool output expansion |