@brimveyn/aimux 1.23.1 → 1.23.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.23.1",
3
+ "version": "1.23.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",
@@ -66,7 +66,7 @@
66
66
  "bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
67
67
  },
68
68
  "dependencies": {
69
- "@brimveyn/aimux-config": "0.10.2",
69
+ "@brimveyn/aimux-config": "0.10.5",
70
70
  "@opentui/core": "^0.1.90",
71
71
  "@opentui/react": "^0.1.90",
72
72
  "@resvg/resvg-wasm": "^2.6.2",
@@ -93,8 +93,13 @@ export function useAutoCommitDriver({
93
93
  const lastGitHashRef = useRef<string | null>(null)
94
94
  const gitStabilizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
95
95
 
96
+ // `state.gitPanel`, not `state`: the payload is built from the git panel and
97
+ // nothing else, while `state` changes on every PTY frame. Keyed on the whole
98
+ // state this ran — and JSON.stringify'd the changed-file list — at the rate
99
+ // the assistants print.
96
100
  useEffect(() => {
97
101
  if (!configRef.current.enabled) return
102
+ const state = stateRef.current
98
103
  const projectId = state.currentProjectId
99
104
  if (!(projectId != null && projectId !== '')) return
100
105
  const payload = gitPayloadFromState(state)
@@ -126,7 +131,7 @@ export function useAutoCommitDriver({
126
131
  tabId: activeTab.id,
127
132
  })
128
133
  }, GIT_STABILIZATION_DEBOUNCE_MS)
129
- }, [state])
134
+ }, [state.gitPanel, stateRef])
130
135
 
131
136
  useEffect(
132
137
  () => () => {
@@ -19,6 +19,9 @@ interface BackendRuntimeOptions {
19
19
  syntaxOverlayEnabled: () => boolean
20
20
  }
21
21
 
22
+ /** Below the eye's notice on a deliberate switch; wide enough to swallow key repeat. */
23
+ const ATTACH_SETTLE_MS = 90
24
+
22
25
  export interface TabRuntimeControls {
23
26
  clearIdleTimer: (tabId: string) => void
24
27
  clearStartupGrace: (tabId: string) => void
@@ -45,14 +48,28 @@ export function useBackendRuntime({
45
48
  return
46
49
  }
47
50
 
48
- return attachCurrentSession({
49
- attachRequestIdRef,
50
- backend,
51
- currentProjectId,
52
- currentProjectProjectSnapshot,
53
- dispatch,
54
- layoutRef,
55
- })
51
+ // Settle before attaching. An attach is a socket teardown + reconnect +
52
+ // handshake to the daemon, which then re-attaches every PTY of the project
53
+ // and classifies them — and holding `j`/`k` across the sidebar walks
54
+ // through projects at key-repeat rate. Firing one per keypress meant every
55
+ // intermediate project paid that round trip only to have its result
56
+ // discarded by the next. Only where the cursor comes to rest attaches.
57
+ let detach: (() => void) | null = null
58
+ const timer = setTimeout(() => {
59
+ detach = attachCurrentSession({
60
+ attachRequestIdRef,
61
+ backend,
62
+ currentProjectId,
63
+ currentProjectProjectSnapshot,
64
+ dispatch,
65
+ layoutRef,
66
+ })
67
+ }, ATTACH_SETTLE_MS)
68
+
69
+ return () => {
70
+ clearTimeout(timer)
71
+ detach?.()
72
+ }
56
73
  }, [backend, currentProjectId, currentProjectProjectSnapshot, dispatch, layoutRef])
57
74
 
58
75
  useEffect(() => {
@@ -73,6 +73,9 @@ export function startWorkspaceCreation(
73
73
  * conclusion. Only prefixed when a setup is actually live.
74
74
  */
75
75
  function buildWorkspacePrompt(ctx: SideEffectContext, pending: PendingWorkspaceLaunch): string {
76
+ // No prompt is a valid choice: the user wanted the worktree, not a task. Send
77
+ // nothing — not even the setup note, which would arrive as a prompt of its own.
78
+ if (pending.prompt.trim() === '') return ''
76
79
  const setupTab = findSetupTab(ctx.getState().tabs, pending.workspaceId)
77
80
  // No setup tab yet does not mean no setup: the workspace can land in the
78
81
  // store the same tick the user picks, and the runner only spawns on the next
@@ -95,6 +98,8 @@ function renameWorkspaceFromLaunch(
95
98
  workspace: WorkspaceRecord,
96
99
  assistant: AssistantId
97
100
  ): void {
101
+ // Nothing to name it after; the `wt-<project>` placeholder stands.
102
+ if (pending.prompt.trim() === '') return
98
103
  void renameWorkspaceFromPrompt(
99
104
  { projectId: pending.projectId, prompt: pending.prompt, provider: assistant, workspace },
100
105
  {
@@ -18,36 +18,29 @@ export function useWorkspaceBranchPolling(enabled: boolean): void {
18
18
  useEffect(() => {
19
19
  if (!enabled) return
20
20
 
21
- let cancelled = false
22
21
  let timer: ReturnType<typeof setTimeout> | null = null
23
22
 
24
- const tick = async () => {
25
- const projects = appStore.getState().projects
26
- await Promise.all(
27
- projects.flatMap((project) =>
28
- (project.workspaces ?? []).map(async (workspace) => {
29
- if (workspace.path == null || workspace.path === '') return
30
- const branch = await getCurrentBranch(workspace.path)
31
- if (cancelled) return
32
- if (branch != null && branch !== workspace.branch) {
33
- dispatchGlobal({
34
- patch: { branch },
35
- projectId: project.id,
36
- type: 'update-workspace-record',
37
- workspaceId: workspace.id,
38
- })
39
- }
40
- })
41
- )
42
- )
43
- if (cancelled) return
44
- timer = setTimeout(() => void tick(), INTERVAL_MS)
23
+ const tick = () => {
24
+ for (const project of appStore.getState().projects) {
25
+ for (const workspace of project.workspaces ?? []) {
26
+ if (workspace.path == null || workspace.path === '') continue
27
+ const branch = getCurrentBranch(workspace.path)
28
+ if (branch != null && branch !== workspace.branch) {
29
+ dispatchGlobal({
30
+ patch: { branch },
31
+ projectId: project.id,
32
+ type: 'update-workspace-record',
33
+ workspaceId: workspace.id,
34
+ })
35
+ }
36
+ }
37
+ }
38
+ timer = setTimeout(tick, INTERVAL_MS)
45
39
  }
46
40
 
47
- void tick()
41
+ tick()
48
42
 
49
43
  return () => {
50
- cancelled = true
51
44
  if (timer != null) clearTimeout(timer)
52
45
  }
53
46
  }, [enabled])
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
2
2
 
3
3
  import type { BranchDivergence } from '../state/types'
4
4
 
5
- import { useAppStore } from '../state/app-store'
5
+ import { appStore } from '../state/app-store'
6
6
  import { dispatchGlobal } from '../state/dispatch-ref'
7
7
  import { getBranchDivergence, getWorkspaceDiffStat } from './divergence'
8
8
 
@@ -14,25 +14,30 @@ const INTERVAL_MS = 4000
14
14
  // never forked, so they fall back to their own upstream — for a root checkout on
15
15
  // main that reads as "unpushed commits + dirty work". A branch with no upstream
16
16
  // makes git fail, which the poller already renders as nothing.
17
+ //
18
+ // Runs once when enabled; reads projects from the store on each tick so project
19
+ // updates do NOT re-create the effect. Taking `projects` as a dependency meant
20
+ // every workspace switch tore the loop down and fired a fresh tick — holding
21
+ // `j` in the sidebar launched a full git fan-out per keypress (~200ms of
22
+ // subprocesses each) and the machine spent the whole time spawning `git`.
17
23
  export function useWorkspaceDivergencePolling(enabled: boolean): void {
18
- const projects = useAppStore((s) => s.projects)
19
-
20
24
  useEffect(() => {
21
25
  if (!enabled) return
22
- // Every project, not just the current one: each dispatch replaces the whole
23
- // map, so polling one project's workspaces blanks the stats of every other
24
- // project's rows — which the sidebar shows all of at once.
25
- // ponytail: one unbounded fan-out per tick, two `git` spawns per workspace.
26
- // Batch or stagger if a machine with many projects feels it.
27
- const targets = projects
28
- .flatMap((project) => project.workspaces ?? [])
29
- .filter((w) => w.branch != null && w.branch !== '')
30
- if (targets.length === 0) return
31
26
 
32
27
  let cancelled = false
33
28
  let timer: ReturnType<typeof setTimeout> | null = null
34
29
 
35
30
  const tick = async () => {
31
+ // Every project, not just the current one: each dispatch replaces the
32
+ // whole map, so polling one project's workspaces blanks the stats of
33
+ // every other project's rows — which the sidebar shows all of at once.
34
+ // ponytail: one unbounded fan-out per tick, two `git` spawns per
35
+ // workspace. Batch or stagger if a machine with many projects feels it.
36
+ const targets = appStore
37
+ .getState()
38
+ .projects.flatMap((project) => project.workspaces ?? [])
39
+ .filter((w) => w.branch != null && w.branch !== '')
40
+
36
41
  const entries = await Promise.all(
37
42
  targets.map(async (workspace) => {
38
43
  const branch = workspace.branch
@@ -54,11 +59,15 @@ export function useWorkspaceDivergencePolling(enabled: boolean): void {
54
59
  })
55
60
  )
56
61
  if (cancelled) return
57
- const next: Record<string, BranchDivergence> = {}
58
- for (const entry of entries) {
59
- if (entry != null) next[entry[0]] = entry[1]
62
+ // Only when there was something to measure: an empty dispatch on a tick
63
+ // that found no branches would blank rows the previous tick filled.
64
+ if (targets.length > 0) {
65
+ const next: Record<string, BranchDivergence> = {}
66
+ for (const entry of entries) {
67
+ if (entry != null) next[entry[0]] = entry[1]
68
+ }
69
+ dispatchGlobal({ divergence: next, type: 'set-workspace-divergence' })
60
70
  }
61
- dispatchGlobal({ divergence: next, type: 'set-workspace-divergence' })
62
71
  timer = setTimeout(() => void tick(), INTERVAL_MS)
63
72
  }
64
73
 
@@ -68,5 +77,5 @@ export function useWorkspaceDivergencePolling(enabled: boolean): void {
68
77
  cancelled = true
69
78
  if (timer != null) clearTimeout(timer)
70
79
  }
71
- }, [enabled, projects])
80
+ }, [enabled])
72
81
  }
@@ -7,10 +7,14 @@ import { usePrStatusPolling } from '../../../../git/pr-status-poller'
7
7
  import { useRepoDiscovery } from '../../../../git/use-repo-discovery'
8
8
  import { useAppStore } from '../../../../state/app-store'
9
9
  import { getActiveWorkspacePath } from '../../../../state/project-workspaces'
10
+ import { useSettled } from '../../../hooks/use-settled'
10
11
  import { GitPanel } from '../git-panel'
11
12
  import { GitPaneHeader, type GitPaneTab } from './git-pane-header'
12
13
  import { PrChecksPanel } from './pr-checks-panel'
13
14
 
15
+ /** Long enough to swallow key repeat, short enough to read as instant. */
16
+ const PATH_SETTLE_MS = 150
17
+
14
18
  interface GitPaneWidgetProps {
15
19
  pollingEnabled: boolean
16
20
  contentWidth: number
@@ -32,7 +36,9 @@ export const GitPaneWidget = memo(function GitPaneWidget({
32
36
  currentProjectId != null && currentProjectId !== ''
33
37
  ? projects.find((s) => s.id === currentProjectId)
34
38
  : undefined
35
- const projectPath = getActiveWorkspacePath(currentProject)
39
+ // Settled: every poller below keys off this path and fires an immediate tick
40
+ // when it changes, and holding `j`/`k` in the sidebar changes it per keypress.
41
+ const projectPath = useSettled(getActiveWorkspacePath(currentProject), PATH_SETTLE_MS)
36
42
 
37
43
  const [tab, setTab] = useState<GitPaneTab>('diff')
38
44
 
@@ -17,8 +17,12 @@ interface NewTabModalProps {
17
17
  cursorPos?: number
18
18
  editingCommand: AssistantId | null
19
19
  editBuffer: string
20
- /** Chained from `<C-p>`: a shell cannot take the prompt, so Terminal is hidden. */
21
- excludeTerminal: boolean
20
+ /**
21
+ * Chained from `<C-p>`: the prompt waiting for the picked assistant, `''` when
22
+ * the workspace was created without one, `null` when this is a plain new tab.
23
+ * A shell cannot take a prompt, so Terminal is hidden only when there is one.
24
+ */
25
+ pendingPrompt: string | null
22
26
  }
23
27
 
24
28
  export function NewTabModal({
@@ -26,11 +30,16 @@ export function NewTabModal({
26
30
  customCommands,
27
31
  editBuffer,
28
32
  editingCommand,
29
- excludeTerminal,
30
33
  filter,
34
+ pendingPrompt,
31
35
  selectedIndex,
32
36
  }: NewTabModalProps) {
33
37
  const t = useTheme()
38
+ const excludeTerminal = pendingPrompt != null && pendingPrompt.trim() !== ''
39
+ const footerText =
40
+ pendingPrompt == null
41
+ ? 'Enter launches in the active workspace'
42
+ : `Enter launches in the new workspace${excludeTerminal ? ' and sends your prompt' : ''}`
34
43
  const options = useMemo(() => getAllAssistantOptions(customCommands), [customCommands])
35
44
  const filtered = useMemo(
36
45
  () => getNewTabAssistantOptions(customCommands, filter, excludeTerminal),
@@ -100,13 +109,7 @@ export function NewTabModal({
100
109
  selectedIndex={selectedIndex}
101
110
  emptyState={<text fg={t.textMuted}>No matching assistants.</text>}
102
111
  onHover={handleHover}
103
- footer={
104
- <text fg={t.textMuted}>
105
- {excludeTerminal
106
- ? 'Enter launches in the new workspace and sends your prompt'
107
- : 'Enter launches in the active workspace'}
108
- </text>
109
- }
112
+ footer={<text fg={t.textMuted}>{footerText}</text>}
110
113
  />
111
114
  )
112
115
  }
@@ -70,11 +70,11 @@ export function CreateWorkspaceModal({
70
70
  <box flexDirection="column">
71
71
  <TextField
72
72
  active={activeField === 'prompt'}
73
- label="What do you want to work on?"
74
- description="Sent to the assistant, and names the workspace and its branch."
73
+ label="What do you want to work on? (optional)"
74
+ description="Sent to the assistant, and names the workspace and its branch. Leave empty for a bare workspace."
75
75
  value={prompt}
76
76
  cursorPos={activeField === 'prompt' ? cursorPos : undefined}
77
- placeholder="Describe the task..."
77
+ placeholder="Describe the task, or leave empty..."
78
78
  minLines={PROMPT_LINES}
79
79
  />
80
80
  {branchError != null && branchError !== '' ? (
@@ -1,10 +1,31 @@
1
- import { $ } from 'bun'
1
+ import { readFileSync, statSync } from 'node:fs'
2
+ import { isAbsolute, join, resolve } from 'node:path'
2
3
 
3
- export async function getCurrentBranch(cwd: string): Promise<string | null> {
4
+ /**
5
+ * `.git` is a directory in a normal checkout and a `gitdir: <path>` pointer
6
+ * file inside a worktree — which is what every aimux-created workspace is.
7
+ */
8
+ function gitDirOf(cwd: string): string {
9
+ const dotGit = join(cwd, '.git')
10
+ if (statSync(dotGit).isDirectory()) return dotGit
11
+ const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, 'utf8'))?.[1]?.trim()
12
+ if (pointer == null || pointer === '') throw new Error('unreadable .git pointer')
13
+ return isAbsolute(pointer) ? pointer : resolve(cwd, pointer)
14
+ }
15
+
16
+ /**
17
+ * Current branch name, or null when detached (or when `cwd` is not a checkout) —
18
+ * the same contract as `git branch --show-current`, from one file read instead
19
+ * of a subprocess.
20
+ *
21
+ * The branch poller calls this for every workspace of every project every four
22
+ * seconds. Spawning git there cost ~35ms a tick on a 13-workspace setup; the
23
+ * reads cost ~0.1ms.
24
+ */
25
+ export function getCurrentBranch(cwd: string): string | null {
4
26
  try {
5
- const result = await $`git -C ${cwd} branch --show-current`.quiet()
6
- const branch = result.text().trim()
7
- return branch || null
27
+ const head = readFileSync(join(gitDirOf(cwd), 'HEAD'), 'utf8').trim()
28
+ return /^ref:\s*refs\/heads\/(.+)$/.exec(head)?.[1] ?? null
8
29
  } catch {
9
30
  return null
10
31
  }
@@ -0,0 +1,22 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /**
4
+ * `value`, but only once it has stopped changing for `ms`. The first value is
5
+ * returned as-is, so mounting is never delayed.
6
+ *
7
+ * Everything keyed on the active workspace's path — git status, PR checks,
8
+ * nested-repo discovery — restarts its poller and fires an immediate tick when
9
+ * that path changes. Holding `j`/`k` through the sidebar changes it at
10
+ * key-repeat rate, so without this every keypress spawned a `git status`
11
+ * fan-out and a network-bound `gh pr view` that the next keypress discarded.
12
+ */
13
+ export function useSettled<T>(value: T, ms: number): T {
14
+ const [settled, setSettled] = useState(value)
15
+
16
+ useEffect(() => {
17
+ const timer = setTimeout(() => setSettled(value), ms)
18
+ return () => clearTimeout(timer)
19
+ }, [value, ms])
20
+
21
+ return settled
22
+ }
package/src/ui/root.tsx CHANGED
@@ -109,7 +109,7 @@ function renderModal(
109
109
  cursorPos={modal.cursorPos}
110
110
  editingCommand={modal.type === 'new-tab' ? modal.editingCommand : null}
111
111
  editBuffer={modal.editBuffer ?? ''}
112
- excludeTerminal={modal.type === 'new-tab' && modal.pendingWorkspace != null}
112
+ pendingPrompt={modal.type === 'new-tab' ? (modal.pendingWorkspace?.prompt ?? null) : null}
113
113
  />
114
114
  )
115
115
  case 'create-workspace':