mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-22 00:00:27 +00:00
feat(tui): query Kitty keyboard protocol support before enabling
- Query terminal with CSI ? u before enabling Kitty protocol - Only enable protocol if terminal responds (100ms timeout) - Mode-aware key parsing: when Kitty active, interpret legacy sequences as custom terminal mappings (e.g. \x1b\r = shift+enter) - Add Terminal Setup section to README with Ghostty/wezterm config fixes #439
This commit is contained in:
parent
0b9e3ada0c
commit
c5d54a8413
5 changed files with 186 additions and 18 deletions
|
|
@ -18,6 +18,7 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows-
|
||||||
- [Getting Started](#getting-started)
|
- [Getting Started](#getting-started)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Windows Setup](#windows-setup)
|
- [Windows Setup](#windows-setup)
|
||||||
|
- [Terminal Setup](#terminal-setup)
|
||||||
- [API Keys & OAuth](#api-keys--oauth)
|
- [API Keys & OAuth](#api-keys--oauth)
|
||||||
- [Quick Start](#quick-start)
|
- [Quick Start](#quick-start)
|
||||||
- [Usage](#usage)
|
- [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
|
### API Keys & OAuth
|
||||||
|
|
||||||
**Option 1: Auth file** (recommended)
|
**Option 1: Auth file** (recommended)
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export {
|
||||||
setEditorKeybindings,
|
setEditorKeybindings,
|
||||||
} from "./keybindings.js";
|
} from "./keybindings.js";
|
||||||
// Keyboard input handling
|
// 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
|
// Terminal interface and implementations
|
||||||
export { ProcessTerminal, type Terminal } from "./terminal.js";
|
export { ProcessTerminal, type Terminal } from "./terminal.js";
|
||||||
// Terminal image support
|
// Terminal image support
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,31 @@
|
||||||
* - matchesKey(data, keyId) - Check if input matches a key identifier
|
* - matchesKey(data, keyId) - Check if input matches a key identifier
|
||||||
* - parseKey(data) - Parse input and return the key identifier
|
* - parseKey(data) - Parse input and return the key identifier
|
||||||
* - Key - Helper object for creating typed key identifiers
|
* - 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
|
// Type-Safe Key Identifiers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -402,17 +425,35 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||||
case "enter":
|
case "enter":
|
||||||
case "return":
|
case "return":
|
||||||
if (shift && !ctrl && !alt) {
|
if (shift && !ctrl && !alt) {
|
||||||
return (
|
// CSI u sequences (standard Kitty protocol)
|
||||||
|
if (
|
||||||
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) ||
|
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) ||
|
||||||
matchesKittySequence(data, CODEPOINTS.kpEnter, 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) {
|
if (alt && !ctrl && !shift) {
|
||||||
return (
|
// CSI u sequences (standard Kitty protocol)
|
||||||
data === "\x1b\r" ||
|
if (
|
||||||
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) ||
|
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) ||
|
||||||
matchesKittySequence(data, CODEPOINTS.kpEnter, 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) {
|
if (modifier === 0) {
|
||||||
return (
|
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 === "\x1b") return "escape";
|
||||||
if (data === "\t") return "tab";
|
if (data === "\t") return "tab";
|
||||||
if (data === "\r" || data === "\x1bOM") return "enter";
|
if (data === "\r" || data === "\x1bOM") return "enter";
|
||||||
if (data === " ") return "space";
|
if (data === " ") return "space";
|
||||||
if (data === "\x7f" || data === "\x08") return "backspace";
|
if (data === "\x7f" || data === "\x08") return "backspace";
|
||||||
if (data === "\x1b[Z") return "shift+tab";
|
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\x7f") return "alt+backspace";
|
||||||
if (data === "\x1b[A") return "up";
|
if (data === "\x1b[A") return "up";
|
||||||
if (data === "\x1b[B") return "down";
|
if (data === "\x1b[B") return "down";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { setKittyProtocolActive } from "./keys.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal terminal interface for TUI
|
* Minimal terminal interface for TUI
|
||||||
*/
|
*/
|
||||||
|
|
@ -15,6 +17,9 @@ export interface Terminal {
|
||||||
get columns(): number;
|
get columns(): number;
|
||||||
get rows(): number;
|
get rows(): number;
|
||||||
|
|
||||||
|
// Whether Kitty keyboard protocol is active
|
||||||
|
get kittyProtocolActive(): boolean;
|
||||||
|
|
||||||
// Cursor positioning (relative to current position)
|
// Cursor positioning (relative to current position)
|
||||||
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
|
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 wasRaw = false;
|
||||||
private inputHandler?: (data: string) => void;
|
private inputHandler?: (data: string) => void;
|
||||||
private resizeHandler?: () => void;
|
private resizeHandler?: () => void;
|
||||||
|
private _kittyProtocolActive = false;
|
||||||
|
|
||||||
|
get kittyProtocolActive(): boolean {
|
||||||
|
return this._kittyProtocolActive;
|
||||||
|
}
|
||||||
|
|
||||||
start(onInput: (data: string) => void, onResize: () => void): void {
|
start(onInput: (data: string) => void, onResize: () => void): void {
|
||||||
this.inputHandler = onInput;
|
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~
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
||||||
process.stdout.write("\x1b[?2004h");
|
process.stdout.write("\x1b[?2004h");
|
||||||
|
|
||||||
// Enable Kitty keyboard protocol (disambiguate escape codes)
|
// Set up resize handler immediately
|
||||||
// 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);
|
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 ? <flags> 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[?<flags>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 {
|
stop(): void {
|
||||||
// Disable bracketed paste mode
|
// Disable bracketed paste mode
|
||||||
process.stdout.write("\x1b[?2004l");
|
process.stdout.write("\x1b[?2004l");
|
||||||
|
|
||||||
// Disable Kitty keyboard protocol (pop the flags we pushed)
|
// Disable Kitty keyboard protocol (pop the flags we pushed) - only if we enabled it
|
||||||
process.stdout.write("\x1b[<u");
|
if (this._kittyProtocolActive) {
|
||||||
|
process.stdout.write("\x1b[<u");
|
||||||
|
this._kittyProtocolActive = false;
|
||||||
|
setKittyProtocolActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove event handlers
|
// Remove event handlers
|
||||||
if (this.inputHandler) {
|
if (this.inputHandler) {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,11 @@ export class VirtualTerminal implements Terminal {
|
||||||
return this._rows;
|
return this._rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get kittyProtocolActive(): boolean {
|
||||||
|
// Virtual terminal always reports Kitty protocol as active for testing
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
moveBy(lines: number): void {
|
moveBy(lines: number): void {
|
||||||
if (lines > 0) {
|
if (lines > 0) {
|
||||||
// Move down
|
// Move down
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue