@brimveyn/aimux 1.22.10 → 1.22.12

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.
@@ -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' &&