diff --git a/README.md b/README.md index 42f88fe..4610160 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ CodeBurn TUI dashboard

-By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, and **OpenCode** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export. +By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **OpenCode**, and **Pi** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. macOS menu bar widget via SwiftBar. CSV/JSON export. Works by reading session data directly from disk. No wrapper, no proxy, no API keys. Pricing from LiteLLM (auto-cached, all models supported). @@ -38,7 +38,7 @@ npx codeburn ### Requirements - Node.js 20+ -- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, and/or OpenCode +- Claude Code (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), Cursor, OpenCode, and/or Pi (`~/.pi/agent/sessions/`) - For Cursor/OpenCode support: `better-sqlite3` is installed automatically as an optional dependency ## Usage @@ -67,6 +67,7 @@ codeburn report --provider claude # Claude Code only codeburn report --provider codex # Codex only codeburn report --provider cursor # Cursor only codeburn report --provider opencode # OpenCode only +codeburn report --provider pi # Pi only codeburn today --provider codex # Codex today codeburn export --provider claude # export Claude data only ``` @@ -82,7 +83,8 @@ The `--provider` flag works on all commands: `report`, `today`, `month`, `status | Codex (OpenAI) | `~/.codex/sessions/` | Supported | | Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | Supported | | OpenCode | `~/.local/share/opencode/` (SQLite) | Supported | -| Pi, Amp | -- | Planned (provider plugin system) | +| Pi | `~/.pi/agent/sessions/` | Supported | +| Amp | -- | Planned (provider plugin system) | Codex tool names are normalized to match Claude's conventions (`exec_command` shows as `Bash`, `read_file` as `Read`, etc.) so the activity classifier and tool breakdown work across providers. @@ -157,7 +159,9 @@ Requires [SwiftBar](https://github.com/swiftbar/SwiftBar) (`brew install --cask **OpenCode** stores sessions in SQLite databases at `~/.local/share/opencode/opencode*.db`. CodeBurn queries the `session`, `message`, and `part` tables read-only, extracts token counts and tool usage, and recalculates cost using the LiteLLM pricing engine. Falls back to OpenCode's own cost field for models not in our pricing data. Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double-counting. Supports multiple channel databases and respects `XDG_DATA_HOME`. -CodeBurn reads these files, deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session+message ID for OpenCode), filters by date range per entry, and classifies each turn. +**Pi** stores sessions as JSONL at `~/.pi/agent/sessions//*.jsonl`. Each assistant message carries token usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` content blocks. CodeBurn extracts token counts, normalizes Pi's lowercase tool names to the standard set (`bash` -> `Bash`, `dispatch_agent` -> `Agent`), and pulls bash commands from `toolCall.arguments.command` for the shell breakdown. + +CodeBurn reads these files, deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session+message ID for OpenCode, by responseId for Pi), filters by date range per entry, and classifies each turn. ## Environment variables @@ -190,6 +194,7 @@ src/ codex.ts Codex session discovery and JSONL parsing cursor.ts Cursor SQLite parsing, language extraction opencode.ts OpenCode SQLite session discovery and parsing + pi.ts Pi agent JSONL session discovery and parsing ``` ## License diff --git a/package.json b/package.json index 2fdcb27..15cc02d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "cursor", "codex", "opencode", + "pi", "ai-coding", "token-usage", "cost-tracking", diff --git a/src/bash-utils.ts b/src/bash-utils.ts index 08b215a..c578972 100644 --- a/src/bash-utils.ts +++ b/src/bash-utils.ts @@ -30,10 +30,12 @@ export function extractBashCommands(command: string): string[] { const segment = command.slice(start, end).trim() if (!segment) continue - const firstToken = segment.split(/\s+/)[0] - const base = basename(firstToken) + const tokens = segment.split(/\s+/) + let i = 0 + while (i < tokens.length && /^\w+=/.test(tokens[i]!)) i++ + const base = i < tokens.length ? basename(tokens[i]!) : '' - if (base && base !== 'cd') { + if (base && base !== 'cd' && base !== 'true' && base !== 'false') { commands.push(base) } } diff --git a/src/dashboard.tsx b/src/dashboard.tsx index abd3abe..e8aabf6 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -48,6 +48,8 @@ const PROVIDER_COLORS: Record = { claude: '#FF8C42', codex: '#5BF5A0', cursor: '#00B4D8', + opencode: '#A78BFA', + pi: '#F472B6', all: '#FF8C42', } @@ -427,6 +429,8 @@ const PROVIDER_DISPLAY_NAMES: Record = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor', + opencode: 'OpenCode', + pi: 'Pi', } function getProviderDisplayName(name: string): string { diff --git a/src/providers/index.ts b/src/providers/index.ts index cee5d61..0ac83c6 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,5 +1,6 @@ import { claude } from './claude.js' import { codex } from './codex.js' +import { pi } from './pi.js' import type { Provider, SessionSource } from './types.js' let cursorProvider: Provider | null = null @@ -32,7 +33,7 @@ async function loadOpenCode(): Promise { } } -const coreProviders: Provider[] = [claude, codex] +const coreProviders: Provider[] = [claude, codex, pi] export async function getAllProviders(): Promise { const [cursor, opencode] = await Promise.all([loadCursor(), loadOpenCode()]) @@ -68,4 +69,3 @@ export async function getProvider(name: string): Promise { } return coreProviders.find(p => p.name === name) } - diff --git a/src/providers/pi.ts b/src/providers/pi.ts new file mode 100644 index 0000000..4fb42cf --- /dev/null +++ b/src/providers/pi.ts @@ -0,0 +1,227 @@ +import { readdir, readFile, stat } from 'fs/promises' +import { basename, join } from 'path' +import { homedir } from 'os' + +import { calculateCost } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +const modelDisplayNames: Record = { + 'gpt-5.4': 'GPT-5.4', + 'gpt-5.4-mini': 'GPT-5.4 Mini', + 'gpt-5': 'GPT-5', + 'gpt-4o': 'GPT-4o', + 'gpt-4o-mini': 'GPT-4o Mini', +} + +const toolNameMap: Record = { + bash: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + dispatch_agent: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + patch: 'Patch', +} + +// Pre-sorted by key length descending so longer/more-specific keys match first +const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length) + +type PiEntry = { + type: string + id?: string + timestamp?: string + cwd?: string + message?: { + role?: string + content?: Array<{ type?: string; text?: string; name?: string; arguments?: Record }> + model?: string + responseId?: string + usage?: { + input: number + output: number + cacheRead: number + cacheWrite: number + } + } +} + +function getPiSessionsDir(override?: string): string { + return override ?? join(homedir(), '.pi', 'agent', 'sessions') +} + +async function readFirstEntry(filePath: string): Promise { + try { + const content = await readFile(filePath, 'utf-8') + const line = content.split('\n')[0] + if (!line?.trim()) return null + return JSON.parse(line) as PiEntry + } catch { + return null + } +} + +async function discoverSessionsInDir(sessionsDir: string): Promise { + const sources: SessionSource[] = [] + + let projectDirs: string[] + try { + projectDirs = await readdir(sessionsDir) + } catch { + return sources + } + + for (const dirName of projectDirs) { + const dirPath = join(sessionsDir, dirName) + const dirStat = await stat(dirPath).catch(() => null) + if (!dirStat?.isDirectory()) continue + + let files: string[] + try { + files = await readdir(dirPath) + } catch { + continue + } + + for (const file of files) { + if (!file.endsWith('.jsonl')) continue + const filePath = join(dirPath, file) + const fileStat = await stat(filePath).catch(() => null) + if (!fileStat?.isFile()) continue + + const first = await readFirstEntry(filePath) + if (!first || first.type !== 'session') continue + + const cwd = first.cwd ?? dirName + sources.push({ path: filePath, project: basename(cwd), provider: 'pi' }) + } + } + + return sources +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + let content: string + try { + content = await readFile(source.path, 'utf-8') + } catch { + return + } + + const lines = content.split('\n').filter(l => l.trim()) + let sessionId = basename(source.path, '.jsonl') + let pendingUserMessage = '' + + for (const [lineIdx, line] of lines.entries()) { + let entry: PiEntry + try { + entry = JSON.parse(line) as PiEntry + } catch { + continue + } + + if (entry.type === 'session') { + sessionId = entry.id ?? sessionId + continue + } + + if (entry.type !== 'message') continue + + const msg = entry.message + if (!msg) continue + + if (msg.role === 'user') { + const texts = (msg.content ?? []) + .filter(c => c.type === 'text') + .map(c => c.text ?? '') + .filter(Boolean) + if (texts.length > 0) pendingUserMessage = texts.join(' ') + continue + } + + if (msg.role !== 'assistant' || !msg.usage) continue + + const { input, output, cacheRead, cacheWrite } = msg.usage + if (input === 0 && output === 0) continue + + const model = msg.model ?? 'gpt-5' + const responseId = msg.responseId ?? '' + const dedupKey = `pi:${source.path}:${responseId || entry.id || entry.timestamp || String(lineIdx)}` + + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + const toolCalls = (msg.content ?? []).filter(c => c.type === 'toolCall' && c.name) + const tools = toolCalls.map(c => toolNameMap[c.name!] ?? c.name!) + const bashCommands = toolCalls + .filter(c => c.name === 'bash') + .flatMap(c => { + const cmd = c.arguments?.['command'] + return typeof cmd === 'string' ? extractBashCommands(cmd) : [] + }) + + const costUSD = calculateCost(model, input, output, cacheWrite, cacheRead, 0) + const timestamp = entry.timestamp ?? '' + + yield { + provider: 'pi', + model, + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools, + bashCommands, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: pendingUserMessage, + sessionId, + } + + pendingUserMessage = '' + } + }, + } +} + +export function createPiProvider(sessionsDir?: string): Provider { + const dir = getPiSessionsDir(sessionsDir) + + return { + name: 'pi', + displayName: 'Pi', + + modelDisplayName(model: string): string { + for (const [key, name] of modelDisplayEntries) { + if (model.startsWith(key)) return name + } + return model + }, + + toolDisplayName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool + }, + + async discoverSessions(): Promise { + return discoverSessionsInDir(dir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const pi = createPiProvider() diff --git a/tests/bash-commands.test.ts b/tests/bash-commands.test.ts index e5e52e7..2c830e9 100644 --- a/tests/bash-commands.test.ts +++ b/tests/bash-commands.test.ts @@ -58,6 +58,21 @@ describe('extractBashCommands', () => { it('handles single-quoted separators', () => { expect(extractBashCommands("echo 'hello && world'")).toEqual(['echo']) }) + + it('skips leading env var assignments', () => { + expect(extractBashCommands('NODE_ENV=prod npm test')).toEqual(['npm']) + expect(extractBashCommands('FOO=bar BAZ=qux ls -la')).toEqual(['ls']) + }) + + it('skips standalone true/false', () => { + expect(extractBashCommands('true && git status')).toEqual(['git']) + expect(extractBashCommands('false || echo done')).toEqual(['echo']) + expect(extractBashCommands('true')).toEqual([]) + }) + + it('handles env vars combined with chained commands', () => { + expect(extractBashCommands('NODE_ENV=test npm test && git push')).toEqual(['npm', 'git']) + }) }) describe('BASH_TOOLS', () => { diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 9e71eae..79e5e24 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']) + expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'pi']) }) it('includes sqlite providers after async load', async () => { diff --git a/tests/providers/pi.test.ts b/tests/providers/pi.test.ts new file mode 100644 index 0000000..74f8274 --- /dev/null +++ b/tests/providers/pi.test.ts @@ -0,0 +1,336 @@ +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 { createPiProvider } from '../../src/providers/pi.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'pi-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function sessionMeta(opts: { id?: string; cwd?: string } = {}) { + return JSON.stringify({ + type: 'session', + version: 3, + id: opts.id ?? 'sess-001', + timestamp: '2026-04-14T10:00:00.000Z', + cwd: opts.cwd ?? '/Users/test/myproject', + }) +} + +function userMessage(text: string, timestamp?: string) { + return JSON.stringify({ + type: 'message', + id: 'msg-user-1', + timestamp: timestamp ?? '2026-04-14T10:00:10.000Z', + message: { + role: 'user', + content: [{ type: 'text', text }], + timestamp: 1776023210000, + }, + }) +} + +function assistantMessage(opts: { + id?: string + responseId?: string + timestamp?: string + model?: string + input?: number + output?: number + cacheRead?: number + cacheWrite?: number + tools?: Array<{ name: string; command?: string }> +}) { + const content = (opts.tools ?? []).map(t => ({ + type: 'toolCall', + id: `call-${t.name}`, + name: t.name, + arguments: t.command !== undefined ? { command: t.command } : {}, + })) + + return JSON.stringify({ + type: 'message', + id: opts.id ?? 'msg-asst-1', + timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z', + message: { + role: 'assistant', + content, + api: 'openai-codex-responses', + provider: 'openai-codex', + model: opts.model ?? 'gpt-5.4', + responseId: opts.responseId ?? 'resp-001', + usage: { + input: opts.input ?? 1000, + output: opts.output ?? 200, + cacheRead: opts.cacheRead ?? 0, + cacheWrite: opts.cacheWrite ?? 0, + totalTokens: (opts.input ?? 1000) + (opts.output ?? 200) + (opts.cacheRead ?? 0), + cost: { input: 0.0025, output: 0.003, cacheRead: 0, cacheWrite: 0, total: 0.0055 }, + }, + stopReason: 'stop', + timestamp: 1776023230000, + }, + }) +} + +async function writeSession(projectDir: string, filename: string, lines: string[]) { + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, filename) + await writeFile(filePath, lines.join('\n') + '\n') + return filePath +} + +describe('pi provider - session discovery', () => { + it('discovers sessions grouped by project directory', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, '2026-04-14T10-00-00-000Z_sess-001.jsonl', [ + sessionMeta({ cwd: '/Users/test/myproject' }), + assistantMessage({}), + ]) + + const provider = createPiProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('pi') + expect(sessions[0]!.project).toBe('myproject') + expect(sessions[0]!.path).toContain('sess-001.jsonl') + }) + + it('discovers sessions across multiple project directories', async () => { + const dir1 = join(tmpDir, '--Users-test-project-a--') + const dir2 = join(tmpDir, '--Users-test-project-b--') + await writeSession(dir1, 'session1.jsonl', [sessionMeta({ cwd: '/Users/test/project-a' }), assistantMessage({})]) + await writeSession(dir2, 'session2.jsonl', [sessionMeta({ cwd: '/Users/test/project-b' }), assistantMessage({})]) + + const provider = createPiProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(2) + const projects = sessions.map(s => s.project).sort() + expect(projects).toEqual(['project-a', 'project-b']) + }) + + it('returns empty for non-existent directory', async () => { + const provider = createPiProvider('/nonexistent/path/that/does/not/exist') + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('skips files whose first line is not a session entry', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, 'bad.jsonl', [ + JSON.stringify({ type: 'message', id: 'x' }), + ]) + + const provider = createPiProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('skips non-jsonl files', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, 'notes.txt'), 'not a session') + + const provider = createPiProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) +}) + +describe('pi provider - JSONL parsing', () => { + it('extracts token usage and metadata from an assistant message', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta({ id: 'sess-abc', cwd: '/Users/test/myproject' }), + userMessage('implement the feature'), + assistantMessage({ + responseId: 'resp-abc', + timestamp: '2026-04-14T10:00:30.000Z', + model: 'gpt-5.4', + input: 2000, + output: 400, + cacheRead: 5000, + cacheWrite: 100, + }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.provider).toBe('pi') + expect(call.model).toBe('gpt-5.4') + expect(call.inputTokens).toBe(2000) + expect(call.outputTokens).toBe(400) + expect(call.cacheReadInputTokens).toBe(5000) + expect(call.cachedInputTokens).toBe(5000) + expect(call.cacheCreationInputTokens).toBe(100) + expect(call.sessionId).toBe('sess-abc') + expect(call.userMessage).toBe('implement the feature') + expect(call.timestamp).toBe('2026-04-14T10:00:30.000Z') + expect(call.costUSD).toBeGreaterThan(0) + expect(call.deduplicationKey).toContain('pi:') + expect(call.deduplicationKey).toContain('resp-abc') + }) + + it('collects tool names from toolCall content items', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ + tools: [ + { name: 'read' }, + { name: 'edit' }, + { name: 'bash', command: 'git status' }, + ], + }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash']) + }) + + it('extracts bash commands from bash tool arguments', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ + tools: [ + { name: 'bash', command: 'git status && bun test' }, + ], + }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls[0]!.bashCommands).toEqual(['git', 'bun']) + }) + + it('skips assistant messages with zero tokens', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ input: 0, output: 0 }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(0) + }) + + it('deduplicates calls seen across multiple parses', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta(), + assistantMessage({ responseId: 'resp-dup' }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const seenKeys = new Set() + + const firstRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + firstRun.push(call) + } + + const secondRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) { + secondRun.push(call) + } + + expect(firstRun).toHaveLength(1) + expect(secondRun).toHaveLength(0) + }) + + it('yields one call per assistant message in a multi-turn session', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const filePath = await writeSession(projectDir, 'session.jsonl', [ + sessionMeta({ id: 'sess-multi' }), + userMessage('first question'), + assistantMessage({ responseId: 'resp-1', timestamp: '2026-04-14T10:00:30.000Z', input: 500, output: 100 }), + userMessage('second question'), + assistantMessage({ responseId: 'resp-2', timestamp: '2026-04-14T10:01:00.000Z', input: 600, output: 120 }), + ]) + + const provider = createPiProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('first question') + expect(calls[0]!.inputTokens).toBe(500) + expect(calls[1]!.userMessage).toBe('second question') + expect(calls[1]!.inputTokens).toBe(600) + }) + + it('handles missing session file gracefully', async () => { + const provider = createPiProvider(tmpDir) + const source = { path: '/nonexistent/session.jsonl', project: 'test', provider: 'pi' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + expect(calls).toHaveLength(0) + }) +}) + +describe('pi provider - display names', () => { + const provider = createPiProvider('/tmp') + + it('has correct name and displayName', () => { + expect(provider.name).toBe('pi') + expect(provider.displayName).toBe('Pi') + }) + + it('maps known models to readable names', () => { + expect(provider.modelDisplayName('gpt-5.4')).toBe('GPT-5.4') + expect(provider.modelDisplayName('gpt-5.4-mini')).toBe('GPT-5.4 Mini') + expect(provider.modelDisplayName('gpt-5')).toBe('GPT-5') + }) + + it('returns raw name for unknown models', () => { + expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model') + }) + + it('normalizes tool names to capitalized form', () => { + expect(provider.toolDisplayName('bash')).toBe('Bash') + expect(provider.toolDisplayName('read')).toBe('Read') + expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool') + }) +})