u
This commit is contained in:
462
src/fido.js
462
src/fido.js
@@ -1,15 +1,17 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Fido — Minimal Coding Assistant
|
||||
* Fido \u2014 Minimal Coding Assistant
|
||||
*
|
||||
* A sandboxed, interactive coding assistant using LLMOrbiter.
|
||||
*
|
||||
* Entry point: wires configuration, tools, and the console REPL together.
|
||||
* Shared infrastructure lives in sibling modules (config, sandbox, dir-listing,
|
||||
* undo); each tool lives in its own file under src/tools/.
|
||||
* 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 c:\src\fido\ or the working directory):
|
||||
* 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
|
||||
@@ -22,7 +24,6 @@
|
||||
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import { editor, input, select, confirm } from '@inquirer/prompts';
|
||||
import { marked } from 'marked';
|
||||
import { markedTerminal } from 'marked-terminal';
|
||||
|
||||
@@ -34,216 +35,144 @@ 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 { autocompleteInput } from './autocomplete-input.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 } from './ui/blocks.js';
|
||||
|
||||
// ─── Markdown → console renderer ───────────────────────────────────────────────
|
||||
// \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: 76, tab: 2 }));
|
||||
marked.use(markedTerminal({ reflowText: true, width: getBlockContentWidth(), tab: 2 }));
|
||||
|
||||
// ─── Background-color blocks ───────────────────────────────────────────────────
|
||||
// \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ïvely wrapping
|
||||
// (\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.
|
||||
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
const RESET = '\x1b[0m';
|
||||
const BG_CLOSE = '\x1b[49m';
|
||||
// Transcript blocks use the shared role renderer in ./ui/blocks.js.
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── System prompt builder ─────────────────────────────────────────────────────
|
||||
// \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 — use list_files to explore)`;
|
||||
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"}\` — 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.
|
||||
`;
|
||||
return `You're Fido, a coding assistant. Inspect files, list dirs, edit code precisely, run shell cmds in the workspace.\n\n## Workspace\nRoot: ${ROOT_DIR}\nTotal files: ${total}\n\n## Workspace root (${total} entries):\n${dirSection}\n\n## Tools & rules of thumb\n- \`read_file(filename, seek, limit)\`: read file w/ line nums. Inspect before editing.\n- \`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.\n- \`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).\n- \`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.\n- \`patch(operations)\`: apply file edits/creates.\n - create/overwrite: \`{"file":"path","search":null,"replace":"content"}\` (dirs auto-created).\n - search&replace: \`{"file":"path","search":"exact old","replace":"new"}\` \u2014 replaces FIRST hit.\n - \`skiplines\` (opt int, 0-idx): skip N lines b4 matching; use if search text appears multiple times.\n- \`mkdir(dirname)\`: make dir (recursive).\n- \`rmdir(dirname)\`: delete dir + contents.\n- \`rm(filename)\`: delete file.\n- \`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).\n- \`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.\n- \`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.\n- \`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).\n- \`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\`.\n\n## File mentions\n- User inserts refs via Ctrl+O: \`@path\` (whole file) or \`@path:start-end\` (line range).\n- Mentions are refs only \u2014 you must \`read_file\` yourself. Paths rel to root, forward slashes.\n- Mention line nums are 1-based; \`read_file\` is 0-idx \u2192 \`@path:start-end\` \u27f9 \`read_file(path, seek=start-1)\`.\n\n## Rules\n- 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.)\n- Always \`read_file\` before patching (confirm exact search text + indentation).\n- Prefer targeted SEARCH/REPLACE over rewriting whole files.\n- Multiple \`patch\` ops in one call run sequentially.\n- Undo tracked per op, managed externally by user.\n`;
|
||||
}
|
||||
|
||||
// ─── Console UI ────────────────────────────────────────────────────────────────
|
||||
// \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 printBanner() {
|
||||
console.log('');
|
||||
console.log(chalk.cyan.bold('╔══════════════════════════════════════════════╗'));
|
||||
console.log(chalk.cyan.bold('║') + chalk.white.bold(' 🐕 FIDO — Coding Assistant ') + chalk.cyan.bold('║'));
|
||||
console.log(chalk.cyan.bold('╚══════════════════════════════════════════════╝'));
|
||||
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_DIR)}`));
|
||||
console.log('');
|
||||
function divider() {
|
||||
return chalk.dim(' ' + '\u2500'.repeat(50));
|
||||
}
|
||||
|
||||
function printDivider() {
|
||||
console.log(chalk.dim(' ' + '─'.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 printAssistant(content) {
|
||||
console.log('');
|
||||
console.log(chalk.green.bold(' 🤖 Assistant'));
|
||||
printDivider();
|
||||
// Render markdown to ANSI-styled console output, then indent to match the UI
|
||||
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)';
|
||||
}
|
||||
// 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('');
|
||||
|
||||
return [
|
||||
'',
|
||||
renderAssistantBlock([
|
||||
chalk.green.bold('\ud83e\udd16 Assistant'),
|
||||
chalk.dim('\u2500'.repeat(50)),
|
||||
rendered,
|
||||
].join('\n')),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function printStatus(msg) {
|
||||
process.stdout.write(chalk.yellow.dim(` ⚡ ${msg}\n`));
|
||||
function toolErrorLine(name, error) {
|
||||
return renderToolBlock(chalk.red(`\u2716 Tool error [${name}]: ${error}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 on a gray block.
|
||||
// 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)) {
|
||||
printShellResult(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;
|
||||
console.log(chalk.dim(` ↳ ${display}`));
|
||||
ui.log(renderToolBlock(chalk.dim(`\u21b3 ${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}`));
|
||||
}
|
||||
|
||||
function printUsage(u, totals) {
|
||||
const parts = [`in=${u.input}`, `out=${u.output}`];
|
||||
if (u.cached) parts.push(`cached=${u.cached}`);
|
||||
if (u.cost) parts.push(`cost=$${u.cost.toFixed(5)}`);
|
||||
let line = ` 📊 ${parts.join(' ')}`;
|
||||
if (totals) {
|
||||
totals.calls += 1;
|
||||
totals.input += u.input || 0;
|
||||
totals.output += u.output || 0;
|
||||
totals.cached += u.cached || 0;
|
||||
totals.cost += u.cost || 0;
|
||||
const acc = [`in=${totals.input}`, `out=${totals.output}`];
|
||||
if (totals.cached) acc.push(`cached=${totals.cached}`);
|
||||
if (totals.cost) acc.push(`cost=$${totals.cost.toFixed(5)}`);
|
||||
line += ` [(${totals.calls}) ${acc.join(', ')}]`;
|
||||
}
|
||||
console.log(chalk.dim(line));
|
||||
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) {
|
||||
@@ -252,42 +181,24 @@ function formatWhen(iso) {
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
|
||||
}
|
||||
|
||||
function printCommandHelp() {
|
||||
function commandHelp() {
|
||||
const width = Math.max(...COMMANDS.map((c) => c.name.length));
|
||||
console.log('');
|
||||
console.log(chalk.green.bold(' 📖 Commands'));
|
||||
printDivider();
|
||||
const rows = [chalk.green.bold(' \ud83d\udcd6 Commands'), divider()];
|
||||
for (const c of COMMANDS) {
|
||||
console.log(' ' + chalk.cyan(c.name.padEnd(width)) + ' ' + chalk.dim(c.description));
|
||||
rows.push(' ' + chalk.cyan(c.name.padEnd(width)) + ' ' + chalk.dim(c.description));
|
||||
}
|
||||
console.log('');
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
// ─── 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 ─────────────────────────────────────────────────────────────────
|
||||
// \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();
|
||||
printBanner();
|
||||
|
||||
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.
|
||||
@@ -301,13 +212,17 @@ async function main() {
|
||||
|
||||
const provider = new OpenRouterProvider({ apiKey: API_KEY });
|
||||
|
||||
// Running totals across the session, echoed after each per-call usage line.
|
||||
const usageTotals = { calls: 0, input: 0, output: 0, cached: 0, cost: 0 };
|
||||
|
||||
const orbiter = new LLMOrbiter(provider, {
|
||||
onStatus: (s) => printStatus(s),
|
||||
onReasoning: (text) => { turnReasoning = text; },
|
||||
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);
|
||||
@@ -318,16 +233,19 @@ async function main() {
|
||||
else emitGenericToolResult(r.name, r.result);
|
||||
printToolResult(r.name, r.result);
|
||||
},
|
||||
onToolError: (e) => printToolError(e.name, e.error),
|
||||
onUsage: (u) => printUsage(u, usageTotals),
|
||||
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 }];
|
||||
|
||||
console.log(chalk.dim(' Type your request below. Type / for commands, /paste for multiline.'));
|
||||
console.log(chalk.dim(' Ctrl+O add a file mention. Ctrl+C exits at the prompt; while the model is running it cancels the turn.\n'));
|
||||
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.
|
||||
@@ -336,24 +254,16 @@ async function main() {
|
||||
while (true) {
|
||||
let userInput;
|
||||
try {
|
||||
userInput = await autocompleteInput({
|
||||
message: chalk.bold.cyan('You'),
|
||||
default: draft,
|
||||
theme: {
|
||||
prefix: chalk.cyan('›'),
|
||||
style: {
|
||||
answer: chalk.white,
|
||||
message: chalk.bold.cyan,
|
||||
},
|
||||
},
|
||||
});
|
||||
userInput = await ui.ask({ message: 'You', prefill: draft });
|
||||
} catch {
|
||||
// Ctrl+C / EOF
|
||||
console.log(chalk.dim('\n Goodbye! 🐕\n'));
|
||||
break;
|
||||
userInput = null;
|
||||
}
|
||||
draft = '';
|
||||
|
||||
if (userInput === null) {
|
||||
ui.log(goodbye());
|
||||
break;
|
||||
}
|
||||
if (!userInput.trim()) continue;
|
||||
|
||||
// Handle special commands
|
||||
@@ -361,42 +271,43 @@ async function main() {
|
||||
|
||||
if (userInput.trim().startsWith('/') && command === null) {
|
||||
const name = userInput.trim().split(/\s+/)[0];
|
||||
console.log(chalk.yellow(`\n Unknown command: ${name}`));
|
||||
printCommandHelp();
|
||||
ui.log(chalk.yellow(` Unknown command: ${name}`));
|
||||
ui.log(commandHelp());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (command === '/quit') {
|
||||
console.log(chalk.dim('\n Goodbye! 🐕\n'));
|
||||
ui.log(goodbye());
|
||||
break;
|
||||
}
|
||||
|
||||
if (command === '/paste') {
|
||||
try {
|
||||
const pasted = await promptMultiline();
|
||||
const pasted = await ui.composeInEditor('');
|
||||
if (!pasted || !pasted.trim()) {
|
||||
console.log(chalk.yellow('\n Nothing pasted.\n'));
|
||||
ui.log(chalk.yellow(' Nothing pasted.'));
|
||||
continue;
|
||||
}
|
||||
userInput = pasted;
|
||||
printStatus(`Pasted ${pasted.split('\n').length} line(s), sending…`);
|
||||
ui.log(chalk.dim(` \u26a1 Pasted ${pasted.split('\n').length} line(s), sending\u2026`));
|
||||
} catch {
|
||||
console.log(chalk.yellow('\n Paste cancelled.\n'));
|
||||
ui.log(chalk.yellow(' Paste cancelled.'));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (command === '/undo') {
|
||||
const undoLog = getUndoLog();
|
||||
if (undoLog.length === 0) {
|
||||
console.log(chalk.yellow('\n Nothing to undo.\n'));
|
||||
ui.log(chalk.yellow(' Nothing to undo.'));
|
||||
} else {
|
||||
console.log(chalk.yellow('\n Undo log (newest first):'));
|
||||
const rows = [chalk.yellow(' Undo log (newest first):')];
|
||||
for (const e of undoLog.slice(0, 10)) {
|
||||
console.log(chalk.dim(` ${e.op} ${e.path}`));
|
||||
rows.push(chalk.dim(` ${e.op} ${e.path}`));
|
||||
}
|
||||
if (undoLog.length > 10) {
|
||||
console.log(chalk.dim(` ... and ${undoLog.length - 10} more in ${UNDO_FILE}`));
|
||||
rows.push(chalk.dim(` ... and ${undoLog.length - 10} more in ${UNDO_FILE}`));
|
||||
}
|
||||
console.log('');
|
||||
ui.log(rows.join('\n'));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -404,17 +315,18 @@ async function main() {
|
||||
if (command === '/reasoning') {
|
||||
const arg = userInput.trim().slice('/reasoning'.length).trim().toLowerCase();
|
||||
if (!arg) {
|
||||
console.log(chalk.dim(`\n Reasoning effort: ${reasoningEffort || 'default (model)'}`));
|
||||
console.log(chalk.dim(` Usage: /reasoning ${REASONING_LEVELS.join('|')}\n`));
|
||||
ui.log(chalk.dim(` Reasoning effort: ${reasoningEffort || 'default (model)'}`));
|
||||
ui.log(chalk.dim(` Usage: /reasoning ${REASONING_LEVELS.join('|')}`));
|
||||
continue;
|
||||
}
|
||||
if (!REASONING_LEVELS.includes(arg)) {
|
||||
console.log(chalk.yellow(`\n Unknown reasoning level: ${arg}`));
|
||||
console.log(chalk.dim(` Choose one of: ${REASONING_LEVELS.join(' | ')}\n`));
|
||||
ui.log(chalk.yellow(` Unknown reasoning level: ${arg}`));
|
||||
ui.log(chalk.dim(` Choose one of: ${REASONING_LEVELS.join(' | ')}`));
|
||||
continue;
|
||||
}
|
||||
reasoningEffort = arg;
|
||||
console.log(chalk.green(`\n 🧠 Reasoning effort set to ${arg}\n`));
|
||||
ui.setMeta({ reasoning: arg });
|
||||
ui.log(chalk.green(` \ud83e\udde0 Reasoning effort set to ${arg}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -424,56 +336,46 @@ async function main() {
|
||||
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 },
|
||||
},
|
||||
})
|
||||
await ui.input({ message: 'Short description for this suspend', color: 'cyan' })
|
||||
).trim();
|
||||
} catch {
|
||||
console.log(chalk.yellow('\n Suspend cancelled.\n'));
|
||||
ui.log(chalk.yellow(' Suspend cancelled.'));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!description) {
|
||||
console.log(chalk.yellow('\n No description given — suspend cancelled.\n'));
|
||||
ui.log(chalk.yellow(' No description given \u2014 suspend cancelled.'));
|
||||
continue;
|
||||
}
|
||||
if (messages.length <= 1) {
|
||||
console.log(chalk.yellow('\n Nothing to suspend yet — say something first.\n'));
|
||||
ui.log(chalk.yellow(' Nothing to suspend yet \u2014 say something first.'));
|
||||
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_DIR}\n`));
|
||||
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) {
|
||||
console.log(chalk.yellow('\n Nothing to recall.\n'));
|
||||
ui.log(chalk.yellow(' Nothing to recall.'));
|
||||
continue;
|
||||
}
|
||||
let choice;
|
||||
try {
|
||||
choice = await select({
|
||||
message: chalk.bold.cyan('Recall a suspended conversation'),
|
||||
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,
|
||||
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'));
|
||||
ui.log(chalk.yellow(' Recall cancelled.'));
|
||||
continue;
|
||||
}
|
||||
const snap = suspends[choice];
|
||||
@@ -485,19 +387,18 @@ async function main() {
|
||||
...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`));
|
||||
ui.log(chalk.green(` \u25b6 Recalled: ${snap.description}`));
|
||||
ui.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored.`));
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// No prompt owns the terminal while the model works, so a Ctrl+C would
|
||||
// otherwise hit Node's default handler and kill the process. Catch it
|
||||
// here instead: abort the active run and fall back through to the prompt
|
||||
// (prefilled with the message) rather than exiting.
|
||||
// Fresh reasoning buffer for this turn.
|
||||
// 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);
|
||||
@@ -523,20 +424,22 @@ async function main() {
|
||||
// not re-sent every turn (nor mistaken for the current file).
|
||||
const invalidatedReads = invalidateStaleReadResults(messages);
|
||||
if (COMPACT_DEBUG) {
|
||||
console.log(chalk.dim(
|
||||
` [compact] patch tool calls compacted: ${compactedPatches} · ` +
|
||||
ui.log(chalk.dim(
|
||||
` [compact] patch tool calls compacted: ${compactedPatches} \u00b7 ` +
|
||||
`stale read results invalidated: ${invalidatedReads}`
|
||||
));
|
||||
}
|
||||
|
||||
printAssistant(result.content);
|
||||
ui.finalizeReasoning();
|
||||
ui.log(assistantBlock(result.content));
|
||||
} catch (err) {
|
||||
ui.clearReasoning();
|
||||
if (err && err.name === 'AbortError') {
|
||||
// Ctrl+C cancelled the run — put the message back in the prompt
|
||||
// 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;
|
||||
console.log(chalk.dim('\n ⏹ Cancelled — your message is back in the prompt; edit it and press Enter to resend.\n'));
|
||||
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
|
||||
@@ -547,35 +450,46 @@ async function main() {
|
||||
if (reasoning) {
|
||||
let keep = false;
|
||||
try {
|
||||
keep = await confirm({
|
||||
message: chalk.bold.cyan('Keep the reasoning the model produced before cancelling?'),
|
||||
keep = await ui.confirm({
|
||||
message: 'Keep the reasoning the model produced before cancelling?',
|
||||
default: true,
|
||||
theme: {
|
||||
prefix: chalk.cyan('›'),
|
||||
style: { message: chalk.bold.cyan },
|
||||
},
|
||||
color: 'cyan',
|
||||
});
|
||||
} catch {
|
||||
keep = false; // prompt dismissed (e.g. Ctrl+C) — don't keep
|
||||
keep = false; // prompt dismissed (e.g. Ctrl+C) \u2014 don't keep
|
||||
}
|
||||
if (keep) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: `${reasoning}\n---USER-CANCELLED---`,
|
||||
});
|
||||
console.log(chalk.dim(' ↗ Reasoning kept in the transcript.\n'));
|
||||
ui.finalizeReasoning(reasoning);
|
||||
ui.log(chalk.dim(' \u2197 Reasoning kept in the transcript.'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(chalk.red(`\n ✖ Error: ${err.message}\n`));
|
||||
ui.log(chalk.red(` \u2716 Error: ${err.message}`));
|
||||
}
|
||||
} finally {
|
||||
process.removeListener('SIGINT', onSigint);
|
||||
ui.busy(false);
|
||||
}
|
||||
}
|
||||
|
||||
await unmount();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
if (!API_KEY) {
|
||||
console.error(chalk.red.bold('\n\u2716 Missing OPENROUTER_API_KEY \u2014 add it to .env\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
try {
|
||||
await unmount();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
console.error(chalk.red.bold('\nFATAL: ' + err.message));
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user