This commit is contained in:
seb
2026-09-25 07:23:01 +02:00
parent af0955b7c1
commit 9f0217d38d
5 changed files with 675 additions and 601 deletions

171
src/commands.js Normal file
View File

@@ -0,0 +1,171 @@
'use strict';
/**
* Slash-command handling for the interactive loop.
*
* The handler owns command-specific prompts and mutations of the small state
* object passed by the REPL. It returns a result describing whether the input
* should be sent to the provider, ignored, or end the session.
*/
import chalk from 'chalk';
import { SUSPEND_DIR, UNDO_FILE } from './config.js';
import { getUndoLog } from './undo.js';
import { addSuspend, getSuspends } from './suspend.js';
import { findCommand, REASONING_LEVELS } from './slash-commands.js';
import { ui } from './ui/index.js';
import { commandHelp, formatWhen, goodbye, logRecalledMessages } from './transcript.js';
const handled = () => ({ action: 'handled' });
const send = (input) => ({ action: 'send', input });
/**
* Handle one line of user input.
*
* `state` contains the mutable conversation fields used by the REPL:
* { messages, systemPrompt, reasoningEffort }
*/
export async function handleCommand(input, state) {
const trimmed = input.trim();
const command = findCommand(trimmed);
if (trimmed.startsWith('/') && command === null) {
const name = trimmed.split(/\s+/)[0];
ui.log(chalk.yellow(` Unknown command: ${name}`));
ui.log(commandHelp());
return handled();
}
// Ordinary user input goes to the provider unchanged (including whitespace
// inside the message).
if (command === null) return send(input);
if (command === '/quit') {
ui.log(goodbye());
return { action: 'quit' };
}
if (command === '/paste') {
try {
const pasted = await ui.composeInEditor('');
if (!pasted || !pasted.trim()) {
ui.log(chalk.yellow(' Nothing pasted.'));
return handled();
}
ui.log(chalk.dim(` ⚡ Pasted ${pasted.split('\n').length} line(s), sending…`));
return send(pasted);
} catch {
ui.log(chalk.yellow(' Paste cancelled.'));
return handled();
}
}
if (command === '/undo') {
const undoLog = getUndoLog();
if (undoLog.length === 0) {
ui.log(chalk.yellow(' Nothing to undo.'));
} else {
const rows = [chalk.yellow(' Undo log (newest first):')];
for (const e of undoLog.slice(0, 10)) {
rows.push(chalk.dim(` ${e.op} ${e.path}`));
}
if (undoLog.length > 10) {
rows.push(chalk.dim(` ... and ${undoLog.length - 10} more in ${UNDO_FILE}`));
}
ui.log(rows.join('\n'));
}
return handled();
}
if (command === '/reasoning') {
const arg = trimmed.slice('/reasoning'.length).trim().toLowerCase();
if (!arg) {
ui.log(chalk.dim(` Reasoning effort: ${state.reasoningEffort || 'default (model)'}`));
ui.log(chalk.dim(` Usage: /reasoning ${REASONING_LEVELS.join('|')}`));
return handled();
}
if (!REASONING_LEVELS.includes(arg)) {
ui.log(chalk.yellow(` Unknown reasoning level: ${arg}`));
ui.log(chalk.dim(` Choose one of: ${REASONING_LEVELS.join(' | ')}`));
return handled();
}
state.reasoningEffort = arg;
ui.setMeta({ reasoning: arg });
ui.log(chalk.green(` 🧠 Reasoning effort set to ${arg}`));
return handled();
}
if (command === '/suspend') {
// Description is the text after the command; ask for one if omitted.
let description = trimmed.slice('/suspend'.length).trim();
if (!description) {
try {
description = (
await ui.input({ message: 'Short description for this suspend', color: 'cyan' })
).trim();
} catch {
ui.log(chalk.yellow(' Suspend cancelled.'));
return handled();
}
}
if (!description) {
ui.log(chalk.yellow(' No description given — suspend cancelled.'));
return handled();
}
if (state.messages.length <= 1) {
ui.log(chalk.yellow(' Nothing to suspend yet — say something first.'));
return handled();
}
addSuspend(description, state.messages);
const count = getSuspends().length;
ui.log(chalk.green(` ⏸ Suspended: ${description}`));
ui.log(chalk.dim(` ${count} snapshot${count === 1 ? '' : 's'} in ${SUSPEND_DIR}`));
return handled();
}
if (command === '/recall') {
const suspends = getSuspends();
if (suspends.length === 0) {
ui.log(chalk.yellow(' Nothing to recall.'));
return handled();
}
let choice;
try {
choice = await ui.select({
message: 'Recall a suspended conversation',
color: 'cyan',
choices: suspends.map((s, i) => ({
name: `${s.description} ${chalk.dim(formatWhen(s.createdAt))}`,
value: i,
})),
});
} catch {
ui.log(chalk.yellow(' Recall cancelled.'));
return handled();
}
const snap = suspends[choice];
if (!snap) {
ui.log(chalk.yellow(' Recall cancelled.'));
return handled();
}
const restored = Array.isArray(snap.messages) ? snap.messages : [];
const restoredMessages = restored.filter((m) => m && m.role !== 'system');
// Keep the freshly built system prompt (workspace may have changed)
// and restore the rest of the conversation verbatim.
state.messages = [
{ role: 'system', content: state.systemPrompt },
...restoredMessages,
];
const turns = state.messages.length - 1;
ui.log(chalk.green(` ▶ Recalled: ${snap.description}`));
ui.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored; showing transcript below.`));
logRecalledMessages(restoredMessages);
return handled();
}
// findCommand currently defines the complete built-in command set. Keep
// this guard so adding a command to the registry cannot accidentally send
// a command line to the model.
return handled();
}

View File

@@ -1,621 +1,28 @@
'use strict';
/**
* Fido \u2014 Minimal Coding Assistant
* Fido — Minimal Coding Assistant
*
* A sandboxed, interactive coding assistant using LLMOrbiter.
*
* Entry point: wires configuration, tools, and the Ink REPL together. Shared
* infrastructure lives in sibling modules (config, sandbox, dir-listing, undo);
* each tool lives in its own file under src/tools/; the terminal UI lives under
* src/ui/ (a single Ink app the command loop and the tools drive through a
* small store).
*
* Config via .env (place in ~/.fido/ or the working directory):
* OPENROUTER_API_KEY=sk-or-...
* MODEL=xiaomi/mimo-v2.6-flash
* UNDO_FILE=.fido-undo.json
* ROOT_DIR=. (optional, defaults to cwd)
* Application entry point. The interactive loop lives in ./repl.js, with the
* system prompt, transcript rendering, and slash commands in sibling modules.
*
* Usage:
* node src/fido.js
* node src/fido.js /path/to/project
*/
import path from 'path';
import chalk from 'chalk';
import { marked } from 'marked';
import { markedTerminal } from 'marked-terminal';
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
import { API_KEY, MODEL, UNDO_FILE, SUSPEND_DIR, ROOT_DIR, COMPACT_DEBUG } from './config.js';
import { listDir } from './dir-listing.js';
import { loadUndoLog, getUndoLog } from './undo.js';
import { loadSuspends, addSuspend, getSuspends } from './suspend.js';
import { tools, toolCallNotifications, toolResultNotifications } from './tools/index.js';
import { printToolCall, emitGenericToolResult } from './tools/notify.js';
import { COMMANDS, REASONING_LEVELS, findCommand } from './slash-commands.js';
import { compactPatchToolCalls, invalidateStaleReadResults } from './compact.js';
import { mount, unmount, ui } from './ui/index.js';
import {
getBlockContentWidth,
renderAssistantBlock,
renderToolBlock,
renderUserBlock,
} from './ui/blocks.js';
// \u2500\u2500\u2500 Markdown \u2192 console renderer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
// 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: getBlockContentWidth(), tab: 2 }));
// \u2500\u2500\u2500 Background-color blocks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
//
// marked-terminal (and chalk) end every styled span with a full reset
// (\x1b[0m). A full reset also clears the active background, so na\u00efvely 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.
// Transcript blocks use the shared role renderer in ./ui/blocks.js.
// \u2500\u2500\u2500 System prompt builder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
function buildSystemPrompt() {
const { entries, total, truncated } = listDir(ROOT_DIR, { seek: 0, limit: 50, recursive: false });
const fmt = (e) => e.type === 'd' ? `d ${e.path}` : `f ${e.size ?? '?'} ${e.path}`;
let dirSection = entries.map(fmt).join('\n');
if (truncated > 0) {
dirSection += `\n ... (${truncated} more entries not shown \u2014 use list_files to explore)`;
}
return `You're Fido, a coding assistant. Inspect files, list dirs, edit code precisely, run shell cmds in the workspace.
## Workspace
Root: ${ROOT_DIR}
Total files: ${total}
## Workspace root (${total} entries):
${dirSection}
## Tools & rules of thumb
- \`read_file(filename, seek, limit)\`: read file w/ line nums. Inspect before editing.
- \`list_files(dirname, recursive, seek, limit, includeDotDirs)\`: list dir (non-recursive default). Honors .gitignore; dot-dirs (.git) hidden unless includeDotDirs=true. recursive=true walks subtree. seek/limit paginate. Paths rel to root.
- \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: regex-search file contents across ws (literal=true = plain str). Recurse dirs, honors .gitignore, skips binary/big files, hides dot-dirs. Returns matches {file,line,column,text}; line is 0-idx, feed straight to read_file seek. glob limits files searched. Long lines capped (textTruncated per match, linesTruncated count).
- \`find_files(pattern, path, type, includeDotDirs, ignore_case, max_results)\`: find files/dirs by name glob across ws (like ripgrep, but for names). Recurses dirs, honors .gitignore, hides dot-dirs. A glob without "/" matches the basename (e.g. "*.js"); with "/" it matches the path. Returns entries {path,type,size}: type "f"/"d", size in bytes (null for dirs). type restricts to "f"/"d"; max_results caps the list.
- \`patch(operations)\`: apply file edits/creates.
- create/overwrite: \`{"file":"path","search":null,"replace":"content"}\` (dirs auto-created).
- search&replace: \`{"file":"path","search":"exact old","replace":"new"}\` \u2014 replaces FIRST hit.
- \`skiplines\` (opt int, 0-idx): skip N lines b4 matching; use if search text appears multiple times.
- \`mkdir(dirname)\`: make dir (recursive).
- \`rmdir(dirname)\`: delete dir + contents.
- \`rm(filename)\`: delete file.
- \`bash_exec(command, tail_lines, timeout)\`: run bash in ws root. No color/interactivity, stdin closed, killed at timeout. Needs explicit user approval \u2014 user may decline. stdout/stderr trimmed to last \`tail_lines\` lines (default 100).
- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source)\`: web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). Use for current info, external docs, or facts not in ws. Untrusted 3rd-party text.
- \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: fetch full contents of web page(s) via Tavily extract. \`urls\` = URL or array. Optional \`query\` = prompt for only relevant bits of each page. Use to read a specific URL (external docs) beyond search snippets. Untrusted 3rd-party text.
- \`ask_user(question, options)\`: ask user when intent ambiguous or you need a decision. Give a concise question + up to 3 labeled options (A,B,C); the user may pick one or type free-form (D).
- \`ask_an_expert(question, context)\`: escalate to a human expert when you're genuinely stuck (a confusing or unclear problem, or you've tried and are still spinning). Don't use it for things you can resolve yourself. Question shown to user as copy-pasteable block for their expert; reply returned to you. Self-contained \`question\`; put supporting detail (errors, code, what you tried) in optional \`context\`.
## File mentions
- User inserts refs via Ctrl+O: \`@path\` (whole file) or \`@path:start-end\` (line range).
- Mentions are refs only \u2014 you must \`read_file\` yourself. Paths rel to root, forward slashes.
- Mention line nums are 1-based; \`read_file\` is 0-idx \u2192 \`@path:start-end\` \u27f9 \`read_file(path, seek=start-1)\`.
## Rules
- Only access files under ws root. Prefer paths relative to the root; avoid ".." and absolute paths. (\`bash_exec\` starts in the ws root, so relative paths resolve there.)
- Always \`read_file\` before patching (confirm exact search text + indentation).
- Prefer targeted SEARCH/REPLACE over rewriting whole files.
- Multiple \`patch\` ops in one call run sequentially.
- Undo tracked per op, managed externally by user.
`;
}
// \u2500\u2500\u2500 Transcript blocks \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
//
// Each builder returns a pre-rendered (mostly ANSI-styled) string that is
// pushed to the transcript via `ui.log`. Nothing here writes to the terminal
// directly \u2014 all output flows through the Ink app.
function divider() {
return chalk.dim(' ' + '\u2500'.repeat(50));
}
function banner() {
return [
'',
chalk.cyan.bold('\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557'),
chalk.cyan.bold('\u2551') + chalk.white.bold(' \ud83d\udc15 FIDO \u2014 Coding Assistant ') + chalk.cyan.bold('\u2551'),
chalk.cyan.bold('\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d'),
chalk.dim(` Workspace: ${ROOT_DIR}`),
chalk.dim(` Model: ${MODEL}`),
chalk.dim(` Undo log: ${path.resolve(ROOT_DIR, UNDO_FILE)}`),
chalk.dim(` Suspends: ${path.resolve(ROOT_DIR, SUSPEND_DIR)}`),
'',
].join('\n');
}
function introLines() {
return [
chalk.dim(' Type your request in the command bar below. Type / for commands, /paste for multiline.'),
chalk.dim(' Ctrl+O add a file mention. Ctrl+C exits at the prompt; while the model is running it cancels the turn.'),
chalk.dim(' PgUp/PgDn scroll the history, End jumps back to live. Token and cost totals live in the footer box.'),
'',
].join('\n');
}
function goodbye() {
return chalk.dim(' Goodbye! \ud83d\udc15');
}
function assistantBlock(content) {
let rendered;
try {
rendered = marked.parse(content || '(no response)');
} catch {
rendered = content || '(no response)';
}
return [
'',
renderAssistantBlock([
chalk.green.bold('\ud83e\udd16 Assistant'),
chalk.dim('\u2500'.repeat(50)),
rendered,
].join('\n')),
'',
].join('\n');
}
function toolErrorLine(name, error) {
return renderToolBlock(chalk.red(`\u2716 Tool error [${name}]: ${error}`));
}
const RECALL_TOOL_LIMIT = 4000;
/** Convert the provider's content variants into text suitable for a block. */
function messageText(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.map((part) => {
if (typeof part === 'string') return part;
if (part && typeof part === 'object') return part.text ?? part.content ?? '';
return '';
}).filter(Boolean).join('\n');
}
if (content == null) return '';
if (typeof content === 'object') {
try {
return JSON.stringify(content) ?? String(content);
} catch {
return String(content);
}
}
return String(content);
}
function clipRecallText(text, limit = RECALL_TOOL_LIMIT) {
const value = String(text ?? '');
if (value.length <= limit) return value;
return value.slice(0, limit - 20) + '\n… [recalled message truncated] …';
}
/** Render one restored provider message in the same role blocks as live turns. */
function recalledMessageBlock(message) {
if (!message || typeof message !== 'object') return null;
const content = messageText(message.content);
if (message.role === 'user') {
if (!content.trim()) return null;
return renderUserBlock(`› You ${content.replace(/\n/g, '\n ')}`);
}
if (message.role === 'assistant') {
if (content.trim()) return assistantBlock(content);
const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
if (calls.length === 0) return null;
const lines = calls.map((call) => {
const fn = call?.function || {};
let args = fn.arguments ?? '';
if (typeof args !== 'string') {
try {
args = JSON.stringify(args);
} catch {
args = String(args);
}
}
args = String(args).replace(/\n/g, ' ').trim();
if (args.length > 240) args = args.slice(0, 237) + '...';
return `⚙ Tool: ${fn.name || 'unknown'}${args ? `(${args})` : ''}`;
});
return renderToolBlock(chalk.dim(lines.join('\n')));
}
if (message.role === 'tool') {
if (!content.trim()) return null;
return renderToolBlock(chalk.dim(`↳ ${clipRecallText(content)}`));
}
return null;
}
/** Add the restored conversation to the visible transcript. */
function logRecalledMessages(restored) {
let shown = 0;
for (const message of restored) {
const block = recalledMessageBlock(message);
if (block) {
ui.log(block);
shown++;
}
}
return shown;
}
/**
* Render a bash_exec result as a complete tool block: status, stdout/stderr,
* and any truncation notes share the same teal background and amber rail.
*/
function shellBlock(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');
const lines = [chalk.dim(`\u21b3 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');
if (text.trim()) lines.push(text);
if (r.truncationNote) lines.push(chalk.dim(`\u21b3 ${r.truncationNote}`));
if (r.note) lines.push(chalk.dim(`\u21b3 ${r.note}`));
return renderToolBlock(lines.join('\n'));
}
function printToolResult(name, result) {
// Shell output gets a dedicated render: full stdout/stderr in a tool 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)) {
ui.log(shellBlock(r));
return;
}
}
const str = typeof result === 'string' ? result : JSON.stringify(result);
const display = str.length > 200 ? str.slice(0, 197) + '...' : str;
ui.log(renderToolBlock(chalk.dim(`\u21b3 ${display}`)));
}
function printUsage(u) {
// Usage now lives in the fixed token/cost footer box (store totals) \u2014
// no per-call history line, so the transcript is not spammed.
ui.addUsage(u);
}
function formatWhen(iso) {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
}
function commandHelp() {
const width = Math.max(...COMMANDS.map((c) => c.name.length));
const rows = [chalk.green.bold(' \ud83d\udcd6 Commands'), divider()];
for (const c of COMMANDS) {
rows.push(' ' + chalk.cyan(c.name.padEnd(width)) + ' ' + chalk.dim(c.description));
}
return rows.join('\n');
}
// \u2500\u2500\u2500 Main loop \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
async function main() {
loadUndoLog();
loadSuspends();
mount();
ui.setMeta({ model: MODEL, workspace: ROOT_DIR });
ui.log(banner());
// Reasoning effort for this session; null means "let the model default apply".
// When set, it is sent as body.reasoning.effort on every request.
let reasoningEffort = null;
// Most recent reasoning the model streamed during the active turn. The
// provider reports reasoning cumulatively, so we keep the latest value. If a
// turn is cancelled mid-flight this is offered back to the user so it can be
// preserved in the transcript instead of being discarded.
let turnReasoning = '';
const provider = new OpenRouterProvider({ apiKey: API_KEY });
const orbiter = new LLMOrbiter(provider, {
onStatus: (s) => ui.status(s),
onReasoning: (text) => {
turnReasoning = text;
ui.setReasoning(text);
},
onContent: () => {
ui.finalizeReasoning();
},
onToolCall: (c) => {
ui.finalizeReasoning();
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) => ui.log(toolErrorLine(e.name, e.error)),
onUsage: (u) => printUsage(u),
});
// While a turn is running there is no prompt, so Ctrl+C (delivered by Ink as
// a keypress) is routed here to abort the turn.
ui.onCancel(() => orbiter.abort('cancelled by user'));
// Conversation state: persisted across turns
const systemPrompt = buildSystemPrompt();
let messages = [{ role: 'system', content: systemPrompt }];
ui.log(introLines());
// Restored into the prompt after a Ctrl+C cancels a running turn, so the
// message can be edited and resent instead of retyped.
let draft = '';
while (true) {
let userInput;
try {
userInput = await ui.ask({ message: 'You', prefill: draft });
} catch {
userInput = null;
}
draft = '';
if (userInput === null) {
ui.log(goodbye());
break;
}
if (!userInput.trim()) continue;
// Handle special commands
const command = findCommand(userInput.trim());
if (userInput.trim().startsWith('/') && command === null) {
const name = userInput.trim().split(/\s+/)[0];
ui.log(chalk.yellow(` Unknown command: ${name}`));
ui.log(commandHelp());
continue;
}
if (command === '/quit') {
ui.log(goodbye());
break;
}
if (command === '/paste') {
try {
const pasted = await ui.composeInEditor('');
if (!pasted || !pasted.trim()) {
ui.log(chalk.yellow(' Nothing pasted.'));
continue;
}
userInput = pasted;
ui.log(chalk.dim(` \u26a1 Pasted ${pasted.split('\n').length} line(s), sending\u2026`));
} catch {
ui.log(chalk.yellow(' Paste cancelled.'));
continue;
}
}
if (command === '/undo') {
const undoLog = getUndoLog();
if (undoLog.length === 0) {
ui.log(chalk.yellow(' Nothing to undo.'));
} else {
const rows = [chalk.yellow(' Undo log (newest first):')];
for (const e of undoLog.slice(0, 10)) {
rows.push(chalk.dim(` ${e.op} ${e.path}`));
}
if (undoLog.length > 10) {
rows.push(chalk.dim(` ... and ${undoLog.length - 10} more in ${UNDO_FILE}`));
}
ui.log(rows.join('\n'));
}
continue;
}
if (command === '/reasoning') {
const arg = userInput.trim().slice('/reasoning'.length).trim().toLowerCase();
if (!arg) {
ui.log(chalk.dim(` Reasoning effort: ${reasoningEffort || 'default (model)'}`));
ui.log(chalk.dim(` Usage: /reasoning ${REASONING_LEVELS.join('|')}`));
continue;
}
if (!REASONING_LEVELS.includes(arg)) {
ui.log(chalk.yellow(` Unknown reasoning level: ${arg}`));
ui.log(chalk.dim(` Choose one of: ${REASONING_LEVELS.join(' | ')}`));
continue;
}
reasoningEffort = arg;
ui.setMeta({ reasoning: arg });
ui.log(chalk.green(` \ud83e\udde0 Reasoning effort set to ${arg}`));
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 ui.input({ message: 'Short description for this suspend', color: 'cyan' })
).trim();
} catch {
ui.log(chalk.yellow(' Suspend cancelled.'));
continue;
}
}
if (!description) {
ui.log(chalk.yellow(' No description given \u2014 suspend cancelled.'));
continue;
}
if (messages.length <= 1) {
ui.log(chalk.yellow(' Nothing to suspend yet \u2014 say something first.'));
continue;
}
addSuspend(description, messages);
const count = getSuspends().length;
ui.log(chalk.green(` \u23f8 Suspended: ${description}`));
ui.log(chalk.dim(` ${count} snapshot${count === 1 ? '' : 's'} in ${SUSPEND_DIR}`));
continue;
}
if (command === '/recall') {
const suspends = getSuspends();
if (suspends.length === 0) {
ui.log(chalk.yellow(' Nothing to recall.'));
continue;
}
let choice;
try {
choice = await ui.select({
message: 'Recall a suspended conversation',
color: 'cyan',
choices: suspends.map((s, i) => ({
name: `${s.description} ${chalk.dim(formatWhen(s.createdAt))}`,
value: i,
})),
});
} catch {
ui.log(chalk.yellow(' Recall cancelled.'));
continue;
}
const snap = suspends[choice];
if (!snap) {
ui.log(chalk.yellow(' Recall cancelled.'));
continue;
}
const restored = Array.isArray(snap.messages) ? snap.messages : [];
const restoredMessages = restored.filter((m) => m && m.role !== 'system');
// Keep the freshly built system prompt (workspace may have changed)
// and restore the rest of the conversation verbatim.
messages = [
{ role: 'system', content: systemPrompt },
...restoredMessages,
];
const turns = messages.length - 1;
ui.log(chalk.green(` \u25b6 Recalled: ${snap.description}`));
ui.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored; showing transcript below.`));
logRecalledMessages(restoredMessages);
continue;
}
// No prompt owns the terminal while the model works, so Ctrl+C is
// handled via the UI (Ink keypress) and, as a fallback when raw mode is
// unavailable, via a SIGINT listener for the duration of the turn.
turnReasoning = '';
ui.clearReasoning();
ui.busy(true);
ui.status('thinking\u2026');
const onSigint = () => orbiter.abort('cancelled by user');
process.on('SIGINT', onSigint);
try {
const result = await orbiter.run({
prompt: userInput,
messages,
model: MODEL,
tools,
maxIterations: 20,
...(reasoningEffort ? { extraBody: { reasoning: { effort: reasoningEffort } } } : {}),
});
// Update conversation history for next turn
messages = result.messages;
// Token savings: the patch tool's arguments carry whole file bodies
// (search/replace). Once applied they are dead weight, so shrink each
// patch tool call in the transcript to a head/tail excerpt.
const compactedPatches = compactPatchToolCalls(messages);
// And the other half of the same problem: a read_file result carries a
// whole file body that a later patch made stale. Replace it with a
// short "file was overwritten meanwhile" marker so the old contents are
// not re-sent every turn (nor mistaken for the current file).
const invalidatedReads = invalidateStaleReadResults(messages);
if (COMPACT_DEBUG) {
ui.log(chalk.dim(
` [compact] patch tool calls compacted: ${compactedPatches} \u00b7 ` +
`stale read results invalidated: ${invalidatedReads}`
));
}
ui.finalizeReasoning();
ui.log(assistantBlock(result.content));
} catch (err) {
ui.clearReasoning();
if (err && err.name === 'AbortError') {
// Ctrl+C cancelled the run \u2014 put the message back in the prompt
// so it can be edited and resent. The aborted transcript is
// discarded, so `messages` is untouched and the turn is not lost.
draft = userInput;
ui.log(chalk.dim(' \u23f9 Cancelled \u2014 your message is back in the prompt; edit it and press Enter to resend.'));
// If the model already produced reasoning before the cancel,
// offer to keep it. Kept reasoning is added to the transcript
// exactly like normal reasoning (an assistant message carrying
// the reasoning text), tagged with a marker at the end so it's
// clear the turn was cut short.
const reasoning = turnReasoning.trim();
if (reasoning) {
let keep = false;
try {
keep = await ui.confirm({
message: 'Keep the reasoning the model produced before cancelling?',
default: true,
color: 'cyan',
});
} catch {
keep = false; // prompt dismissed (e.g. Ctrl+C) \u2014 don't keep
}
if (keep) {
messages.push({
role: 'assistant',
content: `${reasoning}\n---USER-CANCELLED---`,
});
ui.finalizeReasoning(reasoning);
ui.log(chalk.dim(' \u2197 Reasoning kept in the transcript.'));
}
}
} else {
ui.log(chalk.red(` \u2716 Error: ${err.message}`));
}
} finally {
process.removeListener('SIGINT', onSigint);
ui.busy(false);
}
}
await unmount();
}
import { API_KEY } from './config.js';
import { unmount } from './ui/index.js';
import { runRepl } from './repl.js';
if (!API_KEY) {
console.error(chalk.red.bold('\n\u2716 Missing OPENROUTER_API_KEY \u2014 add it to .env\n'));
console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n'));
process.exit(1);
}
main().catch(async (err) => {
runRepl().catch(async (err) => {
try {
await unmount();
} catch {

207
src/repl.js Normal file
View File

@@ -0,0 +1,207 @@
'use strict';
/**
* Interactive conversation loop.
*
* This module owns session state and provider turns. Rendering, slash
* commands, and the system prompt live in their own modules so this loop stays
* focused on coordinating them.
*/
import chalk from 'chalk';
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
import { API_KEY, COMPACT_DEBUG, MODEL, ROOT_DIR } from './config.js';
import { loadUndoLog } from './undo.js';
import { loadSuspends } from './suspend.js';
import { tools, toolCallNotifications, toolResultNotifications } from './tools/index.js';
import { printToolCall, emitGenericToolResult } from './tools/notify.js';
import { compactPatchToolCalls, invalidateStaleReadResults } from './compact.js';
import { mount, unmount, ui } from './ui/index.js';
import { buildSystemPrompt } from './system-prompt.js';
import { handleCommand } from './commands.js';
import {
assistantBlock,
banner,
goodbye,
introLines,
printToolResult,
printUsage,
toolErrorLine,
} from './transcript.js';
function createOrbiter(turnReasoning) {
const provider = new OpenRouterProvider({ apiKey: API_KEY });
return new LLMOrbiter(provider, {
onStatus: (s) => ui.status(s),
onReasoning: (text) => {
turnReasoning.text = text;
ui.setReasoning(text);
},
onContent: () => {
ui.finalizeReasoning();
},
onToolCall: (c) => {
ui.finalizeReasoning();
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) => ui.log(toolErrorLine(e.name, e.error)),
onUsage: (u) => printUsage(u),
});
}
/**
* Run one provider turn and return the updated conversation and prompt draft.
* Keeping this operation in one function ensures the SIGINT listener and busy
* state are always cleaned up, including the cancellation path.
*/
async function runTurn({ orbiter, userInput, messages, reasoningEffort, turnReasoning }) {
// No prompt owns the terminal while the model works, so Ctrl+C is handled
// via the UI and, as a fallback when raw mode is unavailable, via SIGINT.
turnReasoning.text = '';
ui.clearReasoning();
ui.busy(true);
ui.status('thinking…');
const onSigint = () => orbiter.abort('cancelled by user');
process.on('SIGINT', onSigint);
try {
const result = await orbiter.run({
prompt: userInput,
messages,
model: MODEL,
tools,
maxIterations: 20,
...(reasoningEffort ? { extraBody: { reasoning: { effort: reasoningEffort } } } : {}),
});
// Update conversation history for next turn.
messages = result.messages;
// Token savings: the patch tool's arguments carry whole file bodies
// (search/replace). Once applied they are dead weight, so shrink each
// patch tool call in the transcript to a head/tail excerpt.
const compactedPatches = compactPatchToolCalls(messages);
// Replace whole-file read results made stale by a later patch.
const invalidatedReads = invalidateStaleReadResults(messages);
if (COMPACT_DEBUG) {
ui.log(chalk.dim(
` [compact] patch tool calls compacted: ${compactedPatches} · ` +
`stale read results invalidated: ${invalidatedReads}`
));
}
ui.finalizeReasoning();
ui.log(assistantBlock(result.content));
return { messages, draft: '' };
} catch (err) {
ui.clearReasoning();
if (err && err.name === 'AbortError') {
// Put the message back in the prompt so it can be edited and resent.
ui.log(chalk.dim(' ⏹ Cancelled — your message is back in the prompt; edit it and press Enter to resend.'));
// If the model already produced reasoning before the cancel, offer to
// keep it in the transcript exactly like normal reasoning.
const reasoning = turnReasoning.text.trim();
if (reasoning) {
let keep = false;
try {
keep = await ui.confirm({
message: 'Keep the reasoning the model produced before cancelling?',
default: true,
color: 'cyan',
});
} catch {
keep = false; // prompt dismissed (e.g. Ctrl+C) — don't keep
}
if (keep) {
messages.push({
role: 'assistant',
content: `${reasoning}\n---USER-CANCELLED---`,
});
ui.finalizeReasoning(reasoning);
ui.log(chalk.dim(' ↗ Reasoning kept in the transcript.'));
}
}
return { messages, draft: userInput };
}
ui.log(chalk.red(` ✖ Error: ${err.message}`));
return { messages, draft: '' };
} finally {
process.removeListener('SIGINT', onSigint);
ui.busy(false);
}
}
/** Start the interactive Fido session and resolve after it exits. */
export async function runRepl() {
loadUndoLog();
loadSuspends();
mount();
try {
ui.setMeta({ model: MODEL, workspace: ROOT_DIR });
ui.log(banner());
// Reasoning effort for this session; null means "let the model default
// apply". When set, it is sent on every request.
const turnReasoning = { text: '' };
const orbiter = createOrbiter(turnReasoning);
const systemPrompt = buildSystemPrompt();
const state = {
messages: [{ role: 'system', content: systemPrompt }],
systemPrompt,
reasoningEffort: null,
};
// While a turn is running there is no prompt, so Ctrl+C (delivered by
// Ink as a keypress) is routed here to abort the turn.
ui.onCancel(() => orbiter.abort('cancelled by user'));
ui.log(introLines());
// Restored into the prompt after Ctrl+C cancels a running turn.
let draft = '';
while (true) {
let userInput;
try {
userInput = await ui.ask({ message: 'You', prefill: draft });
} catch {
userInput = null;
}
draft = '';
if (userInput === null) {
ui.log(goodbye());
break;
}
if (!userInput.trim()) continue;
const commandResult = await handleCommand(userInput, state);
if (commandResult.action === 'quit') break;
if (commandResult.action === 'handled') continue;
userInput = commandResult.input;
const turn = await runTurn({
orbiter,
userInput,
messages: state.messages,
reasoningEffort: state.reasoningEffort,
turnReasoning,
});
state.messages = turn.messages;
draft = turn.draft;
}
} finally {
await unmount();
}
}

60
src/system-prompt.js Normal file
View File

@@ -0,0 +1,60 @@
'use strict';
/**
* Build the system prompt sent at the beginning of every provider request.
*
* Keeping this separate from the REPL makes the prompt easy to inspect and
* update without adding more state-management code to the command loop.
*/
import { ROOT_DIR } from './config.js';
import { listDir } from './dir-listing.js';
export function buildSystemPrompt() {
const { entries, total, truncated } = listDir(ROOT_DIR, { seek: 0, limit: 50, recursive: false });
const fmt = (e) => e.type === 'd' ? `d ${e.path}` : `f ${e.size ?? '?'} ${e.path}`;
let dirSection = entries.map(fmt).join('\n');
if (truncated > 0) {
dirSection += `\n ... (${truncated} more entries not shown — use list_files to explore)`;
}
return `You're Fido, a coding assistant. Inspect files, list dirs, edit code precisely, run shell cmds in the workspace.
## Workspace
Root: ${ROOT_DIR}
Total files: ${total}
## Workspace root (${total} entries):
${dirSection}
## Tools & rules of thumb
- \`read_file(filename, seek, limit)\`: read file w/ line nums. Inspect before editing.
- \`list_files(dirname, recursive, seek, limit, includeDotDirs)\`: list dir (non-recursive default). Honors .gitignore; dot-dirs (.git) hidden unless includeDotDirs=true. recursive=true walks subtree. seek/limit paginate. Paths rel to root.
- \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: regex-search file contents across ws (literal=true = plain str). Recurse dirs, honors .gitignore, skips binary/big files, hides dot-dirs. Returns matches {file,line,column,text}; line is 0-idx, feed straight to read_file seek. glob limits files searched. Long lines capped (textTruncated per match, linesTruncated count).
- \`find_files(pattern, path, type, includeDotDirs, ignore_case, max_results)\`: find files/dirs by name glob across ws (like ripgrep, but for names). Recurses dirs, honors .gitignore, hides dot-dirs. A glob without "/" matches the basename (e.g. "*.js"); with "/" it matches the path. Returns entries {path,type,size}: type "f"/"d", size in bytes (null for dirs). type restricts to "f"/"d"; max_results caps the list.
- \`patch(operations)\`: apply file edits/creates.
- create/overwrite: \`{"file":"path","search":null,"replace":"content"}\` (dirs auto-created).
- search&replace: \`{"file":"path","search":"exact old","replace":"new"}\` — replaces FIRST hit.
- \`skiplines\` (opt int, 0-idx): skip N lines b4 matching; use if search text appears multiple times.
- \`mkdir(dirname)\`: make dir (recursive).
- \`rmdir(dirname)\`: delete dir + contents.
- \`rm(filename)\`: delete file.
- \`bash_exec(command, tail_lines, timeout)\`: run bash in ws root. No color/interactivity, stdin closed, killed at timeout. Needs explicit user approval — user may decline. stdout/stderr trimmed to last \`tail_lines\` lines (default 100).
- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source)\`: web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). Use for current info, external docs, or facts not in ws. Untrusted 3rd-party text.
- \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: fetch full contents of web page(s) via Tavily extract. \`urls\` = URL or array. Optional \`query\` = prompt for only relevant bits of each page. Use to read a specific URL (external docs) beyond search snippets. Untrusted 3rd-party text.
- \`ask_user(question, options)\`: ask user when intent ambiguous or you need a decision. Give a concise question + up to 3 labeled options (A,B,C); the user may pick one or type free-form (D).
- \`ask_an_expert(question, context)\`: escalate to a human expert when you're genuinely stuck (a confusing or unclear problem, or you've tried and are still spinning). Don't use it for things you can resolve yourself. Question shown to user as copy-pasteable block for their expert; reply returned to you. Self-contained \`question\`; put supporting detail (errors, code, what you tried) in optional \`context\`.
## File mentions
- User inserts refs via Ctrl+O: \`@path\` (whole file) or \`@path:start-end\` (line range).
- Mentions are refs only — you must \`read_file\` yourself. Paths rel to root, forward slashes.
- Mention line nums are 1-based; \`read_file\` is 0-idx → \`@path:start-end\` ⟹ \`read_file(path, seek=start-1)\`.
## Rules
- Only access files under ws root. Prefer paths relative to the root; avoid ".." and absolute paths. (\`bash_exec\` starts in the ws root, so relative paths resolve there.)
- Always \`read_file\` before patching (confirm exact search text + indentation).
- Prefer targeted SEARCH/REPLACE over rewriting whole files.
- Multiple \`patch\` ops in one call run sequentially.
- Undo tracked per op, managed externally by user.
`;
}

229
src/transcript.js Normal file
View File

@@ -0,0 +1,229 @@
'use strict';
/**
* Transcript rendering and persistence-facing display helpers.
*
* Everything here returns a string for the Ink history pane (or forwards a
* completed provider message to it). The REPL owns conversation state; this
* module owns the visual representation of that state.
*/
import path from 'path';
import chalk from 'chalk';
import { marked } from 'marked';
import { markedTerminal } from 'marked-terminal';
import { MODEL, ROOT_DIR, SUSPEND_DIR, UNDO_FILE } from './config.js';
import { COMMANDS } from './slash-commands.js';
import { ui } from './ui/index.js';
import {
getBlockContentWidth,
renderAssistantBlock,
renderToolBlock,
renderUserBlock,
} from './ui/blocks.js';
// 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: getBlockContentWidth(), tab: 2 }));
const BANNER_WIDTH = 50;
const BANNER_TOP = `╔${'═'.repeat(BANNER_WIDTH - 2)}╗`;
const BANNER_BOTTOM = `╚${'═'.repeat(BANNER_WIDTH - 2)}╝`;
/** Render a small separator used by the command help panel. */
function divider() {
return chalk.dim(' ' + '─'.repeat(BANNER_WIDTH));
}
export function banner() {
return [
'',
chalk.cyan.bold(BANNER_TOP),
chalk.cyan.bold('║') + chalk.white.bold(' 🐕 FIDO — Coding Assistant ') + chalk.cyan.bold('║'),
chalk.cyan.bold(BANNER_BOTTOM),
chalk.dim(` Workspace: ${ROOT_DIR}`),
chalk.dim(` Model: ${MODEL}`),
chalk.dim(` Undo log: ${path.resolve(ROOT_DIR, UNDO_FILE)}`),
chalk.dim(` Suspends: ${path.resolve(ROOT_DIR, SUSPEND_DIR)}`),
'',
].join('\n');
}
export function introLines() {
return [
chalk.dim(' Type your request in the command bar below. Type / for commands, /paste for multiline.'),
chalk.dim(' Ctrl+O add a file mention. Ctrl+C exits at the prompt; while the model is running it cancels the turn.'),
chalk.dim(' PgUp/PgDn scroll the history, End jumps back to live. Token and cost totals live in the footer box.'),
'',
].join('\n');
}
export function goodbye() {
return chalk.dim(' Goodbye! 🐕');
}
export function assistantBlock(content) {
let rendered;
try {
rendered = marked.parse(content || '(no response)');
} catch {
rendered = content || '(no response)';
}
return [
'',
renderAssistantBlock([
chalk.green.bold('🤖 Assistant'),
chalk.dim('─'.repeat(BANNER_WIDTH)),
rendered,
].join('\n')),
'',
].join('\n');
}
export function toolErrorLine(name, error) {
return renderToolBlock(chalk.red(`✖ Tool error [${name}]: ${error}`));
}
export const RECALL_TOOL_LIMIT = 4000;
/** Convert the provider's content variants into text suitable for a block. */
export function messageText(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.map((part) => {
if (typeof part === 'string') return part;
if (part && typeof part === 'object') return part.text ?? part.content ?? '';
return '';
}).filter(Boolean).join('\n');
}
if (content == null) return '';
if (typeof content === 'object') {
try {
return JSON.stringify(content) ?? String(content);
} catch {
return String(content);
}
}
return String(content);
}
export function clipRecallText(text, limit = RECALL_TOOL_LIMIT) {
const value = String(text ?? '');
if (value.length <= limit) return value;
return value.slice(0, limit - 20) + '\n… [recalled message truncated] …';
}
/** Render one restored provider message in the same role blocks as live turns. */
export function recalledMessageBlock(message) {
if (!message || typeof message !== 'object') return null;
const content = messageText(message.content);
if (message.role === 'user') {
if (!content.trim()) return null;
return renderUserBlock(`› You ${content.replace(/\n/g, '\n ')}`);
}
if (message.role === 'assistant') {
if (content.trim()) return assistantBlock(content);
const calls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
if (calls.length === 0) return null;
const lines = calls.map((call) => {
const fn = call?.function || {};
let args = fn.arguments ?? '';
if (typeof args !== 'string') {
try {
args = JSON.stringify(args);
} catch {
args = String(args);
}
}
args = String(args).replace(/\n/g, ' ').trim();
if (args.length > 240) args = args.slice(0, 237) + '...';
return `⚙ Tool: ${fn.name || 'unknown'}${args ? `(${args})` : ''}`;
});
return renderToolBlock(chalk.dim(lines.join('\n')));
}
if (message.role === 'tool') {
if (!content.trim()) return null;
return renderToolBlock(chalk.dim(`↳ ${clipRecallText(content)}`));
}
return null;
}
/** Add the restored conversation to the visible transcript. */
export function logRecalledMessages(restored) {
let shown = 0;
for (const message of restored) {
const block = recalledMessageBlock(message);
if (block) {
ui.log(block);
shown++;
}
}
return shown;
}
/**
* Render a bash_exec result as a complete tool block: status, stdout/stderr,
* and any truncation notes share the same teal background and amber rail.
*/
function shellBlock(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');
const lines = [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');
if (text.trim()) lines.push(text);
if (r.truncationNote) lines.push(chalk.dim(`↳ ${r.truncationNote}`));
if (r.note) lines.push(chalk.dim(`↳ ${r.note}`));
return renderToolBlock(lines.join('\n'));
}
export function printToolResult(name, result) {
// Shell output gets a dedicated render: full stdout/stderr in a tool 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)) {
ui.log(shellBlock(r));
return;
}
}
const str = typeof result === 'string' ? result : JSON.stringify(result);
const display = str.length > 200 ? str.slice(0, 197) + '...' : str;
ui.log(renderToolBlock(chalk.dim(`↳ ${display}`)));
}
export function printUsage(u) {
// Usage now lives in the fixed token/cost footer box (store totals) —
// no per-call history line, so the transcript is not spammed.
ui.addUsage(u);
}
export function formatWhen(iso) {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
}
export function commandHelp() {
const width = Math.max(...COMMANDS.map((c) => c.name.length));
const rows = [chalk.green.bold(' 📖 Commands'), divider()];
for (const c of COMMANDS) {
rows.push(' ' + chalk.cyan(c.name.padEnd(width)) + ' ' + chalk.dim(c.description));
}
return rows.join('\n');
}