@brimveyn/aimux 1.22.12 → 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
@@ -0,0 +1,305 @@
1
+ import type { BoxRenderable, MouseEvent, RGBA } from '@opentui/core'
2
+
3
+ import { useCallback, useMemo, useRef, useState } from 'react'
4
+
5
+ import {
6
+ buildHeatmap,
7
+ coveredWeeks,
8
+ type HeatmapCell,
9
+ mixColor,
10
+ monthLabels,
11
+ promptCounts,
12
+ } from '../../../services/usage-history/stats'
13
+ import { localDay, type UsageDays } from '../../../services/usage-history/store'
14
+ import { useTheme } from '../../theme'
15
+ import { dayDetails, DayFacts, DayPanel, PANEL_WIDTH } from './day-facts'
16
+
17
+ /**
18
+ * The activity calendar.
19
+ *
20
+ * A cell is `md-square_rounded`, a filled rounded square that fills its
21
+ * terminal cell. Unicode has no filled rounded square — `▢` U+25A2 is an
22
+ * outline — so this is a nerd-font glyph, like the status bar's round separator.
23
+ *
24
+ * One hue, light to dark, because the only thing a cell encodes is magnitude.
25
+ * A hue per month grouped the calendar prettily but spent the colour channel on
26
+ * something the month labels already say, and it borrowed the status colours,
27
+ * which on this page mean a quota is running out.
28
+ */
29
+
30
+ /** nf-md-square_rounded. Needs a nerd font, and measures one cell wide. */
31
+ const CELL = '\u{F14FB}'
32
+ /** The glyph plus its gap — the stride one day occupies. */
33
+ const CELL_WIDTH = 2
34
+ const BLANK = ' '.repeat(CELL_WIDTH)
35
+ const LABEL_WIDTH = 5
36
+
37
+ const ROW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
38
+ /** A constant, not an inline attribute: a JSX string does not process `\u` escapes. */
39
+ const CHEVRON = '\u{00BB}'
40
+ const RULE = '\u{2500}'
41
+
42
+ /** Rows the grid draws above the first weekday: the month header and its rule. */
43
+ const HEADER_ROWS = 2
44
+ /** The box's own border and padding on the left. */
45
+ const BOX_INSET = 2
46
+ /** Between the calendar and the day panel beside it. */
47
+ const PANEL_GAP = 2
48
+ type Ramp = (RGBA | string)[]
49
+
50
+ /** Light to dark on one hue, over the neutral an unrecorded day keeps. */
51
+ function rampOf(empty: string, hue: string): Ramp {
52
+ return [
53
+ empty,
54
+ mixColor(empty, hue, 0.35),
55
+ mixColor(empty, hue, 0.6),
56
+ mixColor(empty, hue, 0.82),
57
+ hue,
58
+ ]
59
+ }
60
+
61
+ export function useHeatmapRamp(): Ramp {
62
+ const t = useTheme()
63
+ // borderSubtle rather than a background token: backgrounds resolve to alpha 0
64
+ // in transparent mode, which would erase the empty days entirely.
65
+ return useMemo(() => rampOf(t.borderSubtle, t.primary), [t.borderSubtle, t.primary])
66
+ }
67
+
68
+ export function HeatmapLegend({ ramp }: { ramp: Ramp }) {
69
+ const t = useTheme()
70
+ return (
71
+ <box flexDirection="row" flexShrink={0}>
72
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
73
+ {'Less '}
74
+ </text>
75
+ {ramp.map((color, index) => (
76
+ <text key={String(color) + String(index)} fg={color} selectable={false} wrapMode="none">
77
+ {`${CELL} `}
78
+ </text>
79
+ ))}
80
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
81
+ More
82
+ </text>
83
+ </box>
84
+ )
85
+ }
86
+
87
+ function Gutter({ label }: { label: string }) {
88
+ const t = useTheme()
89
+ return (
90
+ <box width={LABEL_WIDTH} flexShrink={0}>
91
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
92
+ {label}
93
+ </text>
94
+ </box>
95
+ )
96
+ }
97
+
98
+ function HeatRow({
99
+ cells,
100
+ label,
101
+ ramp,
102
+ selected,
103
+ }: {
104
+ cells: HeatmapCell[]
105
+ label: string
106
+ ramp: Ramp
107
+ selected: number
108
+ }) {
109
+ const t = useTheme()
110
+
111
+ // One <text> per colour run, not per cell: a year is 371 of them.
112
+ const runs: { color: RGBA | string; start: number; text: string }[] = []
113
+ for (const [index, cell] of cells.entries()) {
114
+ let color: RGBA | string = ''
115
+ if (cell.day !== '') {
116
+ color = index === selected ? t.text : (ramp[cell.level] ?? t.borderSubtle)
117
+ }
118
+ const glyph = cell.day === '' ? BLANK : `${CELL} `
119
+ const last = runs.at(-1)
120
+ // The selected cell always starts its own run, so it keeps its own colour.
121
+ if (last !== undefined && last.color === color && index !== selected) last.text += glyph
122
+ else runs.push({ color, start: index, text: glyph })
123
+ }
124
+
125
+ return (
126
+ <box flexDirection="row" flexShrink={0}>
127
+ <Gutter label={label} />
128
+ {runs.map((run) =>
129
+ run.color === '' ? (
130
+ <text key={run.start} selectable={false} wrapMode="none">
131
+ {run.text}
132
+ </text>
133
+ ) : (
134
+ <text key={run.start} fg={run.color} selectable={false} wrapMode="none">
135
+ {run.text}
136
+ </text>
137
+ )
138
+ )}
139
+ </box>
140
+ )
141
+ }
142
+
143
+ /** Month names over the columns they occupy. */
144
+ function MonthHeader({ grid, weeks }: { grid: HeatmapCell[][]; weeks: number }) {
145
+ const t = useTheme()
146
+ const labels = monthLabels(grid, weeks, CELL_WIDTH)
147
+
148
+ let cursor = 0
149
+ const parts: string[] = []
150
+ for (const label of labels) {
151
+ parts.push(' '.repeat(Math.max(0, label.offset - cursor)), label.name)
152
+ cursor = label.offset + label.name.length
153
+ }
154
+
155
+ return (
156
+ <box flexDirection="row" flexShrink={0}>
157
+ <Gutter label={CHEVRON} />
158
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
159
+ {parts.join('')}
160
+ </text>
161
+ </box>
162
+ )
163
+ }
164
+
165
+ interface Selection {
166
+ column: number
167
+ key: string
168
+ row: number
169
+ }
170
+
171
+ /**
172
+ * How many weeks the grid draws in `width` columns.
173
+ *
174
+ * Bounded by what is recorded: empty months predating the first rollup read as
175
+ * broken, not as history.
176
+ */
177
+ function weeksFor(days: UsageDays, today: Date, width: number): number {
178
+ // Less the day-name column and the box's own border and padding, so the grid
179
+ // sizes to what is actually left inside it.
180
+ const usable = Math.max(CELL_WIDTH, width - LABEL_WIDTH - 4)
181
+ return coveredWeeks(days, today, Math.max(4, Math.min(53, Math.floor(usable / CELL_WIDTH))))
182
+ }
183
+
184
+ /**
185
+ * The columns the calendar's box actually occupies.
186
+ *
187
+ * The section's legend is pinned to this rather than to the pane, so it sits
188
+ * over the grid's own top-right corner: the calendar is as wide as the history
189
+ * is long, and a legend flush with the far edge of the page belongs to nothing
190
+ * the eye can see.
191
+ */
192
+ export function heatmapWidth(days: UsageDays, today: Date, width: number): number {
193
+ return BOX_INSET * 2 + LABEL_WIDTH + weeksFor(days, today, width) * CELL_WIDTH
194
+ }
195
+
196
+ export function Heatmap({
197
+ days,
198
+ ramp,
199
+ summary,
200
+ today,
201
+ width,
202
+ }: {
203
+ days: UsageDays
204
+ ramp: Ramp
205
+ /** What the whole calendar says, above the day the pointer is on. */
206
+ summary?: string
207
+ today: Date
208
+ width: number
209
+ }) {
210
+ const t = useTheme()
211
+ const boxRef = useRef<BoxRenderable | null>(null)
212
+ const [selected, setSelected] = useState<Selection | null>(null)
213
+
214
+ const weeks = weeksFor(days, today, width)
215
+ const grid = buildHeatmap(promptCounts(days), weeks, today)
216
+
217
+ /**
218
+ * The grid is a fixed pitch, so where the pointer is is arithmetic rather
219
+ * than a hit test: one handler beats a handler on each of 371 cells, and the
220
+ * cells stay batched into colour runs for rendering.
221
+ *
222
+ * Movement rather than clicks. Reading a day was a mode — open the popover,
223
+ * read it, dismiss it — for numbers that take a second to scan, and the box
224
+ * covered the month it was describing. Following the pointer has no state to
225
+ * open or to get stuck in, and scanning a week is a sweep rather than a dozen
226
+ * clicks. Mouse-down runs the same path so the readout still answers a click,
227
+ * which is what a terminal without motion reporting will send.
228
+ */
229
+ const handlePoint = useCallback(
230
+ (event: MouseEvent) => {
231
+ const box = boxRef.current
232
+ if (box === null) return
233
+ const row = event.y - box.y - HEADER_ROWS - 1
234
+ const column = Math.floor((event.x - box.x - BOX_INSET - LABEL_WIDTH) / CELL_WIDTH)
235
+ const key = grid[row]?.[column]?.day
236
+ // A cell with no day behind it — the future, or before the first rollup —
237
+ // leaves the last one up rather than blanking the slot as the pointer
238
+ // crosses it.
239
+ if (key == null || key === '') return
240
+ setSelected((current) => (current?.key === key ? current : { column, key, row }))
241
+ },
242
+ [grid]
243
+ )
244
+
245
+ // Today until the pointer says otherwise: the slot is worth its rows from the
246
+ // moment the page opens, and today is the day a reader wants first.
247
+ const shown = selected?.key ?? localDay(today)
248
+ const details = useMemo(() => dayDetails(shown, days[shown]), [days, shown])
249
+
250
+ // The calendar is bounded by what has been recorded, so it usually leaves
251
+ // room to its right. The panel goes there when it fits, and drops to running
252
+ // lines underneath when it does not.
253
+ const beside = width - heatmapWidth(days, today, width) - PANEL_GAP >= PANEL_WIDTH
254
+
255
+ return (
256
+ <box flexDirection="column" flexShrink={0} alignSelf="flex-start">
257
+ <box flexDirection="row" flexShrink={0}>
258
+ <box
259
+ ref={boxRef}
260
+ border
261
+ borderStyle="rounded"
262
+ borderColor={t.border}
263
+ paddingLeft={1}
264
+ paddingRight={1}
265
+ flexDirection="column"
266
+ flexShrink={0}
267
+ onMouseDown={handlePoint}
268
+ onMouseMove={handlePoint}
269
+ >
270
+ <MonthHeader grid={grid} weeks={weeks} />
271
+ <box flexDirection="row" flexShrink={0}>
272
+ <Gutter label="" />
273
+ <text fg={t.borderSubtle} selectable={false} wrapMode="none">
274
+ {RULE.repeat(weeks * CELL_WIDTH)}
275
+ </text>
276
+ </box>
277
+ {grid.map((cells, index) => (
278
+ <HeatRow
279
+ key={ROW_LABELS[index]}
280
+ cells={cells}
281
+ label={ROW_LABELS[index] ?? ''}
282
+ ramp={ramp}
283
+ selected={selected?.row === index ? selected.column : -1}
284
+ />
285
+ ))}
286
+ </box>
287
+ {beside ? (
288
+ <>
289
+ <box width={PANEL_GAP} flexShrink={0} />
290
+ <DayPanel details={details} />
291
+ </>
292
+ ) : null}
293
+ </box>
294
+ {summary == null || summary === '' ? null : (
295
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
296
+ {summary}
297
+ </text>
298
+ )}
299
+ {/* Below only when it could not go beside. The date line is the only
300
+ bright text down here, so the year and the day never read as one
301
+ paragraph. */}
302
+ {beside ? null : <DayFacts details={details} width={width} />}
303
+ </box>
304
+ )
305
+ }
@@ -0,0 +1,293 @@
1
+ import { dayCost, formatUsd } from '../../../services/usage-history/cost'
2
+ import {
3
+ lastDays,
4
+ lateNightPrompts,
5
+ meanOf,
6
+ peakDay,
7
+ projectTotals,
8
+ SESSION_BUCKETS,
9
+ sessionLengths,
10
+ sessionStats,
11
+ streaks,
12
+ totalMean,
13
+ } from '../../../services/usage-history/insights'
14
+ import { summarizeDays } from '../../../services/usage-history/stats'
15
+ import { formatCompact } from '../../format-number'
16
+ import { chartColumns } from './chart'
17
+ import { formatCount, formatDayLabel, formatDuration, shortenPath, weeklyLabels } from './format'
18
+ import {
19
+ BarRow,
20
+ GLYPH,
21
+ Muted,
22
+ pageLayout,
23
+ PageNotice,
24
+ type PageTile,
25
+ recordsOf,
26
+ RecordsSection,
27
+ Section,
28
+ StatsPage,
29
+ TwoColumn,
30
+ VBarChart,
31
+ } from './shared'
32
+ import { isEmpty, type StatsData } from './use-stats-data'
33
+
34
+ /**
35
+ * Projects — where the work went and what a session looks like.
36
+ *
37
+ * Usage answers "how much"; this page answers "on what, and in what shape". The
38
+ * session-length bars are the reason it exists: a median says where the middle
39
+ * is, and the middle is the least interesting thing about a day made of one long
40
+ * session or of twenty two-minute ones.
41
+ */
42
+
43
+ /** Tall enough to read a week off, short enough to sit beside a list of bars. */
44
+ const CHART_HEIGHT = 7
45
+
46
+ /**
47
+ * The median session length, or why it is zero.
48
+ *
49
+ * `formatDuration` renders nothing-at-all as an em dash, which is right for a
50
+ * missing measurement and wrong here: with thousands of single-prompt sessions
51
+ * the median really is zero, and "—" would report the most interesting fact on
52
+ * the page as an absence of data.
53
+ */
54
+ function medianLabel(count: number, medianMs: number): string {
55
+ if (count === 0) return '\u{2014}'
56
+ return medianMs <= 0 ? '< 1 min' : formatDuration(medianMs)
57
+ }
58
+
59
+ function projectTiles(
60
+ projects: number,
61
+ branches: number,
62
+ sessions: number,
63
+ typical: string
64
+ ): PageTile[] {
65
+ return [
66
+ { glyph: GLYPH.projects, label: 'Projects', value: formatCount(projects) },
67
+ { glyph: GLYPH.branches, label: 'Branches', value: formatCount(branches) },
68
+ { glyph: GLYPH.sessions, label: 'Sessions', value: formatCount(sessions) },
69
+ { glyph: GLYPH.clock, label: 'Typical', value: typical },
70
+ ]
71
+ }
72
+
73
+ export function ProjectsPage({ data, width }: { data: StatsData; width: number }) {
74
+ // First, before the derivations it would make pointless.
75
+ if (isEmpty(data.claude)) {
76
+ return <PageNotice>no history yet — the first rollup runs in the background</PageNotice>
77
+ }
78
+
79
+ const { split, usable } = pageLayout(width)
80
+ const { leftWidth, rightWidth } = split
81
+
82
+ const projects = projectTotals(data.claude, 8)
83
+ const summary = summarizeDays(data.claude)
84
+ const sessions = sessionStats(data.claude)
85
+ const lengths = sessionLengths(data.claude)
86
+ const promptChars = totalMean(data.claude, (day) => day.promptChars)
87
+ const turns = totalMean(data.claude, (day) => day.turnMs)
88
+ const streak = streaks(data.claude, data.todayDate)
89
+
90
+ const projectMax = projects.entries[0]?.[1] ?? 0
91
+ const branchMax = summary.branches[0]?.[1] ?? 0
92
+ const lengthMax = Math.max(...lengths, 0)
93
+ const perDay = sessions.days === 0 ? 0 : sessions.count / sessions.days
94
+
95
+ const chartDays = chartColumns(rightWidth - 8)
96
+ const daily = lastDays(
97
+ data.claude,
98
+ chartDays,
99
+ data.todayDate,
100
+ (day) => Object.keys(day.sessions).length
101
+ )
102
+ const chartLabels = weeklyLabels(chartDays, data.todayDate)
103
+
104
+ const busiest = peakDay(data.claude, (day) => day.prompts)
105
+ const richest = peakDay(data.claude, (day) => day.tokens.total)
106
+ const priciest = peakDay(data.claude, (day) => dayCost(day).total)
107
+ const lateNight = lateNightPrompts(data.claude)
108
+
109
+ // Only records that exist. A row of `—` teaches nothing and reads as broken.
110
+ const records = recordsOf([
111
+ busiest.value === 0
112
+ ? null
113
+ : {
114
+ label: 'Busiest day',
115
+ value: `${formatCount(busiest.value)} prompts`,
116
+ when: formatDayLabel(busiest.day),
117
+ },
118
+ streak.longest === 0
119
+ ? null
120
+ : { label: 'Longest streak', value: `${streak.longest} days`, when: '' },
121
+ sessions.longestMs === 0
122
+ ? null
123
+ : {
124
+ label: 'Longest session',
125
+ value: formatDuration(sessions.longestMs),
126
+ when: formatDayLabel(sessions.longestDay),
127
+ },
128
+ richest.value === 0
129
+ ? null
130
+ : {
131
+ label: 'Most tokens',
132
+ value: `${formatCompact(richest.value)} in a day`,
133
+ when: formatDayLabel(richest.day),
134
+ },
135
+ priciest.value === 0
136
+ ? null
137
+ : {
138
+ label: 'Priciest day',
139
+ value: `${formatUsd(priciest.value)} est.`,
140
+ when: formatDayLabel(priciest.day),
141
+ },
142
+ promptChars.max === 0
143
+ ? null
144
+ : { label: 'Longest prompt', value: `${formatCount(promptChars.max)} chars`, when: '' },
145
+ lateNight === 0
146
+ ? null
147
+ : {
148
+ label: 'Late nights',
149
+ value: `${formatCount(lateNight)} prompts`,
150
+ when: 'between 02:00 and 05:00',
151
+ },
152
+ ])
153
+
154
+ const left = (
155
+ <>
156
+ <Section
157
+ glyph={GLYPH.projects}
158
+ title="Projects"
159
+ note={projects.entries.length === 0 ? '' : `${formatCount(projects.total)} prompts`}
160
+ width={leftWidth}
161
+ >
162
+ {projects.entries.length === 0 ? (
163
+ <Muted>recorded from the next rollup onward</Muted>
164
+ ) : (
165
+ projects.entries.map(([path, value]) => (
166
+ <BarRow
167
+ key={path}
168
+ label={shortenPath(path)}
169
+ max={projectMax}
170
+ value={value}
171
+ valueText={formatCount(value)}
172
+ width={leftWidth}
173
+ />
174
+ ))
175
+ )}
176
+ </Section>
177
+
178
+ <Section
179
+ glyph={GLYPH.sessions}
180
+ title="Session length"
181
+ note={
182
+ sessions.count === 0 ? '' : `median ${medianLabel(sessions.count, sessions.medianMs)}`
183
+ }
184
+ width={leftWidth}
185
+ >
186
+ {sessions.count === 0 ? (
187
+ <Muted>recorded from the next rollup onward</Muted>
188
+ ) : (
189
+ <>
190
+ {SESSION_BUCKETS.map((label, index) => (
191
+ <BarRow
192
+ key={label}
193
+ label={label}
194
+ max={lengthMax}
195
+ value={lengths[index] ?? 0}
196
+ valueText={formatCount(lengths[index] ?? 0)}
197
+ width={leftWidth}
198
+ />
199
+ ))}
200
+ <Muted>
201
+ {[
202
+ `longest ${formatDuration(sessions.longestMs)}`,
203
+ sessions.singlePrompt === 0
204
+ ? ''
205
+ : `${formatCount(sessions.singlePrompt)} one-prompt`,
206
+ turns.count === 0 ? '' : `average turn ${formatDuration(meanOf(turns))}`,
207
+ ]
208
+ .filter((part) => part !== '')
209
+ .join(' \u{00B7} ')}
210
+ </Muted>
211
+ </>
212
+ )}
213
+ </Section>
214
+ </>
215
+ )
216
+
217
+ const right = (
218
+ <>
219
+ <Section
220
+ glyph={GLYPH.branches}
221
+ title="Branches"
222
+ note={summary.branchTotal === 0 ? '' : `${formatCompact(summary.branchTotal)} tokens`}
223
+ width={rightWidth}
224
+ >
225
+ {summary.branches.length === 0 ? (
226
+ <Muted>no branch attribution recorded</Muted>
227
+ ) : (
228
+ summary.branches.map(([branch, value]) => (
229
+ <BarRow
230
+ key={branch}
231
+ label={branch}
232
+ max={branchMax}
233
+ value={value}
234
+ valueText={formatCompact(value)}
235
+ width={rightWidth}
236
+ />
237
+ ))
238
+ )}
239
+ </Section>
240
+
241
+ <Section
242
+ glyph={GLYPH.calendar}
243
+ title="Sessions a day"
244
+ note={sessions.count === 0 ? '' : `${perDay.toFixed(1)} on an active day`}
245
+ width={rightWidth}
246
+ >
247
+ {sessions.count === 0 ? (
248
+ <Muted>recorded from the next rollup onward</Muted>
249
+ ) : (
250
+ <>
251
+ <VBarChart
252
+ caption={`last ${chartDays} days`}
253
+ height={CHART_HEIGHT}
254
+ labels={chartLabels}
255
+ values={daily}
256
+ />
257
+ <Muted>
258
+ {[
259
+ `${formatCount(sessions.count)} over ${formatCount(sessions.days)} days`,
260
+ promptChars.count === 0 ? '' : `${formatCount(meanOf(promptChars))} chars a prompt`,
261
+ ]
262
+ .filter((part) => part !== '')
263
+ .join(' \u{00B7} ')}
264
+ </Muted>
265
+ </>
266
+ )}
267
+ </Section>
268
+ </>
269
+ )
270
+
271
+ return (
272
+ <StatsPage
273
+ tiles={projectTiles(
274
+ projects.entries.length,
275
+ summary.branches.length,
276
+ sessions.count,
277
+ medianLabel(sessions.count, sessions.medianMs)
278
+ )}
279
+ usable={usable}
280
+ >
281
+ <TwoColumn split={split}>
282
+ {left}
283
+ {right}
284
+ </TwoColumn>
285
+
286
+ <RecordsSection
287
+ empty="nothing to beat yet — records appear as history accumulates"
288
+ records={records}
289
+ width={usable}
290
+ />
291
+ </StatsPage>
292
+ )
293
+ }