fix(agent-title): treat Claude Code quarter-circle spinners as working (#13889) (#13925)

* fix(agent-title): treat Claude Code quarter-circle spinners as working

Claude Code 2.1.228 swapped its busy OSC title spinner from braille
(U+2800-U+28FF) to quarter circles (U+25D0/U+25D1). Orca recognized a busy
Claude title only by braille codepoints, so the new frames matched nothing.

The summary-bearing busy frame ("<glyph> Say hi in one word") carries no
"claude" name token, so it resolved to no-status. The tracker's "idle or
permission followed by no-status means the agent exited" rule then fired
mid-turn, confirmPtyAgentExit confirmed it, and the chat surface routed
exitChat -- kicking the tab to the terminal view on every message.

Widen the accepted glyph set via a shared containsAgentSpinnerGlyph helper.
Agent-specific braille frame shapes (Grok, Pi, synthetic Cursor) stay pinned
to their own glyph set.

Fixes #13889

* fix(agent-title): satisfy static analysis and trim scope
This commit is contained in:
Brennan Benson
2026-08-11 21:13:27 -07:00
committed by GitHub
parent 948d85cabc
commit 137e724119
8 changed files with 146 additions and 17 deletions
+2 -2
View File
@@ -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 {
@@ -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 {
+24
View File
@@ -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)
}
+5 -5
View File
@@ -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()
+2 -2
View File
@@ -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')
+4 -2
View File
@@ -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'
}
@@ -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 Geminis ◇ idle glyph, which neighbors the spinner block', () => {
expect(detectAgentStatusFromTitle('◇ Gemini CLI')).toBe('idle')
expect(getAgentLabel('◇ Gemini CLI')).toBe('Gemini CLI')
})
})
+3 -3
View File
@@ -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<Record<string, TuiAgent>> = {
function hasGenericClaudeStatusPrefix(title: string): boolean {
return (
containsBrailleSpinner(title) ||
containsAgentSpinnerGlyph(title) ||
title.startsWith('✳ ') ||
title === '✳' ||
title.startsWith('. ') ||