Compare commits

...

21 Commits

Author SHA1 Message Date
seb
0d42b51f63 u 2026-09-27 23:45:07 +02:00
seb
0149846a15 u 2026-09-26 19:19:48 +02:00
seb
5b1d896157 u 2026-09-25 11:41:25 +02:00
seb
968ab8789e u 2026-09-25 07:24:52 +02:00
seb
9f0217d38d u 2026-09-25 07:23:01 +02:00
seb
af0955b7c1 u 2026-09-25 07:00:42 +02:00
seb
2efd9bda39 u 2026-09-25 06:26:59 +02:00
seb
b7ef321e05 u 2026-09-25 05:37:09 +02:00
seb
917d3d455c find files 2026-09-23 07:23:03 +02:00
seb
e416ca09c1 u 2026-09-23 06:03:21 +02:00
seb
9f61a05e8b Add file browser, tests, and bin entrypoint 2026-09-23 06:01:42 +02:00
seb
80bd648412 u 2026-09-23 02:03:36 +02:00
seb
5c6e90c2ce u 2026-09-23 01:26:51 +02:00
seb
b80e4dd7de u 2026-09-23 01:26:47 +02:00
seb
5aab24998d upd 2026-09-23 00:50:44 +02:00
seb
73e7f7e7f8 u 2026-09-22 21:40:13 +02:00
seb
ab2f953e06 u 2026-09-22 20:59:58 +02:00
seb
96d2b80b6c u 2026-09-22 20:11:55 +02:00
seb
b15ddfadc3 u 2026-09-22 19:32:51 +02:00
seb
eb885621c7 u 2026-09-22 19:21:14 +02:00
seb
d74ca40076 u 2026-09-22 19:11:54 +02:00
62 changed files with 9049 additions and 648 deletions

View File

@@ -7,8 +7,23 @@ OPENROUTER_API_KEY=sk-or-...
# Optional: Model to use (default: xiaomi/mimo-v2.6-flash) # Optional: Model to use (default: xiaomi/mimo-v2.6-flash)
MODEL=xiaomi/mimo-v2.6-flash MODEL=xiaomi/mimo-v2.6-flash
# Optional: File to persist the undo log (relative to workspace root) # 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).
# 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 UNDO_FILE=.fido-undo.json
# 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) # Optional: Workspace root directory (defaults to cwd when running fido)
# ROOT_DIR=. # 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

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules/ node_modules/
.env .env
.fido-*

29
.ui-order.mjs Normal file
View File

@@ -0,0 +1,29 @@
import { createElement as h, useState } from 'react';
import { render, Box, useInput } from 'ink';
import TextInput from 'ink-text-input';
const order = [];
function Gate() {
useInput((input, key) => {
if (key.ctrl && input === 'o') order.push('gate');
});
return null;
}
function App() {
const [v, setV] = useState('');
return h(
Box,
null,
h(Gate, { key: 'g' }),
h(TextInput, {
key: 't',
value: v,
onChange: (nv) => { order.push('onChange:' + JSON.stringify(nv)); setV(nv); },
}),
);
}
render(h(App), { exitOnCtrlC: false });
setTimeout(() => {
process.stdout.write('\nORDER=' + JSON.stringify(order) + '\n');
process.exit(0);
}, 1200);

9
.ui-pty.mjs Normal file
View File

@@ -0,0 +1,9 @@
import { mount, unmount, ui } from './src/ui/index.js';
mount();
ui.log('READY');
const v = await ui.ask({ message: 'You', prefill: '' });
ui.log('GOT:' + JSON.stringify(v));
await new Promise((r) => setTimeout(r, 120));
await unmount();
process.exit(0);

18
.ui-pty2.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { mount, unmount, ui } from './src/ui/index.js';
mount();
const c = await ui.confirm({ message: 'Keep?', default: true });
ui.log('CONFIRM:' + c);
const s = await ui.select({
message: 'Pick',
choices: [
{ name: 'a', value: 'A' },
{ name: 'b', value: 'B' },
{ name: 'c', value: 'C' },
],
});
ui.log('SELECT:' + s);
const t = await ui.input({ message: 'Text' });
ui.log('INPUT:' + t);
await unmount();
process.exit(0);

120
.ui-smoke.mjs Normal file
View File

@@ -0,0 +1,120 @@
import { createElement as h } from 'react';
import { renderToString } from 'ink';
import assert from 'node:assert/strict';
import chalk from 'chalk';
import { createStore } from './src/ui/store.js';
import { App } from './src/ui/app.js';
import { TURN_STATES } from './src/turn-state.js';
// --- fixture: exercises header, history, command bar, token footer ---
function buildMainStore() {
const store = createStore();
const RESET = '\x1b[0m', BG = '\x1b[100m', BGCLOSE = '\x1b[49m';
const paint = (text, width = 40) => text.split('\n').map((l) => {
const vis = l.replace(/\x1b\[[0-9;]*m/g, '').length;
const pad = l + ' '.repeat(Math.max(0, width - vis));
return BG + pad.replace(/\x1b\[0m/g, RESET + BG) + BGCLOSE;
}).join('\n');
store.setMeta({ model: 'smoke-model', workspace: '/tmp/ws', reasoning: 'low' });
store.log(chalk.cyan.bold(' [banner line]'));
store.log('\n ' + chalk.green.bold('Assistant') + '\n ' + '-'.repeat(20) + '\n' + paint(' ' + chalk.bold('hello') + ' world'));
store.addUsage({ input: 1234, output: 567, cached: 100, cost: 0.01234 });
store.prompt({ type: 'main', message: 'You', prefill: '/rea' });
return store;
}
// --- 1. main pane layout ---
const store = buildMainStore();
const out = renderToString(h(App, { store }), { columns: 80 });
console.log('----- JSON -----');
console.log(JSON.stringify(out));
console.log('----- VISIBLE -----');
console.log(out);
// Strip ANSI for structural assertions.
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
const vis = strip(out);
assert.ok(vis.includes('FIDO'), 'header chrome renders');
assert.ok(vis.includes('smoke-model'), 'header shows model');
assert.ok(vis.includes('[banner line]'), 'history renders');
assert.ok(vis.includes('hello world'), 'history renders painted block');
assert.ok(vis.includes('/reasoning'), 'command bar shows slash autocomplete');
assert.ok(vis.includes('in=1,234'), 'footer shows token totals');
assert.ok(vis.includes('cost=$0.01234'), 'footer shows cost');
assert.ok(vis.includes('PgUp/PgDn'), 'footer shows scroll hints');
store.resolve(null); // settle the prompt promise so the process can exit
// --- 2. scrolled history hides the live tail, shows the scroll badge ---
const scrolled = buildMainStore();
for (let i = 1; i <= 30; i++) scrolled.log(` filler line ${i}`);
scrolled.scrollBy(10);
const outScrolled = strip(renderToString(h(App, { store: scrolled }), { columns: 80 }));
assert.ok(outScrolled.includes('▲ 10 lines'), 'header shows scroll badge when scrolled');
assert.ok(!outScrolled.includes('filler line 30'), 'scrolled view hides the live tail');
assert.ok(outScrolled.includes('filler line 20'), 'scrolled view shows older lines');
scrolled.resolve(null);
// Wheel decoding is intentionally exercised through the same store movement
// semantics as the terminal listener: up increases the offset, down decreases.
const wheel = buildMainStore();
for (let i = 1; i <= 30; i++) wheel.log(` wheel line ${i}`);
wheel.scrollBy(3);
assert.equal(wheel.getState().scroll, 3, 'wheel-up delta moves toward older history');
wheel.scrollBy(-3);
assert.equal(wheel.getState().scroll, 0, 'wheel-down delta returns to the live tail');
wheel.resolve(null);
// --- 3. tiny-terminal fallback (rows < 12): plain column, no clipping ---
// NOTE: renderToString only accepts `columns`, so drive the fallback through
// App's `sizeOverride` test seam instead of render options.
const tiny = buildMainStore();
const outTiny = strip(renderToString(h(App, { store: tiny, sizeOverride: { columns: 80, rows: 8 } }), { columns: 80 }));
assert.ok(outTiny.includes('[banner line]'), 'tiny fallback still renders history');
assert.ok(outTiny.includes('in=1,234'), 'tiny fallback still shows totals');
assert.ok(!outTiny.includes('PgUp/PgDn history'), 'tiny fallback drops fullscreen key hints');
tiny.resolve(null);
// --- 4. the status line renders the label for every non-idle state ---
// The status line is driven by the connection-state enum, so the smoke test
// walks the store through each busy state and checks the matching label. The
// previous fixture hardcoded the free-text 'thinking…'.
for (const [state, label] of [
[TURN_STATES.CONNECTING, 'connecting…'],
[TURN_STATES.THINKING, 'thinking…'],
[TURN_STATES.WORKING, 'working…'],
[TURN_STATES.STREAMING, 'streaming…'],
]) {
const s = createStore();
s.setBusy(true);
s.setTurnState(state);
const rendered = strip(renderToString(h(App, { store: s, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
assert.ok(rendered.includes(label), `${state} renders as ${label}`);
assert.ok(!rendered.includes('› (idle)'), `${state} is not shown as idle`);
}
// The idle placeholder is the only thing shown when no turn is running.
const idleStore = createStore();
idleStore.setBusy(false);
const outIdle = strip(renderToString(h(App, { store: idleStore, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
assert.ok(outIdle.includes('› (idle)'), 'idle placeholder renders when not busy');
// --- 5. live reasoning streams into the scrollable history pane ---
const reasoningStore = createStore();
reasoningStore.setBusy(true);
reasoningStore.setTurnState(TURN_STATES.THINKING);
reasoningStore.setReasoningDraft('Analyzing user question\nChecking relevant files');
const outLiveReasoning = strip(renderToString(h(App, { store: reasoningStore, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
assert.ok(outLiveReasoning.includes('🧠 Reasoning (thinking…)'), 'history pane streams live reasoning with thinking header');
assert.ok(outLiveReasoning.includes('Analyzing user question'), 'history pane shows line 1 of reasoning');
assert.ok(outLiveReasoning.includes('Checking relevant files'), 'history pane shows line 2 of reasoning');
// Finalizing promotes to permanent entry without "(thinking…)"
reasoningStore.finalizeReasoning();
const outFinalReasoning = strip(renderToString(h(App, { store: reasoningStore, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
assert.ok(outFinalReasoning.includes('🧠 Reasoning'), 'finalized reasoning has header');
assert.ok(!outFinalReasoning.includes('🧠 Reasoning (thinking…)'), 'finalized reasoning drops thinking indicator from header');
assert.ok(outFinalReasoning.includes('Analyzing user question'), 'finalized reasoning preserves content');
console.log('\nSMOKE-OK: header/history/command-bar/footer + scroll + tiny fallback + connection states + streaming reasoning');

378
PLAN.md Normal file
View File

@@ -0,0 +1,378 @@
# Fido Todo/Plan and Subagent Plan
**Status:** Proposed
**Scope:** Add a small coordination layer without turning Fido into a general workflow engine.
## Goals
- Preserve a concise, durable task plan across turns and restarts.
- Make progress and blockers visible to the user in a persistent, full todo panel.
- Make the complete current todo state available to the model in the conversation, as a tool response rather than as mutable initial prompt text.
- Keep the system-prompt prefix stable so plan updates do not unnecessarily bust prompt caching.
- Allow bounded delegation for genuinely independent investigation or review work, with an explicit subagent identity and a clearly isolated message fork.
- Keep the advertised tool catalog stable for prompt caching; enforce read-only and no-recursion policy at invocation time rather than changing schemas or tool lists when entering a subagent.
- Show live subagent progress in a separate, accent-colored console activity bar.
- Keep the tool surface small: no separate task database UI, queue service, or shell wrappers.
- Keep the parent agent responsible for integration, safety, and final verification.
## Non-goals
- A general-purpose project-management system.
- Autonomous recursive agents.
- Background workers, job queues, or a persistent multi-agent scheduler in the first version.
- Letting subagents silently modify the user's working tree.
- Replacing `bash_exec`; shell remains the general escape hatch for tests, builds, Git, and unusual operations.
## Design principles
1. **State is not shell state.** A plan is structured session/workspace state and should not be implemented by parsing files or maintaining a second conversation transcript.
2. **One tool per capability.** Use one `update_plan` tool and one optional `delegate` tool. Do not add `get_plan`, `complete_task`, `list_agents`, or `join_subagent` initially.
3. **Short-lived, explicit delegation.** A delegated task has a goal, scope, budget, and output contract. Results return to the parent before the parent continues.
4. **Read-only by runtime policy.** Subagents see the same stable advertised tool catalog as the parent, but invocation guards reject mutating tools and recursive delegation. Write-capable delegation is a later, explicitly approved feature.
5. **Evidence over confidence.** A plan item is complete only after the relevant verification is recorded; a subagent finding is a hypothesis until the parent checks it.
6. **No repository litter.** Runtime plan and delegation state should use the existing `.fido-*` ignore convention or the per-workspace state location, not a tracked `TODO.md`.
7. **Cache-stable system context.** The system message contains stable instructions and tool contracts only. The current todo state is delivered through the `update_plan` tool result (and a restored tool-result event after a restart), so changing a plan does not rewrite the cached system-prompt prefix.
8. **Explicit delegation boundary.** A subagent receives an independent snapshot of the parent message array plus a bootstrap result that states its role and responsibilities. The parent receives the subagent's final output, not its private transcript.
9. **Stable tool advertisements.** The tool set, schemas, descriptions, and ordering must not change when a main agent spawns a subagent. `delegate` remains advertised inside the subagent; a runtime identity/depth guard converts a nested call into an explicit failure tool result.
10. **Observable delegation.** While a subagent is working, the console displays a second activity bar, distinct from the main status bar and visually marked with an accent color.
## 1. Todo/plan capability
### Tool
Add one model-facing tool:
```text
update_plan({
reason: string,
items: [
{
id: string,
title: string,
status: "pending" | "in_progress" | "blocked" | "done",
details?: string,
dependencies?: string[],
next_action?: string,
evidence?: string[]
}
]
})
```
The tool replaces the current plan atomically. The model must include existing IDs when changing status; unknown IDs, invalid dependencies, and more than one `in_progress` item should produce a clear `ToolError`.
A single replacement operation is intentional: it is easier for the model to reason about and keeps the tool schema small. On every successful call, the tool result is the **complete canonical plan state** for the next model request. The result must include the version, update timestamp, reason, and every normalized item, including all optional fields. Do not replace it with a count, a summary, a partial list, or an ellipsis. The model should treat the latest `update_plan` result—not memory of an earlier call—as the source of truth.
### Model-facing tool description
The description for `update_plan` must explicitly say:
> Replace the workspace todo list and return its complete normalized state. The returned tool result is the model-visible source of truth for the current plan; it contains the full `version`, `updatedAt`, `reason`, and every item with its ID, title, status, details, dependencies, next action, and evidence. The plan is delivered in the tool result, not interpolated into the initial system prompt, so updating it does not change the cached system-prompt prefix. Use it at milestones, preserve existing IDs, and keep exactly zero or one `in_progress` item.
### Message placement and cache rule
- Build the system message once per session from the stable workspace/tool instructions. It must never contain the current plan JSON, a plan timestamp, or a plan-dependent status.
- After a successful replacement, the normal provider `tool` message is already part of the model's message history. The next request therefore sees the full state without a second prompt or a `get_plan` call.
- If a plan is restored from disk after a restart, load it into the state module and restore it as a provider-compatible `tool` result (with an internal matching call/event if the provider requires one), not by rewriting the system message. This restore result must also contain the complete plan state.
- Compaction must preserve the latest plan-state tool result. If older results are elided, carry the latest complete result forward as the newest tool result rather than replacing it with a summary.
- The plan UI consumes the same normalized object returned by the tool, so the model-visible state and the floating panel cannot silently diverge.
### State model
Persist a versioned JSON document, initially at `.fido-plan.json` (covered by the existing `.fido-*` ignore rule), with this shape:
```json
{
"version": 1,
"updatedAt": "2025-01-01T00:00:00.000Z",
"reason": "Current task plan",
"items": []
}
```
Recommended rules:
- Keep IDs stable and human-readable (`inspect`, `implement`, `verify`).
- Allow at most 20 items and keep titles/details short.
- Permit zero or one `in_progress` item; represent waiting on a person or external service as `blocked`.
- Require `next_action` for `pending`, `in_progress`, and `blocked` items.
- Record concise evidence such as `npm test` or `test/ui.test.js:42`; do not store full command output in the plan.
- Never treat a plan update as an undoable source-code edit.
### Runtime integration
1. Add a small `src/plan.js` state module with `loadPlan`, `getPlan`, and `setPlan`/validation helpers.
2. Add `PLAN_FILE` to `src/config.js`, defaulting to `.fido-plan.json`.
3. Load the plan during startup for the UI and local commands, but do **not** interpolate it into the system prompt. When a persisted plan exists, arrange for the initial model-visible message history to contain a full plan-state tool result (or the equivalent provider-compatible restore event).
4. Register `update_plan` in `src/tools/index.js`; its description and full-result contract must be available to the model.
5. Add `plan` to the UI store and call `ui.setPlan(...)` after startup load, every successful `update_plan`, and `/plan clear`. The panel must update atomically from the canonical normalized result.
6. Add a `PlanPanel` floating box anchored to the right side of the full-screen Ink layout. It is a persistent overlay/side rail, not a transcript entry, and it must show the **full todo list**, not only the active item or a count. Render every item and every bounded item field (ID, title, status, details, dependencies, next action, and evidence), with an empty-state message when there is no plan. At narrow widths, use a responsive overlay/fallback, but do not discard the state or silently reduce the model-facing result.
7. Keep a concise tool-call notification for the transcript, but do not dump the full plan there; the full state belongs in the right-side panel and in the tool result.
8. Add `/plan` to display the current plan and `/plan clear` to start a new plan. Keep these as local commands; they do not require an LLM turn.
9. Decide explicitly whether `/recall` restores the plan. Recommended default: recall restores conversation messages but keeps the workspace plan unchanged, because the plan describes the current workspace rather than a historical transcript.
### Usage policy
The stable system prompt should tell the model to:
- create a plan when a request has multiple meaningful steps, spans several files, or is likely to be interrupted;
- skip a formal plan for a small, obvious change;
- call `update_plan` when the current state needs to be established, after a milestone, or when a blocker changes the next action;
- read the complete state from the latest `update_plan` tool result instead of assuming that an earlier result or the initial prompt is still current;
- keep exactly zero or one current item in progress;
- preserve stable IDs and record concise blockers, next actions, and evidence;
- update the plan at milestones, not after every tool call; and
- never place the plan in the system prompt or use a shell command to maintain it.
A full plan snapshot in a tool result is model-visible prompt context, even though it is not the initial system prompt. It must survive normal turn handoff and compaction.
## 2. Subagent capability
### Tool
Add one optional model-facing tool:
```text
delegate({
task: string,
context?: string,
scope?: {
paths?: string[],
symbols?: string[]
},
mode?: "read_only",
output_contract?: "findings",
timeout_ms?: number
})
```
The first version should run one bounded task synchronously and return a concise result. There should be no background job ID and no separate status/join tool. A later background implementation may add those only after demonstrating a real need.
### Required model-facing tool description
The `delegate` description must not merely say “spawn a helper.” It must tell the parent model all of the following:
> Run one bounded, synchronous, read-only subagent and return only its final normalized result. At invocation, Fido creates a private fork (an independent snapshot) of the parent's current `messages` array; the subagent works from that fork, not from a live reference to the parent array. The subagent's forked messages include a bootstrap tool result that explicitly gives it its current identity (`role: "subagent"`, a unique `agent_id`, and `parent_role: "main"`) and explains that its final output is for the parent. When the subagent finishes, its final output becomes the `delegate` tool result in the **parent's** messages. The parent does not receive the subagent's private tool calls or full transcript. The tool catalog remains unchanged in the subagent: if a subagent calls `delegate` again, that invocation must immediately return a normal `status: "failed"` tool result explaining that nested delegation is forbidden. No further subagent is created. The subagent is read-only, and the parent remains responsible for verification and integration.
The same identity/fork/return-path facts must be enforced in the tool result payload and covered by tests, so the model is not relying only on prose in the tool description.
### Subagent prompt and tools
Create a separate LLM context by forking the parent's current `messages` array at the instant `delegate` is called. The fork must be isolated: later parent messages and mutations must not appear in or mutate the subagent context, and a subagent must not mutate the parent's array.
The forked array is augmented with a bootstrap **tool result** before it is sent to the subagent. That result, not an unstated system-prompt convention, tells the subagent what identity it now has and how to return work. At minimum it has this shape:
```json
{
"type": "subagent_bootstrap",
"identity": {
"role": "subagent",
"agent_id": "subagent-123",
"parent_role": "main"
},
"context_mode": "forked_parent_messages",
"forked_message_count": 42,
"task": "the delegated task",
"responsibility": "Inspect only; return the final result to the parent. You are not the main agent, you may not delegate, and the parent will verify your claims."
}
```
The bootstrap result is a `role: "tool"` message in the messages array the subagent takes with it. If the provider requires a matching assistant tool call for every tool result, create an internal, non-model-facing matching call/event; do not add another model-facing coordination tool.
In addition to that fork/bootstrap result, the separate subagent system/developer instructions must include:
- the parent task and why delegation was chosen;
- the relevant workspace-relative paths and symbols;
- a strict read-only instruction;
- a request to inspect rather than implement; and
- the required final output format below.
The `scope` fields constrain investigation, but they do not replace or truncate the message-array fork. Pass the fork directly to the isolated runner rather than re-encoding the entire parent transcript as one prompt string.
#### Stable advertised catalog and runtime guards
Do **not** construct a reduced subagent tool array. Parent and subagent runs must receive the same immutable tool objects, names, JSON schemas, descriptions, and ordering. In particular, do not remove `delegate` from the subagent's advertised tools: doing so changes the cached tool prefix and leaves the attempted nested call without a normal tool-result turn.
Instead, pass an invocation context containing the current `identity` and `delegation_depth`. Enforce policy in the tool invocation guard:
- Allowed read-only operations may include `list_files`, `read_file`, `ripgrep`, and explicitly approved `browse_url`/`websearch` calls.
- `patch`, `mkdir`, `rm`, `rmdir`, unrestricted `bash_exec`, `ask_user`, and other parent-only or mutating tools return a normal `permission_denied` tool result for a subagent. They must not execute their side effects.
- A subagent call to `delegate` returns an immediate `status: "failed"` tool result with a stable error code. It does not start a provider request or create another fork.
The nested-call result must be returned through the normal provider tool-result path, not thrown as a runner exception, so the subagent receives a valid response for the tool call it made. Its minimum shape is:
```json
{
"type": "delegate_result",
"identity": {
"role": "subagent",
"agent_id": "subagent-123",
"parent_role": "main"
},
"status": "failed",
"summary": "Nested delegation is not allowed.",
"findings": [],
"recommended_next_steps": [
"Continue the assigned task without calling delegate."
],
"tests_to_run": [],
"uncertainties": [],
"error": {
"code": "nested_delegation_forbidden",
"message": "A subagent cannot call delegate; no nested subagent was created."
}
}
```
The advertised `delegate` description must tell the model that this failure result is the expected runtime behavior and that the tool remains visible solely to preserve a stable cacheable tool catalog. The same guard remains in place even if the bootstrap result is ignored or the subagent attempts to claim the main-agent identity.
### Console subagent activity bar
The full-screen console must provide a second activity bar dedicated to subagents; do not overload the main command/status bar and do not inject subagent chatter into the normal transcript. Place the bar directly below the header (or above the command bar) as a distinct fixed row, separate from the right-side todo panel.
- Use a dedicated accent color, specified by the UI design (for example violet/blue such as `#a78bfa`) with contrasting text, rather than the cyan main-chrome or default transcript colors.
- Show `SUBAGENT`, the short `agent_id`, and the current phase/action such as `starting`, `reasoning`, `read_file`, `reviewing`, `completed`, `partial`, `failed`, or `cancelled`.
- Show elapsed time and a concise, sanitized activity detail. Never print the private message array, raw reasoning, or full subagent transcript.
- Update the bar for subagent provider status, tool-call, tool-result, and completion callbacks. A nested `delegate` attempt should briefly show `nested delegation rejected` and then remain failed/rejected rather than creating a child.
- The bar is visible while the subagent is active and remains available for the final outcome until the parent turn advances. At narrow widths it may become full-width and wrap/truncate details, but it must still identify the active subagent and status.
- UI state should be driven by the same store/bridge used by the main app, so nested activity does not steal the input prompt or corrupt the main status line.
The parent retains its own full messages array. The subagent gets a private fork of that array as specified above; “minimum relevant context” applies to `task`, `context`, `scope`, and external data, not to silently replacing the required messages-array fork with an empty context. Neither the parent UI nor the returned tool result may expose the subagent's private transcript.
### Result contract
Normalize the subagent's final output to one small object before placing it in the parent conversation as the `delegate` tool response:
```json
{
"type": "delegate_result",
"identity": {
"role": "subagent",
"agent_id": "subagent-123",
"parent_role": "main"
},
"status": "completed" | "partial" | "failed",
"summary": "one-paragraph result",
"findings": [
{
"file": "src/example.js",
"line": 42,
"claim": "what was observed",
"confidence": "high" | "medium" | "low"
}
],
"recommended_next_steps": ["..."],
"tests_to_run": ["..."],
"uncertainties": ["..."]
}
```
There are two normal identity-bearing result paths, plus the nested-call failure path:
1. The `subagent_bootstrap` tool result tells the **subagent** that its current identity is the named, read-only fork shown above and that it must finish with a result for `main`.
2. The `delegate_result` tool response tells the **parent** which subagent identity produced the final output. It contains the normalized final output and provenance, not the subagent's intermediate messages.
3. If that subagent calls `delegate`, the nested invocation returns the `nested_delegation_forbidden` `status: "failed"` tool result to the subagent. It is a valid tool response, not a second successful delegation and not a private transcript sent to the parent.
The parent must verify claims against current files and own integration and the final user-facing response. A subagent must never report a change as complete when it was only asked to investigate.
### Safety and lifecycle
- Default to read-only operation.
- Deep-fork (or otherwise structurally isolate) the parent message array at delegation time; never share a mutable live message reference between parent and subagent.
- Generate a unique subagent identity in the runner, include it in both the bootstrap tool result and the final parent-facing result, and do not accept identity text supplied by the model as authoritative.
- Limit concurrency to one for the first implementation; add parallelism only after rate limits and UI behavior are tested.
- Enforce a timeout and a result-size limit.
- Propagate Ctrl+C/cancellation to the subagent.
- Prevent recursive delegation without changing the advertised tool catalog: keep `delegate` available, enforce a maximum depth of one in the invocation guard, and return a normal failed tool result when a subagent attempts it.
- Return only the normalized final output to the parent. Never append the subagent's private message/tool transcript to the parent array or render it as ordinary parent history.
- Treat subagent output as untrusted input: it may be wrong, stale, or influenced by repository content.
- Do not expose provider keys, private subagent messages, or full parent transcripts in UI messages; the activity bar may expose only sanitized lifecycle metadata.
- If write-capable agents are added later, require explicit user approval, use isolated Git worktrees, and require the parent to review and test the diff before integration.
## 3. Minimal tool surface
After this work, the coordination-related surface should be only:
```text
update_plan # durable task state
delegate # optional, bounded read-only delegation
```
Existing filesystem and execution tools remain unchanged. `bash_exec` already covers tests, builds, Git, and other commands; do not add `run_tests`, `git_status`, or similar wrappers.
## 4. Implementation order
### Phase 1: Plan state and todo UI
- [ ] Add plan configuration, validation, and atomic state helpers.
- [ ] Add `update_plan` and its unit tests; assert that every successful tool response contains the complete normalized plan with no truncation or summary-only payload.
- [ ] Keep the system prompt stable and add a post-system plan-state tool-result restore path for persisted plans; test that updates do not rewrite `messages[0]`.
- [ ] Add plan state to the UI store and implement the persistent right-side floating full-plan panel with responsive narrow-terminal behavior.
- [ ] Add `/plan` and `/plan clear` local UI commands.
- [ ] Verify restart persistence, compaction preservation of the latest full plan result, and malformed-input errors.
### Phase 2: Read-only delegation MVP
- [ ] Extract a subagent runner that deep-forks the parent's current `messages` array into an isolated model context.
- [ ] Add the identity-bearing `subagent_bootstrap` tool result to that fork and make it an explicit part of the `delegate` tool contract.
- [ ] Add the stable advertised tool catalog and identity/depth-aware invocation guards; do not remove `delegate` from the subagent's tool list.
- [ ] Add `delegate` with timeout, cancellation, and final result normalization; place only the final `delegate_result` in the parent array.
- [ ] Add the second accent-colored subagent activity bar and wire it to sanitized nested-runner lifecycle events.
- [ ] Add fake-runner tests; do not require a live provider for unit tests.
- [ ] Assert that the subagent receives a fork plus its identity, the parent receives the named subagent's final output as a tool response, neither side shares a live mutable messages reference, and the parent receives no full subagent transcript.
- [ ] Assert that a subagent's `delegate` call returns a normal `status: "failed"` tool result with `error.code: "nested_delegation_forbidden"`, starts no nested provider run, and does not mutate the advertised tools or tool schemas.
- [ ] Assert that the console renders a separate accent-colored activity bar during a subagent run and shows the final outcome without exposing the private transcript.
- [ ] Verify the parent remains responsible for final tests, file inspection, and integration.
### Phase 3: UX and safety hardening
- [ ] Add status/cancellation rendering for delegation.
- [ ] Add usage/cost reporting for subagent calls.
- [ ] Test provider errors, timeouts, oversized results, and partial findings.
- [ ] Document when to delegate and when not to.
### Phase 4: Optional write-capable delegation
Only begin this phase if read-only delegation proves insufficient.
- [ ] Define explicit approval and worktree isolation.
- [ ] Define patch review/merge behavior.
- [ ] Prevent two agents from editing the same files.
- [ ] Add integration tests for applying and reverting agent changes.
## Acceptance criteria
### Plan
- A multi-step request can create, update, reload, and clear a plan.
- The current plan is available after restarting Fido, and the restored snapshot is delivered to the model as a full tool result rather than system-prompt text.
- Every successful `update_plan` response contains the complete normalized state (version, timestamp, reason, all items, and all present optional fields) with no truncation, ellipsis, or count-only replacement.
- Updating the plan does not mutate the system message or force a rebuilt cached system-prompt prefix; a test can snapshot `messages[0]` before and after many updates and prove it is identical.
- The latest full plan-state tool result remains available after turn handoff and compaction.
- Invalid IDs, dependencies, and status combinations fail without corrupting saved state.
- A plan update does not modify source files or appear in the undo log.
- The UI renders every current todo item in a persistent floating box anchored to the right side of the screen; it does not merely append a compact plan to transcript history.
### Subagent
- A bounded read-only task returns the documented structured result.
- At call time the subagent receives an isolated fork of the parent `messages` array, not a shared live reference.
- The fork contains a tool result that tells the subagent its current identity (`role: "subagent"`, unique `agent_id`, `parent_role: "main"`) and return responsibility.
- The parent receives only the subagent's normalized final output, as a `delegate` tool response carrying the producing subagent's identity; it does not receive the subagent's private tool/message transcript.
- The `delegate` tool description explicitly states the fork, identity, read-only restrictions, stable advertised catalog, nested-call failure behavior, return path, and parent ownership responsibilities.
- The same tool names, schemas, descriptions, and ordering are advertised to parent and subagent; policy is enforced at invocation time rather than by mutating the tool list.
- A subagent that calls `delegate` receives a normal failed tool result with `nested_delegation_forbidden`; no second subagent is created and no nested provider request is made.
- The console displays a separate accent-colored subagent activity bar with agent ID, sanitized activity, and final status while delegating.
- Timeout, cancellation, provider failure, and malformed output are handled gracefully.
- The parent can continue normally after a partial or failed delegation.
- Unit tests pass without making a live model/API request.
### Regression
- Existing `npm test` tests remain green.
- Existing `/undo`, `/suspend`, `/recall`, and tool notification behavior remain intact.
- No new tool duplicates an existing shell operation unless it provides a clear safety or state-management benefit.
## Recommended first release
Ship the plan capability first. Add read-only `delegate` only after the plan state is stable. This gives the agent a reliable way to decompose work without introducing concurrency, isolation, and model-context bugs all at once.

313
README.md Normal file
View File

@@ -0,0 +1,313 @@
# Fido Project Overview
Fido is a minimal, interactive terminal coding assistant. It connects an OpenRouter-hosted model to a deliberately small set of workspace tools, then presents the conversation and tool activity in a full-screen terminal UI.
The project is written in modern JavaScript as native ES modules. It runs directly from source with Node.js; there is no transpiler, bundler, or JSX build step.
## What Fido Can Do
- Inspect and search a project without leaving the terminal.
- List directories, find files by name, search file contents, and read files in chunks.
- Apply precise file edits, create files and directories, and remove files or directory trees.
- Run shell commands after explicit user approval.
- Search the web and extract web pages through Tavily when configured.
- Ask the user clarifying questions or request help from a human expert.
- Stream model reasoning and tool notifications into a scrollable terminal interface.
- Navigate project files from the prompt and insert whole-file, single-line, or line-range mentions.
- Keep mutation and conversation state isolated per workspace, outside repositories when installed.
- Compact large tool payloads between turns to reduce token usage and avoid retaining stale file contents.
## Runtime Architecture
```text
bin/fido
├─ loads Fido configuration
├─ selects per-workspace state
└─ starts src/fido.js
└─ runRepl()
├─ builds the system prompt
├─ drives LLMOrbiter + OpenRouter
├─ executes tools from src/tools/
└─ drives the Ink UI through src/ui/
model tool call
└─ tool registry
├─ workspace path validation
├─ filesystem, shell, or Tavily operation
└─ structured result returned to the model and shown in the UI
```
### Main Modules
| Module | Responsibility |
| --- | --- |
| `bin/fido` | Installed/source launcher; loads user configuration and creates per-workspace state paths. |
| `src/fido.js` | Process entry point; checks for the required API key and starts the REPL. |
| `src/repl.js` | Owns conversation state, provider turns, cancellation, tool events, and transcript compaction. |
| `src/turn-state.js` | Closed enum of connection states plus the pure `(state, event) -> state` reducer that drives the command-bar label. |
| `src/config.js` | Loads `.env` files and resolves the model, workspace root, and state locations. |
| `src/system-prompt.js` | Builds the workspace-aware model instructions sent at session start. |
| `src/commands.js` | Implements local slash commands without spending a model turn. |
| `src/slash-commands.js` | Defines and resolves the built-in command registry. |
| `src/tools/index.js` | Registers the model-facing tools and their UI notification renderers. |
| `src/tools/*.js` | Implements individual filesystem, shell, web, and interaction tools. |
| `src/sandbox.js` | Resolves relative tool paths and rejects absolute paths or lexical traversal outside the workspace. |
| `src/dir-listing.js` | Shared `.gitignore`-aware directory walking and pagination. |
| `src/glob.js` | Small shared glob dialect used by name and content searches. |
| `src/lines.js` | Shared line splitting with editor-compatible trailing-newline behavior. |
| `src/compact.js` | Shrinks old patch arguments and invalidates file reads made stale by later patches. |
| `src/undo.js` | Persists a newest-first log describing mutations made by tools. |
| `src/suspend.js` | Saves and restores manually suspended conversation snapshots. |
| `src/transcript.js` | Formats assistant, user, tool, error, command, and recalled-message output. |
| `src/file-browser.js` | Provides testable file-browser navigation and mention-formatting helpers. |
| `src/ui/index.js` | Exposes an imperative UI singleton to the REPL and tools. |
| `src/ui/store.js` | External, subscriber-based state store for prompts, history, status, and usage totals. |
| `src/ui/app.js` | Root Ink application and fixed terminal layout. |
| `src/ui/main-input.js` | Main input, slash-command completion, and `Ctrl+O` file/line browser. |
## Conversation Flow
1. Configuration resolves the workspace root using the first positional argument, then `ROOT_DIR`, then the current directory.
2. The REPL loads undo and suspend state and mounts the Ink UI.
3. It builds a system prompt containing the workspace root, a short root listing, tool contracts, and operating rules.
4. The user submits a normal message or a local slash command.
5. Normal input is sent to `LLMOrbiter`, which uses the OpenRouter provider and advertises the registered tools to the model.
6. A provider turn can iterate up to 20 times, allowing the model to call tools and continue reasoning before returning a final response.
7. Tool calls and results are rendered in the transcript; the structured result is also returned to the model.
8. Between user turns, Fido compacts large patch arguments and replaces pre-patch file reads that are no longer current.
9. The resulting message array remains in memory for the session. It can be saved explicitly with `/suspend`.
## Model-Facing Tools
All currently registered tools are defined in `src/tools/index.js`.
| Tool | Purpose and notable behavior |
| --- | --- |
| `list_files` | Lists one directory, optionally recursively, with `.gitignore` support and pagination. Dot-directories are hidden unless requested. |
| `find_files` | Recursively finds files or directories by a simple name glob. Slashless patterns also match basenames. |
| `read_file` | Reads a workspace file as line-based content and supports zero-indexed `seek`/`limit` chunking. |
| `ripgrep` | Performs a pure-JavaScript regular-expression content search. It skips binary files, files over 2 MiB, ignored paths, and hidden directories by default. Long matching lines are capped. |
| `patch` | Applies ordered create/overwrite or exact first-match search/replace operations. Supports `skiplines`, CRLF adaptation, partial-progress reporting, and undo records. |
| `mkdir` | Recursively creates a directory and records its creation. |
| `rm` | Deletes a file while retaining its contents in the undo log. |
| `rmdir` | Recursively deletes a directory. Its undo entry records the path but not the removed contents. |
| `bash_exec` | Runs `bash -c` with the workspace as its initial working directory. Requires explicit approval, closes stdin, disables interactive/color behavior, limits output, and kills the process tree at timeout. |
| `javascript_execute` | Runs a JavaScript snippet in a vm2 sandbox on a worker thread, terminated at `timeout`. Returns `module.exports` (sync or a promise) and captured `console` output. `require` is restricted to workspace files — no host modules, no `node_modules`, no `process`/`fs`. `fetch` is off unless `allow_network=true` is explicitly approved by the user. |
| `websearch` | Uses Tavily to return a synthesized answer and ranked web results. It fails when no Tavily key is configured. |
| `browse_url` | Uses Tavily to extract one or more web pages, optionally focused by a relevance query. It fails when no Tavily key is configured. |
| `ask_user` | Prompts the user for clarification or a decision, with up to three suggested choices and a free-text option. |
| `ask_an_expert` | Displays a copyable question for a human expert and waits for the user to paste the answer back. It does not contact an external expert directly. |
The web tools remain registered even when `TAVILY_API_KEY` is absent; configuration is validated lazily when one is invoked.
## Built-In Commands
| Command | Behavior |
| --- | --- |
| `/paste` | Temporarily hands the terminal to `$EDITOR` for multiline input, then sends the resulting text to the model. |
| `/suspend [description]` | Saves the current message array as a timestamped JSON snapshot. A description is requested if omitted. |
| `/recall` | Lets the user select a saved snapshot. The old conversation is restored under a newly built system prompt. |
| `/undo` | Displays the most recent mutation-log entries. It currently inspects the log; it does not execute a rollback operation. |
| `/reasoning [level]` | Shows or sets provider reasoning effort to `minimal`, `low`, `medium`, or `high`. Omitting the level leaves the model default in effect. |
| `/quit` | Ends the session. |
Typing `/` in the main input opens inline command completion. All recognized slash commands are handled locally and do not consume a model turn.
## Terminal UI
The UI is a React application rendered by Ink in the terminal's alternate screen.
- **Header:** model, workspace, reasoning setting, and history-scroll state.
- **History:** bottom-anchored transcript with streaming reasoning and line-granular scrolling.
- **Command bar:** main input, local command completion, approval prompts, selections, confirmations, and the `Ctrl+O` file browser.
- **Footer:** cumulative model calls, input/output/cache tokens, estimated cost, and key hints.
- **History controls:** `PgUp`/`PgDn`, mouse wheel, and `End` to return to the live tail.
- **Cancellation:** `Ctrl+C` at the main prompt exits; during a model turn it aborts the provider request and restores the draft.
The store retains the latest 1,000 logical history entries, while the view mounts only the lines that fit in the current terminal. Mouse reporting can be disabled with `FIDO_NO_MOUSE=1`.
### Connection States
While a turn runs, the command bar shows an animated spinner plus one of five states. The state is a closed enum (`src/turn-state.js`) and every transition is driven by an observable provider event rather than a timer, so the label always names what the stream is currently doing.
| State | Label | Entered when |
| --- | --- | --- |
| `idle` | *(the `› (idle)` placeholder)* | Session start, after a turn completes, after cancel, after an error. |
| `connecting` | `connecting…` | The turn started and the request is in flight, but no delta has arrived yet. |
| `thinking` | `thinking…` | Every reasoning delta. |
| `working` | `working…` | Every tool-call delta. |
| `streaming` | `streaming…` | Every content delta. |
A turn therefore reads as `connecting → streaming` for a plain answer, `connecting → thinking → streaming` when the model reasons first, and `connecting → thinking → working → streaming` when it also calls a tool. Each delta wins immediately, so a later reasoning delta can move the label back from `streaming` to `thinking`; the label tracks the newest activity rather than the furthest point reached. Cancel and error are terminal: both fall back to `idle` so the prompt always returns.
The REPL (`src/repl.js`) is the only module that decides a transition, and it does so through a pure `(state, event) -> state` reducer, so the machine is unit-testable without React, the store, or a live provider. Text supplied by the provider's `onStatus` is treated as a non-authoritative detail shown next to the state label, never as a replacement for it.
## File Mentions
Press `Ctrl+O` in the main prompt to open the file browser.
- Arrow keys move the selection.
- `Enter` opens a directory or enters a file's line selector.
- `Tab` inserts a whole-file mention from the browser.
- `Backspace` moves to the parent directory.
- `Esc` leaves an overlay.
- In line mode, `v` starts or ends a range, `g`/`G` jump to the first/last line, and `Enter` inserts the mention.
Mentions are references, not embedded file contents:
```text
@README.md
@src/fido.js:12
@src/fido.js:12-40
```
The browser hides ignored and dot-directory entries by default. Mentions use forward-slash workspace-relative paths and one-based line numbers; the model maps those numbers to `read_file`'s zero-based `seek`.
## Safety and Trust Boundaries
Fido has useful path and workflow safeguards, but it is not an operating-system sandbox.
- Dedicated filesystem tools require relative paths and reject lexical paths that resolve outside `ROOT_DIR`.
- Direct filesystem access can still follow filesystem semantics such as symlinks; path checks should not be treated as a hard kernel-enforced isolation boundary.
- `bash_exec` pins only the shell's starting directory. The shell can use absolute paths or change directories, so interactive approval is the primary protection around shell execution.
- `javascript_execute` runs code in a vm2 sandbox on a worker thread. `NodeVM` ignores vm2's own `timeout` option, so the parent enforces a hard wall-clock limit and terminates the thread — this is what stops `while (true) {}`. The sandbox gets no `process`, no built-in modules and no `node_modules`; `require` resolves only against the workspace root.
- `javascript_execute` never enables the network implicitly. `fetch` and the WHATWG classes are injected only when `allow_network=true` is passed *and* the user approves; declining runs the snippet offline rather than failing the call. Approved network access still permits SSRF (localhost, internal services, cloud metadata endpoints), so the approval prompt is the only guard.
- Users can approve one command or all shell commands for the rest of that Fido session.
- Network and extracted web content is explicitly treated as untrusted third-party input.
- The model is instructed to inspect files before patching and to prefer targeted replacements over whole-file rewrites.
- API keys are loaded from environment files that are excluded by `.gitignore`; installed configuration lives under `FIDO_HOME`.
- Tool notifications shorten large arguments, but full model tool results remain part of the provider conversation.
## State and Persistence
### Installed layout
The installer defaults to `FIDO_HOME=~/.fido`:
```text
~/.fido/
app/ # this repository
llmorbiter/ # bundled local dependency
bin/fido # launcher
.env # API keys and configuration
state/
<workspace-slug>-<checksum>/
undo.json
suspends/
<timestamp>-<description>.json
```
The workspace slug is derived from the canonical workspace path and augmented with a checksum, keeping state isolated between projects and avoiding repository litter.
### Source-checkout defaults
When run without the installed launcher, the defaults are:
```text
UNDO_FILE=.fido-undo.json
SUSPEND_DIR=.fido-suspends
```
These paths are resolved from the selected workspace. They are covered by the repository's `.fido-*` ignore rule.
Normal conversations are not automatically persisted on exit. `/suspend` and `/recall` provide explicit manual snapshots.
## Configuration
Copy `.env.example` to `.env` and set at least `OPENROUTER_API_KEY`.
| Variable | Required | Default / meaning |
| --- | --- | --- |
| `OPENROUTER_API_KEY` | Yes | Credential for the OpenRouter provider. |
| `MODEL` | No | `xiaomi/mimo-v2.6-flash` |
| `TAVILY_API_KEY` | No | Enables `websearch` and `browse_url`. |
| `ROOT_DIR` | No | Workspace root when no positional path is supplied. |
| `UNDO_FILE` | No | Undo-log file; the installed launcher overrides it per workspace. |
| `SUSPEND_DIR` | No | Suspend directory; the installed launcher overrides it per workspace. |
| `COMPACT_DEBUG` | No | Set to `1` to report transcript-compaction statistics. |
| `FIDO_HOME` | No | Installer/launcher home, default `~/.fido`. |
| `LLMORBITER_SRC` | No | Installer override for the sibling `llmorbiter` checkout. |
| `BASHRC` | No | Installer shell RC file, default `~/.bashrc`. |
| `FIDO_SKIP_DEPS` | No | Set to `1` to let the installer skip `npm install`. |
| `FIDO_NO_MOUSE` | No | Set to `1` to disable terminal mouse reporting. |
For installed use, launcher-exported variables take precedence over workspace `.env` values. The source application also checks the repository's `.env` and then the current working directory's `.env`.
## Installation and Development
### Source checkout
Fido requires the sibling local dependency declared in `package.json`:
```text
<parent-directory>/
fido/
llmorbiter/
```
From the Fido checkout:
```sh
npm install
cp .env.example .env
# Edit .env and set OPENROUTER_API_KEY.
npm start
```
Run against another workspace with:
```sh
npm start -- /path/to/project
```
### Installed launcher
```sh
./install.sh
source ~/.bashrc
fido
# or
fido /path/to/project
```
The installer copies the app and `llmorbiter` into `FIDO_HOME`, installs dependencies, installs the launcher, and adds `$FIDO_HOME/bin` to `PATH`. Reinstalling preserves an existing `$FIDO_HOME/.env`. Use `./install.sh --uninstall` to remove the install and its PATH block.
If `llmorbiter` is not a sibling checkout:
```sh
LLMORBITER_SRC=/path/to/llmorbiter ./install.sh
```
### Tests
```sh
npm test
# or one file
node --test test/compact.test.js
```
The suite uses Node's built-in test runner and focuses on pure helper behavior: transcript compaction, file-browser paths and mention formatting, glob translation, line counting, mouse decoding, and UI/store rendering behavior. Unit tests do not make live model or Tavily requests.
## Extending the Project
To add a model-facing tool:
1. Create a module under `src/tools/` using `defineTool` from `llmorbiter`.
2. Validate arguments and return structured data or throw `ToolError`.
3. Use `resolveSafe` for workspace-relative filesystem paths.
4. Export a concise `notifyCall` and, when useful, a tailored `notifyResult` renderer.
5. Register the tool and notification renderers in `src/tools/index.js`.
6. Add its contract to `src/system-prompt.js` when the model needs extra usage rules.
7. Add focused tests that do not require live network or provider calls.
The non-React REPL and tool modules should interact with rendering through the `ui` facade in `src/ui/index.js`; direct console writes would bypass the full-screen UI state.
## Current Status and Planned Work
The current implementation does **not** include persistent todo plans or subagent delegation. `PLAN.md` is a proposed design for those features, including an `update_plan` tool, a right-side plan panel, a bounded read-only `delegate` tool, and separate subagent activity/status UI. Those capabilities should not be treated as available until their modules, registry entries, persistence, and tests are implemented.
Other practical limitations today include manual-only conversation snapshots, a view-only `/undo` command, no kernel-level sandbox, and no live provider integration tests in the standard suite.
## License
Fido is released under the [0BSD License](README.md#license).

191
STATE_IMPROVEMENT_PLAN.md Normal file
View File

@@ -0,0 +1,191 @@
# Fido Connection-State Improvement Plan
**Status:** Proposed
**Scope:** Replace the free-text, single "thinking…" status with a small, explicit state machine so the command bar accurately reflects what the session is doing at every moment.
## Goals
- Replace the ambiguous free-text status line with a closed set of clearly distinct connection states: **idle**, **connecting**, **thinking**, **working**, and **streaming**.
- Make every state transition driven by an observable provider event, not by timers or guesswork, so the status always tells the truth.
- Eliminate the dead-time window between submitting a message and the first model delta, during which the UI currently shows a misleading "thinking…".
- Distinguish "the model is reasoning" from "the model is calling a tool" from "the model is writing a reply", because these feel very different to the user even though all three are "busy".
- Keep the existing architecture: the state lives in the UI store, the REPL drives transitions, and nothing about the provider or message flow changes.
- Preserve cache-stable system prompts and the existing transcript/tool-notification behavior.
## Non-goals
- Changing the provider, the HTTP/SSE transport, or `llmorbiter` itself.
- Streaming visible assistant text into the history pane (the current UI logs the assistant reply at end of turn; that is a separate feature).
- Adding progress bars, elapsed-time counters, or token-rate meters.
- Changing the prompt, tool contracts, or any tool behavior.
## Design principles
1. **A closed enum, not free text.** The status is one of five constants, never a hand-written sentence assembled by string concatenation.
2. **Events drive transitions, not timers.** "Thinking" starts because a reasoning delta arrived, not after a debounce. There is no time-based guesswork.
3. **A state machine with a total function.** Every event maps to a state, so the behavior is exhaustive and testable, including out-of-order and backward moves.
4. **Last event wins; switch immediately.** Each delta that arrives sets the state right away, including a delta that moves the state backward. Reasoning, tool calls, and output are not interleaved within a request, so the reducer never has to arbitrate a genuine tie; the goal is to reflect the newest activity the instant it is observed rather than to smooth it out.
5. **One owner.** The REPL (`src/repl.js`) is the only module that decides transitions, because it already owns the turn lifecycle and the provider callbacks. The UI store and view only render the state they are given.
6. **Backward-compatible rendering.** The spinner animation, colors, and layout stay. Only the label text and its source change.
7. **Cancel and error are terminal, not busy.** Cancellation and errors must always fall back to `idle` so the prompt returns; no state may strand the session busy.
## The five states
| State | Meaning | Entered when |
| --- | --- | --- |
| `idle` | No turn is running; the main prompt is shown and awaiting user input. | Session start, after a turn completes, after cancel, after an error. |
| `connecting` | The turn has started and the HTTP request to establish the SSE stream has been issued, but the stream is not yet producing deltas. This is the "we sent it, nothing has come back yet" window. | Immediately when a turn begins, before any delta. |
| `thinking` | The model is producing reasoning deltas. | Every `onReasoning` delta. |
| `working` | The model is calling a tool. | Every `onToolCall` delta. |
| `streaming` | The model is writing visible output (assistant content). | Every `onContent` delta. |
Note: `thinking`, `working`, and `streaming` describe what the model stream is currently doing, so the label tracks the most recent delta at all times. The work performed *inside* a tool still surfaces through the existing per-tool transcript notifications; this plan does not replace that.
## State machine
The state is a pure function of the most recent event. `connecting` holds only until the first delta of any kind; from then on each delta sets the state directly, and the state is free to move in either direction.
```text
(turn begins)
idle ─────────────────────────► connecting
▲ │
│ │ any delta kind
│ ┌────────────────────────┼────────────────────────┐
│ │ onReasoning │ onToolCall │ onContent
│ ▼ ▼ ▼
│ thinking working streaming
│ └────────────────────────┼────────────────────────┘
│ │
│ (each later delta re-enters directly;
│ direction is not restricted)
│
turn ends / cancel / error
```
### Legal transitions
| From | Event | To | Notes |
| --- | --- | --- | --- |
| `idle` | turn start (`runTurn`) | `connecting` | Clears reasoning, sets busy, issues the request. |
| any | `onReasoning` delta | `thinking` | Reasoning is the newest observed activity. |
| any | `onToolCall` delta | `working` | A tool call is the newest observed activity. |
| any | `onContent` delta | `streaming` | Visible output is the newest observed activity. |
| any | turn end (success) | `idle` | Reply logged, busy cleared, prompt returns. |
| any | `AbortError` / cancel | `idle` | Draft restored, busy cleared. |
| any | error | `idle` | Error logged, busy cleared. |
The three delta rows are deliberately *not* restricted to particular source states. A delta always sets the state, even if it moves the state backward, so the label always names the model's current activity rather than the furthest point it has reached. `connecting` is the one state with a special property: it is only left via a delta, and a turn that produces no deltas at all stays in `connecting` until the turn ends.
### Ordering nuance: a turn can run several provider iterations
`runTurn` calls `orbiter.run(...)` with `maxIterations: 20`, so one user message can produce several request/response cycles. Two options:
- **Turn-scoped (recommended first version):** the state persists across the whole `runTurn` call and only resets to `connecting` at the start of the *next* user turn. A tool-calling iteration therefore shows `working`, and the final reply shows `streaming`; it never returns to `connecting` mid-turn, which is correct — the session is still connected.
- **Iteration-scoped (later refinement):** reset to `connecting` at the top of each provider iteration so the user sees the reconnect between iterations. This is more precise but noisier, and needs an iteration-boundary hook from the provider. Do not add it until the turn-scoped version is in place.
## Implementation
### 1. A dedicated state module
Add `src/turn-state.js` exporting the enum and a pure reducer, so the rules are unit-testable without React, the store, or a provider:
```js
export const TURN_STATES = {
IDLE: 'idle',
CONNECTING: 'connecting',
THINKING: 'thinking',
WORKING: 'working',
STREAMING: 'streaming',
};
// Pure: (current, event) -> next state. Deltas always win; the current
// value is irrelevant to every row except the turn-level ones.
export function nextState(current, event) { /* per the transition table */ }
// Optional helper for the status line.
export function statusLabel(state) { /* 'connecting…' | 'thinking…' | ... */ }
```
`event` is one of `'turn_start'`, `'reasoning'`, `'tool_call'`, `'content'`, `'turn_end'`, `'cancel'`, `'error'`. Because the reducer is a pure function of `(current, event) -> state`, the whole machine is trivially testable, and an unknown event resolves to a defined result rather than an exception. With this design the reducer is stateless for every delta event, so it cannot drift out of sync: the state is always exactly "whatever the last delta said", or `connecting`/`idle` when no delta applies.
### 2. Store and view changes
- `src/ui/store.js`: replace the free-text `status: ''` with the enum (`status: 'idle'` initially). Update `setStatus` to accept a state, and make `setBusy(false)` reset the state to `idle` so no path strands the command bar busy.
- `src/ui/index.js`: keep the `status(text)` facade but repurpose it for state values, or add a clearly named `setTurnState(state)`. The REPL remains the only caller.
- `src/ui/app.js` `StatusLine`: map each state to a label instead of rendering a raw provider string or the hardcoded `'working…'` fallback. Keep the existing spinner and yellow color:
- `connecting` → `connecting…`
- `thinking` → `thinking…`
- `working` → `working…`
- `streaming` → `streaming…`
- `idle` → not rendered; the existing `› (idle)` placeholder covers the non-busy case.
`CommandBar` and the tiny-terminal fallback in `app.js` both render `StatusLine`, so one change to the label mapping updates both paths.
### 3. REPL wiring
In `src/repl.js`:
- In `createOrbiter`, dispatch the corresponding event from `onReasoning`, `onToolCall`, and `onContent` and push the resulting state to the UI. These callbacks already fire repeatedly with growing text (`turnReasoning.text = text`, `ui.setReasoning(text)`), so every callback invocation is a live transition opportunity and the label follows the newest delta.
- In `runTurn`, emit `turn_start` (→ `connecting`) where it currently calls `ui.busy(true)` and `ui.status('thinking…')`, and emit `turn_end` (→ `idle`) in the `finally` block next to `ui.busy(false)`. The `finally` block already covers the cancel and error paths, so the terminal transition is centralized.
- Reset the state at the start of every turn so a new turn always begins at `connecting` and never inherits the previous turn's terminal state.
### Relationship to `onStatus`
`createOrbiter` currently wires `onStatus: (s) => ui.status(s)`, forwarding a provider-supplied string straight into the status line. That is a different concern from the connection state, and it is the reason the state is not trustworthy today: the label depends on whatever text the provider chooses to emit, and the hardcoded `'thinking…'` at turn start contradicts it.
The plan:
- The **connection state** is owned by Fido and driven by Fido's own observable events (`turn_start`, the delta callbacks, `turn_end`) per the table above.
- If `onStatus` text is still displayed, treat it as an optional **detail** layered on the state label, never as a replacement for it. If the two disagree, the state machine wins.
- This keeps the UI correct even when the provider emits sparse, absent, or misleading status text.
## Implementation order
### Phase 1: State module and unit tests
- [ ] Add `src/turn-state.js` with `TURN_STATES`, `nextState(current, event)`, and `statusLabel(state)`.
- [ ] Add `test/turn-state.test.js` covering every row of the transition table, each delta kind reached from every state, and the backward cases (a later `reasoning` delta after `streaming` *does* move the state back to `thinking`).
### Phase 2: Store and view rendering
- [ ] Convert the store status to the state enum; reset to `idle` on turn end, cancel, error, and `setBusy(false)`.
- [ ] Map each state to a label in `StatusLine`; keep the spinner, color, and both render paths (fullscreen layout and tiny-terminal fallback).
- [ ] Update `.ui-smoke.mjs` to drive the store into each non-idle state and assert the matching label renders, replacing the current hardcoded `setStatus('thinking…')` fixture.
### Phase 3: REPL wiring
- [ ] Emit `turn_start`/`turn_end` and the delta events from `createOrbiter`/`runTurn`; drive the UI exclusively through the reducer.
- [ ] Ensure each new turn starts at `connecting` with no cross-turn state bleed, and that cancel and error always land on `idle`.
- [ ] Exercise a fake provider (no live model call) to confirm the observed label sequences: plain answer `connecting → streaming`; reasoning then answer `connecting → thinking → streaming`; reasoning, tool, answer `connecting → thinking → working → streaming`.
### Phase 4: Polish and edge cases
- [ ] Decide whether a per-state detail from `onStatus` is shown; if so, add it as a non-authoritative suffix and cap its visible width.
- [ ] Add a short "Connection states" note to the README UI section documenting the five states and the spinner.
- [ ] Check that a fast reasoning→tool→output turn reads as three quick, distinct label changes rather than one stalled label, and that the switch happens on the first delta of each kind.
## Acceptance criteria
### State machine
- `turn_start` from `idle` always yields `connecting`; no turn ever begins in `thinking`, `working`, or `streaming`.
- Every reasoning delta sets the state to `thinking`, every tool-call delta to `working`, and every content delta to `streaming`, regardless of which state was current.
- A later delta of a kind that already occurred still updates the state, including when it moves the state backward; the label is never a stale "furthest point reached" value.
- Turn end, cancel, and error all resolve to `idle`; no path strands the session busy.
- A turn with no reasoning and no tools goes `connecting → streaming`; a turn that reasons then calls a tool goes `connecting → thinking → working`.
### UI
- The command bar shows the correct label for each state, with the spinner still animating, in both the fullscreen layout and the tiny-terminal fallback.
- The existing `› (idle)` placeholder appears only when no turn is running; the busy state is never blank.
- The footer, header, transcript, tool notifications, and scroll behavior are unchanged.
### Regression
- Existing `npm test` tests remain green.
- The state model is pure and unit-tested without React, the store, or a live provider.
- No change to the provider, transport, system prompt, tools, or message flow.
## Relationship to PLAN.md
This document is independent of the todo/subagent plan in `PLAN.md`. The two touch the same command bar and status chrome, so if both are implemented, reconcile them: `PLAN.md` proposes a second accent-colored subagent activity bar, which should get its own explicit lifecycle state rather than overloading `working`. Keep the five main-agent states defined here, and layer delegation on top as a distinct, additive concept.

61
bin/fido Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
#
# fido — launcher for the Fido coding assistant.
#
# Installed to $FIDO_HOME/bin/fido (default ~/.fido/bin/fido) by install.sh, but
# this file also works when run straight from a source checkout (bin/fido ->
# ../src/fido.js).
#
# All per-user state is kept under $FIDO_HOME:
# $FIDO_HOME/.env your API keys / model config
# $FIDO_HOME/state/<workspace>/undo.json the undo log for that workspace
# $FIDO_HOME/state/<workspace>/suspends/ /suspend snapshots for it
#
# Every workspace you run fido in gets its own state folder, so undo/suspend
# history never mixes between projects — and never litters the projects
# themselves (no more .fido-undo.json / .fido-suspends in your repos).
set -euo pipefail
FIDO_HOME="${FIDO_HOME:-$HOME/.fido}"
# ── Load user config ──────────────────────────────────────────────────────────
# Exported vars win over any .env inside the workspace, because dotenv (used by
# src/config.js) never overwrites variables already present in the environment.
if [ -f "$FIDO_HOME/.env" ]; then
set -a
# shellcheck disable=SC1090
. "$FIDO_HOME/.env"
set +a
fi
# ── Resolve the app directory ─────────────────────────────────────────────────
if [ -f "$FIDO_HOME/app/src/fido.js" ]; then
APP_DIR="$FIDO_HOME/app"
else
# Running from a source checkout: this script lives in <root>/bin/.
self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(cd "$self_dir/.." && pwd)"
fi
# ── Per-workspace state under $FIDO_HOME/state/<slug>/ ────────────────────────
# The workspace is the first argument (like the app itself: `fido /path/to/proj`),
# falling back to the current directory.
workspace="${1:-$PWD}"
if [ -d "$workspace" ]; then
workspace="$(cd "$workspace" && pwd)"
else
workspace="$PWD"
fi
# Human-readable, filesystem-safe slug + a short hash to guarantee uniqueness.
slug="$(printf '%s' "$workspace" | sed -e 's,^/,,' -e 's,/,_,g' -e 's,[^A-Za-z0-9._-],-,g')"
[ -n "$slug" ] || slug="root"
sum="$(printf '%s' "$workspace" | cksum | cut -d' ' -f1)"
state_dir="$FIDO_HOME/state/${slug}-${sum}"
mkdir -p "$state_dir/suspends"
export UNDO_FILE="$state_dir/undo.json"
export SUSPEND_DIR="$state_dir/suspends"
exec node "$APP_DIR/src/fido.js" "$@"

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

1767
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,13 +8,21 @@
"main": "src/fido.js", "main": "src/fido.js",
"scripts": { "scripts": {
"start": "node src/fido.js", "start": "node src/fido.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "node --test"
}, },
"dependencies": { "dependencies": {
"@inquirer/prompts": "^7.5.1", "@tavily/core": "^0.7.13",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"dotenv": "^18.0.2", "dotenv": "^18.0.2",
"ignore": "^7.0.10", "ignore": "^7.0.10",
"llmorbiter": "file:../llmorbiter" "ink": "^7.1.1",
"ink-text-input": "^6.0.0",
"llmorbiter": "file:../llmorbiter",
"marked": "^15.0.12",
"marked-terminal": "^7.3.0",
"react": "^19.3.0",
"string-width": "^4.2.3",
"vm2": "^3.12.2",
"wrap-ansi": "^7.0.0"
} }
} }

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();
}

246
src/compact.js Normal file
View File

@@ -0,0 +1,246 @@
'use strict';
/**
* Transcript compaction — token-saving rewrites applied to the persisted
* conversation history between turns.
*
* The `patch` tool's arguments embed entire file bodies: the `search` and
* `replace` text of every operation. Once a patch has been applied those bodies
* are pure dead weight — they are re-sent to the model on every later turn but
* 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;
// Explanatory text carried by the omission marker. The marker is inserted by
// the memory-management system when long tool-call text is elided between turns
// to save tokens. It is NOT written by the model, and the note says so
// explicitly so the model does not mistake it for its own earlier output.
const OMISSION_NOTE =
'omitted from memory to save tokens — note to self: the Fido Agent did NOT ' +
'write this note; the memory management system did';
/** Escape a string so it can be embedded literally inside a RegExp. */
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Matches the omission marker line emitted below, so an already-compacted field
// can be recognised and left alone (keeping repeated passes idempotent). Built
// from OMISSION_NOTE so the pattern can never drift from the emitted marker.
const OMISSION_MARKER_RE = new RegExp(`^… \\[\\d+ lines? ${escapeRegExp(OMISSION_NOTE)}\\] …$`);
/**
* Keep the first `head` and last `tail` lines of `text`, inserting a marker that
* notes how many lines were omitted. Text that is already short enough
* (<= head + tail lines), or that already carries an omission marker, is
* returned unchanged.
*/
export function excerptLines(text, head = HEAD_LINES, tail = TAIL_LINES) {
if (typeof text !== 'string') return text;
const lines = text.split('\n');
if (lines.length <= head + tail) return text;
// Already compacted — don't re-truncate (the marker itself would otherwise
// be counted as an extra line and trimmed away on the next pass).
if (lines.some((l) => OMISSION_MARKER_RE.test(l))) return text;
const omitted = lines.length - head - tail;
return [
...lines.slice(0, head),
`… [${omitted} line${omitted === 1 ? '' : 's'} ${OMISSION_NOTE}] …`,
...lines.slice(-tail),
].join('\n');
}
/**
* Rewrite every `patch` tool call in `messages` in place, replacing the long
* `search` and `replace` fields of each operation with a head/tail excerpt.
* Malformed arguments are left untouched. Returns the number of tool calls that
* were actually compacted.
*/
export function compactPatchToolCalls(messages) {
if (!Array.isArray(messages)) return 0;
let compacted = 0;
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?.function?.name !== 'patch' || typeof tc.function.arguments !== 'string') continue;
let args;
try {
args = JSON.parse(tc.function.arguments);
} catch {
continue; // leave malformed arguments untouched
}
if (!args || !Array.isArray(args.operations)) continue;
let changed = false;
for (const op of args.operations) {
if (!op || typeof op !== 'object') continue;
for (const field of ['search', 'replace']) {
const next = excerptLines(op[field]);
if (next !== op[field]) {
op[field] = next;
changed = true;
}
}
}
if (changed) {
tc.function.arguments = JSON.stringify(args);
compacted++;
}
}
}
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

@@ -7,7 +7,12 @@
* OPENROUTER_API_KEY=sk-or-... * OPENROUTER_API_KEY=sk-or-...
* MODEL=xiaomi/mimo-v2.6-flash * MODEL=xiaomi/mimo-v2.6-flash
* UNDO_FILE=.fido-undo.json * UNDO_FILE=.fido-undo.json
* SUSPEND_DIR=.fido-suspends (optional, directory of /suspend snapshot files)
* TAVILY_API_KEY=tvly-dev-... (optional, enables the websearch and browse_url tools)
* ROOT_DIR=. (optional, defaults to cwd) * ROOT_DIR=. (optional, defaults to cwd)
*
* The workspace root can also be given as the first command-line argument:
* node src/fido.js /path/to/project
*/ */
import path from 'path'; import path from 'path';
@@ -17,17 +22,27 @@ import dotenv from 'dotenv';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Load .env from the fido project root (one level up from src/) // 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 // 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 API_KEY = process.env.OPENROUTER_API_KEY;
export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash'; export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash';
export const UNDO_FILE = process.env.UNDO_FILE || '.fido-undo.json'; 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;
// Root dir: --dir flag wins, then env ROOT_DIR, then cwd // When set to a truthy value (1/true/yes/on), the REPL logs how many transcript
const args = process.argv.slice(2); // entries each between-turns compaction pass rewrote. Useful for verifying that
const dirFlagIdx = args.indexOf('--dir'); // stale read_file results are being elided, and that repeated passes are
export const ROOT_DIR = path.resolve( // idempotent (they report 0). Off by default so normal runs stay quiet.
(dirFlagIdx !== -1 && args[dirFlagIdx + 1]) || process.env.ROOT_DIR || process.cwd() 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(
args[0] || process.env.ROOT_DIR || process.cwd()
); );

View File

@@ -47,9 +47,11 @@ export function buildIgnoreFilter(dir) {
* Returns { entries: Array<{path,type,size}>, total, truncated } * Returns { entries: Array<{path,type,size}>, total, truncated }
* - type: 'd' for directory, 'f' for file * - type: 'd' for directory, 'f' for file
* - size: file size in bytes (null for directories) * - size: file size in bytes (null for directories)
* Dot-directories (names starting with '.', e.g. .git) are skipped unless
* `includeDotDirs` is true.
* All paths are relative to ROOT_DIR and use forward slashes. * All paths are relative to ROOT_DIR and use forward slashes.
*/ */
export function listDir(dir, { seek = 0, limit = 50, recursive = false } = {}) { export function listDir(dir, { seek = 0, limit = 50, recursive = false, includeDotDirs = false } = {}) {
const ig = buildIgnoreFilter(dir); const ig = buildIgnoreFilter(dir);
const allEntries = []; const allEntries = [];
@@ -61,6 +63,8 @@ export function listDir(dir, { seek = 0, limit = 50, recursive = false } = {}) {
return; return;
} }
for (const e of entries) { for (const e of entries) {
// Skip dot-directories (e.g. .git) by default.
if (e.isDirectory() && e.name.startsWith('.') && !includeDotDirs) continue;
const full = path.join(current, e.name); const full = path.join(current, e.name);
const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/'); const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/');
if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue; if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue;
@@ -81,3 +85,78 @@ export function listDir(dir, { seek = 0, limit = 50, recursive = false } = {}) {
const truncated = total - (seek + sliced.length); const truncated = total - (seek + sliced.length);
return { entries: sliced, total, truncated: Math.max(0, truncated) }; return { entries: sliced, total, truncated: Math.max(0, truncated) };
} }
/**
* Recursively walk the *files* under `dir`, honoring .gitignore rules
* (including nested .gitignore files) plus the hardcoded node_modules rule.
* Returns an array of { abs, rel } where `rel` is relative to ROOT_DIR using
* forward slashes. Symlinks are skipped to avoid loops.
*/
export function walkFiles(dir) {
const results = [];
const walk = (current) => {
// Rebuild per directory so nested .gitignore files apply to their subtree.
const ig = buildIgnoreFilter(current);
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(current, e.name);
const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/');
if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue;
if (e.isDirectory()) {
walk(full);
} else if (e.isFile()) {
results.push({ abs: full, rel });
}
}
};
walk(dir);
return results;
}
/**
* Recursively walk the entries under `dir` (both files and directories),
* honoring .gitignore rules (including nested .gitignore files) plus the
* hardcoded node_modules rule. Dot-directories (names starting with '.', e.g.
* .git) are skipped unless `includeDotDirs` is true. Symlinks are skipped to
* avoid loops.
*
* Returns an array of { abs, rel, type, size, name } where `rel` is relative to
* ROOT_DIR using forward slashes: files have type 'f' and a byte `size`,
* directories have type 'd', size null, and a trailing "/" on `rel` (matching
* the spelling used by listDir). `name` is the entry's basename.
*/
export function walkTree(dir, { includeDotDirs = false } = {}) {
const results = [];
const walk = (current) => {
// Rebuild per directory so nested .gitignore files apply to their subtree.
const ig = buildIgnoreFilter(current);
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
// Skip dot-directories (e.g. .git) by default.
if (e.isDirectory() && e.name.startsWith('.') && !includeDotDirs) continue;
const full = path.join(current, e.name);
const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/');
if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue;
if (e.isDirectory()) {
results.push({ abs: full, rel: rel + '/', type: 'd', size: null, name: e.name });
walk(full);
} else if (e.isFile()) {
let size = null;
try { size = fs.statSync(full).size; } catch { /* skip */ }
results.push({ abs: full, rel, type: 'f', size, name: e.name });
}
}
};
walk(dir);
return results;
}

View File

@@ -3,224 +3,31 @@
/** /**
* Fido — Minimal Coding Assistant * Fido — Minimal Coding Assistant
* *
* A sandboxed, interactive coding assistant using LLMOrbiter. * Application entry point. The interactive loop lives in ./repl.js, with the
* * system prompt, transcript rendering, and slash commands in sibling modules.
* 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/.
*
* Config via .env (place in c:\src\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)
* *
* Usage: * Usage:
* node src/fido.js * node src/fido.js
* node src/fido.js --dir /path/to/project * node src/fido.js /path/to/project
*/ */
import path from 'path';
import chalk from 'chalk'; import chalk from 'chalk';
import { input } from '@inquirer/prompts';
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter'; import { API_KEY } from './config.js';
import { unmount } from './ui/index.js';
import { API_KEY, MODEL, UNDO_FILE, ROOT_DIR } from './config.js'; import { runRepl } from './repl.js';
import { listDir } from './dir-listing.js';
import { loadUndoLog, getUndoLog } from './undo.js';
import { tools } from './tools/index.js';
if (!API_KEY) { if (!API_KEY) {
console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n')); console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n'));
process.exit(1); process.exit(1);
} }
// ─── System prompt builder ───────────────────────────────────────────────────── runRepl().catch(async (err) => {
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 are Fido, a coding assistant.
You inspect files, list directories, and apply precise code changes within the workspace.
## Workspace
Root directory: ${ROOT_DIR}
Total files: ${total}
## Workspace root (${total} entries):
${dirSection}
## Available Tools & Guidelines
- \`read_file(filename, seek, limit)\`: Inspect file contents and line numbers before modifying them.
- \`list_files(dirname, recursive, seek, limit)\`: Explore workspace folders (respects .gitignore).
- \`patch(operations)\`: Apply file updates or create files.
- Create or overwrite: \`{"file": "path", "search": null, "replace": "content"}\` (parent directories auto-created).
- Search & replace: \`{"file": "path", "search": "exact old text", "replace": "new text"}\`. Replaces the FIRST match.
- \`skiplines\`: (optional integer, 0-indexed) skips N lines from the top before matching. Use this if the search text appears multiple times.
- \`mkdir(dirname)\`: Create directories (auto-recursive).
- \`rmdir(dirname)\`: Remove a directory and all its contents.
- \`rm(filename)\`: Delete an individual file.
- \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision.
## Rules
- You may ONLY access files within the workspace root. Never use ".." or absolute paths.
- Always inspect files with \`read_file\` before patching to ensure exact search string and indentation matches.
- Prefer targeted SEARCH/REPLACE operations over rewriting entire files.
- Multiple operations in a single \`patch\` call execute sequentially.
- Undo is tracked automatically per operation and managed externally by the user.
`;
}
// ─── Console UI ────────────────────────────────────────────────────────────────
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('');
}
function printDivider() {
console.log(chalk.dim(' ' + '─'.repeat(50)));
}
function printAssistant(content) {
console.log('');
console.log(chalk.green.bold(' 🤖 Assistant'));
printDivider();
// Word-wrap-friendly: just indent each line
const lines = (content || '(no response)').split('\n');
for (const line of lines) {
console.log(chalk.white(' ' + line));
}
console.log('');
}
function printStatus(msg) {
process.stdout.write(chalk.yellow.dim(` ⚡ ${msg}\n`));
}
function printToolCall(name, args) {
const argStr = JSON.stringify(args);
const display = argStr.length > 120 ? argStr.slice(0, 117) + '...' : argStr;
console.log(chalk.cyan(` ⚙ Tool: ${chalk.bold(name)}(${display})`));
}
function printToolResult(name, result) {
const str = typeof result === 'string' ? result : JSON.stringify(result);
const display = str.length > 200 ? str.slice(0, 197) + '...' : str;
console.log(chalk.dim(` ↳ ${display}`));
}
function printToolError(name, error) {
console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`));
}
function printUsage(u) {
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(' ')}`));
}
// ─── Main loop ─────────────────────────────────────────────────────────────────
async function main() {
loadUndoLog();
printBanner();
const provider = new OpenRouterProvider({ apiKey: API_KEY });
const orbiter = new LLMOrbiter(provider, {
onStatus: (s) => printStatus(s),
onToolCall: (c) => printToolCall(c.name, c.args),
onToolResult: (r) => printToolResult(r.name, r.result),
onToolError: (e) => printToolError(e.name, e.error),
onUsage: (u) => printUsage(u),
});
// Conversation state: persisted across turns
const systemPrompt = buildSystemPrompt();
let messages = [{ role: 'system', content: systemPrompt }];
console.log(chalk.dim(' Type your request below. Press Ctrl+C to exit.\n'));
while (true) {
let userInput;
try { try {
userInput = await input({ await unmount();
message: chalk.bold.cyan('You'),
prefix: chalk.cyan('›'),
theme: {
prefix: chalk.cyan('›'),
style: {
answer: chalk.white,
message: chalk.bold.cyan,
},
},
});
} catch { } catch {
// Ctrl+C / EOF /* ignore */
console.log(chalk.dim('\n Goodbye! 🐕\n'));
break;
} }
if (!userInput.trim()) continue;
// Handle special commands
if (userInput.trim() === '/quit' || userInput.trim() === '/exit') {
console.log(chalk.dim('\n Goodbye! 🐕\n'));
break;
}
if (userInput.trim() === '/undo') {
const undoLog = getUndoLog();
if (undoLog.length === 0) {
console.log(chalk.yellow('\n Nothing to undo.\n'));
} else {
console.log(chalk.yellow('\n Undo log (newest first):'));
for (const e of undoLog.slice(0, 10)) {
console.log(chalk.dim(` ${e.op} ${e.path}`));
}
if (undoLog.length > 10) {
console.log(chalk.dim(` ... and ${undoLog.length - 10} more in ${UNDO_FILE}`));
}
console.log('');
}
continue;
}
console.log('');
try {
const result = await orbiter.run({
prompt: userInput,
messages,
model: MODEL,
tools,
maxIterations: 20,
});
// Update conversation history for next turn
messages = result.messages;
printAssistant(result.content);
} catch (err) {
console.log(chalk.red(`\n ✖ Error: ${err.message}\n`));
}
}
}
main().catch((err) => {
console.error(chalk.red.bold('\nFATAL: ' + err.message)); console.error(chalk.red.bold('\nFATAL: ' + err.message));
process.exit(1); process.exit(1);
}); });

142
src/file-browser.js Normal file
View File

@@ -0,0 +1,142 @@
'use strict';
/**
* File-browser helpers for prompt file mentions.
*
* Pure, terminal-free helpers used by the in-prompt file browser in
* `src/ui/main-input.js`. Keeping them here means the listing, mention
* formatting and range math can be unit-tested without a TTY.
*
* Everything path-related stays relative to `ROOT_DIR` and uses forward
* slashes, matching the rest of the system (`list_files`, `read_file`,
* `ripgrep`). File reads are constrained to the workspace via `resolveSafe`.
*/
import fs from 'fs';
import path from 'path';
import { ROOT_DIR } from './config.js';
import { listDir } from './dir-listing.js';
import { resolveSafe } from './sandbox.js';
import { splitLines } from './lines.js';
/** Normalize a workspace-relative path to forward slashes, no trailing slash. */
function normalizeRel(rel) {
return String(rel || '').replace(/\\/g, '/').replace(/\/+$/, '');
}
/** Return the parent of a workspace-relative directory path ('' at the root). */
export function parentOf(cwd) {
const rel = normalizeRel(cwd);
if (!rel) return '';
const idx = rel.lastIndexOf('/');
return idx === -1 ? '' : rel.slice(0, idx);
}
/** Basename of a workspace-relative path. */
export function baseOf(rel) {
const clean = normalizeRel(rel);
const idx = clean.lastIndexOf('/');
return idx === -1 ? clean : clean.slice(idx + 1);
}
/**
* List the entries of a workspace-relative directory for the browser.
*
* Directories come first (then files), each alphabetically. A synthetic
* `..` entry is prepended unless we are already at the workspace root.
* Respects `.gitignore` and hides dot-directories by delegating to `listDir`.
*
* Returns an array of `{ name, rel, type }` where `type` is one of
* `'parent' | 'd' | 'f'` and `rel` is relative to `ROOT_DIR` (no trailing
* slash; '' for the parent entry at depth 1).
*/
export function listEntries(cwd = '') {
const rel = normalizeRel(cwd);
const dir = rel ? path.join(ROOT_DIR, rel) : ROOT_DIR;
const { entries } = listDir(dir, { seek: 0, limit: Number.MAX_SAFE_INTEGER });
const dirs = entries.filter((e) => e.type === 'd');
const files = entries.filter((e) => e.type === 'f');
const byName = (a, b) => a.path.localeCompare(b.path);
dirs.sort(byName);
files.sort(byName);
const out = [];
if (rel) out.push({ name: '..', rel: parentOf(rel), type: 'parent' });
for (const e of dirs) {
const childRel = normalizeRel(e.path);
out.push({ name: baseOf(childRel), rel: childRel, type: 'd' });
}
for (const e of files) {
const childRel = normalizeRel(e.path);
out.push({ name: baseOf(childRel), rel: childRel, type: 'f' });
}
return out;
}
/**
* Read a workspace-relative file as an array of lines (split on `\n`).
*
* Returns `{ lines, binary }`. An empty file yields `[]`. A file containing a
* NUL byte in its first 8000 bytes is reported as binary with no lines, so the
* line selector can fall back to a whole-file mention without garbling output.
* Read/permission errors yield `{ lines: [], binary: false, error: true }`
* rather than throwing, so the browser can refresh and keep going.
*/
export function readLines(relPath) {
let abs;
try {
abs = resolveSafe(relPath);
} catch {
return { lines: [], binary: false, error: true };
}
let buf;
try {
buf = fs.readFileSync(abs);
} catch {
return { lines: [], binary: false, error: true };
}
if (buf.subarray(0, 8000).includes(0)) {
return { lines: [], binary: true };
}
const text = buf.toString('utf8');
const lines = splitLines(text);
return { lines, binary: false };
}
/**
* Format a mention for insertion into the prompt.
*
* formatMention('a/b.js') -> '@a/b.js'
* formatMention('a/b.js', 10, 10) -> '@a/b.js:10'
* formatMention('a/b.js', 10, 20) -> '@a/b.js:10-20'
*/
export function formatMention(relPath, start, end) {
const p = normalizeRel(relPath);
if (start === undefined || start === null) return `@${p}`;
if (end === undefined || end === null || end === start) return `@${p}:${start}`;
return `@${p}:${start}-${end}`;
}
/**
* Normalize a marked range from two 0-based line indices to the 1-based
* `{ start, end }` shown to the user (start <= end).
*/
export function normalizeRange(anchor, cursor) {
const a = Math.min(anchor, cursor);
const b = Math.max(anchor, cursor);
return { start: a + 1, end: b + 1 };
}
/**
* Top index of the visible window of `size` rows that keeps `cursor` in view.
* Returns 0 when everything fits.
*/
export function windowStart(cursor, total, size) {
if (total <= size) return 0;
let start = cursor - Math.floor(size / 2);
if (start < 0) start = 0;
if (start + size > total) start = total - size;
return start;
}

68
src/glob.js Normal file
View File

@@ -0,0 +1,68 @@
'use strict';
/**
* Minimal glob support shared by the file-finding tools.
*
* Both `ripgrep` (content search filtered by `glob`) and `find_files` (name
* search) need to turn a small glob dialect into an anchored RegExp. Keeping a
* single implementation here avoids the two tools drifting apart.
*/
/** Escape a string so it can be matched literally inside a RegExp. */
export function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Translate a simple glob into a RegExp anchored at both ends.
* ** -> any characters (including "/")
* * -> any characters except "/"
* ? -> a single character except "/"
* \\ -> escapes the next character
* All other characters are matched literally (no brace/char-class expansion).
* When `ignoreCase` is true the pattern is compiled case-insensitively.
*/
export function globToRegExp(pattern, { ignoreCase = false } = {}) {
let re = '';
for (let i = 0; i < pattern.length; i++) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
re += '.*';
i++;
// Let "**/" also match zero directories.
if (pattern[i + 1] === '/') i++;
} else {
re += '[^/]*';
}
} else if (c === '?') {
re += '[^/]';
} else if ('\\^$.|+()[]{}'.includes(c)) {
re += '\\' + c;
} else {
re += c;
}
}
return new RegExp('^' + re + '$', ignoreCase ? 'i' : '');
}
/**
* Build a predicate testing a workspace-relative path (forward slashes) against
* a glob. A glob without "/" is also matched against the basename, mirroring
* ripgrep's `-g '*.ext'` behaviour. A trailing "/" (how directories are spelled
* in listings) is ignored while matching, and callers may pass the basename
* explicitly via the second argument.
*/
export function makeGlobMatcher(pattern, { ignoreCase = false } = {}) {
const rx = globToRegExp(pattern, { ignoreCase });
const basenameOnly = !pattern.includes('/');
return (rel, base) => {
const trimmed = rel.endsWith('/') ? rel.slice(0, -1) : rel;
if (rx.test(trimmed)) return true;
if (basenameOnly) {
const name = base ?? trimmed.slice(trimmed.lastIndexOf('/') + 1);
return rx.test(name);
}
return false;
};
}

22
src/lines.js Normal file
View File

@@ -0,0 +1,22 @@
'use strict';
/**
* Line-splitting helper shared by `read_file` and the file browser.
*
* Splitting on '\n' naively counts a trailing newline as the start of an extra
* empty line: "a\nb\nc\n" would become 4 elements. Editors and `wc -l` treat
* the final newline as *terminating* the last line, so the file has 3 lines -
* the same as the non-terminated variant "a\nb\nc". This helper matches that
* convention so callers stay internally consistent.
*
* An empty file yields no lines.
*/
export function splitLines(text) {
if (text.length === 0) return [];
const lines = text.split('\n');
// A trailing '\n' produces a final '' element that is a terminator, not a
// line; drop it. A file ending in a real blank line ("a\n\n") still keeps
// that blank line ("a", "").
if (lines[lines.length - 1] === '') lines.pop();
return lines;
}

266
src/repl.js Normal file
View File

@@ -0,0 +1,266 @@
'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 { TURN_EVENTS, nextState } from './turn-state.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';
/**
* The single owner of connection-state transitions.
*
* The REPL already owns the turn lifecycle and the provider callbacks, so it
* is also the only module that decides a new state. Every transition goes
* through the pure reducer, which means the label can never drift away from
* the events that actually produced it: the store and the view only render
* the value they are handed.
*/
export function dispatchTurnEvent(event, uiApi = ui) {
const next = nextState(uiApi.store.getState().status, event);
uiApi.setTurnState(next);
return next;
}
/**
* Provider callbacks for the session, factored out of `createOrbiter` so a
* fake provider can replay them without a live model call.
*
* Each delta callback is a live transition opportunity: the orbiter invokes
* them repeatedly with growing text, so every invocation re-enters the state
* machine and the label tracks the newest activity.
*/
export function createTurnHandlers(turnReasoning, uiApi = ui) {
return {
// Provider status text is an optional detail only. The state machine
// stays authoritative when the two disagree, or when the provider
// emits nothing at all.
onStatus: (s) => uiApi.setStatusDetail(s),
onReasoning: (text) => {
turnReasoning.text = text;
uiApi.setReasoning(text);
dispatchTurnEvent(TURN_EVENTS.REASONING, uiApi);
},
onContent: () => {
uiApi.finalizeReasoning();
dispatchTurnEvent(TURN_EVENTS.CONTENT, uiApi);
},
onToolCall: (c) => {
uiApi.finalizeReasoning();
const notify = toolCallNotifications[c.name];
if (notify) notify(c.args);
else printToolCall(c.name, c.args);
dispatchTurnEvent(TURN_EVENTS.TOOL_CALL, uiApi);
},
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) => uiApi.log(toolErrorLine(e.name, e.error)),
onUsage: (u) => printUsage(u),
};
}
function createOrbiter(turnReasoning) {
const provider = new OpenRouterProvider({ apiKey: API_KEY });
return new LLMOrbiter(provider, createTurnHandlers(turnReasoning));
}
/**
* Timestamp prefix for the user message, e.g. `[14:32]`.
*
* The system prompt carries the current date once per session, but a session
* can run for hours or across a midnight boundary, so the time is stamped on
* each message at the moment it is sent. Local time, zero-padded 24-hour, so
* it reads unambiguously next to whatever the user typed.
*/
export function stampUserInput(text, now = new Date()) {
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
return `[${hh}:${mm}] ${text}`;
}
/**
* 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);
// "We sent it, nothing has come back yet" — the window that used to show a
// misleading "thinking…" before the first delta. Emitted on every turn, so
// a new turn can never inherit the previous turn's terminal state.
dispatchTurnEvent(TURN_EVENTS.TURN_START);
const onSigint = () => orbiter.abort('cancelled by user');
process.on('SIGINT', onSigint);
try {
const result = await orbiter.run({
// Stamped only on the way to the model: the transcript already
// echoed the raw text at submit time, and `draft` below must stay
// the undecorated text so an edit-and-resend stays clean.
prompt: stampUserInput(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') {
// Cancel is terminal, not busy: the prompt has to come back.
dispatchTurnEvent(TURN_EVENTS.CANCEL);
// 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 };
}
dispatchTurnEvent(TURN_EVENTS.ERROR);
ui.log(chalk.red(` ✖ Error: ${err.message}`));
return { messages, draft: '' };
} finally {
process.removeListener('SIGINT', onSigint);
// The finally block also covers cancel and error, so the terminal
// transition is centralized here and no path can strand the bar busy.
dispatchTurnEvent(TURN_EVENTS.TURN_END);
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();
}
}

34
src/slash-commands.js Normal file
View File

@@ -0,0 +1,34 @@
'use strict';
/**
* Slash-command registry.
*
* Built-in REPL directives — everything the user types that starts with "/".
* The autocomplete prompt uses this list to show inline help while typing, and
* the main loop uses `findCommand` to dispatch a line to the right handler.
*/
// Reasoning effort levels accepted by /reasoning (maps to body.reasoning.effort).
export const REASONING_LEVELS = ['minimal', 'low', 'medium', 'high'];
export const COMMANDS = [
{ name: '/paste', description: 'Compose or paste multiline input in $EDITOR' },
{ name: '/suspend', description: 'Save the current conversation under a short description' },
{ name: '/recall', description: 'Restore a previously suspended conversation' },
{ name: '/undo', description: 'Show recent file changes from the undo log' },
{ name: '/reasoning', description: `Show or set reasoning effort (${REASONING_LEVELS.join('|')})` },
{ name: '/quit', description: 'Exit Fido' },
];
/** Return the commands whose name starts with `term` (case-insensitive). */
export function filterCommands(term) {
const t = (term || '').toLowerCase();
return COMMANDS.filter((c) => c.name.toLowerCase().startsWith(t));
}
/** Resolve a raw input line to a command name, or `null` if unknown. */
export function findCommand(line) {
if (typeof line !== 'string' || !line.startsWith('/')) return null;
const name = line.trim().split(/\s+/)[0].toLowerCase();
return COMMANDS.some((c) => c.name === name) ? name : null;
}

111
src/suspend.js Normal file
View File

@@ -0,0 +1,111 @@
'use strict';
/**
* Suspend snapshots — one JSON file per snapshot, stored in a directory.
*
* `/suspend <description>` captures the current conversation state under a
* short description so it can be listed and restored later via `/recall`.
* Each snapshot is written to its own file in `SUSPEND_DIR`, named with a
* sortable timestamp and a slug of the description so it can be opened and
* edited individually:
*
* .fido-suspends/20240115-103000-fix-the-parser.json
*
* Each file contains: { description, createdAt, messages }
*/
import fs from 'fs';
import path from 'path';
import { ROOT_DIR, SUSPEND_DIR } from './config.js';
let snapshots = [];
function suspendDir() {
return path.resolve(ROOT_DIR, SUSPEND_DIR);
}
/** Filesystem-safe, sortable local timestamp: `YYYYMMDD-HHMMSS`. */
function timestamp(date = new Date()) {
const p = (n) => String(n).padStart(2, '0');
return (
`${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}` +
`-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`
);
}
/** Turn a description into a short, lowercase, filesystem-safe slug. */
function slugify(text) {
const slug = String(text ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60)
.replace(/-+$/g, '');
return slug || 'suspend';
}
/** Non-colliding `<dir>/<base>.json` path (adds `-2`, `-3`, … if needed). */
function uniquePath(dir, base) {
let candidate = path.join(dir, `${base}.json`);
let n = 2;
while (fs.existsSync(candidate)) {
candidate = path.join(dir, `${base}-${n}.json`);
n++;
}
return candidate;
}
/** Load every snapshot file from `SUSPEND_DIR`, newest first. */
export function loadSuspends() {
snapshots = [];
const dir = suspendDir();
if (!fs.existsSync(dir)) return;
let names;
try {
names = fs.readdirSync(dir);
} catch {
return;
}
const loaded = [];
for (const name of names) {
if (!name.toLowerCase().endsWith('.json')) continue;
const full = path.join(dir, name);
try {
if (!fs.statSync(full).isFile()) continue;
const data = JSON.parse(fs.readFileSync(full, 'utf8'));
if (data && typeof data === 'object') loaded.push(data);
} catch {
// Skip unreadable or malformed snapshot files.
}
}
loaded.sort((a, b) =>
String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? '')),
);
snapshots = loaded;
}
/**
* Capture `messages` under `description` (newest first) and write it to its own
* file in `SUSPEND_DIR`. Returns the stored entry.
*/
export function addSuspend(description, messages) {
const now = new Date();
const entry = {
description,
createdAt: now.toISOString(),
messages,
};
const dir = suspendDir();
fs.mkdirSync(dir, { recursive: true });
const file = uniquePath(dir, `${timestamp(now)}-${slugify(description)}`);
fs.writeFileSync(file, JSON.stringify(entry, null, 2) + '\n', 'utf8');
snapshots.unshift(entry);
return entry;
}
export function getSuspends() {
return snapshots;
}

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

@@ -0,0 +1,67 @@
'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, assistant. Inspect files, list dirs, edit code precisely, run shell cmds in the workspace.
## Context
Current date: ${new Date().toISOString().slice(0, 10)}
User messages are prefixed with the local time they were sent, e.g. "[14:32] add a test".
## 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).
- \`javascript_execute(code, timeout, max_output, allow_network)\`: run JS in a vm2 sandbox, in a worker thread killed at \`timeout\` (so \`while(true){}\` cannot hang fido). Return a value via \`module.exports\`; for async assign the PROMISE: \`module.exports = (async () => { ... })()\`. No host modules/node_modules/process/fs — only ws files via require. console output captured and returned as \`stdout\`. Results serialized JSON-safe (functions → {type:"function"}). \`fetch\` is OFF unless \`allow_network=true\`, which needs explicit user approval (user may decline → runs offline).
- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source, start_date, end_date)\`: web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). Use for current info, external docs, or facts not in ws. Optional \`start_date\`/\`end_date\` (YYYY-MM-DD) restrict results to a publication-date range (see "Current date" above for today). 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.
Answer in GERMAN only.
`;
}

68
src/tools/ask-expert.js Normal file
View File

@@ -0,0 +1,68 @@
'use strict';
import chalk from 'chalk';
import { defineTool } from 'llmorbiter';
import { ui } from '../ui/index.js';
import { renderToolBlock } from '../ui/blocks.js';
import { emitToolCall, clip } from './notify.js';
export const askExpertTool = defineTool({
name: 'ask_an_expert',
description:
'Escalate to a human expert when you are genuinely stuck (a confusing or unclear problem, or ' +
'you have tried and are still spinning). Do not 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`.',
parameters: {
type: 'object',
additionalProperties: false,
required: ['question'],
properties: {
question: {
type: 'string',
description: 'Question for the expert. Concise & specific.',
},
context: {
type: 'string',
description:
'Opt supporting detail the expert needs (errors, code, what you tried).',
},
},
},
invoke: async ({ question, context = '' }) => {
const hasContext = context && context.trim().length > 0;
// Present the question as a single, self-contained block the user can
// select and copy straight into a chat with their expert.
const lines = [hasContext ? `Question: ${question}` : question];
if (hasContext) {
lines.push('', 'Context:', context.trim());
}
const message = lines.join('\n');
const panel = renderToolBlock([
chalk.blue.bold('\ud83e\uddd1\u200d\ud83d\udd2c Ask an expert \u2014 copy & paste this to your expert:'),
chalk.dim('\u2500'.repeat(60)),
...message.split('\n').map((line) => chalk.white(line)),
].join('\n'));
ui.log('');
ui.log(panel);
ui.log('');
// Wait for the user to bring back the expert's answer.
const answer = await ui.input({
message: "Expert's answer (paste it here)",
color: 'blue',
});
ui.log('');
return { answer };
},
});
/**
* Call notification: the question being put to the expert.
*/
export function notifyCall(args = {}) {
emitToolCall(askExpertTool.name, clip(JSON.stringify(args.question ?? '')));
}

View File

@@ -1,16 +1,16 @@
'use strict'; 'use strict';
import chalk from 'chalk'; import chalk from 'chalk';
import { input, select } from '@inquirer/prompts';
import { defineTool } from 'llmorbiter'; import { defineTool } from 'llmorbiter';
import { ui } from '../ui/index.js';
import { renderToolBlock } from '../ui/blocks.js';
import { emitToolCall, clip } from './notify.js';
export const askUserTool = defineTool({ export const askUserTool = defineTool({
name: 'ask_user', name: 'ask_user',
description: description:
'Ask the user a question when you need clarification or a decision. ' + 'Ask user when you need clarification/decision. Give concise question + up to 3 labeled options (A,B,C); ' +
'Provide a concise question and up to 3 labelled options (A, B, C). ' + 'user may pick one or type free-form (D). Use instead of guessing when intent is ambiguous.',
'The user can pick an option or type a free-form answer (D). ' +
'Use this instead of guessing whenever intent is ambiguous.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -18,11 +18,11 @@ export const askUserTool = defineTool({
properties: { properties: {
question: { question: {
type: 'string', type: 'string',
description: 'The question to ask the user.', description: 'Question for the user.',
}, },
options: { options: {
type: 'array', type: 'array',
description: 'Up to 3 short option labels for the user to choose from.', description: 'Up to 3 short option labels.',
items: { type: 'string' }, items: { type: 'string' },
maxItems: 3, maxItems: 3,
}, },
@@ -32,21 +32,19 @@ export const askUserTool = defineTool({
const capped = options.slice(0, 3); const capped = options.slice(0, 3);
const letters = ['A', 'B', 'C']; const letters = ['A', 'B', 'C'];
console.log(''); ui.log('');
console.log(chalk.magenta.bold(' ❓ Fido asks:')); ui.log(renderToolBlock([
console.log(chalk.white(' ' + question)); chalk.magenta.bold('\u2753 Fido asks:'),
chalk.white(question),
].join('\n')));
if (capped.length === 0) { if (capped.length === 0) {
// No options — just free text // No options \u2014 just free text
const answer = await input({ const answer = await ui.input({
message: chalk.bold.magenta('Your answer'), message: 'Your answer',
prefix: chalk.magenta('›'), color: 'magenta',
theme: {
prefix: chalk.magenta('›'),
style: { answer: chalk.white, message: chalk.bold.magenta },
},
}); });
console.log(''); ui.log('');
return { answer }; return { answer };
} }
@@ -55,37 +53,35 @@ export const askUserTool = defineTool({
...capped.map((opt, i) => ({ ...capped.map((opt, i) => ({
name: `${letters[i]}) ${opt}`, name: `${letters[i]}) ${opt}`,
value: letters[i], value: letters[i],
short: letters[i],
})), })),
{ name: 'D) Type my own answer…', value: '__custom__', short: 'D' }, { name: 'D) Type my own answer\u2026', value: '__custom__' },
]; ];
const selection = await select({ const selection = await ui.select({
message: question, message: question,
choices, choices,
prefix: chalk.magenta('›'), color: 'magenta',
theme: {
prefix: chalk.magenta('›'),
style: { message: chalk.bold.magenta },
},
}); });
let answer; let answer;
if (selection === '__custom__') { if (selection === '__custom__') {
answer = await input({ answer = await ui.input({
message: chalk.bold.magenta('Your answer'), message: 'Your answer',
prefix: chalk.magenta('›'), color: 'magenta',
theme: {
prefix: chalk.magenta('›'),
style: { answer: chalk.white, message: chalk.bold.magenta },
},
}); });
} else { } else {
const idx = letters.indexOf(selection); const idx = letters.indexOf(selection);
answer = capped[idx]; answer = capped[idx];
} }
console.log(''); ui.log('');
return { answer }; return { answer };
}, },
}); });
/**
* Call notification: the question being asked.
*/
export function notifyCall(args = {}) {
emitToolCall(askUserTool.name, clip(JSON.stringify(args.question ?? '')));
}

328
src/tools/bash-exec.js Normal file
View File

@@ -0,0 +1,328 @@
'use strict';
/**
* bash_exec — run a shell command in the workspace after explicit user approval.
*
* Design notes:
* - Runs `bash -c <command>` with cwd pinned to the workspace root, so
* relative paths start there. NOTE: this is convenience, not confinement —
* the shell can still touch absolute paths and escape with `cd ..`, so the
* interactive approval prompt below is the only guard.
* - Child processes run with color and interactivity disabled (see
* NONINTERACTIVE_ENV) and with stdin closed, so a command can never block
* on a terminal prompt.
* - Every command requires interactive approval via a select menu (run once,
* allow for the rest of this session, reject, or reject with a comment),
* using the same Ink/chalk styling as the ask_user tool.
* - A hard timeout kills the whole process group; output is trimmed to the
* last `tail_lines` lines before being returned to the model.
*/
import { spawn } from 'child_process';
import chalk from 'chalk';
import { defineTool, ToolError } from 'llmorbiter';
import { ROOT_DIR } from '../config.js';
import { ui } from '../ui/index.js';
import { renderToolBlock } from '../ui/blocks.js';
import { emitToolCall, emitToolResult, clip } from './notify.js';
const DEFAULT_TAIL_LINES = 100; // trailing lines of output returned to the model
const DEFAULT_TIMEOUT_MS = 30_000; // 30s hard limit per command
const MAX_CAPTURE_CHARS = 1_000_000; // cap raw output kept in memory (~1 MB of text)
/**
* Environment overrides applied to every child process. These disable ANSI
* color and anything that would try to prompt, page, or open an editor, so a
* command run by the agent is fully non-interactive.
*/
const NONINTERACTIVE_ENV = {
// Color
NO_COLOR: '1',
FORCE_COLOR: '0',
CLICOLOR: '0',
CLICOLOR_FORCE: '0',
TERM: 'dumb',
// Non-interactive / automation signals
CI: '1',
DEBIAN_FRONTEND: 'noninteractive',
// Credentials, pagers, editors — never block waiting on the user
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: 'echo',
SSH_ASKPASS: 'echo',
GIT_PAGER: 'cat',
GIT_EDITOR: 'true',
PAGER: 'cat',
// npm / npx
npm_config_color: 'false',
npm_config_yes: 'true',
npm_config_fund: 'false',
npm_config_audit: 'false',
npm_config_progress: 'false',
// Python
PYTHONUNBUFFERED: '1',
PYTHONDONTWRITEBYTECODE: '1',
};
/**
* Keep only the last `maxLines` lines of `text`.
* Returns { text, lines, omitted } where `lines` is the total before trimming
* and `omitted` is how many lines were dropped.
*/
function tailLines(text, maxLines) {
if (!text) return { text: '', lines: 0, omitted: 0 };
const lines = text.replace(/\r\n/g, '\n').split('\n');
// Drop the single trailing empty element produced by a trailing newline.
if (lines.length && lines[lines.length - 1] === '') lines.pop();
const total = lines.length;
if (total <= maxLines) return { text: lines.join('\n'), lines: total, omitted: 0 };
return {
text: lines.slice(total - maxLines).join('\n'),
lines: total,
omitted: total - maxLines,
};
}
// Session-scoped approval: once the user answers "a", every later command runs
// without prompting until fido exits.
let sessionApproved = false;
/**
* Ask the user to approve a command before it is executed.
* Throws ToolError when the user declines or when no TTY is available.
*/
async function requestApproval(command, timeoutMs) {
if (!process.stdin.isTTY) {
throw new ToolError(
'bash_exec requires interactive approval, but no TTY is attached. ' +
'Run fido in an interactive terminal to approve shell commands.'
);
}
ui.log('');
ui.log(renderToolBlock([
chalk.yellow.bold('⚠ Fido wants to run a shell command'),
chalk.dim(`cwd: ${ROOT_DIR}`),
chalk.dim(`timeout: ${timeoutMs} ms`),
chalk.white(command.replace(/\r?\n/g, '\n')),
].join('\n')));
ui.log('');
// A prior "yes for this session" skips the prompt entirely.
if (sessionApproved) {
ui.log(renderToolBlock(chalk.dim('⚡ Auto-approved (allowed for this session).')));
ui.log('');
return;
}
let choice;
try {
choice = await ui.select({
message: 'Approve running this command?',
color: 'yellow',
default: 'Y',
choices: [
{ name: 'Yes — run it once', value: 'Y' },
{ name: 'Yes — allow for the rest of this session', value: 'a' },
{ name: 'No — skip this command', value: 'n' },
{ name: 'No — skip and leave a comment', value: 'c' },
],
});
} catch {
// Ctrl+C / EOF during the prompt → treat as a rejection.
choice = 'n';
}
ui.log('');
if (choice === 'Y') {
return;
}
if (choice === 'a') {
sessionApproved = true;
ui.log(renderToolBlock(chalk.dim('✓ Allowed — shell commands will run without prompting for the rest of this session.')));
ui.log('');
return;
}
if (choice === 'c') {
let comment = '';
try {
comment = (
await ui.input({
message: 'Reason (optional comment)',
color: 'yellow',
})
).trim();
} catch {
// Ctrl+C / EOF during the comment prompt → reject with no comment.
comment = '';
}
ui.log('');
throw new ToolError(
comment
? `Command rejected by the user. Comment: ${comment}`
: 'Command rejected by the user.'
);
}
// 'n' or anything unexpected
throw new ToolError('Command rejected by the user.');
}
/**
* Spawn bash and collect stdout/stderr until the process exits or the timeout
* fires. Resolves (never rejects) with the captured output and exit metadata.
*/
function runCommand(command, timeoutMs) {
return new Promise((resolve) => {
const detached = process.platform !== 'win32';
const child = spawn('bash', ['-c', command], {
cwd: ROOT_DIR,
env: { ...process.env, ...NONINTERACTIVE_ENV },
stdio: ['ignore', 'pipe', 'pipe'],
detached,
windowsHide: true,
});
let stdout = '';
let stderr = '';
let outputTruncated = false; // true if the size cap dropped earlier output
let timedOut = false;
let settled = false;
// Keep the *tail* of the output, not the head. The tool is designed to
// return the last `tail_lines` lines, so once the cap is exceeded we must
// retain the newest text and drop the oldest — capping the head would
// return stale lines from the middle of the stream and lose the real end.
const append = (prev, chunk) => {
const next = prev + chunk.toString('utf8');
if (next.length <= MAX_CAPTURE_CHARS) return next;
outputTruncated = true;
return next.slice(next.length - MAX_CAPTURE_CHARS);
};
child.stdout.on('data', (chunk) => { stdout = append(stdout, chunk); });
child.stderr.on('data', (chunk) => { stderr = append(stderr, chunk); });
// Kill the whole process tree (bash and anything it spawned).
const killTree = () => {
try {
if (detached && child.pid) process.kill(-child.pid, 'SIGKILL');
else child.kill('SIGKILL');
} catch {
try { child.kill('SIGKILL'); } catch { /* already gone */ }
}
};
const timer = setTimeout(() => {
timedOut = true;
killTree();
}, timeoutMs);
const finish = (payload) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(payload);
};
child.on('error', (err) =>
finish({ code: null, signal: null, stdout, stderr, timedOut, outputTruncated, spawnError: err }));
child.on('close', (code, signal) =>
finish({ code, signal, stdout, stderr, timedOut, outputTruncated }));
});
}
export const bashExecTool = defineTool({
name: 'bash_exec',
description:
'Run a bash cmd in ws root, return output. ' +
'No color/interactivity, stdin closed, killed at timeout. ' +
'IMPORTANT: needs explicit user approval — user may decline. ' +
'stdout/stderr trimmed to last `tail_lines` lines.',
parameters: {
type: 'object',
additionalProperties: false,
required: ['command'],
properties: {
command: {
type: 'string',
description: 'Bash cmd to run. Runs via `bash -c`, cwd = ws root.',
},
tail_lines: {
type: 'number',
description: `Trailing stdout/stderr lines to return. Default ${DEFAULT_TAIL_LINES}.`,
},
timeout: {
type: 'number',
description: `Max runtime ms before kill. Default ${DEFAULT_TIMEOUT_MS}.`,
},
},
},
invoke: async ({ command, tail_lines = DEFAULT_TAIL_LINES, timeout = DEFAULT_TIMEOUT_MS }) => {
if (!command || typeof command !== 'string' || !command.trim()) {
throw new ToolError('"command" must be a non-empty string.');
}
if (!Number.isInteger(tail_lines) || tail_lines < 1) {
throw new ToolError(`"tail_lines" must be a positive integer (received ${tail_lines}).`);
}
if (!Number.isInteger(timeout) || timeout < 1) {
throw new ToolError(`"timeout" must be a positive integer of milliseconds (received ${timeout}).`);
}
await requestApproval(command, timeout);
const { code, signal, stdout, stderr, timedOut, outputTruncated, spawnError } = await runCommand(command, timeout);
if (spawnError) {
throw new ToolError(`Failed to launch bash: ${spawnError.message}`);
}
const out = tailLines(stdout, tail_lines);
const err = tailLines(stderr, tail_lines);
const omitted = [];
if (outputTruncated) omitted.push('earlier output (size cap)');
if (out.omitted) omitted.push(`${out.omitted} stdout line(s)`);
if (err.omitted) omitted.push(`${err.omitted} stderr line(s)`);
const result = {
command,
exitCode: code,
timedOut,
stdout: out.text,
stderr: err.text,
};
if (signal) result.signal = signal;
if (omitted.length) {
result.truncated = true;
result.truncationNote = `${omitted.join(' and ')} omitted; showing the last ${tail_lines} lines.`;
}
if (timedOut) {
result.note = `Command exceeded the ${timeout} ms timeout and was killed.`;
}
return result;
},
});
/**
* Call notification: the command flattened to one line, plus a custom timeout
* when one is set. The approval prompt that follows shows the full command.
*/
export function notifyCall(args = {}) {
const { command, timeout } = args;
const oneLine = String(command ?? '').replace(/\s*\n\s*/g, ' ; ');
const extras = Number.isInteger(timeout) && timeout !== DEFAULT_TIMEOUT_MS ? ` timeout=${timeout}` : '';
emitToolCall(bashExecTool.name, clip(`${oneLine}${extras}`));
}
/**
* Result notification: the process exit status (or signal / timeout).
*/
export function notifyResult(result = {}) {
const parts = [];
if (result.exitCode !== undefined && result.exitCode !== null) parts.push(`exit ${result.exitCode}`);
else if (result.signal) parts.push(`signal ${result.signal}`);
if (result.timedOut) parts.push('timed out');
emitToolResult(bashExecTool.name, parts.join(' ') || 'done');
}

177
src/tools/browse-url.js Normal file
View File

@@ -0,0 +1,177 @@
'use strict';
/**
* browse_url — fetch and extract the contents of one or more web pages via
* Tavily, returning the page text (or just the relevant pieces when a `query`
* is supplied).
*
* Design notes:
* - Requires a Tavily API key (TAVILY_API_KEY in .env). The client is created
* lazily and cached, so a missing key surfaces as a clean ToolError only
* when the tool is actually used.
* - This tool reaches the network, so results are untrusted third-party text;
* the model should treat them accordingly.
* - `urls` accepts either a single URL string or an array of them; it is
* normalized to an array before being handed to Tavily's extract().
* - `query` is an optional LLM prompt used to pull only the relevant pieces
* of information out of a page, so the whole page need not be returned.
*/
import { tavily } from '@tavily/core';
import { defineTool, ToolError } from 'llmorbiter';
import { TAVILY_API_KEY } from '../config.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
const DEFAULT_FORMAT = 'text'; // default output format
const FORMATS = ['markdown', 'text'];
const EXTRACT_DEPTHS = ['basic', 'advanced'];
const DEFAULT_EXTRACT_DEPTH = 'basic';
const DEFAULT_CHUNKS_PER_SOURCE = 1;
const MAX_URLS = 20; // hard upper bound to keep requests sane
let client = null;
/** Lazily construct (and cache) the Tavily client, failing cleanly if unset. */
function getClient() {
if (!TAVILY_API_KEY) {
throw new ToolError(
'browse_url requires a Tavily API key. Add TAVILY_API_KEY=... to your .env file.'
);
}
if (!client) {
client = tavily({ apiKey: TAVILY_API_KEY });
}
return client;
}
/** Normalize the `urls` argument to a validated array of http(s) URLs. */
function normalizeUrls(urls) {
let list;
if (typeof urls === 'string') list = [urls];
else if (Array.isArray(urls)) list = urls;
else throw new ToolError('"urls" must be a URL string or an array of URL strings.');
const cleaned = list.map((u) => (typeof u === 'string' ? u.trim() : ''));
if (cleaned.length === 0 || cleaned.some((u) => !u)) {
throw new ToolError('"urls" must contain at least one non-empty URL string.');
}
for (const u of cleaned) {
if (!/^https?:\/\//i.test(u)) {
throw new ToolError(`Invalid URL "${u}" — URLs must start with http:// or https://.`);
}
}
if (cleaned.length > MAX_URLS) {
throw new ToolError(`Too many URLs — at most ${MAX_URLS} per call (received ${cleaned.length}).`);
}
return cleaned;
}
export const browseUrlTool = defineTool({
name: 'browse_url',
description:
'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.',
parameters: {
type: 'object',
additionalProperties: false,
required: ['urls'],
properties: {
urls: {
type: ['string', 'array'],
items: { type: 'string' },
description: 'URL or list of URLs to extract.',
},
query: {
type: 'string',
description:
'Opt prompt describing info to extract from the page(s). ' +
'When set, only relevant content is returned.',
},
chunks_per_source: {
type: 'number',
description: `Content chunks/source when "query" set (1-3). Default ${DEFAULT_CHUNKS_PER_SOURCE}.`,
},
format: {
type: 'string',
enum: FORMATS,
description: 'Output format: "markdown" or "text". Default "text".',
},
extract_depth: {
type: 'string',
enum: EXTRACT_DEPTHS,
description: 'Depth: "basic" (faster) or "advanced" (deeper). Default "basic".',
},
},
},
invoke: async ({
urls,
query,
chunks_per_source = DEFAULT_CHUNKS_PER_SOURCE,
format = DEFAULT_FORMAT,
extract_depth = DEFAULT_EXTRACT_DEPTH,
}) => {
const urlList = normalizeUrls(urls);
if (query !== undefined && (typeof query !== 'string' || !query.trim())) {
throw new ToolError('"query" must be a non-empty string when provided.');
}
if (!Number.isInteger(chunks_per_source) || chunks_per_source < 1) {
throw new ToolError(`"chunks_per_source" must be a positive integer (received ${chunks_per_source}).`);
}
if (!FORMATS.includes(format)) {
throw new ToolError(`"format" must be one of ${FORMATS.join(', ')} (received "${format}").`);
}
if (!EXTRACT_DEPTHS.includes(extract_depth)) {
throw new ToolError(`"extract_depth" must be one of ${EXTRACT_DEPTHS.join(', ')} (received "${extract_depth}").`);
}
const options = { format, extractDepth: extract_depth };
if (query) {
options.query = query;
options.chunksPerSource = chunks_per_source;
}
const response = await getClient().extract(urlList, options);
const results = (response.results || []).map((r) => {
const item = { url: r.url, content: r.rawContent };
if (r.title) item.title = r.title;
return item;
});
const failed = (response.failedResults || []).map((f) => ({
url: f.url,
error: f.error,
}));
const result = { urls: urlList, results };
if (query) result.query = query;
if (failed.length) result.failedResults = failed;
if (response.responseTime !== undefined) result.responseTime = response.responseTime;
return result;
},
});
/**
* Call notification: the URL(s) plus any non-default extract options.
*/
export function notifyCall(args = {}) {
const { urls, query, format, extract_depth } = args;
const list = Array.isArray(urls) ? urls : [urls];
const parts = [clip(list.filter(Boolean).join(', '))];
if (query) parts.push(`query=${clip(JSON.stringify(query))}`);
if (format && format !== DEFAULT_FORMAT) parts.push(`format=${format}`);
if (extract_depth && extract_depth !== DEFAULT_EXTRACT_DEPTH) parts.push(`depth=${extract_depth}`);
emitToolCall(browseUrlTool.name, clip(parts.join(' ')));
}
/**
* Result notification: pages fetched (and any failures) plus the payload size.
*/
export function notifyResult(result = {}) {
const n = Array.isArray(result.results) ? result.results.length : 0;
const failed = Array.isArray(result.failedResults) ? result.failedResults.length : 0;
let detail = plural(n, 'page');
if (failed) detail += `, ${failed} failed`;
emitToolResult(browseUrlTool.name, `${detail} ${formatBytes(resultSize(result))}`);
}

146
src/tools/find-files.js Normal file
View File

@@ -0,0 +1,146 @@
'use strict';
/**
* find_files — recursive, .gitignore-aware search for files and directories by
* name (the name-space sibling of ripgrep, which searches file contents).
*
* Directory traversal lives in src/dir-listing.js (walkTree); glob matching
* lives in src/glob.js (makeGlobMatcher). Result entries mirror list_files:
* { path, type, size } with type 'f'/'d' and size in bytes (null for dirs).
*/
import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js';
import { ROOT_DIR } from '../config.js';
import { walkTree } from '../dir-listing.js';
import { makeGlobMatcher } from '../glob.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
export const findFilesTool = defineTool({
name: 'find_files',
description:
'Find files and/or directories by name glob across ws (like ripgrep, but for names). ' +
'Recurses dirs, honors .gitignore, hides dot-dirs (.git) by default. ' +
'A glob without "/" matches the basename (e.g. "*.js"); with "/" it matches the path. ' +
'Returns entries {path,type,size}: type is "f"/"d", size in bytes (null for dirs). ' +
'type restricts to files ("f") or dirs ("d"); max_results caps the list.',
parameters: {
type: 'object',
additionalProperties: false,
required: ['pattern'],
properties: {
pattern: {
type: 'string',
description: 'Name glob, e.g. "*.js", "test*" or "src/**/*.ts".',
},
path: {
type: 'string',
description: 'Dir to search under, rel to root. Default "."',
},
type: {
type: 'string',
enum: ['f', 'd'],
description: 'Restrict to files ("f") or dirs ("d"). Default both.',
},
includeDotDirs: {
type: 'boolean',
description: 'true = descend into dot-dirs (.git). Default false.',
},
ignore_case: {
type: 'boolean',
description: 'Case-insensitive glob match. Default false.',
},
max_results: {
type: 'number',
description: 'Max entries. Default 100.',
},
},
},
invoke: async ({
pattern,
path: searchPath = '.',
type,
includeDotDirs = false,
ignore_case = false,
max_results = 100,
}) => {
if (typeof pattern !== 'string' || pattern.length === 0) {
throw new ToolError('"pattern" must be a non-empty string.');
}
if (type !== undefined && type !== 'f' && type !== 'd') {
throw new ToolError(`"type" must be "f" or "d" (received ${JSON.stringify(type)}).`);
}
if (!Number.isInteger(max_results) || max_results < 1) {
throw new ToolError(`"max_results" must be a positive integer (received ${max_results}).`);
}
const absTarget = resolveSafe(searchPath);
if (!fs.existsSync(absTarget)) {
throw new ToolError(`Path not found: "${searchPath}"`);
}
// A file target is matched directly; a directory is walked recursively.
const targetStat = fs.statSync(absTarget);
let candidates;
if (targetStat.isDirectory()) {
candidates = walkTree(absTarget, { includeDotDirs });
} else {
const rel = path.relative(ROOT_DIR, absTarget).replace(/\\/g, '/');
candidates = [{ abs: absTarget, rel, type: 'f', size: targetStat.size, name: path.basename(absTarget) }];
}
const matchName = makeGlobMatcher(pattern, { ignoreCase: ignore_case });
const matched = candidates.filter(
(e) => (!type || e.type === type) && matchName(e.rel, e.name),
);
const total = matched.length;
const shown = matched.slice(0, max_results);
const entries = shown.map((e) => ({ path: e.rel, type: e.type, size: e.size }));
const result = {
pattern,
path: searchPath,
type: type ?? null,
ignoreCase: ignore_case,
entries,
returned: entries.length,
total,
};
const truncated = total - entries.length;
if (truncated > 0) {
result.truncated = truncated;
result.truncationNote =
`${truncated} more entr${truncated === 1 ? 'y' : 'ies'} matched but were not shown. ` +
`Refine the pattern or raise max_results to see more.`;
}
return result;
},
});
/**
* Call notification: the name glob plus scope (path) and filters in effect.
*/
export function notifyCall(args = {}) {
const { pattern, path: searchPath, type, includeDotDirs = false, ignore_case = false } = args;
const parts = [JSON.stringify(pattern)];
if (searchPath && searchPath !== '.') parts.push(`path=${JSON.stringify(searchPath)}`);
if (type) parts.push(`type=${type}`);
if (includeDotDirs) parts.push('includeDotDirs');
if (ignore_case) parts.push('ignore_case');
emitToolCall(findFilesTool.name, clip(parts.join(' ')));
}
/**
* Result notification: how many entries came back and the payload size.
*/
export function notifyResult(result = {}) {
const returned = result.returned ?? (Array.isArray(result.entries) ? result.entries.length : 0);
const total = result.total ?? returned;
const count = total > returned
? `${returned} of ${total} entries`
: plural(returned, 'entry', 'entries');
emitToolResult(findFilesTool.name, `${count} ${formatBytes(resultSize(result))}`);
}

View File

@@ -1,33 +1,101 @@
'use strict'; 'use strict';
/** /**
* Tool registry — one module per tool, aggregated here in registration order. * Tool registry \u2014 one module per tool, aggregated here in registration order.
*
* Each tool module also exports a `notifyCall(args)` renderer. Those are
* collected into `toolCallNotifications` (tool name -> renderer) so the UI can
* print a tailored notification for each individual tool call.
*/ */
import { listFilesTool } from './list-files.js'; import { listFilesTool, notifyCall as listFilesNotify, notifyResult as listFilesResult } from './list-files.js';
import { readFileTool } from './read-file.js'; import { findFilesTool, notifyCall as findFilesNotify, notifyResult as findFilesResult } from './find-files.js';
import { patchTool } from './patch.js'; import { readFileTool, notifyCall as readFileNotify, notifyResult as readFileResult } from './read-file.js';
import { mkdirTool } from './mkdir.js'; import { ripgrepTool, notifyCall as ripgrepNotify, notifyResult as ripgrepResult } from './ripgrep.js';
import { rmdirTool } from './rmdir.js'; import { patchTool, notifyCall as patchNotify } from './patch.js';
import { rmTool } from './rm.js'; import { mkdirTool, notifyCall as mkdirNotify } from './mkdir.js';
import { askUserTool } from './ask-user.js'; import { rmdirTool, notifyCall as rmdirNotify } from './rmdir.js';
import { rmTool, notifyCall as rmNotify } from './rm.js';
import { bashExecTool, notifyCall as bashExecNotify, notifyResult as bashExecResult } from './bash-exec.js';
import { javascriptExecuteTool, notifyCall as javascriptExecuteNotify, notifyResult as javascriptExecuteResult } from './javascript.js';
import { websearchTool, notifyCall as websearchNotify, notifyResult as websearchResult } from './websearch.js';
import { browseUrlTool, notifyCall as browseUrlNotify, notifyResult as browseUrlResult } from './browse-url.js';
import { askUserTool, notifyCall as askUserNotify } from './ask-user.js';
import { askExpertTool, notifyCall as askExpertNotify } from './ask-expert.js';
export { export {
listFilesTool, listFilesTool,
findFilesTool,
readFileTool, readFileTool,
ripgrepTool,
patchTool, patchTool,
mkdirTool, mkdirTool,
rmdirTool, rmdirTool,
rmTool, rmTool,
bashExecTool,
javascriptExecuteTool,
websearchTool,
browseUrlTool,
askUserTool, askUserTool,
askExpertTool,
}; };
export const tools = [ export const tools = [
listFilesTool, listFilesTool,
findFilesTool,
readFileTool, readFileTool,
ripgrepTool,
patchTool, patchTool,
mkdirTool, mkdirTool,
rmdirTool, rmdirTool,
rmTool, rmTool,
bashExecTool,
javascriptExecuteTool,
websearchTool,
browseUrlTool,
askUserTool, askUserTool,
askExpertTool,
]; ];
/**
* Per-tool call-notification renderers, keyed by tool name. The UI looks up the
* renderer for the tool being invoked so each tool gets an individual
* notification; tools without an entry fall back to a generic renderer.
*/
export const toolCallNotifications = Object.fromEntries(
[
[listFilesTool, listFilesNotify],
[findFilesTool, findFilesNotify],
[readFileTool, readFileNotify],
[ripgrepTool, ripgrepNotify],
[patchTool, patchNotify],
[mkdirTool, mkdirNotify],
[rmdirTool, rmdirNotify],
[rmTool, rmNotify],
[bashExecTool, bashExecNotify],
[javascriptExecuteTool, javascriptExecuteNotify],
[websearchTool, websearchNotify],
[browseUrlTool, browseUrlNotify],
[askUserTool, askUserNotify],
[askExpertTool, askExpertNotify],
].map(([tool, notify]) => [tool.name, notify]),
);
/**
* Per-tool result-notification renderers, keyed by tool name. The UI looks up
* the renderer for the tool that just finished so each tool gets a tailored
* completion notification (payload size, exit code, and so on); tools without
* an entry fall back to a generic size renderer.
*/
export const toolResultNotifications = Object.fromEntries(
[
[listFilesTool, listFilesResult],
[findFilesTool, findFilesResult],
[readFileTool, readFileResult],
[ripgrepTool, ripgrepResult],
[bashExecTool, bashExecResult],
[javascriptExecuteTool, javascriptExecuteResult],
[websearchTool, websearchResult],
[browseUrlTool, browseUrlResult],
].map(([tool, notify]) => [tool.name, notify]),
);

View File

@@ -0,0 +1,211 @@
'use strict';
/**
* javascript_execute worker — the half of the tool that actually runs code.
*
* Lives in its own file (loaded via `new Worker(path)`) so it is executed in a
* fresh V8 isolate. The parent terminates this thread when the hard timeout
* fires, which is the only reliable way to stop runaway code because NodeVM
* does not honor vm2's own `timeout` (see src/tools/javascript.js).
*
* Protocol: the parent sends nothing; everything arrives via `workerData`.
* Exactly one `postMessage` report is sent back, then the thread exits.
* Never throws past the message boundary — errors are reported in the payload
* so the tool layer can surface them as a normal ToolError.
*/
import { parentPort, workerData } from 'worker_threads';
import { NodeVM } from 'vm2';
const { code, root, vmTimeout, bufferLimit, network } = workerData;
/**
* Network surface exposed to the sandbox when the user approved it.
*
* These are injected as sandbox GLOBALS, not via `require.builtin`. That
* distinction matters: vm2's builtin allowlist only governs `require()` of
* Node core modules (`fs`, `path`, …), and `fetch` is not one of them — it is
* a global on the host realm. Listing it in `require.builtin` therefore had
* no effect at all; a snippet still saw `typeof fetch === "undefined"`.
* Copying the host references into `sandbox` is what actually makes them
* reachable.
*
* Note this still permits SSRF (localhost, internal services, cloud metadata)
* — which is exactly why the parent gates it behind explicit approval.
*/
const NETWORK_GLOBALS = network
? {
fetch: globalThis.fetch,
Headers: globalThis.Headers,
Request: globalThis.Request,
Response: globalThis.Response,
FormData: globalThis.FormData,
AbortController: globalThis.AbortController,
AbortSignal: globalThis.AbortSignal,
URL: globalThis.URL,
URLSearchParams: globalThis.URLSearchParams,
Blob: globalThis.Blob,
File: globalThis.File,
TextEncoder: globalThis.TextEncoder,
TextDecoder: globalThis.TextDecoder,
// An AbortController the snippet can reach lets a hung request be
// cancelled, so the parent timeout can actually free the worker.
setTimeout,
clearTimeout,
}
: {};
// Collected console output. Each entry is rendered to a string here, inside
// the sandbox host, so we never hold a live proxy reference across the boundary.
const lines = [];
const push = (prefix) => (...args) => {
let text;
try {
text = args
.map((a) => {
if (typeof a === 'string') return a;
try { return JSON.stringify(a); } catch { return String(a); }
})
.join(' ');
} catch {
text = String(args);
}
lines.push(prefix + text);
};
const sandboxConsole = {
log: push(''),
info: push(''),
debug: push(''),
warn: push('warn: '),
error: push('error: '),
trace: push('trace: '),
};
// Cap on elements/keys copied out of a single sandbox array/object.
const MAX_ITEMS = 200;
/**
* Recursive, depth- and width-limited JSON-safe copy of a sandbox value.
*
* Two subtleties drive this implementation:
* - vm2 hands back *host proxies*, not plain values. A proxied array passes
* `Array.isArray` but is not a real `Array`, so handing it to
* `postMessage` fails with "[object Array] could not be cloned". We must
* copy into arrays/objects WE allocate on the host side (`new Array()` /
* `{}`), never reuse the proxy as a container. Copying via `slice().map()`
* still returns a proxied array — hence the explicit loop below.
* - Getters or revoked proxies can throw on property access, so every read is
* guarded and degrades to a placeholder instead of killing the report.
*/
function sanitize(value, depth = 0) {
if (value === null) return null;
const t = typeof value;
if (t === 'undefined') return { type: 'undefined' };
if (t === 'boolean' || t === 'string') return value;
if (t === 'number') return Number.isFinite(value) ? value : String(value);
if (t === 'bigint') return `${value.toString()}n`;
if (t === 'symbol') return String(value);
if (t === 'function') return { type: 'function' };
if (depth >= 4) return { type: t, note: 'too deeply nested to serialize' };
if (Array.isArray(value)) {
// Build a REAL host array; the source is a proxy.
const arr = new Array();
const len = Math.min(typeof value.length === 'number' ? value.length : 0, MAX_ITEMS);
for (let i = 0; i < len; i++) {
try { arr[i] = sanitize(value[i], depth + 1); }
catch { arr[i] = { type: 'unreadable' }; }
}
return arr;
}
if (t === 'object') {
let keys;
try { keys = Object.keys(value); } catch { return { type: 'object', note: 'not serializable' }; }
// Plain host object — never the proxy itself.
const out = {};
for (const k of keys.slice(0, MAX_ITEMS)) {
try { out[k] = sanitize(value[k], depth + 1); }
catch { out[k] = { type: 'unreadable' }; }
}
return out;
}
return { type: t };
}
function report(payload) {
try {
parentPort.postMessage(payload);
} catch {
// Structured-clone of the payload failed (e.g. a value slipped past
// sanitize). Fall back to a minimal, always-cloneable report.
try {
parentPort.postMessage({ error: 'Sandbox produced a result that could not be serialized.' });
} catch { /* thread is going away anyway */ }
}
}
// Wrapped in an async IIFE so `await` is available for draining a returned
// promise; a bare top-level `try` cannot contain `await` in an ESM module.
void (async () => {
try {
const vm = new NodeVM({
console: 'off', // we install our own console into the sandbox instead
sandbox: { console: sandboxConsole, ...NETWORK_GLOBALS },
require: {
root,
external: false,
builtin: [],
},
nesting: false,
bufferAllocLimit: bufferLimit,
timeout: vmTimeout, // advisory; the parent's hard timeout is the real guard
});
const started = Date.now();
const done = () => ({
stdout: lines.join('\n'),
consoleLineCount: lines.length,
durationMs: Date.now() - started,
});
let value;
let failed = null;
try {
value = vm.run(code, 'javascript_execute.js');
// `vm.run` returns as soon as the synchronous body finishes, which for
// a top-level async IIFE is BEFORE its promise settles. Without the
// drain below, a snippet like
// (async () => { module.exports = await fetch(url); })()
// reported an empty result and any console output was lost, because
// this thread exited before the request resolved. Awaiting a
// thenable lets it finish; the parent's hard timeout still bounds
// this, so a never-resolving promise cannot wedge the worker.
if (value && typeof value.then === 'function') {
value = await Promise.race([
value,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Async snippet did not settle before the timeout.')), vmTimeout)
),
]);
}
} catch (err) {
// A thrown error is a normal outcome — report it, don't crash the thread.
failed = {
error: (err && err.message) || String(err),
errorName: (err && err.name) || 'Error',
stack:
err && typeof err.stack === 'string'
? err.stack.split('\n').slice(0, 12).join('\n')
: undefined,
};
}
if (failed) {
report({ ...done(), ...failed });
} else {
report({ result: sanitize(value), ...done() });
}
} catch (err) {
// Failed to construct the VM (e.g. bad root, vm2 internal failure).
report({ error: (err && err.message) || String(err), errorName: (err && err.name) || 'Error' });
}
})();

315
src/tools/javascript.js Normal file
View File

@@ -0,0 +1,315 @@
/**
* javascript_execute — run a JavaScript snippet in a vm2 sandbox and return
* its result, console output, or thrown error.
*
* Design notes:
* - The snippet runs in a worker thread, NOT inline. NodeVM does not honor
* vm2's own `timeout` option (documented upstream: "Timeout is not
* effective for NodeVM"), so `while (true) {}` would hang the whole REPL
* forever. The parent enforces a hard wall-clock timeout and terminates
* the worker; that is the only reliable stop. Measured: a snippet with
* `while(true){}` is killed at timeout + ~1 ms.
* - Inside the worker, `require` is restricted: `external: false`,
* `builtin: []`, `root` = workspace. So a snippet can pull in workspace
* files but gets no host modules, no node_modules, no `process`/`fs`.
* - `console` is redirected into a buffer and returned as `stdout` instead
* of writing into the real terminal. `log/info/warn` are plain lines,
* `error`/`debug` are prefixed, matching how a terminal would show them.
* - Return values are made JSON-safe INSIDE the worker before crossing the
* postMessage boundary. vm2 hands back Proxy objects, and structured-clone
* rejects those ("[object Array] could not be cloned"), so sanitize()
* rebuilds plain host arrays/objects. Functions degrade to {type:'function'}.
* - Network access (fetch et al.) is OFF by default and only injected as
* sandbox globals when the user approved it for this call. It still
* permits SSRF, which is why approval is required.
*
* @see javascript-worker.js for the sandbox side.
*/
'use strict';
/**
* javascript_execute — run a snippet inside a vm2 sandbox and return its result.
*
* Why a worker thread:
* vm2's own `timeout` option is NOT reliable for NodeVM (the library's docs
* say so explicitly: "Timeout is not effective for NodeVM so it is not
* immune to while (true) {} or similar evil"). We confirmed this against the
* installed vm2: a NodeVM with `timeout: 500` happily ran `while(true){}`.
* A model that hands us such a snippet would wedge the whole REPL forever.
* So the snippet runs in a `worker_threads` worker and the parent enforces a
* hard wall-clock timeout by terminating the worker. The worker timeout is
* the only reliable guard; the vm2 timeout is a best-effort inner limit.
*
* Sandbox posture (deliberately tight — this is agent-authored code, not a
* general untrusted-code runner):
* - require.root = workspace root, so local project files can be loaded.
* - require.external = false, require.builtin = [] — no host modules, no
* `node_modules` packages. Anything outside the workspace is unreachable.
* - nesting = false — a snippet cannot build a NodeVM and escape.
* - bufferAllocLimit caps a single Buffer allocation (DoS defense).
* - console is redirected into a buffer that is returned as `stdout`, so a
* snippet never writes to the user's terminal directly.
*
* Results are serialized INSIDE the sandbox, before crossing the worker
* boundary: vm2 hands back host proxies for objects/arrays/functions, and
* structured-cloning those across threads is both lossy and an escape surface.
* Only JSON-safe primitives and objects are allowed out.
*/
import { Worker } from 'worker_threads';
import { fileURLToPath } from 'url';
import chalk from 'chalk';
import { defineTool, ToolError } from 'llmorbiter';
import { ROOT_DIR } from '../config.js';
import { ui } from '../ui/index.js';
import { renderToolBlock } from '../ui/blocks.js';
import { emitToolCall, emitToolResult, clip, plural } from './notify.js';
const DEFAULT_TIMEOUT_MS = 5_000; // hard wall-clock limit enforced by the parent
const MAX_TIMEOUT_MS = 60_000; // never allow an absurdly long run
const DEFAULT_MAX_OUTPUT = 20_000; // chars of captured console output returned
const DEFAULT_BUFFER_LIMIT = 32 * 1024 * 1024; // 32 MiB per Buffer.alloc
const VM_TIMEOUT_MS = 2_000; // best-effort inner vm2 timeout (advisory)
// Path of the worker that actually constructs and drives the NodeVM.
const WORKER_PATH = fileURLToPath(new URL('./javascript-worker.js', import.meta.url));
/**
* Run one snippet in a worker and resolve with the worker's report.
* Resolves (never rejects) — failures are reported through the payload.
*/
function runInWorker(code, timeoutMs, network) {
return new Promise((resolve) => {
let settled = false;
const worker = new Worker(WORKER_PATH, {
workerData: {
code,
root: ROOT_DIR,
vmTimeout: Math.min(timeoutMs, VM_TIMEOUT_MS),
bufferLimit: DEFAULT_BUFFER_LIMIT,
network,
},
});
const finish = (payload) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { worker.terminate(); } catch { /* already gone */ }
resolve(payload);
};
const timer = setTimeout(() => {
finish({ timedOut: true, timeoutMs });
}, timeoutMs);
worker.on('message', (msg) => finish(msg));
worker.on('error', (err) => finish({ error: err.message, errorName: err.name }));
worker.on('exit', (code) => {
finish({
error: `Sandbox worker exited unexpectedly (code ${code}).`,
errorName: 'WorkerExit',
});
});
});
}
// Session-scoped network approval, mirroring bash_exec: one "a" and every
// later javascript_execute call with allow_network skips the prompt.
let networkApproved = false;
/**
* Ask the user before handing the sandbox a `fetch`.
*
* Network access from sandboxed model-written code is a genuine escalation:
* it can reach localhost, internal services and cloud metadata endpoints, so
* it can be used to exfiltrate workspace contents. The snippet is shown, the
* user decides, and a rejection is a normal ToolError rather than a crash.
*/
async function requestNetworkApproval() {
if (networkApproved) {
ui.log(renderToolBlock(chalk.dim('⚡ Network access auto-approved (allowed for this session).')));
return true;
}
if (!process.stdin.isTTY) {
throw new ToolError(
'javascript_execute(allow_network: true) requires interactive approval, but no TTY is attached. ' +
'Re-run without allow_network, or run fido in an interactive terminal.'
);
}
ui.log('');
ui.log(renderToolBlock([
chalk.yellow.bold('⚠ Fido wants to give the sandbox network access'),
chalk.dim('fetch() can reach localhost, internal services and cloud metadata endpoints.'),
chalk.dim('The snippet below would be able to make requests.'),
].join('\n')));
ui.log('');
let choice;
try {
choice = await ui.select({
message: 'Allow network access for this snippet?',
color: 'yellow',
default: 'n',
choices: [
{ name: 'Yes — allow for this session', value: 'a' },
{ name: 'Yes — allow this call only', value: 'Y' },
{ name: 'No — run without network', value: 'n' },
],
});
} catch {
choice = 'n'; // Ctrl+C / EOF → treat as a rejection
}
ui.log('');
if (choice === 'a') {
networkApproved = true;
ui.log(renderToolBlock(chalk.dim('✓ Network access allowed for the rest of this session.')));
ui.log('');
return true;
}
if (choice === 'Y') {
ui.log(renderToolBlock(chalk.dim('✓ Network access allowed for this call.')));
ui.log('');
return true;
}
// The user declined. Run the snippet anyway, just offline.
ui.log(renderToolBlock(chalk.dim('✗ Running without network — fetch() will not be available.')));
ui.log('');
return false;
}
export const javascriptExecuteTool = defineTool({
name: 'javascript_execute',
description:
'Run JavaScript in a vm2 sandbox and return the result. ' +
'Runs in a worker thread, killed at timeout (a while(true){} is terminated, it cannot hang fido). ' +
'Return a value by assigning `module.exports` (sync or a promise). ' +
'No host modules, no node_modules, no process/fs; only workspace files via require. ' +
'console.* is captured and returned as stdout (error/debug prefixed). ' +
'Return values are serialized to JSON-safe form; functions become {type:"function"}, depth>4 summarized. ' +
'For async: `module.exports = (async () => { ... })()` — assign the PROMISE, not a bare IIFE call. ' +
'fetch/HTTP is OFF unless allow_network=true, which needs explicit user approval ' +
'(the user may decline, then the snippet runs offline).',
parameters: {
type: 'object',
additionalProperties: false,
required: ['code'],
properties: {
code: {
type: 'string',
description: 'JavaScript source to run in a CommonJS wrapper. Set `module.exports` to return a value; ' +
'for async code assign the PROMISE, e.g. `module.exports = (async () => { ... })()`.',
},
timeout: {
type: 'number',
description: `Hard wall-clock limit in ms; the sandbox is killed past it. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`,
},
max_output: {
type: 'number',
description: `Max chars of captured console output to return. Default ${DEFAULT_MAX_OUTPUT}.`,
},
allow_network: {
type: 'boolean',
description:
'Expose fetch() and the WHATWG network classes in the sandbox. ' +
'Default false (offline). Needs explicit user approval — the user may decline, ' +
'in which case the snippet runs without network access. ' +
'Warning: network access also allows reaching localhost, internal services and cloud metadata endpoints.',
},
},
},
invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT, allow_network = false }) => {
if (typeof code !== 'string' || !code.trim()) {
throw new ToolError('"code" must be a non-empty string.');
}
if (!Number.isInteger(timeout) || timeout < 1) {
throw new ToolError(`"timeout" must be a positive integer (received ${timeout}).`);
}
const hardTimeout = Math.min(timeout, MAX_TIMEOUT_MS);
if (!Number.isInteger(max_output) || max_output < 1) {
throw new ToolError(`"max_output" must be a positive integer (received ${max_output}).`);
}
// Network access is opt-in and gated on explicit user approval. A
// declined prompt is not an error: the snippet just runs offline.
let network = false;
if (allow_network === true) {
network = await requestNetworkApproval();
} else if (allow_network !== undefined && typeof allow_network !== 'boolean') {
throw new ToolError(`"allow_network" must be a boolean (received ${allow_network}).`);
}
const report = await runInWorker(code, hardTimeout, network);
if (report.timedOut) {
return {
code,
timedOut: true,
timeoutMs: report.timeoutMs,
note: `Sandbox exceeded the ${report.timeoutMs} ms timeout and was killed. ` +
'Infinite loops and blocking calls are the usual cause.',
};
}
if (report.error) {
throw new ToolError(
`Sandbox failed: ${report.error}` +
(report.errorName && report.errorName !== 'Error' ? ` (${report.errorName})` : '')
);
}
// Cap the captured output.
let stdout = report.stdout ?? '';
let truncated = false;
if (stdout.length > max_output) {
stdout = stdout.slice(0, max_output);
truncated = true;
}
const result = {
code,
result: report.result,
};
if (stdout) result.stdout = stdout;
if (truncated) {
result.truncated = true;
result.truncationNote = `Console output longer than ${max_output} chars was capped.`;
}
if (report.consoleLineCount !== undefined) result.consoleLines = report.consoleLineCount;
if (report.durationMs !== undefined) result.durationMs = report.durationMs;
return result;
},
});
/**
* Call notification: a one-line preview of the snippet plus a custom timeout.
*/
export function notifyCall(args = {}) {
const { code, timeout, allow_network = false } = args;
const oneLine = String(code ?? '').replace(/\s*\n\s*/g, ' ; ');
const extras = [];
if (Number.isInteger(timeout) && timeout !== DEFAULT_TIMEOUT_MS) extras.push(`timeout=${timeout}`);
if (allow_network) extras.push('network');
emitToolCall(javascriptExecuteTool.name, clip(`${oneLine}${extras.length ? ' ' + extras.join(' ') : ''}`));
}
/**
* Result notification: duration, the type/shape of the return value, and
* whether the sandbox timed out.
*/
export function notifyResult(result = {}) {
const parts = [];
if (result.timedOut) parts.push('timed out');
if (result.durationMs !== undefined) parts.push(`${result.durationMs} ms`);
if (result.stdout) parts.push(plural((result.stdout.match(/\n/g) || []).length + 1, 'log line'));
if (!result.timedOut && result.result !== undefined) {
const t = result.result === null ? 'null' : Array.isArray(result.result) ? 'array' : typeof result.result;
parts.push(`→ ${t}`);
}
emitToolResult(javascriptExecuteTool.name, parts.join(' ') || 'done');
}

View File

@@ -4,14 +4,15 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { listDir } from '../dir-listing.js'; import { listDir } from '../dir-listing.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
export const listFilesTool = defineTool({ export const listFilesTool = defineTool({
name: 'list_files', name: 'list_files',
description: description:
'List the immediate contents of a directory within the workspace (non-recursive by default). ' + 'List immediate contents of a workspace dir (non-recursive default). ' +
'Entries matching .gitignore patterns (and node_modules/) are excluded automatically. ' + 'Honors .gitignore; node_modules/ skipped. ' +
'Set recursive=true to walk the full subtree. ' + 'Dot-dirs (".git") hidden unless includeDotDirs=true. recursive=true walks subtree. ' +
'Use seek/limit for pagination. Paths are relative to the workspace root.', 'seek/limit paginate. Paths rel to root.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -19,23 +20,27 @@ export const listFilesTool = defineTool({
properties: { properties: {
dirname: { dirname: {
type: 'string', type: 'string',
description: 'Directory path relative to workspace root. Use "." for root.', description: 'Dir path rel to root. "." = root.',
}, },
recursive: { recursive: {
type: 'boolean', type: 'boolean',
description: 'If true, walk the full subtree. Default: false (immediate children only).', description: 'true = walk subtree. Default false.',
}, },
seek: { seek: {
type: 'number', type: 'number',
description: 'Number of entries to skip (for pagination). Default: 0.', description: 'Entries to skip (paging). Default 0.',
}, },
limit: { limit: {
type: 'number', type: 'number',
description: 'Maximum number of entries to return. Default: 50.', description: 'Max entries. Default 50.',
},
includeDotDirs: {
type: 'boolean',
description: 'true = include dot-dirs (.git). Default false.',
}, },
}, },
}, },
invoke: async ({ dirname, recursive = false, seek = 0, limit = 50 }) => { invoke: async ({ dirname, recursive = false, seek = 0, limit = 50, includeDotDirs = false }) => {
if (!Number.isInteger(limit) || limit < 1) { if (!Number.isInteger(limit) || limit < 1) {
throw new ToolError(`"limit" must be a positive integer (received ${limit}).`); throw new ToolError(`"limit" must be a positive integer (received ${limit}).`);
} }
@@ -49,7 +54,7 @@ export const listFilesTool = defineTool({
if (!fs.statSync(absDir).isDirectory()) { if (!fs.statSync(absDir).isDirectory()) {
throw new ToolError(`"${dirname}" is not a directory.`); throw new ToolError(`"${dirname}" is not a directory.`);
} }
const { entries, total, truncated } = listDir(absDir, { seek, limit, recursive }); const { entries, total, truncated } = listDir(absDir, { seek, limit, recursive, includeDotDirs });
const result = { dirname, recursive, entries, total, returned: entries.length, seek }; const result = { dirname, recursive, entries, total, returned: entries.length, seek };
if (truncated > 0) { if (truncated > 0) {
result.truncated = truncated; result.truncated = truncated;
@@ -58,3 +63,27 @@ export const listFilesTool = defineTool({
return result; return result;
}, },
}); });
/**
* Call notification: the target directory plus any traversal flags in effect.
*/
export function notifyCall(args = {}) {
const { dirname = '.', recursive = false, includeDotDirs = false } = args;
const flags = [];
if (recursive) flags.push('recursive');
if (includeDotDirs) flags.push('includeDotDirs');
const detail = `${JSON.stringify(dirname)}${flags.length ? ' ' + flags.join(' ') : ''}`;
emitToolCall(listFilesTool.name, clip(detail));
}
/**
* Result notification: how many entries came back and the payload size.
*/
export function notifyResult(result = {}) {
const returned = result.returned ?? (Array.isArray(result.entries) ? result.entries.length : 0);
const total = result.total ?? returned;
const count = total > returned
? `${returned} of ${total} entries`
: plural(returned, 'entry', 'entries');
emitToolResult(listFilesTool.name, `${count} ${formatBytes(resultSize(result))}`);
}

View File

@@ -4,10 +4,11 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js'; import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
export const mkdirTool = defineTool({ export const mkdirTool = defineTool({
name: 'mkdir', name: 'mkdir',
description: 'Create a directory in the workspace (automatically recursive if parent directories do not exist).', description: 'Make a dir in ws (recursive if parents missing).',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -15,7 +16,7 @@ export const mkdirTool = defineTool({
properties: { properties: {
dirname: { dirname: {
type: 'string', type: 'string',
description: 'Directory path relative to workspace root.', description: 'Dir path rel to root.',
}, },
}, },
}, },
@@ -32,3 +33,10 @@ export const mkdirTool = defineTool({
return { message: `Directory created: "${dirname}"`, created: true }; return { message: `Directory created: "${dirname}"`, created: true };
}, },
}); });
/**
* Call notification: the directory to create.
*/
export function notifyCall(args = {}) {
emitToolCall(mkdirTool.name, clip(JSON.stringify(args.dirname ?? '')));
}

94
src/tools/notify.js Normal file
View File

@@ -0,0 +1,94 @@
'use strict';
/**
* Shared helpers for per-tool call notifications.
*
* Every tool module exports a `notifyCall(args)` renderer that prints a
* tailored notification when the model invokes that tool. These helpers keep
* the styling consistent across tools and provide the generic fallback used
* for any tool that does not supply its own renderer.
*
* Notifications are emitted through the UI store (Ink), not `console`, so they
* appear in the transcript above the live prompt.
*/
import chalk from 'chalk';
import { ui } from '../ui/index.js';
import { renderToolBlock } from '../ui/blocks.js';
// Longest argument string echoed in a notification before it is elided.
const MAX_ARG_LEN = 120;
/** Truncate `text` to at most `max` characters, appending an ellipsis. */
export function clip(text, max = MAX_ARG_LEN) {
const str = String(text ?? '');
return str.length > max ? str.slice(0, max - 3) + '...' : str;
}
/**
* Print the one-line tool-call notification:
* "\u2699 Tool: <name>(<detail>)"
*/
export function emitToolCall(name, detail = '') {
ui.log(renderToolBlock(chalk.cyan(`\u2699 Tool: ${chalk.bold(name)}(${detail})`)));
}
/**
* Generic renderer \u2014 serialize the args as JSON and emit the notification.
* Used as the fallback for tools that do not supply their own `notifyCall`.
*/
export function printToolCall(name, args) {
emitToolCall(name, clip(JSON.stringify(args)));
}
/**
* Print the one-line tool-completion notification, the counterpart to
* `emitToolCall`:
* " \u2713 <name> <detail>"
* Emitted when a tool finishes (see `onToolResult`) so every call is bracketed
* by a "started" and a "finished" line.
*/
export function emitToolResult(name, detail = '') {
const suffix = detail ? ' ' + detail : '';
ui.log(renderToolBlock(chalk.green(`\u2713 ${chalk.bold(name)}${suffix}`)));
}
/**
* Format a byte count for display. The requested unit is kilobytes; small
* payloads fall back to bytes (so a tiny result never reads as "0.0 KB") and
* large ones roll up to megabytes.
*/
export function formatBytes(bytes) {
const n = Math.max(0, Number(bytes) || 0);
if (n < 1024) return `${n} B`;
const kb = n / 1024;
return kb < 1024 ? `${kb.toFixed(1)} KB` : `${(kb / 1024).toFixed(1)} MB`;
}
/**
* Byte length of a tool result as it is handed back to the model (the
* JSON-serialized payload). Used for the size shown in result notifications.
*/
export function resultSize(value) {
let str;
try {
str = typeof value === 'string' ? value : JSON.stringify(value ?? '');
} catch {
str = String(value ?? '');
}
return Buffer.byteLength(str, 'utf8');
}
/** Render "1 entry" / "2 entries" (or a custom plural form). */
export function plural(n, singular, pluralForm) {
return `${n} ${n === 1 ? singular : (pluralForm ?? (singular + 's'))}`;
}
/**
* Generic result renderer - emit a completion notification carrying the payload
* size. Used as the fallback for tools that do not supply their own
* `notifyResult`.
*/
export function emitGenericToolResult(name, result) {
emitToolResult(name, formatBytes(resultSize(result)));
}

View File

@@ -5,6 +5,7 @@ import path from 'path';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js'; import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
// ─── Patch executor ──────────────────────────────────────────────────────────── // ─── Patch executor ────────────────────────────────────────────────────────────
@@ -165,12 +166,12 @@ export function applyOperations(operations) {
export const patchTool = defineTool({ export const patchTool = defineTool({
name: 'patch', name: 'patch',
description: `Apply one or more patch operations to files in the workspace. description: `Apply one or more edit ops to files.
Each operation has: Each op:
- file: (required) relative path to target file - file: (req) rel path to target
- search: string to be replaced (exact match). If null, creating/overwriting the entire file (with automatic directory creation). - search: exact text to replace; null = create/overwrite whole file (dirs auto-created)
- replace: string to write - replace: text to write
- skiplines: (optional) line number offset to skip before matching (0-indexed) if there are multiple occurrences, since patch replaces the first hit. - skiplines: (opt) 0-idx offset to skip before matching; use when search text occurs multiple times (patch replaces first hit)
Example create file: Example create file:
[{"file": "src/utils.js", "search": null, "replace": "export const x = 1;\\n", "skiplines": null}] [{"file": "src/utils.js", "search": null, "replace": "export const x = 1;\\n", "skiplines": null}]
@@ -184,7 +185,7 @@ Example search/replace:
properties: { properties: {
operations: { operations: {
type: 'array', type: 'array',
description: 'List of patch operations to apply sequentially.', description: 'Ops to apply in order.',
items: { items: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -192,19 +193,19 @@ Example search/replace:
properties: { properties: {
file: { file: {
type: 'string', type: 'string',
description: 'Relative path to file.', description: 'Rel path.',
}, },
search: { search: {
type: ['string', 'null'], type: ['string', 'null'],
description: 'String to be replaced (exact match). If null, writes/creates the entire file.', description: 'Exact text to replace. null = write/create whole file.',
}, },
replace: { replace: {
type: 'string', type: 'string',
description: 'String to write.', description: 'Text to write.',
}, },
skiplines: { skiplines: {
type: ['number', 'null'], type: ['number', 'null'],
description: 'Optional line number offset to skip before searching for search text.', description: 'Opt 0-idx offset to skip before matching search.',
}, },
}, },
}, },
@@ -222,3 +223,14 @@ Example search/replace:
return result; return result;
}, },
}); });
/**
* Call notification: how many operations and which files they touch.
*/
export function notifyCall(args = {}) {
const ops = Array.isArray(args.operations) ? args.operations : [];
const files = [...new Set(ops.map((op) => op?.file).filter(Boolean))];
const n = ops.length;
const detail = `${n} op${n === 1 ? '' : 's'} \u2192 ${files.join(', ')}`;
emitToolCall(patchTool.name, clip(detail));
}

View File

@@ -3,12 +3,14 @@
import fs from 'fs'; import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { splitLines } from '../lines.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
export const readFileTool = defineTool({ export const readFileTool = defineTool({
name: 'read_file', name: 'read_file',
description: description:
'Read a file from the workspace. Use seek/limit to read in chunks. ' + 'Read a workspace file. seek/limit chunk it. ' +
'Returns truncation info when the file is larger than the limit.', 'Returns truncation info if file bigger than limit.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -16,15 +18,15 @@ export const readFileTool = defineTool({
properties: { properties: {
filename: { filename: {
type: 'string', type: 'string',
description: 'File path relative to workspace root.', description: 'File path rel to root.',
}, },
seek: { seek: {
type: 'number', type: 'number',
description: 'Line number to start reading from (0-indexed). Default: 0.', description: 'Start line, 0-idx. Default 0.',
}, },
limit: { limit: {
type: 'number', type: 'number',
description: 'Maximum number of lines to return. Default: 200.', description: 'Max lines. Default 200.',
}, },
}, },
}, },
@@ -36,7 +38,7 @@ export const readFileTool = defineTool({
if (!fs.statSync(absPath).isFile()) { if (!fs.statSync(absPath).isFile()) {
throw new ToolError(`"${filename}" is not a file.`); throw new ToolError(`"${filename}" is not a file.`);
} }
const allLines = fs.readFileSync(absPath, 'utf8').split('\n'); const allLines = splitLines(fs.readFileSync(absPath, 'utf8'));
const total = allLines.length; const total = allLines.length;
const slice = allLines.slice(seek, seek + limit); const slice = allLines.slice(seek, seek + limit);
const truncated = total - (seek + slice.length); const truncated = total - (seek + slice.length);
@@ -55,3 +57,25 @@ export const readFileTool = defineTool({
return result; return result;
}, },
}); });
/**
* Call notification: the file plus any non-default seek/limit windowing.
*/
export function notifyCall(args = {}) {
const { filename, seek = 0, limit = 200 } = args;
const extras = [];
if (seek) extras.push(`seek=${seek}`);
if (limit !== 200) extras.push(`limit=${limit}`);
const detail = `${JSON.stringify(filename)}${extras.length ? ' ' + extras.join(' ') : ''}`;
emitToolCall(readFileTool.name, clip(detail));
}
/**
* Result notification: lines returned and the payload size.
*/
export function notifyResult(result = {}) {
const returned = result.returnedLines ?? 0;
const total = result.totalLines ?? returned;
const lines = total > returned ? `${returned} of ${total} lines` : plural(returned, 'line');
emitToolResult(readFileTool.name, `${lines} ${formatBytes(resultSize(result))}`);
}

242
src/tools/ripgrep.js Normal file
View File

@@ -0,0 +1,242 @@
'use strict';
/**
* ripgrep — recursive, .gitignore-aware content search (pure JS, no binaries).
*
* One tool per file: this module owns the search tool plus its small helpers.
* Directory traversal lives in src/dir-listing.js (walkFiles).
*/
import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js';
import { ROOT_DIR } from '../config.js';
import { walkFiles } from '../dir-listing.js';
import { escapeRegExp, makeGlobMatcher } from '../glob.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
// Files larger than this are skipped during a search to keep things responsive.
const MAX_FILE_BYTES = 2 * 1024 * 1024;
// Longest slice of a matching line echoed back in `text`. Longer lines are capped
// (non-silently: see `textTruncated` on each match and `linesTruncated` in the result).
const MAX_MATCH_TEXT_LEN = 300;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* True when a workspace-relative path (forward slashes) lives inside a
* dot-directory — i.e. any *directory* segment starts with ".". Traversal into
* such directories (".git", ".cache", …) is suppressed by default so their
* internals never appear in search results. The final segment is the file name
* and is not checked, so dot-files like ".env" remain searchable.
*/
function isInDotDirectory(rel) {
const segments = rel.split('/');
for (let i = 0; i < segments.length - 1; i++) {
if (segments[i].startsWith('.')) return true;
}
return false;
}
// ─── Tool definition ───────────────────────────────────────────────────────────
export const ripgrepTool = defineTool({
name: 'ripgrep',
description:
'Regex-search file contents across ws (literal=true = plain str). ' +
'Recurses dirs, honors .gitignore, skips binary/big files, hides dot-dirs (.git) by default. ' +
'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).',
parameters: {
type: 'object',
additionalProperties: false,
required: ['pattern'],
properties: {
pattern: {
type: 'string',
description: 'Regex (literal str when literal=true) to find.',
},
path: {
type: 'string',
description: 'File/dir to search, rel to root. Default "."',
},
glob: {
type: 'string',
description: 'Only files whose path matches glob, e.g. "*.js" or "src/**/*.ts".',
},
ignore_case: {
type: 'boolean',
description: 'Case-insensitive. Default false.',
},
literal: {
type: 'boolean',
description: 'Treat pattern as literal str, not regex. Default false.',
},
max_results: {
type: 'number',
description: 'Max matches. Default 100.',
},
},
},
invoke: async ({
pattern,
path: searchPath = '.',
glob,
ignore_case = false,
literal = false,
max_results = 100,
}) => {
if (typeof pattern !== 'string' || pattern.length === 0) {
throw new ToolError('"pattern" must be a non-empty string.');
}
if (!Number.isInteger(max_results) || max_results < 1) {
throw new ToolError(`"max_results" must be a positive integer (received ${max_results}).`);
}
const absTarget = resolveSafe(searchPath);
if (!fs.existsSync(absTarget)) {
throw new ToolError(`Path not found: "${searchPath}"`);
}
// Compile the search pattern once (global so matchAll can iterate hits).
let regex;
try {
regex = new RegExp(literal ? escapeRegExp(pattern) : pattern, ignore_case ? 'gi' : 'g');
} catch (err) {
throw new ToolError(`Invalid pattern: ${err.message}`);
}
const globMatch = glob ? makeGlobMatcher(glob) : null;
// Gather candidate files: a single file, or a recursive walk of a directory.
const targetStat = fs.statSync(absTarget);
const candidates = targetStat.isFile()
? [{ abs: absTarget, rel: path.relative(ROOT_DIR, absTarget).replace(/\\/g, '/') }]
: walkFiles(absTarget);
// Drop anything inside a dot-directory (e.g. ".git/…") by default, then apply
// the glob up front so the eligible-file count is known even when the scan
// stops early on max_results. Dot-files (".env", ".gitignore") are kept.
const scoped = candidates.filter(
({ rel }) => !isInDotDirectory(rel) && (globMatch ? globMatch(rel) : true),
);
const matches = [];
let filesSearched = 0;
let filesSkipped = 0;
let linesTruncated = 0;
let truncated = false;
search:
for (const { abs, rel } of scoped) {
let size = 0;
try {
size = fs.statSync(abs).size;
} catch {
continue;
}
if (size > MAX_FILE_BYTES) {
filesSkipped++;
continue;
}
let content;
try {
content = fs.readFileSync(abs, 'utf8');
} catch {
continue;
}
// Skip binary files (a NUL byte is a good heuristic).
if (content.includes('\u0000')) {
filesSkipped++;
continue;
}
filesSearched++;
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i];
const lineText = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
let shown;
let capped = false;
for (const m of lineText.matchAll(regex)) {
if (matches.length >= max_results) {
// We found one more than requested: stop and flag truncation.
truncated = true;
break search;
}
// Cap long lines once per line, only when a match actually needs it.
if (shown === undefined) {
capped = lineText.length > MAX_MATCH_TEXT_LEN;
shown = capped ? lineText.slice(0, MAX_MATCH_TEXT_LEN) + '…' : lineText;
}
const entry = { file: rel, line: i, column: m.index, text: shown };
if (capped) {
entry.textTruncated = true;
entry.lineLength = lineText.length;
}
matches.push(entry);
}
if (capped) linesTruncated++;
}
}
const result = {
pattern,
path: searchPath,
glob: glob ?? null,
ignoreCase: ignore_case,
literal,
filesSearched,
filesEligible: scoped.length,
matches,
returned: matches.length,
};
if (filesSkipped > 0) {
result.skippedFiles = filesSkipped;
result.skippedNote = `${filesSkipped} file(s) skipped (binary or larger than ${MAX_FILE_BYTES} bytes).`;
}
if (linesTruncated > 0) {
result.linesTruncated = linesTruncated;
result.textTruncationNote =
`${linesTruncated} matching line(s) longer than ${MAX_MATCH_TEXT_LEN} chars were capped in "text" ` +
`(marked with a trailing "…" and textTruncated: true; "column" still refers to the full line).`;
}
if (truncated) {
result.truncated = true;
result.truncationNote =
`Showing the first ${max_results} matches. Refine the pattern or raise max_results to see more. ` +
`(Search stopped early: filesSearched counts only files scanned so far; ` +
`${scoped.length} file(s) were eligible before scanning.)`;
} else {
result.totalMatches = matches.length;
}
return result;
},
});
/**
* Call notification: the pattern plus scope (path/glob) and match modifiers.
*/
export function notifyCall(args = {}) {
const { pattern, path: searchPath, glob, ignore_case = false, literal = false } = args;
const parts = [JSON.stringify(pattern)];
if (searchPath && searchPath !== '.') parts.push(`path=${JSON.stringify(searchPath)}`);
if (glob) parts.push(`glob=${JSON.stringify(glob)}`);
if (ignore_case) parts.push('ignore_case');
if (literal) parts.push('literal');
emitToolCall(ripgrepTool.name, clip(parts.join(' ')));
}
/**
* Result notification: match count and the payload size.
*/
export function notifyResult(result = {}) {
const returned = result.returned ?? (Array.isArray(result.matches) ? result.matches.length : 0);
const count = result.truncated
? `${returned} matches (capped)`
: plural(returned, 'match', 'matches');
emitToolResult(ripgrepTool.name, `${count} ${formatBytes(resultSize(result))}`);
}

View File

@@ -4,10 +4,11 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js'; import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
export const rmTool = defineTool({ export const rmTool = defineTool({
name: 'rm', name: 'rm',
description: 'Delete a file from the workspace.', description: 'Delete a file from ws.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -15,7 +16,7 @@ export const rmTool = defineTool({
properties: { properties: {
filename: { filename: {
type: 'string', type: 'string',
description: 'File path relative to workspace root.', description: 'File path rel to root.',
}, },
}, },
}, },
@@ -33,3 +34,10 @@ export const rmTool = defineTool({
return { message: `File deleted: "${filename}"`, deleted: true }; return { message: `File deleted: "${filename}"`, deleted: true };
}, },
}); });
/**
* Call notification: the file to delete.
*/
export function notifyCall(args = {}) {
emitToolCall(rmTool.name, clip(JSON.stringify(args.filename ?? '')));
}

View File

@@ -4,10 +4,11 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js'; import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
export const rmdirTool = defineTool({ export const rmdirTool = defineTool({
name: 'rmdir', name: 'rmdir',
description: 'Remove a directory and its contents from the workspace.', description: 'Delete a dir + contents from ws.',
parameters: { parameters: {
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -15,7 +16,7 @@ export const rmdirTool = defineTool({
properties: { properties: {
dirname: { dirname: {
type: 'string', type: 'string',
description: 'Directory path relative to workspace root.', description: 'Dir path rel to root.',
}, },
}, },
}, },
@@ -32,3 +33,10 @@ export const rmdirTool = defineTool({
return { message: `Directory removed: "${dirname}"`, removed: true }; return { message: `Directory removed: "${dirname}"`, removed: true };
}, },
}); });
/**
* Call notification: the directory to remove.
*/
export function notifyCall(args = {}) {
emitToolCall(rmdirTool.name, clip(JSON.stringify(args.dirname ?? '')));
}

185
src/tools/websearch.js Normal file
View File

@@ -0,0 +1,185 @@
'use strict';
/**
* websearch — query the web via Tavily and return a synthesized answer plus
* ranked results.
*
* Design notes:
* - Requires a Tavily API key (TAVILY_API_KEY in .env). The client is created
* lazily and cached, so a missing key surfaces as a clean ToolError only
* when the tool is actually used.
* - This tool reaches the network, so results are untrusted third-party text;
* the model should treat them accordingly.
*/
import { tavily } from '@tavily/core';
import { defineTool, ToolError } from 'llmorbiter';
import { TAVILY_API_KEY } from '../config.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
const DEFAULT_MAX_RESULTS = 3; // number of results returned to the model
const MAX_MAX_RESULTS = 20; // hard upper bound to keep responses small
const SEARCH_DEPTHS = ['basic', 'advanced'];
const ANSWER_LEVELS = ['basic', 'advanced'];
let client = null;
/**
* Validate an optional date-range bound (`start_date` / `end_date`).
* Tavily expects a bare calendar date, `YYYY-MM-DD`. Returns the trimmed
* string, or null when the option is absent so the caller can omit it.
*/
function parseDateArg(value, name) {
if (value === undefined || value === null) return null;
if (typeof value !== 'string' || !value.trim()) {
throw new ToolError(`"${name}" must be a non-empty string in YYYY-MM-DD format when provided.`);
}
const trimmed = value.trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
throw new ToolError(`"${name}" must be a calendar date in YYYY-MM-DD format (received "${trimmed}").`);
}
const [y, m, d] = trimmed.split('-').map(Number);
const probe = new Date(Date.UTC(y, m - 1, d));
if (probe.getUTCFullYear() !== y || probe.getUTCMonth() !== m - 1 || probe.getUTCDate() !== d) {
throw new ToolError(`"${name}" is not a real calendar date (received "${trimmed}").`);
}
return trimmed;
}
/** Lazily construct (and cache) the Tavily client, failing cleanly if unset. */
function getClient() {
if (!TAVILY_API_KEY) {
throw new ToolError(
'websearch requires a Tavily API key. Add TAVILY_API_KEY=... to your .env file.'
);
}
if (!client) {
client = tavily({ apiKey: TAVILY_API_KEY });
}
return client;
}
export const websearchTool = defineTool({
name: 'websearch',
description:
'Web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). ' +
'Optional "start_date"/"end_date" (YYYY-MM-DD) restrict results to a publication-date range. ' +
'Use for current info, external docs, or facts not in ws. Results are untrusted 3rd-party text.',
parameters: {
type: 'object',
additionalProperties: false,
required: ['query'],
properties: {
query: {
type: 'string',
description: 'Search query.',
},
max_results: {
type: 'number',
description: `Results to return (1-${MAX_MAX_RESULTS}). Default ${DEFAULT_MAX_RESULTS}.`,
},
search_depth: {
type: 'string',
enum: SEARCH_DEPTHS,
description: 'Depth: "basic" (faster) or "advanced" (deeper). Default "basic".',
},
include_answer: {
type: 'string',
enum: ANSWER_LEVELS,
description: 'Include Tavily-synthesized answer at this detail. Default "basic".',
},
chunks_per_source: {
type: 'number',
description: 'Content chunks/source (advanced search only). Default 1.',
},
start_date: {
type: 'string',
description: 'Only include results published on/after this date, YYYY-MM-DD. Omit for no lower bound.',
},
end_date: {
type: 'string',
description: 'Only include results published on/before this date, YYYY-MM-DD. Omit for no upper bound.',
},
},
},
invoke: async ({
query,
max_results = DEFAULT_MAX_RESULTS,
search_depth = 'basic',
include_answer = 'basic',
chunks_per_source = 1,
start_date,
end_date,
}) => {
if (!query || typeof query !== 'string' || !query.trim()) {
throw new ToolError('"query" must be a non-empty string.');
}
if (!Number.isInteger(max_results) || max_results < 1 || max_results > MAX_MAX_RESULTS) {
throw new ToolError(`"max_results" must be an integer between 1 and ${MAX_MAX_RESULTS} (received ${max_results}).`);
}
if (!SEARCH_DEPTHS.includes(search_depth)) {
throw new ToolError(`"search_depth" must be one of ${SEARCH_DEPTHS.join(', ')} (received "${search_depth}").`);
}
if (!ANSWER_LEVELS.includes(include_answer)) {
throw new ToolError(`"include_answer" must be one of ${ANSWER_LEVELS.join(', ')} (received "${include_answer}").`);
}
if (!Number.isInteger(chunks_per_source) || chunks_per_source < 1) {
throw new ToolError(`"chunks_per_source" must be a positive integer (received ${chunks_per_source}).`);
}
const startDate = parseDateArg(start_date, 'start_date');
const endDate = parseDateArg(end_date, 'end_date');
const options = {
includeAnswer: include_answer,
searchDepth: search_depth,
maxResults: max_results,
chunksPerSource: chunks_per_source,
};
// Only forward the date range when the model actually asked for one, so
// Tavily keeps its own default window.
if (startDate) options.startDate = startDate;
if (endDate) options.endDate = endDate;
const response = await getClient().search(query, options);
const results = (response.results || []).map((r) => ({
title: r.title,
url: r.url,
content: r.content,
score: r.score,
}));
const result = { query, results };
if (startDate || endDate) {
result.dateRange = {
...(startDate ? { start: startDate } : {}),
...(endDate ? { end: endDate } : {}),
};
}
if (response.answer) result.answer = response.answer;
if (response.responseTime !== undefined) result.responseTime = response.responseTime;
return result;
},
});
/**
* Call notification: the query plus any non-default search options.
*/
export function notifyCall(args = {}) {
const { query, search_depth, max_results, include_answer, start_date, end_date } = args;
const parts = [JSON.stringify(query)];
if (search_depth && search_depth !== 'basic') parts.push(`depth=${search_depth}`);
if (max_results && max_results !== DEFAULT_MAX_RESULTS) parts.push(`max_results=${max_results}`);
if (include_answer && include_answer !== 'basic') parts.push(`answer=${include_answer}`);
if (start_date) parts.push(`from=${start_date}`);
if (end_date) parts.push(`to=${end_date}`);
emitToolCall(websearchTool.name, clip(parts.join(' ')));
}
/**
* Result notification: number of results and the payload size.
*/
export function notifyResult(result = {}) {
const n = Array.isArray(result.results) ? result.results.length : 0;
emitToolResult(websearchTool.name, `${plural(n, 'result')} ${formatBytes(resultSize(result))}`);
}

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');
}

99
src/turn-state.js Normal file
View File

@@ -0,0 +1,99 @@
'use strict';
/**
* Connection state for a model turn.
*
* The command bar used to render a free-text status string assembled from
* whatever the provider happened to emit, so the label could be absent, stale,
* or contradict the hardcoded "thinking…" shown at turn start. This module
* replaces that with a closed enum and a pure reducer, so the label always
* names what the stream is currently doing.
*
* Five states:
* idle no turn is running; the main prompt is shown.
* connecting the turn started, the request is in flight, no delta yet.
* thinking the model is producing reasoning deltas.
* working the model is calling a tool.
* streaming the model is producing visible assistant content.
*
* Transitions are driven purely by observable events, never by timers:
* turn_start -> connecting, reasoning -> thinking, tool_call -> working,
* content -> streaming, and turn_end / cancel / error -> idle.
*
* Deltas always win, even moving the state backward, so the label names the
* newest observed activity rather than the furthest point reached. There is no
* React, store, or provider dependency here, which keeps the machine trivially
* unit-testable.
*/
/** The five connection states. */
export const TURN_STATES = {
IDLE: 'idle',
CONNECTING: 'connecting',
THINKING: 'thinking',
WORKING: 'working',
STREAMING: 'streaming',
};
/** The events that drive transitions. */
export const TURN_EVENTS = {
TURN_START: 'turn_start',
REASONING: 'reasoning',
TOOL_CALL: 'tool_call',
CONTENT: 'content',
TURN_END: 'turn_end',
CANCEL: 'cancel',
ERROR: 'error',
};
const ALL_STATES = new Set(Object.values(TURN_STATES));
/** True when `value` is one of the five known state strings. */
export function isTurnState(value) {
return ALL_STATES.has(value);
}
/**
* Pure reducer: (current, event) -> next state.
*
* Deltas are stateless — reasoning, tool_call, and content map directly to
* their state regardless of `current`, which is why a later reasoning delta can
* move the label back from streaming to thinking. Only the terminal events
* depend on the current value: `turn_start` always yields `connecting`, and
* turn_end / cancel / error always yield `idle` so no path strands the session
* busy. An unknown event resolves to a defined result rather than throwing,
* keeping the function total.
*/
export function nextState(current, event) {
switch (event) {
case TURN_EVENTS.TURN_START:
return TURN_STATES.CONNECTING;
case TURN_EVENTS.REASONING:
return TURN_STATES.THINKING;
case TURN_EVENTS.TOOL_CALL:
return TURN_STATES.WORKING;
case TURN_EVENTS.CONTENT:
return TURN_STATES.STREAMING;
case TURN_EVENTS.TURN_END:
case TURN_EVENTS.CANCEL:
case TURN_EVENTS.ERROR:
return TURN_STATES.IDLE;
default:
return isTurnState(current) ? current : TURN_STATES.IDLE;
}
}
/**
* Map a state to its status-line label.
* Returns an empty string for `idle` (and any unknown value) because the idle
* case is covered by the `› (idle)` placeholder rather than the spinner.
*/
export function statusLabel(state) {
switch (state) {
case TURN_STATES.CONNECTING: return 'connecting…';
case TURN_STATES.THINKING: return 'thinking…';
case TURN_STATES.WORKING: return 'working…';
case TURN_STATES.STREAMING: return 'streaming…';
default: return '';
}
}

363
src/ui/app.js Normal file
View File

@@ -0,0 +1,363 @@
'use strict';
/**
* The root Ink component — fullscreen fixed-pane TUI.
*
* Layout (locked to the terminal size, alternate screen):
* header — 1-line chrome: model / workspace / reasoning / scroll state.
* history — flex-grow pane, bottom-anchored, top-clipped (Yoga
* `overflow: hidden` + `justifyContent: flex-end`). Entry-
* level windowing: only the last N entries are mounted and
* `store.scroll` slices upward from the live tail.
* command bar — bordered box, fixed at the bottom: the active prompt, or a
* placeholder while the model works.
* footer — token/cost totals box + key hints.
*
* History windowing is line-granular: an entry is one logged block (banner,
* reply, tool notice...), but scrolling operates on the rendered lines inside
* those blocks. PgUp/PgDn and mouse-wheel events therefore move without
* skipping a complete multi-line block. Any new log line keeps the current
* offset (clamped) so reading history isn't yanked to the tail.
*
* State lives in the external store (see ./store.js); we subscribe with
* `useSyncExternalStore` so the plain async command loop and the tools can
* drive the view without being React components.
*
* Written without JSX (the project runs straight from source).
*/
import {
createElement as h,
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import {
Box,
Text,
measureElement,
useApp,
useBoxMetrics,
useInput,
useWindowSize,
} from 'ink';
import chalk from 'chalk';
import { statusLabel } from '../turn-state.js';
import { MainInput } from './main-input.js';
import { TextPrompt, SelectPrompt, ConfirmPrompt } from './prompts.js';
import { wheelDeltaFromInput } from './mouse.js';
import { renderReasoningBlock } from './blocks.js';
const SPINNER = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'];
// Max history entries mounted per frame. Fullscreen re-renders history every
// frame (unlike <Static>'s write-once), so cap reconciler work. The store
// keeps up to 1000; only the tail near the viewport is mounted.
const MAX_VISIBLE_LINES = 120;
const SCROLL_STEP = 10;
const WHEEL_STEP = 3;
// SGR (1006) mouse-reporting sequences. 1000 = click + wheel reports only
// (no hover/drag flood); 1006 = SGR extended format (\x1b[<Cb;X;YM).
// Wheel up = Cb 64 (or 96 with Shift), wheel down = 65 (or 97 with Shift).
const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1006h';
const MOUSE_DISABLE = '\x1b[?1000l\x1b[?1006l';
function setMouseTracking(on) {
if (!process.stdout?.isTTY) return;
if (process.env.FIDO_NO_MOUSE === '1') return;
try {
process.stdout.write(on ? MOUSE_ENABLE : MOUSE_DISABLE);
} catch {
/* non-interactive / closed stdout; ignore */
}
}
const fmtInt = (n) => Number(n || 0).toLocaleString('en-US');
const fmtCost = (c) => '$' + Number(c || 0).toFixed(5);
/** Format streaming or finalized reasoning as transcript entry lines. */
export function formatReasoning(text, isLive = false, options = {}) {
if (!text || !String(text).trim()) return [];
const header = chalk.magenta.bold('\ud83e\udde0 Reasoning') + (isLive ? chalk.dim(' (thinking\u2026)') : '');
const div = chalk.dim('\u2500'.repeat(50));
const lines = String(text).replace(/\n+$/, '').split('\n');
const body = lines.map((l) => chalk.dim(l));
const rendered = renderReasoningBlock([header, div, ...body].join('\n'), options);
return ['', ...rendered.split('\n'), ''];
}
/** Truncate a plain (no-ANSI) string to `width` visible columns. */
function truncatePlain(s, width) {
const str = String(s || '');
if (width <= 0) return '';
if (str.length <= width) return str;
return str.slice(0, Math.max(0, width - 1)) + '\u2026';
}
// Longest provider status detail shown next to the state label. The state is
// authoritative, so the detail is a bounded, dimmed suffix — never a
// replacement that could contradict it.
const MAX_DETAIL_WIDTH = 28;
/**
* The busy command-bar line: a spinner plus a label derived from the
* connection state. `idle` renders no label (the `› (idle)` placeholder covers
* the non-busy case), and any unexpected value falls back to a label so the
* busy bar is never blank.
*/
function StatusLine({ status, detail }) {
const [tick, setTick] = useState(0);
useEffect(() => {
const t = setInterval(() => setTick((n) => n + 1), 120);
return () => clearInterval(t);
}, []);
const frame = SPINNER[tick % SPINNER.length];
const label = statusLabel(status) || 'working\u2026';
// A detail that repeats the label adds nothing, and provider text is
// untrusted, so it is plain-truncated to keep the bar exactly one row tall.
const text = detail == null ? '' : String(detail).trim();
const suffix = text && text !== label
? chalk.dim(' \u00b7 ' + truncatePlain(text, MAX_DETAIL_WIDTH))
: '';
return h(Text, { color: 'yellow' }, ` ${frame} ${label}${suffix}`);
}
function PromptView({ prompt, store }) {
switch (prompt.type) {
case 'main':
return h(MainInput, { prompt, store });
case 'input':
return h(TextPrompt, { prompt, store });
case 'select':
return h(SelectPrompt, { prompt, store });
case 'confirm':
return h(ConfirmPrompt, { prompt, store });
default:
return null;
}
}
function Header({ meta, scroll, columns }) {
const bits = ['\ud83d\udc15 FIDO'];
if (meta.model) bits.push(meta.model);
if (meta.workspace) bits.push(meta.workspace);
if (meta.reasoning) bits.push('reasoning:' + meta.reasoning);
if (scroll > 0) bits.push(`\u25b2 ${scroll} lines (PgUp/PgDn \u00b7 wheel \u00b7 End=live)`);
// 4 columns of chrome: 2 borders + 2 padding.
const line = truncatePlain(bits.join(' \u00b7 '), Math.max(0, columns - 4));
return h(
Box,
{ borderStyle: 'round', borderColor: 'cyan', flexShrink: 0 },
h(Text, { bold: true, wrap: 'truncate' }, ' ' + line + ' '),
);
}
/** Count the terminal lines represented by a transcript. */
export function countHistoryLines(entries) {
if (!Array.isArray(entries)) return 0;
return entries.reduce((count, entry) => {
if (!entry || entry.text == null || entry.text === '') return count;
return count + String(entry.text).split('\n').length;
}, 0);
}
/**
* Return a bounded window ending at the live transcript tail.
*
* Transcript entries are logical blocks, but their rendered height can span
* many lines. Flattening them here means `scroll=1` removes one rendered line,
* not one complete block.
*/
export function getHistoryWindow(entries, scroll = 0, maxLines = MAX_VISIBLE_LINES) {
const lines = [];
if (Array.isArray(entries)) {
entries.forEach((entry, entryIndex) => {
if (!entry || entry.text == null || entry.text === '') return;
String(entry.text).split('\n').forEach((text, lineIndex) => {
lines.push({
key: `${entry.id ?? entryIndex}:${lineIndex}`,
text,
});
});
});
}
const totalLines = lines.length;
const requested = Math.trunc(Number(scroll)) || 0;
// Keep at least one line mounted when scrolling to the oldest history.
const offset = totalLines === 0
? 0
: Math.max(0, Math.min(totalLines - 1, requested));
const end = Math.max(0, totalLines - offset);
const limit = Math.max(1, Math.trunc(Number(maxLines)) || 1);
const start = Math.max(0, end - limit);
return { lines: lines.slice(start, end), totalLines, start, end };
}
function History({ entries, scroll }) {
// Yoga clips an oversized child from its top edge, even when the parent is
// bottom-anchored. Mount no more lines than the history pane can actually
// display; otherwise an overflowing 120-line window hides the live tail.
const ref = useRef(null);
const metrics = useBoxMetrics(ref);
const [layoutHeight, setLayoutHeight] = useState(0);
// useBoxMetrics updates when a sibling (for example, the file browser)
// changes the command-bar height. A layout effect also gives synchronous
// renderToString output a correctly sized initial window.
useLayoutEffect(() => {
const node = ref.current;
if (!node) return;
const height = measureElement(node).height;
setLayoutHeight((previous) => previous === height ? previous : height);
});
const height = metrics.hasMeasured ? metrics.height : layoutHeight;
const maxLines = Math.max(1, Math.trunc(Number(height)) || 1);
const { lines } = getHistoryWindow(entries, scroll, maxLines);
const visibleText = lines.map((line) => line.text).join('\n');
return h(
Box,
{
ref,
flexGrow: 1,
flexShrink: 1,
flexDirection: 'column',
justifyContent: 'flex-end',
overflow: 'hidden',
},
lines.length ? h(Text, { flexShrink: 0 }, visibleText) : null,
);
}
function CommandBar({ prompt, busy, status, detail, store }) {
let body;
if (prompt) body = h(PromptView, { prompt, store });
else if (busy) body = h(StatusLine, { status, detail });
else body = h(Text, { dimColor: true }, ' \u203a (idle)');
return h(
Box,
{ borderStyle: 'single', borderColor: 'cyan', flexShrink: 0, flexDirection: 'column' },
body,
);
}
function TokenFooter({ totals }) {
const t = totals || { calls: 0, input: 0, output: 0, cached: 0, cost: 0 };
const tok = `\u03a3 in=${fmtInt(t.input)} out=${fmtInt(t.output)}` +
(t.cached ? ` cached=${fmtInt(t.cached)}` : '') +
` cost=${fmtCost(t.cost)} (${t.calls} call${t.calls === 1 ? '' : 's'})`;
const right = h(Text, { dimColor: true }, ' PgUp/PgDn or wheel history \u00b7 End live \u00b7 Ctrl+O mention \u00b7 Ctrl+C cancel');
return h(
Box,
{ borderStyle: 'round', borderColor: 'gray', flexShrink: 0, flexDirection: 'column' },
h(Text, { dimColor: true }, ' ' + tok),
right,
);
}
export function App({ store, sizeOverride }) {
const state = useSyncExternalStore(store.subscribe, store.getState);
const { suspendTerminal } = useApp();
// sizeOverride is a test seam: renderToString only accepts `columns`, so
// the tiny-terminal fallback (rows < 12) is unreachable without it.
const wsSize = useWindowSize();
const columns = (sizeOverride && sizeOverride.columns) || wsSize.columns || 80;
const rows = sizeOverride ? (sizeOverride.rows || 24) : (wsSize.rows >= 12 ? wsSize.rows : 24);
const liveReasoningLines = formatReasoning(state.reasoningDraft, true, { columns });
let transcript = state.entries;
if (liveReasoningLines.length > 0) {
const liveEntries = liveReasoningLines.map((line, i) => ({
id: `live-reasoning-${i}`,
text: line,
}));
transcript = [...transcript, ...liveEntries];
}
// Expose Ink's terminal-suspend helper so the command loop can run an
// external $EDITOR (for /paste) without corrupting the Ink frame.
useEffect(() => {
store._suspend = (fn) => suspendTerminal(fn);
return () => {
store._suspend = null;
};
}, [store, suspendTerminal]);
// Mouse mode is terminal-global. Keep its lifecycle independent from the
// command-suspension effect so a re-render (or a prompt replacement) cannot
// accidentally leave tracking disabled.
useEffect(() => {
store._setMouseTracking = (on) => setMouseTracking(on);
setMouseTracking(true);
return () => {
store._setMouseTracking = null;
setMouseTracking(false);
};
}, [store]);
// Global keys. Prompt components own arrows/Tab/Enter; the text input
// swallows printable chars and ignores PageUp/PageDown, so history scrolling
// here never fights them.
// Ctrl+C with no prompt cancels the running turn (handled by the loop).
useInput((input, key) => {
const wheelDelta = wheelDeltaFromInput(input);
if (wheelDelta !== 0) {
// `scroll` counts entries above the live tail: wheel up moves
// toward older history (+), while wheel down returns toward the
// live tail (-).
store.scrollBy(-wheelDelta * WHEEL_STEP);
return;
}
if (key.ctrl && input === 'c' && state.prompt === null) {
store.emitCancel();
return;
}
if (key.pageUp) {
store.scrollBy(SCROLL_STEP);
return;
}
if (key.pageDown) {
store.scrollBy(-SCROLL_STEP);
return;
}
if (key.end) {
store.scrollToBottom();
}
});
// Degraded mode for tiny / non-interactive terminals: skip fixed heights
// and clipping so content still renders instead of being squeezed away.
if (rows < 12) {
let dynamic = null;
if (state.prompt) dynamic = h(PromptView, { prompt: state.prompt, store });
else if (state.busy) {
dynamic = h(StatusLine, { status: state.status, detail: state.statusDetail });
}
return h(
Box,
{ flexDirection: 'column' },
...transcript.map((entry) => h(Text, { key: entry.id }, entry.text)),
dynamic,
h(Text, { dimColor: true }, ` in=${fmtInt(state.totals.input)} out=${fmtInt(state.totals.output)} cost=${fmtCost(state.totals.cost)}`),
);
}
return h(
Box,
{ flexDirection: 'column', height: rows, width: columns },
h(Header, { meta: state.meta, scroll: state.scroll, columns }),
h(History, { entries: transcript, scroll: state.scroll }),
h(CommandBar, {
prompt: state.prompt,
busy: state.busy,
status: state.status,
detail: state.statusDetail,
store,
}),
h(TokenFooter, { totals: state.totals }),
);
}

134
src/ui/blocks.js Normal file
View File

@@ -0,0 +1,134 @@
'use strict';
import stringWidth from 'string-width';
import wrapAnsi from 'wrap-ansi';
/**
* Transcript block styling for the message and reasoning roles.
*
* The transcript is rendered by Ink, but its history entries are pre-rendered
* strings. This module keeps the role colours in one place and makes it
* possible to paint a complete rectangular block even when the content has
* nested ANSI styles (Markdown and chalk both emit reset sequences).
*/
const ANSI_RE = /\x1b\[[0-9;]*m/g;
const RESET = '\x1b[0m';
const BG_CLOSE = '\x1b[49m';
const LEFT_INDENT = ' ';
const LEFT_RAIL = '\u258c'; // ▌
const DEFAULT_COLUMNS = 80;
const BLOCK_PREFIX_WIDTH = LEFT_INDENT.length + LEFT_RAIL.length + 1;
/**
* Default content width for an 80-column terminal. The four columns outside
* this width are the two-space margin, the rail, and the body gutter.
*/
export const BLOCK_CONTENT_WIDTH = DEFAULT_COLUMNS - BLOCK_PREFIX_WIDTH; // 76
/**
* Keep the roles visually distinct. The rail is deliberately a
* different hue from the fill so the role remains identifiable at a glance,
* even when the transcript is rendered in a terminal with a subtle theme.
*/
export const BLOCK_STYLES = Object.freeze({
assistant: Object.freeze({
background: '\x1b[48;2;30;41;59m', // #1e293b — slate
accent: '\x1b[38;2;125;211;252m', // light blue
}),
user: Object.freeze({
background: '\x1b[48;2;49;31;68m', // #311f44 — muted purple
accent: '\x1b[38;2;196;181;253m', // lavender
}),
tool: Object.freeze({
background: '\x1b[48;2;27;45;45m', // #1b2d2d — dark teal
accent: '\x1b[38;2;251;191;36m', // amber
}),
reasoning: Object.freeze({
background: '\x1b[48;2;54;35;72m', // #362348 — dark plum
accent: '\x1b[38;2;232;121;249m', // bright pink
}),
// Keep the thinking terminology available to callers as well as reasoning.
thinking: Object.freeze({
background: '\x1b[48;2;54;35;72m', // #362348 — dark plum
accent: '\x1b[38;2;232;121;249m', // bright pink
}),
});
const stripAnsi = (value) => String(value).replace(ANSI_RE, '');
/** Resolve the terminal width used for a block when no explicit width is given. */
function getColumns(options) {
const requested = Number(options.columns ?? process.stdout?.columns ?? DEFAULT_COLUMNS);
return Number.isFinite(requested) && requested > 0
? Math.trunc(requested)
: DEFAULT_COLUMNS;
}
/** Resolve the visible body width, excluding the block's fixed chrome. */
export function getBlockContentWidth(options = {}) {
const requested = Number(options.width);
if (Number.isFinite(requested) && requested > 0) return Math.trunc(requested);
return Math.max(1, getColumns(options) - BLOCK_PREFIX_WIDTH);
}
function contentLines(content) {
const source = Array.isArray(content) ? content.join('\n') : (content ?? '');
return String(source)
.replace(/\r\n?/g, '\n')
.replace(/\n+$/, '');
}
/**
* Wrap the complete source before painting it. Ink must not be allowed to do
* the final wrap: when it wraps a pre-painted line, the continuation starts
* without the rail/background. wrap-ansi carries SGR state across the
* explicit newlines while keeping every resulting line inside the body width.
*/
function wrappedContentLines(content, width) {
return wrapAnsi(contentLines(content), width, { trim: false, hard: true }).split('\n');
}
/**
* Paint one line as:
*
* [left indent][accent rail][background + one-space body][close]
*
* A full ANSI reset also clears a background. Re-open the background after
* every reset so Markdown/chalk styling cannot make the block disappear in
* the middle of a line.
*/
function paintLine(line, style, width) {
const visible = stringWidth(line);
const padded = visible < width ? line + ' '.repeat(width - visible) : line;
const restored = padded.replace(/\x1b\[0m/g, RESET + style.background);
return LEFT_INDENT
+ style.background
+ style.accent
+ LEFT_RAIL
+ RESET
+ style.background
+ ' '
+ restored
+ BG_CLOSE
+ RESET;
}
/** Render `content` as a left-railed, rectangular role block. */
export function renderBlock(kind, content, options = {}) {
const style = BLOCK_STYLES[kind];
if (!style) throw new Error(`Unknown transcript block kind: ${kind}`);
const width = getBlockContentWidth(options);
return wrappedContentLines(content, width)
.map((line) => paintLine(line, style, width))
.join('\n');
}
export const renderAssistantBlock = (content, options) => renderBlock('assistant', content, options);
export const renderUserBlock = (content, options) => renderBlock('user', content, options);
export const renderToolBlock = (content, options) => renderBlock('tool', content, options);
export const renderReasoningBlock = (content, options) => renderBlock('reasoning', content, options);
export const renderThinkingBlock = (content, options) => renderReasoningBlock(content, options);

38
src/ui/editor.js Normal file
View File

@@ -0,0 +1,38 @@
'use strict';
/**
* Compose or paste multiline text in the user's $VISUAL/$EDITOR.
*
* Used by the /paste command. Returns the saved buffer as a string (empty if
* the user saved nothing). Must be run inside `ui.runExternally(...)` so Ink
* releases the terminal while the editor owns it.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
export function composeInEditor(initial = '') {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fido-paste-'));
const file = path.join(dir, 'message.md');
fs.writeFileSync(file, initial);
const editor =
process.env.VISUAL ||
process.env.EDITOR ||
(process.platform === 'win32' ? 'notepad' : 'vim');
try {
// `shell: true` lets multi-word $EDITOR values (e.g. "code --wait")
// work; the path is quoted so spaces survive.
spawnSync(`${editor} ${JSON.stringify(file)}`, { stdio: 'inherit', shell: true });
return fs.readFileSync(file, 'utf8');
} finally {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
/* best-effort cleanup */
}
}
}

113
src/ui/index.js Normal file
View File

@@ -0,0 +1,113 @@
'use strict';
/**
* UI singleton \u2014 the imperative face of the Ink app.
*
* The REPL's command loop and the tools import `ui` and call these methods;
* they never touch React or Ink directly. `mount()` renders the <App>, and the
* rest of the methods push transcript lines or install prompts through the
* shared store (see ./store.js and ./app.js).
*
* Prompt methods return promises:
* - `ask()` resolves with the submitted string, or `null` if the user pressed
* Ctrl+C (the caller should then exit).
* - `input()`/`select()`/`confirm()` resolve with the answer, and reject with
* an Error on Ctrl+C (matching the old @inquirer behaviour).
*
* Written without JSX (the project runs straight from source).
*/
import { createElement as h } from 'react';
import { render } from 'ink';
import { createStore } from './store.js';
import { App } from './app.js';
import { composeInEditor } from './editor.js';
const store = createStore();
let instance = null;
/** Mount the Ink app. Safe to call once. */
export function mount() {
if (instance) return instance;
// exitOnCtrlC:false \u2014 we handle Ctrl+C ourselves so it can cancel a turn
// or back out of an overlay instead of killing the process.
// alternateScreen:true \u2014 fullscreen fixed panes (header / history /
// command bar / footer) on a separate buffer; the shell scrollback is
// restored on exit. Ignored automatically when non-interactive.
instance = render(h(App, { store }), { exitOnCtrlC: false, alternateScreen: true });
return instance;
}
/** Unmount the Ink app and wait for the final frame to flush. */
export async function unmount() {
if (!instance) return;
const inst = instance;
instance = null;
inst.unmount();
try {
await inst.waitUntilExit();
} catch {
/* exit(error) is not used; ignore */
}
}
export const ui = {
/** Append a history entry to the transcript pane. */
log: (text) => store.log(text),
/**
* Set the connection state shown while busy (a TURN_STATES value).
* The REPL is the only caller; it derives the value from the pure reducer
* in ../turn-state.js, never from free text.
*/
setTurnState: (state) => store.setTurnState(state),
/**
* Optional provider status text layered on the state label. Purely
* decorative: the state machine stays authoritative.
*/
setStatusDetail: (text) => store.setStatusDetail(text),
/** Toggle "model working" mode; clearing it also returns the state to idle. */
busy: (value) => store.setBusy(value),
/** Accumulate one usage record into the token/cost footer totals. */
addUsage: (u) => store.addUsage(u),
/** Set header chrome (model / workspace / reasoning). */
setMeta: (patch) => store.setMeta(patch),
/** Update live streaming reasoning text shown in the history pane. */
setReasoning: (text) => store.setReasoningDraft(text),
/** Finalize streaming reasoning into permanent history entries. */
finalizeReasoning: (text) => store.finalizeReasoning(text),
/** Clear live reasoning without committing to history. */
clearReasoning: () => store.clearReasoning(),
/** The main prompt. Resolves with the submitted string, or null on Ctrl+C. */
ask: (opts = {}) => store.prompt({ type: 'main', message: 'You', ...opts }),
/** A one-line free-text prompt. */
input: (opts = {}) => store.prompt({ type: 'input', ...opts }),
/** A single-select list prompt. */
select: (opts = {}) => store.prompt({ type: 'select', ...opts }),
/** A yes/no confirmation prompt. */
confirm: (opts = {}) => store.prompt({ type: 'confirm', ...opts }),
/** Register a Ctrl+C (turn-running) handler; returns an unsubscribe fn. */
onCancel: (fn) => store.onCancel(fn),
/** Hand the terminal to `fn` (e.g. an external editor), then restore it. */
runExternally: (fn) => store.runExternally(fn),
/** Compose multiline text in $EDITOR, inside a terminal suspension. */
composeInEditor: (initial) => store.runExternally(() => composeInEditor(initial)),
/** The underlying store (for tests / advanced use). */
store,
};

383
src/ui/main-input.js Normal file
View File

@@ -0,0 +1,383 @@
'use strict';
/**
* The main prompt: a single-line ink-text-input with two overlays.
*
* 1. Slash-command autocomplete. While the draft starts with "/" (and no
* space has been typed) the matching commands are listed inline.
* \u2191 / \u2193 move the highlight
* Tab complete the highlighted command
* Enter submit
* The filter term is frozen while you navigate so completing a command
* with Tab does not collapse the list.
*
* 2. In-prompt file browser (opened with Ctrl+O) with two modes, browse and
* line-select. The user picks a whole file (`@path`) or a line range
* (`@path:start-end`), and the reference is appended to the draft.
*
* ink-text-input consumes printable keys and left/right/backspace itself, but
* ignores \u2191/\u2193/Tab (and Ctrl+C). Those therefore reach our own `useInput`
* handler below. Ctrl+O is the one control chord ink-text-input would mistype as
* a literal "o", so a tiny <CtrlOGate> is rendered *before* the text input: its
* useEffect runs first, registering its input listener first, so it sees Ctrl+O
* before ink-text-input does and can suppress the stray character.
*
* Written without JSX (the project runs straight from source).
*/
import { createElement as h, useRef, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { SafeTextInput } from './safe-text-input.js';
import { renderUserBlock } from './blocks.js';
import { COMMANDS, filterCommands } from '../slash-commands.js';
import {
listEntries,
readLines,
formatMention,
normalizeRange,
windowStart,
parentOf,
} from '../file-browser.js';
const MAX_SUGGESTIONS = 6;
const NAME_WIDTH = Math.max(...COMMANDS.map((c) => c.name.length));
const BROWSE_WINDOW = 12;
const LINE_WINDOW = 18;
/** The slash-command filter term, or null when the menu should be closed. */
function termFrom(line) {
if (typeof line !== 'string' || !line.startsWith('/')) return null;
if (line.includes(' ')) return null;
return line.toLowerCase();
}
/**
* A zero-size gate whose only job is to observe Ctrl+O before ink-text-input.
* Rendering it as the first child guarantees its effect (and therefore its
* input listener) is registered ahead of the text input's.
*/
function CtrlOGate({ onOpen }) {
useInput((input, key) => {
if (key.ctrl && input === 'o') onOpen();
});
return null;
}
function BrowseRow({ entry, active }) {
const marker = active ? h(Text, { color: 'cyan' }, '\u276f ') : h(Text, null, ' ');
let label;
if (entry.type === 'parent') label = h(Text, { dimColor: true }, '../');
else if (entry.type === 'd') label = h(Text, { color: 'cyan', bold: true }, entry.name + '/');
else label = h(Text, null, entry.name);
return h(Text, null, marker, label);
}
function LineRow({ line, index, cursor, lo, hi, width }) {
const cur = index === cursor ? h(Text, { color: 'cyan' }, '\u276f ') : h(Text, null, ' ');
const inRange = lo !== null && index >= lo && index <= hi;
const mark = inRange ? h(Text, { color: 'cyan' }, '\u2502 ') : h(Text, null, ' ');
const num = h(Text, { dimColor: true }, String(index + 1).padStart(width) + ' ');
const text = line.replace(/\t/g, ' ');
const body = index === cursor ? h(Text, { color: 'white' }, text) : h(Text, null, text);
return h(Text, null, cur, num, mark, body);
}
export function MainInput({ prompt, store }) {
const message = prompt.message || 'You';
const [value, setValue] = useState(prompt.prefill || '');
const valueRef = useRef(value);
// The input value is local to the prompt. It is only appended to history
// when the user submits it.
valueRef.current = value;
// Slash menu: `frozen` keeps the filter term pinned while navigating.
const [frozen, setFrozen] = useState(null);
const [active, setActive] = useState(0);
// File browser state.
const [mode, setMode] = useState('input'); // 'input' | 'browse' | 'lines'
const [cwd, setCwd] = useState('');
const [entries, setEntries] = useState([]);
const [browseActive, setBrowseActive] = useState(0);
const [selFile, setSelFile] = useState(null);
const [fileLines, setFileLines] = useState([]);
const [fileBinary, setFileBinary] = useState(false);
const [cursor, setCursor] = useState(0);
const [anchor, setAnchor] = useState(null);
const [notice, setNotice] = useState('');
// Draft captured when the browser opens, so a cancel restores it.
const draftRef = useRef('');
// Set by <CtrlOGate> just before ink-text-input would insert the stray "o".
const ctrlORef = useRef(false);
const modeRef = useRef('input');
modeRef.current = mode;
const cwdRef = useRef(cwd);
cwdRef.current = cwd;
const term = frozen !== null ? frozen : termFrom(value);
const suggestions = term === null ? [] : filterCommands(term).slice(0, MAX_SUGGESTIONS);
const count = suggestions.length;
const activeIdx = count ? active % count : 0;
// \u2500\u2500 Browser helpers \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
const openDir = (rel) => {
setCwd(rel);
setEntries(listEntries(rel));
setBrowseActive(0);
setNotice('');
};
const openFile = (rel) => {
const { lines, binary, error } = readLines(rel);
if (error) {
setNotice(`could not read ${rel}`);
setEntries(listEntries(cwdRef.current));
return;
}
setSelFile(rel);
setFileLines(lines);
setFileBinary(binary);
setCursor(0);
setAnchor(null);
setNotice(binary ? 'binary file \u2014 whole-file mention only' : '');
setMode('lines');
};
const cancelToInput = () => {
setValue(draftRef.current);
setMode('input');
setNotice('');
};
// ink-text-input does not expose the caret position, so mentions are
// appended at the end of the draft (space-separated) rather than spliced at
// the caret.
const insertMention = (text) => {
const draft = draftRef.current || '';
const pad = draft.length && !/\s$/.test(draft) ? ' ' : '';
const next = draft + pad + text;
setValue(next);
setFrozen(null);
setActive(0);
setMode('input');
setNotice('');
};
const openBrowser = () => {
ctrlORef.current = true;
draftRef.current = valueRef.current;
openDir('');
setMode('browse');
};
const handleChange = (next) => {
// Swallow the literal "o" ink-text-input inserts for Ctrl+O.
if (ctrlORef.current) {
ctrlORef.current = false;
return;
}
setValue(next);
setFrozen(null);
setActive(0);
};
const submit = (submitted) => {
store.resolve(
submitted,
submitted
? renderUserBlock(`\u203a ${message} ${String(submitted).replace(/\n/g, '\n ')}`)
: '',
);
};
// \u2500\u2500 Key handling (browse / lines / input special keys) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
useInput((input, key) => {
// Ctrl+C: at the prompt it exits; inside an overlay it backs out first.
if (key.ctrl && input === 'c') {
if (modeRef.current === 'input') store.resolve(null);
else cancelToInput();
return;
}
if (modeRef.current === 'browse') {
const n = entries.length;
if (key.upArrow) {
if (n) setBrowseActive((b) => (b - 1 + n) % n);
return;
}
if (key.downArrow) {
if (n) setBrowseActive((b) => (b + 1) % n);
return;
}
if (key.return) {
const entry = entries[browseActive];
if (!entry) return;
if (entry.type === 'f') openFile(entry.rel);
else openDir(entry.rel);
return;
}
if (key.tab) {
const entry = entries[browseActive];
if (entry && entry.type === 'f') insertMention(formatMention(entry.rel));
return;
}
if (key.backspace) {
if (cwd) openDir(parentOf(cwd));
return;
}
if (key.escape) cancelToInput();
return;
}
if (modeRef.current === 'lines') {
const n = fileLines.length;
if (key.upArrow) {
setCursor((c) => Math.max(0, c - 1));
return;
}
if (key.downArrow) {
setCursor((c) => Math.max(0, Math.min(n - 1, c + 1)));
return;
}
if (!fileBinary && n > 0) {
if (input === 'v') {
setAnchor((a) => (a === null ? cursor : null));
return;
}
if (input === 'g') {
setCursor(0);
return;
}
if (input === 'G') {
setCursor(n - 1);
return;
}
}
if (key.return) {
let text;
if (anchor !== null && n > 0) {
const { start, end } = normalizeRange(anchor, cursor);
text = formatMention(selFile, start, end);
} else {
text = formatMention(selFile);
}
insertMention(text);
return;
}
if (key.escape || key.backspace) {
setMode('browse');
setNotice('');
}
return;
}
// \u2500\u2500 Input mode: special keys not consumed by ink-text-input \u2500\u2500
// (Ctrl+O is handled by <CtrlOGate> so it runs before the text input.)
if (count > 0 && (key.upArrow || key.downArrow)) {
const delta = key.upArrow ? -1 : 1;
setActive((a) => (a + delta + count) % count);
return;
}
if (key.tab) {
if (count === 0) return;
const pick = suggestions[activeIdx];
setValue(pick.name);
setFrozen(term); // keep the menu open while completing repeatedly
return;
}
});
// \u2500\u2500 Render \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\u2500\u2500\u2500
if (mode === 'browse') {
const n = entries.length;
const start = windowStart(browseActive, n, BROWSE_WINDOW);
const end = Math.min(n, start + BROWSE_WINDOW);
const rows = [];
for (let i = start; i < end; i++) {
rows.push(h(BrowseRow, { key: i, entry: entries[i], active: i === browseActive }));
}
const where = cwd ? `${cwd}/` : '(workspace root)';
return h(
Box,
{ flexDirection: 'column' },
h(Text, { color: 'cyan', bold: true }, ' \ud83d\udcc1 File browser ', h(Text, { dimColor: true }, where)),
rows.length ? h(Box, { flexDirection: 'column' }, ...rows) : h(Text, { dimColor: true }, ' (empty directory)'),
notice ? h(Text, { dimColor: true }, ' ' + notice) : null,
h(Text, { dimColor: true }, ' \u2191/\u2193 move \u00b7 Enter open \u00b7 Tab mention \u00b7 Backspace up \u00b7 Esc cancel'),
);
}
if (mode === 'lines') {
const n = fileLines.length;
const children = [
h(Text, { key: 'title' }, h(Text, { color: 'cyan', bold: true }, ' ' + selFile), h(Text, { dimColor: true }, ' \u2014 select lines')),
];
if (fileBinary) {
children.push(h(Text, { key: 'bin', dimColor: true }, ' (binary file \u2014 Enter mentions the whole file)'));
} else if (n === 0) {
children.push(h(Text, { key: 'empty', dimColor: true }, ' (empty file \u2014 Enter mentions the whole file)'));
} else {
const start = windowStart(cursor, n, LINE_WINDOW);
const end = Math.min(n, start + LINE_WINDOW);
const width = String(n).length;
const lo = anchor === null ? null : Math.min(anchor, cursor);
const hi = anchor === null ? null : Math.max(anchor, cursor);
const rows = [];
for (let i = start; i < end; i++) {
rows.push(
h(LineRow, {
key: i,
line: fileLines[i],
index: i,
cursor,
lo,
hi,
width,
}),
);
}
children.push(h(Box, { key: 'rows', flexDirection: 'column' }, ...rows));
}
if (notice) children.push(h(Text, { key: 'notice', dimColor: true }, ' ' + notice));
children.push(
h(Text, { key: 'help', dimColor: true }, ' \u2191/\u2193 move \u00b7 v mark \u00b7 g/G top/bottom \u00b7 Enter insert \u00b7 Esc back'),
);
return h(Box, { flexDirection: 'column' }, ...children);
}
// \u2500\u2500 Input view \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
const menu = count
? h(
Box,
{ flexDirection: 'column' },
...suggestions.map((c, i) =>
h(
Text,
{ key: c.name },
i === activeIdx ? h(Text, { color: 'cyan' }, ' \u276f ') : h(Text, null, ' '),
i === activeIdx
? h(Text, { bold: true, color: 'cyan' }, c.name.padEnd(NAME_WIDTH + 2))
: h(Text, { bold: true }, c.name.padEnd(NAME_WIDTH + 2)),
h(Text, { dimColor: true }, c.description),
),
),
count > 1 ? h(Text, { dimColor: true }, ' \u2191/\u2193 choose \u00b7 Tab complete') : null,
)
: null;
return h(
Box,
{ flexDirection: 'column' },
menu,
h(
Box,
null,
h(CtrlOGate, { key: 'ctrl-o', onOpen: openBrowser }),
h(Text, { color: 'cyan', bold: true }, '\u203a ' + message + ' '),
h(SafeTextInput, { value, onChange: handleChange, onSubmit: submit }),
),
);
}

120
src/ui/mouse.js Normal file
View File

@@ -0,0 +1,120 @@
'use strict';
/**
* Helpers for terminal mouse reports.
*
* We enable SGR mouse reporting (mode 1006), so wheel events arrive as
* CSI sequences such as ESC [ < 65 ; 88 ; 20 M. Ink exposes the sequence to
* useInput as text (without the leading ESC), which is normally useful for
* key handling but is dangerous for text inputs: an unknown CSI sequence can
* be inserted into the draft.
*/
const MOUSE_SEQUENCE_RE = /\x1b?\[<(\d+);\d+;\d+[Mm]/g;
const COMPLETE_MOUSE_SEQUENCE_RE = /^\x1b?\[<(\d+);\d+;\d+[Mm]$/;
// Some terminals fall back to the original X10 encoding even after SGR
// tracking is requested: ESC [ M <button> <x> <y>.
const X10_MOUSE_SEQUENCE_RE = /\x1b?\[M[\x20-\xff]{3}/g;
const COMPLETE_X10_MOUSE_SEQUENCE_RE = /^\x1b?\[M([\x20-\xff])([\x20-\xff])([\x20-\xff])$/;
const SGR_PREFIX_RE = /^\x1b?\[<(?:\d{0,3})(?:;\d{0,3}){0,2}$/;
const X10_PREFIX_RE = /^\x1b?\[M[\x20-\xff]{0,3}$/;
const SGR_FRAGMENT_AT_END_RE = /\x1b?\[<\d*(?:;\d*){0,2}$/;
const MOUSE_CONTINUATION_AT_END_RE = /\d*(?:;\d+){1,2}[Mm]$/;
let pendingWheelFragment = '';
let pendingStripFragment = '';
/**
* Return the scroll direction for an SGR mouse event.
*
* -1 means wheel up (older history), +1 means wheel down (toward the live
* tail), and 0 means the event is not a wheel event. The 64 bit identifies
* wheel buttons; the low bit is the up/down distinction. Mode 1000 does not
* request motion/drag reports, so a report carrying the wheel bit is safe to
* treat as a wheel event even when a terminal adds modifier bits.
*/
function normalizeMouseInput(value) {
return value.startsWith('\x1b') ? value.slice(1) : value;
}
function decodeCompleteMouse(value) {
const sgr = COMPLETE_MOUSE_SEQUENCE_RE.exec(value);
if (sgr) {
const code = Number(sgr[1]);
if ((code & 64) === 0) return 0;
return code & 1 ? 1 : -1;
}
const x10 = COMPLETE_X10_MOUSE_SEQUENCE_RE.exec(value);
if (!x10) return null;
const code = x10[1].charCodeAt(0) - 32;
if ((code & 64) === 0) return 0;
return code & 1 ? 1 : -1;
}
function isMousePrefix(value) {
return SGR_PREFIX_RE.test(value) || X10_PREFIX_RE.test(value);
}
export function wheelDeltaFromInput(input) {
if (typeof input !== 'string') {
pendingWheelFragment = '';
return 0;
}
const normalized = normalizeMouseInput(input);
if (pendingWheelFragment) {
const combined = pendingWheelFragment + normalized;
const delta = decodeCompleteMouse(combined);
if (delta !== null) {
pendingWheelFragment = '';
return delta;
}
if (combined.length <= 32 && isMousePrefix(combined)) {
pendingWheelFragment = combined;
return 0;
}
// The pending escape was not a mouse report after all. Continue with
// this ordinary input rather than carrying stale state forever.
pendingWheelFragment = '';
}
const delta = decodeCompleteMouse(normalized);
if (delta !== null) return delta;
if (normalized.length <= 32 && isMousePrefix(normalized)) {
pendingWheelFragment = normalized;
return 0;
}
return 0;
}
/** True when a value contains one or more SGR mouse reports. */
export function containsMouseSequence(value) {
if (typeof value !== 'string') return false;
return /\x1b?\[<\d+;\d+;\d+[Mm]/.test(value)
|| /\x1b?\[M[\x20-\xff]{3}/.test(value);
}
/** Remove SGR mouse reports from a value while preserving all other text. */
export function stripMouseSequences(value) {
if (typeof value !== 'string') return value;
let cleaned = value;
if (pendingStripFragment) {
const continuation = cleaned.match(MOUSE_CONTINUATION_AT_END_RE);
if (continuation && continuation.index === cleaned.length - continuation[0].length) {
cleaned = cleaned.slice(0, continuation.index);
}
pendingStripFragment = '';
}
cleaned = cleaned
.replace(MOUSE_SEQUENCE_RE, '')
.replace(X10_MOUSE_SEQUENCE_RE, '');
const prefix = cleaned.match(SGR_FRAGMENT_AT_END_RE);
if (prefix && prefix[0].length > 2) {
pendingStripFragment = prefix[0];
cleaned = cleaned.slice(0, prefix.index);
}
return cleaned;
}

128
src/ui/prompts.js Normal file
View File

@@ -0,0 +1,128 @@
'use strict';
/**
* Simple Ink prompt components used by the REPL and by tools that need to ask
* the user something mid-turn (ask_user, ask_an_expert, bash_exec approval,
* /suspend, /recall, the cancel-confirm).
*
* Each component receives the active `prompt` object (installed in the store)
* and the `store`. It renders the prompt and, on submit, calls
* `store.resolve(value)`. Text prompts add a single permanent
* history block only when submitted. Ctrl+C calls `store.cancel()` which rejects the
* prompt's promise — mirroring the old @inquirer behaviour, so the existing
* try/catch call sites keep working.
*
* Written without JSX (the project runs straight from source with no build
* step), so elements are built with React.createElement.
*/
import { createElement as h, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { SafeTextInput } from './safe-text-input.js';
import { renderUserBlock } from './blocks.js';
/** A single-line free-text prompt, with mouse reports filtered out. */
export function TextPrompt({ prompt, store }) {
const accent = prompt.color || 'cyan';
const [value, setValue] = useState(prompt.prefill || '');
const handleChange = (next) => {
setValue(next);
};
const submit = (submitted) => {
const text = submitted
? renderUserBlock(`\u203a ${prompt.message} ${String(submitted).replace(/\n/g, '\n ')}`)
: '';
store.resolve(submitted, text);
};
useInput((input, key) => {
if (key.ctrl && input === 'c') store.cancel();
});
return h(
Box,
{ flexDirection: 'column' },
prompt.help ? h(Text, { dimColor: true }, ' ' + prompt.help) : null,
h(
Box,
null,
h(Text, { color: accent }, ' \u203a '),
h(Text, { bold: true, color: accent }, prompt.message + ' '),
h(SafeTextInput, {
value,
onChange: handleChange,
onSubmit: submit,
placeholder: prompt.placeholder || '',
}),
),
);
}
/** A vertical single-select list (arrow keys + Enter). */
export function SelectPrompt({ prompt, store }) {
const accent = prompt.color || 'cyan';
const choices = prompt.choices || [];
const [active, setActive] = useState(() => {
if (prompt.default === undefined) return 0;
const i = choices.findIndex((c) => c.value === prompt.default);
return i < 0 ? 0 : i;
});
useInput((input, key) => {
if (key.ctrl && input === 'c') return store.cancel();
if (key.upArrow) {
setActive((a) => (a - 1 + choices.length) % choices.length);
return;
}
if (key.downArrow) {
setActive((a) => (a + 1) % choices.length);
return;
}
if (key.return) {
const pick = choices[active];
if (pick) store.resolve(pick.value);
}
});
return h(
Box,
{ flexDirection: 'column' },
h(Text, { bold: true, color: accent }, ' ' + prompt.message),
...choices.map((c, i) =>
h(
Text,
{ key: i },
i === active ? h(Text, { color: accent }, ' \u276f ') : h(Text, null, ' '),
i === active ? h(Text, { color: accent, bold: true }, c.name) : h(Text, null, c.name),
),
),
h(Text, { dimColor: true }, ' \u2191/\u2193 choose \u00b7 Enter select \u00b7 Ctrl+C cancel'),
);
}
/** A yes/no confirmation (y / n / Enter, default configurable). */
export function ConfirmPrompt({ prompt, store }) {
const accent = prompt.color || 'cyan';
const def = prompt.default !== false; // default yes unless explicitly false
useInput((input, key) => {
if (key.ctrl && input === 'c') return store.cancel();
if (input === 'y' || input === 'Y') return store.resolve(true);
if (input === 'n' || input === 'N') return store.resolve(false);
if (key.return) return store.resolve(def);
});
return h(
Box,
{ flexDirection: 'column' },
h(Text, { bold: true, color: accent }, ' ' + prompt.message),
h(
Text,
{ dimColor: true },
def ? ' (Y/n) ' : ' (y/N) ',
h(Text, { color: accent }, 'Ctrl+C cancel'),
),
);
}

32
src/ui/safe-text-input.js Normal file
View File

@@ -0,0 +1,32 @@
'use strict';
/**
* ink-text-input does not know about SGR mouse reports. If one reaches it,
* the report is treated as ordinary printable input and appears in the
* prompt. Filter it before the parent sees the new value and remount the
* underlying input so its private cursor state cannot retain the length of
* the discarded report.
*/
import { createElement as h, useState } from 'react';
import TextInput from 'ink-text-input';
import { stripMouseSequences } from './mouse.js';
export function SafeTextInput(props) {
const [generation, setGeneration] = useState(0);
const handleChange = (next) => {
const cleaned = stripMouseSequences(next);
if (cleaned !== next) {
setGeneration((value) => value + 1);
}
props.onChange?.(cleaned);
};
return h(TextInput, {
...props,
key: generation,
onChange: handleChange,
});
}

272
src/ui/store.js Normal file
View File

@@ -0,0 +1,272 @@
'use strict';
/**
* External store for the Ink UI.
*
* Fido's command loop and its tools are plain async functions, not React
* components, so they talk to the view through this store rather than writing
* to the terminal directly:
*
* - `log(text)` append a permanent line to the transcript (history
* pane, windowed to the terminal height; store keeps
* the last 1000 entries, the view mounts the tail).
* - `setTurnState(s)` set the connection state shown while a turn is running
* (one of TURN_STATES; see ../turn-state.js).
* - `setStatusDetail(t)` optional, non-authoritative provider status text
* layered on top of the state label.
* - `setBusy(bool)` toggle between "model working" and "awaiting input".
* - `scrollBy(n)` / `scrollToBottom()` scroll the history pane up/down.
* - `addUsage(u)` accumulate token/cost totals for the footer box.
* - `setMeta(patch)` header chrome (model / workspace / reasoning).
* - `prompt(request)` install a prompt the <App> renders and return a
* promise that settles when the user answers (resolve)
* or cancels with Ctrl+C (reject).
* - `onCancel(fn)` register a listener for Ctrl+C while no prompt is
* shown (i.e. a turn is running) so the turn is aborted
* instead of exiting.
* - `runExternally(fn)` hand the terminal to a child process (e.g. $EDITOR)
* via Ink's `suspendTerminal`, then restore it.
*
* The store is intentionally tiny: immutable snapshot state plus a Set of
* subscribers. React subscribes through `useSyncExternalStore`.
*/
import { TURN_STATES, isTurnState } from '../turn-state.js';
import { countHistoryLines, formatReasoning } from './app.js';
export function createStore() {
let idSeq = 0;
const MAX_ENTRIES = 1000;
let state = {
entries: [],
prompt: null,
reasoningDraft: '',
busy: false,
// One of TURN_STATES, not free text: the view maps it to a label.
status: TURN_STATES.IDLE,
// Optional provider-emitted status text, shown as a detail only.
statusDetail: '',
// Rendered lines hidden above the live tail. 0 = follow.
scroll: 0,
totals: { calls: 0, input: 0, output: 0, cached: 0, cost: 0 },
meta: { model: '', workspace: '', reasoning: '' },
};
const listeners = new Set();
const cancelListeners = new Set();
const setState = (patch) => {
state = { ...state, ...patch };
for (const l of listeners) l();
};
const clampScroll = (scroll, entries) => {
if (!(scroll > 0)) return 0;
const totalLines = countHistoryLines(entries);
return Math.max(0, Math.min(scroll, Math.max(0, totalLines - 1)));
};
const appendEntry = (entries, text) => {
const str = text == null ? '' : String(text);
if (!str) return entries;
let next = [...entries, { id: ++idSeq, text: str }];
// Bound memory + per-frame reconciler work (fullscreen re-renders
// history every frame, unlike <Static>'s write-once). Drop oldest.
if (next.length > MAX_ENTRIES) next = next.slice(-MAX_ENTRIES);
return next;
};
const store = {
getState: () => state,
subscribe: (l) => {
listeners.add(l);
return () => listeners.delete(l);
},
/** Append a permanent transcript line (a pre-rendered ANSI string). */
log(text) {
let entries = state.entries;
if (state.reasoningDraft && state.reasoningDraft.trim()) {
const lines = formatReasoning(state.reasoningDraft, false);
for (const line of lines) {
entries = appendEntry(entries, line);
}
}
entries = appendEntry(entries, text);
if (entries === state.entries && !state.reasoningDraft) return;
setState({
entries,
reasoningDraft: '',
scroll: clampScroll(state.scroll, entries),
});
},
/**
* Set the connection state shown while busy. Values outside the closed
* TURN_STATES enum are ignored so a bad caller can never blank the
* command bar; unknown events are resolved by the reducer in
* ../turn-state.js before they get here.
*/
setTurnState(status) {
if (!isTurnState(status)) return;
if (state.status !== status) setState({ status });
},
/**
* Optional detail text from the provider (e.g. 'connected'). Never
* authoritative: the state label always wins, and this is cleared
* whenever the state changes so it cannot go stale.
*/
setStatusDetail(detail) {
const next = detail == null ? '' : String(detail);
if (state.statusDetail !== next) setState({ statusDetail: next });
},
setBusy(busy) {
const next = !!busy;
// Leaving the busy state always resets to idle, so no path can
// strand the command bar showing a spinner after the turn is over.
setState({
busy: next,
status: next ? state.status : TURN_STATES.IDLE,
statusDetail: next ? state.statusDetail : '',
});
},
/** Scroll history up (+) / down (-) by N rendered lines. Clamped. */
scrollBy(delta) {
const d = Math.trunc(delta) || 0;
if (!d) return;
setState({ scroll: clampScroll(state.scroll + d, state.entries) });
},
/** Jump back to the live tail. */
scrollToBottom() {
if (state.scroll !== 0) setState({ scroll: 0 });
},
/** Accumulate one model-call usage record into the footer totals. */
addUsage(u) {
if (!u) return;
const t = state.totals;
setState({
totals: {
calls: t.calls + 1,
input: t.input + (u.input || 0),
output: t.output + (u.output || 0),
cached: t.cached + (u.cached || 0),
cost: t.cost + (u.cost || 0),
},
});
},
/** Static chrome data for the header (model / workspace / reasoning). */
setMeta(patch) {
setState({ meta: { ...state.meta, ...(patch || {}) } });
},
/** Install a prompt; the returned promise settles when it is answered. */
prompt(request) {
return new Promise((resolve, reject) => {
const prompt = { ...request, resolve, reject };
setState({ prompt });
});
},
/** Update the live reasoning text shown in history while the model reasons. */
setReasoningDraft(value) {
const reasoningDraft = value == null ? '' : String(value);
if (reasoningDraft !== state.reasoningDraft) {
setState({ reasoningDraft });
}
},
/** Finalize live reasoning into permanent history entries. */
finalizeReasoning(customText) {
const raw = customText !== undefined ? customText : state.reasoningDraft;
if (!raw || !raw.trim()) {
if (state.reasoningDraft) setState({ reasoningDraft: '' });
return;
}
const lines = formatReasoning(raw, false);
let entries = state.entries;
for (const line of lines) {
entries = appendEntry(entries, line);
}
setState({
entries,
reasoningDraft: '',
scroll: clampScroll(state.scroll, entries),
});
},
/** Clear live reasoning without committing to history. */
clearReasoning() {
if (state.reasoningDraft) {
setState({ reasoningDraft: '' });
}
},
/**
* Called by the view when the user answers the active prompt.
* `historyText` is appended as one permanent transcript entry as the
* prompt closes.
*/
resolve(value, historyText) {
const p = state.prompt;
if (!p) return;
const entries = appendEntry(state.entries, historyText);
setState({
...(entries === state.entries ? {} : { entries }),
prompt: null,
scroll: clampScroll(state.scroll, entries),
});
p.resolve(value);
},
/** Called by the view when the user cancels the active prompt. */
cancel() {
const p = state.prompt;
if (!p) return;
setState({
prompt: null,
scroll: clampScroll(state.scroll, state.entries),
});
p.reject(new Error('User cancelled'));
},
/** Ctrl+C while no prompt is shown: notify cancel listeners. */
emitCancel() {
for (const l of cancelListeners) l();
},
onCancel(fn) {
cancelListeners.add(fn);
return () => cancelListeners.delete(fn);
},
// Bridge to Ink's `suspendTerminal`, registered by <App> on mount.
_suspend: null,
// Bridge to the App's terminal mouse-mode controller.
_setMouseTracking: null,
async runExternally(fn) {
if (!store._suspend) throw new Error('UI is not mounted');
// Ink's suspendTerminal callback intentionally resolves with no
// value. Capture the callback result here so callers such as
// /paste receive the text written by the external editor.
let result;
try {
await store._suspend(async () => {
result = await fn();
});
return result;
} finally {
// Exiting the alternate screen for an external editor can cause
// some terminals to reset mouse tracking. Restore it when Fido
// takes the terminal back.
store._setMouseTracking?.(true);
}
},
};
return store;
}

148
test/compact.test.js Normal file
View File

@@ -0,0 +1,148 @@
'use strict';
/**
* Tests for invalidateStaleReadResults in src/compact.js.
*
* Run with: node --test
* (or) node --test test/compact.test.js
*
* These are pure unit tests: they build synthetic transcript arrays and never
* call a model or touch the network. Importing compact.js also loads config.js
* (dotenv + ROOT_DIR) which is harmless and needs no API key.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { invalidateStaleReadResults } from '../src/compact.js';
// A file body long enough that the replacement marker is strictly smaller.
const BODY = Array.from({ length: 40 }, (_, i) => `line ${i} of the file body`).join('\n');
// Assistant message declaring a tool call.
const call = (id, name, args) => ({
role: 'assistant',
tool_calls: [{ id, function: { name, arguments: JSON.stringify(args) } }],
});
// A read_file tool result carrying a whole file body.
const read = (id, filename, content = BODY) =>
({ role: 'tool', tool_call_id: id, content });
// A patch tool result. `applied` is the array of mutation tags the real
// applyOperations emits, e.g. ["PATCH in src/a.js"].
const patchResult = (id, applied) =>
({ role: 'tool', tool_call_id: id, content: JSON.stringify({ applied }) });
const isMarker = (content) => {
try {
const parsed = JSON.parse(content);
return typeof parsed.note === 'string' && parsed.note.includes('overwritten meanwhile');
} catch {
return false;
}
};
test('read before a later patch is invalidated', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
const n = invalidateStaleReadResults(messages);
assert.equal(n, 1);
assert.ok(isMarker(messages[1].content), 'read result replaced with marker');
assert.equal(JSON.parse(messages[1].content).filename, 'src/a.js');
});
test('read after the last patch is left intact (still matches disk)', () => {
const messages = [
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[3].content, BODY);
});
test('a file no patch ever wrote is left intact', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/untouched.js' }),
read('r1', 'src/untouched.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, BODY);
});
test('a failed patch (no applied entries) invalidates nothing', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', []), // failure => nothing actually written
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, BODY);
});
test('partial failure: appliedSoFar still invalidates a written file', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
{ role: 'tool', tool_call_id: 'p1', content: JSON.stringify({ appliedSoFar: ['CREATE/WRITE src/a.js'], error: 'boom' }) },
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('CREATE/WRITE tag is recognised', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/new.js' }),
read('r1', 'src/new.js'),
call('p1', 'patch', { operations: [{ file: 'src/new.js', search: null, replace: 'hi' }] }),
patchResult('p1', ['CREATE/WRITE src/new.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('path canonicalization: ./src/a.js matches src/a.js', () => {
const messages = [
call('r1', 'read_file', { filename: './src/a.js' }),
read('r1', './src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
assert.ok(isMarker(messages[1].content));
});
test('size guard: a tiny read is never traded for a longer marker', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js', 'tiny'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, 'tiny');
});
test('idempotent: a second pass changes nothing', () => {
const messages = [
call('r1', 'read_file', { filename: 'src/a.js' }),
read('r1', 'src/a.js'),
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
patchResult('p1', ['PATCH in src/a.js']),
];
assert.equal(invalidateStaleReadResults(messages), 1);
const afterFirst = messages[1].content;
assert.equal(invalidateStaleReadResults(messages), 0);
assert.equal(messages[1].content, afterFirst);
});

146
test/file-browser.test.js Normal file
View File

@@ -0,0 +1,146 @@
'use strict';
/**
* Tests for the file-browser helpers in src/file-browser.js.
*
* Run with: node --test
* (or) node --test test/file-browser.test.js
*
* Pure unit tests except for `listEntries`/`readLines`, which read the real
* workspace (ROOT_DIR defaults to the process cwd, i.e. the repo root).
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
listEntries,
readLines,
formatMention,
normalizeRange,
windowStart,
parentOf,
baseOf,
} from '../src/file-browser.js';
// ─── mention formatting ───────────────────────────────────────────────────────
test('formatMention: whole file', () => {
assert.equal(formatMention('a/b.js'), '@a/b.js');
});
test('formatMention: single line collapses start==end', () => {
assert.equal(formatMention('a/b.js', 10, 10), '@a/b.js:10');
assert.equal(formatMention('a/b.js', 10, null), '@a/b.js:10');
});
test('formatMention: line range', () => {
assert.equal(formatMention('a/b.js', 10, 20), '@a/b.js:10-20');
});
test('formatMention: normalizes backslashes and trailing slashes', () => {
assert.equal(formatMention('a\\b.js', 1, 2), '@a/b.js:1-2');
assert.equal(formatMention('src/', 1, 2), '@src:1-2');
});
// ─── range normalization ──────────────────────────────────────────────────────
test('normalizeRange: anchor above cursor', () => {
assert.deepEqual(normalizeRange(2, 5), { start: 3, end: 6 });
});
test('normalizeRange: anchor below cursor', () => {
assert.deepEqual(normalizeRange(5, 2), { start: 3, end: 6 });
});
test('normalizeRange: single line', () => {
assert.deepEqual(normalizeRange(4, 4), { start: 5, end: 5 });
});
// ─── windowing ────────────────────────────────────────────────────────────────
test('windowStart: everything fits', () => {
assert.equal(windowStart(0, 5, 200), 0);
assert.equal(windowStart(4, 5, 200), 0);
});
test('windowStart: centers around the cursor', () => {
assert.equal(windowStart(100, 1000, 20), 90);
});
test('windowStart: clamps at the top', () => {
assert.equal(windowStart(0, 1000, 20), 0);
assert.equal(windowStart(3, 1000, 20), 0);
});
test('windowStart: clamps at the bottom', () => {
assert.equal(windowStart(999, 1000, 20), 980);
});
// ─── path helpers ─────────────────────────────────────────────────────────────
test('parentOf / baseOf', () => {
assert.equal(parentOf(''), '');
assert.equal(parentOf('src'), '');
assert.equal(parentOf('src/tools'), 'src');
assert.equal(baseOf('src/tools'), 'tools');
assert.equal(baseOf('README.md'), 'README.md');
});
// ─── listing (real workspace) ─────────────────────────────────────────────────
test('listEntries: dirs before files, dot-dirs and gitignored hidden', () => {
const entries = listEntries('');
const names = entries.map((e) => e.name);
// node_modules is hardcoded-ignored, .git is a dot-dir: both must be absent.
assert.ok(!names.includes('node_modules'), 'node_modules hidden');
assert.ok(!names.includes('.git'), '.git hidden');
// A root listing has no synthetic parent entry.
assert.ok(!names.includes('..'), 'no parent entry at root');
// src/ is a directory and README.md a file: directory must come first.
const srcIdx = names.indexOf('src');
const readmeIdx = names.indexOf('README.md');
assert.ok(srcIdx !== -1, 'src listed');
assert.ok(readmeIdx !== -1, 'README.md listed');
assert.ok(srcIdx < readmeIdx, 'directories sort before files');
const src = entries.find((e) => e.name === 'src');
assert.equal(src.type, 'd');
assert.equal(src.rel, 'src');
const readme = entries.find((e) => e.name === 'README.md');
assert.equal(readme.type, 'f');
assert.equal(readme.rel, 'README.md');
});
test('listEntries: nested listing prepends a parent entry', () => {
const entries = listEntries('src');
assert.equal(entries[0].name, '..');
assert.equal(entries[0].type, 'parent');
assert.equal(entries[0].rel, '');
assert.ok(entries.some((e) => e.name === 'fido.js' && e.type === 'f'));
});
// ─── reading (real workspace) ─────────────────────────────────────────────────
test('readLines: reads a real file into lines', () => {
const { lines, binary } = readLines('README.md');
assert.equal(binary, false);
assert.ok(Array.isArray(lines));
assert.ok(lines.length > 0);
assert.ok(lines[0].includes('FIDO'));
});
test('readLines: rejects paths that escape the workspace', () => {
const res = readLines('../secrets.txt');
assert.equal(res.error, true);
assert.deepEqual(res.lines, []);
});
test('readLines: missing file reports error, not a throw', () => {
const res = readLines('does/not/exist.js');
assert.equal(res.error, true);
assert.deepEqual(res.lines, []);
});

61
test/glob.test.js Normal file
View File

@@ -0,0 +1,61 @@
'use strict';
/**
* Tests for the glob helpers in src/glob.js, shared by ripgrep and find_files.
*
* Run with: node --test
* (or) node --test test/glob.test.js
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { escapeRegExp, globToRegExp, makeGlobMatcher } from '../src/glob.js';
test('escapeRegExp: escapes regex metacharacters', () => {
assert.equal(escapeRegExp('a.b*c'), 'a\\.b\\*c');
assert.ok(new RegExp(escapeRegExp('a.b')).test('a.b'));
assert.ok(!new RegExp(escapeRegExp('a.b')).test('axb'));
});
test('globToRegExp: * stays within a path segment', () => {
const rx = globToRegExp('*.js');
assert.ok(rx.test('a.js'));
assert.ok(!rx.test('src/a.js'));
});
test('globToRegExp: ** crosses path separators', () => {
const rx = globToRegExp('src/**/*.js');
assert.ok(rx.test('src/a/b/c.js'));
// "**/" also matches zero directories.
assert.ok(rx.test('src/a.js'));
});
test('globToRegExp: ? matches a single non-slash char', () => {
const rx = globToRegExp('a?c');
assert.ok(rx.test('abc'));
assert.ok(!rx.test('ac'));
assert.ok(!rx.test('a/c'));
});
test('makeGlobMatcher: slashless glob also matches the basename', () => {
const m = makeGlobMatcher('*.js');
assert.ok(m('src/a.js', 'a.js'));
assert.ok(!m('src/a.ts', 'a.ts'));
});
test('makeGlobMatcher: glob with a slash matches the whole path', () => {
const m = makeGlobMatcher('src/**/*.js');
assert.ok(m('src/tools/a.js', 'a.js'));
assert.ok(!m('test/a.js', 'a.js'));
});
test('makeGlobMatcher: ignores a trailing slash on directories', () => {
const m = makeGlobMatcher('node_modules');
assert.ok(m('node_modules/', 'node_modules'));
});
test('makeGlobMatcher: ignoreCase makes matching case-insensitive', () => {
assert.ok(makeGlobMatcher('*.JS', { ignoreCase: true })('a.js', 'a.js'));
assert.ok(!makeGlobMatcher('*.JS')('a.js', 'a.js'));
});

220
test/javascript.test.js Normal file
View File

@@ -0,0 +1,220 @@
'use strict';
/**
* javascript_execute — sandbox execution, isolation guarantees, and the
* timeout/serialization behaviour of the worker bridge.
*
* These tests spawn real worker threads and run real vm2 sandboxes: the
* security properties (a runaway loop, blocked host modules) are exactly
* what a mocked-out vm2 would fail to verify.
*
* Note: no test passes allow_network=true, because that path requires an
* interactive TTY approval prompt. Network plumbing is exercised separately
* in a scratch worker, not here.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { Worker } from 'worker_threads';
import { fileURLToPath } from 'url';
import { javascriptExecuteTool } from '../src/tools/javascript.js';
const WORKER_PATH = fileURLToPath(new URL('../src/tools/javascript-worker.js', import.meta.url));
/** Run a snippet through the real tool, normalizing errors to a message. */
const exec = (args) =>
javascriptExecuteTool.invoke(args).then(
(result) => ({ ok: true, result }),
(err) => ({ ok: false, error: err.message }),
);
/**
* Drive the worker directly, bypassing the approval layer, so worker-level
* behaviour (timeouts, serialization) can be asserted without a TTY.
*/
function runInWorker(code, { network = false, timeout = 5000, cap = 20_000 } = {}) {
return new Promise((resolve) => {
const worker = new Worker(WORKER_PATH, {
workerData: {
code,
root: process.cwd(),
vmTimeout: timeout,
bufferLimit: 32 * 1024 * 1024,
network,
},
});
const bail = setTimeout(() => {
worker.terminate();
resolve({ timedOut: true });
}, cap);
worker.on('message', (msg) => {
clearTimeout(bail);
worker.terminate();
resolve(msg);
});
worker.on('error', (err) => {
clearTimeout(bail);
worker.terminate();
resolve({ error: err.message });
});
});
}
// ─── Result serialization ───────────────────────────────────────────────────
test('a snippet returns its value via module.exports', async () => {
const { ok, result } = await exec({ code: 'module.exports = 6 * 7;' });
assert.ok(ok, 'expected the call to succeed');
assert.equal(result.result, 42);
});
test('nested objects and arrays survive the worker boundary', async () => {
// vm2 returns Proxy objects; structured-clone rejects them outright, so
// the worker rebuilds plain host containers before posting.
const { ok, result } = await exec({
code: 'module.exports = { a: 1, b: [1, 2, { c: true }], d: "x" };',
});
assert.ok(ok);
assert.deepEqual(result.result, { a: 1, b: [1, 2, { c: true }], d: 'x' });
});
test('a returned array is not reported as unserializable', async () => {
const { ok, result } = await exec({ code: 'module.exports = [[1, 2], [3, { d: 4 }]];' });
assert.ok(ok);
assert.deepEqual(result.result, [[1, 2], [3, { d: 4 }]]);
});
test('a returned function degrades to a type marker instead of throwing', async () => {
const { ok, result } = await exec({ code: 'module.exports = function f() {};' });
assert.ok(ok);
assert.deepEqual(result.result, { type: 'function' });
});
test('values nested deeper than the depth limit are summarized, not dropped silently', async () => {
const { ok, result } = await exec({ code: 'module.exports = { a: { b: { c: { d: { e: 1 } } } } };' });
assert.ok(ok);
assert.equal(result.result.a.b.c.d.type, 'object');
assert.match(result.result.a.b.c.d.note, /deeply nested/);
});
// ─── Console capture ────────────────────────────────────────────────────────
test('console output is captured and returned rather than printed to the terminal', async () => {
const { ok, result } = await exec({
code: 'console.log("plain"); console.error("boom"); module.exports = 1;',
});
assert.ok(ok);
assert.equal(result.stdout, 'plain\nerror: boom');
assert.equal(result.consoleLines, 2);
});
test('console output beyond max_output is capped and flagged', async () => {
const { ok, result } = await exec({
code: 'for (let i = 0; i < 100; i++) console.log("x".repeat(20));',
max_output: 60,
});
assert.ok(ok);
assert.equal(result.truncated, true);
assert.ok(result.stdout.length <= 60, 'stdout must respect the cap');
assert.match(result.truncationNote, /60 chars/);
});
// ─── Errors ─────────────────────────────────────────────────────────────────
test('a thrown error surfaces as a ToolError carrying name, message and stack', async () => {
const { ok, error } = await exec({ code: 'null.x;' });
assert.equal(ok, false);
assert.match(error, /Cannot read properties of null/);
assert.match(error, /TypeError/);
});
test('a syntax error is reported rather than crashing the worker', async () => {
const { ok, error } = await exec({ code: 'const = ;' });
assert.equal(ok, false);
assert.ok(error.length > 0);
});
test('blank code is rejected before any worker is spawned', async () => {
const { ok, error } = await exec({ code: ' ' });
assert.equal(ok, false);
assert.match(error, /non-empty string/);
});
test('a non-positive timeout is rejected', async () => {
const { ok, error } = await exec({ code: '1;', timeout: 0 });
assert.equal(ok, false);
assert.match(error, /positive integer/);
});
// ─── Timeout: the reason this runs in a worker at all ───────────────────────
test('an infinite loop is killed at the timeout instead of hanging', async () => {
// The core guarantee. NodeVM does not honor vm2's own `timeout`, so
// without the worker being terminated this call never returns.
const started = Date.now();
const { ok, result } = await exec({ code: 'while (true) {}', timeout: 1000 });
const elapsed = Date.now() - started;
assert.ok(ok, 'a timeout is a normal outcome, not a thrown error');
assert.equal(result.timedOut, true);
assert.ok(elapsed < 8000, `expected a prompt kill, took ${elapsed} ms`);
assert.match(result.note, /timeout/);
});
test('the worker-level hard kill also stops a blocking loop', async () => {
const report = await runInWorker('while (true) {}', { cap: 3000 });
assert.equal(report.timedOut, true);
});
// ─── Isolation ──────────────────────────────────────────────────────────────
test('built-in host modules are not reachable', async () => {
for (const mod of ['fs', 'child_process', 'os', 'node:fs']) {
const { ok, error } = await exec({ code: `require(${JSON.stringify(mod)});` });
assert.equal(ok, false, `${mod} must not be requireable`);
assert.match(error, /Cannot find module/);
}
});
test('node_modules packages are not reachable', async () => {
const { ok, error } = await exec({ code: 'require("chalk");' });
assert.equal(ok, false);
assert.match(error, /Cannot find module/);
});
test('vm2 itself is not reachable (no nesting escape)', async () => {
const { ok, error } = await exec({ code: 'require("vm2");' });
assert.equal(ok, false);
assert.match(error, /Cannot find module/);
});
test('fetch is absent unless network access was granted', async () => {
const offline = await runInWorker('module.exports = typeof fetch;');
assert.equal(offline.result, 'undefined');
});
// ─── Async ──────────────────────────────────────────────────────────────────
test('a promise assigned to module.exports is awaited', async () => {
const { ok, result } = await exec({
code: 'async function f() { return 41 + 1; }\nmodule.exports = f();',
});
assert.ok(ok);
assert.equal(result.result, 42);
});
test('a rejected promise is reported as an error', async () => {
const { ok, error } = await exec({
code: 'module.exports = (async () => { throw new Error("async boom"); })();',
});
assert.equal(ok, false);
assert.match(error, /async boom/);
});
test('a promise that never settles is bounded by the timeout', async () => {
const report = await runInWorker('module.exports = new Promise(() => {});', {
timeout: 800,
cap: 6000,
});
assert.match(String(report.error ?? ''), /did not settle/);
});

49
test/lines.test.js Normal file
View File

@@ -0,0 +1,49 @@
'use strict';
/**
* Tests for splitLines in src/lines.js.
*
* Run with: node --test
* (or) node --test test/lines.test.js
*
* The helper must count lines the way editors and `wc -l` do: a trailing
* newline terminates the final line rather than starting an extra empty one.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { splitLines } from '../src/lines.js';
test('splitLines: empty file has no lines', () => {
assert.deepEqual(splitLines(''), []);
});
test('splitLines: no trailing newline is unchanged', () => {
assert.deepEqual(splitLines('a\nb\nc'), ['a', 'b', 'c']);
});
test('splitLines: trailing newline does not add a phantom line', () => {
// The reported bug: this used to yield 4 elements (['a','b','c','']).
assert.deepEqual(splitLines('a\nb\nc\n'), ['a', 'b', 'c']);
assert.equal(splitLines('a\nb\nc\n').length, 3);
});
test('splitLines: terminated and non-terminated agree on line count', () => {
assert.equal(splitLines('a\nb\nc\n').length, splitLines('a\nb\nc').length);
});
test('splitLines: a real trailing blank line is preserved', () => {
assert.deepEqual(splitLines('a\n\n'), ['a', '']);
assert.equal(splitLines('a\n\n').length, 2);
});
test('splitLines: a lone newline is one empty line', () => {
assert.deepEqual(splitLines('\n'), ['']);
assert.equal(splitLines('\n').length, 1);
});
test('splitLines: multiple trailing newlines keep the real blank lines', () => {
assert.deepEqual(splitLines('a\n\n\n'), ['a', '', '']);
assert.equal(splitLines('a\n\n\n').length, 3);
});

55
test/mouse.test.js Normal file
View File

@@ -0,0 +1,55 @@
'use strict';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
wheelDeltaFromInput,
containsMouseSequence,
stripMouseSequences,
} from '../src/ui/mouse.js';
test('decodes SGR wheel direction', () => {
assert.equal(wheelDeltaFromInput('\x1b[<64;88;20M'), -1);
assert.equal(wheelDeltaFromInput('[<65;88;20M'), 1);
// Some terminals add modifier bits to the button code.
assert.equal(wheelDeltaFromInput('[<96;88;20M'), -1);
assert.equal(wheelDeltaFromInput('[<97;88;20M'), 1);
});
test('decodes legacy X10 wheel direction', () => {
const x10Up = `\x1b[M${String.fromCharCode(96)}!#`;
const x10Down = `\x1b[M${String.fromCharCode(97)}!#`;
assert.equal(wheelDeltaFromInput(x10Up), -1);
assert.equal(wheelDeltaFromInput(x10Down), 1);
assert.equal(wheelDeltaFromInput(x10Up.slice(1)), -1, 'accepts Ink-stripped ESC');
});
test('ignores non-wheel and incomplete mouse input', () => {
assert.equal(wheelDeltaFromInput('[<0;1;1M'), 0);
assert.equal(wheelDeltaFromInput('[<64;1;1'), 0);
assert.equal(wheelDeltaFromInput('hello'), 0);
});
test('removes mouse reports without dropping surrounding text', () => {
const value = 'before\x1b[<65;88;20Mafter';
assert.equal(containsMouseSequence(value), true);
assert.equal(stripMouseSequences(value), 'beforeafter');
assert.equal(stripMouseSequences('plain text'), 'plain text');
const x10 = `\x1b[M${String.fromCharCode(96)}!#`;
assert.equal(containsMouseSequence(x10), true);
assert.equal(stripMouseSequences(`before${x10}after`), 'beforeafter');
});
test('reassembles an SGR wheel report split across input events', () => {
assert.equal(wheelDeltaFromInput('[<64;'), 0);
assert.equal(wheelDeltaFromInput('88;20M'), -1);
// A normal key clears any stale partial sequence state.
assert.equal(wheelDeltaFromInput('x'), 0);
});
test('removes split SGR fragments from a text-input value', () => {
assert.equal(stripMouseSequences('before[<64;'), 'before');
assert.equal(stripMouseSequences('before88;20M'), 'before');
});

222
test/turn-flow.test.js Normal file
View File

@@ -0,0 +1,222 @@
'use strict';
/**
* Connection-state flow through the real store and the real REPL provider
* callbacks, driven by a fake provider. No live model call, no mounted UI: the
* orbiter is bypassed and its callbacks are replayed by hand, which is exactly
* what the provider contract guarantees.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement as h } from 'react';
import { renderToString } from 'ink';
import { createTurnHandlers, dispatchTurnEvent, stampUserInput } from '../src/repl.js';
import { TURN_EVENTS, TURN_STATES } from '../src/turn-state.js';
import { createStore } from '../src/ui/store.js';
import { App } from '../src/ui/app.js';
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
/**
* A fake provider turn. `script` is a list of provider callbacks to invoke in
* order, the way LLMOrbiter would during `orbiter.run(...)`. It records the
* state after every callback, so the observed label sequence is an assertion
* about the real wiring rather than about the reducer alone.
*/
function playTurn(script) {
const store = createStore();
const uiApi = {
store,
setTurnState: (state) => store.setTurnState(state),
setStatusDetail: (text) => store.setStatusDetail(text),
setReasoning: (text) => store.setReasoningDraft(text),
clearReasoning: () => store.setReasoningDraft(''),
finalizeReasoning: () => store.finalizeReasoning(),
log: (text) => store.log(text),
};
const turnReasoning = { text: '' };
const handlers = createTurnHandlers(turnReasoning, uiApi);
const observed = [];
const record = () => observed.push(store.getState().status);
store.setBusy(true);
dispatchTurnEvent(TURN_EVENTS.TURN_START, uiApi);
record();
for (const step of script) {
handlers[step.cb](step.arg);
record();
}
dispatchTurnEvent(TURN_EVENTS.TURN_END, uiApi);
store.setBusy(false);
record();
return { store, observed };
}
test('a user message is prefixed with a zero-padded 24-hour local time', () => {
const at = (h, m) => new Date(2026, 8, 17, h, m);
assert.equal(stampUserInput('add a test', at(14, 32)), '[14:32] add a test');
assert.equal(stampUserInput('list dirs', at(9, 5)), '[09:05] list dirs');
assert.equal(stampUserInput('midnight', at(0, 0)), '[00:00] midnight');
assert.equal(stampUserInput('last minute', at(23, 59)), '[23:59] last minute');
});
test('the timestamp prefix never touches the text it wraps', () => {
const at = new Date(2026, 8, 17, 8, 7);
const text = ' run websearch with start_date \n and a second line ';
assert.equal(stampUserInput(text, at), `[08:07] ${text}`);
assert.equal(stampUserInput('', at), '[08:07] ');
});
test('a plain answer reads as connecting -> streaming', () => {
const { observed } = playTurn([
{ cb: 'onContent' },
{ cb: 'onContent' },
]);
assert.deepEqual(observed, [
TURN_STATES.CONNECTING,
TURN_STATES.STREAMING,
TURN_STATES.STREAMING,
TURN_STATES.IDLE,
]);
});
test('reasoning then an answer reads as connecting -> thinking -> streaming', () => {
const { observed } = playTurn([
{ cb: 'onReasoning', arg: 'weighing options' },
{ cb: 'onReasoning', arg: 'weighing options carefully' },
{ cb: 'onContent' },
]);
assert.deepEqual(observed, [
TURN_STATES.CONNECTING,
TURN_STATES.THINKING,
TURN_STATES.THINKING,
TURN_STATES.STREAMING,
TURN_STATES.IDLE,
]);
});
test('reasoning, a tool, then an answer reads as connecting -> thinking -> working -> streaming', () => {
const { observed } = playTurn([
{ cb: 'onReasoning', arg: 'need to list the directory' },
{ cb: 'onToolCall', arg: { name: 'read_file', args: { path: 'README.md' } } },
{ cb: 'onContent' },
]);
assert.deepEqual(observed, [
TURN_STATES.CONNECTING,
TURN_STATES.THINKING,
TURN_STATES.WORKING,
TURN_STATES.STREAMING,
TURN_STATES.IDLE,
]);
});
test('a turn that produces no deltas stays in connecting until it ends', () => {
const { observed } = playTurn([]);
assert.deepEqual(observed, [TURN_STATES.CONNECTING, TURN_STATES.IDLE]);
});
test('each new turn starts at connecting with no bleed from the previous turn', () => {
const store = createStore();
const uiApi = { store, setTurnState: (s) => store.setTurnState(s) };
store.setBusy(true);
dispatchTurnEvent(TURN_EVENTS.TURN_START, uiApi);
dispatchTurnEvent(TURN_EVENTS.CONTENT, uiApi);
dispatchTurnEvent(TURN_EVENTS.TURN_END, uiApi);
store.setBusy(false);
assert.equal(store.getState().status, TURN_STATES.IDLE);
store.setBusy(true);
dispatchTurnEvent(TURN_EVENTS.TURN_START, uiApi);
assert.equal(
store.getState().status,
TURN_STATES.CONNECTING,
'the next turn begins at connecting, not the previous terminal state',
);
});
test('cancel and error both fall back to idle so the prompt returns', () => {
for (const event of [TURN_EVENTS.CANCEL, TURN_EVENTS.ERROR]) {
const store = createStore();
const uiApi = { store, setTurnState: (s) => store.setTurnState(s) };
store.setBusy(true);
dispatchTurnEvent(TURN_EVENTS.REASONING, uiApi);
dispatchTurnEvent(event, uiApi);
assert.equal(store.getState().status, TURN_STATES.IDLE, `${event} resolves to idle`);
store.setBusy(false);
assert.equal(store.getState().status, TURN_STATES.IDLE);
}
});
test('clearing busy returns the state to idle even without a turn_end event', () => {
const store = createStore();
store.setBusy(true);
store.setTurnState(TURN_STATES.STREAMING);
store.setBusy(false);
assert.equal(store.getState().status, TURN_STATES.IDLE, 'no path strands the bar busy');
assert.equal(store.getState().statusDetail, '', 'the stale detail is dropped too');
});
test('the store ignores values outside the closed state enum', () => {
const store = createStore();
store.setBusy(true);
store.setTurnState(TURN_STATES.THINKING);
store.setTurnState('thinking…');
assert.equal(store.getState().status, TURN_STATES.THINKING, 'free text cannot replace a state');
});
test('provider status text is a detail, never a replacement for the state', () => {
const store = createStore();
const uiApi = {
store,
setTurnState: (state) => store.setTurnState(state),
setStatusDetail: (text) => store.setStatusDetail(text),
setReasoning: () => {},
clearReasoning: () => {},
finalizeReasoning: () => {},
log: () => {},
};
const handlers = createTurnHandlers({ text: '' }, uiApi);
store.setBusy(true);
dispatchTurnEvent(TURN_EVENTS.TURN_START, uiApi);
handlers.onStatus('connected');
assert.equal(
store.getState().status,
TURN_STATES.CONNECTING,
'status text does not move the state out of connecting',
);
assert.equal(store.getState().statusDetail, 'connected', 'it is kept as a detail');
// A later delta still wins, even with provider text present.
handlers.onContent();
assert.equal(store.getState().status, TURN_STATES.STREAMING, 'the delta drives the state');
assert.equal(store.getState().statusDetail, 'connected', 'the detail survives as decoration');
});
test('the command bar renders the label for every non-idle state', () => {
const expectations = [
[TURN_STATES.CONNECTING, 'connecting…'],
[TURN_STATES.THINKING, 'thinking…'],
[TURN_STATES.WORKING, 'working…'],
[TURN_STATES.STREAMING, 'streaming…'],
];
for (const [state, label] of expectations) {
const store = createStore();
store.setBusy(true);
store.setTurnState(state);
const out = strip(renderToString(h(App, { store, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
assert.ok(out.includes(label), `${state} renders as ${label}`);
assert.ok(!out.includes('› (idle)'), `${state} is not shown as idle`);
}
});
test('the idle placeholder shows only when no turn is running', () => {
const store = createStore();
store.setBusy(false);
const out = strip(renderToString(h(App, { store, sizeOverride: { columns: 80, rows: 24 } }), { columns: 80 }));
assert.ok(out.includes('› (idle)'), 'idle placeholder renders when not busy');
});

147
test/turn-state.test.js Normal file
View File

@@ -0,0 +1,147 @@
'use strict';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TURN_STATES,
TURN_EVENTS,
isTurnState,
nextState,
statusLabel,
} from '../src/turn-state.js';
// Every state the machine can be in when a delta arrives.
const BUSY_STATES = [
TURN_STATES.CONNECTING,
TURN_STATES.THINKING,
TURN_STATES.WORKING,
TURN_STATES.STREAMING,
];
const DELTA_EVENTS = [TURN_EVENTS.REASONING, TURN_EVENTS.TOOL_CALL, TURN_EVENTS.CONTENT];
test('the state set is closed and enumerated', () => {
assert.deepEqual(Object.values(TURN_STATES), [
'idle', 'connecting', 'thinking', 'working', 'streaming',
]);
for (const state of Object.values(TURN_STATES)) {
assert.equal(isTurnState(state), true, `${state} is a known state`);
}
for (const value of ['', 'thinking…', 'IDLE', null, undefined, 3, {}]) {
assert.equal(isTurnState(value), false, `${String(value)} is not a state`);
}
});
test('turn_start always yields connecting, from any state', () => {
for (const current of Object.values(TURN_STATES)) {
assert.equal(
nextState(current, TURN_EVENTS.TURN_START),
TURN_STATES.CONNECTING,
`turn_start from ${current}`,
);
}
});
test('turn end, cancel, and error always resolve to idle', () => {
for (const current of Object.values(TURN_STATES)) {
for (const event of [TURN_EVENTS.TURN_END, TURN_EVENTS.CANCEL, TURN_EVENTS.ERROR]) {
assert.equal(
nextState(current, event),
TURN_STATES.IDLE,
`${event} from ${current}`,
);
}
}
});
test('each delta kind maps to its state from every state', () => {
const expected = {
[TURN_EVENTS.REASONING]: TURN_STATES.THINKING,
[TURN_EVENTS.TOOL_CALL]: TURN_STATES.WORKING,
[TURN_EVENTS.CONTENT]: TURN_STATES.STREAMING,
};
for (const from of [...BUSY_STATES, TURN_STATES.IDLE]) {
for (const event of DELTA_EVENTS) {
assert.equal(
nextState(from, event),
expected[event],
`${event} from ${from}`,
);
}
}
});
test('a later delta moves the state backward, never stalling on the furthest point', () => {
// Streaming back to thinking, and thinking back to working.
assert.equal(
nextState(TURN_STATES.STREAMING, TURN_EVENTS.REASONING),
TURN_STATES.THINKING,
'a reasoning delta after content moves back to thinking',
);
assert.equal(
nextState(TURN_STATES.THINKING, TURN_EVENTS.TOOL_CALL),
TURN_STATES.WORKING,
'a tool-call delta after reasoning moves back to working',
);
assert.equal(
nextState(TURN_STATES.WORKING, TURN_EVENTS.CONTENT),
TURN_STATES.STREAMING,
'content after a tool call moves forward to streaming',
);
});
test('a repeated delta of the same kind still updates the state', () => {
for (const event of DELTA_EVENTS) {
const first = nextState(TURN_STATES.CONNECTING, event);
const again = nextState(first, event);
assert.equal(again, first, `${event} is idempotent on its own state`);
}
});
test('a turn with no reasoning and no tools goes connecting -> streaming', () => {
let state = nextState(TURN_STATES.IDLE, TURN_EVENTS.TURN_START);
assert.equal(state, TURN_STATES.CONNECTING);
for (let i = 0; i < 3; i++) state = nextState(state, TURN_EVENTS.CONTENT);
assert.equal(state, TURN_STATES.STREAMING);
state = nextState(state, TURN_EVENTS.TURN_END);
assert.equal(state, TURN_STATES.IDLE);
});
test('a reasoning-then-tool turn goes connecting -> thinking -> working', () => {
let state = nextState(TURN_STATES.IDLE, TURN_EVENTS.TURN_START);
const states = [state];
for (const event of [TURN_EVENTS.REASONING, TURN_EVENTS.TOOL_CALL, TURN_EVENTS.TURN_END]) {
state = nextState(state, event);
states.push(state);
}
assert.deepEqual(states, ['connecting', 'thinking', 'working', 'idle']);
});
test('no turn ever begins in a busy state and no delta strands the session', () => {
for (const terminal of [TURN_EVENTS.TURN_END, TURN_EVENTS.CANCEL, TURN_EVENTS.ERROR]) {
const state = nextState(TURN_STATES.STREAMING, terminal);
const restarted = nextState(state, TURN_EVENTS.TURN_START);
assert.equal(restarted, TURN_STATES.CONNECTING, 'a new turn always starts at connecting');
}
});
test('the reducer is total: an unknown event never throws and never invents a state', () => {
for (const current of [...Object.values(TURN_STATES), 'bogus', undefined, null]) {
for (const event of ['nope', undefined, null, 42]) {
const next = nextState(current, event);
assert.equal(isTurnState(next), true, `unknown event keeps a real state`);
}
}
// A known current state survives an unknown event; an invalid one resets.
assert.equal(nextState(TURN_STATES.WORKING, 'nope'), TURN_STATES.WORKING);
assert.equal(nextState('bogus', 'nope'), TURN_STATES.IDLE);
});
test('statusLabel names each busy state and leaves idle to the placeholder', () => {
assert.equal(statusLabel(TURN_STATES.CONNECTING), 'connecting…');
assert.equal(statusLabel(TURN_STATES.THINKING), 'thinking…');
assert.equal(statusLabel(TURN_STATES.WORKING), 'working…');
assert.equal(statusLabel(TURN_STATES.STREAMING), 'streaming…');
assert.equal(statusLabel(TURN_STATES.IDLE), '', 'idle is not rendered by the spinner');
assert.equal(statusLabel('bogus'), '', 'an unknown value renders no busy label');
});

115
test/ui.test.js Normal file
View File

@@ -0,0 +1,115 @@
'use strict';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement as h } from 'react';
import { PassThrough } from 'node:stream';
import { render, renderToString } from 'ink';
import { App, getHistoryWindow } from '../src/ui/app.js';
import { createStore } from '../src/ui/store.js';
const stripAnsi = (value) => value.replace(/\x1b\[[0-9;]*m/g, '');
const historyLines = (output) => stripAnsi(output)
.split('\n')
.filter((line) => /^F\d+$/.test(line));
test('history stays bottom-anchored and scrolls through a long transcript', () => {
const store = createStore();
store.setMeta({ model: 'test-model', workspace: '/test' });
store.prompt({ type: 'main', message: 'You' });
for (let i = 1; i <= 1000; i++) store.log(`F${i}`);
const live = renderToString(
h(App, { store, sizeOverride: { columns: 80, rows: 24 } }),
{ columns: 80 },
);
const liveLines = historyLines(live);
assert.equal(liveLines.at(-1), 'F1000', 'live view ends at the current tail');
assert.equal(liveLines.length, 14, 'window is sized to the available pane');
store.scrollBy(10);
const scrolled = renderToString(
h(App, { store, sizeOverride: { columns: 80, rows: 24 } }),
{ columns: 80 },
);
const scrolledLines = historyLines(scrolled);
assert.equal(scrolledLines.at(-1), 'F990', 'scrolling moves the window toward older lines');
assert.ok(!scrolledLines.includes('F1000'), 'scrolled view hides the live tail');
});
test('App translates SGR wheel reports to history scroll', async () => {
const stdin = new PassThrough();
stdin.isTTY = true;
stdin.setRawMode = () => {};
stdin.setEncoding = () => {};
stdin.ref = () => stdin;
stdin.unref = () => stdin;
const stdout = new PassThrough();
stdout.isTTY = true;
stdout.columns = 80;
stdout.rows = 24;
const store = createStore();
store.prompt({ type: 'main', message: 'You' });
for (let i = 1; i <= 30; i++) store.log(`wheel line ${i}`);
const instance = render(h(App, { store }), {
stdin,
stdout,
alternateScreen: false,
exitOnCtrlC: false,
patchConsole: false,
});
try {
await new Promise((resolve) => setTimeout(resolve, 50));
stdin.push('\x1b[<64;88;20M');
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(store.getState().scroll, 3);
} finally {
instance.unmount();
await instance.waitUntilExit();
}
});
test('history window helper bounds the mounted line count', () => {
const entries = Array.from({ length: 200 }, (_, i) => ({
id: i,
text: `line ${i}`,
}));
const window = getHistoryWindow(entries, 0, 10);
assert.equal(window.lines.length, 10);
assert.equal(window.lines.at(-1).text, 'line 199');
assert.equal(window.start, 190);
assert.equal(window.end, 200);
});
test('runExternally returns the callback result through Ink suspension', async () => {
const store = createStore();
store._suspend = async (callback) => {
await callback();
};
let mouseTrackingRestores = 0;
store._setMouseTracking = (on) => {
if (on) mouseTrackingRestores++;
};
assert.equal(await store.runExternally(() => 'saved editor text'), 'saved editor text');
assert.equal(mouseTrackingRestores, 1, 'mouse tracking is restored after suspension');
});
test('finalizing reasoning or submitting preserves the history offset', async () => {
const store = createStore();
for (let i = 1; i <= 10; i++) store.log(`line ${i}`);
store.scrollBy(3);
store.setReasoningDraft('checking the files');
store.finalizeReasoning();
assert.equal(store.getState().scroll, 3, 'finalizing does not jump to the tail');
const prompt = store.prompt({ type: 'main', message: 'You' });
store.resolve('next question', '› You next question');
assert.equal(store.getState().scroll, 3, 'submitting does not jump to the tail');
await prompt;
});