fix(sidebar): show Cursor rows and stop a stray "claude" title hijacking OpenCode (#12466)
* fix(sidebar): show Cursor rows and stop a stray "claude" title hijacking OpenCode Two defects in the same title-resolution path. **#10258** — Cursor's only native OSC title is the literal `cursor agent`, which both title trackers dropped unconditionally. A hookless Cursor pane therefore had neither a status entry nor any title carrying Cursor identity, so the worktree card showed nothing at all. **#8940** — two owner-blind paths let an incidental `claude` token anywhere in an OpenCode session or task title outrank the pane's known owner, so the tab icon and sidebar row flipped to Claude Code. #10258: let the literal through exactly once as identity, so a restored or mobile tab keeps its Cursor row instead of vanishing. #8940: require an *identity frame* — after stripping status decoration the title must PRESENT Claude, not merely mention it — before a Claude title may reclaim a pane from its prior identity, and make the sidebar row builder owner-aware. > These two are in one PR because they share the `ownerAgentType` plumbing through `buildTitleDerivedAgentRow` — split apart, neither half compiles on its own. Fixes #10258 Fixes #8940 Co-authored-by: Orca <help@stably.ai> * test(e2e): add recordable proof for sidebar-agent-row-identity Fails on origin/main, passes on this branch. Test: sidebar keeps a Cursor pane visible and an OpenCode pane out of Claude Code hands Co-authored-by: Orca <help@stably.ai> * fix(terminal): preserve restored Cursor identity * test(terminal): cover restored Cursor redraw suppression * refactor(terminal): tighten Cursor identity handling and Claude frame matching Review follow-ups on the title-resolution path: - pty-transport dropped a native Cursor literal that main emits whenever a non-Cursor title preceded it, re-introducing the #10258 blank row in the renderer path. The pre-filter now projects the predecessor the drain will actually see, and defers to the drain gate while facts are still queued. - applyTrackedPtyTitle threaded the cursor flag through 12 sites, including ptyRecordChanged bookkeeping the sole caller ignores. Force the status null once, and the activity-gated effects fall out unchanged. - isClaudeIdentityFrameTitle missed a multiplexer-wrapped Claude title ("zsh | Claude Code"), costing a genuine Claude pane its identity. Reuse the ' | ' segment split that agent-title-owner already had inline. - Keep title normalization on launchAgent: it only rewrites within an identity group (OMP wraps Pi), so a split does not make it wrong, and the hook-row path normalizes the same way. - Drop the tab.ptyId tracker fallback, which read a pty that the pane identity check had just rejected. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
ea8881a3c7
commit
970696a008
|
|
@ -9087,6 +9087,103 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(runtime.getPtyOutputSequence('pty-1')).toBe(0)
|
||||
})
|
||||
|
||||
it('emits live Cursor identity without storing it as liveness evidence', async () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Cursor Agent\x07', 100)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([
|
||||
{ kind: 'title', normalizedTitle: 'Cursor Agent', rawTitle: 'Cursor Agent' }
|
||||
])
|
||||
expect((await runtime.listTerminals()).terminals[0].title).not.toBe('Cursor Agent')
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({
|
||||
facts: [{ kind: 'title', normalizedTitle: 'Cursor Agent', rawTitle: 'Cursor Agent' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps live Cursor identity in mobile titles without making it agent liveness', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Terminal 1',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-1'
|
||||
}
|
||||
],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-cursor',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: 'tab-1::pane:1',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::pane:1',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
ptyId: 'pty-1',
|
||||
title: 'Terminal 1',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Cursor Agent\x07', 100)
|
||||
|
||||
const terminal = (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]
|
||||
expect(terminal).toMatchObject({ type: 'terminal', title: 'Cursor Agent' })
|
||||
expect(terminal).not.toHaveProperty('agentStatus')
|
||||
})
|
||||
|
||||
it('lets an explicit terminal rename override cached Cursor identity and restores it after clearing', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Cursor Agent\x07', 100)
|
||||
const mobileTerminal = (
|
||||
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
).tabs.find((tab) => tab.type === 'terminal')
|
||||
if (mobileTerminal?.type !== 'terminal' || !mobileTerminal.terminal) {
|
||||
throw new Error('expected mobile terminal handle')
|
||||
}
|
||||
expect(mobileTerminal.terminal).toBe(created.handle)
|
||||
|
||||
await runtime.renameTerminal(mobileTerminal.terminal, 'Pinned Cursor')
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]).toMatchObject(
|
||||
{ type: 'terminal', title: 'Pinned Cursor' }
|
||||
)
|
||||
|
||||
await runtime.renameTerminal(mobileTerminal.terminal, null)
|
||||
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]).toMatchObject(
|
||||
{ type: 'terminal', title: 'Cursor Agent' }
|
||||
)
|
||||
})
|
||||
|
||||
it('confirms title-based agent exits against the foreground process', async () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
|
@ -9898,12 +9995,12 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(runtime.getTerminalSideEffectSnapshot('pty-unknown')).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the cursor-agent literal from record-fallback snapshots', () => {
|
||||
it('keeps the cursor-agent literal in record-fallback snapshots only without a tracker title', () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', 'plain output\n', 100)
|
||||
// Simulate a record title restored by a path that bypassed the tracker (which itself refuses to store the bare native title).
|
||||
// Simulate a record title restored by a path that bypassed the tracker.
|
||||
const records = (
|
||||
runtime as unknown as {
|
||||
ptysById: Map<string, { lastOscTitle: string | null }>
|
||||
|
|
@ -9911,7 +10008,17 @@ describe('OrcaRuntimeService', () => {
|
|||
).ptysById
|
||||
records.get('pty-1')!.lastOscTitle = 'Cursor Agent'
|
||||
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toBeNull()
|
||||
// Why: a hookless Cursor pane has no other identity to restore (#10258).
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({
|
||||
facts: [{ kind: 'title', normalizedTitle: 'Cursor Agent', rawTitle: 'Cursor Agent' }]
|
||||
})
|
||||
|
||||
// A synthesized Cursor title owns the pane; the bare literal must not replay over it.
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
|
||||
|
||||
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({
|
||||
facts: [{ kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('emits the chunk agentStatus events before its side-effect batch', () => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import type {
|
|||
import {
|
||||
createTerminalTitleTracker,
|
||||
stripBrailleSpinnerGlyphs,
|
||||
type TerminalTitleFactMeta,
|
||||
type TerminalTitleTracker
|
||||
} from '../../shared/terminal-output-side-effects'
|
||||
import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status'
|
||||
|
|
@ -10210,11 +10211,14 @@ export class OrcaRuntimeService {
|
|||
getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null {
|
||||
const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker
|
||||
const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle
|
||||
// Why: the cursor-agent literal drop applies to every title surface; a
|
||||
// record-fallback snapshot must not replay the bare native title the
|
||||
// tracker would have refused to emit live.
|
||||
const rawTitle = recordTitle && !isCursorNativeAgentTitle(recordTitle) ? recordTitle : null
|
||||
const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null
|
||||
// Why: a record-fallback snapshot must not replay the bare cursor-agent literal over a
|
||||
// tracker title Orca synthesized from hooks — but with no tracker title it is the pane's
|
||||
// only Cursor identity, so restored/mobile tabs keep it (#10258).
|
||||
const rawTitle =
|
||||
recordTitle && (normalizedTitle === null || !isCursorNativeAgentTitle(recordTitle))
|
||||
? recordTitle
|
||||
: null
|
||||
if (normalizedTitle === null && !rawTitle) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -10248,12 +10252,37 @@ export class OrcaRuntimeService {
|
|||
return null
|
||||
}
|
||||
|
||||
private isLiveCursorNativeTitle(rawTitle: string, meta?: TerminalTitleFactMeta): boolean {
|
||||
return isCursorNativeAgentTitle(rawTitle) && meta?.staleWorkingTitleClear !== true
|
||||
}
|
||||
|
||||
/** Display fallback for identities intentionally omitted from liveness records. */
|
||||
private getTrackedDisplayTitleForPty(ptyId: string): string | null {
|
||||
return (
|
||||
this.getTrackedRawTitleForPty(ptyId) ??
|
||||
this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private getUnpersistedTrackedTitleForPty(ptyId: string | null): string | null {
|
||||
if (!ptyId || this.getTrackedRawTitleForPty(ptyId) !== null) {
|
||||
return null
|
||||
}
|
||||
// Why: a manual title is authoritative until explicitly cleared with null.
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty && pty.title !== null) {
|
||||
return null
|
||||
}
|
||||
return this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ?? null
|
||||
}
|
||||
|
||||
/** Why: synthetic agent title frames no longer ride pty:data, so neither
|
||||
* renderer xterm nor the headless emulator observes them. Mobile-parity
|
||||
* snapshot titles must prefer main's tracker over snapshot lastTitle, or
|
||||
* hook-driven spinner/idle titles vanish from mobile tabs. */
|
||||
private preferTrackedLastTitle<T extends { lastTitle?: string }>(ptyId: string, snapshot: T): T {
|
||||
const tracked = this.getTrackedRawTitleForPty(ptyId)
|
||||
const tracked = this.getTrackedDisplayTitleForPty(ptyId)
|
||||
if (!tracked) {
|
||||
return snapshot
|
||||
}
|
||||
|
|
@ -10296,8 +10325,11 @@ export class OrcaRuntimeService {
|
|||
rawTitle,
|
||||
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
|
||||
})
|
||||
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle)
|
||||
if (!changed) {
|
||||
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta)
|
||||
// Why: an identity-only cursor title records nothing, so on a fresh pane `changed` is
|
||||
// false — but the tracker title is that pane's only Cursor identity and still has to
|
||||
// fan out to the sidebar and mobile (#10258).
|
||||
if (!changed && !this.isLiveCursorNativeTitle(rawTitle, meta)) {
|
||||
return
|
||||
}
|
||||
const live = this.ptyTitleTrackersByPtyId.get(ptyId)
|
||||
|
|
@ -10377,25 +10409,42 @@ export class OrcaRuntimeService {
|
|||
|
||||
/** Apply one observed OSC title (raw form) to the PTY and leaf records.
|
||||
* Returns true when the PTY record's title or status changed. */
|
||||
private applyTrackedPtyTitle(ptyId: string, rawTitle: string, normalizedTitle: string): boolean {
|
||||
private applyTrackedPtyTitle(
|
||||
ptyId: string,
|
||||
rawTitle: string,
|
||||
normalizedTitle: string,
|
||||
meta?: TerminalTitleFactMeta
|
||||
): boolean {
|
||||
// Why: status is detected from the RAW title (mirrors the renderer tracker),
|
||||
// so working/idle transitions are unaffected by normalization; the records
|
||||
// store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to
|
||||
// one stable stored label (#7880) instead of churning `ps`/mobile tabs.
|
||||
const agentStatus = detectAgentStatusFromTitle(rawTitle)
|
||||
//
|
||||
// Why the identity-only case: the bare cursor-agent literal identifies the pane without
|
||||
// asserting activity, so it records NO title/status evidence — only the tracker keeps it,
|
||||
// for display (#10258). Nulling the status here rather than trusting the detector keeps
|
||||
// that contract local, since every activity-gated effect below is keyed on status.
|
||||
const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
|
||||
const recordedTitle = identityOnlyTitle ? null : normalizedTitle
|
||||
const agentStatus = identityOnlyTitle ? null : detectAgentStatusFromTitle(rawTitle)
|
||||
let ptyRecordChanged = false
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty) {
|
||||
const prevStatus = pty.lastAgentStatus
|
||||
const prevTitle = pty.lastOscTitle
|
||||
const observedAt = this.nextTitleObservationSequence()
|
||||
pty.lastOscTitle = normalizedTitle
|
||||
pty.lastOscTitleAt = observedAt
|
||||
pty.lastOscTitleEpochMs = Date.now()
|
||||
pty.lastOscTitle = recordedTitle
|
||||
pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt
|
||||
pty.lastOscTitleEpochMs = identityOnlyTitle ? null : Date.now()
|
||||
pty.lastAgentStatus = agentStatus
|
||||
pty.lastAgentStatusObservedLive = true
|
||||
this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt)
|
||||
ptyRecordChanged = prevTitle !== normalizedTitle || prevStatus !== agentStatus
|
||||
if (identityOnlyTitle) {
|
||||
pty.managementTitle = null
|
||||
pty.managementTitleAt = null
|
||||
} else {
|
||||
this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt)
|
||||
}
|
||||
ptyRecordChanged = prevTitle !== recordedTitle || prevStatus !== agentStatus
|
||||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||||
this.resolvePtyTuiIdleWaiters(pty, ptyId)
|
||||
}
|
||||
|
|
@ -10428,10 +10477,10 @@ export class OrcaRuntimeService {
|
|||
// daemon-hosted terminals (no renderer pushing pane titles) had no
|
||||
// way to clear a stale 'working' status after the agent exited and
|
||||
// the shell took over the title — the stuck-spinner bug in #1437.
|
||||
leaf.lastOscTitle = normalizedTitle
|
||||
leaf.lastOscTitleAt = this.nextTitleObservationSequence()
|
||||
const prevStatus = leaf.lastAgentStatus
|
||||
const prevObservedLive = leaf.lastAgentStatusObservedLive
|
||||
leaf.lastOscTitle = recordedTitle
|
||||
leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence()
|
||||
// Why: when a new OSC title doesn't classify as an agent state (e.g.
|
||||
// bare shell title after the agent exits), clear lastAgentStatus so
|
||||
// it is no longer sticky. Tui-idle waiters that needed the previous
|
||||
|
|
@ -30335,6 +30384,12 @@ export class OrcaRuntimeService {
|
|||
const hookAgentStatus = tab.agentStatus
|
||||
? this.getHookAgentRowForPane(getHookRowsForPane(paneKey))
|
||||
: null
|
||||
// Why not tab.ptyId: findPtyForMobileTerminalTab already rejected it when it returned
|
||||
// null, because persisted ids can collide with an unrelated pane after restart — reading
|
||||
// that pane's tracker would publish its title here, ahead of every other source.
|
||||
const trackerOnlyTitle = this.getUnpersistedTrackedTitleForPty(
|
||||
liveLeafPtyId ?? pty?.ptyId ?? null
|
||||
)
|
||||
const leafTitle = leaf
|
||||
? getLatestAgentCandidateTitle(
|
||||
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
|
||||
|
|
@ -30362,7 +30417,7 @@ export class OrcaRuntimeService {
|
|||
pty?.foregroundAgent ??
|
||||
null
|
||||
const title = normalizeCompatibleAgentTitleForOwner(
|
||||
leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title,
|
||||
trackerOnlyTitle ?? leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title,
|
||||
ownerAgent
|
||||
)
|
||||
const liveTitleEvidence = leafTitle ?? ptyTitle
|
||||
|
|
@ -30532,6 +30587,9 @@ export class OrcaRuntimeService {
|
|||
? { providerSession: hookRow.providerSession }
|
||||
: {}
|
||||
const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null
|
||||
const trackerOnlyTitle = this.getUnpersistedTrackedTitleForPty(
|
||||
pty?.ptyId ?? leaf?.ptyId ?? null
|
||||
)
|
||||
const ptyTitle = pty
|
||||
? getLatestAgentCandidateTitle(
|
||||
{ title: pty.title, updatedAt: pty.titleUpdatedAt },
|
||||
|
|
@ -30572,7 +30630,7 @@ export class OrcaRuntimeService {
|
|||
pty?.foregroundAgent ??
|
||||
null
|
||||
const terminalTitle = normalizeCompatibleAgentTitleForOwner(
|
||||
(pty ? getLatestPtyTitle(pty) : null) ?? tab.title,
|
||||
trackerOnlyTitle ?? (pty ? getLatestPtyTitle(pty) : null) ?? tab.title,
|
||||
ownerAgent
|
||||
)
|
||||
// Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970).
|
||||
|
|
|
|||
|
|
@ -271,4 +271,97 @@ describe('buildTitleDerivedAgentRows', () => {
|
|||
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
// #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', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'cursor', title: 'Cursor Agent' })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: 'Cursor Agent' } },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-cursor'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows.map((row) => [row.agentType, row.state, row.entry.lastAssistantMessage])).toEqual([
|
||||
['cursor', 'idle', 'Idle']
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the Cursor row running while a synthesized spinner title is painted', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'cursor' })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ Cursor Agent' } },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-cursor'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows.map((row) => [row.agentType, row.state])).toEqual([['cursor', 'working']])
|
||||
})
|
||||
|
||||
// #8940: an OpenCode pane's own task text must not hand the row to Claude Code.
|
||||
it('keeps an OpenCode-launched pane OpenCode across its own status frames', () => {
|
||||
const frames: [string, string][] = [
|
||||
['OC | ⠋ ask claude about this', 'working'],
|
||||
['⠋ OpenCode', 'working'],
|
||||
['⠋ use Claude Sonnet', 'working'],
|
||||
['⠋ claude 스타일로 리팩터', 'working'],
|
||||
['. Compare Opencode Vs Orca', 'working'],
|
||||
['OpenCode ready', 'idle']
|
||||
]
|
||||
|
||||
for (const [title, state] of frames) {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'opencode' })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: title } },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-opencode'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows.map((row) => [row.agentType, row.state, row.entry.prompt])).toEqual([
|
||||
['opencode', state, 'OpenCode']
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('still resolves Claude from a title that presents Claude, owner or not', () => {
|
||||
const rowsFor = (title: string, launchAgent?: TuiAgent) =>
|
||||
buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', launchAgent ? { launchAgent } : {})],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: title } },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-agent'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rowsFor('⠋ Claude Code').map((row) => row.agentType)).toEqual(['claude'])
|
||||
// Pane reuse: the user exited OpenCode and ran claude in the same pane.
|
||||
expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['claude'])
|
||||
// No owner to defend the pane: naming Claude stays the only available identity.
|
||||
expect(rowsFor('⠋ use Claude Sonnet').map((row) => row.agentType)).toEqual(['claude'])
|
||||
expect(rowsFor('zsh', 'opencode')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not brand a split pane with the tab-scoped launch agent', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'opencode' })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ implementing the feature' } },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSplitLayout() },
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
|
||||
import { formatAgentTypeLabel, isClaudeManagementTitle } from '@/lib/agent-status'
|
||||
import { containsBrailleSpinner } from '../../../../shared/agent-title-core'
|
||||
import { isCursorAgentTitle } from '../../../../shared/agent-title-core'
|
||||
import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import type {
|
||||
|
|
@ -19,6 +19,8 @@ import {
|
|||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
} from '../../../../shared/agent-title-owner'
|
||||
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
|
||||
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
|
||||
|
||||
const EMPTY_RUNTIME_TITLES: Record<string, Record<number, string>> = {}
|
||||
const EMPTY_LIVE_PTY_IDS: Record<string, string[]> = {}
|
||||
|
|
@ -84,6 +86,7 @@ export function buildTitleDerivedAgentRows(args: {
|
|||
tab,
|
||||
leafId,
|
||||
title,
|
||||
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
|
||||
now: args.now,
|
||||
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
|
||||
})
|
||||
|
|
@ -104,6 +107,7 @@ export function buildTitleDerivedAgentRows(args: {
|
|||
tab,
|
||||
leafId,
|
||||
title: tab.title,
|
||||
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
|
||||
now: args.now,
|
||||
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
|
||||
})
|
||||
|
|
@ -125,15 +129,24 @@ function buildTitleDerivedAgentRow(args: {
|
|||
tab: TerminalTab
|
||||
leafId: string
|
||||
title: string
|
||||
ownerAgentType: AgentType | null
|
||||
now: number
|
||||
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
|
||||
}): DashboardAgentRow | null {
|
||||
// Why launchAgent, not ownerAgentType: this only rewrites a title within its own identity
|
||||
// group (OMP wraps Pi and emits Pi frames), which stays correct in a split. Pane ownership
|
||||
// is a separate, stricter question — it decides identity, so it uses ownerAgentType below.
|
||||
const title = normalizeCompatibleAgentTitleForOwner(args.title, args.tab.launchAgent)
|
||||
const isClaudeAgentsTitle = isClaudeManagementTitle(title)
|
||||
// Why: `claude agents` is a live Claude Code Agent Teams surface, but the
|
||||
// shared detector keeps it neutral so runtime liveness probes do not treat
|
||||
// the management/list screen as active work.
|
||||
const status = isClaudeAgentsTitle ? 'idle' : classifyTitleActivity(title)
|
||||
// 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 status = isClaudeAgentsTitle
|
||||
? 'idle'
|
||||
: (classifyTitleActivity(title) ?? (isCursorAgentTitle(title) ? 'idle' : null))
|
||||
const label = isClaudeAgentsTitle ? 'Claude Code' : resolveTitleActivityLabel(title)
|
||||
if (!status || !label) {
|
||||
return null
|
||||
|
|
@ -143,16 +156,15 @@ function buildTitleDerivedAgentRow(args: {
|
|||
}
|
||||
const paneKey = makePaneKey(args.tab.id, args.leafId)
|
||||
const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey]
|
||||
const titleAgentType = isClaudeAgentsTitle ? 'claude' : resolveTitleDerivedAgentType(title, label)
|
||||
// Why: a braille spinner proves activity, not identity, so the resolver drops
|
||||
// it. Hook-less agents over SSH (Codex, #8711) surface only spinner+cwd titles;
|
||||
// fall back to the tab's launch identity instead of hiding the pane. Gated on
|
||||
// the spinner on purpose — unlike the hook path's unconditional launchAgent
|
||||
// fallback (resolveRowAgentType), this path manufactures agent-ness from a
|
||||
// title alone, so a non-agent title must never become a row. Residual: a split
|
||||
// pane whose own title carries a braille glyph is still attributed to launchAgent.
|
||||
const agentType =
|
||||
titleAgentType ?? (containsBrailleSpinner(title) ? (args.tab.launchAgent ?? null) : null)
|
||||
const titleAgentType = isClaudeAgentsTitle
|
||||
? 'claude'
|
||||
: resolveTitleDerivedAgentType(title, label, args.ownerAgentType)
|
||||
// Why: a status frame proves activity, not identity, so the resolver drops it.
|
||||
// Hook-less agents over SSH (Codex, #8711; OpenCode's '. '/'* ' frames, #8940)
|
||||
// surface only decorated task titles; fall back to the pane's known owner instead
|
||||
// of hiding the pane. Safe because the `!status || !label` gate above already
|
||||
// rejects plain shell titles — this path must never manufacture a row from one.
|
||||
const agentType = titleAgentType ?? args.ownerAgentType
|
||||
if (!agentType) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -184,7 +196,11 @@ function buildTitleDerivedAgentRow(args: {
|
|||
}
|
||||
}
|
||||
|
||||
export function resolveTitleDerivedAgentType(title: string, label: string): AgentType | null {
|
||||
export function resolveTitleDerivedAgentType(
|
||||
title: string,
|
||||
label: string,
|
||||
ownerAgentType?: AgentType | null
|
||||
): AgentType | null {
|
||||
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
|
||||
if (agentType !== 'claude') {
|
||||
return agentType
|
||||
|
|
@ -192,7 +208,29 @@ export function resolveTitleDerivedAgentType(title: string, label: string): Agen
|
|||
// Why: Claude's task-title spinner heuristic has no provider identity. In
|
||||
// split panes it can match arbitrary terminal spinners, so sidebar rows only
|
||||
// accept Claude when the title itself names Claude.
|
||||
return CLAUDE_AGENT_TOKEN_RE.test(title) ? agentType : null
|
||||
if (!CLAUDE_AGENT_TOKEN_RE.test(title)) {
|
||||
return null
|
||||
}
|
||||
// Why: a "claude" word inside another agent's task text is a mention, not identity.
|
||||
// Only a title that PRESENTS Claude may take a pane away from its known owner (#8940).
|
||||
const owner = ownerAgentType && ownerAgentType !== 'unknown' ? ownerAgentType : null
|
||||
if (owner && owner !== 'claude' && !isClaudeIdentityFrameTitle(title)) {
|
||||
return null
|
||||
}
|
||||
return agentType
|
||||
}
|
||||
|
||||
function resolveTitleDerivedPaneOwner(
|
||||
tab: TerminalTab,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
leafId: string
|
||||
): AgentType | null {
|
||||
// Why: launchAgent is tab-scoped, so it is pane ownership only while the tab has one
|
||||
// leaf; applying it inside a split would let one pane brand its sibling.
|
||||
if (layout?.root?.type !== 'leaf' || layout.root.leafId !== leafId) {
|
||||
return null
|
||||
}
|
||||
return resolvePaneAgentOwner({ launchAgent: tab.launchAgent })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -210,7 +248,7 @@ export function resolveAgentTypeFromTerminalTitle(
|
|||
const label = resolveTitleActivityLabel(normalizedTitle)
|
||||
return label
|
||||
? (resolveCompatibleAgentTypeForOwner(
|
||||
resolveTitleDerivedAgentType(normalizedTitle, label),
|
||||
resolveTitleDerivedAgentType(normalizedTitle, label, ownerAgentType),
|
||||
ownerAgentType
|
||||
) ?? null)
|
||||
: null
|
||||
|
|
|
|||
|
|
@ -175,14 +175,25 @@ describe('startParkedTerminalByteWatcher', () => {
|
|||
dispose()
|
||||
})
|
||||
|
||||
it('drops the bare cursor-agent native title before it reaches the store', async () => {
|
||||
// Why: a hookless Cursor pane has no other identity, so the literal reaches the store
|
||||
// once (#10258); its redraw repeats must not stomp a synthesized Cursor title.
|
||||
it('stores the bare cursor-agent native title once, then keeps the synthetic title', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x1b]0;Cursor Agent\x07')
|
||||
emit('\x1b]0;Cursor Agent\x07')
|
||||
emit('\x1b]0;⠋ Cursor Agent\x07')
|
||||
emit('\x1b]0;Cursor Agent\x07')
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([
|
||||
[TAB_ID, PANE_ID, 'Cursor Agent'],
|
||||
[TAB_ID, PANE_ID, '⠋ Cursor Agent']
|
||||
])
|
||||
expect(mockStoreState.updateTabTitle.mock.calls).toEqual([
|
||||
[TAB_ID, 'Cursor Agent'],
|
||||
[TAB_ID, '⠋ Cursor Agent']
|
||||
])
|
||||
dispose()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -801,7 +801,8 @@ describe('createIpcPtyTransport', () => {
|
|||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(onTitleChange).not.toHaveBeenCalled()
|
||||
// Why: the literal is the pane's identity once (#10258); the redraw repeats stay ignored.
|
||||
expect(onTitleChange.mock.calls).toEqual([['Cursor Agent', 'Cursor Agent']])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import {
|
|||
clearWorkingIndicators,
|
||||
createAgentStatusTracker,
|
||||
normalizeTerminalTitle,
|
||||
extractAllOscTitles
|
||||
extractAllOscTitles,
|
||||
isCursorNativeAgentTitle,
|
||||
shouldSuppressCursorNativeTitle
|
||||
} from '../../../../shared/agent-detection'
|
||||
import {
|
||||
isTerminalInputTooLargeWithDeferredMeasurement,
|
||||
|
|
@ -112,27 +114,27 @@ type PendingPtySideEffect = {
|
|||
suppressAttentionEvents: boolean
|
||||
}
|
||||
|
||||
function isIgnoredCursorNativeTitle(title: string): boolean {
|
||||
return title.trim().toLowerCase() === 'cursor agent'
|
||||
}
|
||||
|
||||
function removeIgnoredCursorNativeTitles(titles: string[]): boolean {
|
||||
// Why: mirrors main's applyObservedTitle — the literal survives whenever the title before it
|
||||
// is not already Cursor-owned, which is how a pane re-establishes Cursor identity after the
|
||||
// shell prompt repaints the title (#10258). Only the redraw repeats are dropped, so they cost
|
||||
// neither an allocation nor a drain slot; `processObservedTitles` re-checks and stays
|
||||
// authoritative, so this must never drop a title that gate would have emitted.
|
||||
function removeSuppressedCursorNativeTitles(
|
||||
titles: string[],
|
||||
precedingTitle: string | null
|
||||
): boolean {
|
||||
let writeIndex = 0
|
||||
let removed = false
|
||||
for (let readIndex = 0; readIndex < titles.length; readIndex += 1) {
|
||||
const title = titles[readIndex]
|
||||
if (isIgnoredCursorNativeTitle(title)) {
|
||||
removed = true
|
||||
let previousTitle = precedingTitle
|
||||
for (const title of titles) {
|
||||
if (isCursorNativeAgentTitle(title) && shouldSuppressCursorNativeTitle(previousTitle)) {
|
||||
continue
|
||||
}
|
||||
if (writeIndex !== readIndex) {
|
||||
titles[writeIndex] = title
|
||||
}
|
||||
previousTitle = normalizeTerminalTitle(title)
|
||||
titles[writeIndex] = title
|
||||
writeIndex += 1
|
||||
}
|
||||
if (removed) {
|
||||
titles.length = writeIndex
|
||||
}
|
||||
const removed = writeIndex < titles.length
|
||||
titles.length = writeIndex
|
||||
return removed
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +178,10 @@ export function createPtyOutputProcessor({
|
|||
retained: () => pendingSideEffects.length
|
||||
}
|
||||
const disposePendingSideEffectGauge = registerPtySideEffectPendingGauge(pendingSideEffectGauge)
|
||||
const initialAgentStatusTitle =
|
||||
initialAgentTitle !== undefined && !isCursorNativeAgentTitle(initialAgentTitle)
|
||||
? initialAgentTitle
|
||||
: undefined
|
||||
const agentTracker =
|
||||
onAgentBecameIdle || onAgentBecameWorking || onAgentExited
|
||||
? createAgentStatusTracker(
|
||||
|
|
@ -184,7 +190,7 @@ export function createPtyOutputProcessor({
|
|||
},
|
||||
onAgentBecameWorking,
|
||||
onAgentExited,
|
||||
initialAgentTitle
|
||||
initialAgentStatusTitle
|
||||
)
|
||||
: null
|
||||
|
||||
|
|
@ -288,7 +294,13 @@ export function createPtyOutputProcessor({
|
|||
const scannedForTitles = Boolean(onTitleChange && data.includes('\x1b]'))
|
||||
const titles = scannedForTitles ? extractAllOscTitles(data) : []
|
||||
// Why: Cursor emits this ignored title every redraw; keep one queue fact instead of an allocation and drain slot per frame.
|
||||
const ignoredCursorNativeTitle = removeIgnoredCursorNativeTitles(titles)
|
||||
// Why the drained check: `lastEmittedTitle` only advances on drain, so while facts are
|
||||
// still queued it is not the predecessor the drain will see — leave that call to the gate.
|
||||
const drained = pendingSideEffectIndex >= pendingSideEffects.length
|
||||
const ignoredCursorNativeTitle = removeSuppressedCursorNativeTitles(
|
||||
titles,
|
||||
drained ? lastEmittedTitle : null
|
||||
)
|
||||
const deliveredPayloads =
|
||||
onAgentStatus && !suppressAttentionEvents && payloads.length > 0 ? payloads : []
|
||||
const containsBell = Boolean(
|
||||
|
|
@ -436,6 +448,14 @@ export function createPtyOutputProcessor({
|
|||
if (titles.length > 0) {
|
||||
clearStaleTitleTimer()
|
||||
for (const title of titles) {
|
||||
if (isCursorNativeAgentTitle(title)) {
|
||||
// Why: identity for a hookless Cursor pane (#10258), never activity — the literal's
|
||||
// null status would read as an agent exit, and a repeat must not stomp hook state.
|
||||
if (!shouldSuppressCursorNativeTitle(lastEmittedTitle)) {
|
||||
applyObservedTerminalTitle(title, true)
|
||||
}
|
||||
continue
|
||||
}
|
||||
applyObservedTerminalTitle(title, suppressAgentTracker)
|
||||
}
|
||||
} else if (titleScanEffect === 'ignored-cursor-native') {
|
||||
|
|
|
|||
|
|
@ -28,16 +28,20 @@ type TitleFactEvent =
|
|||
type TitleFactPath = {
|
||||
events: TitleFactEvent[]
|
||||
feed: (chunk: string) => void
|
||||
flush: () => void
|
||||
}
|
||||
|
||||
function createRendererPath(): TitleFactPath {
|
||||
// Why deferDrain: flushing per chunk hides schedule-vs-drain divergence, because the renderer
|
||||
// decides what to queue against a `lastEmittedTitle` that only later chunks advance.
|
||||
function createRendererPath(initialTitle?: string, deferDrain = false): TitleFactPath {
|
||||
const events: TitleFactEvent[] = []
|
||||
const processor = createPtyOutputProcessor({
|
||||
onTitleChange: (normalized, raw) => events.push({ kind: 'title', normalized, raw }),
|
||||
onAgentBecameWorking: () => events.push({ kind: 'became-working' }),
|
||||
onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }),
|
||||
onAgentExited: () => events.push({ kind: 'agent-exited' }),
|
||||
onBell: () => events.push({ kind: 'bell' })
|
||||
onBell: () => events.push({ kind: 'bell' }),
|
||||
initialAgentTitle: initialTitle
|
||||
})
|
||||
const callbacks = { onData: () => {} }
|
||||
return {
|
||||
|
|
@ -47,28 +51,36 @@ function createRendererPath(): TitleFactPath {
|
|||
// Why: the renderer defers side effects behind a setTimeout(0) drain to
|
||||
// protect xterm paint. Flush synchronously so both paths observe each
|
||||
// chunk at the same fake-timer instant.
|
||||
processor.flushPendingSideEffects()
|
||||
}
|
||||
if (!deferDrain) {
|
||||
processor.flushPendingSideEffects()
|
||||
}
|
||||
},
|
||||
flush: () => processor.flushPendingSideEffects()
|
||||
}
|
||||
}
|
||||
|
||||
function createMainPath(): TitleFactPath {
|
||||
function createMainPath(initialTitle?: string): TitleFactPath {
|
||||
const events: TitleFactEvent[] = []
|
||||
// Why: mirrors OrcaRuntimeService.onPtyData — the per-PTY OSC 9999
|
||||
// processor strips status payloads before the title tracker sees the chunk.
|
||||
const processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
const tracker = createTerminalTitleTracker({
|
||||
onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }),
|
||||
onAgentBecameWorking: () => events.push({ kind: 'became-working' }),
|
||||
onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }),
|
||||
onAgentExited: () => events.push({ kind: 'agent-exited' }),
|
||||
onBell: () => events.push({ kind: 'bell' })
|
||||
})
|
||||
const tracker = createTerminalTitleTracker(
|
||||
{
|
||||
onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }),
|
||||
onAgentBecameWorking: () => events.push({ kind: 'became-working' }),
|
||||
onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }),
|
||||
onAgentExited: () => events.push({ kind: 'agent-exited' }),
|
||||
onBell: () => events.push({ kind: 'bell' })
|
||||
},
|
||||
initialTitle !== undefined ? { initialTitle } : undefined
|
||||
)
|
||||
return {
|
||||
events,
|
||||
feed(chunk: string): void {
|
||||
tracker.handleChunk(processAgentStatusChunk(chunk).cleanData)
|
||||
}
|
||||
},
|
||||
// Why: main applies every title inline, so there is nothing to defer.
|
||||
flush: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +127,63 @@ describe('main title tracker parity with the renderer transport processor', () =
|
|||
expect(paths.main.events).toContainEqual({ kind: 'became-idle', title: 'Codex done' })
|
||||
})
|
||||
|
||||
// Why: #10258 — a hookless Cursor pane's only identity is the bare literal, so both
|
||||
// paths must emit it once (never as an agent state) before the repeats are dropped.
|
||||
it('emits the bare cursor-agent native title once in both paths', () => {
|
||||
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
|
||||
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([
|
||||
{ kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' }
|
||||
])
|
||||
})
|
||||
|
||||
it('lets a synthesized Cursor title follow the native literal in both paths', () => {
|
||||
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
|
||||
feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`)
|
||||
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events.map((event) => event.kind)).toEqual([
|
||||
'title',
|
||||
'title',
|
||||
'became-working'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a restored native Cursor title identity-only before synthesized work', () => {
|
||||
const restoredPaths = {
|
||||
renderer: createRendererPath('Cursor Agent'),
|
||||
main: createMainPath('Cursor Agent')
|
||||
}
|
||||
|
||||
feedBoth(restoredPaths, `${ESC}]0;Cursor Agent${BEL}`)
|
||||
expect(restoredPaths.main.events).toEqual([])
|
||||
|
||||
feedBoth(restoredPaths, `${ESC}]0;⠋ Cursor Agent${BEL}`)
|
||||
|
||||
expect(restoredPaths.main.events).toEqual(restoredPaths.renderer.events)
|
||||
expect(restoredPaths.main.events).toEqual([
|
||||
{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' },
|
||||
{ kind: 'became-working' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps native/synthesized Cursor title order inside one coalesced chunk', () => {
|
||||
feedBoth(
|
||||
paths,
|
||||
`${ESC}]0;Cursor Agent${BEL}${ESC}]0;⠋ Cursor Agent${BEL}${ESC}]0;Cursor Agent${BEL}`
|
||||
)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events).toEqual([
|
||||
{ kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' },
|
||||
{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' },
|
||||
{ kind: 'became-working' }
|
||||
])
|
||||
})
|
||||
|
||||
it('drops the bare cursor-agent native title in both paths', () => {
|
||||
feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`)
|
||||
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
|
||||
|
|
@ -124,6 +193,41 @@ describe('main title tracker parity with the renderer transport processor', () =
|
|||
expect(titles).toEqual([{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' }])
|
||||
})
|
||||
|
||||
// Why: once the shell prompt repaints the title, the literal is again the pane's only Cursor
|
||||
// identity — dropping it there would leave a re-entered cursor-agent with no row (#10258).
|
||||
it('re-emits the native literal after a non-Cursor title took the pane', () => {
|
||||
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
|
||||
feedBoth(paths, `${ESC}]0;zsh${BEL}${ESC}]0;Cursor Agent${BEL}`)
|
||||
|
||||
expect(paths.main.events).toEqual(paths.renderer.events)
|
||||
expect(paths.main.events.filter((event) => event.kind === 'title')).toEqual([
|
||||
{ kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' },
|
||||
{ kind: 'title', normalized: 'zsh', raw: 'zsh' },
|
||||
{ kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' }
|
||||
])
|
||||
})
|
||||
|
||||
// Why one flush: the renderer prunes titles when the chunk is scheduled but only advances its
|
||||
// emitted-title memory on drain, so chunks that drain together must not decide against a
|
||||
// predecessor that is already stale.
|
||||
it('re-emits the native literal across chunks that drain together', () => {
|
||||
const deferred = {
|
||||
renderer: createRendererPath(undefined, true),
|
||||
main: createMainPath()
|
||||
}
|
||||
|
||||
feedBoth(deferred, `${ESC}]0;Cursor Agent${BEL}`)
|
||||
// Why drain here: it leaves the renderer's emitted-title memory on the literal, which is
|
||||
// exactly the stale predecessor the next two chunks must not be judged against.
|
||||
deferred.renderer.flush()
|
||||
feedBoth(deferred, `${ESC}]0;zsh${BEL}`)
|
||||
feedBoth(deferred, `${ESC}]0;Cursor Agent${BEL}`)
|
||||
deferred.renderer.flush()
|
||||
|
||||
expect(deferred.main.events).toEqual(deferred.renderer.events)
|
||||
expect(deferred.main.events.filter((event) => event.kind === 'title')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('clears a stale working title after the 3s timeout in both paths', () => {
|
||||
feedBoth(paths, `${ESC}]0;. Claude working${BEL}`)
|
||||
feedBoth(paths, 'output with no title\r\n')
|
||||
|
|
|
|||
|
|
@ -272,6 +272,40 @@ describe('resolveTabAgentFromSignals', () => {
|
|||
).toBe('opencode')
|
||||
})
|
||||
|
||||
// Why: #8940 — an OpenCode session whose task text mentions Claude flipped the tab icon
|
||||
// to Claude Code as soon as its hook row went stale (restart, mobile, between turns).
|
||||
it('keeps an OpenCode tab OpenCode when its task title merely mentions Claude', () => {
|
||||
for (const title of [
|
||||
'OC | ⠋ ask claude about this',
|
||||
'⠋ OpenCode',
|
||||
'⠋ use Claude Sonnet',
|
||||
'⠋ claude 스타일로 리팩터',
|
||||
'OpenCode ready'
|
||||
]) {
|
||||
for (const hasObservedAgentSignal of [true, false]) {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
hasObservedAgentSignal,
|
||||
isRemote: false,
|
||||
title,
|
||||
hookAgent: null,
|
||||
launchAgent: 'opencode'
|
||||
})
|
||||
).toBe('opencode')
|
||||
}
|
||||
}
|
||||
// Real pane reuse: the title PRESENTS Claude, so it still reclaims the pane.
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
hasObservedAgentSignal: true,
|
||||
isRemote: false,
|
||||
title: '✳ Claude Code',
|
||||
hookAgent: null,
|
||||
launchAgent: 'opencode'
|
||||
})
|
||||
).toBe('claude')
|
||||
})
|
||||
|
||||
it('does not let an explicit title override launch identity before any activity is observed', () => {
|
||||
expect(
|
||||
resolveTabAgentFromSignals({
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import {
|
|||
resolveSiblingRetainedTabAgent,
|
||||
resolveSiblingTabAgent
|
||||
} from './tab-agent'
|
||||
import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
|
||||
import {
|
||||
isClaudeIdentityFrameTitle,
|
||||
resolveExplicitTerminalTitleAgentType
|
||||
} from '../../../shared/terminal-title-agent-type'
|
||||
import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner'
|
||||
import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title'
|
||||
import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner'
|
||||
|
|
@ -115,11 +118,16 @@ export function resolveTabAgentFromSignals(args: {
|
|||
)
|
||||
const priorIdentity = idleFocusedIdentity ?? launchAgent
|
||||
const nativeOpenCodeTitle = explicitTitleAgent === 'opencode' && isOpenCodeNativeTitle(args.title)
|
||||
// Why: a "claude" token in another agent's task text is a mention, not identity, so it must
|
||||
// not take a pane from its known owner — only a title that PRESENTS Claude may (#8940).
|
||||
const titleClaimsIdentity =
|
||||
explicitTitleAgent !== 'claude' || isClaudeIdentityFrameTitle(args.title)
|
||||
// Why: native OpenCode titles can reclaim stale launch intent before any observed hook signal.
|
||||
const titleReclaimsReusedPane =
|
||||
priorIdentity !== null &&
|
||||
explicitTitleAgent !== null &&
|
||||
explicitTitleAgent !== priorIdentity &&
|
||||
titleClaimsIdentity &&
|
||||
(args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle)
|
||||
// Why: native OpenCode titles lack a provider generation and cannot displace durable ownership.
|
||||
const titleAgent =
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export {
|
|||
isCursorNativeAgentTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isPiTerminalTitle,
|
||||
shouldSuppressCursorNativeTitle,
|
||||
STRONG_IDLE_KEYWORDS_RE,
|
||||
STRONG_WORKING_KEYWORDS_RE
|
||||
} from './agent-title-core'
|
||||
|
|
|
|||
|
|
@ -130,3 +130,10 @@ export function isCursorAgentTitle(title: string | null | undefined): boolean {
|
|||
// treat the controlled synthetic Cursor spinner title as Cursor identity.
|
||||
return /^[\u2800-\u28ff] Cursor Agent$/u.test(trimmed)
|
||||
}
|
||||
|
||||
// Why: cursor-agent re-emits its bare native title every redraw, which would stomp
|
||||
// Orca's hook-synthesized spinner state, but only once a Cursor-owned title already
|
||||
// owns the pane. A hookless Cursor pane still needs the literal once, for identity.
|
||||
export function shouldSuppressCursorNativeTitle(lastEmittedTitle: string | null): boolean {
|
||||
return lastEmittedTitle !== null && isCursorAgentTitle(lastEmittedTitle)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type SyntheticAgentTitleProfile
|
||||
} from './synthetic-agent-title'
|
||||
import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title'
|
||||
import { getWrapperTitleSegments } from './terminal-title-wrapper-segments'
|
||||
|
||||
type TitleProfileMatch = {
|
||||
profile: SyntheticAgentTitleProfile
|
||||
|
|
@ -36,18 +37,8 @@ function getProfileForTitleLabel(label: string | null): TitleLabelProfileMatch |
|
|||
* Resolves the synthetic title profile matching a given terminal title.
|
||||
*/
|
||||
function getProfileForTitle(title: string): TitleProfileMatch | null {
|
||||
// Multiplexers/session wrappers prefix dynamic titles with ` | `, so inspect
|
||||
// each suffix to preserve the inner compatible agent identity.
|
||||
const candidates = [title]
|
||||
let wrapperSeparatorIndex = title.indexOf(' | ')
|
||||
while (wrapperSeparatorIndex >= 0) {
|
||||
const wrappedPaneTitle = title.slice(wrapperSeparatorIndex + 3).trim()
|
||||
if (wrappedPaneTitle && !candidates.includes(wrappedPaneTitle)) {
|
||||
candidates.push(wrappedPaneTitle)
|
||||
}
|
||||
wrapperSeparatorIndex = title.indexOf(' | ', wrapperSeparatorIndex + 3)
|
||||
}
|
||||
|
||||
// Why each segment: a wrapper prefix must not hide the inner compatible agent identity.
|
||||
const candidates = getWrapperTitleSegments(title)
|
||||
let fallback: TitleProfileMatch | null = null
|
||||
for (const candidate of candidates) {
|
||||
const labelProfile = getProfileForTitleLabel(getAgentLabel(candidate))
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import {
|
|||
detectAgentStatusFromTitle,
|
||||
extractAllOscTitles,
|
||||
isCursorNativeAgentTitle,
|
||||
normalizeTerminalTitle
|
||||
normalizeTerminalTitle,
|
||||
shouldSuppressCursorNativeTitle
|
||||
} from './agent-detection'
|
||||
import { createBellDetector } from './terminal-bell-detector'
|
||||
import {
|
||||
|
|
@ -137,6 +138,10 @@ export function createTerminalTitleTracker(
|
|||
let staleTitleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// Why: flags the stale-timer clear so its idle callback carries timer provenance, not a genuine task-complete.
|
||||
let applyingStaleWorkingTitleClear = false
|
||||
const initialAgentStatusTitle =
|
||||
options.initialTitle !== undefined && !isCursorNativeAgentTitle(options.initialTitle)
|
||||
? options.initialTitle
|
||||
: undefined
|
||||
const agentTracker =
|
||||
onAgentBecameIdle || onAgentBecameWorking || onAgentExited
|
||||
? createAgentStatusTracker(
|
||||
|
|
@ -148,7 +153,7 @@ export function createTerminalTitleTracker(
|
|||
},
|
||||
onAgentBecameWorking,
|
||||
onAgentExited,
|
||||
options.initialTitle
|
||||
initialAgentStatusTitle
|
||||
)
|
||||
: null
|
||||
|
||||
|
|
@ -162,6 +167,13 @@ export function createTerminalTitleTracker(
|
|||
function applyObservedTitle(rawTitle: string): void {
|
||||
// Why: cursor-agent re-emits its bare native title mid-turn; passing it through would stomp Orca's synthesized spinner state.
|
||||
if (isCursorNativeAgentTitle(rawTitle)) {
|
||||
if (shouldSuppressCursorNativeTitle(lastEmittedTitle)) {
|
||||
return
|
||||
}
|
||||
// Why: a hookless Cursor pane needs the literal once so it has an identity (#10258),
|
||||
// but never as activity — its null status would read as an exit in the status tracker.
|
||||
lastEmittedTitle = normalizeTerminalTitle(rawTitle)
|
||||
onTitle?.(lastEmittedTitle, rawTitle)
|
||||
return
|
||||
}
|
||||
lastEmittedTitle = normalizeTerminalTitle(rawTitle)
|
||||
|
|
@ -258,12 +270,15 @@ export function createTerminalTitleTracker(
|
|||
handleChunk,
|
||||
applySyntheticTitleFrame,
|
||||
seedInitialTitle(rawTitle: string): void {
|
||||
// Why: the cursor-agent literal drop applies to seeds too — a bare native title would stomp synthesized spinner state.
|
||||
if (lastEmittedTitle !== null || !rawTitle || isCursorNativeAgentTitle(rawTitle)) {
|
||||
if (lastEmittedTitle !== null || !rawTitle) {
|
||||
return
|
||||
}
|
||||
lastEmittedTitle = normalizeTerminalTitle(rawTitle)
|
||||
agentTracker?.seedTitle(rawTitle)
|
||||
// Why: the cursor-agent literal seeds identity only — feeding its null status to the
|
||||
// tracker would make the next real frame look like an agent exit.
|
||||
if (!isCursorNativeAgentTitle(rawTitle)) {
|
||||
agentTracker?.seedTitle(rawTitle)
|
||||
}
|
||||
},
|
||||
restoreLastAgentExit(): AgentStatus | null {
|
||||
return agentTracker?.restoreLastExit() ?? null
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { getAgentLabel as getSharedAgentLabel } from './agent-title-identity'
|
|||
import { isOpenCodeNativeTitle } from './opencode-terminal-title'
|
||||
import {
|
||||
isClaudeAgent,
|
||||
isClaudeIdentityFrameTitle,
|
||||
isGrokRotatingWorkingTitle,
|
||||
resolveExplicitTerminalTitleAgentType,
|
||||
resolveTerminalTitleAgentType
|
||||
|
|
@ -90,6 +91,37 @@ describe('resolveExplicitTerminalTitleAgentType', () => {
|
|||
expect(resolveExplicitTerminalTitleAgentType('. Claude Code compare Opencode')).toBe('claude')
|
||||
})
|
||||
|
||||
// Why (#8940): only a title that PRESENTS Claude may take a pane from its known owner —
|
||||
// a "claude" token inside another agent's task text is a mention, not identity.
|
||||
it('separates Claude identity frames from an incidental claude token', () => {
|
||||
for (const title of [
|
||||
'Claude Code',
|
||||
'✳ Claude Code',
|
||||
'⠋ Claude Code',
|
||||
'claude',
|
||||
'. claude',
|
||||
'Claude Code ready',
|
||||
'Claude - action required',
|
||||
// A multiplexer prefix must not read as task text and cost Claude its identity.
|
||||
'zsh | ⠋ Claude Code',
|
||||
'tmux | dev | Claude Code ready'
|
||||
]) {
|
||||
expect(isClaudeIdentityFrameTitle(title)).toBe(true)
|
||||
}
|
||||
for (const title of [
|
||||
'⠋ use Claude Sonnet',
|
||||
'OC | ⠋ ask claude about this',
|
||||
'⠋ port the claude prompt',
|
||||
'. ship it with claude',
|
||||
'⠋ claude 스타일로 리팩터',
|
||||
'. Claude Code compare Opencode',
|
||||
'✳ investigating startup',
|
||||
'✳'
|
||||
]) {
|
||||
expect(isClaudeIdentityFrameTitle(title)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for plain shell and unknown titles', () => {
|
||||
expect(resolveExplicitTerminalTitleAgentType('Terminal 1')).toBeNull()
|
||||
expect(resolveExplicitTerminalTitleAgentType('zsh')).toBeNull()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import {
|
|||
titleHasAgentName
|
||||
} from './agent-name-token-match'
|
||||
import { isCursorAgentTitle } from './agent-title-core'
|
||||
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
|
||||
import { isOpenCodeNativeTitle } from './opencode-terminal-title'
|
||||
import { getWrapperTitleSegments } from './terminal-title-wrapper-segments'
|
||||
import {
|
||||
getPiCompatibleSyntheticAgentLabel,
|
||||
isLegacyPiCompatibleTitle
|
||||
|
|
@ -241,6 +243,26 @@ function hasGenericClaudeStatusPrefix(title: string): boolean {
|
|||
)
|
||||
}
|
||||
|
||||
// Claude's own name plus, at most, one of its status words — never free-form task text.
|
||||
const CLAUDE_IDENTITY_FRAME_RE =
|
||||
/^claude(?: code)?(?:\s+(?:ready|idle|done|working|thinking|running))?(?:\s*-\s*action required)?$/
|
||||
|
||||
/**
|
||||
* Whether a title PRESENTS Claude rather than merely mentioning it, once its leading
|
||||
* status decoration is stripped. A "claude" token inside free-form task text is a mention,
|
||||
* so it must not take a pane away from its known owner (#8940) — owner-blind consumers
|
||||
* keep using `resolveExplicitTerminalTitleAgentType`, whose token match is looser.
|
||||
*/
|
||||
export function isClaudeIdentityFrameTitle(title: string): boolean {
|
||||
// Why segments: a multiplexer prefix (`zsh | ⠋ Claude Code`) would otherwise read as task
|
||||
// text and cost a genuine Claude pane its identity.
|
||||
return getWrapperTitleSegments(title).some((segment) =>
|
||||
CLAUDE_IDENTITY_FRAME_RE.test(
|
||||
stripLeadingAgentTitleDecorationOrEmpty(segment).trim().toLowerCase()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): boolean {
|
||||
return (
|
||||
titleAgent === 'claude' &&
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
const WRAPPER_SEPARATOR = ' | '
|
||||
|
||||
/**
|
||||
* A wrapped terminal title split into the texts an identity check should consider, whole
|
||||
* title first and innermost pane title last. Multiplexers and session wrappers prefix the
|
||||
* pane's own dynamic title (`zsh | ⠋ Claude Code`), so a check anchored to the start of the
|
||||
* string would miss the agent that actually owns the pane.
|
||||
*/
|
||||
export function getWrapperTitleSegments(title: string): string[] {
|
||||
const segments = [title]
|
||||
let separatorIndex = title.indexOf(WRAPPER_SEPARATOR)
|
||||
while (separatorIndex >= 0) {
|
||||
const wrapped = title.slice(separatorIndex + WRAPPER_SEPARATOR.length).trim()
|
||||
if (wrapped && !segments.includes(wrapped)) {
|
||||
segments.push(wrapped)
|
||||
}
|
||||
separatorIndex = title.indexOf(WRAPPER_SEPARATOR, separatorIndex + WRAPPER_SEPARATOR.length)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import { runNodeScriptInTerminal } from './helpers/run-node-script-in-terminal'
|
||||
import { waitForRestoredTerminalInputReady } from './helpers/restored-terminal-input-readiness'
|
||||
import { worktreeRow } from './worktree-row-locators'
|
||||
|
||||
/**
|
||||
* Sidebar agent-row identity, driven by real OSC titles on real PTYs.
|
||||
*
|
||||
* #10258 — Cursor's only native OSC title is the literal `cursor agent`; it was
|
||||
* dropped unconditionally, so a hookless Cursor pane produced no sidebar row.
|
||||
* #8940 — an incidental `claude` token inside an OpenCode task title outranked
|
||||
* the pane's known owner, flipping the row label + identity icon to Claude Code.
|
||||
*/
|
||||
|
||||
// The literal Cursor emits on every redraw — the pane's ONLY identity signal.
|
||||
const CURSOR_NATIVE_OSC_TITLE = 'Cursor Agent'
|
||||
// An OpenCode task title that merely MENTIONS claude (see #8940).
|
||||
const OPENCODE_TASK_OSC_TITLE = '⠋ use Claude Sonnet'
|
||||
|
||||
/** Printed banner that proves the emitter ran; the OSC title trails it in the same chunk. */
|
||||
const PANE_HOLD_MARKER = 'agent pane holding'
|
||||
|
||||
// Why: the emitter must never exit — a returning shell prompt would repaint its own
|
||||
// cwd title over the agent title, which a real TUI holding the pane never allows.
|
||||
// Why one write: a later title-less chunk arms the stale-title probe, which strips
|
||||
// the working frame the row depends on.
|
||||
function oscTitleHolderScript(escapedTitle: string): string {
|
||||
return [
|
||||
`process.stdout.write('${PANE_HOLD_MARKER}\\r\\n\\u001b]0;${escapedTitle}\\u0007')`,
|
||||
'setInterval(() => {}, 1e9)',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function useFullAgentActivityRows(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const state = store.getState()
|
||||
// Why: 'full' renders every agent row with its label inline, so the proof
|
||||
// reads off the rendered sidebar instead of a collapsed summary pill.
|
||||
state.setAgentActivityDisplayMode('full')
|
||||
if (!state.worktreeCardProperties.includes('inline-agents')) {
|
||||
state.toggleWorktreeCardProperty('inline-agents')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function paneTitles(page: Page, tabId: string): Promise<string[]> {
|
||||
return page.evaluate((tabId) => {
|
||||
const byPane = window.__store?.getState().runtimePaneTitlesByTabId?.[tabId] ?? {}
|
||||
return Object.values(byPane).filter((title): title is string => typeof title === 'string')
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a terminal tab launched as `launchAgent`, exactly like the tab-bar quick
|
||||
* launch, and proves the shell round-trips a command before returning — a cold
|
||||
* PTY silently swallows the emitter command otherwise.
|
||||
*/
|
||||
async function openAgentTab(
|
||||
page: Page,
|
||||
worktreeId: string,
|
||||
launchAgent: 'cursor' | 'opencode'
|
||||
): Promise<{ tabId: string; ptyId: string }> {
|
||||
const tabId = await page.evaluate(
|
||||
({ worktreeId, launchAgent }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
const state = store.getState()
|
||||
const tab = state.createTab(worktreeId, undefined, undefined, {
|
||||
launchAgent
|
||||
})
|
||||
state.setActiveTab(tab.id)
|
||||
state.setActiveTabType('terminal')
|
||||
return tab.id
|
||||
},
|
||||
{ worktreeId, launchAgent }
|
||||
)
|
||||
await waitForActiveTerminalManager(page)
|
||||
// Why the raised budget: the first tab of a cold app can bind its PTY well past
|
||||
// the helper's 15s default on a loaded machine.
|
||||
const ptyId = await waitForActivePanePtyId(page, 45_000)
|
||||
expect(
|
||||
await waitForRestoredTerminalInputReady(page, ptyId, 20_000),
|
||||
`the shell in the ${launchAgent} tab never echoed a probe command`
|
||||
).toBe(true)
|
||||
return { tabId, ptyId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of each rendered sidebar agent row, read off the row's identity icon
|
||||
* tooltip (the first titled span in the row) — i.e. the glyph the user sees.
|
||||
* Read in one evaluate so a sidebar re-render cannot split the snapshot.
|
||||
*/
|
||||
function sidebarAgentRowIdentities(page: Page, agentListSelector: string): Promise<string[]> {
|
||||
return page.evaluate((selector) => {
|
||||
const list = document.querySelector(selector)
|
||||
return list
|
||||
? [...list.children]
|
||||
.map((row) => row.querySelector('span[title]')?.getAttribute('title') ?? '')
|
||||
.filter((identity) => identity.length > 0)
|
||||
.sort()
|
||||
: []
|
||||
}, agentListSelector)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the rendered row identities to hold still, then returns them, so the
|
||||
* claim below is asserted against one settled snapshot with a readable diff.
|
||||
*/
|
||||
async function settledSidebarAgentRowIdentities(
|
||||
page: Page,
|
||||
agentListSelector: string
|
||||
): Promise<string[]> {
|
||||
const requiredStableReads = 4
|
||||
let previousKey = ''
|
||||
let stableReads = 0
|
||||
let settled: string[] = []
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
settled = await sidebarAgentRowIdentities(page, agentListSelector)
|
||||
const key = JSON.stringify(settled)
|
||||
stableReads = key === previousKey && settled.length > 0 ? stableReads + 1 : 0
|
||||
previousKey = key
|
||||
return stableReads >= requiredStableReads
|
||||
},
|
||||
{
|
||||
timeout: 20_000,
|
||||
intervals: [200],
|
||||
message: 'the sidebar agent rows never settled on a stable set'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
return settled
|
||||
}
|
||||
|
||||
test('sidebar keeps a Cursor pane visible and an OpenCode pane out of Claude Code hands', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const worktreeId = await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await useFullAgentActivityRows(orcaPage)
|
||||
|
||||
const openCode = await openAgentTab(orcaPage, worktreeId, 'opencode')
|
||||
const openCodeScript = await runNodeScriptInTerminal(
|
||||
orcaPage,
|
||||
openCode.ptyId,
|
||||
// ⠋ is the braille spinner frame OpenCode paints ahead of its task text.
|
||||
oscTitleHolderScript('\\u280b use Claude Sonnet')
|
||||
)
|
||||
await waitForTerminalOutput(orcaPage, PANE_HOLD_MARKER, 15_000)
|
||||
// Precondition, not the claim under test: this title is filtered on neither
|
||||
// branch, so a failure here means the PTY never emitted it.
|
||||
await expect
|
||||
.poll(() => paneTitles(orcaPage, openCode.tabId), {
|
||||
timeout: 15_000,
|
||||
message: 'the OpenCode task title never reached the renderer'
|
||||
})
|
||||
.toContain(OPENCODE_TASK_OSC_TITLE)
|
||||
|
||||
const cursor = await openAgentTab(orcaPage, worktreeId, 'cursor')
|
||||
const cursorScript = await runNodeScriptInTerminal(
|
||||
orcaPage,
|
||||
cursor.ptyId,
|
||||
oscTitleHolderScript(CURSOR_NATIVE_OSC_TITLE)
|
||||
)
|
||||
// Settle gate: the emitter has run, so the literal has been offered to the title
|
||||
// pipeline — kept as Cursor identity on the fix, dropped on main.
|
||||
await waitForTerminalOutput(orcaPage, PANE_HOLD_MARKER, 15_000)
|
||||
|
||||
// Only the active worktree's card has agents, so this resolves to one list.
|
||||
const agentListSelector = `[data-worktree-sidebar] [aria-label="Agents"]`
|
||||
const agentList = worktreeRow(orcaPage, worktreeId).locator('[aria-label="Agents"]')
|
||||
await expect(agentList.locator('> div').first()).toBeVisible()
|
||||
|
||||
// #10258: the Cursor pane gets a row at all. #8940: the OpenCode pane stays OpenCode.
|
||||
expect(await settledSidebarAgentRowIdentities(orcaPage, agentListSelector)).toEqual([
|
||||
'Cursor',
|
||||
'OpenCode'
|
||||
])
|
||||
|
||||
// Both panes are on the card: the Cursor row exists at all (#10258) next to the
|
||||
// OpenCode row still labelled by its own task text (#8940).
|
||||
await expect(agentList.locator('> div')).toHaveCount(2)
|
||||
await expect(agentList).toContainText('Cursor')
|
||||
await expect(agentList).toContainText('use Claude Sonnet')
|
||||
|
||||
openCodeScript.cleanup()
|
||||
cursorScript.cleanup()
|
||||
})
|
||||
Loading…
Reference in New Issue