mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user