Keep finished terminals visible as attention

This commit is contained in:
Mohamed Boudra
2026-06-15 14:06:48 +07:00
parent 71b5c35d9b
commit a4c4418164
12 changed files with 147 additions and 55 deletions
+3 -3
View File
@@ -27,7 +27,7 @@ TerminalSession
Each `onChange` delivers both the new snapshot and the `previous` one (`{ state, changedAt }`). The transition flows unchanged up through `TerminalSession.onActivityChange` (as `{ activity, previous }`), the worker protocol's `terminalActivityChange` event, and the manager-level `subscribeTerminalActivity(listener)` stream (`{ terminalId, name, cwd, activity, previous }`). Each `onChange` delivers both the new snapshot and the `previous` one (`{ state, changedAt }`). The transition flows unchanged up through `TerminalSession.onActivityChange` (as `{ activity, previous }`), the worker protocol's `terminalActivityChange` event, and the manager-level `subscribeTerminalActivity(listener)` stream (`{ terminalId, name, cwd, activity, previous }`).
The daemon's notification policy consumes these transitions, not snapshots. When a transition moves from `working` to `idle`, the websocket layer fires a "Terminal finished" attention notification. It keeps no per-terminal timing state of its own — the previous snapshot carried in the transition is the entire history it needs. A terminal that exits while still working emits no turn-end notification. The daemon consumes these transitions, not snapshots. When a transition moves from `working` to `idle`, the tracker records finished attention, so the terminal shows the same green finished dot as an idle agent that needs review. The websocket layer also fires a "Terminal finished" attention notification. A terminal that exits while still working emits no turn-end notification.
Terminal workspace membership is path-prefix based: a terminal opened in a workspace subdirectory rolls up to the deepest active parent workspace for status buckets and notification routing. Terminal workspace membership is path-prefix based: a terminal opened in a workspace subdirectory rolls up to the deepest active parent workspace for status buckets and notification routing.
@@ -62,11 +62,11 @@ OpenCode uses a server plugin instead of command hooks. The plugin listens to Op
- `permission.asked` → `needs-input` - `permission.asked` → `needs-input`
- `permission.replied` → `running` - `permission.replied` → `running`
The daemon maps hook states onto internal terminal activity states: `running` → `working`, `idle` → `idle`, and `needs-input` → `attention`. The daemon maps hook states onto terminal activity like an agent lifecycle plus unread attention: `running` → `state: working`, `idle` → `state: idle`, and `needs-input` → `state: idle` with `attentionReason: needs_input`. A `working` → `idle` transition records `state: idle` with `attentionReason: finished` until the user focuses that terminal; plain idle terminals still contribute no workspace status.
## Focus clearing ## Focus clearing
Client heartbeats include the focused terminal id. When a visible client focuses a terminal that is in `attention`, the daemon clears that terminal back to `idle`. `idle` terminal activity does not contribute to workspace status, so a workspace whose only attention source was that terminal rolls up from `needs_input` back to `done`. Client heartbeats include the focused terminal id. When a visible client focuses a terminal with an `attentionReason`, the daemon clears the attention and leaves the terminal idle. Plain idle terminal activity does not contribute to workspace status, so a workspace whose only attention source was that terminal rolls up from `needs_input` or `attention` back to `done`.
### Agent hook installation ### Agent hook installation
+2 -2
View File
@@ -5,6 +5,7 @@ import { Terminal } from "lucide-react-native";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import type { ListTerminalsResponse } from "@getpaseo/protocol/messages"; import type { ListTerminalsResponse } from "@getpaseo/protocol/messages";
import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity";
import { TerminalPane } from "@/components/terminal-pane"; import { TerminalPane } from "@/components/terminal-pane";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
@@ -13,7 +14,6 @@ import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
import { terminalActivityToStatusBucket } from "@/utils/terminal-activity-bucket";
type ListTerminalsPayload = ListTerminalsResponse["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"];
@@ -70,7 +70,7 @@ function useTerminalPanelDescriptor(
subtitle: t("workspace.tabs.fallback.terminal"), subtitle: t("workspace.tabs.fallback.terminal"),
titleState: "ready", titleState: "ready",
icon: Terminal, icon: Terminal,
statusBucket: terminalActivityToStatusBucket(terminal?.activity?.state), statusBucket: deriveTerminalActivityStatusBucket(terminal?.activity),
}; };
} }
@@ -1,20 +0,0 @@
import { describe, expect, it } from "vitest";
import { terminalActivityToStatusBucket } from "./terminal-activity-bucket";
describe("terminalActivityToStatusBucket", () => {
it("maps working to running", () => {
expect(terminalActivityToStatusBucket("working")).toBe("running");
});
it("maps idle to null", () => {
expect(terminalActivityToStatusBucket("idle")).toBeNull();
});
it("maps null to null", () => {
expect(terminalActivityToStatusBucket(null)).toBeNull();
});
it("maps undefined to null", () => {
expect(terminalActivityToStatusBucket(undefined)).toBeNull();
});
});
@@ -1,10 +0,0 @@
import type { TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
export function terminalActivityToStatusBucket(
state: TerminalActivityState | null | undefined,
): SidebarStateBucket | null {
if (state === "working") return "running";
if (state === "attention") return "needs_input";
return null;
}
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { TERMINAL_ACTIVITY_STATES, TerminalActivitySchema } from "./terminal-activity.js"; import {
TERMINAL_ACTIVITY_ATTENTION_REASONS,
TERMINAL_ACTIVITY_STATES,
TerminalActivitySchema,
deriveTerminalActivityStatusBucket,
} from "./terminal-activity.js";
describe("TerminalActivitySchema", () => { describe("TerminalActivitySchema", () => {
it("parses the known activity states", () => { it("parses the known activity states", () => {
@@ -16,4 +21,32 @@ describe("TerminalActivitySchema", () => {
expect(parsed.state).toBe("idle"); expect(parsed.state).toBe("idle");
expect(parsed.changedAt).toBe(1718000000000); expect(parsed.changedAt).toBe(1718000000000);
}); });
it("parses known attention reasons", () => {
for (const attentionReason of TERMINAL_ACTIVITY_ATTENTION_REASONS) {
expect(
TerminalActivitySchema.parse({ state: "attention", attentionReason, changedAt: 1 })
.attentionReason,
).toBe(attentionReason);
}
});
it("maps terminal activity to workspace status buckets", () => {
expect(deriveTerminalActivityStatusBucket({ state: "working", changedAt: 1 })).toBe("running");
expect(
deriveTerminalActivityStatusBucket({
state: "idle",
attentionReason: "finished",
changedAt: 1,
}),
).toBe("attention");
expect(
deriveTerminalActivityStatusBucket({
state: "idle",
attentionReason: "needs_input",
changedAt: 1,
}),
).toBe("needs_input");
expect(deriveTerminalActivityStatusBucket({ state: "idle", changedAt: 1 })).toBeNull();
});
}); });
@@ -1,15 +1,31 @@
import { z } from "zod"; import { z } from "zod";
export const TERMINAL_ACTIVITY_STATES = ["idle", "working", "attention"] as const; export const TERMINAL_ACTIVITY_STATES = ["idle", "working", "attention"] as const;
export const TERMINAL_ACTIVITY_ATTENTION_REASONS = ["finished", "needs_input"] as const;
export type TerminalActivityState = (typeof TERMINAL_ACTIVITY_STATES)[number]; export type TerminalActivityState = (typeof TERMINAL_ACTIVITY_STATES)[number];
export type TerminalActivityAttentionReason = (typeof TERMINAL_ACTIVITY_ATTENTION_REASONS)[number];
export const TerminalActivitySchema = z.object({ export const TerminalActivitySchema = z.object({
// Forward-compat: a newer daemon may send a state this client doesn't know. // Forward-compat: a newer daemon may send a state this client doesn't know.
// Degrade unknown states to "idle" (no indicator, no notification) so the // Degrade unknown states to "idle" (no indicator, no notification) so the
// message still parses, instead of a strict enum rejecting the whole payload. // message still parses, instead of a strict enum rejecting the whole payload.
state: z.enum(TERMINAL_ACTIVITY_STATES).catch("idle"), state: z.enum(TERMINAL_ACTIVITY_STATES).catch("idle"),
attentionReason: z.enum(TERMINAL_ACTIVITY_ATTENTION_REASONS).nullable().optional().catch(null),
changedAt: z.number(), changedAt: z.number(),
}); });
export type TerminalActivity = z.infer<typeof TerminalActivitySchema>; export type TerminalActivity = z.infer<typeof TerminalActivitySchema>;
export type TerminalActivityStatusBucket = "running" | "needs_input" | "attention";
export function deriveTerminalActivityStatusBucket(
activity: TerminalActivity | null | undefined,
): TerminalActivityStatusBucket | null {
if (!activity) return null;
if (activity.attentionReason === "needs_input") return "needs_input";
if (activity.attentionReason === "finished") return "attention";
if (activity.state === "working") return "running";
if (activity.state === "attention") return "needs_input";
return null;
}
@@ -27,6 +27,7 @@ import {
wrapSessionMessage, wrapSessionMessage,
} from "./messages.js"; } from "./messages.js";
import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frames/index"; import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frames/index";
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
import type { HostnamesConfig } from "./hostnames.js"; import type { HostnamesConfig } from "./hostnames.js";
import { isHostnameAllowed } from "./hostnames.js"; import { isHostnameAllowed } from "./hostnames.js";
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
@@ -84,9 +85,12 @@ type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
type TerminalAttentionReason = "finished" | "needs_input"; type TerminalAttentionReason = "finished" | "needs_input";
function resolveTerminalAttentionReason(input: { function resolveTerminalAttentionReason(input: {
attentionReason?: TerminalActivity["attentionReason"];
previousState: "working" | "idle" | "attention" | null; previousState: "working" | "idle" | "attention" | null;
state: "working" | "idle" | "attention" | null; state: "working" | "idle" | "attention" | null;
}): TerminalAttentionReason | null { }): TerminalAttentionReason | null {
if (input.attentionReason === "finished") return "finished";
if (input.attentionReason === "needs_input") return "needs_input";
if (input.state === "attention") return "needs_input"; if (input.state === "attention") return "needs_input";
if (input.previousState === "working" && input.state === "idle") return "finished"; if (input.previousState === "working" && input.state === "idle") return "finished";
return null; return null;
@@ -556,6 +560,7 @@ export class VoiceAssistantWebSocketServer {
if (this.terminalManager) { if (this.terminalManager) {
this.unsubscribeTerminalActivity = this.terminalManager.subscribeTerminalActivity((event) => { this.unsubscribeTerminalActivity = this.terminalManager.subscribeTerminalActivity((event) => {
const reason = resolveTerminalAttentionReason({ const reason = resolveTerminalAttentionReason({
attentionReason: event.activity?.attentionReason,
previousState: event.previous?.state ?? null, previousState: event.previous?.state ?? null,
state: event.activity?.state ?? null, state: event.activity?.state ?? null,
}); });
@@ -177,6 +177,13 @@ class WorkspaceStatus {
}); });
} }
hasFinishedTerminal(changedAt: number): void {
this.terminals.push({
cwd: this.workspace.cwd,
activity: { state: "idle", attentionReason: "finished", changedAt },
});
}
hasUnknownTerminal(): void { hasUnknownTerminal(): void {
this.terminals.push({ this.terminals.push({
cwd: this.workspace.cwd, cwd: this.workspace.cwd,
@@ -342,6 +349,15 @@ describe("WorkspaceDirectory", () => {
await expect(workspace.workspaceStatus()).resolves.toBe("running"); await expect(workspace.workspaceStatus()).resolves.toBe("running");
}); });
test("finished terminal contributes attention to workspace status", async () => {
const workspace = new WorkspaceStatus();
const changedAt = new Date(NOW).getTime();
workspace.hasFinishedTerminal(changedAt);
await expect(workspace.workspaceStatus()).resolves.toBe("attention");
});
test("idle terminal contributes nothing to workspace status", async () => { test("idle terminal contributes nothing to workspace status", async () => {
const workspace = new WorkspaceStatus(); const workspace = new WorkspaceStatus();
const changedAt = new Date(NOW).getTime(); const changedAt = new Date(NOW).getTime();
@@ -20,7 +20,10 @@ import {
resolveActiveWorkspaceRecordForCwd, resolveActiveWorkspaceRecordForCwd,
resolveWorkspaceIdForRecord, resolveWorkspaceIdForRecord,
} from "./workspace-registry-model.js"; } from "./workspace-registry-model.js";
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import {
deriveTerminalActivityStatusBucket,
type TerminalActivity,
} from "@getpaseo/protocol/terminal-activity";
type WorkspaceIdResolver = (cwd: string) => string | undefined; type WorkspaceIdResolver = (cwd: string) => string | undefined;
@@ -359,14 +362,8 @@ export class WorkspaceDirectory {
if (!activity) { if (!activity) {
continue; continue;
} }
let bucket: WorkspaceStateBucket; const bucket = deriveTerminalActivityStatusBucket(activity);
if (activity.state === "working") { if (!bucket) continue;
bucket = "running";
} else if (activity.state === "attention") {
bucket = "needs_input";
} else {
continue;
}
const workspaceId = const workspaceId =
resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords) ?? resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords) ??
resolveWorkspaceIdForCwd(cwd); resolveWorkspaceIdForCwd(cwd);
@@ -18,6 +18,31 @@ describe("TerminalActivityTracker — set", () => {
expect(tracker.getSnapshot().state).toBe("working"); expect(tracker.getSnapshot().state).toBe("working");
}); });
it("marks a working to idle transition as finished attention", () => {
const tracker = new TerminalActivityTracker();
tracker.set("working");
tracker.set("idle");
expect(tracker.getSnapshot()).toMatchObject({
state: "idle",
attentionReason: "finished",
});
});
it("keeps finished attention across repeated idle reports", () => {
const tracker = new TerminalActivityTracker();
tracker.set("working");
tracker.set("idle");
tracker.set("idle");
expect(tracker.getSnapshot()).toMatchObject({
state: "idle",
attentionReason: "finished",
});
});
}); });
describe("TerminalActivityTracker — clearAttention", () => { describe("TerminalActivityTracker — clearAttention", () => {
@@ -27,7 +52,7 @@ describe("TerminalActivityTracker — clearAttention", () => {
tracker.set("attention"); tracker.set("attention");
expect(tracker.clearAttention()).toBe(true); expect(tracker.clearAttention()).toBe(true);
expect(tracker.getSnapshot().state).toBe("idle"); expect(tracker.getSnapshot()).toMatchObject({ state: "idle", attentionReason: null });
}); });
it("leaves non-attention states unchanged", () => { it("leaves non-attention states unchanged", () => {
@@ -102,7 +127,10 @@ describe("TerminalActivityTracker — onChange listener", () => {
expect(transitions[0].previous.state).toBeNull(); expect(transitions[0].previous.state).toBeNull();
expect(transitions[0].snapshot.state).toBe("working"); expect(transitions[0].snapshot.state).toBe("working");
expect(transitions[1].previous).toEqual(transitions[0].snapshot); expect(transitions[1].previous).toEqual(transitions[0].snapshot);
expect(transitions[1].snapshot.state).toBe("idle"); expect(transitions[1].snapshot).toMatchObject({
state: "idle",
attentionReason: "finished",
});
}); });
}); });
@@ -1,13 +1,18 @@
import type { TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import type {
TerminalActivityAttentionReason,
TerminalActivityState,
} from "@getpaseo/protocol/terminal-activity";
export interface TerminalActivitySnapshot { export interface TerminalActivitySnapshot {
state: TerminalActivityState | null; state: TerminalActivityState | null;
attentionReason: TerminalActivityAttentionReason | null;
changedAt: number; changedAt: number;
} }
export class TerminalActivityTracker { export class TerminalActivityTracker {
// unknown != idle: a plain shell, or a terminal whose agent was killed, has no dot or rollup. // unknown != idle: a plain shell, or a terminal whose agent was killed, has no dot or rollup.
private resolvedState: TerminalActivityState | null = null; private resolvedState: TerminalActivityState | null = null;
private attentionReason: TerminalActivityAttentionReason | null = null;
private changedAt = Date.now(); private changedAt = Date.now();
private readonly changeListeners = new Set< private readonly changeListeners = new Set<
@@ -15,28 +20,44 @@ export class TerminalActivityTracker {
>(); >();
set(state: TerminalActivityState): void { set(state: TerminalActivityState): void {
this.setState(state); if (state === "idle" && this.resolvedState === "working") {
this.setState("idle", "finished");
return;
}
if (state === "idle" && this.attentionReason === "finished") {
return;
}
this.setState(
state === "attention" ? "idle" : state,
state === "attention" ? "needs_input" : null,
);
} }
clear(): void { clear(): void {
this.setState(null); this.setState(null, null);
} }
clearAttention(): boolean { clearAttention(): boolean {
if (this.resolvedState !== "attention") { if (!this.attentionReason) {
return false; return false;
} }
this.setState("idle"); this.setState("idle", null);
return true; return true;
} }
private setState(state: TerminalActivityState | null): void { private setState(
if (state === this.resolvedState) { state: TerminalActivityState | null,
attentionReason: TerminalActivityAttentionReason | null,
): void {
if (state === this.resolvedState && attentionReason === this.attentionReason) {
return; return;
} }
const previous = this.getSnapshot(); const previous = this.getSnapshot();
this.resolvedState = state; this.resolvedState = state;
this.attentionReason = attentionReason;
this.changedAt = Date.now(); this.changedAt = Date.now();
const snapshot = this.getSnapshot(); const snapshot = this.getSnapshot();
@@ -57,6 +78,7 @@ export class TerminalActivityTracker {
getSnapshot(): TerminalActivitySnapshot { getSnapshot(): TerminalActivitySnapshot {
return { return {
state: this.resolvedState, state: this.resolvedState,
attentionReason: this.attentionReason,
changedAt: this.changedAt, changedAt: this.changedAt,
}; };
} }
+6 -1
View File
@@ -132,12 +132,17 @@ export interface CreateTerminalOptions {
function toTerminalActivity(snapshot: { function toTerminalActivity(snapshot: {
state: TerminalActivityState | null; state: TerminalActivityState | null;
attentionReason?: TerminalActivity["attentionReason"];
changedAt: number; changedAt: number;
}): TerminalActivity | null { }): TerminalActivity | null {
if (!snapshot.state) { if (!snapshot.state) {
return null; return null;
} }
return { state: snapshot.state, changedAt: snapshot.changedAt }; return {
state: snapshot.state,
...(snapshot.attentionReason ? { attentionReason: snapshot.attentionReason } : {}),
changedAt: snapshot.changedAt,
};
} }
function resolveInitialTitleMode(presetTitle: string | undefined): "auto" | "manual" { function resolveInitialTitleMode(presetTitle: string | undefined): "auto" | "manual" {