diff --git a/CHANGELOG.md b/CHANGELOG.md index 588dec35e..33a22184e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename ## [Unreleased] +### Fixed + +- Dry-fleet transcript and `/status` report the outcome tally only + (`2 done, 1 failed`). They no longer claim `nothing running` when the + parent may still continue. +- The prompt-box lockup stays on while a fleet is live or a dry-fleet + continuation is pending, even if the parent turn has settled. The word + cycles through a closed live-activity set (`working`, `warping`, + `buzzing`, `grinding`, `thinking`, `doing`, `cooking`, `creating`, + `imagining`, `inventing`) instead of going blank. + ## [0.3.19] - 2026-09-10 ### Security diff --git a/docs/TUI.md b/docs/TUI.md index 3e0452ade..ebb1d602b 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -94,13 +94,16 @@ and `@mention` tokens anywhere paint `UI.action`. Bare skill or agent words (`implement`, `emil`, `brand review`) stay unstyled, as does a `/review` that appears mid-prose. -While a turn is live the lockup slot swaps the wordmark for a semantic -activity word — never the raw tool, MCP server, or plugin identifier that is -actually executing. `resolveTurnLabel` (`src/tui/session-chrome.ts`) -maps execution onto the closed set `ACTIVITY_STATES` exported from that -module (`thinking`, `planning`, `researching`, `building`, `working`, -`waiting`, `stalled`, `stopping`); that export is the source -of truth for what the slot can say, not this list. It is led by a single density cell +While a turn is live — or the session is still occupied by a live fleet +or a pending dry-fleet continuation — the lockup slot swaps the wordmark +for a semantic activity word — never the raw tool, MCP server, or plugin +identifier that is actually executing. Live occupation cycles +`LIVE_ACTIVITY_WORDS` (`working`, `warping`, `buzzing`, `grinding`, +`thinking`, `doing`, `cooking`, `creating`, `imagining`, `inventing`) +on `LIVE_WORD_MS`; gated turns still read `waiting` or `stopping`. +`ACTIVITY_STATES` exported from the session chrome module is the source +of truth for what the slot can say, not this list. It is led by a single +density cell (`rampPulse`, `src/tui/ramp.ts`). The cell, not the word, is what says whether the session is healthy, and it carries four states: @@ -120,16 +123,16 @@ printed identically, so the only way to tell them apart was to wait. waiting on something outside itself — and are told apart by motion: `blocked` holds perfectly still, which is the signal that the session is waiting on _you_. -While fleet agents are running, the slot reports the _fleet_, not the parent. -`resolveTurnLabel` and `resolveRampPhase` take a `FleetProgress` roll-up and -rank it above the parent's own stall clock: with live lanes the parent is -idle by design, so its silence says nothing about whether the session is -progressing, and reporting it was how a session with every lane wedged still -read as `working`. A fleet with no stalled lane reads `working`; one -stalled lane makes the whole indicator read `stalled`, which is the state that -should pull an operator's eye to the panel. A blocked gate and a stopping turn -still outrank the fleet. With zero running fleet agents the roll-up is empty and -every path through both functions behaves exactly as it does for a plain +While fleet agents are running, the slot stays live even when the parent +turn has settled (idle-with-fleet). `resolveTurnLabel` and +`resolveRampPhase` take a `FleetProgress` roll-up plus session occupancy: +with live lanes the parent is idle by design, so its silence says nothing +about whether the session is progressing, and blanking the lockup made a +busy fleet look hung. Occupied sessions keep cycling a live-activity +word; recovery stays silent rather than painting `stalled`. A blocked +gate and a stopping turn still outrank the fleet. With zero running fleet +agents and no pending continuation the roll-up is empty and every path +through both functions behaves exactly as it does for a plain single-agent turn. The stall phase is driven by the watchdog's own silence clock @@ -255,8 +258,10 @@ Parent prose owns success narratives. Transcript fleet notices exist only for attention live spawn_agent rows cannot keep: a lane **failed** or **cancelled** while other work is still running, and **one** dry-fleet line when the last lane finishes -(`N done · nothing running`; failed and cancelled counts appear only -when non-zero, e.g. `N done, M failed, K cancelled · nothing running`). +(`N done`; failed and cancelled counts appear only +when non-zero, e.g. `N done, M failed, K cancelled`). +The line does not claim the run is idle — the parent often continues. +The prompt-box lockup is what names that occupation, not this tally. Per-lane `done — summary` walls and live `dispatched` re-announcements are never printed. That dry-fleet line stays operator-facing. If tasks are still todo/doing, the runtime re-enters the parent with collected diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index 63b82f25c..151ff6dc6 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -89,7 +89,7 @@ describe("observeFleet", () => { ], T0 + 1000, ); - expect(updates).toEqual(["2 done · nothing running"]); + expect(updates).toEqual(["2 done"]); }); test("a failure names what went wrong while the fleet is still live", () => { @@ -155,7 +155,7 @@ describe("observeFleet", () => { : { ...l, status: "failed" as const, error: "boom" }, ); const { updates } = observeFleet(seeded, after, T0 + 1000); - expect(updates).toEqual(["9 done, 3 failed · nothing running"]); + expect(updates).toEqual(["9 done, 3 failed"]); }); test("a cancelled-only dry fleet counts cancelled, not failed", () => { @@ -172,7 +172,7 @@ describe("observeFleet", () => { ], T0 + 1000, ); - expect(updates).toEqual(["0 done, 2 cancelled · nothing running"]); + expect(updates).toEqual(["0 done, 2 cancelled"]); }); test("a mixed dry fleet names done, failed, and cancelled separately", () => { @@ -190,9 +190,7 @@ describe("observeFleet", () => { ], T0 + 1000, ); - expect(updates).toEqual([ - "1 done, 1 failed, 1 cancelled · nothing running", - ]); + expect(updates).toEqual(["1 done, 1 failed, 1 cancelled"]); }); test("a burst of live cancels coalesces as cancelled, not failed", () => { @@ -236,11 +234,11 @@ describe("fleetDigest", () => { expect(digest).toBe("2 running (api 1:20, docs 0:20) · 1 done · 1 failed"); }); - test("a fleet with nothing left running says so rather than going blank", () => { + test("a dry fleet is the outcome tally, not an idle claim", () => { expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe( - "nothing running · 1 done", + "1 done", ); - expect(fleetDigest([], T0)).toBe("nothing running"); + expect(fleetDigest([], T0)).toBe(""); }); test("cancelled lanes are named separately from failed", () => { @@ -252,7 +250,7 @@ describe("fleetDigest", () => { ], T0, ), - ).toBe("nothing running · 1 failed · 1 cancelled"); + ).toBe("1 failed · 1 cancelled"); }); }); diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index 2dc98173b..f14000dd6 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -185,6 +185,10 @@ type Change = | { readonly kind: "cancelled"; readonly line: string } | { readonly kind: "stalled"; readonly line: string }; +export interface FleetReportOptions { + readonly stallMs?: number; +} + export interface FleetObservation { readonly watch: FleetWatch; /** Ready-to-print lines, already coalesced. Usually empty. */ @@ -195,8 +199,9 @@ export function observeFleet( previous: FleetWatch, lanes: readonly FleetLane[], nowMs: number, - stallMs: number = DEFAULT_STALL_MS, + options: FleetReportOptions = {}, ): FleetObservation { + const stallMs = options.stallMs ?? DEFAULT_STALL_MS; const marks = new Map(); const changes: Change[] = []; let running = 0; @@ -262,9 +267,7 @@ export function observeFleet( if (wentDry) { return { watch, - updates: [ - clip(`${idleSummary(lanes)} · nothing running`, MAX_UPDATE_CHARS), - ], + updates: [clip(idleSummary(lanes), MAX_UPDATE_CHARS)], }; } @@ -355,14 +358,12 @@ function idleSummary(lanes: readonly FleetLane[]): string { export function fleetDigest( lanes: readonly FleetLane[], nowMs: number, - stallMs: number = DEFAULT_STALL_MS, + options: FleetReportOptions = {}, ): string { - if (lanes.length === 0) return "nothing running"; + const stallMs = options.stallMs ?? DEFAULT_STALL_MS; const running = lanes.filter((l) => l.status === "running"); const parts: string[] = []; - if (running.length === 0) { - parts.push("nothing running"); - } else { + if (running.length > 0) { const named = running .slice(0, DIGEST_NAMED_LANES) .map((lane) => { diff --git a/src/subagent/index.ts b/src/subagent/index.ts index 7729d62f9..7f6d9110d 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -22,6 +22,7 @@ export { pendingAskWakeText, type FleetLane, type FleetObservation, + type FleetReportOptions, type FleetWatch, type PendingAskWake, } from "./fleet-report.js"; diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 7f09e04e3..0e09c82b2 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -79,6 +79,16 @@ describe("/status command", () => { }); }); + it("noops when the live fleet digest is empty so idle /status paints no blank row", () => { + const ctx: CommandContext = { + signalClear: () => undefined, + getFleetStatus: () => "", + }; + expect(defined(getCommand("status"), "status").handler("", ctx)).toEqual({ + type: "noop", + }); + }); + it("says so rather than throwing when no fleet source is wired", () => { expect( defined(getCommand("status"), "status").handler("", makeCtx()), diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 865420c15..884599e2f 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -146,6 +146,9 @@ export function registerBuiltInCommands(): void { text: "Fleet status is not available in this session.", }; } + if (status.length === 0) { + return { type: "noop" }; + } return { type: "message", text: status }; }, }); diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 0655c062b..a1b088f52 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -14,6 +14,7 @@ import { streamRowCount } from "./shell/transcript"; import { STEER_WAIT_NOTICE_MS } from "./notice-line"; import { withTestRenderer } from "./harness"; import { badgeCount } from "./session-queue"; +import { LIVE_ACTIVITY_WORDS } from "./session-chrome"; describe("mapReactorLike", () => { test("message.received → user", () => { @@ -1377,6 +1378,12 @@ describe("idle-with-fleet (CL-7057)", () => { // The parent turn settled but the fleet is live: the run stays // busy and the follow-up does not drain at mere parent-idle. expect(shell.session.run).toBe("busy"); + expect(shell.lockupPhase).not.toBeNull(); + expect( + (LIVE_ACTIVITY_WORDS as readonly string[]).includes( + shell.lockupPhase ?? "", + ), + ).toBe(true); expect(badgeCount(shell.session)).toBe(1); expect(port.calls).toEqual([]); await h.renderOnce(); @@ -1616,9 +1623,11 @@ describe("fleet-dry open-task drive (CL-7540)", () => { bridge.handle({ type: "fleet", running: 0 }); expect(drives).toBe(0); expect(shell.session.run).toBe("busy"); + expect(shell.lockupPhase).not.toBeNull(); settleToollessTurn(bridge); expect(drives).toBe(1); expect(shell.session.run).toBe("busy"); + expect(shell.lockupPhase).not.toBeNull(); bridge.submit("when it finishes, summarize", "queue"); expect(badgeCount(shell.session)).toBe(1); port.clear(); diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 65fd79273..360040bd4 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -1011,6 +1011,11 @@ function drainLiveSteersAtBoundary(shell: AppShell, bag: BridgeBag): void { } } +function occupancyHold(bag: BridgeBag, runBusy: boolean): boolean { + if (bag.liveFleet > 0 || bag.awaitingContinuationInference) return true; + return runBusy && bag.pendingDryOpenDrive; +} + /** * Release the run to idle and drain everything queued — but only at true * session-idle. A live fleet holds the run busy after the parent turn settles @@ -1286,15 +1291,18 @@ export function attachSessionBridge( status: turn.status, currentToolName: turn.currentToolName, streamingType: turn.streamingType, + nowMs, + sessionActive: occupancyHold(bag, shell.session.run === "busy"), }; const fleet = fleetProgress(bag.agentSessions, nowMs); const label = resolveTurnLabel(input, isStalled, fleet); + const sessionLive = label !== undefined; if (label === undefined) { // The bottom-left status slot rides the same re-entry as the landing // mark, so it crossfades between phases without a timer of its own. setLockupFrame(shell, { nowMs, - animating: turn.isProcessing, + animating: false, phase: null, rampPhase: null, stalledForMs: null, @@ -1309,7 +1317,7 @@ export function attachSessionBridge( const stalledFor = stalledForMs(nowMs, rampPhase === "stalled"); setLockupFrame(shell, { nowMs, - animating: turn.isProcessing, + animating: sessionLive, phase: label, rampPhase, stalledForMs: stalledFor, @@ -1383,13 +1391,19 @@ export function attachSessionBridge( if (onTurnBoundary(event) && bag.turn.activeToolCalls.length > 0) { drainLiveSteersAtBoundary(shell, bag); } - if (settled) settleRun(); + if (settled) { + settleRun(); + paintPhase(); + } return; } if (isBridgeInbound(event)) { applyInbound(shell, bag, event); } - if (settled) settleRun(); + if (settled) { + settleRun(); + paintPhase(); + } }; const recordLastSent = ( diff --git a/src/tui/session-chrome.test.ts b/src/tui/session-chrome.test.ts index b354c105a..6cd508317 100644 --- a/src/tui/session-chrome.test.ts +++ b/src/tui/session-chrome.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import { defined } from "../../tests/helpers/defined.js"; import { ACTIVITY_STATES, + LIVE_WORD_MS, classifyAgentSendFailure, classifySendFailureMessage, resolveRampPhase, @@ -67,7 +68,7 @@ describe("resolveTurnLabel closed-set guarantee", () => { null, ); // Recovery is silent — never paint "stalled" in the ticker. - expect(label).toBe("building"); + expect(label).toBe("working"); expect(ACTIVITY_STATES).toContain(defined(label)); }); @@ -134,7 +135,34 @@ describe("resolveTurnLabel", () => { ).toBe("stopping"); }); - test("tool phase maps to its semantic activity, never the raw name", () => { + test("a settled interrupt does not keep the stopping label", () => { + expect( + resolveTurnLabel( + { + isProcessing: false, + status: "stopping", + currentToolName: null, + streamingType: null, + }, + false, + null, + ), + ).toBeUndefined(); + }); + + test("occupied lockup cycles live-activity words, never the raw tool name", () => { + expect( + resolveTurnLabel( + { + isProcessing: true, + status: "running", + currentToolName: "grep", + streamingType: "tool", + }, + false, + null, + ), + ).toBe("working"); expect( resolveTurnLabel( { @@ -142,14 +170,15 @@ describe("resolveTurnLabel", () => { status: "running", currentToolName: "grep", streamingType: "tool", + nowMs: LIVE_WORD_MS, }, false, null, ), - ).toBe("researching"); + ).toBe("warping"); }); - test("thinking and text phases", () => { + test("thinking and text phases cycle the same live-activity words", () => { const base = { isProcessing: true, status: "running" as const, @@ -157,7 +186,14 @@ describe("resolveTurnLabel", () => { }; expect( resolveTurnLabel({ ...base, streamingType: "thinking" }, false, null), - ).toBe("thinking"); + ).toBe("working"); + expect( + resolveTurnLabel( + { ...base, streamingType: "thinking", nowMs: LIVE_WORD_MS }, + false, + null, + ), + ).toBe("warping"); expect( resolveTurnLabel({ ...base, streamingType: "text" }, false, null), ).toBe("working"); @@ -351,7 +387,7 @@ describe("fleet state in the top-level indicator", () => { resolveTurnLabel(parentAwaitingChildren, false, null), ); expect(resolveTurnLabel(parentAwaitingChildren, true, none)).toBe( - "planning", + "working", ); expect(resolveRampPhase(parentAwaitingChildren, true, none)).toBe( "working", @@ -374,4 +410,41 @@ describe("fleet state in the top-level indicator", () => { ), ).toBe("stopping"); }); + + test("a settled parent with live lanes still names activity in the lockup", () => { + const idleParent = { + isProcessing: false, + status: "done" as const, + currentToolName: null, + streamingType: null, + sessionActive: true, + }; + expect(resolveTurnLabel(idleParent, false, fleet(2, 0))).toBe("working"); + expect(resolveRampPhase(idleParent, false, fleet(2, 0))).toBe("working"); + expect( + resolveTurnLabel( + { ...idleParent, nowMs: LIVE_WORD_MS }, + false, + fleet(2, 0), + ), + ).toBe("warping"); + expect( + resolveTurnLabel( + { ...idleParent, sessionActive: true, nowMs: LIVE_WORD_MS }, + false, + fleet(0, 0), + ), + ).toBe("warping"); + }); + + test("live-activity words cycle while the session is occupied", () => { + const live = { + isProcessing: true, + status: "running" as const, + currentToolName: null, + streamingType: null, + nowMs: LIVE_WORD_MS, + }; + expect(resolveTurnLabel(live, false, null)).toBe("warping"); + }); }); diff --git a/src/tui/session-chrome.ts b/src/tui/session-chrome.ts index d225c4832..985fb0d90 100644 --- a/src/tui/session-chrome.ts +++ b/src/tui/session-chrome.ts @@ -25,6 +25,13 @@ export interface TurnLabelInput { readonly status: TurnStatus; readonly currentToolName: string | null; readonly streamingType: "text" | "thinking" | "tool" | null; + /** Clock for cycling live-activity words. Missing means the first word. */ + readonly nowMs?: number; + /** + * Session is still occupied even if this parent turn has settled — + * live fleet occupancy or a pending dry-fleet continuation. + */ + readonly sessionActive?: boolean; } /** @@ -35,11 +42,16 @@ export interface TurnLabelInput { * can appear in the ticker." */ export const ACTIVITY_STATES = [ - "thinking", - "planning", - "researching", - "building", "working", + "warping", + "buzzing", + "grinding", + "thinking", + "doing", + "cooking", + "creating", + "imagining", + "inventing", "waiting", "stalled", "stopping", @@ -47,34 +59,35 @@ export const ACTIVITY_STATES = [ export type ActivityState = (typeof ACTIVITY_STATES)[number]; -/** - * Execution → activity-state mapping, kept in this one place with an - * explicit fallback so a newly added tool (built-in, MCP, or plugin) renders - * a generic "working" state instead of leaking its identifier — no ticker - * change is required to add a tool correctly. - */ -const TOOL_ACTIVITY_STATES: Readonly> = { - read_file: "researching", - search_files: "researching", - grep: "researching", - list_dir: "researching", - web_search: "researching", - web_fetch: "researching", - write_file: "building", - edit_file: "building", - run_shell: "building", - delete_file: "building", - manage_tasks: "planning", - task: "planning", - tool_search: "researching", - search_agents: "researching", - ask_operator: "waiting", - submit_output: "working", -}; +/** Words the lockup cycles while the session is live and not gated. */ +export const LIVE_ACTIVITY_WORDS = [ + "working", + "warping", + "buzzing", + "grinding", + "thinking", + "doing", + "cooking", + "creating", + "imagining", + "inventing", +] as const; -function activityStateForTool(name: string | null): ActivityState { - if (name === null) return "working"; - return TOOL_ACTIVITY_STATES[name] ?? "working"; +/** How long each live-activity word holds before the next. */ +export const LIVE_WORD_MS = 4_000; + +function liveActivityWord(nowMs: number): ActivityState { + const index = Math.floor(nowMs / LIVE_WORD_MS) % LIVE_ACTIVITY_WORDS.length; + return LIVE_ACTIVITY_WORDS[index] ?? "working"; +} + +function sessionIsLive( + input: TurnLabelInput, + fleet: FleetProgress | null, +): boolean { + if (input.isProcessing) return true; + if (input.sessionActive === true) return true; + return fleet !== null && fleet.running > 0; } /** @@ -94,23 +107,22 @@ export function resolveTurnLabel( isStalled: boolean, fleet: FleetProgress | null, ): ActivityState | undefined { - if (!input.isProcessing) return undefined; - if (input.status === "blocked") return "waiting"; - if (input.status === "stopping" || input.status === "stopped") { - return "stopping"; + const occupied = sessionIsLive(input, fleet); + if (input.status === "blocked" && (input.isProcessing || occupied)) { + return "waiting"; } - // Live fleet means the session is working — recovery is silent. Never paint - // "stalled" for the operator; the orchestrator keeps lanes moving. - if (fleet !== null && fleet.running > 0) { - return "working"; + // Stopping is this parent turn aborting. A settled parent with live + // lanes is still occupied — don't let a leftover stopping status blank + // the lockup or freeze it on "stopping". + if ( + input.isProcessing && + (input.status === "stopping" || input.status === "stopped") + ) { + return "stopping"; } - // Parent silence is still work-in-progress from the operator's POV; nudge - // paths handle recovery without renaming the ticker. + if (!occupied) return undefined; void isStalled; - if (input.currentToolName !== null) - return activityStateForTool(input.currentToolName); - if (input.streamingType === "thinking") return "thinking"; - return "working"; + return liveActivityWord(input.nowMs ?? 0); } /** @@ -125,12 +137,16 @@ export function resolveRampPhase( fleet: FleetProgress | null, ): RampPhase { if (input.status === "blocked") return "blocked"; - if (input.status === "done") return "done"; - // Operator chrome never enters the stalled ramp: fleet or parent silence is - // still "working" while recovery runs under the hood. - if (fleet !== null && fleet.running > 0) { + // Occupied session (live lanes or a pending continuation) stays the working + // ramp even if this parent turn already settled as done. + if ( + sessionIsLive(input, fleet) && + (input.sessionActive === true || (fleet !== null && fleet.running > 0)) + ) { + void isStalled; return "working"; } + if (input.status === "done") return "done"; void isStalled; return "working"; } diff --git a/src/tui/turn-monitor.test.ts b/src/tui/turn-monitor.test.ts index 5e2286580..e1361d771 100644 --- a/src/tui/turn-monitor.test.ts +++ b/src/tui/turn-monitor.test.ts @@ -10,6 +10,7 @@ import { noticeText } from "./shell/chrome.js"; import { createAppShell } from "./shell/index.js"; import { withTestRenderer } from "./harness.js"; import { RUNTIME_FLASH_MS } from "./runtime-notices.js"; +import { LIVE_WORD_MS } from "./session-chrome.js"; import { STALL_NOTICE_MESSAGE, STALL_RECOVERY_MESSAGE, @@ -71,7 +72,7 @@ describe("turn progress label", () => { type: "inference.thinking.delta", data: { token: "hm" }, }); - expect(t.shell.lockupPhase).toBe("thinking"); + expect(t.shell.lockupPhase).toBe("working"); t.bridge.handle({ type: "inference.text.delta", @@ -111,13 +112,13 @@ describe("turn progress label", () => { expect(t.shell.lockupPhase).toBe("working"); const started = t.shell.lockupChangedMs; - t.advance(500); + t.advance(LIVE_WORD_MS); t.bridge.handle({ type: "inference.thinking.delta", data: { token: "hm" }, }); - expect(t.shell.lockupPhase).toBe("thinking"); - // A new phase restamps the fade so the crossfade starts over. + expect(t.shell.lockupPhase).toBe("warping"); + // A new word restamps the fade so the crossfade starts over. expect(t.shell.lockupChangedMs).toBeGreaterThan(started); t.bridge.handle({ type: "reactor.done", data: {} });