Clear terminal attention when the user views the terminal

A finished or input-waiting terminal shows an attention indicator. The
focus heartbeat already asked the daemon to clear it, but the clear was a
no-op: it guarded on activity state === "attention", while the real
tracker encodes attention as state "idle" plus an attentionReason
("finished" / "needs_input"). The state is never literally "attention",
so the indicator only ever cleared when the agent started a new turn.

The worker proxy also had no way to reach the tracker's clearAttention():
it only spoke setActivity, and setActivity("idle") is short-circuited by
the tracker while a "finished" reason is set. Add a clearAttention worker
message that calls the existing manager.clearTerminalAttention(), and
guard both clear paths on attentionReason instead of state.
This commit is contained in:
Mohamed Boudra
2026-06-15 14:31:37 +07:00
parent 713a042dc5
commit e84a3ff574
4 changed files with 55 additions and 12 deletions
@@ -258,6 +258,12 @@ async function handleRequest(message: TerminalWorkerRequest): Promise<void> {
return;
}
case "clearAttention": {
await manager.clearTerminalAttention(message.terminalId);
sendToParent({ type: "response", requestId: message.requestId, ok: true });
return;
}
case "killTerminal": {
const session = manager.getTerminal(message.terminalId);
const cwd = session?.cwd;
@@ -54,6 +54,11 @@ export type TerminalWorkerRequest =
terminalId: string;
state: TerminalActivityState;
}
| {
type: "clearAttention";
requestId: string;
terminalId: string;
}
| {
type: "killTerminal";
requestId: string;
@@ -665,7 +665,7 @@ it("sets terminal activity through a worker request", async () => {
await expect(result).resolves.toBe(true);
});
it("clears terminal attention through a worker activity update", async () => {
it("clears terminal attention through a worker request", async () => {
const worker = new FakeTerminalWorker();
manager = createWorkerTerminalManager({
requestTimeoutMs: 50,
@@ -677,19 +677,16 @@ it("clears terminal attention through a worker activity update", async () => {
id: "terminal-a",
name: "Shell",
cwd: "/workspace",
activity: { state: "attention", changedAt: 1000 },
activity: { state: "idle", attentionReason: "finished", changedAt: 1000 },
},
state: createTerminalState(),
});
const result = manager.clearTerminalAttention("terminal-a");
const request = worker.sentMessages.find(
(message) => message.type === "setActivity" && message.state === "idle",
);
const request = worker.sentMessages.find((message) => message.type === "clearAttention");
expect(request).toMatchObject({
type: "setActivity",
type: "clearAttention",
terminalId: "terminal-a",
state: "idle",
});
if (!request) {
throw new Error("attention clear request not sent");
@@ -699,6 +696,41 @@ it("clears terminal attention through a worker activity update", async () => {
await expect(result).resolves.toBe(true);
});
it("clears finished attention on a real terminal", async () => {
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-attention-"));
temporaryDirs.push(cwd);
manager = createWorkerTerminalManager();
const session = trackTerminal(
await manager.createTerminal({
cwd,
...nodeTerminalCommand("setInterval(() => {}, 1000);"),
}),
);
// A working -> idle transition is how the real tracker records a "finished"
// attention: { state: "idle", attentionReason: "finished" }. The state is
// never literally "attention", so a clear that checks state === "attention"
// would never fire — the bug this reproduces.
await manager.setTerminalActivity(session.id, "working");
await manager.setTerminalActivity(session.id, "idle");
await waitForCondition(
() => manager?.getTerminal(session.id)?.getActivity()?.attentionReason === "finished",
5000,
);
const cleared = await manager.clearTerminalAttention(session.id);
expect(cleared).toBe(true);
await waitForCondition(
() => manager?.getTerminal(session.id)?.getActivity()?.attentionReason == null,
5000,
);
expect(manager.getTerminal(session.id)?.getActivity()).toEqual({
state: "idle",
changedAt: expect.any(Number),
});
});
it("removes worker terminals after killAndWait", async () => {
const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-kill-"));
temporaryDirs.push(cwd);
@@ -294,11 +294,11 @@ export function createWorkerTerminalManager(
sendBestEffortRequest({ type: "setActivity", terminalId: record.info.id, state });
},
clearActivityAttention(): boolean {
if (record.activity?.state !== "attention") {
if (record.activity?.attentionReason == null) {
return false;
}
record.activity = { state: "idle", changedAt: Date.now() };
sendBestEffortRequest({ type: "setActivity", terminalId: record.info.id, state: "idle" });
record.activity = { state: record.activity.state, changedAt: Date.now() };
sendBestEffortRequest({ type: "clearAttention", terminalId: record.info.id });
return true;
},
getSize(): { rows: number; cols: number } {
@@ -753,10 +753,10 @@ export function createWorkerTerminalManager(
async clearTerminalAttention(id: string): Promise<boolean> {
const record = recordsById.get(id);
if (!record || record.activity?.state !== "attention") {
if (!record || record.activity?.attentionReason == null) {
return false;
}
await sendRequest({ type: "setActivity", terminalId: id, state: "idle" });
await sendRequest({ type: "clearAttention", terminalId: id });
return true;
},