@brimveyn/aimux 1.22.6 → 1.22.7
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 +1 -1
- package/src/app-runtime/side-effects.ts +15 -132
- package/src/app-runtime/tab-actions.ts +36 -0
- package/src/app-runtime/workspace-actions.ts +19 -6
- package/src/app-runtime/workspace-launch.ts +135 -0
- package/src/git/pr-status.ts +28 -2
- package/src/git/workspace-divergence-poller.ts +21 -14
- package/src/git/worktree.ts +9 -0
- package/src/ui/components/git/pane/pr-state-row.tsx +60 -7
- package/src/ui/components/layout/sidebar/project-list.tsx +127 -100
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +12 -29
- package/src/ui/project-ordering.ts +18 -0
package/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { isAutoCommitEnabled } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
3
|
import type { SideEffect } from '../input/modes/types'
|
|
4
|
-
import type { AssistantId, PendingWorkspaceLaunch } from '../state/types'
|
|
5
4
|
import type { SideEffectContext } from './side-effect-context'
|
|
6
5
|
|
|
7
6
|
import { loadConfig, saveConfig } from '../config'
|
|
@@ -9,7 +8,6 @@ import { logInputDebug } from '../debug/input-log'
|
|
|
9
8
|
import { enqueueGitOp } from '../git/command-queue'
|
|
10
9
|
import { countDirtyFiles } from '../git/move-workspace'
|
|
11
10
|
import { getCurrentBranch, getDefaultBranch, listLocalBranches } from '../git/worktree'
|
|
12
|
-
import { assistantAcceptsPromptArg } from '../pty/command-registry'
|
|
13
11
|
import { allLeafIds, getGroupIdForTab } from '../state/layout-tree'
|
|
14
12
|
import { saveCurrentProject } from '../state/project-save'
|
|
15
13
|
import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
|
|
@@ -39,7 +37,6 @@ import {
|
|
|
39
37
|
handleSwitchProjectEffect,
|
|
40
38
|
restartTabSession,
|
|
41
39
|
} from './project-actions'
|
|
42
|
-
import { injectPromptWhenReady } from './prompt-injection'
|
|
43
40
|
import { getSelectedAssistantOption, getSelectedProject, getSelectedSnippet } from './selection'
|
|
44
41
|
import {
|
|
45
42
|
changeSelectedSetting,
|
|
@@ -48,7 +45,6 @@ import {
|
|
|
48
45
|
resetSelectedSetting,
|
|
49
46
|
} from './settings-actions'
|
|
50
47
|
import {
|
|
51
|
-
findSetupTab,
|
|
52
48
|
handleAskAgentForSetupScriptEffect,
|
|
53
49
|
handleConfigureSetupScriptEffect,
|
|
54
50
|
handlePromoteSetupTabEffect,
|
|
@@ -64,17 +60,16 @@ import {
|
|
|
64
60
|
confirmSplitSelection,
|
|
65
61
|
createTabSession,
|
|
66
62
|
executeSplitPane,
|
|
67
|
-
|
|
63
|
+
launchWithPrompt,
|
|
68
64
|
startExistingTab,
|
|
69
65
|
} from './tab-actions'
|
|
70
66
|
import {
|
|
71
|
-
createAimuxTempWorkspace,
|
|
72
67
|
isForceableWorkspaceDeleteError,
|
|
73
68
|
runDeleteWorkspace,
|
|
74
69
|
runMoveWorkspace,
|
|
75
70
|
setProjectDefaultBaseRef,
|
|
76
71
|
} from './workspace-actions'
|
|
77
|
-
import {
|
|
72
|
+
import { launchPendingWorkspace, startWorkspaceCreation } from './workspace-launch'
|
|
78
73
|
|
|
79
74
|
function handleProjectSelection(ctx: SideEffectContext): void {
|
|
80
75
|
const { backend, dispatch, state } = ctx
|
|
@@ -214,79 +209,6 @@ function hoistBranch(branches: string[], first: string | undefined): string[] {
|
|
|
214
209
|
return [first, ...branches.filter((branch) => branch !== first)]
|
|
215
210
|
}
|
|
216
211
|
|
|
217
|
-
/**
|
|
218
|
-
* Setup runs concurrently with the agent by design, so say so rather than
|
|
219
|
-
* letting the agent run tests against a half-installed tree and draw the wrong
|
|
220
|
-
* conclusion. Only prefixed when a setup is actually live.
|
|
221
|
-
*/
|
|
222
|
-
function buildWorkspacePrompt(ctx: SideEffectContext, pending: PendingWorkspaceLaunch): string {
|
|
223
|
-
const setupRunning = findSetupTab(ctx.getState().tabs, pending.workspaceId)?.status === 'running'
|
|
224
|
-
if (!setupRunning) return pending.prompt
|
|
225
|
-
return `Note: a setup script is currently installing this workspace's dependencies in the background. Wait for it to finish before running builds, tests, or anything that reads installed dependencies.\n\n${pending.prompt}`
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Name the workspace after what its prompt describes, using the assistant the
|
|
230
|
-
* user just picked. Background work that must never block or fail the launch.
|
|
231
|
-
*
|
|
232
|
-
* Always `pending.prompt`, never the setup-annotated variant built for the
|
|
233
|
-
* agent — the note is guidance, not part of what the user asked for.
|
|
234
|
-
*/
|
|
235
|
-
function renameWorkspaceFromLaunch(
|
|
236
|
-
ctx: SideEffectContext,
|
|
237
|
-
pending: PendingWorkspaceLaunch,
|
|
238
|
-
assistant: AssistantId
|
|
239
|
-
): void {
|
|
240
|
-
const workspace = ctx
|
|
241
|
-
.getState()
|
|
242
|
-
.projects.find((entry) => entry.id === pending.projectId)
|
|
243
|
-
?.workspaces?.find((entry) => entry.id === pending.workspaceId)
|
|
244
|
-
if (!workspace) return
|
|
245
|
-
|
|
246
|
-
void renameWorkspaceFromPrompt(
|
|
247
|
-
{ projectId: pending.projectId, prompt: pending.prompt, provider: assistant, workspace },
|
|
248
|
-
{
|
|
249
|
-
applyName: (projectId, workspaceId, patch) =>
|
|
250
|
-
ctx.dispatch({ patch, projectId, type: 'update-workspace-record', workspaceId }),
|
|
251
|
-
}
|
|
252
|
-
)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* The `<C-p>` flow: create a workspace in the current project, then chain into
|
|
257
|
-
* the new-tab modal rather than leaving the user in an empty workspace.
|
|
258
|
-
*/
|
|
259
|
-
async function createWorkspaceFromModal(
|
|
260
|
-
ctx: SideEffectContext,
|
|
261
|
-
projectId: string,
|
|
262
|
-
params: {
|
|
263
|
-
prompt: string
|
|
264
|
-
baseRef?: string
|
|
265
|
-
}
|
|
266
|
-
): Promise<void> {
|
|
267
|
-
// A name derived locally from the prompt, so the sidebar reads right from the
|
|
268
|
-
// first frame. The model-generated one replaces it a few seconds later.
|
|
269
|
-
// The branch is left to `createAimuxTempWorkspace`, which suffixes it with a
|
|
270
|
-
// timestamp: two workspaces started from the same prompt must not collide on
|
|
271
|
-
// the branch name before the model has had a chance to distinguish them.
|
|
272
|
-
const workspace = await createAimuxTempWorkspace(
|
|
273
|
-
ctx,
|
|
274
|
-
projectId,
|
|
275
|
-
placeholderWorkspaceName(params.prompt),
|
|
276
|
-
undefined,
|
|
277
|
-
params.baseRef
|
|
278
|
-
)
|
|
279
|
-
// Undefined means the create was rejected (e.g. branch already checked out);
|
|
280
|
-
// the modal stays open showing the error.
|
|
281
|
-
if (!workspace) return
|
|
282
|
-
|
|
283
|
-
ctx.dispatch({ type: 'close-modal' })
|
|
284
|
-
ctx.dispatch({
|
|
285
|
-
pendingWorkspace: { projectId, prompt: params.prompt, workspaceId: workspace.id },
|
|
286
|
-
type: 'open-new-tab-modal',
|
|
287
|
-
})
|
|
288
|
-
}
|
|
289
|
-
|
|
290
212
|
export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): void {
|
|
291
213
|
const { backend, dispatch, state } = ctx
|
|
292
214
|
|
|
@@ -313,47 +235,16 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
313
235
|
}
|
|
314
236
|
case 'launch-selected-assistant': {
|
|
315
237
|
const assistant = getSelectedAssistantOption(state).id
|
|
316
|
-
|
|
317
|
-
//
|
|
318
|
-
// Otherwise
|
|
319
|
-
// launchAssistant resolves itself.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const prompt = pending
|
|
324
|
-
? buildWorkspacePrompt(ctx, pending)
|
|
325
|
-
: ((state.modal.type === 'new-tab' ? state.modal.pendingPrompt : undefined) ?? '')
|
|
326
|
-
|
|
327
|
-
// Hand the prompt to the CLI at spawn where the CLI takes one. Pasting it
|
|
328
|
-
// into a live TUI works — it is what this flow did — but it means polling
|
|
329
|
-
// for readiness, probing the screen, and retrying. An argv slot has none of
|
|
330
|
-
// those failure modes.
|
|
331
|
-
const atSpawn = prompt !== '' && assistantAcceptsPromptArg(assistant, state.customCommands)
|
|
332
|
-
logInputDebug('app.launchSelectedAssistant', {
|
|
333
|
-
assistant,
|
|
334
|
-
chained: pending != null,
|
|
335
|
-
modal: state.modal.type,
|
|
336
|
-
promptAtSpawn: atSpawn,
|
|
337
|
-
promptLength: prompt.length,
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
const tabId = launchAssistant(
|
|
341
|
-
ctx,
|
|
342
|
-
assistant,
|
|
343
|
-
pending?.workspaceId,
|
|
344
|
-
atSpawn ? [prompt] : undefined
|
|
345
|
-
)
|
|
346
|
-
// Delivery is decided and done here, chained or not: two call sites meant
|
|
347
|
-
// the prompt could be built twice, from two different reads of the store.
|
|
348
|
-
if (prompt !== '' && !atSpawn) {
|
|
349
|
-
void injectPromptWhenReady({
|
|
350
|
-
backend: ctx.backend,
|
|
351
|
-
getState: ctx.getState,
|
|
352
|
-
prompt,
|
|
353
|
-
tabId,
|
|
354
|
-
})
|
|
238
|
+
const newTab = state.modal.type === 'new-tab' ? state.modal : undefined
|
|
239
|
+
// Chained from `<C-p>`: the tab is pinned to the workspace being cut, and
|
|
240
|
+
// waits for it. Otherwise it lands in the project's active workspace,
|
|
241
|
+
// which launchAssistant resolves itself.
|
|
242
|
+
if (newTab?.pendingWorkspace) {
|
|
243
|
+
launchPendingWorkspace(ctx, assistant, newTab.pendingWorkspace)
|
|
244
|
+
return
|
|
355
245
|
}
|
|
356
|
-
|
|
246
|
+
// Normalized to '' so "is there a prompt" stays one comparison.
|
|
247
|
+
launchWithPrompt(ctx, assistant, newTab?.pendingPrompt ?? '', undefined)
|
|
357
248
|
return
|
|
358
249
|
}
|
|
359
250
|
case 'edit-selected-assistant': {
|
|
@@ -389,18 +280,10 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
389
280
|
const projectId = state.currentProjectId
|
|
390
281
|
if (!(projectId != null && projectId !== '')) return
|
|
391
282
|
const { baseRef, prompt } = state.modal
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
baseRef: baseRef !== '' ? baseRef : undefined,
|
|
397
|
-
prompt,
|
|
398
|
-
})
|
|
399
|
-
)
|
|
400
|
-
} catch (error) {
|
|
401
|
-
toast.error(error instanceof Error ? error.message : String(error))
|
|
402
|
-
}
|
|
403
|
-
})()
|
|
283
|
+
startWorkspaceCreation(ctx, projectId, {
|
|
284
|
+
baseRef: baseRef !== '' ? baseRef : undefined,
|
|
285
|
+
prompt,
|
|
286
|
+
})
|
|
404
287
|
return
|
|
405
288
|
}
|
|
406
289
|
case 'confirm-selected-project': {
|
|
@@ -4,6 +4,7 @@ import type { SideEffectContext } from './side-effect-context'
|
|
|
4
4
|
import { logInputDebug } from '../debug/input-log'
|
|
5
5
|
import { createPrefixedId } from '../platform/id'
|
|
6
6
|
import {
|
|
7
|
+
assistantAcceptsPromptArg,
|
|
7
8
|
getAllAssistantOptions,
|
|
8
9
|
getAssistantOption,
|
|
9
10
|
isCommandAvailable,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
} from '../state/layout-tree'
|
|
21
22
|
import { getActiveWorkspace, getCurrentProject } from '../state/project-workspaces'
|
|
22
23
|
import { createDefaultTerminalModes } from '../state/terminal-modes'
|
|
24
|
+
import { injectPromptWhenReady } from './prompt-injection'
|
|
23
25
|
import { getSelectedAssistantOption } from './selection'
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -171,6 +173,40 @@ export function launchAssistant(
|
|
|
171
173
|
return tab.id
|
|
172
174
|
}
|
|
173
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Spawn the assistant and get `prompt` in front of it.
|
|
178
|
+
*
|
|
179
|
+
* Handed over at spawn where the CLI takes one. Pasting it into a live TUI
|
|
180
|
+
* works — it is what the `<C-p>` flow did — but it means polling for readiness,
|
|
181
|
+
* probing the screen, and retrying. An argv slot has none of those failure
|
|
182
|
+
* modes. Deciding it here means the prompt cannot be built twice, from two
|
|
183
|
+
* different reads of the store.
|
|
184
|
+
*/
|
|
185
|
+
export function launchWithPrompt(
|
|
186
|
+
ctx: SideEffectContext,
|
|
187
|
+
assistant: AssistantId,
|
|
188
|
+
prompt: string,
|
|
189
|
+
workspaceId: string | undefined
|
|
190
|
+
): void {
|
|
191
|
+
const atSpawn = prompt !== '' && assistantAcceptsPromptArg(assistant, ctx.state.customCommands)
|
|
192
|
+
logInputDebug('app.launchSelectedAssistant', {
|
|
193
|
+
assistant,
|
|
194
|
+
chained: workspaceId != null,
|
|
195
|
+
promptAtSpawn: atSpawn,
|
|
196
|
+
promptLength: prompt.length,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const tabId = launchAssistant(ctx, assistant, workspaceId, atSpawn ? [prompt] : undefined)
|
|
200
|
+
if (prompt !== '' && !atSpawn) {
|
|
201
|
+
void injectPromptWhenReady({
|
|
202
|
+
backend: ctx.backend,
|
|
203
|
+
getState: ctx.getState,
|
|
204
|
+
prompt,
|
|
205
|
+
tabId,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
174
210
|
export function getTabProjectPath(
|
|
175
211
|
ctx: SideEffectContext,
|
|
176
212
|
tab: Pick<TabSession, 'workspaceId'>
|
|
@@ -94,14 +94,27 @@ function normalizeBranchName(branch: string | undefined): string | undefined {
|
|
|
94
94
|
export async function createAimuxTempWorkspace(
|
|
95
95
|
ctx: SideEffectContext,
|
|
96
96
|
projectId: string,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
options: {
|
|
98
|
+
name?: string
|
|
99
|
+
branchName?: string
|
|
100
|
+
baseRef?: string
|
|
101
|
+
sourceWorkspaceId?: string
|
|
102
|
+
/**
|
|
103
|
+
* Pre-allocated by callers that must name the workspace before git is done
|
|
104
|
+
* cutting it — the `<C-p>` chain pins a tab to it while it is still being
|
|
105
|
+
* created.
|
|
106
|
+
*/
|
|
107
|
+
workspaceId?: string
|
|
108
|
+
} = {}
|
|
101
109
|
): Promise<WorkspaceRecord | undefined> {
|
|
110
|
+
const {
|
|
111
|
+
baseRef: requestedBaseRef,
|
|
112
|
+
branchName: requestedBranchName,
|
|
113
|
+
name: requestedName,
|
|
114
|
+
} = options
|
|
102
115
|
const project = ctx.state.projects.find((entry) => entry.id === projectId)
|
|
103
116
|
const source =
|
|
104
|
-
project?.workspaces?.find((entry) => entry.id === sourceWorkspaceId) ??
|
|
117
|
+
project?.workspaces?.find((entry) => entry.id === options.sourceWorkspaceId) ??
|
|
105
118
|
getActiveWorkspace(project)
|
|
106
119
|
const sourcePath = source?.path ?? getActiveWorkspacePath(project)
|
|
107
120
|
if (!project || !(sourcePath != null && sourcePath !== '')) return undefined
|
|
@@ -111,7 +124,7 @@ export async function createAimuxTempWorkspace(
|
|
|
111
124
|
const repoRoot = (await getMainWorktreeRoot(sourcePath)) ?? source?.repoRoot ?? sourcePath
|
|
112
125
|
const baseBranch = (await getCurrentBranch(sourcePath)) ?? source?.branch ?? 'HEAD'
|
|
113
126
|
const baseRef = requestedBaseRef ?? baseBranch
|
|
114
|
-
const workspaceId = createPrefixedId('workspace')
|
|
127
|
+
const workspaceId = options.workspaceId ?? createPrefixedId('workspace')
|
|
115
128
|
const trimmedName = requestedName?.trim()
|
|
116
129
|
const workspaceName =
|
|
117
130
|
trimmedName != null && trimmedName !== ''
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { AssistantId, PendingWorkspaceLaunch, WorkspaceRecord } from '../state/types'
|
|
2
|
+
import type { SideEffectContext } from './side-effect-context'
|
|
3
|
+
|
|
4
|
+
import { enqueueGitOp } from '../git/command-queue'
|
|
5
|
+
import { createPrefixedId } from '../platform/id'
|
|
6
|
+
import { hasSetupScript } from '../state/project-data'
|
|
7
|
+
import { findWorkspace } from '../state/project-workspaces'
|
|
8
|
+
import { toast } from '../state/toast-store'
|
|
9
|
+
import { findSetupTab } from './setup-actions'
|
|
10
|
+
import { launchWithPrompt } from './tab-actions'
|
|
11
|
+
import { createAimuxTempWorkspace } from './workspace-actions'
|
|
12
|
+
import { placeholderWorkspaceName, renameWorkspaceFromPrompt } from './workspace-naming'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The `<C-p>` worktree being cut while its assistant picker is already open.
|
|
16
|
+
* A barrier, not a value: what the launch needs to know — did the worktree
|
|
17
|
+
* land — is read back from the store, which is the only thing that can still
|
|
18
|
+
* be true a tick later.
|
|
19
|
+
*
|
|
20
|
+
* Single-slot: only one create-workspace modal can be open, so only one chain
|
|
21
|
+
* can ever be in flight.
|
|
22
|
+
*/
|
|
23
|
+
let pendingWorkspaceCreate: Promise<void> | null = null
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The `<C-p>` flow: chain into the new-tab modal *now* and cut the worktree in
|
|
27
|
+
* the background. `git fetch` + `worktree add` take seconds, and the next
|
|
28
|
+
* question — which assistant — does not depend on either, so making the user
|
|
29
|
+
* watch a frozen modal buys nothing. `launchPendingWorkspace` waits this out,
|
|
30
|
+
* so the tab still lands in a finished workspace.
|
|
31
|
+
*/
|
|
32
|
+
export function startWorkspaceCreation(
|
|
33
|
+
ctx: SideEffectContext,
|
|
34
|
+
projectId: string,
|
|
35
|
+
params: {
|
|
36
|
+
prompt: string
|
|
37
|
+
baseRef?: string
|
|
38
|
+
}
|
|
39
|
+
): void {
|
|
40
|
+
// Allocated here rather than by the create: the picker below pins its tab to
|
|
41
|
+
// this workspace before git has produced anything to pin to.
|
|
42
|
+
const workspaceId = createPrefixedId('workspace')
|
|
43
|
+
pendingWorkspaceCreate = (async () => {
|
|
44
|
+
try {
|
|
45
|
+
await enqueueGitOp(async () =>
|
|
46
|
+
// A name derived locally from the prompt, so the sidebar reads right from
|
|
47
|
+
// the first frame. The model-generated one replaces it a few seconds later.
|
|
48
|
+
// The branch is left to `createAimuxTempWorkspace`, which suffixes it with
|
|
49
|
+
// a timestamp: two workspaces started from the same prompt must not
|
|
50
|
+
// collide on the branch name before the model has distinguished them.
|
|
51
|
+
createAimuxTempWorkspace(ctx, projectId, {
|
|
52
|
+
baseRef: params.baseRef,
|
|
53
|
+
name: placeholderWorkspaceName(params.prompt),
|
|
54
|
+
workspaceId,
|
|
55
|
+
})
|
|
56
|
+
)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// The modal that would have shown this inline is already gone, so the
|
|
59
|
+
// toast is the only channel left. The launch reads the missing record.
|
|
60
|
+
toast.error(error instanceof Error ? error.message : String(error))
|
|
61
|
+
}
|
|
62
|
+
})()
|
|
63
|
+
ctx.dispatch({ type: 'close-modal' })
|
|
64
|
+
ctx.dispatch({
|
|
65
|
+
pendingWorkspace: { projectId, prompt: params.prompt, workspaceId },
|
|
66
|
+
type: 'open-new-tab-modal',
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Setup runs concurrently with the agent by design, so say so rather than
|
|
72
|
+
* letting the agent run tests against a half-installed tree and draw the wrong
|
|
73
|
+
* conclusion. Only prefixed when a setup is actually live.
|
|
74
|
+
*/
|
|
75
|
+
function buildWorkspacePrompt(ctx: SideEffectContext, pending: PendingWorkspaceLaunch): string {
|
|
76
|
+
const setupTab = findSetupTab(ctx.getState().tabs, pending.workspaceId)
|
|
77
|
+
// No setup tab yet does not mean no setup: the workspace can land in the
|
|
78
|
+
// store the same tick the user picks, and the runner only spawns on the next
|
|
79
|
+
// render. A project with a script is about to run it, so say so.
|
|
80
|
+
const setupRunning = setupTab ? setupTab.status === 'running' : hasSetupScript(pending.projectId)
|
|
81
|
+
if (!setupRunning) return pending.prompt
|
|
82
|
+
return `Note: a setup script is currently installing this workspace's dependencies in the background. Wait for it to finish before running builds, tests, or anything that reads installed dependencies.\n\n${pending.prompt}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Name the workspace after what its prompt describes, using the assistant the
|
|
87
|
+
* user just picked. Background work that must never block or fail the launch.
|
|
88
|
+
*
|
|
89
|
+
* Always `pending.prompt`, never the setup-annotated variant built for the
|
|
90
|
+
* agent — the note is guidance, not part of what the user asked for.
|
|
91
|
+
*/
|
|
92
|
+
function renameWorkspaceFromLaunch(
|
|
93
|
+
ctx: SideEffectContext,
|
|
94
|
+
pending: PendingWorkspaceLaunch,
|
|
95
|
+
workspace: WorkspaceRecord,
|
|
96
|
+
assistant: AssistantId
|
|
97
|
+
): void {
|
|
98
|
+
void renameWorkspaceFromPrompt(
|
|
99
|
+
{ projectId: pending.projectId, prompt: pending.prompt, provider: assistant, workspace },
|
|
100
|
+
{
|
|
101
|
+
applyName: (projectId, workspaceId, patch) =>
|
|
102
|
+
ctx.dispatch({ patch, projectId, type: 'update-workspace-record', workspaceId }),
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pin the tab to the workspace `<C-p>` is cutting, hand it the prompt, and name
|
|
109
|
+
* the workspace after what it describes.
|
|
110
|
+
*
|
|
111
|
+
* Waits for the worktree first: spawning early would drop the tab in the
|
|
112
|
+
* project checkout instead, since that is what an unresolvable workspace id
|
|
113
|
+
* falls back to.
|
|
114
|
+
*/
|
|
115
|
+
export function launchPendingWorkspace(
|
|
116
|
+
ctx: SideEffectContext,
|
|
117
|
+
assistant: AssistantId,
|
|
118
|
+
pending: PendingWorkspaceLaunch
|
|
119
|
+
): void {
|
|
120
|
+
const creating = pendingWorkspaceCreate
|
|
121
|
+
pendingWorkspaceCreate = null
|
|
122
|
+
void (async () => {
|
|
123
|
+
await creating
|
|
124
|
+
// Read the store again: the record landed after `ctx.state` was captured,
|
|
125
|
+
// and its absence is how a failed create says "do not spawn".
|
|
126
|
+
const fresh = { ...ctx, state: ctx.getState() }
|
|
127
|
+
const found = findWorkspace(fresh.state.projects, pending.workspaceId)
|
|
128
|
+
if (!found) {
|
|
129
|
+
toast.error('Workspace creation failed — no tab was opened')
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
launchWithPrompt(fresh, assistant, buildWorkspacePrompt(fresh, pending), pending.workspaceId)
|
|
133
|
+
renameWorkspaceFromLaunch(fresh, pending, found.workspace, assistant)
|
|
134
|
+
})()
|
|
135
|
+
}
|
package/src/git/pr-status.ts
CHANGED
|
@@ -145,7 +145,7 @@ export function parsePrView(raw: unknown): PrStatusResult {
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
export type PrAction = 'merge' | null
|
|
148
|
+
export type PrAction = 'cleanup' | 'merge' | null
|
|
149
149
|
|
|
150
150
|
export interface PrActionState {
|
|
151
151
|
label: string
|
|
@@ -160,7 +160,11 @@ export interface PrActionState {
|
|
|
160
160
|
* offer an action for still gets an honest label rather than a dead button.
|
|
161
161
|
*/
|
|
162
162
|
export function prActionState(pr: PrSummary, checks: PrCheck[]): PrActionState {
|
|
163
|
-
|
|
163
|
+
// Merged is the one terminal state with something left to do: the workspace
|
|
164
|
+
// that carried the branch is now dead weight. The row offers the removal
|
|
165
|
+
// right where the merge happened; whether it's actually removable (not the
|
|
166
|
+
// primary workspace) is the caller's call, not the PR's.
|
|
167
|
+
if (pr.state === 'MERGED') return { action: 'cleanup', label: 'Merged', tone: 'ok' }
|
|
164
168
|
if (pr.state === 'CLOSED') return { action: null, label: 'Closed', tone: 'blocked' }
|
|
165
169
|
if (pr.isDraft) return { action: null, label: 'Draft', tone: 'neutral' }
|
|
166
170
|
if (pr.mergeable === 'CONFLICTING') {
|
|
@@ -182,6 +186,28 @@ export function prActionState(pr: PrSummary, checks: PrCheck[]): PrActionState {
|
|
|
182
186
|
return { action: null, label: 'Checking…', tone: 'neutral' }
|
|
183
187
|
}
|
|
184
188
|
|
|
189
|
+
export type PrCleanupKind = 'branch' | 'worktree' | null
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* What "clean up" means for a merged PR, which depends on where its branch
|
|
193
|
+
* lives. A linked workspace is disposable, so it goes. The repo checkout is not
|
|
194
|
+
* — the equivalent is leaving the merged branch for the one it landed on, which
|
|
195
|
+
* the PR names, so a PR into `develop` doesn't drop you on `main`.
|
|
196
|
+
*
|
|
197
|
+
* `gh pr view` resolves the PR from the checked-out branch, so a PR on screen
|
|
198
|
+
* means we're on its head — but a base already equal to it has nowhere to go.
|
|
199
|
+
*/
|
|
200
|
+
export function prCleanupKind(
|
|
201
|
+
action: PrAction,
|
|
202
|
+
pr: Pick<PrSummary, 'base' | 'head'>,
|
|
203
|
+
workspaceIsRemovable: boolean
|
|
204
|
+
): PrCleanupKind {
|
|
205
|
+
if (action !== 'cleanup') return null
|
|
206
|
+
if (workspaceIsRemovable) return 'worktree'
|
|
207
|
+
if (pr.base === '' || pr.base === pr.head) return null
|
|
208
|
+
return 'branch'
|
|
209
|
+
}
|
|
210
|
+
|
|
185
211
|
export interface ClampedBody {
|
|
186
212
|
text: string
|
|
187
213
|
truncated: boolean
|
|
@@ -9,22 +9,24 @@ import { getBranchDivergence, getWorkspaceDiffStat } from './divergence'
|
|
|
9
9
|
const INTERVAL_MS = 4000
|
|
10
10
|
|
|
11
11
|
// Polls per-workspace base divergence for the current project while enabled and
|
|
12
|
-
// dispatches it into workspaceDivergence.
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// dispatches it into workspaceDivergence. aimux-created workspaces are measured
|
|
13
|
+
// against the ref they forked from; the primary and externally-discovered ones
|
|
14
|
+
// never forked, so they fall back to their own upstream — for a root checkout on
|
|
15
|
+
// main that reads as "unpushed commits + dirty work". A branch with no upstream
|
|
16
|
+
// makes git fail, which the poller already renders as nothing.
|
|
15
17
|
export function useWorkspaceDivergencePolling(enabled: boolean): void {
|
|
16
|
-
const currentProjectId = useAppStore((s) => s.currentProjectId)
|
|
17
18
|
const projects = useAppStore((s) => s.projects)
|
|
18
19
|
|
|
19
20
|
useEffect(() => {
|
|
20
21
|
if (!enabled) return
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 !== '')
|
|
28
30
|
if (targets.length === 0) return
|
|
29
31
|
|
|
30
32
|
let cancelled = false
|
|
@@ -33,9 +35,14 @@ export function useWorkspaceDivergencePolling(enabled: boolean): void {
|
|
|
33
35
|
const tick = async () => {
|
|
34
36
|
const entries = await Promise.all(
|
|
35
37
|
targets.map(async (workspace) => {
|
|
36
|
-
const base = workspace.baseRef
|
|
37
38
|
const branch = workspace.branch
|
|
38
|
-
if (
|
|
39
|
+
if (branch == null) return null
|
|
40
|
+
// `<branch>@{upstream}` rather than a bare `@{upstream}`: the latter
|
|
41
|
+
// resolves against the repo root's HEAD, which is not this workspace.
|
|
42
|
+
const base =
|
|
43
|
+
workspace.baseRef != null && workspace.baseRef !== ''
|
|
44
|
+
? workspace.baseRef
|
|
45
|
+
: `${branch}@{upstream}`
|
|
39
46
|
// Commits come from the repo root (comparing two refs); lines come
|
|
40
47
|
// from the workspace itself, so uncommitted work is counted too.
|
|
41
48
|
const [divergence, stat] = await Promise.all([
|
|
@@ -61,5 +68,5 @@ export function useWorkspaceDivergencePolling(enabled: boolean): void {
|
|
|
61
68
|
cancelled = true
|
|
62
69
|
if (timer != null) clearTimeout(timer)
|
|
63
70
|
}
|
|
64
|
-
}, [enabled,
|
|
71
|
+
}, [enabled, projects])
|
|
65
72
|
}
|
package/src/git/worktree.ts
CHANGED
|
@@ -181,6 +181,15 @@ export async function pruneGitWorktrees(repoPath: string): Promise<void> {
|
|
|
181
181
|
await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// Switch a checkout to an existing branch. Throws git's own message rather than
|
|
185
|
+
// a paraphrase: "local changes would be overwritten" is the one thing the user
|
|
186
|
+
// needs to read, and no wording of ours beats it.
|
|
187
|
+
export async function checkoutBranch(cwd: string, branch: string): Promise<void> {
|
|
188
|
+
const result = await $`git -C ${cwd} checkout ${branch}`.quiet().nothrow()
|
|
189
|
+
if (result.exitCode === 0) return
|
|
190
|
+
throw new Error(result.stderr.toString().trim() || `failed to check out ${branch}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
184
193
|
// Drop every `aimux/` branch left behind by deleted temp worktrees. git refuses
|
|
185
194
|
// to delete branches still checked out in a live worktree, so this only removes
|
|
186
195
|
// true orphans. Returns the number of branches removed.
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { memo, useCallback, useState } from 'react'
|
|
2
2
|
|
|
3
|
+
import { enqueueGitOp } from '../../../../git/command-queue'
|
|
3
4
|
import { approveAndMergePr } from '../../../../git/pr-merge'
|
|
4
|
-
import { type PrActionState, prActionState } from '../../../../git/pr-status'
|
|
5
|
+
import { type PrActionState, prActionState, prCleanupKind } from '../../../../git/pr-status'
|
|
5
6
|
import { refreshPrStatus } from '../../../../git/pr-status-poller'
|
|
7
|
+
import { checkoutBranch } from '../../../../git/worktree'
|
|
6
8
|
import { openUrl } from '../../../../platform/open-url'
|
|
9
|
+
import { useAppStore } from '../../../../state/app-store'
|
|
10
|
+
import { runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
7
11
|
import { usePrStatusStore } from '../../../../state/pr-status-store'
|
|
12
|
+
import { getActiveWorkspace } from '../../../../state/project-workspaces'
|
|
8
13
|
import { toast } from '../../../../state/toast-store'
|
|
9
14
|
import { useBusySpinner } from '../../../hooks/use-busy-spinner'
|
|
10
15
|
import { type ResolvedTuiTheme, useTheme, useTransparent } from '../../../theme'
|
|
@@ -22,18 +27,62 @@ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPat
|
|
|
22
27
|
const transparent = useTransparent()
|
|
23
28
|
const bg = transparent ? undefined : t.backgroundElement
|
|
24
29
|
const result = usePrStatusStore((s) => s.result)
|
|
30
|
+
const currentProjectId = useAppStore((s) => s.currentProjectId)
|
|
31
|
+
const projects = useAppStore((s) => s.projects)
|
|
25
32
|
const [confirming, setConfirming] = useState(false)
|
|
26
33
|
const [merging, setMerging] = useState(false)
|
|
27
34
|
const spinner = useBusySpinner(merging)
|
|
28
35
|
|
|
29
36
|
const pr = result?.kind === 'ok' ? result.pr : null
|
|
37
|
+
const status = result?.kind === 'ok' ? prActionState(result.pr, result.checks) : null
|
|
30
38
|
const prUrl = pr?.url ?? ''
|
|
31
39
|
|
|
40
|
+
// The PR is polled against the active workspace's path, so its branch is this
|
|
41
|
+
// workspace's branch — which is what makes offering the removal here honest.
|
|
42
|
+
const project = projects.find((entry) => entry.id === currentProjectId)
|
|
43
|
+
const workspace = getActiveWorkspace(project)
|
|
44
|
+
const projectId = project?.id ?? null
|
|
45
|
+
const removableWorkspaceId =
|
|
46
|
+
workspace !== undefined && workspace.source !== 'primary' ? workspace.id : null
|
|
47
|
+
|
|
48
|
+
const base = pr?.base ?? ''
|
|
49
|
+
const cleanupKind =
|
|
50
|
+
pr === null ? null : prCleanupKind(status?.action ?? null, pr, removableWorkspaceId !== null)
|
|
51
|
+
|
|
32
52
|
const openPr = useCallback(() => openUrl(prUrl), [prUrl])
|
|
33
53
|
const askConfirm = useCallback(() => setConfirming(true), [])
|
|
34
54
|
const cancel = useCallback(() => setConfirming(false), [])
|
|
35
55
|
const confirm = useCallback(() => {
|
|
36
56
|
setConfirming(false)
|
|
57
|
+
if (cleanupKind === 'worktree') {
|
|
58
|
+
if (projectId === null || removableWorkspaceId === null) return
|
|
59
|
+
// closeTabs (not force) mirrors the sidebar's "Remove workspace": the
|
|
60
|
+
// workspace's tabs are disposed, but a dirty worktree still re-prompts for
|
|
61
|
+
// an explicit force-delete instead of silently discarding work.
|
|
62
|
+
runSideEffectGlobal({
|
|
63
|
+
closeTabs: true,
|
|
64
|
+
projectId,
|
|
65
|
+
type: 'delete-workspace',
|
|
66
|
+
workspaceId: removableWorkspaceId,
|
|
67
|
+
})
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
if (cleanupKind === 'branch') {
|
|
71
|
+
void (async () => {
|
|
72
|
+
try {
|
|
73
|
+
// Queued like every other mutating git op: the pollers read this same
|
|
74
|
+
// checkout, and a checkout mid-status is how you get a torn panel.
|
|
75
|
+
await enqueueGitOp(async () => checkoutBranch(projectPath, base))
|
|
76
|
+
} catch (error) {
|
|
77
|
+
toast.error(error instanceof Error ? error.message : String(error))
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
// The branch poller would catch up within its tick; refreshing here
|
|
81
|
+
// retires the row now instead of leaving a merged PR sitting on screen.
|
|
82
|
+
await refreshPrStatus(projectPath)
|
|
83
|
+
})()
|
|
84
|
+
return
|
|
85
|
+
}
|
|
37
86
|
setMerging(true)
|
|
38
87
|
void (async () => {
|
|
39
88
|
const merged = await approveAndMergePr(projectPath)
|
|
@@ -42,14 +91,18 @@ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPat
|
|
|
42
91
|
else toast.error(merged.message)
|
|
43
92
|
await refreshPrStatus(projectPath)
|
|
44
93
|
})()
|
|
45
|
-
}, [projectPath])
|
|
94
|
+
}, [base, cleanupKind, projectId, projectPath, removableWorkspaceId])
|
|
46
95
|
|
|
47
96
|
// Nothing known yet (or nothing to show): stay out of the layout entirely and
|
|
48
97
|
// appear only once a fetch reports a PR.
|
|
49
|
-
if (result?.kind !== 'ok' || pr === null) return null
|
|
50
|
-
const
|
|
98
|
+
if (result?.kind !== 'ok' || pr === null || status === null) return null
|
|
99
|
+
const showAction = cleanupKind !== null || status.action === 'merge'
|
|
51
100
|
let label = status.label
|
|
52
|
-
if (confirming)
|
|
101
|
+
if (confirming) {
|
|
102
|
+
if (cleanupKind === 'worktree') label = 'Remove this worktree?'
|
|
103
|
+
else if (cleanupKind === 'branch') label = `Switch to ${base}?`
|
|
104
|
+
else label = 'Merge this PR?'
|
|
105
|
+
}
|
|
53
106
|
if (merging) label = `${spinner} merging…`
|
|
54
107
|
|
|
55
108
|
return (
|
|
@@ -82,10 +135,10 @@ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPat
|
|
|
82
135
|
</text>
|
|
83
136
|
</box>
|
|
84
137
|
) : null}
|
|
85
|
-
{
|
|
138
|
+
{showAction && !confirming && !merging ? (
|
|
86
139
|
<box flexShrink={0}>
|
|
87
140
|
<text selectable={false} fg={t.primary} bg={bg} wrapMode="none" onMouseDown={askConfirm}>
|
|
88
|
-
<strong>Merge</strong>
|
|
141
|
+
<strong>{cleanupKind === null ? 'Merge' : 'Clean up'}</strong>
|
|
89
142
|
</text>
|
|
90
143
|
</box>
|
|
91
144
|
) : null}
|
|
@@ -13,7 +13,7 @@ import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-
|
|
|
13
13
|
import { getPrimaryWorkspace } from '../../../../state/project-workspaces'
|
|
14
14
|
// eslint-disable-next-line no-duplicate-imports
|
|
15
15
|
import { IDLE_PROJECT_STATUS } from '../../../../state/types'
|
|
16
|
-
import {
|
|
16
|
+
import { moveIdToInsertIndex, orderProjectsForDisplay } from '../../../project-ordering'
|
|
17
17
|
import { useBaseTheme, useTheme } from '../../../theme'
|
|
18
18
|
import { truncate } from '../../../truncate'
|
|
19
19
|
import { FlashLabelBadge } from '../../flash/flash-label-badge'
|
|
@@ -28,6 +28,8 @@ interface ProjectListProps {
|
|
|
28
28
|
const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
|
|
29
29
|
|
|
30
30
|
const RULE = '─'
|
|
31
|
+
/** Heavier than the chrome rules, so the drop preview never reads as a border. */
|
|
32
|
+
const DROP_BAR = '━'
|
|
31
33
|
const HEADER_TITLE = 'Projects'
|
|
32
34
|
/**
|
|
33
35
|
* U+2699, not the nerd-font gear: its Emoji_Presentation is No, so a conforming
|
|
@@ -36,12 +38,10 @@ const HEADER_TITLE = 'Projects'
|
|
|
36
38
|
*/
|
|
37
39
|
const SETTINGS_GLYPH = '⚙'
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
return true
|
|
41
|
+
interface DragState {
|
|
42
|
+
id: string
|
|
43
|
+
/** Gap the drop would land in, or null while the pointer is off the list. */
|
|
44
|
+
dropIndex: number | null
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
@@ -50,10 +50,18 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
50
50
|
const currentProjectId = useAppStore((s) => s.currentProjectId)
|
|
51
51
|
const statusMap = useAppStore((s) => s.projectStatuses)
|
|
52
52
|
|
|
53
|
+
// The drag lives in a ref because mouse events can arrive before React has
|
|
54
|
+
// committed the state they set — reading `draggingId` out of a handler
|
|
55
|
+
// closure saw `null` for the whole gesture. The two state copies below exist
|
|
56
|
+
// only so the row highlight and the drop bar redraw.
|
|
57
|
+
const dragRef = useRef<DragState | null>(null)
|
|
53
58
|
const [draggingId, setDraggingId] = useState<string | null>(null)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
// Where the drop would land, as a gap index (0 = above the first project,
|
|
60
|
+
// projects.length = below the last). The list itself never moves while
|
|
61
|
+
// dragging — only this bar does, so rows can't slide out from under the
|
|
62
|
+
// pointer and make the drag oscillate.
|
|
63
|
+
const [dropIndex, setDropIndex] = useState<number | null>(null)
|
|
64
|
+
const gapRefs = useRef(new Map<number, BoxRenderable>())
|
|
57
65
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
58
66
|
|
|
59
67
|
const ordered = useMemo(() => orderProjectsForDisplay(projects), [projects])
|
|
@@ -83,58 +91,64 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
83
91
|
visible: true,
|
|
84
92
|
})
|
|
85
93
|
|
|
86
|
-
const
|
|
87
|
-
if (ref)
|
|
88
|
-
else
|
|
94
|
+
const setGapRef = useCallback((index: number, ref: BoxRenderable | null): void => {
|
|
95
|
+
if (ref) gapRefs.current.set(index, ref)
|
|
96
|
+
else gapRefs.current.delete(index)
|
|
89
97
|
}, [])
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
// The gaps *are* the insertion points, so the nearest one to the pointer is
|
|
100
|
+
// the drop slot — no row-height arithmetic, which matters because a workspace
|
|
101
|
+
// row is one line or two depending on whether it has a branch.
|
|
102
|
+
const findDropIndex = useCallback((event: OtuiMouseEvent): number | null => {
|
|
103
|
+
const box = scrollRef.current
|
|
104
|
+
if (box && (event.x < box.x || event.x >= box.x + box.width)) return null
|
|
105
|
+
let best: number | null = null
|
|
106
|
+
let bestDistance = Number.POSITIVE_INFINITY
|
|
107
|
+
for (const [index, ref] of gapRefs.current) {
|
|
108
|
+
const distance = Math.abs(event.y - ref.y)
|
|
109
|
+
if (distance < bestDistance) {
|
|
110
|
+
bestDistance = distance
|
|
111
|
+
best = index
|
|
112
|
+
}
|
|
94
113
|
}
|
|
95
|
-
return
|
|
114
|
+
return best
|
|
96
115
|
}, [])
|
|
97
116
|
|
|
98
|
-
const handleRowDragStart = useCallback(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
},
|
|
104
|
-
[baselineOrder]
|
|
105
|
-
)
|
|
117
|
+
const handleRowDragStart = useCallback((id: string) => {
|
|
118
|
+
dragRef.current = { dropIndex: null, id }
|
|
119
|
+
setDraggingId(id)
|
|
120
|
+
setDropIndex(null)
|
|
121
|
+
}, [])
|
|
106
122
|
|
|
107
|
-
const
|
|
123
|
+
const handleDrag = useCallback(
|
|
108
124
|
(event: OtuiMouseEvent) => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return
|
|
114
|
-
}
|
|
115
|
-
if (hit === draggingId) {
|
|
116
|
-
lastSwapWithRef.current = null
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
if (hit === lastSwapWithRef.current) return
|
|
120
|
-
setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
|
|
121
|
-
lastSwapWithRef.current = hit
|
|
125
|
+
const drag = dragRef.current
|
|
126
|
+
if (!drag) return
|
|
127
|
+
drag.dropIndex = findDropIndex(event)
|
|
128
|
+
setDropIndex(drag.dropIndex)
|
|
122
129
|
},
|
|
123
|
-
[
|
|
130
|
+
[findDropIndex]
|
|
124
131
|
)
|
|
125
132
|
|
|
133
|
+
// Bound to both `up` and `drag-end`: a plain click only ever sends `up`,
|
|
134
|
+
// while a released drag sends `drag-end` first. Clearing the ref makes the
|
|
135
|
+
// second one a no-op, so either order does the same thing once.
|
|
126
136
|
const commitDrop = useCallback(() => {
|
|
127
|
-
const
|
|
128
|
-
|
|
137
|
+
const drag = dragRef.current
|
|
138
|
+
dragRef.current = null
|
|
129
139
|
setDraggingId(null)
|
|
130
|
-
|
|
131
|
-
lastSwapWithRef.current = null
|
|
140
|
+
setDropIndex(null)
|
|
132
141
|
|
|
133
|
-
if (
|
|
142
|
+
if (!drag) return
|
|
143
|
+
const source = drag.id
|
|
134
144
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
145
|
+
if (drag.dropIndex !== null) {
|
|
146
|
+
const nextOrder = moveIdToInsertIndex(baselineOrder, source, drag.dropIndex)
|
|
147
|
+
// Identity means the drop landed back where it started — a released drag,
|
|
148
|
+
// not a click, so it must not fall through to switching project.
|
|
149
|
+
if (nextOrder !== baselineOrder) {
|
|
150
|
+
dispatchGlobal({ orderedIds: nextOrder, type: 'reorder-projects' })
|
|
151
|
+
}
|
|
138
152
|
return
|
|
139
153
|
}
|
|
140
154
|
|
|
@@ -152,13 +166,7 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
152
166
|
workspaceId: sourcePrimaryId,
|
|
153
167
|
})
|
|
154
168
|
}
|
|
155
|
-
}, [baselineOrder,
|
|
156
|
-
|
|
157
|
-
const cancelDrag = useCallback(() => {
|
|
158
|
-
setDraggingId(null)
|
|
159
|
-
setDragOrder(null)
|
|
160
|
-
lastSwapWithRef.current = null
|
|
161
|
-
}, [])
|
|
169
|
+
}, [baselineOrder, ordered])
|
|
162
170
|
|
|
163
171
|
const handleNewProject = useCallback((e: OtuiMouseEvent) => {
|
|
164
172
|
e.stopPropagation()
|
|
@@ -175,17 +183,22 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
175
183
|
dispatchGlobal({ type: 'enter-settings' })
|
|
176
184
|
}, [])
|
|
177
185
|
|
|
178
|
-
const visibleProjects =
|
|
179
|
-
dragOrder !== null
|
|
180
|
-
? dragOrder
|
|
181
|
-
.map((id) => ordered.find((s) => s.id === id))
|
|
182
|
-
.filter((s): s is ProjectRecord => !!s)
|
|
183
|
-
: ordered
|
|
184
|
-
|
|
185
186
|
const rule = RULE.repeat(Math.max(1, contentWidth))
|
|
186
187
|
|
|
187
188
|
return (
|
|
188
|
-
|
|
189
|
+
// Drag and release are handled here, not on the row that started them:
|
|
190
|
+
// opentui captures the pointer at the first drag event, wherever it lands,
|
|
191
|
+
// and a one-line heading is left the moment the pointer moves down. Any row
|
|
192
|
+
// that captures is a descendant of this box, so the events bubble here.
|
|
193
|
+
<box
|
|
194
|
+
flexDirection="column"
|
|
195
|
+
flexGrow={1}
|
|
196
|
+
flexShrink={1}
|
|
197
|
+
overflow="hidden"
|
|
198
|
+
onMouseDrag={handleDrag}
|
|
199
|
+
onMouseUp={commitDrop}
|
|
200
|
+
onMouseDragEnd={commitDrop}
|
|
201
|
+
>
|
|
189
202
|
<box flexShrink={0}>
|
|
190
203
|
<text fg={t.border} selectable={false} wrapMode="none">
|
|
191
204
|
{rule}
|
|
@@ -212,8 +225,8 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
212
225
|
// one of its workspaces. One map, one React keypath per visible row;
|
|
213
226
|
// transitions are a single atomic reconciliation.
|
|
214
227
|
const rows: ReactNode[] = []
|
|
215
|
-
for (const project of
|
|
216
|
-
const projectIndex =
|
|
228
|
+
for (const [index, project] of ordered.entries()) {
|
|
229
|
+
const projectIndex = index + 1
|
|
217
230
|
const isCurrentProject = project.id === currentProjectId
|
|
218
231
|
const workspaces = project.workspaces ?? []
|
|
219
232
|
// Every workspace gets a row, the checkout included. Folding it into
|
|
@@ -224,6 +237,18 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
224
237
|
project.activeWorkspaceId != null && project.activeWorkspaceId !== ''
|
|
225
238
|
? project.activeWorkspaceId
|
|
226
239
|
: getPrimaryWorkspace(workspaces)?.id
|
|
240
|
+
rows.push(
|
|
241
|
+
// Every project already had a blank line above it, which doubles
|
|
242
|
+
// as the gap under the header. The drop bar is drawn *in* that
|
|
243
|
+
// line, so previewing a slot never shifts a single row.
|
|
244
|
+
<DropGap
|
|
245
|
+
key={`gap:${project.id}`}
|
|
246
|
+
index={index}
|
|
247
|
+
active={dropIndex === index}
|
|
248
|
+
contentWidth={contentWidth}
|
|
249
|
+
setGapRef={setGapRef}
|
|
250
|
+
/>
|
|
251
|
+
)
|
|
227
252
|
rows.push(
|
|
228
253
|
<ProjectRow
|
|
229
254
|
key={`ws:${project.id}`}
|
|
@@ -233,15 +258,7 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
233
258
|
status={statusMap[project.id] ?? IDLE_PROJECT_STATUS}
|
|
234
259
|
dragging={draggingId === project.id}
|
|
235
260
|
contentWidth={contentWidth}
|
|
236
|
-
// Every project gets a blank line above it, which doubles as
|
|
237
|
-
// the gap under the header — one rule instead of a spacer row
|
|
238
|
-
// that would shift the list every time the header changes.
|
|
239
|
-
marginTop={1}
|
|
240
|
-
setRowRef={setRowRef}
|
|
241
261
|
onDragStart={handleRowDragStart}
|
|
242
|
-
onDrag={handleRowDrag}
|
|
243
|
-
onDrop={commitDrop}
|
|
244
|
-
onDragCancel={cancelDrag}
|
|
245
262
|
/>
|
|
246
263
|
)
|
|
247
264
|
for (const workspace of workspaces) {
|
|
@@ -258,6 +275,16 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
258
275
|
)
|
|
259
276
|
}
|
|
260
277
|
}
|
|
278
|
+
// Trailing slot, so "after the last project" is reachable.
|
|
279
|
+
rows.push(
|
|
280
|
+
<DropGap
|
|
281
|
+
key="gap:end"
|
|
282
|
+
index={ordered.length}
|
|
283
|
+
active={dropIndex === ordered.length}
|
|
284
|
+
contentWidth={contentWidth}
|
|
285
|
+
setGapRef={setGapRef}
|
|
286
|
+
/>
|
|
287
|
+
)
|
|
261
288
|
return rows
|
|
262
289
|
})()}
|
|
263
290
|
</scrollbox>
|
|
@@ -275,6 +302,32 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
|
|
|
275
302
|
)
|
|
276
303
|
}
|
|
277
304
|
|
|
305
|
+
interface DropGapProps {
|
|
306
|
+
/** Insertion slot this gap stands for: 0 is above the first project. */
|
|
307
|
+
index: number
|
|
308
|
+
active: boolean
|
|
309
|
+
contentWidth: number
|
|
310
|
+
setGapRef: (index: number, ref: BoxRenderable | null) => void
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The one-line gap above a project — blank, or the drop preview mid-drag. */
|
|
314
|
+
function DropGap({ active, contentWidth, index, setGapRef }: DropGapProps) {
|
|
315
|
+
const t = useTheme()
|
|
316
|
+
const handleRef = useCallback(
|
|
317
|
+
(r: BoxRenderable | null) => setGapRef(index, r),
|
|
318
|
+
[setGapRef, index]
|
|
319
|
+
)
|
|
320
|
+
return (
|
|
321
|
+
<box ref={handleRef} flexShrink={0} height={1}>
|
|
322
|
+
{active ? (
|
|
323
|
+
<text fg={t.primary} selectable={false} wrapMode="none">
|
|
324
|
+
{DROP_BAR.repeat(Math.max(1, contentWidth))}
|
|
325
|
+
</text>
|
|
326
|
+
) : null}
|
|
327
|
+
</box>
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
|
|
278
331
|
interface ProjectRowProps {
|
|
279
332
|
project: ProjectRecord
|
|
280
333
|
/**
|
|
@@ -289,27 +342,17 @@ interface ProjectRowProps {
|
|
|
289
342
|
status: ProjectStatus
|
|
290
343
|
dragging: boolean
|
|
291
344
|
contentWidth: number
|
|
292
|
-
/**
|
|
293
|
-
marginTop: number
|
|
294
|
-
setRowRef: (id: string, ref: BoxRenderable | null) => void
|
|
345
|
+
/** Only the gesture's start lives here — the list owns drag and release. */
|
|
295
346
|
onDragStart: (id: string) => void
|
|
296
|
-
onDrag: (event: OtuiMouseEvent) => void
|
|
297
|
-
onDrop: () => void
|
|
298
|
-
onDragCancel: () => void
|
|
299
347
|
}
|
|
300
348
|
|
|
301
349
|
const ProjectRow = memo(function ProjectRow({
|
|
302
350
|
contentWidth,
|
|
303
351
|
dragging,
|
|
304
352
|
inCurrentGroup,
|
|
305
|
-
marginTop,
|
|
306
|
-
onDrag,
|
|
307
|
-
onDragCancel,
|
|
308
353
|
onDragStart,
|
|
309
|
-
onDrop,
|
|
310
354
|
project,
|
|
311
355
|
projectIndex,
|
|
312
|
-
setRowRef,
|
|
313
356
|
status,
|
|
314
357
|
}: ProjectRowProps) {
|
|
315
358
|
const t = useTheme()
|
|
@@ -340,10 +383,6 @@ const ProjectRow = memo(function ProjectRow({
|
|
|
340
383
|
const waitingColor = t.warning
|
|
341
384
|
const currentProjectId = useAppStore((s) => s.currentProjectId)
|
|
342
385
|
|
|
343
|
-
const handleRef = useCallback(
|
|
344
|
-
(r: BoxRenderable | null) => setRowRef(project.id, r),
|
|
345
|
-
[setRowRef, project.id]
|
|
346
|
-
)
|
|
347
386
|
const handleMouseDown = useCallback(
|
|
348
387
|
(e: OtuiMouseEvent) => {
|
|
349
388
|
e.preventDefault()
|
|
@@ -352,13 +391,6 @@ const ProjectRow = memo(function ProjectRow({
|
|
|
352
391
|
},
|
|
353
392
|
[onDragStart, project.id]
|
|
354
393
|
)
|
|
355
|
-
const handleMouseUp = useCallback(
|
|
356
|
-
(e: OtuiMouseEvent) => {
|
|
357
|
-
e.preventDefault()
|
|
358
|
-
onDrop()
|
|
359
|
-
},
|
|
360
|
-
[onDrop]
|
|
361
|
-
)
|
|
362
394
|
const handleNewWorkspace = useCallback(
|
|
363
395
|
(e: OtuiMouseEvent) => {
|
|
364
396
|
e.preventDefault()
|
|
@@ -412,19 +444,14 @@ const ProjectRow = memo(function ProjectRow({
|
|
|
412
444
|
|
|
413
445
|
return (
|
|
414
446
|
<ContextMenuBox
|
|
415
|
-
ref={handleRef}
|
|
416
447
|
id={`sidebar-ws-${project.id}`}
|
|
417
448
|
flexDirection="column"
|
|
418
449
|
flexShrink={0}
|
|
419
|
-
marginTop={marginTop}
|
|
420
450
|
paddingLeft={1}
|
|
421
451
|
paddingRight={1}
|
|
422
452
|
backgroundColor={bgColor}
|
|
423
453
|
rightClickMenu={rightClickMenu}
|
|
424
454
|
onMouseDown={handleMouseDown}
|
|
425
|
-
onMouseDrag={onDrag}
|
|
426
|
-
onMouseUp={handleMouseUp}
|
|
427
|
-
onMouseDragEnd={onDragCancel}
|
|
428
455
|
>
|
|
429
456
|
<box flexDirection="row" alignItems="center">
|
|
430
457
|
<text fg={leadingColor} selectable={false} wrapMode="none">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AIUsageTool
|
|
1
|
+
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
3
|
import { useCallback } from 'react'
|
|
4
4
|
|
|
@@ -6,7 +6,11 @@ import { useAIUsageStore } from '../../../../state/ai-usage-store'
|
|
|
6
6
|
import { dispatchGlobal } from '../../../../state/dispatch-ref'
|
|
7
7
|
import { useTheme } from '../../../theme'
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** nf-cod-claude / nf-cod-openai. Needs a nerd font, like the status bar separators. */
|
|
10
|
+
const ICON: Record<AIUsageTool, string> = {
|
|
11
|
+
claude: '\u{ec82}',
|
|
12
|
+
codex: '\u{ec81}',
|
|
13
|
+
}
|
|
10
14
|
|
|
11
15
|
function formatTokens(total: number): string {
|
|
12
16
|
if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`
|
|
@@ -14,12 +18,6 @@ function formatTokens(total: number): string {
|
|
|
14
18
|
return String(total)
|
|
15
19
|
}
|
|
16
20
|
|
|
17
|
-
function pickDotColor(t: ResolvedTuiTheme, percent: number): string {
|
|
18
|
-
if (percent >= 85) return t.error
|
|
19
|
-
if (percent >= 60) return t.warning
|
|
20
|
-
return t.success
|
|
21
|
-
}
|
|
22
|
-
|
|
23
21
|
export function AIUsageIndicator() {
|
|
24
22
|
const t = useTheme()
|
|
25
23
|
const enabled = useAIUsageStore((s) => s.enabled)
|
|
@@ -59,35 +57,20 @@ export function AIUsageIndicator() {
|
|
|
59
57
|
if (snap.error != null && snap.error !== '' && !(snap.stale === true)) {
|
|
60
58
|
return (
|
|
61
59
|
<box key={tool} flexDirection="row">
|
|
62
|
-
<text fg={t.error} selectable={false}>
|
|
63
|
-
{DOT}
|
|
64
|
-
</text>
|
|
65
|
-
</box>
|
|
66
|
-
)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (snap.percent !== null) {
|
|
70
|
-
const p = Math.round(snap.percent)
|
|
71
|
-
const color = pickDotColor(t, snap.percent)
|
|
72
|
-
return (
|
|
73
|
-
<box key={tool} flexDirection="row">
|
|
74
|
-
<text fg={color} selectable={false}>
|
|
75
|
-
{DOT}
|
|
76
|
-
</text>
|
|
77
60
|
<text fg={t.text} selectable={false}>
|
|
78
|
-
{
|
|
61
|
+
{ICON[tool]}
|
|
79
62
|
</text>
|
|
80
63
|
</box>
|
|
81
64
|
)
|
|
82
65
|
}
|
|
83
66
|
|
|
67
|
+
const value =
|
|
68
|
+
snap.percent !== null ? `${Math.round(snap.percent)}%` : formatTokens(snap.tokens.total)
|
|
69
|
+
|
|
84
70
|
return (
|
|
85
71
|
<box key={tool} flexDirection="row">
|
|
86
|
-
<text fg={t.
|
|
87
|
-
{
|
|
88
|
-
</text>
|
|
89
|
-
<text fg={t.textMuted} selectable={false}>
|
|
90
|
-
{` ${formatTokens(snap.tokens.total)}`}
|
|
72
|
+
<text fg={t.text} selectable={false}>
|
|
73
|
+
{`${ICON[tool]} ${value}`}
|
|
91
74
|
</text>
|
|
92
75
|
</box>
|
|
93
76
|
)
|
|
@@ -13,6 +13,24 @@ export function orderProjectsForDisplay(projects: ProjectRecord[]): ProjectRecor
|
|
|
13
13
|
})
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Move `moveId` into the gap `insertIndex` — 0 is before the first id,
|
|
18
|
+
* `ids.length` is after the last, matching the drop bars drawn between rows.
|
|
19
|
+
* Returns the input array itself when the move changes nothing, so callers can
|
|
20
|
+
* skip the dispatch with a reference check.
|
|
21
|
+
*/
|
|
22
|
+
export function moveIdToInsertIndex(ids: string[], moveId: string, insertIndex: number): string[] {
|
|
23
|
+
const from = ids.indexOf(moveId)
|
|
24
|
+
if (from < 0) return ids
|
|
25
|
+
// Removing the id first shifts every later gap down by one.
|
|
26
|
+
const to = insertIndex > from ? insertIndex - 1 : insertIndex
|
|
27
|
+
if (to === from) return ids
|
|
28
|
+
const next = [...ids]
|
|
29
|
+
next.splice(from, 1)
|
|
30
|
+
next.splice(to, 0, moveId)
|
|
31
|
+
return next
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
/**
|
|
17
35
|
* Move `moveId` to the slot currently held by `intoPositionOfId`, shifting the
|
|
18
36
|
* displaced id in the opposite direction. Pure; returns a new array. Returns
|