From 9d515f03e6f4e7586c46636155cc3a76e8c9b0ed Mon Sep 17 00:00:00 2001 From: MoerAI Date: Sun, 6 Sep 2026 03:39:36 +0900 Subject: [PATCH 1/2] feat(tui,coding-agent): show working/idle agent status in terminal tabs Terminal tabs gave no signal about whether a senpi session was busy or waiting for input, so users had to focus each tab to find out. Emit an event-driven status instead of a spinner (Codex's animated title spins bells under screen and does not animate in tmux, openai/codex#17198): - tui: setTitle() now emits OSC 2 alongside OSC 0. OSC 2 is the sequence Zed documents for terminal titles and tmux/screen derive window names from it, so titles reach more terminals. Extracted as formatTerminalTitleSequence(). - coding-agent: track agent activity and prefix the terminal title with "[working]" on agent_start/compaction_start, returning to the plain title on agent_end/compaction_end/stop. The token leads the title so it survives the truncation tab bars apply. - coding-agent: mirror the status into process.title, since Zed builds its tab label from the foreground process (name + argv), not from OSC. Known limitation: on macOS process.title rewrites argv only (p_comm and proc_pidpath stay "node"), and Zed's pty_info has_changed compares only cwd and name, so Zed will not repaint a tab from an argv-only change. The refreshed argv is still cached, so the tab picks it up on the next repaint. The OSC title reaches Zed breadcrumbs and other terminals' tabs immediately. --- packages/coding-agent/CHANGELOG.md | 1 + .../interactive/agent-activity-status.ts | 51 ++++++ .../src/modes/interactive/interactive-mode.ts | 42 ++++- .../test/interactive-mode-compaction.test.ts | 8 +- .../test/terminal-tab-agent-status.test.ts | 168 ++++++++++++++++++ packages/tui/CHANGELOG.md | 4 + packages/tui/src/index.ts | 2 +- packages/tui/src/terminal.ts | 18 +- packages/tui/test/virtual-terminal.ts | 5 +- 9 files changed, 286 insertions(+), 13 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/agent-activity-status.ts create mode 100644 packages/coding-agent/test/terminal-tab-agent-status.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8bbb99a535..5e93f4a8b9 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 terminal tabs show when a session is busy. The title is prefixed with `[working]` while the agent runs (and while compacting) and returns to the plain title when idle; the status is mirrored into `process.title` for terminals that build tab labels from the foreground process. ### 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..e6102cc624 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/agent-activity-status.ts @@ -0,0 +1,51 @@ +/** + * Working/idle activity status surfaced to the host terminal. + * + * Two channels carry the status, because no single one reaches every terminal: + * + * 1. The OSC title (`Terminal.setTitle`, OSC 0 + OSC 2). This is the standard + * channel: iTerm2, Ghostty, WezTerm, Windows Terminal and tmux/screen window + * names all follow it, and Zed renders it in the terminal breadcrumbs. + * 2. `process.title`. Zed builds its terminal *tab* label from the foreground + * process (`name` + `argv[1..]`) rather than from the OSC title, so the + * status token is mirrored into argv to reach the tab label as well. + * + * The status token is a fixed, event-driven string rather than an animated + * spinner: spinners either spam the title stream or render as bells under + * screen/tmux (openai/codex#17198). + */ + +export type AgentActivityStatus = "working" | "idle"; + +const STATUS_TOKENS: Record = { + working: "[working]", + idle: "[idle]", +}; + +/** + * Prefix a terminal title with the activity status token. + * + * The token goes first so it survives the aggressive title truncation that tab + * bars apply (Zed truncates to 25 characters). + */ +export function formatAgentActivityTitle(status: AgentActivityStatus, title: string): string { + if (status !== "working") { + // Idle is the resting state: keep the plain title so tabs stay readable and + // only the actively-working sessions stand out. + return title; + } + const token = STATUS_TOKENS.working; + if (!title) { + return token; + } + return `${token} ${title}`; +} + +/** + * Build the `process.title` value that mirrors the activity status into argv. + * + * Kept short: it is rendered inside an already narrow tab label. + */ +export function formatAgentActivityProcessTitle(status: AgentActivityStatus, appName: string): string { + return `${appName} ${STATUS_TOKENS[status]}`; +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 83686dc7ab..82ff08d8ac 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -101,6 +101,11 @@ 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, + formatAgentActivityProcessTitle, + 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 +383,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 +862,31 @@ 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)); + } + + /** + * Mirror the working/idle status into `process.title`. + * + * Zed derives its terminal tab label from the foreground process rather than + * from the OSC title, so argv is the only channel that can reach that label. + */ + private applyProcessActivityTitle(): void { + process.title = formatAgentActivityProcessTitle(this.agentActivityStatus, APP_NAME); + } + + private setAgentActivityStatus(status: AgentActivityStatus): void { + if (this.agentActivityStatus === status) { + return; + } + this.agentActivityStatus = status; + this.applyProcessActivityTitle(); + this.applyTerminalTitle(); } private updateTerminalTitle(): void { @@ -3071,6 +3096,7 @@ export class InteractiveMode { this.clearPendingTools(); this.clearActiveToolExecutionStatus(); this.clearToolHookStatuses(); + this.setAgentActivityStatus("working"); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); } @@ -3262,6 +3288,7 @@ export class InteractiveMode { } case "agent_end": + this.setAgentActivityStatus("idle"); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); } @@ -3281,6 +3308,7 @@ export class InteractiveMode { break; case "compaction_start": { + this.setAgentActivityStatus("working"); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); } @@ -3327,6 +3355,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 +6150,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..d863b44600 --- /dev/null +++ b/packages/coding-agent/test/terminal-tab-agent-status.test.ts @@ -0,0 +1,168 @@ +import { formatTerminalTitleSequence } from "@earendil-works/pi-tui"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import { APP_NAME, APP_TITLE } from "../src/config.ts"; +import { + formatAgentActivityProcessTitle, + 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"; + +type ActivityStatusThis = { + activeToolExecutionTerminalTitle: string | undefined; + activeToolTerminalTitle: string | undefined; + agentActivityStatus: "working" | "idle"; + extensionTerminalTitle: string | undefined; + applyProcessActivityTitle(): void; + applyTerminalTitle(): void; + getNormalTerminalTitle(): string; + setAgentActivityStatus(status: "working" | "idle"): void; + sessionManager: { + getCwd(): string; + getSessionName(): string | undefined; + }; + ui: { + terminal: { + setTitle(title: string): void; + }; + }; +}; + +type ActivityStatusPrototype = { + applyProcessActivityTitle(this: ActivityStatusThis): void; + applyTerminalTitle(this: ActivityStatusThis): void; + getNormalTerminalTitle(this: ActivityStatusThis): string; + setAgentActivityStatus(this: ActivityStatusThis, status: "working" | "idle"): void; +}; + +function createActivityStatusThis(setTitle: (title: string) => void): ActivityStatusThis { + const prototype = InteractiveMode.prototype as unknown as ActivityStatusPrototype; + const fakeThis: ActivityStatusThis = { + activeToolExecutionTerminalTitle: undefined, + activeToolTerminalTitle: undefined, + agentActivityStatus: "idle", + extensionTerminalTitle: undefined, + applyProcessActivityTitle: prototype.applyProcessActivityTitle, + applyTerminalTitle: prototype.applyTerminalTitle, + getNormalTerminalTitle: prototype.getNormalTerminalTitle, + setAgentActivityStatus: prototype.setAgentActivityStatus, + sessionManager: { + getCwd: () => "/tmp/senpi-project", + getSessionName: () => "Visible Session", + }, + ui: { + terminal: { setTitle }, + }, + }; + return fakeThis; +} + +describe("terminal tab agent status", () => { + beforeAll(() => { + initTheme("dark"); + }); + + test("OSC title sequence includes OSC 2, the sequence Zed documents for terminal titles", () => { + // Given / When + const sequence = formatTerminalTitleSequence("senpi - project"); + + // Then + expect(sequence).toContain("\x1b]2;senpi - project\x07"); + // OSC 0 is kept so terminals that only track the icon name still update. + expect(sequence).toContain("\x1b]0;senpi - project\x07"); + }); + + test("terminal title carries a leading working token while the agent runs", () => { + // Given + const setTitle = vi.fn(); + const fakeThis = createActivityStatusThis(setTitle); + + // When + fakeThis.setAgentActivityStatus("working"); + + // Then + expect(setTitle).toHaveBeenLastCalledWith(`[working] ${APP_TITLE} - Visible Session - senpi-project`); + + // When + fakeThis.setAgentActivityStatus("idle"); + + // Then: idle is the resting state and keeps the plain title + expect(setTitle).toHaveBeenLastCalledWith(`${APP_TITLE} - Visible Session - senpi-project`); + }); + + test("an unset activity status renders the plain title, never a stray token", () => { + // Given a caller that never initialized the status field + const setTitle = vi.fn(); + const fakeThis = createActivityStatusThis(setTitle); + (fakeThis as { agentActivityStatus?: "working" | "idle" }).agentActivityStatus = undefined; + + // When + fakeThis.applyTerminalTitle(); + + // Then + expect(setTitle).toHaveBeenLastCalledWith(`${APP_TITLE} - Visible Session - senpi-project`); + }); + + test("status token stays in front of active tool titles so it survives tab truncation", () => { + // Given + const setTitle = vi.fn(); + const fakeThis = createActivityStatusThis(setTitle); + fakeThis.activeToolExecutionTerminalTitle = `${APP_TITLE} - Running bash: npm run check`; + + // When + fakeThis.setAgentActivityStatus("working"); + + // Then + const title = setTitle.mock.calls.at(-1)?.[0] as string; + expect(title.startsWith("[working] ")).toBe(true); + expect(title).toContain("Running bash: npm run check"); + }); + + test("mirrors the status into process.title for terminals that label tabs from the process", () => { + // Given + const originalProcessTitle = process.title; + const setTitle = vi.fn(); + const fakeThis = createActivityStatusThis(setTitle); + + try { + // When + fakeThis.setAgentActivityStatus("working"); + + // Then + expect(process.title).toBe(`${APP_NAME} [working]`); + + // When + fakeThis.setAgentActivityStatus("idle"); + + // Then + expect(process.title).toBe(`${APP_NAME} [idle]`); + } finally { + process.title = originalProcessTitle; + } + }); + + test("repeated identical status updates do not rewrite the title", () => { + // Given + const setTitle = vi.fn(); + const fakeThis = createActivityStatusThis(setTitle); + const originalProcessTitle = process.title; + + try { + // When + fakeThis.setAgentActivityStatus("working"); + fakeThis.setAgentActivityStatus("working"); + + // Then + expect(setTitle).toHaveBeenCalledTimes(1); + } finally { + process.title = originalProcessTitle; + } + }); + + test("formats status tokens for empty titles without stray separators", () => { + expect(formatAgentActivityTitle("working", "")).toBe("[working]"); + expect(formatAgentActivityTitle("idle", "")).toBe(""); + expect(formatAgentActivityProcessTitle("idle", "senpi")).toBe("senpi [idle]"); + expect(formatAgentActivityProcessTitle("working", "senpi")).toBe("senpi [working]"); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 22d7159dbb..cd3c2c79aa 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,8 +6,12 @@ ### Added +- Added `formatTerminalTitleSequence(title)` for building the terminal title escape sequence. + ### Changed +- `Terminal.setTitle()` now emits OSC 2 in addition to OSC 0. OSC 2 is the sequence Zed documents for terminal titles, and tmux/screen derive window names from it, so titles now reach more terminals. + ### Fixed ### Removed diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 842a346233..44567cc4f8 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -68,7 +68,7 @@ export { // Input buffering for batch splitting export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations -export { ProcessTerminal, type Terminal } from "./terminal.ts"; +export { formatTerminalTitleSequence, ProcessTerminal, type Terminal } from "./terminal.ts"; // Terminal colors export { parseOsc11BackgroundColor, type RgbColor } from "./terminal-colors.ts"; // Terminal image support diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 3fe458a7d0..0d360e12c1 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -16,6 +16,17 @@ const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7; const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150; const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`; +/** + * Build the escape sequence that sets the terminal title. + * + * Emits OSC 0 (icon name + window title) followed by OSC 2 (window title). + * OSC 2 is the sequence Zed documents for terminal titles, and tmux/screen + * derive their window names from it, so both are needed for full coverage. + */ +export function formatTerminalTitleSequence(title: string): string { + return `\x1b]0;${title}\x07\x1b]2;${title}\x07`; +} + export type KeyboardProtocolNegotiationSequence = | { type: "kitty-flags"; flags: number } | { type: "device-attributes" }; @@ -513,8 +524,11 @@ export class ProcessTerminal implements Terminal { } setTitle(title: string): void { - // OSC 0;title BEL - set terminal window title - process.stdout.write(`\x1b]0;${title}\x07`); + // OSC 0 sets icon name + window title; OSC 2 sets the window/tab title only. + // Zed documents OSC 2 as the sequence it reads for terminal titles, and + // multiplexers (tmux/screen) key their window names off OSC 2 as well, so + // emit both for the widest terminal coverage. + process.stdout.write(formatTerminalTitleSequence(title)); } setProgress(active: boolean): void { diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 4e067f4e5d..b60db78221 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -1,6 +1,6 @@ import type { Terminal as XtermTerminalType } from "@xterm/headless"; import xterm from "@xterm/headless"; -import type { Terminal } from "../src/terminal.ts"; +import { formatTerminalTitleSequence, type Terminal } from "../src/terminal.ts"; // Extract Terminal class from the module const XtermTerminal = xterm.Terminal; @@ -96,8 +96,7 @@ export class VirtualTerminal implements Terminal { } setTitle(title: string): void { - // OSC 0;title BEL - set terminal window title - this.xterm.write(`\x1b]0;${title}\x07`); + this.xterm.write(formatTerminalTitleSequence(title)); } setProgress(_active: boolean): void {} From 705ba26a8f0083ed96b44c226ee6eb3501d3030f Mon Sep 17 00:00:00 2001 From: MoerAI Date: Sun, 6 Sep 2026 03:50:23 +0900 Subject: [PATCH 2/2] fix(coding-agent): narrow terminal status to the OSC title Review follow-up on the working/idle terminal status. Revert the tui OSC 2 change. The terminal parser Zed/Alacritty use dispatches OSC `0 | 2` to the same set_title handler (alacritty/vte osc_dispatch), so OSC 0 already set the title and the extra sequence and its exported helper were redundant. packages/tui is now untouched. Drop the process.title mirror. Measured against the installed Zed 1.18.1 (tag v1.18.1) and a KERN_PROCARGS2 probe: Zed renders `name + argv[1..]`, while process.title on macOS rewrites argv[0] only, so the status could never reach a Zed tab. name is immutable for a live process, and under Bun the setter does not change argv at all. Zed users see the status in the terminal breadcrumbs; a Zed tab indicator needs a Zed-side feature. Test the real event seams (agent_start, agent_end, compaction_start, compaction_end while streaming, stop) through handleEvent instead of calling the setter directly, so deleting the wiring fails the suite, and restore process.title in afterEach. --- packages/coding-agent/CHANGELOG.md | 2 +- .../interactive/agent-activity-status.ts | 48 +-- .../src/modes/interactive/interactive-mode.ts | 17 +- .../test/terminal-tab-agent-status.test.ts | 290 ++++++++++++------ packages/tui/CHANGELOG.md | 4 - packages/tui/src/index.ts | 2 +- packages/tui/src/terminal.ts | 18 +- packages/tui/test/virtual-terminal.ts | 5 +- 8 files changed, 212 insertions(+), 174 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5e93f4a8b9..d013715a3e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,7 +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 terminal tabs show when a session is busy. The title is prefixed with `[working]` while the agent runs (and while compacting) and returns to the plain title when idle; the status is mirrored into `process.title` for terminals that build tab labels from the foreground process. +- 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 index e6102cc624..8e849cdcce 100644 --- a/packages/coding-agent/src/modes/interactive/agent-activity-status.ts +++ b/packages/coding-agent/src/modes/interactive/agent-activity-status.ts @@ -1,51 +1,35 @@ /** - * Working/idle activity status surfaced to the host terminal. + * Working/idle activity status surfaced to the host terminal via the OSC title. * - * Two channels carry the status, because no single one reaches every terminal: + * 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). * - * 1. The OSC title (`Terminal.setTitle`, OSC 0 + OSC 2). This is the standard - * channel: iTerm2, Ghostty, WezTerm, Windows Terminal and tmux/screen window - * names all follow it, and Zed renders it in the terminal breadcrumbs. - * 2. `process.title`. Zed builds its terminal *tab* label from the foreground - * process (`name` + `argv[1..]`) rather than from the OSC title, so the - * status token is mirrored into argv to reach the tab label as well. - * - * The status token is a fixed, event-driven string 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 STATUS_TOKENS: Record = { - working: "[working]", - idle: "[idle]", -}; +const WORKING_TOKEN = "[working]"; /** * Prefix a terminal title with the activity status token. * - * The token goes first so it survives the aggressive title truncation that tab - * bars apply (Zed truncates to 25 characters). + * 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") { - // Idle is the resting state: keep the plain title so tabs stay readable and - // only the actively-working sessions stand out. return title; } - const token = STATUS_TOKENS.working; if (!title) { - return token; + return WORKING_TOKEN; } - return `${token} ${title}`; -} - -/** - * Build the `process.title` value that mirrors the activity status into argv. - * - * Kept short: it is rendered inside an already narrow tab label. - */ -export function formatAgentActivityProcessTitle(status: AgentActivityStatus, appName: string): string { - return `${appName} ${STATUS_TOKENS[status]}`; + 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 82ff08d8ac..4c3311624f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -101,11 +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, - formatAgentActivityProcessTitle, - formatAgentActivityTitle, -} from "./agent-activity-status.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"; @@ -870,22 +866,11 @@ export class InteractiveMode { this.ui.terminal.setTitle(formatAgentActivityTitle(this.agentActivityStatus, title)); } - /** - * Mirror the working/idle status into `process.title`. - * - * Zed derives its terminal tab label from the foreground process rather than - * from the OSC title, so argv is the only channel that can reach that label. - */ - private applyProcessActivityTitle(): void { - process.title = formatAgentActivityProcessTitle(this.agentActivityStatus, APP_NAME); - } - private setAgentActivityStatus(status: AgentActivityStatus): void { if (this.agentActivityStatus === status) { return; } this.agentActivityStatus = status; - this.applyProcessActivityTitle(); this.applyTerminalTitle(); } diff --git a/packages/coding-agent/test/terminal-tab-agent-status.test.ts b/packages/coding-agent/test/terminal-tab-agent-status.test.ts index d863b44600..9c9f7756d8 100644 --- a/packages/coding-agent/test/terminal-tab-agent-status.test.ts +++ b/packages/coding-agent/test/terminal-tab-agent-status.test.ts @@ -1,168 +1,254 @@ -import { formatTerminalTitleSequence } from "@earendil-works/pi-tui"; -import { beforeAll, describe, expect, test, vi } from "vitest"; -import { APP_NAME, APP_TITLE } from "../src/config.ts"; -import { - formatAgentActivityProcessTitle, - formatAgentActivityTitle, -} from "../src/modes/interactive/agent-activity-status.ts"; +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"; -type ActivityStatusThis = { - activeToolExecutionTerminalTitle: string | undefined; - activeToolTerminalTitle: string | undefined; - agentActivityStatus: "working" | "idle"; - extensionTerminalTitle: string | undefined; - applyProcessActivityTitle(): void; - applyTerminalTitle(): void; - getNormalTerminalTitle(): string; - setAgentActivityStatus(status: "working" | "idle"): void; - sessionManager: { - getCwd(): string; - getSessionName(): string | undefined; - }; - ui: { - terminal: { - setTitle(title: string): void; - }; - }; -}; +const NORMAL_TITLE = `${APP_TITLE} - Visible Session - senpi-project`; -type ActivityStatusPrototype = { - applyProcessActivityTitle(this: ActivityStatusThis): void; - applyTerminalTitle(this: ActivityStatusThis): void; - getNormalTerminalTitle(this: ActivityStatusThis): string; - setAgentActivityStatus(this: ActivityStatusThis, status: "working" | "idle"): void; +type HandleEventThis = Record & { + agentActivityStatus: "working" | "idle"; + titles: string[]; }; -function createActivityStatusThis(setTitle: (title: string) => void): ActivityStatusThis { - const prototype = InteractiveMode.prototype as unknown as ActivityStatusPrototype; - const fakeThis: ActivityStatusThis = { - activeToolExecutionTerminalTitle: undefined, - activeToolTerminalTitle: undefined, +/** + * 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", - extensionTerminalTitle: undefined, - applyProcessActivityTitle: prototype.applyProcessActivityTitle, + + // real implementations under test applyTerminalTitle: prototype.applyTerminalTitle, - getNormalTerminalTitle: prototype.getNormalTerminalTitle, 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: { - terminal: { setTitle }, + 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"); }); - test("OSC title sequence includes OSC 2, the sequence Zed documents for terminal titles", () => { - // Given / When - const sequence = formatTerminalTitleSequence("senpi - project"); + // 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(sequence).toContain("\x1b]2;senpi - project\x07"); - // OSC 0 is kept so terminals that only track the icon name still update. - expect(sequence).toContain("\x1b]0;senpi - project\x07"); + expect(fakeThis.agentActivityStatus).toBe("idle"); + expect(fakeThis.titles.at(-1)).toBe(NORMAL_TITLE); }); - test("terminal title carries a leading working token while the agent runs", () => { + test("compaction_start marks working and compaction_end restores idle", async () => { // Given - const setTitle = vi.fn(); - const fakeThis = createActivityStatusThis(setTitle); + const fakeThis = createEventSeamThis(); // When - fakeThis.setAgentActivityStatus("working"); + await dispatch(fakeThis, { type: "compaction_start", reason: "extension" }); // Then - expect(setTitle).toHaveBeenLastCalledWith(`[working] ${APP_TITLE} - Visible Session - senpi-project`); + expect(fakeThis.agentActivityStatus).toBe("working"); + expect(fakeThis.titles.at(-1)).toBe(`[working] ${NORMAL_TITLE}`); // When - fakeThis.setAgentActivityStatus("idle"); + await dispatch(fakeThis, { + type: "compaction_end", + reason: "manual", + result: { tokensBefore: 1, summary: "s" }, + aborted: false, + willRetry: false, + }); - // Then: idle is the resting state and keeps the plain title - expect(setTitle).toHaveBeenLastCalledWith(`${APP_TITLE} - Visible Session - senpi-project`); + // Then + expect(fakeThis.agentActivityStatus).toBe("idle"); + expect(fakeThis.titles.at(-1)).toBe(NORMAL_TITLE); }); - test("an unset activity status renders the plain title, never a stray token", () => { - // Given a caller that never initialized the status field - const setTitle = vi.fn(); - const fakeThis = createActivityStatusThis(setTitle); - (fakeThis as { agentActivityStatus?: "working" | "idle" }).agentActivityStatus = undefined; + 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 - fakeThis.applyTerminalTitle(); + 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(setTitle).toHaveBeenLastCalledWith(`${APP_TITLE} - Visible Session - senpi-project`); + 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", () => { + test("status token stays in front of active tool titles so it survives tab truncation", async () => { // Given - const setTitle = vi.fn(); - const fakeThis = createActivityStatusThis(setTitle); + const fakeThis = createEventSeamThis(); fakeThis.activeToolExecutionTerminalTitle = `${APP_TITLE} - Running bash: npm run check`; // When - fakeThis.setAgentActivityStatus("working"); + await dispatch(fakeThis, { type: "agent_start" }); // Then - const title = setTitle.mock.calls.at(-1)?.[0] as string; + const title = String(fakeThis.titles.at(-1)); expect(title.startsWith("[working] ")).toBe(true); expect(title).toContain("Running bash: npm run check"); }); - test("mirrors the status into process.title for terminals that label tabs from the process", () => { + test("repeated identical status events do not rewrite the title", async () => { // Given - const originalProcessTitle = process.title; - const setTitle = vi.fn(); - const fakeThis = createActivityStatusThis(setTitle); + const fakeThis = createEventSeamThis(); - try { - // When - fakeThis.setAgentActivityStatus("working"); + // When + await dispatch(fakeThis, { type: "agent_start" }); + await dispatch(fakeThis, { type: "agent_start" }); - // Then - expect(process.title).toBe(`${APP_NAME} [working]`); + // Then + expect(fakeThis.titles.filter((t) => t.startsWith("[working]"))).toHaveLength(1); + }); - // When - fakeThis.setAgentActivityStatus("idle"); + 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; - // Then - expect(process.title).toBe(`${APP_NAME} [idle]`); - } finally { - process.title = originalProcessTitle; - } - }); + // When + const applyTerminalTitle = Reflect.get(InteractiveMode.prototype, "applyTerminalTitle") as ( + this: HandleEventThis, + ) => void; + applyTerminalTitle.call(fakeThis); - test("repeated identical status updates do not rewrite the title", () => { - // Given - const setTitle = vi.fn(); - const fakeThis = createActivityStatusThis(setTitle); - const originalProcessTitle = process.title; - - try { - // When - fakeThis.setAgentActivityStatus("working"); - fakeThis.setAgentActivityStatus("working"); - - // Then - expect(setTitle).toHaveBeenCalledTimes(1); - } finally { - process.title = originalProcessTitle; - } + // Then + expect(fakeThis.titles.at(-1)).toBe(NORMAL_TITLE); }); - test("formats status tokens for empty titles without stray separators", () => { + 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(""); - expect(formatAgentActivityProcessTitle("idle", "senpi")).toBe("senpi [idle]"); - expect(formatAgentActivityProcessTitle("working", "senpi")).toBe("senpi [working]"); + }); + + 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); }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index cd3c2c79aa..22d7159dbb 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,12 +6,8 @@ ### Added -- Added `formatTerminalTitleSequence(title)` for building the terminal title escape sequence. - ### Changed -- `Terminal.setTitle()` now emits OSC 2 in addition to OSC 0. OSC 2 is the sequence Zed documents for terminal titles, and tmux/screen derive window names from it, so titles now reach more terminals. - ### Fixed ### Removed diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 44567cc4f8..842a346233 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -68,7 +68,7 @@ export { // Input buffering for batch splitting export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations -export { formatTerminalTitleSequence, ProcessTerminal, type Terminal } from "./terminal.ts"; +export { ProcessTerminal, type Terminal } from "./terminal.ts"; // Terminal colors export { parseOsc11BackgroundColor, type RgbColor } from "./terminal-colors.ts"; // Terminal image support diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 0d360e12c1..3fe458a7d0 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -16,17 +16,6 @@ const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7; const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150; const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`; -/** - * Build the escape sequence that sets the terminal title. - * - * Emits OSC 0 (icon name + window title) followed by OSC 2 (window title). - * OSC 2 is the sequence Zed documents for terminal titles, and tmux/screen - * derive their window names from it, so both are needed for full coverage. - */ -export function formatTerminalTitleSequence(title: string): string { - return `\x1b]0;${title}\x07\x1b]2;${title}\x07`; -} - export type KeyboardProtocolNegotiationSequence = | { type: "kitty-flags"; flags: number } | { type: "device-attributes" }; @@ -524,11 +513,8 @@ export class ProcessTerminal implements Terminal { } setTitle(title: string): void { - // OSC 0 sets icon name + window title; OSC 2 sets the window/tab title only. - // Zed documents OSC 2 as the sequence it reads for terminal titles, and - // multiplexers (tmux/screen) key their window names off OSC 2 as well, so - // emit both for the widest terminal coverage. - process.stdout.write(formatTerminalTitleSequence(title)); + // OSC 0;title BEL - set terminal window title + process.stdout.write(`\x1b]0;${title}\x07`); } setProgress(active: boolean): void { diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index b60db78221..4e067f4e5d 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -1,6 +1,6 @@ import type { Terminal as XtermTerminalType } from "@xterm/headless"; import xterm from "@xterm/headless"; -import { formatTerminalTitleSequence, type Terminal } from "../src/terminal.ts"; +import type { Terminal } from "../src/terminal.ts"; // Extract Terminal class from the module const XtermTerminal = xterm.Terminal; @@ -96,7 +96,8 @@ export class VirtualTerminal implements Terminal { } setTitle(title: string): void { - this.xterm.write(formatTerminalTitleSequence(title)); + // OSC 0;title BEL - set terminal window title + this.xterm.write(`\x1b]0;${title}\x07`); } setProgress(_active: boolean): void {}