mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 08:00:59 +00:00
Merge kitty-protocol-support into main
This commit is contained in:
commit
139af12b37
11 changed files with 281 additions and 28 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.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";
|
||||
|
|
@ -259,7 +260,7 @@ export class Editor implements Component {
|
|||
// Handle special key combinations first
|
||||
|
||||
// Ctrl+C - Exit (let parent handle this)
|
||||
if (data.charCodeAt(0) === 3) {
|
||||
if (isCtrlC(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -359,34 +360,36 @@ export class Editor implements Component {
|
|||
|
||||
// Continue with rest of input handling
|
||||
// Ctrl+K - Delete to end of line
|
||||
if (data.charCodeAt(0) === 11) {
|
||||
if (isCtrlK(data)) {
|
||||
this.deleteToEndOfLine();
|
||||
}
|
||||
// Ctrl+U - Delete to start of line
|
||||
else if (data.charCodeAt(0) === 21) {
|
||||
else if (isCtrlU(data)) {
|
||||
this.deleteToStartOfLine();
|
||||
}
|
||||
// Ctrl+W - Delete word backwards
|
||||
else if (data.charCodeAt(0) === 23) {
|
||||
else if (isCtrlW(data)) {
|
||||
this.deleteWordBackwards();
|
||||
}
|
||||
// Option/Alt+Backspace (e.g. Ghostty sends ESC + DEL)
|
||||
else if (data === "\x1b\x7f") {
|
||||
// Option/Alt+Backspace - Delete word backwards
|
||||
else if (isAltBackspace(data)) {
|
||||
this.deleteWordBackwards();
|
||||
}
|
||||
// Ctrl+A - Move to start of line
|
||||
else if (data.charCodeAt(0) === 1) {
|
||||
else if (isCtrlA(data)) {
|
||||
this.moveToLineStart();
|
||||
}
|
||||
// Ctrl+E - Move to end of line
|
||||
else if (data.charCodeAt(0) === 5) {
|
||||
else if (isCtrlE(data)) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isAltBackspace, isCtrlA, isCtrlE, isCtrlK, isCtrlU, isCtrlW } from "../keys.js";
|
||||
import type { Component } from "../tui.js";
|
||||
import { visibleWidth } from "../utils.js";
|
||||
|
||||
|
|
@ -101,38 +102,38 @@ export class Input implements Component {
|
|||
return;
|
||||
}
|
||||
|
||||
if (data === "\x01") {
|
||||
if (isCtrlA(data)) {
|
||||
// Ctrl+A - beginning of line
|
||||
this.cursor = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === "\x05") {
|
||||
if (isCtrlE(data)) {
|
||||
// Ctrl+E - end of line
|
||||
this.cursor = this.value.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.charCodeAt(0) === 23) {
|
||||
if (isCtrlW(data)) {
|
||||
// Ctrl+W - delete word backwards
|
||||
this.deleteWordBackwards();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data === "\x1b\x7f") {
|
||||
if (isAltBackspace(data)) {
|
||||
// Option/Alt+Backspace - delete word backwards
|
||||
this.deleteWordBackwards();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.charCodeAt(0) === 21) {
|
||||
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) {
|
||||
if (isCtrlK(data)) {
|
||||
// Ctrl+K - delete from cursor to end of line
|
||||
this.value = this.value.slice(0, this.cursor);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isCtrlC } from "../keys.js";
|
||||
import type { Component } from "../tui.js";
|
||||
import { truncateToWidth } from "../utils.js";
|
||||
|
||||
|
|
@ -162,7 +163,7 @@ export class SelectList implements Component {
|
|||
}
|
||||
}
|
||||
// Escape or Ctrl+C
|
||||
else if (keyData === "\x1b" || keyData === "\x03") {
|
||||
else if (keyData === "\x1b" || isCtrlC(keyData)) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,22 @@ 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 {
|
||||
isAltBackspace,
|
||||
isCtrlA,
|
||||
isCtrlC,
|
||||
isCtrlD,
|
||||
isCtrlE,
|
||||
isCtrlK,
|
||||
isCtrlO,
|
||||
isCtrlP,
|
||||
isCtrlT,
|
||||
isCtrlU,
|
||||
isCtrlW,
|
||||
isShiftTab,
|
||||
Keys,
|
||||
} from "./keys.js";
|
||||
// Terminal interface and implementations
|
||||
export { ProcessTerminal, type Terminal } from "./terminal.js";
|
||||
// Terminal image support
|
||||
|
|
|
|||
196
packages/tui/src/keys.ts
Normal file
196
packages/tui/src/keys.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* 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,
|
||||
d: 100,
|
||||
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_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),
|
||||
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);
|
||||
}
|
||||
|
||||
// Raw control character codes
|
||||
const RAW = {
|
||||
CTRL_A: "\x01",
|
||||
CTRL_C: "\x03",
|
||||
CTRL_D: "\x04",
|
||||
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 === 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).
|
||||
*/
|
||||
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 === RAW.SHIFT_TAB || data === Keys.SHIFT_TAB;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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...");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue