@brimveyn/aimux 1.22.12 → 1.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app-runtime/settings-actions.ts +24 -13
- 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/search.ts +13 -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/index.ts +23 -12
- 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 +13 -3
- package/src/state/app-store.ts +12 -1
- package/src/state/reducers/modal-state.ts +12 -17
- package/src/state/reducers/settings-state.ts +29 -51
- 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 +22 -7
- 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/row-value.tsx +22 -3
- package/src/ui/components/settings/settings-footer.tsx +170 -0
- package/src/ui/components/settings/settings-row.tsx +109 -33
- package/src/ui/components/settings/settings-search-bar.tsx +61 -0
- package/src/ui/components/settings/settings-view.tsx +163 -77
- 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 +654 -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', {
|
package/src/settings/search.ts
CHANGED
|
@@ -7,17 +7,21 @@ export interface SettingSearchHit {
|
|
|
7
7
|
row: SettingRow
|
|
8
8
|
sectionId: string
|
|
9
9
|
sectionLabel: string
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Position in the screen's list — counted across every section, matches or
|
|
12
|
+
* not, because the screen is one list and that is what its cursor holds. So a
|
|
13
|
+
* search result can be jumped to with the index it already carries.
|
|
14
|
+
*/
|
|
11
15
|
rowIndex: number
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
|
-
* Every setting the query matches, across every section.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
19
|
+
* Every setting the query matches, across every section. With no query it is
|
|
20
|
+
* the screen's own list, which is why both go through here: one filter, so the
|
|
21
|
+
* list you search and the list you scroll can never disagree.
|
|
18
22
|
*
|
|
19
|
-
* Shared with `getModalOptionCount` so the picker and the reducer that
|
|
20
|
-
* cursor count the same list
|
|
23
|
+
* Shared with `getModalOptionCount` too, so the picker and the reducer that
|
|
24
|
+
* moves its cursor count the same list.
|
|
21
25
|
*/
|
|
22
26
|
export function filterSettingRows(
|
|
23
27
|
projects: readonly ProjectRecord[],
|
|
@@ -25,12 +29,12 @@ export function filterSettingRows(
|
|
|
25
29
|
): SettingSearchHit[] {
|
|
26
30
|
const needle = (query ?? '').trim().toLowerCase()
|
|
27
31
|
const hits: SettingSearchHit[] = []
|
|
32
|
+
let rowIndex = -1
|
|
28
33
|
|
|
29
34
|
for (const section of SETTING_SECTIONS) {
|
|
30
35
|
const rows = sectionRows(section, projects)
|
|
31
|
-
for (
|
|
32
|
-
|
|
33
|
-
if (!row) continue
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
rowIndex++
|
|
34
38
|
// The id is in the haystack deliberately: it is the key in `aimux.json`, so
|
|
35
39
|
// someone who saw it in that file can search for it.
|
|
36
40
|
const haystack = `${row.label} ${row.description ?? ''} ${row.id} ${section.label}`
|
|
@@ -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: [
|
|
@@ -44,8 +44,6 @@ export const ALL_SETTING_ROWS: readonly SettingRow[] = SETTING_SECTIONS.flatMap(
|
|
|
44
44
|
Array.isArray(section.rows) ? section.rows : []
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
-
export const DEFAULT_SECTION_ID = SETTING_SECTIONS[0]?.id ?? 'about'
|
|
48
|
-
|
|
49
47
|
export function getSection(sectionId: string): SettingSection | undefined {
|
|
50
48
|
return SETTING_SECTIONS.find((section) => section.id === sectionId)
|
|
51
49
|
}
|
|
@@ -70,18 +68,31 @@ export function sectionRowCount(
|
|
|
70
68
|
return Array.isArray(section.rows) ? section.rows.length : section.rows(projects).length
|
|
71
69
|
}
|
|
72
70
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
71
|
+
/**
|
|
72
|
+
* How many rows the screen holds in total. The screen is one list, so this is
|
|
73
|
+
* what clamps its cursor — and like `sectionRowCount`, it counts without
|
|
74
|
+
* building, which is what keeps the reducer off the disk.
|
|
75
|
+
*/
|
|
76
|
+
export function totalRowCount(projects: readonly ProjectRecord[]): number {
|
|
77
|
+
let total = 0
|
|
78
|
+
for (const section of SETTING_SECTIONS) total += sectionRowCount(section, projects)
|
|
79
|
+
return total
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
/**
|
|
83
|
+
* The flat index of each section's first row, in screen order. What `}` and `{`
|
|
84
|
+
* jump between, and the reason they can: an empty section contributes no index,
|
|
85
|
+
* so the cursor never lands on a heading with nothing under it.
|
|
86
|
+
*/
|
|
87
|
+
export function sectionStartIndexes(projects: readonly ProjectRecord[]): number[] {
|
|
88
|
+
const starts: number[] = []
|
|
89
|
+
let index = 0
|
|
90
|
+
for (const section of SETTING_SECTIONS) {
|
|
91
|
+
const count = sectionRowCount(section, projects)
|
|
92
|
+
if (count > 0) starts.push(index)
|
|
93
|
+
index += count
|
|
94
|
+
}
|
|
95
|
+
return starts
|
|
85
96
|
}
|
|
86
97
|
|
|
87
98
|
/** The row with this id, dynamic ones included. */
|
|
@@ -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> }
|
|
@@ -219,13 +219,22 @@ export type UIAction =
|
|
|
219
219
|
export type SettingsAction =
|
|
220
220
|
| { type: 'enter-settings' }
|
|
221
221
|
| { type: 'exit-settings' }
|
|
222
|
-
| { type: 'settings-focus-pane'; pane: 'nav' | 'rows' }
|
|
223
222
|
| { type: 'settings-move-selection'; delta: -1 | 1 }
|
|
224
|
-
| { type: 'settings-
|
|
223
|
+
| { type: 'settings-jump-section'; delta: -1 | 1 }
|
|
225
224
|
| { type: 'settings-select-row'; rowIndex: number }
|
|
226
225
|
| { type: 'open-settings-search' }
|
|
227
226
|
| { type: 'open-setting-text-modal'; settingId: string; label: string; value: string }
|
|
228
227
|
|
|
228
|
+
export type StatsAction =
|
|
229
|
+
| { type: 'enter-stats' }
|
|
230
|
+
| { type: 'exit-stats' }
|
|
231
|
+
| { type: 'stats-move-page'; delta: -1 | 1 }
|
|
232
|
+
| { type: 'stats-select-page'; pageIndex: number }
|
|
233
|
+
/** Rows, not pixels: the view holds a scroll offset the page applies to its box. */
|
|
234
|
+
| { type: 'stats-scroll'; delta: number }
|
|
235
|
+
/** The offset the scrollbox actually accepted, sent back so the state cannot run past the page. */
|
|
236
|
+
| { type: 'stats-scroll-settled'; scrollTop: number }
|
|
237
|
+
|
|
229
238
|
export type GitPanelAction =
|
|
230
239
|
| { type: 'git-refresh-success'; payload: GitRefreshPayload }
|
|
231
240
|
| { type: 'git-refresh-error'; kind: GitPanelError }
|
|
@@ -335,6 +344,7 @@ export type AppAction =
|
|
|
335
344
|
| LayoutAction
|
|
336
345
|
| UIAction
|
|
337
346
|
| SettingsAction
|
|
347
|
+
| StatsAction
|
|
338
348
|
| DataAction
|
|
339
349
|
| GitPanelAction
|
|
340
350
|
| GitModeAction
|