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); + }); +});