@brimveyn/aimux 1.22.12 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +5 -0
  3. package/src/app-runtime/use-mouse-handlers.ts +2 -0
  4. package/src/app.tsx +6 -0
  5. package/src/index.tsx +6 -0
  6. package/src/input/keymap/help-entries.ts +1 -0
  7. package/src/input/modes/bridge.ts +2 -1
  8. package/src/input/modes/transitions.ts +7 -3
  9. package/src/input/modes/types.ts +2 -1
  10. package/src/restart-daemon.ts +5 -0
  11. package/src/services/ai-usage/projection.ts +44 -0
  12. package/src/services/aimux-counters/index.ts +87 -0
  13. package/src/services/aimux-counters/observe.ts +39 -0
  14. package/src/services/aimux-counters/store.ts +150 -0
  15. package/src/services/aimux-counters/summary.ts +78 -0
  16. package/src/services/usage-history/cost.ts +149 -0
  17. package/src/services/usage-history/insights.ts +314 -0
  18. package/src/services/usage-history/rollup.ts +78 -6
  19. package/src/services/usage-history/stats.ts +66 -35
  20. package/src/services/usage-history/store.ts +128 -9
  21. package/src/settings/sections/about.ts +1 -0
  22. package/src/settings/sections/appearance.ts +1 -0
  23. package/src/settings/sections/automation.ts +1 -0
  24. package/src/settings/sections/commands.ts +1 -0
  25. package/src/settings/sections/editor.ts +1 -0
  26. package/src/settings/sections/experimental.ts +1 -0
  27. package/src/settings/sections/git.ts +1 -0
  28. package/src/settings/sections/integrations.ts +1 -0
  29. package/src/settings/sections/layout.ts +1 -0
  30. package/src/settings/sections/notifications.ts +1 -0
  31. package/src/settings/sections/setup.ts +1 -0
  32. package/src/settings/sections/status-bar.ts +1 -0
  33. package/src/settings/sections/workspace.ts +1 -0
  34. package/src/settings/types.ts +10 -0
  35. package/src/state/actions.ts +12 -1
  36. package/src/state/app-store.ts +12 -1
  37. package/src/state/reducers/modal-state.ts +12 -17
  38. package/src/state/reducers/stats-state.ts +56 -0
  39. package/src/state/stats-pages.ts +33 -0
  40. package/src/state/store.ts +5 -0
  41. package/src/state/types.ts +17 -4
  42. package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
  43. package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
  44. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
  45. package/src/ui/components/settings/settings-view.tsx +6 -3
  46. package/src/ui/components/stats/aimux-page.tsx +245 -0
  47. package/src/ui/components/stats/chart.ts +157 -0
  48. package/src/ui/components/stats/day-facts.tsx +268 -0
  49. package/src/ui/components/stats/format.ts +98 -0
  50. package/src/ui/components/stats/heatmap.tsx +305 -0
  51. package/src/ui/components/stats/projects-page.tsx +293 -0
  52. package/src/ui/components/stats/quotas.tsx +210 -0
  53. package/src/ui/components/stats/shared.tsx +645 -0
  54. package/src/ui/components/stats/stats-view.tsx +153 -0
  55. package/src/ui/components/stats/usage-page.tsx +291 -0
  56. package/src/ui/components/stats/use-stats-data.ts +48 -0
  57. package/src/ui/root.tsx +7 -3
  58. package/src/ui/components/modals/app/ai-usage-modal.tsx +0 -520
@@ -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', {
@@ -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: [
@@ -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> }
@@ -226,6 +226,16 @@ export type SettingsAction =
226
226
  | { type: 'open-settings-search' }
227
227
  | { type: 'open-setting-text-modal'; settingId: string; label: string; value: string }
228
228
 
229
+ export type StatsAction =
230
+ | { type: 'enter-stats' }
231
+ | { type: 'exit-stats' }
232
+ | { type: 'stats-move-page'; delta: -1 | 1 }
233
+ | { type: 'stats-select-page'; pageIndex: number }
234
+ /** Rows, not pixels: the view holds a scroll offset the page applies to its box. */
235
+ | { type: 'stats-scroll'; delta: number }
236
+ /** The offset the scrollbox actually accepted, sent back so the state cannot run past the page. */
237
+ | { type: 'stats-scroll-settled'; scrollTop: number }
238
+
229
239
  export type GitPanelAction =
230
240
  | { type: 'git-refresh-success'; payload: GitRefreshPayload }
231
241
  | { type: 'git-refresh-error'; kind: GitPanelError }
@@ -335,6 +345,7 @@ export type AppAction =
335
345
  | LayoutAction
336
346
  | UIAction
337
347
  | SettingsAction
348
+ | StatsAction
338
349
  | DataAction
339
350
  | GitPanelAction
340
351
  | GitModeAction
@@ -4,6 +4,7 @@ import { createStore } from 'zustand/vanilla'
4
4
  import type { AppAction } from './actions'
5
5
  import type { AppState } from './types'
6
6
 
7
+ import { countAction } from '../services/aimux-counters/observe'
7
8
  import { appReducer, createInitialState } from './store'
8
9
 
9
10
  export interface AppStore extends AppState {
@@ -12,7 +13,17 @@ export interface AppStore extends AppState {
12
13
 
13
14
  export const appStore = createStore<AppStore>((set) => ({
14
15
  ...createInitialState(),
15
- dispatch: (action: AppAction) => set((state) => appReducer(state, action)),
16
+ // Counting happens here rather than inside the reducers, which stay pure —
17
+ // and on the outcome rather than the intent: an `add-tab` or a `split-pane`
18
+ // the reducer declines is not a tab or a split, and counting it would report
19
+ // something that never happened. Outside the `set` updater, which has to stay
20
+ // a pure function of the state it is handed.
21
+ dispatch: (action: AppAction) => {
22
+ const before = appStore.getState()
23
+ const after = appReducer(before, action)
24
+ if (after !== before) countAction(action)
25
+ set(after)
26
+ },
16
27
  }))
17
28
 
18
29
  export function useAppStore<T>(selector: (state: AppStore) => T): T {
@@ -62,9 +62,6 @@ function getCreateWorkspaceBaseOptions(state: AppState, queryOverride?: string):
62
62
  function getModalOptionCount(state: AppState): number {
63
63
  const { modal } = state
64
64
  switch (modal.type) {
65
- // Not a list: the two pages of the AI usage modal, Live and History.
66
- case 'ai-usage':
67
- return 2
68
65
  case 'create-project':
69
66
  return modal.directoryResults.length
70
67
  case 'help':
@@ -145,19 +142,6 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
145
142
  },
146
143
  }
147
144
  }
148
- case 'open-ai-usage-modal': {
149
- return {
150
- ...state,
151
- focusMode: 'modal',
152
- modal: {
153
- cursorPos: 0,
154
- editBuffer: '',
155
- projectTargetId: null,
156
- selectedIndex: 0,
157
- type: 'ai-usage',
158
- },
159
- }
160
- }
161
145
  case 'open-workspace-move-modal': {
162
146
  // Overlay: keep focusMode (git when opened via `m`, navigation when opened
163
147
  // from a tab menu) so the view underneath stays mounted, like the help
@@ -476,6 +460,18 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
476
460
  type: 'theme-picker',
477
461
  },
478
462
  }
463
+ case 'open-quotas-modal':
464
+ return {
465
+ ...state,
466
+ focusMode: 'modal',
467
+ modal: {
468
+ cursorPos: 0,
469
+ editBuffer: null,
470
+ projectTargetId: null,
471
+ selectedIndex: 0,
472
+ type: 'quotas',
473
+ },
474
+ }
479
475
  case 'open-update-available-modal':
480
476
  return {
481
477
  ...state,
@@ -540,7 +536,6 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
540
536
  return { ...state, modal: { ...state.modal, selectedIndex: nextIndex } }
541
537
  }
542
538
  if (
543
- state.modal.type !== 'ai-usage' &&
544
539
  state.modal.type !== 'new-tab' &&
545
540
  state.modal.type !== 'project-picker' &&
546
541
  state.modal.type !== 'snippet-picker' &&