diff --git a/src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts new file mode 100644 index 00000000000..8d2ebcdb517 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + getCyclicProjectedWorktreeLineageIds, + getLineageRenderInfo, + getProjectedWorktreeLineageChildrenByParentId +} from './worktree-lineage-projection' + +function makeWorktree(id: string): Worktree { + return { + id, + repoId: 'repo-1', + instanceId: `${id}-instance`, + path: `/tmp/${id}`, + branch: id, + isMainWorktree: false + } as unknown as Worktree +} + +function makeLineage(childId: string, parentId: string): WorktreeLineage { + return { + worktreeId: childId, + worktreeInstanceId: `${childId}-instance`, + parentWorktreeId: parentId, + parentWorktreeInstanceId: `${parentId}-instance` + } as unknown as WorktreeLineage +} + +/** + * A sidebar-scale fixture: one root with many children, mirroring the shape the + * row builder scans on every store write. + */ +function buildFixture(childCount: number): { + lineageById: Record + worktreeMap: Map +} { + const worktreeMap = new Map() + const lineageById: Record = {} + worktreeMap.set('root', makeWorktree('root')) + for (let index = 0; index < childCount; index += 1) { + const id = `child-${index}` + worktreeMap.set(id, makeWorktree(id)) + lineageById[id] = makeLineage(id, 'root') + } + return { lineageById, worktreeMap } +} + +describe('worktree lineage projection cache', () => { + it('reuses the cyclic-id scan for an unchanged input pair', () => { + const { lineageById, worktreeMap } = buildFixture(8) + const first = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + const second = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + expect(second).toBe(first) + }) + + it('reuses the children projection for an unchanged input pair', () => { + const { lineageById, worktreeMap } = buildFixture(8) + const first = getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap) + const second = getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap) + expect(second).toBe(first) + expect(first.get('root')?.map((worktree) => worktree.id)).toEqual([ + 'child-0', + 'child-1', + 'child-2', + 'child-3', + 'child-4', + 'child-5', + 'child-6', + 'child-7' + ]) + }) + + it('rescans when either input is replaced', () => { + const { lineageById, worktreeMap } = buildFixture(4) + const baseline = getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap) + + const replacedLineage = { ...lineageById } + expect(getProjectedWorktreeLineageChildrenByParentId(replacedLineage, worktreeMap)).not.toBe( + baseline + ) + + const replacedWorktrees = new Map(worktreeMap) + expect(getProjectedWorktreeLineageChildrenByParentId(lineageById, replacedWorktrees)).not.toBe( + baseline + ) + }) + + it('reflects a removed lineage edge as soon as the record is replaced', () => { + const { lineageById, worktreeMap } = buildFixture(2) + expect( + getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap).get('root') + ).toHaveLength(2) + + const withoutFirstChild = { ...lineageById } + delete withoutFirstChild['child-0'] + const reprojected = getProjectedWorktreeLineageChildrenByParentId( + withoutFirstChild, + worktreeMap + ) + expect(reprojected.get('root')?.map((worktree) => worktree.id)).toEqual(['child-1']) + expect( + getLineageRenderInfo( + worktreeMap.get('child-0') as Worktree, + withoutFirstChild, + worktreeMap, + getCyclicProjectedWorktreeLineageIds(withoutFirstChild, worktreeMap) + ).state + ).toBe('none') + }) + + it('still reports cycles from the cached scan', () => { + const worktreeMap = new Map([ + ['a', makeWorktree('a')], + ['b', makeWorktree('b')] + ]) + const lineageById: Record = { + a: makeLineage('a', 'b'), + b: makeLineage('b', 'a') + } + const cyclic = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + expect([...cyclic].sort()).toEqual(['a', 'b']) + expect(getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)).toBe(cyclic) + expect(getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap).size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts index 337140f586c..b8748f287c5 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts @@ -22,10 +22,49 @@ export function getProjectedWorktreeLineage( return (worktree as WorktreeWithResolvedLineage).lineage } +type LineageProjection = { + cyclicLineageIds?: Set + childrenByParentId?: Map +} + +/** + * Why: both projections are O(worktrees) scans that the sidebar row builder and + * the pinned/attached-children readers re-run several times per pass, and + * zustand re-runs those on every store write. Both are pure in the two inputs, + * and both inputs are immutable store-derived collections that are REPLACED + * rather than mutated, so their identity pair is a sound cache key. Weak on both + * levels so a superseded lineage record or worktree index is not pinned. + */ +const projectionByLineageAndWorktreeMap = new WeakMap< + Readonly>, + WeakMap, LineageProjection> +>() + +function getLineageProjection( + lineageById: Readonly>, + worktreeMap: ReadonlyMap +): LineageProjection { + let byWorktreeMap = projectionByLineageAndWorktreeMap.get(lineageById) + if (!byWorktreeMap) { + byWorktreeMap = new WeakMap() + projectionByLineageAndWorktreeMap.set(lineageById, byWorktreeMap) + } + let projection = byWorktreeMap.get(worktreeMap) + if (!projection) { + projection = {} + byWorktreeMap.set(worktreeMap, projection) + } + return projection +} + export function getCyclicProjectedWorktreeLineageIds( lineageById: Readonly>, worktreeMap: ReadonlyMap ): Set { + const projection = getLineageProjection(lineageById, worktreeMap) + if (projection.cyclicLineageIds) { + return projection.cyclicLineageIds + } const validLineageByChildId = new Map() for (const worktree of worktreeMap.values()) { const lineage = getProjectedWorktreeLineage(worktree, lineageById) @@ -37,7 +76,9 @@ export function getCyclicProjectedWorktreeLineageIds( validLineageByChildId.set(worktree.id, lineage) } } - return getCyclicWorktreeLineageChildIds(validLineageByChildId) + const cyclicLineageIds = getCyclicWorktreeLineageChildIds(validLineageByChildId) + projection.cyclicLineageIds = cyclicLineageIds + return cyclicLineageIds } export function getLineageRenderInfo( @@ -65,6 +106,10 @@ export function getProjectedWorktreeLineageChildrenByParentId( lineageById: Readonly>, worktreeMap: ReadonlyMap ): Map { + const projection = getLineageProjection(lineageById, worktreeMap) + if (projection.childrenByParentId) { + return projection.childrenByParentId + } const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) const childrenByParentId = new Map() for (const worktree of worktreeMap.values()) { @@ -76,6 +121,7 @@ export function getProjectedWorktreeLineageChildrenByParentId( children.push(worktree) childrenByParentId.set(lineage.parent.id, children) } + projection.childrenByParentId = childrenByParentId return childrenByParentId } diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts index 5d2c688b7d2..3d0bdbc0feb 100644 --- a/src/shared/agent-title-core.ts +++ b/src/shared/agent-title-core.ts @@ -7,6 +7,7 @@ import { } from './agent-name-token-match' import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration' import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' +import { memoizeTitleClassification } from './terminal-title-classification-memo' import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName } @@ -54,7 +55,7 @@ export const BRAILLE_SPINNER_RE = /[\u2800-\u28ff]/g // Reserve the whole quarter-circle block so a later frame addition cannot regress this. export const QUARTER_CIRCLE_SPINNER_RE = /[\u25d0-\u25d3]/g -export function isGeminiTerminalTitle(title: string): boolean { +function computeIsGeminiTerminalTitle(title: string): boolean { // Why: Gemini OSC glyphs are stronger evidence than any cwd/session text. if ( title.includes(GEMINI_PERMISSION) || @@ -80,6 +81,11 @@ export function isGeminiTerminalTitle(title: string): boolean { return titleHasAgentName(title, 'gemini') } +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const isGeminiTerminalTitle: (title: string) => boolean = memoizeTitleClassification( + computeIsGeminiTerminalTitle +) + export function isPiTerminalTitle(title: string): boolean { return isLegacyPiCompatibleTitle(title) && !containsBrailleSpinner(title) } diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index cdfc6946777..2b5194bfda8 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -12,12 +12,13 @@ import { } from './agent-title-core' import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' +import { memoizeTitleClassification } from './terminal-title-classification-memo' /** * Returns true when the terminal title matches Claude Code's title conventions. * Used to scope prompt-cache-timer behavior to Claude sessions only. */ -export function isClaudeAgent(title: string): boolean { +function computeIsClaudeAgent(title: string): boolean { if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } @@ -43,7 +44,11 @@ export function isClaudeAgent(title: string): boolean { ) } -export function getAgentLabel(title: string): string | null { +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const isClaudeAgent: (title: string) => boolean = + memoizeTitleClassification(computeIsClaudeAgent) + +function computeAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } @@ -119,3 +124,7 @@ export function getAgentLabel(title: string): string | null { return null } + +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const getAgentLabel: (title: string) => string | null = + memoizeTitleClassification(computeAgentLabel) diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 423af759281..fa1e35652e2 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -32,6 +32,7 @@ import { import { clearPiStateWorkingMarker, getPiStateTitleStatus } from './pi-state-title-marker' import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type' +import { memoizeTitleClassification } from './terminal-title-classification-memo' /** * Strip working-status indicators so stale exit titles stop reporting working. @@ -178,7 +179,7 @@ function canonicalizeBrailleSpinnerFrame(title: string): string { return canonical } -export function detectAgentStatusFromTitle(title: string): AgentStatus | null { +function computeAgentStatusFromTitle(title: string): AgentStatus | null { if (!title || isClaudeManagementTitle(title)) { return null } @@ -262,6 +263,13 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { return 'idle' } +/** + * Pure in `title`, so it is memoized on the title string: sidebar/tab selectors + * re-ask for the same unchanged titles on every store write. + */ +export const detectAgentStatusFromTitle: (title: string) => AgentStatus | null = + memoizeTitleClassification(computeAgentStatusFromTitle) + /** * True when a quarter-circle spinner frame is the only agent evidence a title carries. * Any TUI animates those glyphs, so they prove activity, not identity — callers that diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 4a078fde877..cdbce788806 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -10,6 +10,7 @@ import { getPiCompatibleSyntheticAgentLabel, isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' +import { memoizeTitleClassification } from './terminal-title-classification-memo' import type { TuiAgent } from './tui-agent' export const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) @@ -84,7 +85,7 @@ export function isPiAgentTitle(title: string): boolean { * Used to scope prompt-cache-timer behavior to Claude sessions only — other * agents have different (or no) caching semantics. */ -export function isClaudeAgent(title: string): boolean { +function computeIsClaudeAgent(title: string): boolean { if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } @@ -121,11 +122,15 @@ export function isClaudeAgent(title: string): boolean { return false } +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const isClaudeAgent: (title: string) => boolean = + memoizeTitleClassification(computeIsClaudeAgent) + export function isClaudeManagementTitle(title: string): boolean { return CLAUDE_MANAGEMENT_TITLE_RE.test(title) } -export function getAgentLabel(title: string): string | null { +function computeAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } @@ -235,6 +240,10 @@ const TITLE_LABEL_TO_AGENT: Partial> = { OMP: 'omp' } +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const getAgentLabel: (title: string) => string | null = + memoizeTitleClassification(computeAgentLabel) + function hasGenericClaudeStatusPrefix(title: string): boolean { return ( containsAgentSpinnerGlyph(title) || @@ -266,10 +275,14 @@ export function resolveTerminalTitleAgentType(title: string): TuiAgent | null { * that something is running, not proof the agent is Claude — so a task or * worktree title cannot become Claude without an explicit "Claude Code" name. */ -export function resolveExplicitTerminalTitleAgentType(title: string): TuiAgent | null { +function computeExplicitTerminalTitleAgentType(title: string): TuiAgent | null { const titleAgent = resolveTerminalTitleAgentType(title) if (isGenericClaudeStatusClaim(title, titleAgent)) { return null } return titleAgent } + +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const resolveExplicitTerminalTitleAgentType: (title: string) => TuiAgent | null = + memoizeTitleClassification(computeExplicitTerminalTitleAgentType) diff --git a/src/shared/terminal-title-classification-corpus.test.ts b/src/shared/terminal-title-classification-corpus.test.ts new file mode 100644 index 00000000000..ae41d451b20 --- /dev/null +++ b/src/shared/terminal-title-classification-corpus.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from 'vitest' +import { isGeminiTerminalTitle } from './agent-title-core' +import { getAgentLabel, isClaudeAgent } from './agent-title-identity' +import { detectAgentStatusFromTitle } from './agent-title-status' +import { TERMINAL_TITLE_CLASSIFICATION_CORPUS } from './terminal-title-classification-corpus' +import { + getAgentLabel as getExplicitAgentLabel, + isClaudeAgent as isExplicitClaudeAgent, + resolveExplicitTerminalTitleAgentType, + resolveTerminalTitleAgentType +} from './terminal-title-agent-type' + +/** + * Pins the exact verdict every title classifier returns for a realistic corpus. + * + * Why: these classifiers are now memoized on the title string, and a caching bug + * here would repaint a pane under the wrong agent. This table is the proof that + * memoization is transparent — it was generated from the pre-memo implementation + * and must keep matching byte-for-byte. + */ +type PinnedRow = [ + title: string, + status: string | null, + label: string | null, + claude: boolean, + gemini: boolean, + explicitLabel: string | null, + explicitClaude: boolean, + titleAgent: string | null, + explicitTitleAgent: string | null +] + +const PINNED_CLASSIFICATIONS: readonly PinnedRow[] = [ + ['', null, null, false, false, null, false, null, null], + ['zsh', null, null, false, false, null, false, null, null], + ['bash', null, null, false, false, null, false, null, null], + ['nwparker@mac: ~/orca', null, null, false, false, null, false, null, null], + ['npm run dev', null, null, false, false, null, false, null, null], + ['opencode-blinker', null, null, false, false, null, false, null, null], + [ + 'openclaude', + 'idle', + 'OpenClaude', + false, + false, + 'OpenClaude', + false, + 'openclaude', + 'openclaude' + ], + ['openclaude-scratch', null, null, false, false, null, false, null, null], + ['claude-scratch', null, null, false, false, null, false, null, null], + ['~/codex/ready', null, null, false, false, null, false, null, null], + ['review-14600-codex', null, null, false, false, null, false, null, null], + ['timestamp ready', null, null, false, false, null, false, null, null], + ['android build running', null, null, false, false, null, false, null, null], + ['~/hermes/working', null, null, false, false, null, false, null, null], + ['C:\\tools\\codex\\run', null, null, false, false, null, false, null, null], + ['/usr/local/bin/claude/notes', null, null, false, false, null, false, null, null], + ['agy-nightly', null, null, false, false, null, false, null, null], + ['codex.exe', 'idle', 'Codex', false, false, 'Codex', false, 'codex', 'codex'], + [ + 'openclaude.cmd', + 'idle', + 'OpenClaude', + false, + false, + 'OpenClaude', + false, + 'openclaude', + 'openclaude' + ], + [ + 'claude.bat working', + 'working', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + ['aider.ps1 ready', 'idle', 'Aider', false, false, 'Aider', false, 'aider', 'aider'], + [ + 'copilot.exe - action required', + 'permission', + 'GitHub Copilot', + false, + false, + 'GitHub Copilot', + false, + 'copilot', + 'copilot' + ], + ['droid.exe', null, null, false, false, null, false, null, null], + ['\u2733', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + [ + '\u2733 Claude Code', + 'idle', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + ['\u2733 ready', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['. building the parser', null, 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['* done', null, 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['Claude Code', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', 'claude'], + [ + 'claude - action required', + 'permission', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + ['Claude ready', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', 'claude'], + ['claude agents', null, null, false, false, null, false, null, null], + ['"/usr/local/bin/claude" agents', null, null, false, false, null, false, null, null], + [ + '\u280b Claude Code', + 'working', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + [ + '\u2809 Codex \u2014 refactoring', + 'working', + 'Codex', + true, + false, + 'Codex', + true, + 'codex', + 'codex' + ], + ['\u25d0 working', 'working', 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['\u25d3 Grok', 'working', 'Grok', true, false, 'Grok', true, 'grok', 'grok'], + ['\u280b Cursor Agent', 'working', 'Cursor', false, false, 'Cursor', false, 'cursor', 'cursor'], + ['\u280b Droid', 'working', 'Droid', true, false, 'Droid', true, 'droid', 'droid'], + ['\u280b Hermes', 'working', 'Hermes', true, false, 'Hermes', true, 'hermes', 'hermes'], + ['\u2726 gemini', 'working', 'Gemini CLI', false, true, 'Gemini CLI', false, 'gemini', 'gemini'], + [ + '\u23f2 Gemini CLI', + 'working', + 'Gemini CLI', + false, + true, + 'Gemini CLI', + false, + 'gemini', + 'gemini' + ], + ['\u25c7 Gemini CLI', 'idle', 'Gemini CLI', false, true, 'Gemini CLI', false, 'gemini', 'gemini'], + [ + '\u270b Gemini CLI', + 'permission', + 'Gemini CLI', + false, + true, + 'Gemini CLI', + false, + 'gemini', + 'gemini' + ], + ['gemini', 'idle', 'Gemini CLI', false, true, 'Gemini CLI', false, 'gemini', 'gemini'], + [ + 'antigravity gemini 3 pro', + 'idle', + 'Antigravity', + false, + false, + 'Antigravity', + false, + 'antigravity', + 'antigravity' + ], + [ + 'agy - gemini 2 flash', + 'idle', + 'Antigravity', + false, + false, + 'Antigravity', + false, + 'antigravity', + 'antigravity' + ], + ['codex working', 'working', 'Codex', false, false, 'Codex', false, 'codex', 'codex'], + ['codex ready', 'idle', 'Codex', false, false, 'Codex', false, 'codex', 'codex'], + [ + 'copilot waiting', + 'permission', + 'GitHub Copilot', + false, + false, + 'GitHub Copilot', + false, + 'copilot', + 'copilot' + ], + ['devin thinking', 'working', 'Devin', false, false, 'Devin', false, 'devin', 'devin'], + ['mimo idle', 'idle', 'MiMo Code', false, false, 'MiMo Code', false, 'mimo-code', 'mimo-code'], + ['aider running', 'working', 'Aider', false, false, 'Aider', false, 'aider', 'aider'], + ['grok done', 'idle', 'Grok', false, false, 'Grok', false, 'grok', 'grok'], + ['opencode ready', 'idle', 'OpenCode', false, false, 'OpenCode', false, 'opencode', 'opencode'], + ['hermes ready', 'idle', 'Hermes', false, false, 'Hermes', false, 'hermes', 'hermes'], + ['droid ready', 'idle', 'Droid', false, false, 'Droid', false, 'droid', 'droid'], + ['cursor agent', null, 'Cursor', false, false, 'Cursor', false, 'cursor', 'cursor'], + ['cursor ready', 'idle', 'Cursor', false, false, 'Cursor', false, 'cursor', 'cursor'], + [ + 'cursor - action required', + 'permission', + 'Cursor', + false, + false, + 'Cursor', + false, + 'cursor', + 'cursor' + ], + ['cursor position reset', 'idle', null, false, false, null, false, null, null], + ['\u03c0 > session - ~/orca', 'idle', 'Pi', false, false, 'Pi', false, 'pi', 'pi'], + ['\u03c0 ! blocked-session', 'permission', 'Pi', false, false, 'Pi', false, 'pi', 'pi'], + ['\u280b \u03c0 - session - ~/orca', 'working', 'Pi', true, false, 'Pi', true, 'pi', 'pi'], + ['zsh | \u280b Codex', 'working', 'Codex', true, false, 'Codex', true, 'codex', 'codex'], + ['tmux | claude - action required', 'permission', null, false, false, null, false, null, null], + [ + 'ssh host | opencode ready', + 'idle', + 'OpenCode', + false, + false, + 'OpenCode', + false, + 'opencode', + 'opencode' + ] +] + +describe('terminal title classification', () => { + it('covers every corpus title exactly once', () => { + expect(PINNED_CLASSIFICATIONS.map(([title]) => title)).toEqual([ + ...TERMINAL_TITLE_CLASSIFICATION_CORPUS + ]) + }) + + it.each(PINNED_CLASSIFICATIONS)( + 'classifies %j identically', + ( + title, + status, + label, + claude, + gemini, + explicitLabel, + explicitClaude, + titleAgent, + explicitTitleAgent + ) => { + expect(detectAgentStatusFromTitle(title)).toBe(status) + expect(getAgentLabel(title)).toBe(label) + expect(isClaudeAgent(title)).toBe(claude) + expect(isGeminiTerminalTitle(title)).toBe(gemini) + expect(getExplicitAgentLabel(title)).toBe(explicitLabel) + expect(isExplicitClaudeAgent(title)).toBe(explicitClaude) + expect(resolveTerminalTitleAgentType(title)).toBe(titleAgent) + expect(resolveExplicitTerminalTitleAgentType(title)).toBe(explicitTitleAgent) + } + ) + + it('returns the same verdict on the second read of every title', () => { + for (const title of TERMINAL_TITLE_CLASSIFICATION_CORPUS) { + expect(detectAgentStatusFromTitle(title)).toBe(detectAgentStatusFromTitle(title)) + expect(getAgentLabel(title)).toBe(getAgentLabel(title)) + expect(resolveExplicitTerminalTitleAgentType(title)).toBe( + resolveExplicitTerminalTitleAgentType(title) + ) + } + }) +}) diff --git a/src/shared/terminal-title-classification-corpus.ts b/src/shared/terminal-title-classification-corpus.ts new file mode 100644 index 00000000000..e1eb6e833a7 --- /dev/null +++ b/src/shared/terminal-title-classification-corpus.ts @@ -0,0 +1,86 @@ +/** + * Realistic terminal-title corpus for pinning agent classification. + * + * Why a shared const: the corpus is the contract the memoized classifiers must + * reproduce byte-for-byte, so the pinning test and the memo regression test + * read the same titles. + */ +export const TERMINAL_TITLE_CLASSIFICATION_CORPUS: readonly string[] = [ + // Plain shell / directory titles — must classify as nothing. + '', + 'zsh', + 'bash', + 'nwparker@mac: ~/orca', + 'npm run dev', + // Boundary-guard cases from agent-name-token-match.ts's header comment. + 'opencode-blinker', + 'openclaude', + 'openclaude-scratch', + 'claude-scratch', + '~/codex/ready', + 'review-14600-codex', + 'timestamp ready', + 'android build running', + '~/hermes/working', + 'C:\\tools\\codex\\run', + '/usr/local/bin/claude/notes', + 'agy-nightly', + // Windows launcher suffixes. + 'codex.exe', + 'openclaude.cmd', + 'claude.bat working', + 'aider.ps1 ready', + 'copilot.exe - action required', + 'droid.exe', + // Claude Code prefixes and identity frames. + '\u2733', + '\u2733 Claude Code', + '\u2733 ready', + '. building the parser', + '* done', + 'Claude Code', + 'claude - action required', + 'Claude ready', + 'claude agents', + '"/usr/local/bin/claude" agents', + // Leading spinner glyphs (braille + quarter circle). + '\u280b Claude Code', + '\u2809 Codex \u2014 refactoring', + '\u25d0 working', + '\u25d3 Grok', + '\u280b Cursor Agent', + '\u280b Droid', + '\u280b Hermes', + // Gemini glyph vocabulary. + '\u2726 gemini', + '\u23f2 Gemini CLI', + '\u25c7 Gemini CLI', + '\u270b Gemini CLI', + 'gemini', + 'antigravity gemini 3 pro', + 'agy - gemini 2 flash', + // Named agents with status words. + 'codex working', + 'codex ready', + 'copilot waiting', + 'devin thinking', + 'mimo idle', + 'aider running', + 'grok done', + 'opencode ready', + 'hermes ready', + 'droid ready', + // Cursor's closed identity set. + 'cursor agent', + 'cursor ready', + 'cursor - action required', + 'cursor position reset', + // Pi / OMP compatible titles. + '\u03c0 > session - ~/orca', + '\u03c0 ! blocked-session', + '\u280b \u03c0 - session - ~/orca', + // Wrapper/multiplexer prefixes. + 'zsh | \u280b Codex', + 'tmux | claude - action required', + 'ssh host | opencode ready' +] diff --git a/src/shared/terminal-title-classification-memo.test.ts b/src/shared/terminal-title-classification-memo.test.ts new file mode 100644 index 00000000000..e2606a58291 --- /dev/null +++ b/src/shared/terminal-title-classification-memo.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as AgentNameTokenMatchModule from './agent-name-token-match' +import { getAgentLabel } from './agent-title-identity' +import { detectAgentStatusFromTitle } from './agent-title-status' +import { memoizeTitleClassification } from './terminal-title-classification-memo' +import { resolveExplicitTerminalTitleAgentType } from './terminal-title-agent-type' + +// Why a module mock: `titleHasAgentName` is the leaf regex test every title +// classifier funnels into, so counting its invocations is the direct measure of +// what one store write costs when no title has changed. +vi.mock('./agent-name-token-match', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, titleHasAgentName: vi.fn(actual.titleHasAgentName) } +}) + +const classifierCalls = vi.mocked(AgentNameTokenMatchModule.titleHasAgentName) + +// Titles a real sidebar holds steady while unrelated agent-status writes churn. +const UNCHANGED_TITLES = [ + 'codex working', + 'opencode-blinker', + 'zsh', + '✳ Claude Code', + 'copilot.exe - action required', + 'gemini', + 'cursor agent' +] +const STORE_WRITES = 50 + +function classifyEveryTitle(): void { + for (const title of UNCHANGED_TITLES) { + getAgentLabel(title) + detectAgentStatusFromTitle(title) + resolveExplicitTerminalTitleAgentType(title) + } +} + +describe('terminal title classification memo', () => { + beforeEach(() => { + classifierCalls.mockClear() + }) + + it('classifies each distinct title once across repeated store writes', () => { + // Warm the caches the way the first render would, then measure steady state. + classifyEveryTitle() + classifierCalls.mockClear() + + for (let write = 0; write < STORE_WRITES; write += 1) { + classifyEveryTitle() + } + + // Unmemoized this is STORE_WRITES x titles x the whole regex ladder — 4,350 + // leaf matches for this fixture. Memoized, an unchanged title costs nothing. + expect(classifierCalls).not.toHaveBeenCalled() + }) + + it('classifies a title once no matter how many readers ask', () => { + const title = 'aider running' + getAgentLabel(title) + const firstReadCalls = classifierCalls.mock.calls.length + expect(firstReadCalls).toBeGreaterThan(0) + + for (let read = 0; read < 20; read += 1) { + getAgentLabel(title) + } + expect(classifierCalls.mock.calls.length).toBe(firstReadCalls) + }) + + it('reclassifies as soon as the title changes', () => { + expect(getAgentLabel('codex ready')).toBe('Codex') + expect(getAgentLabel('grok ready')).toBe('Grok') + expect(detectAgentStatusFromTitle('codex ready')).toBe('idle') + expect(detectAgentStatusFromTitle('codex working')).toBe('working') + }) + + it('caches null and false verdicts, not just truthy ones', () => { + const classify = vi.fn((): string | null => null) + const memoized = memoizeTitleClassification(classify) + expect(memoized('zsh')).toBeNull() + expect(memoized('zsh')).toBeNull() + expect(classify).toHaveBeenCalledTimes(1) + }) + + it('evicts oldest entries instead of growing without bound', () => { + const classify = vi.fn((title: string) => title.length) + const memoized = memoizeTitleClassification(classify) + // Cap is 1024; overflow it and confirm the newest key still hits while the + // oldest was evicted. + for (let index = 0; index < 1030; index += 1) { + memoized(`title-${index}`) + } + const afterFill = classify.mock.calls.length + memoized('title-1029') + expect(classify.mock.calls.length).toBe(afterFill) + memoized('title-0') + expect(classify.mock.calls.length).toBe(afterFill + 1) + }) +}) diff --git a/src/shared/terminal-title-classification-memo.ts b/src/shared/terminal-title-classification-memo.ts new file mode 100644 index 00000000000..c712ca65c96 --- /dev/null +++ b/src/shared/terminal-title-classification-memo.ts @@ -0,0 +1,43 @@ +/** + * Bounded memo for pure `(title: string) => T` terminal-title classifiers. + * + * Why: the sidebar cards and tab strip re-derive agent identity/status from + * every pane title inside zustand selectors and render bodies, so an UNCHANGED + * title was re-tested against every agent-name regex on every store write — + * thousands of classifications per second while the app sat idle. Every + * classifier below depends on nothing but the title string, so the verdict is + * reusable until the title itself changes; a new title is simply a new key, so + * there is no staleness window and no invalidation signal to miss. + */ + +/** + * Cap: comfortably above the live working set (one title per open pane plus + * retained rows) so steady-state hit rate stays ~100%, small enough that the + * map cannot grow with session length. Entries hold a reference to a string the + * store already retains, so the marginal cost is the map entry itself. + */ +const MAX_MEMOIZED_TITLES = 1024 + +export function memoizeTitleClassification( + classify: (title: string) => T +): (title: string) => T { + // Boxed values so `undefined`/`null` verdicts are still cache hits. + const cache = new Map() + return (title: string): T => { + const cached = cache.get(title) + if (cached) { + return cached.value + } + const value = classify(title) + // Insertion-ordered FIFO eviction: a pane's superseded title frames are the + // oldest keys and the least likely to be asked for again. + if (cache.size >= MAX_MEMOIZED_TITLES) { + const oldest = cache.keys().next() + if (!oldest.done) { + cache.delete(oldest.value) + } + } + cache.set(title, { value }) + return value + } +}