UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

108 lines (107 loc) • 4.23 kB
/** * Raw-mode stdin decoder. * * Node hands us whatever bytes happened to arrive in one read, not one keypress. * Key autorepeat, fast typing, mouse-wheel-as-arrows, and paste all coalesce * several keys into a single "data" chunk (e.g. "\x1b[B\x1b[B\x1b[B" or "ub"), * and any handler that compares the whole chunk against a single key drops * everything but an exact match. This splits a chunk back into discrete keys. * * A chunk can also end mid-sequence, so trailing partial escapes are buffered * and prepended to the next chunk. A lone ESC is indistinguishable from the * start of a split sequence, so the caller flushes on a short timer (see * ESC_FLUSH_MS) to keep the Escape key responsive. */ /** How long to wait for the rest of an escape sequence before treating ESC as the Escape key. */ export const ESC_FLUSH_MS = 30; const ESC = "\x1b"; // One code point plus any trailing combining marks / variation selectors, so a // surrogate pair or accented character is never split down the middle. const GRAPHEME = /^(?:\P{M}\p{M}*)/u; function nextChar(buf, i) { const m = GRAPHEME.exec(buf.slice(i)); return m ? m[0] : buf[i]; } /** * Split `buf` into discrete keys. * * Returns the decoded keys plus any trailing bytes that form an incomplete * escape sequence; the caller must prepend `pending` to the next chunk. */ export function decodeKeys(buf) { const keys = []; let i = 0; while (i < buf.length) { const ch = buf[i]; if (ch !== ESC) { const char = nextChar(buf, i); keys.push(char); i += char.length; continue; } // ESC is the last byte we have — could be the Escape key or a split sequence. if (i + 1 >= buf.length) return { keys, pending: buf.slice(i) }; const next = buf[i + 1]; // ESC ESC — the first is a complete Escape keypress. if (next === ESC) { keys.push(ESC); i += 1; continue; } // CSI: ESC [ params intermediates final if (next === "[") { let j = i + 2; while (j < buf.length && buf[j] >= "\x30" && buf[j] <= "\x3f") j++; while (j < buf.length && buf[j] >= "\x20" && buf[j] <= "\x2f") j++; if (j >= buf.length) return { keys, pending: buf.slice(i) }; if (buf[j] >= "\x40" && buf[j] <= "\x7e") { keys.push(buf.slice(i, j + 1)); i = j + 1; continue; } // Malformed — drop the ESC [ introducer and resync on the next byte. i += 2; continue; } // SS3: ESC O <final> — arrows in application cursor mode, F1-F4. if (next === "O") { if (i + 2 >= buf.length) return { keys, pending: buf.slice(i) }; keys.push(buf.slice(i, i + 3)); i += 3; continue; } // ESC followed by a printable character. The shell binds no Alt combos, so // emitting them separately recovers both keys (Esc-then-shortcut is common) // instead of dropping an unrecognised two-character token. keys.push(ESC); i += 1; } return { keys, pending: "" }; } /** Stateful wrapper that carries incomplete sequences across chunk boundaries. */ export class KeyDecoder { pending = ""; /** Decode a stdin chunk into discrete keys. */ push(chunk) { const { keys, pending } = decodeKeys(this.pending + chunk); this.pending = pending; return keys; } /** True when a partial sequence is buffered and a flush timer should be armed. */ hasPending() { return this.pending.length > 0; } /** Emit buffered bytes as literal keys — call when no continuation arrived in time. */ flush() { if (!this.pending) return []; const buffered = this.pending; this.pending = ""; // A lone ESC is the Escape key; anything longer is a sequence the terminal // truncated, so surface it whole and let the handler ignore it. return buffered === ESC ? [ESC] : [buffered]; } }