@brimveyn/aimux 1.22.3 → 1.22.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.22.3",
3
+ "version": "1.22.4",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -8,7 +8,7 @@ import { loadConfig, saveConfig } from '../config'
8
8
  import { logInputDebug } from '../debug/input-log'
9
9
  import { enqueueGitOp } from '../git/command-queue'
10
10
  import { countDirtyFiles } from '../git/move-workspace'
11
- import { getDefaultBranch, listLocalBranches } from '../git/worktree'
11
+ import { getCurrentBranch, getDefaultBranch, listLocalBranches } from '../git/worktree'
12
12
  import { assistantAcceptsPromptArg } from '../pty/command-registry'
13
13
  import { allLeafIds, getGroupIdForTab } from '../state/layout-tree'
14
14
  import { saveCurrentProject } from '../state/project-save'
@@ -72,6 +72,7 @@ import {
72
72
  isForceableWorkspaceDeleteError,
73
73
  runDeleteWorkspace,
74
74
  runMoveWorkspace,
75
+ setProjectDefaultBaseRef,
75
76
  } from './workspace-actions'
76
77
  import { placeholderWorkspaceName, renameWorkspaceFromPrompt } from './workspace-naming'
77
78
 
@@ -207,6 +208,12 @@ function applyThemeEffect(
207
208
  }
208
209
  }
209
210
 
211
+ /** The given branch first, the rest in the order they came. */
212
+ function hoistBranch(branches: string[], first: string | undefined): string[] {
213
+ if (first == null || !branches.includes(first)) return branches
214
+ return [first, ...branches.filter((branch) => branch !== first)]
215
+ }
216
+
210
217
  /**
211
218
  * Setup runs concurrently with the agent by design, so say so rather than
212
219
  * letting the agent run tests against a half-installed tree and draw the wrong
@@ -359,12 +366,21 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
359
366
  const project = state.projects.find((entry) => entry.id === state.currentProjectId)
360
367
  const sourcePath = getActiveWorkspace(project)?.path ?? getActiveWorkspacePath(project)
361
368
  if (!(sourcePath != null && sourcePath !== '')) return
362
- const [branches, defaultBranch] = await Promise.all([
369
+ const [branches, defaultBranch, currentBranch] = await Promise.all([
363
370
  listLocalBranches(sourcePath),
364
371
  getDefaultBranch(sourcePath),
372
+ getCurrentBranch(sourcePath),
365
373
  ])
366
374
  if (ctx.getState().modal.type !== 'create-workspace') return
367
- ctx.dispatch({ branches, defaultBranch, type: 'set-create-workspace-base-branches' })
375
+ ctx.dispatch({
376
+ // Stacking on the branch you are already on is the other common base,
377
+ // and committer date buries it once anyone else pushes.
378
+ branches: hoistBranch(branches, currentBranch),
379
+ // The project's convention wins over what the repo declares: a gitflow
380
+ // repo still says `main` while everyone branches off `develop`.
381
+ defaultBranch: project?.defaultBaseRef ?? defaultBranch,
382
+ type: 'set-create-workspace-base-branches',
383
+ })
368
384
  })()
369
385
  return
370
386
  }
@@ -678,6 +694,10 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
678
694
  handleConfigureSetupScriptEffect(ctx, effect.projectId)
679
695
  return
680
696
  }
697
+ case 'set-project-default-base-ref': {
698
+ setProjectDefaultBaseRef(ctx, effect.projectId, effect.baseRef)
699
+ return
700
+ }
681
701
  case 'ask-agent-for-setup-script': {
682
702
  handleAskAgentForSetupScriptEffect(ctx)
683
703
  return
@@ -729,11 +749,18 @@ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
729
749
  void ctx.backend.destroy(true)
730
750
  ctx.renderer.destroy()
731
751
  process.stdout.write(`\nUpdating aimux to ${latestVersion}...\n`)
732
- const proc = Bun.spawn(['bun', 'update', '-g', '@brimveyn/aimux', '@brimveyn/aimux-config'], {
733
- stderr: 'inherit',
734
- stdin: 'inherit',
735
- stdout: 'inherit',
736
- })
752
+ // `bun update -g` is a no-op when the global entry pins an exact version
753
+ // (`"@brimveyn/aimux": "1.22.2"`), which is what `install -g pkg@version`
754
+ // writes — the update "succeeded", the version never moved, and the modal
755
+ // came back every launch. Install the resolved version explicitly instead.
756
+ const proc = Bun.spawn(
757
+ ['bun', 'install', '-g', `@brimveyn/aimux@${latestVersion}`, '@brimveyn/aimux-config@latest'],
758
+ {
759
+ stderr: 'inherit',
760
+ stdin: 'inherit',
761
+ stdout: 'inherit',
762
+ }
763
+ )
737
764
  void (async () => {
738
765
  const code = await proc.exited
739
766
  if (code === 0) {
@@ -23,6 +23,7 @@ import {
23
23
  makeWorktreePath,
24
24
  sanitizePathSegment,
25
25
  } from '../platform/worktree-paths'
26
+ import { shouldRefreshBase } from '../settings/flags'
26
27
  import { saveProjectCatalog } from '../state/project-catalog'
27
28
  import { pruneSnapshotOfWorkspace } from '../state/project-persistence'
28
29
  import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
@@ -64,6 +65,27 @@ export function handleSwitchWorkspace(
64
65
  ctx.dispatch({ projects, type: 'set-projects' })
65
66
  }
66
67
 
68
+ /**
69
+ * Set (or clear, when blank) the branch this project's new workspaces fork from.
70
+ * Here rather than with the project actions: the value is workspace policy that
71
+ * happens to be stored on the project, and this is where the record-update
72
+ * helper and the rest of that policy already live.
73
+ */
74
+ export function setProjectDefaultBaseRef(
75
+ ctx: SideEffectContext,
76
+ projectId: string,
77
+ baseRef: string
78
+ ): void {
79
+ const trimmed = baseRef.trim()
80
+ const projects = replaceProject(ctx, projectId, (entry) => ({
81
+ ...entry,
82
+ defaultBaseRef: trimmed === '' ? undefined : trimmed,
83
+ updatedAt: new Date().toISOString(),
84
+ }))
85
+ saveProjectCatalog(projects)
86
+ ctx.dispatch({ projects, type: 'set-projects' })
87
+ }
88
+
67
89
  function normalizeBranchName(branch: string | undefined): string | undefined {
68
90
  return branch?.replace(/^refs\/heads\//, '').trim()
69
91
  }
@@ -116,11 +138,19 @@ export async function createAimuxTempWorkspace(
116
138
 
117
139
  await mkdir(dirname(targetPath), { recursive: true })
118
140
  await assertSafeAimuxWorktreePath(targetPath)
119
- await createGitWorktree({ baseRef, branchName, repoPath: repoRoot, targetPath })
141
+ // What it forked from, not what was asked for: the two differ whenever the
142
+ // base was refreshed from origin, and the record is what "based on" reads.
143
+ const forkRef = await createGitWorktree({
144
+ baseRef,
145
+ branchName,
146
+ refreshBase: shouldRefreshBase(),
147
+ repoPath: repoRoot,
148
+ targetPath,
149
+ })
120
150
  const now = new Date().toISOString()
121
151
 
122
152
  const workspace: WorkspaceRecord = {
123
- baseRef,
153
+ baseRef: forkRef,
124
154
  branch: branchName,
125
155
  commitSha: await getHeadSha(targetPath),
126
156
  createdAt: now,
@@ -10,6 +10,7 @@ import {
10
10
  makeWorktreePath,
11
11
  pruneEmptyWorktreeParent,
12
12
  } from '../../../platform/worktree-paths'
13
+ import { shouldRefreshBase } from '../../../settings/flags'
13
14
 
14
15
  export interface CreateWorkspaceParams {
15
16
  /** Base ref for the branch (callers default to 'HEAD'). */
@@ -68,10 +69,12 @@ export async function createProjectWorkspace(
68
69
  await ensureAimuxWorktreeRoot()
69
70
  await assertSafeAimuxWorktreePath(targetPath)
70
71
 
72
+ let forkRef: string
71
73
  try {
72
- await createGitWorktree({
74
+ forkRef = await createGitWorktree({
73
75
  baseRef: base,
74
76
  branchName: branch,
77
+ refreshBase: shouldRefreshBase(),
75
78
  repoPath: primary.repoRoot,
76
79
  targetPath,
77
80
  })
@@ -84,7 +87,8 @@ export async function createProjectWorkspace(
84
87
 
85
88
  const now = new Date().toISOString()
86
89
  const record: WorkspaceRecord = {
87
- baseRef: base,
90
+ // What it forked from: `origin/<base>` when the base was refreshed.
91
+ baseRef: forkRef,
88
92
  branch,
89
93
  createdAt: now,
90
94
  createdByAimux: true,
@@ -98,9 +98,48 @@ export async function renameGitBranch(
98
98
  return result.exitCode === 0
99
99
  }
100
100
 
101
+ /**
102
+ * The ref a new workspace should actually fork from.
103
+ *
104
+ * "Base it on main" means the main the team pushed, not whatever the local main
105
+ * happened to be at the last pull — a workspace started ten commits behind says
106
+ * nothing at the time and shows up as conflicts at merge. So fetch the base's
107
+ * counterpart on origin and fork from that when there is one.
108
+ *
109
+ * Every step is best-effort on purpose. The fetch is `nothrow`, so a machine
110
+ * with no network still gets a workspace off the local ref; and a base with no
111
+ * counterpart on origin — a local-only branch, a SHA, `HEAD`, or an
112
+ * already-qualified `origin/x` — resolves to nothing and is used as given, which
113
+ * is why no ref-shape guessing is needed here.
114
+ *
115
+ * The refspec is spelled out rather than left to `remote.origin.fetch`: a bare
116
+ * `fetch origin main` on a remote with no configured refspec updates only
117
+ * FETCH_HEAD, leaving a stale `origin/main` that resolves perfectly well — so
118
+ * the fork would silently land on exactly the old commit this exists to avoid.
119
+ *
120
+ * ponytail: unpushed commits on the local base are unreachable from the picker
121
+ * once this is on — the remote ref wins whenever it exists, and the toggle is
122
+ * the only way back. Listing `main` and `origin/main` as separate base options
123
+ * is the upgrade path.
124
+ */
125
+ export async function resolveWorktreeBaseRef(repoPath: string, baseRef: string): Promise<string> {
126
+ const remoteRef = `origin/${baseRef}`
127
+ await $`git -C ${repoPath} fetch origin +${baseRef}:refs/remotes/${remoteRef}`.quiet().nothrow()
128
+ return (await resolveGitRef(repoPath, remoteRef)) !== undefined ? remoteRef : baseRef
129
+ }
130
+
131
+ /**
132
+ * Returns the ref it forked from, which is not always the one it was handed.
133
+ *
134
+ * `refreshBase` is required rather than read from the user's config here: this
135
+ * module wraps git and nothing else, and a caller that has to name the policy
136
+ * cannot forget it exists — which a settings lookup hidden in here would let
137
+ * them do. `shouldRefreshBase` is what both callers name it with.
138
+ */
101
139
  export async function createGitWorktree({
102
140
  baseRef,
103
141
  branchName,
142
+ refreshBase,
104
143
  repoPath,
105
144
  targetPath,
106
145
  }: {
@@ -108,10 +147,18 @@ export async function createGitWorktree({
108
147
  targetPath: string
109
148
  branchName: string
110
149
  baseRef: string
111
- }): Promise<void> {
112
- const result = await $`git -C ${repoPath} worktree add -b ${branchName} ${targetPath} ${baseRef}`
113
- .quiet()
114
- .nothrow()
150
+ refreshBase: boolean
151
+ }): Promise<string> {
152
+ const forkRef = refreshBase ? await resolveWorktreeBaseRef(repoPath, baseRef) : baseRef
153
+ // `--no-track`: forking from `origin/main` would otherwise set that as the new
154
+ // branch's upstream, and a workspace branch tracking main is not a workspace
155
+ // branch — `git push` either refuses it (push.default=simple) or, worse,
156
+ // pushes the branch onto main. Forking from a local branch never set tracking,
157
+ // so this only pins down what refreshing the base would have changed.
158
+ const result =
159
+ await $`git -C ${repoPath} worktree add --no-track -b ${branchName} ${targetPath} ${forkRef}`
160
+ .quiet()
161
+ .nothrow()
115
162
  if (result.exitCode !== 0) {
116
163
  // Always name the repository. A bare "fatal: not a valid object name: 'X'"
117
164
  // reads as a ref-resolution bug when the real cause is that git ran in a
@@ -119,6 +166,7 @@ export async function createGitWorktree({
119
166
  const stderr = result.stderr.toString().trim()
120
167
  throw new Error(`${stderr || 'failed to create git worktree'} (in ${repoPath})`)
121
168
  }
169
+ return forkRef
122
170
  }
123
171
 
124
172
  // Force-delete a local branch. Returns false (without throwing) when git
@@ -114,6 +114,7 @@ export type SideEffect =
114
114
  | { type: 'run-setup' }
115
115
  | { type: 'stop-setup' }
116
116
  | { type: 'configure-setup-script'; projectId?: string }
117
+ | { type: 'set-project-default-base-ref'; projectId: string; baseRef: string }
117
118
  | { type: 'ask-agent-for-setup-script' }
118
119
  | { type: 'promote-setup-tab' }
119
120
  /** Toggle a checkbox, cycle an enum, run a row's action — whatever the row is. */
@@ -0,0 +1,15 @@
1
+ import { loadConfig } from '../config'
2
+ import { FETCH_BASE } from './sections/git'
3
+
4
+ /**
5
+ * Settings read outside the screen, off the file rather than the store.
6
+ *
7
+ * `live.ts` is the same read side for the running TUI, through hooks over the
8
+ * hydrated store. These have to answer where there is no store: the CLI never
9
+ * hydrates one, and a setting only the TUI honours is a setting that lies.
10
+ */
11
+
12
+ /** Whether a new workspace forks from the base as pushed, or as last pulled. */
13
+ export function shouldRefreshBase(): boolean {
14
+ return loadConfig().settings?.[FETCH_BASE] !== false
15
+ }
@@ -5,6 +5,8 @@ import type { SettingSection, SettingValue } from '../types'
5
5
 
6
6
  import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
7
7
 
8
+ export const FETCH_BASE = 'git.fetchBase'
9
+
8
10
  function isFileListMode(value: SettingValue): value is GitFileListMode {
9
11
  return value === 'tree' || value === 'flat'
10
12
  }
@@ -81,6 +83,16 @@ export const GIT_SECTION: SettingSection = {
81
83
  step: 1,
82
84
  storage: 'settings',
83
85
  },
86
+ {
87
+ // No `apply`: `shouldRefreshBase` reads this off the file, so the CLI —
88
+ // where this screen never hydrates — honours it too.
89
+ description: 'Fetch the base branch before forking from it. Off forks from your local copy.',
90
+ fallback: true,
91
+ id: FETCH_BASE,
92
+ kind: 'toggle',
93
+ label: 'Refresh the base branch',
94
+ storage: 'settings',
95
+ },
84
96
  {
85
97
  apply: (value) => setMultiRepoConfig({ ...getMultiRepoConfig(), enabled: value === true }),
86
98
  description: 'Aggregate the status of git repos nested under the project.',
@@ -13,6 +13,7 @@ import { LAYOUT_SECTION } from './layout'
13
13
  import { NOTIFICATIONS_SECTION } from './notifications'
14
14
  import { SETUP_SECTION } from './setup'
15
15
  import { STATUS_BAR_SECTION } from './status-bar'
16
+ import { WORKSPACE_SECTION } from './workspace'
16
17
 
17
18
  /**
18
19
  * Every section, in the order the screen lists them. Adding a setting is one
@@ -24,6 +25,7 @@ export const SETTING_SECTIONS: readonly SettingSection[] = [
24
25
  AUTOMATION_SECTION,
25
26
  NOTIFICATIONS_SECTION,
26
27
  SETUP_SECTION,
28
+ WORKSPACE_SECTION,
27
29
  GIT_SECTION,
28
30
  COMMANDS_SECTION,
29
31
  EDITOR_SECTION,
@@ -0,0 +1,44 @@
1
+ import type { ProjectRecord } from '../../state/types'
2
+ import type { SettingRow, SettingSection } from '../types'
3
+
4
+ import { runSideEffectGlobal } from '../../state/dispatch-ref'
5
+
6
+ const BASE_REF_ROW_PREFIX = 'workspace.defaultBaseRef.'
7
+
8
+ /**
9
+ * One row per project, over `defaultBaseRef` on its catalog record.
10
+ *
11
+ * Per project rather than global because the convention belongs to the repo:
12
+ * one clone branches off `develop`, the next off `main`, and a single value
13
+ * would be wrong for every repo but one. Left empty it follows whatever the
14
+ * repo declares as its default branch, which is the right answer most of the
15
+ * time and the reason this is a row and not a prompt.
16
+ */
17
+ function defaultBaseRefRow(project: ProjectRecord): SettingRow {
18
+ return {
19
+ description: "Branch new workspaces fork from. Empty follows the repo's own default.",
20
+ id: `${BASE_REF_ROW_PREFIX}${project.id}`,
21
+ kind: 'text',
22
+ label: project.name,
23
+ placeholder: "the repo's default",
24
+ // Read through the live state, not the record this row was built from: the
25
+ // screen rebuilds its rows from the same list it just wrote to.
26
+ read: (ctx) =>
27
+ ctx.state.projects.find((entry) => entry.id === project.id)?.defaultBaseRef ?? '',
28
+ storage: 'app',
29
+ write: (value) =>
30
+ runSideEffectGlobal({
31
+ baseRef: String(value),
32
+ projectId: project.id,
33
+ type: 'set-project-default-base-ref',
34
+ }),
35
+ }
36
+ }
37
+
38
+ export const WORKSPACE_SECTION: SettingSection = {
39
+ id: 'workspace',
40
+ label: 'Workspaces',
41
+ rowCount: (projects) => projects.length,
42
+ rows: (projects: readonly ProjectRecord[]) =>
43
+ projects.map((project) => defaultBaseRefRow(project)),
44
+ }
@@ -217,6 +217,14 @@ export interface ProjectRecord {
217
217
  projectSnapshot?: ProjectSnapshotV1
218
218
  workspaces?: WorkspaceRecord[]
219
219
  activeWorkspaceId?: string
220
+ /**
221
+ * The branch new workspaces fork from, when the repo's own default is the
222
+ * wrong answer — a gitflow repo branches off `develop` while still declaring
223
+ * `main` as its default, and nothing about the repo says so. Per project
224
+ * because the convention is the team's, not the user's: three repos, three
225
+ * answers. Unset means the repo's default branch.
226
+ */
227
+ defaultBaseRef?: string
220
228
  }
221
229
 
222
230
  export interface ProjectBarState {
@@ -180,7 +180,8 @@ export function isProjectRecord(value: unknown): value is ProjectRecord {
180
180
  (value.projectSnapshot === undefined || isProjectSnapshotV1(value.projectSnapshot)) &&
181
181
  (value.workspaces === undefined ||
182
182
  (Array.isArray(value.workspaces) && value.workspaces.every(isWorkspaceRecord))) &&
183
- (value.activeWorkspaceId === undefined || isString(value.activeWorkspaceId))
183
+ (value.activeWorkspaceId === undefined || isString(value.activeWorkspaceId)) &&
184
+ (value.defaultBaseRef === undefined || isString(value.defaultBaseRef))
184
185
  )
185
186
  }
186
187