Skip to content

Commit 7f35cb8

Browse files
committed
Render a semantic activity state in the status ticker, not raw tool names
The ticker rendered whatever tool identifier was currently executing — internal plumbing vocabulary leaking into a product surface, and redundant with the transcript, which already shows the tool call. Replace it with a closed set of human activity states (thinking, planning, researching, building, working, waiting, stalled, stopping). The execution-to-state mapping lives in one place with an explicit fallback to 'working', so an unmapped tool, MCP server, or plugin name can never reach the ticker and adding a tool needs no ticker change. Stalled reuses the existing stall-watchdog signal rather than a second notion of stuck, and waiting on operator approval is now its own state distinct from active work.
1 parent dcff30d commit 7f35cb8

4 files changed

Lines changed: 75 additions & 40 deletions

File tree

src/tui-opentui/runtime-bridge.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -805,9 +805,8 @@ export function attachSessionBridge(
805805
awaitingResponse: turn.awaitingResponse,
806806
currentToolName: turn.currentToolName,
807807
streamingType: turn.streamingType,
808-
streamTokenCount: turn.streamTokenCount,
809808
}
810-
const label = resolveTurnLabel(input)
809+
const label = resolveTurnLabel(input, isStalled)
811810
if (label === undefined) {
812811
// The bottom-left status slot rides the same re-entry as the landing
813812
// mark, so it crossfades between phases without a timer of its own.

src/tui-opentui/session-chrome.test.ts

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe("resolveTurnLabel closed-set guarantee", () => {
4747
streamingType: "tool",
4848
})
4949
expect(label).not.toBe(currentToolName)
50-
expect(ACTIVITY_STATES).toContain(label)
50+
expect(ACTIVITY_STATES).toContain(label!)
5151
})
5252
}
5353

@@ -63,7 +63,7 @@ describe("resolveTurnLabel closed-set guarantee", () => {
6363
true,
6464
)
6565
expect(label).toBe("stalled")
66-
expect(ACTIVITY_STATES).toContain(label)
66+
expect(ACTIVITY_STATES).toContain(label!)
6767
})
6868

6969
test("waiting on the operator is distinguishable from working", () => {
@@ -76,7 +76,7 @@ describe("resolveTurnLabel closed-set guarantee", () => {
7676
})
7777
expect(label).toBe("waiting")
7878
expect(label).not.toBe("working")
79-
expect(ACTIVITY_STATES).toContain(label)
79+
expect(ACTIVITY_STATES).toContain(label!)
8080
})
8181
})
8282

@@ -93,7 +93,7 @@ describe("resolveTurnLabel", () => {
9393
).toBeUndefined()
9494
})
9595

96-
test("blocked gate shows approval wait", () => {
96+
test("blocked gate shows a waiting-on-operator state", () => {
9797
expect(
9898
resolveTurnLabel({
9999
isProcessing: true,
@@ -102,7 +102,7 @@ describe("resolveTurnLabel", () => {
102102
currentToolName: "run_shell",
103103
streamingType: "tool",
104104
}),
105-
).toBe("blocked")
105+
).toBe("waiting")
106106
})
107107

108108
test("stopping beats tool phase", () => {
@@ -117,7 +117,7 @@ describe("resolveTurnLabel", () => {
117117
).toBe("stopping")
118118
})
119119

120-
test("tool phase beats generic working", () => {
120+
test("tool phase maps to its semantic activity, never the raw name", () => {
121121
expect(
122122
resolveTurnLabel({
123123
isProcessing: true,
@@ -126,7 +126,7 @@ describe("resolveTurnLabel", () => {
126126
currentToolName: "grep",
127127
streamingType: "tool",
128128
}),
129-
).toBe("grep")
129+
).toBe("researching")
130130
})
131131

132132
test("thinking and text phases", () => {
@@ -140,8 +140,8 @@ describe("resolveTurnLabel", () => {
140140
resolveTurnLabel({ ...base, streamingType: "thinking" }),
141141
).toBe("thinking")
142142
expect(
143-
resolveTurnLabel({ ...base, streamingType: "text", streamTokenCount: 7 }),
144-
).toBe("streaming 7 tok")
143+
resolveTurnLabel({ ...base, streamingType: "text" }),
144+
).toBe("working")
145145
expect(
146146
resolveTurnLabel({
147147
...base,
@@ -150,18 +150,6 @@ describe("resolveTurnLabel", () => {
150150
}),
151151
).toBe("working")
152152
})
153-
154-
test("text phase with no count yet reads zero", () => {
155-
expect(
156-
resolveTurnLabel({
157-
isProcessing: true,
158-
status: "running",
159-
awaitingResponse: false,
160-
currentToolName: null,
161-
streamingType: "text",
162-
}),
163-
).toBe("streaming 0 tok")
164-
})
165153
})
166154

167155
describe("resolveRampPhase", () => {

src/tui-opentui/session-chrome.ts

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,31 +23,77 @@ export type TurnLabelInput = {
2323
readonly awaitingResponse: boolean
2424
readonly currentToolName: string | null
2525
readonly streamingType: "text" | "thinking" | "tool" | null
26-
/** Text deltas seen so far this turn; read only while `streamingType` is `text`. */
27-
readonly streamTokenCount?: number
26+
}
27+
28+
/**
29+
* Closed set the status ticker is allowed to render. Every path through
30+
* `resolveTurnLabel` returns one of these — never a tool identifier, MCP
31+
* server name, or plugin name. This is what the leak-prevention test checks
32+
* membership against, so it must stay the single source of truth for "what
33+
* can appear in the ticker."
34+
*/
35+
export const ACTIVITY_STATES = [
36+
"thinking",
37+
"planning",
38+
"researching",
39+
"building",
40+
"working",
41+
"waiting",
42+
"stalled",
43+
"stopping",
44+
] as const
45+
46+
export type ActivityState = (typeof ACTIVITY_STATES)[number]
47+
48+
/**
49+
* Execution → activity-state mapping, kept in this one place with an
50+
* explicit fallback so a newly added tool (built-in, MCP, or plugin) renders
51+
* a generic "working" state instead of leaking its identifier — no ticker
52+
* change is required to add a tool correctly.
53+
*/
54+
const TOOL_ACTIVITY_STATES: Readonly<Record<string, ActivityState>> = {
55+
read_file: "researching",
56+
search_files: "researching",
57+
grep: "researching",
58+
list_dir: "researching",
59+
web_search: "researching",
60+
web_fetch: "researching",
61+
write_file: "building",
62+
edit_file: "building",
63+
run_shell: "building",
64+
manage_tasks: "planning",
65+
task: "planning",
66+
ask_operator: "waiting",
67+
submit_output: "working",
68+
}
69+
70+
function activityStateForTool(name: string | null): ActivityState {
71+
if (name === null) return "working"
72+
return TOOL_ACTIVITY_STATES[name] ?? "working"
2873
}
2974

3075
/**
3176
* Single session-phase label accompanying the density ramp. Lowercase and
3277
* unpunctuated — the ramp's color and motion carry the state, so the word only
3378
* has to name it. Returns undefined when idle so the phase segment disappears.
3479
*
35-
* Text streaming carries a live count (`streaming 7 tok`) rather than the
36-
* bare word: it is the one phase with something to count, and the count is
37-
* what tells the operator the slot is not stalled.
80+
* `isStalled` is the caller's own `shouldNoticeStall`/`isStalledForDisplay`
81+
* result (see stall-watchdog.ts) — this function does not re-derive
82+
* staleness, it only ranks "stalled" against the other phases so the ticker
83+
* and the ramp never disagree about which runs look stuck.
3884
*/
39-
export function resolveTurnLabel(input: TurnLabelInput): string | undefined {
85+
export function resolveTurnLabel(
86+
input: TurnLabelInput,
87+
isStalled: boolean = false,
88+
): ActivityState | undefined {
4089
if (!input.isProcessing) return undefined
41-
if (input.status === "blocked") return "blocked"
90+
if (input.status === "blocked") return "waiting"
4291
if (input.status === "stopping" || input.status === "stopped") {
4392
return "stopping"
4493
}
45-
if (input.currentToolName !== null) return input.currentToolName
46-
if (input.streamingType === "tool") return "tool"
94+
if (isStalled) return "stalled"
95+
if (input.currentToolName !== null) return activityStateForTool(input.currentToolName)
4796
if (input.streamingType === "thinking") return "thinking"
48-
if (input.streamingType === "text") {
49-
return `streaming ${String(input.streamTokenCount ?? 0)} tok`
50-
}
5197
return "working"
5298
}
5399

src/tui-opentui/turn-monitor.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,18 @@ describe("turn progress label", () => {
7373
expect(t.shell.lockupPhase).toBe("thinking")
7474

7575
t.bridge.handle({ type: "inference.text.delta", data: { token: "hi" } })
76-
expect(t.shell.lockupPhase).toBe("streaming 1 tok")
76+
expect(t.shell.lockupPhase).toBe("working")
7777

7878
t.bridge.handle({ type: "inference.text.delta", data: { token: " there" } })
79-
expect(t.shell.lockupPhase).toBe("streaming 2 tok")
79+
expect(t.shell.lockupPhase).toBe("working")
8080

8181
t.bridge.handle({
8282
type: "inference.tool_call.end",
8383
data: { name: "bash", callId: "c1" },
8484
})
85-
expect(t.shell.lockupPhase).toBe("bash")
85+
// Unmapped tool identifiers fall back to the generic working state
86+
// rather than leaking the raw name.
87+
expect(t.shell.lockupPhase).toBe("working")
8688

8789
t.bridge.handle({ type: "reactor.done", data: {} })
8890
expect(t.shell.lockupPhase).toBeNull()
@@ -223,15 +225,15 @@ describe("turn progress label", () => {
223225
})
224226
})
225227

226-
test("an open permission overlay freezes the ramp and reads blocked", async () => {
228+
test("an open permission overlay freezes the ramp and reads waiting", async () => {
227229
await withTestRenderer(async (h) => {
228230
const t: Harness = await setup(h)
229231
try {
230232
t.bridge.handle({ type: "inference.start", data: {} })
231233
t.shell.overlayKind = "permissions"
232234
t.bridge.gateOpened()
233235
t.tick()
234-
expect(t.shell.lockupPhase).toBe("blocked")
236+
expect(t.shell.lockupPhase).toBe("waiting")
235237

236238
// Frozen is the signal: the ramp must not move while a human is asked.
237239
const frozen = t.shell.lockupPhase

0 commit comments

Comments
 (0)