diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 6354aef36..ef414b88a 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -904,6 +904,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 6d7d8ca77..baefee448 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -23,12 +23,14 @@ import type { InteractiveModeContext, TodoPhase } from "../../modes/types"; 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 { PROPOSE_DEVICE_NAME, writeDeviceDispatch } from "../../tools/resolve"; 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 { @@ -80,7 +82,22 @@ export class EventController { #lastVisibleBlockCount = 0; #renderedCustomMessages = new Set(); #lastIntent: string | undefined = undefined; - #backgroundTaskCallIds = new Set(); +1: #backgroundTaskCallIds = 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(); +2: this.#toolTimelineComponents.clear(); + this.#postToolAssistantComponents.clear(); + this.#backgroundTaskCallIds.clear(); + this.#approvalAttentionToolCallIds.clear(); +3: import { + disposeTerminalTitleState, + popTerminalTitle, + pushTerminalTitle, + setSessionTerminalTitle, + setTerminalTitleStateEnabled, +} from "../utils/title-generator"; +import { aggregateVibeWorkerTokensPerSecond, VibeSessionRegistry } from "../vibe/runtime"; #readToolCallArgs = new Map>(); #readToolCallAssistantComponents = new Map(); #toolTimelineComponents = new Map(); @@ -319,9 +336,22 @@ export class EventController { this.#lastVisibleBlockCount = 0; this.#renderedCustomMessages.clear(); this.#lastIntent = undefined; - this.#toolTimelineComponents.clear(); +1: #backgroundTaskCallIds = 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(); +2: this.#toolTimelineComponents.clear(); this.#postToolAssistantComponents.clear(); this.#backgroundTaskCallIds.clear(); + this.#approvalAttentionToolCallIds.clear(); +3: import { + disposeTerminalTitleState, + popTerminalTitle, + pushTerminalTitle, + setSessionTerminalTitle, + setTerminalTitleStateEnabled, +} from "../utils/title-generator"; +import { aggregateVibeWorkerTokensPerSecond, VibeSessionRegistry } from "../vibe/runtime"; this.#readToolCallArgs.clear(); this.#readToolCallAssistantComponents.clear(); this.#lastAssistantComponent = undefined; @@ -428,6 +458,7 @@ export class EventController { this.ctx.statusLine.markActivityStart(); this.#setTerminalProgress(true); this.ctx.ensureLoadingAnimation(); + setTerminalTitleState("working"); this.ctx.ui.requestRender(); } @@ -943,6 +974,10 @@ export class EventController { async #handleToolExecutionStart(event: Extract): Promise { this.#ensureWorkingLoaderWhileStreaming(); this.#updateWorkingMessageFromIntent(event.intent); + 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" && readArgsCollapseIntoGroup(event.args)) { @@ -1005,6 +1040,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 { @@ -1040,6 +1092,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" || 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); @@ -1148,6 +1203,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(); } @@ -1186,6 +1242,7 @@ export class EventController { this.#backgroundTaskCallIds = new Set( Array.from(this.#backgroundTaskCallIds).filter(toolCallId => this.ctx.pendingTools.has(toolCallId)), ); + this.#approvalAttentionToolCallIds.clear(); this.#readToolCallArgs.clear(); this.#readToolCallAssistantComponents.clear(); this.#toolTimelineComponents.clear(); 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 f62b77211..f38d965f7 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -28,7 +28,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; const ASK_OTHER_OPTION = "Other (type your own)"; @@ -94,7 +94,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/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index efd0263bf..ccde99edb 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -130,7 +130,21 @@ 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"; +1: #backgroundTaskCallIds = 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(); +2: this.#toolTimelineComponents.clear(); + this.#postToolAssistantComponents.clear(); + this.#backgroundTaskCallIds.clear(); + this.#approvalAttentionToolCallIds.clear(); +3: import { + disposeTerminalTitleState, + popTerminalTitle, + pushTerminalTitle, + setSessionTerminalTitle, + setTerminalTitleStateEnabled, +} from "../utils/title-generator"; import { aggregateVibeWorkerTokensPerSecond, VibeSessionRegistry } from "../vibe/runtime"; import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; @@ -976,6 +990,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 @@ -3825,6 +3840,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/src/utils/title-generator.ts b/packages/coding-agent/src/utils/title-generator.ts index 99427ec83..4aa6a5083 100644 --- a/packages/coding-agent/src/utils/title-generator.ts +++ b/packages/coding-agent/src/utils/title-generator.ts @@ -387,7 +387,135 @@ export function setTerminalTitle(title: string): void { } export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void { - setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd)); + // 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 + * 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: 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", + frame: 0, + enabled: true, + timer: undefined, + lastEmitted: undefined, + extensionOverride: 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 { + // 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); +} + +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..ef571223c --- /dev/null +++ b/packages/coding-agent/test/terminal-title-state.test.ts @@ -0,0 +1,124 @@ +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"; + +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); + }); +}); + +// 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;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([]); + }); +}); diff --git a/packages/coding-agent/test/title-generator.test.ts b/packages/coding-agent/test/title-generator.test.ts index 461215dc8..40f792988 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<Api> { const model = getBundledModel("anthropic", id); @@ -570,3 +576,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;<title>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); + }); +});