mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 15:02:32 +00:00
232 lines
6.6 KiB
TypeScript
232 lines
6.6 KiB
TypeScript
import { setKittyProtocolActive } from "./keys.js";
|
|
import { StdinBuffer } from "./stdin-buffer.js";
|
|
|
|
/**
|
|
* Minimal terminal interface for TUI
|
|
*/
|
|
export interface Terminal {
|
|
// Start the terminal with input and resize handlers
|
|
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
|
|
// Stop the terminal and restore state
|
|
stop(): void;
|
|
|
|
// Write output to terminal
|
|
write(data: string): void;
|
|
|
|
// Get terminal dimensions
|
|
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
|
|
|
|
// Cursor visibility
|
|
hideCursor(): void; // Hide the cursor
|
|
showCursor(): void; // Show the cursor
|
|
|
|
// Clear operations
|
|
clearLine(): void; // Clear current line
|
|
clearFromCursor(): void; // Clear from cursor to end of screen
|
|
clearScreen(): void; // Clear entire screen and move cursor to (0,0)
|
|
|
|
// Title operations
|
|
setTitle(title: string): void; // Set terminal window title
|
|
}
|
|
|
|
/**
|
|
* Real terminal using process.stdin/stdout
|
|
*/
|
|
export class ProcessTerminal implements Terminal {
|
|
private wasRaw = false;
|
|
private inputHandler?: (data: string) => void;
|
|
private resizeHandler?: () => void;
|
|
private _kittyProtocolActive = false;
|
|
private stdinBuffer?: StdinBuffer;
|
|
private stdinDataHandler?: (data: string) => void;
|
|
|
|
get kittyProtocolActive(): boolean {
|
|
return this._kittyProtocolActive;
|
|
}
|
|
|
|
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
this.inputHandler = onInput;
|
|
this.resizeHandler = onResize;
|
|
|
|
// Save previous state and enable raw mode
|
|
this.wasRaw = process.stdin.isRaw || false;
|
|
if (process.stdin.setRawMode) {
|
|
process.stdin.setRawMode(true);
|
|
}
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.resume();
|
|
|
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
process.stdout.write("\x1b[?2004h");
|
|
|
|
// Set up resize handler immediately
|
|
process.stdout.on("resize", this.resizeHandler);
|
|
|
|
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
if (process.platform !== "win32") {
|
|
process.kill(process.pid, "SIGWINCH");
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
/**
|
|
* Set up StdinBuffer to split batched input into individual sequences.
|
|
* This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
|
|
*
|
|
* Also watches for Kitty protocol response and enables it when detected.
|
|
* This is done here (after stdinBuffer parsing) rather than on raw stdin
|
|
* to handle the case where the response arrives split across multiple events.
|
|
*/
|
|
private setupStdinBuffer(): void {
|
|
this.stdinBuffer = new StdinBuffer({ timeout: 10 });
|
|
|
|
// Kitty protocol response pattern: \x1b[?<flags>u
|
|
const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
|
|
|
|
// Forward individual sequences to the input handler
|
|
this.stdinBuffer.on("data", (sequence) => {
|
|
// Check for Kitty protocol response (only if not already enabled)
|
|
if (!this._kittyProtocolActive) {
|
|
const match = sequence.match(kittyResponsePattern);
|
|
if (match) {
|
|
this._kittyProtocolActive = true;
|
|
setKittyProtocolActive(true);
|
|
|
|
// Enable Kitty keyboard protocol (push flags)
|
|
// Flag 1 = disambiguate escape codes
|
|
// Flag 2 = report event types (press/repeat/release)
|
|
// Flag 4 = report alternate keys (shifted key, base layout key)
|
|
// Base layout key enables shortcuts to work with non-Latin keyboard layouts
|
|
process.stdout.write("\x1b[>7u");
|
|
return; // Don't forward protocol response to TUI
|
|
}
|
|
}
|
|
|
|
if (this.inputHandler) {
|
|
this.inputHandler(sequence);
|
|
}
|
|
});
|
|
|
|
// Re-wrap paste content with bracketed paste markers for existing editor handling
|
|
this.stdinBuffer.on("paste", (content) => {
|
|
if (this.inputHandler) {
|
|
this.inputHandler(`\x1b[200~${content}\x1b[201~`);
|
|
}
|
|
});
|
|
|
|
// Handler that pipes stdin data through the buffer
|
|
this.stdinDataHandler = (data: string) => {
|
|
this.stdinBuffer!.process(data);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* The response is detected in setupStdinBuffer's data handler, which properly
|
|
* handles the case where the response arrives split across multiple stdin events.
|
|
*/
|
|
private queryAndEnableKittyProtocol(): void {
|
|
this.setupStdinBuffer();
|
|
process.stdin.on("data", this.stdinDataHandler!);
|
|
process.stdout.write("\x1b[?u");
|
|
}
|
|
|
|
stop(): void {
|
|
// Disable bracketed paste mode
|
|
process.stdout.write("\x1b[?2004l");
|
|
|
|
// Disable Kitty keyboard protocol (pop the flags we pushed) - only if we enabled it
|
|
if (this._kittyProtocolActive) {
|
|
process.stdout.write("\x1b[<u");
|
|
this._kittyProtocolActive = false;
|
|
setKittyProtocolActive(false);
|
|
}
|
|
|
|
// Clean up StdinBuffer
|
|
if (this.stdinBuffer) {
|
|
this.stdinBuffer.destroy();
|
|
this.stdinBuffer = undefined;
|
|
}
|
|
|
|
// Remove event handlers
|
|
if (this.stdinDataHandler) {
|
|
process.stdin.removeListener("data", this.stdinDataHandler);
|
|
this.stdinDataHandler = undefined;
|
|
}
|
|
this.inputHandler = undefined;
|
|
if (this.resizeHandler) {
|
|
process.stdout.removeListener("resize", this.resizeHandler);
|
|
this.resizeHandler = undefined;
|
|
}
|
|
|
|
// Restore raw mode state
|
|
if (process.stdin.setRawMode) {
|
|
process.stdin.setRawMode(this.wasRaw);
|
|
}
|
|
}
|
|
|
|
write(data: string): void {
|
|
process.stdout.write(data);
|
|
}
|
|
|
|
get columns(): number {
|
|
return process.stdout.columns || 80;
|
|
}
|
|
|
|
get rows(): number {
|
|
return process.stdout.rows || 24;
|
|
}
|
|
|
|
moveBy(lines: number): void {
|
|
if (lines > 0) {
|
|
// Move down
|
|
process.stdout.write(`\x1b[${lines}B`);
|
|
} else if (lines < 0) {
|
|
// Move up
|
|
process.stdout.write(`\x1b[${-lines}A`);
|
|
}
|
|
// lines === 0: no movement
|
|
}
|
|
|
|
hideCursor(): void {
|
|
process.stdout.write("\x1b[?25l");
|
|
}
|
|
|
|
showCursor(): void {
|
|
process.stdout.write("\x1b[?25h");
|
|
}
|
|
|
|
clearLine(): void {
|
|
process.stdout.write("\x1b[K");
|
|
}
|
|
|
|
clearFromCursor(): void {
|
|
process.stdout.write("\x1b[J");
|
|
}
|
|
|
|
clearScreen(): void {
|
|
process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
|
|
}
|
|
|
|
setTitle(title: string): void {
|
|
// OSC 0;title BEL - set terminal window title
|
|
process.stdout.write(`\x1b]0;${title}\x07`);
|
|
}
|
|
}
|