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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user