diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index e12d197dda9..6f39d8c1e0b 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -2,7 +2,7 @@ import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id' -import { containsBrailleSpinner } from '../../../shared/agent-title-core' +import { containsAgentSpinnerGlyph } from '../../../shared/agent-title-core' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, @@ -106,7 +106,7 @@ function titleStatusIsAgentAttributable(title: string, launchAgent?: TuiAgent | // Why: a spinner proves activity but not identity (Claude's thinking title has no provider // token, #9040); the tab's launch identity supplies it, mirroring the row builder's spinner // fallback (#9647) so the dot and the sidebar row agree. - return containsBrailleSpinner(title) && Boolean(launchAgent) + return containsAgentSpinnerGlyph(title) && Boolean(launchAgent) } export function getWorktreeStatusLabel(status: WorktreeStatus): string { diff --git a/src/shared/agent-decorative-title-signature.ts b/src/shared/agent-decorative-title-signature.ts index 29f1a65e6a2..317a744d098 100644 --- a/src/shared/agent-decorative-title-signature.ts +++ b/src/shared/agent-decorative-title-signature.ts @@ -27,7 +27,10 @@ function normalizeDecorativeAgentTitleText(title: string): string { let pendingWhitespace = false for (let index = 0; index < title.length; index += 1) { const code = title.charCodeAt(index) - if (normalized.length === 0 && (isDecorativeTitleWhitespace(code) || isBrailleSpinner(code))) { + if ( + normalized.length === 0 && + (isDecorativeTitleWhitespace(code) || isSpinnerFrameGlyph(code)) + ) { continue } if (isDecorativeTitleWhitespace(code)) { @@ -43,8 +46,9 @@ function normalizeDecorativeAgentTitleText(title: string): string { return normalized } -function isBrailleSpinner(code: number): boolean { - return code >= 0x2800 && code <= 0x28ff +// Why: braille (most agents) plus quarter circles (Claude Code 2.1.228+, #13889). +function isSpinnerFrameGlyph(code: number): boolean { + return (code >= 0x2800 && code <= 0x28ff) || (code >= 0x25d0 && code <= 0x25d3) } function isDecorativeTitleWhitespace(code: number): boolean { diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts index 83dcc12bf92..c96121b432c 100644 --- a/src/shared/agent-title-core.ts +++ b/src/shared/agent-title-core.ts @@ -47,6 +47,11 @@ export const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent' // eslint-disable-next-line no-control-regex -- intentional unicode range export const BRAILLE_SPINNER_RE = /[\u2800-\u28ff]/g +// Why: Claude Code 2.1.228 swapped its busy title spinner from braille to +// quarter circles (#13889), which read as "no agent" and looked like an exit. +// Reserve the whole quarter-circle block so a later frame addition cannot regress this. +export const QUARTER_CIRCLE_SPINNER_RE = /[\u25d0-\u25d3]/g + export function isGeminiTerminalTitle(title: string): boolean { // Why: Gemini OSC glyphs are stronger evidence than any cwd/session text. if ( @@ -83,6 +88,25 @@ export function containsBrailleSpinner(title: string): boolean { return false } +export function containsQuarterCircleSpinner(title: string): boolean { + for (const char of title) { + const codePoint = char.codePointAt(0) + if (codePoint !== undefined && codePoint >= 0x25d0 && codePoint <= 0x25d3) { + return true + } + } + return false +} + +/** + * Any spinner frame glyph an agent animates its OSC title with. Use this for + * generic "something is running" checks; agent-specific frame shapes (Grok, + * Pi, synthetic Cursor) stay pinned to their own glyph set. + */ +export function containsAgentSpinnerGlyph(title: string): boolean { + return containsBrailleSpinner(title) || containsQuarterCircleSpinner(title) +} + export function containsLegacyAgentName(title: string): boolean { return titleHasAnyLegacyAgentName(title) } diff --git a/src/shared/agent-title-decoration.ts b/src/shared/agent-title-decoration.ts index bc05badf8aa..47a6db679e7 100644 --- a/src/shared/agent-title-decoration.ts +++ b/src/shared/agent-title-decoration.ts @@ -1,11 +1,11 @@ // Leading status decorations that coding agents prepend to their OSC title — -// Claude's '✳', Gemini's glyphs (✦ ⏲ ◇ ✋), braille spinners, and Claude's -// '. '/'* ' working/idle prefixes. Once the tab bar shows the provider icon, -// this leading glyph reads as a redundant second icon, so strip it from the -// displayed title. Scoped to titles we already know belong to an agent. +// Claude's '✳', Gemini's glyphs (✦ ⏲ ◇ ✋), braille and quarter-circle spinners, +// and Claude's '. '/'* ' working/idle prefixes. Once the tab bar shows the +// provider icon, this leading glyph reads as a redundant second icon, so strip +// it from the displayed title. Scoped to titles we already know belong to an agent. const LEADING_AGENT_TITLE_DECORATION_RE = // eslint-disable-next-line no-control-regex -- intentional unicode status-glyph ranges - /^(?:[✳✦⏲◇✋⠀-⣿]+|[.*]\s)\s*/ + /^(?:[✳✦⏲◇✋⠀-⣿◐-◓]+|[.*]\s)\s*/ export function stripLeadingAgentTitleDecorationOrEmpty(title: string): string { return title.replace(LEADING_AGENT_TITLE_DECORATION_RE, '').trimStart() diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index af86de1667d..cdfc6946777 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -3,7 +3,7 @@ import { CLAUDE_IDLE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, - containsBrailleSpinner, + containsAgentSpinnerGlyph, isClaudeManagementTitle, isCursorAgentTitle, isGeminiTerminalTitle, @@ -31,7 +31,7 @@ export function isClaudeAgent(title: string): boolean { if (title.startsWith('. ') || title.startsWith('* ')) { return true } - if (containsBrailleSpinner(title)) { + if (containsAgentSpinnerGlyph(title)) { // Why: named non-Claude agents carry braille spinners too. Gate Cursor by its // identity title, not the token, so a Claude title mentioning a cursor stays Claude. return !isCursorAgentTitle(title) && !lower.includes('openclaude') diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 09ffc07a677..551acf87328 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -9,12 +9,13 @@ import { GEMINI_SILENT_WORKING, GEMINI_WORKING, HERMES_AGENT_NAME_RE, + QUARTER_CIRCLE_SPINNER_RE, STRONG_IDLE_KEYWORDS_RE, STRONG_WORKING_KEYWORDS_RE, STRONG_WORKING_KEYWORDS_RE_GLOBAL, containsAgentName, + containsAgentSpinnerGlyph, containsAny, - containsBrailleSpinner, containsLegacyAgentName, isClaudeManagementTitle, isGeminiTerminalTitle, @@ -34,6 +35,7 @@ export function clearWorkingIndicators(title: string): string { cleaned = cleaned.replace(GEMINI_WORKING, '') cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') cleaned = cleaned.replace(BRAILLE_SPINNER_RE, '') + cleaned = cleaned.replace(QUARTER_CIRCLE_SPINNER_RE, '') if (cleaned.startsWith('. ')) { cleaned = cleaned.slice(2) } @@ -181,7 +183,7 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { if (isPiTerminalTitle(title)) { return 'idle' } - if (containsBrailleSpinner(title)) { + if (containsAgentSpinnerGlyph(title)) { return 'working' } diff --git a/src/shared/repro-13889-claude-quarter-circle-busy-title.test.ts b/src/shared/repro-13889-claude-quarter-circle-busy-title.test.ts new file mode 100644 index 00000000000..6156489a567 --- /dev/null +++ b/src/shared/repro-13889-claude-quarter-circle-busy-title.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' + +import { + clearWorkingIndicators, + createAgentStatusTracker, + detectAgentStatusFromTitle, + getAgentLabel, + isClaudeAgent +} from './agent-detection' +import { isDecorativeAgentTitleFrameChange } from './agent-decorative-title-signature' +import { stripLeadingAgentTitleDecoration } from './agent-title-decoration' +import { resolveExplicitTerminalTitleAgentType } from './terminal-title-agent-type' + +// Titles captured from real `claude` binaries running the same one-line prompt. +// 2.1.228 swapped the busy spinner from braille to quarter circles, which read as +// "no agent" and made the tracker report a confirmed exit mid-turn (#13889). +const BUSY_2_1_227 = ['⠂ Claude Code', '⠐ Claude Code', '⠂ Say hi in one word'] as const +const BUSY_2_1_228 = ['◐ Claude Code', '◑ Claude Code', '◑ Say hi in one word'] as const + +const CAPTURED_2_1_228_TURN = [ + '✳ Claude Code', + '◐ Claude Code', + '◑ Claude Code', + '◑ Say hi in one word', + '◐ Say hi in one word', + '✳ Say hi in one word' +] as const + +function trackTurn(titles: readonly string[]): string[] { + const events: string[] = [] + const tracker = createAgentStatusTracker( + () => events.push('idle'), + () => events.push('working'), + () => events.push('exited') + ) + for (const title of titles) { + tracker.handleTitle(title) + } + return events +} + +describe('Claude Code quarter-circle busy titles (#13889)', () => { + it('reports working for every quarter-circle spinner frame', () => { + for (const title of ['◐ Claude Code', '◑ Claude Code', '◒ Claude Code', '◓ Claude Code']) { + expect(detectAgentStatusFromTitle(title)).toBe('working') + } + }) + + it('reports working when the busy title carries task text instead of the agent name', () => { + // Why: the summary-bearing frame has no "claude" token, so it previously fell + // through to null — the value the tracker reads as an exit. + expect(detectAgentStatusFromTitle('◐ Say hi in one word')).toBe('working') + }) + + it('keeps Claude identity while busy', () => { + for (const title of BUSY_2_1_228) { + expect(isClaudeAgent(title)).toBe(true) + expect(getAgentLabel(title)).toBe('Claude Code') + } + }) + + it('matches 2.1.227 status and identity frame for frame', () => { + BUSY_2_1_228.forEach((title, index) => { + const braille = BUSY_2_1_227[index] + expect(detectAgentStatusFromTitle(title)).toBe(detectAgentStatusFromTitle(braille)) + expect(getAgentLabel(title)).toBe(getAgentLabel(braille)) + expect(resolveExplicitTerminalTitleAgentType(title)).toBe( + resolveExplicitTerminalTitleAgentType(braille) + ) + }) + }) + + it('never confirms an agent exit across a real 2.1.228 turn', () => { + const events = trackTurn(CAPTURED_2_1_228_TURN) + expect(events).not.toContain('exited') + expect(events).toEqual(['working', 'idle']) + }) + + it('tracks the 2.1.228 turn exactly like the 2.1.227 turn', () => { + const brailleTurn = CAPTURED_2_1_228_TURN.map((title) => + title.replace('◐', '⠂').replace('◑', '⠐') + ) + expect(trackTurn(CAPTURED_2_1_228_TURN)).toEqual(trackTurn(brailleTurn)) + }) + + it('strips the spinner from stale exit titles and displayed labels', () => { + expect(clearWorkingIndicators('◐ Say hi in one word')).toBe('Say hi in one word') + expect(stripLeadingAgentTitleDecoration('◐ Claude Code')).toBe('Claude Code') + }) + + it('treats a spinner tick as decoration, not a title change', () => { + expect(isDecorativeAgentTitleFrameChange('◐ Say hi', '◑ Say hi')).toBe(true) + }) + + it('does not claim Gemini’s ◇ idle glyph, which neighbors the spinner block', () => { + expect(detectAgentStatusFromTitle('◇ Gemini CLI')).toBe('idle') + expect(getAgentLabel('◇ Gemini CLI')).toBe('Gemini CLI') + }) +}) diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 7e38a227f34..0952d96e63f 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -4,7 +4,7 @@ import { HERMES_AGENT_NAME_RE, titleHasAgentName } from './agent-name-token-match' -import { isCursorAgentTitle } from './agent-title-core' +import { containsAgentSpinnerGlyph, isCursorAgentTitle } from './agent-title-core' import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration' import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' @@ -96,7 +96,7 @@ export function isClaudeAgent(title: string): boolean { if (title.startsWith('. ') || title.startsWith('* ')) { return true } - if (containsBrailleSpinner(title)) { + if (containsAgentSpinnerGlyph(title)) { // Why: named non-Claude agents carry braille spinners too. Gate Cursor by its // identity title, not the token, so a Claude title mentioning a cursor stays Claude. return !isCursorAgentTitle(title) && !lower.includes('openclaude') @@ -231,7 +231,7 @@ const TITLE_LABEL_TO_AGENT: Partial> = { function hasGenericClaudeStatusPrefix(title: string): boolean { return ( - containsBrailleSpinner(title) || + containsAgentSpinnerGlyph(title) || title.startsWith('✳ ') || title === '✳' || title.startsWith('. ') ||