@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.
- 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 +111 -4
- package/src/app-runtime/split-drag-controller.ts +31 -1
- package/src/app-runtime/use-auto-commit-driver.ts +133 -0
- package/src/app-runtime/use-mouse-handlers.ts +93 -5
- package/src/app-runtime/use-terminal-resize.ts +24 -16
- package/src/app.tsx +71 -19
- 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 +45 -2
- 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 +6 -0
- package/src/input/modes/transitions.ts +3 -1
- package/src/input/modes/types.ts +4 -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 +139 -0
- package/src/services/ai-usage/adapters/codex.ts +191 -0
- package/src/services/ai-usage/provider.ts +84 -0
- package/src/services/ai-usage/spawn.ts +49 -0
- package/src/services/ai-usage/types.ts +20 -0
- package/src/session-backend/local-session-backend.ts +10 -2
- package/src/state/ai-usage-store.ts +29 -0
- package/src/state/git-pane-sizing.ts +15 -0
- package/src/state/reducers/auto-commit-state.ts +59 -0
- package/src/state/reducers/git-panel-state.ts +12 -7
- package/src/state/reducers/modal-state.ts +106 -2
- package/src/state/reducers/session-state.ts +26 -14
- package/src/state/reducers/ui-state.ts +6 -0
- package/src/state/session-persistence.ts +14 -5
- package/src/state/store.ts +26 -15
- package/src/state/types.ts +59 -3
- package/src/state/workspace-save.ts +6 -1
- package/src/ui/ai-usage/controller.ts +35 -0
- package/src/ui/components/ai-usage-indicator.tsx +131 -0
- package/src/ui/components/ai-usage-popover.tsx +152 -0
- package/src/ui/components/context-menu-overlay.tsx +4 -2
- package/src/ui/components/create-session-modal.tsx +2 -2
- package/src/ui/components/git-commit-modal.tsx +167 -18
- package/src/ui/components/git-pane-context-menu.ts +26 -0
- 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 +117 -31
- package/src/ui/components/status-bar.tsx +2 -0
- package/src/ui/components/terminal-pane.tsx +18 -3
- package/src/ui/root.tsx +114 -13
- 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
|
|
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
|

|
|
@@ -12,10 +12,10 @@ snippets, themes, and fully configurable keymaps.
|
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
|
-
- multi-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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, '
|
|
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
|
|
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` -
|
|
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
|
-
###
|
|
114
|
+
### Workspaces
|
|
104
115
|
|
|
105
|
-
|
|
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
|
|
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
|
|
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
|
|
137
|
-
- `Ctrl+W 1` through `Ctrl+W 9` - switch
|
|
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
|
|
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.
|
|
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.
|
|
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({
|
|
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
|