@brimveyn/aimux 1.7.3 → 1.8.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 (62) 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 +111 -4
  8. package/src/app-runtime/split-drag-controller.ts +31 -1
  9. package/src/app-runtime/use-auto-commit-driver.ts +133 -0
  10. package/src/app-runtime/use-mouse-handlers.ts +93 -5
  11. package/src/app-runtime/use-terminal-resize.ts +24 -16
  12. package/src/app.tsx +71 -19
  13. package/src/auto-commit/default-auto-commit-prompt.md +46 -0
  14. package/src/auto-commit/headless-commands.ts +40 -0
  15. package/src/auto-commit/output-parser.ts +21 -0
  16. package/src/auto-commit/prompt-loader.ts +33 -0
  17. package/src/auto-commit/staging-mode.ts +5 -0
  18. package/src/auto-commit/strip-ansi.ts +13 -0
  19. package/src/auto-commit/suggestion-runner.ts +55 -0
  20. package/src/auto-commit/working-tree-hash.ts +24 -0
  21. package/src/config.ts +45 -2
  22. package/src/daemon/session-registry.ts +1 -0
  23. package/src/index.tsx +1 -1
  24. package/src/input/keymap/help-entries.ts +4 -4
  25. package/src/input/modes/bridge.ts +6 -0
  26. package/src/input/modes/transitions.ts +3 -1
  27. package/src/input/modes/types.ts +4 -0
  28. package/src/ipc/manager-protocol.ts +2 -2
  29. package/src/ipc/protocol.ts +2 -8
  30. package/src/pty/assistant-status-detector.ts +1 -1
  31. package/src/pty/terminal-snapshot.ts +38 -4
  32. package/src/services/ai-usage/adapters/claude.ts +139 -0
  33. package/src/services/ai-usage/adapters/codex.ts +191 -0
  34. package/src/services/ai-usage/provider.ts +84 -0
  35. package/src/services/ai-usage/spawn.ts +49 -0
  36. package/src/services/ai-usage/types.ts +20 -0
  37. package/src/session-backend/local-session-backend.ts +10 -2
  38. package/src/state/ai-usage-store.ts +29 -0
  39. package/src/state/git-pane-sizing.ts +15 -0
  40. package/src/state/reducers/auto-commit-state.ts +59 -0
  41. package/src/state/reducers/git-panel-state.ts +12 -7
  42. package/src/state/reducers/modal-state.ts +106 -2
  43. package/src/state/reducers/session-state.ts +26 -14
  44. package/src/state/reducers/ui-state.ts +6 -0
  45. package/src/state/session-persistence.ts +14 -5
  46. package/src/state/store.ts +26 -15
  47. package/src/state/types.ts +59 -3
  48. package/src/state/workspace-save.ts +6 -1
  49. package/src/ui/ai-usage/controller.ts +35 -0
  50. package/src/ui/components/ai-usage-indicator.tsx +131 -0
  51. package/src/ui/components/ai-usage-popover.tsx +152 -0
  52. package/src/ui/components/context-menu-overlay.tsx +4 -2
  53. package/src/ui/components/create-session-modal.tsx +2 -2
  54. package/src/ui/components/git-commit-modal.tsx +167 -18
  55. package/src/ui/components/git-pane-context-menu.ts +26 -0
  56. package/src/ui/components/session-bar.tsx +2 -2
  57. package/src/ui/components/session-picker-modal.tsx +4 -4
  58. package/src/ui/components/sidebar.tsx +117 -31
  59. package/src/ui/components/status-bar.tsx +2 -0
  60. package/src/ui/components/terminal-pane.tsx +18 -3
  61. package/src/ui/root.tsx +114 -13
  62. package/src/ui/status-bar-model.ts +1 -1
@@ -0,0 +1,191 @@
1
+ import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
2
+
3
+ import { readFile } from 'node:fs/promises'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import type { UsageSnapshot } from '../types'
8
+
9
+ interface CodexAuthFile {
10
+ tokens?: {
11
+ access_token?: string
12
+ account_id?: string
13
+ }
14
+ }
15
+
16
+ interface WindowSnapshot {
17
+ used_percent?: number
18
+ reset_at?: number
19
+ limit_window_seconds?: number
20
+ }
21
+
22
+ interface CodexUsageResponse {
23
+ plan_type?: string
24
+ rate_limit?: {
25
+ primary_window?: WindowSnapshot | null
26
+ secondary_window?: WindowSnapshot | null
27
+ }
28
+ credits?: {
29
+ has_credits?: boolean
30
+ unlimited?: boolean
31
+ balance?: number | string | null
32
+ }
33
+ }
34
+
35
+ const DEFAULT_CHATGPT_BASE = 'https://chatgpt.com/backend-api'
36
+ const USAGE_PATH = '/wham/usage'
37
+ const AUTH_TIMEOUT_MS = 15_000
38
+
39
+ function codexHome(): string {
40
+ const env = process.env.CODEX_HOME?.trim()
41
+ if (env) return env
42
+ return join(homedir(), '.codex')
43
+ }
44
+
45
+ function parseChatGPTBaseFromConfig(contents: string): string | null {
46
+ for (const rawLine of contents.split(/\r?\n/)) {
47
+ const line = rawLine.split('#', 1)[0]?.trim() ?? ''
48
+ if (!line) continue
49
+ const eq = line.indexOf('=')
50
+ if (eq < 0) continue
51
+ const key = line.slice(0, eq).trim()
52
+ if (key !== 'chatgpt_base_url') continue
53
+ let value = line.slice(eq + 1).trim()
54
+ if (
55
+ (value.startsWith('"') && value.endsWith('"')) ||
56
+ (value.startsWith("'") && value.endsWith("'"))
57
+ ) {
58
+ value = value.slice(1, -1)
59
+ }
60
+ return value.trim()
61
+ }
62
+ return null
63
+ }
64
+
65
+ function normalizeBase(value: string): string {
66
+ let trimmed = value.trim() || DEFAULT_CHATGPT_BASE
67
+ while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1)
68
+ if (
69
+ (trimmed.startsWith('https://chatgpt.com') || trimmed.startsWith('https://chat.openai.com')) &&
70
+ !trimmed.includes('/backend-api')
71
+ ) {
72
+ trimmed += '/backend-api'
73
+ }
74
+ return trimmed
75
+ }
76
+
77
+ async function resolveUsageURL(): Promise<string> {
78
+ let base = DEFAULT_CHATGPT_BASE
79
+ try {
80
+ const contents = await readFile(join(codexHome(), 'config.toml'), 'utf8')
81
+ const parsed = parseChatGPTBaseFromConfig(contents)
82
+ if (parsed) base = parsed
83
+ } catch {
84
+ // no config.toml or unreadable — fall back to default
85
+ }
86
+ const normalized = normalizeBase(base)
87
+ const path = normalized.includes('/backend-api') ? USAGE_PATH : '/api/codex/usage'
88
+ return normalized + path
89
+ }
90
+
91
+ async function loadAuth(): Promise<{ accessToken: string; accountId: string | null }> {
92
+ const raw = await readFile(join(codexHome(), 'auth.json'), 'utf8')
93
+ const parsed = JSON.parse(raw) as CodexAuthFile
94
+ const accessToken = parsed.tokens?.access_token
95
+ if (!accessToken) {
96
+ throw new Error('no access_token in ~/.codex/auth.json — run `codex` to sign in')
97
+ }
98
+ return {
99
+ accessToken,
100
+ accountId: parsed.tokens?.account_id ?? null,
101
+ }
102
+ }
103
+
104
+ function formatRemainingFromReset(resetAtSeconds: number | undefined): string | null {
105
+ if (!resetAtSeconds) return null
106
+ const diffMs = resetAtSeconds * 1000 - Date.now()
107
+ if (diffMs <= 0) return null
108
+ const totalMin = Math.round(diffMs / 60_000)
109
+ const h = Math.floor(totalMin / 60)
110
+ const m = totalMin % 60
111
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}h`
112
+ }
113
+
114
+ function pickPrimaryWindow(rate: CodexUsageResponse['rate_limit']): WindowSnapshot | null {
115
+ if (!rate) return null
116
+ // Primary = 5h session window. Pick the one closest to 300 minutes (18000s),
117
+ // falling back to whichever is defined.
118
+ const windows = [rate.primary_window, rate.secondary_window].filter(
119
+ (w): w is WindowSnapshot => !!w
120
+ )
121
+ if (windows.length === 0) return null
122
+ const sessionWindow = windows.find((w) => w.limit_window_seconds === 18_000)
123
+ return sessionWindow ?? windows[0] ?? null
124
+ }
125
+
126
+ export async function fetchCodexUsage(_config: AIUsageToolConfig): Promise<UsageSnapshot> {
127
+ const now = new Date().toISOString()
128
+ const base: UsageSnapshot = {
129
+ burnRatePerHour: null,
130
+ costUSD: null,
131
+ lastUpdated: now,
132
+ percent: null,
133
+ resetAt: null,
134
+ timeRemaining: null,
135
+ tokens: { cache: 0, input: 0, output: 0, total: 0 },
136
+ tool: 'codex',
137
+ }
138
+
139
+ try {
140
+ const { accessToken, accountId } = await loadAuth()
141
+ const url = await resolveUsageURL()
142
+
143
+ const headers: Record<string, string> = {
144
+ 'Accept': 'application/json',
145
+ 'Authorization': `Bearer ${accessToken}`,
146
+ 'User-Agent': 'aimux',
147
+ }
148
+ if (accountId) headers['ChatGPT-Account-Id'] = accountId
149
+
150
+ const controller = new AbortController()
151
+ const timer = setTimeout(() => controller.abort(), AUTH_TIMEOUT_MS)
152
+ let response: Response
153
+ try {
154
+ response = await fetch(url, { headers, signal: controller.signal })
155
+ } finally {
156
+ clearTimeout(timer)
157
+ }
158
+
159
+ if (response.status === 401 || response.status === 403) {
160
+ return { ...base, error: 'codex oauth expired — run `codex` to re-auth' }
161
+ }
162
+ if (!response.ok) {
163
+ return { ...base, error: `codex api ${response.status}` }
164
+ }
165
+
166
+ const parsed = (await response.json()) as CodexUsageResponse
167
+ const window = pickPrimaryWindow(parsed.rate_limit)
168
+ if (!window) {
169
+ return { ...base, error: 'no rate_limit data' }
170
+ }
171
+
172
+ const percent =
173
+ typeof window.used_percent === 'number'
174
+ ? Math.max(0, Math.min(100, window.used_percent))
175
+ : null
176
+ const resetAt = window.reset_at ? new Date(window.reset_at * 1000).toISOString() : null
177
+
178
+ return {
179
+ ...base,
180
+ percent,
181
+ resetAt,
182
+ timeRemaining: formatRemainingFromReset(window.reset_at),
183
+ tool: 'codex',
184
+ }
185
+ } catch (error) {
186
+ return {
187
+ ...base,
188
+ error: error instanceof Error ? error.message : String(error),
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,84 @@
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
+
8
+ const DEFAULT_POLL_SECONDS = 60
9
+ const DEFAULT_TOOLS: AIUsageTool[] = ['claude', 'codex']
10
+
11
+ export interface AIUsageServiceHandle {
12
+ stop: () => void
13
+ refresh: () => void
14
+ }
15
+
16
+ async function fetchFor(tool: AIUsageTool, config: AIUsageToolConfig): Promise<UsageSnapshot> {
17
+ switch (tool) {
18
+ case 'claude':
19
+ return fetchClaudeUsage(config)
20
+ case 'codex':
21
+ return fetchCodexUsage(config)
22
+ }
23
+ }
24
+
25
+ export function startAIUsageService(
26
+ config: AIUsageToolConfig,
27
+ onUpdate: (snap: UsageSnapshot) => void
28
+ ): AIUsageServiceHandle {
29
+ const tools = config.tools && config.tools.length > 0 ? config.tools : DEFAULT_TOOLS
30
+ const pollMs = Math.max(5, config.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000
31
+
32
+ let stopped = false
33
+ let timer: ReturnType<typeof setTimeout> | null = null
34
+
35
+ const tick = async (): Promise<void> => {
36
+ if (stopped) return
37
+ const results = await Promise.allSettled(tools.map((t) => fetchFor(t, config)))
38
+ if (stopped) return
39
+ for (let i = 0; i < results.length; i++) {
40
+ const result = results[i]
41
+ const tool = tools[i]
42
+ if (!result || !tool) continue
43
+ if (result.status === 'fulfilled') {
44
+ onUpdate(result.value)
45
+ } else {
46
+ onUpdate({
47
+ burnRatePerHour: null,
48
+ costUSD: null,
49
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
50
+ lastUpdated: new Date().toISOString(),
51
+ percent: null,
52
+ resetAt: null,
53
+ timeRemaining: null,
54
+ tokens: { cache: 0, input: 0, output: 0, total: 0 },
55
+ tool,
56
+ })
57
+ }
58
+ }
59
+ if (!stopped) {
60
+ timer = setTimeout(() => {
61
+ void tick()
62
+ }, pollMs)
63
+ }
64
+ }
65
+
66
+ void tick()
67
+
68
+ return {
69
+ refresh: () => {
70
+ if (timer) {
71
+ clearTimeout(timer)
72
+ timer = null
73
+ }
74
+ void tick()
75
+ },
76
+ stop: () => {
77
+ stopped = true
78
+ if (timer) {
79
+ clearTimeout(timer)
80
+ timer = null
81
+ }
82
+ },
83
+ }
84
+ }
@@ -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,20 @@
1
+ import type { AIUsageTool } from '@brimveyn/aimux-config'
2
+
3
+ export type { AIUsageTool }
4
+
5
+ export interface UsageSnapshot {
6
+ tool: AIUsageTool
7
+ percent: number | null
8
+ tokens: {
9
+ input: number
10
+ output: number
11
+ cache: number
12
+ total: number
13
+ }
14
+ costUSD: number | null
15
+ resetAt: string | null
16
+ timeRemaining: string | null
17
+ burnRatePerHour: number | null
18
+ lastUpdated: string
19
+ error?: string
20
+ }
@@ -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,29 @@
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
+ snapshots: { ...state.snapshots, [snap.tool]: snap },
23
+ })),
24
+ snapshots: {},
25
+ }))
26
+
27
+ export function useAIUsageStore<T>(selector: (state: AIUsageState) => T): T {
28
+ return useStore(aiUsageStore, selector)
29
+ }
@@ -0,0 +1,15 @@
1
+ export const GIT_PANE_MIN_RATIO = 0.2
2
+ export const GIT_PANE_MAX_RATIO = 0.8
3
+ export const GIT_PANE_MIN_WIDTH = 20
4
+ export const GIT_PANE_MAX_WIDTH = 80
5
+
6
+ export function clampGitPaneRatio(value: number): number {
7
+ return Math.max(GIT_PANE_MIN_RATIO, Math.min(GIT_PANE_MAX_RATIO, value))
8
+ }
9
+
10
+ export function getGitPaneWidthFromRatio(ratio: number): number {
11
+ return Math.max(
12
+ GIT_PANE_MIN_WIDTH,
13
+ Math.min(GIT_PANE_MAX_WIDTH, Math.round(clampGitPaneRatio(ratio) * GIT_PANE_MAX_WIDTH))
14
+ )
15
+ }
@@ -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
+ }
@@ -1,11 +1,9 @@
1
1
  import type { AppAction, AppState, GitFileEntry, GitFileSection, GitPanelState } from '../types'
2
2
 
3
+ import { clampGitPaneRatio } from '../git-pane-sizing'
3
4
  import { reconcileSelectedGitEntryKey } from '../git-tree'
4
5
  import { clearDiffCacheForPaths } from './diff-cache'
5
6
 
6
- export const GIT_PANEL_MIN_RATIO = 0.2
7
- export const GIT_PANEL_MAX_RATIO = 0.8
8
-
9
7
  const SECTION_RANK: Record<GitFileSection, number> = {
10
8
  historical: 0,
11
9
  staged: 1,
@@ -23,7 +21,7 @@ export function sortFilesBySection(files: GitFileEntry[]): GitFileEntry[] {
23
21
  }
24
22
 
25
23
  function clampRatio(value: number): number {
26
- return Math.max(GIT_PANEL_MIN_RATIO, Math.min(GIT_PANEL_MAX_RATIO, value))
24
+ return clampGitPaneRatio(value)
27
25
  }
28
26
 
29
27
  export function emptyGitPanel(): GitPanelState {
@@ -93,9 +91,16 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
93
91
  }
94
92
  }
95
93
  case 'resize-git-pane': {
96
- const nextRatio = clampRatio(state.gitPane.ratio + action.delta)
97
- if (nextRatio === state.gitPane.ratio) return state
98
- return { ...state, gitPane: { ...state.gitPane, ratio: nextRatio } }
94
+ const target = state.gitPane.mode === 'pane' ? 'paneRatio' : 'embeddedRatio'
95
+ const nextRatio = clampRatio(state.gitPane[target] + action.delta)
96
+ if (nextRatio === state.gitPane[target]) return state
97
+ return { ...state, gitPane: { ...state.gitPane, [target]: nextRatio } }
98
+ }
99
+ case 'set-git-pane-ratio': {
100
+ const key = action.target === 'pane' ? 'paneRatio' : 'embeddedRatio'
101
+ const nextRatio = clampRatio(action.ratio)
102
+ if (nextRatio === state.gitPane[key]) return state
103
+ return { ...state, gitPane: { ...state.gitPane, [key]: nextRatio } }
99
104
  }
100
105
  case 'resize-git-diff-pane': {
101
106
  const nextRatio = clampRatio(state.gitPane.diffModeRatio + action.delta)
@@ -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 {
@@ -199,7 +200,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
199
200
  type: 'update-available',
200
201
  },
201
202
  }
202
- case 'open-git-commit-modal':
203
+ case 'open-git-commit-modal': {
204
+ const sessionId = action.sessionId
203
205
  return {
204
206
  ...state,
205
207
  focusMode: 'command-edit',
@@ -209,10 +211,112 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
209
211
  cursorPos: 0,
210
212
  editBuffer: '',
211
213
  selectedIndex: 0,
212
- sessionTargetId: null,
214
+ sessionTargetId: sessionId ?? null,
215
+ stage: 'edit',
213
216
  type: 'git-commit',
214
217
  },
215
218
  }
219
+ }
220
+ case 'git-commit-enter-confirm': {
221
+ if (state.modal.type !== 'git-commit') return state
222
+ return {
223
+ ...state,
224
+ focusMode: 'command-edit',
225
+ modal: { ...state.modal, stage: 'confirm' },
226
+ }
227
+ }
228
+ case 'git-commit-leave-confirm': {
229
+ if (state.modal.type !== 'git-commit') return state
230
+ return {
231
+ ...state,
232
+ focusMode: 'command-edit',
233
+ modal: { ...state.modal, stage: 'edit' },
234
+ }
235
+ }
236
+ case 'git-commit-enter-generating': {
237
+ if (state.modal.type !== 'git-commit') return state
238
+ return {
239
+ ...state,
240
+ focusMode: 'modal',
241
+ modal: { ...state.modal, sessionTargetId: action.sessionId, stage: 'generating' },
242
+ }
243
+ }
244
+ case 'git-commit-leave-generating': {
245
+ if (state.modal.type !== 'git-commit') return state
246
+ return {
247
+ ...state,
248
+ focusMode: 'command-edit',
249
+ modal: { ...state.modal, stage: 'edit' },
250
+ }
251
+ }
252
+ case 'auto-commit-generation-ready': {
253
+ if (
254
+ state.modal.type !== 'git-commit' ||
255
+ state.modal.stage !== 'generating' ||
256
+ state.modal.sessionTargetId !== action.sessionId
257
+ ) {
258
+ return null
259
+ }
260
+ const nextAutoCommit = reduceAutoCommitState(state.autoCommit, action)
261
+ if (!nextAutoCommit) {
262
+ // Stale result (hash mismatch or slice was cleared mid-flight): don't
263
+ // strand the modal in `generating` — flip back to edit so the user
264
+ // isn't stuck staring at a spinner that will never resolve.
265
+ return {
266
+ ...state,
267
+ focusMode: 'command-edit',
268
+ modal: { ...state.modal, stage: 'edit' },
269
+ }
270
+ }
271
+ return {
272
+ ...state,
273
+ autoCommit: nextAutoCommit,
274
+ focusMode: 'command-edit',
275
+ modal: {
276
+ ...state.modal,
277
+ activeField: 'title',
278
+ contentBuffer: action.body,
279
+ cursorPos: action.title.length,
280
+ editBuffer: action.title,
281
+ stage: 'confirm',
282
+ },
283
+ }
284
+ }
285
+ case 'git-commit-use-background-suggestion': {
286
+ if (state.modal.type !== 'git-commit' || state.modal.sessionTargetId !== action.sessionId) {
287
+ return null
288
+ }
289
+ const suggestion = state.autoCommit.bySession[action.sessionId]
290
+ if (!suggestion || suggestion.kind !== 'ready') return null
291
+ return {
292
+ ...state,
293
+ focusMode: 'command-edit',
294
+ modal: {
295
+ ...state.modal,
296
+ activeField: 'title',
297
+ contentBuffer: suggestion.body,
298
+ cursorPos: suggestion.title.length,
299
+ editBuffer: suggestion.title,
300
+ stage: 'confirm',
301
+ },
302
+ }
303
+ }
304
+ case 'auto-commit-clear': {
305
+ if (
306
+ state.modal.type !== 'git-commit' ||
307
+ state.modal.stage !== 'generating' ||
308
+ state.modal.sessionTargetId !== action.sessionId
309
+ ) {
310
+ return null
311
+ }
312
+ const nextAutoCommit = reduceAutoCommitState(state.autoCommit, action)
313
+ return {
314
+ ...state,
315
+ autoCommit: nextAutoCommit ?? state.autoCommit,
316
+ focusMode: 'command-edit',
317
+ modal: { ...state.modal, stage: 'edit' },
318
+ }
319
+ }
216
320
  case 'set-help-entry-count': {
217
321
  if (state.modal.type !== 'help') return state
218
322
  if (state.modal.entryCount === action.count) {