mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
* 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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 Gemini’s ◇ idle glyph, which neighbors the spinner block', () => {
|
||||
expect(detectAgentStatusFromTitle('◇ Gemini CLI')).toBe('idle')
|
||||
expect(getAgentLabel('◇ Gemini CLI')).toBe('Gemini CLI')
|
||||
})
|
||||
})
|
||||
@@ -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('. ') ||
|
||||
|
||||
Reference in New Issue
Block a user