@brimveyn/aimux 1.22.5 → 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.
@@ -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">
@@ -44,32 +44,6 @@ interface TerminalPaneProps {
44
44
  junctionEdges?: JunctionEdges
45
45
  }
46
46
 
47
- function getTitle(
48
- tab: TabSession | undefined,
49
- isActive: boolean,
50
- focusMode: TerminalPaneProps['focusMode'],
51
- emptyContext: { projectName: string; workspaceName: string }
52
- ): string {
53
- if (!tab) {
54
- const { projectName, workspaceName } = emptyContext
55
- if (projectName === '' && workspaceName === '') return 'No active project'
56
- if (workspaceName === '' || workspaceName === projectName) {
57
- return `${projectName} · no tabs`
58
- }
59
- return `${projectName} / ${workspaceName} · no tabs`
60
- }
61
-
62
- if (isActive && focusMode === 'terminal-input') {
63
- return `● ${tab.title} · ${tab.status}`
64
- }
65
-
66
- if (isActive) {
67
- return `▸ ${tab.title} · ${tab.status}`
68
- }
69
-
70
- return `${tab.title} · ${tab.status}`
71
- }
72
-
73
47
  function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMode']): string {
74
48
  const t = getCurrentTheme()
75
49
  if (!isActive) return t.border
@@ -526,10 +500,6 @@ export function TerminalPane({
526
500
  <ContextMenuBox
527
501
  border
528
502
  borderColor={getBorderColor(paneIsActive, focusMode)}
529
- title={getTitle(tab, paneIsActive, focusMode, {
530
- projectName: emptyProjectName,
531
- workspaceName: emptyWorkspaceName,
532
- })}
533
503
  padding={0}
534
504
  flexDirection="column"
535
505
  flexGrow={1}
@@ -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