Merge PR #4451: feat(coding-agent): reflect agent run state in terminal title (@mattwilkinsonn)

# Conflicts:
#	packages/coding-agent/src/modes/controllers/event-controller.ts
#	packages/coding-agent/src/modes/interactive-mode.ts
This commit is contained in:
can1357
2026-07-23 17:31:30 +02:00
8 changed files with 493 additions and 10 deletions
@@ -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,
@@ -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<string>();
#lastIntent: string | undefined = undefined;
#backgroundTaskCallIds = new Set<string>();
1: #backgroundTaskCallIds = new Set<string>();
/** 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<string>();
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<string, Record<string, unknown>>();
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
#toolTimelineComponents = new Map<string, Component>();
@@ -319,9 +336,22 @@ export class EventController {
this.#lastVisibleBlockCount = 0;
this.#renderedCustomMessages.clear();
this.#lastIntent = undefined;
this.#toolTimelineComponents.clear();
1: #backgroundTaskCallIds = new Set<string>();
/** 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<string>();
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<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
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<string, unknown>;
return resolveApproval(tool, args, mode, userPolicies).policy === "prompt";
}
async #handleToolExecutionUpdate(
event: Extract<AgentSessionEvent, { type: "tool_execution_update" }>,
): Promise<void> {
@@ -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();
@@ -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);
@@ -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();
}
@@ -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<string>();
/** 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<string>();
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();
@@ -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();
}
/**
@@ -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;<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([]);
});
});
@@ -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);
});
});