@brimveyn/aimux 1.22.11 → 1.23.0
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/app-runtime/side-effects.ts +5 -0
- package/src/app-runtime/use-mouse-handlers.ts +2 -0
- package/src/app.tsx +6 -0
- package/src/index.tsx +6 -0
- package/src/input/keymap/help-entries.ts +1 -0
- package/src/input/modes/bridge.ts +2 -1
- package/src/input/modes/transitions.ts +7 -3
- package/src/input/modes/types.ts +2 -1
- package/src/restart-daemon.ts +5 -0
- package/src/services/ai-usage/projection.ts +44 -0
- package/src/services/aimux-counters/index.ts +87 -0
- package/src/services/aimux-counters/observe.ts +39 -0
- package/src/services/aimux-counters/store.ts +150 -0
- package/src/services/aimux-counters/summary.ts +78 -0
- package/src/services/usage-history/cost.ts +149 -0
- package/src/services/usage-history/insights.ts +314 -0
- package/src/services/usage-history/rollup.ts +78 -6
- package/src/services/usage-history/stats.ts +66 -35
- package/src/services/usage-history/store.ts +128 -9
- package/src/settings/sections/about.ts +1 -0
- package/src/settings/sections/appearance.ts +1 -0
- package/src/settings/sections/automation.ts +1 -0
- package/src/settings/sections/commands.ts +1 -0
- package/src/settings/sections/editor.ts +1 -0
- package/src/settings/sections/experimental.ts +1 -0
- package/src/settings/sections/git.ts +1 -0
- package/src/settings/sections/integrations.ts +1 -0
- package/src/settings/sections/layout.ts +1 -0
- package/src/settings/sections/notifications.ts +1 -0
- package/src/settings/sections/setup.ts +1 -0
- package/src/settings/sections/status-bar.ts +1 -0
- package/src/settings/sections/workspace.ts +1 -0
- package/src/settings/types.ts +10 -0
- package/src/state/actions.ts +12 -1
- package/src/state/app-store.ts +12 -1
- package/src/state/reducers/modal-state.ts +12 -17
- package/src/state/reducers/stats-state.ts +56 -0
- package/src/state/stats-pages.ts +33 -0
- package/src/state/store.ts +5 -0
- package/src/state/types.ts +17 -4
- package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
- package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
- package/src/ui/components/settings/settings-view.tsx +6 -3
- package/src/ui/components/stats/aimux-page.tsx +245 -0
- package/src/ui/components/stats/chart.ts +157 -0
- package/src/ui/components/stats/day-facts.tsx +268 -0
- package/src/ui/components/stats/format.ts +98 -0
- package/src/ui/components/stats/heatmap.tsx +305 -0
- package/src/ui/components/stats/projects-page.tsx +293 -0
- package/src/ui/components/stats/quotas.tsx +210 -0
- package/src/ui/components/stats/shared.tsx +645 -0
- package/src/ui/components/stats/stats-view.tsx +153 -0
- package/src/ui/components/stats/usage-page.tsx +291 -0
- package/src/ui/components/stats/use-stats-data.ts +48 -0
- package/src/ui/root.tsx +7 -3
- package/src/ui/components/modals/app/ai-usage-modal.tsx +0 -520
|
@@ -1,520 +0,0 @@
|
|
|
1
|
-
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
-
import type { RGBA } from '@opentui/core'
|
|
3
|
-
|
|
4
|
-
import { useTerminalDimensions } from '@opentui/react'
|
|
5
|
-
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
|
6
|
-
|
|
7
|
-
import type {
|
|
8
|
-
UsagePaceStage,
|
|
9
|
-
UsageSnapshot,
|
|
10
|
-
UsageWindow,
|
|
11
|
-
} from '../../../../services/ai-usage/types'
|
|
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'
|
|
27
|
-
import { useAIUsageStore } from '../../../../state/ai-usage-store'
|
|
28
|
-
import { formatCompact } from '../../../format-number'
|
|
29
|
-
import { useTheme } from '../../../theme'
|
|
30
|
-
import { truncate } from '../../../truncate'
|
|
31
|
-
import { uiTokens } from '../../../ui-tokens'
|
|
32
|
-
import { ModalShell } from '../shared/modal-shell'
|
|
33
|
-
|
|
34
|
-
const TOOL_TITLE: Record<AIUsageTool, string> = {
|
|
35
|
-
claude: 'Claude',
|
|
36
|
-
codex: 'Codex',
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const BAR_SEGMENTS = 32
|
|
40
|
-
const BAR_FILLED_CHAR = '\u{2501}'
|
|
41
|
-
const BAR_EMPTY_CHAR = '\u{2500}'
|
|
42
|
-
|
|
43
|
-
function buildBar(percent: number | null): { empty: string; filled: string } {
|
|
44
|
-
const p = percent ?? 0
|
|
45
|
-
let filledCount = 0
|
|
46
|
-
for (let i = 0; i < BAR_SEGMENTS; i++) {
|
|
47
|
-
if (p > i * (100 / BAR_SEGMENTS)) filledCount++
|
|
48
|
-
}
|
|
49
|
-
return {
|
|
50
|
-
empty: BAR_EMPTY_CHAR.repeat(BAR_SEGMENTS - filledCount),
|
|
51
|
-
filled: BAR_FILLED_CHAR.repeat(filledCount),
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function formatRelative(iso: string, now: number = Date.now()): string {
|
|
56
|
-
const diffMs = now - new Date(iso).getTime()
|
|
57
|
-
if (!Number.isFinite(diffMs) || diffMs < 0) return 'just now'
|
|
58
|
-
const s = Math.floor(diffMs / 1000)
|
|
59
|
-
if (s < 10) return 'just now'
|
|
60
|
-
if (s < 60) return `${s}s ago`
|
|
61
|
-
const m = Math.floor(s / 60)
|
|
62
|
-
if (m < 60) return `${m}m ago`
|
|
63
|
-
const h = Math.floor(m / 60)
|
|
64
|
-
if (h < 24) return `${h}h ago`
|
|
65
|
-
const d = Math.floor(h / 24)
|
|
66
|
-
return `${d}d ago`
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function paceStageIsAhead(stage: UsagePaceStage): boolean {
|
|
70
|
-
return stage === 'ahead' || stage === 'farAhead' || stage === 'slightlyAhead'
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function paceStageIsBehind(stage: UsagePaceStage): boolean {
|
|
74
|
-
return stage === 'behind' || stage === 'farBehind' || stage === 'slightlyBehind'
|
|
75
|
-
}
|
|
76
|
-
|
|
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() {
|
|
113
|
-
const t = useTheme()
|
|
114
|
-
const snapshots = useAIUsageStore((s) => s.snapshots)
|
|
115
|
-
|
|
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
|
-
}
|
|
127
|
-
|
|
128
|
-
return (
|
|
129
|
-
<box flexDirection="column" gap={1}>
|
|
130
|
-
{sections.map(({ snap, tool }) => (
|
|
131
|
-
<ToolSection key={tool} snap={snap} tool={tool} />
|
|
132
|
-
))}
|
|
133
|
-
</box>
|
|
134
|
-
)
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
interface ToolSectionProps {
|
|
138
|
-
snap: UsageSnapshot
|
|
139
|
-
tool: AIUsageTool
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function ToolSection({ snap, tool }: ToolSectionProps) {
|
|
143
|
-
const t = useTheme()
|
|
144
|
-
const isHardError = Boolean(snap.error) && !(snap.stale === true)
|
|
145
|
-
const relative = formatRelative(snap.lastUpdated)
|
|
146
|
-
|
|
147
|
-
let body: ReactNode
|
|
148
|
-
if (isHardError) {
|
|
149
|
-
body = (
|
|
150
|
-
<text fg={t.error} selectable={false}>
|
|
151
|
-
{`error: ${snap.error ?? ''}`}
|
|
152
|
-
</text>
|
|
153
|
-
)
|
|
154
|
-
} else if (snap.windows.length === 0) {
|
|
155
|
-
body = (
|
|
156
|
-
<text fg={t.textMuted} selectable={false}>
|
|
157
|
-
no window data
|
|
158
|
-
</text>
|
|
159
|
-
)
|
|
160
|
-
} else {
|
|
161
|
-
body = snap.windows.map((window) => <WindowRow key={window.kind} window={window} />)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return (
|
|
165
|
-
<box flexDirection="column">
|
|
166
|
-
<box flexDirection="row" justifyContent="space-between">
|
|
167
|
-
<text fg={t.text} selectable={false}>
|
|
168
|
-
{TOOL_TITLE[tool]}
|
|
169
|
-
</text>
|
|
170
|
-
{snap.planTier != null && snap.planTier !== '' ? (
|
|
171
|
-
<text fg={t.textMuted} selectable={false}>
|
|
172
|
-
{snap.planTier}
|
|
173
|
-
</text>
|
|
174
|
-
) : null}
|
|
175
|
-
</box>
|
|
176
|
-
<text fg={t.textMuted} selectable={false}>
|
|
177
|
-
{`Updated ${relative}`}
|
|
178
|
-
</text>
|
|
179
|
-
{body}
|
|
180
|
-
</box>
|
|
181
|
-
)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function WindowRow({ window }: { window: UsageWindow }) {
|
|
185
|
-
const t = useTheme()
|
|
186
|
-
const percent = window.percent
|
|
187
|
-
const { empty, filled } = buildBar(percent)
|
|
188
|
-
|
|
189
|
-
let barColor = t.success
|
|
190
|
-
if (percent !== null) {
|
|
191
|
-
if (percent >= 85) barColor = t.error
|
|
192
|
-
else if (percent >= 60) barColor = t.warning
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const pctText = percent === null ? '—' : `${Math.round(percent)}% used`
|
|
196
|
-
const resetText =
|
|
197
|
-
window.timeRemaining != null && window.timeRemaining !== ''
|
|
198
|
-
? `Resets in ${window.timeRemaining}`
|
|
199
|
-
: null
|
|
200
|
-
|
|
201
|
-
return (
|
|
202
|
-
<box flexDirection="column" paddingTop={1}>
|
|
203
|
-
<text fg={t.text} selectable={false}>
|
|
204
|
-
{window.label}
|
|
205
|
-
</text>
|
|
206
|
-
<box flexDirection="row">
|
|
207
|
-
<text fg={barColor} selectable={false}>
|
|
208
|
-
{filled}
|
|
209
|
-
</text>
|
|
210
|
-
<text fg={t.textMuted} selectable={false}>
|
|
211
|
-
{empty}
|
|
212
|
-
</text>
|
|
213
|
-
</box>
|
|
214
|
-
<box flexDirection="row" justifyContent="space-between">
|
|
215
|
-
<text fg={t.textMuted} selectable={false}>
|
|
216
|
-
{pctText}
|
|
217
|
-
</text>
|
|
218
|
-
{resetText != null && resetText !== '' ? (
|
|
219
|
-
<text fg={t.textMuted} selectable={false}>
|
|
220
|
-
{resetText}
|
|
221
|
-
</text>
|
|
222
|
-
) : null}
|
|
223
|
-
</box>
|
|
224
|
-
{window.pace ? <PaceLine pace={window.pace} /> : null}
|
|
225
|
-
</box>
|
|
226
|
-
)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function PaceLine({ pace }: { pace: NonNullable<UsageWindow['pace']> }) {
|
|
230
|
-
const t = useTheme()
|
|
231
|
-
let color = t.textMuted
|
|
232
|
-
if (paceStageIsBehind(pace.stage)) color = t.warning
|
|
233
|
-
else if (paceStageIsAhead(pace.stage)) color = t.success
|
|
234
|
-
|
|
235
|
-
const suffix = pace.rightText != null && pace.rightText !== '' ? ` · ${pace.rightText}` : ''
|
|
236
|
-
return (
|
|
237
|
-
<text fg={color} selectable={false}>
|
|
238
|
-
{`Pace: ${pace.label}${suffix}`}
|
|
239
|
-
</text>
|
|
240
|
-
)
|
|
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
|
-
}
|