@brimveyn/aimux 1.22.5 → 1.22.6

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.5",
3
+ "version": "1.22.6",
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",
@@ -16,6 +16,7 @@ import {
16
16
  pruneGitWorktrees,
17
17
  removeGitWorktree,
18
18
  } from '../git/worktree'
19
+ import { copyWorktreeFiles } from '../git/worktree-files'
19
20
  import { createPrefixedId } from '../platform/id'
20
21
  import {
21
22
  assertSafeAimuxWorktreePath,
@@ -23,7 +24,7 @@ import {
23
24
  makeWorktreePath,
24
25
  sanitizePathSegment,
25
26
  } from '../platform/worktree-paths'
26
- import { shouldRefreshBase } from '../settings/flags'
27
+ import { shouldRefreshBase, worktreeCopyPatterns } from '../settings/flags'
27
28
  import { saveProjectCatalog } from '../state/project-catalog'
28
29
  import { pruneSnapshotOfWorkspace } from '../state/project-persistence'
29
30
  import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
@@ -120,7 +121,11 @@ export async function createAimuxTempWorkspace(
120
121
  const branchName =
121
122
  trimmedBranch != null && trimmedBranch !== ''
122
123
  ? trimmedBranch
123
- : `aimux/${sanitizePathSegment(workspaceName, 40)}-${Date.now().toString(36)}`
124
+ : // Placeholder only: the model replaces it with a conventional
125
+ // `<type>/<subject>` branch seconds later. Kept in the `aimux/`
126
+ // namespace and lowercased so what survives a failed generation still
127
+ // reads as a throwaway branch rather than a shouted prompt.
128
+ `aimux/${sanitizePathSegment(workspaceName, 40).toLowerCase()}-${Date.now().toString(36)}`
124
129
  const targetPath = makeWorktreePath({ repoRoot, workspaceId, workspaceName })
125
130
 
126
131
  const existingWorkspace = (await listGitWorktrees(repoRoot)).find(
@@ -147,6 +152,7 @@ export async function createAimuxTempWorkspace(
147
152
  repoPath: repoRoot,
148
153
  targetPath,
149
154
  })
155
+ await copyWorktreeFiles(repoRoot, targetPath, worktreeCopyPatterns())
150
156
  const now = new Date().toISOString()
151
157
 
152
158
  const workspace: WorkspaceRecord = {
@@ -209,12 +215,15 @@ export async function runDeleteWorkspace(
209
215
 
210
216
  const repoPath = resolveWorkspaceGitDir(project, workspace)
211
217
  const isAimuxTemp = workspace.source === 'aimux-temp' && workspace.createdByAimux
212
- // Drop the throwaway aimux branch alongside the workspace so deleted temp
213
- // workspaces don't accumulate in the repo or haunt the base picker. Scoped to
214
- // the `aimux/` namespace (matches the picker filter); best-effort.
218
+ // Drop the throwaway branch alongside the workspace so deleted temp
219
+ // workspaces don't accumulate in the repo. Scoped by the record rather than by
220
+ // a name prefix: auto-naming renames the branch to a conventional
221
+ // `<type>/<subject>`, indistinguishable from a hand-made one, and
222
+ // `aimux-temp` + `createdByAimux` is exactly "aimux created this branch".
223
+ // Best-effort; git refuses while another worktree still has it checked out.
215
224
  const cleanupAimuxBranch = async (): Promise<void> => {
216
225
  const branch = workspace.branch
217
- if (isAimuxTemp && branch != null && branch !== '' && branch.startsWith('aimux/')) {
226
+ if (isAimuxTemp && branch != null && branch !== '') {
218
227
  await deleteGitBranch(repoPath, branch)
219
228
  }
220
229
  }
@@ -5,24 +5,22 @@
5
5
  // the generated one replaces it in place when it arrives. The heuristic result
6
6
  // is not a stopgap to be embarrassed about — when no headless CLI is installed
7
7
  // it is the final name, and it is still derived from what the user asked for.
8
+ //
9
+ // The branch is not a slug of that name. A tab title belongs to the user and
10
+ // speaks their language; a branch name is read by git, by reviewers and by CI,
11
+ // so the model is asked for it separately, in English and under a
12
+ // conventional-commit type. A branch is only renamed when the model returns one
13
+ // in that shape — the placeholder is a better outcome than a bad convention.
8
14
 
9
15
  import type { AssistantId, WorkspaceRecord } from '../state/types'
10
16
 
11
17
  import { heuristicTitle } from '../auto-rename/heuristic-title'
12
- import { generateTabTitle, type TitleSpawnFn } from '../auto-rename/title-runner'
18
+ import { generateWorkspaceNaming, type TitleSpawnFn } from '../auto-rename/title-runner'
13
19
  import { renameGitBranch } from '../git/worktree'
14
- import { sanitizePathSegment } from '../platform/worktree-paths'
15
20
 
16
21
  /** Model naming is best-effort background work; it must never outlive the app. */
17
22
  const NAMING_TIMEOUT_MS = 30_000
18
23
 
19
- export const BRANCH_PREFIX = 'aimux/'
20
- const BRANCH_SLUG_MAX = 40
21
-
22
- export function branchNameFor(name: string): string {
23
- return `${BRANCH_PREFIX}${sanitizePathSegment(name, BRANCH_SLUG_MAX).toLowerCase()}`
24
- }
25
-
26
24
  /**
27
25
  * The name a workspace carries until the model answers. Undefined when the
28
26
  * prompt yields nothing usable, which lets `createAimuxTempWorkspace` fall back
@@ -60,7 +58,7 @@ export async function renameWorkspaceFromPrompt(
60
58
  target: WorkspaceNamingTarget,
61
59
  deps: WorkspaceNamingDeps
62
60
  ): Promise<void> {
63
- const result = await generateTabTitle({
61
+ const result = await generateWorkspaceNaming({
64
62
  firstPrompt: target.prompt,
65
63
  provider: target.provider,
66
64
  signal: deps.signal ?? new AbortController().signal,
@@ -70,21 +68,24 @@ export async function renameWorkspaceFromPrompt(
70
68
  if (result.status !== 'ok') return
71
69
 
72
70
  const { workspace } = target
73
- if (result.title === workspace.name) return
74
-
75
- const branch = branchNameFor(result.title)
71
+ const { branch } = result
76
72
  const rename = deps.renameBranch ?? renameGitBranch
77
73
  // Renamed from inside the workspace, not the main checkout: the branch is
78
74
  // that worktree's current branch, which is the form git always accepts.
79
75
  // Renaming first means a refusal (the name is taken) leaves the record
80
76
  // pointing at the branch that actually exists.
81
- const branchRenamed =
82
- workspace.branch != null && workspace.branch !== '' && branch !== workspace.branch
83
- ? await rename(workspace.path, workspace.branch, branch)
84
- : false
77
+ const renamedBranch =
78
+ branch != null &&
79
+ workspace.branch != null &&
80
+ workspace.branch !== '' &&
81
+ branch !== workspace.branch &&
82
+ (await rename(workspace.path, workspace.branch, branch))
83
+ ? branch
84
+ : undefined
85
85
 
86
+ if (result.title === workspace.name && renamedBranch == null) return
86
87
  deps.applyName(target.projectId, workspace.id, {
87
88
  name: result.title,
88
- ...(branchRenamed ? { branch } : null),
89
+ ...(renamedBranch == null ? null : { branch: renamedBranch }),
89
90
  })
90
91
  }
package/src/app.tsx CHANGED
@@ -82,12 +82,18 @@ export function App({
82
82
  resolvedConfig: ResolvedConfig
83
83
  userConfig: AimuxUserConfig
84
84
  }) {
85
- // Publish the auto-commit enabled flag before any children render so
86
- // actions (which live outside React) can read it synchronously.
87
- setAutoCommitEnabled(resolvedConfig.autoCommit.enabled)
88
- setMultiRepoConfig(resolvedConfig.multiRepo)
89
- setExternalEditorConfig(resolvedConfig.externalEditor)
90
- setStatusBarSeparator(resolvedConfig.statusBar?.separator)
85
+ // Publish the config-file baseline into the runtime singletons before any
86
+ // children render, so actions (which live outside React) can read them
87
+ // synchronously. Lazy initializer, not the render body: this component
88
+ // re-renders on every dispatch, and re-running these would overwrite what the
89
+ // settings screen has since applied on top of the baseline (`hydrateSettings`).
90
+ useState(() => {
91
+ setAutoCommitEnabled(resolvedConfig.autoCommit.enabled)
92
+ setMultiRepoConfig(resolvedConfig.multiRepo)
93
+ setExternalEditorConfig(resolvedConfig.externalEditor)
94
+ setStatusBarSeparator(resolvedConfig.statusBar?.separator)
95
+ return null
96
+ })
91
97
 
92
98
  const keymapHandlers = useMemo(
93
99
  () => {
@@ -1,4 +1,5 @@
1
1
  import { buildHeadlessInvocation, type HeadlessInvocation } from '../auto-commit/headless-commands'
2
+ import { foldDiacritics } from '../platform/worktree-paths'
2
3
  import { clampTitle } from './title-format'
3
4
 
4
5
  export type TitleSpawnFn = (
@@ -16,6 +17,33 @@ export type TitleResult =
16
17
  | { status: 'failed' }
17
18
  | { status: 'unavailable' }
18
19
 
20
+ /**
21
+ * A workspace needs two different names from one request: a tab title in the
22
+ * user's own language, and a branch name — which is read by git, by reviewers
23
+ * and by CI, so it follows the repo's conventions instead: English, a
24
+ * conventional-commit type, kebab-case. `branch` is null when the model gave
25
+ * nothing that qualifies; the caller keeps the branch it already has.
26
+ */
27
+ export type NamingResult =
28
+ | { status: 'ok'; title: string; branch: string | null }
29
+ | { status: 'failed' }
30
+ | { status: 'unavailable' }
31
+
32
+ /** Conventional-commit types a generated branch may use; anything else is refused. */
33
+ const BRANCH_TYPES = new Set([
34
+ 'build',
35
+ 'chore',
36
+ 'ci',
37
+ 'docs',
38
+ 'feat',
39
+ 'fix',
40
+ 'perf',
41
+ 'refactor',
42
+ 'style',
43
+ 'test',
44
+ ])
45
+ const BRANCH_SUBJECT_WORDS = 5
46
+
19
47
  export function buildTitlePrompt(firstPrompt: string): string {
20
48
  return [
21
49
  'Create a concise tab title for the user request below.',
@@ -26,15 +54,56 @@ export function buildTitlePrompt(firstPrompt: string): string {
26
54
  ].join('\n')
27
55
  }
28
56
 
29
- export function sanitizeGeneratedTitle(raw: string): string | null {
30
- const first = raw
57
+ export function buildWorkspaceNamingPrompt(firstPrompt: string): string {
58
+ return [
59
+ 'Name a workspace for the user request below.',
60
+ 'Return exactly two lines and nothing else.',
61
+ 'Line 1 — a tab title: 2 to 6 words, at most 48 characters, in the same language as the request.',
62
+ 'Line 2 — a git branch named <type>/<subject>, always in English whatever the language of the request.',
63
+ '<type> is one of: feat, fix, refactor, perf, docs, test, chore, ci, style, build.',
64
+ '<subject> is 2 to 5 lowercase words joined by hyphens, naming what changes rather than restating the request.',
65
+ 'Example line 2: fix/scroll-drift-on-resize',
66
+ 'No quotes, no labels, no numbering, no markdown, no ending punctuation.',
67
+ '',
68
+ firstPrompt.slice(0, 8_000),
69
+ ].join('\n')
70
+ }
71
+
72
+ function nonEmptyLines(raw: string): string[] {
73
+ return raw
31
74
  .split(/\r?\n/u)
32
75
  .map((line) => line.trim())
33
- .find(Boolean)
34
- if (first == null || first === '') return null
76
+ .filter(Boolean)
77
+ }
78
+
79
+ /** Strip the wrappers a model reaches for even when told not to: labels, list markers, quotes. */
80
+ function unwrapLine(line: string, label: RegExp): string {
81
+ return line
82
+ .replace(label, '')
83
+ .replace(/^(?:\d+[.)]|[-*])\s*/u, '')
84
+ .replaceAll(/^["'`“”‘’]+|["'`“”‘’]+$/gu, '')
85
+ }
35
86
 
36
- const unlabelled = first.replace(/^TITLE\s*:\s*/iu, '').replaceAll(/^["'“”‘’]+|["'“”‘’]+$/gu, '')
37
- return clampTitle(unlabelled)
87
+ export function sanitizeGeneratedTitle(raw: string): string | null {
88
+ const first = nonEmptyLines(raw)[0]
89
+ if (first == null) return null
90
+ return clampTitle(unwrapLine(first, /^TITLE\s*:\s*/iu))
91
+ }
92
+
93
+ export function sanitizeGeneratedBranch(raw: string): string | null {
94
+ const line = unwrapLine(raw.trim(), /^BRANCH\s*:\s*/iu)
95
+ const slash = line.indexOf('/')
96
+ if (slash < 0) return null
97
+ const type = line.slice(0, slash).trim().toLowerCase()
98
+ if (!BRANCH_TYPES.has(type)) return null
99
+ // Whole words only: a mid-word cut ("...-bran") names nothing.
100
+ const subject = foldDiacritics(line.slice(slash + 1))
101
+ .toLowerCase()
102
+ .split(/[^a-z0-9]+/u)
103
+ .filter(Boolean)
104
+ .slice(0, BRANCH_SUBJECT_WORDS)
105
+ .join('-')
106
+ return subject === '' ? null : `${type}/${subject}`
38
107
  }
39
108
 
40
109
  function executableOnPath(executable: string): boolean {
@@ -46,7 +115,7 @@ function executableOnPath(executable: string): boolean {
46
115
  }
47
116
  }
48
117
 
49
- export async function generateTabTitle(options: {
118
+ export interface NamingOptions {
50
119
  provider: string
51
120
  model?: string
52
121
  firstPrompt: string
@@ -54,12 +123,13 @@ export async function generateTabTitle(options: {
54
123
  signal: AbortSignal
55
124
  spawn?: TitleSpawnFn
56
125
  isExecutableAvailable?: (executable: string) => boolean
57
- }): Promise<TitleResult> {
58
- const invocation = buildHeadlessInvocation(
59
- options.provider,
60
- buildTitlePrompt(options.firstPrompt),
61
- options.model
62
- )
126
+ }
127
+
128
+ async function runNamingModel(
129
+ options: NamingOptions,
130
+ prompt: string
131
+ ): Promise<{ status: 'ok'; stdout: string } | { status: 'failed' } | { status: 'unavailable' }> {
132
+ const invocation = buildHeadlessInvocation(options.provider, prompt, options.model)
63
133
  if (!invocation) return { status: 'unavailable' }
64
134
 
65
135
  // A caller-supplied spawn does not go through PATH, so only probe it for the
@@ -72,13 +142,33 @@ export async function generateTabTitle(options: {
72
142
  try {
73
143
  const result = await (options.spawn ?? defaultSpawn)(invocation, signal)
74
144
  if (!result || result.exitCode !== 0 || signal.aborted) return { status: 'failed' }
75
- const title = sanitizeGeneratedTitle(result.stdout)
76
- return title == null ? { status: 'failed' } : { status: 'ok', title }
145
+ return { status: 'ok', stdout: result.stdout }
77
146
  } catch {
78
147
  return { status: 'failed' }
79
148
  }
80
149
  }
81
150
 
151
+ export async function generateTabTitle(options: NamingOptions): Promise<TitleResult> {
152
+ const run = await runNamingModel(options, buildTitlePrompt(options.firstPrompt))
153
+ if (run.status !== 'ok') return run
154
+ const title = sanitizeGeneratedTitle(run.stdout)
155
+ return title == null ? { status: 'failed' } : { status: 'ok', title }
156
+ }
157
+
158
+ /** One model call for both names — a workspace must not wait on two. */
159
+ export async function generateWorkspaceNaming(options: NamingOptions): Promise<NamingResult> {
160
+ const run = await runNamingModel(options, buildWorkspaceNamingPrompt(options.firstPrompt))
161
+ if (run.status !== 'ok') return run
162
+ const [titleLine, branchLine] = nonEmptyLines(run.stdout)
163
+ const title = titleLine == null ? null : sanitizeGeneratedTitle(titleLine)
164
+ if (title == null) return { status: 'failed' }
165
+ return {
166
+ branch: branchLine == null ? null : sanitizeGeneratedBranch(branchLine),
167
+ status: 'ok',
168
+ title,
169
+ }
170
+ }
171
+
82
172
  async function defaultSpawn(
83
173
  invocation: HeadlessInvocation,
84
174
  signal: AbortSignal
@@ -2,6 +2,7 @@ import type { ProjectRecord, WorkspaceRecord } from '../../../state/types'
2
2
  import type { DaemonClient } from '../../client/daemon-client'
3
3
 
4
4
  import { createGitWorktree, removeGitWorktree, resolveGitRef } from '../../../git/worktree'
5
+ import { copyWorktreeFiles } from '../../../git/worktree-files'
5
6
  import { IPC_CAPABILITY_WORKSPACE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
6
7
  import { createPrefixedId } from '../../../platform/id'
7
8
  import {
@@ -10,7 +11,7 @@ import {
10
11
  makeWorktreePath,
11
12
  pruneEmptyWorktreeParent,
12
13
  } from '../../../platform/worktree-paths'
13
- import { shouldRefreshBase } from '../../../settings/flags'
14
+ import { shouldRefreshBase, worktreeCopyPatterns } from '../../../settings/flags'
14
15
 
15
16
  export interface CreateWorkspaceParams {
16
17
  /** Base ref for the branch (callers default to 'HEAD'). */
@@ -84,6 +85,7 @@ export async function createProjectWorkspace(
84
85
  await pruneEmptyWorktreeParent(targetPath)
85
86
  throw error
86
87
  }
88
+ await copyWorktreeFiles(primary.repoRoot, targetPath, worktreeCopyPatterns())
87
89
 
88
90
  const now = new Date().toISOString()
89
91
  const record: WorkspaceRecord = {
@@ -15,14 +15,16 @@ interface Options {
15
15
 
16
16
  /** One-shot refetch, for when an action we took just invalidated the state. */
17
17
  export async function refreshPrStatus(projectPath: string): Promise<void> {
18
- prStatusStore.getState().setResult(await collectPrStatus(projectPath))
18
+ prStatusStore.getState().setResult(projectPath, await collectPrStatus(projectPath))
19
19
  }
20
20
 
21
21
  export function usePrStatusPolling({ enabled, projectPath }: Options): void {
22
22
  useEffect(() => {
23
23
  if (!enabled || !(projectPath != null && projectPath !== '')) return
24
24
 
25
- prStatusStore.getState().reset()
25
+ // Show what we last knew about this path while the fetch runs in the
26
+ // background, rather than blanking the row on every workspace switch.
27
+ prStatusStore.getState().selectPath(projectPath)
26
28
 
27
29
  let cancelled = false
28
30
  let timer: ReturnType<typeof setTimeout> | null = null
@@ -36,7 +38,7 @@ export function usePrStatusPolling({ enabled, projectPath }: Options): void {
36
38
  const tick = async () => {
37
39
  const result = await collectPrStatus(projectPath)
38
40
  if (cancelled) return
39
- prStatusStore.getState().setResult(result)
41
+ prStatusStore.getState().setResult(projectPath, result)
40
42
  if (result.kind === 'error') {
41
43
  delay = Math.min(delay * 2, MAX_INTERVAL_MS)
42
44
  } else if (result.kind === 'ok' && result.checks.some((c) => c.state === 'pending')) {
@@ -0,0 +1,51 @@
1
+ import { Glob } from 'bun'
2
+ import { existsSync } from 'node:fs'
3
+ import { cp, mkdir } from 'node:fs/promises'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ import { logDebug } from '../debug/input-log'
7
+
8
+ /**
9
+ * Seed a fresh worktree with the untracked local files it needs to run.
10
+ *
11
+ * A worktree checkout holds tracked files and nothing else, so every ignored
12
+ * local file — `.env` above all — is missing the moment the workspace opens.
13
+ * The setup script can recreate some of them from a template; it cannot invent
14
+ * the secrets, which is why they are copied from the main checkout instead.
15
+ *
16
+ * ponytail: the patterns are matched against the whole checkout, gitignore and
17
+ * all — a recursive pattern walks `node_modules` too. Feeding the scan the ignore
18
+ * rules is the upgrade path if that ever costs anything noticeable.
19
+ *
20
+ * Best-effort by design: a pattern that matches nothing, or a file that cannot
21
+ * be read, must not take the workspace down with it. Nothing is ever
22
+ * overwritten — a match that already exists in the new worktree is tracked
23
+ * content, and clobbering it would dirty the workspace before its first commit.
24
+ */
25
+ export async function copyWorktreeFiles(
26
+ repoPath: string,
27
+ targetPath: string,
28
+ patterns: readonly string[]
29
+ ): Promise<void> {
30
+ for (const pattern of patterns) {
31
+ try {
32
+ const matches = new Glob(pattern).scan({ cwd: repoPath, dot: true, onlyFiles: true })
33
+ for await (const relative of matches) {
34
+ const to = join(targetPath, relative)
35
+ if (existsSync(to)) continue
36
+ await mkdir(dirname(to), { recursive: true })
37
+ await cp(join(repoPath, relative), to)
38
+ }
39
+ } catch (error) {
40
+ // A workspace without its .env still opens; a workspace that failed to be
41
+ // created does not. Logged rather than swallowed outright: a file that
42
+ // silently never arrives is otherwise indistinguishable from one the
43
+ // pattern never matched.
44
+ logDebug('worktree.seed.error', {
45
+ error: error instanceof Error ? error.message : String(error),
46
+ pattern,
47
+ repoPath,
48
+ })
49
+ }
50
+ }
51
+ }
@@ -21,8 +21,13 @@ export function getAimuxWorktreeRoot(): string {
21
21
  return root != null && root !== '' ? root : defaultWorktreeRoot()
22
22
  }
23
23
 
24
+ /** `améliorer` → `ameliorer`, so accents slug as letters instead of separators. */
25
+ export function foldDiacritics(input: string): string {
26
+ return input.normalize('NFD').replaceAll(/\p{Diacritic}/gu, '')
27
+ }
28
+
24
29
  export function sanitizePathSegment(input: string, maxLength = MAX_SLUG_LENGTH): string {
25
- const sanitized = input
30
+ const sanitized = foldDiacritics(input)
26
31
  .trim()
27
32
  .replaceAll(/[^A-Za-z0-9._-]+/g, '-')
28
33
  .replaceAll(/\.\.+/g, '-')
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from '../config'
2
- import { FETCH_BASE } from './sections/git'
2
+ import { COPY_FILES, COPY_FILES_DEFAULT, FETCH_BASE } from './sections/git'
3
3
 
4
4
  /**
5
5
  * Settings read outside the screen, off the file rather than the store.
@@ -13,3 +13,13 @@ import { FETCH_BASE } from './sections/git'
13
13
  export function shouldRefreshBase(): boolean {
14
14
  return loadConfig().settings?.[FETCH_BASE] !== false
15
15
  }
16
+
17
+ /** Untracked files a new workspace is seeded with, as globs relative to the repo. */
18
+ export function worktreeCopyPatterns(): string[] {
19
+ const value = loadConfig().settings?.[COPY_FILES]
20
+ // Only an explicit empty string disables it; an untouched setting gets `.env`.
21
+ return String(value ?? COPY_FILES_DEFAULT)
22
+ .split(',')
23
+ .map((pattern) => pattern.trim())
24
+ .filter((pattern) => pattern !== '')
25
+ }
@@ -6,6 +6,8 @@ import type { SettingSection, SettingValue } from '../types'
6
6
  import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
7
7
 
8
8
  export const FETCH_BASE = 'git.fetchBase'
9
+ export const COPY_FILES = 'git.worktreeCopyFiles'
10
+ export const COPY_FILES_DEFAULT = '.env'
9
11
 
10
12
  function isFileListMode(value: SettingValue): value is GitFileListMode {
11
13
  return value === 'tree' || value === 'flat'
@@ -93,6 +95,18 @@ export const GIT_SECTION: SettingSection = {
93
95
  label: 'Refresh the base branch',
94
96
  storage: 'settings',
95
97
  },
98
+ {
99
+ // No `apply`, same reason as the row above: `worktreeCopyPatterns` reads
100
+ // this off the file so `aimux workspace create` honours it too.
101
+ description:
102
+ 'Untracked files copied into each new workspace. Globs from the repo root: **/.env for nested ones.',
103
+ fallback: COPY_FILES_DEFAULT,
104
+ id: COPY_FILES,
105
+ kind: 'text',
106
+ label: 'Seed new workspaces with',
107
+ placeholder: 'nothing',
108
+ storage: 'settings',
109
+ },
96
110
  {
97
111
  apply: (value) => setMultiRepoConfig({ ...getMultiRepoConfig(), enabled: value === true }),
98
112
  description: 'Aggregate the status of git repos nested under the project.',
@@ -5,34 +5,38 @@ import type { PrStatusResult } from '../git/pr-status'
5
5
 
6
6
  export interface PrStatusState {
7
7
  result: PrStatusResult | null
8
+ /** Last result seen per project path, so a workspace switch shows its own
9
+ * previous state instead of blanking while the background fetch runs. */
10
+ byPath: Record<string, PrStatusResult>
8
11
  /** True once a fetch failed but we are still showing the previous good result. */
9
12
  stale: boolean
10
- setResult: (result: PrStatusResult) => void
11
- reset: () => void
13
+ setResult: (path: string, result: PrStatusResult) => void
14
+ /** Point the row at a project path, showing whatever we last knew about it. */
15
+ selectPath: (path: string) => void
12
16
  }
13
17
 
14
18
  export const prStatusStore = createStore<PrStatusState>((set) => ({
15
- reset: () => set({ result: null, stale: false }),
19
+ byPath: {},
16
20
  result: null,
17
- setResult: (result: PrStatusResult) =>
21
+ selectPath: (path: string) =>
22
+ set((state) => ({ result: state.byPath[path] ?? null, stale: false })),
23
+ setResult: (path: string, result: PrStatusResult) =>
18
24
  set((state) => {
19
25
  // A transient `gh` failure shouldn't blank a PR we already resolved — keep
20
26
  // the last good snapshot and mark it stale instead (same contract as
21
27
  // ai-usage-store's setSnapshot).
22
28
  if (result.kind === 'error' && state.result?.kind === 'ok') return { stale: true }
23
- return { result, stale: false }
29
+ return { byPath: { ...state.byPath, [path]: result }, result, stale: false }
24
30
  }),
25
31
  stale: false,
26
32
  }))
27
33
 
28
34
  /**
29
- * The PR state row occupies its band both while the first fetch is in flight
30
- * and once it resolved to a PR anything else (no PR, no gh, error) gives the
31
- * row back. Shared so the header and the row itself can never disagree and
32
- * shift the layout under the user.
35
+ * The PR state row only occupies its band once we know there is a PR — an
36
+ * unknown or resolved-to-nothing state gives the row back. Shared so the header
37
+ * and the row itself can never disagree and shift the layout under the user.
33
38
  */
34
- export const selectPrRowVisible = (state: PrStatusState): boolean =>
35
- state.result === null || state.result.kind === 'ok'
39
+ export const selectPrRowVisible = (state: PrStatusState): boolean => state.result?.kind === 'ok'
36
40
 
37
41
  export function usePrStatusStore<T>(selector: (state: PrStatusState) => T): T {
38
42
  return useStore(prStatusStore, selector)
@@ -44,18 +44,9 @@ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPat
44
44
  })()
45
45
  }, [projectPath])
46
46
 
47
- // First fetch still in flight: hold the band empty so the tabs below don't
48
- // jump a row once the PR lands.
49
- if (result === null) {
50
- return (
51
- <box backgroundColor={bg} paddingLeft={1} paddingRight={1}>
52
- <text selectable={false} bg={bg} wrapMode="none">
53
- {' '}
54
- </text>
55
- </box>
56
- )
57
- }
58
- if (result.kind !== 'ok' || pr === null) return null
47
+ // Nothing known yet (or nothing to show): stay out of the layout entirely and
48
+ // appear only once a fetch reports a PR.
49
+ if (result?.kind !== 'ok' || pr === null) return null
59
50
  const status = prActionState(pr, result.checks)
60
51
  let label = status.label
61
52
  if (confirming) label = 'Merge this PR?'
@@ -44,32 +44,6 @@ interface TerminalPaneProps {
44
44
  junctionEdges?: JunctionEdges
45
45
  }
46
46
 
47
- function getTitle(
48
- tab: TabSession | undefined,
49
- isActive: boolean,
50
- focusMode: TerminalPaneProps['focusMode'],
51
- emptyContext: { projectName: string; workspaceName: string }
52
- ): string {
53
- if (!tab) {
54
- const { projectName, workspaceName } = emptyContext
55
- if (projectName === '' && workspaceName === '') return 'No active project'
56
- if (workspaceName === '' || workspaceName === projectName) {
57
- return `${projectName} · no tabs`
58
- }
59
- return `${projectName} / ${workspaceName} · no tabs`
60
- }
61
-
62
- if (isActive && focusMode === 'terminal-input') {
63
- return `● ${tab.title} · ${tab.status}`
64
- }
65
-
66
- if (isActive) {
67
- return `▸ ${tab.title} · ${tab.status}`
68
- }
69
-
70
- return `${tab.title} · ${tab.status}`
71
- }
72
-
73
47
  function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMode']): string {
74
48
  const t = getCurrentTheme()
75
49
  if (!isActive) return t.border
@@ -526,10 +500,6 @@ export function TerminalPane({
526
500
  <ContextMenuBox
527
501
  border
528
502
  borderColor={getBorderColor(paneIsActive, focusMode)}
529
- title={getTitle(tab, paneIsActive, focusMode, {
530
- projectName: emptyProjectName,
531
- workspaceName: emptyWorkspaceName,
532
- })}
533
503
  padding={0}
534
504
  flexDirection="column"
535
505
  flexGrow={1}