@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.
Files changed (58) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +5 -0
  3. package/src/app-runtime/use-mouse-handlers.ts +2 -0
  4. package/src/app.tsx +6 -0
  5. package/src/index.tsx +6 -0
  6. package/src/input/keymap/help-entries.ts +1 -0
  7. package/src/input/modes/bridge.ts +2 -1
  8. package/src/input/modes/transitions.ts +7 -3
  9. package/src/input/modes/types.ts +2 -1
  10. package/src/restart-daemon.ts +5 -0
  11. package/src/services/ai-usage/projection.ts +44 -0
  12. package/src/services/aimux-counters/index.ts +87 -0
  13. package/src/services/aimux-counters/observe.ts +39 -0
  14. package/src/services/aimux-counters/store.ts +150 -0
  15. package/src/services/aimux-counters/summary.ts +78 -0
  16. package/src/services/usage-history/cost.ts +149 -0
  17. package/src/services/usage-history/insights.ts +314 -0
  18. package/src/services/usage-history/rollup.ts +78 -6
  19. package/src/services/usage-history/stats.ts +66 -35
  20. package/src/services/usage-history/store.ts +128 -9
  21. package/src/settings/sections/about.ts +1 -0
  22. package/src/settings/sections/appearance.ts +1 -0
  23. package/src/settings/sections/automation.ts +1 -0
  24. package/src/settings/sections/commands.ts +1 -0
  25. package/src/settings/sections/editor.ts +1 -0
  26. package/src/settings/sections/experimental.ts +1 -0
  27. package/src/settings/sections/git.ts +1 -0
  28. package/src/settings/sections/integrations.ts +1 -0
  29. package/src/settings/sections/layout.ts +1 -0
  30. package/src/settings/sections/notifications.ts +1 -0
  31. package/src/settings/sections/setup.ts +1 -0
  32. package/src/settings/sections/status-bar.ts +1 -0
  33. package/src/settings/sections/workspace.ts +1 -0
  34. package/src/settings/types.ts +10 -0
  35. package/src/state/actions.ts +12 -1
  36. package/src/state/app-store.ts +12 -1
  37. package/src/state/reducers/modal-state.ts +12 -17
  38. package/src/state/reducers/stats-state.ts +56 -0
  39. package/src/state/stats-pages.ts +33 -0
  40. package/src/state/store.ts +5 -0
  41. package/src/state/types.ts +17 -4
  42. package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
  43. package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
  44. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
  45. package/src/ui/components/settings/settings-view.tsx +6 -3
  46. package/src/ui/components/stats/aimux-page.tsx +245 -0
  47. package/src/ui/components/stats/chart.ts +157 -0
  48. package/src/ui/components/stats/day-facts.tsx +268 -0
  49. package/src/ui/components/stats/format.ts +98 -0
  50. package/src/ui/components/stats/heatmap.tsx +305 -0
  51. package/src/ui/components/stats/projects-page.tsx +293 -0
  52. package/src/ui/components/stats/quotas.tsx +210 -0
  53. package/src/ui/components/stats/shared.tsx +645 -0
  54. package/src/ui/components/stats/stats-view.tsx +153 -0
  55. package/src/ui/components/stats/usage-page.tsx +291 -0
  56. package/src/ui/components/stats/use-stats-data.ts +48 -0
  57. package/src/ui/root.tsx +7 -3
  58. 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
- }