@brimveyn/aimux 1.22.10 → 1.22.11
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/package.json +1 -1
- package/src/index.tsx +15 -0
- package/src/integrations/claude-hooks-install.ts +3 -7
- package/src/integrations/claude-theme-sync.ts +5 -9
- package/src/platform/assistant-home.ts +25 -0
- package/src/platform/daemon-control.ts +10 -1
- package/src/services/ai-usage/adapters/claude.ts +2 -8
- package/src/services/ai-usage/adapters/codex.ts +1 -7
- package/src/services/usage-history/rollup.ts +278 -0
- package/src/services/usage-history/stats.ts +193 -0
- package/src/services/usage-history/store.ts +174 -0
- package/src/state/reducers/modal-state.ts +4 -0
- package/src/ui/components/modals/app/ai-usage-modal.tsx +351 -24
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +2 -7
- package/src/ui/format-number.ts +12 -0
- package/src/ui/root.tsx +1 -1
package/package.json
CHANGED
package/src/index.tsx
CHANGED
|
@@ -51,6 +51,13 @@ if (command === 'update') {
|
|
|
51
51
|
process.exit(await runUpdate())
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Spawned detached by `maybeSpawnUsageRollup` below. Walks hundreds of MB of
|
|
55
|
+
// assistant JSONL, so it gets its own process and never shares one with the UI.
|
|
56
|
+
if (command === 'usage-rollup') {
|
|
57
|
+
const { runUsageRollup } = await import('./services/usage-history/rollup')
|
|
58
|
+
process.exit(await runUsageRollup())
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
const { logDebug } = await import('./debug/input-log')
|
|
55
62
|
const { getRuntimeProfile } = await import('./daemon/runtime-paths')
|
|
56
63
|
const runtimeProfile = getRuntimeProfile()
|
|
@@ -87,6 +94,7 @@ const [
|
|
|
87
94
|
{ setHostPalette },
|
|
88
95
|
{ createSessionBackend },
|
|
89
96
|
{ maybeAutoInstallCompletion },
|
|
97
|
+
{ maybeSpawnUsageRollup },
|
|
90
98
|
] = await Promise.all([
|
|
91
99
|
import('@opentui/react'),
|
|
92
100
|
import('./app'),
|
|
@@ -95,6 +103,7 @@ const [
|
|
|
95
103
|
import('./ui/host-palette'),
|
|
96
104
|
import('./session-backend/bootstrap'),
|
|
97
105
|
import('./cli/completion/install'),
|
|
106
|
+
import('./services/usage-history/store'),
|
|
98
107
|
])
|
|
99
108
|
const { resolved: resolvedConfig, user: userConfig } = await loadUserConfig()
|
|
100
109
|
|
|
@@ -104,6 +113,12 @@ const { resolved: resolvedConfig, user: userConfig } = await loadUserConfig()
|
|
|
104
113
|
// AIMUX_NO_COMPLETION_INSTALL=1; `aimux doctor` reports what landed where.
|
|
105
114
|
maybeAutoInstallCompletion()
|
|
106
115
|
|
|
116
|
+
// Snapshot today's AI usage before Claude Code prunes the transcripts it came
|
|
117
|
+
// from — roughly a month survives on disk, so the aggregates aimux keeps are
|
|
118
|
+
// the only long-term record. Detached subprocess, at most once per ~day, never
|
|
119
|
+
// blocking. Opt out with AIMUX_NO_USAGE_ROLLUP=1.
|
|
120
|
+
maybeSpawnUsageRollup()
|
|
121
|
+
|
|
107
122
|
const renderer = await createCliRenderer({
|
|
108
123
|
autoFocus: true,
|
|
109
124
|
// Transparent clear color so cells untouched by BoxRenderable paints (e.g.
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// Hooks reference: https://code.claude.com/docs/en/hooks.md
|
|
7
7
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
9
|
-
import { homedir } from 'node:os'
|
|
10
9
|
import { dirname, join, resolve } from 'node:path'
|
|
11
10
|
import { fileURLToPath } from 'node:url'
|
|
12
11
|
|
|
13
12
|
import { logDebug } from '../debug/input-log'
|
|
13
|
+
import { claudeHome } from '../platform/assistant-home'
|
|
14
14
|
|
|
15
15
|
const HOOK_EVENTS = [
|
|
16
16
|
'UserPromptSubmit',
|
|
@@ -34,12 +34,8 @@ interface HookGroupEntry {
|
|
|
34
34
|
hooks: HookCommandEntry[]
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function claudeDir(): string {
|
|
38
|
-
return join(homedir(), '.claude')
|
|
39
|
-
}
|
|
40
|
-
|
|
41
37
|
function settingsFilePath(): string {
|
|
42
|
-
return join(
|
|
38
|
+
return join(claudeHome(), 'settings.json')
|
|
43
39
|
}
|
|
44
40
|
|
|
45
41
|
function writeAtomic(target: string, contents: string): void {
|
|
@@ -180,7 +176,7 @@ export function ensureClaudeSettingsHooks(): boolean {
|
|
|
180
176
|
parsed.hooks = nextHooks
|
|
181
177
|
|
|
182
178
|
try {
|
|
183
|
-
mkdirSync(
|
|
179
|
+
mkdirSync(claudeHome(), { recursive: true })
|
|
184
180
|
writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
185
181
|
logDebug('claude-hooks-install:wrote', { events: HOOK_EVENTS, path: target, scriptPath })
|
|
186
182
|
return true
|
|
@@ -12,24 +12,20 @@ import {
|
|
|
12
12
|
type ThemeMode,
|
|
13
13
|
} from '@brimveyn/aimux-config'
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
15
|
-
import { homedir } from 'node:os'
|
|
16
15
|
import { join } from 'node:path'
|
|
17
16
|
|
|
18
17
|
import { logDebug } from '../debug/input-log'
|
|
18
|
+
import { claudeHome } from '../platform/assistant-home'
|
|
19
19
|
|
|
20
20
|
const THEME_SLUG = 'aimux'
|
|
21
21
|
const THEME_PREF_VALUE = `custom:${THEME_SLUG}`
|
|
22
22
|
|
|
23
|
-
function claudeDir(): string {
|
|
24
|
-
return join(homedir(), '.claude')
|
|
25
|
-
}
|
|
26
|
-
|
|
27
23
|
function themeFilePath(): string {
|
|
28
|
-
return join(
|
|
24
|
+
return join(claudeHome(), 'themes', `${THEME_SLUG}.json`)
|
|
29
25
|
}
|
|
30
26
|
|
|
31
27
|
function settingsFilePath(): string {
|
|
32
|
-
return join(
|
|
28
|
+
return join(claudeHome(), 'settings.json')
|
|
33
29
|
}
|
|
34
30
|
|
|
35
31
|
function writeAtomic(target: string, contents: string): void {
|
|
@@ -67,7 +63,7 @@ export function syncClaudeTheme(resolved: ResolvedTuiTheme, mode: ThemeMode): vo
|
|
|
67
63
|
|
|
68
64
|
const target = themeFilePath()
|
|
69
65
|
try {
|
|
70
|
-
mkdirSync(join(
|
|
66
|
+
mkdirSync(join(claudeHome(), 'themes'), { recursive: true })
|
|
71
67
|
writeAtomic(target, `${JSON.stringify(theme, null, 2)}\n`)
|
|
72
68
|
} catch (error) {
|
|
73
69
|
logSyncWarn('write-failed', { err: String(error), path: target })
|
|
@@ -110,7 +106,7 @@ export function ensureClaudeSettingsThemePref(): void {
|
|
|
110
106
|
parsed.theme = THEME_PREF_VALUE
|
|
111
107
|
|
|
112
108
|
try {
|
|
113
|
-
mkdirSync(
|
|
109
|
+
mkdirSync(claudeHome(), { recursive: true })
|
|
114
110
|
writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
115
111
|
} catch (error) {
|
|
116
112
|
logSyncWarn('settings-write-failed', { err: String(error), path: target })
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where each assistant CLI keeps its state.
|
|
6
|
+
*
|
|
7
|
+
* Both relocate wholesale through an environment variable, so anything reading
|
|
8
|
+
* or writing inside those directories has to go through here — three hand-rolled
|
|
9
|
+
* copies of `join(homedir(), '.claude')` used to disagree about whether
|
|
10
|
+
* `CLAUDE_CONFIG_DIR` existed, which sent the hook installer and the theme sync
|
|
11
|
+
* to a directory Claude Code was not reading.
|
|
12
|
+
*/
|
|
13
|
+
function envHome(variable: string, fallback: string): string {
|
|
14
|
+
const value = process.env[variable]?.trim()
|
|
15
|
+
if (value != null && value !== '') return value
|
|
16
|
+
return join(homedir(), fallback)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function claudeHome(): string {
|
|
20
|
+
return envHome('CLAUDE_CONFIG_DIR', '.claude')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function codexHome(): string {
|
|
24
|
+
return envHome('CODEX_HOME', '.codex')
|
|
25
|
+
}
|
|
@@ -61,14 +61,23 @@ async function waitForSocket(socketPath: string): Promise<boolean> {
|
|
|
61
61
|
return false
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Re-run this same aimux with another subcommand, detached and unwatched.
|
|
66
|
+
*
|
|
67
|
+
* The entrypoint is resolved from this module rather than `process.argv`, which
|
|
68
|
+
* points at whatever wrapper or shim launched us.
|
|
69
|
+
*/
|
|
70
|
+
export function spawnDetachedCommand(command: string): void {
|
|
65
71
|
Bun.spawn([process.execPath, 'run', ENTRY_POINT, command], {
|
|
66
72
|
detached: true,
|
|
67
73
|
stderr: 'ignore',
|
|
68
74
|
stdin: 'ignore',
|
|
69
75
|
stdout: 'ignore',
|
|
70
76
|
}).unref()
|
|
77
|
+
}
|
|
71
78
|
|
|
79
|
+
async function spawnDetachedProcess(command: 'daemon' | 'terminal-manager', socketPath: string) {
|
|
80
|
+
spawnDetachedCommand(command)
|
|
72
81
|
return waitForSocket(socketPath)
|
|
73
82
|
}
|
|
74
83
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
3
|
import { readFile } from 'node:fs/promises'
|
|
4
|
-
import { homedir } from 'node:os'
|
|
5
4
|
import { join } from 'node:path'
|
|
6
5
|
|
|
7
6
|
import type { UsageSnapshot, UsageWindow, UsageWindowKind } from '../types'
|
|
8
7
|
|
|
8
|
+
import { claudeHome } from '../../../platform/assistant-home'
|
|
9
9
|
import { computePace, formatTimeRemaining } from '../pace'
|
|
10
10
|
import { runCli } from '../spawn'
|
|
11
11
|
|
|
@@ -81,15 +81,9 @@ function planTierFromPayload(payload: ClaudeCredentialsPayload): string | null {
|
|
|
81
81
|
return null
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
function claudeConfigDir(): string {
|
|
85
|
-
const env = process.env.CLAUDE_CONFIG_DIR?.trim()
|
|
86
|
-
if (env != null && env !== '') return env
|
|
87
|
-
return join(homedir(), '.claude')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
84
|
async function readCredsFromFile(): Promise<string | null> {
|
|
91
85
|
try {
|
|
92
|
-
const raw = await readFile(join(
|
|
86
|
+
const raw = await readFile(join(claudeHome(), '.credentials.json'), 'utf8')
|
|
93
87
|
return raw.trim() || null
|
|
94
88
|
} catch {
|
|
95
89
|
return null
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
3
|
import { readFile } from 'node:fs/promises'
|
|
4
|
-
import { homedir } from 'node:os'
|
|
5
4
|
import { join } from 'node:path'
|
|
6
5
|
|
|
7
6
|
import type { UsageSnapshot, UsageWindow, UsageWindowKind } from '../types'
|
|
8
7
|
|
|
8
|
+
import { codexHome } from '../../../platform/assistant-home'
|
|
9
9
|
import { computePace, formatTimeRemaining } from '../pace'
|
|
10
10
|
|
|
11
11
|
interface CodexAuthFile {
|
|
@@ -38,12 +38,6 @@ const DEFAULT_CHATGPT_BASE = 'https://chatgpt.com/backend-api'
|
|
|
38
38
|
const USAGE_PATH = '/wham/usage'
|
|
39
39
|
const AUTH_TIMEOUT_MS = 15_000
|
|
40
40
|
|
|
41
|
-
function codexHome(): string {
|
|
42
|
-
const env = process.env.CODEX_HOME?.trim()
|
|
43
|
-
if (env != null && env !== '') return env
|
|
44
|
-
return join(homedir(), '.codex')
|
|
45
|
-
}
|
|
46
|
-
|
|
47
41
|
function parseChatGPTBaseFromConfig(contents: string): string | null {
|
|
48
42
|
for (const rawLine of contents.split(/\r?\n/)) {
|
|
49
43
|
const line = rawLine.split('#', 1)[0]?.trim() ?? ''
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { logDebug } from '../../debug/input-log'
|
|
5
|
+
import { claudeHome, codexHome } from '../../platform/assistant-home'
|
|
6
|
+
import {
|
|
7
|
+
emptyDay,
|
|
8
|
+
localDay,
|
|
9
|
+
saveUsageHistory,
|
|
10
|
+
type UsageDay,
|
|
11
|
+
type UsageDays,
|
|
12
|
+
type UsageTools,
|
|
13
|
+
} from './store'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Rebuilds the usage aggregates from what the assistants still have on disk.
|
|
17
|
+
*
|
|
18
|
+
* Its own process (`aimux usage-rollup`). Nothing here may be imported from the
|
|
19
|
+
* startup path: it walks hundreds of megabytes of JSONL.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const FILE_CONCURRENCY = 8
|
|
23
|
+
|
|
24
|
+
function dayAt(days: UsageDays, date: string): UsageDay {
|
|
25
|
+
const existing = days[date]
|
|
26
|
+
if (existing !== undefined) return existing
|
|
27
|
+
const created = emptyDay()
|
|
28
|
+
days[date] = created
|
|
29
|
+
return created
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bump(counts: Record<string, number>, key: string, amount: number): void {
|
|
33
|
+
counts[key] = (counts[key] ?? 0) + amount
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Streamed: the largest transcript here is ~90 MB, which `Bun.file().text()` would materialize whole. */
|
|
37
|
+
export async function readJsonlLines(path: string, onLine: (line: string) => void): Promise<void> {
|
|
38
|
+
const decoder = new TextDecoder()
|
|
39
|
+
let carry = ''
|
|
40
|
+
|
|
41
|
+
for await (const chunk of Bun.file(path).stream()) {
|
|
42
|
+
// `stream: true` holds back a partial UTF-8 sequence at the chunk edge
|
|
43
|
+
// instead of replacing it with U+FFFD and corrupting the line.
|
|
44
|
+
const parts = (carry + decoder.decode(chunk, { stream: true })).split('\n')
|
|
45
|
+
carry = parts.pop() ?? ''
|
|
46
|
+
for (const line of parts) onLine(line)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
carry += decoder.decode()
|
|
50
|
+
// No trailing newline, or a file being appended to right now — the half-written
|
|
51
|
+
// last line just fails JSON.parse downstream.
|
|
52
|
+
if (carry !== '') onLine(carry)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findJsonl(root: string): string[] {
|
|
56
|
+
try {
|
|
57
|
+
// Recursive: transcripts nest at varying depths and a flat readdir misses half.
|
|
58
|
+
return readdirSync(root, { recursive: true })
|
|
59
|
+
.filter((entry): entry is string => typeof entry === 'string' && entry.endsWith('.jsonl'))
|
|
60
|
+
.map((entry) => join(root, entry))
|
|
61
|
+
} catch {
|
|
62
|
+
return [] // assistant not installed on this machine
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function forEachFile(paths: string[], run: (path: string) => Promise<void>): Promise<void> {
|
|
67
|
+
let cursor = 0
|
|
68
|
+
// `cursor++` needs no lock: no await between read and increment, single thread.
|
|
69
|
+
const workers = Array.from({ length: Math.min(FILE_CONCURRENCY, paths.length) }, async () => {
|
|
70
|
+
while (cursor < paths.length) {
|
|
71
|
+
const path = paths[cursor++]
|
|
72
|
+
if (path === undefined) return
|
|
73
|
+
try {
|
|
74
|
+
await run(path)
|
|
75
|
+
} catch {
|
|
76
|
+
// One unreadable transcript must never sink the whole rollup.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
await Promise.all(workers)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface TranscriptUsage {
|
|
84
|
+
cache_creation_input_tokens?: number
|
|
85
|
+
cache_read_input_tokens?: number
|
|
86
|
+
input_tokens?: number
|
|
87
|
+
output_tokens?: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface TranscriptLine {
|
|
91
|
+
gitBranch?: string
|
|
92
|
+
message?: { id?: string; model?: string; usage?: TranscriptUsage }
|
|
93
|
+
requestId?: string
|
|
94
|
+
timestamp?: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `seen` spans the whole run: resume, fork and compaction re-emit the same billed request across files. */
|
|
98
|
+
export function consumeTranscriptLine(line: string, seen: Set<string>, days: UsageDays): void {
|
|
99
|
+
// ~23k of ~126k lines carry usage; the rest hold base64 thinking blobs. A
|
|
100
|
+
// substring test beats parsing them.
|
|
101
|
+
if (!line.includes('"output_tokens"')) return
|
|
102
|
+
|
|
103
|
+
let entry: TranscriptLine
|
|
104
|
+
try {
|
|
105
|
+
entry = JSON.parse(line) as TranscriptLine
|
|
106
|
+
} catch {
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const usage = entry.message?.usage
|
|
111
|
+
if (usage === undefined || entry.timestamp == null) return
|
|
112
|
+
|
|
113
|
+
// Placeholder messages (API errors, interrupted turns) carry junk usage.
|
|
114
|
+
const model = entry.message?.model
|
|
115
|
+
if (model === '<synthetic>') return
|
|
116
|
+
|
|
117
|
+
const id = entry.message?.id
|
|
118
|
+
const { requestId } = entry
|
|
119
|
+
// Both halves or nothing: without an id a duplicate cannot be proven, and
|
|
120
|
+
// undercounting a stats page is worse than a rare double.
|
|
121
|
+
if (id != null && id !== '' && requestId != null && requestId !== '') {
|
|
122
|
+
const key = `${id}:${requestId}`
|
|
123
|
+
if (seen.has(key)) return
|
|
124
|
+
seen.add(key)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const ms = Date.parse(entry.timestamp)
|
|
128
|
+
if (!Number.isFinite(ms)) return
|
|
129
|
+
|
|
130
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0
|
|
131
|
+
const cacheWrite = usage.cache_creation_input_tokens ?? 0
|
|
132
|
+
const input = usage.input_tokens ?? 0
|
|
133
|
+
const output = usage.output_tokens ?? 0
|
|
134
|
+
const total = input + output + cacheRead + cacheWrite
|
|
135
|
+
if (total <= 0) return
|
|
136
|
+
|
|
137
|
+
const day = dayAt(days, localDay(new Date(ms)))
|
|
138
|
+
day.tokens.cacheRead += cacheRead
|
|
139
|
+
day.tokens.cacheWrite += cacheWrite
|
|
140
|
+
day.tokens.input += input
|
|
141
|
+
day.tokens.output += output
|
|
142
|
+
day.tokens.total += total
|
|
143
|
+
|
|
144
|
+
if (model != null && model !== '') bump(day.models, model, total)
|
|
145
|
+
const branch = entry.gitBranch
|
|
146
|
+
if (branch != null && branch !== '') bump(day.branches, branch, total)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** `history.jsonl`, the prompt log — the only source that survives transcript pruning. */
|
|
150
|
+
export function consumeHistoryLine(line: string, days: UsageDays): void {
|
|
151
|
+
if (!line.includes('"timestamp"')) return
|
|
152
|
+
|
|
153
|
+
let entry: { timestamp?: number | string }
|
|
154
|
+
try {
|
|
155
|
+
entry = JSON.parse(line) as { timestamp?: number | string }
|
|
156
|
+
} catch {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A number today, quoted in past releases. `Number()` covers both, where
|
|
161
|
+
// `new Date(quoted)` returns Invalid Date and drops the entry.
|
|
162
|
+
const ms = Number(entry.timestamp)
|
|
163
|
+
if (!Number.isFinite(ms) || ms <= 0) return
|
|
164
|
+
|
|
165
|
+
dayAt(days, localDay(new Date(ms))).prompts += 1
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
interface CodexLine {
|
|
169
|
+
payload?: {
|
|
170
|
+
info?: {
|
|
171
|
+
last_token_usage?: {
|
|
172
|
+
cached_input_tokens?: number
|
|
173
|
+
input_tokens?: number
|
|
174
|
+
output_tokens?: number
|
|
175
|
+
total_tokens?: number
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
model?: string
|
|
179
|
+
}
|
|
180
|
+
timestamp?: string
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface CodexFileState {
|
|
184
|
+
model: string | null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `last_token_usage` is the per-turn delta; the sibling `total_token_usage` is cumulative — summing it overcounts by n²/2. */
|
|
188
|
+
export function consumeCodexLine(line: string, state: CodexFileState, days: UsageDays): void {
|
|
189
|
+
if (!line.includes('"model"') && !line.includes('"last_token_usage"')) return
|
|
190
|
+
|
|
191
|
+
let entry: CodexLine
|
|
192
|
+
try {
|
|
193
|
+
entry = JSON.parse(line) as CodexLine
|
|
194
|
+
} catch {
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const model = entry.payload?.model
|
|
199
|
+
if (model != null && model !== '') state.model = model
|
|
200
|
+
|
|
201
|
+
const usage = entry.payload?.info?.last_token_usage
|
|
202
|
+
if (usage === undefined || entry.timestamp == null) return
|
|
203
|
+
|
|
204
|
+
const ms = Date.parse(entry.timestamp)
|
|
205
|
+
if (!Number.isFinite(ms)) return
|
|
206
|
+
|
|
207
|
+
const cacheRead = usage.cached_input_tokens ?? 0
|
|
208
|
+
const total = usage.total_tokens ?? 0
|
|
209
|
+
if (total <= 0) return
|
|
210
|
+
|
|
211
|
+
const day = dayAt(days, localDay(new Date(ms)))
|
|
212
|
+
day.tokens.cacheRead += cacheRead
|
|
213
|
+
// cached_input_tokens ⊂ input_tokens, so the cached share is subtracted not added.
|
|
214
|
+
day.tokens.input += Math.max(0, (usage.input_tokens ?? 0) - cacheRead)
|
|
215
|
+
day.tokens.output += usage.output_tokens ?? 0
|
|
216
|
+
day.tokens.total += total
|
|
217
|
+
|
|
218
|
+
if (state.model != null && state.model !== '') bump(day.models, state.model, total)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function collectClaude(): Promise<UsageDays> {
|
|
222
|
+
const days: UsageDays = {}
|
|
223
|
+
const seen = new Set<string>()
|
|
224
|
+
const root = claudeHome()
|
|
225
|
+
|
|
226
|
+
await forEachFile(findJsonl(join(root, 'projects')), async (path) => {
|
|
227
|
+
await readJsonlLines(path, (line) => {
|
|
228
|
+
consumeTranscriptLine(line, seen, days)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
await readJsonlLines(join(root, 'history.jsonl'), (line) => {
|
|
234
|
+
consumeHistoryLine(line, days)
|
|
235
|
+
})
|
|
236
|
+
} catch {
|
|
237
|
+
// No prompt log: the heatmap falls back to whatever the transcripts cover.
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return days
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function collectCodex(): Promise<UsageDays> {
|
|
244
|
+
const days: UsageDays = {}
|
|
245
|
+
|
|
246
|
+
await forEachFile(findJsonl(join(codexHome(), 'sessions')), async (path) => {
|
|
247
|
+
const state: CodexFileState = { model: null }
|
|
248
|
+
await readJsonlLines(path, (line) => {
|
|
249
|
+
consumeCodexLine(line, state, days)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
return days
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function runUsageRollup(): Promise<number> {
|
|
257
|
+
try {
|
|
258
|
+
const [claude, codex] = await Promise.all([collectClaude(), collectCodex()])
|
|
259
|
+
|
|
260
|
+
const fresh: UsageTools = {}
|
|
261
|
+
if (Object.keys(claude).length > 0) fresh.claude = claude
|
|
262
|
+
if (Object.keys(codex).length > 0) fresh.codex = codex
|
|
263
|
+
|
|
264
|
+
// Nothing parsed means every source was missing. Writing would bump the
|
|
265
|
+
// mtime, which schedules the next run, and buy a day of not retrying.
|
|
266
|
+
if (Object.keys(fresh).length === 0) {
|
|
267
|
+
logDebug('usageHistory.rollupEmpty', {})
|
|
268
|
+
return 1
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return saveUsageHistory(fresh) ? 0 : 1
|
|
272
|
+
} catch (error) {
|
|
273
|
+
logDebug('usageHistory.rollupError', {
|
|
274
|
+
error: error instanceof Error ? error.message : String(error),
|
|
275
|
+
})
|
|
276
|
+
return 1
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { parseColor, RGBA } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { emptyTokens, localDay, type UsageDays, type UsageTokens } from './store'
|
|
4
|
+
|
|
5
|
+
/** Pure shaping of stored usage days into what the History page renders. */
|
|
6
|
+
|
|
7
|
+
export interface HeatmapCell {
|
|
8
|
+
/** 'YYYY-MM-DD', or '' for a cell outside the covered range. */
|
|
9
|
+
day: string
|
|
10
|
+
level: number
|
|
11
|
+
value: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseDay(key: string): Date {
|
|
15
|
+
const [year, month, day] = key.split('-').map(Number)
|
|
16
|
+
return new Date(year ?? 0, (month ?? 1) - 1, day ?? 1)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Whole days apart. Via `Date.UTC` on the calendar parts: a DST span is not a whole number of 24h periods. */
|
|
20
|
+
function daysBetween(from: Date, to: Date): number {
|
|
21
|
+
const utcOf = (date: Date): number =>
|
|
22
|
+
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
|
|
23
|
+
return Math.round((utcOf(to) - utcOf(from)) / 86_400_000)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Quartiles of the non-empty days: a linear `value / max` ramp lets one outlier flatten the year to level 1. */
|
|
27
|
+
export function cutPoints(values: number[]): [number, number, number] {
|
|
28
|
+
const sorted = values.filter((value) => value > 0).sort((left, right) => left - right)
|
|
29
|
+
const at = (quantile: number): number =>
|
|
30
|
+
sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * quantile))] ?? 0
|
|
31
|
+
return [at(0.25), at(0.5), at(0.75)]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function levelOf(value: number, cuts: [number, number, number]): number {
|
|
35
|
+
if (value <= 0) return 0
|
|
36
|
+
if (value <= cuts[0]) return 1
|
|
37
|
+
if (value <= cuts[1]) return 2
|
|
38
|
+
if (value <= cuts[2]) return 3
|
|
39
|
+
return 4
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Week columns needed to cover what is recorded, so the grid does not open on empty months. */
|
|
43
|
+
export function coveredWeeks(days: UsageDays, today: Date, maxWeeks: number): number {
|
|
44
|
+
const dates = Object.keys(days).sort()
|
|
45
|
+
const first = dates[0]
|
|
46
|
+
if (first === undefined) return Math.min(maxWeeks, 12)
|
|
47
|
+
const elapsed = daysBetween(parseDay(first), today)
|
|
48
|
+
return Math.max(4, Math.min(maxWeeks, Math.ceil((elapsed + 1) / 7) + 1))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 7 rows (Monday first) by `weeks` columns, oldest left. Cells after today come back empty. */
|
|
52
|
+
export function buildHeatmap(
|
|
53
|
+
counts: Record<string, number>,
|
|
54
|
+
weeks: number,
|
|
55
|
+
today: Date
|
|
56
|
+
): HeatmapCell[][] {
|
|
57
|
+
const cuts = cutPoints(Object.values(counts))
|
|
58
|
+
|
|
59
|
+
// Anchored on the Sunday closing this week, so the last column is the week in progress.
|
|
60
|
+
const mondayIndex = (today.getDay() + 6) % 7
|
|
61
|
+
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate() + (6 - mondayIndex))
|
|
62
|
+
// Calendar arithmetic, never `getTime() - n * DAY_MS`: across a DST change the
|
|
63
|
+
// millisecond form lands on 23:00 the day before and rotates every row by one.
|
|
64
|
+
const start = new Date(end.getFullYear(), end.getMonth(), end.getDate() - (weeks * 7 - 1))
|
|
65
|
+
const todayKey = localDay(today)
|
|
66
|
+
|
|
67
|
+
const rows: HeatmapCell[][] = []
|
|
68
|
+
for (let row = 0; row < 7; row++) {
|
|
69
|
+
const cells: HeatmapCell[] = []
|
|
70
|
+
for (let column = 0; column < weeks; column++) {
|
|
71
|
+
const date = new Date(
|
|
72
|
+
start.getFullYear(),
|
|
73
|
+
start.getMonth(),
|
|
74
|
+
start.getDate() + column * 7 + row
|
|
75
|
+
)
|
|
76
|
+
const key = localDay(date)
|
|
77
|
+
if (key > todayKey) {
|
|
78
|
+
cells.push({ day: '', level: 0, value: 0 })
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const value = counts[key] ?? 0
|
|
82
|
+
cells.push({ day: key, level: levelOf(value, cuts), value })
|
|
83
|
+
}
|
|
84
|
+
rows.push(cells)
|
|
85
|
+
}
|
|
86
|
+
return rows
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Month initial over each column that opens a month. `cellWidth` mirrors the grid's own. */
|
|
90
|
+
export function monthRuler(grid: HeatmapCell[][], weeks: number, cellWidth: number): string {
|
|
91
|
+
const initials = 'JFMAMJJASOND'
|
|
92
|
+
const chars: string[] = Array.from({ length: weeks * cellWidth }, () => ' ')
|
|
93
|
+
let previous = ''
|
|
94
|
+
for (let column = 0; column < weeks; column++) {
|
|
95
|
+
const day = grid[0]?.[column]?.day
|
|
96
|
+
if (day == null || day === '') continue
|
|
97
|
+
const month = day.slice(5, 7)
|
|
98
|
+
if (month !== previous) {
|
|
99
|
+
chars[column * cellWidth] = initials[Number(month) - 1] ?? ' '
|
|
100
|
+
previous = month
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return chars.join('')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function promptCounts(days: UsageDays): Record<string, number> {
|
|
107
|
+
const counts: Record<string, number> = {}
|
|
108
|
+
for (const [date, day] of Object.entries(days)) counts[date] = day.prompts
|
|
109
|
+
return counts
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface UsageSummary {
|
|
113
|
+
branches: [string, number][]
|
|
114
|
+
/** Denominator for branch shares — branch-less entries are excluded. */
|
|
115
|
+
branchTotal: number
|
|
116
|
+
models: [string, number][]
|
|
117
|
+
modelTotal: number
|
|
118
|
+
peakPrompts: number
|
|
119
|
+
promptDays: number
|
|
120
|
+
/** Days carrying token data — a shorter span than `promptDays` once pruning starts. */
|
|
121
|
+
tokenDays: number
|
|
122
|
+
tokens: UsageTokens
|
|
123
|
+
totalPrompts: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function rank(totals: Record<string, number>): { entries: [string, number][]; total: number } {
|
|
127
|
+
const entries = Object.entries(totals)
|
|
128
|
+
entries.sort((left, right) => right[1] - left[1])
|
|
129
|
+
let total = 0
|
|
130
|
+
for (const [, value] of entries) total += value
|
|
131
|
+
return { entries, total }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function summarizeDays(days: UsageDays, limit = 6): UsageSummary {
|
|
135
|
+
const tokens = emptyTokens()
|
|
136
|
+
const models: Record<string, number> = {}
|
|
137
|
+
const branches: Record<string, number> = {}
|
|
138
|
+
let peakPrompts = 0
|
|
139
|
+
let promptDays = 0
|
|
140
|
+
let tokenDays = 0
|
|
141
|
+
let totalPrompts = 0
|
|
142
|
+
|
|
143
|
+
for (const day of Object.values(days)) {
|
|
144
|
+
tokens.cacheRead += day.tokens.cacheRead
|
|
145
|
+
tokens.cacheWrite += day.tokens.cacheWrite
|
|
146
|
+
tokens.input += day.tokens.input
|
|
147
|
+
tokens.output += day.tokens.output
|
|
148
|
+
tokens.total += day.tokens.total
|
|
149
|
+
|
|
150
|
+
if (day.tokens.total > 0) tokenDays += 1
|
|
151
|
+
if (day.prompts > 0) {
|
|
152
|
+
promptDays += 1
|
|
153
|
+
totalPrompts += day.prompts
|
|
154
|
+
if (day.prompts > peakPrompts) peakPrompts = day.prompts
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const [model, count] of Object.entries(day.models)) {
|
|
158
|
+
models[model] = (models[model] ?? 0) + count
|
|
159
|
+
}
|
|
160
|
+
for (const [branch, count] of Object.entries(day.branches)) {
|
|
161
|
+
branches[branch] = (branches[branch] ?? 0) + count
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const rankedModels = rank(models)
|
|
166
|
+
const rankedBranches = rank(branches)
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
branches: rankedBranches.entries.slice(0, limit),
|
|
170
|
+
branchTotal: rankedBranches.total,
|
|
171
|
+
models: rankedModels.entries.slice(0, limit),
|
|
172
|
+
modelTotal: rankedModels.total,
|
|
173
|
+
peakPrompts,
|
|
174
|
+
promptDays,
|
|
175
|
+
tokenDays,
|
|
176
|
+
tokens,
|
|
177
|
+
totalPrompts,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Blends two theme colours: the palette is flat, so the five-step ramp has to be derived per theme. */
|
|
182
|
+
export function mixColor(from: string, to: string, ratio: number): RGBA {
|
|
183
|
+
const start = parseColor(from)
|
|
184
|
+
const end = parseColor(to)
|
|
185
|
+
// A transparent anchor would fade the whole ramp out; keep the target instead.
|
|
186
|
+
if (start.a === 0) return end
|
|
187
|
+
return RGBA.fromValues(
|
|
188
|
+
start.r + (end.r - start.r) * ratio,
|
|
189
|
+
start.g + (end.g - start.g) * ratio,
|
|
190
|
+
start.b + (end.b - start.b) * ratio,
|
|
191
|
+
1
|
|
192
|
+
)
|
|
193
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { logDebug } from '../../debug/input-log'
|
|
8
|
+
import { spawnDetachedCommand } from '../../platform/daemon-control'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Long-term AI usage, per local calendar day.
|
|
12
|
+
*
|
|
13
|
+
* Claude Code prunes its transcripts after about a month, so this is not a cache
|
|
14
|
+
* of something re-derivable: once a day is pruned, this file is the only place
|
|
15
|
+
* its numbers still exist. Hence the refusal to overwrite anything it cannot
|
|
16
|
+
* fully round-trip.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const HISTORY_VERSION = 1
|
|
20
|
+
/** A file that exists but did not parse. Never equal to HISTORY_VERSION, so the save guard refuses it. */
|
|
21
|
+
const UNREADABLE_VERSION = -1
|
|
22
|
+
const ROLLUP_INTERVAL_MS = 20 * 60 * 60 * 1000
|
|
23
|
+
|
|
24
|
+
export interface UsageTokens {
|
|
25
|
+
cacheRead: number
|
|
26
|
+
cacheWrite: number
|
|
27
|
+
input: number
|
|
28
|
+
output: number
|
|
29
|
+
total: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UsageDay {
|
|
33
|
+
/** git branch -> tokens. Recent window only; Codex carries no branch. */
|
|
34
|
+
branches: Record<string, number>
|
|
35
|
+
/** model id -> tokens. Recent window only. */
|
|
36
|
+
models: Record<string, number>
|
|
37
|
+
/** The only field with full-year coverage, and claude-only. */
|
|
38
|
+
prompts: number
|
|
39
|
+
tokens: UsageTokens
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 'YYYY-MM-DD' in the machine's local calendar -> that day's usage. */
|
|
43
|
+
export type UsageDays = Record<string, UsageDay>
|
|
44
|
+
|
|
45
|
+
/** The key format above — `toISOString()` would shift every evening east of Greenwich by one. */
|
|
46
|
+
export function localDay(date: Date): string {
|
|
47
|
+
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
48
|
+
const day = String(date.getDate()).padStart(2, '0')
|
|
49
|
+
return `${date.getFullYear()}-${month}-${day}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type UsageTools = Partial<Record<AIUsageTool, UsageDays>>
|
|
53
|
+
|
|
54
|
+
export interface UsageHistoryFile {
|
|
55
|
+
tools: UsageTools
|
|
56
|
+
version: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function emptyTokens(): UsageTokens {
|
|
60
|
+
return { cacheRead: 0, cacheWrite: 0, input: 0, output: 0, total: 0 }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function emptyDay(): UsageDay {
|
|
64
|
+
return { branches: {}, models: {}, prompts: 0, tokens: emptyTokens() }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Resolved per call, not at module scope, so a `HOME` override in tests reaches it. */
|
|
68
|
+
export function usageHistoryPath(): string {
|
|
69
|
+
const home = process.env.HOME ?? homedir()
|
|
70
|
+
return join(home, '.config', 'aimux', 'usage-history.json')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readUsageHistory(): UsageHistoryFile {
|
|
74
|
+
let raw: string
|
|
75
|
+
try {
|
|
76
|
+
raw = readFileSync(usageHistoryPath(), 'utf8')
|
|
77
|
+
} catch {
|
|
78
|
+
return { tools: {}, version: HISTORY_VERSION } // no file yet; safe to create
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const file = JSON.parse(raw) as Partial<UsageHistoryFile>
|
|
83
|
+
if (typeof file.version !== 'number') return { tools: {}, version: UNREADABLE_VERSION }
|
|
84
|
+
if (typeof file.tools !== 'object' || file.tools === null) {
|
|
85
|
+
return { tools: {}, version: UNREADABLE_VERSION }
|
|
86
|
+
}
|
|
87
|
+
return { tools: file.tools, version: file.version }
|
|
88
|
+
} catch {
|
|
89
|
+
return { tools: {}, version: UNREADABLE_VERSION }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function mergeDay(stored: UsageDay, fresh: UsageDay): UsageDay {
|
|
94
|
+
// Pruning only removes data, so a smaller fresh total means the stored copy is
|
|
95
|
+
// the richer one. `>=` rather than `>` is what makes re-running converge.
|
|
96
|
+
const keepFresh = fresh.tokens.total >= stored.tokens.total
|
|
97
|
+
// Prompts merge separately: they outlive the transcripts the tokens came from.
|
|
98
|
+
return {
|
|
99
|
+
branches: keepFresh ? fresh.branches : stored.branches,
|
|
100
|
+
models: keepFresh ? fresh.models : stored.models,
|
|
101
|
+
prompts: Math.max(fresh.prompts, stored.prompts),
|
|
102
|
+
tokens: keepFresh ? fresh.tokens : stored.tokens,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Replacement per day, never accumulation — adding would double every total per rollup. */
|
|
107
|
+
export function mergeUsageHistory(stored: UsageTools, fresh: UsageTools): UsageTools {
|
|
108
|
+
const merged: UsageTools = { ...stored }
|
|
109
|
+
|
|
110
|
+
for (const [tool, freshDays] of Object.entries(fresh) as [AIUsageTool, UsageDays][]) {
|
|
111
|
+
const storedDays = stored[tool] ?? {}
|
|
112
|
+
const days: UsageDays = { ...storedDays }
|
|
113
|
+
for (const [date, freshDay] of Object.entries(freshDays)) {
|
|
114
|
+
const storedDay = storedDays[date]
|
|
115
|
+
days[date] = storedDay === undefined ? freshDay : mergeDay(storedDay, freshDay)
|
|
116
|
+
}
|
|
117
|
+
merged[tool] = days
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return merged
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** False when nothing was written, which leaves the mtime alone so the next launch retries. */
|
|
124
|
+
export function saveUsageHistory(fresh: UsageTools): boolean {
|
|
125
|
+
const stored = readUsageHistory()
|
|
126
|
+
|
|
127
|
+
// A newer aimux owns a shape this build cannot round-trip; an unreadable file
|
|
128
|
+
// may still hold years this rollup can no longer see. Neither gets written over.
|
|
129
|
+
if (stored.version !== HISTORY_VERSION) {
|
|
130
|
+
logDebug('usageHistory.refusedWrite', { version: stored.version })
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const path = usageHistoryPath()
|
|
135
|
+
const file: UsageHistoryFile = {
|
|
136
|
+
tools: mergeUsageHistory(stored.tools, fresh),
|
|
137
|
+
version: HISTORY_VERSION,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
142
|
+
// pid-suffixed: two launches can roll up at once, and a shared tmp name would
|
|
143
|
+
// let one truncate the other's half-written file.
|
|
144
|
+
const tmpPath = `${path}.${process.pid}.tmp`
|
|
145
|
+
writeFileSync(tmpPath, `${JSON.stringify(file, null, 2)}\n`)
|
|
146
|
+
renameSync(tmpPath, path)
|
|
147
|
+
return true
|
|
148
|
+
} catch (error) {
|
|
149
|
+
logDebug('usageHistory.writeError', {
|
|
150
|
+
error: error instanceof Error ? error.message : String(error),
|
|
151
|
+
})
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Detached because the parse is a second of CPU over hundreds of MB of JSONL.
|
|
158
|
+
* 20h rather than 24h so opening aimux at the same time each morning does not
|
|
159
|
+
* land exactly on the boundary and skip every other day.
|
|
160
|
+
*/
|
|
161
|
+
export function maybeSpawnUsageRollup(): void {
|
|
162
|
+
try {
|
|
163
|
+
if (process.env.AIMUX_NO_USAGE_ROLLUP === '1') return
|
|
164
|
+
// mtime is the last successful rollup: the file is written on success and
|
|
165
|
+
// nothing else. Beats parsing an ever-growing JSON on the startup path.
|
|
166
|
+
const rolledUpAt = statSync(usageHistoryPath(), { throwIfNoEntry: false })?.mtimeMs ?? 0
|
|
167
|
+
if (Date.now() - rolledUpAt < ROLLUP_INTERVAL_MS) return
|
|
168
|
+
spawnDetachedCommand('usage-rollup')
|
|
169
|
+
} catch (error) {
|
|
170
|
+
logDebug('usageHistory.spawnError', {
|
|
171
|
+
error: error instanceof Error ? error.message : String(error),
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -62,6 +62,9 @@ function getCreateWorkspaceBaseOptions(state: AppState, queryOverride?: string):
|
|
|
62
62
|
function getModalOptionCount(state: AppState): number {
|
|
63
63
|
const { modal } = state
|
|
64
64
|
switch (modal.type) {
|
|
65
|
+
// Not a list: the two pages of the AI usage modal, Live and History.
|
|
66
|
+
case 'ai-usage':
|
|
67
|
+
return 2
|
|
65
68
|
case 'create-project':
|
|
66
69
|
return modal.directoryResults.length
|
|
67
70
|
case 'help':
|
|
@@ -537,6 +540,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
537
540
|
return { ...state, modal: { ...state.modal, selectedIndex: nextIndex } }
|
|
538
541
|
}
|
|
539
542
|
if (
|
|
543
|
+
state.modal.type !== 'ai-usage' &&
|
|
540
544
|
state.modal.type !== 'new-tab' &&
|
|
541
545
|
state.modal.type !== 'project-picker' &&
|
|
542
546
|
state.modal.type !== 'snippet-picker' &&
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
-
import type {
|
|
2
|
+
import type { RGBA } from '@opentui/core'
|
|
3
|
+
|
|
4
|
+
import { useTerminalDimensions } from '@opentui/react'
|
|
5
|
+
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
|
3
6
|
|
|
4
7
|
import type {
|
|
5
8
|
UsagePaceStage,
|
|
@@ -7,8 +10,24 @@ import type {
|
|
|
7
10
|
UsageWindow,
|
|
8
11
|
} from '../../../../services/ai-usage/types'
|
|
9
12
|
|
|
13
|
+
import {
|
|
14
|
+
buildHeatmap,
|
|
15
|
+
coveredWeeks,
|
|
16
|
+
type HeatmapCell,
|
|
17
|
+
mixColor,
|
|
18
|
+
monthRuler,
|
|
19
|
+
promptCounts,
|
|
20
|
+
summarizeDays,
|
|
21
|
+
} from '../../../../services/usage-history/stats'
|
|
22
|
+
import {
|
|
23
|
+
readUsageHistory,
|
|
24
|
+
type UsageDays,
|
|
25
|
+
type UsageHistoryFile,
|
|
26
|
+
} from '../../../../services/usage-history/store'
|
|
10
27
|
import { useAIUsageStore } from '../../../../state/ai-usage-store'
|
|
28
|
+
import { formatCompact } from '../../../format-number'
|
|
11
29
|
import { useTheme } from '../../../theme'
|
|
30
|
+
import { truncate } from '../../../truncate'
|
|
12
31
|
import { uiTokens } from '../../../ui-tokens'
|
|
13
32
|
import { ModalShell } from '../shared/modal-shell'
|
|
14
33
|
|
|
@@ -55,34 +74,63 @@ function paceStageIsBehind(stage: UsagePaceStage): boolean {
|
|
|
55
74
|
return stage === 'behind' || stage === 'farBehind' || stage === 'slightlyBehind'
|
|
56
75
|
}
|
|
57
76
|
|
|
58
|
-
|
|
77
|
+
const TOOLS: AIUsageTool[] = ['claude', 'codex']
|
|
78
|
+
const PAGE_LABELS = ['Live', 'History'] as const
|
|
79
|
+
|
|
80
|
+
/** A year of weeks plus the gutter needs ~58 columns, so History takes the terminal where Live stays a popover. */
|
|
81
|
+
const HISTORY_MAX_WIDTH = 104
|
|
82
|
+
|
|
83
|
+
export function AIUsageModal({ page }: { page: number }) {
|
|
84
|
+
const dimensions = useTerminalDimensions()
|
|
85
|
+
const isHistory = page === 1
|
|
86
|
+
|
|
87
|
+
const width = isHistory
|
|
88
|
+
? Math.max(uiTokens.modalWidth.md, Math.min(HISTORY_MAX_WIDTH, dimensions.width - 8))
|
|
89
|
+
: uiTokens.modalWidth.md
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<ModalShell title="AI usage" keybindsModeId="modal.ai-usage" width={width} listGap={1}>
|
|
93
|
+
<PageTabs active={page} />
|
|
94
|
+
{isHistory ? <HistoryPage width={width} /> : <LivePage />}
|
|
95
|
+
</ModalShell>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function PageTabs({ active }: { active: number }) {
|
|
100
|
+
const t = useTheme()
|
|
101
|
+
return (
|
|
102
|
+
<box flexDirection="row" gap={2}>
|
|
103
|
+
{PAGE_LABELS.map((label, index) => (
|
|
104
|
+
<text key={label} fg={index === active ? t.text : t.textMuted} selectable={false}>
|
|
105
|
+
{label}
|
|
106
|
+
</text>
|
|
107
|
+
))}
|
|
108
|
+
</box>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function LivePage() {
|
|
59
113
|
const t = useTheme()
|
|
60
114
|
const snapshots = useAIUsageStore((s) => s.snapshots)
|
|
61
115
|
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
116
|
+
const sections = TOOLS.map((tool) => ({ snap: snapshots[tool], tool })).filter(
|
|
117
|
+
(s): s is { snap: UsageSnapshot; tool: AIUsageTool } => s.snap !== undefined
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if (sections.length === 0) {
|
|
121
|
+
return (
|
|
122
|
+
<text fg={t.textMuted} selectable={false}>
|
|
123
|
+
no data yet — collecting…
|
|
124
|
+
</text>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
66
127
|
|
|
67
128
|
return (
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
>
|
|
74
|
-
{sections.length === 0 ? (
|
|
75
|
-
<text fg={t.textMuted} selectable={false}>
|
|
76
|
-
no data yet — collecting…
|
|
77
|
-
</text>
|
|
78
|
-
) : (
|
|
79
|
-
<box flexDirection="column" gap={1}>
|
|
80
|
-
{sections.map(({ snap, tool }) => (
|
|
81
|
-
<ToolSection key={tool} snap={snap} tool={tool} />
|
|
82
|
-
))}
|
|
83
|
-
</box>
|
|
84
|
-
)}
|
|
85
|
-
</ModalShell>
|
|
129
|
+
<box flexDirection="column" gap={1}>
|
|
130
|
+
{sections.map(({ snap, tool }) => (
|
|
131
|
+
<ToolSection key={tool} snap={snap} tool={tool} />
|
|
132
|
+
))}
|
|
133
|
+
</box>
|
|
86
134
|
)
|
|
87
135
|
}
|
|
88
136
|
|
|
@@ -191,3 +239,282 @@ function PaceLine({ pace }: { pace: NonNullable<UsageWindow['pace']> }) {
|
|
|
191
239
|
</text>
|
|
192
240
|
)
|
|
193
241
|
}
|
|
242
|
+
|
|
243
|
+
/** One block per day, shaded by colour. Varying the glyph (`░▒▓█`) instead produced an indistinct mass. */
|
|
244
|
+
const CELL = '\u{2588}'
|
|
245
|
+
const ROW_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', ''] as const
|
|
246
|
+
const ROW_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const
|
|
247
|
+
const GUTTER = 5
|
|
248
|
+
const COLUMN_GAP = 3
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Width on the box, never on the text: an empty <text> is not zero columns wide,
|
|
252
|
+
* so `Mon` and a padded blank did not start at the same column and the grid came
|
|
253
|
+
* out as a brick wall. Padding inside one string is fine; across siblings it is not.
|
|
254
|
+
*/
|
|
255
|
+
function Gutter({ label }: { label: string }) {
|
|
256
|
+
const t = useTheme()
|
|
257
|
+
return (
|
|
258
|
+
<box width={GUTTER} flexShrink={0}>
|
|
259
|
+
<text fg={t.textMuted} selectable={false}>
|
|
260
|
+
{label}
|
|
261
|
+
</text>
|
|
262
|
+
</box>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function SectionHeader({ label, note }: { label: string; note?: string }) {
|
|
267
|
+
const t = useTheme()
|
|
268
|
+
return (
|
|
269
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
270
|
+
<text fg={t.textMuted} selectable={false}>
|
|
271
|
+
{label}
|
|
272
|
+
</text>
|
|
273
|
+
{note != null && note !== '' ? (
|
|
274
|
+
<text fg={t.textMuted} selectable={false}>
|
|
275
|
+
{note}
|
|
276
|
+
</text>
|
|
277
|
+
) : null}
|
|
278
|
+
</box>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Width of the `input` / `output` / `cache` label column. */
|
|
283
|
+
const LABEL_WIDTH = 11
|
|
284
|
+
|
|
285
|
+
function LabelledRow({ label, value }: { label: string; value: string }) {
|
|
286
|
+
const t = useTheme()
|
|
287
|
+
return (
|
|
288
|
+
<box flexDirection="row">
|
|
289
|
+
<box width={LABEL_WIDTH} flexShrink={0}>
|
|
290
|
+
<text fg={t.textMuted} selectable={false}>
|
|
291
|
+
{label}
|
|
292
|
+
</text>
|
|
293
|
+
</box>
|
|
294
|
+
<text fg={t.text} selectable={false}>
|
|
295
|
+
{value}
|
|
296
|
+
</text>
|
|
297
|
+
</box>
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function HeatRow({
|
|
302
|
+
cells,
|
|
303
|
+
cellWidth,
|
|
304
|
+
label,
|
|
305
|
+
ramp,
|
|
306
|
+
}: {
|
|
307
|
+
cells: HeatmapCell[]
|
|
308
|
+
cellWidth: number
|
|
309
|
+
label: string
|
|
310
|
+
ramp: (string | RGBA)[]
|
|
311
|
+
}) {
|
|
312
|
+
const t = useTheme()
|
|
313
|
+
|
|
314
|
+
// One <text> per colour run, not per cell: a full year is 371 cells.
|
|
315
|
+
const runs: { color: RGBA | string; start: number; text: string }[] = []
|
|
316
|
+
for (const [index, cell] of cells.entries()) {
|
|
317
|
+
const color = ramp[cell.level] ?? t.textMuted
|
|
318
|
+
const glyph = (cell.day === '' ? ' ' : CELL).repeat(cellWidth)
|
|
319
|
+
const last = runs.at(-1)
|
|
320
|
+
if (last !== undefined && last.color === color) last.text += glyph
|
|
321
|
+
else runs.push({ color, start: index, text: glyph })
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<box flexDirection="row">
|
|
326
|
+
<Gutter label={label} />
|
|
327
|
+
{runs.map((run) => (
|
|
328
|
+
<text key={run.start} fg={run.color} selectable={false}>
|
|
329
|
+
{run.text}
|
|
330
|
+
</text>
|
|
331
|
+
))}
|
|
332
|
+
</box>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function Legend({ cellWidth, ramp }: { cellWidth: number; ramp: (string | RGBA)[] }) {
|
|
337
|
+
const t = useTheme()
|
|
338
|
+
return (
|
|
339
|
+
<box flexDirection="row">
|
|
340
|
+
<Gutter label="" />
|
|
341
|
+
<text fg={t.textMuted} selectable={false}>
|
|
342
|
+
less
|
|
343
|
+
</text>
|
|
344
|
+
<box width={1} flexShrink={0} />
|
|
345
|
+
{ramp.map((color, index) => (
|
|
346
|
+
<text key={color + String(index)} fg={color} selectable={false}>
|
|
347
|
+
{CELL.repeat(cellWidth)}
|
|
348
|
+
</text>
|
|
349
|
+
))}
|
|
350
|
+
<box width={1} flexShrink={0} />
|
|
351
|
+
<text fg={t.textMuted} selectable={false}>
|
|
352
|
+
more
|
|
353
|
+
</text>
|
|
354
|
+
</box>
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Right-hand ` 2.8B 45%` block, fixed so values line up down the column. */
|
|
359
|
+
const VALUE_WIDTH = 12
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* One ranked table. Columns are boxes, not padded strings; two side by side line
|
|
363
|
+
* up because they share a width. Sizing each to its own longest name gave the
|
|
364
|
+
* tables unrelated geometries and stranded `main` 45 columns from its number.
|
|
365
|
+
*/
|
|
366
|
+
function TopColumn({
|
|
367
|
+
entries,
|
|
368
|
+
title,
|
|
369
|
+
total,
|
|
370
|
+
width,
|
|
371
|
+
}: {
|
|
372
|
+
entries: [string, number][]
|
|
373
|
+
title: string
|
|
374
|
+
total: number
|
|
375
|
+
width: number
|
|
376
|
+
}) {
|
|
377
|
+
const t = useTheme()
|
|
378
|
+
const nameWidth = Math.max(4, width - VALUE_WIDTH)
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<box flexDirection="column" width={width} flexShrink={0}>
|
|
382
|
+
<text fg={t.textMuted} selectable={false}>
|
|
383
|
+
{title}
|
|
384
|
+
</text>
|
|
385
|
+
{entries.map(([name, value]) => (
|
|
386
|
+
<box key={name} flexDirection="row">
|
|
387
|
+
<box width={nameWidth} flexShrink={0}>
|
|
388
|
+
<text fg={t.text} selectable={false}>
|
|
389
|
+
{truncate(name, nameWidth)}
|
|
390
|
+
</text>
|
|
391
|
+
</box>
|
|
392
|
+
{/* Padding inside one node, so nothing here is a trailing space. */}
|
|
393
|
+
<text fg={t.text} selectable={false}>
|
|
394
|
+
{formatCompact(value).padStart(7) +
|
|
395
|
+
(total > 0 ? `${Math.round((value / total) * 100)}%` : '').padStart(5)}
|
|
396
|
+
</text>
|
|
397
|
+
</box>
|
|
398
|
+
))}
|
|
399
|
+
</box>
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function HistoryPage({ width }: { width: number }) {
|
|
404
|
+
const t = useTheme()
|
|
405
|
+
const [history, setHistory] = useState<UsageHistoryFile | null>(null)
|
|
406
|
+
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
// Synchronous, and ~100 KB even after a full year of rollups.
|
|
409
|
+
setHistory(readUsageHistory())
|
|
410
|
+
}, [])
|
|
411
|
+
|
|
412
|
+
// borderSubtle rather than a background token: backgrounds resolve to alpha 0
|
|
413
|
+
// in transparent mode. Above the early returns — hooks cannot be conditional.
|
|
414
|
+
const ramp = useMemo(
|
|
415
|
+
() => [
|
|
416
|
+
t.borderSubtle,
|
|
417
|
+
mixColor(t.borderSubtle, t.success, 0.35),
|
|
418
|
+
mixColor(t.borderSubtle, t.success, 0.6),
|
|
419
|
+
mixColor(t.borderSubtle, t.success, 0.82),
|
|
420
|
+
t.success,
|
|
421
|
+
],
|
|
422
|
+
[t.borderSubtle, t.success]
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
if (history === null) {
|
|
426
|
+
return (
|
|
427
|
+
<text fg={t.textMuted} selectable={false}>
|
|
428
|
+
reading history…
|
|
429
|
+
</text>
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const claude: UsageDays = history.tools.claude ?? {}
|
|
434
|
+
const codex: UsageDays = history.tools.codex ?? {}
|
|
435
|
+
|
|
436
|
+
if (Object.keys(claude).length === 0 && Object.keys(codex).length === 0) {
|
|
437
|
+
return (
|
|
438
|
+
<text fg={t.textMuted} selectable={false}>
|
|
439
|
+
no history yet — the first rollup runs in the background; reopen in a minute
|
|
440
|
+
</text>
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const inner = width - 4
|
|
445
|
+
const today = new Date()
|
|
446
|
+
// Bounded by what is recorded: empty months predating the first rollup read as broken.
|
|
447
|
+
const weeks = coveredWeeks(claude, today, Math.max(4, Math.min(53, inner - GUTTER)))
|
|
448
|
+
// Terminal cells are ~2x taller than wide, so a two-column day is roughly square.
|
|
449
|
+
const cellWidth = weeks * 2 + GUTTER <= inner ? 2 : 1
|
|
450
|
+
const grid = buildHeatmap(promptCounts(claude), weeks, today)
|
|
451
|
+
const summary = summarizeDays(claude)
|
|
452
|
+
const codexSummary = summarizeDays(codex)
|
|
453
|
+
|
|
454
|
+
const average =
|
|
455
|
+
summary.promptDays === 0 ? 0 : Math.round(summary.totalPrompts / summary.promptDays)
|
|
456
|
+
const { tokens } = summary
|
|
457
|
+
// `2 * columnWidth + COLUMN_GAP <= inner` by construction.
|
|
458
|
+
const columnWidth = Math.max(16, Math.floor((inner - COLUMN_GAP) / 2))
|
|
459
|
+
|
|
460
|
+
return (
|
|
461
|
+
<box flexDirection="column" gap={1}>
|
|
462
|
+
<box flexDirection="column">
|
|
463
|
+
<SectionHeader
|
|
464
|
+
label="ACTIVITY"
|
|
465
|
+
note={`${summary.totalPrompts.toLocaleString('en-US').replaceAll(',', ' ')} prompts over ${summary.promptDays} days · ${average} avg · ${summary.peakPrompts} peak`}
|
|
466
|
+
/>
|
|
467
|
+
<box flexDirection="row">
|
|
468
|
+
<Gutter label="" />
|
|
469
|
+
<text fg={t.textMuted} selectable={false}>
|
|
470
|
+
{monthRuler(grid, weeks, cellWidth)}
|
|
471
|
+
</text>
|
|
472
|
+
</box>
|
|
473
|
+
{grid.map((cells, index) => (
|
|
474
|
+
<HeatRow
|
|
475
|
+
key={ROW_KEYS[index]}
|
|
476
|
+
cells={cells}
|
|
477
|
+
cellWidth={cellWidth}
|
|
478
|
+
label={ROW_LABELS[index] ?? ''}
|
|
479
|
+
ramp={ramp}
|
|
480
|
+
/>
|
|
481
|
+
))}
|
|
482
|
+
<Legend cellWidth={cellWidth} ramp={ramp} />
|
|
483
|
+
</box>
|
|
484
|
+
|
|
485
|
+
<box flexDirection="column">
|
|
486
|
+
<SectionHeader
|
|
487
|
+
label="TOKENS"
|
|
488
|
+
note={`${summary.tokenDays} days retained · ${formatCompact(tokens.total)} total`}
|
|
489
|
+
/>
|
|
490
|
+
<LabelledRow label="input" value={formatCompact(tokens.input)} />
|
|
491
|
+
<LabelledRow label="output" value={formatCompact(tokens.output)} />
|
|
492
|
+
<LabelledRow
|
|
493
|
+
label="cache"
|
|
494
|
+
value={`${formatCompact(tokens.cacheRead)} read · ${formatCompact(tokens.cacheWrite)} written`}
|
|
495
|
+
/>
|
|
496
|
+
{codexSummary.tokens.total > 0 ? (
|
|
497
|
+
<LabelledRow
|
|
498
|
+
label="codex"
|
|
499
|
+
value={`${formatCompact(codexSummary.tokens.total)} over ${codexSummary.tokenDays} days`}
|
|
500
|
+
/>
|
|
501
|
+
) : null}
|
|
502
|
+
</box>
|
|
503
|
+
|
|
504
|
+
<box flexDirection="row" gap={COLUMN_GAP}>
|
|
505
|
+
<TopColumn
|
|
506
|
+
entries={summary.models}
|
|
507
|
+
title="MODELS"
|
|
508
|
+
total={summary.modelTotal}
|
|
509
|
+
width={columnWidth}
|
|
510
|
+
/>
|
|
511
|
+
<TopColumn
|
|
512
|
+
entries={summary.branches}
|
|
513
|
+
title="BRANCHES"
|
|
514
|
+
total={summary.branchTotal}
|
|
515
|
+
width={columnWidth}
|
|
516
|
+
/>
|
|
517
|
+
</box>
|
|
518
|
+
</box>
|
|
519
|
+
)
|
|
520
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useCallback } from 'react'
|
|
|
4
4
|
|
|
5
5
|
import { useAIUsageStore } from '../../../../state/ai-usage-store'
|
|
6
6
|
import { dispatchGlobal } from '../../../../state/dispatch-ref'
|
|
7
|
+
import { formatCompact } from '../../../format-number'
|
|
7
8
|
import { useTheme } from '../../../theme'
|
|
8
9
|
|
|
9
10
|
/** nf-cod-claude / nf-cod-openai. Needs a nerd font, like the status bar separators. */
|
|
@@ -12,12 +13,6 @@ const ICON: Record<AIUsageTool, string> = {
|
|
|
12
13
|
codex: '\u{ec81}',
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
function formatTokens(total: number): string {
|
|
16
|
-
if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`
|
|
17
|
-
if (total >= 1_000) return `${(total / 1_000).toFixed(1)}k`
|
|
18
|
-
return String(total)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
16
|
export function AIUsageIndicator() {
|
|
22
17
|
const t = useTheme()
|
|
23
18
|
const enabled = useAIUsageStore((s) => s.enabled)
|
|
@@ -65,7 +60,7 @@ export function AIUsageIndicator() {
|
|
|
65
60
|
}
|
|
66
61
|
|
|
67
62
|
const value =
|
|
68
|
-
snap.percent !== null ? `${Math.round(snap.percent)}%` :
|
|
63
|
+
snap.percent !== null ? `${Math.round(snap.percent)}%` : formatCompact(snap.tokens.total)
|
|
69
64
|
|
|
70
65
|
return (
|
|
71
66
|
<box key={tool} flexDirection="row">
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token counts, short enough for a status bar and a stats table alike.
|
|
3
|
+
*
|
|
4
|
+
* One function rather than one per surface: the indicator and the usage modal
|
|
5
|
+
* had drifted to `1.2k` and `1.2K` for the same number.
|
|
6
|
+
*/
|
|
7
|
+
export function formatCompact(value: number): string {
|
|
8
|
+
if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`
|
|
9
|
+
if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`
|
|
10
|
+
if (value >= 1e3) return `${(value / 1e3).toFixed(1)}k`
|
|
11
|
+
return String(value)
|
|
12
|
+
}
|