Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,39 @@ the way down. Only once every other collapsible zone ahead of it in
`COLLAPSE_ORDER` and the panel itself are exhausted does it reach 0, the
same last-resort floor every other optional zone shares.

### Unprompted fleet reports

The agents panel is a standing picture of what is running right now; it says
nothing when a lane finishes, stalls, or fails unless the operator interrupts
to ask. `src/subagent/fleet-report.ts` closes that gap with its own channel:
a system-notice line, pushed into the transcript through the same
`surfaceSystemNotice` path as any other system row, the moment a lane
transition is worth saying. It does not touch the panel's rows or its
`laneState()` computation — it reads the same sub-agent session store the
panel reads, and calls the same `agentProgress()` stall definition
(`isStalled`) so the two surfaces never disagree about whether a lane is
stalled, only about *when* they say so: the panel shows it continuously,
the notice announces the transition once.

Store changes drive it directly, so a lane finishing or failing lands the
moment it happens. A `FLEET_REPORT_SETTLE_MS` (400ms) timer lets a parallel
dispatch that lands as N store changes settle into one observation instead
of N lines. Quiet detection is separate: `FLEET_STALL_POLL_MS` (5s) re-runs
observation so a lane that went quiet with no further store event is still
announced once. Past `COALESCE_ABOVE` (3) changes in one observation the
individual lines collapse into a single tally (`"9 done, 3 failed"`); below
that threshold each change gets its own line. The one case both the fleet
going idle and a coalesced tally would otherwise say the same thing —
all changes are terminal and the tally alone already says "N done, N
failed" — the idle line replaces the tally instead of repeating it with
"— nothing running" tacked on.

Outcomes and errors are clipped to `OUTCOME_CHARS`/`MAX_UPDATE_CHARS` on the
same "one update is one row, never wrapped" rule the panel's rows follow.
`fleetDigest()` is the on-demand counterpart: the same picture in one line,
answering "where is the fleet" without an interrupt, for `/status` or an
operator question mid-run.

## How pop-ups should feel

A blocking surface (permissions, an operator question, the model/provider
Expand Down Expand Up @@ -446,7 +479,13 @@ Ctrl+C interrupts a busy run (or clears a non-empty idle prompt); a second
Ctrl+C within a 2-second window (`CTRL_C_EXIT_WINDOW_MS`) quits — this
replaced an Ink-era yes/no exit-confirm modal with the same intent (an
explicit second confirmation) without adding a modal (`handleCtrlC`,
`shell.ts`).
`shell.ts`). The interrupt keeps whatever is sitting in the queue rather than
discarding it — the operator typed those messages meaning them delivered, not
meaning "cancel this run and also throw away what I typed"; the transcript
row says so (`"interrupt — N pending kept"`). Kept items are handed over at
the interrupt itself (`doInterrupt` in `runtime-bridge.ts` drains after
`port.interrupt()`), serialized behind the agent rebuild the stop starts —
a stop does not reliably produce an idle event to drain against later.

## Overflows, scrolling, and key macros

Expand Down
158 changes: 158 additions & 0 deletions src/subagent/fleet-report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { describe, expect, test } from "bun:test";
import {
createFleetWatch,
fleetDigest,
observeFleet,
type FleetLane,
} from "./fleet-report.js";

const T0 = 1_000_000;

function lane(overrides: Partial<FleetLane> & { id: string }): FleetLane {
return {
description: overrides.id,
status: "running",
startedAt: T0,
lastActivityAt: T0,
currentToolName: null,
currentToolStartedAt: null,
...overrides,
};
}

describe("observeFleet", () => {
test("the first observation seeds without announcing an in-flight fleet", () => {
const { watch, updates } = observeFleet(
createFleetWatch(),
[lane({ id: "api" }), lane({ id: "docs" })],
T0,
);
expect(updates).toEqual([]);
expect(watch.running).toBe(2);
});

test("a finished lane is reported with what it produced", () => {
const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch;
const { updates } = observeFleet(
seeded,
[
lane({
id: "api",
status: "done",
report: "## Summary\nRewired the reporter and added six tests.",
}),
],
T0 + 1000,
);
expect(updates[0]).toBe(
"fleet · api done — Rewired the reporter and added six tests.",
);
});

test("the last lane finishing says so, which is the silence the operator hit", () => {
const seeded = observeFleet(
createFleetWatch(),
[lane({ id: "api" }), lane({ id: "docs", status: "done" })],
T0,
).watch;
const { updates } = observeFleet(
seeded,
[lane({ id: "api", status: "done", report: "done" }), lane({ id: "docs", status: "done" })],
T0 + 1000,
);
expect(updates).toEqual([
"fleet · api done — done",
"fleet · 2 done — nothing running",
]);
});

test("a failure names what went wrong", () => {
const seeded = observeFleet(createFleetWatch(), [lane({ id: "build" })], T0).watch;
const { updates } = observeFleet(
seeded,
[lane({ id: "build", status: "failed", error: "typecheck exited 1" })],
T0 + 1000,
);
expect(updates[0]).toContain("build failed — typecheck exited 1");
});

test("a dispatch carries the load it was decided against", () => {
const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch;
const { updates } = observeFleet(
seeded,
[lane({ id: "api" }), lane({ id: "docs" })],
T0 + 1000,
);
expect(updates).toEqual(["fleet · dispatched docs (2 running)"]);
});

test("a quiet lane is announced once, not on every tick it stays quiet", () => {
const quiet = lane({ id: "api", lastActivityAt: T0 });
const seeded = observeFleet(createFleetWatch(), [quiet], T0).watch;
const first = observeFleet(seeded, [quiet], T0 + 60_000);
expect(first.updates[0]).toContain("api stalled");
const second = observeFleet(first.watch, [quiet], T0 + 90_000);
expect(second.updates).toEqual([]);
});

test("routine activity that changes nothing produces no update", () => {
const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch;
const busy = observeFleet(
seeded,
[lane({ id: "api", lastActivityAt: T0 + 4000, currentToolName: "grep" })],
T0 + 5000,
);
expect(busy.updates).toEqual([]);
});

test("an update is one row — a long outcome is clipped, never wrapped", () => {
const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch;
const { updates } = observeFleet(
seeded,
[
lane({
id: "api",
status: "done",
report: "Rewired the reporter, added the digest, wired the poll, and updated every affected test in the suite.",
}),
],
T0 + 1000,
);
expect(updates[0]!.length).toBeLessThanOrEqual(76);
expect(updates[0]).toContain("…");
});

test("a dozen lanes landing at once collapse into one tally", () => {
const before = Array.from({ length: 12 }, (_, i) => lane({ id: `l${i}` }));
const seeded = observeFleet(createFleetWatch(), before, T0).watch;
const after = before.map((l, i) =>
i < 9
? { ...l, status: "done" as const, report: "ok" }
: { ...l, status: "failed" as const, error: "boom" },
);
const { updates } = observeFleet(seeded, after, T0 + 1000);
expect(updates).toEqual(["fleet · 9 done, 3 failed — nothing running"]);
});
});

describe("fleetDigest", () => {
test("one row carries running lanes, their clocks, and the finished tally", () => {
const digest = fleetDigest(
[
lane({ id: "api", startedAt: T0 - 80_000, lastActivityAt: T0 - 1000 }),
lane({ id: "docs", startedAt: T0 - 20_000, lastActivityAt: T0 - 120_000 }),
lane({ id: "web", status: "done" }),
lane({ id: "cli", status: "failed" }),
],
T0,
);
expect(digest).toBe("fleet · 2 running (api 1:20, docs 0:20 stalled) · 1 done · 1 failed");
});

test("a fleet with nothing left running says so rather than going blank", () => {
expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe(
"fleet · nothing running · 1 done",
);
expect(fleetDigest([], T0)).toBe("fleet · no lanes dispatched");
});
});
Loading
Loading