/** * session-input-harness.ts — Input injection interface for the live session TUI. * * Provides a text input area at the bottom of the live session overlay, * allowing the user to inject messages into a running agent session. */ import { type Component, matchesKey, type TUI, truncateToWidth, } from "@mariozechner/pi-tui"; import type { SessionEventStream } from "../session-events.js"; import type { Theme } from "./agent-widget.js"; /** * Input harness component for injecting messages into a running agent session. */ export class SessionInputHarness implements Component { /** Whether this component has keyboard focus. */ focused = false; private inputBuffer = ""; private history: string[] = []; private historyIndex = -1; private closed = false; constructor( private tui: TUI, private events: SessionEventStream, private theme: Theme, private onClose: () => void, ) {} // ---- Component Interface ---- render(width: number): string[] { if (this.closed || width < 10) return []; const th = this.theme; const lines: string[] = []; // Separator line const sepCount = Math.max(1, width - 2); lines.push(th.fg("border", "═".repeat(sepCount))); if (this.focused) { // Active input mode const prompt = "> "; const cursor = th.fg("accent", "█"); // static cursor indicator (no blink animation yet) const displayText = this.inputBuffer + cursor; const promptLine = truncateToWidth(prompt + displayText, width); lines.push(promptLine); // Hint line const hint = "Ctrl+C interrupt · Enter send · ↑↓ history · Esc close"; lines.push(th.fg("dim", truncateToWidth(hint, width))); } else { // Inactive: just show hint to focus lines.push(th.fg("dim", truncateToWidth("Press Tab to focus input", width))); lines.push(""); // spacer to match active height } return lines; } handleInput(data: string): void { if (this.closed) return; // Escape to close the live session if (matchesKey(data, "escape")) { this.onClose(); return; } // Tab to toggle focus if (matchesKey(data, "tab")) { this.focused = !this.focused; this.tui.requestRender(); return; } if (!this.focused) return; // Enter to send if (matchesKey(data, "enter") || matchesKey(data, "return")) { const text = this.inputBuffer.trim(); if (text) { this.events.inject(text); this.history.push(text); this.historyIndex = this.history.length; this.inputBuffer = ""; this.tui.requestRender(); } return; } // Ctrl+C to interrupt if (matchesKey(data, "ctrl+c")) { this.events.interrupt(); this.inputBuffer = ""; this.tui.requestRender(); return; } // Backspace if (matchesKey(data, "backspace") || data === "\x7f") { this.inputBuffer = this.inputBuffer.slice(0, -1); this.tui.requestRender(); return; } // Arrow up — history back if (matchesKey(data, "up")) { if (this.history.length > 0 && this.historyIndex > 0) { this.historyIndex--; this.inputBuffer = this.history[this.historyIndex] ?? ""; this.tui.requestRender(); } return; } // Arrow down — history forward if (matchesKey(data, "down")) { if (this.historyIndex < this.history.length - 1) { this.historyIndex++; this.inputBuffer = this.history[this.historyIndex] ?? ""; } else { this.historyIndex = this.history.length; this.inputBuffer = ""; } this.tui.requestRender(); return; } // Regular character input — currently ASCII-only (printable 0x20-0x7e). // TODO: support Unicode multi-byte code points by iterating with for...of or Array.from(). if (data.length === 1 && data.charCodeAt(0) >= 0x20 && data.charCodeAt(0) <= 0x7e) { this.inputBuffer += data; this.tui.requestRender(); return; } } invalidate(): void { // No cached state } dispose(): void { this.closed = true; this.inputBuffer = ""; this.history = []; } }