diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 1293b18..d66e827 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -190,7 +190,9 @@ import { } from "./selection-command.ts"; import { emitSessionTerminalTitle, - sessionTitleFromFirstMessage + SESSION_TITLE_SPINNER_FRAME_DURATION_MS, + sessionTitleFromFirstMessage, + sessionTitleSpinnerFrame } from "./session-title.ts"; import { SkillCatalog } from "./skills.ts"; import { @@ -670,6 +672,8 @@ class ZCodeTui { private turnTimingVisible = false; private turnHadWorkActivity = false; private turnTimer?: ReturnType; + private sessionTitleSpinnerTimer?: ReturnType; + private sessionTitleSpinnerStartedAt?: number; private pendingTurnNotification?: TurnNotificationKind; private pendingTurnNotificationDetail = ""; private goal?: GoalState; @@ -678,6 +682,7 @@ class ZCodeTui { private sessionId?: string; private sessionTitleEmitted = false; private sessionTerminalTitle?: string; + private emittedSessionTerminalTitle?: string; private sessionMetrics: SessionMetrics = {}; private usageRefreshInFlight = false; private usageRefreshPending = false; @@ -2104,6 +2109,8 @@ class ZCodeTui { this.sessionId = undefined; this.sessionTitleEmitted = false; this.sessionTerminalTitle = undefined; + this.stopSessionTitleSpinner(); + this.emittedSessionTerminalTitle = ""; emitSessionTerminalTitle(this.options.stdout ?? process.stdout, ""); this.sessionMetrics = {}; this.restoreTranscript(restoredMessages(result.restoredMessages)); @@ -5316,19 +5323,51 @@ class ZCodeTui { this.updateTurnStatus(requestRender); } - // Keeps the terminal title in sync: "ZC | ⠋ " while a turn runs, + // Keeps the terminal title in sync: "ZC | " while a turn runs, // "ZC | " when idle (mirrors opencode's live title). private refreshSessionTerminalTitle(): void { - if (!this.sessionTerminalTitle) return; + if (!this.sessionTerminalTitle) { + this.stopSessionTitleSpinner(); + return; + } + const now = performance.now(); const working = this.activeSubmissions > 0 && this.activity - ? `⠋ ${this.activity}` + ? `${sessionTitleSpinnerFrame( + this.sessionTitleSpinnerStartedAt === undefined + ? 0 + : Math.max(0, now - this.sessionTitleSpinnerStartedAt), + this.animateTurnTimer + )} ${this.activity}` : undefined; + if (working && this.animateTurnTimer) { + if (this.sessionTitleSpinnerStartedAt === undefined) this.sessionTitleSpinnerStartedAt = now; + if (!this.sessionTitleSpinnerTimer) { + this.sessionTitleSpinnerTimer = setInterval( + () => this.refreshSessionTerminalTitle(), + SESSION_TITLE_SPINNER_FRAME_DURATION_MS + ); + this.sessionTitleSpinnerTimer.unref?.(); + } + } else { + this.stopSessionTitleSpinner(); + } + const nextTitle = working ?? this.sessionTerminalTitle; + if (nextTitle === this.emittedSessionTerminalTitle) return; + this.emittedSessionTerminalTitle = nextTitle; emitSessionTerminalTitle( this.options.stdout ?? process.stdout, - working ?? this.sessionTerminalTitle + nextTitle ); } + private stopSessionTitleSpinner(): void { + if (this.sessionTitleSpinnerTimer) { + clearInterval(this.sessionTitleSpinnerTimer); + this.sessionTitleSpinnerTimer = undefined; + } + this.sessionTitleSpinnerStartedAt = undefined; + } + private updateTurnStatus(requestRender = true): void { if (this.turnStartedAt !== undefined) { this.turnElapsedMilliseconds = Math.max(0, performance.now() - this.turnStartedAt); @@ -5354,6 +5393,7 @@ class ZCodeTui { const right = goalText ? goalStyle(`[ Goal: ${goalText} ]`) : undefined; const compactRight = goalLabel ? goalStyle(`[ Goal: ${goalLabel} ]`) : undefined; this.turnStatus.setContent(left, right, compactRight); + this.refreshSessionTerminalTitle(); this.updateRuntimeActivity(false); if (requestRender) this.ui.requestRender(); } @@ -5651,6 +5691,7 @@ class ZCodeTui { this.updateCheckAbortController?.abort(); this.modelCatalogRefresh?.stop(); if (this.turnTimer) clearInterval(this.turnTimer); + this.stopSessionTitleSpinner(); if (this.rewindEscapeTimer) clearTimeout(this.rewindEscapeTimer); if (this.fullscreenWelcomeTransitionTimer) clearTimeout(this.fullscreenWelcomeTransitionTimer); if (this.runtimeRefreshTimer) clearTimeout(this.runtimeRefreshTimer); diff --git a/packages/zcode-tui/src/session-title.ts b/packages/zcode-tui/src/session-title.ts index ac5b929..1bfc2b4 100644 --- a/packages/zcode-tui/src/session-title.ts +++ b/packages/zcode-tui/src/session-title.ts @@ -6,6 +6,17 @@ export const SESSION_TITLE_PREFIX = "ZC | "; export const MAX_SESSION_TITLE_CHARS = 50; +export const SESSION_TITLE_SPINNER_FRAME_DURATION_MS = 120; + +// Braille frames keep the title width stable while making an active turn visible. +const sessionTitleSpinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const; + +export function sessionTitleSpinnerFrame(elapsedMilliseconds: number, animated = true): string { + if (!animated) return sessionTitleSpinnerFrames[0]; + const frame = Math.floor(Math.max(0, elapsedMilliseconds) / SESSION_TITLE_SPINNER_FRAME_DURATION_MS); + return sessionTitleSpinnerFrames[frame % sessionTitleSpinnerFrames.length] ?? sessionTitleSpinnerFrames[0]; +} + export function sessionTitleFromFirstMessage(message: string): string | null { const normalized = sanitizeTerminalText(message).replace(/\s+/gu, " ").trim(); if (!normalized) { diff --git a/test/session-title.test.ts b/test/session-title.test.ts index 2f901ee..65dac36 100644 --- a/test/session-title.test.ts +++ b/test/session-title.test.ts @@ -3,8 +3,10 @@ import { describe, expect, test } from "bun:test"; import { MAX_SESSION_TITLE_CHARS, SESSION_TITLE_PREFIX, + SESSION_TITLE_SPINNER_FRAME_DURATION_MS, emitSessionTerminalTitle, - sessionTitleFromFirstMessage + sessionTitleFromFirstMessage, + sessionTitleSpinnerFrame } from "../packages/zcode-tui/src/session-title.ts"; describe("session title from first message", () => { @@ -86,3 +88,15 @@ describe("terminal title emission", () => { expect(() => emitSessionTerminalTitle(undefined, "fix the login bug")).not.toThrow(); }); }); + +describe("terminal title spinner", () => { + test("advances through stable-width frames", () => { + expect(sessionTitleSpinnerFrame(0)).toBe("⠋"); + expect(sessionTitleSpinnerFrame(SESSION_TITLE_SPINNER_FRAME_DURATION_MS)).toBe("⠙"); + expect(sessionTitleSpinnerFrame(10 * SESSION_TITLE_SPINNER_FRAME_DURATION_MS)).toBe("⠋"); + }); + + test("supports a settled frame for reduced motion", () => { + expect(sessionTitleSpinnerFrame(5_000, false)).toBe("⠋"); + }); +});