@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.
- 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
|
@@ -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(
|
|
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 (
|
|
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
|
|
60
|
-
|
|
61
|
-
const end = new Date(
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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[
|
|
96
|
-
if (day
|
|
97
|
-
const month = day.slice(5, 7)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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 =
|
|
20
|
-
/** A file that exists but did not parse.
|
|
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 {
|
|
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
|
-
//
|
|
128
|
-
//
|
|
129
|
-
|
|
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
|
|
167
|
-
|
|
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', {
|
|
@@ -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: [
|
|
@@ -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: [
|
|
@@ -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: [
|
package/src/settings/types.ts
CHANGED
|
@@ -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
|
package/src/state/actions.ts
CHANGED
|
@@ -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
|
package/src/state/app-store.ts
CHANGED
|
@@ -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
|
-
|
|
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' &&
|