@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.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +5 -0
- package/src/app-runtime/use-mouse-handlers.ts +2 -0
- package/src/app.tsx +6 -0
- package/src/index.tsx +6 -0
- package/src/input/keymap/help-entries.ts +1 -0
- package/src/input/modes/bridge.ts +2 -1
- package/src/input/modes/transitions.ts +7 -3
- package/src/input/modes/types.ts +2 -1
- package/src/restart-daemon.ts +5 -0
- package/src/services/ai-usage/projection.ts +44 -0
- package/src/services/aimux-counters/index.ts +87 -0
- package/src/services/aimux-counters/observe.ts +39 -0
- package/src/services/aimux-counters/store.ts +150 -0
- package/src/services/aimux-counters/summary.ts +78 -0
- package/src/services/usage-history/cost.ts +149 -0
- package/src/services/usage-history/insights.ts +314 -0
- package/src/services/usage-history/rollup.ts +78 -6
- package/src/services/usage-history/stats.ts +66 -35
- package/src/services/usage-history/store.ts +128 -9
- package/src/settings/sections/about.ts +1 -0
- package/src/settings/sections/appearance.ts +1 -0
- package/src/settings/sections/automation.ts +1 -0
- package/src/settings/sections/commands.ts +1 -0
- package/src/settings/sections/editor.ts +1 -0
- package/src/settings/sections/experimental.ts +1 -0
- package/src/settings/sections/git.ts +1 -0
- package/src/settings/sections/integrations.ts +1 -0
- package/src/settings/sections/layout.ts +1 -0
- package/src/settings/sections/notifications.ts +1 -0
- package/src/settings/sections/setup.ts +1 -0
- package/src/settings/sections/status-bar.ts +1 -0
- package/src/settings/sections/workspace.ts +1 -0
- package/src/settings/types.ts +10 -0
- package/src/state/actions.ts +12 -1
- package/src/state/app-store.ts +12 -1
- package/src/state/reducers/modal-state.ts +12 -17
- package/src/state/reducers/stats-state.ts +56 -0
- package/src/state/stats-pages.ts +33 -0
- package/src/state/store.ts +5 -0
- package/src/state/types.ts +17 -4
- package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
- package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
- package/src/ui/components/settings/settings-view.tsx +6 -3
- package/src/ui/components/stats/aimux-page.tsx +245 -0
- package/src/ui/components/stats/chart.ts +157 -0
- package/src/ui/components/stats/day-facts.tsx +268 -0
- package/src/ui/components/stats/format.ts +98 -0
- package/src/ui/components/stats/heatmap.tsx +305 -0
- package/src/ui/components/stats/projects-page.tsx +293 -0
- package/src/ui/components/stats/quotas.tsx +210 -0
- package/src/ui/components/stats/shared.tsx +645 -0
- package/src/ui/components/stats/stats-view.tsx +153 -0
- package/src/ui/components/stats/usage-page.tsx +291 -0
- package/src/ui/components/stats/use-stats-data.ts +48 -0
- package/src/ui/root.tsx +7 -3
- 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 ? (
|