SPB Git

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%
7.5 KB · 221 lines typescript
Raw Blame History
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