@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.22.12",
3
+ "version": "1.23.1",
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.1",
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",
@@ -3,7 +3,7 @@ import type { AppState } from '../state/types'
3
3
  import type { SideEffectContext } from './side-effect-context'
4
4
 
5
5
  import { filterSettingRows } from '../settings/search'
6
- import { findSettingRow, getSectionRows } from '../settings/sections'
6
+ import { findSettingRow } from '../settings/sections'
7
7
  import { readRow, resetRow, settingsStore, writeRow } from '../settings/settings-store'
8
8
  import { dispatchGlobal } from '../state/dispatch-ref'
9
9
  import { toast } from '../state/toast-store'
@@ -30,14 +30,20 @@ function nextOptionValue(
30
30
  return options[(index + delta + size) % size]?.value
31
31
  }
32
32
 
33
+ /**
34
+ * One press, one step. Deliberately not a fraction of the range: every number
35
+ * row here crosses its own range in under sixty presses, because each declares
36
+ * a `step` scaled to what it measures — so a "drag the gauge" stride would be
37
+ * arithmetic wrapped around a number the row already got right.
38
+ */
33
39
  function nextNumberValue(
34
40
  row: Extract<SettingRow, { kind: 'number' }>,
35
41
  current: SettingValue,
36
42
  delta: 1 | -1
37
43
  ): number {
38
44
  const base = typeof current === 'number' ? current : row.min
39
- // Clamped, never wrapped: `-`/`+` name a direction, and holding + on a width
40
- // until it snaps to the narrowest is not what anyone meant.
45
+ // Clamped, never wrapped: a direction is a direction, and holding `l` on a
46
+ // width until it snaps to the narrowest is not what anyone meant.
41
47
  return Math.min(row.max, Math.max(row.min, round(base + delta * row.step)))
42
48
  }
43
49
 
@@ -55,6 +61,15 @@ function readCtx(state: AppState): SettingCtx {
55
61
  return { state, values: settingsStore.getState().values }
56
62
  }
57
63
 
64
+ /**
65
+ * The row under the cursor. Unfiltered, because the cursor counts every row on
66
+ * the screen — the same list `filterSettingRows` numbers its hits against.
67
+ */
68
+ function selectedRow(state: AppState): SettingRow | undefined {
69
+ if (state.focusMode !== 'settings') return undefined
70
+ return filterSettingRows(state.projects, null)[state.settings.rowIndex]?.row
71
+ }
72
+
58
73
  /**
59
74
  * One entry point for every kind of row. `delta` is the direction asked for
60
75
  * (`-`/`+`); without it the row advances the way activating it should — a toggle
@@ -62,9 +77,7 @@ function readCtx(state: AppState): SettingCtx {
62
77
  */
63
78
  export function changeSelectedSetting(runtime: SettingsContext, delta?: 1 | -1): void {
64
79
  const state = runtime.getState()
65
- if (state.focusMode !== 'settings' || state.settings.pane !== 'rows') return
66
-
67
- const row = getSectionRows(state.settings.sectionId, state.projects)[state.settings.rowIndex]
80
+ const row = selectedRow(state)
68
81
  if (!row || row.kind === 'info') return
69
82
 
70
83
  const ctx = readCtx(state)
@@ -102,9 +115,10 @@ export function changeSelectedSetting(runtime: SettingsContext, delta?: 1 | -1):
102
115
  }
103
116
 
104
117
  /**
105
- * Move the screen's cursor onto the setting the search had highlighted, wherever
106
- * it turned out to live. The list is recomputed from the same filter the picker
107
- * drew, so the index still means what it meant.
118
+ * Move the screen's cursor onto the setting the search had highlighted. The hit
119
+ * carries its position in the screen's list, so there is nothing to look up
120
+ * and the list is recomputed from the same filter the picker drew, so the index
121
+ * still means what it meant.
108
122
  */
109
123
  export function confirmSettingsSearch(runtime: SettingsContext): void {
110
124
  const state = runtime.getState()
@@ -112,15 +126,12 @@ export function confirmSettingsSearch(runtime: SettingsContext): void {
112
126
  const hit = filterSettingRows(state.projects, state.modal.editBuffer)[state.modal.selectedIndex]
113
127
  dispatchGlobal({ type: 'close-modal' })
114
128
  if (!hit) return
115
- dispatchGlobal({ sectionId: hit.sectionId, type: 'settings-select-section' })
116
129
  dispatchGlobal({ rowIndex: hit.rowIndex, type: 'settings-select-row' })
117
130
  }
118
131
 
119
132
  /** Puts the selected row back to what it would be if this screen had never run. */
120
133
  export function resetSelectedSetting(runtime: SettingsContext): void {
121
- const state = runtime.getState()
122
- if (state.focusMode !== 'settings' || state.settings.pane !== 'rows') return
123
- const row = getSectionRows(state.settings.sectionId, state.projects)[state.settings.rowIndex]
134
+ const row = selectedRow(runtime.getState())
124
135
  if (row) resetRow(row)
125
136
  }
126
137
 
@@ -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
- 'ai-usage': 'modal.ai-usage',
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.ai-usage',
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
- 'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage', 'settings'],
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 {
@@ -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 }
@@ -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
+ }