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