From c506b76e2d3cf94d8793a993c23024a4068e4adb Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 01:04:56 -0700 Subject: [PATCH 1/4] Drop nothing running from dry fleet tallies --- docs/TUI.md | 8 ++- src/subagent/fleet-report.test.ts | 83 +++++++++++++++++++++++++++---- src/subagent/fleet-report.ts | 42 +++++++++++++--- src/tui/runner/commands.ts | 7 ++- src/tui/runner/wiring.ts | 6 +++ 5 files changed, 125 insertions(+), 21 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 3e0452ade..6b10135bb 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -255,8 +255,12 @@ 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`). +When the parent is still in a turn or still has todo/doing work, that +line appends `orchestrator continuing`. The suffix is omitted when the +orchestrator is idle, and is not added while specialist lanes are still +running. An empty fleet with no outcomes does not claim the job closed. 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..22c7cf698 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", () => { @@ -216,6 +214,46 @@ describe("observeFleet", () => { const { updates } = observeFleet(seeded, after, T0 + 1000); expect(updates).toEqual(["2 failed, 2 cancelled"]); }); + + test("a dry fleet appends orchestrator continuing only when the parent is still working", () => { + const seeded = observeFleet( + createFleetWatch(), + [lane({ id: "api" }), lane({ id: "docs", status: "done" })], + T0, + ).watch; + const lanes = [ + lane({ id: "api", status: "done" as const, report: "done" }), + lane({ id: "docs", status: "done" as const }), + ]; + expect(observeFleet(seeded, lanes, T0 + 1000).updates).toEqual(["2 done"]); + expect( + observeFleet(seeded, lanes, T0 + 1000, { + orchestratorContinuing: true, + }).updates, + ).toEqual(["2 done · orchestrator continuing"]); + }); + + test("orchestrator continuing is not added while a specialist lane is still running", () => { + const seeded = observeFleet( + createFleetWatch(), + [lane({ id: "build" }), lane({ id: "docs" })], + T0, + ).watch; + const { updates } = observeFleet( + seeded, + [ + lane({ + id: "build", + status: "failed", + error: "typecheck exited 1", + }), + lane({ id: "docs" }), + ], + T0 + 1000, + { orchestratorContinuing: true }, + ); + expect(updates).toEqual(["build failed — typecheck exited 1"]); + }); }); describe("fleetDigest", () => { @@ -236,11 +274,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 with outcomes is the tally only — idle does not claim the job closed", () => { 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 +290,32 @@ describe("fleetDigest", () => { ], T0, ), - ).toBe("nothing running · 1 failed · 1 cancelled"); + ).toBe("1 failed · 1 cancelled"); + }); + + test("orchestrator continuing is a dry-fleet suffix, never a live-lane claim", () => { + expect( + fleetDigest([lane({ id: "api", status: "done" })], T0, { + orchestratorContinuing: true, + }), + ).toBe("1 done · orchestrator continuing"); + expect(fleetDigest([], T0, { orchestratorContinuing: true })).toBe( + "orchestrator continuing", + ); + expect( + fleetDigest( + [ + lane({ + id: "api", + startedAt: T0 - 80_000, + lastActivityAt: T0 - 1000, + }), + lane({ id: "web", status: "done" }), + ], + T0, + { orchestratorContinuing: true }, + ), + ).toBe("1 running (api 1:20) · 1 done"); }); }); diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index 2dc98173b..046ed2134 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -191,12 +191,19 @@ export interface FleetObservation { readonly updates: readonly string[]; } +export interface FleetReportOpts { + readonly stallMs?: number; + readonly orchestratorContinuing?: boolean; +} + export function observeFleet( previous: FleetWatch, lanes: readonly FleetLane[], nowMs: number, - stallMs: number = DEFAULT_STALL_MS, + opts: FleetReportOpts = {}, ): FleetObservation { + const stallMs = opts.stallMs ?? DEFAULT_STALL_MS; + const orchestratorContinuing = opts.orchestratorContinuing === true; const marks = new Map(); const changes: Change[] = []; let running = 0; @@ -263,7 +270,7 @@ export function observeFleet( return { watch, updates: [ - clip(`${idleSummary(lanes)} · nothing running`, MAX_UPDATE_CHARS), + clip(dryFleetLine(lanes, orchestratorContinuing), MAX_UPDATE_CHARS), ], }; } @@ -348,6 +355,20 @@ function idleSummary(lanes: readonly FleetLane[]): string { }).join(", "); } +function withOrchestratorContinuing(line: string, continuing: boolean): string { + if (!continuing) return line; + return line.length === 0 + ? "orchestrator continuing" + : `${line} · orchestrator continuing`; +} + +function dryFleetLine( + lanes: readonly FleetLane[], + orchestratorContinuing: boolean, +): string { + return withOrchestratorContinuing(idleSummary(lanes), orchestratorContinuing); +} + /** * The answer to "where are we" on demand — the same picture the unprompted * lines build up to, in one row, so asking never costs an interrupt. @@ -355,14 +376,16 @@ function idleSummary(lanes: readonly FleetLane[]): string { export function fleetDigest( lanes: readonly FleetLane[], nowMs: number, - stallMs: number = DEFAULT_STALL_MS, + opts: FleetReportOpts = {}, ): string { - if (lanes.length === 0) return "nothing running"; + const stallMs = opts.stallMs ?? DEFAULT_STALL_MS; + const orchestratorContinuing = opts.orchestratorContinuing === true; + if (lanes.length === 0) { + return orchestratorContinuing ? "orchestrator continuing" : ""; + } 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) => { @@ -379,5 +402,8 @@ export function fleetDigest( parts.push( ...formatOutcomeParts(outcomeCounts(lanes), { includeZeroDone: false }), ); - return parts.join(" · "); + const digest = parts.join(" · "); + // Live lanes already name themselves; the suffix is only for a dry fleet. + if (running.length > 0) return digest; + return withOrchestratorContinuing(digest, orchestratorContinuing); } diff --git a/src/tui/runner/commands.ts b/src/tui/runner/commands.ts index e28db591b..f8ef6e6c0 100644 --- a/src/tui/runner/commands.ts +++ b/src/tui/runner/commands.ts @@ -35,6 +35,7 @@ import { } from "../../cost/cost-summary.js"; import { contextTokensFromUsage } from "../../provider/context-window.js"; import { fleetDigest } from "../../subagent/index.js"; +import { hasActiveTasks } from "../../agent/tasks.js"; import { renameSession } from "../../session/index.js"; import { truncateSessionLabel } from "../../session/session-label.js"; import { surfaceSystemNotice, attachClipboardImage } from "../shell/prompt.js"; @@ -166,7 +167,11 @@ export function createCommandLayer( }, startWorkflow: (name) => services.workflowHost.start(name), getFleetStatus: () => - fleetDigest(services.subAgentSessions.list(), Date.now()), + fleetDigest(services.subAgentSessions.list(), Date.now(), { + orchestratorContinuing: + hostOf(state).bridge.turn.isProcessing || + hasActiveTasks(services.directorHolder.instance?.getTasks() ?? []), + }), renameSession: (name) => { const trimmed = name.trim(); if (trimmed.length === 0) return "Session name cannot be empty"; diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index 5dbac175a..45e827da9 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -31,6 +31,7 @@ import { import { scheduleUpgradeNotice } from "../../upgrade/index.js"; import pkg from "../../../package.json" with { type: "json" }; import { hydrateTasksFromTurns } from "../../agent/director.js"; +import { hasActiveTasks } from "../../agent/tasks.js"; import { cycleReasoningEffort } from "../../provider/reasoning-effort.js"; import { isCodexProviderName } from "../../config/codex-providers.js"; import { RUNTIME_FLASH_MS } from "../runtime-notices.js"; @@ -181,6 +182,11 @@ export function wirePostStartup( fleetWatch, services.subAgentSessions.list(), Date.now(), + { + orchestratorContinuing: + sessionBridge.turn.isProcessing || + hasActiveTasks(services.directorHolder.instance?.getTasks() ?? []), + }, ); fleetWatch = observation.watch; for (const update of observation.updates) From 6bdcd7cd091816841d3e3a604a5c7946e356d481 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 01:58:34 -0700 Subject: [PATCH 2/4] Skip empty idle status fleet digest --- src/tui/commands/built-in.test.ts | 10 ++++++++++ src/tui/commands/built-in.ts | 3 +++ 2 files changed, 13 insertions(+) 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 }; }, }); From dde35ae80992811bb972489e73051fe91ec5954a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 00:37:06 -0700 Subject: [PATCH 3/4] Keep occupation visible in the prompt lockup A dry fleet no longer means the run is over, so the tally must not claim nothing is running. The lockup is what names that the parent or fleet is still working. --- CHANGELOG.md | 11 +++ docs/TUI.md | 44 ++++++------ src/subagent/fleet-report.test.ts | 71 +----------------- src/subagent/fleet-report.ts | 45 +++--------- src/subagent/index.ts | 1 + src/tui/runner/commands.ts | 7 +- src/tui/runner/wiring.ts | 6 -- src/tui/runtime-bridge.test.ts | 5 ++ src/tui/runtime-bridge.ts | 7 +- src/tui/session-chrome.test.ts | 45 +++++++++++- src/tui/session-chrome.ts | 115 +++++++++++++++++++++--------- 11 files changed, 182 insertions(+), 175 deletions(-) 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 6b10135bb..52c72adbe 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -94,13 +94,17 @@ 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. `resolveTurnLabel` +(`src/tui/session-chrome.ts`) maps execution onto the closed set +`ACTIVITY_STATES` exported from that module. 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`. +That export 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 +124,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 @@ -257,10 +261,8 @@ while other work is still running, and **one** dry-fleet line when the last lane finishes (`N done`; failed and cancelled counts appear only when non-zero, e.g. `N done, M failed, K cancelled`). -When the parent is still in a turn or still has todo/doing work, that -line appends `orchestrator continuing`. The suffix is omitted when the -orchestrator is idle, and is not added while specialist lanes are still -running. An empty fleet with no outcomes does not claim the job closed. +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 22c7cf698..03e9ee6ec 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -214,46 +214,6 @@ describe("observeFleet", () => { const { updates } = observeFleet(seeded, after, T0 + 1000); expect(updates).toEqual(["2 failed, 2 cancelled"]); }); - - test("a dry fleet appends orchestrator continuing only when the parent is still working", () => { - const seeded = observeFleet( - createFleetWatch(), - [lane({ id: "api" }), lane({ id: "docs", status: "done" })], - T0, - ).watch; - const lanes = [ - lane({ id: "api", status: "done" as const, report: "done" }), - lane({ id: "docs", status: "done" as const }), - ]; - expect(observeFleet(seeded, lanes, T0 + 1000).updates).toEqual(["2 done"]); - expect( - observeFleet(seeded, lanes, T0 + 1000, { - orchestratorContinuing: true, - }).updates, - ).toEqual(["2 done · orchestrator continuing"]); - }); - - test("orchestrator continuing is not added while a specialist lane is still running", () => { - const seeded = observeFleet( - createFleetWatch(), - [lane({ id: "build" }), lane({ id: "docs" })], - T0, - ).watch; - const { updates } = observeFleet( - seeded, - [ - lane({ - id: "build", - status: "failed", - error: "typecheck exited 1", - }), - lane({ id: "docs" }), - ], - T0 + 1000, - { orchestratorContinuing: true }, - ); - expect(updates).toEqual(["build failed — typecheck exited 1"]); - }); }); describe("fleetDigest", () => { @@ -274,10 +234,8 @@ describe("fleetDigest", () => { expect(digest).toBe("2 running (api 1:20, docs 0:20) · 1 done · 1 failed"); }); - test("a dry fleet with outcomes is the tally only — idle does not claim the job closed", () => { - expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe( - "1 done", - ); + test("a dry fleet is the outcome tally, not an idle claim", () => { + expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe("1 done"); expect(fleetDigest([], T0)).toBe(""); }); @@ -292,31 +250,6 @@ describe("fleetDigest", () => { ), ).toBe("1 failed · 1 cancelled"); }); - - test("orchestrator continuing is a dry-fleet suffix, never a live-lane claim", () => { - expect( - fleetDigest([lane({ id: "api", status: "done" })], T0, { - orchestratorContinuing: true, - }), - ).toBe("1 done · orchestrator continuing"); - expect(fleetDigest([], T0, { orchestratorContinuing: true })).toBe( - "orchestrator continuing", - ); - expect( - fleetDigest( - [ - lane({ - id: "api", - startedAt: T0 - 80_000, - lastActivityAt: T0 - 1000, - }), - lane({ id: "web", status: "done" }), - ], - T0, - { orchestratorContinuing: true }, - ), - ).toBe("1 running (api 1:20) · 1 done"); - }); }); describe("forced-stop reasons", () => { diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts index 046ed2134..f14000dd6 100644 --- a/src/subagent/fleet-report.ts +++ b/src/subagent/fleet-report.ts @@ -185,25 +185,23 @@ 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. */ readonly updates: readonly string[]; } -export interface FleetReportOpts { - readonly stallMs?: number; - readonly orchestratorContinuing?: boolean; -} - export function observeFleet( previous: FleetWatch, lanes: readonly FleetLane[], nowMs: number, - opts: FleetReportOpts = {}, + options: FleetReportOptions = {}, ): FleetObservation { - const stallMs = opts.stallMs ?? DEFAULT_STALL_MS; - const orchestratorContinuing = opts.orchestratorContinuing === true; + const stallMs = options.stallMs ?? DEFAULT_STALL_MS; const marks = new Map(); const changes: Change[] = []; let running = 0; @@ -269,9 +267,7 @@ export function observeFleet( if (wentDry) { return { watch, - updates: [ - clip(dryFleetLine(lanes, orchestratorContinuing), MAX_UPDATE_CHARS), - ], + updates: [clip(idleSummary(lanes), MAX_UPDATE_CHARS)], }; } @@ -355,20 +351,6 @@ function idleSummary(lanes: readonly FleetLane[]): string { }).join(", "); } -function withOrchestratorContinuing(line: string, continuing: boolean): string { - if (!continuing) return line; - return line.length === 0 - ? "orchestrator continuing" - : `${line} · orchestrator continuing`; -} - -function dryFleetLine( - lanes: readonly FleetLane[], - orchestratorContinuing: boolean, -): string { - return withOrchestratorContinuing(idleSummary(lanes), orchestratorContinuing); -} - /** * The answer to "where are we" on demand — the same picture the unprompted * lines build up to, in one row, so asking never costs an interrupt. @@ -376,13 +358,9 @@ function dryFleetLine( export function fleetDigest( lanes: readonly FleetLane[], nowMs: number, - opts: FleetReportOpts = {}, + options: FleetReportOptions = {}, ): string { - const stallMs = opts.stallMs ?? DEFAULT_STALL_MS; - const orchestratorContinuing = opts.orchestratorContinuing === true; - if (lanes.length === 0) { - return orchestratorContinuing ? "orchestrator continuing" : ""; - } + const stallMs = options.stallMs ?? DEFAULT_STALL_MS; const running = lanes.filter((l) => l.status === "running"); const parts: string[] = []; if (running.length > 0) { @@ -402,8 +380,5 @@ export function fleetDigest( parts.push( ...formatOutcomeParts(outcomeCounts(lanes), { includeZeroDone: false }), ); - const digest = parts.join(" · "); - // Live lanes already name themselves; the suffix is only for a dry fleet. - if (running.length > 0) return digest; - return withOrchestratorContinuing(digest, orchestratorContinuing); + return parts.join(" · "); } 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/runner/commands.ts b/src/tui/runner/commands.ts index f8ef6e6c0..e28db591b 100644 --- a/src/tui/runner/commands.ts +++ b/src/tui/runner/commands.ts @@ -35,7 +35,6 @@ import { } from "../../cost/cost-summary.js"; import { contextTokensFromUsage } from "../../provider/context-window.js"; import { fleetDigest } from "../../subagent/index.js"; -import { hasActiveTasks } from "../../agent/tasks.js"; import { renameSession } from "../../session/index.js"; import { truncateSessionLabel } from "../../session/session-label.js"; import { surfaceSystemNotice, attachClipboardImage } from "../shell/prompt.js"; @@ -167,11 +166,7 @@ export function createCommandLayer( }, startWorkflow: (name) => services.workflowHost.start(name), getFleetStatus: () => - fleetDigest(services.subAgentSessions.list(), Date.now(), { - orchestratorContinuing: - hostOf(state).bridge.turn.isProcessing || - hasActiveTasks(services.directorHolder.instance?.getTasks() ?? []), - }), + fleetDigest(services.subAgentSessions.list(), Date.now()), renameSession: (name) => { const trimmed = name.trim(); if (trimmed.length === 0) return "Session name cannot be empty"; diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index 45e827da9..5dbac175a 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -31,7 +31,6 @@ import { import { scheduleUpgradeNotice } from "../../upgrade/index.js"; import pkg from "../../../package.json" with { type: "json" }; import { hydrateTasksFromTurns } from "../../agent/director.js"; -import { hasActiveTasks } from "../../agent/tasks.js"; import { cycleReasoningEffort } from "../../provider/reasoning-effort.js"; import { isCodexProviderName } from "../../config/codex-providers.js"; import { RUNTIME_FLASH_MS } from "../runtime-notices.js"; @@ -182,11 +181,6 @@ export function wirePostStartup( fleetWatch, services.subAgentSessions.list(), Date.now(), - { - orchestratorContinuing: - sessionBridge.turn.isProcessing || - hasActiveTasks(services.directorHolder.instance?.getTasks() ?? []), - }, ); fleetWatch = observation.watch; for (const update of observation.updates) diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 0655c062b..a3c1e88a3 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,10 @@ 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(); diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 65fd79273..42e2573ca 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -1286,15 +1286,18 @@ export function attachSessionBridge( status: turn.status, currentToolName: turn.currentToolName, streamingType: turn.streamingType, + nowMs, + sessionActive: bag.liveFleet > 0, }; 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 +1312,7 @@ export function attachSessionBridge( const stalledFor = stalledForMs(nowMs, rampPhase === "stalled"); setLockupFrame(shell, { nowMs, - animating: turn.isProcessing, + animating: sessionLive, phase: label, rampPhase, stalledForMs: stalledFor, diff --git a/src/tui/session-chrome.test.ts b/src/tui/session-chrome.test.ts index b354c105a..0363826bf 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("creating"); expect(ACTIVITY_STATES).toContain(defined(label)); }); @@ -134,6 +135,21 @@ describe("resolveTurnLabel", () => { ).toBe("stopping"); }); + test("a settled interrupt does not keep the stopping label", () => { + expect( + resolveTurnLabel( + { + isProcessing: false, + status: "stopping", + currentToolName: null, + streamingType: null, + }, + false, + null, + ), + ).toBeUndefined(); + }); + test("tool phase maps to its semantic activity, never the raw name", () => { expect( resolveTurnLabel( @@ -146,7 +162,7 @@ describe("resolveTurnLabel", () => { false, null, ), - ).toBe("researching"); + ).toBe("grinding"); }); test("thinking and text phases", () => { @@ -351,7 +367,7 @@ describe("fleet state in the top-level indicator", () => { resolveTurnLabel(parentAwaitingChildren, false, null), ); expect(resolveTurnLabel(parentAwaitingChildren, true, none)).toBe( - "planning", + "imagining", ); expect(resolveRampPhase(parentAwaitingChildren, true, none)).toBe( "working", @@ -374,4 +390,27 @@ 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"); + }); + + 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..0efface13 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,6 +59,23 @@ export const ACTIVITY_STATES = [ export type ActivityState = (typeof ACTIVITY_STATES)[number]; +/** 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; + +/** How long each live-activity word holds before the next. */ +export const LIVE_WORD_MS = 4_000; + /** * Execution → activity-state mapping, kept in this one place with an * explicit fallback so a newly added tool (built-in, MCP, or plugin) renders @@ -54,22 +83,22 @@ export type ActivityState = (typeof ACTIVITY_STATES)[number]; * 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", + read_file: "grinding", + search_files: "grinding", + grep: "grinding", + list_dir: "grinding", + web_search: "grinding", + web_fetch: "grinding", + write_file: "creating", + edit_file: "creating", + run_shell: "creating", + delete_file: "creating", + manage_tasks: "imagining", + task: "imagining", + tool_search: "grinding", + search_agents: "grinding", ask_operator: "waiting", - submit_output: "working", + submit_output: "doing", }; function activityStateForTool(name: string | null): ActivityState { @@ -77,6 +106,17 @@ function activityStateForTool(name: string | null): ActivityState { return TOOL_ACTIVITY_STATES[name] ?? "working"; } +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; +} + /** * Single session-phase label accompanying the density ramp. Lowercase and * unpunctuated — the ramp's color and motion carry the state, so the word only @@ -94,23 +134,28 @@ 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") { + const occupied = sessionIsLive(input, fleet); + if (input.status === "blocked" && (input.isProcessing || occupied)) { + return "waiting"; + } + // 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"; } - // 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"; + if (!occupied) return undefined; + // Live fleet / parent continuation means the session is working — recovery + // is silent. Never paint "stalled" for the operator. + const fleetLive = fleet !== null && fleet.running > 0; + if (fleetLive || input.sessionActive === true) { + return liveActivityWord(input.nowMs ?? 0); } - // Parent silence is still work-in-progress from the operator's POV; nudge - // paths handle recovery without renaming the ticker. 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 +170,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"; } From be3d9de570c1ef196257008ee68576205b04d0db Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 10 Sep 2026 06:40:02 -0700 Subject: [PATCH 4/4] Keep the lockup cycling while the session is occupied --- docs/TUI.md | 9 +++--- src/subagent/fleet-report.test.ts | 4 ++- src/tui/runtime-bridge.test.ts | 10 +++++-- src/tui/runtime-bridge.ts | 17 +++++++++-- src/tui/session-chrome.test.ts | 46 +++++++++++++++++++++++++---- src/tui/session-chrome.ts | 49 +++++-------------------------- src/tui/turn-monitor.test.ts | 9 +++--- 7 files changed, 81 insertions(+), 63 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index 52c72adbe..ebb1d602b 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -97,14 +97,13 @@ that appears mid-prose. 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. `resolveTurnLabel` -(`src/tui/session-chrome.ts`) maps execution onto the closed set -`ACTIVITY_STATES` exported from that module. Live occupation cycles +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`. -That export is the source of truth for what the slot can say, not this -list. It is led by a single density cell +`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: diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts index 03e9ee6ec..151ff6dc6 100644 --- a/src/subagent/fleet-report.test.ts +++ b/src/subagent/fleet-report.test.ts @@ -235,7 +235,9 @@ describe("fleetDigest", () => { }); test("a dry fleet is the outcome tally, not an idle claim", () => { - expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe("1 done"); + expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe( + "1 done", + ); expect(fleetDigest([], T0)).toBe(""); }); diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index a3c1e88a3..a1b088f52 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -1379,9 +1379,11 @@ describe("idle-with-fleet (CL-7057)", () => { // 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( + (LIVE_ACTIVITY_WORDS as readonly string[]).includes( + shell.lockupPhase ?? "", + ), + ).toBe(true); expect(badgeCount(shell.session)).toBe(1); expect(port.calls).toEqual([]); await h.renderOnce(); @@ -1621,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 42e2573ca..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 @@ -1287,7 +1292,7 @@ export function attachSessionBridge( currentToolName: turn.currentToolName, streamingType: turn.streamingType, nowMs, - sessionActive: bag.liveFleet > 0, + sessionActive: occupancyHold(bag, shell.session.run === "busy"), }; const fleet = fleetProgress(bag.agentSessions, nowMs); const label = resolveTurnLabel(input, isStalled, fleet); @@ -1386,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 0363826bf..6cd508317 100644 --- a/src/tui/session-chrome.test.ts +++ b/src/tui/session-chrome.test.ts @@ -68,7 +68,7 @@ describe("resolveTurnLabel closed-set guarantee", () => { null, ); // Recovery is silent — never paint "stalled" in the ticker. - expect(label).toBe("creating"); + expect(label).toBe("working"); expect(ACTIVITY_STATES).toContain(defined(label)); }); @@ -150,7 +150,19 @@ describe("resolveTurnLabel", () => { ).toBeUndefined(); }); - test("tool phase maps to its semantic activity, never the raw name", () => { + 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( { @@ -158,14 +170,15 @@ describe("resolveTurnLabel", () => { status: "running", currentToolName: "grep", streamingType: "tool", + nowMs: LIVE_WORD_MS, }, false, null, ), - ).toBe("grinding"); + ).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, @@ -173,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"); @@ -367,7 +387,7 @@ describe("fleet state in the top-level indicator", () => { resolveTurnLabel(parentAwaitingChildren, false, null), ); expect(resolveTurnLabel(parentAwaitingChildren, true, none)).toBe( - "imagining", + "working", ); expect(resolveRampPhase(parentAwaitingChildren, true, none)).toBe( "working", @@ -401,6 +421,20 @@ describe("fleet state in the top-level indicator", () => { }; 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", () => { diff --git a/src/tui/session-chrome.ts b/src/tui/session-chrome.ts index 0efface13..985fb0d90 100644 --- a/src/tui/session-chrome.ts +++ b/src/tui/session-chrome.ts @@ -76,42 +76,15 @@ export const LIVE_ACTIVITY_WORDS = [ /** How long each live-activity word holds before the next. */ export const LIVE_WORD_MS = 4_000; -/** - * 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: "grinding", - search_files: "grinding", - grep: "grinding", - list_dir: "grinding", - web_search: "grinding", - web_fetch: "grinding", - write_file: "creating", - edit_file: "creating", - run_shell: "creating", - delete_file: "creating", - manage_tasks: "imagining", - task: "imagining", - tool_search: "grinding", - search_agents: "grinding", - ask_operator: "waiting", - submit_output: "doing", -}; - -function activityStateForTool(name: string | null): ActivityState { - if (name === null) return "working"; - return TOOL_ACTIVITY_STATES[name] ?? "working"; -} - 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 { +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; @@ -141,20 +114,14 @@ export function resolveTurnLabel( // 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")) { + if ( + input.isProcessing && + (input.status === "stopping" || input.status === "stopped") + ) { return "stopping"; } if (!occupied) return undefined; - // Live fleet / parent continuation means the session is working — recovery - // is silent. Never paint "stalled" for the operator. - const fleetLive = fleet !== null && fleet.running > 0; - if (fleetLive || input.sessionActive === true) { - return liveActivityWord(input.nowMs ?? 0); - } void isStalled; - if (input.currentToolName !== null) - return activityStateForTool(input.currentToolName); - if (input.streamingType === "thinking") return "thinking"; return liveActivityWord(input.nowMs ?? 0); } 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: {} });