Compare commits

...

9 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
44 changed files with 5874 additions and 1524 deletions

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.

339
README.md
View File

@@ -1,78 +1,313 @@
**FIDO** a minimal coding assistant.
# Fido Project Overview
Fido is a console REPL assistant that inspects files, lists directories, searches file
contents, applies changes, and runs shell commands (with user approval).
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.
## Install
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.
Installs into `~/.fido` and adds a `fido` launcher to your `PATH` via `~/.bashrc`:
## What Fido Can Do
```sh
git clone <this-repo> ~/src/fidoca
cd ~/src/fidoca
./install.sh
source ~/.bashrc
- 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
```
The installer needs the sibling `llmorbiter` checkout (this project depends on it
via `file:../llmorbiter`); it is bundled into `~/.fido/llmorbiter`. Override its
location with `LLMORBITER_SRC=/path/to/llmorbiter`.
### Main Modules
Then, from any project directory:
| 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. |
```sh
fido # work on the current directory
fido /path/to/project # work on a specific directory
## 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
```
Set `OPENROUTER_API_KEY` (and optionally `TAVILY_API_KEY`) in `~/.fido/.env`.
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`.
### File mentions
## Safety and Trust Boundaries
While typing a message, press **`Ctrl+O`** to open an in-prompt file browser.
It is `.gitignore`-aware and hides dot-directories, just like `list_files`.
Fido has useful path and workflow safeguards, but it is not an operating-system sandbox.
| Key | Action |
| ----------- | -------------------------------------------------- |
| `↑` / `↓` | Move the highlight |
| `Enter` | Descend into a directory, or open the line selector|
| `Tab` | Mention the highlighted file (whole file) |
| `Backspace` | Go to the parent directory |
| `Esc` | Cancel back to the prompt |
- 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.
In the line selector, `↑`/`↓` move the cursor, `v` starts/ends a range, `g`/`G`
jump to the first/last line, and `Enter` inserts the mention.
## State and Persistence
The mention is plain text spliced in at the cursor; the model reads the file
itself via `read_file` (it is a *reference*, not embedded contents):
### Installed layout
| Selection | Inserted text |
| ----------- | ----------------------- |
| Whole file | `@src/fido.js` |
| Line range | `@src/fido.js:120-145` |
| Single line | `@src/fido.js:120` |
The installer defaults to `FIDO_HOME=~/.fido`:
Paths are relative to the workspace root with forward slashes; line numbers are
1-based (the model maps them to `read_file`'s 0-indexed `seek`).
### Layout
```
```text
~/.fido/
app/ the fido app (this repo)
llmorbiter/ bundled dependency
bin/fido the launcher (added to PATH)
.env your config / API keys
state/<workspace>/ per-workspace state
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
```
Undo and `/suspend` state is stored **per workspace** under `~/.fido/state/`, so it
never mixes between projects and never litters your repositories.
The workspace slug is derived from the canonical workspace path and augmented with a checksum, keeping state isolated between projects and avoiding repository litter.
To remove everything: `./install.sh --uninstall`.
### 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
[0BSD](https://opensource.org/license/0bsd)
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.

1084
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,14 +11,18 @@
"test": "node --test"
},
"dependencies": {
"@inquirer/core": "^10.3.2",
"@inquirer/prompts": "^7.5.1",
"@tavily/core": "^0.7.13",
"chalk": "^5.4.1",
"dotenv": "^18.0.2",
"ignore": "^7.0.10",
"ink": "^7.1.1",
"ink-text-input": "^6.0.0",
"llmorbiter": "file:../llmorbiter",
"marked": "^15.0.12",
"marked-terminal": "^7.3.0"
"marked-terminal": "^7.3.0",
"react": "^19.3.0",
"string-width": "^4.2.3",
"vm2": "^3.12.2",
"wrap-ansi": "^7.0.0"
}
}

View File

@@ -1,362 +0,0 @@
'use strict';
/**
* Slash-command autocomplete prompt.
*
* An `input`-style single-line prompt built on @inquirer/core. It behaves like
* a normal text input, but as soon as the buffer starts with "/" (and no
* arguments have been typed yet) it renders the matching slash commands as
* inline help below the prompt.
*
* ↑ / ↓ move the highlight between matches
* Tab complete the highlighted command into the buffer
* Enter submit the buffer (like a normal input)
*
* The filter term is frozen while the menu is open, so completing a command
* with Tab does not collapse the list — repeated ↑/↓ + Tab still works.
*
* It also hosts an 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 spliced into the draft
* at the caret. See `file-browser.js` for the pure helpers and PLAN.md for the
* design.
*/
import chalk from 'chalk';
import {
createPrompt,
useState,
useKeypress,
useEffect,
useRef,
usePrefix,
isEnterKey,
isTabKey,
isUpKey,
isDownKey,
isBackspaceKey,
makeTheme,
} from '@inquirer/core';
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; // visible rows in the file browser
const LINE_WINDOW = 18; // visible rows in the line selector
/** Show the inline menu only while the first word is being typed (no space yet). */
function termFrom(line) {
if (typeof line !== 'string' || !line.startsWith('/')) return null;
if (line.includes(' ')) return null;
return line.toLowerCase();
}
/**
* Replace readline's actual edit buffer, not just the visible terminal line.
* `rl.clearLine()` only erases terminal output; using it before `rl.write()`
* leaves the old buffer in place and turns a completion such as `/p` into
* `/p/paste` internally.
*/
function replaceBuffer(rl, nextValue) {
rl.write(null, { ctrl: true, name: 'a' });
rl.write(null, { ctrl: true, name: 'k' });
rl.write(nextValue);
}
export const autocompleteInput = createPrompt((config, done) => {
const theme = makeTheme({}, config.theme);
const initial = typeof config.default === 'string' ? config.default : '';
const [status, setStatus] = useState('idle');
const [value, setValue] = useState(initial);
const [term, setTerm] = useState(termFrom(initial)); // frozen filter term while the menu is open
const [active, setActive] = useState(0); // highlighted suggestion index
// ── File-browser state ──────────────────────────────────────────────
// mode: 'input' (normal editing) | 'browse' (file picker) | 'lines' (line selector)
const [mode, setMode] = useState('input');
const [cwd, setCwd] = useState(''); // browse dir, relative to ROOT_DIR ('' = root)
const [entries, setEntries] = useState([]); // listing for `cwd`
const [browseActive, setBrowseActive] = useState(0);
const [selFile, setSelFile] = useState(null); // file chosen in 'lines' mode
const [fileLines, setFileLines] = useState([]);
const [fileBinary, setFileBinary] = useState(false);
const [cursor, setCursor] = useState(0); // current line index in 'lines' mode
const [anchor, setAnchor] = useState(null); // range-selection anchor (line index) or null
const [notice, setNotice] = useState(''); // dim status/notice line
// The draft (and caret) captured when the browser opened, so a cancel can
// restore the untouched message and an insert knows where to splice.
const draftRef = useRef('');
const caretRef = useRef(0);
// Mirror `mode` into a ref so the SIGINT guard (installed once) sees it.
const modeRef = useRef('input');
modeRef.current = mode;
const prefix = usePrefix({ status, theme });
const suggestions = term === null ? [] : filterCommands(term).slice(0, MAX_SUGGESTIONS);
const count = suggestions.length;
// ── Browser helpers ─────────────────────────────────────────────────
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(cwd)); // refresh in case it moved/vanished
return;
}
setSelFile(rel);
setFileLines(lines);
setFileBinary(binary);
setCursor(0);
setAnchor(null);
setNotice(binary ? 'binary file — whole-file mention only' : '');
setMode('lines');
};
const cancelToInput = (rl) => {
replaceBuffer(rl, draftRef.current);
const back = draftRef.current.length - caretRef.current;
for (let i = 0; i < back; i++) rl.write(null, { ctrl: true, name: 'b' });
setMode('input');
setNotice('');
};
// Splice `text` into the captured draft at the captured caret, leaving the
// caret just after the inserted text, then return to normal input mode.
const insertMention = (rl, text) => {
const draft = draftRef.current;
const pos = Math.min(caretRef.current, draft.length);
const before = draft.slice(0, pos);
const after = draft.slice(pos);
const pad = before.length && !/\s$/.test(before) ? ' ' : '';
const next = before + pad + text + after;
replaceBuffer(rl, next);
for (let i = 0; i < after.length; i++) rl.write(null, { ctrl: true, name: 'b' });
setValue(next);
setTerm(termFrom(next));
setActive(0);
setMode('input');
setNotice('');
};
useKeypress((key, rl) => {
if (status !== 'idle') return;
// ── Browse mode ─────────────────────────────────────────────────
if (mode === 'browse') {
const n = entries.length;
if (isUpKey(key)) {
if (n) setBrowseActive((browseActive - 1 + n) % n);
return;
}
if (isDownKey(key)) {
if (n) setBrowseActive((browseActive + 1) % n);
return;
}
if (isEnterKey(key)) {
const entry = entries[browseActive];
if (!entry) return;
if (entry.type === 'f') openFile(entry.rel);
else openDir(entry.rel); // dir or synthetic '..'
return;
}
if (isTabKey(key)) {
const entry = entries[browseActive];
if (entry && entry.type === 'f') insertMention(rl, formatMention(entry.rel));
return;
}
if (isBackspaceKey(key)) {
if (cwd) openDir(parentOf(cwd));
return;
}
if (key.name === 'escape') cancelToInput(rl);
return; // swallow every other key so the draft is untouched
}
// ── Line-select mode ────────────────────────────────────────────
if (mode === 'lines') {
const n = fileLines.length;
if (isUpKey(key)) {
setCursor(Math.max(0, cursor - 1));
return;
}
if (isDownKey(key)) {
setCursor(Math.max(0, Math.min(n - 1, cursor + 1)));
return;
}
if (!fileBinary && n > 0) {
if (key.name === 'v') { setAnchor(anchor === null ? cursor : null); return; }
if (key.name === 'g') { setCursor(0); return; }
if (key.name === 'G') { setCursor(n - 1); return; }
}
if (isEnterKey(key)) {
let text;
if (anchor !== null && n > 0) {
const { start, end } = normalizeRange(anchor, cursor);
text = formatMention(selFile, start, end);
} else {
text = formatMention(selFile);
}
insertMention(rl, text);
return;
}
if (key.name === 'escape' || isBackspaceKey(key)) {
setMode('browse');
setNotice('');
}
return;
}
// ── Input mode (normal editing) ─────────────────────────────────
// Ctrl+O opens the file browser at the workspace root.
if (key.ctrl && key.name === 'o') {
draftRef.current = rl.line;
caretRef.current = rl.cursor;
openDir('');
setMode('browse');
return;
}
if (isEnterKey(key)) {
const answer = value || rl.line;
setValue(answer);
setStatus('done');
done(answer);
return;
}
// Move the highlight within the open menu. Arrow keys do not change
// readline's text buffer, so no line clearing or rewriting is needed.
if (count > 0 && (isUpKey(key) || isDownKey(key))) {
const delta = isUpKey(key) ? -1 : 1;
setActive((active + delta + count) % count);
return;
}
// Complete the highlighted command into both the displayed value and
// readline's real edit buffer.
if (isTabKey(key)) {
if (count === 0) return;
const pick = suggestions[active % count];
replaceBuffer(rl, pick.name);
setValue(pick.name);
return; // keep `term` frozen so the menu stays open
}
// Ordinary editing: mirror the readline buffer and refresh the menu.
const line = rl.line;
setValue(line);
setTerm(termFrom(line));
setActive(0);
});
// Prefill the editable readline buffer (and mirror it into `value`) when an
// initial value is supplied — used to restore a message that a Ctrl+C
// cancelled back into the prompt so it can be edited and resent.
useEffect((rl) => {
if (initial) rl.write(initial);
}, []);
// While browsing, Ctrl+C should return to the prompt, not abort the whole
// prompt. @inquirer/core installs its own SIGINT listener that rejects the
// prompt; wrap it so it only fires when we're in normal input mode.
useEffect((rl) => {
const existing = rl.listeners('SIGINT');
rl.removeAllListeners('SIGINT');
const guarded = (...args) => {
if (modeRef.current === 'input') existing.forEach((fn) => fn.apply(rl, args));
};
rl.on('SIGINT', guarded);
return () => {
rl.removeAllListeners('SIGINT');
existing.forEach((fn) => rl.on('SIGINT', fn));
};
}, []);
const message = theme.style.message(config.message);
const formattedValue = status === 'done' ? theme.style.answer(value) : value;
// ── File-browser view ───────────────────────────────────────────────
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++) {
const e = entries[i];
const marker = i === browseActive ? chalk.cyan('❯') : ' ';
let label;
if (e.type === 'parent') label = chalk.dim('../');
else if (e.type === 'd') label = chalk.bold.cyan(`${e.name}/`);
else label = e.name;
rows.push(` ${marker} ${label}`);
}
const where = cwd ? `${cwd}/` : '(workspace root)';
const out = [
` ${chalk.bold.cyan('📁 File browser')} ${chalk.dim(where)}`,
rows.length ? rows.join('\n') : chalk.dim(' (empty directory)'),
];
if (notice) out.push(chalk.dim(` ${notice}`));
out.push(chalk.dim(' ↑/↓ move · Enter open · Tab mention · Backspace up · Esc cancel'));
return out.join('\n');
}
// ── Line-selector view ──────────────────────────────────────────────
if (mode === 'lines') {
const n = fileLines.length;
const out = [` ${chalk.bold.cyan(selFile)} ${chalk.dim('— select lines')}`];
if (fileBinary) {
out.push(chalk.dim(' (binary file — Enter mentions the whole file)'));
} else if (n === 0) {
out.push(chalk.dim(' (empty file — 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++) {
const cur = i === cursor ? chalk.cyan('❯') : ' ';
const mark = lo !== null && i >= lo && i <= hi ? chalk.cyan('│') : ' ';
const num = chalk.dim(String(i + 1).padStart(width));
const text = fileLines[i].replace(/\t/g, ' ');
rows.push(` ${cur} ${num} ${mark} ${i === cursor ? chalk.white(text) : text}`);
}
out.push(rows.join('\n'));
}
if (notice) out.push(chalk.dim(` ${notice}`));
out.push(chalk.dim(' ↑/↓ move · v mark · g/G top/bottom · Enter insert · Esc back'));
return out.join('\n');
}
// ── Input view (unchanged) ──────────────────────────────────────────
const lines = suggestions.map((c, i) => {
const marker = i === active % count ? chalk.cyan('❯') : ' ';
return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(NAME_WIDTH + 2))} ${chalk.dim(c.description)}`;
});
if (count > 1) {
lines.push(chalk.dim(' ↑/↓ choose · Tab complete'));
}
const header = [prefix, message, formattedValue].filter((v) => v !== undefined).join(' ');
return [header, lines.length ? lines.join('\n') : undefined];
});
export default autocompleteInput;

171
src/commands.js Normal file
View File

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

View File

@@ -117,3 +117,46 @@ export function walkFiles(dir) {
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,578 +3,31 @@
/**
* Fido — Minimal Coding Assistant
*
* A sandboxed, interactive coding assistant using LLMOrbiter.
*
* Entry point: wires configuration, tools, and the console REPL together.
* Shared infrastructure lives in sibling modules (config, sandbox, dir-listing,
* undo); each tool lives in its own file under src/tools/.
*
* 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)
* Application entry point. The interactive loop lives in ./repl.js, with the
* system prompt, transcript rendering, and slash commands in sibling modules.
*
* Usage:
* node src/fido.js
* node src/fido.js /path/to/project
*/
import path from 'path';
import chalk from 'chalk';
import { editor, input, select, confirm } from '@inquirer/prompts';
import { marked } from 'marked';
import { markedTerminal } from 'marked-terminal';
import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter';
import { API_KEY, MODEL, UNDO_FILE, SUSPEND_DIR, ROOT_DIR, COMPACT_DEBUG } from './config.js';
import { listDir } from './dir-listing.js';
import { loadUndoLog, getUndoLog } from './undo.js';
import { loadSuspends, addSuspend, getSuspends } from './suspend.js';
import { tools, toolCallNotifications, toolResultNotifications } from './tools/index.js';
import { printToolCall, emitGenericToolResult } from './tools/notify.js';
import { autocompleteInput } from './autocomplete-input.js';
import { COMMANDS, REASONING_LEVELS, findCommand } from './slash-commands.js';
import { compactPatchToolCalls, invalidateStaleReadResults } from './compact.js';
// ─── Markdown → console renderer ───────────────────────────────────────────────
// Configure marked to emit ANSI-styled terminal output. Width accounts for the
// 2-space UI indent applied when printing the assistant's response.
marked.use(markedTerminal({ reflowText: true, width: 76, tab: 2 }));
// ─── Background-color blocks ───────────────────────────────────────────────────
//
// marked-terminal (and chalk) end every styled span with a full reset
// (\x1b[0m). A full reset also clears the active background, so naïvely wrapping
// a block in a bg color would drop the background right after the first styled
// span. To keep the background alive across a block we:
// 1. re-emit the background open code immediately after every reset, and
// 2. right-pad each line to a fixed width so the block reads as a rectangle.
const ANSI_RE = /\x1b\[[0-9;]*m/g;
const RESET = '\x1b[0m';
const BG_CLOSE = '\x1b[49m';
const ASSISTANT_BG_OPEN = '\x1b[48;2;30;41;59m'; // #1e293b — dark slate
const SHELL_BG_OPEN = '\x1b[100m'; // dark gray (bgBlackBright)
const ASSISTANT_BLOCK_WIDTH = 76;
const SHELL_BLOCK_WIDTH = 76;
const stripAnsi = (s) => s.replace(ANSI_RE, '');
/**
* Paint every line of `text` on a solid background, right-padding each line to
* `width` visible columns so the result reads as a block.
*/
function paintBackground(text, open, width) {
return String(text)
.replace(/\n+$/, '')
.split('\n')
.map((line) => {
const visible = stripAnsi(line).length;
const padded = visible < width ? line + ' '.repeat(width - visible) : line;
const patched = padded.replace(/\x1b\[0m/g, RESET + open);
return open + patched + BG_CLOSE;
})
.join('\n');
}
import { API_KEY } from './config.js';
import { unmount } from './ui/index.js';
import { runRepl } from './repl.js';
if (!API_KEY) {
console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n'));
process.exit(1);
}
// ─── System prompt builder ─────────────────────────────────────────────────────
function buildSystemPrompt() {
const { entries, total, truncated } = listDir(ROOT_DIR, { seek: 0, limit: 50, recursive: false });
const fmt = (e) => e.type === 'd' ? `d ${e.path}` : `f ${e.size ?? '?'} ${e.path}`;
let dirSection = entries.map(fmt).join('\n');
if (truncated > 0) {
dirSection += `\n ... (${truncated} more entries not shown — use list_files to explore)`;
}
return `You're Fido, a coding assistant. Inspect files, list dirs, edit code precisely, run shell cmds in the workspace.
## Workspace
Root: ${ROOT_DIR}
Total files: ${total}
## Workspace root (${total} entries):
${dirSection}
## Tools & rules of thumb
- \`read_file(filename, seek, limit)\`: read file w/ line nums. Inspect before editing.
- \`list_files(dirname, recursive, seek, limit, includeDotDirs)\`: list dir (non-recursive default). Honors .gitignore; dot-dirs (.git) hidden unless includeDotDirs=true. recursive=true walks subtree. seek/limit paginate. Paths rel to root.
- \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: regex-search file contents across ws (literal=true = plain str). Recurse dirs, honors .gitignore, skips binary/big files, hides dot-dirs. Returns matches {file,line,column,text}; line is 0-idx, feed straight to read_file seek. glob limits files searched. Long lines capped (textTruncated per match, linesTruncated count).
- \`patch(operations)\`: apply file edits/creates.
- create/overwrite: \`{"file":"path","search":null,"replace":"content"}\` (dirs auto-created).
- search&replace: \`{"file":"path","search":"exact old","replace":"new"}\` — replaces FIRST hit.
- \`skiplines\` (opt int, 0-idx): skip N lines b4 matching; use if search text appears multiple times.
- \`mkdir(dirname)\`: make dir (recursive).
- \`rmdir(dirname)\`: delete dir + contents.
- \`rm(filename)\`: delete file.
- \`bash_exec(command, tail_lines, timeout)\`: run bash in ws root. No color/interactivity, stdin closed, killed at timeout. Needs explicit user approval — user may decline. stdout/stderr trimmed to last \`tail_lines\` lines (default 100).
- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source)\`: web search via Tavily. Returns short synthesized answer + ranked results (title,url,snippet). Use for current info, external docs, or facts not in ws. Untrusted 3rd-party text.
- \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: fetch full contents of web page(s) via Tavily extract. \`urls\` = URL or array. Optional \`query\` = prompt for only relevant bits of each page. Use to read a specific URL (external docs) beyond search snippets. Untrusted 3rd-party text.
- \`ask_user(question, options)\`: ask user when intent ambiguous or you need a decision. Give a concise question + up to 3 labeled options (A,B,C); the user may pick one or type free-form (D).
- \`ask_an_expert(question, context)\`: escalate to a human expert when you're genuinely stuck (a confusing or unclear problem, or you've tried and are still spinning). Don't use it for things you can resolve yourself. Question shown to user as copy-pasteable block for their expert; reply returned to you. Self-contained \`question\`; put supporting detail (errors, code, what you tried) in optional \`context\`.
## File mentions
- User inserts refs via Ctrl+O: \`@path\` (whole file) or \`@path:start-end\` (line range).
- Mentions are refs only — you must \`read_file\` yourself. Paths rel to root, forward slashes.
- Mention line nums are 1-based; \`read_file\` is 0-idx → \`@path:start-end\` ⟹ \`read_file(path, seek=start-1)\`.
## Rules
- Only access files under ws root. Prefer paths relative to the root; avoid ".." and absolute paths. (\`bash_exec\` starts in the ws root, so relative paths resolve there.)
- Always \`read_file\` before patching (confirm exact search text + indentation).
- Prefer targeted SEARCH/REPLACE over rewriting whole files.
- Multiple \`patch\` ops in one call run sequentially.
- Undo tracked per op, managed externally by user.
`;
}
// ─── 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(chalk.dim(` Suspends: ${path.resolve(ROOT_DIR, SUSPEND_DIR)}`));
console.log('');
}
function printDivider() {
console.log(chalk.dim(' ' + '─'.repeat(50)));
}
function printAssistant(content) {
console.log('');
console.log(chalk.green.bold(' 🤖 Assistant'));
printDivider();
// Render markdown to ANSI-styled console output, then indent to match the UI
let rendered;
runRepl().catch(async (err) => {
try {
rendered = marked.parse(content || '(no response)');
await unmount();
} catch {
rendered = content || '(no response)';
/* ignore */
}
// Render the markdown on a dark-slate background block, indented to match
// the rest of the UI.
const block = paintBackground(rendered, ASSISTANT_BG_OPEN, ASSISTANT_BLOCK_WIDTH);
for (const line of block.split('\n')) {
console.log(' ' + line);
}
console.log('');
}
function printStatus(msg) {
process.stdout.write(chalk.yellow.dim(` ⚡ ${msg}\n`));
}
function printToolResult(name, result) {
// Shell output gets a dedicated render: full stdout/stderr on a gray block.
if (name === 'bash_exec') {
let r = result;
if (typeof r === 'string') {
try { r = JSON.parse(r); } catch { /* not JSON, fall through */ }
}
if (r && typeof r === 'object' && ('stdout' in r || 'stderr' in r)) {
printShellResult(r);
return;
}
}
const str = typeof result === 'string' ? result : JSON.stringify(result);
const display = str.length > 200 ? str.slice(0, 197) + '...' : str;
console.log(chalk.dim(` ↳ ${display}`));
}
/**
* Render a bash_exec result: a one-line summary, then stdout/stderr on a
* dark-gray background block (indented to match the rest of the UI).
*/
function printShellResult(r) {
const meta = [];
if (r.exitCode !== undefined && r.exitCode !== null) meta.push(`exit ${r.exitCode}`);
if (r.signal) meta.push(`signal ${r.signal}`);
if (r.timedOut) meta.push('timed out');
console.log(chalk.dim(` ↳ bash_exec${meta.length ? ' (' + meta.join(', ') + ')' : ''}`));
const body = [];
if (r.stdout) body.push(r.stdout.replace(/\n+$/, ''));
if (r.stderr) body.push(r.stderr.replace(/\n+$/, ''));
const text = body.join('\n').replace(/\n/g, '\n ');
if (text.trim()) {
const block = paintBackground(text, SHELL_BG_OPEN, SHELL_BLOCK_WIDTH);
for (const line of block.split('\n')) {
console.log(' ' + line);
}
}
if (r.truncationNote) console.log(chalk.dim(` ↳ ${r.truncationNote}`));
if (r.note) console.log(chalk.dim(` ↳ ${r.note}`));
}
function printToolError(name, error) {
console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`));
}
function printUsage(u, totals) {
const parts = [`in=${u.input}`, `out=${u.output}`];
if (u.cached) parts.push(`cached=${u.cached}`);
if (u.cost) parts.push(`cost=$${u.cost.toFixed(5)}`);
let line = ` 📊 ${parts.join(' ')}`;
if (totals) {
totals.calls += 1;
totals.input += u.input || 0;
totals.output += u.output || 0;
totals.cached += u.cached || 0;
totals.cost += u.cost || 0;
const acc = [`in=${totals.input}`, `out=${totals.output}`];
if (totals.cached) acc.push(`cached=${totals.cached}`);
if (totals.cost) acc.push(`cost=$${totals.cost.toFixed(5)}`);
line += ` [(${totals.calls}) ${acc.join(', ')}]`;
}
console.log(chalk.dim(line));
}
function formatWhen(iso) {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
}
function printCommandHelp() {
const width = Math.max(...COMMANDS.map((c) => c.name.length));
console.log('');
console.log(chalk.green.bold(' 📖 Commands'));
printDivider();
for (const c of COMMANDS) {
console.log(' ' + chalk.cyan(c.name.padEnd(width)) + ' ' + chalk.dim(c.description));
}
console.log('');
}
// ─── Multiline input (editor prompt) ─────────────────────────────────────────────
/**
* Open the user's $VISUAL/$EDITOR (falls back to notepad on Windows, vim
* elsewhere) to compose or paste multiline content. Launches immediately —
* no extra keypress — and returns the saved buffer as a string.
*/
async function promptMultiline(message = 'Compose or paste your text, then save & close the editor') {
return editor({
message: chalk.bold.cyan(message),
waitForUserInput: false,
postfix: '.md',
theme: {
prefix: chalk.cyan('›'),
style: { message: chalk.bold.cyan },
},
});
}
// ─── Main loop ─────────────────────────────────────────────────────────────────
async function main() {
loadUndoLog();
loadSuspends();
printBanner();
// Reasoning effort for this session; null means "let the model default apply".
// When set, it is sent as body.reasoning.effort on every request.
let reasoningEffort = null;
// Most recent reasoning the model streamed during the active turn. The
// provider reports reasoning cumulatively, so we keep the latest value. If a
// turn is cancelled mid-flight this is offered back to the user so it can be
// preserved in the transcript instead of being discarded.
let turnReasoning = '';
const provider = new OpenRouterProvider({ apiKey: API_KEY });
// Running totals across the session, echoed after each per-call usage line.
const usageTotals = { calls: 0, input: 0, output: 0, cached: 0, cost: 0 };
const orbiter = new LLMOrbiter(provider, {
onStatus: (s) => printStatus(s),
onReasoning: (text) => { turnReasoning = text; },
onToolCall: (c) => {
const notify = toolCallNotifications[c.name];
if (notify) notify(c.args);
else printToolCall(c.name, c.args);
},
onToolResult: (r) => {
const notifyResult = toolResultNotifications[r.name];
if (notifyResult) notifyResult(r.result);
else emitGenericToolResult(r.name, r.result);
printToolResult(r.name, r.result);
},
onToolError: (e) => printToolError(e.name, e.error),
onUsage: (u) => printUsage(u, usageTotals),
});
// Conversation state: persisted across turns
const systemPrompt = buildSystemPrompt();
let messages = [{ role: 'system', content: systemPrompt }];
console.log(chalk.dim(' Type your request below. Type / for commands, /paste for multiline.'));
console.log(chalk.dim(' Ctrl+O add a file mention. Ctrl+C exits at the prompt; while the model is running it cancels the turn.\n'));
// Restored into the prompt after a Ctrl+C cancels a running turn, so the
// message can be edited and resent instead of retyped.
let draft = '';
while (true) {
let userInput;
try {
userInput = await autocompleteInput({
message: chalk.bold.cyan('You'),
default: draft,
theme: {
prefix: chalk.cyan('›'),
style: {
answer: chalk.white,
message: chalk.bold.cyan,
},
},
});
} catch {
// Ctrl+C / EOF
console.log(chalk.dim('\n Goodbye! 🐕\n'));
break;
}
draft = '';
if (!userInput.trim()) continue;
// Handle special commands
const command = findCommand(userInput.trim());
if (userInput.trim().startsWith('/') && command === null) {
const name = userInput.trim().split(/\s+/)[0];
console.log(chalk.yellow(`\n Unknown command: ${name}`));
printCommandHelp();
continue;
}
if (command === '/quit') {
console.log(chalk.dim('\n Goodbye! 🐕\n'));
break;
}
if (command === '/paste') {
try {
const pasted = await promptMultiline();
if (!pasted || !pasted.trim()) {
console.log(chalk.yellow('\n Nothing pasted.\n'));
continue;
}
userInput = pasted;
printStatus(`Pasted ${pasted.split('\n').length} line(s), sending…`);
} catch {
console.log(chalk.yellow('\n Paste cancelled.\n'));
continue;
}
}
if (command === '/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;
}
if (command === '/reasoning') {
const arg = userInput.trim().slice('/reasoning'.length).trim().toLowerCase();
if (!arg) {
console.log(chalk.dim(`\n Reasoning effort: ${reasoningEffort || 'default (model)'}`));
console.log(chalk.dim(` Usage: /reasoning ${REASONING_LEVELS.join('|')}\n`));
continue;
}
if (!REASONING_LEVELS.includes(arg)) {
console.log(chalk.yellow(`\n Unknown reasoning level: ${arg}`));
console.log(chalk.dim(` Choose one of: ${REASONING_LEVELS.join(' | ')}\n`));
continue;
}
reasoningEffort = arg;
console.log(chalk.green(`\n 🧠 Reasoning effort set to ${arg}\n`));
continue;
}
if (command === '/suspend') {
// Description is the text after the command; ask for one if omitted.
let description = userInput.trim().slice('/suspend'.length).trim();
if (!description) {
try {
description = (
await input({
message: chalk.bold.cyan('Short description for this suspend'),
theme: {
prefix: chalk.cyan('›'),
style: { answer: chalk.white, message: chalk.bold.cyan },
},
})
).trim();
} catch {
console.log(chalk.yellow('\n Suspend cancelled.\n'));
continue;
}
}
if (!description) {
console.log(chalk.yellow('\n No description given — suspend cancelled.\n'));
continue;
}
if (messages.length <= 1) {
console.log(chalk.yellow('\n Nothing to suspend yet — say something first.\n'));
continue;
}
addSuspend(description, messages);
const count = getSuspends().length;
console.log(chalk.green(`\n ⏸ Suspended: ${description}`));
console.log(chalk.dim(` ${count} snapshot${count === 1 ? '' : 's'} in ${SUSPEND_DIR}\n`));
continue;
}
if (command === '/recall') {
const suspends = getSuspends();
if (suspends.length === 0) {
console.log(chalk.yellow('\n Nothing to recall.\n'));
continue;
}
let choice;
try {
choice = await select({
message: chalk.bold.cyan('Recall a suspended conversation'),
choices: suspends.map((s, i) => ({
name: `${s.description} ${chalk.dim(formatWhen(s.createdAt))}`,
value: i,
short: s.description,
})),
theme: {
prefix: chalk.cyan('›'),
style: { message: chalk.bold.cyan, highlight: chalk.cyan.bold },
},
});
} catch {
console.log(chalk.yellow('\n Recall cancelled.\n'));
continue;
}
const snap = suspends[choice];
const restored = Array.isArray(snap.messages) ? snap.messages : [];
// Keep the freshly built system prompt (workspace may have changed)
// and restore the rest of the conversation verbatim.
messages = [
{ role: 'system', content: systemPrompt },
...restored.filter((m) => m.role !== 'system'),
];
const turns = messages.length - 1;
console.log(chalk.green(`\n ▶ Recalled: ${snap.description}`));
console.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored.\n`));
continue;
}
console.log('');
// No prompt owns the terminal while the model works, so a Ctrl+C would
// otherwise hit Node's default handler and kill the process. Catch it
// here instead: abort the active run and fall back through to the prompt
// (prefilled with the message) rather than exiting.
// Fresh reasoning buffer for this turn.
turnReasoning = '';
const onSigint = () => orbiter.abort('cancelled by user');
process.on('SIGINT', onSigint);
try {
const result = await orbiter.run({
prompt: userInput,
messages,
model: MODEL,
tools,
maxIterations: 20,
...(reasoningEffort ? { extraBody: { reasoning: { effort: reasoningEffort } } } : {}),
});
// Update conversation history for next turn
messages = result.messages;
// Token savings: the patch tool's arguments carry whole file bodies
// (search/replace). Once applied they are dead weight, so shrink each
// patch tool call in the transcript to a head/tail excerpt.
const compactedPatches = compactPatchToolCalls(messages);
// And the other half of the same problem: a read_file result carries a
// whole file body that a later patch made stale. Replace it with a
// short "file was overwritten meanwhile" marker so the old contents are
// not re-sent every turn (nor mistaken for the current file).
const invalidatedReads = invalidateStaleReadResults(messages);
if (COMPACT_DEBUG) {
console.log(chalk.dim(
` [compact] patch tool calls compacted: ${compactedPatches} · ` +
`stale read results invalidated: ${invalidatedReads}`
));
}
printAssistant(result.content);
} catch (err) {
if (err && err.name === 'AbortError') {
// Ctrl+C cancelled the run — put the message back in the prompt
// so it can be edited and resent. The aborted transcript is
// discarded, so `messages` is untouched and the turn is not lost.
draft = userInput;
console.log(chalk.dim('\n ⏹ Cancelled — your message is back in the prompt; edit it and press Enter to resend.\n'));
// If the model already produced reasoning before the cancel,
// offer to keep it. Kept reasoning is added to the transcript
// exactly like normal reasoning (an assistant message carrying
// the reasoning text), tagged with a marker at the end so it's
// clear the turn was cut short.
const reasoning = turnReasoning.trim();
if (reasoning) {
let keep = false;
try {
keep = await confirm({
message: chalk.bold.cyan('Keep the reasoning the model produced before cancelling?'),
default: true,
theme: {
prefix: chalk.cyan('›'),
style: { message: chalk.bold.cyan },
},
});
} catch {
keep = false; // prompt dismissed (e.g. Ctrl+C) — don't keep
}
if (keep) {
messages.push({
role: 'assistant',
content: `${reasoning}\n---USER-CANCELLED---`,
});
console.log(chalk.dim(' ↗ Reasoning kept in the transcript.\n'));
}
}
} else {
console.log(chalk.red(`\n ✖ Error: ${err.message}\n`));
}
} finally {
process.removeListener('SIGINT', onSigint);
}
}
}
main().catch((err) => {
console.error(chalk.red.bold('\nFATAL: ' + err.message));
process.exit(1);
});

View File

@@ -4,7 +4,7 @@
* File-browser helpers for prompt file mentions.
*
* Pure, terminal-free helpers used by the in-prompt file browser in
* `autocomplete-input.js`. Keeping them here means the listing, mention
* `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

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

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

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.
`;
}

View File

@@ -1,8 +1,9 @@
'use strict';
import chalk from 'chalk';
import { input } from '@inquirer/prompts';
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({
@@ -39,26 +40,22 @@ export const askExpertTool = defineTool({
}
const message = lines.join('\n');
console.log('');
console.log(chalk.blue.bold(' 🧑\u200d🔬 Ask an expert — copy & paste this to your expert:'));
console.log(chalk.dim(' ┌' + '─'.repeat(60)));
for (const line of message.split('\n')) {
console.log(chalk.white(' │ ' + line));
}
console.log(chalk.dim(' └' + '─'.repeat(60)));
console.log('');
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 input({
message: chalk.bold.blue("Expert's answer (paste it here)"),
prefix: chalk.blue('›'),
theme: {
prefix: chalk.blue('›'),
style: { answer: chalk.white, message: chalk.bold.blue },
},
const answer = await ui.input({
message: "Expert's answer (paste it here)",
color: 'blue',
});
console.log('');
ui.log('');
return { answer };
},
});

View File

@@ -1,8 +1,9 @@
'use strict';
import chalk from 'chalk';
import { input, select } from '@inquirer/prompts';
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({
@@ -31,21 +32,19 @@ export const askUserTool = defineTool({
const capped = options.slice(0, 3);
const letters = ['A', 'B', 'C'];
console.log('');
console.log(chalk.magenta.bold(' ❓ Fido asks:'));
console.log(chalk.white(' ' + question));
ui.log('');
ui.log(renderToolBlock([
chalk.magenta.bold('\u2753 Fido asks:'),
chalk.white(question),
].join('\n')));
if (capped.length === 0) {
// No options — just free text
const answer = await input({
message: chalk.bold.magenta('Your answer'),
prefix: chalk.magenta('›'),
theme: {
prefix: chalk.magenta('›'),
style: { answer: chalk.white, message: chalk.bold.magenta },
},
// No options \u2014 just free text
const answer = await ui.input({
message: 'Your answer',
color: 'magenta',
});
console.log('');
ui.log('');
return { answer };
}
@@ -54,37 +53,28 @@ export const askUserTool = defineTool({
...capped.map((opt, i) => ({
name: `${letters[i]}) ${opt}`,
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,
choices,
prefix: chalk.magenta('›'),
theme: {
prefix: chalk.magenta('›'),
style: { message: chalk.bold.magenta },
},
color: 'magenta',
});
let answer;
if (selection === '__custom__') {
answer = await input({
message: chalk.bold.magenta('Your answer'),
prefix: chalk.magenta('›'),
theme: {
prefix: chalk.magenta('›'),
style: { answer: chalk.white, message: chalk.bold.magenta },
},
answer = await ui.input({
message: 'Your answer',
color: 'magenta',
});
} else {
const idx = letters.indexOf(selection);
answer = capped[idx];
}
console.log('');
ui.log('');
return { answer };
},
});

View File

@@ -13,16 +13,17 @@
* 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 inquirer/chalk styling as the ask_user tool.
* 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 { input, select } from '@inquirer/prompts';
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
@@ -97,43 +98,41 @@ async function requestApproval(command, timeoutMs) {
);
}
console.log('');
console.log(chalk.yellow.bold(' ⚠ Fido wants to run a shell command'));
console.log(chalk.dim(` cwd: ${ROOT_DIR}`));
console.log(chalk.dim(` timeout: ${timeoutMs} ms`));
console.log(chalk.white(' ' + command.replace(/\r?\n/g, '\n ')));
console.log('');
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) {
console.log(chalk.dim(' ⚡ Auto-approved (allowed for this session).'));
console.log('');
ui.log(renderToolBlock(chalk.dim('⚡ Auto-approved (allowed for this session).')));
ui.log('');
return;
}
let choice;
try {
choice = await select({
message: chalk.bold.yellow('Approve running this command?'),
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' },
],
default: 'Y',
prefix: chalk.yellow('›'),
theme: {
prefix: chalk.yellow('›'),
style: { message: chalk.bold.yellow, highlight: chalk.yellow.bold },
},
});
} catch {
// Ctrl+C / EOF during the prompt → treat as a rejection.
choice = 'n';
}
console.log('');
ui.log('');
if (choice === 'Y') {
return;
@@ -141,8 +140,8 @@ async function requestApproval(command, timeoutMs) {
if (choice === 'a') {
sessionApproved = true;
console.log(chalk.dim(' ✓ Allowed — shell commands will run without prompting for the rest of this session.'));
console.log('');
ui.log(renderToolBlock(chalk.dim('✓ Allowed — shell commands will run without prompting for the rest of this session.')));
ui.log('');
return;
}
@@ -150,19 +149,16 @@ async function requestApproval(command, timeoutMs) {
let comment = '';
try {
comment = (
await input({
message: chalk.bold.yellow('Reason (optional comment)'),
theme: {
prefix: chalk.yellow('›'),
style: { answer: chalk.white, message: chalk.bold.yellow },
},
await ui.input({
message: 'Reason (optional comment)',
color: 'yellow',
})
).trim();
} catch {
// Ctrl+C / EOF during the comment prompt → reject with no comment.
comment = '';
}
console.log('');
ui.log('');
throw new ToolError(
comment
? `Command rejected by the user. Comment: ${comment}`

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

@@ -9,6 +9,7 @@
*/
import { listFilesTool, notifyCall as listFilesNotify, notifyResult as listFilesResult } from './list-files.js';
import { findFilesTool, notifyCall as findFilesNotify, notifyResult as findFilesResult } from './find-files.js';
import { readFileTool, notifyCall as readFileNotify, notifyResult as readFileResult } from './read-file.js';
import { ripgrepTool, notifyCall as ripgrepNotify, notifyResult as ripgrepResult } from './ripgrep.js';
import { patchTool, notifyCall as patchNotify } from './patch.js';
@@ -16,6 +17,7 @@ import { mkdirTool, notifyCall as mkdirNotify } from './mkdir.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';
@@ -23,6 +25,7 @@ import { askExpertTool, notifyCall as askExpertNotify } from './ask-expert.js';
export {
listFilesTool,
findFilesTool,
readFileTool,
ripgrepTool,
patchTool,
@@ -30,6 +33,7 @@ export {
rmdirTool,
rmTool,
bashExecTool,
javascriptExecuteTool,
websearchTool,
browseUrlTool,
askUserTool,
@@ -38,6 +42,7 @@ export {
export const tools = [
listFilesTool,
findFilesTool,
readFileTool,
ripgrepTool,
patchTool,
@@ -45,6 +50,7 @@ export const tools = [
rmdirTool,
rmTool,
bashExecTool,
javascriptExecuteTool,
websearchTool,
browseUrlTool,
askUserTool,
@@ -59,6 +65,7 @@ export const tools = [
export const toolCallNotifications = Object.fromEntries(
[
[listFilesTool, listFilesNotify],
[findFilesTool, findFilesNotify],
[readFileTool, readFileNotify],
[ripgrepTool, ripgrepNotify],
[patchTool, patchNotify],
@@ -66,6 +73,7 @@ export const toolCallNotifications = Object.fromEntries(
[rmdirTool, rmdirNotify],
[rmTool, rmNotify],
[bashExecTool, bashExecNotify],
[javascriptExecuteTool, javascriptExecuteNotify],
[websearchTool, websearchNotify],
[browseUrlTool, browseUrlNotify],
[askUserTool, askUserNotify],
@@ -82,9 +90,11 @@ export const toolCallNotifications = Object.fromEntries(
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

@@ -7,9 +7,14 @@
* 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;
@@ -25,7 +30,7 @@ export function clip(text, max = MAX_ARG_LEN) {
* "\u2699 Tool: <name>(<detail>)"
*/
export function emitToolCall(name, detail = '') {
console.log(chalk.cyan(` \u2699 Tool: ${chalk.bold(name)}(${detail})`));
ui.log(renderToolBlock(chalk.cyan(`\u2699 Tool: ${chalk.bold(name)}(${detail})`)));
}
/**
@@ -45,7 +50,7 @@ export function printToolCall(name, args) {
*/
export function emitToolResult(name, detail = '') {
const suffix = detail ? ' ' + detail : '';
console.log(chalk.green(` \u2713 ${chalk.bold(name)}${suffix}`));
ui.log(renderToolBlock(chalk.green(`\u2713 ${chalk.bold(name)}${suffix}`)));
}
/**

View File

@@ -13,6 +13,7 @@ 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.
@@ -24,61 +25,6 @@ const MAX_MATCH_TEXT_LEN = 300;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Escape a string so it can be matched literally inside a RegExp. */
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).
*/
function globToRegExp(pattern) {
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 + '$');
}
/**
* 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.
*/
function makeGlobMatcher(pattern) {
const rx = globToRegExp(pattern);
const basenameOnly = !pattern.includes('/');
return (rel) => {
if (rx.test(rel)) return true;
if (basenameOnly) {
const base = rel.slice(rel.lastIndexOf('/') + 1);
return rx.test(base);
}
return false;
};
}
/**
* True when a workspace-relative path (forward slashes) lives inside a
* dot-directory — i.e. any *directory* segment starts with ".". Traversal into

View File

@@ -24,6 +24,28 @@ 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) {
@@ -41,6 +63,7 @@ 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',
@@ -69,6 +92,14 @@ export const websearchTool = defineTool({
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 ({
@@ -77,6 +108,8 @@ export const websearchTool = defineTool({
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.');
@@ -93,13 +126,21 @@ export const websearchTool = defineTool({
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 response = await getClient().search(query, {
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,
@@ -109,6 +150,12 @@ export const websearchTool = defineTool({
}));
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;
@@ -119,11 +166,13 @@ export const websearchTool = defineTool({
* Call notification: the query plus any non-default search options.
*/
export function notifyCall(args = {}) {
const { query, search_depth, max_results, include_answer } = 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(' ')));
}

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

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

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