@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.
- package/package.json +2 -2
- 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
|
@@ -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' &&
|