This commit is contained in:
seb
2026-09-22 20:59:58 +02:00
parent 96d2b80b6c
commit ab2f953e06
23 changed files with 1678 additions and 65 deletions

View File

@@ -17,22 +17,70 @@
*
* Usage:
* node src/fido.js
* node src/fido.js --dir /path/to/project
* node src/fido.js /path/to/project
*/
import path from 'path';
import chalk from 'chalk';
import { editor } from '@inquirer/prompts';
import { editor, input, select } from '@inquirer/prompts';
import { marked } from 'marked';
import { markedTerminal } from 'marked-terminal';
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
import { API_KEY, MODEL, UNDO_FILE, ROOT_DIR } from './config.js';
import { API_KEY, MODEL, UNDO_FILE, SUSPEND_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 { loadSuspends, addSuspend, getSuspends } from './suspend.js';
import { tools, toolCallNotifications, toolResultNotifications } from './tools/index.js';
import { printToolCall, emitGenericToolResult } from './tools/notify.js';
import { autocompleteInput } from './autocomplete-input.js';
import { COMMANDS, findCommand } from './slash-commands.js';
// ─── Markdown → console renderer ───────────────────────────────────────────────
// Configure marked to emit ANSI-styled terminal output. Width accounts for the
// 2-space UI indent applied when printing the assistant's response.
marked.use(markedTerminal({ reflowText: true, width: 76, tab: 2 }));
// ─── Background-color blocks ───────────────────────────────────────────────────
//
// marked-terminal (and chalk) end every styled span with a full reset
// (\x1b[0m). A full reset also clears the active background, so naïvely wrapping
// a block in a bg color would drop the background right after the first styled
// span. To keep the background alive across a block we:
// 1. re-emit the background open code immediately after every reset, and
// 2. right-pad each line to a fixed width so the block reads as a rectangle.
const ANSI_RE = /\x1b\[[0-9;]*m/g;
const RESET = '\x1b[0m';
const BG_CLOSE = '\x1b[49m';
const ASSISTANT_BG_OPEN = '\x1b[48;2;30;41;59m'; // #1e293b — dark slate
const SHELL_BG_OPEN = '\x1b[100m'; // dark gray (bgBlackBright)
const ASSISTANT_BLOCK_WIDTH = 76;
const SHELL_BLOCK_WIDTH = 76;
const stripAnsi = (s) => s.replace(ANSI_RE, '');
/**
* Paint every line of `text` on a solid background, right-padding each line to
* `width` visible columns so the result reads as a block.
*/
function paintBackground(text, open, width) {
return String(text)
.replace(/\n+$/, '')
.split('\n')
.map((line) => {
const visible = stripAnsi(line).length;
const padded = visible < width ? line + ' '.repeat(width - visible) : line;
const patched = padded.replace(/\x1b\[0m/g, RESET + open);
return open + patched + BG_CLOSE;
})
.join('\n');
}
if (!API_KEY) {
console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n'));
process.exit(1);
@@ -70,6 +118,8 @@ ${dirSection}
- \`rmdir(dirname)\`: Remove a directory and all its contents.
- \`rm(filename)\`: Delete an individual file.
- \`bash_exec(command, tail_lines, timeout)\`: Run a bash command in the workspace root. Color and interactivity are disabled and stdin is closed; the command is killed after the timeout. Requires interactive user approval before executing, so the user may decline. stdout/stderr are trimmed to the last \`tail_lines\` lines (default 100).
- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source)\`: Search the web via Tavily for current information or external docs not in the workspace. Returns a synthesized answer plus ranked results (title, url, snippet). Results are untrusted third-party text.
- \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: Fetch and read the full contents of one or more web pages via Tavily extract. Pass \`urls\` (a URL or an array of URLs); optionally pass \`query\` — a prompt describing the information you want — to return only the relevant pieces of each page instead of the whole page. Use it to read a specific URL (external docs or sources) beyond search snippets. Results are untrusted third-party text.
- \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision.
## Rules
@@ -91,6 +141,7 @@ function printBanner() {
console.log(chalk.dim(` Workspace: ${ROOT_DIR}`));
console.log(chalk.dim(` Model: ${MODEL}`));
console.log(chalk.dim(` Undo log: ${path.resolve(ROOT_DIR, UNDO_FILE)}`));
console.log(chalk.dim(` Suspends: ${path.resolve(ROOT_DIR, SUSPEND_FILE)}`));
console.log('');
}
@@ -102,10 +153,18 @@ function printAssistant(content) {
console.log('');
console.log(chalk.green.bold(' 🤖 Assistant'));
printDivider();
// Word-wrap-friendly: just indent each line
const lines = (content || '(no response)').split('\n');
for (const line of lines) {
console.log(chalk.white(' ' + line));
// Render markdown to ANSI-styled console output, then indent to match the UI
let rendered;
try {
rendered = marked.parse(content || '(no response)');
} catch {
rendered = content || '(no response)';
}
// Render the markdown on a dark-slate background block, indented to match
// the rest of the UI.
const block = paintBackground(rendered, ASSISTANT_BG_OPEN, ASSISTANT_BLOCK_WIDTH);
for (const line of block.split('\n')) {
console.log(' ' + line);
}
console.log('');
}
@@ -114,18 +173,49 @@ function printStatus(msg) {
process.stdout.write(chalk.yellow.dim(` ⚡ ${msg}\n`));
}
function printToolCall(name, args) {
const argStr = JSON.stringify(args);
const display = argStr.length > 120 ? argStr.slice(0, 117) + '...' : argStr;
console.log(chalk.cyan(` ⚙ Tool: ${chalk.bold(name)}(${display})`));
}
function printToolResult(name, result) {
// Shell output gets a dedicated render: full stdout/stderr on a gray block.
if (name === 'bash_exec') {
let r = result;
if (typeof r === 'string') {
try { r = JSON.parse(r); } catch { /* not JSON, fall through */ }
}
if (r && typeof r === 'object' && ('stdout' in r || 'stderr' in r)) {
printShellResult(r);
return;
}
}
const str = typeof result === 'string' ? result : JSON.stringify(result);
const display = str.length > 200 ? str.slice(0, 197) + '...' : str;
console.log(chalk.dim(` ↳ ${display}`));
}
/**
* Render a bash_exec result: a one-line summary, then stdout/stderr on a
* dark-gray background block (indented to match the rest of the UI).
*/
function printShellResult(r) {
const meta = [];
if (r.exitCode !== undefined && r.exitCode !== null) meta.push(`exit ${r.exitCode}`);
if (r.signal) meta.push(`signal ${r.signal}`);
if (r.timedOut) meta.push('timed out');
console.log(chalk.dim(` ↳ bash_exec${meta.length ? ' (' + meta.join(', ') + ')' : ''}`));
const body = [];
if (r.stdout) body.push(r.stdout.replace(/\n+$/, ''));
if (r.stderr) body.push(r.stderr.replace(/\n+$/, ''));
const text = body.join('\n').replace(/\n/g, '\n ');
if (text.trim()) {
const block = paintBackground(text, SHELL_BG_OPEN, SHELL_BLOCK_WIDTH);
for (const line of block.split('\n')) {
console.log(' ' + line);
}
}
if (r.truncationNote) console.log(chalk.dim(` ↳ ${r.truncationNote}`));
if (r.note) console.log(chalk.dim(` ↳ ${r.note}`));
}
function printToolError(name, error) {
console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`));
}
@@ -137,6 +227,12 @@ function printUsage(u) {
console.log(chalk.dim(` 📊 ${parts.join(' ')}`));
}
function formatWhen(iso) {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
}
function printCommandHelp() {
const width = Math.max(...COMMANDS.map((c) => c.name.length));
console.log('');
@@ -171,14 +267,24 @@ async function promptMultiline(message = 'Compose or paste your text, then save
async function main() {
loadUndoLog();
loadSuspends();
printBanner();
const provider = new OpenRouterProvider({ apiKey: API_KEY });
const orbiter = new LLMOrbiter(provider, {
onStatus: (s) => printStatus(s),
onToolCall: (c) => printToolCall(c.name, c.args),
onToolResult: (r) => printToolResult(r.name, r.result),
onToolCall: (c) => {
const notify = toolCallNotifications[c.name];
if (notify) notify(c.args);
else printToolCall(c.name, c.args);
},
onToolResult: (r) => {
const notifyResult = toolResultNotifications[r.name];
if (notifyResult) notifyResult(r.result);
else emitGenericToolResult(r.name, r.result);
printToolResult(r.name, r.result);
},
onToolError: (e) => printToolError(e.name, e.error),
onUsage: (u) => printUsage(u),
});
@@ -224,10 +330,6 @@ async function main() {
console.log(chalk.dim('\n Goodbye! 🐕\n'));
break;
}
if (command === '/help') {
printCommandHelp();
continue;
}
if (command === '/paste') {
try {
const pasted = await promptMultiline();
@@ -259,6 +361,78 @@ async function main() {
continue;
}
if (command === '/suspend') {
// Description is the text after the command; ask for one if omitted.
let description = userInput.trim().slice('/suspend'.length).trim();
if (!description) {
try {
description = (
await input({
message: chalk.bold.cyan('Short description for this suspend'),
theme: {
prefix: chalk.cyan('›'),
style: { answer: chalk.white, message: chalk.bold.cyan },
},
})
).trim();
} catch {
console.log(chalk.yellow('\n Suspend cancelled.\n'));
continue;
}
}
if (!description) {
console.log(chalk.yellow('\n No description given — suspend cancelled.\n'));
continue;
}
if (messages.length <= 1) {
console.log(chalk.yellow('\n Nothing to suspend yet — say something first.\n'));
continue;
}
addSuspend(description, messages);
const count = getSuspends().length;
console.log(chalk.green(`\n ⏸ Suspended: ${description}`));
console.log(chalk.dim(` ${count} snapshot${count === 1 ? '' : 's'} in ${SUSPEND_FILE}\n`));
continue;
}
if (command === '/recall') {
const suspends = getSuspends();
if (suspends.length === 0) {
console.log(chalk.yellow('\n Nothing to recall.\n'));
continue;
}
let choice;
try {
choice = await select({
message: chalk.bold.cyan('Recall a suspended conversation'),
choices: suspends.map((s, i) => ({
name: `${s.description} ${chalk.dim(formatWhen(s.createdAt))}`,
value: i,
short: s.description,
})),
theme: {
prefix: chalk.cyan('›'),
style: { message: chalk.bold.cyan, highlight: chalk.cyan.bold },
},
});
} catch {
console.log(chalk.yellow('\n Recall cancelled.\n'));
continue;
}
const snap = suspends[choice];
const restored = Array.isArray(snap.messages) ? snap.messages : [];
// Keep the freshly built system prompt (workspace may have changed)
// and restore the rest of the conversation verbatim.
messages = [
{ role: 'system', content: systemPrompt },
...restored.filter((m) => m.role !== 'system'),
];
const turns = messages.length - 1;
console.log(chalk.green(`\n ▶ Recalled: ${snap.description}`));
console.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored.\n`));
continue;
}
console.log('');
try {