From 564191d6fa6f8659c0a110bbd87cf1de2a4d1c52 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Tue, 28 Apr 2026 03:05:13 +0200 Subject: [PATCH 1/4] Add Kiro provider and transparent auto-model naming - Add Kiro IDE provider: parses .chat JSON files, estimates tokens, normalizes dot-versioned model IDs for cost lookup - Show "Cursor (auto)", "Copilot (auto)", "Kiro (auto)" in CLI dashboard instead of pretending to know which model was used - Route auto model names through BUILTIN_ALIASES for cost estimation --- src/models.ts | 12 + src/providers/copilot.ts | 3 +- src/providers/cursor-agent.ts | 12 +- src/providers/cursor.ts | 8 +- src/providers/index.ts | 3 +- src/providers/kiro.ts | 277 ++++++++++++++++++++++ tests/provider-registry.test.ts | 4 +- tests/providers/cursor-agent.test.ts | 6 +- tests/providers/cursor.test.ts | 4 +- tests/providers/kiro.test.ts | 328 +++++++++++++++++++++++++++ 10 files changed, 640 insertions(+), 17 deletions(-) create mode 100644 src/providers/kiro.ts create mode 100644 tests/providers/kiro.test.ts diff --git a/src/models.ts b/src/models.ts index e501fbf..16110b6 100644 --- a/src/models.ts +++ b/src/models.ts @@ -136,6 +136,10 @@ const BUILTIN_ALIASES: Record = { 'anthropic--claude-4.5-opus': 'claude-opus-4-5', 'anthropic--claude-4.5-sonnet': 'claude-sonnet-4-5', 'anthropic--claude-4.5-haiku': 'claude-haiku-4-5', + 'cursor-auto': 'claude-sonnet-4-5', + 'cursor-agent-auto': 'claude-sonnet-4-5', + 'copilot-auto': 'claude-sonnet-4-5', + 'kiro-auto': 'claude-sonnet-4-5', } let userAliases: Record = {} @@ -201,7 +205,15 @@ export function calculateCost( ) } +const autoModelNames: Record = { + 'cursor-auto': 'Cursor (auto)', + 'cursor-agent-auto': 'Cursor (auto)', + 'copilot-auto': 'Copilot (auto)', + 'kiro-auto': 'Kiro (auto)', +} + export function getShortModelName(model: string): string { + if (autoModelNames[model]) return autoModelNames[model] const canonical = resolveAlias(getCanonicalName(model)) const shortNames: Record = { 'claude-opus-4-7': 'Opus 4.7', diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index 0a469a9..4f9f9b9 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -152,7 +152,7 @@ function inferModelFromToolCallIds(events: TranscriptEvent[]): string { if (t.toolCallId?.startsWith('call_')) return 'gpt-4.1' } } - return 'gpt-4.1' + return 'copilot-auto' } function parseTranscriptEvents(content: string, sessionId: string, seenKeys: Set): ParsedProviderCall[] { @@ -374,6 +374,7 @@ export function createCopilotProvider(sessionStateDir?: string, workspaceStorage displayName: 'Copilot', modelDisplayName(model: string): string { + if (model === 'copilot-auto') return 'Copilot (auto)' for (const [key, name] of modelDisplayEntries) { if (model === key || model.startsWith(key + '-')) return name } diff --git a/src/providers/cursor-agent.ts b/src/providers/cursor-agent.ts index 5827a60..0a10308 100644 --- a/src/providers/cursor-agent.ts +++ b/src/providers/cursor-agent.ts @@ -31,7 +31,7 @@ type ParsedTurn = { assistant: AssistantTurn } -const CURSOR_AGENT_DEFAULT_MODEL = 'claude-sonnet-4-5' +const CURSOR_AGENT_COST_MODEL = 'claude-sonnet-4-5' const CHARS_PER_TOKEN = 4 const MAX_USER_TEXT_LENGTH = 500 const DIGITS_ONLY = /^\d+$/ @@ -129,10 +129,14 @@ function prettifyProjectId(raw: string): string { } function resolveModel(raw: string | null | undefined): string { - if (!raw || raw === 'default') return CURSOR_AGENT_DEFAULT_MODEL + if (!raw || raw === 'default') return 'cursor-agent-auto' return raw } +function costModel(model: string): string { + return model === 'cursor-agent-auto' ? CURSOR_AGENT_COST_MODEL : model +} + function toConversationId(transcriptPath: string): string { const filename = basename(transcriptPath, '.txt') if (filename.length === 36 && UUID_LIKE.test(filename)) return filename @@ -378,7 +382,7 @@ function createParser( seenKeys.add(deduplicationKey) const costUSD = calculateCost( - model, + costModel(model), inputTokens, outputTokens + reasoningTokens, 0, @@ -424,7 +428,7 @@ export function createCursorAgentProvider(baseDirOverride?: string): Provider { displayName: 'Cursor Agent', modelDisplayName(model: string): string { - if (model === 'default') return modelDisplayNames.default + if (model === 'cursor-agent-auto') return 'Cursor (auto)' const label = modelDisplayNames[model] ?? model return `${label} (est.)` }, diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index bbe6b65..d86d322 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -7,7 +7,7 @@ import { readCachedResults, writeCachedResults } from '../cursor-cache.js' import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' -const CURSOR_DEFAULT_MODEL = 'claude-sonnet-4-5' +const CURSOR_COST_MODEL = 'claude-sonnet-4-5' const modelDisplayNames: Record = { 'claude-4.5-opus-high-thinking': 'Opus 4.5 (Thinking)', @@ -23,7 +23,7 @@ const modelDisplayNames: Record = { 'gpt-5.1-codex-high': 'GPT-5.1 Codex', 'gpt-5': 'GPT-5', 'gpt-4.1': 'GPT-4.1', - 'default': 'Auto (Sonnet est.)', + 'cursor-auto': 'Cursor (auto)', } type BubbleRow = { @@ -89,12 +89,12 @@ function extractLanguages(codeBlocksJson: string | null): string[] { } function resolveModel(raw: string | null): string { - if (!raw || raw === 'default') return CURSOR_DEFAULT_MODEL + if (!raw || raw === 'default') return CURSOR_COST_MODEL return raw } function modelForDisplay(raw: string | null): string { - if (!raw || raw === 'default') return 'default' + if (!raw || raw === 'default') return 'cursor-auto' return raw } diff --git a/src/providers/index.ts b/src/providers/index.ts index 5ce5473..9c27814 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,6 +1,7 @@ import { claude } from './claude.js' import { codex } from './codex.js' import { copilot } from './copilot.js' +import { kiro } from './kiro.js' import { pi, omp } from './pi.js' import type { Provider, SessionSource } from './types.js' @@ -49,7 +50,7 @@ async function loadCursorAgent(): Promise { } } -const coreProviders: Provider[] = [claude, codex, copilot, pi, omp] +const coreProviders: Provider[] = [claude, codex, copilot, kiro, pi, omp] export async function getAllProviders(): Promise { const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()]) diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts new file mode 100644 index 0000000..46b2007 --- /dev/null +++ b/src/providers/kiro.ts @@ -0,0 +1,277 @@ +import { readdir, readFile, stat } from 'fs/promises' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { readSessionFile } from '../fs-utils.js' +import { calculateCost } from '../models.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const CHARS_PER_TOKEN = 4 + +const modelDisplayNames: Record = { + 'claude-sonnet-4-6': 'Sonnet 4.6', + 'claude-sonnet-4-5': 'Sonnet 4.5', + 'claude-sonnet-4': 'Sonnet 4', + 'claude-haiku-4-5': 'Haiku 4.5', + 'claude-3-7-sonnet': 'Sonnet 3.7', + 'claude-3-5-sonnet': 'Sonnet 3.5', + 'claude-3-5-haiku': 'Haiku 3.5', +} + +const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length) + +const toolNameMap: Record = { + readFile: 'Read', + read_file: 'Read', + writeFile: 'Edit', + write_file: 'Edit', + editFile: 'Edit', + edit_file: 'Edit', + createFile: 'Write', + create_file: 'Write', + deleteFile: 'Delete', + listDir: 'LS', + list_dir: 'LS', + openFolders: 'LS', + runCommand: 'Bash', + run_command: 'Bash', + searchFiles: 'Grep', + search_files: 'Grep', + findFiles: 'Glob', + find_files: 'Glob', + webSearch: 'WebSearch', + web_search: 'WebSearch', +} + +type KiroChatMessage = { + role: 'human' | 'bot' | 'tool' + content: string +} + +type KiroChatFile = { + executionId: string + actionId: string + chat: KiroChatMessage[] + metadata: { + modelId: string + modelProvider: string + workflow: string + workflowId: string + startTime: number + endTime: number + } +} + +function normalizeModelId(raw: string): string { + return raw.replace(/(\d+)\.(\d+)/g, '$1-$2') +} + +function extractToolNames(content: string): string[] { + const tools: string[] = [] + const regex = /\s*([^<]+)<\/name>/g + let match + while ((match = regex.exec(content)) !== null) { + const name = match[1]!.trim() + tools.push(toolNameMap[name] ?? name) + } + return tools +} + +function parseChatFile(data: KiroChatFile, sessionId: string, project: string, seenKeys: Set): ParsedProviderCall[] { + const results: ParsedProviderCall[] = [] + const { chat, metadata } = data + + let modelId = normalizeModelId(metadata.modelId ?? '') + if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' + + let pendingUserMessage = '' + const allTools: string[] = [] + + for (const msg of chat) { + if (msg.role === 'human') { + if (msg.content.startsWith('')) continue + pendingUserMessage = msg.content.slice(0, 500) + } + if (msg.role === 'bot') { + allTools.push(...extractToolNames(msg.content)) + } + } + + const botMessages = chat.filter(m => m.role === 'bot' && m.content.length > 0) + const totalOutputChars = botMessages.reduce((sum, m) => sum + m.content.length, 0) + if (totalOutputChars === 0) return results + + const dedupKey = `kiro:${sessionId}:${data.executionId}` + if (seenKeys.has(dedupKey)) return results + seenKeys.add(dedupKey) + + const outputTokens = Math.ceil(totalOutputChars / CHARS_PER_TOKEN) + const inputTokens = Math.ceil(pendingUserMessage.length / CHARS_PER_TOKEN) + const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) + const timestamp = new Date(metadata.startTime).toISOString() + + results.push({ + provider: 'kiro', + model: modelId, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [...new Set(allTools)], + bashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + }) + + return results +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const content = await readSessionFile(source.path) + if (content === null) return + + let data: KiroChatFile + try { + data = JSON.parse(content) + } catch { + return + } + + if (!data.chat || !data.metadata) return + + const sessionId = data.metadata.workflowId ?? basename(source.path, '.chat') + const calls = parseChatFile(data, sessionId, source.project, seenKeys) + for (const call of calls) { + yield call + } + }, + } +} + +// --- Discovery --- + +function getKiroAgentDir(override?: string): string { + if (override) return override + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + } + if (process.platform === 'win32') { + return join(homedir(), 'AppData', 'Roaming', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + } + return join(homedir(), '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') +} + +function getKiroWorkspaceStorageDir(override?: string): string { + if (override) return override + if (process.platform === 'darwin') { + return join(homedir(), 'Library', 'Application Support', 'Kiro', 'User', 'workspaceStorage') + } + if (process.platform === 'win32') { + return join(homedir(), 'AppData', 'Roaming', 'Kiro', 'User', 'workspaceStorage') + } + return join(homedir(), '.config', 'Kiro', 'User', 'workspaceStorage') +} + +async function readWorkspaceProject(workspaceDir: string): Promise { + try { + const raw = await readFile(join(workspaceDir, 'workspace.json'), 'utf-8') + const data = JSON.parse(raw) as { folder?: string } + if (data.folder) { + const url = data.folder.replace(/^file:\/\//, '') + return basename(decodeURIComponent(url)) + } + } catch {} + return basename(workspaceDir) +} + +async function resolveWorkspaceProject(agentDir: string, workspaceStorageDir: string, workspaceHash: string): Promise { + const wsDir = join(workspaceStorageDir, workspaceHash) + const project = await readWorkspaceProject(wsDir) + if (project !== workspaceHash) return project + + try { + const sessionsPath = join(agentDir, 'workspace-sessions') + const dirs = await readdir(sessionsPath) + for (const dir of dirs) { + const decoded = Buffer.from(dir.replace(/_$/, ''), 'base64').toString('utf-8') + if (decoded) return basename(decoded) + } + } catch {} + + return workspaceHash +} + +async function discoverSessions(agentDir: string, workspaceStorageDir: string): Promise { + const sources: SessionSource[] = [] + + let workspaceDirs: string[] + try { + const entries = await readdir(agentDir, { withFileTypes: true }) + workspaceDirs = entries.filter(e => e.isDirectory() && e.name.length === 32).map(e => e.name) + } catch { + return sources + } + + for (const wsHash of workspaceDirs) { + const wsPath = join(agentDir, wsHash) + const project = await resolveWorkspaceProject(agentDir, workspaceStorageDir, wsHash) + + let files: string[] + try { + const entries = await readdir(wsPath) + files = entries.filter(f => f.endsWith('.chat')) + } catch { + continue + } + + for (const file of files) { + const filePath = join(wsPath, file) + const s = await stat(filePath).catch(() => null) + if (!s?.isFile()) continue + sources.push({ path: filePath, project, provider: 'kiro' }) + } + } + + return sources +} + +export function createKiroProvider(agentDirOverride?: string, workspaceStorageDirOverride?: string): Provider { + const agentDir = getKiroAgentDir(agentDirOverride) + const wsDir = getKiroWorkspaceStorageDir(workspaceStorageDirOverride) + + return { + name: 'kiro', + displayName: 'Kiro', + + modelDisplayName(model: string): string { + if (model === 'kiro-auto') return 'Kiro (auto)' + for (const [key, name] of modelDisplayEntries) { + if (model === key || model.startsWith(key + '-')) return name + } + return model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + return discoverSessions(agentDir, wsDir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const kiro = createKiroProvider() diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index f2db2b6..26b7d82 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js' describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'pi', 'omp']) + expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'kiro', 'pi', 'omp']) }) it('includes sqlite providers after async load', async () => { @@ -62,7 +62,7 @@ describe('provider registry', () => { it('cursor model display names handle auto mode', async () => { const all = await getAllProviders() const cursor = all.find(p => p.name === 'cursor')! - expect(cursor.modelDisplayName('default')).toBe('Auto (Sonnet est.)') + expect(cursor.modelDisplayName('cursor-auto')).toBe('Cursor (auto)') expect(cursor.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking)') expect(cursor.modelDisplayName('grok-code-fast-1')).toBe('Grok Code Fast') expect(cursor.modelDisplayName('unknown-model')).toBe('unknown-model') diff --git a/tests/providers/cursor-agent.test.ts b/tests/providers/cursor-agent.test.ts index e95c125..e688b21 100644 --- a/tests/providers/cursor-agent.test.ts +++ b/tests/providers/cursor-agent.test.ts @@ -10,7 +10,7 @@ import type { ParsedProviderCall, Provider, SessionSource } from '../../src/prov import { isSqliteAvailable } from '../../src/sqlite.js' const CHARS_PER_TOKEN = 4 -const CURSOR_AGENT_DEFAULT_MODEL = 'claude-sonnet-4-5' +const CURSOR_AGENT_DEFAULT_MODEL = 'cursor-agent-auto' const FIXED_UUID = '123e4567-e89b-12d3-a456-426614174000' const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip @@ -61,9 +61,9 @@ describe('cursor-agent provider', () => { expect(provider?.displayName).toBe('Cursor Agent') }) - it('maps default model to auto with estimation label', () => { + it('maps default model to Cursor (auto) label', () => { const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture') - expect(provider.modelDisplayName('default')).toBe('Auto (Sonnet est.)') + expect(provider.modelDisplayName('cursor-agent-auto')).toBe('Cursor (auto)') }) it('maps known models and appends estimation label', () => { diff --git a/tests/providers/cursor.test.ts b/tests/providers/cursor.test.ts index 2e1f758..29b8b1e 100644 --- a/tests/providers/cursor.test.ts +++ b/tests/providers/cursor.test.ts @@ -16,8 +16,8 @@ describe('cursor provider', () => { }) describe('model display names', () => { - it('maps default to Auto with estimation label', () => { - expect(cursorProvider.modelDisplayName('default')).toBe('Auto (Sonnet est.)') + it('maps cursor-auto to Cursor (auto) label', () => { + expect(cursorProvider.modelDisplayName('cursor-auto')).toBe('Cursor (auto)') }) it('maps known models to readable names', () => { diff --git a/tests/providers/kiro.test.ts b/tests/providers/kiro.test.ts new file mode 100644 index 0000000..a157d4e --- /dev/null +++ b/tests/providers/kiro.test.ts @@ -0,0 +1,328 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { kiro, createKiroProvider } from '../../src/providers/kiro.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +function makeChatFile(opts: { + executionId?: string + modelId?: string + workflowId?: string + startTime?: number + endTime?: number + userPrompt?: string + botResponses?: string[] +}) { + const chat = [ + { role: 'human', content: '\nYou are Kiro.\n' }, + { role: 'bot', content: '' }, + { role: 'tool', content: 'workspace tree...' }, + { role: 'bot', content: 'I will follow these instructions.' }, + ] + + if (opts.userPrompt) { + chat.push({ role: 'human', content: opts.userPrompt }) + } + + for (const resp of opts.botResponses ?? ['Done.']) { + chat.push({ role: 'bot', content: resp }) + } + + return JSON.stringify({ + executionId: opts.executionId ?? 'exec-001', + actionId: 'act', + context: [], + validations: {}, + chat, + metadata: { + modelId: opts.modelId ?? 'claude-haiku-4-5', + modelProvider: 'qdev', + workflow: 'act', + workflowId: opts.workflowId ?? 'wf-001', + startTime: opts.startTime ?? 1777333000000, + endTime: opts.endTime ?? 1777333010000, + }, + }) +} + +describe('kiro provider - chat file parsing', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kiro-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('parses a basic chat file', async () => { + const wsHash = 'a'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'abc123.chat') + await writeFile(chatPath, makeChatFile({ + modelId: 'claude-haiku-4-5', + userPrompt: 'explain the code', + botResponses: ['Here is an explanation of the code structure.'], + })) + + const source = { path: chatPath, project: 'myproject', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('kiro') + expect(call.model).toBe('claude-haiku-4-5') + expect(call.outputTokens).toBeGreaterThan(0) + expect(call.userMessage).toBe('explain the code') + expect(call.bashCommands).toEqual([]) + expect(call.costUSD).toBeGreaterThan(0) + }) + + it('stores kiro-auto when model is auto', async () => { + const wsHash = 'b'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'abc.chat') + await writeFile(chatPath, makeChatFile({ + modelId: 'auto', + botResponses: ['some output'], + })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('kiro-auto') + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('skips chat files with no bot output', async () => { + const wsHash = 'c'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'empty.chat') + await writeFile(chatPath, JSON.stringify({ + executionId: 'exec-empty', + actionId: 'act', + context: [], + validations: {}, + chat: [ + { role: 'human', content: '\nYou are Kiro.\n' }, + { role: 'bot', content: '' }, + { role: 'human', content: 'do something' }, + { role: 'bot', content: '' }, + ], + metadata: { + modelId: 'claude-haiku-4-5', + modelProvider: 'qdev', + workflow: 'act', + workflowId: 'wf-empty', + startTime: 1777333000000, + endTime: 1777333010000, + }, + })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(0) + }) + + it('deduplicates across parser runs', async () => { + const wsHash = 'd'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'dup.chat') + await writeFile(chatPath, makeChatFile({ botResponses: ['hello'] })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const seenKeys = new Set() + + const calls1: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls1.push(call) + + const calls2: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls2.push(call) + + expect(calls1).toHaveLength(1) + expect(calls2).toHaveLength(0) + }) + + it('returns empty for missing file', async () => { + const source = { path: '/nonexistent/test.chat', project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) + + it('returns empty for invalid JSON', async () => { + const wsHash = 'e'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'bad.chat') + await writeFile(chatPath, 'not json at all') + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) + + it('estimates tokens from text length', async () => { + const wsHash = 'f'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'tokens.chat') + const longResponse = 'x'.repeat(400) + await writeFile(chatPath, makeChatFile({ botResponses: [longResponse] })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.outputTokens).toBe(109) + }) + + it('normalizes dot-versioned model IDs to dashes', async () => { + const wsHash = 'h'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'dot.chat') + await writeFile(chatPath, makeChatFile({ + modelId: 'claude-haiku-4.5', + botResponses: ['response text here'], + })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('claude-haiku-4-5') + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('uses workflowId as sessionId', async () => { + const wsHash = 'g'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'sess.chat') + await writeFile(chatPath, makeChatFile({ + workflowId: 'my-workflow-id', + botResponses: ['ok'], + })) + + const source = { path: chatPath, project: 'test', provider: 'kiro' } + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.sessionId).toBe('my-workflow-id') + }) +}) + +describe('kiro provider - discoverSessions', () => { + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'kiro-test-')) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers chat files from workspace hash directories', async () => { + const wsHash = 'a1b2c3d4e5f6'.padEnd(32, '0') + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + await writeFile(join(wsDir, 'session1.chat'), makeChatFile({})) + await writeFile(join(wsDir, 'session2.chat'), makeChatFile({})) + + const provider = createKiroProvider(tmpDir, '/nonexistent/ws') + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + expect(sessions.every(s => s.provider === 'kiro')).toBe(true) + expect(sessions.every(s => s.path.endsWith('.chat'))).toBe(true) + }) + + it('reads project name from workspace.json', async () => { + const wsHash = 'b'.repeat(32) + const agentWsDir = join(tmpDir, wsHash) + await mkdir(agentWsDir, { recursive: true }) + await writeFile(join(agentWsDir, 'test.chat'), makeChatFile({})) + + const workspaceStorageDir = join(tmpDir, 'ws-storage') + const wsStorageEntry = join(workspaceStorageDir, wsHash) + await mkdir(wsStorageEntry, { recursive: true }) + await writeFile(join(wsStorageEntry, 'workspace.json'), JSON.stringify({ folder: 'file:///home/user/myapp' })) + + const provider = createKiroProvider(tmpDir, workspaceStorageDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('myapp') + }) + + it('returns empty when directory does not exist', async () => { + const provider = createKiroProvider('/nonexistent/agent', '/nonexistent/ws') + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('skips non-32-char directories', async () => { + const shortDir = join(tmpDir, 'short') + await mkdir(shortDir, { recursive: true }) + await writeFile(join(shortDir, 'test.chat'), makeChatFile({})) + + const provider = createKiroProvider(tmpDir, '/nonexistent/ws') + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) + + it('skips files without .chat extension', async () => { + const wsHash = 'c'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + await writeFile(join(wsDir, 'index.json'), '{}') + await writeFile(join(wsDir, 'notes.txt'), 'hello') + + const provider = createKiroProvider(tmpDir, '/nonexistent/ws') + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(0) + }) +}) + +describe('kiro provider - metadata', () => { + it('has correct name and displayName', () => { + expect(kiro.name).toBe('kiro') + expect(kiro.displayName).toBe('Kiro') + }) + + it('normalizes model display names', () => { + expect(kiro.modelDisplayName('claude-haiku-4-5')).toBe('Haiku 4.5') + expect(kiro.modelDisplayName('claude-sonnet-4-5')).toBe('Sonnet 4.5') + expect(kiro.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6') + expect(kiro.modelDisplayName('unknown-model')).toBe('unknown-model') + }) + + it('normalizes tool display names', () => { + expect(kiro.toolDisplayName('readFile')).toBe('Read') + expect(kiro.toolDisplayName('writeFile')).toBe('Edit') + expect(kiro.toolDisplayName('runCommand')).toBe('Bash') + expect(kiro.toolDisplayName('searchFiles')).toBe('Grep') + expect(kiro.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) + + it('longest-prefix match for versioned model IDs', () => { + expect(kiro.modelDisplayName('claude-sonnet-4-5-20260101')).toBe('Sonnet 4.5') + expect(kiro.modelDisplayName('claude-haiku-4-5-20260101')).toBe('Haiku 4.5') + }) +}) From 538cc44d40bfe44f4757832bed4f040463392dad Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Tue, 28 Apr 2026 03:31:00 +0200 Subject: [PATCH 2/4] Fix menubar tabs: add missing providers, show period-scoped costs - Add Kiro, OMP to ProviderFilter enum so installed providers appear as tabs - Merge Cursor + Cursor Agent into single Cursor tab - Tab costs now reflect the selected period (7d/30d/month/all) instead of always showing today - Tab visibility still uses today's provider list so tabs don't disappear when switching to periods with no data --- mac/Sources/CodeBurnMenubar/AppStore.swift | 23 +++++++++++++- .../CodeBurnMenubar/Views/AgentTabStrip.swift | 30 +++++++++---------- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index f6d60e9..ed4f6de 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -44,6 +44,12 @@ final class AppStore { cache[PayloadCacheKey(period: .today, provider: .all)]?.payload } + /// All-provider payload for the selected period. Used by the tab strip to show + /// per-provider costs that match the active period, not just today. + var periodAllPayload: MenubarPayload? { + cache[PayloadCacheKey(period: selectedPeriod, provider: .all)]?.payload + } + var hasCachedData: Bool { cache[currentKey] != nil } @@ -86,6 +92,11 @@ final class AppStore { lastError = String(describing: error) NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)") } + + let allKey = PayloadCacheKey(period: selectedPeriod, provider: .all) + if key != allKey, cache[allKey]?.isFresh != true { + await refreshQuietly(period: selectedPeriod) + } } /// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge). @@ -211,12 +222,20 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case codex = "Codex" case cursor = "Cursor" case copilot = "Copilot" + case kiro = "Kiro" case opencode = "OpenCode" case pi = "Pi" + case omp = "OMP" var id: String { rawValue } - /// Maps to the CLI's `--provider` argument values. + var providerKeys: [String] { + switch self { + case .cursor: ["cursor", "cursor agent"] + default: [rawValue.lowercased()] + } + } + var cliArg: String { switch self { case .all: "all" @@ -224,8 +243,10 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .codex: "codex" case .cursor: "cursor" case .copilot: "copilot" + case .kiro: "kiro" case .opencode: "opencode" case .pi: "pi" + case .omp: "omp" } } } diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index e4522dd..c8fdb55 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -25,34 +25,30 @@ struct AgentTabStrip: View { } } - /// Drive tab visibility and per-tab cost labels from the *all-provider* payload (today), - /// not the currently selected provider's payload. Without this, switching to Codex (which - /// has no data) would hide every other tab including Claude. - private var allProvidersToday: MenubarPayload { + private var todayAll: MenubarPayload { store.todayPayload ?? store.payload } + private var periodAll: MenubarPayload { + store.periodAllPayload ?? store.payload + } + private var visibleFilters: [ProviderFilter] { - // Show a tab for every provider detected on this machine. The CLI decides what - // to include in the providers map based on session dirs / credential files it - // finds, so zero-cost-today is still "installed" and the user expects to see - // it. Only providers that aren't installed at all are absent from the map. let detectedKeys = Set( - allProvidersToday.current.providers.keys.map { $0.lowercased() } + todayAll.current.providers.keys.map { $0.lowercased() } ) return ProviderFilter.allCases.filter { filter in if filter == .all { return true } - return detectedKeys.contains(filter.rawValue.lowercased()) + return filter.providerKeys.contains(where: detectedKeys.contains) } } private func cost(for filter: ProviderFilter) -> Double? { - switch filter { - case .all: - return allProvidersToday.current.cost - default: - let key = filter.rawValue.lowercased() - return allProvidersToday.current.providers[key] + let data = periodAll + if filter == .all { return data.current.cost } + let providers = data.current.providers + return filter.providerKeys.reduce(0.0) { sum, key in + sum + (providers[key] ?? 0) } } } @@ -93,8 +89,10 @@ extension ProviderFilter { case .codex: return Theme.categoricalCodex case .cursor: return Theme.categoricalCursor case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0) + case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0) case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0) case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0) + case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0) } } } From 6e83bed02a19ef157376a5a152ef93ae55f43f05 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Tue, 28 Apr 2026 04:09:38 +0200 Subject: [PATCH 3/4] Add accent color picker to menubar with Apple system presets - 9 presets using Apple's exact macOS dark-mode accent colors (Ember, Blue, Purple, Pink, Red, Orange, Yellow, Green, Graphite) - Color picker in header, persisted via UserDefaults - "Burn" text stays fixed ember regardless of accent - ThemeState is MainActor-isolated for thread safety - Picker state lifted to AppStore so it survives .id() tree rebuild - Accessibility labels on all color swatches - Renamed brandAccentDark/brandEmberDeep/brandEmberGlow to match their actual light/deep/glow semantics --- mac/Sources/CodeBurnMenubar/AppStore.swift | 4 + mac/Sources/CodeBurnMenubar/Theme/Theme.swift | 13 +-- .../CodeBurnMenubar/Theme/ThemeState.swift | 86 +++++++++++++++++++ .../CodeBurnMenubar/Views/AgentTabStrip.swift | 2 +- .../Views/FindingsSection.swift | 11 +-- .../CodeBurnMenubar/Views/HeroSection.swift | 2 +- .../Views/MenuBarContent.swift | 69 +++++++++++++-- 7 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index ed4f6de..d1b7f3b 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -20,6 +20,10 @@ final class AppStore { var selectedProvider: ProviderFilter = .all var selectedPeriod: Period = .today var selectedInsight: InsightMode = .trend + var accentPreset: AccentPreset = ThemeState.shared.preset { + didSet { ThemeState.shared.preset = accentPreset } + } + var showingAccentPicker: Bool = false var currency: String = "USD" var isLoading: Bool = false var lastError: String? diff --git a/mac/Sources/CodeBurnMenubar/Theme/Theme.swift b/mac/Sources/CodeBurnMenubar/Theme/Theme.swift index de79860..07fe146 100644 --- a/mac/Sources/CodeBurnMenubar/Theme/Theme.swift +++ b/mac/Sources/CodeBurnMenubar/Theme/Theme.swift @@ -1,11 +1,14 @@ import SwiftUI -/// Design tokens. Warm terracotta-ember palette, not generic orange. +/// Design tokens. Accent colors are driven by ThemeState so the user can switch palettes. +@MainActor enum Theme { - static let brandAccent = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0) - static let brandAccentDark = Color(red: 0xE8/255.0, green: 0x77/255.0, blue: 0x4A/255.0) - static let brandEmberDeep = Color(red: 0x8B/255.0, green: 0x3E/255.0, blue: 0x13/255.0) - static let brandEmberGlow = Color(red: 0xF0/255.0, green: 0xA0/255.0, blue: 0x70/255.0) + static let brandEmber = Color(red: 0xC9/255.0, green: 0x52/255.0, blue: 0x1D/255.0) + + static var brandAccent: Color { ThemeState.shared.preset.base } + static var brandAccentLight: Color { ThemeState.shared.preset.light } + static var brandAccentDeep: Color { ThemeState.shared.preset.deep } + static var brandAccentGlow: Color { ThemeState.shared.preset.glow } static let warmSurface = Color(red: 0xFA/255.0, green: 0xF7/255.0, blue: 0xF3/255.0) static let warmSurfaceDark = Color(red: 0x1C/255.0, green: 0x18/255.0, blue: 0x16/255.0) diff --git a/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift b/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift new file mode 100644 index 0000000..a2cb7ee --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Theme/ThemeState.swift @@ -0,0 +1,86 @@ +import SwiftUI + +enum AccentPreset: String, CaseIterable, Identifiable { + case ember = "Ember" + case blue = "Blue" + case purple = "Purple" + case pink = "Pink" + case red = "Red" + case orange = "Orange" + case yellow = "Yellow" + case green = "Green" + case graphite = "Graphite" + + var id: String { rawValue } + + /// Apple macOS dark-mode system accent colors (NSColor.system*). + var base: Color { + switch self { + case .ember: Color(red: 0xC9/255, green: 0x52/255, blue: 0x1D/255) + case .blue: Color(red: 0x0A/255, green: 0x84/255, blue: 0xFF/255) + case .purple: Color(red: 0xBF/255, green: 0x5A/255, blue: 0xF2/255) + case .pink: Color(red: 0xFF/255, green: 0x37/255, blue: 0x5F/255) + case .red: Color(red: 0xFF/255, green: 0x45/255, blue: 0x3A/255) + case .orange: Color(red: 0xFF/255, green: 0x9F/255, blue: 0x0A/255) + case .yellow: Color(red: 0xFF/255, green: 0xD6/255, blue: 0x0A/255) + case .green: Color(red: 0x30/255, green: 0xD1/255, blue: 0x58/255) + case .graphite: Color(red: 0x98/255, green: 0x98/255, blue: 0x9D/255) + } + } + + var light: Color { + switch self { + case .ember: Color(red: 0xE8/255, green: 0x77/255, blue: 0x4A/255) + case .blue: Color(red: 0x40/255, green: 0x9C/255, blue: 0xFF/255) + case .purple: Color(red: 0xDA/255, green: 0x8F/255, blue: 0xF7/255) + case .pink: Color(red: 0xFF/255, green: 0x6E/255, blue: 0x8C/255) + case .red: Color(red: 0xFF/255, green: 0x6E/255, blue: 0x63/255) + case .orange: Color(red: 0xFF/255, green: 0xBD/255, blue: 0x4A/255) + case .yellow: Color(red: 0xFF/255, green: 0xE0/255, blue: 0x4A/255) + case .green: Color(red: 0x5A/255, green: 0xE0/255, blue: 0x78/255) + case .graphite: Color(red: 0xAE/255, green: 0xAE/255, blue: 0xB2/255) + } + } + + var deep: Color { + switch self { + case .ember: Color(red: 0x8B/255, green: 0x3E/255, blue: 0x13/255) + case .blue: Color(red: 0x06/255, green: 0x52/255, blue: 0xB3/255) + case .purple: Color(red: 0x7C/255, green: 0x38/255, blue: 0xA8/255) + case .pink: Color(red: 0xB3/255, green: 0x26/255, blue: 0x42/255) + case .red: Color(red: 0xB3/255, green: 0x30/255, blue: 0x28/255) + case .orange: Color(red: 0xB3/255, green: 0x6F/255, blue: 0x06/255) + case .yellow: Color(red: 0xB3/255, green: 0x96/255, blue: 0x06/255) + case .green: Color(red: 0x20/255, green: 0x92/255, blue: 0x3D/255) + case .graphite: Color(red: 0x5E/255, green: 0x5E/255, blue: 0x62/255) + } + } + + var glow: Color { + switch self { + case .ember: Color(red: 0xF0/255, green: 0xA0/255, blue: 0x70/255) + case .blue: Color(red: 0x80/255, green: 0xC0/255, blue: 0xFF/255) + case .purple: Color(red: 0xE0/255, green: 0xB8/255, blue: 0xFA/255) + case .pink: Color(red: 0xFF/255, green: 0x99/255, blue: 0xB0/255) + case .red: Color(red: 0xFF/255, green: 0x99/255, blue: 0x90/255) + case .orange: Color(red: 0xFF/255, green: 0xD0/255, blue: 0x80/255) + case .yellow: Color(red: 0xFF/255, green: 0xEA/255, blue: 0x80/255) + case .green: Color(red: 0x80/255, green: 0xF0/255, blue: 0x98/255) + case .graphite: Color(red: 0xC8/255, green: 0xC8/255, blue: 0xCC/255) + } + } +} + +@MainActor +final class ThemeState { + static let shared = ThemeState() + + var preset: AccentPreset { + didSet { UserDefaults.standard.set(preset.rawValue, forKey: "CodeBurnAccentPreset") } + } + + private init() { + let saved = UserDefaults.standard.string(forKey: "CodeBurnAccentPreset") ?? "" + self.preset = AccentPreset(rawValue: saved) ?? .ember + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index c8fdb55..de35697 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -82,7 +82,7 @@ private struct AgentTab: View { } extension ProviderFilter { - var color: Color { + @MainActor var color: Color { switch self { case .all: return Theme.brandAccent case .claude: return Theme.categoricalClaude diff --git a/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift index 5e9190c..86f174c 100644 --- a/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/FindingsSection.swift @@ -1,8 +1,5 @@ import SwiftUI -private let winColor = Theme.brandAccent -private let riskColor = Theme.brandAccent -private let improveColor = Theme.brandAccent /// Three-category insights panel: wins, improvements, risks. /// Wins/risks are derived from current + history; improvements come from the optimize findings. @@ -133,7 +130,7 @@ private struct TipItem: Identifiable { let trailing: String? } -private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] { +@MainActor private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] { let stats = computeHistoryStats(history: payload.history.daily) // What's working @@ -201,9 +198,9 @@ private func computeTipGroups(payload: MenubarPayload) -> [TipGroup] { } return [ - TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: winColor, items: wins), - TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: improveColor, items: improvements), - TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: riskColor, items: risks), + TipGroup(label: "What's working", icon: "checkmark.circle.fill", color: Theme.brandAccent, items: wins), + TipGroup(label: "What to improve", icon: "arrow.up.right.circle.fill", color: Theme.brandAccent, items: improvements), + TipGroup(label: "Risks", icon: "exclamationmark.triangle.fill", color: Theme.brandAccent, items: risks), ] } diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index ca30cee..056f5b0 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -14,7 +14,7 @@ struct HeroSection: View { .tracking(-1) .foregroundStyle( LinearGradient( - colors: [Theme.brandAccent, Theme.brandEmberDeep], + colors: [Theme.brandAccent, Theme.brandAccentDeep], startPoint: .top, endPoint: .bottom ) diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index c31201b..86d1d91 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -55,6 +55,7 @@ struct MenuBarContent: View { StarBanner() } + .id(store.accentPreset) } /// True when a specific provider tab is selected and that provider has no spend in the @@ -147,7 +148,7 @@ private struct BurnFlame: View { // Soft outer glow that pulses, matching the brand terracotta palette. Image(systemName: "flame.fill") .font(.system(size: size, weight: .regular)) - .foregroundStyle(Theme.brandEmberGlow.opacity(glowing ? 0.55 : 0.20)) + .foregroundStyle(Theme.brandAccentGlow.opacity(glowing ? 0.55 : 0.20)) .blur(radius: glowing ? 14 : 6) // Empty (cool) flame as base @@ -161,10 +162,10 @@ private struct BurnFlame: View { .foregroundStyle( LinearGradient( colors: [ - Theme.brandEmberGlow, - Theme.brandAccentDark, + Theme.brandAccentGlow, + Theme.brandAccentLight, Theme.brandAccent, - Theme.brandEmberDeep + Theme.brandAccentDeep ], startPoint: .bottom, endPoint: .top @@ -184,13 +185,12 @@ private struct BurnFlame: View { private struct Header: View { @Environment(UpdateChecker.self) private var updateChecker - var body: some View { HStack { VStack(alignment: .leading, spacing: 1) { ( Text("Code").foregroundStyle(.primary) - + Text("Burn").foregroundStyle(Theme.brandAccent) + + Text("Burn").foregroundStyle(Theme.brandEmber) ) .font(.system(size: 13, weight: .semibold)) .tracking(-0.15) @@ -202,6 +202,7 @@ private struct Header: View { if updateChecker.updateAvailable { UpdateBadge() } + AccentPicker() } .padding(.horizontal, 14) .padding(.top, 10) @@ -209,6 +210,60 @@ private struct Header: View { } } +private struct AccentPicker: View { + @Environment(AppStore.self) private var store + + var body: some View { + HStack(spacing: 0) { + if store.showingAccentPicker { + HStack(spacing: 5) { + ForEach(AccentPreset.allCases) { preset in + Button { + withAnimation(.easeInOut(duration: 0.15)) { + store.accentPreset = preset + } + } label: { + Circle() + .fill(preset.base) + .frame(width: 12, height: 12) + .overlay( + Circle() + .stroke(.white.opacity(store.accentPreset == preset ? 0.9 : 0), lineWidth: 1.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(preset.rawValue) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.08)) + ) + .transition(.opacity.combined(with: .move(edge: .trailing))) + } + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + store.showingAccentPicker.toggle() + } + } label: { + Circle() + .fill(store.accentPreset.base) + .frame(width: 14, height: 14) + .overlay( + Circle() + .stroke(.white.opacity(0.3), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Change accent color") + .padding(.leading, 4) + } + } +} + private struct UpdateBadge: View { @Environment(UpdateChecker.self) private var updateChecker @@ -244,7 +299,7 @@ struct FlameMark: View { RoundedRectangle(cornerRadius: 5) .fill( LinearGradient( - colors: [Theme.brandAccentDark, Theme.brandEmberDeep], + colors: [Theme.brandAccentLight, Theme.brandAccentDeep], startPoint: .topLeading, endPoint: .bottomTrailing ) From 71f3aa44c9239cd798e6fb46ce8ada09d77668bb Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Tue, 28 Apr 2026 04:20:52 +0200 Subject: [PATCH 4/4] Fix review findings: case-sensitive cost lookup, Kiro timestamp guard, cache versioning - Normalize provider dictionary keys to lowercase in tab cost lookup so "Cursor Agent" (title-case from CLI) matches providerKeys - Guard against missing/invalid/epoch startTime in Kiro parser to prevent RangeError crash or 1970-01-01 ghost entries - Bump DAILY_CACHE_VERSION to 4 so upgraded users get a clean recompute with the new auto-model naming (cursor-auto vs default) - Add version field to cursor-results.json cache to invalidate stale entries that still use the old 'default' model name --- mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift | 5 ++++- src/cursor-cache.ts | 6 +++++- src/daily-cache.ts | 2 +- src/providers/kiro.ts | 4 +++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index de35697..3cd57bf 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -46,7 +46,10 @@ struct AgentTabStrip: View { private func cost(for filter: ProviderFilter) -> Double? { let data = periodAll if filter == .all { return data.current.cost } - let providers = data.current.providers + let providers = Dictionary( + data.current.providers.map { ($0.key.lowercased(), $0.value) }, + uniquingKeysWith: + + ) return filter.providerKeys.reduce(0.0) { sum, key in sum + (providers[key] ?? 0) } diff --git a/src/cursor-cache.ts b/src/cursor-cache.ts index e743020..62cc394 100644 --- a/src/cursor-cache.ts +++ b/src/cursor-cache.ts @@ -4,7 +4,10 @@ import { homedir } from 'os' import type { ParsedProviderCall } from './providers/types.js' +const CURSOR_CACHE_VERSION = 2 + type ResultCache = { + version?: number dbMtimeMs: number dbSizeBytes: number calls: ParsedProviderCall[] @@ -37,7 +40,7 @@ export async function readCachedResults(dbPath: string): Promise