perf(sidebar,terminal): memoize terminal-title agent classification and lineage projections (#18148)

Idle-app CPU profiling showed `titleHasAgentName` running 11,771x/sec and the
legacy any-agent regex 4,399x/sec, roughly once per zustand subscriber notify.
The regexes were already precompiled; the problem was call volume — every store
write re-classified every unchanged pane title through the whole agent-name
ladder.

Every title classifier is pure in the title string, so memoize them on it
(bounded FIFO, 1024 entries). A new title is a new key, so there is no staleness
window. The same profile showed the sidebar lineage projection re-scanning all
worktrees several times per pass; cache it on the identity pair of its two
immutable inputs, mirroring store/worktree-repo-index.ts.
This commit is contained in:
Neil
2026-09-02 12:59:46 -07:00
committed by GitHub
parent f03043544a
commit c0e5b189aa
10 changed files with 736 additions and 8 deletions
@@ -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<string, WorktreeLineage>
worktreeMap: Map<string, Worktree>
} {
const worktreeMap = new Map<string, Worktree>()
const lineageById: Record<string, WorktreeLineage> = {}
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<string, Worktree>([
['a', makeWorktree('a')],
['b', makeWorktree('b')]
])
const lineageById: Record<string, WorktreeLineage> = {
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)
})
})
@@ -22,10 +22,49 @@ export function getProjectedWorktreeLineage(
return (worktree as WorktreeWithResolvedLineage).lineage
}
type LineageProjection = {
cyclicLineageIds?: Set<string>
childrenByParentId?: Map<string, Worktree[]>
}
/**
* 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<Record<string, WorktreeLineage>>,
WeakMap<ReadonlyMap<string, Worktree>, LineageProjection>
>()
function getLineageProjection(
lineageById: Readonly<Record<string, WorktreeLineage>>,
worktreeMap: ReadonlyMap<string, Worktree>
): 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<Record<string, WorktreeLineage>>,
worktreeMap: ReadonlyMap<string, Worktree>
): Set<string> {
const projection = getLineageProjection(lineageById, worktreeMap)
if (projection.cyclicLineageIds) {
return projection.cyclicLineageIds
}
const validLineageByChildId = new Map<string, WorktreeLineage>()
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<Record<string, WorktreeLineage>>,
worktreeMap: ReadonlyMap<string, Worktree>
): Map<string, Worktree[]> {
const projection = getLineageProjection(lineageById, worktreeMap)
if (projection.childrenByParentId) {
return projection.childrenByParentId
}
const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)
const childrenByParentId = new Map<string, Worktree[]>()
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
}
+7 -1
View File
@@ -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)
}
+11 -2
View File
@@ -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)
+9 -1
View File
@@ -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
+16 -3
View File
@@ -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<Record<string, TuiAgent>> = {
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)
@@ -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)
)
}
})
})
@@ -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'
]
@@ -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<typeof AgentNameTokenMatchModule>()
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)
})
})
@@ -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<T>(
classify: (title: string) => T
): (title: string) => T {
// Boxed values so `undefined`/`null` verdicts are still cache hits.
const cache = new Map<string, { value: T }>()
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
}
}