Merge branch 'feat/pi-provider'

This commit is contained in:
AgentSeal 2026-04-16 02:14:53 -07:00
commit 55d82a4526
9 changed files with 600 additions and 10 deletions

View file

@ -19,7 +19,7 @@
<img src="https://raw.githubusercontent.com/AgentSeal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
</p>
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/<sanitized-cwd>/*.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

View file

@ -21,6 +21,7 @@
"cursor",
"codex",
"opencode",
"pi",
"ai-coding",
"token-usage",
"cost-tracking",

View file

@ -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)
}
}

View file

@ -48,6 +48,8 @@ const PROVIDER_COLORS: Record<string, string> = {
claude: '#FF8C42',
codex: '#5BF5A0',
cursor: '#00B4D8',
opencode: '#A78BFA',
pi: '#F472B6',
all: '#FF8C42',
}
@ -427,6 +429,8 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
claude: 'Claude',
codex: 'Codex',
cursor: 'Cursor',
opencode: 'OpenCode',
pi: 'Pi',
}
function getProviderDisplayName(name: string): string {

View file

@ -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<Provider | null> {
}
}
const coreProviders: Provider[] = [claude, codex]
const coreProviders: Provider[] = [claude, codex, pi]
export async function getAllProviders(): Promise<Provider[]> {
const [cursor, opencode] = await Promise.all([loadCursor(), loadOpenCode()])
@ -68,4 +69,3 @@ export async function getProvider(name: string): Promise<Provider | undefined> {
}
return coreProviders.find(p => p.name === name)
}

227
src/providers/pi.ts Normal file
View file

@ -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<string, string> = {
'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<string, string> = {
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<string, unknown> }>
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<PiEntry | null> {
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<SessionSource[]> {
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<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
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<SessionSource[]> {
return discoverSessionsInDir(dir)
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const pi = createPiProvider()

View file

@ -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', () => {

View file

@ -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 () => {

336
tests/providers/pi.test.ts Normal file
View file

@ -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<string>()
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')
})
})