@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
@@ -1,5 +1,6 @@
1
1
  import { parseColor, RGBA } from '@opentui/core'
2
2
 
3
+ import { daysBetween, parseDayKey } from './insights'
3
4
  import { emptyTokens, localDay, type UsageDays, type UsageTokens } from './store'
4
5
 
5
6
  /** Pure shaping of stored usage days into what the History page renders. */
@@ -11,18 +12,6 @@ export interface HeatmapCell {
11
12
  value: number
12
13
  }
13
14
 
14
- function parseDay(key: string): Date {
15
- const [year, month, day] = key.split('-').map(Number)
16
- return new Date(year ?? 0, (month ?? 1) - 1, day ?? 1)
17
- }
18
-
19
- /** Whole days apart. Via `Date.UTC` on the calendar parts: a DST span is not a whole number of 24h periods. */
20
- function daysBetween(from: Date, to: Date): number {
21
- const utcOf = (date: Date): number =>
22
- Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
23
- return Math.round((utcOf(to) - utcOf(from)) / 86_400_000)
24
- }
25
-
26
15
  /** Quartiles of the non-empty days: a linear `value / max` ramp lets one outlier flatten the year to level 1. */
27
16
  export function cutPoints(values: number[]): [number, number, number] {
28
17
  const sorted = values.filter((value) => value > 0).sort((left, right) => left - right)
@@ -44,11 +33,11 @@ export function coveredWeeks(days: UsageDays, today: Date, maxWeeks: number): nu
44
33
  const dates = Object.keys(days).sort()
45
34
  const first = dates[0]
46
35
  if (first === undefined) return Math.min(maxWeeks, 12)
47
- const elapsed = daysBetween(parseDay(first), today)
36
+ const elapsed = daysBetween(parseDayKey(first), today)
48
37
  return Math.max(4, Math.min(maxWeeks, Math.ceil((elapsed + 1) / 7) + 1))
49
38
  }
50
39
 
51
- /** 7 rows (Monday first) by `weeks` columns, oldest left. Cells after today come back empty. */
40
+ /** 7 rows (Sunday first) by `weeks` columns, oldest left. Cells after today come back empty. */
52
41
  export function buildHeatmap(
53
42
  counts: Record<string, number>,
54
43
  weeks: number,
@@ -56,9 +45,13 @@ export function buildHeatmap(
56
45
  ): HeatmapCell[][] {
57
46
  const cuts = cutPoints(Object.values(counts))
58
47
 
59
- // Anchored on the Sunday closing this week, so the last column is the week in progress.
60
- const mondayIndex = (today.getDay() + 6) % 7
61
- const end = new Date(today.getFullYear(), today.getMonth(), today.getDate() + (6 - mondayIndex))
48
+ // Anchored on the Saturday closing this week, so the last column is the week
49
+ // in progress. Weeks run Sunday to Saturday, which is what the row labels say.
50
+ const end = new Date(
51
+ today.getFullYear(),
52
+ today.getMonth(),
53
+ today.getDate() + (6 - today.getDay())
54
+ )
62
55
  // Calendar arithmetic, never `getTime() - n * DAY_MS`: across a DST change the
63
56
  // millisecond form lands on 23:00 the day before and rotates every row by one.
64
57
  const start = new Date(end.getFullYear(), end.getMonth(), end.getDate() - (weeks * 7 - 1))
@@ -86,21 +79,64 @@ export function buildHeatmap(
86
79
  return rows
87
80
  }
88
81
 
89
- /** Month initial over each column that opens a month. `cellWidth` mirrors the grid's own. */
90
- export function monthRuler(grid: HeatmapCell[][], weeks: number, cellWidth: number): string {
91
- const initials = 'JFMAMJJASOND'
92
- const chars: string[] = Array.from({ length: weeks * cellWidth }, () => ' ')
93
- let previous = ''
82
+ export interface MonthLabel {
83
+ /** 0-based calendar month, so the caller can colour the label like its cells. */
84
+ month: number
85
+ name: string
86
+ /** Cell offset from the left edge of the grid where the name starts. */
87
+ offset: number
88
+ }
89
+
90
+ const MONTH_NAMES = [
91
+ 'Jan',
92
+ 'Feb',
93
+ 'Mar',
94
+ 'Apr',
95
+ 'May',
96
+ 'Jun',
97
+ 'Jul',
98
+ 'Aug',
99
+ 'Sep',
100
+ 'Oct',
101
+ 'Nov',
102
+ 'Dec',
103
+ ]
104
+
105
+ /**
106
+ * One label per month present in the grid, centred over the columns that month
107
+ * occupies. `cellWidth` mirrors the grid's own so the offsets are in the same
108
+ * units the caller pads with.
109
+ *
110
+ * A column belongs to the month of its first recorded day: a week straddling a
111
+ * month boundary has to pick one, and the earlier one is where the column
112
+ * starts on screen.
113
+ */
114
+ export function monthLabels(grid: HeatmapCell[][], weeks: number, cellWidth: number): MonthLabel[] {
115
+ const spans: { end: number; month: number; start: number }[] = []
94
116
  for (let column = 0; column < weeks; column++) {
95
- const day = grid[0]?.[column]?.day
96
- if (day == null || day === '') continue
97
- const month = day.slice(5, 7)
98
- if (month !== previous) {
99
- chars[column * cellWidth] = initials[Number(month) - 1] ?? ' '
100
- previous = month
101
- }
117
+ const day = grid.map((row) => row[column]?.day ?? '').find((key) => key !== '')
118
+ if (day === undefined) continue
119
+ const month = Number(day.slice(5, 7)) - 1
120
+ const last = spans.at(-1)
121
+ if (last !== undefined && last.month === month) last.end = column
122
+ else spans.push({ end: column, month, start: column })
123
+ }
124
+
125
+ const labels: MonthLabel[] = []
126
+ let usedUpTo = 0
127
+ for (const span of spans) {
128
+ const name = MONTH_NAMES[span.month] ?? ''
129
+ const rendered = name.length
130
+ const width = (span.end - span.start + 1) * cellWidth
131
+ const centred = span.start * cellWidth + Math.floor((width - rendered) / 2)
132
+ // Never behind the previous label: a narrow leading month would otherwise
133
+ // centre its name on top of the one before it.
134
+ const offset = Math.max(usedUpTo, centred)
135
+ if (offset + rendered > weeks * cellWidth) continue
136
+ labels.push({ month: span.month, name, offset })
137
+ usedUpTo = offset + rendered + 1
102
138
  }
103
- return chars.join('')
139
+ return labels
104
140
  }
105
141
 
106
142
  export function promptCounts(days: UsageDays): Record<string, number> {
@@ -117,8 +153,6 @@ export interface UsageSummary {
117
153
  modelTotal: number
118
154
  peakPrompts: number
119
155
  promptDays: number
120
- /** Days carrying token data — a shorter span than `promptDays` once pruning starts. */
121
- tokenDays: number
122
156
  tokens: UsageTokens
123
157
  totalPrompts: number
124
158
  }
@@ -137,7 +171,6 @@ export function summarizeDays(days: UsageDays, limit = 6): UsageSummary {
137
171
  const branches: Record<string, number> = {}
138
172
  let peakPrompts = 0
139
173
  let promptDays = 0
140
- let tokenDays = 0
141
174
  let totalPrompts = 0
142
175
 
143
176
  for (const day of Object.values(days)) {
@@ -147,7 +180,6 @@ export function summarizeDays(days: UsageDays, limit = 6): UsageSummary {
147
180
  tokens.output += day.tokens.output
148
181
  tokens.total += day.tokens.total
149
182
 
150
- if (day.tokens.total > 0) tokenDays += 1
151
183
  if (day.prompts > 0) {
152
184
  promptDays += 1
153
185
  totalPrompts += day.prompts
@@ -172,7 +204,6 @@ export function summarizeDays(days: UsageDays, limit = 6): UsageSummary {
172
204
  modelTotal: rankedModels.total,
173
205
  peakPrompts,
174
206
  promptDays,
175
- tokenDays,
176
207
  tokens,
177
208
  totalPrompts,
178
209
  }
@@ -16,8 +16,8 @@ import { spawnDetachedCommand } from '../../platform/daemon-control'
16
16
  * fully round-trip.
17
17
  */
18
18
 
19
- export const HISTORY_VERSION = 1
20
- /** A file that exists but did not parse. Never equal to HISTORY_VERSION, so the save guard refuses it. */
19
+ export const HISTORY_VERSION = 2
20
+ /** A file that exists but did not parse. Below every real version, so the save guard refuses it. */
21
21
  const UNREADABLE_VERSION = -1
22
22
  const ROLLUP_INTERVAL_MS = 20 * 60 * 60 * 1000
23
23
 
@@ -29,14 +29,43 @@ export interface UsageTokens {
29
29
  total: number
30
30
  }
31
31
 
32
+ /**
33
+ * A running sum, its divisor and the largest sample. Enough for an average and a
34
+ * record without keeping every sample — which is also why the pages that read
35
+ * this say "average", never "median": the samples are gone.
36
+ */
37
+ export interface UsageMean {
38
+ count: number
39
+ max: number
40
+ sum: number
41
+ }
42
+
43
+ export interface UsageSession {
44
+ /** ms epoch of the first prompt. */
45
+ first: number
46
+ /** ms epoch of the last prompt. */
47
+ last: number
48
+ prompts: number
49
+ }
50
+
32
51
  export interface UsageDay {
33
52
  /** git branch -> tokens. Recent window only; Codex carries no branch. */
34
53
  branches: Record<string, number>
54
+ /** 24 buckets, prompts per local hour. Empty on a v1 day. */
55
+ hours: number[]
35
56
  /** model id -> tokens. Recent window only. */
36
57
  models: Record<string, number>
58
+ /** Characters per prompt. Empty on a v1 day. */
59
+ promptChars: UsageMean
60
+ /** absolute project path -> prompts. Empty on a v1 day. */
61
+ projects: Record<string, number>
37
62
  /** The only field with full-year coverage, and claude-only. */
38
63
  prompts: number
64
+ /** sessionId -> span. Empty on a v1 day. */
65
+ sessions: Record<string, UsageSession>
39
66
  tokens: UsageTokens
67
+ /** Milliseconds between consecutive assistant turns. Empty on a v1 day. */
68
+ turnMs: UsageMean
40
69
  }
41
70
 
42
71
  /** 'YYYY-MM-DD' in the machine's local calendar -> that day's usage. */
@@ -60,8 +89,65 @@ export function emptyTokens(): UsageTokens {
60
89
  return { cacheRead: 0, cacheWrite: 0, input: 0, output: 0, total: 0 }
61
90
  }
62
91
 
92
+ export const HOURS_IN_DAY = 24
93
+
94
+ export function emptyHours(): number[] {
95
+ return Array.from({ length: HOURS_IN_DAY }, () => 0)
96
+ }
97
+
98
+ export function emptyMean(): UsageMean {
99
+ return { count: 0, max: 0, sum: 0 }
100
+ }
101
+
63
102
  export function emptyDay(): UsageDay {
64
- return { branches: {}, models: {}, prompts: 0, tokens: emptyTokens() }
103
+ return {
104
+ branches: {},
105
+ hours: emptyHours(),
106
+ models: {},
107
+ projects: {},
108
+ promptChars: emptyMean(),
109
+ prompts: 0,
110
+ sessions: {},
111
+ tokens: emptyTokens(),
112
+ turnMs: emptyMean(),
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Fills in what a v1 day does not carry, so every reader sees one shape instead
118
+ * of guarding each field. The filled-in values are empty rather than zeroed
119
+ * measurements — a page that would render them as `0 min` has to check `count`
120
+ * and say "no data" instead.
121
+ */
122
+ function normalizeDay(day: UsageDay): UsageDay {
123
+ const hours = Array.isArray(day.hours) && day.hours.length === HOURS_IN_DAY ? day.hours : null
124
+ if (
125
+ hours !== null &&
126
+ day.projects != null &&
127
+ day.sessions != null &&
128
+ day.promptChars != null &&
129
+ day.turnMs != null
130
+ ) {
131
+ return day
132
+ }
133
+ return {
134
+ ...day,
135
+ hours: hours ?? emptyHours(),
136
+ projects: day.projects ?? {},
137
+ promptChars: day.promptChars ?? emptyMean(),
138
+ sessions: day.sessions ?? {},
139
+ turnMs: day.turnMs ?? emptyMean(),
140
+ }
141
+ }
142
+
143
+ function normalizeTools(tools: UsageTools): UsageTools {
144
+ const normalized: UsageTools = {}
145
+ for (const [tool, days] of Object.entries(tools) as [AIUsageTool, UsageDays][]) {
146
+ const out: UsageDays = {}
147
+ for (const [date, day] of Object.entries(days)) out[date] = normalizeDay(day)
148
+ normalized[tool] = out
149
+ }
150
+ return normalized
65
151
  }
66
152
 
67
153
  /** Resolved per call, not at module scope, so a `HOME` override in tests reaches it. */
@@ -84,7 +170,7 @@ export function readUsageHistory(): UsageHistoryFile {
84
170
  if (typeof file.tools !== 'object' || file.tools === null) {
85
171
  return { tools: {}, version: UNREADABLE_VERSION }
86
172
  }
87
- return { tools: file.tools, version: file.version }
173
+ return { tools: normalizeTools(file.tools), version: file.version }
88
174
  } catch {
89
175
  return { tools: {}, version: UNREADABLE_VERSION }
90
176
  }
@@ -95,11 +181,19 @@ function mergeDay(stored: UsageDay, fresh: UsageDay): UsageDay {
95
181
  // the richer one. `>=` rather than `>` is what makes re-running converge.
96
182
  const keepFresh = fresh.tokens.total >= stored.tokens.total
97
183
  // Prompts merge separately: they outlive the transcripts the tokens came from.
184
+ // Everything derived from `history.jsonl` follows the prompt count for the same
185
+ // reason — that log is append-only, so it is never the pruned side.
186
+ const keepFreshPrompts = fresh.prompts >= stored.prompts
98
187
  return {
99
188
  branches: keepFresh ? fresh.branches : stored.branches,
189
+ hours: keepFreshPrompts ? fresh.hours : stored.hours,
100
190
  models: keepFresh ? fresh.models : stored.models,
191
+ projects: keepFreshPrompts ? fresh.projects : stored.projects,
192
+ promptChars: keepFreshPrompts ? fresh.promptChars : stored.promptChars,
101
193
  prompts: Math.max(fresh.prompts, stored.prompts),
194
+ sessions: keepFreshPrompts ? fresh.sessions : stored.sessions,
102
195
  tokens: keepFresh ? fresh.tokens : stored.tokens,
196
+ turnMs: keepFresh ? fresh.turnMs : stored.turnMs,
103
197
  }
104
198
  }
105
199
 
@@ -124,9 +218,12 @@ export function mergeUsageHistory(stored: UsageTools, fresh: UsageTools): UsageT
124
218
  export function saveUsageHistory(fresh: UsageTools): boolean {
125
219
  const stored = readUsageHistory()
126
220
 
127
- // A newer aimux owns a shape this build cannot round-trip; an unreadable file
128
- // may still hold years this rollup can no longer see. Neither gets written over.
129
- if (stored.version !== HISTORY_VERSION) {
221
+ // Two separate refusals, not one equality check. A newer aimux owns a shape
222
+ // this build cannot round-trip, and an unreadable file may still hold years
223
+ // this rollup can no longer see — neither gets written over. An *older* file
224
+ // is the one case that is safe: this build reads v1 and writes it back as v2,
225
+ // which is how the upgrade happens at all.
226
+ if (stored.version > HISTORY_VERSION || stored.version < 1) {
130
227
  logDebug('usageHistory.refusedWrite', { version: stored.version })
131
228
  return false
132
229
  }
@@ -153,6 +250,22 @@ export function saveUsageHistory(fresh: UsageTools): boolean {
153
250
  }
154
251
  }
155
252
 
253
+ /**
254
+ * Whether what is on disk predates this build's schema.
255
+ *
256
+ * Without this an upgrade waits out the whole interval before the new fields
257
+ * exist, and until then the pages report them as "recorded from the next rollup
258
+ * onward" — which reads as a bug and costs whoever hits it a diagnosis.
259
+ *
260
+ * ponytail: a regex over the raw text rather than a parse. 1.2 MB parses in 6 ms
261
+ * and this runs before the first frame; no `UsageDay` carries a `version` key,
262
+ * so the first match is the file's own. Parse it properly if one ever does.
263
+ */
264
+ export function storedVersionIsStale(raw: string): boolean {
265
+ const version = Number(/"version":\s*(\d+)/.exec(raw)?.[1])
266
+ return Number.isFinite(version) && version < HISTORY_VERSION
267
+ }
268
+
156
269
  /**
157
270
  * Detached because the parse is a second of CPU over hundreds of MB of JSONL.
158
271
  * 20h rather than 24h so opening aimux at the same time each morning does not
@@ -163,8 +276,14 @@ export function maybeSpawnUsageRollup(): void {
163
276
  if (process.env.AIMUX_NO_USAGE_ROLLUP === '1') return
164
277
  // mtime is the last successful rollup: the file is written on success and
165
278
  // nothing else. Beats parsing an ever-growing JSON on the startup path.
166
- const rolledUpAt = statSync(usageHistoryPath(), { throwIfNoEntry: false })?.mtimeMs ?? 0
167
- if (Date.now() - rolledUpAt < ROLLUP_INTERVAL_MS) return
279
+ const path = usageHistoryPath()
280
+ const rolledUpAt = statSync(path, { throwIfNoEntry: false })?.mtimeMs ?? 0
281
+ if (
282
+ Date.now() - rolledUpAt < ROLLUP_INTERVAL_MS &&
283
+ !storedVersionIsStale(readFileSync(path, 'utf8'))
284
+ ) {
285
+ return
286
+ }
168
287
  spawnDetachedCommand('usage-rollup')
169
288
  } catch (error) {
170
289
  logDebug('usageHistory.spawnError', {
@@ -7,17 +7,21 @@ export interface SettingSearchHit {
7
7
  row: SettingRow
8
8
  sectionId: string
9
9
  sectionLabel: string
10
- /** Index within its own section, which is what the cursor is expressed in. */
10
+ /**
11
+ * Position in the screen's list — counted across every section, matches or
12
+ * not, because the screen is one list and that is what its cursor holds. So a
13
+ * search result can be jumped to with the index it already carries.
14
+ */
11
15
  rowIndex: number
12
16
  }
13
17
 
14
18
  /**
15
- * Every setting the query matches, across every section. Ten sections is more
16
- * than anyone will scan, and this is the only list in the app you could not type
17
- * into to find something.
19
+ * Every setting the query matches, across every section. With no query it is
20
+ * the screen's own list, which is why both go through here: one filter, so the
21
+ * list you search and the list you scroll can never disagree.
18
22
  *
19
- * Shared with `getModalOptionCount` so the picker and the reducer that moves its
20
- * cursor count the same list — two filters would drift the moment one changed.
23
+ * Shared with `getModalOptionCount` too, so the picker and the reducer that
24
+ * moves its cursor count the same list.
21
25
  */
22
26
  export function filterSettingRows(
23
27
  projects: readonly ProjectRecord[],
@@ -25,12 +29,12 @@ export function filterSettingRows(
25
29
  ): SettingSearchHit[] {
26
30
  const needle = (query ?? '').trim().toLowerCase()
27
31
  const hits: SettingSearchHit[] = []
32
+ let rowIndex = -1
28
33
 
29
34
  for (const section of SETTING_SECTIONS) {
30
35
  const rows = sectionRows(section, projects)
31
- for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
32
- const row = rows[rowIndex]
33
- if (!row) continue
36
+ for (const row of rows) {
37
+ rowIndex++
34
38
  // The id is in the haystack deliberately: it is the key in `aimux.json`, so
35
39
  // someone who saw it in that file can search for it.
36
40
  const haystack = `${row.label} ${row.description ?? ''} ${row.id} ${section.label}`
@@ -11,6 +11,7 @@ import { settingsStore } from '../settings-store'
11
11
  * files are, and how many settings your own config file is holding onto.
12
12
  */
13
13
  export const ABOUT_SECTION: SettingSection = {
14
+ glyph: '\u{00A7}',
14
15
  id: 'about',
15
16
  label: 'About',
16
17
  rows: [
@@ -10,6 +10,7 @@ import { getCurrentMode, getCurrentThemeId, getTransparent } from '../../ui/them
10
10
  * re-renders them all and these reads never go stale.
11
11
  */
12
12
  export const APPEARANCE_SECTION: SettingSection = {
13
+ glyph: '\u{25D1}',
13
14
  id: 'appearance',
14
15
  label: 'Appearance',
15
16
  rows: [
@@ -15,6 +15,7 @@ export const AUTO_COMMIT_TIMEOUT = 'autoCommit.timeoutMs'
15
15
  * what `restart` puts on the row.
16
16
  */
17
17
  export const AUTOMATION_SECTION: SettingSection = {
18
+ glyph: '\u{27F3}',
18
19
  id: 'automation',
19
20
  label: 'Automation',
20
21
  rows: [
@@ -46,6 +46,7 @@ function autoCommitModelRow(assistant: (typeof MODEL_ASSISTANTS)[number]): Setti
46
46
  }
47
47
 
48
48
  export const COMMANDS_SECTION: SettingSection = {
49
+ glyph: '\u{276F}',
49
50
  id: 'commands',
50
51
  label: 'Commands',
51
52
  rows: [
@@ -11,6 +11,7 @@ const AUTO = 'auto'
11
11
  * really need to be.
12
12
  */
13
13
  export const EDITOR_SECTION: SettingSection = {
14
+ glyph: '\u{270E}',
14
15
  id: 'editor',
15
16
  label: 'Editor',
16
17
  rows: [
@@ -13,6 +13,7 @@ export const ACTIVITY_SPRITES = 'theme.beta.experimentalActivitySprites'
13
13
  */
14
14
  export const EXPERIMENTAL_SECTION: SettingSection = {
15
15
  description: 'Unfinished. These can change behaviour, or go away, in any release.',
16
+ glyph: '\u{2727}',
16
17
  id: 'experimental',
17
18
  label: 'Experimental',
18
19
  rows: [
@@ -20,6 +20,7 @@ function isFileListMode(value: SettingValue): value is GitFileListMode {
20
20
  * `AppState` on change; multi-repo is a module singleton, like auto-commit.
21
21
  */
22
22
  export const GIT_SECTION: SettingSection = {
23
+ glyph: '\u{2387}',
23
24
  id: 'git',
24
25
  label: 'Git',
25
26
  rows: [
@@ -44,8 +44,6 @@ export const ALL_SETTING_ROWS: readonly SettingRow[] = SETTING_SECTIONS.flatMap(
44
44
  Array.isArray(section.rows) ? section.rows : []
45
45
  )
46
46
 
47
- export const DEFAULT_SECTION_ID = SETTING_SECTIONS[0]?.id ?? 'about'
48
-
49
47
  export function getSection(sectionId: string): SettingSection | undefined {
50
48
  return SETTING_SECTIONS.find((section) => section.id === sectionId)
51
49
  }
@@ -70,18 +68,31 @@ export function sectionRowCount(
70
68
  return Array.isArray(section.rows) ? section.rows.length : section.rows(projects).length
71
69
  }
72
70
 
73
- /** Rows of the given section, or an empty list when the id is unknown. */
74
- export function getSectionRows(
75
- sectionId: string,
76
- projects: readonly ProjectRecord[]
77
- ): readonly SettingRow[] {
78
- const section = getSection(sectionId)
79
- return section ? sectionRows(section, projects) : []
71
+ /**
72
+ * How many rows the screen holds in total. The screen is one list, so this is
73
+ * what clamps its cursor — and like `sectionRowCount`, it counts without
74
+ * building, which is what keeps the reducer off the disk.
75
+ */
76
+ export function totalRowCount(projects: readonly ProjectRecord[]): number {
77
+ let total = 0
78
+ for (const section of SETTING_SECTIONS) total += sectionRowCount(section, projects)
79
+ return total
80
80
  }
81
81
 
82
- export function getSectionRowCount(sectionId: string, projects: readonly ProjectRecord[]): number {
83
- const section = getSection(sectionId)
84
- return section ? sectionRowCount(section, projects) : 0
82
+ /**
83
+ * The flat index of each section's first row, in screen order. What `}` and `{`
84
+ * jump between, and the reason they can: an empty section contributes no index,
85
+ * so the cursor never lands on a heading with nothing under it.
86
+ */
87
+ export function sectionStartIndexes(projects: readonly ProjectRecord[]): number[] {
88
+ const starts: number[] = []
89
+ let index = 0
90
+ for (const section of SETTING_SECTIONS) {
91
+ const count = sectionRowCount(section, projects)
92
+ if (count > 0) starts.push(index)
93
+ index += count
94
+ }
95
+ return starts
85
96
  }
86
97
 
87
98
  /** The row with this id, dynamic ones included. */
@@ -6,6 +6,7 @@ import type { SettingSection } from '../types'
6
6
  * take effect before the next launch.
7
7
  */
8
8
  export const INTEGRATIONS_SECTION: SettingSection = {
9
+ glyph: '\u{21C4}',
9
10
  id: 'integrations',
10
11
  label: 'Integrations',
11
12
  rows: [
@@ -10,6 +10,7 @@ import { dispatchGlobal } from '../../state/dispatch-ref'
10
10
  * value it already has.
11
11
  */
12
12
  export const LAYOUT_SECTION: SettingSection = {
13
+ glyph: '\u{25A6}',
13
14
  id: 'layout',
14
15
  label: 'Layout',
15
16
  rows: [
@@ -53,6 +53,7 @@ export function playNotificationSound(): boolean {
53
53
 
54
54
  export const NOTIFICATIONS_SECTION: SettingSection = {
55
55
  description: 'Plays when an assistant needs an answer, or finishes a turn.',
56
+ glyph: '\u{25CE}',
56
57
  id: 'notifications',
57
58
  label: 'Notifications',
58
59
  rows: [
@@ -46,6 +46,7 @@ function setupRow(projectId: string, projectName: string): SettingRow {
46
46
  }
47
47
 
48
48
  export const SETUP_SECTION: SettingSection = {
49
+ glyph: '\u{2726}',
49
50
  id: 'setup',
50
51
  label: 'Setup',
51
52
  rowCount: (projects) => projects.length,
@@ -20,6 +20,7 @@ const SEPARATORS: { value: StatusBarSeparator; label: string }[] = [
20
20
  * worse than no row.
21
21
  */
22
22
  export const STATUS_BAR_SECTION: SettingSection = {
23
+ glyph: '\u{25AC}',
23
24
  id: 'statusBar',
24
25
  label: 'Status bar',
25
26
  rows: [
@@ -36,6 +36,7 @@ function defaultBaseRefRow(project: ProjectRecord): SettingRow {
36
36
  }
37
37
 
38
38
  export const WORKSPACE_SECTION: SettingSection = {
39
+ glyph: '\u{2302}',
39
40
  id: 'workspace',
40
41
  label: 'Workspaces',
41
42
  rowCount: (projects) => projects.length,
@@ -86,6 +86,16 @@ export type SettingRow =
86
86
 
87
87
  export interface SettingSection {
88
88
  id: string
89
+ /**
90
+ * One cell, text presentation, present in the base fonts — the same rule the
91
+ * stats screen's section glyphs follow, so the eye finds a section by shape
92
+ * before it reads the label.
93
+ *
94
+ * Required, and on the section rather than in a lookup keyed by id: a map
95
+ * would need a fallback, and a fallback turns a renamed or added section into
96
+ * a silent placeholder instead of a compile error.
97
+ */
98
+ glyph: string
89
99
  label: string
90
100
  /** Shown once under the section's title, for a caveat that covers every row. */
91
101
  description?: string
@@ -73,8 +73,8 @@ export type ModalAction =
73
73
  | { type: 'set-theme-entry-count'; count: number }
74
74
  | { type: 'open-theme-picker'; returnTo?: FocusMode }
75
75
  | { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
76
+ | { type: 'open-quotas-modal' }
76
77
  | { type: 'set-modal-selection-index'; index: number }
77
- | { type: 'open-ai-usage-modal' }
78
78
  | { type: 'open-workspace-move-modal'; sourceWorkspaceId: string }
79
79
  | { type: 'toggle-workspace-move-delete' }
80
80
  | { type: 'set-workspace-move-stats'; dirtyFiles: Record<string, number> }
@@ -219,13 +219,22 @@ export type UIAction =
219
219
  export type SettingsAction =
220
220
  | { type: 'enter-settings' }
221
221
  | { type: 'exit-settings' }
222
- | { type: 'settings-focus-pane'; pane: 'nav' | 'rows' }
223
222
  | { type: 'settings-move-selection'; delta: -1 | 1 }
224
- | { type: 'settings-select-section'; sectionId: string }
223
+ | { type: 'settings-jump-section'; delta: -1 | 1 }
225
224
  | { type: 'settings-select-row'; rowIndex: number }
226
225
  | { type: 'open-settings-search' }
227
226
  | { type: 'open-setting-text-modal'; settingId: string; label: string; value: string }
228
227
 
228
+ export type StatsAction =
229
+ | { type: 'enter-stats' }
230
+ | { type: 'exit-stats' }
231
+ | { type: 'stats-move-page'; delta: -1 | 1 }
232
+ | { type: 'stats-select-page'; pageIndex: number }
233
+ /** Rows, not pixels: the view holds a scroll offset the page applies to its box. */
234
+ | { type: 'stats-scroll'; delta: number }
235
+ /** The offset the scrollbox actually accepted, sent back so the state cannot run past the page. */
236
+ | { type: 'stats-scroll-settled'; scrollTop: number }
237
+
229
238
  export type GitPanelAction =
230
239
  | { type: 'git-refresh-success'; payload: GitRefreshPayload }
231
240
  | { type: 'git-refresh-error'; kind: GitPanelError }
@@ -335,6 +344,7 @@ export type AppAction =
335
344
  | LayoutAction
336
345
  | UIAction
337
346
  | SettingsAction
347
+ | StatsAction
338
348
  | DataAction
339
349
  | GitPanelAction
340
350
  | GitModeAction