@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,170 @@
1
+ import { memo, useCallback } from 'react'
2
+
3
+ import type { SettingRow } from '../../../settings/types'
4
+
5
+ import { readRow, storedRow, useSettingsStore } from '../../../settings/settings-store'
6
+ import { useAppStore } from '../../../state/app-store'
7
+ import { dispatchGlobal } from '../../../state/dispatch-ref'
8
+ import { useTheme } from '../../theme'
9
+ import { truncate } from '../../truncate'
10
+ import { Rule } from '../stats/shared'
11
+ import { describeValue } from './row-value'
12
+
13
+ /**
14
+ * Everything the rows used to carry on a second line of their own, moved to the
15
+ * foot of the screen and shown for the selected row only.
16
+ *
17
+ * Three lines, always, whatever the row is: a footer that grows and shrinks
18
+ * moves the list above it, and a list that shifts while you walk down it is the
19
+ * thing this screen was rebuilt to stop doing.
20
+ */
21
+
22
+ /**
23
+ * `[key, what it does]`, so the keys can wear the theme and the prose cannot.
24
+ *
25
+ * Cut to what works on the row you are standing on: `←/→` does nothing to a
26
+ * text field, and a hint for a key that does nothing is worse than no hint.
27
+ */
28
+ function hintsFor(row: SettingRow | null): [string, string][] {
29
+ const hints: [string, string][] = []
30
+ if (row?.kind === 'number' || row?.kind === 'select' || row?.kind === 'toggle') {
31
+ hints.push(['←/→', 'adjust'])
32
+ }
33
+ if (row != null && row.kind !== 'info') {
34
+ hints.push(['↵', row.kind === 'number' || row.kind === 'text' ? 'type a value' : 'change'])
35
+ }
36
+ hints.push(['r', 'reset'], ['/', 'search'], ['} {', 'section'], ['esc', 'close'])
37
+ return hints
38
+ }
39
+
40
+ /**
41
+ * What the row itself does not say. Not its description — the row carries that
42
+ * under its own label now — but where its value comes from and when it takes
43
+ * effect, which belong to the row you are on rather than to the setting.
44
+ */
45
+ function notesFor(row: SettingRow, fromConfigFile: boolean): string {
46
+ if (fromConfigFile) return 'set in aimux.config.ts — comes back on restart'
47
+ return storedRow(row)?.restart === true ? 'applies on restart' : ''
48
+ }
49
+
50
+ /**
51
+ * The options of a select, spelled out with the current one lit. The row can
52
+ * only show the one it is on, and stepping through the rest to find out what
53
+ * they are is not reading, it is guessing.
54
+ */
55
+ function OptionStrip({ row }: { row: Extract<SettingRow, { kind: 'select' }> }) {
56
+ const t = useTheme()
57
+ const values = useSettingsStore((s) => s.values)
58
+ const current = useAppStore((s) => readRow(row, { state: s, values }))
59
+ return (
60
+ <box flexDirection="row" gap={1}>
61
+ {row.options.map((option) => (
62
+ <text
63
+ key={String(option.value)}
64
+ fg={option.value === current ? t.text : t.textMuted}
65
+ selectable={false}
66
+ wrapMode="none"
67
+ >
68
+ {option.value === current ? `[${option.label}]` : option.label}
69
+ </text>
70
+ ))}
71
+ </box>
72
+ )
73
+ }
74
+
75
+ /** What the second line says when the row is not one with options to list. */
76
+ function rangeOf(row: SettingRow): string {
77
+ if (row.kind === 'number') return `${String(row.min)} – ${String(row.max)}`
78
+ if (row.kind === 'toggle') return 'on · off'
79
+ return ''
80
+ }
81
+
82
+ /**
83
+ * The whole of a value the row could only show the start of. A command or a
84
+ * path is the one kind of value that does not fit its column, and it is also
85
+ * the one you most need to read before changing it.
86
+ */
87
+ function FullValue({ row }: { row: Extract<SettingRow, { kind: 'text' | 'action' | 'info' }> }) {
88
+ const t = useTheme()
89
+ const values = useSettingsStore((s) => s.values)
90
+ const value = useAppStore((s) => readRow(row, { state: s, values }))
91
+ return (
92
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
93
+ {String(value)}
94
+ </text>
95
+ )
96
+ }
97
+
98
+ export const SettingsFooter = memo(function SettingsFooter({
99
+ pad,
100
+ row,
101
+ width,
102
+ }: {
103
+ pad: number
104
+ row: SettingRow | null
105
+ width: number
106
+ }) {
107
+ const t = useTheme()
108
+ const fromConfigFile = useSettingsStore((s) => (row ? s.fromConfigFile.has(row.id) : false))
109
+ const touched = useSettingsStore((s) => (row ? s.touched.has(row.id) : false))
110
+ const defaultValue = useSettingsStore((s) => (row ? s.defaults[row.id] : undefined))
111
+
112
+ const handleClose = useCallback(() => dispatchGlobal({ type: 'exit-settings' }), [])
113
+
114
+ const notes = row ? notesFor(row, fromConfigFile) : ''
115
+ // Only once the row has been changed — `r` is otherwise a key you press to
116
+ // find out what it does.
117
+ const resetHint =
118
+ row && touched && defaultValue !== undefined
119
+ ? `default: ${describeValue(row, defaultValue)}`
120
+ : null
121
+
122
+ return (
123
+ <box flexDirection="column" flexShrink={0} paddingLeft={pad} paddingRight={pad}>
124
+ <Rule width={width} />
125
+ <box width={width} flexDirection="row" flexShrink={0}>
126
+ <box flexGrow={1} flexShrink={1}>
127
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
128
+ {truncate(notes, Math.max(0, width - (resetHint?.length ?? 0) - 2))}
129
+ </text>
130
+ </box>
131
+ {resetHint != null ? (
132
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
133
+ {resetHint}
134
+ </text>
135
+ ) : null}
136
+ </box>
137
+ <box height={1} flexShrink={0} overflow="hidden">
138
+ {row?.kind === 'select' ? <OptionStrip row={row} /> : null}
139
+ {row?.kind === 'text' || row?.kind === 'action' || row?.kind === 'info' ? (
140
+ <FullValue row={row} />
141
+ ) : null}
142
+ {row?.kind === 'number' || row?.kind === 'toggle' ? (
143
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
144
+ {rangeOf(row)}
145
+ </text>
146
+ ) : null}
147
+ </box>
148
+ {/* Clipped rather than squeezed: a row of hints allowed to shrink loses
149
+ the spaces *inside* the words first, and `↵change -/+step` is worse
150
+ than the same line with its tail cut off on a narrow terminal. */}
151
+ <box width={width} flexDirection="row" flexShrink={0} gap={2} overflow="hidden">
152
+ {hintsFor(row).map(([key, what]) => (
153
+ <box key={key} flexDirection="row" flexShrink={0}>
154
+ <text
155
+ fg={t.primary}
156
+ selectable={false}
157
+ wrapMode="none"
158
+ onMouseDown={key === 'esc' ? handleClose : undefined}
159
+ >
160
+ {key}
161
+ </text>
162
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
163
+ {` ${what}`}
164
+ </text>
165
+ </box>
166
+ ))}
167
+ </box>
168
+ </box>
169
+ )
170
+ })
@@ -2,53 +2,118 @@ import { memo } from 'react'
2
2
 
3
3
  import type { SettingRow } from '../../../settings/types'
4
4
 
5
- import { storedRow, useSettingsStore } from '../../../settings/settings-store'
5
+ import { readRow, useSettingsStore } from '../../../settings/settings-store'
6
+ import { useAppStore } from '../../../state/app-store'
6
7
  import { useTheme } from '../../theme'
8
+ import { truncate } from '../../truncate'
7
9
  import { ListItem } from '../primitives/list-item'
8
- import { describeValue, RowValue } from './row-value'
10
+ import { Bar } from '../stats/shared'
11
+ import { RowValue } from './row-value'
9
12
 
10
- /** Where the value comes from, on the left, out of the value column's way. */
11
- const CONFIG_FILE_MARK = '*'
12
- const TOUCHED_MARK = '~'
13
+ /**
14
+ * Where the value comes from, on the left, out of the value column's way. Both
15
+ * are legended by the headline row at the top of the screen, which counts them.
16
+ */
17
+ export const CONFIG_FILE_MARK = '*'
18
+ export const TOUCHED_MARK = '~'
19
+
20
+ /** The cursor, the mark, their spaces, and the row's own padding either side. */
21
+ const ROW_CHROME = 6
22
+ /** The column every value ends on, and the least the widest of them needs. */
23
+ const VALUE_WIDTH = 12
24
+ /**
25
+ * Uncapped, unlike the stats bars, which stop at 32.
26
+ *
27
+ * That cap is there because those bars share a scale and get compared to each
28
+ * other, and comparing two lengths gets harder as they grow. These do not: each
29
+ * one is a position inside its own row's range, read on its own. So it runs to
30
+ * the value column and the row has no gap in the middle of it.
31
+ */
32
+ const BAR_MAX = Infinity
33
+ /**
34
+ * Every label there is fits in this, so nothing on the screen is cropped when
35
+ * the column is wide enough to give it — which is what the column layout is
36
+ * sized around. (`Auto-rename minimum words` is the longest at 25.)
37
+ */
38
+ const LABEL_MAX = 28
13
39
 
14
40
  interface SettingsRowProps {
15
41
  row: SettingRow
16
42
  active: boolean
17
43
  index: number
44
+ /** The column this row stands in — it fills it, edge to edge. */
45
+ width: number
18
46
  onSelect: (index: number) => void
19
47
  }
20
48
 
49
+ /**
50
+ * The gauge on a row that has a floor and a ceiling.
51
+ *
52
+ * A bar is honest here for the same reason it is honest on a quota and dishonest
53
+ * on a headline number: `min` and `max` are a real scale, so the fraction means
54
+ * something. It is measured from `min`, not from zero — a delay that runs 60s to
55
+ * 3600s sitting at 60 is at the bottom of its range, not at 2% of it.
56
+ */
57
+ function RangeGauge({
58
+ row,
59
+ width,
60
+ }: {
61
+ row: Extract<SettingRow, { kind: 'number' }>
62
+ width: number
63
+ }) {
64
+ const t = useTheme()
65
+ const values = useSettingsStore((s) => s.values)
66
+ const value = useAppStore((s) => readRow(row, { state: s, values }))
67
+ const span = row.max - row.min
68
+ if (span <= 0) return null
69
+ return (
70
+ <Bar
71
+ color={t.primary}
72
+ max={span}
73
+ segments={width}
74
+ value={Math.max(0, (typeof value === 'number' ? value : row.min) - row.min)}
75
+ />
76
+ )
77
+ }
78
+
79
+ /**
80
+ * What it is, what it is set to, where that sits in its range, and what it does
81
+ * underneath. It can afford the second line because the screen shows one section
82
+ * at a time — fifty rows in one scroll could not, which is what made the old
83
+ * two-line row a wall rather than a paragraph.
84
+ *
85
+ * The same three-part geometry as a stats bar row: label on the left, the gauge
86
+ * in the middle, the column's right edge on the right. Every value on the screen
87
+ * therefore ends on one column, which is what makes a page of them read as a
88
+ * page rather than as rows that happen to sit above each other.
89
+ *
90
+ * Both columns at full strength, unlike a stats row, which mutes its label. On
91
+ * that screen the number is the content and the label only says which number it
92
+ * is; here you are scanning the labels to find the one you came for.
93
+ */
21
94
  export const SettingsRow = memo(function SettingsRow({
22
95
  active,
23
96
  index,
24
97
  onSelect,
25
98
  row,
99
+ width,
26
100
  }: SettingsRowProps) {
27
101
  const t = useTheme()
28
102
  const fromConfigFile = useSettingsStore((s) => s.fromConfigFile.has(row.id))
29
103
  const touched = useSettingsStore((s) => s.touched.has(row.id))
30
- const defaultValue = useSettingsStore((s) => s.defaults[row.id])
31
- const editable = row.kind !== 'info'
32
104
  // A blank when neither applies, never nothing: a marker that comes and goes
33
105
  // would shift the label of every row it is missing from.
34
106
  let mark = ' '
35
107
  if (fromConfigFile) mark = CONFIG_FILE_MARK
36
108
  else if (touched) mark = TOUCHED_MARK
37
109
 
38
- // Always, never only on the focused row: a row that grows a line when you land
39
- // on it shifts everything under it, so the list moves under the cursor the
40
- // whole way down. A taller list that holds still reads better than a compact
41
- // one that does not.
42
- const notes: string[] = []
43
- if (row.description != null && row.description !== '') notes.push(row.description)
44
- if (fromConfigFile) notes.push('set in aimux.config.ts comes back on restart')
45
- else if (storedRow(row)?.restart === true) notes.push('applies on restart')
46
- // Right-aligned on the same line rather than tacked onto the end of the
47
- // description: it is about the row, not about what the setting does, and the
48
- // value column above it is on that edge too. Only once the row has been
49
- // changed — `r` is otherwise a key you press to find out what it does.
50
- const resetHint =
51
- touched && defaultValue !== undefined ? `r resets to ${describeValue(row, defaultValue)}` : null
110
+ // The label gets first call on the column and the gauge takes what is left,
111
+ // not the other way round: a bar two cells shorter still reads as the same
112
+ // proportion, where `Transparent bac…` and `Transparent bak…` do not read as
113
+ // different settings.
114
+ const inner = Math.max(12, width - ROW_CHROME)
115
+ const labelWidth = Math.max(10, Math.min(LABEL_MAX, inner - VALUE_WIDTH))
116
+ const barWidth = Math.min(BAR_MAX, inner - labelWidth - VALUE_WIDTH - 1)
52
117
 
53
118
  return (
54
119
  <ListItem
@@ -57,22 +122,33 @@ export const SettingsRow = memo(function SettingsRow({
57
122
  index={index}
58
123
  onClickIndex={onSelect}
59
124
  leading={<text fg={fromConfigFile ? t.warning : t.secondary}>{mark}</text>}
60
- title={<text fg={editable ? t.text : t.textMuted}>{row.label}</text>}
61
125
  subtitle={
62
- notes.length > 0 || resetHint != null ? (
63
- <box flexDirection="row">
64
- <box flexGrow={1} flexShrink={1}>
65
- <text fg={t.textMuted}>{notes.join(' · ')}</text>
66
- </box>
67
- {resetHint != null ? (
68
- <text fg={t.textMuted} wrapMode="none">
69
- {resetHint}
70
- </text>
71
- ) : null}
126
+ row.description != null && row.description !== '' ? (
127
+ <box width={inner}>
128
+ <text fg={t.textMuted} selectable={false}>
129
+ {row.description}
130
+ </text>
72
131
  </box>
73
132
  ) : undefined
74
133
  }
75
- trailing={<RowValue row={row} />}
134
+ title={
135
+ <box width={inner} flexDirection="row" flexShrink={0}>
136
+ <box width={labelWidth} flexShrink={0}>
137
+ <text fg={row.kind === 'info' ? t.textMuted : t.text} wrapMode="none">
138
+ {truncate(row.label, labelWidth - 1)}
139
+ </text>
140
+ </box>
141
+ {row.kind === 'number' && barWidth > 0 ? <RangeGauge row={row} width={barWidth} /> : null}
142
+ {/* Grows into whatever the label and the gauge left, so the value ends
143
+ on the column's edge whether or not the row has a gauge. */}
144
+ <box flexGrow={1} flexDirection="row" justifyContent="flex-end">
145
+ <RowValue
146
+ row={row}
147
+ maxWidth={inner - labelWidth - (barWidth > 0 && row.kind === 'number' ? barWidth : 0)}
148
+ />
149
+ </box>
150
+ </box>
151
+ }
76
152
  />
77
153
  )
78
154
  })
@@ -0,0 +1,61 @@
1
+ import { memo, useCallback } from 'react'
2
+
3
+ import { dispatchGlobal } from '../../../state/dispatch-ref'
4
+ import { useTheme } from '../../theme'
5
+
6
+ const SEARCH_GLYPH = '\u{2315}'
7
+
8
+ /**
9
+ * The way into the search, drawn rather than remembered.
10
+ *
11
+ * `/` opened the picker long before this existed, and the footer said so — but a
12
+ * key you have to read a hint to know about is a key nobody presses. A field you
13
+ * can see and click is the only version of this that gets used, so the screen
14
+ * spends three rows on it and keeps it out of the scroll.
15
+ *
16
+ * It never holds text. Clicking it opens the picker, which is where the typing
17
+ * and the filtering actually happen; this is its handle.
18
+ */
19
+ export const SettingsSearchBar = memo(function SettingsSearchBar({ width }: { width: number }) {
20
+ const t = useTheme()
21
+ const handleOpen = useCallback(() => {
22
+ dispatchGlobal({ type: 'open-settings-search' })
23
+ }, [])
24
+
25
+ // Filled, bordered in the active colour, and the prompt at full strength: it
26
+ // is the one control on a read-mostly screen, and a control drawn like the
27
+ // text around it is a control nobody sees.
28
+ return (
29
+ <box
30
+ border
31
+ borderStyle="rounded"
32
+ borderColor={t.borderActive}
33
+ backgroundColor={t.backgroundElement}
34
+ width={width}
35
+ flexShrink={0}
36
+ flexDirection="row"
37
+ paddingLeft={1}
38
+ paddingRight={1}
39
+ onMouseDown={handleOpen}
40
+ >
41
+ <text fg={t.primary} selectable={false} wrapMode="none">
42
+ {`${SEARCH_GLYPH} `}
43
+ </text>
44
+ <box flexGrow={1} flexShrink={1}>
45
+ <text fg={t.text} selectable={false} wrapMode="none">
46
+ Search every setting…
47
+ </text>
48
+ </box>
49
+ {/* The key drawn as the cap you press, not as a letter in a sentence. */}
50
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
51
+ {'[ '}
52
+ </text>
53
+ <text fg={t.primary} selectable={false} wrapMode="none">
54
+ /
55
+ </text>
56
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
57
+ {' ]'}
58
+ </text>
59
+ </box>
60
+ )
61
+ })