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[<u)
- Add centralized key definitions in packages/tui/src/keys.ts
- Support both legacy and Kitty sequences for all modifier+key combos:
  - Shift+Enter, Alt+Enter for newlines
  - Shift+Tab for thinking level cycling
  - Ctrl+C, Ctrl+A, Ctrl+E, Ctrl+K, Ctrl+U, Ctrl+W, Ctrl+O, Ctrl+P, Ctrl+T
  - Alt+Backspace for word deletion
- Export Keys constants and helper functions from @mariozechner/pi-tui
This commit is contained in:
Ahmed Kamal 2025-12-18 19:20:30 +02:00
parent 2f86c8bc3c
commit 4a4531f887
10 changed files with 182 additions and 47 deletions

View file

@ -1,4 +1,4 @@
import { Editor } from "@mariozechner/pi-tui";
import { Editor, isShiftTab, Keys } from "@mariozechner/pi-tui";
/**
* Custom editor that handles Escape and Ctrl+C keys for coding-agent
@ -12,26 +12,26 @@ export class CustomEditor extends Editor {
public onCtrlT?: () => 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;
}

View file

@ -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

View file

@ -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();
}

View file

@ -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

View file

@ -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;
}

View file

@ -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();
}

View file

@ -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

108
packages/tui/src/keys.ts Normal file
View file

@ -0,0 +1,108 @@
/**
* Kitty keyboard protocol key sequence helpers.
*
* The Kitty keyboard protocol sends enhanced escape sequences in the format:
* \x1b[<codepoint>;<modifier>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+<letter> 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+<key> 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;
}

View file

@ -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[<u");
// Remove event handlers
if (this.inputHandler) {
process.stdin.removeListener("data", this.inputHandler);

View file

@ -1,4 +1,5 @@
#!/usr/bin/env node
import { isCtrlC } from "../src/keys.js";
import { ProcessTerminal } from "../src/terminal.js";
import { type Component, TUI } from "../src/tui.js";
@ -15,6 +16,13 @@ class KeyLogger implements Component {
}
handleInput(data: string): void {
// Handle Ctrl+C (raw or Kitty protocol) for exit
if (isCtrlC(data)) {
this.tui.stop();
console.log("\nExiting...");
process.exit(0);
}
// Convert to various representations
const hex = Buffer.from(data).toString("hex");
const charCodes = Array.from(data)
@ -67,6 +75,8 @@ class KeyLogger implements Component {
// Footer
lines.push("=".repeat(width));
lines.push("Test these:".padEnd(width));
lines.push(" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)".padEnd(width));
lines.push(" - Alt/Option + Enter".padEnd(width));
lines.push(" - Option/Alt + Backspace".padEnd(width));
lines.push(" - Cmd/Ctrl + Backspace".padEnd(width));
lines.push(" - Regular Backspace".padEnd(width));
@ -84,7 +94,7 @@ const logger = new KeyLogger(tui);
tui.addChild(logger);
tui.setFocus(logger);
// Handle Ctrl+C for clean exit
// Handle Ctrl+C for clean exit (SIGINT still works for raw mode)
process.on("SIGINT", () => {
tui.stop();
console.log("\nExiting...");