From bcf134c19f32ad0098f59a14b607a4bde12fdf97 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:43:19 -0700 Subject: [PATCH] fix: clear stale agent working indicators when title stops updating (#100) Adds clearWorkingIndicators to strip spinner/prefix/keyword markers from terminal titles after 3s of data flow without a title update, preventing the UI from showing a stuck "working" status after an agent exits. Co-authored-by: Claude Opus 4.6 --- .../components/terminal-pane/pty-transport.ts | 42 +++++++++++++++++++ src/renderer/src/lib/agent-status.test.ts | 27 +++++++++++- src/renderer/src/lib/agent-status.ts | 36 ++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 1e2f4a782..89777b101 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -1,3 +1,5 @@ +import { detectAgentStatusFromTitle, clearWorkingIndicators } from '@/lib/agent-status' + export type PtyTransport = { connect: (options: { url: string @@ -69,6 +71,12 @@ export function createIpcPtyTransport( let pendingEscape = false let inOsc = false let pendingOscEscape = false + let lastEmittedTitle: string | null = null + let staleTitleTimer: ReturnType | null = null + + // How long data must flow without a title update before we consider + // the last agent-working title stale and clear it (ms). + const STALE_TITLE_TIMEOUT = 3000 let storedCallbacks: { onConnect?: () => void onDisconnect?: () => void @@ -110,7 +118,33 @@ export function createIpcPtyTransport( if (onTitleChange) { const title = extractLastOscTitle(data) if (title !== null) { + // Got a fresh title — clear any pending stale-title timer + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } + lastEmittedTitle = title onTitleChange(title) + } else if ( + lastEmittedTitle && + detectAgentStatusFromTitle(lastEmittedTitle) === 'working' + ) { + // Data flowing but no title update — the agent may have exited. + // Start/restart a debounce timer to clear the stale working title. + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + } + staleTitleTimer = setTimeout(() => { + staleTitleTimer = null + if ( + lastEmittedTitle && + detectAgentStatusFromTitle(lastEmittedTitle) === 'working' + ) { + const cleared = clearWorkingIndicators(lastEmittedTitle) + lastEmittedTitle = cleared + onTitleChange(cleared) + } + }, STALE_TITLE_TIMEOUT) } } if (onBell && chunkContainsBell(data)) { @@ -120,6 +154,10 @@ export function createIpcPtyTransport( const spawnedId = result.id ptyExitHandlers.set(spawnedId, (code) => { + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } connected = false ptyId = null unregisterPtyHandlers(spawnedId) @@ -137,6 +175,10 @@ export function createIpcPtyTransport( }, disconnect() { + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } if (ptyId) { const id = ptyId window.api.pty.kill(id) diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 93f7ff5e9..91c48cf2c 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { detectAgentStatusFromTitle } from './agent-status' +import { detectAgentStatusFromTitle, clearWorkingIndicators } from './agent-status' describe('detectAgentStatusFromTitle', () => { it('detects permission requests from agent titles', () => { @@ -11,3 +11,28 @@ describe('detectAgentStatusFromTitle', () => { expect(detectAgentStatusFromTitle('◇ Gemini CLI')).toBe('idle') }) }) + +describe('clearWorkingIndicators', () => { + it('strips Claude Code ". " working prefix', () => { + const cleared = clearWorkingIndicators('. claude') + expect(cleared).toBe('claude') + expect(detectAgentStatusFromTitle(cleared)).not.toBe('working') + }) + + it('strips braille spinner characters and working keywords', () => { + const cleared = clearWorkingIndicators('⠋ Codex is thinking') + expect(cleared).toBe('Codex is') + expect(detectAgentStatusFromTitle(cleared)).not.toBe('working') + }) + + it('strips Gemini working symbol', () => { + const cleared = clearWorkingIndicators('✦ Gemini CLI') + expect(cleared).toBe('Gemini CLI') + expect(detectAgentStatusFromTitle(cleared)).not.toBe('working') + }) + + it('returns original title if no working indicators found', () => { + expect(clearWorkingIndicators('* claude')).toBe('* claude') + expect(clearWorkingIndicators('Terminal 1')).toBe('Terminal 1') + }) +}) diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index f775ce8c5..1db3198a4 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -26,6 +26,42 @@ function containsAny(title: string, words: string[]): boolean { return words.some((word) => lower.includes(word)) } +const WORKING_KEYWORDS = ['working', 'thinking', 'running'] + +/** + * Strip working-status indicators from a title so that + * `detectAgentStatusFromTitle` will no longer return 'working'. + * Used to clear stale titles when an agent exits without resetting its title. + */ +export function clearWorkingIndicators(title: string): string { + let cleaned = title + + // Gemini working symbol + cleaned = cleaned.replace(GEMINI_WORKING, '') + + // Braille spinner characters (U+2800–U+28FF) + // eslint-disable-next-line no-control-regex -- intentional unicode range + cleaned = cleaned.replace(/[\u2800-\u28FF]/g, '') + + // Claude Code ". " working prefix + if (cleaned.startsWith('. ')) { + cleaned = cleaned.slice(2) + } + + // Strip working keywords that detectAgentStatusFromTitle would pick up + // when the title also contains an agent name. + if (containsAgentName(cleaned)) { + for (const keyword of WORKING_KEYWORDS) { + cleaned = cleaned.replace(new RegExp(`\\b${keyword}\\b`, 'gi'), '') + } + } + + // Collapse whitespace after removals + cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() + + return cleaned || title +} + export function detectAgentStatusFromTitle(title: string): AgentStatus | null { if (!title) { return null