feat(coding-agent): reflect agent run state in terminal title

The terminal title (OSC 0) now carries a run-state prefix: an animated spinner while the agent is working and a steady dot when idle, so a backgrounded tab/pane shows which session is busy vs done. `setTerminalTitleState` also exposes an `attention` ([!]) state for callers; rendering is gated by `tui.titleState` (default on), dedups writes, and is TTY-guarded.
Refs can1357/oh-my-pi#3587
This commit is contained in:
Matt Wilkinson
2026-07-09 22:51:32 -04:00
parent e8d0a93db6
commit 997f2c5e8b
5 changed files with 163 additions and 2 deletions
@@ -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<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
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();
}
@@ -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