diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index 751adbd..d594002 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -97,22 +97,25 @@ private struct InsightPillSwitcher: View { let visibleModes: [InsightMode] var body: some View { - HStack(spacing: 4) { - ForEach(visibleModes) { mode in - Button { - selected = mode - } label: { - Text(mode.rawValue) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary)) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(selected == mode ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.10))) - ) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(visibleModes) { mode in + Button { + selected = mode + } label: { + Text(mode.rawValue) + .font(.system(size: 11, weight: .medium)) + .fixedSize() + .foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(selected == mode ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.10))) + ) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } } } diff --git a/src/classifier.ts b/src/classifier.ts index 9a5de49..b0d97bd 100644 --- a/src/classifier.ts +++ b/src/classifier.ts @@ -154,13 +154,22 @@ function classifyConversation(userMessage: string): TaskCategory { } function countRetries(turn: ParsedTurn): number { + const steps: string[][] = [] + for (const call of turn.assistantCalls) { + if (call.toolSequence && call.toolSequence.length > 0) { + steps.push(...call.toolSequence) + } else if (call.tools.length > 0) { + steps.push(call.tools) + } + } + let sawEditBeforeBash = false let sawBashAfterEdit = false let retries = 0 - for (const call of turn.assistantCalls) { - const hasEdit = call.tools.some(t => EDIT_TOOLS.has(t)) - const hasBash = call.tools.some(t => BASH_TOOLS.has(t)) + for (const tools of steps) { + const hasEdit = tools.some(t => EDIT_TOOLS.has(t)) + const hasBash = tools.some(t => BASH_TOOLS.has(t)) if (hasEdit) { if (sawBashAfterEdit) retries++ diff --git a/src/parser.ts b/src/parser.ts index 162d9c5..e899e93 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1560,6 +1560,7 @@ function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn { deduplicationKey: call.deduplicationKey, project: call.project, projectPath: call.projectPath, + toolSequence: call.toolSequence, }], } } @@ -1597,6 +1598,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall { bashCommands: call.bashCommands, deduplicationKey: call.deduplicationKey, cacheCreationOneHourTokens: u.cacheCreationOneHourTokens || undefined, + toolSequence: call.toolSequence, } } diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts index 87517d8..0f52c30 100644 --- a/src/providers/gemini.ts +++ b/src/providers/gemini.ts @@ -79,6 +79,7 @@ function parseSession(data: GeminiSession, seenKeys: Set): ParsedProvide let totalThoughts = 0 const allTools: string[] = [] const bashCommands: string[] = [] + const toolSequence: string[][] = [] let model = '' for (const msg of geminiMessages) { @@ -90,13 +91,16 @@ function parseSession(data: GeminiSession, seenKeys: Set): ParsedProvide if (msg.model && !model) model = msg.model if (msg.toolCalls) { + const msgTools: string[] = [] for (const tc of msg.toolCalls) { const mapped = toolNameMap[tc.displayName ?? ''] ?? toolNameMap[tc.name] ?? tc.displayName ?? tc.name allTools.push(mapped) + msgTools.push(mapped) if (mapped === 'Bash' && tc.args && typeof tc.args.command === 'string') { bashCommands.push(...extractBashCommands(tc.args.command)) } } + if (msgTools.length > 0) toolSequence.push(msgTools) } } @@ -137,6 +141,7 @@ function parseSession(data: GeminiSession, seenKeys: Set): ParsedProvide costUSD, tools: [...new Set(allTools)], bashCommands: [...new Set(bashCommands)], + toolSequence: toolSequence.length > 1 ? toolSequence : undefined, timestamp: tsDate.toISOString(), speed: 'standard', deduplicationKey: dedupKey, diff --git a/src/providers/goose.ts b/src/providers/goose.ts index 9f4abe5..6a10d1d 100644 --- a/src/providers/goose.ts +++ b/src/providers/goose.ts @@ -80,14 +80,15 @@ function parseModelConfig(raw: string | null): ModelConfig { } } -function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tools: string[]; bashCommands: string[] } { +function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tools: string[]; bashCommands: string[]; toolSequence: string[][] } { const tools: string[] = [] const bashCommands: string[] = [] const seen = new Set() + const toolSequence: string[][] = [] try { const rows = db.query<{ content_json: Uint8Array | string }>( - "SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'assistant' AND content_json LIKE '%toolRequest%'", + "SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'assistant' AND content_json LIKE '%toolRequest%' ORDER BY created_timestamp ASC", [sessionId], ) @@ -98,6 +99,7 @@ function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tool } catch { continue } + const msgTools: string[] = [] for (const item of items) { if (item.type !== 'toolRequest') continue const rawName = item.toolCall?.value?.name ?? '' @@ -107,6 +109,7 @@ function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tool seen.add(mapped) tools.push(mapped) } + msgTools.push(mapped) if (mapped === 'Bash') { const cmd = item.toolCall?.value?.arguments?.command if (typeof cmd === 'string') { @@ -116,10 +119,11 @@ function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tool } } } + if (msgTools.length > 0) toolSequence.push(msgTools) } } catch { /* best-effort */ } - return { tools, bashCommands } + return { tools, bashCommands, toolSequence } } function getFirstUserMessage(db: SqliteDatabase, sessionId: string): string { @@ -179,7 +183,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const model = config.model_name ?? 'unknown' const costUSD = calculateCost(model, inputTokens, outputTokens, 0, 0, 0) - const { tools, bashCommands } = extractToolsFromMessages(db, sessionId) + const { tools, bashCommands, toolSequence } = extractToolsFromMessages(db, sessionId) const userMessage = getFirstUserMessage(db, sessionId) const raw = session.updated_at || session.created_at || '' @@ -200,6 +204,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars costUSD, tools, bashCommands, + toolSequence: toolSequence.length > 1 ? toolSequence : undefined, timestamp: ts.toISOString(), speed: 'standard', deduplicationKey: dedupKey, diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index 118bd06..7d616d7 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -86,6 +86,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s let pendingUserMessage = '' const allTools: string[] = [] + const toolSequence: string[][] = [] for (const msg of chat) { if (msg.role === 'human') { @@ -93,7 +94,9 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s pendingUserMessage = msg.content.slice(0, 500) } if (msg.role === 'bot') { - allTools.push(...extractToolNames(msg.content)) + const msgTools = extractToolNames(msg.content) + allTools.push(...msgTools) + if (msgTools.length > 0) toolSequence.push(msgTools) } } @@ -125,6 +128,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s costUSD, tools: [...new Set(allTools)], bashCommands: [], + toolSequence: toolSequence.length > 1 ? toolSequence : undefined, timestamp, speed: 'standard', deduplicationKey: dedupKey, diff --git a/src/providers/mistral-vibe.ts b/src/providers/mistral-vibe.ts index 7feb988..77e083c 100644 --- a/src/providers/mistral-vibe.ts +++ b/src/providers/mistral-vibe.ts @@ -216,18 +216,21 @@ function parseToolArguments(raw: string | Record | null | undef } } -function extractTools(messages: VibeMessage[]): { tools: string[]; bashCommands: string[] } { +function extractTools(messages: VibeMessage[]): { tools: string[]; bashCommands: string[]; toolSequence: string[][] } { const tools: string[] = [] const bashCommands: string[] = [] + const toolSequence: string[][] = [] for (const message of messages) { if (message.role !== 'assistant') continue + const msgTools: string[] = [] for (const toolCall of message.tool_calls ?? []) { const rawName = toolCall.function?.name if (!rawName) continue const mappedName = toolNameMap[rawName] ?? rawName tools.push(mappedName) + msgTools.push(mappedName) if (mappedName !== 'Bash') continue const args = parseToolArguments(toolCall.function?.arguments) @@ -236,11 +239,13 @@ function extractTools(messages: VibeMessage[]): { tools: string[]; bashCommands: bashCommands.push(...extractBashCommands(command)) } } + if (msgTools.length > 0) toolSequence.push(msgTools) } return { tools: [...new Set(tools)], bashCommands: [...new Set(bashCommands)], + toolSequence, } } @@ -287,7 +292,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const messages = await readMessages(messagesPath) const model = resolveModel(metadata) - const { tools, bashCommands } = extractTools(messages) + const { tools, bashCommands, toolSequence } = extractTools(messages) const costUSD = calculateSessionCost(metadata, model, inputTokens, outputTokens) yield { @@ -303,6 +308,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars costUSD, tools, bashCommands, + toolSequence: toolSequence.length > 1 ? toolSequence : undefined, timestamp: metadata.end_time ?? metadata.start_time ?? '', speed: 'standard', deduplicationKey, diff --git a/src/providers/types.ts b/src/providers/types.ts index 90d5e1c..03bc9ba 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -29,6 +29,7 @@ export type ParsedProviderCall = { sessionId: string project?: string projectPath?: string + toolSequence?: string[][] } export type Provider = { diff --git a/src/session-cache.ts b/src/session-cache.ts index 2537a7d..e4df86d 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -29,6 +29,7 @@ export type CachedCall = { deduplicationKey: string project?: string projectPath?: string + toolSequence?: string[][] } export type CachedTurn = { diff --git a/src/types.ts b/src/types.ts index 312906d..7e362e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,6 +84,7 @@ export type ParsedApiCall = { bashCommands: string[] deduplicationKey: string cacheCreationOneHourTokens?: number + toolSequence?: string[][] } export type TaskCategory = diff --git a/tests/classifier.test.ts b/tests/classifier.test.ts index 6a02d64..fc1fad2 100644 --- a/tests/classifier.test.ts +++ b/tests/classifier.test.ts @@ -151,3 +151,46 @@ describe('classifyTurn — feature vs debugging precedence (#196)', () => { expect(c.category).toBe('debugging') }) }) + +describe('classifyTurn — retry detection via toolSequence', () => { + it('detects retries from multi-call turns (Claude-style)', () => { + const turn = makeTurn([ + makeCall({ tools: ['Edit'] }), + makeCall({ tools: ['Bash'] }), + makeCall({ tools: ['Edit'] }), + ], 'fix the build') + const c = classifyTurn(turn) + expect(c.retries).toBe(1) + }) + + it('detects retries from toolSequence on a single call (Gemini/Mistral-style)', () => { + const call = makeCall({ tools: ['Edit', 'Bash'] }) + call.toolSequence = [['Edit'], ['Bash'], ['Edit']] + const turn = makeTurn([call], 'fix the build') + const c = classifyTurn(turn) + expect(c.retries).toBe(1) + }) + + it('returns 0 retries for single call without toolSequence (aggregated tools)', () => { + const call = makeCall({ tools: ['Edit', 'Bash'] }) + const turn = makeTurn([call], 'fix the build') + const c = classifyTurn(turn) + expect(c.retries).toBe(0) + }) + + it('counts multiple retries from toolSequence', () => { + const call = makeCall({ tools: ['Edit', 'Bash'] }) + call.toolSequence = [['Edit'], ['Bash'], ['Edit'], ['Bash'], ['Edit']] + const turn = makeTurn([call], 'fix the build') + const c = classifyTurn(turn) + expect(c.retries).toBe(2) + }) + + it('ignores toolSequence with only one step', () => { + const call = makeCall({ tools: ['Edit', 'Bash'] }) + call.toolSequence = [['Edit', 'Bash']] + const turn = makeTurn([call], 'fix the build') + const c = classifyTurn(turn) + expect(c.retries).toBe(0) + }) +})