@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.
- 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 +34 -12
- package/src/app-runtime/workspace-launch.ts +135 -0
- package/src/app-runtime/workspace-naming.ts +19 -18
- package/src/app.tsx +12 -6
- package/src/auto-rename/title-runner.ts +105 -15
- package/src/cli/commands/workspace/create-core.ts +3 -1
- package/src/git/pr-status-poller.ts +5 -3
- package/src/git/pr-status.ts +28 -2
- package/src/git/workspace-divergence-poller.ts +21 -14
- package/src/git/worktree-files.ts +51 -0
- package/src/git/worktree.ts +9 -0
- package/src/platform/worktree-paths.ts +6 -1
- package/src/settings/flags.ts +11 -1
- package/src/settings/sections/git.ts +14 -0
- package/src/state/pr-status-store.ts +15 -11
- package/src/ui/components/git/pane/pr-state-row.tsx +62 -18
- package/src/ui/components/layout/sidebar/project-list.tsx +127 -100
- package/src/ui/components/layout/terminal-pane.tsx +0 -30
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +12 -29
- package/src/ui/project-ordering.ts +18 -0
|
@@ -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">
|
|
@@ -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
|
|
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
|