@brimveyn/aimux 1.22.9 → 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.
@@ -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