This commit is contained in:
seb
2026-09-26 19:19:48 +02:00
parent 5b1d896157
commit 0149846a15
10 changed files with 822 additions and 29 deletions

View File

@@ -4,6 +4,7 @@ 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() {
@@ -74,10 +75,34 @@ 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. live reasoning streams into the scrollable history pane ---
// --- 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.setStatus('thinking…');
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 }));
@@ -92,4 +117,4 @@ assert.ok(outFinalReasoning.includes('🧠 Reasoning'), 'finalized reasoning has
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 + streaming reasoning');
console.log('\nSMOKE-OK: header/history/command-bar/footer + scroll + tiny fallback + connection states + streaming reasoning');

View File

@@ -44,6 +44,7 @@ model tool call
| `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. |
@@ -124,6 +125,22 @@ The UI is a React application rendered by Ink in the terminal's alternate screen
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.

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.

View File

@@ -17,6 +17,7 @@ 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';
@@ -30,23 +31,50 @@ import {
toolErrorLine,
} from './transcript.js';
function createOrbiter(turnReasoning) {
const provider = new OpenRouterProvider({ apiKey: API_KEY });
/**
* 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;
}
return new LLMOrbiter(provider, {
onStatus: (s) => ui.status(s),
/**
* 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;
ui.setReasoning(text);
uiApi.setReasoning(text);
dispatchTurnEvent(TURN_EVENTS.REASONING, uiApi);
},
onContent: () => {
ui.finalizeReasoning();
uiApi.finalizeReasoning();
dispatchTurnEvent(TURN_EVENTS.CONTENT, uiApi);
},
onToolCall: (c) => {
ui.finalizeReasoning();
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];
@@ -54,9 +82,14 @@ function createOrbiter(turnReasoning) {
else emitGenericToolResult(r.name, r.result);
printToolResult(r.name, r.result);
},
onToolError: (e) => ui.log(toolErrorLine(e.name, e.error)),
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));
}
/**
@@ -70,7 +103,10 @@ async function runTurn({ orbiter, userInput, messages, reasoningEffort, turnReas
turnReasoning.text = '';
ui.clearReasoning();
ui.busy(true);
ui.status('thinking…');
// "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);
@@ -105,6 +141,8 @@ async function runTurn({ orbiter, userInput, messages, reasoningEffort, turnReas
} 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.'));
@@ -134,10 +172,14 @@ async function runTurn({ orbiter, userInput, messages, reasoningEffort, turnReas
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);
}
}

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

View File

@@ -45,6 +45,7 @@ import {
} 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';
@@ -97,14 +98,32 @@ function truncatePlain(s, width) {
return str.slice(0, Math.max(0, width - 1)) + '\u2026';
}
function StatusLine({ status }) {
// 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];
return h(Text, { color: 'yellow' }, ` ${frame} ${status || 'working\u2026'}`);
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 }) {
@@ -216,10 +235,10 @@ function History({ entries, scroll }) {
);
}
function CommandBar({ prompt, busy, status, store }) {
function CommandBar({ prompt, busy, status, detail, store }) {
let body;
if (prompt) body = h(PromptView, { prompt, store });
else if (busy) body = h(StatusLine, { status });
else if (busy) body = h(StatusLine, { status, detail });
else body = h(Text, { dimColor: true }, ' \u203a (idle)');
return h(
Box,
@@ -315,7 +334,9 @@ export function App({ store, sizeOverride }) {
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 });
else if (state.busy) {
dynamic = h(StatusLine, { status: state.status, detail: state.statusDetail });
}
return h(
Box,
{ flexDirection: 'column' },
@@ -330,7 +351,13 @@ export function App({ store, sizeOverride }) {
{ 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, store }),
h(CommandBar, {
prompt: state.prompt,
busy: state.busy,
status: state.status,
detail: state.statusDetail,
store,
}),
h(TokenFooter, { totals: state.totals }),
);
}

View File

@@ -56,10 +56,20 @@ export const ui = {
/** Append a history entry to the transcript pane. */
log: (text) => store.log(text),
/** Update the live status line (shown while busy). */
status: (text) => store.setStatus(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),
/** Toggle "model working" mode; clearing it also clears the status line. */
/**
* 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. */

View File

@@ -10,8 +10,10 @@
* - `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).
* - `setStatus(text)` update the transient status line shown while a turn
* is running.
* - `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.
@@ -29,6 +31,7 @@
* subscribers. React subscribes through `useSyncExternalStore`.
*/
import { TURN_STATES, isTurnState } from '../turn-state.js';
import { countHistoryLines, formatReasoning } from './app.js';
export function createStore() {
@@ -39,7 +42,10 @@ export function createStore() {
prompt: null,
reasoningDraft: '',
busy: false,
status: '',
// 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 },
@@ -94,14 +100,36 @@ export function createStore() {
});
},
/** Update the transient status line (shown only while busy). */
setStatus(status) {
if (state.status !== status) setState({ status: status || '' });
/**
* 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;
setState({ busy: next, status: next ? state.status : '' });
// 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. */

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

@@ -0,0 +1,207 @@
'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 } 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 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');
});