This commit is contained in:
seb
2026-09-22 20:11:55 +02:00
parent b15ddfadc3
commit 96d2b80b6c
4 changed files with 223 additions and 6 deletions

View File

@@ -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",

117
src/autocomplete-input.js Normal file
View File

@@ -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;

View File

@@ -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'));

41
src/slash-commands.js Normal file
View File

@@ -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;
}