mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 01:57:57 +00:00
169 lines
6.2 KiB
TypeScript
169 lines
6.2 KiB
TypeScript
// Resolves the stable "conversation name" an agent row can show instead of the
|
|
// live last-message preview. Sources, in the same precedence the tab bar uses
|
|
// (tab-title-resolution.ts): manual rename → quick-command label → OpenCode's
|
|
// semantic session title → provider session title → Orca's generated title →
|
|
// the agent-set live title.
|
|
// Live titles are accepted only when they carry a real name — pure status,
|
|
// identity-echo, and spinner/cwd titles yield null so callers keep the
|
|
// last-message label.
|
|
import type { AgentType } from './agent-status-types'
|
|
import { isClaudeManagementTitle } from './agent-title-core'
|
|
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
|
|
import { formatAgentTypeLabel } from './agent-type-label'
|
|
import { isJcodeIdentityTerminalTitle } from './jcode-terminal-title'
|
|
import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title'
|
|
import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title'
|
|
import type { TerminalTab } from './terminal-tab-types'
|
|
|
|
export type ConversationNameTab = Pick<
|
|
TerminalTab,
|
|
'customTitle' | 'quickCommandLabel' | 'aiVaultTitle' | 'generatedTitle' | 'title' | 'defaultTitle'
|
|
>
|
|
|
|
// Why: synthetic status titles ("Codex ready", "Cursor - action required") are
|
|
// state, not names. Precomputed once; the profile table is a module constant.
|
|
const SYNTHETIC_STATUS_TITLES_LOWER: ReadonlySet<string> = new Set(
|
|
Object.values(SYNTHETIC_AGENT_TITLE_PROFILES).flatMap((profile) => [
|
|
profile.workingLabel.toLowerCase(),
|
|
profile.permissionLabel.toLowerCase(),
|
|
profile.idleLabel.toLowerCase()
|
|
])
|
|
)
|
|
|
|
// Why: retained rows without a live tab synthesize `title: 'Agent'`
|
|
// (worktree-agent-row-fallback-tab.ts); it is a placeholder, not a name.
|
|
const FALLBACK_TAB_TITLE_LOWER = 'agent'
|
|
|
|
const AGENT_IDENTITY_ALIASES_LOWER: Readonly<Record<string, readonly string[]>> = {
|
|
claude: ['claude code'],
|
|
gemini: ['gemini cli']
|
|
}
|
|
|
|
const STATUS_WITH_CONTEXT_RE = /^(?:ready|idle|done)(?:\s+\([^)]*\))?$/i
|
|
const DEFAULT_TERMINAL_TITLE_RE = /^terminal \d+$/i
|
|
|
|
function isIdentityStatusTitle(titleLower: string, identityLower: string): boolean {
|
|
return (
|
|
titleLower === identityLower ||
|
|
titleLower === `${identityLower} ready` ||
|
|
titleLower === `${identityLower} idle` ||
|
|
titleLower === `${identityLower} done` ||
|
|
titleLower === `${identityLower} working` ||
|
|
titleLower === `${identityLower} thinking` ||
|
|
titleLower === `${identityLower} running` ||
|
|
titleLower === `${identityLower} - action required`
|
|
)
|
|
}
|
|
|
|
function isAgentIdentityStatusTitle(
|
|
titleLower: string,
|
|
agentType: AgentType | null | undefined,
|
|
agentTypeLabelLower: string
|
|
): boolean {
|
|
if (isIdentityStatusTitle(titleLower, agentTypeLabelLower)) {
|
|
return true
|
|
}
|
|
return (
|
|
AGENT_IDENTITY_ALIASES_LOWER[agentType ?? '']?.some((identity) =>
|
|
isIdentityStatusTitle(titleLower, identity)
|
|
) ?? false
|
|
)
|
|
}
|
|
|
|
function isCwdLikeTitle(title: string): boolean {
|
|
// Hook-less agents over SSH surface spinner+cwd titles (#8711); once the
|
|
// spinner is stripped, what remains is a path, not a conversation name.
|
|
if (/^(?:~|[\\/]|[A-Za-z]:[\\/])/.test(title)) {
|
|
return true
|
|
}
|
|
// A single path-ish token ("orca/workspaces") is still a cwd, not a name.
|
|
return !/\s/.test(title) && /[\\/]/.test(title)
|
|
}
|
|
|
|
function conversationNameFromLiveTitle(
|
|
liveTitle: string,
|
|
agentType: AgentType | null | undefined,
|
|
agentTypeLabelLower: string,
|
|
defaultTitle: string | undefined
|
|
): string | null {
|
|
const stripped = stripLeadingAgentTitleDecorationOrEmpty(liveTitle.trim()).trim()
|
|
if (!stripped) {
|
|
return null
|
|
}
|
|
const lower = stripped.toLowerCase()
|
|
if (
|
|
SYNTHETIC_STATUS_TITLES_LOWER.has(lower) ||
|
|
lower === FALLBACK_TAB_TITLE_LOWER ||
|
|
isAgentIdentityStatusTitle(lower, agentType, agentTypeLabelLower) ||
|
|
STATUS_WITH_CONTEXT_RE.test(stripped) ||
|
|
DEFAULT_TERMINAL_TITLE_RE.test(stripped) ||
|
|
isClaudeManagementTitle(stripped) ||
|
|
// Why: jcode repaints `jcode <codename> · +N -M · last ~23s` every second. The
|
|
// tail is live status and the head is its own identity, so accepting it as a
|
|
// name pins a row to "jcode Puppy…" where every other agent shows the prompt.
|
|
isJcodeIdentityTerminalTitle(stripped) ||
|
|
isCwdLikeTitle(stripped)
|
|
) {
|
|
return null
|
|
}
|
|
if (defaultTitle && stripped === defaultTitle.trim()) {
|
|
return null
|
|
}
|
|
return stripped
|
|
}
|
|
|
|
/**
|
|
* The conversation name for an agent row, or null when no usable name exists
|
|
* and the caller should keep its last-message label.
|
|
*/
|
|
export function getAgentRowConversationName(
|
|
tab: ConversationNameTab,
|
|
agentType: AgentType | null | undefined,
|
|
generatedTitlesEnabled: boolean,
|
|
// Why: `tab.title` carries only the FOCUSED pane's title, so in a split tab it
|
|
// names one pane and mislabels its siblings. Callers on a multi-pane tab pass
|
|
// this row's own pane title, or `null` when none resolves; `undefined` (a
|
|
// single-pane tab) keeps the tab title. Tab-owned names above are unaffected:
|
|
// the user gave those to the whole tab and they do not flip on focus.
|
|
paneLiveTitle?: string | null,
|
|
providerSessionId?: string
|
|
): string | null {
|
|
const customTitle = tab.customTitle?.trim()
|
|
if (customTitle) {
|
|
return customTitle
|
|
}
|
|
const quickCommandLabel = tab.quickCommandLabel?.trim()
|
|
if (quickCommandLabel) {
|
|
return quickCommandLabel
|
|
}
|
|
const liveTitle =
|
|
paneLiveTitle === undefined ? (tab.title?.trim() ?? '') : (paneLiveTitle?.trim() ?? '')
|
|
if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
|
|
return liveTitle
|
|
}
|
|
// Provider titles belong to their session, not every pane in the tab.
|
|
const aiVaultTitle = tab.aiVaultTitle
|
|
const providerTitle = aiVaultTitle?.title.trim()
|
|
if (
|
|
aiVaultTitle &&
|
|
providerTitle &&
|
|
aiVaultTitle.agent === agentType &&
|
|
aiVaultTitle.sessionId === providerSessionId
|
|
) {
|
|
return providerTitle
|
|
}
|
|
const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : ''
|
|
if (generatedTitle) {
|
|
return generatedTitle
|
|
}
|
|
if (!liveTitle) {
|
|
return null
|
|
}
|
|
return conversationNameFromLiveTitle(
|
|
liveTitle,
|
|
agentType,
|
|
formatAgentTypeLabel(agentType).toLowerCase(),
|
|
tab.defaultTitle
|
|
)
|
|
}
|