Skip to content

Commit be3d9de

Browse files
committed
Keep the lockup cycling while the session is occupied
1 parent dde35ae commit be3d9de

7 files changed

Lines changed: 81 additions & 63 deletions

File tree

docs/TUI.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,13 @@ that appears mid-prose.
9797
While a turn is live — or the session is still occupied by a live fleet
9898
or a pending dry-fleet continuation — the lockup slot swaps the wordmark
9999
for a semantic activity word — never the raw tool, MCP server, or plugin
100-
identifier that is actually executing. `resolveTurnLabel`
101-
(`src/tui/session-chrome.ts`) maps execution onto the closed set
102-
`ACTIVITY_STATES` exported from that module. Live occupation cycles
100+
identifier that is actually executing. Live occupation cycles
103101
`LIVE_ACTIVITY_WORDS` (`working`, `warping`, `buzzing`, `grinding`,
104102
`thinking`, `doing`, `cooking`, `creating`, `imagining`, `inventing`)
105103
on `LIVE_WORD_MS`; gated turns still read `waiting` or `stopping`.
106-
That export is the source of truth for what the slot can say, not this
107-
list. It is led by a single density cell
104+
`ACTIVITY_STATES` exported from the session chrome module is the source
105+
of truth for what the slot can say, not this list. It is led by a single
106+
density cell
108107
(`rampPulse`, `src/tui/ramp.ts`). The cell, not the word,
109108
is what says whether the session is healthy, and it carries four states:
110109

src/subagent/fleet-report.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,9 @@ describe("fleetDigest", () => {
235235
});
236236

237237
test("a dry fleet is the outcome tally, not an idle claim", () => {
238-
expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe("1 done");
238+
expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe(
239+
"1 done",
240+
);
239241
expect(fleetDigest([], T0)).toBe("");
240242
});
241243

src/tui/runtime-bridge.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,9 +1379,11 @@ describe("idle-with-fleet (CL-7057)", () => {
13791379
// busy and the follow-up does not drain at mere parent-idle.
13801380
expect(shell.session.run).toBe("busy");
13811381
expect(shell.lockupPhase).not.toBeNull();
1382-
expect((LIVE_ACTIVITY_WORDS as readonly string[]).includes(shell.lockupPhase ?? "")).toBe(
1383-
true,
1384-
);
1382+
expect(
1383+
(LIVE_ACTIVITY_WORDS as readonly string[]).includes(
1384+
shell.lockupPhase ?? "",
1385+
),
1386+
).toBe(true);
13851387
expect(badgeCount(shell.session)).toBe(1);
13861388
expect(port.calls).toEqual([]);
13871389
await h.renderOnce();
@@ -1621,9 +1623,11 @@ describe("fleet-dry open-task drive (CL-7540)", () => {
16211623
bridge.handle({ type: "fleet", running: 0 });
16221624
expect(drives).toBe(0);
16231625
expect(shell.session.run).toBe("busy");
1626+
expect(shell.lockupPhase).not.toBeNull();
16241627
settleToollessTurn(bridge);
16251628
expect(drives).toBe(1);
16261629
expect(shell.session.run).toBe("busy");
1630+
expect(shell.lockupPhase).not.toBeNull();
16271631
bridge.submit("when it finishes, summarize", "queue");
16281632
expect(badgeCount(shell.session)).toBe(1);
16291633
port.clear();

src/tui/runtime-bridge.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,11 @@ function drainLiveSteersAtBoundary(shell: AppShell, bag: BridgeBag): void {
10111011
}
10121012
}
10131013

1014+
function occupancyHold(bag: BridgeBag, runBusy: boolean): boolean {
1015+
if (bag.liveFleet > 0 || bag.awaitingContinuationInference) return true;
1016+
return runBusy && bag.pendingDryOpenDrive;
1017+
}
1018+
10141019
/**
10151020
* Release the run to idle and drain everything queued — but only at true
10161021
* session-idle. A live fleet holds the run busy after the parent turn settles
@@ -1287,7 +1292,7 @@ export function attachSessionBridge(
12871292
currentToolName: turn.currentToolName,
12881293
streamingType: turn.streamingType,
12891294
nowMs,
1290-
sessionActive: bag.liveFleet > 0,
1295+
sessionActive: occupancyHold(bag, shell.session.run === "busy"),
12911296
};
12921297
const fleet = fleetProgress(bag.agentSessions, nowMs);
12931298
const label = resolveTurnLabel(input, isStalled, fleet);
@@ -1386,13 +1391,19 @@ export function attachSessionBridge(
13861391
if (onTurnBoundary(event) && bag.turn.activeToolCalls.length > 0) {
13871392
drainLiveSteersAtBoundary(shell, bag);
13881393
}
1389-
if (settled) settleRun();
1394+
if (settled) {
1395+
settleRun();
1396+
paintPhase();
1397+
}
13901398
return;
13911399
}
13921400
if (isBridgeInbound(event)) {
13931401
applyInbound(shell, bag, event);
13941402
}
1395-
if (settled) settleRun();
1403+
if (settled) {
1404+
settleRun();
1405+
paintPhase();
1406+
}
13961407
};
13971408

13981409
const recordLastSent = (

src/tui/session-chrome.test.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ describe("resolveTurnLabel closed-set guarantee", () => {
6868
null,
6969
);
7070
// Recovery is silent — never paint "stalled" in the ticker.
71-
expect(label).toBe("creating");
71+
expect(label).toBe("working");
7272
expect(ACTIVITY_STATES).toContain(defined(label));
7373
});
7474

@@ -150,30 +150,50 @@ describe("resolveTurnLabel", () => {
150150
).toBeUndefined();
151151
});
152152

153-
test("tool phase maps to its semantic activity, never the raw name", () => {
153+
test("occupied lockup cycles live-activity words, never the raw tool name", () => {
154+
expect(
155+
resolveTurnLabel(
156+
{
157+
isProcessing: true,
158+
status: "running",
159+
currentToolName: "grep",
160+
streamingType: "tool",
161+
},
162+
false,
163+
null,
164+
),
165+
).toBe("working");
154166
expect(
155167
resolveTurnLabel(
156168
{
157169
isProcessing: true,
158170
status: "running",
159171
currentToolName: "grep",
160172
streamingType: "tool",
173+
nowMs: LIVE_WORD_MS,
161174
},
162175
false,
163176
null,
164177
),
165-
).toBe("grinding");
178+
).toBe("warping");
166179
});
167180

168-
test("thinking and text phases", () => {
181+
test("thinking and text phases cycle the same live-activity words", () => {
169182
const base = {
170183
isProcessing: true,
171184
status: "running" as const,
172185
currentToolName: null,
173186
};
174187
expect(
175188
resolveTurnLabel({ ...base, streamingType: "thinking" }, false, null),
176-
).toBe("thinking");
189+
).toBe("working");
190+
expect(
191+
resolveTurnLabel(
192+
{ ...base, streamingType: "thinking", nowMs: LIVE_WORD_MS },
193+
false,
194+
null,
195+
),
196+
).toBe("warping");
177197
expect(
178198
resolveTurnLabel({ ...base, streamingType: "text" }, false, null),
179199
).toBe("working");
@@ -367,7 +387,7 @@ describe("fleet state in the top-level indicator", () => {
367387
resolveTurnLabel(parentAwaitingChildren, false, null),
368388
);
369389
expect(resolveTurnLabel(parentAwaitingChildren, true, none)).toBe(
370-
"imagining",
390+
"working",
371391
);
372392
expect(resolveRampPhase(parentAwaitingChildren, true, none)).toBe(
373393
"working",
@@ -401,6 +421,20 @@ describe("fleet state in the top-level indicator", () => {
401421
};
402422
expect(resolveTurnLabel(idleParent, false, fleet(2, 0))).toBe("working");
403423
expect(resolveRampPhase(idleParent, false, fleet(2, 0))).toBe("working");
424+
expect(
425+
resolveTurnLabel(
426+
{ ...idleParent, nowMs: LIVE_WORD_MS },
427+
false,
428+
fleet(2, 0),
429+
),
430+
).toBe("warping");
431+
expect(
432+
resolveTurnLabel(
433+
{ ...idleParent, sessionActive: true, nowMs: LIVE_WORD_MS },
434+
false,
435+
fleet(0, 0),
436+
),
437+
).toBe("warping");
404438
});
405439

406440
test("live-activity words cycle while the session is occupied", () => {

src/tui/session-chrome.ts

Lines changed: 8 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -76,42 +76,15 @@ export const LIVE_ACTIVITY_WORDS = [
7676
/** How long each live-activity word holds before the next. */
7777
export const LIVE_WORD_MS = 4_000;
7878

79-
/**
80-
* Execution → activity-state mapping, kept in this one place with an
81-
* explicit fallback so a newly added tool (built-in, MCP, or plugin) renders
82-
* a generic "working" state instead of leaking its identifier — no ticker
83-
* change is required to add a tool correctly.
84-
*/
85-
const TOOL_ACTIVITY_STATES: Readonly<Record<string, ActivityState>> = {
86-
read_file: "grinding",
87-
search_files: "grinding",
88-
grep: "grinding",
89-
list_dir: "grinding",
90-
web_search: "grinding",
91-
web_fetch: "grinding",
92-
write_file: "creating",
93-
edit_file: "creating",
94-
run_shell: "creating",
95-
delete_file: "creating",
96-
manage_tasks: "imagining",
97-
task: "imagining",
98-
tool_search: "grinding",
99-
search_agents: "grinding",
100-
ask_operator: "waiting",
101-
submit_output: "doing",
102-
};
103-
104-
function activityStateForTool(name: string | null): ActivityState {
105-
if (name === null) return "working";
106-
return TOOL_ACTIVITY_STATES[name] ?? "working";
107-
}
108-
10979
function liveActivityWord(nowMs: number): ActivityState {
11080
const index = Math.floor(nowMs / LIVE_WORD_MS) % LIVE_ACTIVITY_WORDS.length;
11181
return LIVE_ACTIVITY_WORDS[index] ?? "working";
11282
}
11383

114-
function sessionIsLive(input: TurnLabelInput, fleet: FleetProgress | null): boolean {
84+
function sessionIsLive(
85+
input: TurnLabelInput,
86+
fleet: FleetProgress | null,
87+
): boolean {
11588
if (input.isProcessing) return true;
11689
if (input.sessionActive === true) return true;
11790
return fleet !== null && fleet.running > 0;
@@ -141,20 +114,14 @@ export function resolveTurnLabel(
141114
// Stopping is this parent turn aborting. A settled parent with live
142115
// lanes is still occupied — don't let a leftover stopping status blank
143116
// the lockup or freeze it on "stopping".
144-
if (input.isProcessing && (input.status === "stopping" || input.status === "stopped")) {
117+
if (
118+
input.isProcessing &&
119+
(input.status === "stopping" || input.status === "stopped")
120+
) {
145121
return "stopping";
146122
}
147123
if (!occupied) return undefined;
148-
// Live fleet / parent continuation means the session is working — recovery
149-
// is silent. Never paint "stalled" for the operator.
150-
const fleetLive = fleet !== null && fleet.running > 0;
151-
if (fleetLive || input.sessionActive === true) {
152-
return liveActivityWord(input.nowMs ?? 0);
153-
}
154124
void isStalled;
155-
if (input.currentToolName !== null)
156-
return activityStateForTool(input.currentToolName);
157-
if (input.streamingType === "thinking") return "thinking";
158125
return liveActivityWord(input.nowMs ?? 0);
159126
}
160127

src/tui/turn-monitor.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { noticeText } from "./shell/chrome.js";
1010
import { createAppShell } from "./shell/index.js";
1111
import { withTestRenderer } from "./harness.js";
1212
import { RUNTIME_FLASH_MS } from "./runtime-notices.js";
13+
import { LIVE_WORD_MS } from "./session-chrome.js";
1314
import {
1415
STALL_NOTICE_MESSAGE,
1516
STALL_RECOVERY_MESSAGE,
@@ -71,7 +72,7 @@ describe("turn progress label", () => {
7172
type: "inference.thinking.delta",
7273
data: { token: "hm" },
7374
});
74-
expect(t.shell.lockupPhase).toBe("thinking");
75+
expect(t.shell.lockupPhase).toBe("working");
7576

7677
t.bridge.handle({
7778
type: "inference.text.delta",
@@ -111,13 +112,13 @@ describe("turn progress label", () => {
111112
expect(t.shell.lockupPhase).toBe("working");
112113
const started = t.shell.lockupChangedMs;
113114

114-
t.advance(500);
115+
t.advance(LIVE_WORD_MS);
115116
t.bridge.handle({
116117
type: "inference.thinking.delta",
117118
data: { token: "hm" },
118119
});
119-
expect(t.shell.lockupPhase).toBe("thinking");
120-
// A new phase restamps the fade so the crossfade starts over.
120+
expect(t.shell.lockupPhase).toBe("warping");
121+
// A new word restamps the fade so the crossfade starts over.
121122
expect(t.shell.lockupChangedMs).toBeGreaterThan(started);
122123

123124
t.bridge.handle({ type: "reactor.done", data: {} });

0 commit comments

Comments
 (0)