Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
15 changes: 12 additions & 3 deletions src/classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++
Expand Down
2 changes: 2 additions & 0 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,7 @@ function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn {
deduplicationKey: call.deduplicationKey,
project: call.project,
projectPath: call.projectPath,
toolSequence: call.toolSequence,
}],
}
}
Expand Down Expand Up @@ -1597,6 +1598,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
bashCommands: call.bashCommands,
deduplicationKey: call.deduplicationKey,
cacheCreationOneHourTokens: u.cacheCreationOneHourTokens || undefined,
toolSequence: call.toolSequence,
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/providers/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ function parseSession(data: GeminiSession, seenKeys: Set<string>): ParsedProvide
let totalThoughts = 0
const allTools: string[] = []
const bashCommands: string[] = []
const toolSequence: string[][] = []
let model = ''

for (const msg of geminiMessages) {
Expand All @@ -90,13 +91,16 @@ function parseSession(data: GeminiSession, seenKeys: Set<string>): 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)
}
}

Expand Down Expand Up @@ -137,6 +141,7 @@ function parseSession(data: GeminiSession, seenKeys: Set<string>): ParsedProvide
costUSD,
tools: [...new Set(allTools)],
bashCommands: [...new Set(bashCommands)],
toolSequence: toolSequence.length > 1 ? toolSequence : undefined,
timestamp: tsDate.toISOString(),
speed: 'standard',
deduplicationKey: dedupKey,
Expand Down
13 changes: 9 additions & 4 deletions src/providers/goose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
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],
)

Expand All @@ -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 ?? ''
Expand All @@ -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') {
Expand All @@ -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 {
Expand Down Expand Up @@ -179,7 +183,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): 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 || ''
Expand All @@ -200,6 +204,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
costUSD,
tools,
bashCommands,
toolSequence: toolSequence.length > 1 ? toolSequence : undefined,
timestamp: ts.toISOString(),
speed: 'standard',
deduplicationKey: dedupKey,
Expand Down
6 changes: 5 additions & 1 deletion src/providers/kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,17 @@ 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') {
if (msg.content.startsWith('<identity>')) continue
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)
}
}

Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions src/providers/mistral-vibe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,18 +216,21 @@ function parseToolArguments(raw: string | Record<string, unknown> | 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)
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -287,7 +292,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): 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 {
Expand All @@ -303,6 +308,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
costUSD,
tools,
bashCommands,
toolSequence: toolSequence.length > 1 ? toolSequence : undefined,
timestamp: metadata.end_time ?? metadata.start_time ?? '',
speed: 'standard',
deduplicationKey,
Expand Down
1 change: 1 addition & 0 deletions src/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type ParsedProviderCall = {
sessionId: string
project?: string
projectPath?: string
toolSequence?: string[][]
}

export type Provider = {
Expand Down
1 change: 1 addition & 0 deletions src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export type CachedCall = {
deduplicationKey: string
project?: string
projectPath?: string
toolSequence?: string[][]
}

export type CachedTurn = {
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export type ParsedApiCall = {
bashCommands: string[]
deduplicationKey: string
cacheCreationOneHourTokens?: number
toolSequence?: string[][]
}

export type TaskCategory =
Expand Down
43 changes: 43 additions & 0 deletions tests/classifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})