@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,210 @@
|
|
|
1
|
+
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { UsagePaceStage, UsageSnapshot, UsageWindow } from '../../../services/ai-usage/types'
|
|
5
|
+
import type { ResolvedTuiTheme } from '../../themes'
|
|
6
|
+
|
|
7
|
+
import { projectWindow } from '../../../services/ai-usage/projection'
|
|
8
|
+
import { useAIUsageStore } from '../../../state/ai-usage-store'
|
|
9
|
+
import { useTheme } from '../../theme'
|
|
10
|
+
import { truncate } from '../../truncate'
|
|
11
|
+
import { formatClock } from './format'
|
|
12
|
+
import { Bar, GLYPH, Muted, Section } from './shared'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The live quota block.
|
|
16
|
+
*
|
|
17
|
+
* The one place on the stats screen where a gauge is honest: a quota window is a
|
|
18
|
+
* real fraction of a real ceiling, so the bar means what a bar is supposed to
|
|
19
|
+
* mean. Status colours are reserved for these bars and appear nowhere else.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const TOOL_TITLE: Record<AIUsageTool, string> = { claude: 'Claude', codex: 'Codex' }
|
|
23
|
+
const TOOLS: AIUsageTool[] = ['claude', 'codex']
|
|
24
|
+
|
|
25
|
+
const LABEL_WIDTH = 9
|
|
26
|
+
const PERCENT_WIDTH = 5
|
|
27
|
+
const SEGMENTS = 16
|
|
28
|
+
|
|
29
|
+
/** Behind is a warning, ahead is fine, on-track is neither — a state, so a status colour. */
|
|
30
|
+
function paceColor(stage: UsagePaceStage, t: ResolvedTuiTheme): string {
|
|
31
|
+
if (stage === 'behind' || stage === 'farBehind' || stage === 'slightlyBehind') return t.warning
|
|
32
|
+
if (stage === 'ahead' || stage === 'farAhead' || stage === 'slightlyAhead') return t.success
|
|
33
|
+
return t.textMuted
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatRelative(iso: string, now: number): string {
|
|
37
|
+
const diffMs = now - new Date(iso).getTime()
|
|
38
|
+
if (!Number.isFinite(diffMs) || diffMs < 0) return 'just now'
|
|
39
|
+
const s = Math.floor(diffMs / 1000)
|
|
40
|
+
if (s < 60) return `${s}s ago`
|
|
41
|
+
const m = Math.floor(s / 60)
|
|
42
|
+
if (m < 60) return `${m}m ago`
|
|
43
|
+
const h = Math.floor(m / 60)
|
|
44
|
+
if (h < 24) return `${h}h ago`
|
|
45
|
+
return `${Math.floor(h / 24)}d ago`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `14:20` today, `Thu 14:20` later in the week — enough to act on, no more. */
|
|
49
|
+
function formatProjectedTime(at: Date, now: Date): string {
|
|
50
|
+
const clock = formatClock(at.getHours() * 60 + at.getMinutes())
|
|
51
|
+
if (at.toDateString() === now.toDateString()) return clock
|
|
52
|
+
return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][at.getDay()] ?? ''} ${clock}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One window on one line: label, gauge, percent, and whatever context fits in
|
|
57
|
+
* what is left. The pace takes a second line only when there is a pace to
|
|
58
|
+
* report, so an ordinary window costs one row rather than three.
|
|
59
|
+
*/
|
|
60
|
+
function WindowRow({
|
|
61
|
+
now,
|
|
62
|
+
projection,
|
|
63
|
+
width,
|
|
64
|
+
window,
|
|
65
|
+
}: {
|
|
66
|
+
now: Date
|
|
67
|
+
projection: boolean
|
|
68
|
+
width: number
|
|
69
|
+
window: UsageWindow
|
|
70
|
+
}) {
|
|
71
|
+
const t = useTheme()
|
|
72
|
+
const percent = window.percent ?? 0
|
|
73
|
+
|
|
74
|
+
let barColor = t.success
|
|
75
|
+
if (window.percent !== null) {
|
|
76
|
+
if (percent >= 85) barColor = t.error
|
|
77
|
+
else if (percent >= 60) barColor = t.warning
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const meta: string[] = []
|
|
81
|
+
if (window.timeRemaining != null && window.timeRemaining !== '') {
|
|
82
|
+
meta.push(`resets in ${window.timeRemaining}`)
|
|
83
|
+
}
|
|
84
|
+
if (projection) {
|
|
85
|
+
const verdict = projectWindow(window, now)
|
|
86
|
+
if (verdict.kind === 'exhausted') meta.push(`empty ~${formatProjectedTime(verdict.at, now)}`)
|
|
87
|
+
}
|
|
88
|
+
const metaRoom = width - LABEL_WIDTH - SEGMENTS - PERCENT_WIDTH - 1
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<box flexDirection="column" flexShrink={0}>
|
|
92
|
+
<box flexDirection="row" flexShrink={0}>
|
|
93
|
+
<box width={LABEL_WIDTH} flexShrink={0}>
|
|
94
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
95
|
+
{truncate(window.label, LABEL_WIDTH - 1)}
|
|
96
|
+
</text>
|
|
97
|
+
</box>
|
|
98
|
+
<Bar color={barColor} max={100} segments={SEGMENTS} value={Math.min(100, percent)} />
|
|
99
|
+
<text fg={t.text} selectable={false} wrapMode="none">
|
|
100
|
+
{(window.percent === null ? '—' : `${Math.round(percent)}%`).padStart(PERCENT_WIDTH)}
|
|
101
|
+
</text>
|
|
102
|
+
{meta.length > 0 && metaRoom > 8 ? (
|
|
103
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
104
|
+
{` \u{00B7} ${truncate(meta.join(' \u{00B7} '), metaRoom - 3)}`}
|
|
105
|
+
</text>
|
|
106
|
+
) : null}
|
|
107
|
+
</box>
|
|
108
|
+
{window.pace ? (
|
|
109
|
+
<box paddingLeft={LABEL_WIDTH} flexShrink={0}>
|
|
110
|
+
<text fg={paceColor(window.pace.stage, t)} selectable={false} wrapMode="none">
|
|
111
|
+
{truncate(window.pace.label, width - LABEL_WIDTH)}
|
|
112
|
+
</text>
|
|
113
|
+
</box>
|
|
114
|
+
) : null}
|
|
115
|
+
</box>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ToolQuotas({
|
|
120
|
+
now,
|
|
121
|
+
projection,
|
|
122
|
+
snap,
|
|
123
|
+
tool,
|
|
124
|
+
width,
|
|
125
|
+
}: {
|
|
126
|
+
now: Date
|
|
127
|
+
projection: boolean
|
|
128
|
+
snap: UsageSnapshot
|
|
129
|
+
tool: AIUsageTool
|
|
130
|
+
width: number
|
|
131
|
+
}) {
|
|
132
|
+
const t = useTheme()
|
|
133
|
+
const isHardError = Boolean(snap.error) && !(snap.stale === true)
|
|
134
|
+
const plan = snap.planTier != null && snap.planTier !== '' ? ` \u{00B7} ${snap.planTier}` : ''
|
|
135
|
+
|
|
136
|
+
let body: ReactNode = snap.windows.map((window) => (
|
|
137
|
+
<WindowRow key={window.kind} now={now} projection={projection} width={width} window={window} />
|
|
138
|
+
))
|
|
139
|
+
if (isHardError) {
|
|
140
|
+
body = (
|
|
141
|
+
<text fg={t.error} selectable={false} wrapMode="none">
|
|
142
|
+
{truncate(`error: ${snap.error ?? ''}`, width)}
|
|
143
|
+
</text>
|
|
144
|
+
)
|
|
145
|
+
} else if (snap.windows.length === 0) {
|
|
146
|
+
body = <Muted>no window data</Muted>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<box flexDirection="column" flexShrink={0}>
|
|
151
|
+
<box width={width} flexDirection="row" flexShrink={0}>
|
|
152
|
+
<text fg={t.text} selectable={false} wrapMode="none">
|
|
153
|
+
{TOOL_TITLE[tool]}
|
|
154
|
+
</text>
|
|
155
|
+
<box flexGrow={1} flexDirection="row" justifyContent="flex-end">
|
|
156
|
+
<Muted>{`${formatRelative(snap.lastUpdated, now.getTime())}${plan}`}</Muted>
|
|
157
|
+
</box>
|
|
158
|
+
</box>
|
|
159
|
+
{body}
|
|
160
|
+
</box>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The windows themselves, without the section chrome.
|
|
166
|
+
*
|
|
167
|
+
* Split out so the status bar's modal can show exactly this and nothing else.
|
|
168
|
+
*
|
|
169
|
+
* `projection` is passed at both call sites rather than defaulted: the page has
|
|
170
|
+
* the room to say when a window runs dry, and the modal is a glance at what is
|
|
171
|
+
* left — a default would make one of those two an accident.
|
|
172
|
+
*/
|
|
173
|
+
export function QuotaWindows({
|
|
174
|
+
now,
|
|
175
|
+
projection,
|
|
176
|
+
width,
|
|
177
|
+
}: {
|
|
178
|
+
now: Date
|
|
179
|
+
projection: boolean
|
|
180
|
+
width: number
|
|
181
|
+
}) {
|
|
182
|
+
const snapshots = useAIUsageStore((s) => s.snapshots)
|
|
183
|
+
const tools = TOOLS.map((tool) => ({ snap: snapshots[tool], tool })).filter(
|
|
184
|
+
(entry): entry is { snap: UsageSnapshot; tool: AIUsageTool } => entry.snap !== undefined
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if (tools.length === 0) return <Muted>no data yet — collecting…</Muted>
|
|
188
|
+
return (
|
|
189
|
+
<>
|
|
190
|
+
{tools.map(({ snap, tool }) => (
|
|
191
|
+
<ToolQuotas
|
|
192
|
+
key={tool}
|
|
193
|
+
now={now}
|
|
194
|
+
projection={projection}
|
|
195
|
+
snap={snap}
|
|
196
|
+
tool={tool}
|
|
197
|
+
width={width}
|
|
198
|
+
/>
|
|
199
|
+
))}
|
|
200
|
+
</>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function QuotaSection({ now, width }: { now: Date; width: number }) {
|
|
205
|
+
return (
|
|
206
|
+
<Section glyph={GLYPH.quota} title="Quotas" note="live" width={width}>
|
|
207
|
+
<QuotaWindows now={now} projection width={width} />
|
|
208
|
+
</Section>
|
|
209
|
+
)
|
|
210
|
+
}
|