@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.22.6",
3
+ "version": "1.22.7",
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",
@@ -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
- launchAssistant,
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 { placeholderWorkspaceName, renameWorkspaceFromPrompt } from './workspace-naming'
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
- // Chained from `<C-p>`: pin the tab to the workspace just created, hand it
317
- // the prompt, and name the workspace with the assistant the user picked.
318
- // Otherwise the tab lands in the project's active workspace, which
319
- // launchAssistant resolves itself.
320
- const pending = state.modal.type === 'new-tab' ? state.modal.pendingWorkspace : undefined
321
- // Normalized to '' so "is there a prompt" is one comparison rather than a
322
- // null check repeated at each decision below.
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
- if (pending) renameWorkspaceFromLaunch(ctx, pending, assistant)
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
- void (async () => {
393
- try {
394
- await enqueueGitOp(async () =>
395
- createWorkspaceFromModal(ctx, projectId, {
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
- requestedName?: string,
98
- requestedBranchName?: string,
99
- requestedBaseRef?: string,
100
- sourceWorkspaceId?: string
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
+ }
@@ -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
- if (pr.state === 'MERGED') return { action: null, label: 'Merged', tone: 'neutral' }
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. Only workspaces that record a baseRef
13
- // (the aimux-created ones) are measured; the primary and externally-discovered
14
- // workspaces have no recorded base and are left out.
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
- const project =
22
- currentProjectId != null && currentProjectId !== ''
23
- ? projects.find((s) => s.id === currentProjectId)
24
- : undefined
25
- const targets = (project?.workspaces ?? []).filter(
26
- (w) => w.baseRef != null && w.baseRef !== '' && w.branch != null && w.branch !== ''
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 (base == null || branch == null) return null
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, currentProjectId, projects])
71
+ }, [enabled, projects])
65
72
  }
@@ -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 status = prActionState(pr, result.checks)
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) label = 'Merge this PR?'
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
- {status.action === 'merge' && !confirming && !merging ? (
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 { moveIdToIdPosition, orderProjectsForDisplay } from '../../../project-ordering'
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
- function arraysEqual(a: string[], b: string[]): boolean {
40
- if (a.length !== b.length) return false
41
- for (let i = 0; i < a.length; i++) {
42
- if (a[i] !== b[i]) return false
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
- const [dragOrder, setDragOrder] = useState<string[] | null>(null)
55
- const lastSwapWithRef = useRef<string | null>(null)
56
- const rowRefs = useRef(new Map<string, BoxRenderable>())
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 setRowRef = useCallback((id: string, ref: BoxRenderable | null): void => {
87
- if (ref) rowRefs.current.set(id, ref)
88
- else rowRefs.current.delete(id)
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
- const findRowAtY = useCallback((y: number): string | null => {
92
- for (const [id, ref] of rowRefs.current) {
93
- if (y >= ref.y && y < ref.y + ref.height) return id
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 null
114
+ return best
96
115
  }, [])
97
116
 
98
- const handleRowDragStart = useCallback(
99
- (id: string) => {
100
- setDraggingId(id)
101
- setDragOrder(baselineOrder)
102
- lastSwapWithRef.current = null
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 handleRowDrag = useCallback(
123
+ const handleDrag = useCallback(
108
124
  (event: OtuiMouseEvent) => {
109
- if (!(draggingId != null && draggingId !== '')) return
110
- const hit = findRowAtY(event.y)
111
- if (hit === null) {
112
- lastSwapWithRef.current = null
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
- [draggingId, findRowAtY]
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 source = draggingId
128
- const finalOrder = dragOrder
137
+ const drag = dragRef.current
138
+ dragRef.current = null
129
139
  setDraggingId(null)
130
- setDragOrder(null)
131
- lastSwapWithRef.current = null
140
+ setDropIndex(null)
132
141
 
133
- if (source == null || source === '' || !finalOrder) return
142
+ if (!drag) return
143
+ const source = drag.id
134
144
 
135
- const changed = !arraysEqual(finalOrder, baselineOrder)
136
- if (changed) {
137
- dispatchGlobal({ orderedIds: finalOrder, type: 'reorder-projects' })
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, dragOrder, draggingId, ordered])
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
- <box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
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 visibleProjects) {
216
- const projectIndex = baselineOrder.indexOf(project.id) + 1
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
- /** Vertical spacing above this rowused to separate project blocks. */
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, ResolvedTuiTheme } from '@brimveyn/aimux-config'
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
- const DOT = '●'
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
- {` ${p}%`}
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.textMuted} selectable={false}>
87
- {DOT}
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