diff --git a/src/agent/autocomplete.ts b/src/agent/autocomplete.ts new file mode 100644 index 00000000..63c76ac9 --- /dev/null +++ b/src/agent/autocomplete.ts @@ -0,0 +1,269 @@ +/** + * Autocomplete Input + * + * Real-time dropdown autocomplete for terminal input + * No external dependencies - uses raw terminal control + */ + +import * as readline from "readline"; + +export interface AutocompleteOption { + value: string; + label?: string; +} + +export interface AutocompleteConfig { + /** Function to get suggestions based on current input */ + getSuggestions: (input: string) => AutocompleteOption[]; + /** Prompt string */ + prompt?: string; + /** Max suggestions to show */ + maxSuggestions?: number; +} + +// ANSI escape codes +const ESC = "\x1b"; +const CLEAR_LINE = `${ESC}[2K`; +const CURSOR_UP = (n: number) => `${ESC}[${n}A`; +const CURSOR_DOWN = (n: number) => `${ESC}[${n}B`; +const CURSOR_TO_COL = (n: number) => `${ESC}[${n}G`; +const DIM = `${ESC}[2m`; +const RESET = `${ESC}[0m`; +const INVERSE = `${ESC}[7m`; +const HIDE_CURSOR = `${ESC}[?25l`; +const SHOW_CURSOR = `${ESC}[?25h`; + +/** + * Read a line with real-time autocomplete dropdown + */ +export function autocompleteInput(config: AutocompleteConfig): Promise { + return new Promise((resolve) => { + const { getSuggestions, prompt = "> ", maxSuggestions = 5 } = config; + + const stdin = process.stdin; + const stdout = process.stdout; + + let input = ""; + let cursorPos = 0; + let suggestions: AutocompleteOption[] = []; + let selectedIndex = -1; + let displayedLines = 0; + + // Enable raw mode + if (stdin.isTTY) { + stdin.setRawMode(true); + } + readline.emitKeypressEvents(stdin); + + const cleanup = () => { + clearSuggestions(); + stdout.write(SHOW_CURSOR); + if (stdin.isTTY) { + stdin.setRawMode(false); + } + stdin.removeListener("keypress", onKeypress); + }; + + const render = () => { + // Clear previous suggestions + clearSuggestions(); + + // Render input line + stdout.write(`\r${CLEAR_LINE}${prompt}${input}`); + + // Position cursor + const cursorCol = prompt.length + cursorPos + 1; + stdout.write(CURSOR_TO_COL(cursorCol)); + + // Get and display suggestions if input starts with / + if (input.startsWith("/") && input.length > 1) { + suggestions = getSuggestions(input).slice(0, maxSuggestions); + + if (suggestions.length > 0) { + // Ensure selectedIndex is valid + if (selectedIndex >= suggestions.length) { + selectedIndex = suggestions.length - 1; + } + + stdout.write("\n"); + displayedLines = suggestions.length; + + for (let i = 0; i < suggestions.length; i++) { + const opt = suggestions[i]!; + const isSelected = i === selectedIndex; + const prefix = isSelected ? `${INVERSE}` : `${DIM}`; + const suffix = RESET; + const label = opt.label ? ` ${DIM}${opt.label}${RESET}` : ""; + const line = `${prefix} ${opt.value}${suffix}${label}`; + + stdout.write(`${CLEAR_LINE}${line}`); + if (i < suggestions.length - 1) { + stdout.write("\n"); + } + } + + // Move cursor back up to input line + if (displayedLines > 0) { + stdout.write(CURSOR_UP(displayedLines)); + } + stdout.write(CURSOR_TO_COL(cursorCol)); + } + } else { + suggestions = []; + selectedIndex = -1; + } + }; + + const clearSuggestions = () => { + if (displayedLines > 0) { + // Move down and clear each line + for (let i = 0; i < displayedLines; i++) { + stdout.write(`\n${CLEAR_LINE}`); + } + // Move back up + stdout.write(CURSOR_UP(displayedLines)); + displayedLines = 0; + } + }; + + const submit = (value: string) => { + cleanup(); + stdout.write("\n"); + resolve(value); + }; + + const onKeypress = (_char: string, key: readline.Key) => { + if (!key) return; + + // Handle Ctrl+C + if (key.ctrl && key.name === "c") { + cleanup(); + process.exit(0); + } + + // Handle Ctrl+D (EOF) + if (key.ctrl && key.name === "d") { + cleanup(); + stdout.write("\n"); + resolve(""); + return; + } + + // Handle Enter + if (key.name === "return" || key.name === "enter") { + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { + // Use selected suggestion + const selected = suggestions[selectedIndex]!; + submit(selected.value); + } else { + submit(input); + } + return; + } + + // Handle Tab - select first/next suggestion + if (key.name === "tab") { + if (suggestions.length > 0) { + if (key.shift) { + selectedIndex = selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1; + } else { + selectedIndex = selectedIndex >= suggestions.length - 1 ? 0 : selectedIndex + 1; + } + render(); + } + return; + } + + // Handle arrow keys + if (key.name === "up") { + if (suggestions.length > 0) { + selectedIndex = selectedIndex <= 0 ? suggestions.length - 1 : selectedIndex - 1; + render(); + } + return; + } + + if (key.name === "down") { + if (suggestions.length > 0) { + selectedIndex = selectedIndex >= suggestions.length - 1 ? 0 : selectedIndex + 1; + render(); + } + return; + } + + // Handle Escape - clear selection + if (key.name === "escape") { + selectedIndex = -1; + render(); + return; + } + + // Handle backspace + if (key.name === "backspace") { + if (cursorPos > 0) { + input = input.slice(0, cursorPos - 1) + input.slice(cursorPos); + cursorPos--; + selectedIndex = -1; + render(); + } + return; + } + + // Handle delete + if (key.name === "delete") { + if (cursorPos < input.length) { + input = input.slice(0, cursorPos) + input.slice(cursorPos + 1); + selectedIndex = -1; + render(); + } + return; + } + + // Handle left arrow + if (key.name === "left") { + if (cursorPos > 0) { + cursorPos--; + render(); + } + return; + } + + // Handle right arrow + if (key.name === "right") { + if (cursorPos < input.length) { + cursorPos++; + render(); + } + return; + } + + // Handle home + if (key.name === "home" || (key.ctrl && key.name === "a")) { + cursorPos = 0; + render(); + return; + } + + // Handle end + if (key.name === "end" || (key.ctrl && key.name === "e")) { + cursorPos = input.length; + render(); + return; + } + + // Handle printable characters + if (key.sequence && !key.ctrl && !key.meta) { + const char = key.sequence; + if (char.length === 1 && char.charCodeAt(0) >= 32) { + input = input.slice(0, cursorPos) + char + input.slice(cursorPos); + cursorPos++; + selectedIndex = -1; + render(); + } + } + }; + + stdin.on("keypress", onKeypress); + render(); + }); +} diff --git a/src/agent/interactive-cli.ts b/src/agent/interactive-cli.ts index 76f8f4d1..6d6c9edb 100644 --- a/src/agent/interactive-cli.ts +++ b/src/agent/interactive-cli.ts @@ -3,6 +3,7 @@ import * as readline from "readline"; import { Agent } from "./runner.js"; import type { AgentOptions } from "./types.js"; import { SkillManager } from "./skills/index.js"; +import { autocompleteInput, type AutocompleteOption } from "./autocomplete.js"; type CliOptions = { profile?: string | undefined; @@ -126,6 +127,7 @@ class InteractiveCLI { private multilineBuffer: string[] = []; private running = true; private skillManager: SkillManager; + private reservedNames: Set; constructor(opts: CliOptions) { this.opts = opts; @@ -137,13 +139,12 @@ class InteractiveCLI { }); // Build list of reserved command names (built-in CLI commands) - const reservedNames = new Set(Object.keys(COMMANDS)); + this.reservedNames = new Set(Object.keys(COMMANDS)); this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, - completer: (line: string) => this.completer(line, reservedNames), }); this.rl.on("close", () => { @@ -154,41 +155,44 @@ class InteractiveCLI { } /** - * Tab completion handler for readline - * Completes both built-in commands and skill commands + * Get autocomplete suggestions for input */ - private completer( - line: string, - reservedNames: Set, - ): [string[], string] { - // Only complete if line starts with / - if (!line.startsWith("/")) { - return [[], line]; + private getSuggestions(input: string): AutocompleteOption[] { + if (!input.startsWith("/")) { + return []; } - const prefix = line.slice(1).toLowerCase(); + const prefix = input.slice(1).toLowerCase(); + const suggestions: AutocompleteOption[] = []; - // Get built-in command completions - const builtinCompletions = Object.keys(COMMANDS) - .filter((cmd) => cmd.toLowerCase().startsWith(prefix)) - .map((cmd) => `/${cmd}`); + // Add built-in command suggestions + for (const [cmd, desc] of Object.entries(COMMANDS)) { + if (cmd.toLowerCase().startsWith(prefix)) { + suggestions.push({ + value: `/${cmd}`, + label: desc.slice(0, 40), + }); + } + } - // Get skill command completions - const skillCommands = this.skillManager.getSkillCommands({ reservedNames }); - const skillCompletions = skillCommands - .filter((cmd) => cmd.name.toLowerCase().startsWith(prefix)) - .map((cmd) => `/${cmd.name}`); - - // Combine and deduplicate - const allCompletions = [...new Set([...builtinCompletions, ...skillCompletions])]; + // Add skill command suggestions + const skillCommands = this.skillManager.getSkillCommands({ reservedNames: this.reservedNames }); + for (const cmd of skillCommands) { + if (cmd.name.toLowerCase().startsWith(prefix)) { + suggestions.push({ + value: `/${cmd.name}`, + label: cmd.description.slice(0, 40), + }); + } + } // Sort: shorter first, then alphabetically - allCompletions.sort((a, b) => { - if (a.length !== b.length) return a.length - b.length; - return a.localeCompare(b); + suggestions.sort((a, b) => { + if (a.value.length !== b.value.length) return a.value.length - b.value.length; + return a.value.localeCompare(b.value); }); - return [allCompletions, line]; + return suggestions; } private createAgent(sessionId?: string): Agent { @@ -217,10 +221,14 @@ class InteractiveCLI { private async loop() { while (this.running) { - const input = await this.readline(this.prompt()); - if (input === null) break; + let input: string; if (this.multilineMode) { + // Use simple readline for multiline mode + const lineInput = await this.readline(this.prompt()); + if (lineInput === null) break; + input = lineInput; + if (input === ".") { // End of multiline input const fullInput = this.multilineBuffer.join("\n"); @@ -235,6 +243,17 @@ class InteractiveCLI { continue; } + // Use autocomplete input for normal mode + try { + input = await autocompleteInput({ + prompt: this.prompt(), + getSuggestions: (text) => this.getSuggestions(text), + maxSuggestions: 8, + }); + } catch { + break; + } + const trimmed = input.trim(); if (!trimmed) continue;