mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-ask-user-question-status-linger
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ export function getEmptyProjectPlaceholderRepoIds(args: {
|
||||
groupBy: WorktreeGroupBy
|
||||
repos: readonly Repo[]
|
||||
worktreesByRepo: Readonly<Record<string, readonly Worktree[] | undefined>>
|
||||
visibleWorktrees: readonly Worktree[]
|
||||
filterRepoIds: readonly string[]
|
||||
}): Set<string> {
|
||||
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<string>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeState(overrides: Partial<AppState> = {}): AppState {
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserCertificateFailuresByPageId: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
activeTabId: null,
|
||||
|
||||
@@ -26,6 +26,7 @@ function makeState(overrides: Partial<AppState> = {}): AppState {
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserCertificateFailuresByPageId: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
activeTabId: null,
|
||||
@@ -60,6 +61,7 @@ function makeSharedOverrides(): Partial<AppState> {
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
browserCertificateFailuresByPageId: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
agentStatusByPaneKey: {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
// Why: OpenCode abbreviates native OSC session titles as `OC | <task>` (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)
|
||||
}
|
||||
|
||||
@@ -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 | <task>`.
|
||||
* 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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user