Keep finished terminals visible as attention
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
wrapSessionMessage,
|
||||
} from "./messages.js";
|
||||
import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frames/index";
|
||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
import type { HostnamesConfig } from "./hostnames.js";
|
||||
import { isHostnameAllowed } from "./hostnames.js";
|
||||
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
|
||||
@@ -84,9 +85,12 @@ type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics;
|
||||
type TerminalAttentionReason = "finished" | "needs_input";
|
||||
|
||||
function resolveTerminalAttentionReason(input: {
|
||||
attentionReason?: TerminalActivity["attentionReason"];
|
||||
previousState: "working" | "idle" | "attention" | null;
|
||||
state: "working" | "idle" | "attention" | 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.previousState === "working" && input.state === "idle") return "finished";
|
||||
return null;
|
||||
@@ -556,6 +560,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
if (this.terminalManager) {
|
||||
this.unsubscribeTerminalActivity = this.terminalManager.subscribeTerminalActivity((event) => {
|
||||
const reason = resolveTerminalAttentionReason({
|
||||
attentionReason: event.activity?.attentionReason,
|
||||
previousState: event.previous?.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 {
|
||||
this.terminals.push({
|
||||
cwd: this.workspace.cwd,
|
||||
@@ -342,6 +349,15 @@ describe("WorkspaceDirectory", () => {
|
||||
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 () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
const changedAt = new Date(NOW).getTime();
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
resolveActiveWorkspaceRecordForCwd,
|
||||
resolveWorkspaceIdForRecord,
|
||||
} 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;
|
||||
|
||||
@@ -359,14 +362,8 @@ export class WorkspaceDirectory {
|
||||
if (!activity) {
|
||||
continue;
|
||||
}
|
||||
let bucket: WorkspaceStateBucket;
|
||||
if (activity.state === "working") {
|
||||
bucket = "running";
|
||||
} else if (activity.state === "attention") {
|
||||
bucket = "needs_input";
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
const bucket = deriveTerminalActivityStatusBucket(activity);
|
||||
if (!bucket) continue;
|
||||
const workspaceId =
|
||||
resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords) ??
|
||||
resolveWorkspaceIdForCwd(cwd);
|
||||
|
||||
@@ -18,6 +18,31 @@ describe("TerminalActivityTracker — set", () => {
|
||||
|
||||
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", () => {
|
||||
@@ -27,7 +52,7 @@ describe("TerminalActivityTracker — clearAttention", () => {
|
||||
tracker.set("attention");
|
||||
|
||||
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", () => {
|
||||
@@ -102,7 +127,10 @@ describe("TerminalActivityTracker — onChange listener", () => {
|
||||
expect(transitions[0].previous.state).toBeNull();
|
||||
expect(transitions[0].snapshot.state).toBe("working");
|
||||
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 {
|
||||
state: TerminalActivityState | null;
|
||||
attentionReason: TerminalActivityAttentionReason | null;
|
||||
changedAt: number;
|
||||
}
|
||||
|
||||
export class TerminalActivityTracker {
|
||||
// unknown != idle: a plain shell, or a terminal whose agent was killed, has no dot or rollup.
|
||||
private resolvedState: TerminalActivityState | null = null;
|
||||
private attentionReason: TerminalActivityAttentionReason | null = null;
|
||||
private changedAt = Date.now();
|
||||
|
||||
private readonly changeListeners = new Set<
|
||||
@@ -15,28 +20,44 @@ export class TerminalActivityTracker {
|
||||
>();
|
||||
|
||||
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 {
|
||||
this.setState(null);
|
||||
this.setState(null, null);
|
||||
}
|
||||
|
||||
clearAttention(): boolean {
|
||||
if (this.resolvedState !== "attention") {
|
||||
if (!this.attentionReason) {
|
||||
return false;
|
||||
}
|
||||
this.setState("idle");
|
||||
this.setState("idle", null);
|
||||
return true;
|
||||
}
|
||||
|
||||
private setState(state: TerminalActivityState | null): void {
|
||||
if (state === this.resolvedState) {
|
||||
private setState(
|
||||
state: TerminalActivityState | null,
|
||||
attentionReason: TerminalActivityAttentionReason | null,
|
||||
): void {
|
||||
if (state === this.resolvedState && attentionReason === this.attentionReason) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = this.getSnapshot();
|
||||
this.resolvedState = state;
|
||||
this.attentionReason = attentionReason;
|
||||
this.changedAt = Date.now();
|
||||
|
||||
const snapshot = this.getSnapshot();
|
||||
@@ -57,6 +78,7 @@ export class TerminalActivityTracker {
|
||||
getSnapshot(): TerminalActivitySnapshot {
|
||||
return {
|
||||
state: this.resolvedState,
|
||||
attentionReason: this.attentionReason,
|
||||
changedAt: this.changedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,12 +132,17 @@ export interface CreateTerminalOptions {
|
||||
|
||||
function toTerminalActivity(snapshot: {
|
||||
state: TerminalActivityState | null;
|
||||
attentionReason?: TerminalActivity["attentionReason"];
|
||||
changedAt: number;
|
||||
}): TerminalActivity | null {
|
||||
if (!snapshot.state) {
|
||||
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" {
|
||||
|
||||
Reference in New Issue
Block a user