diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8bbb99a535..d013715a3e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added the GLM 5.2 prompt preset with automatic model detection. +- Added a working/idle agent status to the terminal title so a busy session is visible without focusing it. The title is prefixed with `[working]` while the agent runs (and while compacting) and returns to the plain title when idle. Terminals that take their tab/window title from the OSC title show this directly; Zed shows it in the terminal breadcrumbs, since Zed builds its tab label from the foreground process instead. ### Fixed diff --git a/packages/coding-agent/src/modes/interactive/agent-activity-status.ts b/packages/coding-agent/src/modes/interactive/agent-activity-status.ts new file mode 100644 index 0000000000..8e849cdcce --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/agent-activity-status.ts @@ -0,0 +1,35 @@ +/** + * Working/idle activity status surfaced to the host terminal via the OSC title. + * + * The status is a fixed, event-driven token rather than an animated spinner: + * spinners either spam the title stream or render as bells under screen/tmux + * (openai/codex#17198). + * + * Reach, measured rather than assumed: + * - Terminals that take their tab/window title from the OSC title show the + * token directly. + * - Zed routes the OSC title to the terminal *breadcrumbs*; its tab label is + * built from the foreground process instead, so the token reaches Zed's + * breadcrumbs but not its tab. See the Zed note in the PR/QA evidence. + */ + +export type AgentActivityStatus = "working" | "idle"; + +const WORKING_TOKEN = "[working]"; + +/** + * Prefix a terminal title with the activity status token. + * + * Idle is the resting state and renders the plain title, so only actively + * working sessions stand out. The token leads the string so it survives the + * truncation tab bars apply. + */ +export function formatAgentActivityTitle(status: AgentActivityStatus, title: string): string { + if (status !== "working") { + return title; + } + if (!title) { + return WORKING_TOKEN; + } + return `${WORKING_TOKEN} ${title}`; +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 83686dc7ab..4c3311624f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -101,6 +101,7 @@ import { getPiUserAgent } from "../../utils/pi-user-agent.ts"; import { killTrackedDetachedChildren } from "../../utils/shell.ts"; import { checkForNewPiVersion } from "../../utils/version-check.ts"; import { abortedErrorLabel } from "./aborted-error-label.ts"; +import { type AgentActivityStatus, formatAgentActivityTitle } from "./agent-activity-status.ts"; import { ArminComponent } from "./components/armin.ts"; import { AssistantMessageComponent } from "./components/assistant-message.ts"; import { BashExecutionComponent } from "./components/bash-execution.ts"; @@ -378,6 +379,7 @@ export class InteractiveMode { private hookStatusIntervalId: NodeJS.Timeout | undefined = undefined; private activeToolTerminalTitle: string | undefined = undefined; private extensionTerminalTitle: string | undefined = undefined; + private agentActivityStatus: AgentActivityStatus = "idle"; private lastSigintTime = 0; private lastEscapeTime = 0; @@ -856,12 +858,20 @@ export class InteractiveMode { } private applyTerminalTitle(): void { - this.ui.terminal.setTitle( + const title = this.activeToolTerminalTitle ?? - this.activeToolExecutionTerminalTitle ?? - this.extensionTerminalTitle ?? - this.getNormalTerminalTitle(), - ); + this.activeToolExecutionTerminalTitle ?? + this.extensionTerminalTitle ?? + this.getNormalTerminalTitle(); + this.ui.terminal.setTitle(formatAgentActivityTitle(this.agentActivityStatus, title)); + } + + private setAgentActivityStatus(status: AgentActivityStatus): void { + if (this.agentActivityStatus === status) { + return; + } + this.agentActivityStatus = status; + this.applyTerminalTitle(); } private updateTerminalTitle(): void { @@ -3071,6 +3081,7 @@ export class InteractiveMode { this.clearPendingTools(); this.clearActiveToolExecutionStatus(); this.clearToolHookStatuses(); + this.setAgentActivityStatus("working"); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); } @@ -3262,6 +3273,7 @@ export class InteractiveMode { } case "agent_end": + this.setAgentActivityStatus("idle"); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } @@ -3281,6 +3293,7 @@ export class InteractiveMode { break; case "compaction_start": { + this.setAgentActivityStatus("working"); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); } @@ -3327,6 +3340,9 @@ export class InteractiveMode { } case "compaction_end": { + if (!this.session.isStreaming) { + this.setAgentActivityStatus("idle"); + } if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } @@ -6119,6 +6135,7 @@ export class InteractiveMode { } stop(): void { + this.setAgentActivityStatus("idle"); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } diff --git a/packages/coding-agent/test/interactive-mode-compaction.test.ts b/packages/coding-agent/test/interactive-mode-compaction.test.ts index 9e369159af..ea65c88ab6 100644 --- a/packages/coding-agent/test/interactive-mode-compaction.test.ts +++ b/packages/coding-agent/test/interactive-mode-compaction.test.ts @@ -21,7 +21,8 @@ describe("InteractiveMode compaction events", () => { autoCompactionEscapeHandler: undefined as (() => void) | undefined, autoCompactionLoader: undefined as { stop(): void } | undefined, defaultEditor: {} as { onEscape?: () => void }, - session: { abortCompaction: vi.fn() }, + session: { abortCompaction: vi.fn(), isStreaming: false }, + setAgentActivityStatus: vi.fn(), statusContainer, settingsManager: { getShowTerminalProgress: () => false }, ui: { requestRender: vi.fn(), terminal: { setProgress: vi.fn() } }, @@ -56,7 +57,8 @@ describe("InteractiveMode compaction events", () => { autoCompactionLoader: undefined as { stop(): void } | undefined, autoCompactionProgressText: "", defaultEditor: {} as { onEscape?: () => void }, - session: { abortCompaction: vi.fn() }, + session: { abortCompaction: vi.fn(), isStreaming: false }, + setAgentActivityStatus: vi.fn(), statusContainer, settingsManager: { getShowTerminalProgress: () => false }, ui: { requestRender: vi.fn(), terminal: { setProgress: vi.fn() } }, @@ -107,6 +109,8 @@ describe("InteractiveMode compaction events", () => { showError: vi.fn(), showStatus: vi.fn(), flushCompactionQueue: vi.fn().mockResolvedValue(undefined), + session: { isStreaming: false }, + setAgentActivityStatus: vi.fn(), settingsManager: { getShowTerminalProgress: () => false }, ui: { requestRender: vi.fn(), terminal: { setProgress: vi.fn() } }, }; diff --git a/packages/coding-agent/test/terminal-tab-agent-status.test.ts b/packages/coding-agent/test/terminal-tab-agent-status.test.ts new file mode 100644 index 0000000000..9c9f7756d8 --- /dev/null +++ b/packages/coding-agent/test/terminal-tab-agent-status.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import { APP_TITLE } from "../src/config.ts"; +import { formatAgentActivityTitle } from "../src/modes/interactive/agent-activity-status.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; + +const NORMAL_TITLE = `${APP_TITLE} - Visible Session - senpi-project`; + +type HandleEventThis = Record & { + agentActivityStatus: "working" | "idle"; + titles: string[]; +}; + +/** + * Build a `this` for `InteractiveMode.prototype.handleEvent` that carries the real + * activity-status members plus no-op stand-ins for the collaborators the handled + * events touch. Everything status-related is the real prototype implementation, so + * removing the agent_start/agent_end wiring makes these tests fail. + */ +function createEventSeamThis(): HandleEventThis { + const prototype = InteractiveMode.prototype as unknown as Record unknown>; + const titles: string[] = []; + + const fakeThis: HandleEventThis = { + titles, + agentActivityStatus: "idle", + + // real implementations under test + applyTerminalTitle: prototype.applyTerminalTitle, + setAgentActivityStatus: prototype.setAgentActivityStatus, + getNormalTerminalTitle: prototype.getNormalTerminalTitle, + updateTerminalTitle: prototype.updateTerminalTitle, + + // title composition inputs + activeToolTerminalTitle: undefined, + activeToolExecutionTerminalTitle: undefined, + extensionTerminalTitle: undefined, + sessionManager: { + getCwd: () => "/tmp/senpi-project", + getSessionName: () => "Visible Session", + }, + ui: { + requestRender: vi.fn(), + terminal: { + setTitle: (title: string) => titles.push(title), + setProgress: vi.fn(), + }, + }, + + // collaborators touched by the handled events + isInitialized: true, + footer: { invalidate: vi.fn() }, + session: { isStreaming: false, abortCompaction: vi.fn() }, + settingsManager: { getShowTerminalProgress: () => false }, + statusContainer: { clear: vi.fn(), addChild: vi.fn() }, + chatContainer: { clear: vi.fn() }, + defaultEditor: {} as { onEscape?: () => void }, + autoCompactionEscapeHandler: undefined, + autoCompactionLoader: undefined, + autoCompactionProgressText: "", + workingVisible: false, + retryEscapeHandler: undefined, + retryCountdown: undefined, + retryLoader: undefined, + streamingComponent: undefined, + pendingTools: new Map(), + activeToolExecutions: new Map(), + activeToolHooks: new Map(), + hookStatusContainer: { clear: vi.fn(), addChild: vi.fn() }, + clearPendingTools: vi.fn(), + clearActiveToolExecutionStatus: vi.fn(), + clearToolHookStatuses: vi.fn(), + stopToolHookStatusTimer: vi.fn(), + stopWorkingLoader: vi.fn(), + startWorkingElapsedTimer: vi.fn(), + checkShutdownRequested: vi.fn().mockResolvedValue(undefined), + rebuildChatFromMessages: vi.fn(), + addMessageToChat: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + flushCompactionQueue: vi.fn().mockResolvedValue(undefined), + }; + return fakeThis; +} + +async function dispatch(fakeThis: HandleEventThis, event: Record): Promise { + const handleEvent = Reflect.get(InteractiveMode.prototype, "handleEvent") as ( + this: HandleEventThis, + e: Record, + ) => Promise; + await handleEvent.call(fakeThis, event); +} + +describe("terminal tab agent status", () => { + beforeAll(() => { + initTheme("dark"); + }); + + // process.title is global state; guard against any test leaking a mutation. + const originalProcessTitle = process.title; + afterEach(() => { + process.title = originalProcessTitle; + }); + + test("agent_start marks the title working and agent_end restores the idle title", async () => { + // Given + const fakeThis = createEventSeamThis(); + + // When + await dispatch(fakeThis, { type: "agent_start" }); + + // Then + expect(fakeThis.agentActivityStatus).toBe("working"); + expect(fakeThis.titles.at(-1)).toBe(`[working] ${NORMAL_TITLE}`); + + // When + await dispatch(fakeThis, { type: "agent_end" }); + + // Then + expect(fakeThis.agentActivityStatus).toBe("idle"); + expect(fakeThis.titles.at(-1)).toBe(NORMAL_TITLE); + }); + + test("compaction_start marks working and compaction_end restores idle", async () => { + // Given + const fakeThis = createEventSeamThis(); + + // When + await dispatch(fakeThis, { type: "compaction_start", reason: "extension" }); + + // Then + expect(fakeThis.agentActivityStatus).toBe("working"); + expect(fakeThis.titles.at(-1)).toBe(`[working] ${NORMAL_TITLE}`); + + // When + await dispatch(fakeThis, { + type: "compaction_end", + reason: "manual", + result: { tokensBefore: 1, summary: "s" }, + aborted: false, + willRetry: false, + }); + + // Then + expect(fakeThis.agentActivityStatus).toBe("idle"); + expect(fakeThis.titles.at(-1)).toBe(NORMAL_TITLE); + }); + + test("compaction_end keeps the working status while the agent is still streaming", async () => { + // Given a compaction that happens mid-turn + const fakeThis = createEventSeamThis(); + await dispatch(fakeThis, { type: "agent_start" }); + (fakeThis.session as { isStreaming: boolean }).isStreaming = true; + await dispatch(fakeThis, { type: "compaction_start", reason: "threshold" }); + + // When + await dispatch(fakeThis, { + type: "compaction_end", + reason: "threshold", + result: { tokensBefore: 1, summary: "s" }, + aborted: false, + willRetry: false, + }); + + // Then the turn is still running, so the title must stay working + expect(fakeThis.agentActivityStatus).toBe("working"); + expect(fakeThis.titles.at(-1)).toBe(`[working] ${NORMAL_TITLE}`); + }); + + test("stop() clears the working status so the title does not stay stuck", async () => { + // Given + const fakeThis = createEventSeamThis(); + await dispatch(fakeThis, { type: "agent_start" }); + expect(fakeThis.agentActivityStatus).toBe("working"); + + Object.assign(fakeThis, { + isInitialized: false, + clearExtensionTerminalInputListeners: vi.fn(), + footer: { invalidate: vi.fn(), dispose: vi.fn() }, + footerDataProvider: { dispose: vi.fn() }, + unsubscribe: undefined, + unregisterSignalHandlers: vi.fn(), + }); + + // When + const stop = Reflect.get(InteractiveMode.prototype, "stop") as (this: HandleEventThis) => void; + stop.call(fakeThis); + + // Then + expect(fakeThis.agentActivityStatus).toBe("idle"); + expect(fakeThis.titles.at(-1)).toBe(NORMAL_TITLE); + }); + + test("status token stays in front of active tool titles so it survives tab truncation", async () => { + // Given + const fakeThis = createEventSeamThis(); + fakeThis.activeToolExecutionTerminalTitle = `${APP_TITLE} - Running bash: npm run check`; + + // When + await dispatch(fakeThis, { type: "agent_start" }); + + // Then + const title = String(fakeThis.titles.at(-1)); + expect(title.startsWith("[working] ")).toBe(true); + expect(title).toContain("Running bash: npm run check"); + }); + + test("repeated identical status events do not rewrite the title", async () => { + // Given + const fakeThis = createEventSeamThis(); + + // When + await dispatch(fakeThis, { type: "agent_start" }); + await dispatch(fakeThis, { type: "agent_start" }); + + // Then + expect(fakeThis.titles.filter((t) => t.startsWith("[working]"))).toHaveLength(1); + }); + + test("an unset activity status renders the plain title, never a stray token", () => { + // Given a caller that never initialized the status field + const fakeThis = createEventSeamThis(); + (fakeThis as { agentActivityStatus?: "working" | "idle" }).agentActivityStatus = undefined; + + // When + const applyTerminalTitle = Reflect.get(InteractiveMode.prototype, "applyTerminalTitle") as ( + this: HandleEventThis, + ) => void; + applyTerminalTitle.call(fakeThis); + + // Then + expect(fakeThis.titles.at(-1)).toBe(NORMAL_TITLE); + }); + + test("formats the working token, and leaves idle titles untouched", () => { + expect(formatAgentActivityTitle("working", "senpi - project")).toBe("[working] senpi - project"); + expect(formatAgentActivityTitle("working", "")).toBe("[working]"); + expect(formatAgentActivityTitle("idle", "senpi - project")).toBe("senpi - project"); + expect(formatAgentActivityTitle("idle", "")).toBe(""); + }); + + test("does not mutate process.title", async () => { + // Given + const before = process.title; + const fakeThis = createEventSeamThis(); + + // When + await dispatch(fakeThis, { type: "agent_start" }); + await dispatch(fakeThis, { type: "agent_end" }); + + // Then + expect(process.title).toBe(before); + }); +});