@tduyng/pi-cost 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +178 -0
- package/README.md +69 -2
- package/package.json +47 -4
- package/src/cost/config.ts +46 -0
- package/src/cost/index.ts +460 -0
- package/src/cost/logic.ts +513 -0
- package/src/cost/overhead.ts +70 -0
- package/src/statusline/index.ts +162 -0
- package/src/statusline/logic.ts +68 -0
- package/src/timestamps/index.ts +43 -0
- package/src/timestamps/logic.ts +77 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
DynamicBorder,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
getAgentDir,
|
|
8
|
+
type ExtensionCommandContext,
|
|
9
|
+
type Theme
|
|
10
|
+
} from '@earendil-works/pi-coding-agent'
|
|
11
|
+
import { Container, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
|
|
12
|
+
|
|
13
|
+
import { addTurnCost, formatCostUsd, type ModelRates } from '../statusline/logic.js'
|
|
14
|
+
import { loadCostConfig } from './config.js'
|
|
15
|
+
import {
|
|
16
|
+
type AnthropicUsagePayload,
|
|
17
|
+
anthropicRows,
|
|
18
|
+
bar,
|
|
19
|
+
cachedQuota,
|
|
20
|
+
cachedRecords,
|
|
21
|
+
type CommandCodeCreditsPayload,
|
|
22
|
+
commandCodeRows,
|
|
23
|
+
formatCacheHit,
|
|
24
|
+
formatDuration,
|
|
25
|
+
formatReset,
|
|
26
|
+
formatTokenCell,
|
|
27
|
+
formatTokens,
|
|
28
|
+
type LimitRow,
|
|
29
|
+
type OpenCodeUsagePayload,
|
|
30
|
+
claudeAccessToken,
|
|
31
|
+
openCodeRows,
|
|
32
|
+
type ProviderTokenRow,
|
|
33
|
+
providerTokenRows,
|
|
34
|
+
readCostCache,
|
|
35
|
+
scanUsage,
|
|
36
|
+
type SessionTotals,
|
|
37
|
+
type SessionTurn,
|
|
38
|
+
sessionTotals,
|
|
39
|
+
tokenScanSince,
|
|
40
|
+
totalTokens,
|
|
41
|
+
writeCostCache
|
|
42
|
+
} from './logic.js'
|
|
43
|
+
import { readOverhead, sumOverhead, type OverheadTotals } from './overhead.js'
|
|
44
|
+
|
|
45
|
+
const PANEL_SHARE = 0.7
|
|
46
|
+
const REQUEST_TIMEOUT_MS = 10_000
|
|
47
|
+
const KEYCHAIN_TIMEOUT_MS = 5_000
|
|
48
|
+
const LABEL_WIDTH = 20
|
|
49
|
+
const TOKEN_COLUMN_WIDTH = 9
|
|
50
|
+
const TOKEN_HEADERS = ['in', 'out', 'cache rd', 'cache wr', 'total', 'cost']
|
|
51
|
+
const PROVIDER_WIDTH = 15
|
|
52
|
+
|
|
53
|
+
interface SessionSpend {
|
|
54
|
+
costUsd: number
|
|
55
|
+
totals: SessionTotals
|
|
56
|
+
overhead: OverheadTotals
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface Section {
|
|
60
|
+
provider: string
|
|
61
|
+
title: string
|
|
62
|
+
rows?: LimitRow[]
|
|
63
|
+
error?: string
|
|
64
|
+
fetchedAt?: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface QuotaSection {
|
|
68
|
+
provider: string
|
|
69
|
+
title: string
|
|
70
|
+
probe: (ctx: ExtensionCommandContext, pi: ExtensionAPI) => Promise<Section | undefined>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const QUOTA_SECTIONS: readonly QuotaSection[] = [
|
|
74
|
+
{ provider: 'commandcode', title: 'Command Code', probe: commandCodeSection },
|
|
75
|
+
{ provider: 'opencode-go', title: 'OpenCode Go', probe: openCodeGoSection },
|
|
76
|
+
{ provider: 'claude', title: 'Claude', probe: (_ctx, pi) => claudeSection(pi) }
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
async function getJson<T>(url: string, headers: Record<string, string>): Promise<T> {
|
|
80
|
+
const response = await fetch(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) })
|
|
81
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
82
|
+
return (await response.json()) as T
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function errorText(cause: unknown): string {
|
|
86
|
+
return cause instanceof Error ? cause.message : String(cause)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function claudeToken(
|
|
90
|
+
pi: ExtensionAPI,
|
|
91
|
+
platform: NodeJS.Platform = process.platform
|
|
92
|
+
): Promise<string | undefined> {
|
|
93
|
+
let raw: string | undefined
|
|
94
|
+
if (platform === 'darwin') {
|
|
95
|
+
const result = await pi.exec('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], {
|
|
96
|
+
cwd: os.homedir(),
|
|
97
|
+
timeout: KEYCHAIN_TIMEOUT_MS
|
|
98
|
+
})
|
|
99
|
+
if (result.code && result.code !== 44) throw new Error(`keychain lookup failed (exit ${result.code})`)
|
|
100
|
+
raw = result.stdout
|
|
101
|
+
}
|
|
102
|
+
if (!raw) {
|
|
103
|
+
raw = await fs.readFile(path.join(os.homedir(), '.claude', '.credentials.json'), 'utf8').catch(() => undefined)
|
|
104
|
+
}
|
|
105
|
+
return claudeAccessToken(raw)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function fetchAnthropicUsage(token: string): Promise<AnthropicUsagePayload> {
|
|
109
|
+
return getJson<AnthropicUsagePayload>('https://api.anthropic.com/api/oauth/usage', {
|
|
110
|
+
authorization: `Bearer ${token}`,
|
|
111
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
112
|
+
accept: 'application/json'
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function commandCodeSection(ctx: ExtensionCommandContext): Promise<Section | undefined> {
|
|
117
|
+
const key = await ctx.modelRegistry.getApiKeyForProvider('commandcode').catch(() => undefined)
|
|
118
|
+
if (!key) return undefined
|
|
119
|
+
const section: Section = { provider: 'commandcode', title: 'Command Code' }
|
|
120
|
+
try {
|
|
121
|
+
const payload = await getJson<CommandCodeCreditsPayload>('https://api.commandcode.ai/alpha/billing/credits', {
|
|
122
|
+
authorization: `Bearer ${key}`,
|
|
123
|
+
accept: 'application/json'
|
|
124
|
+
})
|
|
125
|
+
section.rows = commandCodeRows(payload)
|
|
126
|
+
if (section.rows.length === 0) section.error = 'no quota reported for this key'
|
|
127
|
+
} catch (error) {
|
|
128
|
+
section.error = errorText(error)
|
|
129
|
+
}
|
|
130
|
+
return section
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function openCodeGoSection(ctx: ExtensionCommandContext): Promise<Section | undefined> {
|
|
134
|
+
const key =
|
|
135
|
+
(await ctx.modelRegistry.getApiKeyForProvider('opencode-go').catch(() => undefined)) ||
|
|
136
|
+
process.env['OPENCODE_API_KEY']
|
|
137
|
+
if (!key) return undefined
|
|
138
|
+
const section: Section = { provider: 'opencode-go', title: 'OpenCode Go' }
|
|
139
|
+
try {
|
|
140
|
+
const payload = await getJson<OpenCodeUsagePayload>('https://opencode.ai/zen/go/v1/usage', {
|
|
141
|
+
authorization: `Bearer ${key}`,
|
|
142
|
+
accept: 'application/json'
|
|
143
|
+
})
|
|
144
|
+
section.rows = openCodeRows(payload)
|
|
145
|
+
if (section.rows.length === 0) section.error = 'no usage windows reported'
|
|
146
|
+
} catch (error) {
|
|
147
|
+
section.error = errorText(error)
|
|
148
|
+
}
|
|
149
|
+
return section
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function claudeSection(pi: ExtensionAPI): Promise<Section | undefined> {
|
|
153
|
+
const token = await claudeToken(pi)
|
|
154
|
+
if (!token) return undefined
|
|
155
|
+
const section: Section = { provider: 'claude', title: 'Claude' }
|
|
156
|
+
try {
|
|
157
|
+
section.rows = anthropicRows(await fetchAnthropicUsage(token))
|
|
158
|
+
if (section.rows.length === 0) section.error = 'no usage windows reported'
|
|
159
|
+
} catch (error) {
|
|
160
|
+
section.error = errorText(error)
|
|
161
|
+
}
|
|
162
|
+
return section
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function modelRates(ctx: ExtensionCommandContext): ModelRates | undefined {
|
|
166
|
+
const cost = ctx.model?.cost
|
|
167
|
+
return cost
|
|
168
|
+
? { input: cost.input, output: cost.output, cacheRead: cost.cacheRead, cacheWrite: cost.cacheWrite }
|
|
169
|
+
: undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function sessionSpend(ctx: ExtensionCommandContext, overheadRecords: ReturnType<typeof readOverhead>): SessionSpend {
|
|
173
|
+
const turns: SessionTurn[] = []
|
|
174
|
+
let costUsd = 0
|
|
175
|
+
let collapsed = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
176
|
+
const currentRates = modelRates(ctx)
|
|
177
|
+
const ratesFor = (provider: string, model: string): ModelRates | undefined => {
|
|
178
|
+
const found = ctx.modelRegistry.find(provider, model)
|
|
179
|
+
const cost = found?.cost
|
|
180
|
+
return cost
|
|
181
|
+
? { input: cost.input, output: cost.output, cacheRead: cost.cacheRead, cacheWrite: cost.cacheWrite }
|
|
182
|
+
: undefined
|
|
183
|
+
}
|
|
184
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
185
|
+
if (entry.type === 'branch_summary') {
|
|
186
|
+
if (entry.usage) {
|
|
187
|
+
const usage = entry.usage
|
|
188
|
+
collapsed = {
|
|
189
|
+
input: collapsed.input + usage.input,
|
|
190
|
+
output: collapsed.output + usage.output,
|
|
191
|
+
cacheRead: collapsed.cacheRead + usage.cacheRead,
|
|
192
|
+
cacheWrite: collapsed.cacheWrite + usage.cacheWrite
|
|
193
|
+
}
|
|
194
|
+
costUsd = addTurnCost(costUsd, usage, currentRates)
|
|
195
|
+
}
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (entry.type !== 'message') continue
|
|
199
|
+
if (entry.message.role !== 'assistant') {
|
|
200
|
+
turns.push({ usage: undefined, timestamp: entry.timestamp })
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
const usage = entry.message.usage
|
|
204
|
+
costUsd = addTurnCost(costUsd, usage, ratesFor(entry.message.provider, entry.message.model) ?? currentRates)
|
|
205
|
+
turns.push({ usage, timestamp: entry.timestamp })
|
|
206
|
+
}
|
|
207
|
+
const totals = sessionTotals(turns)
|
|
208
|
+
totals.input += collapsed.input
|
|
209
|
+
totals.output += collapsed.output
|
|
210
|
+
totals.cacheRead += collapsed.cacheRead
|
|
211
|
+
totals.cacheWrite += collapsed.cacheWrite
|
|
212
|
+
return { costUsd, totals, overhead: sumOverhead(overheadRecords) }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function sessionLines(session: SessionSpend, theme: Theme): string[] {
|
|
216
|
+
const label = (text: string) => theme.fg('dim', text.padEnd(LABEL_WIDTH))
|
|
217
|
+
const totals = session.totals
|
|
218
|
+
const hit = formatCacheHit(totals)
|
|
219
|
+
const extensions = [...session.overhead.byExtension].map(([name, spend]) => `${name} ${spend.calls}`).join(', ')
|
|
220
|
+
return [
|
|
221
|
+
` ${label('Cost')}${formatCostUsd(session.costUsd)}`,
|
|
222
|
+
...(session.overhead.calls === 0
|
|
223
|
+
? []
|
|
224
|
+
: [
|
|
225
|
+
` ${label('Extensions')}${formatCostUsd(session.overhead.costUsd)} · ${formatTokens(session.overhead.input)} in · ${formatTokens(session.overhead.output)} out · ${session.overhead.calls} calls (${extensions})`
|
|
226
|
+
]),
|
|
227
|
+
` ${label('Tokens')}${formatTokens(totals.input)} in · ${formatTokens(totals.output)} out · ${formatTokens(totals.cacheRead)} cache read · ${formatTokens(totals.cacheWrite)} cache write`,
|
|
228
|
+
...(hit === undefined
|
|
229
|
+
? []
|
|
230
|
+
: [` ${label('Cache hit')}${hit} ${theme.fg('dim', 'of prompt tokens served from cache')}`]),
|
|
231
|
+
` ${label('Turns')}${totals.turns} in ${formatDuration(totals.wallMs)}`
|
|
232
|
+
]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function tokenRowLine(row: ProviderTokenRow, repeated: boolean, cost: string, theme: Theme): string {
|
|
236
|
+
const label = `${(repeated ? '' : row.provider).padEnd(PROVIDER_WIDTH)}${row.window}`
|
|
237
|
+
const breakdown = [row.input, row.output, row.cacheRead, row.cacheWrite, totalTokens(row)]
|
|
238
|
+
.map((value) => formatTokenCell(value).padStart(TOKEN_COLUMN_WIDTH))
|
|
239
|
+
.join('')
|
|
240
|
+
return ` ${theme.fg('dim', label.padEnd(LABEL_WIDTH))}${theme.fg('dim', breakdown + cost.padStart(TOKEN_COLUMN_WIDTH))}`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function tokenLines(
|
|
244
|
+
rows: ProviderTokenRow[],
|
|
245
|
+
theme: Theme,
|
|
246
|
+
note: string | undefined,
|
|
247
|
+
costShown: (provider: string) => boolean
|
|
248
|
+
): string[] {
|
|
249
|
+
const headers = TOKEN_HEADERS.map((header) => header.padStart(TOKEN_COLUMN_WIDTH)).join('')
|
|
250
|
+
const lines = [
|
|
251
|
+
`${theme.fg('accent', 'Tokens by provider')} ${theme.fg('dim', 'all projects')}`,
|
|
252
|
+
` ${' '.repeat(LABEL_WIDTH)}${theme.fg('dim', headers)}`,
|
|
253
|
+
...rows.map((row, index) => {
|
|
254
|
+
const cost = !costShown(row.provider) ? '' : row.costUsd > 0 ? formatCostUsd(row.costUsd) : '·'
|
|
255
|
+
return tokenRowLine(row, row.provider === rows[index - 1]?.provider, cost, theme)
|
|
256
|
+
})
|
|
257
|
+
]
|
|
258
|
+
if (note !== undefined) lines.push(` ${theme.fg('dim', note)}`)
|
|
259
|
+
return lines
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function severity(percent: number): 'error' | 'warning' | 'success' {
|
|
263
|
+
if (percent >= 90) return 'error'
|
|
264
|
+
if (percent >= 70) return 'warning'
|
|
265
|
+
return 'success'
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function rowLine(row: LimitRow, theme: Theme): string {
|
|
269
|
+
const detail = [row.detail, formatReset(row.resetAt)].filter(Boolean).join(' ')
|
|
270
|
+
const label = ` ${theme.fg('dim', row.label.padEnd(LABEL_WIDTH))}`
|
|
271
|
+
if (row.percent === undefined) return `${label}${detail}`
|
|
272
|
+
const percent = `${Math.round(row.percent)}%`.padStart(4)
|
|
273
|
+
return [
|
|
274
|
+
label,
|
|
275
|
+
theme.fg(severity(row.percent), bar(row.percent)),
|
|
276
|
+
` ${percent}`,
|
|
277
|
+
detail ? ` ${theme.fg('dim', detail)}` : ''
|
|
278
|
+
].join('')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function sectionLines(section: Section, theme: Theme): string[] {
|
|
282
|
+
const age =
|
|
283
|
+
section.fetchedAt === undefined
|
|
284
|
+
? ''
|
|
285
|
+
: ` ${theme.fg('dim', `${formatDuration(Date.now() - section.fetchedAt)} old`)}`
|
|
286
|
+
const lines = [`${theme.fg('accent', `${section.title} (${section.provider})`)}${age}`]
|
|
287
|
+
if (section.error !== undefined) lines.push(` ${theme.fg('warning', section.error)}`)
|
|
288
|
+
if (section.rows) lines.push(...section.rows.map((row) => rowLine(row, theme)))
|
|
289
|
+
else if (section.error === undefined) lines.push(` ${theme.fg('dim', 'loading…')}`)
|
|
290
|
+
return lines
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export default function costExtension(pi: ExtensionAPI): void {
|
|
294
|
+
pi.registerCommand('cost', {
|
|
295
|
+
description: 'Show session spend and live provider rate-limit windows',
|
|
296
|
+
handler: async (_args, ctx) => {
|
|
297
|
+
if (ctx.mode !== 'tui') {
|
|
298
|
+
ctx.ui.notify('/cost requires TUI mode.', 'warning')
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const cacheFile = path.join(getAgentDir(), 'cost-cache.json')
|
|
303
|
+
const cache = await readCostCache(cacheFile)
|
|
304
|
+
const { config, problem } = loadCostConfig()
|
|
305
|
+
if (problem !== undefined) ctx.ui.notify(problem, 'warning')
|
|
306
|
+
const costShown = (provider: string) => config.cost === undefined || config.cost.includes(provider)
|
|
307
|
+
const quotaSections = QUOTA_SECTIONS.filter(
|
|
308
|
+
({ provider }) => config.limits === undefined || config.limits.includes(provider)
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
const sections = new Map<string, Section>(
|
|
312
|
+
quotaSections.map(({ provider, title }) => {
|
|
313
|
+
const cached = cachedQuota(cache, provider)
|
|
314
|
+
return [provider, cached ? { provider, title, ...cached } : { provider, title }]
|
|
315
|
+
})
|
|
316
|
+
)
|
|
317
|
+
let render: (() => void) | undefined
|
|
318
|
+
|
|
319
|
+
const settle = (provider: string, section: Section) => {
|
|
320
|
+
const previous = sections.get(provider)
|
|
321
|
+
if (section.error !== undefined && previous?.rows) {
|
|
322
|
+
sections.set(provider, { ...section, rows: previous.rows, fetchedAt: previous.fetchedAt })
|
|
323
|
+
} else {
|
|
324
|
+
sections.set(provider, section)
|
|
325
|
+
if (section.rows) cache.quota[provider] = { rows: section.rows, fetchedAt: Date.now() }
|
|
326
|
+
}
|
|
327
|
+
render?.()
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const resolve = (probe: Promise<Section | undefined>, provider: string) =>
|
|
331
|
+
probe.then(
|
|
332
|
+
(section) => {
|
|
333
|
+
if (section) {
|
|
334
|
+
settle(provider, section)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
sections.delete(provider)
|
|
338
|
+
delete cache.quota[provider]
|
|
339
|
+
render?.()
|
|
340
|
+
},
|
|
341
|
+
(cause: unknown) =>
|
|
342
|
+
settle(provider, {
|
|
343
|
+
provider,
|
|
344
|
+
title: sections.get(provider)?.title ?? provider,
|
|
345
|
+
error: errorText(cause)
|
|
346
|
+
})
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
const probes = quotaSections.map(({ provider, probe }) => resolve(probe(ctx, pi), provider))
|
|
350
|
+
|
|
351
|
+
const sessionDir = ctx.sessionManager.getSessionDir()
|
|
352
|
+
const agentDir = getAgentDir()
|
|
353
|
+
const defaultRoot = path.join(agentDir, 'sessions')
|
|
354
|
+
// Background jobs keep their own sessions under the agent directory, and their
|
|
355
|
+
// spend is this suite's least visible cost, so they are scanned with the rest.
|
|
356
|
+
const scanRoots = sessionDir.startsWith(defaultRoot + path.sep)
|
|
357
|
+
? [defaultRoot, path.join(agentDir, 'jobs')]
|
|
358
|
+
: [sessionDir]
|
|
359
|
+
const since = tokenScanSince()
|
|
360
|
+
const seeded = cachedRecords(cache, since)
|
|
361
|
+
let windowRows = seeded.length > 0 ? providerTokenRows(seeded, Date.now(), config.providers) : undefined
|
|
362
|
+
const scan = scanUsage(scanRoots, since, cache).then((records) => {
|
|
363
|
+
windowRows = providerTokenRows(records, Date.now(), config.providers)
|
|
364
|
+
render?.()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
void Promise.allSettled([...probes, scan]).then(() => writeCostCache(cacheFile, cache))
|
|
368
|
+
|
|
369
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
370
|
+
const container = new Container()
|
|
371
|
+
const border = new DynamicBorder((s: string) => theme.fg('accent', s))
|
|
372
|
+
const body = new Text('', 1, 0)
|
|
373
|
+
const footer = new Text('', 1, 0)
|
|
374
|
+
container.addChild(border)
|
|
375
|
+
container.addChild(body)
|
|
376
|
+
container.addChild(footer)
|
|
377
|
+
container.addChild(border)
|
|
378
|
+
|
|
379
|
+
const usage = ctx.getContextUsage()
|
|
380
|
+
const overheadRecords = readOverhead(getAgentDir(), ctx.sessionManager.getSessionId())
|
|
381
|
+
const header = [
|
|
382
|
+
ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
383
|
+
usage?.percent != null ? `context ${Math.round(usage.percent)}%` : undefined
|
|
384
|
+
]
|
|
385
|
+
.filter(Boolean)
|
|
386
|
+
.join(' · ')
|
|
387
|
+
|
|
388
|
+
let lines: string[] = []
|
|
389
|
+
let scroll = 0
|
|
390
|
+
|
|
391
|
+
const viewport = (): number => {
|
|
392
|
+
const rows = tui.terminal.rows || 24
|
|
393
|
+
const panelRows = Math.max(5, Math.min(rows - 2, Math.floor(rows * PANEL_SHARE)))
|
|
394
|
+
return Math.max(1, panelRows - 3)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
render = () => {
|
|
398
|
+
const session = sessionSpend(ctx, overheadRecords)
|
|
399
|
+
lines = [
|
|
400
|
+
`${theme.fg('accent', theme.bold('Cost and limits'))} ${theme.fg('dim', header)}`,
|
|
401
|
+
'',
|
|
402
|
+
theme.fg('accent', 'Session'),
|
|
403
|
+
...sessionLines(session, theme)
|
|
404
|
+
]
|
|
405
|
+
for (const section of sections.values()) lines.push('', ...sectionLines(section, theme))
|
|
406
|
+
if (sections.size === 0) {
|
|
407
|
+
lines.push('', theme.fg('warning', 'No provider credentials found for a quota lookup.'))
|
|
408
|
+
}
|
|
409
|
+
lines.push(
|
|
410
|
+
'',
|
|
411
|
+
...tokenLines(
|
|
412
|
+
windowRows ?? [],
|
|
413
|
+
theme,
|
|
414
|
+
windowRows ? undefined : 'reading past sessions…',
|
|
415
|
+
costShown
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
tui.requestRender()
|
|
419
|
+
}
|
|
420
|
+
render()
|
|
421
|
+
|
|
422
|
+
return {
|
|
423
|
+
render: (width: number) => {
|
|
424
|
+
const height = viewport()
|
|
425
|
+
const maxScroll = Math.max(0, lines.length - height)
|
|
426
|
+
scroll = Math.min(scroll, maxScroll)
|
|
427
|
+
const inner = Math.max(10, width - 2)
|
|
428
|
+
const visible = lines.slice(scroll, scroll + height)
|
|
429
|
+
while (visible.length < height) visible.push('')
|
|
430
|
+
body.setText(visible.map((line) => truncateToWidth(line, inner, '...')).join('\n'))
|
|
431
|
+
const position =
|
|
432
|
+
maxScroll > 0
|
|
433
|
+
? `${scroll + 1}-${Math.min(lines.length, scroll + height)}/${lines.length} · ↑/↓ scroll · `
|
|
434
|
+
: ''
|
|
435
|
+
footer.setText(theme.fg('dim', `${position}Enter or Esc to close`))
|
|
436
|
+
return container.render(width)
|
|
437
|
+
},
|
|
438
|
+
invalidate: () => container.invalidate(),
|
|
439
|
+
handleInput: (data: string) => {
|
|
440
|
+
if (matchesKey(data, 'enter') || matchesKey(data, 'escape')) {
|
|
441
|
+
render = undefined
|
|
442
|
+
done(undefined)
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
const height = viewport()
|
|
446
|
+
const maxScroll = Math.max(0, lines.length - height)
|
|
447
|
+
let next = scroll
|
|
448
|
+
if (matchesKey(data, 'down')) next += 1
|
|
449
|
+
else if (matchesKey(data, 'up')) next -= 1
|
|
450
|
+
else if (matchesKey(data, 'pageDown')) next += height
|
|
451
|
+
else if (matchesKey(data, 'pageUp')) next -= height
|
|
452
|
+
else return
|
|
453
|
+
scroll = Math.min(Math.max(0, next), maxScroll)
|
|
454
|
+
tui.requestRender()
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
}
|