u
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@inquirer/core": "^10.3.2",
|
||||||
"@inquirer/prompts": "^7.5.1",
|
"@inquirer/prompts": "^7.5.1",
|
||||||
"chalk": "^5.4.1",
|
"chalk": "^5.4.1",
|
||||||
"dotenv": "^18.0.2",
|
"dotenv": "^18.0.2",
|
||||||
|
|||||||
117
src/autocomplete-input.js
Normal file
117
src/autocomplete-input.js
Normal 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;
|
||||||
70
src/fido.js
70
src/fido.js
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { input } from '@inquirer/prompts';
|
import { editor } from '@inquirer/prompts';
|
||||||
|
|
||||||
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
|
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 { listDir } from './dir-listing.js';
|
||||||
import { loadUndoLog, getUndoLog } from './undo.js';
|
import { loadUndoLog, getUndoLog } from './undo.js';
|
||||||
import { tools } from './tools/index.js';
|
import { tools } from './tools/index.js';
|
||||||
|
import { autocompleteInput } from './autocomplete-input.js';
|
||||||
|
import { COMMANDS, findCommand } from './slash-commands.js';
|
||||||
|
|
||||||
if (!API_KEY) {
|
if (!API_KEY) {
|
||||||
console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n'));
|
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(' ')}`));
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Main loop ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -155,14 +187,13 @@ async function main() {
|
|||||||
const systemPrompt = buildSystemPrompt();
|
const systemPrompt = buildSystemPrompt();
|
||||||
let messages = [{ role: 'system', content: systemPrompt }];
|
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) {
|
while (true) {
|
||||||
let userInput;
|
let userInput;
|
||||||
try {
|
try {
|
||||||
userInput = await input({
|
userInput = await autocompleteInput({
|
||||||
message: chalk.bold.cyan('You'),
|
message: chalk.bold.cyan('You'),
|
||||||
prefix: chalk.cyan('›'),
|
|
||||||
theme: {
|
theme: {
|
||||||
prefix: chalk.cyan('›'),
|
prefix: chalk.cyan('›'),
|
||||||
style: {
|
style: {
|
||||||
@@ -180,11 +211,38 @@ async function main() {
|
|||||||
if (!userInput.trim()) continue;
|
if (!userInput.trim()) continue;
|
||||||
|
|
||||||
// Handle special commands
|
// 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'));
|
console.log(chalk.dim('\n Goodbye! 🐕\n'));
|
||||||
break;
|
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();
|
const undoLog = getUndoLog();
|
||||||
if (undoLog.length === 0) {
|
if (undoLog.length === 0) {
|
||||||
console.log(chalk.yellow('\n Nothing to undo.\n'));
|
console.log(chalk.yellow('\n Nothing to undo.\n'));
|
||||||
|
|||||||
41
src/slash-commands.js
Normal file
41
src/slash-commands.js
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user