From 997f2c5e8b7a9dba5e27303aebaeda091dd074ef Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Fri, 26 Jun 2026 21:26:50 -0400 Subject: [PATCH 1/4] feat(coding-agent): reflect agent run state in terminal title The terminal title (OSC 0) now carries a run-state prefix: an animated spinner while the agent is working and a steady dot when idle, so a backgrounded tab/pane shows which session is busy vs done. `setTerminalTitleState` also exposes an `attention` ([!]) state for callers; rendering is gated by `tui.titleState` (default on), dedups writes, and is TTY-guarded. Refs can1357/oh-my-pi#3587 --- .../src/config/settings-schema.ts | 12 ++ .../src/modes/controllers/event-controller.ts | 5 + .../src/modes/interactive-mode.ts | 8 +- .../coding-agent/src/utils/title-generator.ts | 106 +++++++++++++++++- .../test/terminal-title-state.test.ts | 34 ++++++ 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/test/terminal-title-state.test.ts diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index c632057bf..ae38dbb34 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -845,6 +845,18 @@ export const SETTINGS_SCHEMA = { }, }, + "tui.titleState": { + type: "boolean", + default: true, + ui: { + tab: "appearance", + group: "Display", + label: "Terminal Title Run State", + description: + "Prefix the terminal title with the agent run state — an animated spinner while working, a dot when idle", + }, + }, + "tui.hyperlinks": { type: "enum", values: ["off", "auto", "always"] as const, diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index e2baa993e..ec312ff9f 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -30,6 +30,7 @@ import { nextActionableTask } from "../../tools/todo"; import { SpeechEnhancer } from "../../tts/speech-enhancer"; import { vocalizer } from "../../tts/vocalizer"; import { canonicalizeMessage } from "../../utils/thinking-display"; +import { setTerminalTitleState } from "../../utils/title-generator"; import { interruptHint } from "../shared"; import { createAssistantMessageComponent } from "../utils/interactive-context-helpers"; import { assistantUsageIsBilled } from "../utils/transcript-render-helpers"; @@ -386,6 +387,7 @@ export class EventController { this.ctx.statusLine.markActivityStart(); this.#setTerminalProgress(true); this.ctx.ensureLoadingAnimation(); + setTerminalTitleState("working"); this.ctx.ui.requestRender(); } @@ -875,6 +877,7 @@ export class EventController { async #handleToolExecutionStart(event: Extract): Promise { this.#ensureWorkingLoaderWhileStreaming(); this.#updateWorkingMessageFromIntent(event.intent); + if (event.toolName === "ask") setTerminalTitleState("attention"); this.#resolveDisplaceablePoll(event.toolName); if (!this.ctx.pendingTools.has(event.toolCallId)) { if (event.toolName === "read" && readArgsHaveTarget(event.args) && !readArgsTargetInternalUrl(event.args)) { @@ -964,6 +967,7 @@ export class EventController { // which only fire `tool_execution_end`, never `_update` — do not leave // the UI looking idle while the session keeps streaming (#3857). this.#ensureWorkingLoaderWhileStreaming(); + if (event.toolName === "ask") setTerminalTitleState("working"); if (event.toolName === "read") { if (this.#inlineReadToolImages(event.toolCallId, event.result)) { const component = this.ctx.pendingTools.get(event.toolCallId); @@ -1071,6 +1075,7 @@ export class EventController { // the loader and finalizes it at its own agent_end (isStreaming === false by // then). Mirrors the collab guest's !isStreaming loader reconciler. if (this.ctx.session.isStreaming) return; + setTerminalTitleState("idle"); await this.#finishAgentEnd(); } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 5bd64e097..69ca1cca7 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -124,7 +124,12 @@ import type { EventBus } from "../utils/event-bus"; import { getEditorCommand, openInEditor } from "../utils/external-editor"; import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color"; import { messageHasDisplayableThinking } from "../utils/thinking-display"; -import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator"; +import { + popTerminalTitle, + pushTerminalTitle, + setSessionTerminalTitle, + setTerminalTitleStateEnabled, +} from "../utils/title-generator"; import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; import { ChatBlock, type ChatBlockHost } from "./components/chat-block"; @@ -947,6 +952,7 @@ export class InteractiveMode implements InteractiveModeContext { // the initial welcome frame does not append over the previous run's scrollback. this.ui.start({ clearScrollback: options.clearInitialTerminalHistory === true }); pushTerminalTitle(); + setTerminalTitleStateEnabled(this.settings.get("tui.titleState")); setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd()); this.updateEditorBorderColor(); // Single side-effect point for title changes: every setSessionName caller diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index a799fa3c0..4b11cd165 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -308,7 +308,111 @@ export function setTerminalTitle(title: string): void { } export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void { - setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd)); + terminalTitleRuntime.base = formatSessionTerminalTitle(sessionName, cwd); + emitTerminalTitle(); +} + +export type TerminalTitleState = "idle" | "working" | "attention"; + +/** Braille spinner frames for the `working` state. Self-contained (not the theme's + * symbol set) to avoid a utils→modes import cycle; OSC titles render in tab/window + * bars that handle Unicode. */ +const TITLE_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const; +const TITLE_SPINNER_INTERVAL_MS = 80; +const TITLE_IDLE_GLYPH = "●"; +const TITLE_ATTENTION_GLYPH = "[!]"; + +const terminalTitleRuntime: { + base: string; + state: TerminalTitleState; + frame: number; + enabled: boolean; + timer: ReturnType | undefined; + lastEmitted: string | undefined; +} = { + base: DEFAULT_TERMINAL_TITLE, + state: "idle", + frame: 0, + enabled: true, + timer: undefined, + lastEmitted: undefined, +}; + +/** + * Compose the run-state prefix with the base title. Pure (no I/O) so the + * state→glyph contract is unit-testable: `working` shows an animated spinner + * frame, `idle` a steady dot, `attention` a bracketed bang; when disabled it + * renders the bare title (the pre-state behavior). + */ +export function buildTerminalTitleWithState( + base: string, + state: TerminalTitleState, + frame: number, + enabled: boolean, +): string { + if (!enabled) return base; + switch (state) { + case "working": + return `${TITLE_SPINNER_FRAMES[frame % TITLE_SPINNER_FRAMES.length]} ${base}`; + case "attention": + return `${TITLE_ATTENTION_GLYPH} ${base}`; + case "idle": + return `${TITLE_IDLE_GLYPH} ${base}`; + } +} + +function emitTerminalTitle(): void { + const next = buildTerminalTitleWithState( + terminalTitleRuntime.base, + terminalTitleRuntime.state, + terminalTitleRuntime.frame, + terminalTitleRuntime.enabled, + ); + if (next === terminalTitleRuntime.lastEmitted) return; + terminalTitleRuntime.lastEmitted = next; + setTerminalTitle(next); +} + +function stopTerminalTitleSpinner(): void { + if (terminalTitleRuntime.timer) { + clearInterval(terminalTitleRuntime.timer); + terminalTitleRuntime.timer = undefined; + } +} + +function startTerminalTitleSpinner(): void { + if (terminalTitleRuntime.timer || !process.stdout.isTTY) return; + terminalTitleRuntime.timer = setInterval(() => { + terminalTitleRuntime.frame = (terminalTitleRuntime.frame + 1) % TITLE_SPINNER_FRAMES.length; + emitTerminalTitle(); + }, TITLE_SPINNER_INTERVAL_MS); + // Never keep the event loop alive for a cosmetic animation. + terminalTitleRuntime.timer.unref?.(); +} + +/** + * Reflect the agent run state in the terminal title: `working` animates a + * spinner, `idle` shows a steady dot, `attention` flags that the agent is + * blocked on the user. Gated off by `tui.titleState`. + */ +export function setTerminalTitleState(state: TerminalTitleState): void { + terminalTitleRuntime.state = state; + if (state === "working" && terminalTitleRuntime.enabled) startTerminalTitleSpinner(); + else stopTerminalTitleSpinner(); + emitTerminalTitle(); +} + +/** Enable/disable the run-state prefix (driven by the `tui.titleState` setting). */ +export function setTerminalTitleStateEnabled(enabled: boolean): void { + terminalTitleRuntime.enabled = enabled; + if (enabled && terminalTitleRuntime.state === "working") startTerminalTitleSpinner(); + else stopTerminalTitleSpinner(); + emitTerminalTitle(); +} + +/** Stop the spinner timer; call on session/UI teardown. */ +export function disposeTerminalTitleState(): void { + stopTerminalTitleSpinner(); } /** diff --git a/packages/coding-agent/test/terminal-title-state.test.ts b/packages/coding-agent/test/terminal-title-state.test.ts new file mode 100644 index 000000000..8f3932b04 --- /dev/null +++ b/packages/coding-agent/test/terminal-title-state.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; +import { buildTerminalTitleWithState } from "@oh-my-pi/pi-coding-agent/utils/title-generator"; + +const BASE = "π: my-project"; + +describe("buildTerminalTitleWithState", () => { + it("shows a steady dot when idle/done", () => { + expect(buildTerminalTitleWithState(BASE, "idle", 0, true)).toBe(`● ${BASE}`); + }); + + it("shows a bracketed bang when the agent needs attention", () => { + expect(buildTerminalTitleWithState(BASE, "attention", 0, true)).toBe(`[!] ${BASE}`); + }); + + it("animates a spinner glyph while working", () => { + const frame0 = buildTerminalTitleWithState(BASE, "working", 0, true); + const frame1 = buildTerminalTitleWithState(BASE, "working", 1, true); + // A single glyph + space precedes the base, and the glyph advances per frame. + expect(frame0.endsWith(` ${BASE}`)).toBe(true); + expect(frame0.length).toBeGreaterThan(BASE.length + 1); + expect(frame1).not.toBe(frame0); + // The frame index is taken modulo the frame count, so it never throws or + // produces an "undefined" glyph for a large counter. + const wrapped = buildTerminalTitleWithState(BASE, "working", 9999, true); + expect(wrapped.endsWith(` ${BASE}`)).toBe(true); + expect(wrapped).not.toContain("undefined"); + }); + + it("renders the bare title (pre-state behavior) when disabled, regardless of state", () => { + expect(buildTerminalTitleWithState(BASE, "working", 3, false)).toBe(BASE); + expect(buildTerminalTitleWithState(BASE, "idle", 0, false)).toBe(BASE); + expect(buildTerminalTitleWithState(BASE, "attention", 0, false)).toBe(BASE); + }); +}); From 55630876f5c2ee444bd299cdfa83c41c2eb7e3eb Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 22:33:52 -0400 Subject: [PATCH 2/4] fix(coding-agent): reconcile terminal title run-state with session lifecycle Reset the run-state title to idle when focusing an idle session (was inheriting the previous session's stuck spinner); drive the title to attention while a tool blocks on an approval prompt (not just ask), returning to working at its end; let an extension setTitle() own the terminal verbatim so neither the run-state prefix nor the spinner tick clobbers it, cleared when the app sets an authoritative session title; use NodeJS.Timeout for the spinner timer field. --- .../src/modes/controllers/event-controller.ts | 32 +++++++++++++++- .../controllers/extension-ui-controller.ts | 4 +- .../controllers/session-focus-controller.ts | 7 +++- .../coding-agent/src/utils/title-generator.ts | 38 +++++++++++++++---- 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index ec312ff9f..2c3ab2bac 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -24,6 +24,7 @@ import type { PlanApprovalDetails } from "../../plan-mode/approved-plan"; import idleRecapPrompt from "../../prompts/system/recap-user.md" with { type: "text" }; import type { AgentSessionEvent } from "../../session/agent-session"; import { isSilentAbort, readQueueChipText, resolveAbortLabel } from "../../session/messages"; +import { type ApprovalMode, resolveApproval } from "../../tools/approval"; import { previewLine, TRUNCATE_LENGTHS } from "../../tools/render-utils"; import type { ResolveToolDetails } from "../../tools/resolve"; import { nextActionableTask } from "../../tools/todo"; @@ -78,6 +79,9 @@ export class EventController { #renderedCustomMessages = new Set(); #lastIntent: string | undefined = undefined; #backgroundToolCallIds = new Set(); + /** Tool calls whose approval prompt drove the title into `attention`; cleared + * at their tool_execution_end so the title returns to `working`. */ + #approvalAttentionToolCallIds = new Set(); #readToolCallArgs = new Map>(); #readToolCallAssistantComponents = new Map(); #lastAssistantComponent: AssistantMessageComponent | undefined = undefined; @@ -283,6 +287,7 @@ export class EventController { this.#renderedCustomMessages.clear(); this.#lastIntent = undefined; this.#backgroundToolCallIds.clear(); + this.#approvalAttentionToolCallIds.clear(); this.#readToolCallArgs.clear(); this.#readToolCallAssistantComponents.clear(); this.#lastAssistantComponent = undefined; @@ -877,7 +882,10 @@ export class EventController { async #handleToolExecutionStart(event: Extract): Promise { this.#ensureWorkingLoaderWhileStreaming(); this.#updateWorkingMessageFromIntent(event.intent); - if (event.toolName === "ask") setTerminalTitleState("attention"); + if (event.toolName === "ask" || this.#toolWillPromptForApproval(event.toolName, event.args)) { + this.#approvalAttentionToolCallIds.add(event.toolCallId); + setTerminalTitleState("attention"); + } this.#resolveDisplaceablePoll(event.toolName); if (!this.ctx.pendingTools.has(event.toolCallId)) { if (event.toolName === "read" && readArgsHaveTarget(event.args) && !readArgsTargetInternalUrl(event.args)) { @@ -938,6 +946,23 @@ export class EventController { } } + /** + * Whether this tool call will block on an approval prompt before executing. + * The extension wrapper waits on `uiContext.select(...)` after emitting + * `tool_execution_start`, so an approval-mode / per-tool `prompt` policy is + * user-blocking — the title should read `attention`, not `working`. Mirrors + * the wrapper's `resolveApproval` inputs (approvalMode + tools.approval); uses + * `resolveApproval` rather than `requiresApproval` so a `deny` policy does not + * throw in the render path. + */ + #toolWillPromptForApproval(toolName: string, args: unknown): boolean { + const tool = this.ctx.viewSession.getToolByName(toolName); + if (!tool) return false; + const mode = (settings.get("tools.approvalMode") ?? "yolo") as ApprovalMode; + const userPolicies = (settings.get("tools.approval") ?? {}) as Record; + return resolveApproval(tool, args, mode, userPolicies).policy === "prompt"; + } + async #handleToolExecutionUpdate( event: Extract, ): Promise { @@ -967,7 +992,9 @@ export class EventController { // which only fire `tool_execution_end`, never `_update` — do not leave // the UI looking idle while the session keeps streaming (#3857). this.#ensureWorkingLoaderWhileStreaming(); - if (event.toolName === "ask") setTerminalTitleState("working"); + if (event.toolName === "ask" || this.#approvalAttentionToolCallIds.delete(event.toolCallId)) { + setTerminalTitleState("working"); + } if (event.toolName === "read") { if (this.#inlineReadToolImages(event.toolCallId, event.result)) { const component = this.ctx.pendingTools.get(event.toolCallId); @@ -1114,6 +1141,7 @@ export class EventController { this.#backgroundToolCallIds = new Set( Array.from(this.#backgroundToolCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)), ); + this.#approvalAttentionToolCallIds.clear(); this.#readToolCallArgs.clear(); this.#readToolCallAssistantComponents.clear(); this.#resetReadGroup(); diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 684642b12..ab5f6cb1d 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -25,7 +25,7 @@ import { HookSelectorComponent, type HookSelectorSlider } from "../../modes/comp import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme"; import type { InteractiveModeContext, InteractiveSelectorDialogOptions } from "../../modes/types"; import { normalizeCustomMessagePayload, USER_INTERRUPT_LABEL } from "../../session/messages"; -import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator"; +import { setExtensionTerminalTitle, setSessionTerminalTitle } from "../../utils/title-generator"; const MAX_WIDGET_LINES = 10; @@ -69,7 +69,7 @@ export class ExtensionUiController { setStatus: (key, text) => this.setHookStatus(key, text), setWorkingMessage: message => this.ctx.setWorkingMessage(message), setWidget: (key, content, options) => this.setHookWidget(key, content, options), - setTitle: title => setTerminalTitle(title), + setTitle: title => setExtensionTerminalTitle(title), custom: (factory, options) => this.showHookCustom(factory, options), setEditorText: text => { this.ctx.editor.setText(text); diff --git a/packages/coding-agent/src/modes/controllers/session-focus-controller.ts b/packages/coding-agent/src/modes/controllers/session-focus-controller.ts index 90c2fa6f5..8a9ebb19f 100644 --- a/packages/coding-agent/src/modes/controllers/session-focus-controller.ts +++ b/packages/coding-agent/src/modes/controllers/session-focus-controller.ts @@ -12,6 +12,7 @@ import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; import { AgentRegistry, MAIN_AGENT_ID, type RegistryEvent } from "../../registry/agent-registry"; import type { AgentSession } from "../../session/agent-session"; +import { setTerminalTitleState } from "../../utils/title-generator"; import type { InteractiveModeContext } from "../types"; export class SessionFocusController { @@ -104,8 +105,12 @@ export class SessionFocusController { }); this.ctx.statusLine.setSession(target, this.#focusedAgentId); this.ctx.renderInitialMessages({ clearTerminalHistory: true }); - // Mid-turn attach: no agent_start will arrive; arm the loader/turn state manually. + // Sync the run-state title to the attached target: a streaming target has no + // agent_start incoming, so arm the loader/working title manually; an idle + // target would otherwise inherit the previous session's stuck spinner, so + // reset it to idle (agent_end teardown already ran via clearTransientSessionUi). if (target.isStreaming) await this.ctx.eventController.handleEvent({ type: "agent_start" }); + else setTerminalTitleState("idle"); this.ctx.updateEditorBorderColor(); this.ctx.ui.requestRender(); } diff --git a/packages/coding-agent/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index 4b11cd165..62e5ddb37 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -308,10 +308,24 @@ export function setTerminalTitle(title: string): void { } export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void { + // An authoritative session title (rename, new session, focus swap) supersedes + // any extension override so the base title tracks the real session again. + terminalTitleRuntime.extensionOverride = undefined; terminalTitleRuntime.base = formatSessionTerminalTitle(sessionName, cwd); emitTerminalTitle(); } +/** + * Set a terminal title from an extension's `setTitle()`. Unlike the session base + * title, this owns the terminal verbatim: the run-state spinner will not prefix a + * glyph or overwrite it on its next tick. Cleared when the app next sets an + * authoritative session title via {@link setSessionTerminalTitle}. + */ +export function setExtensionTerminalTitle(title: string): void { + terminalTitleRuntime.extensionOverride = title; + emitTerminalTitle(); +} + export type TerminalTitleState = "idle" | "working" | "attention"; /** Braille spinner frames for the `working` state. Self-contained (not the theme's @@ -327,8 +341,13 @@ const terminalTitleRuntime: { state: TerminalTitleState; frame: number; enabled: boolean; - timer: ReturnType | undefined; + timer: NodeJS.Timeout | undefined; lastEmitted: string | undefined; + /** A title an extension set via `setTitle()`. While set, it owns the terminal + * title verbatim: the run-state spinner never prefixes or overwrites it. Cleared + * when the app next establishes an authoritative session title (rename, new + * session, focus swap) via `setSessionTerminalTitle`. */ + extensionOverride: string | undefined; } = { base: DEFAULT_TERMINAL_TITLE, state: "idle", @@ -336,6 +355,7 @@ const terminalTitleRuntime: { enabled: true, timer: undefined, lastEmitted: undefined, + extensionOverride: undefined, }; /** @@ -362,12 +382,16 @@ export function buildTerminalTitleWithState( } function emitTerminalTitle(): void { - const next = buildTerminalTitleWithState( - terminalTitleRuntime.base, - terminalTitleRuntime.state, - terminalTitleRuntime.frame, - terminalTitleRuntime.enabled, - ); + // An extension override owns the terminal verbatim; the run-state prefix and + // spinner ticks must not clobber it (still deduped via lastEmitted). + const next = + terminalTitleRuntime.extensionOverride ?? + buildTerminalTitleWithState( + terminalTitleRuntime.base, + terminalTitleRuntime.state, + terminalTitleRuntime.frame, + terminalTitleRuntime.enabled, + ); if (next === terminalTitleRuntime.lastEmitted) return; terminalTitleRuntime.lastEmitted = next; setTerminalTitle(next); From 5e88b241627618cef5fdc7f4477d82446b579a4b Mon Sep 17 00:00:00 2001 From: Matt Wilkinson Date: Wed, 8 Jul 2026 23:13:29 -0400 Subject: [PATCH 3/4] test(coding-agent): cover terminal title extension-override precedence Guards the run-state title fixes: an extension setTitle() override survives run-state changes and spinner ticks verbatim, is superseded by an authoritative session title, and emission dedupes. Red-verified against the pre-fix emit path. --- .../coding-agent/test/title-generator.test.ts | 144 +++++++++++++++++- 1 file changed, 141 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/test/title-generator.test.ts b/packages/coding-agent/test/title-generator.test.ts index c6042c9cc..3faf9bb93 100644 --- a/packages/coding-agent/test/title-generator.test.ts +++ b/packages/coding-agent/test/title-generator.test.ts @@ -1,9 +1,15 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, spyOn, vi } from "bun:test"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import * as ai from "@oh-my-pi/pi-ai"; import { type GeneratedProvider, getBundledModel } from "@oh-my-pi/pi-catalog/models"; -import { generateSessionTitle } from "@oh-my-pi/pi-coding-agent/utils/title-generator"; -import { logger } from "@oh-my-pi/pi-utils"; +import { + disposeTerminalTitleState, + generateSessionTitle, + setExtensionTerminalTitle, + setSessionTerminalTitle, + setTerminalTitleState, +} from "@oh-my-pi/pi-coding-agent/utils/title-generator"; +import { logger, setTerminalHeadless } from "@oh-my-pi/pi-utils"; function getModelOrThrow(id: string): Model { const model = getBundledModel("anthropic", id); @@ -435,3 +441,135 @@ describe("title generator", () => { expect(mockComplete.mock.calls[0]?.[0]).toBe(smolModel); }); }); + +// The terminal title runtime is a module-global. `emitTerminalTitle()` composes +// the emitted OSC title from three inputs — an extension override, a run-state +// prefix (spinner/dot/bang), and the session base — and writes it to +// `process.stdout` as `ESC]0;BEL`. These tests pin the observable +// contract at that sink: what STRING actually reaches the terminal after a +// given sequence of the exported state transitions. +// +// Two seams must be opened for the real write to happen under `bun test`: +// - `isTerminalHeadless()` defaults to true in the test runtime and short- +// circuits `setTerminalTitle` before any write; we opt out with +// `setTerminalHeadless(false)` and restore it. +// - `setTerminalTitle` also no-ops unless `process.stdout.isTTY`; we force it. + +const OSC_TITLE_RE = /\x1b\]0;([\s\S]*?)\x07/; + +// Braille spinner frames used by the `working` state (mirrors the module's +// private TITLE_SPINNER_FRAMES); a clobbered override would surface one of these. +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +describe("terminal title runtime", () => { + let writes: string[] = []; + let stdoutSpy: { mockRestore(): void } | undefined; + let prevHeadless = false; + let ttyDescriptor: PropertyDescriptor | undefined; + + // Titles emitted (newest last) since the last reset of `writes`; used across + // every assertion, so the OSC extraction lives here rather than at each site. + function emittedTitles(): string[] { + return writes.map(payload => OSC_TITLE_RE.exec(payload)?.[1]).filter((t): t is string => t !== undefined); + } + + beforeEach(() => { + // Deterministic clock so the real spinner interval can be advanced without + // a wall-clock wait. + vi.useFakeTimers(); + + // Force the real write path: not headless, stdout is a TTY. + prevHeadless = setTerminalHeadless(false); + ttyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + writes = []; + stdoutSpy = spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk as Uint8Array)); + return true; + }); + + // Drive the module-global back to a known state from the public API so + // the tests are order-independent: clear any override + session base and + // settle the run state to idle. + setSessionTerminalTitle(undefined); + setTerminalTitleState("idle"); + + // Discard the reset's own emissions; each test asserts only its own writes. + writes.length = 0; + }); + + afterEach(() => { + // Stop any spinner timer started during a test before tearing spies down. + disposeTerminalTitleState(); + stdoutSpy?.mockRestore(); + stdoutSpy = undefined; + if (ttyDescriptor) Object.defineProperty(process.stdout, "isTTY", ttyDescriptor); + else Reflect.deleteProperty(process.stdout, "isTTY"); + setTerminalHeadless(prevHeadless); + vi.useRealTimers(); + }); + + it("keeps an extension override verbatim across a run-state change (spinner never clobbers it)", () => { + // CONTRACT (core regression): once an extension owns the title, flipping + // the run state to `working` must NOT re-emit the base title with a + // spinner prefix. The override wins verbatim. + setExtensionTerminalTitle("Deploying prod"); + expect(emittedTitles().at(-1)).toBe("Deploying prod"); + + writes.length = 0; + setTerminalTitleState("working"); + setTerminalTitleState("attention"); + setTerminalTitleState("idle"); + + // No state transition produced a NEW title away from the override. + // (Deduped emits mean the sink may not fire at all; if it does, only "Deploying prod".) + for (const title of emittedTitles()) expect(title).toBe("Deploying prod"); + for (const payload of writes) { + for (const frame of SPINNER_FRAMES) expect(payload).not.toContain(frame); + } + }); + + it("keeps the override verbatim across a real spinner tick", () => { + // CONTRACT: the periodic spinner tick (frame++ → emit) must also respect + // the override. This exercises the timer-driven emission path, not just + // the synchronous state setter. + setExtensionTerminalTitle("Long extension task"); + writes.length = 0; + + // Enter `working` to start the spinner interval, then advance the fake + // clock across several tick intervals (interval is 80ms). + setTerminalTitleState("working"); + vi.advanceTimersByTime(400); + + for (const title of emittedTitles()) expect(title).toBe("Long extension task"); + for (const payload of writes) { + for (const frame of SPINNER_FRAMES) expect(payload).not.toContain(frame); + } + }); + + it("clears the override when an authoritative session title is set", () => { + // CONTRACT: `setSessionTerminalTitle` supersedes any extension override — + // the emitted title tracks the real session, not the stale override. + setExtensionTerminalTitle("Stale extension title"); + writes.length = 0; + + setSessionTerminalTitle("my-session"); + + const last = emittedTitles().at(-1); + expect(last).toBeDefined(); + expect(last).toContain("my-session"); + expect(last).not.toContain("Stale extension title"); + }); + + it("dedupes: emitting the same computed title twice writes to the terminal once", () => { + // CONTRACT: emission is deduped via `lastEmitted`; a redundant set is a no-op. + setSessionTerminalTitle("dup-session"); + expect(writes.length).toBe(1); + expect(emittedTitles().at(-1)).toContain("dup-session"); + + setSessionTerminalTitle("dup-session"); + // Second identical set produced no additional write. + expect(writes.length).toBe(1); + }); +}); From 13547e784aa4801d93c4b7af9859db9e6932bc41 Mon Sep 17 00:00:00 2001 From: Matt Wilkinson <mattwilki17@gmail.com> Date: Thu, 9 Jul 2026 23:26:01 -0400 Subject: [PATCH 4/4] fix(coding-agent): dispose title spinner on shutdown The run-state `working` spinner arms a periodic setInterval that re-emits the terminal title as an OSC-0 write each tick. shutdown() restored the shell title via popTerminalTitle() but never stopped the interval, so a pending tick could fire after the restore and leave the parent shell tab reading a stale spinner title post-exit. Wire the existing disposeTerminalTitleState() into shutdown() before popTerminalTitle(). Co-Authored-By: seal <noreply@sealedsecurity.com> --- .../src/modes/interactive-mode.ts | 5 + .../test/terminal-title-state.test.ts | 94 ++++++++++++++++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 69ca1cca7..cc379d4a4 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -125,6 +125,7 @@ import { getEditorCommand, openInEditor } from "../utils/external-editor"; import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color"; import { messageHasDisplayableThinking } from "../utils/thinking-display"; import { + disposeTerminalTitleState, popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle, @@ -3504,6 +3505,10 @@ export class InteractiveMode implements InteractiveModeContext { // Drain any in-flight Kitty key release events before stopping. // This prevents escape sequences from leaking to the parent shell over slow SSH. await this.ui.terminal.drainInput(1000); + // Stop the run-state spinner interval BEFORE restoring the shell title, so a + // pending tick cannot re-emit an OSC title after `popTerminalTitle` hands the + // terminal back (which would leave the parent shell with a `⠋ π: …` tab). + disposeTerminalTitleState(); popTerminalTitle(); this.stop(); diff --git a/packages/coding-agent/test/terminal-title-state.test.ts b/packages/coding-agent/test/terminal-title-state.test.ts index 8f3932b04..ef571223c 100644 --- a/packages/coding-agent/test/terminal-title-state.test.ts +++ b/packages/coding-agent/test/terminal-title-state.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, it } from "bun:test"; -import { buildTerminalTitleWithState } from "@oh-my-pi/pi-coding-agent/utils/title-generator"; +import { afterEach, beforeEach, describe, expect, it, spyOn, vi } from "bun:test"; +import { + buildTerminalTitleWithState, + disposeTerminalTitleState, + setSessionTerminalTitle, + setTerminalTitleState, +} from "@oh-my-pi/pi-coding-agent/utils/title-generator"; +import { setTerminalHeadless } from "@oh-my-pi/pi-utils"; const BASE = "π: my-project"; @@ -32,3 +38,87 @@ describe("buildTerminalTitleWithState", () => { expect(buildTerminalTitleWithState(BASE, "attention", 0, false)).toBe(BASE); }); }); + +// Regression coverage for the shutdown-leak bug (PR #4451): the run-state +// `working` spinner arms a periodic `setInterval` that, on every tick, re-emits +// the terminal title as an OSC-0 write (`ESC]0;<title>BEL`). If that interval is +// not cleared on teardown, a pending tick can fire AFTER the shell title was +// restored, leaving the parent shell tab reading `⠋ π: …` post-exit. +// `disposeTerminalTitleState()` (now wired into `InteractiveMode.shutdown()`) +// must stop the timer so no further OSC-title write reaches stdout. +// +// The contract is pinned at the observable sink — `process.stdout.write` — not +// at the timer plumbing. Two seams are opened so the real write path runs under +// `bun test`, mirroring the sibling `terminal title runtime` suite: +// - `isTerminalHeadless()` defaults to true in the test runtime and short- +// circuits `setTerminalTitle` before any write; opt out with +// `setTerminalHeadless(false)` and restore it. +// - `setTerminalTitle` (and the spinner start) also no-op unless +// `process.stdout.isTTY`; force it true and restore. +// `vi.useFakeTimers()` makes the real 80ms interval advanceable without a +// wall-clock wait, so the test is fully deterministic. + +const OSC_TITLE_SEQ = "\x1b]0;"; + +describe("disposeTerminalTitleState", () => { + let writes: string[] = []; + let stdoutSpy: { mockRestore(): void } | undefined; + let prevHeadless = false; + let ttyDescriptor: PropertyDescriptor | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + + prevHeadless = setTerminalHeadless(false); + ttyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + + writes = []; + stdoutSpy = spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { + writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk as Uint8Array)); + return true; + }); + + // Drive the module-global to a known state from the public API so the + // tests are order-independent: a fresh session base, run state idle. + setSessionTerminalTitle("my-project"); + setTerminalTitleState("idle"); + writes.length = 0; + }); + + afterEach(() => { + // A started interval must never leak between tests. + disposeTerminalTitleState(); + stdoutSpy?.mockRestore(); + stdoutSpy = undefined; + if (ttyDescriptor) Object.defineProperty(process.stdout, "isTTY", ttyDescriptor); + else Reflect.deleteProperty(process.stdout, "isTTY"); + setTerminalHeadless(prevHeadless); + vi.useRealTimers(); + }); + + it("stops the spinner so no further OSC-title write fires on a tick after dispose", () => { + // CONTRACT (the fix): entering `working` arms the spinner interval; once + // `disposeTerminalTitleState()` runs, advancing the clock across many tick + // periods must produce ZERO additional OSC-title writes. A pending tick + // re-emitting the title after teardown is exactly the shell-tab leak. + setTerminalTitleState("working"); + + // Control: BEFORE dispose the interval is live — advancing the clock across + // several 80ms tick periods DOES emit further OSC-title writes (proves the + // timer was actually running, so the post-dispose silence is meaningful and + // not a headless/TTY misconfiguration masking all writes). + writes.length = 0; + vi.advanceTimersByTime(400); + const ticksWhileLive = writes.filter(payload => payload.includes(OSC_TITLE_SEQ)).length; + expect(ticksWhileLive).toBeGreaterThan(0); + + // The fix under test. + disposeTerminalTitleState(); + + // After dispose: advance far past many tick periods. No tick may fire. + writes.length = 0; + vi.advanceTimersByTime(4000); + expect(writes.filter(payload => payload.includes(OSC_TITLE_SEQ))).toEqual([]); + }); +});