spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/tui/renderer/input.ts4 * Description: Raw-mode key decoder — bytes to typed KeyEvents, with bracketed-paste reassembly across chunks.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910export type ArrowKey = "up" | "down" | "left" | "right";1112export type KeyEvent =13 | { type: "char"; ch: string }14 | { type: "enter" }15 | { type: "shift-enter" }16 | { type: "tab" }17 | { type: "backspace" }18 | { type: "delete" }19 | { type: "esc" }20 | { type: "home" }21 | { type: "end" }22 | { type: "arrow"; key: ArrowKey; alt: boolean; ctrl: boolean }23 | { type: "ctrl"; ch: string } // "a".."z", "_" (Ctrl+_ undo)24 | { type: "alt"; ch: string } // alt+b/f/d word ops25 | { type: "alt-backspace" }26 | { type: "paste"; text: string };2728const PASTE_START = "\x1b[200~";29const PASTE_END = "\x1b[201~";3031/** CSI final bytes we silently discard when the sequence is not a key (probe replies, mouse, …). */32const CSI_SEQ = /^\x1b\[([0-9;?]*)([ -/]*)([@-~])/;33const OSC_SEQ = /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/;3435function arrowFor(final: string): ArrowKey | null {36 switch (final) {37 case "A":38 return "up";39 case "B":40 return "down";41 case "C":42 return "right";43 case "D":44 return "left";45 default:46 return null;47 }48}4950/** True when `s` could still grow into a complete escape sequence. */51function isPartialEscape(s: string): boolean {52 if (s === "\x1b" || s === "\x1b[" || s === "\x1b]" || s === "\x1bO") return true;53 if (s.startsWith("\x1b[")) return /^\x1b\[[0-9;?]*[ -/]*$/.test(s);54 if (s.startsWith("\x1b]")) return !/(?:\x07|\x1b\\)/.test(s);55 return false;56}5758/**59 * Stateful raw-input decoder. State exists only for content split across60 * chunks (bracketed pastes, torn escape sequences); each `push` returns the61 * keys decoded so far. Unrecognized escape sequences (terminal query replies,62 * mouse reports) are dropped silently — they must never reach the composer63 * as garbage characters.64 */65export class KeyDecoder {66 private pending = "";67 private pasting = false;68 private pasteBuffer = "";6970 push(chunk: Buffer | string): KeyEvent[] {71 this.pending += typeof chunk === "string" ? chunk : chunk.toString("utf8");72 const events: KeyEvent[] = [];7374 while (this.pending.length > 0) {75 if (this.pasting) {76 const end = this.pending.indexOf(PASTE_END);77 if (end === -1) {78 // Keep a suffix that might be a torn PASTE_END marker.79 const keep = tornSuffixLength(this.pending, PASTE_END);80 this.pasteBuffer += this.pending.slice(0, this.pending.length - keep);81 this.pending = this.pending.slice(this.pending.length - keep);82 break;83 }84 this.pasteBuffer += this.pending.slice(0, end);85 this.pending = this.pending.slice(end + PASTE_END.length);86 events.push({ type: "paste", text: this.pasteBuffer });87 this.pasteBuffer = "";88 this.pasting = false;89 continue;90 }9192 const s = this.pending;9394 if (s.startsWith(PASTE_START)) {95 this.pasting = true;96 this.pending = s.slice(PASTE_START.length);97 continue;98 }99100 if (s[0] === "\x1b") {101 if (isPartialEscape(s)) break; // wait for the rest of the sequence102103 // ESC + single printable → Alt+key family.104 const second = s[1];105 if (second !== undefined && second !== "[" && second !== "]" && second !== "O") {106 this.pending = s.slice(2);107 if (second === "\x7f") events.push({ type: "alt-backspace" });108 else if (second === "\r") events.push({ type: "ctrl", ch: "j" }); // Alt+Enter → newline109 else if (/[a-zA-Z]/.test(second)) events.push({ type: "alt", ch: second.toLowerCase() });110 // other ESC+byte pairs dropped111 continue;112 }113114 const csi = CSI_SEQ.exec(s);115 if (csi) {116 this.pending = s.slice(csi[0].length);117 const params = (csi[1] ?? "").split(";");118 const final = csi[3] as string;119 const arrow = arrowFor(final);120 if (arrow) {121 const mod = params.length >= 2 ? Number(params[1]) : 1;122 events.push({123 type: "arrow",124 key: arrow,125 alt: mod === 3 || mod === 9,126 ctrl: mod === 5 || mod === 7,127 });128 continue;129 }130 if (final === "H") {131 events.push({ type: "home" });132 continue;133 }134 if (final === "F") {135 events.push({ type: "end" });136 continue;137 }138 if (final === "~") {139 const code = Number(params[0]);140 if (code === 1 || code === 7) events.push({ type: "home" });141 else if (code === 4 || code === 8) events.push({ type: "end" });142 else if (code === 3) events.push({ type: "delete" });143 continue;144 }145 if (final === "u") {146 // kitty keyboard protocol: CSI unicode-key ; modifiers u147 const code = Number(params[0]);148 const mod = params.length >= 2 ? Number(params[1]) : 1;149 if (code === 13) {150 events.push(mod === 2 ? { type: "shift-enter" } : { type: "enter" });151 } else if (code === 27) {152 events.push({ type: "esc" });153 } else if (code >= 32 && mod <= 1) {154 events.push({ type: "char", ch: String.fromCodePoint(code) });155 }156 continue;157 }158 continue; // unrecognized CSI (probe replies, mouse) — dropped159 }160161 const osc = OSC_SEQ.exec(s);162 if (osc) {163 this.pending = s.slice(osc[0].length); // OSC reply — dropped164 continue;165 }166167 if (s.startsWith("\x1bO") && s.length >= 3) {168 const final = s[2] as string;169 this.pending = s.slice(3);170 const arrow = arrowFor(final);171 if (arrow) events.push({ type: "arrow", key: arrow, alt: false, ctrl: false });172 else if (final === "H") events.push({ type: "home" });173 else if (final === "F") events.push({ type: "end" });174 continue;175 }176177 // Lone ESC key.178 this.pending = s.slice(1);179 events.push({ type: "esc" });180 continue;181 }182183 // Non-escape byte / code point.184 const cp = s.codePointAt(0) as number;185 const ch = String.fromCodePoint(cp);186 this.pending = s.slice(ch.length);187188 if (ch === "\r") events.push({ type: "enter" });189 else if (ch === "\n") events.push({ type: "ctrl", ch: "j" });190 else if (ch === "\t") events.push({ type: "tab" });191 else if (ch === "\x7f") events.push({ type: "backspace" });192 else if (ch === "\x08") events.push({ type: "backspace" }); // Ctrl+H193 else if (cp === 0x1f) events.push({ type: "ctrl", ch: "_" });194 else if (cp >= 1 && cp <= 26) {195 events.push({ type: "ctrl", ch: String.fromCharCode(96 + cp) });196 } else if (cp >= 32) {197 events.push({ type: "char", ch });198 }199 // NUL and other C0 leftovers dropped.200 }201202 // Chunk-boundary heuristic: terminals deliver escape sequences within one203 // read. A chunk that ends on exactly ESC is the Esc key, not a torn CSI.204 if (this.pending === "\x1b" && !this.pasting) {205 this.pending = "";206 events.push({ type: "esc" });207 }208209 return events;210 }211}212213/** Length of the longest suffix of `s` that is a proper prefix of `marker`. */214function tornSuffixLength(s: string, marker: string): number {215 const max = Math.min(s.length, marker.length - 1);216 for (let len = max; len > 0; len--) {217 if (s.endsWith(marker.slice(0, len))) return len;218 }219 return 0;220}221