diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index bdc37e9fb..5200d996a 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -58,7 +58,7 @@ export default function TerminalPane({ const updateTabTitle = useAppStore((store) => store.updateTabTitle) const updateTabPtyId = useAppStore((store) => store.updateTabPtyId) const clearTabPtyId = useAppStore((store) => store.clearTabPtyId) - const markWorktreeUnreadFromBell = useAppStore((store) => store.markWorktreeUnreadFromBell) + const markWorktreeUnread = useAppStore((store) => store.markWorktreeUnread) const settings = useAppStore((store) => store.settings) const settingsRef = useRef(settings) settingsRef.current = settings @@ -114,7 +114,7 @@ export default function TerminalPane({ clearTabPtyId, updateTabTitle, updateTabPtyId, - markWorktreeUnreadFromBell, + markWorktreeUnread, setTabPaneExpanded, setTabCanExpandPane, setExpandedPane, diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index a74f8ad22..ecb875349 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -13,7 +13,7 @@ type PtyConnectionDeps = { clearTabPtyId: (tabId: string, ptyId: string) => void updateTabTitle: (tabId: string, title: string) => void updateTabPtyId: (tabId: string, ptyId: string) => void - markWorktreeUnreadFromBell: (worktreeId: string) => void + markWorktreeUnread: (worktreeId: string) => void } export function connectPanePty( @@ -36,9 +36,17 @@ export function connectPanePty( } const onPtySpawn = (ptyId: string): void => deps.updateTabPtyId(deps.tabId, ptyId) - const onBell = (): void => deps.markWorktreeUnreadFromBell(deps.worktreeId) + const onBell = (): void => deps.markWorktreeUnread(deps.worktreeId) + const onAgentBecameIdle = (): void => deps.markWorktreeUnread(deps.worktreeId) - const transport = createIpcPtyTransport(deps.cwd, onExit, onTitleChange, onPtySpawn, onBell) + const transport = createIpcPtyTransport({ + cwd: deps.cwd, + onPtyExit: onExit, + onTitleChange, + onPtySpawn, + onBell, + onAgentBecameIdle + }) deps.paneTransportsRef.current.set(pane.id, transport) pane.terminal.onData((data) => { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 89777b101..e78ee2355 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -1,4 +1,8 @@ -import { detectAgentStatusFromTitle, clearWorkingIndicators } from '@/lib/agent-status' +import { + detectAgentStatusFromTitle, + clearWorkingIndicators, + createAgentStatusTracker +} from '@/lib/agent-status' export type PtyTransport = { connect: (options: { @@ -58,13 +62,17 @@ export function extractLastOscTitle(data: string): string | null { return last } -export function createIpcPtyTransport( - cwd?: string, - onPtyExit?: (ptyId: string) => void, - onTitleChange?: (title: string) => void, - onPtySpawn?: (ptyId: string) => void, +export type IpcPtyTransportOptions = { + cwd?: string + onPtyExit?: (ptyId: string) => void + onTitleChange?: (title: string) => void + onPtySpawn?: (ptyId: string) => void onBell?: () => void -): PtyTransport { + onAgentBecameIdle?: () => void +} + +export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTransport { + const { cwd, onPtyExit, onTitleChange, onPtySpawn, onBell, onAgentBecameIdle } = opts let connected = false let destroyed = false let ptyId: string | null = null @@ -73,6 +81,7 @@ export function createIpcPtyTransport( let pendingOscEscape = false let lastEmittedTitle: string | null = null let staleTitleTimer: ReturnType | null = null + const agentTracker = onAgentBecameIdle ? createAgentStatusTracker(onAgentBecameIdle) : null // How long data must flow without a title update before we consider // the last agent-working title stale and clear it (ms). @@ -125,6 +134,7 @@ export function createIpcPtyTransport( } lastEmittedTitle = title onTitleChange(title) + agentTracker?.handleTitle(title) } else if ( lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working' diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index d18c319d9..4f265331b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -34,7 +34,7 @@ type UseTerminalPaneLifecycleDeps = { clearTabPtyId: (tabId: string, ptyId: string) => void updateTabTitle: (tabId: string, title: string) => void updateTabPtyId: (tabId: string, ptyId: string) => void - markWorktreeUnreadFromBell: (worktreeId: string) => void + markWorktreeUnread: (worktreeId: string) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setExpandedPane: (paneId: number | null) => void @@ -62,7 +62,7 @@ export function useTerminalPaneLifecycle({ clearTabPtyId, updateTabTitle, updateTabPtyId, - markWorktreeUnreadFromBell, + markWorktreeUnread, setTabPaneExpanded, setTabCanExpandPane, setExpandedPane, @@ -151,7 +151,7 @@ export function useTerminalPaneLifecycle({ clearTabPtyId, updateTabTitle, updateTabPtyId, - markWorktreeUnreadFromBell + markWorktreeUnread } const manager = new PaneManager(container, { diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 6cf055e78..f21563fbb 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from 'vitest' -import { detectAgentStatusFromTitle, clearWorkingIndicators } from './agent-status' +import { describe, expect, it, vi } from 'vitest' +import { + detectAgentStatusFromTitle, + clearWorkingIndicators, + createAgentStatusTracker +} from './agent-status' +import { extractLastOscTitle } from '../components/terminal-pane/pty-transport' describe('detectAgentStatusFromTitle', () => { it('returns null for empty string', () => { @@ -24,6 +29,10 @@ describe('detectAgentStatusFromTitle', () => { expect(detectAgentStatusFromTitle('◇ Gemini CLI')).toBe('idle') }) + it('detects Gemini silent working symbol ⏲', () => { + expect(detectAgentStatusFromTitle('⏲ Working… (my-project)')).toBe('working') + }) + it('Gemini permission takes precedence over working', () => { expect(detectAgentStatusFromTitle('✋✦ Gemini CLI')).toBe('permission') }) @@ -107,6 +116,24 @@ describe('detectAgentStatusFromTitle', () => { expect(detectAgentStatusFromTitle('* claude')).toBe('idle') }) + // --- Real Claude Code OSC titles --- + // Claude Code sets title to task description, NOT "Claude Code" + it('detects ✳ prefix as idle (Claude Code with task description)', () => { + expect(detectAgentStatusFromTitle('✳ User acknowledgment and confirmation')).toBe('idle') + }) + + it('detects ✳ prefix as idle (Claude Code with agent name)', () => { + expect(detectAgentStatusFromTitle('✳ Claude Code')).toBe('idle') + }) + + it('detects braille spinner as working (Claude Code with task description)', () => { + expect(detectAgentStatusFromTitle('⠐ User acknowledgment and confirmation')).toBe('working') + }) + + it('detects braille spinner as working (Claude Code with agent name)', () => { + expect(detectAgentStatusFromTitle('⠂ Claude Code')).toBe('working') + }) + // --- Agent name alone defaults to idle --- it('returns idle for bare agent name "claude"', () => { expect(detectAgentStatusFromTitle('claude')).toBe('idle') @@ -150,8 +177,180 @@ describe('clearWorkingIndicators', () => { expect(detectAgentStatusFromTitle(cleared)).not.toBe('working') }) + it('strips Gemini silent working symbol ⏲', () => { + const cleared = clearWorkingIndicators('⏲ Working… (my-project)') + 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') }) }) + +describe('createAgentStatusTracker', () => { + // --- Claude Code: real captured OSC title sequence (v2.1.86) --- + // CRITICAL: Claude Code changes the title to the TASK DESCRIPTION, + // not "Claude Code". The ✳ prefix is the only reliable idle indicator. + it('fires on Claude Code working → idle (real captured titles)', () => { + const onBecameIdle = vi.fn() + const tracker = createAgentStatusTracker(onBecameIdle) + + // Exact sequence captured from Claude Code v2.1.86 via script(1) + tracker.handleTitle('✳ Claude Code') // startup idle + expect(onBecameIdle).not.toHaveBeenCalled() + + tracker.handleTitle('⠂ Claude Code') // working + expect(onBecameIdle).not.toHaveBeenCalled() + + tracker.handleTitle('⠐ Claude Code') // still working + expect(onBecameIdle).not.toHaveBeenCalled() + + // Claude Code changes title to task description mid-stream! + tracker.handleTitle('⠐ User acknowledgment and confirmation') // working + expect(onBecameIdle).not.toHaveBeenCalled() + + tracker.handleTitle('⠂ User acknowledgment and confirmation') // working + expect(onBecameIdle).not.toHaveBeenCalled() + + tracker.handleTitle('✳ User acknowledgment and confirmation') // done → idle + expect(onBecameIdle).toHaveBeenCalledTimes(1) + }) + + // --- Gemini CLI: real title patterns from source code --- + it('fires on Gemini CLI working → idle (real title patterns)', () => { + const onBecameIdle = vi.fn() + const tracker = createAgentStatusTracker(onBecameIdle) + + tracker.handleTitle('◇ Ready (my-project)') // startup idle + expect(onBecameIdle).not.toHaveBeenCalled() + + tracker.handleTitle('✦ Implementing feature (my-project)') // working + expect(onBecameIdle).not.toHaveBeenCalled() + + tracker.handleTitle('◇ Ready (my-project)') // done → idle + expect(onBecameIdle).toHaveBeenCalledTimes(1) + }) + + it('fires on Gemini CLI working → permission', () => { + const onBecameIdle = vi.fn() + const tracker = createAgentStatusTracker(onBecameIdle) + + tracker.handleTitle('✦ Working… (my-project)') // working + tracker.handleTitle('✋ Action Required (my-project)') // permission + expect(onBecameIdle).toHaveBeenCalledTimes(1) + }) + + it('fires on Gemini CLI silent working → idle', () => { + const onBecameIdle = vi.fn() + const tracker = createAgentStatusTracker(onBecameIdle) + + tracker.handleTitle('⏲ Working… (my-project)') // silent working + tracker.handleTitle('◇ Ready (my-project)') // idle + expect(onBecameIdle).toHaveBeenCalledTimes(1) + }) + + // --- Codex: braille spinner working, bare name idle --- + it('fires on Codex working → idle', () => { + const onBecameIdle = vi.fn() + const tracker = createAgentStatusTracker(onBecameIdle) + + tracker.handleTitle('⠋ Codex is thinking') // working + tracker.handleTitle('codex') // idle (bare name) + expect(onBecameIdle).toHaveBeenCalledTimes(1) + }) + + // --- Multiple cycles --- + it('fires on each working → idle cycle', () => { + const onBecameIdle = vi.fn() + const tracker = createAgentStatusTracker(onBecameIdle) + + // Cycle 1 + tracker.handleTitle('⠂ Fix login bug') + tracker.handleTitle('✳ Fix login bug') + expect(onBecameIdle).toHaveBeenCalledTimes(1) + + // Cycle 2 + tracker.handleTitle('⠐ Refactor auth module') + tracker.handleTitle('✳ Refactor auth module') + expect(onBecameIdle).toHaveBeenCalledTimes(2) + }) + + // --- Non-agent titles should not interfere --- + it('ignores non-agent titles without losing working state', () => { + const onBecameIdle = vi.fn() + const tracker = createAgentStatusTracker(onBecameIdle) + + tracker.handleTitle('⠂ Claude Code') // working + tracker.handleTitle('bash') // non-agent (returns null) — should NOT reset + tracker.handleTitle('✳ Some task description') // idle → should still fire + expect(onBecameIdle).toHaveBeenCalledTimes(1) + }) + + it('does not fire on idle → idle', () => { + const onBecameIdle = vi.fn() + const tracker = createAgentStatusTracker(onBecameIdle) + + tracker.handleTitle('✳ Claude Code') // idle + tracker.handleTitle('✳ Some other task') // still idle + expect(onBecameIdle).not.toHaveBeenCalled() + }) + + it('does not fire on working → working', () => { + const onBecameIdle = vi.fn() + const tracker = createAgentStatusTracker(onBecameIdle) + + tracker.handleTitle('⠂ Claude Code') + tracker.handleTitle('⠐ Fix the thing') + tracker.handleTitle('⠂ Fix the thing') + expect(onBecameIdle).not.toHaveBeenCalled() + }) + + // --- End-to-end: raw OSC bytes → extractLastOscTitle → tracker --- + it('end-to-end: extracts OSC title and detects Claude Code transition', () => { + const onBecameIdle = vi.fn() + const tracker = createAgentStatusTracker(onBecameIdle) + + // Simulate raw PTY data chunks containing OSC title sequences + // Uses real title patterns: task description, NOT "Claude Code" + const oscTitle = (title: string): string => `\x1b]0;${title}\x07` + + const chunks = [ + `some output${oscTitle('✳ Claude Code')}more output`, + `data${oscTitle('⠂ Claude Code')}stuff`, + `response text${oscTitle('⠐ Fix the login bug')}more`, + `final output${oscTitle('✳ Fix the login bug')}done` + ] + + for (const chunk of chunks) { + const title = extractLastOscTitle(chunk) + if (title !== null) { + tracker.handleTitle(title) + } + } + + expect(onBecameIdle).toHaveBeenCalledTimes(1) + }) + + it('end-to-end: extracts OSC title and detects Gemini transition', () => { + const onBecameIdle = vi.fn() + const tracker = createAgentStatusTracker(onBecameIdle) + + const oscTitle = (title: string): string => `\x1b]0;${title}\x07` + + const chunks = [ + oscTitle('◇ Ready (workspace)'), + oscTitle('✦ Analyzing code (workspace)'), + oscTitle('◇ Ready (workspace)') + ] + + for (const chunk of chunks) { + const title = extractLastOscTitle(chunk) + if (title !== null) { + tracker.handleTitle(title) + } + } + + expect(onBecameIdle).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 1db3198a4..206bf020e 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -1,6 +1,9 @@ export type AgentStatus = 'working' | 'permission' | 'idle' +const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) + const GEMINI_WORKING = '\u2726' // ✦ +const GEMINI_SILENT_WORKING = '\u23F2' // ⏲ const GEMINI_IDLE = '\u25C7' // ◇ const GEMINI_PERMISSION = '\u270B' // ✋ @@ -36,8 +39,9 @@ const WORKING_KEYWORDS = ['working', 'thinking', 'running'] export function clearWorkingIndicators(title: string): string { let cleaned = title - // Gemini working symbol + // Gemini working symbols cleaned = cleaned.replace(GEMINI_WORKING, '') + cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') // Braille spinner characters (U+2800–U+28FF) // eslint-disable-next-line no-control-regex -- intentional unicode range @@ -62,6 +66,29 @@ export function clearWorkingIndicators(title: string): string { return cleaned || title } +/** + * Tracks agent status transitions from terminal title changes. + * Fires `onBecameIdle` when an agent transitions from working to idle/permission, + * like haunt's attention flag — the key trigger for unread notifications. + */ +export function createAgentStatusTracker(onBecameIdle: () => void): { + handleTitle: (title: string) => void +} { + let lastStatus: AgentStatus | null = null + + return { + handleTitle(title: string): void { + const newStatus = detectAgentStatusFromTitle(title) + if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { + onBecameIdle() + } + if (newStatus !== null) { + lastStatus = newStatus + } + } + } +} + export function detectAgentStatusFromTitle(title: string): AgentStatus | null { if (!title) { return null @@ -71,13 +98,19 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { if (title.includes(GEMINI_PERMISSION)) { return 'permission' } - if (title.includes(GEMINI_WORKING)) { + if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { return 'working' } if (title.includes(GEMINI_IDLE)) { return 'idle' } + // Claude Code uses ✳ prefix for idle — must check before braille/agent-name + // because the title text is the task description, not "Claude Code". + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return 'idle' + } + if (containsBrailleSpinner(title)) { return 'working' } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 2e07b915e..016b797e1 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -19,7 +19,7 @@ export type WorktreeSlice = { ) => Promise<{ ok: true } | { ok: false; error: string }> clearWorktreeDeleteState: (worktreeId: string) => void updateWorktreeMeta: (worktreeId: string, updates: Partial) => Promise - markWorktreeUnreadFromBell: (worktreeId: string) => void + markWorktreeUnread: (worktreeId: string) => void bumpWorktreeActivity: (worktreeId: string) => void setActiveWorktree: (worktreeId: string | null) => void allWorktrees: () => Worktree[] diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 0168e1eeb..139b19a16 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -146,7 +146,7 @@ export const createWorktreeSlice: StateCreator } }, - markWorktreeUnreadFromBell: (worktreeId) => { + markWorktreeUnread: (worktreeId) => { const activeWorktreeId = get().activeWorktreeId if (activeWorktreeId === worktreeId) { return @@ -175,7 +175,7 @@ export const createWorktreeSlice: StateCreator void window.api.worktrees .updateMeta({ worktreeId, updates: { isUnread: true, lastActivityAt: now } }) .catch((err) => { - console.error('Failed to persist unread worktree bell state:', err) + console.error('Failed to persist unread worktree state:', err) void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) }) },