diff --git a/src/main/index.ts b/src/main/index.ts index a2d0db03c..d3e7cfc2d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -87,6 +87,11 @@ import { shouldRecoverRendererAfterProcessGone, type ExpectedTeardownScope } from './crash-reporting/process-gone-classification' +import { + advanceSyntheticTitleSpinnerEntries, + type SyntheticTitleSpinnerEntry +} from './synthetic-title-spinner' +import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' import { isCrashReportReason } from '../shared/crash-reporting' import { KeybindingService } from './keybindings/keybinding-service' @@ -422,16 +427,17 @@ function openMainWindow(): BrowserWindow { // window recreations instead of stacking on top of stale listeners. agentHookServer.setListener(null) setMigrationUnsupportedPtyListener(null) - // Why: any running synthesized-title spinner intervals would fire into a - // destroyed webContents; stop them all here instead of deferring to - // per-pane teardown, which may never run for restored-but-never-torn-down - // panes when the window goes away. stopSyntheticTitleSpinner deletes only - // the current entry, which the Map iterator handles safely. - for (const paneKey of syntheticTitleSpinnerByPaneKey.keys()) { - stopSyntheticTitleSpinner(paneKey) - } + // Why: any running synthesized-title spinner timer would fire into a + // destroyed webContents; stop it here instead of deferring to per-pane + // teardown, which may never run for restored-but-never-torn-down panes + // when the window goes away. + stopAllSyntheticTitleSpinners() }) mainWindow = window + window.on('show', resumeSyntheticTitleSpinnerTimer) + window.on('restore', resumeSyntheticTitleSpinnerTimer) + window.on('hide', stopSyntheticTitleSpinnerTimer) + window.on('minimize', stopSyntheticTitleSpinnerTimer) agentHookServer.setListener( ({ paneKey, tabId, worktreeId, connectionId, payload, receivedAt, stateStartedAt }) => { if (mainWindow?.isDestroyed()) { @@ -634,8 +640,9 @@ const SYNTHETIC_TITLE_PROFILES: Record = { const syntheticTitleSpinnerByPaneKey = new Map< string, - { timer: ReturnType; frame: number; profile: SyntheticTitleProfile } + SyntheticTitleSpinnerEntry >() +let syntheticTitleSpinnerTimer: ReturnType | null = null type ServeOptions = { json: boolean @@ -748,29 +755,107 @@ function installServeSignalHandlers(): void { process.once('SIGTERM', quit) } -// Why: on PTY teardown the paneKey→ptyId mapping is dropped, so the spinner -// interval would keep firing but sendSyntheticTitle would no-op forever. -// Stop the interval explicitly so the process doesn't carry a timer per dead -// pane. +// Why: on PTY teardown the paneKey mapping is dropped, so the spinner tick +// would keep firing but sendSyntheticTitle would no-op forever. Drop the +// entry explicitly so the shared timer shuts down once no panes are active. registerPaneKeyTeardownListener((paneKey) => { stopSyntheticTitleSpinner(paneKey) }) -function sendSyntheticTitle(ptyId: string, data: string): void { +function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void { if (!mainWindow || mainWindow.isDestroyed()) { return } + // Why: repeated working-spinner frames are decorative and can arrive every + // 80ms per agent. Final/permission frames are forced because they drive BEL. + if ( + !shouldSendSyntheticTitleFrame({ + force: options.force === true, + windowVisible: isSyntheticTitleWindowVisible() + }) + ) { + return + } mainWindow.webContents.send('pty:data', { id: ptyId, data }) } +function isSyntheticTitleWindowVisible(): boolean { + return ( + mainWindow !== null && + !mainWindow.isDestroyed() && + mainWindow.isVisible() && + !mainWindow.isMinimized() + ) +} + +function canSendDecorativeSyntheticTitle(): boolean { + return shouldSendSyntheticTitleFrame({ + force: false, + windowVisible: isSyntheticTitleWindowVisible() + }) +} + function stopSyntheticTitleSpinner(paneKey: string): void { - const entry = syntheticTitleSpinnerByPaneKey.get(paneKey) - if (entry) { - clearInterval(entry.timer) - syntheticTitleSpinnerByPaneKey.delete(paneKey) + if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) { + stopSyntheticTitleSpinnerTimerIfIdle() } } +function stopAllSyntheticTitleSpinners(): void { + syntheticTitleSpinnerByPaneKey.clear() + stopSyntheticTitleSpinnerTimer() +} + +function stopSyntheticTitleSpinnerTimer(): void { + if (!syntheticTitleSpinnerTimer) { + return + } + clearInterval(syntheticTitleSpinnerTimer) + syntheticTitleSpinnerTimer = null +} + +function stopSyntheticTitleSpinnerTimerIfIdle(): void { + if (syntheticTitleSpinnerByPaneKey.size === 0) { + stopSyntheticTitleSpinnerTimer() + } +} + +function tickSyntheticTitleSpinners(): void { + if (!canSendDecorativeSyntheticTitle()) { + stopSyntheticTitleSpinnerTimer() + return + } + const ticks = advanceSyntheticTitleSpinnerEntries({ + entries: syntheticTitleSpinnerByPaneKey, + frameCount: SPINNER_FRAMES.length, + getPtyIdForPaneKey + }) + for (const tick of ticks) { + sendSyntheticTitle( + tick.ptyId, + `\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07` + ) + } + stopSyntheticTitleSpinnerTimerIfIdle() +} + +function ensureSyntheticTitleSpinnerTimer(): void { + if ( + syntheticTitleSpinnerTimer || + syntheticTitleSpinnerByPaneKey.size === 0 || + !canSendDecorativeSyntheticTitle() + ) { + return + } + // Why: a single process timer covers all synthesized title spinners; per-pane + // intervals multiplied idle wakeups when several retained agents were working. + syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS) +} + +function resumeSyntheticTitleSpinnerTimer(): void { + ensureSyntheticTitleSpinnerTimer() +} + function driveSyntheticTitleFromHook( paneKey: string, state: string, @@ -794,23 +879,8 @@ function driveSyntheticTitleFromHook( existing.profile = profile return } - const timer = setInterval(() => { - const ptyIdNow = getPtyIdForPaneKey(paneKey) - if (!ptyIdNow) { - stopSyntheticTitleSpinner(paneKey) - return - } - const cur = syntheticTitleSpinnerByPaneKey.get(paneKey) - if (!cur) { - return - } - cur.frame = (cur.frame + 1) % SPINNER_FRAMES.length - sendSyntheticTitle( - ptyIdNow, - `\x1b]0;${SPINNER_FRAMES[cur.frame]} ${cur.profile.workingLabel}\x07` - ) - }, SPINNER_INTERVAL_MS) - syntheticTitleSpinnerByPaneKey.set(paneKey, { timer, frame, profile }) + syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile }) + ensureSyntheticTitleSpinnerTimer() return } // Why: leaving the spinner running after a `blocked`/`waiting`/`done` event @@ -826,7 +896,7 @@ function driveSyntheticTitleFromHook( stopSyntheticTitleSpinner(paneKey) const label = state === 'blocked' || state === 'waiting' ? profile.permissionLabel : profile.idleLabel - sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07\x07`) + sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07\x07`, { force: true }) } app.whenReady().then(async () => { diff --git a/src/main/synthetic-title-spinner.test.ts b/src/main/synthetic-title-spinner.test.ts new file mode 100644 index 000000000..f79afa1b2 --- /dev/null +++ b/src/main/synthetic-title-spinner.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + advanceSyntheticTitleSpinnerEntries, + type SyntheticTitleSpinnerEntry +} from './synthetic-title-spinner' + +describe('advanceSyntheticTitleSpinnerEntries', () => { + it('advances live panes in one shared tick and wraps frames', () => { + const entries = new Map>([ + ['pane-a', { frame: 1, profile: { label: 'A' } }], + ['pane-b', { frame: 2, profile: { label: 'B' } }] + ]) + + const ticks = advanceSyntheticTitleSpinnerEntries({ + entries, + frameCount: 3, + getPtyIdForPaneKey: (paneKey) => `pty-${paneKey}` + }) + + expect(ticks).toEqual([ + { paneKey: 'pane-a', ptyId: 'pty-pane-a', frame: 2, profile: { label: 'A' } }, + { paneKey: 'pane-b', ptyId: 'pty-pane-b', frame: 0, profile: { label: 'B' } } + ]) + expect(entries.get('pane-a')?.frame).toBe(2) + expect(entries.get('pane-b')?.frame).toBe(0) + }) + + it('drops panes whose pty mapping disappeared', () => { + const entries = new Map>([ + ['live-pane', { frame: 0, profile: { label: 'live' } }], + ['stale-pane', { frame: 0, profile: { label: 'stale' } }] + ]) + + const ticks = advanceSyntheticTitleSpinnerEntries({ + entries, + frameCount: 4, + getPtyIdForPaneKey: (paneKey) => (paneKey === 'stale-pane' ? null : `pty-${paneKey}`) + }) + + expect(ticks).toEqual([ + { paneKey: 'live-pane', ptyId: 'pty-live-pane', frame: 1, profile: { label: 'live' } } + ]) + expect(entries.has('live-pane')).toBe(true) + expect(entries.has('stale-pane')).toBe(false) + }) +}) diff --git a/src/main/synthetic-title-spinner.ts b/src/main/synthetic-title-spinner.ts new file mode 100644 index 000000000..df8379a29 --- /dev/null +++ b/src/main/synthetic-title-spinner.ts @@ -0,0 +1,33 @@ +export type SyntheticTitleSpinnerEntry = { + frame: number + profile: TProfile +} + +export type SyntheticTitleSpinnerTick = { + paneKey: string + ptyId: string + frame: number + profile: TProfile +} + +export function advanceSyntheticTitleSpinnerEntries(args: { + entries: Map> + frameCount: number + getPtyIdForPaneKey: (paneKey: string) => string | null | undefined +}): SyntheticTitleSpinnerTick[] { + if (args.frameCount <= 0) { + return [] + } + + const ticks: SyntheticTitleSpinnerTick[] = [] + for (const [paneKey, entry] of args.entries) { + const ptyId = args.getPtyIdForPaneKey(paneKey) + if (!ptyId) { + args.entries.delete(paneKey) + continue + } + entry.frame = (entry.frame + 1) % args.frameCount + ticks.push({ paneKey, ptyId, frame: entry.frame, profile: entry.profile }) + } + return ticks +} diff --git a/src/main/synthetic-title-visibility.test.ts b/src/main/synthetic-title-visibility.test.ts new file mode 100644 index 000000000..b04455f9b --- /dev/null +++ b/src/main/synthetic-title-visibility.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' + +describe('shouldSendSyntheticTitleFrame', () => { + it('skips decorative spinner frames only while the window is hidden', () => { + expect(shouldSendSyntheticTitleFrame({ force: false, windowVisible: false })).toBe(false) + expect(shouldSendSyntheticTitleFrame({ force: false, windowVisible: true })).toBe(true) + }) + + it('always sends forced terminal-state frames', () => { + expect(shouldSendSyntheticTitleFrame({ force: true, windowVisible: false })).toBe(true) + }) +}) diff --git a/src/main/synthetic-title-visibility.ts b/src/main/synthetic-title-visibility.ts new file mode 100644 index 000000000..47f50cb85 --- /dev/null +++ b/src/main/synthetic-title-visibility.ts @@ -0,0 +1,6 @@ +export function shouldSendSyntheticTitleFrame(args: { + force: boolean + windowVisible: boolean +}): boolean { + return args.force || args.windowVisible +}