@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 +2 -2
- package/src/app-runtime/use-auto-commit-driver.ts +6 -1
- package/src/app-runtime/use-backend-runtime.ts +25 -8
- package/src/app-runtime/workspace-launch.ts +5 -0
- package/src/git/workspace-branch-poller.ts +17 -24
- package/src/git/workspace-divergence-poller.ts +26 -17
- package/src/ui/components/git/pane/git-pane-widget.tsx +7 -1
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +13 -10
- package/src/ui/components/modals/workspace/create-workspace-modal.tsx +3 -3
- package/src/ui/git-branch.ts +26 -5
- package/src/ui/hooks/use-settled.ts +22 -0
- package/src/ui/root.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.23.
|
|
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.
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
21
|
-
|
|
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 !== '' ? (
|
package/src/ui/git-branch.ts
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
2
3
|
|
|
3
|
-
|
|
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
|
|
6
|
-
|
|
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
|
-
|
|
112
|
+
pendingPrompt={modal.type === 'new-tab' ? (modal.pendingWorkspace?.prompt ?? null) : null}
|
|
113
113
|
/>
|
|
114
114
|
)
|
|
115
115
|
case 'create-workspace':
|