@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.23.0",
|
|
4
4
|
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
"bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
|
-
"@brimveyn/aimux-config": "0.10.
|
|
69
|
+
"@brimveyn/aimux-config": "0.10.2",
|
|
70
70
|
"@opentui/core": "^0.1.90",
|
|
71
71
|
"@opentui/react": "^0.1.90",
|
|
72
72
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
@@ -8,6 +8,7 @@ import { logInputDebug } from '../debug/input-log'
|
|
|
8
8
|
import { enqueueGitOp } from '../git/command-queue'
|
|
9
9
|
import { countDirtyFiles } from '../git/move-workspace'
|
|
10
10
|
import { getCurrentBranch, getDefaultBranch, listLocalBranches } from '../git/worktree'
|
|
11
|
+
import { countEffect } from '../services/aimux-counters/observe'
|
|
11
12
|
import { allLeafIds, getGroupIdForTab } from '../state/layout-tree'
|
|
12
13
|
import { saveCurrentProject } from '../state/project-save'
|
|
13
14
|
import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
|
|
@@ -212,6 +213,10 @@ function hoistBranch(branches: string[], first: string | undefined): string[] {
|
|
|
212
213
|
export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): void {
|
|
213
214
|
const { backend, dispatch, state } = ctx
|
|
214
215
|
|
|
216
|
+
// Every effect path funnels through here, including the mouse and IPC ones,
|
|
217
|
+
// so this is the one place that sees them all.
|
|
218
|
+
countEffect(effect)
|
|
219
|
+
|
|
215
220
|
switch (effect.type) {
|
|
216
221
|
case 'quit': {
|
|
217
222
|
saveCurrentProject(effect.state)
|
|
@@ -12,6 +12,7 @@ import { logInputDebug } from '../debug/input-log'
|
|
|
12
12
|
import { MultiClickDetector } from '../input/multi-click-detector'
|
|
13
13
|
import { extractStreamText, getLineText } from '../input/terminal-text-extraction'
|
|
14
14
|
import { copyToSystemClipboard } from '../platform/clipboard'
|
|
15
|
+
import { bump } from '../services/aimux-counters'
|
|
15
16
|
import {
|
|
16
17
|
type ClickSelectionResult,
|
|
17
18
|
computeRangeFromLineText,
|
|
@@ -306,6 +307,7 @@ export function useMouseHandlers({
|
|
|
306
307
|
return
|
|
307
308
|
}
|
|
308
309
|
|
|
310
|
+
bump('scrollLines', Math.abs(delta))
|
|
309
311
|
backend.scrollViewport(targetTabId, delta)
|
|
310
312
|
}
|
|
311
313
|
|
package/src/app.tsx
CHANGED
|
@@ -38,6 +38,7 @@ import { highlightSnapshot, warmClaudeSyntaxOverlay } from './integrations/claud
|
|
|
38
38
|
import { ensureClaudeSettingsThemePref, syncClaudeTheme } from './integrations/claude-theme-sync'
|
|
39
39
|
import { getProfileConfigDir, getProfileName } from './profile-paths'
|
|
40
40
|
import { startAIUsageService } from './services/ai-usage/provider'
|
|
41
|
+
import { bump } from './services/aimux-counters'
|
|
41
42
|
import {
|
|
42
43
|
useAIUsageConfig,
|
|
43
44
|
useAutoCommitConfig,
|
|
@@ -553,6 +554,11 @@ export function App({
|
|
|
553
554
|
}, [flashPendingJump])
|
|
554
555
|
|
|
555
556
|
useKeyboard((key) => {
|
|
557
|
+
// Every key in every mode passes here, terminal-input included, which makes
|
|
558
|
+
// it the one honest place to count them. A count and nothing else — no key
|
|
559
|
+
// identity is recorded anywhere.
|
|
560
|
+
bump('keys')
|
|
561
|
+
|
|
556
562
|
const currentState = stateRef.current
|
|
557
563
|
// Global quit: Ctrl+C in any mode except terminal-input
|
|
558
564
|
if (key.ctrl && key.name === 'c' && currentState.focusMode !== 'terminal-input') {
|
package/src/index.tsx
CHANGED
|
@@ -95,6 +95,7 @@ const [
|
|
|
95
95
|
{ createSessionBackend },
|
|
96
96
|
{ maybeAutoInstallCompletion },
|
|
97
97
|
{ maybeSpawnUsageRollup },
|
|
98
|
+
{ startCounters },
|
|
98
99
|
] = await Promise.all([
|
|
99
100
|
import('@opentui/react'),
|
|
100
101
|
import('./app'),
|
|
@@ -104,6 +105,7 @@ const [
|
|
|
104
105
|
import('./session-backend/bootstrap'),
|
|
105
106
|
import('./cli/completion/install'),
|
|
106
107
|
import('./services/usage-history/store'),
|
|
108
|
+
import('./services/aimux-counters'),
|
|
107
109
|
])
|
|
108
110
|
const { resolved: resolvedConfig, user: userConfig } = await loadUserConfig()
|
|
109
111
|
|
|
@@ -119,6 +121,10 @@ maybeAutoInstallCompletion()
|
|
|
119
121
|
// blocking. Opt out with AIMUX_NO_USAGE_ROLLUP=1.
|
|
120
122
|
maybeSpawnUsageRollup()
|
|
121
123
|
|
|
124
|
+
// Start counting what aimux knows about its own use — uptime, keystrokes,
|
|
125
|
+
// workspaces, tabs. Local file, counts only, flushed on a timer and on exit.
|
|
126
|
+
startCounters()
|
|
127
|
+
|
|
122
128
|
const renderer = await createCliRenderer({
|
|
123
129
|
autoFocus: true,
|
|
124
130
|
// Transparent clear color so cells untouched by BoxRenderable paints (e.g.
|
|
@@ -12,6 +12,7 @@ export const HELP_MODE_LABELS: { modeId: ModeId; label: string }[] = [
|
|
|
12
12
|
{ label: 'Terminal input', modeId: 'terminal-input' },
|
|
13
13
|
{ label: 'Git mode', modeId: 'git-mode' },
|
|
14
14
|
{ label: 'Settings', modeId: 'settings' },
|
|
15
|
+
{ label: 'Stats', modeId: 'stats' },
|
|
15
16
|
{ label: 'Git commit', modeId: 'modal.git-commit' },
|
|
16
17
|
{ label: 'New tab', modeId: 'modal.new-tab.command-edit' },
|
|
17
18
|
{ label: 'New tab — command', modeId: 'modal.new-tab.command-edit' },
|
|
@@ -7,6 +7,7 @@ const DIRECT_FOCUS_MODE_IDS: Partial<Record<FocusMode, ModeId>> = {
|
|
|
7
7
|
'git': 'git-mode',
|
|
8
8
|
'navigation': 'navigation',
|
|
9
9
|
'settings': 'settings',
|
|
10
|
+
'stats': 'stats',
|
|
10
11
|
'terminal-input': 'terminal-input',
|
|
11
12
|
}
|
|
12
13
|
|
|
@@ -29,7 +30,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
32
|
-
'
|
|
33
|
+
'quotas': 'modal.quotas',
|
|
33
34
|
'update-available': 'modal.update-available',
|
|
34
35
|
'workspace-delete-confirm': 'modal.workspace-delete-confirm',
|
|
35
36
|
'workspace-move': 'modal.workspace-move',
|
|
@@ -2,7 +2,6 @@ import type { ModeId } from './types'
|
|
|
2
2
|
|
|
3
3
|
const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
4
4
|
'git-mode': ['navigation', 'modal.git-commit', 'modal.workspace-move'],
|
|
5
|
-
'modal.ai-usage': ['navigation', 'terminal-input'],
|
|
6
5
|
'modal.create-project': ['navigation', 'modal.project-picker.filtering'],
|
|
7
6
|
'modal.create-workspace': ['navigation', 'terminal-input'],
|
|
8
7
|
'modal.flash-jump': ['navigation'],
|
|
@@ -14,6 +13,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
|
14
13
|
'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
|
|
15
14
|
'modal.project-name': ['modal.project-picker.filtering', 'navigation'],
|
|
16
15
|
'modal.project-picker.filtering': ['navigation', 'modal.project-name', 'modal.create-project'],
|
|
16
|
+
'modal.quotas': ['navigation', 'terminal-input'],
|
|
17
17
|
'modal.rename-tab': ['navigation'],
|
|
18
18
|
'modal.rename-workspace': ['navigation'],
|
|
19
19
|
'modal.setting-text': ['settings'],
|
|
@@ -40,12 +40,13 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
|
40
40
|
'modal.rename-tab',
|
|
41
41
|
'modal.rename-workspace',
|
|
42
42
|
'modal.update-available',
|
|
43
|
-
'modal.
|
|
43
|
+
'modal.quotas',
|
|
44
44
|
'modal.workspace-delete-confirm',
|
|
45
45
|
'modal.workspace-move-confirm',
|
|
46
46
|
'modal.flash-jump',
|
|
47
47
|
'git-mode',
|
|
48
48
|
'settings',
|
|
49
|
+
'stats',
|
|
49
50
|
],
|
|
50
51
|
// The help overlay opens over settings without a transition (it leaves
|
|
51
52
|
// focusMode alone). The two pickers an action row hands over to are dispatched
|
|
@@ -58,7 +59,10 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
|
58
59
|
'modal.theme-picker.filtering',
|
|
59
60
|
'modal.snippet-picker.filtering',
|
|
60
61
|
],
|
|
61
|
-
|
|
62
|
+
// Read-only screen: the only way out is back to the panes. The help overlay
|
|
63
|
+
// opens on top of it without a transition, the same way it does over settings.
|
|
64
|
+
'stats': ['navigation'],
|
|
65
|
+
'terminal-input': ['navigation', 'modal.split-picker', 'modal.quotas', 'settings', 'stats'],
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
export function isValidTransition(from: ModeId, to: ModeId): boolean {
|
package/src/input/modes/types.ts
CHANGED
|
@@ -30,9 +30,10 @@ export type ModeId =
|
|
|
30
30
|
| 'modal.update-available'
|
|
31
31
|
| 'modal.workspace-move'
|
|
32
32
|
| 'modal.workspace-move-confirm'
|
|
33
|
-
| 'modal.ai-usage'
|
|
34
33
|
| 'modal.flash-jump'
|
|
34
|
+
| 'modal.quotas'
|
|
35
35
|
| 'settings'
|
|
36
|
+
| 'stats'
|
|
36
37
|
|
|
37
38
|
export type SideEffect =
|
|
38
39
|
| { type: 'quit'; state: AppState }
|
package/src/restart-daemon.ts
CHANGED
|
@@ -6,8 +6,13 @@ import {
|
|
|
6
6
|
spawnDaemonReexec,
|
|
7
7
|
spawnDetachedIpcDaemon,
|
|
8
8
|
} from './platform/daemon-control'
|
|
9
|
+
import { recordOnce } from './services/aimux-counters'
|
|
9
10
|
|
|
10
11
|
export async function runRestartDaemon(): Promise<number> {
|
|
12
|
+
// Written immediately: this runs as its own short-lived process, so there is
|
|
13
|
+
// no flush loop to hand it to.
|
|
14
|
+
recordOnce('daemonRestarts')
|
|
15
|
+
|
|
11
16
|
const socketPath = getDaemonSocketPath()
|
|
12
17
|
const pid = await findIpcDaemonPid()
|
|
13
18
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { UsageWindow } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a quota window lands if the current rate holds.
|
|
5
|
+
*
|
|
6
|
+
* Pure, and derived only from what the provider already reports: `percent`,
|
|
7
|
+
* `windowSeconds` and `resetAt` give the elapsed fraction, which is all a linear
|
|
8
|
+
* projection needs. Nothing here parses `timeRemaining` — that is a display
|
|
9
|
+
* string, and a projection built on parsing one breaks the day its wording does.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type ProjectionVerdict =
|
|
13
|
+
/** The window resets before the quota runs out at this rate. */
|
|
14
|
+
| { kind: 'lasts' }
|
|
15
|
+
/** Not enough of the window has elapsed to say anything honest. */
|
|
16
|
+
| { kind: 'unknown' }
|
|
17
|
+
| { kind: 'exhausted'; at: Date }
|
|
18
|
+
|
|
19
|
+
/** Below this, one burst early in a window projects to nonsense. */
|
|
20
|
+
const MIN_ELAPSED_FRACTION = 0.05
|
|
21
|
+
|
|
22
|
+
export function projectWindow(window: UsageWindow, now: Date): ProjectionVerdict {
|
|
23
|
+
const { percent, resetAt, windowSeconds } = window
|
|
24
|
+
if (percent === null || resetAt === null || windowSeconds === null || windowSeconds <= 0) {
|
|
25
|
+
return { kind: 'unknown' }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const resetMs = Date.parse(resetAt)
|
|
29
|
+
if (!Number.isFinite(resetMs)) return { kind: 'unknown' }
|
|
30
|
+
|
|
31
|
+
const remainingSeconds = (resetMs - now.getTime()) / 1000
|
|
32
|
+
const elapsedSeconds = windowSeconds - remainingSeconds
|
|
33
|
+
if (remainingSeconds <= 0) return { kind: 'unknown' }
|
|
34
|
+
if (elapsedSeconds / windowSeconds < MIN_ELAPSED_FRACTION) return { kind: 'unknown' }
|
|
35
|
+
|
|
36
|
+
if (percent >= 100) return { at: now, kind: 'exhausted' }
|
|
37
|
+
const perSecond = percent / elapsedSeconds
|
|
38
|
+
if (perSecond <= 0) return { kind: 'lasts' }
|
|
39
|
+
|
|
40
|
+
const secondsToFull = (100 - percent) / perSecond
|
|
41
|
+
if (secondsToFull >= remainingSeconds) return { kind: 'lasts' }
|
|
42
|
+
|
|
43
|
+
return { at: new Date(now.getTime() + secondsToFull * 1000), kind: 'exhausted' }
|
|
44
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CounterDeltas,
|
|
3
|
+
type CounterKey,
|
|
4
|
+
type MaxCounters,
|
|
5
|
+
saveCounters,
|
|
6
|
+
todayKey,
|
|
7
|
+
} from './store'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The write side of the aimux counters: accumulate in memory, fold into the file
|
|
11
|
+
* on a timer.
|
|
12
|
+
*
|
|
13
|
+
* `bump` is on the keystroke path, so it does nothing but add to a plain object —
|
|
14
|
+
* no date formatting, no I/O, no allocation.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const FLUSH_INTERVAL_MS = 30_000
|
|
18
|
+
|
|
19
|
+
let pending: CounterDeltas = {}
|
|
20
|
+
let flushTimer: ReturnType<typeof setInterval> | null = null
|
|
21
|
+
let runStartMs = 0
|
|
22
|
+
let lastAccrualMs = 0
|
|
23
|
+
|
|
24
|
+
export function bump(key: CounterKey, amount = 1): void {
|
|
25
|
+
if (amount === 0) return
|
|
26
|
+
pending[key] = (pending[key] ?? 0) + amount
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Folds everything pending into the file.
|
|
31
|
+
*
|
|
32
|
+
* On failure the pending deltas are kept rather than dropped, so a transient
|
|
33
|
+
* write error costs a flush interval instead of the counts.
|
|
34
|
+
*
|
|
35
|
+
* ponytail: the whole batch is attributed to the day it is flushed on, so up to
|
|
36
|
+
* one interval of activity can land on the wrong side of midnight. Per-bump day
|
|
37
|
+
* resolution would fix it and would put a date format on the keystroke path;
|
|
38
|
+
* tighten it only if a day boundary ever matters more than that.
|
|
39
|
+
*/
|
|
40
|
+
export function flushCounters(now = Date.now()): void {
|
|
41
|
+
if (runStartMs === 0) return
|
|
42
|
+
|
|
43
|
+
const elapsed = Math.max(0, now - lastAccrualMs)
|
|
44
|
+
const deltas: CounterDeltas = { ...pending }
|
|
45
|
+
if (elapsed > 0) deltas.uptimeMs = (deltas.uptimeMs ?? 0) + elapsed
|
|
46
|
+
const maxima: MaxCounters = { longestRunMs: now - runStartMs }
|
|
47
|
+
|
|
48
|
+
// `elapsed` is already folded into `deltas` above, so this covers the idle
|
|
49
|
+
// case too: nothing pending and no time accrued means nothing to write.
|
|
50
|
+
if (!Object.values(deltas).some((value) => value !== 0)) return
|
|
51
|
+
|
|
52
|
+
if (saveCounters(todayKey(new Date(now)), deltas, maxima)) {
|
|
53
|
+
pending = {}
|
|
54
|
+
lastAccrualMs = now
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Starts the flush loop and arranges a final flush on exit. Safe to call twice. */
|
|
59
|
+
export function startCounters(now = Date.now()): void {
|
|
60
|
+
if (flushTimer !== null) return
|
|
61
|
+
runStartMs = now
|
|
62
|
+
lastAccrualMs = now
|
|
63
|
+
bump('runsStarted')
|
|
64
|
+
|
|
65
|
+
flushTimer = setInterval(() => {
|
|
66
|
+
flushCounters()
|
|
67
|
+
}, FLUSH_INTERVAL_MS)
|
|
68
|
+
// Never hold the process open for a counter.
|
|
69
|
+
flushTimer.unref?.()
|
|
70
|
+
|
|
71
|
+
// Synchronous write, which is the only kind `exit` can run. A SIGKILL still
|
|
72
|
+
// costs at most one interval.
|
|
73
|
+
process.on('exit', () => {
|
|
74
|
+
flushCounters()
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Records one increment and writes it straight away.
|
|
80
|
+
*
|
|
81
|
+
* For the short-lived CLI subcommands (`aimux restart-daemon`), which have no
|
|
82
|
+
* flush loop and exit before any timer would fire. The write is additive like
|
|
83
|
+
* every other, so it composes with a TUI running at the same time.
|
|
84
|
+
*/
|
|
85
|
+
export function recordOnce(key: CounterKey, amount = 1, now = Date.now()): void {
|
|
86
|
+
saveCounters(todayKey(new Date(now)), { [key]: amount }, {})
|
|
87
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SideEffect } from '../../input/modes/types'
|
|
2
|
+
import type { AppAction } from '../../state/actions'
|
|
3
|
+
|
|
4
|
+
import { bump } from './index'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Translates dispatched actions and side effects into counters.
|
|
8
|
+
*
|
|
9
|
+
* One place rather than a `bump()` sprinkled through the reducers: reducers stay
|
|
10
|
+
* pure, and what aimux counts about itself is readable in a single switch
|
|
11
|
+
* instead of being spread across the state layer.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export function countAction(action: AppAction): void {
|
|
15
|
+
switch (action.type) {
|
|
16
|
+
case 'add-tab':
|
|
17
|
+
bump('tabsOpened')
|
|
18
|
+
break
|
|
19
|
+
case 'add-workspace-record':
|
|
20
|
+
bump('workspacesCreated')
|
|
21
|
+
break
|
|
22
|
+
case 'split-pane':
|
|
23
|
+
bump(action.direction === 'vertical' ? 'splitsVertical' : 'splitsHorizontal')
|
|
24
|
+
break
|
|
25
|
+
default:
|
|
26
|
+
break
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function countEffect(effect: SideEffect): void {
|
|
31
|
+
switch (effect.type) {
|
|
32
|
+
case 'paste-selected-snippet':
|
|
33
|
+
case 'paste-snippet-to-group':
|
|
34
|
+
bump('snippetsFired')
|
|
35
|
+
break
|
|
36
|
+
default:
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { logDebug } from '../../debug/input-log'
|
|
6
|
+
import { localDay } from '../usage-history/store'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What aimux knows about its own use, per local calendar day.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately counts and nothing else. `keys` is a number of key presses with
|
|
12
|
+
* no key identity and no content attached — which is also why there is no
|
|
13
|
+
* per-keybind breakdown: that would mean recording *which* key, a different
|
|
14
|
+
* privacy posture for a stat nobody asked for.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here leaves the machine.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const COUNTERS_VERSION = 1
|
|
20
|
+
const UNREADABLE_VERSION = -1
|
|
21
|
+
|
|
22
|
+
export type CounterKey =
|
|
23
|
+
| 'daemonRestarts'
|
|
24
|
+
| 'keys'
|
|
25
|
+
/** aimux launches. Distinct from `tabsOpened`: a tab is a session inside one run. */
|
|
26
|
+
| 'runsStarted'
|
|
27
|
+
| 'scrollLines'
|
|
28
|
+
| 'snippetsFired'
|
|
29
|
+
| 'splitsHorizontal'
|
|
30
|
+
| 'splitsVertical'
|
|
31
|
+
| 'tabsOpened'
|
|
32
|
+
| 'uptimeMs'
|
|
33
|
+
| 'workspacesCreated'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Counters that take the larger of the two values instead of the sum.
|
|
37
|
+
*
|
|
38
|
+
* `longestRunMs` is the length of a single uninterrupted aimux run, so adding
|
|
39
|
+
* two instances' values would invent a run neither of them had.
|
|
40
|
+
*/
|
|
41
|
+
export type MaxCounterKey = 'longestRunMs'
|
|
42
|
+
|
|
43
|
+
export type CounterDeltas = Partial<Record<CounterKey, number>>
|
|
44
|
+
export type MaxCounters = Partial<Record<MaxCounterKey, number>>
|
|
45
|
+
|
|
46
|
+
export type CounterDay = Partial<Record<CounterKey | MaxCounterKey, number>>
|
|
47
|
+
/** 'YYYY-MM-DD' in the machine's local calendar -> that day's counters. */
|
|
48
|
+
export type CounterDays = Record<string, CounterDay>
|
|
49
|
+
|
|
50
|
+
export interface CountersFile {
|
|
51
|
+
days: CounterDays
|
|
52
|
+
version: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MAX_KEYS: readonly MaxCounterKey[] = ['longestRunMs']
|
|
56
|
+
|
|
57
|
+
function isMaxKey(key: string): key is MaxCounterKey {
|
|
58
|
+
return (MAX_KEYS as readonly string[]).includes(key)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolved per call, not at module scope, so a `HOME` override in tests reaches it. */
|
|
62
|
+
export function countersPath(): string {
|
|
63
|
+
const home = process.env.HOME ?? homedir()
|
|
64
|
+
return join(home, '.config', 'aimux', 'usage-counters.json')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function readCounters(): CountersFile {
|
|
68
|
+
let raw: string
|
|
69
|
+
try {
|
|
70
|
+
raw = readFileSync(countersPath(), 'utf8')
|
|
71
|
+
} catch {
|
|
72
|
+
return { days: {}, version: COUNTERS_VERSION } // no file yet; safe to create
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const file = JSON.parse(raw) as Partial<CountersFile>
|
|
77
|
+
if (typeof file.version !== 'number') return { days: {}, version: UNREADABLE_VERSION }
|
|
78
|
+
if (typeof file.days !== 'object' || file.days === null) {
|
|
79
|
+
return { days: {}, version: UNREADABLE_VERSION }
|
|
80
|
+
}
|
|
81
|
+
return { days: file.days, version: file.version }
|
|
82
|
+
} catch {
|
|
83
|
+
return { days: {}, version: UNREADABLE_VERSION }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Folds one day's pending changes into what is already on disk.
|
|
89
|
+
*
|
|
90
|
+
* Additive for every ordinary counter, which is what makes two aimux instances
|
|
91
|
+
* safe to run at once: each flush contributes its own delta rather than
|
|
92
|
+
* overwriting a total it computed from a stale read.
|
|
93
|
+
*/
|
|
94
|
+
export function mergeCounterDay(
|
|
95
|
+
stored: CounterDay | undefined,
|
|
96
|
+
deltas: CounterDeltas,
|
|
97
|
+
maxima: MaxCounters
|
|
98
|
+
): CounterDay {
|
|
99
|
+
const merged: CounterDay = { ...stored }
|
|
100
|
+
for (const [key, value] of Object.entries(deltas)) {
|
|
101
|
+
if (value === 0) continue
|
|
102
|
+
merged[key as CounterKey] = (merged[key as CounterKey] ?? 0) + value
|
|
103
|
+
}
|
|
104
|
+
for (const [key, value] of Object.entries(maxima)) {
|
|
105
|
+
if (!isMaxKey(key)) continue
|
|
106
|
+
merged[key] = Math.max(merged[key] ?? 0, value)
|
|
107
|
+
}
|
|
108
|
+
return merged
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* False when nothing was written. The caller keeps its pending deltas in that
|
|
113
|
+
* case, so a transient failure costs a flush interval rather than the counts.
|
|
114
|
+
*/
|
|
115
|
+
export function saveCounters(day: string, deltas: CounterDeltas, maxima: MaxCounters): boolean {
|
|
116
|
+
const stored = readCounters()
|
|
117
|
+
|
|
118
|
+
// Same posture as the usage history: a newer aimux owns a shape this build
|
|
119
|
+
// cannot round-trip, and an unreadable file may hold counts nothing can
|
|
120
|
+
// regenerate. Neither gets written over.
|
|
121
|
+
if (stored.version > COUNTERS_VERSION || stored.version < 1) {
|
|
122
|
+
logDebug('counters.refusedWrite', { version: stored.version })
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const path = countersPath()
|
|
127
|
+
const file: CountersFile = {
|
|
128
|
+
days: { ...stored.days, [day]: mergeCounterDay(stored.days[day], deltas, maxima) },
|
|
129
|
+
version: COUNTERS_VERSION,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
134
|
+
// pid-suffixed: two instances can flush at once, and a shared tmp name would
|
|
135
|
+
// let one truncate the other's half-written file.
|
|
136
|
+
const tmpPath = `${path}.${process.pid}.tmp`
|
|
137
|
+
writeFileSync(tmpPath, `${JSON.stringify(file, null, 2)}\n`)
|
|
138
|
+
renameSync(tmpPath, path)
|
|
139
|
+
return true
|
|
140
|
+
} catch (error) {
|
|
141
|
+
logDebug('counters.writeError', {
|
|
142
|
+
error: error instanceof Error ? error.message : String(error),
|
|
143
|
+
})
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function todayKey(now: Date = new Date()): string {
|
|
149
|
+
return localDay(now)
|
|
150
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { CounterDays, CounterKey, MaxCounterKey } from './store'
|
|
2
|
+
|
|
3
|
+
import { localDay } from '../usage-history/store'
|
|
4
|
+
|
|
5
|
+
/** Pure shaping of the stored counter days into what the aimux page renders. */
|
|
6
|
+
|
|
7
|
+
export interface CounterPeak {
|
|
8
|
+
day: string
|
|
9
|
+
value: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CounterSummary {
|
|
13
|
+
best: CounterPeak
|
|
14
|
+
today: number
|
|
15
|
+
total: number
|
|
16
|
+
/** Days carrying a non-zero value — the denominator for a daily average. */
|
|
17
|
+
days: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function summarizeCounter(
|
|
21
|
+
days: CounterDays,
|
|
22
|
+
key: CounterKey,
|
|
23
|
+
today: string
|
|
24
|
+
): CounterSummary {
|
|
25
|
+
let best: CounterPeak = { day: '', value: 0 }
|
|
26
|
+
let dayCount = 0
|
|
27
|
+
let total = 0
|
|
28
|
+
|
|
29
|
+
for (const [date, day] of Object.entries(days)) {
|
|
30
|
+
const value = day[key] ?? 0
|
|
31
|
+
if (value <= 0) continue
|
|
32
|
+
dayCount += 1
|
|
33
|
+
total += value
|
|
34
|
+
if (value > best.value) best = { day: date, value }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { best, days: dayCount, today: days[today]?.[key] ?? 0, total }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The largest value ever recorded for a max-merged counter.
|
|
42
|
+
*
|
|
43
|
+
* Not a sum: `longestRunMs` is the length of one uninterrupted aimux run, so the
|
|
44
|
+
* answer across days is the biggest of them, never their total.
|
|
45
|
+
*/
|
|
46
|
+
export function peakOf(days: CounterDays, key: MaxCounterKey): CounterPeak {
|
|
47
|
+
let best: CounterPeak = { day: '', value: 0 }
|
|
48
|
+
for (const [date, day] of Object.entries(days)) {
|
|
49
|
+
const value = day[key] ?? 0
|
|
50
|
+
if (value > best.value) best = { day: date, value }
|
|
51
|
+
}
|
|
52
|
+
return best
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One value per calendar day for the `count` days ending today, oldest first.
|
|
57
|
+
*
|
|
58
|
+
* The counters' twin of `lastDays` over the usage history, and unrecorded days
|
|
59
|
+
* are zero for the same reason: a day aimux never ran is a real zero, not a hole.
|
|
60
|
+
*/
|
|
61
|
+
export function lastCounterDays(
|
|
62
|
+
days: CounterDays,
|
|
63
|
+
count: number,
|
|
64
|
+
today: Date,
|
|
65
|
+
key: CounterKey | MaxCounterKey
|
|
66
|
+
): number[] {
|
|
67
|
+
return Array.from({ length: count }, (_, index) => {
|
|
68
|
+
const date = new Date(today)
|
|
69
|
+
date.setDate(date.getDate() - (count - 1 - index))
|
|
70
|
+
return days[localDay(date)]?.[key] ?? 0
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function sumOf(days: CounterDays, key: CounterKey): number {
|
|
75
|
+
let total = 0
|
|
76
|
+
for (const day of Object.values(days)) total += day[key] ?? 0
|
|
77
|
+
return total
|
|
78
|
+
}
|