@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,153 @@
1
+ import type { ScrollBoxRenderable } from '@opentui/core'
2
+
3
+ import { useTerminalDimensions } from '@opentui/react'
4
+ import { memo, useCallback, useEffect, useRef } from 'react'
5
+
6
+ import { useAppStore } from '../../../state/app-store'
7
+ import { clampBarWidth } from '../../../state/bars'
8
+ import { dispatchGlobal } from '../../../state/dispatch-ref'
9
+ import { STATS_PAGES, statsPageAt } from '../../../state/stats-pages'
10
+ import { useTheme } from '../../theme'
11
+ import { ListItem } from '../primitives/list-item'
12
+ import { AimuxPage } from './aimux-page'
13
+ import { ProjectsPage } from './projects-page'
14
+ import { UsagePage } from './usage-page'
15
+ import { useStatsData } from './use-stats-data'
16
+
17
+ const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
18
+
19
+ /**
20
+ * The stats screen.
21
+ *
22
+ * Same shape as the settings screen — a nav column exactly where the left bar
23
+ * stands, so opening it does not shift the column under the cursor, and a
24
+ * bordered content pane where the terminal would be. Read-only: nothing here
25
+ * dispatches anything but page selection.
26
+ */
27
+ export const StatsView = memo(function StatsView() {
28
+ const t = useTheme()
29
+ const stats = useAppStore((s) => s.stats)
30
+ const navWidth = useAppStore((s) => clampBarWidth(s.bars.left.width))
31
+ const dimensions = useTerminalDimensions()
32
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null)
33
+ const data = useStatsData()
34
+
35
+ const page = statsPageAt(stats.pageIndex)
36
+
37
+ // The reducer owns the offset but cannot know how tall the page renders, so
38
+ // the scrollbox is what actually bounds it: push the requested offset in, read
39
+ // back what it accepted, and tell the reducer. Without the read-back the
40
+ // offset keeps climbing past the bottom of the page and the way up is a dead
41
+ // zone as long as the overshoot.
42
+ useEffect(() => {
43
+ const box = scrollRef.current
44
+ if (box === null) return
45
+ box.scrollTop = stats.scrollTop
46
+ // A box that has not been laid out yet reports no height, and its clamp
47
+ // would snap every offset to zero. Wait for the frame that measures it.
48
+ if (box.scrollHeight <= 0) return
49
+ if (box.scrollTop !== stats.scrollTop) {
50
+ dispatchGlobal({ scrollTop: box.scrollTop, type: 'stats-scroll-settled' })
51
+ }
52
+ }, [stats.scrollTop, stats.pageIndex])
53
+
54
+ const handlePageClick = useCallback((index: number) => {
55
+ dispatchGlobal({ pageIndex: index, type: 'stats-select-page' })
56
+ }, [])
57
+
58
+ const handleClose = useCallback(() => {
59
+ dispatchGlobal({ type: 'exit-stats' })
60
+ }, [])
61
+
62
+ // Same 1-cell seam the bar draws between itself and the terminal, so the two
63
+ // views line up to the column.
64
+ const navContentWidth = Math.max(1, navWidth - 1)
65
+ const contentWidth = Math.max(24, dimensions.width - navWidth - 2)
66
+
67
+ let body = null
68
+ if (data === null) {
69
+ body = (
70
+ <box paddingLeft={2}>
71
+ <text fg={t.textMuted} selectable={false}>
72
+ reading history…
73
+ </text>
74
+ </box>
75
+ )
76
+ } else {
77
+ switch (page.id) {
78
+ case 'usage':
79
+ body = <UsagePage data={data} width={contentWidth} />
80
+ break
81
+ case 'projects':
82
+ body = <ProjectsPage data={data} width={contentWidth} />
83
+ break
84
+ case 'aimux':
85
+ body = <AimuxPage data={data} width={contentWidth} />
86
+ break
87
+ default:
88
+ page.id satisfies never
89
+ }
90
+ }
91
+
92
+ return (
93
+ <box flexDirection="row" flexGrow={1} overflow="hidden">
94
+ <box width={navWidth} flexDirection="row" overflow="hidden" backgroundColor={t.background}>
95
+ <box width={navContentWidth} flexDirection="column" overflow="hidden">
96
+ <box paddingLeft={1} paddingRight={1}>
97
+ <text fg={t.textMuted}>Stats</text>
98
+ </box>
99
+ <box flexGrow={1} flexShrink={1} flexDirection="column" overflow="hidden">
100
+ {STATS_PAGES.map((entry, index) => (
101
+ <ListItem
102
+ key={entry.id}
103
+ active={index === stats.pageIndex}
104
+ index={index}
105
+ onClickIndex={handlePageClick}
106
+ title={
107
+ <text fg={index === stats.pageIndex ? t.text : t.textMuted}>
108
+ {`${entry.glyph} ${entry.label}`}
109
+ </text>
110
+ }
111
+ trailing={index === stats.pageIndex ? <text fg={t.primary}>›</text> : undefined}
112
+ />
113
+ ))}
114
+ </box>
115
+ <box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
116
+ <text fg={t.textMuted} onMouseDown={handleClose}>
117
+ ‹ Close
118
+ </text>
119
+ </box>
120
+ </box>
121
+ <box width={1} flexShrink={0} backgroundColor={t.border} />
122
+ </box>
123
+ {/* The pane the terminal would be in, with the same border — the content
124
+ keeps the inset it had instead of jumping to the edge of the screen. */}
125
+ <box
126
+ border
127
+ borderColor={t.borderActive}
128
+ title={`${page.glyph} ${page.label}`}
129
+ padding={0}
130
+ flexDirection="column"
131
+ flexGrow={1}
132
+ backgroundColor={t.background}
133
+ >
134
+ {data?.unreadable === true ? (
135
+ <box paddingLeft={2} flexShrink={0}>
136
+ <text fg={t.error} selectable={false}>
137
+ usage-history.json did not parse — nothing is being recorded over it
138
+ </text>
139
+ </box>
140
+ ) : null}
141
+ <scrollbox
142
+ ref={scrollRef}
143
+ scrollY
144
+ flexGrow={1}
145
+ flexShrink={1}
146
+ contentOptions={COLUMN_CONTENT_OPTIONS}
147
+ >
148
+ {body}
149
+ </scrollbox>
150
+ </box>
151
+ </box>
152
+ )
153
+ })
@@ -0,0 +1,291 @@
1
+ import { dayCost, formatUsd, totalCost } from '../../../services/usage-history/cost'
2
+ import {
3
+ daysBetween,
4
+ hourTotals,
5
+ lastDays,
6
+ parseDayKey,
7
+ streaks,
8
+ typicalDay,
9
+ weekdayTotals,
10
+ } from '../../../services/usage-history/insights'
11
+ import { summarizeDays } from '../../../services/usage-history/stats'
12
+ import { formatCompact } from '../../format-number'
13
+ import { chartColumns, fitShape } from './chart'
14
+ import { formatClock, formatCount, formatPercent, weeklyLabels } from './format'
15
+ import { Heatmap, HeatmapLegend, heatmapWidth, useHeatmapRamp } from './heatmap'
16
+ import { QuotaSection } from './quotas'
17
+ import {
18
+ BarRow,
19
+ GLYPH,
20
+ Muted,
21
+ pageLayout,
22
+ type PageTile,
23
+ Section,
24
+ StatsPage,
25
+ TwoColumn,
26
+ VBarChart,
27
+ } from './shared'
28
+ import { isEmpty, type StatsData } from './use-stats-data'
29
+
30
+ /**
31
+ * Usage — quotas and the shape of the work, on one page.
32
+ *
33
+ * Quotas and activity were two pages answering one question: how much am I
34
+ * using this. Splitting them meant looking at a gauge on one screen and at the
35
+ * days that filled it on another.
36
+ *
37
+ * There are no summary tables here. A table earns its place when several
38
+ * unrelated numbers have to be read against each other; a row of totals nobody
39
+ * compares is just a border drawn around four numbers, and the same numbers sit
40
+ * better on the headline row or in the charts that show how they got there.
41
+ */
42
+
43
+ /** Sunday first, like the calendar's rows — one week order on the page. */
44
+ const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
45
+
46
+ /**
47
+ * Every fourth hour, at module scope so the ruler is not a fresh array each frame.
48
+ *
49
+ * Six labels rather than twenty-four: the reader is looking for where the mass
50
+ * of the day sits, and a number under every column would draw more ink than the
51
+ * columns do.
52
+ */
53
+ const HOUR_LABELS = Array.from({ length: 24 }, (_, hour) =>
54
+ hour % 4 === 0 ? String(hour).padStart(2, '0') : ''
55
+ )
56
+ /** Tall enough to show the shape of a day, short enough to sit under Quotas. */
57
+ const HOUR_HEIGHT = 6
58
+
59
+ /**
60
+ * `claude-haiku-4-5-20251001` as `haiku-4-5`. The vendor prefix is the same on
61
+ * every row and the release date is a build stamp, not something a reader is
62
+ * comparing models on.
63
+ */
64
+ function modelLabel(model: string): string {
65
+ return model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
66
+ }
67
+
68
+ /** Notional cost per day over the last `span` recorded days. */
69
+ function recentBurn(data: StatsData, span = 7): { cost: number; days: number } {
70
+ let cost = 0
71
+ let days = 0
72
+ for (const [key, day] of Object.entries(data.claude)) {
73
+ if (daysBetween(parseDayKey(key), data.todayDate) >= span) continue
74
+ days += 1
75
+ cost += dayCost(day).total
76
+ }
77
+ return { cost: days === 0 ? 0 : cost / days, days }
78
+ }
79
+
80
+ function monthCost(data: StatsData): number {
81
+ const prefix = data.today.slice(0, 7)
82
+ let cost = 0
83
+ for (const [key, day] of Object.entries(data.claude)) {
84
+ if (key.startsWith(prefix)) cost += dayCost(day).total
85
+ }
86
+ return cost
87
+ }
88
+
89
+ function usageTiles(prompts: number, perDay: number, streak: number, month: number): PageTile[] {
90
+ return [
91
+ { glyph: GLYPH.calendar, label: 'Prompts', value: formatCount(prompts) },
92
+ { glyph: GLYPH.clock, label: 'Per day', value: formatCount(perDay) },
93
+ { glyph: GLYPH.streak, label: 'Streak', value: `${streak}d` },
94
+ { glyph: GLYPH.cost, label: 'Month', value: `${formatUsd(month)} est.` },
95
+ ]
96
+ }
97
+
98
+ export function UsagePage({ data, width }: { data: StatsData; width: number }) {
99
+ const ramp = useHeatmapRamp()
100
+ const now = new Date()
101
+
102
+ const { split, usable } = pageLayout(width)
103
+ const { leftWidth, rightWidth } = split
104
+
105
+ const summary = summarizeDays(data.claude)
106
+ const codexSummary = summarizeDays(data.codex)
107
+ const streak = streaks(data.claude, data.todayDate)
108
+ const hours = hourTotals(data.claude)
109
+ const weekdays = weekdayTotals(data.claude)
110
+ const typical = typicalDay(data.claude)
111
+
112
+ const claudeCost = totalCost(data.claude)
113
+ const codexCost = totalCost(data.codex)
114
+ const month = monthCost(data)
115
+ const burn = recentBurn(data)
116
+
117
+ const average =
118
+ summary.promptDays === 0 ? 0 : Math.round(summary.totalPrompts / summary.promptDays)
119
+ const hoursTotal = hours.reduce((sum, value) => sum + value, 0)
120
+ const lateHours = hours.slice(22).reduce((sum, value) => sum + value, 0)
121
+ const peakHour = hours.indexOf(Math.max(...hours))
122
+ const weekendPrompts = (weekdays[0] ?? 0) + (weekdays[6] ?? 0)
123
+ const weekTotal = weekdays.reduce((sum, value) => sum + value, 0)
124
+ const weekdayMax = Math.max(...weekdays)
125
+ const modelMax = summary.models[0]?.[1] ?? 0
126
+
127
+ // Both charts reserve the same eight columns for the axis and its gutter, so
128
+ // the hours and the days start on the same column of their sections.
129
+ const hourShape = fitShape(hours.length, leftWidth - 8)
130
+ const chartDays = chartColumns(usable - 8)
131
+ const dailyTokens = lastDays(data.claude, chartDays, data.todayDate, (day) => day.tokens.total)
132
+ const chartLabels = weeklyLabels(chartDays, data.todayDate)
133
+
134
+ const hasHistory = !isEmpty(data.claude)
135
+ const tiles = usageTiles(summary.totalPrompts, average, streak.current, month)
136
+
137
+ // The calendar facts, as one muted line rather than a table: nobody reads
138
+ // "busiest day" against "longest gap", they just want to know each.
139
+ const calendarFacts = [
140
+ `${formatCount(summary.promptDays)} active days`,
141
+ summary.peakPrompts === 0 ? '' : `busiest ${formatCount(summary.peakPrompts)}`,
142
+ streak.longest === 0 ? '' : `best streak ${streak.longest}d`,
143
+ streak.longestGapDays === 0 ? '' : `longest gap ${streak.longestGapDays}d`,
144
+ ]
145
+ .filter((part) => part !== '')
146
+ .join(' \u{00B7} ')
147
+
148
+ // Both tools, and what the estimate does not cover. Every Codex model is
149
+ // unpriced today, so reporting its cost as `$0.00` would present the absence
150
+ // of a published rate as a measurement of zero.
151
+ const unpriced = claudeCost.unpricedTokens + codexCost.unpricedTokens
152
+ const costFacts = [
153
+ `${formatCompact(summary.tokens.total + codexSummary.tokens.total)} tokens`,
154
+ `${formatUsd(claudeCost.total + codexCost.total)} estimated`,
155
+ `${formatUsd(claudeCost.saved + codexCost.saved)} saved by cache`,
156
+ unpriced === 0 ? '' : `${formatCompact(unpriced)} at no published price`,
157
+ 'not metered',
158
+ ]
159
+ .filter((part) => part !== '')
160
+ .join(' \u{00B7} ')
161
+
162
+ const left = (
163
+ <>
164
+ <QuotaSection now={now} width={leftWidth} />
165
+
166
+ <Section
167
+ glyph={GLYPH.clock}
168
+ title="Time of day"
169
+ note={hoursTotal === 0 ? '' : `peak ${formatClock(peakHour * 60)}`}
170
+ width={leftWidth}
171
+ >
172
+ {hoursTotal === 0 ? (
173
+ <Muted>recorded from the next rollup onward</Muted>
174
+ ) : (
175
+ <>
176
+ <VBarChart
177
+ bar={hourShape}
178
+ format={formatCompact}
179
+ height={HOUR_HEIGHT}
180
+ labels={HOUR_LABELS}
181
+ values={hours}
182
+ />
183
+ <Muted>
184
+ {[
185
+ typical.days === 0
186
+ ? ''
187
+ : `typical day ${formatClock(typical.startMinutes)} \u{2192} ${formatClock(typical.endMinutes)}`,
188
+ `${formatPercent(lateHours, hoursTotal)} after 22:00`,
189
+ ]
190
+ .filter((part) => part !== '')
191
+ .join(' \u{00B7} ')}
192
+ </Muted>
193
+ </>
194
+ )}
195
+ </Section>
196
+ </>
197
+ )
198
+
199
+ const right = (
200
+ <>
201
+ <Section
202
+ glyph={GLYPH.week}
203
+ title="Week"
204
+ note={weekTotal === 0 ? '' : `weekends ${formatPercent(weekendPrompts, weekTotal)}`}
205
+ width={rightWidth}
206
+ >
207
+ {WEEKDAY_LABELS.map((label, index) => (
208
+ <BarRow
209
+ key={label}
210
+ label={label}
211
+ max={weekdayMax}
212
+ value={weekdays[index] ?? 0}
213
+ valueText={formatCount(weekdays[index] ?? 0)}
214
+ width={rightWidth}
215
+ />
216
+ ))}
217
+ </Section>
218
+
219
+ <Section
220
+ glyph={GLYPH.models}
221
+ title="Models"
222
+ note={formatCompact(summary.modelTotal)}
223
+ width={rightWidth}
224
+ >
225
+ {summary.models.length === 0 ? (
226
+ <Muted>no model attribution recorded</Muted>
227
+ ) : (
228
+ summary.models.map(([model, value]) => (
229
+ <BarRow
230
+ key={model}
231
+ label={modelLabel(model)}
232
+ max={modelMax}
233
+ value={value}
234
+ valueText={formatCompact(value)}
235
+ width={rightWidth}
236
+ />
237
+ ))
238
+ )}
239
+ </Section>
240
+ </>
241
+ )
242
+
243
+ return (
244
+ <StatsPage tiles={tiles} usable={usable}>
245
+ {hasHistory ? (
246
+ <Section
247
+ glyph={GLYPH.calendar}
248
+ title="Activity"
249
+ note={<HeatmapLegend ramp={ramp} />}
250
+ rule={false}
251
+ // The calendar's own width, not the pane's: the legend belongs over
252
+ // the grid's top-right corner, and the grid is only as wide as the
253
+ // history is long. This section draws no rule, so nothing else uses it.
254
+ width={heatmapWidth(data.claude, data.todayDate, usable)}
255
+ >
256
+ <Heatmap
257
+ days={data.claude}
258
+ ramp={ramp}
259
+ summary={calendarFacts}
260
+ today={data.todayDate}
261
+ width={usable}
262
+ />
263
+ </Section>
264
+ ) : (
265
+ <Muted>no history yet — the first rollup runs in the background</Muted>
266
+ )}
267
+
268
+ <TwoColumn split={split}>
269
+ {left}
270
+ {right}
271
+ </TwoColumn>
272
+
273
+ {hasHistory ? (
274
+ <Section
275
+ glyph={GLYPH.tokens}
276
+ title="Tokens"
277
+ note={burn.days === 0 ? '' : `${formatUsd(burn.cost)} a day`}
278
+ width={usable}
279
+ >
280
+ <VBarChart
281
+ caption={`last ${chartDays} days`}
282
+ format={formatCompact}
283
+ labels={chartLabels}
284
+ values={dailyTokens}
285
+ />
286
+ <Muted>{costFacts}</Muted>
287
+ </Section>
288
+ ) : null}
289
+ </StatsPage>
290
+ )
291
+ }
@@ -0,0 +1,48 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ import { type CounterDays, readCounters } from '../../../services/aimux-counters/store'
4
+ import { localDay, readUsageHistory, type UsageDays } from '../../../services/usage-history/store'
5
+
6
+ export interface StatsData {
7
+ claude: UsageDays
8
+ codex: UsageDays
9
+ counters: CounterDays
10
+ /** The history file exists but did not parse, so every number here is missing one. */
11
+ unreadable: boolean
12
+ /** Local day key for "today", captured once so every page agrees on it. */
13
+ today: string
14
+ todayDate: Date
15
+ }
16
+
17
+ /**
18
+ * Reads both on-disk files once, when the screen opens.
19
+ *
20
+ * Synchronous and small: the history file is ~100 KB after a full year and the
21
+ * counters file is a few KB. Re-reading on every page change would reparse them
22
+ * for nothing, so the read is tied to the screen, not to the page.
23
+ */
24
+ export function useStatsData(): StatsData | null {
25
+ const [data, setData] = useState<StatsData | null>(null)
26
+
27
+ useEffect(() => {
28
+ const history = readUsageHistory()
29
+ const todayDate = new Date()
30
+ setData({
31
+ claude: history.tools.claude ?? {},
32
+ codex: history.tools.codex ?? {},
33
+ counters: readCounters().days,
34
+ today: localDay(todayDate),
35
+ todayDate,
36
+ // Below every real version: the file is there and unparseable, which also
37
+ // means the rollup is refusing to write over it. Silent until now.
38
+ unreadable: history.version < 1,
39
+ })
40
+ }, [])
41
+
42
+ return data
43
+ }
44
+
45
+ /** True when nothing has been recorded yet, so a page can say so instead of rendering zeros. */
46
+ export function isEmpty(days: UsageDays): boolean {
47
+ return Object.keys(days).length === 0
48
+ }
package/src/ui/root.tsx CHANGED
@@ -25,8 +25,8 @@ import { SplitLayout } from './components/layout/split-layout'
25
25
  import { StatusBar } from './components/layout/status-bar'
26
26
  import { TerminalPane } from './components/layout/terminal-pane'
27
27
  import { TopTabBar } from './components/layout/top-tab-bar'
28
- import { AIUsageModal } from './components/modals/app/ai-usage-modal'
29
28
  import { HelpModal } from './components/modals/app/help-modal'
29
+ import { QuotasModal } from './components/modals/app/quotas-modal'
30
30
  import { UpdateAvailableModal } from './components/modals/app/update-available-modal'
31
31
  import { GitCommitModal } from './components/modals/git/git-commit-modal'
32
32
  import { CreateProjectModal } from './components/modals/projects/create-project-modal'
@@ -45,6 +45,7 @@ import { ContextMenuOverlay } from './components/overlays/context-menu/context-m
45
45
  import { PendingChordOverlay } from './components/overlays/pending-chord-overlay'
46
46
  import { ToastViewport } from './components/overlays/toast/toast-viewport'
47
47
  import { SettingsView } from './components/settings/settings-view'
48
+ import { StatsView } from './components/stats/stats-view'
48
49
  import { useTheme } from './theme'
49
50
 
50
51
  const EMPTY_WORKSPACES: WorkspaceRecord[] = []
@@ -206,6 +207,8 @@ function renderModal(
206
207
  cursorPos={modal.cursorPos}
207
208
  />
208
209
  )
210
+ case 'quotas':
211
+ return <QuotasModal />
209
212
  case 'update-available':
210
213
  return (
211
214
  <UpdateAvailableModal
@@ -248,8 +251,6 @@ function renderModal(
248
251
  cursorPos={modal.cursorPos}
249
252
  />
250
253
  )
251
- case 'ai-usage':
252
- return <AIUsageModal page={modal.selectedIndex} />
253
254
  case 'workspace-delete-confirm':
254
255
  return (
255
256
  <WorkspaceDeleteConfirm
@@ -434,6 +435,9 @@ export function RootView({
434
435
  let replacesPanes: ReactNode = null
435
436
  if (inGitMode) replacesPanes = <GitView themeId={themeId} />
436
437
  else if (inSettings) replacesPanes = <SettingsView />
438
+ // Read-only, so unlike settings it has no modal that belongs to it and no
439
+ // second condition: the screen is up exactly while focus is on it.
440
+ else if (focusMode === 'stats') replacesPanes = <StatsView />
437
441
 
438
442
  const center =
439
443
  replacesPanes !== null ? (