diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 81671c20616..948228984d8 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -5711,9 +5711,10 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy, repos: visibleReposForRows, worktreesByRepo, + visibleWorktrees, filterRepoIds }) - }, [filterRepoIds, groupBy, visibleReposForRows, worktreesByRepo]) + }, [filterRepoIds, groupBy, visibleReposForRows, visibleWorktrees, worktreesByRepo]) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) // Why: buildRows only needs which creates exist and their repo. Subscribe on a diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts index aaa3be238e9..8a33151f73a 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts @@ -38,6 +38,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: { [repo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [] }) ) @@ -51,6 +52,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: {}, + visibleWorktrees: [], filterRepoIds: [] }) ) @@ -67,6 +69,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [selectedRepo, hiddenRepo], worktreesByRepo: { [selectedRepo.id]: [], [hiddenRepo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [selectedRepo.id] }) ) @@ -79,6 +82,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'none', repos: [repo], worktreesByRepo: { [repo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [] }).size ).toBe(0) @@ -90,8 +94,109 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: { [repo.id]: [worktree] }, + visibleWorktrees: [], filterRepoIds: [] }).size ).toBe(0) }) + + it('keeps grouped repos visible when workspace filters hide all of their rows', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [groupedRepo], + worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] }, + visibleWorktrees: [], + filterRepoIds: [] + }) + ) + ).toEqual([groupedRepo.id]) + }) + + it('does not create a grouped repo placeholder when one of its workspaces is visible', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id } + + expect( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [groupedRepo], + worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] }, + visibleWorktrees: [groupedWorktree], + filterRepoIds: [] + }).size + ).toBe(0) + }) + + it('still respects explicit project filters for sleep-filtered grouped members', () => { + const selected: Repo = { ...repo, id: 'repo-selected', projectGroupId: 'group-1' } + const filteredOut: Repo = { ...repo, id: 'repo-hidden', projectGroupId: 'group-1' } + const selectedWt: Worktree = { ...worktree, id: 'wt-selected', repoId: selected.id } + const hiddenWt: Worktree = { ...worktree, id: 'wt-hidden', repoId: filteredOut.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [selected, filteredOut], + worktreesByRepo: { + [selected.id]: [selectedWt], + [filteredOut.id]: [hiddenWt] + }, + // Why: simulate Hide sleeping removing every card while the project + // filter still intentionally excludes `filteredOut`. + visibleWorktrees: [], + filterRepoIds: [selected.id] + }) + ) + ).toEqual([selected.id]) + }) + + it('placeholders only the fully-filtered members of a multi-project group', () => { + const sleeping: Repo = { ...repo, id: 'repo-sleeping', projectGroupId: 'group-1' } + const awake: Repo = { ...repo, id: 'repo-awake', projectGroupId: 'group-1' } + const sleepingWt: Worktree = { ...worktree, id: 'wt-sleeping', repoId: sleeping.id } + const awakeWt: Worktree = { ...worktree, id: 'wt-awake', repoId: awake.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [sleeping, awake], + worktreesByRepo: { + [sleeping.id]: [sleepingWt], + [awake.id]: [awakeWt] + }, + visibleWorktrees: [awakeWt], + filterRepoIds: [] + }) + ) + ).toEqual([sleeping.id]) + }) + + it('does not placeholder ungrouped neighbors of a filtered grouped member', () => { + const grouped: Repo = { ...repo, id: 'repo-grouped', projectGroupId: 'group-1' } + const ungrouped: Repo = { ...repo, id: 'repo-ungrouped' } + const groupedWt: Worktree = { ...worktree, id: 'wt-grouped', repoId: grouped.id } + const ungroupedWt: Worktree = { ...worktree, id: 'wt-ungrouped', repoId: ungrouped.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [grouped, ungrouped], + worktreesByRepo: { + [grouped.id]: [groupedWt], + [ungrouped.id]: [ungroupedWt] + }, + visibleWorktrees: [], + filterRepoIds: [] + }) + ) + ).toEqual([grouped.id]) + }) }) diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts index 26c248a2a65..57aee59c1bd 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts @@ -5,6 +5,7 @@ export function getEmptyProjectPlaceholderRepoIds(args: { groupBy: WorktreeGroupBy repos: readonly Repo[] worktreesByRepo: Readonly> + visibleWorktrees: readonly Worktree[] filterRepoIds: readonly string[] }): Set { if (args.groupBy !== 'repo') { @@ -12,12 +13,17 @@ export function getEmptyProjectPlaceholderRepoIds(args: { } const filterSet = args.filterRepoIds.length > 0 ? new Set(args.filterRepoIds) : null + const visibleRepoIds = new Set(args.visibleWorktrees.map((worktree) => worktree.repoId)) const placeholderRepoIds = new Set() for (const repo of args.repos) { if (filterSet && !filterSet.has(repo.id)) { continue } - if ((args.worktreesByRepo[repo.id]?.length ?? 0) === 0) { + const hasNoWorktrees = (args.worktreesByRepo[repo.id]?.length ?? 0) === 0 + // Why: workspace filters hide cards, but must not rewrite the visible + // membership of a persisted Project Group. #8865 + const isFilteredProjectGroupMember = repo.projectGroupId != null && !visibleRepoIds.has(repo.id) + if (hasNoWorktrees || isFilteredProjectGroupMember) { placeholderRepoIds.add(repo.id) } } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index b967c4cd356..4b4012f8c7c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -1933,6 +1933,73 @@ describe('project groups', () => { expect(rows[0]).toMatchObject({ label: 'Platform' }) }) + it('keeps sleep-filtered Project Group members as empty project headers', () => { + // Why: #8865 — Hide sleeping removes workspace cards; membership placeholders + // must still project the grouped project header so the group count stays honest. + const group: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const sleepingRepo: Repo = { + ...repo, + id: 'repo-sleeping', + displayName: 'sleeping-project', + projectGroupId: group.id + } + const awakeRepo: Repo = { + ...repo, + id: 'repo-awake', + displayName: 'awake-project', + projectGroupId: group.id + } + const awakeWorktree: Worktree = { + ...worktree, + id: 'wt-awake', + repoId: awakeRepo.id, + path: '/tmp/awake' + } + + const rows = buildRows( + 'repo', + [awakeWorktree], + new Map([ + [sleepingRepo.id, sleepingRepo], + [awakeRepo.id, awakeRepo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[awakeWorktree.id, awakeWorktree]]), + false, + undefined, + [group], + new Set([sleepingRepo.id]) + ) + + expect(rows[0]).toMatchObject({ + type: 'header', + key: 'project-group:group-1', + count: 2 + }) + // Why: empty/placeholder projects sort after projects with visible activity. + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-1', + `repo:${awakeRepo.id}`, + `repo:${sleepingRepo.id}` + ]) + }) + it('renders ungrouped repos as top-level repo rows when Project Groups exist', () => { const group: ProjectGroup = { id: 'group-1', diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 9152159f14b..d2283db7dea 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -258,6 +258,20 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('claude') }) + // Why: #8478 — OpenCode native `OC | …` titles must reclaim a stale Claude + // launch identity so the tab icon is OpenCode, not Claude. + it('uses OpenCode native session titles to replace stale Claude launch identity', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'OC | Understand about the plugin', + hookAgent: null, + launchAgent: 'claude' + }) + ).toBe('opencode') + }) + it('does not let an explicit title override launch identity before any activity is observed', () => { expect( resolveTabAgentFromSignals({ diff --git a/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts b/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts index 6095f4e1840..385a7764fd1 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts @@ -16,6 +16,7 @@ function makeState(overrides: Partial = {}): AppState { activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, openFiles: [], editorDrafts: {}, activeTabId: null, diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index 2de176df7b9..d1082d89cab 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -26,6 +26,7 @@ function makeState(overrides: Partial = {}): AppState { activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, openFiles: [], editorDrafts: {}, activeTabId: null, @@ -60,6 +61,7 @@ function makeSharedOverrides(): Partial { activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, openFiles: [], editorDrafts: {}, agentStatusByPaneKey: {}, diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index e735e130004..fb32a847941 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -1496,7 +1496,7 @@ function buildMobileBrowserTab( // back through ?? would resurrect a stale workspace-level error. loadError: activePage ? activePage.loadError : workspace.loadError, certificateFailure: activePage - ? (state.browserCertificateFailuresByPageId[activePage.id] ?? null) + ? (state.browserCertificateFailuresByPageId?.[activePage.id] ?? null) : null, color: unifiedTab?.color ?? null, isPinned: unifiedTab?.isPinned === true, diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 891f37093db..4fbfff86f24 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -18,6 +18,7 @@ export { STRONG_IDLE_KEYWORDS_RE, STRONG_WORKING_KEYWORDS_RE } from './agent-title-core' +export { isOpenCodeNativeTitle, isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' export { getAgentLabel, isClaudeAgent } from './agent-title-identity' export { clearWorkingIndicators, diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index 9f7746b01bc..af86de1667d 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -10,6 +10,7 @@ import { isPiAgentTitle, titleHasAgentName } from './agent-title-core' +import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' /** @@ -17,7 +18,7 @@ import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-ti * Used to scope prompt-cache-timer behavior to Claude sessions only. */ export function isClaudeAgent(title: string): boolean { - if (!title || isClaudeManagementTitle(title)) { + if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } const lower = title.toLowerCase() @@ -46,6 +47,11 @@ export function getAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } + // Why: the native marker owns the whole title; its session text may name or + // include status glyphs from other agents without changing OpenCode identity. + if (isOpenCodeNativeTitle(title)) { + return 'OpenCode' + } // Why: Claude task titles can mention another CLI; the prefix is the identity // signal, not arbitrary task text. if ( diff --git a/src/shared/opencode-terminal-title.test.ts b/src/shared/opencode-terminal-title.test.ts index fc1b7806e35..f1cb825e8cd 100644 --- a/src/shared/opencode-terminal-title.test.ts +++ b/src/shared/opencode-terminal-title.test.ts @@ -1,16 +1,23 @@ import { describe, expect, it } from 'vitest' -import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' +import { isMeaningfulOpenCodeTerminalTitle, isOpenCodeNativeTitle } from './opencode-terminal-title' describe('OpenCode terminal titles', () => { it('recognizes native session titles', () => { expect(isMeaningfulOpenCodeTerminalTitle('OC | Native Stable Session')).toBe(true) expect(isMeaningfulOpenCodeTerminalTitle(' OC|Session ')).toBe(true) + expect(isOpenCodeNativeTitle('OC | Understand about the plugin')).toBe(true) + expect(isOpenCodeNativeTitle('tmux | OC | ses_123')).toBe(true) }) - it('rejects generic or incomplete OpenCode titles', () => { + it('rejects generic, incomplete, embedded, and lookalike titles', () => { expect(isMeaningfulOpenCodeTerminalTitle('OpenCode')).toBe(false) expect(isMeaningfulOpenCodeTerminalTitle('OpenCode ready')).toBe(false) expect(isMeaningfulOpenCodeTerminalTitle('OC |')).toBe(false) expect(isMeaningfulOpenCodeTerminalTitle(undefined)).toBe(false) + // Why: lowercase is not OpenCode's native marker; avoid "oc |" cwd/task noise. + expect(isOpenCodeNativeTitle('oc | Understand about the plugin')).toBe(false) + // Why: mid-title OC must not steal another agent's braille/task frame. + expect(isOpenCodeNativeTitle('⠋ Fix foo | OC | bar')).toBe(false) + expect(isOpenCodeNativeTitle('my session | OC | task')).toBe(false) }) }) diff --git a/src/shared/opencode-terminal-title.ts b/src/shared/opencode-terminal-title.ts index 64312b94be2..01d3d3819ae 100644 --- a/src/shared/opencode-terminal-title.ts +++ b/src/shared/opencode-terminal-title.ts @@ -1,4 +1,14 @@ +// Why: OpenCode abbreviates native OSC session titles as `OC | ` (no +// agent-name token). Optional single-token multiplexer prefix covers SSH/tmux +// frames like `tmux | OC | …`. Case-sensitive `OC` avoids ordinary lowercase +// "oc" lookalikes; require non-whitespace after the marker so bare `OC |` is not +// identity. Used for both display-title preservation and tab-agent identity. +const OPENCODE_NATIVE_TITLE_RE = /^(?:[^|\s]+ \| )?OC\s*\|\s*\S/u + +export function isOpenCodeNativeTitle(title: string | null | undefined): boolean { + return OPENCODE_NATIVE_TITLE_RE.test(title?.trim() ?? '') +} + export function isMeaningfulOpenCodeTerminalTitle(title: string | null | undefined): boolean { - // Why: bare OpenCode labels are status/identity; `OC | …` carries native session identity. - return /^OC\s*\|\s*\S/u.test(title?.trim() ?? '') + return isOpenCodeNativeTitle(title) } diff --git a/src/shared/repro-8478-opencode-native-title-icon.test.ts b/src/shared/repro-8478-opencode-native-title-icon.test.ts new file mode 100644 index 00000000000..554ac0c5ddb --- /dev/null +++ b/src/shared/repro-8478-opencode-native-title-icon.test.ts @@ -0,0 +1,49 @@ +/** + * Issue #8478 — OpenCode logo / icon not coming up well (Claude glyph on + * OpenCode tabs). + * + * Root cause (pre-fix): OpenCode's native OSC tab title format is `OC | `. + * Title classifiers only recognized OpenCode when the token "opencode" appeared. + * Native `OC | …` titles fell through, so AgentIcon rendered Claude/"?". + * + * OpenCode source (packages/tui/src/app.tsx) sets titles as: + * - "OpenCode" on home / default session titles + * - `OC | ${title}` for named sessions (title truncated at 40 chars) + * - `OC | ${pluginId}` on plugin routes + * + * Related: #8940 (OpenCode activity frames mislabeled Claude Code — separate + * braille-without-token path; not fully solved here). + * + * Re-run: + * pnpm exec vitest run --config config/vitest.config.ts \ + * src/shared/repro-8478-opencode-native-title-icon.test.ts + */ +import { describe, expect, it } from 'vitest' +import { getAgentLabel, isClaudeAgent } from './agent-detection' +import { + resolveExplicitTerminalTitleAgentType, + resolveTerminalTitleAgentType +} from './terminal-title-agent-type' + +describe('#8478 OpenCode native OC | titles map to OpenCode icon', () => { + it('recognizes OpenCode native "OC | …" title format as opencode identity', () => { + const native = 'OC | Understand about the plugin' + expect(getAgentLabel(native)).toBe('OpenCode') + expect(resolveTerminalTitleAgentType(native)).toBe('opencode') + expect(resolveExplicitTerminalTitleAgentType(native)).toBe('opencode') + expect(isClaudeAgent(native)).toBe(false) + }) + + // Why: bare "OpenCode" is what the TUI sets on home/default sessions; keep it + // classified so the icon path stays OpenCode without the OC | abbreviation. + it('keeps bare OpenCode home titles and Claude-style frames classified correctly', () => { + expect(getAgentLabel('OpenCode')).toBe('OpenCode') + expect(resolveTerminalTitleAgentType('OpenCode')).toBe('opencode') + expect(getAgentLabel('OpenCode ready')).toBe('OpenCode') + expect(resolveTerminalTitleAgentType('OpenCode ready')).toBe('opencode') + // Same family as #8940: braille/task frames without "opencode" still become Claude + // when they lack the native OC marker — documented, out of scope for this fix. + expect(isClaudeAgent('⠋ implementing the feature')).toBe(true) + expect(getAgentLabel('⠋ implementing the feature')).toBe('Claude Code') + }) +}) diff --git a/src/shared/terminal-title-agent-type.test.ts b/src/shared/terminal-title-agent-type.test.ts index d28dd43f503..d0fda7dcc45 100644 --- a/src/shared/terminal-title-agent-type.test.ts +++ b/src/shared/terminal-title-agent-type.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import { getAgentLabel as getSharedAgentLabel } from './agent-title-identity' +import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { isClaudeAgent, isGrokRotatingWorkingTitle, @@ -51,6 +53,39 @@ describe('resolveExplicitTerminalTitleAgentType', () => { expect(resolveExplicitTerminalTitleAgentType('* Review Codex behavior')).toBeNull() }) + it('resolves OpenCode native abbreviated session titles before task-text identities', () => { + expect(resolveExplicitTerminalTitleAgentType('OC | Understand about the plugin')).toBe( + 'opencode' + ) + expect(resolveExplicitTerminalTitleAgentType('OC | Compare Codex and Claude')).toBe('opencode') + // Why: Gemini glyphs inside OpenCode session text must not rebrand the tab. + expect(resolveExplicitTerminalTitleAgentType('OC | ✦ Gemini CLI')).toBe('opencode') + expect(getSharedAgentLabel('OC | Compare Codex and Claude')).toBe('OpenCode') + expect(getSharedAgentLabel('OC | ✦ Gemini CLI')).toBe('OpenCode') + expect(resolveExplicitTerminalTitleAgentType('tmux | OC | ses_123')).toBe('opencode') + expect(resolveExplicitTerminalTitleAgentType('OC|compact-session')).toBe('opencode') + expect(resolveExplicitTerminalTitleAgentType('oc | Understand about the plugin')).toBeNull() + }) + + it('does not find an OpenCode marker inside another agent task title', () => { + expect(isOpenCodeNativeTitle('⠋ Fix foo | OC | bar')).toBe(false) + expect(resolveExplicitTerminalTitleAgentType('⠋ Fix foo | OC | bar')).toBeNull() + }) + + // Why: adversarial coverage — native OC must not steal Claude/Codex/Cursor/ + // Gemini/Pi identity, and those agents must keep resolving when titled normally. + it('keeps other agents classified correctly alongside OpenCode native titles', () => { + expect(resolveExplicitTerminalTitleAgentType('✳ Claude Code')).toBe('claude') + expect(resolveExplicitTerminalTitleAgentType('⠋ Codex')).toBe('codex') + expect(resolveExplicitTerminalTitleAgentType('✦ Gemini CLI')).toBe('gemini') + expect(resolveExplicitTerminalTitleAgentType('Cursor Agent')).toBe('cursor') + expect(resolveExplicitTerminalTitleAgentType('Pi ready')).toBe('pi') + expect(resolveExplicitTerminalTitleAgentType('OpenCode ready')).toBe('opencode') + expect(resolveTerminalTitleAgentType('OC | ⠋ implementing the feature')).toBe('opencode') + expect(isClaudeAgent('OC | ⠋ implementing the feature')).toBe(false) + expect(isClaudeAgent('OC | Understand about the plugin')).toBe(false) + }) + it('still resolves Claude when the title explicitly names Claude', () => { expect(resolveExplicitTerminalTitleAgentType('. Claude Code compare Opencode')).toBe('claude') }) diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index ae7c97c09e7..fa92bb198c0 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -5,6 +5,7 @@ import { titleHasAgentName } from './agent-name-token-match' import { isCursorAgentTitle } from './agent-title-core' +import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel, isLegacyPiCompatibleTitle @@ -80,7 +81,7 @@ export function isPiAgentTitle(title: string): boolean { * agents have different (or no) caching semantics. */ export function isClaudeAgent(title: string): boolean { - if (!title || isClaudeManagementTitle(title)) { + if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } const lower = title.toLowerCase() @@ -124,6 +125,11 @@ export function getAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } + // Why: the native marker owns the whole title; its session text may name or + // include status glyphs from other agents without changing OpenCode identity. + if (isOpenCodeNativeTitle(title)) { + return 'OpenCode' + } // Why: Claude Code title text is often the task title. If that task mentions // another CLI, the Claude-specific prefix is the identity signal, not the words. if (