@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.22.10",
3
+ "version": "1.22.11",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
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(claudeDir(), 'settings.json')
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(claudeDir(), { recursive: true })
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(claudeDir(), 'themes', `${THEME_SLUG}.json`)
24
+ return join(claudeHome(), 'themes', `${THEME_SLUG}.json`)
29
25
  }
30
26
 
31
27
  function settingsFilePath(): string {
32
- return join(claudeDir(), 'settings.json')
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(claudeDir(), 'themes'), { recursive: true })
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(claudeDir(), { recursive: true })
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
- async function spawnDetachedProcess(command: 'daemon' | 'terminal-manager', socketPath: string) {
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(claudeConfigDir(), '.credentials.json'), 'utf8')
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 { ReactNode } from 'react'
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
- export function AIUsageModal() {
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 tools: AIUsageTool[] = ['claude', 'codex']
63
- const sections = tools
64
- .map((tool) => ({ snap: snapshots[tool], tool }))
65
- .filter((s): s is { snap: UsageSnapshot; tool: AIUsageTool } => s.snap !== undefined)
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
- <ModalShell
69
- title="AI usage"
70
- keybindsModeId="modal.ai-usage"
71
- width={uiTokens.modalWidth.md}
72
- listGap={1}
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)}%` : formatTokens(snap.tokens.total)
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
+ }
package/src/ui/root.tsx CHANGED
@@ -249,7 +249,7 @@ function renderModal(
249
249
  />
250
250
  )
251
251
  case 'ai-usage':
252
- return <AIUsageModal />
252
+ return <AIUsageModal page={modal.selectedIndex} />
253
253
  case 'workspace-delete-confirm':
254
254
  return (
255
255
  <WorkspaceDeleteConfirm