@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
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # aimux
2
2
 
3
3
  A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, and normal
4
- shell tabs side by side in one TUI with persistent sessions, split panes,
4
+ shell tabs side by side in one TUI with persistent workspaces, split panes,
5
5
  snippets, themes, and fully configurable keymaps.
6
6
 
7
7
  ![Built with Bun](https://img.shields.io/badge/runtime-Bun-f9f1e1)
@@ -12,10 +12,10 @@ snippets, themes, and fully configurable keymaps.
12
12
 
13
13
  ## Features
14
14
 
15
- - multi-session workflow with a dedicated session picker
15
+ - multi-workspace workflow with a dedicated workspace picker
16
16
  - tabs for `claude`, `codex`, `opencode`, and `terminal`
17
17
  - split panes with pane focus and resize shortcuts
18
- - persistent sessions with saved layout and tab state
18
+ - persistent workspaces with saved layout and tab state
19
19
  - profile-isolated config, catalogs, daemon sockets, and runtime state
20
20
  - typed keymap customization through `@brimveyn/aimux-config`
21
21
  - snippets catalog and snippet picker
@@ -51,22 +51,27 @@ import { defineConfig, actions } from '@brimveyn/aimux-config'
51
51
 
52
52
  export default defineConfig({
53
53
  sessionBar: {
54
- position: 'top',
55
- visible: true,
54
+ initialPosition: 'top',
55
+ initialVisible: true,
56
56
  },
57
57
 
58
58
  keymaps: (k) =>
59
- k.mode('navigation', (m) => m.map('<C-p>', actions.sessionPicker, 'Session picker')),
59
+ k.mode('navigation', (m) => m.map('<C-p>', actions.sessionPicker, 'Workspace picker')),
60
60
  })
61
61
  ```
62
62
 
63
+ Typed config is startup intent, not app-managed persisted state. Fields like
64
+ `sessionBar.initialVisible` and `sessionBar.initialPosition` are reapplied on
65
+ every launch, so runtime UI changes for those fields do not stick while the
66
+ config entry remains set.
67
+
63
68
  Then start the app:
64
69
 
65
70
  ```bash
66
71
  aimux
67
72
  ```
68
73
 
69
- On first launch, use the session picker flow to create your first session.
74
+ On first launch, use the workspace picker flow to create your first workspace.
70
75
 
71
76
  For the full setup path, see [`docs/getting-started.md`](docs/getting-started.md).
72
77
 
@@ -95,19 +100,26 @@ profile. See [`docs/concepts/profiles.md`](docs/concepts/profiles.md).
95
100
 
96
101
  - `aimux.config.ts` or `aimux.config.js` - typed user config
97
102
  - `aimux.json` - app-managed preferences and runtime state
98
- - `aimux-sessions.json` - session catalog and workspace snapshots
103
+ - `aimux-sessions.json` - workspace catalog and workspace snapshots
99
104
  - `aimux-snippets.json` - snippet catalog
100
105
 
106
+ Rule of thumb:
107
+
108
+ - `aimux.config.ts` declares startup intent
109
+ - `aimux.json` stores app-managed runtime preferences
110
+ - runtime actions never rewrite `aimux.config.ts`
111
+
101
112
  See [`docs/concepts/config-and-state.md`](docs/concepts/config-and-state.md).
102
113
 
103
- ### Sessions
114
+ ### Workspaces
104
115
 
105
- Sessions are named workspaces. A session can have:
116
+ Workspaces are the top-level user-facing concept in `aimux`. Internally, the
117
+ runtime still uses `session` naming for compatibility. A workspace can have:
106
118
 
107
119
  - a name
108
120
  - an optional project directory
109
121
  - a persisted workspace snapshot
110
- - an order in the session bar and session picker
122
+ - an order in the workspace bar and workspace picker
111
123
 
112
124
  See [`docs/guide/sessions.md`](docs/guide/sessions.md).
113
125
 
@@ -128,13 +140,13 @@ See [`docs/guide/keymaps.md`](docs/guide/keymaps.md).
128
140
  - `i` - focus terminal
129
141
  - `Ctrl+Z` - leave terminal-input mode
130
142
  - `Ctrl+N` - open new-tab modal
131
- - `Ctrl+G` - open session picker
143
+ - `Ctrl+G` - open workspace picker
132
144
  - `Ctrl+S` - open snippet picker
133
145
  - `Ctrl+T` - open theme picker
134
146
  - `Ctrl+B` - toggle sidebar
135
147
  - `Ctrl+D` - enter git mode
136
- - `Ctrl+W b` - toggle session bar
137
- - `Ctrl+W 1` through `Ctrl+W 9` - switch sessions by index
148
+ - `Ctrl+W b` - toggle workspace bar
149
+ - `Ctrl+W 1` through `Ctrl+W 9` - switch workspaces by index
138
150
 
139
151
  The help modal reflects the resolved keymap, so it includes your overrides.
140
152
 
@@ -204,7 +216,7 @@ not collide with a globally installed `aimux` instance.
204
216
  and the source of the bundled theme catalog.
205
217
  - [herdr](https://github.com/ogulcancelik/herdr) by @ogulcancelik — the
206
218
  per-CLI assistant status heuristics (working / waiting-input / idle)
207
- used in the session bar are adapted from herdr's `detect.rs` rule tables.
219
+ used in the workspace bar are adapted from herdr's `detect.rs` rule tables.
208
220
 
209
221
  ## License
210
222
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -60,7 +60,7 @@
60
60
  "bump": "bun run scripts/bump.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@brimveyn/aimux-config": "0.4.5",
63
+ "@brimveyn/aimux-config": "0.5.0",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@xterm/headless": "^6.0.0",
@@ -0,0 +1,286 @@
1
+ import { $ } from 'bun'
2
+
3
+ import type {
4
+ AppAction,
5
+ AppState,
6
+ AssistantId,
7
+ AutoCommitState,
8
+ GitRefreshPayload,
9
+ } from '../state/types'
10
+
11
+ import { buildHeadlessInvocation, isSupportedProvider } from '../auto-commit/headless-commands'
12
+ import { composePromptFromTemplate, loadBriefingTemplate } from '../auto-commit/prompt-loader'
13
+ import { hasStagedFiles } from '../auto-commit/staging-mode'
14
+ import { stripAnsi } from '../auto-commit/strip-ansi'
15
+ import { runSuggestion } from '../auto-commit/suggestion-runner'
16
+ import { workingTreeHash } from '../auto-commit/working-tree-hash'
17
+ import { isCommandAvailable } from '../pty/command-registry'
18
+
19
+ export interface TriggerInput {
20
+ enabled: boolean
21
+ assistant: AssistantId
22
+ hasProjectPath: boolean
23
+ git: GitRefreshPayload
24
+ sessionId: string
25
+ state: AutoCommitState
26
+ currentHash: string
27
+ }
28
+
29
+ export function shouldTriggerAutoCommit(input: TriggerInput): boolean {
30
+ if (!input.enabled) return false
31
+ if (!isSupportedProvider(input.assistant)) return false
32
+ if (!input.hasProjectPath) return false
33
+ if (input.git.files.length === 0) return false
34
+
35
+ const existing = input.state.bySession[input.sessionId]
36
+ if (!existing || existing.kind === 'idle') return true
37
+ if (existing.workingTreeHash === input.currentHash) return false
38
+ return true
39
+ }
40
+
41
+ export interface AutoCommitConfigSnapshot {
42
+ enabled: boolean
43
+ timeoutMs: number
44
+ models: Partial<Record<string, string>>
45
+ }
46
+
47
+ export interface DriverDeps {
48
+ getState: () => AppState
49
+ dispatch: (action: AppAction) => void
50
+ getConfig: () => AutoCommitConfigSnapshot
51
+ getProfileConfigRoot: () => string
52
+ }
53
+
54
+ let cachedTemplate: string | null = null
55
+
56
+ async function getTemplate(deps: DriverDeps): Promise<string> {
57
+ if (cachedTemplate !== null) return cachedTemplate
58
+ cachedTemplate = await loadBriefingTemplate({ profileConfigRoot: deps.getProfileConfigRoot() })
59
+ return cachedTemplate
60
+ }
61
+
62
+ const UNTRACKED_FILE_BYTES_CAP = 8_000
63
+ const SESSION_TAIL_BYTES_CAP = 8_000
64
+
65
+ export function extractSessionTail(buffer: string | undefined): string {
66
+ if (!buffer) return '[no session tail available]'
67
+ const stripped = stripAnsi(buffer).trimEnd()
68
+ if (stripped.length === 0) return '[no session tail available]'
69
+ return stripped.length > SESSION_TAIL_BYTES_CAP
70
+ ? stripped.slice(stripped.length - SESSION_TAIL_BYTES_CAP)
71
+ : stripped
72
+ }
73
+
74
+ interface GatheredContext {
75
+ recentCommits: string
76
+ diff: string
77
+ branch: string
78
+ }
79
+
80
+ async function gatherContext(cwd: string, stagedOnly: boolean): Promise<GatheredContext | null> {
81
+ const logArgs = ['log', '-5', '--format=%h %s']
82
+ const diffArgs = stagedOnly ? ['diff', '--cached'] : ['diff', 'HEAD']
83
+ const branchArgs = ['rev-parse', '--abbrev-ref', 'HEAD']
84
+
85
+ // In staged-only mode we don't include untracked files: a file cannot be
86
+ // staged without first being tracked. Only git-diff-HEAD mode needs the
87
+ // untracked synthetic diffs.
88
+ if (stagedOnly) {
89
+ const [log, diff, branch] = await Promise.all([
90
+ $`git -C ${cwd} ${logArgs}`.quiet().nothrow(),
91
+ $`git -C ${cwd} ${diffArgs}`.quiet().nothrow(),
92
+ $`git -C ${cwd} ${branchArgs}`.quiet().nothrow(),
93
+ ])
94
+ if (log.exitCode !== 0 || diff.exitCode !== 0) return null
95
+ const branchName = branch.exitCode === 0 ? branch.stdout.toString().trim() : ''
96
+ return {
97
+ branch: branchName || '[unknown branch]',
98
+ diff: diff.stdout.toString(),
99
+ recentCommits: log.stdout.toString().trim(),
100
+ }
101
+ }
102
+
103
+ const untrackedArgs = ['ls-files', '--others', '--exclude-standard']
104
+ const [log, diff, untracked, branch] = await Promise.all([
105
+ $`git -C ${cwd} ${logArgs}`.quiet().nothrow(),
106
+ $`git -C ${cwd} ${diffArgs}`.quiet().nothrow(),
107
+ $`git -C ${cwd} ${untrackedArgs}`.quiet().nothrow(),
108
+ $`git -C ${cwd} ${branchArgs}`.quiet().nothrow(),
109
+ ])
110
+ if (log.exitCode !== 0 || diff.exitCode !== 0 || untracked.exitCode !== 0) return null
111
+
112
+ const untrackedPaths = untracked.stdout
113
+ .toString()
114
+ .split('\n')
115
+ .map((p) => p.trim())
116
+ .filter((p) => p.length > 0)
117
+
118
+ const untrackedSections: string[] = []
119
+ for (const path of untrackedPaths) {
120
+ // git diff --no-index exits 1 when files differ (which they always do here);
121
+ // .nothrow() lets us read stdout regardless.
122
+ const diffNew = await $`git -C ${cwd} diff --no-index -- /dev/null ${path}`.quiet().nothrow()
123
+ const text = diffNew.stdout.toString()
124
+ const trimmed =
125
+ text.length > UNTRACKED_FILE_BYTES_CAP
126
+ ? `${text.slice(0, UNTRACKED_FILE_BYTES_CAP)}\n[truncated]`
127
+ : text
128
+ untrackedSections.push(trimmed || `[new file: ${path} — empty or unreadable]`)
129
+ }
130
+
131
+ const branchName = branch.exitCode === 0 ? branch.stdout.toString().trim() : ''
132
+ return {
133
+ branch: branchName || '[unknown branch]',
134
+ diff:
135
+ diff.stdout.toString() +
136
+ (untrackedSections.length > 0 ? `\n${untrackedSections.join('\n')}` : ''),
137
+ recentCommits: log.stdout.toString().trim(),
138
+ }
139
+ }
140
+
141
+ export interface ActivityTransitionArgs {
142
+ sessionId: string
143
+ tabId: string
144
+ assistant: AssistantId
145
+ projectPath: string | undefined
146
+ git: GitRefreshPayload | null
147
+ }
148
+
149
+ export async function onActivityTransition(
150
+ deps: DriverDeps,
151
+ args: ActivityTransitionArgs
152
+ ): Promise<void> {
153
+ const config = deps.getConfig()
154
+ if (!config.enabled) return
155
+ if (!args.git) return
156
+ const currentHash = workingTreeHash(args.git)
157
+
158
+ const state = deps.getState()
159
+ const should = shouldTriggerAutoCommit({
160
+ assistant: args.assistant,
161
+ currentHash,
162
+ enabled: config.enabled,
163
+ git: args.git,
164
+ hasProjectPath: !!args.projectPath,
165
+ sessionId: args.sessionId,
166
+ state: state.autoCommit,
167
+ })
168
+ if (!should) return
169
+
170
+ await runGeneration(deps, args, currentHash, config)
171
+ }
172
+
173
+ export async function onManualTrigger(
174
+ deps: DriverDeps,
175
+ args: ActivityTransitionArgs
176
+ ): Promise<void> {
177
+ const config = deps.getConfig()
178
+ const fail = (reason: string): void => {
179
+ deps.dispatch({ message: `auto-commit: ${reason}`, type: 'git-mode-set-message' })
180
+ deps.dispatch({ sessionId: args.sessionId, type: 'auto-commit-clear' })
181
+ }
182
+ if (!config.enabled) return fail('disabled in config')
183
+ if (!args.git || args.git.files.length === 0) return fail('no changes to summarise')
184
+ if (!args.projectPath) return fail('no project path for current session')
185
+
186
+ const currentHash = workingTreeHash(args.git)
187
+ const existing = deps.getState().autoCommit.bySession[args.sessionId]
188
+ // An in-flight generation will dispatch ready/clear; just wait for it.
189
+ if (existing && existing.kind === 'generating') return
190
+ // Manual trigger is always user-intent to regenerate, even if a ready
191
+ // suggestion exists at the same hash — the user explicitly asked for it.
192
+
193
+ const probe = buildHeadlessInvocation(args.assistant, '__probe__', config.models[args.assistant])
194
+ if (!probe) return fail(`no headless invocation for ${args.assistant}`)
195
+ if (!isCommandAvailable(probe.executable)) {
196
+ return fail(`'${probe.executable}' not found in PATH`)
197
+ }
198
+
199
+ await runGeneration(deps, args, currentHash, config)
200
+ }
201
+
202
+ async function runGeneration(
203
+ deps: DriverDeps,
204
+ args: ActivityTransitionArgs,
205
+ currentHash: string,
206
+ config: AutoCommitConfigSnapshot
207
+ ): Promise<void> {
208
+ const probe = buildHeadlessInvocation(args.assistant, '__probe__', config.models[args.assistant])
209
+ if (!probe) return
210
+ if (!isCommandAvailable(probe.executable)) return
211
+
212
+ // Supersede any in-flight generation for this session. Without this, a
213
+ // rapid sequence of activity transitions (each with a different working
214
+ // tree hash) would leak concurrent headless subprocesses until each hit
215
+ // its 60 s timeout — burning real API credits.
216
+ const existing = deps.getState().autoCommit.bySession[args.sessionId]
217
+ if (existing && existing.kind === 'generating') {
218
+ try {
219
+ existing.abortController.abort()
220
+ } catch {
221
+ // ignore
222
+ }
223
+ }
224
+
225
+ const controller = new AbortController()
226
+ deps.dispatch({
227
+ abortController: controller,
228
+ sessionId: args.sessionId,
229
+ startedAt: Date.now(),
230
+ tabId: args.tabId,
231
+ type: 'auto-commit-generation-started',
232
+ workingTreeHash: currentHash,
233
+ })
234
+
235
+ const template = await getTemplate(deps)
236
+ const stagedOnly = args.git ? hasStagedFiles(args.git) : false
237
+ const ctx = await gatherContext(args.projectPath as string, stagedOnly)
238
+ if (!ctx) {
239
+ deps.dispatch({ sessionId: args.sessionId, type: 'auto-commit-clear' })
240
+ return
241
+ }
242
+ const tab = deps.getState().tabs.find((t) => t.id === args.tabId)
243
+ const sessionTail = extractSessionTail(tab?.buffer)
244
+ const prompt = composePromptFromTemplate(template, { ...ctx, sessionTail })
245
+ const finalInvocation = buildHeadlessInvocation(
246
+ args.assistant,
247
+ prompt,
248
+ config.models[args.assistant]
249
+ )
250
+ if (!finalInvocation) {
251
+ deps.dispatch({ sessionId: args.sessionId, type: 'auto-commit-clear' })
252
+ return
253
+ }
254
+
255
+ const parsed = await runSuggestion({
256
+ invocation: finalInvocation,
257
+ signal: controller.signal,
258
+ timeoutMs: config.timeoutMs,
259
+ })
260
+ if (!parsed) {
261
+ deps.dispatch({ sessionId: args.sessionId, type: 'auto-commit-clear' })
262
+ return
263
+ }
264
+ deps.dispatch({
265
+ body: parsed.body,
266
+ generatedAt: Date.now(),
267
+ sessionId: args.sessionId,
268
+ title: parsed.title,
269
+ type: 'auto-commit-generation-ready',
270
+ workingTreeHash: currentHash,
271
+ })
272
+ }
273
+
274
+ export function onGitRefresh(
275
+ deps: DriverDeps,
276
+ sessionId: string,
277
+ payload: GitRefreshPayload
278
+ ): void {
279
+ const state = deps.getState()
280
+ const current = state.autoCommit.bySession[sessionId]
281
+ if (!current || current.kind === 'idle') return
282
+ const newHash = workingTreeHash(payload)
283
+ if (newHash !== current.workingTreeHash) {
284
+ deps.dispatch({ sessionId, type: 'auto-commit-clear' })
285
+ }
286
+ }
@@ -0,0 +1,20 @@
1
+ import type { ActivityTransitionArgs } from './auto-commit-driver'
2
+
3
+ type TriggerFn = (args: ActivityTransitionArgs) => Promise<void>
4
+
5
+ let activeTrigger: TriggerFn | null = null
6
+
7
+ export function setActiveAutoCommitDriver(trigger: TriggerFn | null): void {
8
+ activeTrigger = trigger
9
+ }
10
+
11
+ // Identity-checked cleanup: only clears the slot if it still holds `trigger`.
12
+ // Prevents a re-mounted hook from nulling out a live registration.
13
+ export function clearActiveAutoCommitDriverIfMatches(trigger: TriggerFn): void {
14
+ if (activeTrigger === trigger) activeTrigger = null
15
+ }
16
+
17
+ export function triggerAutoCommitNow(args: ActivityTransitionArgs): Promise<void> {
18
+ if (!activeTrigger) return Promise.resolve()
19
+ return activeTrigger(args)
20
+ }
@@ -10,6 +10,7 @@ import {
10
10
  getSnapshotTrees,
11
11
  toTerminalContentSize,
12
12
  } from '../state/layout-resize'
13
+ import { getSnapshotScrollIntents } from '../state/session-persistence'
13
14
 
14
15
  export function resizeSnapshotPanes(
15
16
  snapshot: WorkspaceSnapshotV1 | undefined,
@@ -21,13 +22,14 @@ export function resizeSnapshotPanes(
21
22
  }
22
23
 
23
24
  const trees = getSnapshotTrees(snapshot)
25
+ const intents = getSnapshotScrollIntents(snapshot)
24
26
  const bounds = createTerminalBounds(
25
27
  layoutRef.current.terminalCols,
26
28
  layoutRef.current.terminalRows
27
29
  )
28
30
  forEachSplitPaneRect(trees, bounds, (tabId, rect) => {
29
31
  const size = toTerminalContentSize(rect)
30
- backend.resizeTab(tabId, size.cols, size.rows)
32
+ backend.resizeTab(tabId, size.cols, size.rows, intents.get(tabId))
31
33
  })
32
34
  }
33
35
 
@@ -134,7 +134,8 @@ export function handleDeleteSessionEffect(
134
134
  state: AppState,
135
135
  backend: SessionBackend,
136
136
  dispatch: (action: AppAction) => void,
137
- sessionId: string
137
+ sessionId: string,
138
+ options?: { openSessionPicker?: boolean }
138
139
  ): void {
139
140
  const remaining = deleteSessionRecords(state.sessions, sessionId)
140
141
  logInputDebug('app.session.delete', {
@@ -146,7 +147,11 @@ export function handleDeleteSessionEffect(
146
147
  if (sessionId === state.currentSessionId) {
147
148
  void backend.destroy(true)
148
149
  }
149
- dispatch({ sessionId, type: 'delete-session-record' })
150
+ dispatch({
151
+ openSessionPicker: options?.openSessionPicker,
152
+ sessionId,
153
+ type: 'delete-session-record',
154
+ })
150
155
  }
151
156
 
152
157
  export function restartTabSession(
@@ -1,3 +1,4 @@
1
+ import { isAutoCommitEnabled } from '@brimveyn/aimux-config'
1
2
  import { $ } from 'bun'
2
3
 
3
4
  import type { SideEffect } from '../input/modes/types'
@@ -38,6 +39,7 @@ import { filterThemeIds } from '../ui/filter-themes'
38
39
  import { scrollGitDiff } from '../ui/git-view-controls'
39
40
  import { applyTheme, getTransparent, setTransparent } from '../ui/theme'
40
41
  import { type ThemeId } from '../ui/themes'
42
+ import { triggerAutoCommitNow } from './auto-commit-ref'
41
43
  import {
42
44
  handleCreateSessionEffect,
43
45
  handleDeleteSessionEffect,
@@ -100,7 +102,9 @@ function handleSelectedSessionDelete(ctx: SideEffectContext): void {
100
102
  })
101
103
 
102
104
  if (selectedSession) {
103
- handleDeleteSessionEffect(state, backend, dispatch, selectedSession.id)
105
+ handleDeleteSessionEffect(state, backend, dispatch, selectedSession.id, {
106
+ openSessionPicker: true,
107
+ })
104
108
  }
105
109
  }
106
110
 
@@ -477,14 +481,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
477
481
  case 'persist-git-diff-mode-ratio': {
478
482
  const config = loadConfig()
479
483
  const persistedGitPane = config.gitPane
484
+ const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
485
+ const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
480
486
  saveConfig({
481
487
  ...config,
482
488
  gitPane: {
483
489
  diffModeRatio: effect.ratio,
490
+ embeddedRatio,
484
491
  fileListMode: persistedGitPane?.fileListMode,
485
492
  mode: persistedGitPane?.mode ?? 'embedded',
493
+ paneRatio,
486
494
  position: persistedGitPane?.position ?? 'bottom',
487
- ratio: persistedGitPane?.ratio ?? 0.5,
488
495
  treeCompaction: persistedGitPane?.treeCompaction,
489
496
  visible: persistedGitPane?.visible ?? true,
490
497
  },
@@ -494,14 +501,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
494
501
  case 'persist-git-file-list-mode': {
495
502
  const config = loadConfig()
496
503
  const persistedGitPane = config.gitPane
504
+ const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
505
+ const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
497
506
  saveConfig({
498
507
  ...config,
499
508
  gitPane: {
500
509
  diffModeRatio: persistedGitPane?.diffModeRatio,
510
+ embeddedRatio,
501
511
  fileListMode: effect.mode,
502
512
  mode: persistedGitPane?.mode ?? 'embedded',
513
+ paneRatio,
503
514
  position: persistedGitPane?.position ?? 'bottom',
504
- ratio: persistedGitPane?.ratio ?? 0.5,
505
515
  treeCompaction: persistedGitPane?.treeCompaction,
506
516
  visible: persistedGitPane?.visible ?? true,
507
517
  },
@@ -511,14 +521,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
511
521
  case 'persist-git-tree-compaction': {
512
522
  const config = loadConfig()
513
523
  const persistedGitPane = config.gitPane
524
+ const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
525
+ const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
514
526
  saveConfig({
515
527
  ...config,
516
528
  gitPane: {
517
529
  diffModeRatio: persistedGitPane?.diffModeRatio,
530
+ embeddedRatio,
518
531
  fileListMode: persistedGitPane?.fileListMode,
519
532
  mode: persistedGitPane?.mode ?? 'embedded',
533
+ paneRatio,
520
534
  position: persistedGitPane?.position ?? 'bottom',
521
- ratio: persistedGitPane?.ratio ?? 0.5,
522
535
  treeCompaction: effect.enabled,
523
536
  visible: persistedGitPane?.visible ?? true,
524
537
  },
@@ -548,6 +561,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
548
561
  void enqueueGitOp(() => runGitCommit(ctx, title, body))
549
562
  return
550
563
  }
564
+ case 'git-commit-auto': {
565
+ if (!isAutoCommitEnabled()) return
566
+ const { body, title } = effect
567
+ void enqueueGitOp(() => runGitCommitAuto(ctx, title, body))
568
+ return
569
+ }
570
+ case 'generate-auto-commit-now': {
571
+ if (!isAutoCommitEnabled()) return
572
+ void runGenerateAutoCommitNow(ctx, effect.sessionId)
573
+ return
574
+ }
551
575
  case 'git-push': {
552
576
  void enqueueGitOp(() => runGitPush(ctx))
553
577
  return
@@ -675,9 +699,92 @@ async function runGitCommit(ctx: SideEffectContext, title: string, body: string)
675
699
  })
676
700
  return
677
701
  }
702
+ clearAutoCommitForCurrentSession(ctx)
703
+ ctx.dispatch({ message: `committed: ${title}`, type: 'git-mode-set-message' })
704
+ }
705
+
706
+ async function runGitCommitAuto(
707
+ ctx: SideEffectContext,
708
+ title: string,
709
+ body: string
710
+ ): Promise<void> {
711
+ if (!title) {
712
+ ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
713
+ return
714
+ }
715
+ const cwd = ctx.getCurrentSessionProjectPath()
716
+ if (!cwd) return
717
+
718
+ // If the user has manually staged files, respect that intent and commit
719
+ // only the staged set — don't run `git add -A` which would sweep up
720
+ // unrelated unstaged/untracked changes. With nothing staged, `add -A`
721
+ // keeps the "commit everything" behaviour the user expects from auto-commit.
722
+ const hasStaged = ctx.state.gitPanel.files.some((f) => f.section === 'staged')
723
+ if (!hasStaged) {
724
+ const addArgs = ['add', '-A']
725
+ const addResult = await $`git -C ${cwd} ${addArgs}`.quiet().nothrow()
726
+ if (addResult.exitCode !== 0) {
727
+ ctx.dispatch({
728
+ message: addResult.stderr.toString().trim() || 'auto-commit: git add failed',
729
+ type: 'git-mode-set-message',
730
+ })
731
+ return
732
+ }
733
+ }
734
+
735
+ const commitResult = body
736
+ ? await $`git -C ${cwd} commit -m ${title} -m ${body}`.quiet().nothrow()
737
+ : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
738
+
739
+ if (commitResult.exitCode !== 0) {
740
+ ctx.dispatch({
741
+ message: commitResult.stderr.toString().trim() || 'auto-commit: commit failed',
742
+ type: 'git-mode-set-message',
743
+ })
744
+ return
745
+ }
746
+
747
+ clearAutoCommitForCurrentSession(ctx)
678
748
  ctx.dispatch({ message: `committed: ${title}`, type: 'git-mode-set-message' })
679
749
  }
680
750
 
751
+ function clearAutoCommitForCurrentSession(ctx: SideEffectContext): void {
752
+ const sessionId = ctx.state.currentSessionId
753
+ if (!sessionId) return
754
+ ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
755
+ }
756
+
757
+ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: string): Promise<void> {
758
+ const session = ctx.state.sessions.find((s) => s.id === sessionId)
759
+ const panel = ctx.state.gitPanel
760
+ if (panel.error !== null) {
761
+ ctx.dispatch({ message: 'auto-commit: git panel unavailable', type: 'git-mode-set-message' })
762
+ ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
763
+ return
764
+ }
765
+ const tab = ctx.activeTab
766
+ if (!tab) {
767
+ ctx.dispatch({
768
+ message: 'auto-commit: no active assistant tab — open a claude/codex session first',
769
+ type: 'git-mode-set-message',
770
+ })
771
+ ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
772
+ return
773
+ }
774
+ await triggerAutoCommitNow({
775
+ assistant: tab.assistant,
776
+ git: {
777
+ ahead: panel.ahead,
778
+ behind: panel.behind,
779
+ branch: panel.branch,
780
+ files: panel.files,
781
+ },
782
+ projectPath: session?.projectPath,
783
+ sessionId,
784
+ tabId: tab.id,
785
+ })
786
+ }
787
+
681
788
  async function runGitPush(ctx: SideEffectContext): Promise<void> {
682
789
  const cwd = ctx.getCurrentSessionProjectPath()
683
790
  if (!cwd) return