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:
parent
a43568247c
commit
5e4e4ea1db
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue