This commit is contained in:
seb
2026-09-23 00:50:44 +02:00
parent 73e7f7e7f8
commit 5aab24998d
8 changed files with 499 additions and 16 deletions

View File

@@ -10,11 +10,20 @@ MODEL=xiaomi/mimo-v2.6-flash
# Optional: Tavily API key — enables the websearch and browse_url tools
TAVILY_API_KEY=tvly-dev-...
# Optional: File to persist the undo log (relative to workspace root)
# Optional: File to persist the undo log (relative to workspace root).
# When run via the ~/.fido/bin/fido launcher this is overridden to a
# per-workspace path under ~/.fido/state/<workspace>/undo.json.
UNDO_FILE=.fido-undo.json
# Optional: Directory for /suspend snapshots, one JSON file each (relative to workspace root)
# Optional: Directory for /suspend snapshots, one JSON file each (relative to workspace root).
# When run via the ~/.fido/bin/fido launcher this is overridden to a
# per-workspace path under ~/.fido/state/<workspace>/suspends.
SUSPEND_DIR=.fido-suspends
# Optional: Workspace root directory (defaults to cwd when running fido)
# ROOT_DIR=.
# Optional: Set to 1 to log compaction stats each turn (patch tool calls
# compacted / stale read_file results invalidated). Useful for observing the
# between-turns transcript compaction; unset keeps normal runs quiet.
# COMPACT_DEBUG=1

View File

@@ -3,6 +3,48 @@
Fido is a console REPL assistant that inspects files, lists directories, searches file
contents, applies changes, and runs shell commands (with user approval).
## Install
Installs into `~/.fido` and adds a `fido` launcher to your `PATH` via `~/.bashrc`:
```sh
git clone <this-repo> ~/src/fidoca
cd ~/src/fidoca
./install.sh
source ~/.bashrc
```
The installer needs the sibling `llmorbiter` checkout (this project depends on it
via `file:../llmorbiter`); it is bundled into `~/.fido/llmorbiter`. Override its
location with `LLMORBITER_SRC=/path/to/llmorbiter`.
Then, from any project directory:
```sh
fido # work on the current directory
fido /path/to/project # work on a specific directory
```
Set `OPENROUTER_API_KEY` (and optionally `TAVILY_API_KEY`) in `~/.fido/.env`.
### Layout
```
~/.fido/
app/ the fido app (this repo)
llmorbiter/ bundled dependency
bin/fido the launcher (added to PATH)
.env your config / API keys
state/<workspace>/ per-workspace state
undo.json
suspends/
```
Undo and `/suspend` state is stored **per workspace** under `~/.fido/state/`, so it
never mixes between projects and never litters your repositories.
To remove everything: `./install.sh --uninstall`.
## License
[0BSD](https://opensource.org/license/0bsd)

174
install.sh Executable file
View File

@@ -0,0 +1,174 @@
#!/usr/bin/env bash
#
# install.sh — install Fido into ~/.fido and wire it into your shell.
#
# Usage:
# ./install.sh # install (or re-install) into $FIDO_HOME (default ~/.fido)
# FIDO_HOME=/opt/fido ./install.sh
# LLMORBITER_SRC=/path/to/llmorbiter ./install.sh
# ./install.sh --uninstall # remove the install and the PATH entry
# ./install.sh --help
#
# Environment:
# FIDO_HOME install directory (default: ~/.fido)
# LLMORBITER_SRC path to the llmorbiter checkout (default: sibling of this repo)
# BASHRC shell rc file to edit (default: ~/.bashrc)
# FIDO_SKIP_DEPS=1 skip `npm install` (useful for testing)
#
# What it does:
# 1. Copies the app into $FIDO_HOME/app
# 2. Bundles the llmorbiter dep $FIDO_HOME/llmorbiter (required by "file:../llmorbiter")
# 3. Copies your config to $FIDO_HOME/.env
# 4. Installs the launcher at $FIDO_HOME/bin/fido
# 5. Runs `npm install` for the app and for llmorbiter
# 6. Adds $FIDO_HOME/bin to PATH in ~/.bashrc
#
# All undo/suspend state lives under $FIDO_HOME/state/<workspace>/.
set -euo pipefail
FIDO_HOME="${FIDO_HOME:-$HOME/.fido}"
# Directory of this script = the repo root.
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# llmorbiter is a sibling checkout by default; override with LLMORBITER_SRC.
LLMORBITER_SRC="${LLMORBITER_SRC:-$SRC_DIR/../llmorbiter}"
BASHRC="${BASHRC:-$HOME/.bashrc}"
PATH_MARKER_BEGIN="# >>> fido >>>"
PATH_MARKER_END="# <<< fido <<<"
info() { printf '\033[36m▸\033[0m %s\n' "$*"; }
warn() { printf '\033[33m!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m✖\033[0m %s\n' "$*" >&2; exit 1; }
usage() {
sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
}
# Copy a source tree, skipping build/state noise and dotfile cruft.
copy_tree() {
local src="$1" dst="$2"
mkdir -p "$dst"
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete \
--exclude 'node_modules' --exclude '.git' \
--exclude '.env' --exclude '.fido-*' \
"$src/" "$dst/"
else
local tmp
tmp="$(mktemp -d)"
tar -C "$src" \
--exclude=./node_modules --exclude=./.git \
--exclude=./.env --exclude='./.fido-*' \
-cf - . | tar -C "$tmp" -xf -
rm -rf "$dst"
mkdir -p "$dst"
cp -a "$tmp/." "$dst/"
rm -rf "$tmp"
fi
}
add_to_path() {
if [ -f "$BASHRC" ] && grep -qF "$PATH_MARKER_BEGIN" "$BASHRC"; then
info "PATH entry already present in $BASHRC"
return
fi
{
printf '\n%s\n' "$PATH_MARKER_BEGIN"
printf '# Added by the Fido installer — makes the "fido" launcher available.\n'
printf 'export PATH="%s/bin:$PATH"\n' "$FIDO_HOME"
printf '%s\n' "$PATH_MARKER_END"
} >> "$BASHRC"
info "Added $FIDO_HOME/bin to PATH in $BASHRC"
}
remove_from_path() {
[ -f "$BASHRC" ] || return
if grep -qF "$PATH_MARKER_BEGIN" "$BASHRC"; then
sed -i "/^# >>> fido >>>$/,/^# <<< fido <<<$/d" "$BASHRC"
info "Removed PATH entry from $BASHRC"
fi
}
do_install() {
command -v node >/dev/null 2>&1 || die "node not found in PATH — install Node.js first."
command -v npm >/dev/null 2>&1 || die "npm not found in PATH."
[ -f "$SRC_DIR/src/fido.js" ] || die "src/fido.js not found — run this script from the Fido repo root."
[ -f "$SRC_DIR/bin/fido" ] || die "bin/fido not found — is this a complete checkout?"
[ -d "$LLMORBITER_SRC" ] || die "llmorbiter dependency not found at: $LLMORBITER_SRC
Set LLMORBITER_SRC=/path/to/llmorbiter and re-run."
info "Installing Fido into $FIDO_HOME"
mkdir -p "$FIDO_HOME/bin" "$FIDO_HOME/state"
info "Copying app -> $FIDO_HOME/app"
copy_tree "$SRC_DIR" "$FIDO_HOME/app"
info "Bundling llmorbiter -> $FIDO_HOME/llmorbiter"
copy_tree "$LLMORBITER_SRC" "$FIDO_HOME/llmorbiter"
# Config: never clobber an existing install's .env (users edit it in place).
if [ -f "$FIDO_HOME/.env" ]; then
info "Keeping existing $FIDO_HOME/.env"
elif [ -f "$SRC_DIR/.env" ]; then
cp "$SRC_DIR/.env" "$FIDO_HOME/.env"
info "Copied .env -> $FIDO_HOME/.env"
else
cp "$SRC_DIR/.env.example" "$FIDO_HOME/.env"
warn "No .env found — wrote $FIDO_HOME/.env from .env.example."
warn "Edit it and set OPENROUTER_API_KEY before running fido."
fi
info "Installing launcher -> $FIDO_HOME/bin/fido"
cp "$SRC_DIR/bin/fido" "$FIDO_HOME/bin/fido"
chmod +x "$FIDO_HOME/bin/fido"
if [ "${FIDO_SKIP_DEPS:-0}" = "1" ]; then
warn "FIDO_SKIP_DEPS=1 — skipping npm install."
else
info "Installing dependencies (npm install) — this may take a moment…"
( cd "$FIDO_HOME/llmorbiter" && npm install --no-audit --no-fund )
( cd "$FIDO_HOME/app" && npm install --no-audit --no-fund )
fi
add_to_path
cat <<EOF
✅ Fido installed.
Home: $FIDO_HOME
Launcher: $FIDO_HOME/bin/fido
Config: $FIDO_HOME/.env
State: $FIDO_HOME/state/<workspace>/ (undo.json + suspends/)
Activate in this shell: source "$BASHRC"
(or just open a new terminal.)
Then run, from any project directory:
fido # work on the current directory
fido /path/to/project # work on a specific directory
Remember to set OPENROUTER_API_KEY in $FIDO_HOME/.env.
EOF
}
do_uninstall() {
remove_from_path
if [ -d "$FIDO_HOME" ]; then
info "Removing $FIDO_HOME"
rm -rf "$FIDO_HOME"
else
warn "$FIDO_HOME does not exist — nothing to remove."
fi
info "Fido uninstalled. Open a new shell (or re-source $BASHRC) to drop the PATH entry."
}
case "${1:-}" in
""|--install|-i) do_install ;;
--uninstall|-u) do_uninstall ;;
--help|-h) usage ;;
*) die "Unknown argument: $1 (try --help)" ;;
esac

View File

@@ -8,7 +8,7 @@
"main": "src/fido.js",
"scripts": {
"start": "node src/fido.js",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "node --test"
},
"dependencies": {
"@inquirer/core": "^10.3.2",

View File

@@ -21,6 +21,7 @@ import {
createPrompt,
useState,
useKeypress,
useEffect,
usePrefix,
isEnterKey,
isTabKey,
@@ -55,9 +56,10 @@ function replaceBuffer(rl, nextValue) {
export const autocompleteInput = createPrompt((config, done) => {
const theme = makeTheme({}, config.theme);
const initial = typeof config.default === 'string' ? config.default : '';
const [status, setStatus] = useState('idle');
const [value, setValue] = useState('');
const [term, setTerm] = useState(null); // frozen filter term while the menu is open
const [value, setValue] = useState(initial);
const [term, setTerm] = useState(termFrom(initial)); // frozen filter term while the menu is open
const [active, setActive] = useState(0); // highlighted suggestion index
const prefix = usePrefix({ status, theme });
@@ -100,6 +102,13 @@ export const autocompleteInput = createPrompt((config, done) => {
setActive(0);
});
// Prefill the editable readline buffer (and mirror it into `value`) when an
// initial value is supplied — used to restore a message that a Ctrl+C
// cancelled back into the prompt so it can be edited and resent.
useEffect((rl) => {
if (initial) rl.write(initial);
}, []);
const message = theme.style.message(config.message);
const formattedValue = status === 'done' ? theme.style.answer(value) : value;

View File

@@ -10,8 +10,18 @@
* are never needed again. `compactPatchToolCalls` rewrites each `patch` tool
* call in the transcript so its long text fields keep only a head/tail excerpt,
* with a marker noting how many lines were elided.
*
* `invalidateStaleReadResults` attacks the same problem from the other side:
* a `read_file` result carries a whole file body, and once a later `patch`
* writes that file the body no longer matches what is on disk. Keeping it is
* both a token waste and a correctness hazard (the model may reason from stale
* contents). The stale result is replaced by a short "file was overwritten
* meanwhile" marker that tells the model to re-read if it needs the file.
*/
import path from 'path';
import { ROOT_DIR } from './config.js';
// Lines kept from the top and bottom of each long text field.
const HEAD_LINES = 5;
const TAIL_LINES = 5;
@@ -92,3 +102,145 @@ export function compactPatchToolCalls(messages) {
}
return compacted;
}
// ─── Stale read_file results ────────────────────────────────────────────────────
// Marker carried by a replaced read_file result. Detected verbatim so repeated
// passes over the transcript stay idempotent (an already-invalidated result is
// left as-is instead of being wrapped again).
const STALE_MARKER = 'file was overwritten meanwhile';
// Explanatory text carried by the replacement. Like the omission marker above,
// this is written by the memory-management system, not by the model — the note
// says so explicitly so the model does not mistake it for its own earlier
// output or for real file contents.
const STALE_NOTE =
`${STALE_MARKER} — stale contents elided by the memory management system ` +
'to save tokens; re-read with read_file for the current contents';
// `applyOperations` tags each change it makes as either an overwrite or a
// search/replace; both mean the file body a prior read returned is now stale.
const APPLIED_RE = /^(?:CREATE\/WRITE|PATCH in) (.+)$/;
/**
* Canonical key for a workspace-relative path, so a path the model wrote as
* "src/x.js" matches one written as "./src/x.js" or with different separators.
*/
function canonicalPath(p) {
if (typeof p !== 'string' || !p) return null;
try {
return path.resolve(ROOT_DIR, p);
} catch {
return p;
}
}
/** The replacement payload handed to the model in place of a stale read. */
function staleReadResult(filename) {
return JSON.stringify({ filename, note: STALE_NOTE });
}
/**
* Extract the files a `patch` result actually wrote. Both successful results
* (`applied`) and partially-failed ones (`appliedSoFar`) list the mutations, so
* files that were never touched are correctly left alone. Returns canonical
* path keys; an unparseable result yields an empty list.
*/
function filesWrittenByPatch(resultContent) {
let res = resultContent;
if (typeof res === 'string') {
try {
res = JSON.parse(res);
} catch {
return [];
}
}
if (!res || typeof res !== 'object') return [];
const applied = Array.isArray(res.applied)
? res.applied
: Array.isArray(res.appliedSoFar)
? res.appliedSoFar
: [];
const files = new Set();
for (const entry of applied) {
if (typeof entry !== 'string') continue;
const hit = APPLIED_RE.exec(entry);
if (hit) {
const key = canonicalPath(hit[1]);
if (key) files.add(key);
}
}
return [...files];
}
/**
* Replace `read_file` tool results in `messages` with a short "file was
* overwritten meanwhile" marker whenever a later `patch` wrote the same file.
*
* A read result is stale only if the file changed *after* it was read, so we
* compare transcript positions: a read is invalidated when a successful patch
* write sits at a later index. Reads taken after their last patch (which still
* reflect disk) are left untouched, as are reads for files no patch ever wrote.
*
* The rewrite only fires when it strictly shrinks the payload, so a tiny read
* result is never traded for a longer marker. Repeated passes are idempotent.
* Returns the number of results invalidated.
*/
export function invalidateStaleReadResults(messages) {
if (!Array.isArray(messages)) return 0;
// Index every tool call so a tool result can be mapped back to (name, args).
const callInfo = new Map();
for (const m of messages) {
if (!m || m.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue;
for (const tc of m.tool_calls) {
if (!tc || !tc.id) continue;
let args = {};
try {
args = JSON.parse(tc.function?.arguments || '{}');
} catch {
// Malformed arguments: the call stays indexed but argless.
}
callInfo.set(tc.id, { name: tc.function?.name, args });
}
}
// Every place a patch wrote a file, keyed by transcript position.
const writes = [];
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
if (!m || m.role !== 'tool') continue;
if (callInfo.get(m.tool_call_id)?.name !== 'patch') continue;
const files = filesWrittenByPatch(m.content);
if (files.length) writes.push({ index: i, files });
}
if (writes.length === 0) return 0;
let invalidated = 0;
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
if (!m || m.role !== 'tool') continue;
const info = callInfo.get(m.tool_call_id);
if (info?.name !== 'read_file') continue;
const key = canonicalPath(info.args?.filename);
if (!key) continue;
const content = typeof m.content === 'string' ? m.content : '';
// Already replaced on an earlier pass — leave it alone.
if (content.includes(STALE_MARKER)) continue;
// Stale only if some patch wrote this file after the read happened.
const stale = writes.some((w) => w.index > i && w.files.includes(key));
if (!stale) continue;
const replacement = staleReadResult(info.args.filename);
// Never grow the payload: only swap when the marker is actually smaller.
if (replacement.length >= content.length) continue;
m.content = replacement;
invalidated++;
}
return invalidated;
}

View File

@@ -22,9 +22,10 @@ import dotenv from 'dotenv';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Load .env from the fido project root (one level up from src/)
dotenv.config({ path: path.resolve(__dirname, '..', '.env') });
// `quiet: true` suppresses dotenv's "injected env (N) from .env" banner.
dotenv.config({ path: path.resolve(__dirname, '..', '.env'), quiet: true });
// Also try cwd-relative .env
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
dotenv.config({ path: path.resolve(process.cwd(), '.env'), quiet: true });
export const API_KEY = process.env.OPENROUTER_API_KEY;
export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash';
@@ -32,6 +33,14 @@ export const UNDO_FILE = process.env.UNDO_FILE || '.fido-undo.json';
export const SUSPEND_DIR = process.env.SUSPEND_DIR || '.fido-suspends';
export const TAVILY_API_KEY = process.env.TAVILY_API_KEY;
// When set to a truthy value (1/true/yes/on), the REPL logs how many transcript
// entries each between-turns compaction pass rewrote. Useful for verifying that
// stale read_file results are being elided, and that repeated passes are
// idempotent (they report 0). Off by default so normal runs stay quiet.
export const COMPACT_DEBUG = ['1', 'true', 'yes', 'on'].includes(
(process.env.COMPACT_DEBUG || '').toLowerCase()
);
// Root dir: first positional arg wins, then env ROOT_DIR, then cwd
const args = process.argv.slice(2);
export const ROOT_DIR = path.resolve(

View File

@@ -22,13 +22,13 @@
import path from 'path';
import chalk from 'chalk';
import { editor, input, select } from '@inquirer/prompts';
import { editor, input, select, confirm } from '@inquirer/prompts';
import { marked } from 'marked';
import { markedTerminal } from 'marked-terminal';
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
import { API_KEY, MODEL, UNDO_FILE, SUSPEND_DIR, ROOT_DIR } from './config.js';
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';
@@ -36,7 +36,7 @@ import { tools, toolCallNotifications, toolResultNotifications } from './tools/i
import { printToolCall, emitGenericToolResult } from './tools/notify.js';
import { autocompleteInput } from './autocomplete-input.js';
import { COMMANDS, findCommand } from './slash-commands.js';
import { compactPatchToolCalls } from './compact.js';
import { compactPatchToolCalls, invalidateStaleReadResults } from './compact.js';
// ─── Markdown → console renderer ───────────────────────────────────────────────
@@ -221,11 +221,23 @@ function printToolError(name, error) {
console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`));
}
function printUsage(u) {
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)}`);
console.log(chalk.dim(` 📊 ${parts.join(' ')}`));
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 formatWhen(iso) {
@@ -271,10 +283,20 @@ async function main() {
loadSuspends();
printBanner();
// 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 });
// 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; },
onToolCall: (c) => {
const notify = toolCallNotifications[c.name];
if (notify) notify(c.args);
@@ -287,20 +309,26 @@ async function main() {
printToolResult(r.name, r.result);
},
onToolError: (e) => printToolError(e.name, e.error),
onUsage: (u) => printUsage(u),
onUsage: (u) => printUsage(u, usageTotals),
});
// 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. Press Ctrl+C to exit.\n'));
console.log(chalk.dim(' Type your request below. Type / for commands, /paste for multiline.'));
console.log(chalk.dim(' Ctrl+C exits at the prompt; while the model is running it cancels the turn.\n'));
// 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 autocompleteInput({
message: chalk.bold.cyan('You'),
default: draft,
theme: {
prefix: chalk.cyan('›'),
style: {
@@ -314,6 +342,7 @@ async function main() {
console.log(chalk.dim('\n Goodbye! 🐕\n'));
break;
}
draft = '';
if (!userInput.trim()) continue;
@@ -436,6 +465,15 @@ async function main() {
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.
turnReasoning = '';
const onSigint = () => orbiter.abort('cancelled by user');
process.on('SIGINT', onSigint);
try {
const result = await orbiter.run({
prompt: userInput,
@@ -450,11 +488,61 @@ async function main() {
// 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.
compactPatchToolCalls(messages);
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) {
console.log(chalk.dim(
` [compact] patch tool calls compacted: ${compactedPatches} · ` +
`stale read results invalidated: ${invalidatedReads}`
));
}
printAssistant(result.content);
} catch (err) {
console.log(chalk.red(`\n ✖ Error: ${err.message}\n`));
if (err && err.name === 'AbortError') {
// Ctrl+C cancelled the run — 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'));
// 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 confirm({
message: chalk.bold.cyan('Keep the reasoning the model produced before cancelling?'),
default: true,
theme: {
prefix: chalk.cyan('›'),
style: { message: chalk.bold.cyan },
},
});
} catch {
keep = false; // prompt dismissed (e.g. Ctrl+C) — don't keep
}
if (keep) {
messages.push({
role: 'assistant',
content: `${reasoning}\n---USER-CANCELLED---`,
});
console.log(chalk.dim(' ↗ Reasoning kept in the transcript.\n'));
}
}
} else {
console.log(chalk.red(`\n ✖ Error: ${err.message}\n`));
}
} finally {
process.removeListener('SIGINT', onSigint);
}
}
}