@brimveyn/aimux 1.22.12 → 1.23.1

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 (66) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/settings-actions.ts +24 -13
  3. package/src/app-runtime/side-effects.ts +5 -0
  4. package/src/app-runtime/use-mouse-handlers.ts +2 -0
  5. package/src/app.tsx +6 -0
  6. package/src/index.tsx +6 -0
  7. package/src/input/keymap/help-entries.ts +1 -0
  8. package/src/input/modes/bridge.ts +2 -1
  9. package/src/input/modes/transitions.ts +7 -3
  10. package/src/input/modes/types.ts +2 -1
  11. package/src/restart-daemon.ts +5 -0
  12. package/src/services/ai-usage/projection.ts +44 -0
  13. package/src/services/aimux-counters/index.ts +87 -0
  14. package/src/services/aimux-counters/observe.ts +39 -0
  15. package/src/services/aimux-counters/store.ts +150 -0
  16. package/src/services/aimux-counters/summary.ts +78 -0
  17. package/src/services/usage-history/cost.ts +149 -0
  18. package/src/services/usage-history/insights.ts +314 -0
  19. package/src/services/usage-history/rollup.ts +78 -6
  20. package/src/services/usage-history/stats.ts +66 -35
  21. package/src/services/usage-history/store.ts +128 -9
  22. package/src/settings/search.ts +13 -9
  23. package/src/settings/sections/about.ts +1 -0
  24. package/src/settings/sections/appearance.ts +1 -0
  25. package/src/settings/sections/automation.ts +1 -0
  26. package/src/settings/sections/commands.ts +1 -0
  27. package/src/settings/sections/editor.ts +1 -0
  28. package/src/settings/sections/experimental.ts +1 -0
  29. package/src/settings/sections/git.ts +1 -0
  30. package/src/settings/sections/index.ts +23 -12
  31. package/src/settings/sections/integrations.ts +1 -0
  32. package/src/settings/sections/layout.ts +1 -0
  33. package/src/settings/sections/notifications.ts +1 -0
  34. package/src/settings/sections/setup.ts +1 -0
  35. package/src/settings/sections/status-bar.ts +1 -0
  36. package/src/settings/sections/workspace.ts +1 -0
  37. package/src/settings/types.ts +10 -0
  38. package/src/state/actions.ts +13 -3
  39. package/src/state/app-store.ts +12 -1
  40. package/src/state/reducers/modal-state.ts +12 -17
  41. package/src/state/reducers/settings-state.ts +29 -51
  42. package/src/state/reducers/stats-state.ts +56 -0
  43. package/src/state/stats-pages.ts +33 -0
  44. package/src/state/store.ts +5 -0
  45. package/src/state/types.ts +22 -7
  46. package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
  47. package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
  48. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
  49. package/src/ui/components/settings/row-value.tsx +22 -3
  50. package/src/ui/components/settings/settings-footer.tsx +170 -0
  51. package/src/ui/components/settings/settings-row.tsx +109 -33
  52. package/src/ui/components/settings/settings-search-bar.tsx +61 -0
  53. package/src/ui/components/settings/settings-view.tsx +163 -77
  54. package/src/ui/components/stats/aimux-page.tsx +245 -0
  55. package/src/ui/components/stats/chart.ts +157 -0
  56. package/src/ui/components/stats/day-facts.tsx +268 -0
  57. package/src/ui/components/stats/format.ts +98 -0
  58. package/src/ui/components/stats/heatmap.tsx +305 -0
  59. package/src/ui/components/stats/projects-page.tsx +293 -0
  60. package/src/ui/components/stats/quotas.tsx +210 -0
  61. package/src/ui/components/stats/shared.tsx +654 -0
  62. package/src/ui/components/stats/stats-view.tsx +153 -0
  63. package/src/ui/components/stats/usage-page.tsx +291 -0
  64. package/src/ui/components/stats/use-stats-data.ts +48 -0
  65. package/src/ui/root.tsx +7 -3
  66. package/src/ui/components/modals/app/ai-usage-modal.tsx +0 -520
@@ -0,0 +1,654 @@
1
+ import type { BorderSides, RGBA } from '@opentui/core'
2
+ import type { ReactNode } from 'react'
3
+
4
+ import { useTheme } from '../../theme'
5
+ import { truncate } from '../../truncate'
6
+ import { type BarShape, buildChart, buildRuler, strideOf } from './chart'
7
+
8
+ /**
9
+ * The building blocks of a full-screen view — the stats pages, and the settings
10
+ * screen, which is laid out against the same grid so the two read as one app.
11
+ *
12
+ * Every page is the same grid: a headline row of tiles, a rule, then sections
13
+ * measured against the page's own width rather than against their content. A
14
+ * section's note lands on the same column on every page, and that shared right
15
+ * edge is what makes a screen read as one page instead of as blocks that happen
16
+ * to sit near each other.
17
+ *
18
+ * There are no tables. A table earns its place when several unrelated numbers
19
+ * have to be read against each other; a row of totals nobody compares is a
20
+ * border drawn around four numbers, and those numbers sit better on the headline
21
+ * row, in a fact grid, or in the chart that shows how they got there.
22
+ *
23
+ * Colour follows one rule throughout: **text wears text tokens**. Values and
24
+ * labels stay in `text`/`textMuted`, and a coloured mark beside them carries the
25
+ * meaning. The only coloured glyphs are the sequential heatmap ramp (one hue,
26
+ * light to dark) and the reserved status colours on quota bars.
27
+ */
28
+
29
+ /**
30
+ * Section glyphs, as constants rather than inline attributes: a JSX string
31
+ * attribute does not process `\u` escapes, so `glyph="\u{25D4}"` renders the
32
+ * backslash. All single-cell, text-presentation, present in the base fonts.
33
+ */
34
+ export const GLYPH = {
35
+ aimux: '\u{2318}',
36
+ branches: '\u{2442}',
37
+ burn: '\u{25B3}',
38
+ calendar: '\u{25A4}',
39
+ clock: '\u{25F4}',
40
+ cost: '\u{0024}',
41
+ distance: '\u{2192}',
42
+ keyboard: '\u{2328}',
43
+ models: '\u{25C8}',
44
+ projects: '\u{2632}',
45
+ quota: '\u{25D4}',
46
+ records: '\u{2605}',
47
+ sessions: '\u{29C9}',
48
+ streak: '\u{25B2}',
49
+ tokens: '\u{25A6}',
50
+ week: '\u{25A6}',
51
+ } as const
52
+
53
+ const RULE = '\u{2500}'
54
+
55
+ /** A horizontal rule, exactly `width` cells. The page's only separator. */
56
+ export function Rule({ width }: { width: number }) {
57
+ const t = useTheme()
58
+ return (
59
+ <text fg={t.borderSubtle} selectable={false} wrapMode="none">
60
+ {RULE.repeat(Math.max(0, width))}
61
+ </text>
62
+ )
63
+ }
64
+
65
+ /**
66
+ * A section: glyph and title on the left, a note pinned to the right edge of the
67
+ * column, a rule under both.
68
+ *
69
+ * The rule is what does the separating — every section is the same width and
70
+ * every note lands on the same column, so the page reads as a grid rather than
71
+ * as blocks that happen to sit near each other.
72
+ */
73
+ export function Section({
74
+ children,
75
+ glyph,
76
+ note,
77
+ rule = true,
78
+ title,
79
+ width,
80
+ }: {
81
+ children: ReactNode
82
+ glyph: string
83
+ /** A string, or a component when the note is itself a legend. */
84
+ note?: ReactNode
85
+ /** Off when the content draws its own box — a rule over a border is two lines saying one thing. */
86
+ rule?: boolean
87
+ title: string
88
+ width: number
89
+ }) {
90
+ const t = useTheme()
91
+ return (
92
+ <box flexDirection="column" flexShrink={0} paddingBottom={1}>
93
+ <box width={width} flexDirection="row" flexShrink={0}>
94
+ <text fg={t.primary} selectable={false} wrapMode="none">
95
+ {`${glyph} `}
96
+ </text>
97
+ <text fg={t.text} selectable={false} wrapMode="none">
98
+ {title}
99
+ </text>
100
+ {note != null && note !== '' ? (
101
+ <box flexGrow={1} flexDirection="row" justifyContent="flex-end">
102
+ {typeof note === 'string' ? (
103
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
104
+ {note}
105
+ </text>
106
+ ) : (
107
+ note
108
+ )}
109
+ </box>
110
+ ) : null}
111
+ </box>
112
+ {rule ? <Rule width={width} /> : null}
113
+ {children}
114
+ </box>
115
+ )
116
+ }
117
+
118
+ export function Muted({ children }: { children: string }) {
119
+ const t = useTheme()
120
+ return (
121
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
122
+ {children}
123
+ </text>
124
+ )
125
+ }
126
+
127
+ /**
128
+ * The page's grid: one padding column either side, two content columns, and a
129
+ * three-cell gutter carrying the vertical rule.
130
+ *
131
+ * Private on purpose. Every page gets its padding from `StatsPage` and its
132
+ * widths from `pageLayout`, so there is one place that knows this number and no
133
+ * way for a page to disagree with the others about it.
134
+ */
135
+ const PAGE_PAD = 1
136
+ /** The spacer, the rule, and the right column's own padding. */
137
+ const GUTTER = 3
138
+ /** Below this the two columns collide, and a collided section is worse than a tall page. */
139
+ const TWO_COLUMN_MIN = 92
140
+
141
+ export interface Split {
142
+ leftWidth: number
143
+ rightWidth: number
144
+ twoUp: boolean
145
+ }
146
+
147
+ /** The two content columns of `usable`, or one column's worth twice over when narrow. */
148
+ function splitWidths(usable: number): Split {
149
+ const twoUp = usable >= TWO_COLUMN_MIN
150
+ const leftWidth = twoUp ? Math.floor((usable - GUTTER) / 2) : usable
151
+ return { leftWidth, rightWidth: twoUp ? usable - GUTTER - leftWidth : usable, twoUp }
152
+ }
153
+
154
+ export interface PageLayout {
155
+ split: Split
156
+ /** Columns inside the page's own padding — what every section is measured against. */
157
+ usable: number
158
+ }
159
+
160
+ /**
161
+ * The widths a page lays itself out against.
162
+ *
163
+ * One function rather than three copies of the same three lines: the pages only
164
+ * read as one screen for as long as they agree on this arithmetic, and three
165
+ * copies is three chances to disagree.
166
+ */
167
+ export function pageLayout(width: number): PageLayout {
168
+ const usable = Math.max(24, width - PAGE_PAD * 2)
169
+ return { split: splitWidths(usable), usable }
170
+ }
171
+
172
+ /** Module scope: a fresh array every render would repaint the divider each frame. */
173
+ const BORDER_LEFT: BorderSides[] = ['left']
174
+
175
+ /**
176
+ * Two stacks of sections side by side, divided by a rule.
177
+ *
178
+ * The rule is drawn as a border rather than as a guessed number of rows, so it
179
+ * runs the full height of whichever column ends up taller.
180
+ */
181
+ export function TwoColumn({ children, split }: { children: [ReactNode, ReactNode]; split: Split }) {
182
+ const t = useTheme()
183
+ if (!split.twoUp) {
184
+ return (
185
+ <box flexDirection="column" flexShrink={0}>
186
+ {children[0]}
187
+ {children[1]}
188
+ </box>
189
+ )
190
+ }
191
+ return (
192
+ <box flexDirection="row" flexShrink={0}>
193
+ <box width={split.leftWidth} flexDirection="column" flexShrink={0}>
194
+ {children[0]}
195
+ </box>
196
+ <box width={1} flexShrink={0} />
197
+ <box
198
+ border={BORDER_LEFT}
199
+ borderColor={t.borderSubtle}
200
+ paddingLeft={1}
201
+ width={split.rightWidth + 2}
202
+ flexDirection="column"
203
+ flexShrink={0}
204
+ >
205
+ {children[1]}
206
+ </box>
207
+ </box>
208
+ )
209
+ }
210
+
211
+ /** Widest a fact's label is allowed to get before the value starts drifting away from it. */
212
+ const FACT_LABEL_MAX = 20
213
+
214
+ /**
215
+ * Label/value pairs in columns, for the counts a chart cannot carry.
216
+ *
217
+ * Heterogeneous totals — workspaces, tabs, daemon restarts — share no scale, so
218
+ * a bar between them would encode a comparison nobody is making. What they need
219
+ * is to be readable side by side, which is a column of labels and a column of
220
+ * numbers and nothing else.
221
+ */
222
+ export function FactGrid({
223
+ columns = 2,
224
+ facts,
225
+ width,
226
+ }: {
227
+ columns?: number
228
+ facts: [string, string][]
229
+ width: number
230
+ }) {
231
+ const t = useTheme()
232
+ const perColumn = Math.ceil(facts.length / columns)
233
+ const columnWidth = Math.floor(width / columns)
234
+ const labelWidth = Math.max(8, Math.min(FACT_LABEL_MAX, Math.floor(columnWidth / 2)))
235
+
236
+ return (
237
+ <box flexDirection="row" flexShrink={0}>
238
+ {Array.from({ length: columns }, (_, column) => (
239
+ <box
240
+ key={column}
241
+ width={columnWidth}
242
+ flexDirection="column"
243
+ flexShrink={0}
244
+ overflow="hidden"
245
+ >
246
+ {facts.slice(column * perColumn, (column + 1) * perColumn).map(([label, value]) => (
247
+ <box key={label} flexDirection="row" flexShrink={0}>
248
+ <box width={labelWidth} flexShrink={0}>
249
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
250
+ {truncate(label, labelWidth - 1)}
251
+ </text>
252
+ </box>
253
+ <text fg={t.text} selectable={false} wrapMode="none">
254
+ {truncate(value, columnWidth - labelWidth)}
255
+ </text>
256
+ </box>
257
+ ))}
258
+ </box>
259
+ ))}
260
+ </box>
261
+ )
262
+ }
263
+
264
+ export interface StatRecord {
265
+ label: string
266
+ value: string
267
+ /** Blank for a record no single day owns, like a streak or a lifetime total. */
268
+ when: string
269
+ }
270
+
271
+ /**
272
+ * Drops the records that were not set.
273
+ *
274
+ * A page lists every record it could hold and marks the ones with nothing behind
275
+ * them `null`, rather than pushing into an array through a closure: what the
276
+ * section can contain then reads as one list instead of as a run of `if`s.
277
+ */
278
+ export function recordsOf(entries: (StatRecord | null)[]): StatRecord[] {
279
+ return entries.filter((entry): entry is StatRecord => entry !== null)
280
+ }
281
+
282
+ /**
283
+ * One record: what it is, what it was, and when.
284
+ *
285
+ * The same three-part geometry as a bar row — label on the left, the number in
286
+ * the middle, the page's right edge on the right — so a records section lines up
287
+ * with the bars above it instead of reading as a table someone pasted in.
288
+ */
289
+ function RecordRow({
290
+ label,
291
+ value,
292
+ when,
293
+ width,
294
+ }: {
295
+ label: string
296
+ value: string
297
+ when: string
298
+ width: number
299
+ }) {
300
+ const t = useTheme()
301
+ const labels = Math.max(10, Math.min(24, Math.round(width / 3)))
302
+
303
+ return (
304
+ <box width={width} flexDirection="row" flexShrink={0}>
305
+ <box width={labels} flexShrink={0}>
306
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
307
+ {truncate(label, labels - 1)}
308
+ </text>
309
+ </box>
310
+ <text fg={t.text} selectable={false} wrapMode="none">
311
+ {value}
312
+ </text>
313
+ {when === '' ? null : (
314
+ <box flexGrow={1} flexDirection="row" justifyContent="flex-end">
315
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
316
+ {when}
317
+ </text>
318
+ </box>
319
+ )}
320
+ </box>
321
+ )
322
+ }
323
+
324
+ /**
325
+ * A solid bar half a cell tall, resting on a hairline track.
326
+ *
327
+ * Half height, not full: a full block fills its cell top to bottom, so stacked
328
+ * rows touch and seven bars merge into one shape with steps in it — you can see
329
+ * the outline of the data but not where one bar ends and the next begins. The
330
+ * empty top half of each cell is the gap between rows, for free.
331
+ *
332
+ * Solid rather than segmented, though. A repeated part-*width* block like `▍`
333
+ * puts a gap at the same place in every cell, and stacked those gaps line up
334
+ * into vertical stripes that win the eye.
335
+ */
336
+ const BAR_FILL = '\u{2584}'
337
+ const BAR_TRACK = '\u{2581}'
338
+
339
+ export function Bar({
340
+ color,
341
+ max,
342
+ segments = 24,
343
+ value,
344
+ }: {
345
+ color: RGBA | string
346
+ max: number
347
+ segments?: number
348
+ value: number
349
+ }) {
350
+ const t = useTheme()
351
+ const filled =
352
+ max <= 0
353
+ ? 0
354
+ : Math.min(segments, Math.max(value > 0 ? 1 : 0, Math.round((value / max) * segments)))
355
+
356
+ // Each half is dropped when it is empty rather than rendered as `''`: opentui
357
+ // floors a text node at one column (`Math.max(1, widthColsMax)`), so an empty
358
+ // node is a column wide. A full bar would push its value one column right and
359
+ // off the end of the row, and an empty one would indent its own track.
360
+ return (
361
+ <box flexDirection="row" flexShrink={0}>
362
+ {filled === 0 ? null : (
363
+ <text fg={color} selectable={false} wrapMode="none">
364
+ {BAR_FILL.repeat(filled)}
365
+ </text>
366
+ )}
367
+ {filled >= segments ? null : (
368
+ <text fg={t.borderSubtle} selectable={false} wrapMode="none">
369
+ {BAR_TRACK.repeat(segments - filled)}
370
+ </text>
371
+ )}
372
+ </box>
373
+ )
374
+ }
375
+
376
+ /**
377
+ * A headline tile: coloured glyph, `Label ~ value`, and optionally a tick gauge
378
+ * beneath. Three of these across the top of a page are its opening line.
379
+ */
380
+ /**
381
+ * A headline number in its slot on the row.
382
+ *
383
+ * Deliberately without a gauge. A bar has to be a fraction of something real —
384
+ * a quota, a share of a total — and "today against your best day ever" is not
385
+ * that: the record is not a target, so the bar would encode a comparison nobody
386
+ * is making and read as progress toward nothing.
387
+ */
388
+ function StatTile({
389
+ glyph,
390
+ label,
391
+ value,
392
+ width,
393
+ }: {
394
+ glyph: string
395
+ label: string
396
+ value: string
397
+ /** The tile's slot on the row — equal slots are what line the tiles up. */
398
+ width: number
399
+ }) {
400
+ const t = useTheme()
401
+ return (
402
+ <box width={width} flexShrink={0} flexDirection="row">
403
+ <text fg={t.primary} selectable={false} wrapMode="none">
404
+ {`${glyph} `}
405
+ </text>
406
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
407
+ {`${label} `}
408
+ </text>
409
+ <text fg={t.text} selectable={false} wrapMode="none">
410
+ {value}
411
+ </text>
412
+ </box>
413
+ )
414
+ }
415
+
416
+ export interface PageTile {
417
+ glyph: string
418
+ label: string
419
+ value: string
420
+ }
421
+
422
+ /**
423
+ * The headline row: equal slots across the page's width, the last one taking
424
+ * whatever the division left over so the row ends on the page's right edge
425
+ * rather than a column or two short.
426
+ */
427
+ export function TileRow({ tiles, usable }: { tiles: PageTile[]; usable: number }) {
428
+ const tileWidth = Math.floor(usable / Math.max(1, tiles.length))
429
+ return (
430
+ <box flexDirection="row" flexShrink={0}>
431
+ {tiles.map((tile, index) => (
432
+ <StatTile
433
+ key={tile.label}
434
+ glyph={tile.glyph}
435
+ label={tile.label}
436
+ value={tile.value}
437
+ width={index === tiles.length - 1 ? usable - tileWidth * (tiles.length - 1) : tileWidth}
438
+ />
439
+ ))}
440
+ </box>
441
+ )
442
+ }
443
+
444
+ /**
445
+ * Every page's frame: the headline row, the rule under it, and the padding they
446
+ * both sit in.
447
+ *
448
+ * The pages differ in what they measure and agree on how they are laid out, so
449
+ * the layout lives here. Left to each page, the agreement held only for as long
450
+ * as nobody edited one of them — which is how the three drifted apart the first
451
+ * time.
452
+ */
453
+ export function StatsPage({
454
+ children,
455
+ tiles,
456
+ usable,
457
+ }: {
458
+ children: ReactNode
459
+ tiles: PageTile[]
460
+ usable: number
461
+ }) {
462
+ return (
463
+ <box flexDirection="column" paddingLeft={PAGE_PAD} paddingRight={PAGE_PAD}>
464
+ <TileRow tiles={tiles} usable={usable} />
465
+ <box paddingTop={1} paddingBottom={1} flexShrink={0}>
466
+ <Rule width={usable} />
467
+ </box>
468
+ {children}
469
+ </box>
470
+ )
471
+ }
472
+
473
+ /** A whole page that has nothing to show yet, in the page's own padding. */
474
+ export function PageNotice({ children }: { children: string }) {
475
+ return (
476
+ <box paddingLeft={PAGE_PAD} paddingRight={PAGE_PAD}>
477
+ <Muted>{children}</Muted>
478
+ </box>
479
+ )
480
+ }
481
+
482
+ const AXIS_WALL = '\u{2502}'
483
+ const AXIS_FOOT = '\u{2514}'
484
+ const AXIS_FLOOR = '\u{2500}'
485
+ /** The wall and the space between it and the first column. */
486
+ const AXIS_GUTTER = 2
487
+
488
+ /**
489
+ * A column chart: an integer axis down the left, a rule under the columns, and
490
+ * a caption beneath that.
491
+ *
492
+ * The L of wall and floor is what makes the columns read as measured against
493
+ * something. Without it a column chart is a row of blocks floating on the
494
+ * surface, and the eye has nothing to judge a height against. Both are drawn
495
+ * one shade off the surface, so they frame the data without competing with it.
496
+ */
497
+ export function VBarChart({
498
+ bar,
499
+ caption,
500
+ format,
501
+ height = 10,
502
+ labels,
503
+ values,
504
+ }: {
505
+ /** Bar width and gap. Defaults to the two-wide, one-gap day column. */
506
+ bar?: BarShape
507
+ caption?: string
508
+ format?: (value: number) => string
509
+ height?: number
510
+ /** One per bar, blank where a bar goes unlabelled. */
511
+ labels?: string[]
512
+ values: number[]
513
+ }) {
514
+ const t = useTheme()
515
+ const chart = buildChart(values, height, format, bar)
516
+ const chartWidth = chart.bars[0]?.length ?? 0
517
+ const axisWidth = chart.axis[0]?.length ?? 0
518
+ const captionPad =
519
+ axisWidth + AXIS_GUTTER + Math.max(0, Math.floor((chartWidth - (caption?.length ?? 0)) / 2))
520
+
521
+ return (
522
+ <box flexDirection="column" flexShrink={0}>
523
+ {chart.bars.map((line, index) => (
524
+ <box key={`${String(index)}:${line}`} flexDirection="row" flexShrink={0}>
525
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
526
+ {chart.axis[index] ?? ''}
527
+ </text>
528
+ <text fg={t.borderSubtle} selectable={false} wrapMode="none">
529
+ {`${AXIS_WALL} `}
530
+ </text>
531
+ <text fg={t.primary} selectable={false} wrapMode="none">
532
+ {line}
533
+ </text>
534
+ </box>
535
+ ))}
536
+ <box flexDirection="row" flexShrink={0}>
537
+ <text fg={t.borderSubtle} selectable={false} wrapMode="none">
538
+ {`${' '.repeat(axisWidth)}${AXIS_FOOT}${AXIS_FLOOR.repeat(chartWidth + 1)}`}
539
+ </text>
540
+ </box>
541
+ {labels === undefined ? null : (
542
+ <box paddingLeft={axisWidth + AXIS_GUTTER} flexShrink={0}>
543
+ <Muted>{buildRuler(labels, strideOf(bar))}</Muted>
544
+ </box>
545
+ )}
546
+ {caption === undefined ? null : (
547
+ <box paddingLeft={captionPad} flexShrink={0}>
548
+ <Muted>{caption}</Muted>
549
+ </box>
550
+ )}
551
+ </box>
552
+ )
553
+ }
554
+
555
+ /** The column every bar row's value ends on. */
556
+ const VALUE_WIDTH = 10
557
+ /**
558
+ * Longest a bar is allowed to get, however wide the terminal.
559
+ *
560
+ * Past this the bar stops carrying information and starts carrying distance:
561
+ * comparing two lengths gets harder as they grow, and on a wide terminal a row
562
+ * that fills its column separates its own label from its own value.
563
+ */
564
+ const BAR_MAX = 32
565
+
566
+ /**
567
+ * A labelled magnitude bar, scaled against the largest value in its group.
568
+ *
569
+ * The same tick mark as the quota gauges, so one mark means one thing across
570
+ * the screen: a proportion. It carries the value at its tip and nothing else —
571
+ * a second number per row would be read against a different denominator than
572
+ * the bar (share of the total, where the bar is scaled to the largest), which
573
+ * is two scales in one row.
574
+ */
575
+ export function BarRow({
576
+ barWidth = 12,
577
+ label,
578
+ labelWidth = 14,
579
+ max,
580
+ value,
581
+ valueText,
582
+ width,
583
+ }: {
584
+ barWidth?: number
585
+ label: string
586
+ labelWidth?: number
587
+ max: number
588
+ value: number
589
+ valueText: string
590
+ /** Fill exactly this many columns — the bar takes whatever the label and value leave. */
591
+ width?: number
592
+ }) {
593
+ const t = useTheme()
594
+ const labels = width === undefined ? labelWidth : Math.max(6, Math.min(20, Math.round(width / 3)))
595
+ const room = width === undefined ? barWidth : Math.max(4, width - labels - VALUE_WIDTH)
596
+ const bars = Math.min(BAR_MAX, room)
597
+ // Whatever the cap leaves over goes between the bar and its value, so the
598
+ // values stay on the column's right edge and every row still ends together.
599
+ const slack = room - bars
600
+
601
+ return (
602
+ <box flexDirection="row" flexShrink={0}>
603
+ <box width={labels} flexShrink={0}>
604
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
605
+ {truncate(label, labels - 1)}
606
+ </text>
607
+ </box>
608
+ <Bar color={t.primary} max={max} segments={bars} value={value} />
609
+ <text fg={t.text} selectable={false} wrapMode="none">
610
+ {valueText.padStart(VALUE_WIDTH + slack)}
611
+ </text>
612
+ </box>
613
+ )
614
+ }
615
+
616
+ /**
617
+ * The records a page closes on.
618
+ *
619
+ * A records page of its own was mostly empty and every row on it belonged to
620
+ * data another page already showed, so each page keeps its own — which only
621
+ * works if they all draw it the same way.
622
+ */
623
+ export function RecordsSection({
624
+ empty,
625
+ records,
626
+ width,
627
+ }: {
628
+ empty: string
629
+ records: StatRecord[]
630
+ width: number
631
+ }) {
632
+ return (
633
+ <Section
634
+ glyph={GLYPH.records}
635
+ title="Records"
636
+ note={records.length === 0 ? '' : `${records.length} set`}
637
+ width={width}
638
+ >
639
+ {records.length === 0 ? (
640
+ <Muted>{empty}</Muted>
641
+ ) : (
642
+ records.map((record) => (
643
+ <RecordRow
644
+ key={record.label}
645
+ label={record.label}
646
+ value={record.value}
647
+ when={record.when}
648
+ width={width}
649
+ />
650
+ ))
651
+ )}
652
+ </Section>
653
+ )
654
+ }