@brimveyn/aimux 1.7.4 → 1.9.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/README.md +27 -15
- package/package.json +2 -2
- package/src/app-runtime/auto-commit-driver.ts +286 -0
- package/src/app-runtime/auto-commit-ref.ts +20 -0
- package/src/app-runtime/backend-attach-runtime.ts +3 -1
- package/src/app-runtime/session-actions.ts +7 -2
- package/src/app-runtime/side-effects.ts +99 -1
- package/src/app-runtime/use-auto-commit-driver.ts +133 -0
- package/src/app-runtime/use-terminal-resize.ts +20 -12
- package/src/app.tsx +59 -22
- package/src/auto-commit/default-auto-commit-prompt.md +46 -0
- package/src/auto-commit/headless-commands.ts +40 -0
- package/src/auto-commit/output-parser.ts +21 -0
- package/src/auto-commit/prompt-loader.ts +33 -0
- package/src/auto-commit/staging-mode.ts +5 -0
- package/src/auto-commit/strip-ansi.ts +13 -0
- package/src/auto-commit/suggestion-runner.ts +55 -0
- package/src/auto-commit/working-tree-hash.ts +24 -0
- package/src/config.ts +24 -0
- package/src/daemon/session-registry.ts +1 -0
- package/src/index.tsx +1 -1
- package/src/input/keymap/help-entries.ts +4 -4
- package/src/input/modes/bridge.ts +7 -0
- package/src/input/modes/transitions.ts +6 -2
- package/src/input/modes/types.ts +5 -0
- package/src/ipc/manager-protocol.ts +2 -2
- package/src/ipc/protocol.ts +2 -8
- package/src/pty/assistant-status-detector.ts +1 -1
- package/src/pty/terminal-snapshot.ts +38 -4
- package/src/services/ai-usage/adapters/claude.ts +215 -0
- package/src/services/ai-usage/adapters/codex.ts +242 -0
- package/src/services/ai-usage/cache.ts +60 -0
- package/src/services/ai-usage/pace.ts +79 -0
- package/src/services/ai-usage/provider.ts +107 -0
- package/src/services/ai-usage/spawn.ts +49 -0
- package/src/services/ai-usage/types.ts +51 -0
- package/src/session-backend/local-session-backend.ts +10 -2
- package/src/state/ai-usage-store.ts +36 -0
- package/src/state/reducers/auto-commit-state.ts +59 -0
- package/src/state/reducers/modal-state.ts +119 -2
- package/src/state/reducers/session-state.ts +26 -14
- package/src/state/session-persistence.ts +14 -5
- package/src/state/store.ts +24 -14
- package/src/state/types.ts +62 -2
- package/src/state/workspace-save.ts +4 -0
- package/src/ui/components/ai-usage-indicator.tsx +163 -0
- package/src/ui/components/ai-usage-modal.tsx +186 -0
- package/src/ui/components/create-session-modal.tsx +2 -2
- package/src/ui/components/git-commit-modal.tsx +167 -18
- package/src/ui/components/session-bar.tsx +2 -2
- package/src/ui/components/session-picker-modal.tsx +4 -4
- package/src/ui/components/sidebar.tsx +3 -1
- package/src/ui/components/status-bar.tsx +2 -0
- package/src/ui/components/terminal-pane.tsx +18 -3
- package/src/ui/root.tsx +14 -2
- package/src/ui/status-bar-model.ts +1 -1
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import type { UsageSnapshot } from './types'
|
|
8
|
+
|
|
9
|
+
const CACHE_DIR = join(homedir(), '.cache', 'aimux')
|
|
10
|
+
const CACHE_PATH = join(CACHE_DIR, 'ai-usage.json')
|
|
11
|
+
|
|
12
|
+
interface CacheEntry {
|
|
13
|
+
fetchedAt: number
|
|
14
|
+
snapshot: UsageSnapshot
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type CacheFile = Partial<Record<AIUsageTool, CacheEntry>>
|
|
18
|
+
|
|
19
|
+
function readCacheFile(): CacheFile {
|
|
20
|
+
try {
|
|
21
|
+
if (!existsSync(CACHE_PATH)) return {}
|
|
22
|
+
const raw = readFileSync(CACHE_PATH, 'utf8')
|
|
23
|
+
const parsed = JSON.parse(raw) as unknown
|
|
24
|
+
if (typeof parsed !== 'object' || parsed === null) return {}
|
|
25
|
+
return parsed as CacheFile
|
|
26
|
+
} catch {
|
|
27
|
+
return {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CachedSnapshot {
|
|
32
|
+
snapshot: UsageSnapshot
|
|
33
|
+
ageMs: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadCachedSnapshot(tool: AIUsageTool, maxAgeMs: number): CachedSnapshot | null {
|
|
37
|
+
const cache = readCacheFile()
|
|
38
|
+
const entry = cache[tool]
|
|
39
|
+
if (!entry) return null
|
|
40
|
+
if (typeof entry.fetchedAt !== 'number') return null
|
|
41
|
+
const ageMs = Date.now() - entry.fetchedAt
|
|
42
|
+
if (ageMs > maxAgeMs) return null
|
|
43
|
+
if (!entry.snapshot || entry.snapshot.tool !== tool) return null
|
|
44
|
+
return { ageMs, snapshot: entry.snapshot }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function saveCachedSnapshot(snapshot: UsageSnapshot): void {
|
|
48
|
+
if (snapshot.error) return
|
|
49
|
+
if (snapshot.percent === null) return
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(CACHE_DIR, { recursive: true })
|
|
52
|
+
const cache = readCacheFile()
|
|
53
|
+
cache[snapshot.tool] = { fetchedAt: Date.now(), snapshot }
|
|
54
|
+
const tmpPath = `${CACHE_PATH}.${process.pid}.tmp`
|
|
55
|
+
writeFileSync(tmpPath, `${JSON.stringify(cache, null, 2)}\n`)
|
|
56
|
+
renameSync(tmpPath, CACHE_PATH)
|
|
57
|
+
} catch {
|
|
58
|
+
// swallow — cache is best-effort
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { UsagePace, UsagePaceStage } from './types'
|
|
2
|
+
|
|
3
|
+
function stageFor(delta: number): UsagePaceStage {
|
|
4
|
+
const abs = Math.abs(delta)
|
|
5
|
+
if (abs <= 2) return 'onTrack'
|
|
6
|
+
if (abs <= 6) return delta >= 0 ? 'slightlyBehind' : 'slightlyAhead'
|
|
7
|
+
if (abs <= 12) return delta >= 0 ? 'behind' : 'ahead'
|
|
8
|
+
return delta >= 0 ? 'farBehind' : 'farAhead'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function formatDuration(seconds: number): string {
|
|
12
|
+
const total = Math.max(0, Math.round(seconds))
|
|
13
|
+
const h = Math.floor(total / 3600)
|
|
14
|
+
const m = Math.floor((total % 3600) / 60)
|
|
15
|
+
if (h >= 24) {
|
|
16
|
+
const d = Math.floor(h / 24)
|
|
17
|
+
const hh = h % 24
|
|
18
|
+
return hh > 0 ? `${d}d ${hh}h` : `${d}d`
|
|
19
|
+
}
|
|
20
|
+
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`
|
|
21
|
+
return `${m}m`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function computePace(opts: {
|
|
25
|
+
percent: number | null
|
|
26
|
+
resetAtMs: number | null
|
|
27
|
+
windowSeconds: number | null
|
|
28
|
+
now?: number
|
|
29
|
+
}): UsagePace | null {
|
|
30
|
+
const { percent, resetAtMs, windowSeconds } = opts
|
|
31
|
+
const now = opts.now ?? Date.now()
|
|
32
|
+
if (percent === null || resetAtMs === null || !windowSeconds || windowSeconds <= 0) {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const timeUntilReset = (resetAtMs - now) / 1000
|
|
37
|
+
if (timeUntilReset <= 0 || timeUntilReset > windowSeconds) return null
|
|
38
|
+
|
|
39
|
+
const elapsed = Math.max(0, Math.min(windowSeconds, windowSeconds - timeUntilReset))
|
|
40
|
+
const expected = Math.max(0, Math.min(100, (elapsed / windowSeconds) * 100))
|
|
41
|
+
const actual = Math.max(0, Math.min(100, percent))
|
|
42
|
+
const delta = actual - expected
|
|
43
|
+
const rounded = Math.round(delta)
|
|
44
|
+
const stage = stageFor(rounded)
|
|
45
|
+
|
|
46
|
+
let rightText: string | null = null
|
|
47
|
+
if (elapsed > 0 && actual > 0) {
|
|
48
|
+
const rate = actual / elapsed
|
|
49
|
+
if (rate > 0) {
|
|
50
|
+
const remaining = Math.max(0, 100 - actual)
|
|
51
|
+
const candidate = remaining / rate
|
|
52
|
+
if (candidate >= timeUntilReset) {
|
|
53
|
+
rightText = 'Lasts to reset'
|
|
54
|
+
} else {
|
|
55
|
+
rightText = `Runs out in ${formatDuration(candidate)}`
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} else if (elapsed > 0 && actual === 0) {
|
|
59
|
+
rightText = 'Lasts to reset'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let label: string
|
|
63
|
+
if (stage === 'onTrack') {
|
|
64
|
+
label = 'On pace'
|
|
65
|
+
} else if (rounded > 0) {
|
|
66
|
+
label = `Behind (+${rounded}%)`
|
|
67
|
+
} else {
|
|
68
|
+
label = `Ahead (${rounded}%)`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { delta, label, rightText, stage }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatTimeRemaining(resetAtMs: number | null, now?: number): string | null {
|
|
75
|
+
if (resetAtMs === null) return null
|
|
76
|
+
const diff = resetAtMs - (now ?? Date.now())
|
|
77
|
+
if (diff <= 0) return null
|
|
78
|
+
return formatDuration(diff / 1000)
|
|
79
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { AIUsageTool, AIUsageToolConfig } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import type { UsageSnapshot } from './types'
|
|
4
|
+
|
|
5
|
+
import { fetchClaudeUsage } from './adapters/claude'
|
|
6
|
+
import { fetchCodexUsage } from './adapters/codex'
|
|
7
|
+
import { loadCachedSnapshot, saveCachedSnapshot } from './cache'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_POLL_SECONDS = 60
|
|
10
|
+
const DEFAULT_TOOLS: AIUsageTool[] = ['claude', 'codex']
|
|
11
|
+
|
|
12
|
+
export interface AIUsageServiceHandle {
|
|
13
|
+
stop: () => void
|
|
14
|
+
refresh: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function fetchFor(tool: AIUsageTool, config: AIUsageToolConfig): Promise<UsageSnapshot> {
|
|
18
|
+
switch (tool) {
|
|
19
|
+
case 'claude':
|
|
20
|
+
return fetchClaudeUsage(config)
|
|
21
|
+
case 'codex':
|
|
22
|
+
return fetchCodexUsage(config)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function startAIUsageService(
|
|
27
|
+
config: AIUsageToolConfig,
|
|
28
|
+
onUpdate: (snap: UsageSnapshot) => void
|
|
29
|
+
): AIUsageServiceHandle {
|
|
30
|
+
const tools = config.tools && config.tools.length > 0 ? config.tools : DEFAULT_TOOLS
|
|
31
|
+
const pollMs = Math.max(5, config.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000
|
|
32
|
+
|
|
33
|
+
let stopped = false
|
|
34
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
35
|
+
|
|
36
|
+
const tick = async (): Promise<void> => {
|
|
37
|
+
if (stopped) return
|
|
38
|
+
|
|
39
|
+
const toFetch: AIUsageTool[] = []
|
|
40
|
+
let maxCachedAgeMs = 0
|
|
41
|
+
for (const tool of tools) {
|
|
42
|
+
const cached = loadCachedSnapshot(tool, pollMs)
|
|
43
|
+
if (cached) {
|
|
44
|
+
onUpdate(cached.snapshot)
|
|
45
|
+
if (cached.ageMs > maxCachedAgeMs) maxCachedAgeMs = cached.ageMs
|
|
46
|
+
} else {
|
|
47
|
+
toFetch.push(tool)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (toFetch.length > 0) {
|
|
52
|
+
const results = await Promise.allSettled(toFetch.map((t) => fetchFor(t, config)))
|
|
53
|
+
if (stopped) return
|
|
54
|
+
for (let i = 0; i < results.length; i++) {
|
|
55
|
+
const result = results[i]
|
|
56
|
+
const tool = toFetch[i]
|
|
57
|
+
if (!result || !tool) continue
|
|
58
|
+
if (result.status === 'fulfilled') {
|
|
59
|
+
saveCachedSnapshot(result.value)
|
|
60
|
+
onUpdate(result.value)
|
|
61
|
+
} else {
|
|
62
|
+
onUpdate({
|
|
63
|
+
burnRatePerHour: null,
|
|
64
|
+
costUSD: null,
|
|
65
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
66
|
+
lastUpdated: new Date().toISOString(),
|
|
67
|
+
percent: null,
|
|
68
|
+
planTier: null,
|
|
69
|
+
resetAt: null,
|
|
70
|
+
timeRemaining: null,
|
|
71
|
+
tokens: { cache: 0, input: 0, output: 0, total: 0 },
|
|
72
|
+
tool,
|
|
73
|
+
windows: [],
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!stopped) {
|
|
80
|
+
const minDelayMs = 5_000
|
|
81
|
+
const nextDelayMs =
|
|
82
|
+
toFetch.length > 0 ? pollMs : Math.max(minDelayMs, pollMs - maxCachedAgeMs)
|
|
83
|
+
timer = setTimeout(() => {
|
|
84
|
+
void tick()
|
|
85
|
+
}, nextDelayMs)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
void tick()
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
refresh: () => {
|
|
93
|
+
if (timer) {
|
|
94
|
+
clearTimeout(timer)
|
|
95
|
+
timer = null
|
|
96
|
+
}
|
|
97
|
+
void tick()
|
|
98
|
+
},
|
|
99
|
+
stop: () => {
|
|
100
|
+
stopped = true
|
|
101
|
+
if (timer) {
|
|
102
|
+
clearTimeout(timer)
|
|
103
|
+
timer = null
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
interface CliResult {
|
|
2
|
+
ok: boolean
|
|
3
|
+
stdout: string
|
|
4
|
+
stderr: string
|
|
5
|
+
error?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 15_000
|
|
9
|
+
|
|
10
|
+
export async function runCli(
|
|
11
|
+
command: string,
|
|
12
|
+
args: string[],
|
|
13
|
+
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
|
14
|
+
): Promise<CliResult> {
|
|
15
|
+
const proc = Bun.spawn([command, ...args], {
|
|
16
|
+
stderr: 'pipe',
|
|
17
|
+
stdin: 'ignore',
|
|
18
|
+
stdout: 'pipe',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const timeout = setTimeout(() => {
|
|
22
|
+
try {
|
|
23
|
+
proc.kill()
|
|
24
|
+
} catch {
|
|
25
|
+
// process already gone
|
|
26
|
+
}
|
|
27
|
+
}, timeoutMs)
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
31
|
+
new Response(proc.stdout).text(),
|
|
32
|
+
new Response(proc.stderr).text(),
|
|
33
|
+
proc.exited,
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
if (exitCode !== 0) {
|
|
37
|
+
return {
|
|
38
|
+
error: `${command} exit ${exitCode}: ${stderr.trim().slice(0, 200)}`,
|
|
39
|
+
ok: false,
|
|
40
|
+
stderr,
|
|
41
|
+
stdout,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { ok: true, stderr, stdout }
|
|
46
|
+
} finally {
|
|
47
|
+
clearTimeout(timeout)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
export type { AIUsageTool }
|
|
4
|
+
|
|
5
|
+
export type UsageWindowKind = 'session' | 'weekly' | 'sonnet' | 'opus' | 'primary' | 'secondary'
|
|
6
|
+
|
|
7
|
+
export type UsagePaceStage =
|
|
8
|
+
| 'farAhead'
|
|
9
|
+
| 'ahead'
|
|
10
|
+
| 'slightlyAhead'
|
|
11
|
+
| 'onTrack'
|
|
12
|
+
| 'slightlyBehind'
|
|
13
|
+
| 'behind'
|
|
14
|
+
| 'farBehind'
|
|
15
|
+
|
|
16
|
+
export interface UsagePace {
|
|
17
|
+
delta: number
|
|
18
|
+
stage: UsagePaceStage
|
|
19
|
+
label: string
|
|
20
|
+
rightText: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UsageWindow {
|
|
24
|
+
kind: UsageWindowKind
|
|
25
|
+
label: string
|
|
26
|
+
percent: number | null
|
|
27
|
+
resetAt: string | null
|
|
28
|
+
timeRemaining: string | null
|
|
29
|
+
windowSeconds: number | null
|
|
30
|
+
pace: UsagePace | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UsageSnapshot {
|
|
34
|
+
tool: AIUsageTool
|
|
35
|
+
percent: number | null
|
|
36
|
+
tokens: {
|
|
37
|
+
input: number
|
|
38
|
+
output: number
|
|
39
|
+
cache: number
|
|
40
|
+
total: number
|
|
41
|
+
}
|
|
42
|
+
costUSD: number | null
|
|
43
|
+
resetAt: string | null
|
|
44
|
+
timeRemaining: string | null
|
|
45
|
+
burnRatePerHour: number | null
|
|
46
|
+
lastUpdated: string
|
|
47
|
+
planTier: string | null
|
|
48
|
+
windows: UsageWindow[]
|
|
49
|
+
error?: string
|
|
50
|
+
stale?: boolean
|
|
51
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
getSnapshotTrees,
|
|
13
13
|
toTerminalContentSize,
|
|
14
14
|
} from '../state/layout-resize'
|
|
15
|
+
import { getSnapshotScrollIntents } from '../state/session-persistence'
|
|
15
16
|
|
|
16
17
|
export class LocalSessionBackend
|
|
17
18
|
extends EventEmitter<SessionBackendEvents>
|
|
@@ -66,15 +67,22 @@ export class LocalSessionBackend
|
|
|
66
67
|
})
|
|
67
68
|
this.currentSessionId = options.sessionId
|
|
68
69
|
const trees = getSnapshotTrees(options.workspaceSnapshot)
|
|
70
|
+
const intents = getSnapshotScrollIntents(options.workspaceSnapshot)
|
|
69
71
|
const splitTrees = trees.filter((t) => t.type === 'split')
|
|
70
72
|
if (splitTrees.length > 0) {
|
|
71
73
|
const bounds = createTerminalBounds(options.cols, options.rows)
|
|
72
74
|
forEachSplitPaneRect(splitTrees, bounds, (tabId, rect) => {
|
|
73
75
|
const size = toTerminalContentSize(rect)
|
|
74
|
-
this.sessionManager.resizeTab(
|
|
76
|
+
this.sessionManager.resizeTab(
|
|
77
|
+
options.sessionId,
|
|
78
|
+
tabId,
|
|
79
|
+
size.cols,
|
|
80
|
+
size.rows,
|
|
81
|
+
intents.get(tabId)
|
|
82
|
+
)
|
|
75
83
|
})
|
|
76
84
|
} else {
|
|
77
|
-
this.sessionManager.resize(options.sessionId, options.cols, options.rows)
|
|
85
|
+
this.sessionManager.resize(options.sessionId, options.cols, options.rows, intents)
|
|
78
86
|
}
|
|
79
87
|
const attachResult = this.sessionManager.attachSession(
|
|
80
88
|
options.sessionId,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import { useStore } from 'zustand'
|
|
4
|
+
import { createStore } from 'zustand/vanilla'
|
|
5
|
+
|
|
6
|
+
import type { UsageSnapshot } from '../services/ai-usage/types'
|
|
7
|
+
|
|
8
|
+
export interface AIUsageState {
|
|
9
|
+
enabled: boolean
|
|
10
|
+
snapshots: Partial<Record<AIUsageTool, UsageSnapshot>>
|
|
11
|
+
setEnabled: (enabled: boolean) => void
|
|
12
|
+
setSnapshot: (snap: UsageSnapshot) => void
|
|
13
|
+
clear: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const aiUsageStore = createStore<AIUsageState>((set) => ({
|
|
17
|
+
clear: () => set({ snapshots: {} }),
|
|
18
|
+
enabled: false,
|
|
19
|
+
setEnabled: (enabled: boolean) => set({ enabled }),
|
|
20
|
+
setSnapshot: (snap: UsageSnapshot) =>
|
|
21
|
+
set((state) => {
|
|
22
|
+
const prev = state.snapshots[snap.tool]
|
|
23
|
+
const isFailure = Boolean(snap.error)
|
|
24
|
+
const hasPriorValue = prev && prev.percent !== null
|
|
25
|
+
const merged: UsageSnapshot =
|
|
26
|
+
isFailure && hasPriorValue && prev
|
|
27
|
+
? { ...prev, error: snap.error, lastUpdated: snap.lastUpdated, stale: true }
|
|
28
|
+
: snap
|
|
29
|
+
return { snapshots: { ...state.snapshots, [snap.tool]: merged } }
|
|
30
|
+
}),
|
|
31
|
+
snapshots: {},
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
export function useAIUsageStore<T>(selector: (state: AIUsageState) => T): T {
|
|
35
|
+
return useStore(aiUsageStore, selector)
|
|
36
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { AppAction, AppState, AutoCommitState } from '../types'
|
|
2
|
+
|
|
3
|
+
function setBySession(
|
|
4
|
+
state: AutoCommitState,
|
|
5
|
+
sessionId: string,
|
|
6
|
+
next: AutoCommitState['bySession'][string]
|
|
7
|
+
): AutoCommitState {
|
|
8
|
+
return { bySession: { ...state.bySession, [sessionId]: next } }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function reduceAutoCommitState(
|
|
12
|
+
state: AutoCommitState,
|
|
13
|
+
action: AppAction
|
|
14
|
+
): AutoCommitState | null {
|
|
15
|
+
switch (action.type) {
|
|
16
|
+
case 'auto-commit-generation-started': {
|
|
17
|
+
return setBySession(state, action.sessionId, {
|
|
18
|
+
abortController: action.abortController,
|
|
19
|
+
kind: 'generating',
|
|
20
|
+
startedAt: action.startedAt,
|
|
21
|
+
tabId: action.tabId,
|
|
22
|
+
workingTreeHash: action.workingTreeHash,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
case 'auto-commit-generation-ready': {
|
|
26
|
+
const current = state.bySession[action.sessionId]
|
|
27
|
+
if (!current || current.kind !== 'generating') return null
|
|
28
|
+
if (current.workingTreeHash !== action.workingTreeHash) return null
|
|
29
|
+
return setBySession(state, action.sessionId, {
|
|
30
|
+
body: action.body,
|
|
31
|
+
generatedAt: action.generatedAt,
|
|
32
|
+
kind: 'ready',
|
|
33
|
+
tabId: current.tabId,
|
|
34
|
+
title: action.title,
|
|
35
|
+
workingTreeHash: current.workingTreeHash,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
case 'auto-commit-clear': {
|
|
39
|
+
const current = state.bySession[action.sessionId]
|
|
40
|
+
if (!current || current.kind === 'idle') return null
|
|
41
|
+
if (current.kind === 'generating') {
|
|
42
|
+
try {
|
|
43
|
+
current.abortController.abort()
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return setBySession(state, action.sessionId, { kind: 'idle' })
|
|
49
|
+
}
|
|
50
|
+
default:
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function reduceAutoCommit(state: AppState, action: AppAction): AppState | null {
|
|
56
|
+
const next = reduceAutoCommitState(state.autoCommit, action)
|
|
57
|
+
if (next === null) return null
|
|
58
|
+
return { ...state, autoCommit: next }
|
|
59
|
+
}
|
|
@@ -7,6 +7,7 @@ import { getActiveKeymap } from '../../input/keymap/keymap-ref'
|
|
|
7
7
|
import { getAllAssistantOptions } from '../../pty/command-registry'
|
|
8
8
|
import { filterThemeIds } from '../../ui/filter-themes'
|
|
9
9
|
import { filterAssistants, filterSessions, filterSnippets } from '../selectors'
|
|
10
|
+
import { reduceAutoCommitState } from './auto-commit-state'
|
|
10
11
|
|
|
11
12
|
function emptyModal() {
|
|
12
13
|
return {
|
|
@@ -53,6 +54,19 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
53
54
|
},
|
|
54
55
|
}
|
|
55
56
|
}
|
|
57
|
+
case 'open-ai-usage-modal': {
|
|
58
|
+
return {
|
|
59
|
+
...state,
|
|
60
|
+
focusMode: 'modal',
|
|
61
|
+
modal: {
|
|
62
|
+
cursorPos: 0,
|
|
63
|
+
editBuffer: '',
|
|
64
|
+
selectedIndex: 0,
|
|
65
|
+
sessionTargetId: null,
|
|
66
|
+
type: 'ai-usage',
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
56
70
|
case 'open-help-modal': {
|
|
57
71
|
const keymap = getActiveKeymap()
|
|
58
72
|
const scope = action.scope ?? null
|
|
@@ -199,7 +213,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
199
213
|
type: 'update-available',
|
|
200
214
|
},
|
|
201
215
|
}
|
|
202
|
-
case 'open-git-commit-modal':
|
|
216
|
+
case 'open-git-commit-modal': {
|
|
217
|
+
const sessionId = action.sessionId
|
|
203
218
|
return {
|
|
204
219
|
...state,
|
|
205
220
|
focusMode: 'command-edit',
|
|
@@ -209,10 +224,112 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
209
224
|
cursorPos: 0,
|
|
210
225
|
editBuffer: '',
|
|
211
226
|
selectedIndex: 0,
|
|
212
|
-
sessionTargetId: null,
|
|
227
|
+
sessionTargetId: sessionId ?? null,
|
|
228
|
+
stage: 'edit',
|
|
213
229
|
type: 'git-commit',
|
|
214
230
|
},
|
|
215
231
|
}
|
|
232
|
+
}
|
|
233
|
+
case 'git-commit-enter-confirm': {
|
|
234
|
+
if (state.modal.type !== 'git-commit') return state
|
|
235
|
+
return {
|
|
236
|
+
...state,
|
|
237
|
+
focusMode: 'command-edit',
|
|
238
|
+
modal: { ...state.modal, stage: 'confirm' },
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
case 'git-commit-leave-confirm': {
|
|
242
|
+
if (state.modal.type !== 'git-commit') return state
|
|
243
|
+
return {
|
|
244
|
+
...state,
|
|
245
|
+
focusMode: 'command-edit',
|
|
246
|
+
modal: { ...state.modal, stage: 'edit' },
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
case 'git-commit-enter-generating': {
|
|
250
|
+
if (state.modal.type !== 'git-commit') return state
|
|
251
|
+
return {
|
|
252
|
+
...state,
|
|
253
|
+
focusMode: 'modal',
|
|
254
|
+
modal: { ...state.modal, sessionTargetId: action.sessionId, stage: 'generating' },
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
case 'git-commit-leave-generating': {
|
|
258
|
+
if (state.modal.type !== 'git-commit') return state
|
|
259
|
+
return {
|
|
260
|
+
...state,
|
|
261
|
+
focusMode: 'command-edit',
|
|
262
|
+
modal: { ...state.modal, stage: 'edit' },
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
case 'auto-commit-generation-ready': {
|
|
266
|
+
if (
|
|
267
|
+
state.modal.type !== 'git-commit' ||
|
|
268
|
+
state.modal.stage !== 'generating' ||
|
|
269
|
+
state.modal.sessionTargetId !== action.sessionId
|
|
270
|
+
) {
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
const nextAutoCommit = reduceAutoCommitState(state.autoCommit, action)
|
|
274
|
+
if (!nextAutoCommit) {
|
|
275
|
+
// Stale result (hash mismatch or slice was cleared mid-flight): don't
|
|
276
|
+
// strand the modal in `generating` — flip back to edit so the user
|
|
277
|
+
// isn't stuck staring at a spinner that will never resolve.
|
|
278
|
+
return {
|
|
279
|
+
...state,
|
|
280
|
+
focusMode: 'command-edit',
|
|
281
|
+
modal: { ...state.modal, stage: 'edit' },
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
...state,
|
|
286
|
+
autoCommit: nextAutoCommit,
|
|
287
|
+
focusMode: 'command-edit',
|
|
288
|
+
modal: {
|
|
289
|
+
...state.modal,
|
|
290
|
+
activeField: 'title',
|
|
291
|
+
contentBuffer: action.body,
|
|
292
|
+
cursorPos: action.title.length,
|
|
293
|
+
editBuffer: action.title,
|
|
294
|
+
stage: 'confirm',
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
case 'git-commit-use-background-suggestion': {
|
|
299
|
+
if (state.modal.type !== 'git-commit' || state.modal.sessionTargetId !== action.sessionId) {
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
302
|
+
const suggestion = state.autoCommit.bySession[action.sessionId]
|
|
303
|
+
if (!suggestion || suggestion.kind !== 'ready') return null
|
|
304
|
+
return {
|
|
305
|
+
...state,
|
|
306
|
+
focusMode: 'command-edit',
|
|
307
|
+
modal: {
|
|
308
|
+
...state.modal,
|
|
309
|
+
activeField: 'title',
|
|
310
|
+
contentBuffer: suggestion.body,
|
|
311
|
+
cursorPos: suggestion.title.length,
|
|
312
|
+
editBuffer: suggestion.title,
|
|
313
|
+
stage: 'confirm',
|
|
314
|
+
},
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
case 'auto-commit-clear': {
|
|
318
|
+
if (
|
|
319
|
+
state.modal.type !== 'git-commit' ||
|
|
320
|
+
state.modal.stage !== 'generating' ||
|
|
321
|
+
state.modal.sessionTargetId !== action.sessionId
|
|
322
|
+
) {
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
const nextAutoCommit = reduceAutoCommitState(state.autoCommit, action)
|
|
326
|
+
return {
|
|
327
|
+
...state,
|
|
328
|
+
autoCommit: nextAutoCommit ?? state.autoCommit,
|
|
329
|
+
focusMode: 'command-edit',
|
|
330
|
+
modal: { ...state.modal, stage: 'edit' },
|
|
331
|
+
}
|
|
332
|
+
}
|
|
216
333
|
case 'set-help-entry-count': {
|
|
217
334
|
if (state.modal.type !== 'help') return state
|
|
218
335
|
if (state.modal.entryCount === action.count) {
|
|
@@ -53,27 +53,39 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
|
|
|
53
53
|
),
|
|
54
54
|
}
|
|
55
55
|
case 'delete-session-record': {
|
|
56
|
+
const deletingCurrent = action.sessionId === state.currentSessionId
|
|
56
57
|
const newSessions = state.sessions.filter((session) => session.id !== action.sessionId)
|
|
57
|
-
const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
|
|
58
|
-
const maxIndex = filteredNew.length
|
|
59
|
-
const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
|
|
60
58
|
const nextStatuses = { ...state.sessionStatuses }
|
|
61
59
|
delete nextStatuses[action.sessionId]
|
|
60
|
+
if (action.openSessionPicker) {
|
|
61
|
+
const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
|
|
62
|
+
const maxIndex = filteredNew.length
|
|
63
|
+
const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
|
|
64
|
+
return {
|
|
65
|
+
...state,
|
|
66
|
+
activeTabId: deletingCurrent ? null : state.activeTabId,
|
|
67
|
+
currentSessionId: deletingCurrent ? null : state.currentSessionId,
|
|
68
|
+
focusMode: 'modal',
|
|
69
|
+
modal: {
|
|
70
|
+
editBuffer: null,
|
|
71
|
+
selectedIndex: clampedIndex,
|
|
72
|
+
sessionTargetId: null,
|
|
73
|
+
type: 'session-picker',
|
|
74
|
+
},
|
|
75
|
+
sessions: newSessions,
|
|
76
|
+
sessionStatuses: nextStatuses,
|
|
77
|
+
tabs: deletingCurrent ? [] : state.tabs,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
62
80
|
return {
|
|
63
81
|
...state,
|
|
64
|
-
activeTabId:
|
|
65
|
-
currentSessionId:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
modal: {
|
|
69
|
-
editBuffer: null,
|
|
70
|
-
selectedIndex: clampedIndex,
|
|
71
|
-
sessionTargetId: null,
|
|
72
|
-
type: 'session-picker',
|
|
73
|
-
},
|
|
82
|
+
activeTabId: deletingCurrent ? null : state.activeTabId,
|
|
83
|
+
currentSessionId: deletingCurrent ? null : state.currentSessionId,
|
|
84
|
+
focusMode: deletingCurrent ? 'navigation' : state.focusMode,
|
|
85
|
+
modal: deletingCurrent ? CLOSED_MODAL : state.modal,
|
|
74
86
|
sessions: newSessions,
|
|
75
87
|
sessionStatuses: nextStatuses,
|
|
76
|
-
tabs:
|
|
88
|
+
tabs: deletingCurrent ? [] : state.tabs,
|
|
77
89
|
}
|
|
78
90
|
}
|
|
79
91
|
case 'reorder-sessions': {
|