diff --git a/src/renderer/draw.ts b/src/renderer/draw.ts new file mode 100644 index 0000000..4f9fb3b --- /dev/null +++ b/src/renderer/draw.ts @@ -0,0 +1,138 @@ +import type { Attributes, Terminal } from "@hexie/tui"; +import { FRISE_CHARS, friseColor, theme } from "./theme"; + +export function drawBox( + term: Terminal, + row: number, + col: number, + width: number, + height: number, + title?: string, + attrs?: Attributes, +) { + const box = theme.box; + const style: Attributes = { fg: theme.fg.dim, ...attrs }; + + term.putChar(row, col, box.topLeft, style); + for (let i = 1; i < width - 1; i++) { + term.putChar(row, col + i, box.horizontal, style); + } + term.putChar(row, col + width - 1, box.topRight, style); + + if (title) { + const titleText = ` ${title} `; + const startCol = col + Math.floor((width - titleText.length) / 2); + term.putText(row, startCol, titleText, { fg: theme.fg.accent, bold: true }); + } + + for (let r = 1; r < height - 1; r++) { + term.putChar(row + r, col, box.vertical, style); + term.putChar(row + r, col + width - 1, box.vertical, style); + } + + term.putChar(row + height - 1, col, box.bottomLeft, style); + for (let i = 1; i < width - 1; i++) { + term.putChar(row + height - 1, col + i, box.horizontal, style); + } + term.putChar(row + height - 1, col + width - 1, box.bottomRight, style); +} + +export function drawPanel( + term: Terminal, + row: number, + col: number, + width: number, + height: number, + bg?: typeof theme.bg.panel, +) { + const bgColor = bg ?? theme.bg.panel; + for (let r = 0; r < height; r++) { + for (let c = 0; c < width; c++) { + term.putChar(row + r, col + c, " ", { bg: bgColor }); + } + } +} + +export function drawFrise( + term: Terminal, + row: number, + col: number, + width: number, +) { + for (let c = 0; c < width; c++) { + const _paletteIdx = Math.floor(c * friseColor(c).toString().length) % 6; + const charIdx = c % FRISE_CHARS.length; + const color = friseColor(c); + term.putChar(row, col + c, FRISE_CHARS[charIdx] ?? "░", { + fg: color, + }); + } +} + +export function drawSectionLabel( + term: Terminal, + row: number, + col: number, + label: string, + color?: typeof theme.fg.accent, +) { + const c = color ?? theme.fg.muted; + term.putText(row, col, `── ${label} `, { fg: c }); + const labelEnd = col + label.length + 5; + for (let i = 0; i < 20; i++) { + term.putChar(row, labelEnd + i, "─", { fg: theme.fg.dim, dim: true }); + } +} + +export function drawTextWrapped( + term: Terminal, + row: number, + col: number, + width: number, + text: string, + attrs?: Attributes, +): number { + const style: Attributes = { ...attrs }; + let currentRow = row; + let currentCol = col; + let line = ""; + + for (const char of text) { + if (char === "\n") { + term.putText(currentRow, currentCol, line, style); + currentRow++; + currentCol = col; + line = ""; + continue; + } + + line += char; + if (line.length >= width - 1) { + term.putText(currentRow, currentCol, line, style); + currentRow++; + currentCol = col; + line = ""; + } + } + + if (line) { + term.putText(currentRow, currentCol, line, style); + currentRow++; + } + + return currentRow; +} + +export function clearArea( + term: Terminal, + row: number, + col: number, + width: number, + height: number, +) { + for (let r = 0; r < height; r++) { + for (let c = 0; c < width; c++) { + term.putChar(row + r, col + c, " "); + } + } +} diff --git a/src/renderer/input-decoder.ts b/src/renderer/input-decoder.ts new file mode 100644 index 0000000..925c797 --- /dev/null +++ b/src/renderer/input-decoder.ts @@ -0,0 +1,248 @@ +/** + * Self-contained ANSI/VT input decoder. + * + * The bundled `@hexie/tui` decoder only emits usable events when the Kitty + * keyboard protocol is negotiated with the terminal. Terminals that don't + * speak Kitty (macOS Terminal.app, most legacy emulators) send classic VT + * sequences, which that decoder silently drops. This decoder handles the + * classic sequences directly so the TUI works everywhere. + * + * It is intentionally pure: `decodeInput` turns a byte buffer into events plus + * a count of consumed bytes, leaving any incomplete trailing sequence for the + * next chunk. The escape-disambiguation timing lives in the caller (terminal.ts). + */ + +export type KeyCode = string | { char: string }; + +export interface KeyModifiers { + ctrl: boolean; + alt: boolean; + shift: boolean; + meta: boolean; +} + +export interface KeyEvent { + type: "key"; + code: KeyCode; + modifiers: KeyModifiers; +} + +export interface DecodeResult { + events: KeyEvent[]; + /** Number of leading bytes consumed; the rest is an incomplete tail. */ + consumed: number; +} + +const ESC = 0x1b; + +function mods(p: Partial = {}): KeyModifiers { + return { + ctrl: p.ctrl ?? false, + alt: p.alt ?? false, + shift: p.shift ?? false, + meta: p.meta ?? false, + }; +} + +function key(code: KeyCode, m: Partial = {}): KeyEvent { + return { type: "key", code, modifiers: mods(m) }; +} + +/** xterm modifier param (e.g. the `5` in `ESC[1;5A`) → modifier flags. */ +function decodeModifierParam(param: number | undefined): Partial { + if (!param || param <= 1) return {}; + const bits = param - 1; + return { + shift: (bits & 1) !== 0, + alt: (bits & 2) !== 0, + ctrl: (bits & 4) !== 0, + meta: (bits & 8) !== 0, + }; +} + +const CSI_LETTER: Record = { + A: "Up", + B: "Down", + C: "Right", + D: "Left", + H: "Home", + F: "End", +}; + +const CSI_TILDE: Record = { + 1: "Home", + 2: "Insert", + 3: "Delete", + 4: "End", + 5: "PageUp", + 6: "PageDown", + 7: "Home", + 8: "End", +}; + +function isCsiFinal(byte: number): boolean { + return byte >= 0x40 && byte <= 0x7e; +} + +/** Parse a CSI body (params between `ESC[` and the final byte). */ +function parseCsi(params: string, finalByte: number): KeyEvent | null { + const final = String.fromCharCode(finalByte); + + // Mouse reports (we never enable mouse tracking) — swallow silently. + if (params.startsWith("<") || final === "M" || final === "m") return null; + + const parts = params.split(";").map((p) => Number.parseInt(p, 10)); + const modifiers = decodeModifierParam(parts[1]); + + if (final === "Z") return key("Tab", { shift: true }); // Shift+Tab (BackTab) + + if (final === "~") { + const name = CSI_TILDE[parts[0] ?? 0]; + return name ? key(name, modifiers) : null; + } + + const name = CSI_LETTER[final]; + return name ? key(name, modifiers) : null; +} + +const SS3_MAP: Record = { + A: "Up", + B: "Down", + C: "Right", + D: "Left", + H: "Home", + F: "End", + P: "F1", + Q: "F2", + R: "F3", + S: "F4", +}; + +/** + * Decode as many complete key events as possible from `buf`. + * + * A lone trailing `ESC` (or an incomplete escape sequence) is left unconsumed + * so the caller can disambiguate it against a timeout. + */ +export function decodeInput(buf: Uint8Array): DecodeResult { + const events: KeyEvent[] = []; + const len = buf.length; + let i = 0; + + while (i < len) { + const b = buf[i] as number; + + if (b === ESC) { + if (i + 1 >= len) break; // lone/incomplete ESC → wait for more bytes + const b1 = buf[i + 1] as number; + + if (b1 === 0x5b) { + // CSI: ESC [ params final + let j = i + 2; + while (j < len && !isCsiFinal(buf[j] as number)) j++; + if (j >= len) break; // incomplete CSI → wait + const params = String.fromCharCode(...buf.slice(i + 2, j)); + const ev = parseCsi(params, buf[j] as number); + if (ev) events.push(ev); + i = j + 1; + continue; + } + + if (b1 === 0x4f) { + // SS3: ESC O (application cursor / function keys) + if (i + 2 >= len) break; + const name = SS3_MAP[String.fromCharCode(buf[i + 2] as number)]; + if (name) events.push(key(name)); + i += 3; + continue; + } + + if (b1 === ESC) { + // ESC ESC → a standalone Escape followed by more input + events.push(key("Escape")); + i += 1; + continue; + } + + if (b1 >= 0x20 && b1 < 0x7f) { + // Alt/Meta + printable char (ESC prefix) + events.push(key({ char: String.fromCharCode(b1) }, { alt: true })); + i += 2; + continue; + } + + // ESC + control byte we don't model → treat the ESC as Escape. + events.push(key("Escape")); + i += 1; + continue; + } + + if (b === 0x0d || b === 0x0a) { + events.push(key("Enter")); + i += 1; + continue; + } + if (b === 0x09) { + events.push(key("Tab")); + i += 1; + continue; + } + if (b === 0x7f || b === 0x08) { + events.push(key("Backspace")); + i += 1; + continue; + } + // Common Ctrl+ shortcuts (raw mode delivers these as low bytes). + if (b === 0x03) { + events.push(key({ char: "c" }, { ctrl: true })); + i += 1; + continue; + } + if (b === 0x04) { + events.push(key({ char: "d" }, { ctrl: true })); + i += 1; + continue; + } + if (b === 0x01) { + events.push(key({ char: "a" }, { ctrl: true })); + i += 1; + continue; + } + if (b === 0x05) { + events.push(key({ char: "e" }, { ctrl: true })); + i += 1; + continue; + } + if (b === 0x15) { + events.push(key({ char: "u" }, { ctrl: true })); // Ctrl+U (kill line) + i += 1; + continue; + } + if (b === 0x17) { + events.push(key({ char: "w" }, { ctrl: true })); // Ctrl+W (kill word) + i += 1; + continue; + } + if (b < 0x20) { + i += 1; // other control bytes: ignore + continue; + } + if (b < 0x80) { + events.push(key({ char: String.fromCharCode(b) })); + i += 1; + continue; + } + + // UTF-8 multi-byte sequence. + let n = 1; + if (b >= 0xf0) n = 4; + else if (b >= 0xe0) n = 3; + else if (b >= 0xc0) n = 2; + if (i + n > len) break; // incomplete UTF-8 → wait + const char = new TextDecoder().decode(buf.slice(i, i + n)); + events.push(key({ char })); + i += n; + } + + return { events, consumed: i }; +} diff --git a/src/renderer/layout.ts b/src/renderer/layout.ts new file mode 100644 index 0000000..75e70be --- /dev/null +++ b/src/renderer/layout.ts @@ -0,0 +1,81 @@ +export interface Layout { + header: { row: number; col: number; width: number; height: number }; + timeline: { row: number; col: number; width: number; height: number }; + thinking: { row: number; col: number; width: number; height: number }; + agentSteps: { row: number; col: number; width: number; height: number }; + result: { row: number; col: number; width: number; height: number }; + input: { row: number; col: number; width: number; height: number }; + status: { row: number; col: number; width: number; height: number }; +} + +export function calculateLayout( + rows: number, + cols: number, + thinkingVisible: boolean, + agentStepCount = 0, +): Layout { + const headerHeight = 1; + const timelineHeight = 1; + const inputHeight = 3; + const statusHeight = 1; + const padding = 1; + + const fixedHeight = + headerHeight + timelineHeight + inputHeight + statusHeight; + const availableHeight = rows - fixedHeight; + + const thinkingHeight = thinkingVisible + ? Math.floor(availableHeight * 0.3) + : 0; + const agentStepsHeight = agentStepCount > 0 ? Math.min(agentStepCount, 6) : 0; + const resultHeight = availableHeight - thinkingHeight - agentStepsHeight; + + let currentRow = 0; + + const header = { row: currentRow, col: 0, width: cols, height: headerHeight }; + currentRow += headerHeight; + + const timeline = { + row: currentRow, + col: 0, + width: cols, + height: timelineHeight, + }; + currentRow += timelineHeight; + + const thinking = { + row: currentRow, + col: padding, + width: cols - padding * 2, + height: thinkingHeight, + }; + currentRow += thinkingHeight; + + const agentSteps = { + row: currentRow, + col: padding, + width: cols - padding * 2, + height: agentStepsHeight, + }; + currentRow += agentStepsHeight; + + const result = { + row: currentRow, + col: padding, + width: cols - padding * 2, + height: resultHeight, + }; + currentRow += resultHeight; + + const input = { + row: currentRow, + col: padding, + width: cols - padding * 2, + height: inputHeight, + }; + currentRow += inputHeight; + + const status = { row: currentRow, col: 0, width: cols, height: statusHeight }; + + return { header, timeline, thinking, agentSteps, result, input, status }; +} diff --git a/src/renderer/terminal.ts b/src/renderer/terminal.ts new file mode 100644 index 0000000..9ec9efe --- /dev/null +++ b/src/renderer/terminal.ts @@ -0,0 +1,164 @@ +import { Terminal } from "@hexie/tui"; +import { decodeInput, type KeyEvent } from "./input-decoder"; + +/** How long to wait after a lone ESC before treating it as the Escape key. */ +const ESC_FLUSH_MS = 40; + +let term: Terminal | null = null; +let stream: InputStream | null = null; + +/** + * Async, closeable stream of decoded key events read straight from stdin. + * + * We decode classic VT sequences ourselves (see input-decoder.ts) rather than + * relying on the library's Kitty-only decoder, and disambiguate a bare ESC from + * the start of an escape sequence with a short flush timer. + */ +class InputStream implements AsyncIterable { + private queue: KeyEvent[] = []; + private resolvers: Array<(r: IteratorResult) => void> = []; + private pending = new Uint8Array(0); + private escTimer: ReturnType | null = null; + private closed = false; + private reader: ReadableStreamDefaultReader; + + constructor() { + this.reader = Bun.stdin + .stream() + .getReader() as ReadableStreamDefaultReader; + void this.readLoop(); + } + + private emit(event: KeyEvent) { + const resolver = this.resolvers.shift(); + if (resolver) { + resolver({ value: event, done: false }); + } else { + this.queue.push(event); + } + } + + private clearEscTimer() { + if (this.escTimer) { + clearTimeout(this.escTimer); + this.escTimer = null; + } + } + + /** Schedule flushing a stalled ESC-prefixed tail as a standalone Escape. */ + private scheduleEscFlush() { + this.clearEscTimer(); + this.escTimer = setTimeout(() => { + this.escTimer = null; + if (this.pending.length > 0 && this.pending[0] === 0x1b) { + this.pending = new Uint8Array(0); + this.emit({ + type: "key", + code: "Escape", + modifiers: { ctrl: false, alt: false, shift: false, meta: false }, + }); + } + }, ESC_FLUSH_MS); + } + + private async readLoop() { + while (!this.closed) { + let value: Uint8Array | undefined; + try { + const chunk = await this.reader.read(); + if (chunk.done) break; + value = chunk.value; + } catch { + break; + } + if (this.closed) break; + if (!value || value.length === 0) continue; + + this.clearEscTimer(); + + // Append to any incomplete tail from the previous chunk. + const merged = new Uint8Array(this.pending.length + value.length); + merged.set(this.pending, 0); + merged.set(value, this.pending.length); + + const { events, consumed } = decodeInput(merged); + this.pending = merged.slice(consumed); + for (const ev of events) this.emit(ev); + + // A leftover ESC-prefixed tail is either a bare Escape or a sequence + // that arrived split — flush it as Escape if no more bytes follow. + if (this.pending.length > 0 && this.pending[0] === 0x1b) { + this.scheduleEscFlush(); + } + } + for (const resolver of this.resolvers) { + resolver({ value: undefined as unknown as KeyEvent, done: true }); + } + this.resolvers = []; + } + + async *[Symbol.asyncIterator](): AsyncIterator { + while (!this.closed) { + const queued = this.queue.shift(); + if (queued) { + yield queued; + continue; + } + const result = await new Promise>((resolve) => { + this.resolvers.push(resolve); + }); + if (result.done) break; + yield result.value; + } + } + + close() { + this.closed = true; + this.clearEscTimer(); + try { + void this.reader.cancel(); + } catch { + // ignore + } + for (const resolver of this.resolvers) { + resolver({ value: undefined as unknown as KeyEvent, done: true }); + } + this.resolvers = []; + this.queue = []; + } +} + +export async function initTerminal() { + // Terminal.open() already puts stdin into raw mode (no echo, no canonical + // line editing, ISIG off) so Ctrl+C reaches us as a byte we handle ourselves. + term = Terminal.open({ syncUpdate: true }); + + term.write("\x1b[?1049h"); // enter alternate screen + term.hideCursor(); + term.clearScreen(); + term.render(); + + stream = new InputStream(); + return term; +} + +export function getEventStream() { + return stream; +} + +export function getTerminal() { + return term; +} + +export async function cleanupTerminal() { + if (stream) { + stream.close(); + stream = null; + } + if (term) { + term.write("\x1b[?1049l"); // leave alternate screen + term.showCursor(); + term.close(); // restores raw mode + removes signal handlers + term = null; + } +} diff --git a/src/renderer/theme.ts b/src/renderer/theme.ts new file mode 100644 index 0000000..1adc56c --- /dev/null +++ b/src/renderer/theme.ts @@ -0,0 +1,62 @@ +import type { Color } from "@hexie/tui"; + +// Gradient palette for the frise +const FRISE_PALETTE: Color[] = [ + "#ff6b6b" as Color, + "#ff8e53" as Color, + "#ffc93c" as Color, + "#6bcb77" as Color, + "#4ecdc4" as Color, + "#45b7d1" as Color, + "#6c5ce7" as Color, + "#a29bfe" as Color, + "#fd79a8" as Color, + "#e17055" as Color, +]; + +export function friseColor(index: number): Color { + return FRISE_PALETTE[index % FRISE_PALETTE.length] ?? FRISE_PALETTE[0]; +} + +export const FRISE_CHARS = ["░", "▒", "▓", "█", "▓", "▒", "░"]; + +export const theme = { + fg: { + primary: "#e0e0e0" as Color, + secondary: "#8a8a8a" as Color, + dim: "#555555" as Color, + accent: "#45b7d1" as Color, + success: "#6bcb77" as Color, + warning: "#ffc93c" as Color, + error: "#ff6b6b" as Color, + thinking: "#a29bfe" as Color, + muted: "#636e72" as Color, + }, + bg: { + header: "#0d1117" as Color, + panel: "#161b22" as Color, + panelAlt: "#1c2333" as Color, + status: "#0d1117" as Color, + input: "#161b22" as Color, + thinking: "#1a1530" as Color, + }, + symbols: { + bullet: "●", + empty: "○", + divider: "·", + dot: "•", + arrow: "→", + spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + check: "✓", + cross: "✗", + gear: "⚙", + }, + box: { + topLeft: "╭", + topRight: "╮", + bottomLeft: "╰", + bottomRight: "╯", + horizontal: "─", + vertical: "│", + }, +};