From 96d2b80b6cb84f5b5f315eda47729dc02a8b6f0c Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 22 Sep 2026 20:11:55 +0200 Subject: [PATCH] u --- package.json | 1 + src/autocomplete-input.js | 117 ++++++++++++++++++++++++++++++++++++++ src/fido.js | 70 +++++++++++++++++++++-- src/slash-commands.js | 41 +++++++++++++ 4 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 src/autocomplete-input.js create mode 100644 src/slash-commands.js diff --git a/package.json b/package.json index e4cf4cd..21ed05b 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { + "@inquirer/core": "^10.3.2", "@inquirer/prompts": "^7.5.1", "chalk": "^5.4.1", "dotenv": "^18.0.2", diff --git a/src/autocomplete-input.js b/src/autocomplete-input.js new file mode 100644 index 0000000..c00f004 --- /dev/null +++ b/src/autocomplete-input.js @@ -0,0 +1,117 @@ +'use strict'; + +/** + * Slash-command autocomplete prompt. + * + * An `input`-style single-line prompt built on @inquirer/core. It behaves like + * a normal text input, but as soon as the buffer starts with "/" (and no + * arguments have been typed yet) it renders the matching slash commands as + * inline help below the prompt. + * + * ↑ / ↓ move the highlight between matches + * Tab complete the highlighted command into the buffer + * Enter submit the buffer (like a normal input) + * + * The filter term is frozen while the menu is open, so completing a command + * with Tab does not collapse the list — repeated ↑/↓ + Tab still works. + */ + +import chalk from 'chalk'; +import { + createPrompt, + useState, + useKeypress, + usePrefix, + isEnterKey, + isTabKey, + isUpKey, + isDownKey, + makeTheme, +} from '@inquirer/core'; + +import { filterCommands } from './slash-commands.js'; + +const MAX_SUGGESTIONS = 6; + +/** Show the inline menu only while the first word is being typed (no space yet). */ +function termFrom(line) { + if (typeof line !== 'string' || !line.startsWith('/')) return null; + if (line.includes(' ')) return null; + return line.toLowerCase(); +} + +/** + * Replace readline's actual edit buffer, not just the visible terminal line. + * `rl.clearLine()` only erases terminal output; using it before `rl.write()` + * leaves the old buffer in place and turns a completion such as `/h` into + * `/h/help` internally. + */ +function replaceBuffer(rl, nextValue) { + rl.write(null, { ctrl: true, name: 'a' }); + rl.write(null, { ctrl: true, name: 'k' }); + rl.write(nextValue); +} + +export const autocompleteInput = createPrompt((config, done) => { + const theme = makeTheme({}, config.theme); + const [status, setStatus] = useState('idle'); + const [value, setValue] = useState(''); + const [term, setTerm] = useState(null); // frozen filter term while the menu is open + const [active, setActive] = useState(0); // highlighted suggestion index + + const prefix = usePrefix({ status, theme }); + const suggestions = term === null ? [] : filterCommands(term).slice(0, MAX_SUGGESTIONS); + const count = suggestions.length; + + useKeypress((key, rl) => { + if (status !== 'idle') return; + + if (isEnterKey(key)) { + const answer = value || rl.line; + setValue(answer); + setStatus('done'); + done(answer); + return; + } + + // Move the highlight within the open menu. Arrow keys do not change + // readline's text buffer, so no line clearing or rewriting is needed. + if (count > 0 && (isUpKey(key) || isDownKey(key))) { + const delta = isUpKey(key) ? -1 : 1; + setActive((active + delta + count) % count); + return; + } + + // Complete the highlighted command into both the displayed value and + // readline's real edit buffer. + if (isTabKey(key)) { + if (count === 0) return; + const pick = suggestions[active % count]; + replaceBuffer(rl, pick.name); + setValue(pick.name); + return; // keep `term` frozen so the menu stays open + } + + // Ordinary editing: mirror the readline buffer and refresh the menu. + const line = rl.line; + setValue(line); + setTerm(termFrom(line)); + setActive(0); + }); + + const message = theme.style.message(config.message); + const formattedValue = status === 'done' ? theme.style.answer(value) : value; + + const lines = suggestions.map((c, i) => { + const marker = i === active % count ? chalk.cyan('❯') : ' '; + return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(7))} ${chalk.dim(c.description)}`; + }); + if (count > 1) { + lines.push(chalk.dim(' ↑/↓ choose · Tab complete')); + } + + const header = [prefix, message, formattedValue].filter((v) => v !== undefined).join(' '); + return [header, lines.length ? lines.join('\n') : undefined]; +}); + +export default autocompleteInput; diff --git a/src/fido.js b/src/fido.js index 1e3ad5c..47d2e2e 100644 --- a/src/fido.js +++ b/src/fido.js @@ -22,7 +22,7 @@ import path from 'path'; import chalk from 'chalk'; -import { input } from '@inquirer/prompts'; +import { editor } from '@inquirer/prompts'; import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter'; @@ -30,6 +30,8 @@ import { API_KEY, MODEL, UNDO_FILE, ROOT_DIR } from './config.js'; import { listDir } from './dir-listing.js'; import { loadUndoLog, getUndoLog } from './undo.js'; import { tools } from './tools/index.js'; +import { autocompleteInput } from './autocomplete-input.js'; +import { COMMANDS, findCommand } from './slash-commands.js'; if (!API_KEY) { console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n')); @@ -135,6 +137,36 @@ function printUsage(u) { console.log(chalk.dim(` 📊 ${parts.join(' ')}`)); } +function printCommandHelp() { + const width = Math.max(...COMMANDS.map((c) => c.name.length)); + console.log(''); + console.log(chalk.green.bold(' 📖 Commands')); + printDivider(); + for (const c of COMMANDS) { + console.log(' ' + chalk.cyan(c.name.padEnd(width)) + ' ' + chalk.dim(c.description)); + } + console.log(''); +} + +// ─── Multiline input (editor prompt) ───────────────────────────────────────────── + +/** + * Open the user's $VISUAL/$EDITOR (falls back to notepad on Windows, vim + * elsewhere) to compose or paste multiline content. Launches immediately — + * no extra keypress — and returns the saved buffer as a string. + */ +async function promptMultiline(message = 'Compose or paste your text, then save & close the editor') { + return editor({ + message: chalk.bold.cyan(message), + waitForUserInput: false, + postfix: '.md', + theme: { + prefix: chalk.cyan('›'), + style: { message: chalk.bold.cyan }, + }, + }); +} + // ─── Main loop ───────────────────────────────────────────────────────────────── async function main() { @@ -155,14 +187,13 @@ async function main() { const systemPrompt = buildSystemPrompt(); let messages = [{ role: 'system', content: systemPrompt }]; - console.log(chalk.dim(' Type your request below. Press Ctrl+C to exit.\n')); + console.log(chalk.dim(' Type your request below. Type / for commands, /paste for multiline. Press Ctrl+C to exit.\n')); while (true) { let userInput; try { - userInput = await input({ + userInput = await autocompleteInput({ message: chalk.bold.cyan('You'), - prefix: chalk.cyan('›'), theme: { prefix: chalk.cyan('›'), style: { @@ -180,11 +211,38 @@ async function main() { if (!userInput.trim()) continue; // Handle special commands - if (userInput.trim() === '/quit' || userInput.trim() === '/exit') { + const command = findCommand(userInput.trim()); + + if (userInput.trim().startsWith('/') && command === null) { + const name = userInput.trim().split(/\s+/)[0]; + console.log(chalk.yellow(`\n Unknown command: ${name}`)); + printCommandHelp(); + continue; + } + + if (command === '/quit') { console.log(chalk.dim('\n Goodbye! 🐕\n')); break; } - if (userInput.trim() === '/undo') { + if (command === '/help') { + printCommandHelp(); + continue; + } + if (command === '/paste') { + try { + const pasted = await promptMultiline(); + if (!pasted || !pasted.trim()) { + console.log(chalk.yellow('\n Nothing pasted.\n')); + continue; + } + userInput = pasted; + printStatus(`Pasted ${pasted.split('\n').length} line(s), sending…`); + } catch { + console.log(chalk.yellow('\n Paste cancelled.\n')); + continue; + } + } + if (command === '/undo') { const undoLog = getUndoLog(); if (undoLog.length === 0) { console.log(chalk.yellow('\n Nothing to undo.\n')); diff --git a/src/slash-commands.js b/src/slash-commands.js new file mode 100644 index 0000000..d12acf1 --- /dev/null +++ b/src/slash-commands.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Slash-command registry. + * + * Built-in REPL directives — everything the user types that starts with "/". + * The autocomplete prompt uses this list to show inline help while typing, and + * the main loop uses `findCommand` to dispatch a line to the right handler. + */ + +export const COMMANDS = [ + { name: '/help', description: 'Show this list of commands' }, + { name: '/paste', description: 'Compose or paste multiline input in $EDITOR' }, + { name: '/edit', description: 'Alias for /paste' }, + { name: '/undo', description: 'Show recent file changes from the undo log' }, + { name: '/quit', description: 'Exit Fido' }, + { name: '/exit', description: 'Alias for /quit' }, +]; + +/** Aliases resolve to a canonical command name at dispatch time. */ +const ALIASES = { + '/edit': '/paste', + '/exit': '/quit', +}; + +/** Return the commands whose name starts with `term` (case-insensitive). */ +export function filterCommands(term) { + const t = (term || '').toLowerCase(); + return COMMANDS.filter((c) => c.name.toLowerCase().startsWith(t)); +} + +/** + * Resolve a raw input line to a canonical command name (e.g. "/edit" -> + * "/paste"), or `null` when the line is not a known directive. + */ +export function findCommand(line) { + if (typeof line !== 'string' || !line.startsWith('/')) return null; + const name = line.trim().split(/\s+/)[0].toLowerCase(); + if (ALIASES[name]) return ALIASES[name]; + return COMMANDS.some((c) => c.name === name) ? name : null; +}