feat: detect agent idle via terminal title for unread notifications (#178)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neil 2026-03-28 15:40:20 -07:00 committed by GitHub
parent a43568247c
commit 5e4e4ea1db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 272 additions and 22 deletions

View File

@ -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,

View File

@ -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) => {

View File

@ -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<typeof setTimeout> | 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'

View File

@ -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, {

View File

@ -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)
})
})

View File

@ -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+2800U+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'
}

View File

@ -19,7 +19,7 @@ export type WorktreeSlice = {
) => Promise<{ ok: true } | { ok: false; error: string }>
clearWorktreeDeleteState: (worktreeId: string) => void
updateWorktreeMeta: (worktreeId: string, updates: Partial<WorktreeMeta>) => Promise<void>
markWorktreeUnreadFromBell: (worktreeId: string) => void
markWorktreeUnread: (worktreeId: string) => void
bumpWorktreeActivity: (worktreeId: string) => void
setActiveWorktree: (worktreeId: string | null) => void
allWorktrees: () => Worktree[]

View File

@ -146,7 +146,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
},
markWorktreeUnreadFromBell: (worktreeId) => {
markWorktreeUnread: (worktreeId) => {
const activeWorktreeId = get().activeWorktreeId
if (activeWorktreeId === worktreeId) {
return
@ -175,7 +175,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
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))
})
},