@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.
Files changed (56) hide show
  1. package/README.md +27 -15
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +286 -0
  4. package/src/app-runtime/auto-commit-ref.ts +20 -0
  5. package/src/app-runtime/backend-attach-runtime.ts +3 -1
  6. package/src/app-runtime/session-actions.ts +7 -2
  7. package/src/app-runtime/side-effects.ts +99 -1
  8. package/src/app-runtime/use-auto-commit-driver.ts +133 -0
  9. package/src/app-runtime/use-terminal-resize.ts +20 -12
  10. package/src/app.tsx +59 -22
  11. package/src/auto-commit/default-auto-commit-prompt.md +46 -0
  12. package/src/auto-commit/headless-commands.ts +40 -0
  13. package/src/auto-commit/output-parser.ts +21 -0
  14. package/src/auto-commit/prompt-loader.ts +33 -0
  15. package/src/auto-commit/staging-mode.ts +5 -0
  16. package/src/auto-commit/strip-ansi.ts +13 -0
  17. package/src/auto-commit/suggestion-runner.ts +55 -0
  18. package/src/auto-commit/working-tree-hash.ts +24 -0
  19. package/src/config.ts +24 -0
  20. package/src/daemon/session-registry.ts +1 -0
  21. package/src/index.tsx +1 -1
  22. package/src/input/keymap/help-entries.ts +4 -4
  23. package/src/input/modes/bridge.ts +7 -0
  24. package/src/input/modes/transitions.ts +6 -2
  25. package/src/input/modes/types.ts +5 -0
  26. package/src/ipc/manager-protocol.ts +2 -2
  27. package/src/ipc/protocol.ts +2 -8
  28. package/src/pty/assistant-status-detector.ts +1 -1
  29. package/src/pty/terminal-snapshot.ts +38 -4
  30. package/src/services/ai-usage/adapters/claude.ts +215 -0
  31. package/src/services/ai-usage/adapters/codex.ts +242 -0
  32. package/src/services/ai-usage/cache.ts +60 -0
  33. package/src/services/ai-usage/pace.ts +79 -0
  34. package/src/services/ai-usage/provider.ts +107 -0
  35. package/src/services/ai-usage/spawn.ts +49 -0
  36. package/src/services/ai-usage/types.ts +51 -0
  37. package/src/session-backend/local-session-backend.ts +10 -2
  38. package/src/state/ai-usage-store.ts +36 -0
  39. package/src/state/reducers/auto-commit-state.ts +59 -0
  40. package/src/state/reducers/modal-state.ts +119 -2
  41. package/src/state/reducers/session-state.ts +26 -14
  42. package/src/state/session-persistence.ts +14 -5
  43. package/src/state/store.ts +24 -14
  44. package/src/state/types.ts +62 -2
  45. package/src/state/workspace-save.ts +4 -0
  46. package/src/ui/components/ai-usage-indicator.tsx +163 -0
  47. package/src/ui/components/ai-usage-modal.tsx +186 -0
  48. package/src/ui/components/create-session-modal.tsx +2 -2
  49. package/src/ui/components/git-commit-modal.tsx +167 -18
  50. package/src/ui/components/session-bar.tsx +2 -2
  51. package/src/ui/components/session-picker-modal.tsx +4 -4
  52. package/src/ui/components/sidebar.tsx +3 -1
  53. package/src/ui/components/status-bar.tsx +2 -0
  54. package/src/ui/components/terminal-pane.tsx +18 -3
  55. package/src/ui/root.tsx +14 -2
  56. 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(options.sessionId, tabId, size.cols, size.rows)
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: action.sessionId === state.currentSessionId ? null : state.activeTabId,
65
- currentSessionId:
66
- action.sessionId === state.currentSessionId ? null : state.currentSessionId,
67
- focusMode: 'modal',
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: action.sessionId === state.currentSessionId ? [] : state.tabs,
88
+ tabs: deletingCurrent ? [] : state.tabs,
77
89
  }
78
90
  }
79
91
  case 'reorder-sessions': {