@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.
Files changed (66) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/settings-actions.ts +24 -13
  3. package/src/app-runtime/side-effects.ts +5 -0
  4. package/src/app-runtime/use-mouse-handlers.ts +2 -0
  5. package/src/app.tsx +6 -0
  6. package/src/index.tsx +6 -0
  7. package/src/input/keymap/help-entries.ts +1 -0
  8. package/src/input/modes/bridge.ts +2 -1
  9. package/src/input/modes/transitions.ts +7 -3
  10. package/src/input/modes/types.ts +2 -1
  11. package/src/restart-daemon.ts +5 -0
  12. package/src/services/ai-usage/projection.ts +44 -0
  13. package/src/services/aimux-counters/index.ts +87 -0
  14. package/src/services/aimux-counters/observe.ts +39 -0
  15. package/src/services/aimux-counters/store.ts +150 -0
  16. package/src/services/aimux-counters/summary.ts +78 -0
  17. package/src/services/usage-history/cost.ts +149 -0
  18. package/src/services/usage-history/insights.ts +314 -0
  19. package/src/services/usage-history/rollup.ts +78 -6
  20. package/src/services/usage-history/stats.ts +66 -35
  21. package/src/services/usage-history/store.ts +128 -9
  22. package/src/settings/search.ts +13 -9
  23. package/src/settings/sections/about.ts +1 -0
  24. package/src/settings/sections/appearance.ts +1 -0
  25. package/src/settings/sections/automation.ts +1 -0
  26. package/src/settings/sections/commands.ts +1 -0
  27. package/src/settings/sections/editor.ts +1 -0
  28. package/src/settings/sections/experimental.ts +1 -0
  29. package/src/settings/sections/git.ts +1 -0
  30. package/src/settings/sections/index.ts +23 -12
  31. package/src/settings/sections/integrations.ts +1 -0
  32. package/src/settings/sections/layout.ts +1 -0
  33. package/src/settings/sections/notifications.ts +1 -0
  34. package/src/settings/sections/setup.ts +1 -0
  35. package/src/settings/sections/status-bar.ts +1 -0
  36. package/src/settings/sections/workspace.ts +1 -0
  37. package/src/settings/types.ts +10 -0
  38. package/src/state/actions.ts +13 -3
  39. package/src/state/app-store.ts +12 -1
  40. package/src/state/reducers/modal-state.ts +12 -17
  41. package/src/state/reducers/settings-state.ts +29 -51
  42. package/src/state/reducers/stats-state.ts +56 -0
  43. package/src/state/stats-pages.ts +33 -0
  44. package/src/state/store.ts +5 -0
  45. package/src/state/types.ts +22 -7
  46. package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
  47. package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
  48. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
  49. package/src/ui/components/settings/row-value.tsx +22 -3
  50. package/src/ui/components/settings/settings-footer.tsx +170 -0
  51. package/src/ui/components/settings/settings-row.tsx +109 -33
  52. package/src/ui/components/settings/settings-search-bar.tsx +61 -0
  53. package/src/ui/components/settings/settings-view.tsx +163 -77
  54. package/src/ui/components/stats/aimux-page.tsx +245 -0
  55. package/src/ui/components/stats/chart.ts +157 -0
  56. package/src/ui/components/stats/day-facts.tsx +268 -0
  57. package/src/ui/components/stats/format.ts +98 -0
  58. package/src/ui/components/stats/heatmap.tsx +305 -0
  59. package/src/ui/components/stats/projects-page.tsx +293 -0
  60. package/src/ui/components/stats/quotas.tsx +210 -0
  61. package/src/ui/components/stats/shared.tsx +654 -0
  62. package/src/ui/components/stats/stats-view.tsx +153 -0
  63. package/src/ui/components/stats/usage-page.tsx +291 -0
  64. package/src/ui/components/stats/use-stats-data.ts +48 -0
  65. package/src/ui/root.tsx +7 -3
  66. 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
+ }