diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts
index a767a61ccf4b..e4c901ee108b 100644
--- a/src/renderer/src/components/AgentStateDot.test.ts
+++ b/src/renderer/src/components/AgentStateDot.test.ts
@@ -96,6 +96,15 @@ describe('AgentStateDot', () => {
expect(markup).not.toContain('data-agent-spinner')
})
+ it('renders unreported as a muted dashed ring, never the amber lost-contact ring', () => {
+ const markup = renderMarkup('unreported')
+
+ expect(markup).toContain('lucide-circle-dashed')
+ expect(markup).toContain('text-muted-foreground')
+ expect(markup).not.toContain('text-amber-500')
+ expect(markup).toContain('aria-label="No status reported"')
+ })
+
it.each(['blocked', 'interrupted'] satisfies AgentDotState[])(
'renders %s as a red attention dot',
(state) => {
@@ -116,6 +125,7 @@ describe('AgentStateDot', () => {
'done',
'idle',
'unverifiable',
+ 'unreported',
'permission'
] satisfies AgentDotState[]
diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx
index af8ac4efd843..928ffef01dbe 100644
--- a/src/renderer/src/components/AgentStateDot.tsx
+++ b/src/renderer/src/components/AgentStateDot.tsx
@@ -3,6 +3,7 @@ import { Activity, CircleCheck, CircleDashed } from 'lucide-react'
import { cn } from '@/lib/utils'
import { AgentQuestionIcon } from '@/components/AgentQuestionIcon'
import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner'
+import { agentNoStatusReportedLabel } from '@/lib/agent-row-decay-state'
import {
StateIndicatorTooltip,
type StateIndicatorTooltipSide
@@ -35,6 +36,9 @@ export type AgentDotState =
// held there, and never rendered as 'done' or 'working' — it asserts nothing about
// the agent, only about what Orca last heard.
| 'unverifiable'
+ // Why: the title names the agent and nothing says what it is doing. Not 'idle' (no rest
+ // evidence) and not 'unverifiable' (Orca never heard a status, so none went quiet).
+ | 'unreported'
// Why: the sidebar's title-based status flow (StatusIndicator/WorktreeCard)
// collapses blocked + waiting into a single "needs attention" state. Keep
// this as a distinct member so that flow can render without inventing a new
@@ -63,6 +67,8 @@ export function agentStateLabel(state: AgentDotState): string {
return 'Idle'
case 'unverifiable':
return 'No recent update'
+ case 'unreported':
+ return agentNoStatusReportedLabel()
case 'permission':
return 'Needs attention'
}
@@ -134,6 +140,16 @@ export const AgentStateDot = React.memo(function AgentStateDot({
)
+ } else if (state === 'unreported') {
+ // Why: the same "incomplete information" ring, muted: nothing went wrong, so no warning.
+ indicator = (
+
+
+
+ )
} else if (state === 'permission' || state === 'waiting') {
indicator = (
= {
monitoring: 6,
unverifiable: 7,
done: 8,
- idle: 9
+ unreported: 9,
+ idle: 10
}
function activityStatusRank(thread: AgentPaneThread): number {
diff --git a/src/renderer/src/components/activity/activity-thread-presentation.ts b/src/renderer/src/components/activity/activity-thread-presentation.ts
index e38c802f867a..80ab65bc8ec9 100644
--- a/src/renderer/src/components/activity/activity-thread-presentation.ts
+++ b/src/renderer/src/components/activity/activity-thread-presentation.ts
@@ -2,6 +2,7 @@ import type { AgentDotState } from '@/components/AgentStateDot'
import { formatAgentTypeLabel } from '@/lib/agent-status'
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview'
+import { agentNoStatusReportedLabel } from '@/lib/agent-row-decay-state'
import {
agentMainAgentVerdict,
agentVerdictDisplayMark
@@ -205,6 +206,8 @@ export function threadAgentStateLabel(thread: AgentPaneThread): string {
'auto.components.activity.ActivityPrototypePage.state.unverifiable',
'No recent update'
)
+ case 'unreported':
+ return agentNoStatusReportedLabel()
case 'permission':
return translate(
'auto.components.activity.ActivityPrototypePage.state.permission',
diff --git a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts
index 0b4377ac9aa0..49eb8457a5f2 100644
--- a/src/renderer/src/components/dashboard/dashboard-row-bucket.ts
+++ b/src/renderer/src/components/dashboard/dashboard-row-bucket.ts
@@ -10,13 +10,13 @@ import type { AgentRowState } from '@/lib/agent-row-decay-state'
/**
* Project a row state onto the published card vocabulary.
*
- * `unverifiable` stays renderer-local: `DashboardCardDotState` is validated against a fixed
- * allowlist in main (`dashboard-payload-validation.ts`) and read by pop-out windows that may
- * predate the member, so a new value would be dropped rather than rendered. Publishing today's
+ * `unverifiable` and `unreported` stay renderer-local: `DashboardCardDotState` is validated
+ * against a fixed allowlist in main (`dashboard-payload-validation.ts`) and read by pop-out
+ * windows that may predate the member, so a new value would be dropped rather than rendered. Publishing today's
* `idle` keeps those surfaces at today's behavior instead of silently losing the card.
*/
export function dashboardCardDotState(state: AgentRowState): DashboardCardDotState {
- return state === 'unverifiable' ? 'idle' : state
+ return state === 'unverifiable' || state === 'unreported' ? 'idle' : state
}
export type DashboardRowBucketProjection = {
diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx
index 1459e7f1bb30..c29a55e7469e 100644
--- a/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx
+++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.acknowledgement-emphasis.test.tsx
@@ -68,7 +68,8 @@ function agentRow(
entry: {
paneKey,
worktreeId: WORKSPACE,
- state: state === 'idle' || state === 'unverifiable' ? 'done' : state,
+ state:
+ state === 'idle' || state === 'unverifiable' || state === 'unreported' ? 'done' : state,
prompt: paneKey === PANE_A ? 'Review agent A' : 'Review agent B',
lastAssistantMessage: 'Result details',
stateStartedAt: 2000,
diff --git a/src/renderer/src/components/sidebar/smart-attention-bare-agent-name.test.ts b/src/renderer/src/components/sidebar/smart-attention-bare-agent-name.test.ts
new file mode 100644
index 000000000000..7c0593931788
--- /dev/null
+++ b/src/renderer/src/components/sidebar/smart-attention-bare-agent-name.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import { IDLE, buildAttentionByWorktree, resolveAttention } from './smart-attention'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import type { Worktree } from '../../../../shared/worktree/types'
+
+const NOW = new Date('2026-03-27T12:00:00.000Z').getTime()
+
+const WORKTREE: Worktree = {
+ id: 'wt-1',
+ repoId: 'repo-1',
+ path: '/tmp/wt-1',
+ branch: 'refs/heads/wt-1',
+ head: 'abc',
+ isBare: false,
+ isMainWorktree: false,
+ linkedIssue: null,
+ linkedPR: null,
+ linkedLinearIssue: null,
+ isArchived: false,
+ comment: '',
+ isUnread: false,
+ isPinned: false,
+ displayName: 'wt-1',
+ sortOrder: 0,
+ lastActivityAt: 0
+}
+
+const TAB: TerminalTab = {
+ id: 'tab-1',
+ ptyId: 'pty',
+ worktreeId: WORKTREE.id,
+ title: 'bash',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 0
+}
+
+function attentionForTitle(title: string) {
+ return buildAttentionByWorktree(
+ [WORKTREE],
+ { [WORKTREE.id]: [TAB] },
+ {},
+ { [TAB.id]: { 1: title } },
+ { [TAB.id]: ['pty-1'] },
+ NOW
+ ).get(WORKTREE.id)
+}
+
+// Regression guards, hand-built titles: on main a bare name read idle, which ranked the same.
+describe('smart attention for a title that only names its agent', () => {
+ it('ranks the pane with idle, never as attention', () => {
+ for (const title of ['agy', 'grok', 'OpenCode']) {
+ expect(attentionForTitle(title)).toEqual(IDLE)
+ }
+ // Positive control: the same pane with a spinner reaches the working class.
+ expect(attentionForTitle('⠋ agy')?.cls).toBe(3)
+ })
+
+ it('lets an unreported title pane contribute nothing', () => {
+ expect(
+ resolveAttention(
+ [{ kind: 'title', status: 'unreported', worktreeLastActivityAt: NOW - 1_000 }],
+ NOW
+ )
+ ).toEqual(IDLE)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts
index 5130efe9379a..e70473459659 100644
--- a/src/renderer/src/components/sidebar/smart-attention.ts
+++ b/src/renderer/src/components/sidebar/smart-attention.ts
@@ -1,4 +1,6 @@
-import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
+import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
+import { readAgentTitleActivity } from '../../../../shared/agent-title-status'
+import type { AgentTitleActivity } from '../../../../shared/agent-title-core'
import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time'
import { agentTurnStoppedByUser } from '../../../../shared/agent-main-agent-verdict'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
@@ -6,7 +8,6 @@ import { resolveDecayedAgentRowState } from '@/lib/agent-row-decay-state'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { isSyntheticAgentPermissionTitle } from '../../../../shared/synthetic-agent-title'
import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id'
-import type { AgentStatus } from '../../../../shared/agent-detection'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
import {
@@ -114,7 +115,7 @@ export type PaneInput =
// pane's PTY — losing the reporting stream is not the same as nothing running there.
| { kind: 'hook'; entry: AgentStatusEntry; hasLivePty: boolean }
// Why: TerminalTab has no per-tab lastActivityAt; the worktree-level value suffices for cross-worktree ordering.
- | { kind: 'title'; status: AgentStatus | null; worktreeLastActivityAt: number }
+ | { kind: 'title'; status: AgentTitleActivity | null; worktreeLastActivityAt: number }
/**
* Resolve a worktree's class + attention timestamp from its panes' inputs.
@@ -199,7 +200,7 @@ export function resolveAttention(panes: PaneInput[], now: number): WorktreeAtten
cls = 3
ts = pane.worktreeLastActivityAt
} else {
- // 'idle' or null: nothing to assert; pane stays in Class 4.
+ // 'idle', 'unreported' (a bare agent name asserts no activity) or null: never attention.
continue
}
}
@@ -333,7 +334,7 @@ export function collectTabPaneInputs(
// Why: unmounted tabs (restored-but-unvisited) expose only the legacy tab title.
panes.push({
kind: 'title',
- status: classifyTitleActivity(tab.title),
+ status: readAgentTitleActivity(tab.title),
worktreeLastActivityAt
})
}
@@ -353,7 +354,7 @@ export function collectTabPaneInputs(
if ((leafId !== null && coveredLeafIds.has(leafId)) || hasSingleUnmappedHook) {
continue
}
- panes.push({ kind: 'title', status: classifyTitleActivity(title), worktreeLastActivityAt })
+ panes.push({ kind: 'title', status: readAgentTitleActivity(title), worktreeLastActivityAt })
}
return panes
}
diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts
index 76fe81307e90..6e2791945820 100644
--- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts
+++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts
@@ -19,6 +19,8 @@ const SUMMARY_STATE_ORDER: AgentDotState[] = [
'done',
// Why: below every reporting state, above true idle — the pane is still held.
'unverifiable',
+ // Why: sorts with idle; it asserts no activity, so it never outranks a reporting state.
+ 'unreported',
'idle'
]
@@ -46,6 +48,8 @@ export function formatSummaryStateLabel(state: AgentDotState): string {
return 'idle'
case 'unverifiable':
return 'not reporting'
+ case 'unreported':
+ return 'no status reported'
case 'permission':
return 'needs attention'
}
diff --git a/src/renderer/src/components/sidebar/worktree-card-unreported-agent-row.test.ts b/src/renderer/src/components/sidebar/worktree-card-unreported-agent-row.test.ts
new file mode 100644
index 000000000000..2f2b3433d125
--- /dev/null
+++ b/src/renderer/src/components/sidebar/worktree-card-unreported-agent-row.test.ts
@@ -0,0 +1,81 @@
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+import { TooltipProvider } from '@/components/ui/tooltip'
+import { dashboardRowBucketProjection } from '@/components/dashboard/dashboard-row-bucket'
+import type { TerminalTab } from '../../../../shared/terminal-tab-types'
+import { buildWorktreeAgentRows } from './worktree-agent-rows'
+import { CompactAgentRow } from './worktree-card-compact-agent-row'
+import { summarizeAgents } from './worktree-card-agent-summary'
+
+const LEAF_ID = '77777777-7777-4777-8777-777777777777'
+
+function makeTab(id: string): TerminalTab {
+ return {
+ id,
+ worktreeId: 'wt-1',
+ ptyId: null,
+ title: 'Terminal',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: 0
+ }
+}
+
+// Hand-built titles: the sidebar row for a pane whose title is `title` and that has no hook row.
+function titleRow(title: string, tabId = 'tab-1') {
+ const [row] = buildWorktreeAgentRows({
+ tabs: [makeTab(tabId)],
+ entries: [],
+ retained: [],
+ runtimePaneTitlesByTabId: { [tabId]: { 1: title } },
+ ptyIdsByTabId: { [tabId]: [`pty-${tabId}`] },
+ terminalLayoutsByTabId: {
+ [tabId]: {
+ root: { type: 'leaf', leafId: LEAF_ID },
+ activeLeafId: LEAF_ID,
+ expandedLeafId: null
+ }
+ },
+ now: 2000
+ })
+ expect(row).toBeDefined()
+ return row
+}
+
+describe('a sidebar row whose title only names the agent', () => {
+ it('reads "No status reported" beside a muted dashed ring, not Idle', () => {
+ const markup = renderToStaticMarkup(
+ createElement(
+ TooltipProvider,
+ null,
+ createElement(CompactAgentRow, { agent: titleRow('agy'), now: 2000, onActivate: vi.fn() })
+ )
+ )
+
+ expect(markup).toContain('No status reported')
+ expect(markup).toContain('aria-label="No status reported"')
+ expect(markup).toContain('lucide-circle-dashed')
+ expect(markup).toContain('text-muted-foreground')
+ expect(markup).not.toContain('text-amber-500')
+ expect(markup).not.toContain('>Idle<')
+ })
+
+ it('names the state with the same phrase in the worktree summary, ranked just above idle', () => {
+ const unreported = titleRow('grok', 'tab-1')
+ const idle = titleRow('agy ready', 'tab-2')
+
+ expect(summarizeAgents([idle, unreported], 'Agents')).toBe(
+ 'Agents: 1 no status reported, 1 idle'
+ )
+ })
+
+ it('publishes idle to the dashboard card, whose validator drops unknown states', () => {
+ const unreported = dashboardRowBucketProjection(titleRow('aider'))
+ const idle = dashboardRowBucketProjection(titleRow('aider ready'))
+
+ expect(unreported.dotState).toBe('idle')
+ expect(unreported).toEqual(idle)
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
index 729038d46ef2..b2fae9ad50cd 100644
--- a/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
+++ b/src/renderer/src/components/sidebar/worktree-subagent-child-rows.ts
@@ -23,7 +23,9 @@ function subagentRowKey(parentPaneKey: string, subagentId: string): string {
}
/** The lifecycle word every reader of a CLI row already understands; the row's own dot says more. */
-function agentRowStateFor(displayState: AgentChildDisplayState): AgentRowState {
+function agentRowStateFor(
+ displayState: AgentChildDisplayState
+): Exclude {
const runState = agentChildRunStateFor(displayState)
// A CLI row carries monitoring as `working` plus its `workingMode`.
return runState === 'monitoring' ? 'working' : runState
diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts
index e609974512d5..7c4bc7ba6b4b 100644
--- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts
@@ -1,3 +1,5 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
@@ -5,6 +7,8 @@ import type { TuiAgent } from '../../../../shared/tui-agent'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { buildWorktreeAgentRows } from './worktree-agent-rows'
import type { TitleDerivedPaneForeground } from './title-derived-pane-agent-identity'
+import { extractAllOscTitles } from '../../../../shared/osc-title-extraction'
+import { GROK_STARTUP_PTY_TRACE } from '../../../../shared/__fixtures__/grok-startup-pty-trace'
const LEAF_ID_1 = '77777777-7777-4777-8777-777777777777'
const LEAF_ID_2 = '88888888-8888-4888-8888-888888888888'
@@ -66,7 +70,7 @@ describe('buildTitleDerivedAgentRows', () => {
})
expect(rows.map((row) => [row.agentType, row.state, row.entry.lastAssistantMessage])).toEqual([
- ['antigravity', 'idle', 'Idle'],
+ ['antigravity', 'unreported', 'No status reported'],
['codex', 'working', 'Running']
])
expect(rows.map((row) => row.paneKey)).toEqual([
@@ -299,7 +303,7 @@ describe('buildTitleDerivedAgentRows', () => {
})
// #10258: Cursor's native title is deliberately status-less, which used to hide the pane.
- it('adds an idle Cursor row for the bare native cursor-agent title', () => {
+ it('keeps a Cursor row for the bare native cursor-agent title, reporting no status', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'cursor', title: 'Cursor Agent' })],
entries: [],
@@ -311,7 +315,7 @@ describe('buildTitleDerivedAgentRows', () => {
})
expect(rows.map((row) => [row.agentType, row.state, row.entry.lastAssistantMessage])).toEqual([
- ['cursor', 'idle', 'Idle']
+ ['cursor', 'unreported', 'No status reported']
])
})
@@ -454,10 +458,10 @@ describe('hook-less agent rows identified by the foreground process', () => {
it('keeps a launched Codex row when Codex retitles the pane to the project name', () => {
const foreground = processRead('codex')
expect(summarize(rowsFor({ title: 'Codex', launchAgent: 'codex', foreground }))).toEqual([
- ['codex', 'idle', 'Codex', 'Idle']
+ ['codex', 'unreported', 'Codex', 'No status reported']
])
expect(summarize(rowsFor({ title: 'demo-repo', launchAgent: 'codex', foreground }))).toEqual([
- ['codex', 'idle', 'Codex', 'Idle']
+ ['codex', 'unreported', 'Codex', 'No status reported']
])
})
@@ -494,14 +498,14 @@ describe('hook-less agent rows identified by the foreground process', () => {
summarize(
rowsFor({ title: 'demo-repo', launchAgent: 'codex', foreground: processRead('codex') })
)
- ).toEqual([['codex', 'idle', 'Codex', 'Idle']])
+ ).toEqual([['codex', 'unreported', 'Codex', 'No status reported']])
})
it('rows a hand-typed agent from its foreground process, whatever its title says', () => {
for (const agent of ['codex', 'claude', 'gemini', 'opencode', 'grok'] as const) {
const rows = rowsFor({ title: 'demo-repo', foreground: processRead(agent) })
expect(rows.map((row) => [row.paneKey, row.agentType, row.state])).toEqual([
- [PANE_KEY, agent, 'idle']
+ [PANE_KEY, agent, 'unreported']
])
}
})
@@ -531,7 +535,7 @@ describe('hook-less agent rows identified by the foreground process', () => {
['codex', 'working', 'Codex', 'Running']
])
expect(summarize(rowsFor({ title: 'demo-repo', foreground }))).toEqual([
- ['codex', 'idle', 'Codex', 'Idle']
+ ['codex', 'unreported', 'Codex', 'No status reported']
])
// A title naming another agent does not outrank the process that is actually running.
expect(summarize(rowsFor({ title: '⠋ Gemini CLI', foreground }))).toEqual([
@@ -637,7 +641,7 @@ describe('split-pane runtime title attribution', () => {
const rows = rowsFor({ '-1': 'Codex', '-2': '⠋ Codex' }, makeSplitLayout(), ['pty-a', 'pty-b'])
expect(rows.map((row) => [row.paneKey, row.state, row.entry.lastAssistantMessage])).toEqual([
- [makePaneKey('tab-1', LEAF_ID_1), 'idle', 'Idle'],
+ [makePaneKey('tab-1', LEAF_ID_1), 'unreported', 'No status reported'],
[makePaneKey('tab-1', LEAF_ID_2), 'working', 'Running']
])
})
@@ -645,7 +649,7 @@ describe('split-pane runtime title attribution', () => {
it('lets a revealed tab’s live slots outrank the parked slots it left behind', () => {
// Revealing a parked tab mounts live slots without clearing the parked ones, so
// both id spaces describe the same two leaves at once. The live pair is current:
- // leaf 1 has finished, leaf 2 is still working.
+ // leaf 1 shows only its name, leaf 2 is still working.
const rows = rowsFor(
{ '-1': '⠋ Codex', '-2': '⠋ Codex', 1: 'Codex', 2: '⠋ Codex' },
makeSplitLayout(),
@@ -653,7 +657,7 @@ describe('split-pane runtime title attribution', () => {
)
expect(rows.map((row) => [row.paneKey, row.state])).toEqual([
- [makePaneKey('tab-1', LEAF_ID_1), 'idle'],
+ [makePaneKey('tab-1', LEAF_ID_1), 'unreported'],
[makePaneKey('tab-1', LEAF_ID_2), 'working']
])
})
@@ -670,7 +674,7 @@ describe('split-pane runtime title attribution', () => {
.map((row) => [row.paneKey, row.agentType, row.state])
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
).toEqual([
- [makePaneKey('tab-1', LEAF_ID_1), 'antigravity', 'idle'],
+ [makePaneKey('tab-1', LEAF_ID_1), 'antigravity', 'unreported'],
[makePaneKey('tab-1', LEAF_ID_2), 'codex', 'working'],
[makePaneKey('tab-1', LEAF_ID_3), 'gemini', 'working']
])
@@ -693,7 +697,7 @@ describe('split-pane runtime title attribution', () => {
expect(rows.map((row) => [row.paneKey, row.agentType, row.state])).toEqual([
[makePaneKey('tab-1', LEAF_ID_2), 'codex', 'working'],
- [makePaneKey('tab-1', LEAF_ID_3), 'gemini', 'idle']
+ [makePaneKey('tab-1', LEAF_ID_3), 'gemini', 'unreported']
])
expect(rows.some((row) => row.paneKey === makePaneKey('tab-1', LEAF_ID_1))).toBe(false)
})
@@ -719,8 +723,60 @@ describe('split-pane runtime title attribution', () => {
const rows = rowsFor({ 2: 'Codex', 4: '⠋ Gemini CLI' }, survivingLayout, ['pty-old', 'pty-new'])
expect(rows.map((row) => [row.paneKey, row.agentType, row.state])).toEqual([
- [makePaneKey('tab-1', LEAF_ID_2), 'codex', 'idle'],
+ [makePaneKey('tab-1', LEAF_ID_2), 'codex', 'unreported'],
[makePaneKey('tab-1', LEAF_ID_3), 'gemini', 'working']
])
})
})
+
+describe('a title that only names the agent', () => {
+ const PANE_KEY = makePaneKey('tab-1', LEAF_ID_1)
+
+ function summarizeTitle(title: string) {
+ return buildWorktreeAgentRows({
+ tabs: [makeTab('tab-1')],
+ entries: [],
+ retained: [],
+ runtimePaneTitlesByTabId: { 'tab-1': { 1: title } },
+ ptyIdsByTabId: { 'tab-1': ['pty-agent'] },
+ terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
+ now: 2000
+ }).map((row) => [row.paneKey, row.agentType, row.state, row.entry.lastAssistantMessage])
+ }
+
+ // Committed fixture bytes: OpenCode 1.18.32 names itself before any session title.
+ it("reads OpenCode's recorded boot title as no status reported", () => {
+ const transcript = readFileSync(
+ resolve(__dirname, '../../../../main/daemon/__fixtures__/pty-transcripts/opencode.txt'),
+ 'utf8'
+ )
+ const bootTitle = extractAllOscTitles(transcript)[0]
+ expect(bootTitle).toBe('OpenCode')
+
+ expect(summarizeTitle(bootTitle)).toEqual([
+ [PANE_KEY, 'opencode', 'unreported', 'No status reported']
+ ])
+ })
+
+ // Committed fixture bytes: grok 1.0.0 writes its bare name 274 ms after spawn, before its input box.
+ it("reads Grok's recorded boot title as no status reported", () => {
+ const bootTitle = extractAllOscTitles(GROK_STARTUP_PTY_TRACE[0].data ?? '')[0]
+ expect(bootTitle).toBe('grok')
+
+ expect(summarizeTitle(bootTitle)).toEqual([
+ [PANE_KEY, 'grok', 'unreported', 'No status reported']
+ ])
+ })
+
+ // Hand-built title: the shape a busy agy pane shows (no agy recording carries an OSC title).
+ it('reads a bare agy title as no status reported and keeps the row', () => {
+ expect(summarizeTitle('agy')).toEqual([
+ [PANE_KEY, 'antigravity', 'unreported', 'No status reported']
+ ])
+ })
+
+ // Control, hand-built: a rest keyword is evidence, so the ready path still reads Idle.
+ it('still reads Idle when the title carries a rest marker', () => {
+ expect(summarizeTitle('agy ready')).toEqual([[PANE_KEY, 'antigravity', 'idle', 'Idle']])
+ })
+})
diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts
index 7e73363f7091..e0f246f5d961 100644
--- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts
+++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts
@@ -1,7 +1,9 @@
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import { formatAgentTypeLabel, isClaudeManagementTitle } from '@/lib/agent-status'
-import { isCursorAgentTitle } from '../../../../shared/agent-title-core'
-import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence'
+import { isCursorAgentTitle, type AgentTitleActivity } from '../../../../shared/agent-title-core'
+import { readAgentTitleActivity } from '../../../../shared/agent-title-status'
+import { resolveTitleActivityLabel } from '@/lib/pane-agent-evidence'
+import { agentNoStatusReportedLabel, type AgentRowState } from '@/lib/agent-row-decay-state'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import type {
AgentStatusEntry,
@@ -161,10 +163,10 @@ function buildTitleDerivedAgentRow(args: {
// the management/list screen as active work.
// Why (cursor): the native `cursor agent` literal is deliberately status-less so a
// redraw cannot stomp hook state — but it still identifies a live pane, so the row
- // reads idle instead of vanishing (#10258).
- const titleStatus = isClaudeAgentsTitle
+ // stays instead of vanishing (#10258), saying nothing about what Cursor is doing.
+ const titleStatus: AgentTitleActivity | null = isClaudeAgentsTitle
? 'idle'
- : (classifyTitleActivity(title) ?? (isCursorAgentTitle(title) ? 'idle' : null))
+ : (readAgentTitleActivity(title) ?? (isCursorAgentTitle(title) ? 'unreported' : null))
const label = isClaudeAgentsTitle ? 'Claude Code' : resolveTitleActivityLabel(title)
if (!isTerminalLeafId(args.leafId)) {
return null
@@ -190,12 +192,11 @@ function buildTitleDerivedAgentRow(args: {
if (!agentType) {
return null
}
- // Why: the title sets activity only; a plain title on a process-identified pane is idle.
- const status = titleStatus ?? 'idle'
+ // Why: the title sets activity only; a plain title on a process-identified pane says nothing.
+ const status = titleStatus ?? 'unreported'
const rowLabel = agentType === titleAgentType && label ? label : formatAgentTypeLabel(agentType)
const rowState = titleStatusToRowState(status)
- const secondary =
- status === 'permission' ? 'Needs input' : status === 'working' ? 'Running' : 'Idle'
+ const secondary = titleActivitySecondaryText(status)
const entryState: AgentStatusState = rowState === 'waiting' ? 'waiting' : 'working'
const entry: AgentStatusEntry = {
paneKey,
@@ -212,8 +213,8 @@ function buildTitleDerivedAgentRow(args: {
// render, not observed once, so a counter would churn a new revision per frame and break
// memoization. Deriving revision from `now` keeps the stamp deterministic in the same clock
// the row already publishes as updatedAt, and monotonic for the pane.
- // The origin tag is the point: `entryState` above collapses a title-derived IDLE row to
- // 'working' while the row itself reports idle. That contradiction is out of scope here —
+ // The origin tag is the point: `entryState` above collapses a title-derived IDLE or
+ // UNREPORTED row to 'working' while the row itself reports idle/unreported. That contradiction is out of scope here —
// this tag is what makes it findable instead of indistinguishable from a real hook row.
observation: {
origin: 'title',
@@ -277,16 +278,28 @@ export function resolveAgentTypeFromTerminalTitle(
: null
}
-function titleStatusToRowState(
- status: 'working' | 'permission' | 'idle'
-): AgentStatusState | 'idle' {
- if (status === 'permission') {
- return 'waiting'
+function titleStatusToRowState(status: AgentTitleActivity): AgentRowState {
+ switch (status) {
+ case 'permission':
+ return 'waiting'
+ case 'working':
+ case 'idle':
+ case 'unreported':
+ return status
}
- if (status === 'working') {
- return 'working'
+}
+
+function titleActivitySecondaryText(status: AgentTitleActivity): string {
+ switch (status) {
+ case 'permission':
+ return 'Needs input'
+ case 'working':
+ return 'Running'
+ case 'idle':
+ return 'Idle'
+ case 'unreported':
+ return agentNoStatusReportedLabel()
}
- return 'idle'
}
/**
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 1859cb84d741..b2497ccfd5f6 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -1026,6 +1026,9 @@
"symlink": "Symbolic links cannot be attached.",
"permissionDenied": "Permission denied.",
"unsupported": "Unsupported file type."
+ },
+ "agentRowState": {
+ "noStatusReported": "No status reported"
}
},
"hooks": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 731806faa3bb..8cf40cf2a301 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -733,6 +733,9 @@
"totalTooLarge": "{{fileName}} superaría el total de {{maxSize}} de archivos adjuntos."
}
}
+ },
+ "agentRowState": {
+ "noStatusReported": "Sin estado informado"
}
},
"hooks": {
diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json
index 65acfdf49e92..514d1b531f28 100644
--- a/src/renderer/src/i18n/locales/fr.json
+++ b/src/renderer/src/i18n/locales/fr.json
@@ -999,6 +999,9 @@
"symlink": "Les liens symboliques ne peuvent pas être attachés.",
"permissionDenied": "Autorisation refusée.",
"unsupported": "Type de fichier non pris en charge."
+ },
+ "agentRowState": {
+ "noStatusReported": "Aucun état signalé"
}
},
"hooks": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 85491478e07d..de60e6ed09ff 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -900,6 +900,9 @@
"symlink": "シンボリックリンクは接続できません。",
"permissionDenied": "アクセスが拒否されました。",
"unsupported": "サポートされていないファイル形式です。"
+ },
+ "agentRowState": {
+ "noStatusReported": "ステータス未報告"
}
},
"hooks": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 180ed46c24fa..36e3905bc7d0 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -900,6 +900,9 @@
"symlink": "심볼릭 링크는 첨부할 수 없습니다.",
"permissionDenied": "액세스가 거부되었습니다.",
"unsupported": "지원되지 않는 파일 형식입니다."
+ },
+ "agentRowState": {
+ "noStatusReported": "상태 미보고"
}
},
"hooks": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 42d4d19309b3..fa002e94f0f7 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -900,6 +900,9 @@
"symlink": "无法附加符号链接。",
"permissionDenied": "没有访问权限。",
"unsupported": "不支持的文件类型。"
+ },
+ "agentRowState": {
+ "noStatusReported": "未报告状态"
}
},
"hooks": {
diff --git a/src/renderer/src/lib/agent-row-decay-state.ts b/src/renderer/src/lib/agent-row-decay-state.ts
index 10f62a34ca6c..c729120987df 100644
--- a/src/renderer/src/lib/agent-row-decay-state.ts
+++ b/src/renderer/src/lib/agent-row-decay-state.ts
@@ -1,11 +1,15 @@
+import { translate } from '@/i18n/i18n'
import {
agentStatusEvidenceObservedAt,
type AgentStatusEntry,
type AgentStatusState
} from '../../../shared/agent-status-types'
-/** Row states: the hook-reported statuses plus the two Orca derives when an entry goes stale. */
-export type AgentRowState = AgentStatusState | 'idle' | 'unverifiable'
+/**
+ * Row states: the hook-reported statuses, the two Orca derives when an entry goes stale, and
+ * `unreported` — a title-derived row whose title names the agent but says nothing it is doing.
+ */
+export type AgentRowState = AgentStatusState | 'idle' | 'unverifiable' | 'unreported'
type DecayInput = Pick
@@ -54,3 +58,8 @@ export function agentNoUpdateLabel(
): string {
return `No update in ${formatCompactDuration(now - agentStatusEvidenceObservedAt(entry))}`
}
+
+/** The one phrase for an `unreported` row, wherever it shows: row text, dot tooltip, aria label. */
+export function agentNoStatusReportedLabel(): string {
+ return translate('auto.lib.agentRowState.noStatusReported', 'No status reported')
+}
diff --git a/src/renderer/src/lib/agent-row-dot-state.ts b/src/renderer/src/lib/agent-row-dot-state.ts
index bf2b7b1e5320..87fda6adc3c9 100644
--- a/src/renderer/src/lib/agent-row-dot-state.ts
+++ b/src/renderer/src/lib/agent-row-dot-state.ts
@@ -20,6 +20,7 @@ export function agentRowDotState(
case 'done':
case 'idle':
case 'unverifiable':
+ case 'unreported':
return state
}
return 'idle'
diff --git a/src/shared/agent-title-activity.test.ts b/src/shared/agent-title-activity.test.ts
new file mode 100644
index 000000000000..9a4c1b30d1bb
--- /dev/null
+++ b/src/shared/agent-title-activity.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { detectAgentStatusFromTitle, readAgentTitleActivity } from './agent-title-status'
+
+// Hand-built titles throughout; the committed-recording cases live in the sidebar row tests.
+const NAME_ONLY_TITLES = ['agy', 'grok', 'OpenCode', 'Codex', 'aider', 'Gemini CLI']
+
+describe('readAgentTitleActivity', () => {
+ it.each(NAME_ONLY_TITLES)('reads the bare name %s as unreported, not idle', (title) => {
+ expect(readAgentTitleActivity(title)).toBe('unreported')
+ })
+
+ it.each([
+ ['⠋ Codex', 'working'],
+ ['✳ Claude Code', 'idle'],
+ ['agy ready', 'idle'],
+ ['Codex action required', 'permission']
+ ] as const)('keeps the marker in %s as evidence: %s', (title, activity) => {
+ expect(readAgentTitleActivity(title)).toBe(activity)
+ expect(detectAgentStatusFromTitle(title)).toBe(activity)
+ })
+
+ it.each(['zsh', 'demo-repo', 'Droid', 'Cursor Agent'])('finds no activity in %s', (title) => {
+ expect(readAgentTitleActivity(title)).toBeNull()
+ expect(detectAgentStatusFromTitle(title)).toBeNull()
+ })
+})
+
+describe('detectAgentStatusFromTitle (temporary wrapper)', () => {
+ it.each(NAME_ONLY_TITLES)('still reads the bare name %s as idle for settle readers', (title) => {
+ expect(detectAgentStatusFromTitle(title)).toBe('idle')
+ })
+})
diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts
index a3bc238a7e1e..be0a7f96048f 100644
--- a/src/shared/agent-title-core.ts
+++ b/src/shared/agent-title-core.ts
@@ -16,6 +16,8 @@ import { getWrapperTitleSegments } from './terminal-title-wrapper-segments'
export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName }
export type AgentStatus = 'working' | 'permission' | 'idle'
+/** `unreported`: an agent's name is in the title and nothing in it says what the agent is doing. */
+export type AgentTitleActivity = AgentStatus | 'unreported'
export const CLAUDE_IDLE = '\u2733' // ✳
const CLAUDE_COMMAND_RE = String.raw`(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?`
diff --git a/src/shared/agent-title-idle-wrapper-ratchet.test.ts b/src/shared/agent-title-idle-wrapper-ratchet.test.ts
new file mode 100644
index 000000000000..89c68a36087f
--- /dev/null
+++ b/src/shared/agent-title-idle-wrapper-ratchet.test.ts
@@ -0,0 +1,63 @@
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+import { scanSourceTree, stripComments } from './source-scan/source-tree-scan'
+
+const SOURCE_ROOT = resolve(__dirname, '..')
+// Both read a bare agent name as `idle`; `classifyTitleActivity` is the renderer's pass-through.
+const IDLE_GUESS_READER = /\b(?:detectAgentStatusFromTitle|classifyTitleActivity)\b/
+
+// Why: the wrapper is temporary, so its importers may only shrink. Readers that say what a
+// title shows use `readAgentTitleActivity`, which answers `unreported` for a bare name.
+const KNOWN_IDLE_GUESS_READERS = [
+ 'main/runtime/orca-runtime-apply-tracked-pty-title.ts',
+ 'main/runtime/orca-runtime-get-pty-record-for-pane-key.ts',
+ 'main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts',
+ 'main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts',
+ 'main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts',
+ 'main/runtime/runtime-terminal-agent-status-query.ts',
+ 'main/runtime/runtime-worktree-status-projection.ts',
+ 'main/runtime/terminal-wait-detection.ts',
+ 'renderer/src/components/status-bar/workspace-space-presentation.ts',
+ 'renderer/src/components/terminal-pane/agent-completion-title-observer.ts',
+ 'renderer/src/components/terminal-pane/cache-timer-seeding.ts',
+ 'renderer/src/components/terminal-pane/pty-connection-test-environment.ts',
+ 'renderer/src/components/terminal-pane/pty-connection/agent-task-complete-notify.ts',
+ 'renderer/src/components/terminal-pane/pty-connection/command-inferred-pane-agent.ts',
+ 'renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts',
+ 'renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts',
+ 'renderer/src/components/terminal-pane/pty-output-title-observer.ts',
+ 'renderer/src/components/terminal-pane/title-agent-identity.ts',
+ 'renderer/src/lib/active-agent-note-target.ts',
+ 'renderer/src/lib/agent-ready-wait.ts',
+ 'renderer/src/lib/agent-send-title-status.ts',
+ 'renderer/src/lib/agent-status-terminal-title.ts',
+ 'renderer/src/lib/agent-status.ts',
+ 'renderer/src/lib/pane-agent-evidence.ts',
+ 'renderer/src/lib/worktree-status.ts',
+ 'renderer/src/store/slices/terminal-helpers.ts',
+ 'renderer/src/store/slices/workspace-cleanup-local-evidence.ts',
+ 'renderer/src/store/terminals/terminal-ephemeral-state.ts',
+ 'renderer/src/store/terminals/terminal-tab-presentation.ts',
+ 'shared/agent-decorative-title-signature.ts',
+ 'shared/agent-detection.ts',
+ 'shared/agent-title-owner.ts',
+ 'shared/agent-title-status.ts',
+ 'shared/terminal-output-side-effects.ts',
+ 'shared/tui-agent-rest-signal.ts'
+]
+
+describe('temporary name-as-idle title reader ratchet', () => {
+ const readers = scanSourceTree(SOURCE_ROOT)
+ .filter((file) => IDLE_GUESS_READER.test(stripComments(file.source)))
+ .map((file) => file.relativePath)
+ .sort()
+
+ it('admits no new reader of the name-as-idle guess', () => {
+ expect(readers.filter((path) => !KNOWN_IDLE_GUESS_READERS.includes(path))).toEqual([])
+ })
+
+ it('drops a file from the list once it stops reading the guess', () => {
+ expect(KNOWN_IDLE_GUESS_READERS.filter((path) => !readers.includes(path))).toEqual([])
+ })
+})
diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts
index 7cd7a2c292cd..4c31bdd79d1c 100644
--- a/src/shared/agent-title-status.ts
+++ b/src/shared/agent-title-status.ts
@@ -25,7 +25,7 @@ import {
isPiAgentTitle,
isPiTerminalTitle
} from './agent-title-core'
-import type { AgentStatus } from './agent-title-core'
+import type { AgentStatus, AgentTitleActivity } from './agent-title-core'
import { isOpenCodeNativeTitle } from './opencode-terminal-title'
import {
getPiCompatibleTitleSeparatorStatus,
@@ -185,7 +185,7 @@ function canonicalizeBrailleSpinnerFrame(title: string): string {
return canonical
}
-function computeAgentStatusFromTitle(title: string): AgentStatus | null {
+function computeAgentTitleActivity(title: string): AgentTitleActivity | null {
const qoderStatus = qoderTitleStatus(title)
if (qoderStatus) {
return qoderStatus
@@ -276,15 +276,28 @@ function computeAgentStatusFromTitle(title: string): AgentStatus | null {
return null
}
- return 'idle'
+ // Why: a name says which agent is here, never what it is doing — it lies mid-turn
+ // (Droid, Cursor), at boot (Grok, OpenCode) and when a shell auto-title writes it.
+ return 'unreported'
}
/**
- * 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.
+ * What a title says the agent is doing: `null` when no agent is in it, `unreported`
+ * when only its name is. 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)
+export const readAgentTitleActivity: (title: string) => AgentTitleActivity | null =
+ memoizeTitleClassification(computeAgentTitleActivity)
+
+/**
+ * Temporary: `readAgentTitleActivity` with `unreported` read as `idle`, so readers that
+ * settle, deliver or clean up on a title keep today's behavior until each moves over.
+ * Deleted with its last importer (PR-4); agent-title-idle-wrapper-ratchet.test.ts pins them.
+ */
+export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
+ const activity = readAgentTitleActivity(title)
+ return activity === 'unreported' ? 'idle' : activity
+}
/**
* True when a quarter-circle spinner frame is the only agent evidence a title carries.