@brimveyn/aimux 1.20.4 → 1.22.0

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.
Files changed (233) hide show
  1. package/README.md +27 -25
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +22 -27
  4. package/src/app-runtime/backend-attach-runtime.ts +43 -36
  5. package/src/app-runtime/backend-runtime-events.ts +107 -88
  6. package/src/app-runtime/editor-actions.ts +201 -0
  7. package/src/app-runtime/git-actions.ts +197 -0
  8. package/src/app-runtime/measure-ref.ts +19 -0
  9. package/src/app-runtime/navigation-actions.ts +213 -0
  10. package/src/app-runtime/project-actions.ts +188 -0
  11. package/src/app-runtime/prompt-injection.ts +123 -0
  12. package/src/app-runtime/selection.ts +29 -0
  13. package/src/app-runtime/settings-actions.ts +156 -0
  14. package/src/app-runtime/setup-actions.ts +237 -0
  15. package/src/app-runtime/side-effect-context.ts +33 -0
  16. package/src/app-runtime/side-effects.ts +305 -1589
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/split-drag-controller.ts +4 -8
  19. package/src/app-runtime/tab-actions.ts +218 -0
  20. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  21. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  22. package/src/app-runtime/use-backend-runtime.ts +12 -11
  23. package/src/app-runtime/use-directory-search.ts +5 -4
  24. package/src/app-runtime/use-mouse-handlers.ts +28 -42
  25. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  26. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  27. package/src/app-runtime/use-setup-runner.ts +96 -0
  28. package/src/app-runtime/use-terminal-resize.ts +28 -33
  29. package/src/app-runtime/workspace-actions.ts +366 -0
  30. package/src/app-runtime/workspace-activity.ts +138 -0
  31. package/src/app-runtime/workspace-naming.ts +90 -0
  32. package/src/app.tsx +102 -85
  33. package/src/assets/sounds/bell.wav +0 -0
  34. package/src/assets/sounds/ding.wav +0 -0
  35. package/src/assets/sounds/plane.wav +0 -0
  36. package/src/cli/client/daemon-client.ts +2 -2
  37. package/src/cli/client/project-resolver.ts +112 -0
  38. package/src/cli/commands/project/close.ts +32 -0
  39. package/src/cli/commands/project/create.ts +93 -0
  40. package/src/cli/commands/project/list.ts +25 -0
  41. package/src/cli/commands/project/show.ts +32 -0
  42. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  43. package/src/cli/commands/tab/await.ts +2 -2
  44. package/src/cli/commands/tab/close.ts +3 -3
  45. package/src/cli/commands/tab/create.ts +74 -73
  46. package/src/cli/commands/tab/focus.ts +3 -3
  47. package/src/cli/commands/tab/list.ts +6 -6
  48. package/src/cli/commands/tab/run.ts +2 -2
  49. package/src/cli/commands/tab/send.ts +2 -2
  50. package/src/cli/commands/tab/snapshot.ts +2 -2
  51. package/src/cli/commands/tab/tail.ts +2 -2
  52. package/src/cli/commands/tab/wait.ts +2 -2
  53. package/src/cli/commands/worker/await.ts +3 -3
  54. package/src/cli/commands/worker/doctor.ts +30 -30
  55. package/src/cli/commands/worker/list.ts +15 -15
  56. package/src/cli/commands/worker/prompt.ts +3 -3
  57. package/src/cli/commands/worker/run.ts +23 -23
  58. package/src/cli/commands/worker/shared.ts +53 -59
  59. package/src/cli/commands/worker/stop.ts +29 -29
  60. package/src/cli/commands/worker/submit.ts +3 -3
  61. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  62. package/src/cli/commands/workspace/create.ts +30 -68
  63. package/src/cli/commands/workspace/list.ts +52 -9
  64. package/src/cli/commands/workspace/remove.ts +81 -0
  65. package/src/cli/completion/plan.ts +2 -2
  66. package/src/cli/completion/sources.ts +18 -18
  67. package/src/cli/context.ts +13 -13
  68. package/src/cli/flags.ts +30 -10
  69. package/src/cli/index.ts +26 -16
  70. package/src/cli/output.ts +1 -1
  71. package/src/cli/registry.ts +12 -12
  72. package/src/config/loader.ts +16 -5
  73. package/src/config.ts +149 -101
  74. package/src/daemon/catalog-writer.ts +67 -67
  75. package/src/daemon/daemon.ts +205 -201
  76. package/src/daemon/session-manager.ts +39 -39
  77. package/src/daemon/session-registry.ts +14 -14
  78. package/src/git/divergence.ts +33 -2
  79. package/src/git/git-poller.ts +1 -1
  80. package/src/git/git-status.ts +2 -2
  81. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  82. package/src/git/pr-merge.ts +64 -0
  83. package/src/git/pr-status-poller.ts +57 -0
  84. package/src/git/pr-status.ts +227 -0
  85. package/src/git/repo-discovery.ts +1 -1
  86. package/src/git/use-repo-discovery.ts +1 -1
  87. package/src/git/workspace-branch-poller.ts +54 -0
  88. package/src/git/workspace-divergence-poller.ts +65 -0
  89. package/src/git/worktree.ts +29 -0
  90. package/src/index.tsx +11 -6
  91. package/src/input/keymap/help-entries.ts +7 -5
  92. package/src/input/modes/bridge.ts +13 -12
  93. package/src/input/modes/handlers/shared.ts +1 -1
  94. package/src/input/modes/transitions.ts +34 -21
  95. package/src/input/modes/types.ts +48 -31
  96. package/src/ipc/manager-protocol.ts +44 -44
  97. package/src/ipc/protocol.ts +192 -166
  98. package/src/platform/open-url.ts +39 -0
  99. package/src/platform/play-sound.ts +184 -0
  100. package/src/platform/project-search.ts +8 -8
  101. package/src/platform/worktree-paths.ts +8 -6
  102. package/src/pty/assistant-question-extractor.ts +1 -1
  103. package/src/pty/assistant-status-detection-loop.ts +79 -55
  104. package/src/pty/assistant-status-detector.ts +40 -15
  105. package/src/pty/command-registry.ts +81 -1
  106. package/src/restart-terminal-manager.ts +1 -1
  107. package/src/services/ai-usage/provider.ts +9 -2
  108. package/src/services/ai-usage/spawn.ts +3 -1
  109. package/src/session-backend/bootstrap.ts +4 -4
  110. package/src/session-backend/local-session-backend.ts +78 -75
  111. package/src/session-backend/remote-session-backend.ts +64 -52
  112. package/src/session-backend/types.ts +43 -32
  113. package/src/settings/live.ts +90 -0
  114. package/src/settings/search.ts +43 -0
  115. package/src/settings/sections/about.ts +49 -0
  116. package/src/settings/sections/appearance.ts +54 -0
  117. package/src/settings/sections/automation.ts +105 -0
  118. package/src/settings/sections/commands.ts +72 -0
  119. package/src/settings/sections/editor.ts +56 -0
  120. package/src/settings/sections/experimental.ts +57 -0
  121. package/src/settings/sections/git.ts +112 -0
  122. package/src/settings/sections/index.ts +95 -0
  123. package/src/settings/sections/integrations.ts +23 -0
  124. package/src/settings/sections/layout.ts +72 -0
  125. package/src/settings/sections/notifications.ts +99 -0
  126. package/src/settings/sections/setup.ts +54 -0
  127. package/src/settings/sections/status-bar.ts +63 -0
  128. package/src/settings/settings-store.ts +207 -0
  129. package/src/settings/types.ts +108 -0
  130. package/src/snippets/run-shell-var.ts +48 -22
  131. package/src/state/actions.ts +342 -0
  132. package/src/state/app-store.ts +2 -1
  133. package/src/state/bars.ts +75 -0
  134. package/src/state/dispatch-ref.ts +1 -1
  135. package/src/state/git-pane-sizing.ts +0 -9
  136. package/src/state/layout-resize.ts +2 -2
  137. package/src/state/pr-status-store.ts +39 -0
  138. package/src/state/project-catalog.ts +275 -0
  139. package/src/state/project-data.ts +143 -0
  140. package/src/state/{session-persistence.ts → project-persistence.ts} +77 -73
  141. package/src/state/project-save.ts +46 -0
  142. package/src/state/project-workspaces.ts +392 -0
  143. package/src/state/reducers/auto-commit-state.ts +11 -10
  144. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  145. package/src/state/reducers/git-mode-state.ts +1 -1
  146. package/src/state/reducers/git-panel-state.ts +10 -53
  147. package/src/state/reducers/modal-state.ts +290 -681
  148. package/src/state/reducers/multi-repo-state.ts +3 -6
  149. package/src/state/reducers/project-state.ts +312 -0
  150. package/src/state/reducers/settings-state.ts +80 -0
  151. package/src/state/reducers/tab-state.ts +107 -60
  152. package/src/state/reducers/ui-state.ts +86 -15
  153. package/src/state/selectors.ts +49 -37
  154. package/src/state/store.ts +83 -57
  155. package/src/state/types.ts +261 -417
  156. package/src/state/validation.ts +14 -12
  157. package/src/terminal-manager/manager-client.ts +32 -32
  158. package/src/terminal-manager/terminal-manager.ts +24 -24
  159. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
  160. package/src/ui/components/git/git-panel.tsx +2 -2
  161. package/src/ui/components/git/git-view.tsx +12 -12
  162. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  163. package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
  164. package/src/ui/components/git/pane/git-pane-widget.tsx +37 -23
  165. package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
  166. package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
  167. package/src/ui/components/layout/bar.tsx +191 -0
  168. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  169. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  170. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  171. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  172. package/src/ui/components/layout/status-bar.tsx +6 -2
  173. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  174. package/src/ui/components/layout/top-tab-bar.tsx +48 -37
  175. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  176. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  177. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  178. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  179. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  180. package/src/ui/components/modals/shared/form.tsx +34 -22
  181. package/src/ui/components/modals/shared/picker.tsx +10 -10
  182. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  183. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  184. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  185. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  186. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  187. package/src/ui/components/primitives/input-field.tsx +7 -3
  188. package/src/ui/components/primitives/surface.tsx +3 -0
  189. package/src/ui/components/settings/row-value.tsx +74 -0
  190. package/src/ui/components/settings/settings-row.tsx +78 -0
  191. package/src/ui/components/settings/settings-view.tsx +169 -0
  192. package/src/ui/components/setup/setup-widget.tsx +152 -0
  193. package/src/ui/flash/build-labels.ts +27 -27
  194. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  195. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  196. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  197. package/src/ui/root.tsx +164 -230
  198. package/src/ui/status-bar-model.ts +48 -34
  199. package/src/ui/terminal-graphics/kitty.ts +28 -2
  200. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  201. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  202. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  203. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  204. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  205. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  206. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  207. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  208. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  209. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  210. package/src/ui/terminal-graphics/sprites.ts +246 -0
  211. package/src/ui/truncate.ts +7 -0
  212. package/src/ui/widgets/registry.tsx +21 -0
  213. package/src/ui/widgets/widget-context-menu.ts +85 -0
  214. package/src/update.ts +2 -2
  215. package/src/app-runtime/session-actions.ts +0 -187
  216. package/src/cli/client/workspace-resolver.ts +0 -110
  217. package/src/cli/commands/workspace/close.ts +0 -32
  218. package/src/cli/commands/workspace/show.ts +0 -32
  219. package/src/cli/commands/worktree/create.ts +0 -55
  220. package/src/cli/commands/worktree/list.ts +0 -68
  221. package/src/cli/commands/worktree/remove.ts +0 -81
  222. package/src/git/worktree-branch-poller.ts +0 -54
  223. package/src/git/worktree-divergence-poller.ts +0 -59
  224. package/src/state/reducers/session-state.ts +0 -262
  225. package/src/state/session-catalog.ts +0 -143
  226. package/src/state/session-worktrees.ts +0 -260
  227. package/src/state/workspace-save.ts +0 -45
  228. package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
  229. package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
  230. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  231. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  232. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  233. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -0,0 +1,191 @@
1
+ import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
3
+ import { useCallback, useRef } from 'react'
4
+
5
+ import type { BarSide } from '../../../state/types'
6
+
7
+ import { useAppStore } from '../../../state/app-store'
8
+ import { getBarWidth, visibleWidgets } from '../../../state/bars'
9
+ import { dispatchGlobal } from '../../../state/dispatch-ref'
10
+ import { useTheme } from '../../theme'
11
+ import { WIDGET_RENDERERS } from '../../widgets/registry'
12
+ import { buildBarContextMenu, buildWidgetContextMenu } from '../../widgets/widget-context-menu'
13
+ import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
14
+
15
+ export interface BarBoundaryResizeInfo {
16
+ containerStart: number
17
+ index: number
18
+ side: BarSide
19
+ totalSize: number
20
+ }
21
+
22
+ interface BarProps {
23
+ side: BarSide
24
+ onResizeDrag?: (event: OtuiMouseEvent) => boolean
25
+ onResizeDragEnd?: () => void
26
+ onEdgeResizeStart?: (info: { initialWidth: number; screenStart: number; side: BarSide }) => void
27
+ onBoundaryResizeStart?: (info: BarBoundaryResizeInfo) => void
28
+ }
29
+
30
+ const RESIZE_HANDLE = '─'
31
+
32
+ /**
33
+ * One edge bar hosting a vertical stack of widgets. Both bars are this
34
+ * component; the only asymmetry is which side the resize handle sits on.
35
+ */
36
+ export function Bar({
37
+ onBoundaryResizeStart,
38
+ onEdgeResizeStart,
39
+ onResizeDrag,
40
+ onResizeDragEnd,
41
+ side,
42
+ }: BarProps) {
43
+ const t = useTheme()
44
+ const bars = useAppStore((s) => s.bars)
45
+ const focusMode = useAppStore((s) => s.focusMode)
46
+ const bodyRef = useRef<BoxRenderable | null>(null)
47
+
48
+ const bar = bars[side]
49
+ const width = getBarWidth(bar)
50
+
51
+ const handleMouseDown = useCallback(() => {
52
+ if (focusMode === 'terminal-input') {
53
+ dispatchGlobal({ focusMode: 'navigation', type: 'set-focus-mode' })
54
+ }
55
+ }, [focusMode])
56
+ const handleMouseDrag = useCallback(
57
+ (event: OtuiMouseEvent) => {
58
+ if (onResizeDrag?.(event) === true) {
59
+ event.preventDefault()
60
+ event.stopPropagation()
61
+ }
62
+ },
63
+ [onResizeDrag]
64
+ )
65
+ const handleMouseUp = useCallback(() => {
66
+ onResizeDragEnd?.()
67
+ }, [onResizeDragEnd])
68
+ const handleEdgeMouseDown = useCallback(
69
+ (event: OtuiMouseEvent) => {
70
+ event.preventDefault()
71
+ event.stopPropagation()
72
+ onEdgeResizeStart?.({ initialWidth: width, screenStart: event.x, side })
73
+ },
74
+ [onEdgeResizeStart, side, width]
75
+ )
76
+
77
+ if (width === 0) return null
78
+
79
+ const visible = visibleWidgets(bar)
80
+ const contentWidth = Math.max(1, width - 1)
81
+
82
+ // Drag handle on the side facing the terminal.
83
+ const edge = (
84
+ <box width={1} flexShrink={0} backgroundColor={t.border} onMouseDown={handleEdgeMouseDown} />
85
+ )
86
+
87
+ const body = (
88
+ <box ref={bodyRef} flexDirection="column" flexGrow={1} overflow="hidden">
89
+ {visible.map((widget, index) => (
90
+ <BarWidgetSlot
91
+ key={widget.id}
92
+ bodyRef={bodyRef}
93
+ contentWidth={contentWidth}
94
+ grow={widget.grow}
95
+ handleColor={t.border}
96
+ index={index}
97
+ isLast={index === visible.length - 1}
98
+ onBoundaryResizeStart={onBoundaryResizeStart}
99
+ side={side}
100
+ widgetId={widget.id}
101
+ />
102
+ ))}
103
+ </box>
104
+ )
105
+
106
+ return (
107
+ <ContextMenuBox
108
+ width={width}
109
+ padding={0}
110
+ flexDirection="row"
111
+ backgroundColor={t.background}
112
+ gap={0}
113
+ overflow="hidden"
114
+ rightClickMenu={buildBarContextMenu(bars, side)}
115
+ onMouseDown={handleMouseDown}
116
+ onMouseDrag={handleMouseDrag}
117
+ onMouseUp={handleMouseUp}
118
+ >
119
+ {side === 'right' ? edge : null}
120
+ <box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
121
+ {body}
122
+ </box>
123
+ {side === 'left' ? edge : null}
124
+ </ContextMenuBox>
125
+ )
126
+ }
127
+
128
+ function BarWidgetSlot({
129
+ bodyRef,
130
+ contentWidth,
131
+ grow,
132
+ handleColor,
133
+ index,
134
+ isLast,
135
+ onBoundaryResizeStart,
136
+ side,
137
+ widgetId,
138
+ }: {
139
+ bodyRef: React.RefObject<BoxRenderable | null>
140
+ contentWidth: number
141
+ grow: number
142
+ handleColor: string
143
+ index: number
144
+ isLast: boolean
145
+ side: BarSide
146
+ widgetId: string
147
+ onBoundaryResizeStart?: (info: BarBoundaryResizeInfo) => void
148
+ }) {
149
+ const bars = useAppStore((s) => s.bars)
150
+ const render = WIDGET_RENDERERS[widgetId]
151
+
152
+ const handleBoundaryMouseDown = useCallback(
153
+ (event: OtuiMouseEvent) => {
154
+ const body = bodyRef.current
155
+ if (!body) return
156
+ event.preventDefault()
157
+ event.stopPropagation()
158
+ onBoundaryResizeStart?.({
159
+ containerStart: body.y,
160
+ index,
161
+ side,
162
+ totalSize: Math.max(1, body.height),
163
+ })
164
+ },
165
+ [bodyRef, index, onBoundaryResizeStart, side]
166
+ )
167
+
168
+ if (!render) return null
169
+
170
+ return (
171
+ <>
172
+ <ContextMenuBox
173
+ flexDirection="column"
174
+ flexGrow={grow}
175
+ flexShrink={1}
176
+ flexBasis={0}
177
+ overflow="hidden"
178
+ rightClickMenu={buildWidgetContextMenu(bars, side, widgetId)}
179
+ >
180
+ {render(contentWidth)}
181
+ </ContextMenuBox>
182
+ {isLast ? null : (
183
+ <box minHeight={1} flexShrink={0} onMouseDown={handleBoundaryMouseDown}>
184
+ <text fg={handleColor} selectable={false}>
185
+ {RESIZE_HANDLE.repeat(Math.max(1, contentWidth))}
186
+ </text>
187
+ </box>
188
+ )}
189
+ </>
190
+ )
191
+ }
@@ -0,0 +1,444 @@
1
+ import type {
2
+ BoxRenderable,
3
+ MouseEvent as OtuiMouseEvent,
4
+ ScrollBoxRenderable,
5
+ } from '@opentui/core'
6
+
7
+ import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
8
+
9
+ import type { ProjectRecord, ProjectStatus } from '../../../../state/types'
10
+
11
+ import { useAppStore } from '../../../../state/app-store'
12
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
13
+ import { getPrimaryWorkspace } from '../../../../state/project-workspaces'
14
+ // eslint-disable-next-line no-duplicate-imports
15
+ import { IDLE_PROJECT_STATUS } from '../../../../state/types'
16
+ import { moveIdToIdPosition, orderProjectsForDisplay } from '../../../project-ordering'
17
+ import { useBaseTheme, useTheme } from '../../../theme'
18
+ import { truncate } from '../../../truncate'
19
+ import { FlashLabelBadge } from '../../flash/flash-label-badge'
20
+ import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
21
+ import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
22
+ import { WorkspaceRow } from './workspace-row'
23
+
24
+ interface ProjectListProps {
25
+ contentWidth: number
26
+ }
27
+
28
+ const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
29
+
30
+ const RULE = '─'
31
+ const HEADER_TITLE = 'Projects'
32
+ /**
33
+ * U+2699, not the nerd-font gear: its Emoji_Presentation is No, so a conforming
34
+ * terminal draws it text-style in one cell and no font has to be installed for
35
+ * the one button that opens the settings.
36
+ */
37
+ const SETTINGS_GLYPH = '⚙'
38
+
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
45
+ }
46
+
47
+ export function ProjectList({ contentWidth }: ProjectListProps) {
48
+ const t = useTheme()
49
+ const projects = useAppStore((s) => s.projects)
50
+ const currentProjectId = useAppStore((s) => s.currentProjectId)
51
+ const statusMap = useAppStore((s) => s.projectStatuses)
52
+
53
+ 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>())
57
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null)
58
+
59
+ const ordered = useMemo(() => orderProjectsForDisplay(projects), [projects])
60
+ const baselineOrder = useMemo(() => ordered.map((s) => s.id), [ordered])
61
+
62
+ const currentProject = useMemo(
63
+ () =>
64
+ currentProjectId != null && currentProjectId !== ''
65
+ ? projects.find((s) => s.id === currentProjectId)
66
+ : undefined,
67
+ [currentProjectId, projects]
68
+ )
69
+ // The cursor is always on a workspace row now, so there is one id to scroll
70
+ // to instead of two — otherwise it visually "disappears" off-screen when a
71
+ // key press crosses a project boundary.
72
+ const rawActiveWorkspaceId = currentProject?.activeWorkspaceId
73
+ const activeWorkspaceId =
74
+ rawActiveWorkspaceId != null && rawActiveWorkspaceId !== ''
75
+ ? rawActiveWorkspaceId
76
+ : getPrimaryWorkspace(currentProject?.workspaces)?.id
77
+ const activeRowId =
78
+ activeWorkspaceId != null && activeWorkspaceId !== '' ? `sidebar-wt-${activeWorkspaceId}` : null
79
+
80
+ useSidebarAutoScroll({
81
+ activeRowId,
82
+ scrollRef,
83
+ visible: true,
84
+ })
85
+
86
+ const setRowRef = useCallback((id: string, ref: BoxRenderable | null): void => {
87
+ if (ref) rowRefs.current.set(id, ref)
88
+ else rowRefs.current.delete(id)
89
+ }, [])
90
+
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
94
+ }
95
+ return null
96
+ }, [])
97
+
98
+ const handleRowDragStart = useCallback(
99
+ (id: string) => {
100
+ setDraggingId(id)
101
+ setDragOrder(baselineOrder)
102
+ lastSwapWithRef.current = null
103
+ },
104
+ [baselineOrder]
105
+ )
106
+
107
+ const handleRowDrag = useCallback(
108
+ (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
122
+ },
123
+ [draggingId, findRowAtY]
124
+ )
125
+
126
+ const commitDrop = useCallback(() => {
127
+ const source = draggingId
128
+ const finalOrder = dragOrder
129
+ setDraggingId(null)
130
+ setDragOrder(null)
131
+ lastSwapWithRef.current = null
132
+
133
+ if (source == null || source === '' || !finalOrder) return
134
+
135
+ const changed = !arraysEqual(finalOrder, baselineOrder)
136
+ if (changed) {
137
+ dispatchGlobal({ orderedIds: finalOrder, type: 'reorder-projects' })
138
+ return
139
+ }
140
+
141
+ const idx = baselineOrder.indexOf(source)
142
+ if (idx >= 0) {
143
+ // The heading stands for the project itself, not for whichever workspace
144
+ // was last active in it, so clicking it lands on the checkout — the one
145
+ // workspace every project is guaranteed to have.
146
+ const sourceProject = ordered.find((s) => s.id === source)
147
+ const sourceWorkspaces = sourceProject?.workspaces ?? []
148
+ const sourcePrimaryId = getPrimaryWorkspace(sourceWorkspaces)?.id
149
+ runSideEffectGlobal({
150
+ index: idx + 1,
151
+ type: 'switch-project-by-index',
152
+ workspaceId: sourcePrimaryId,
153
+ })
154
+ }
155
+ }, [baselineOrder, dragOrder, draggingId, ordered])
156
+
157
+ const cancelDrag = useCallback(() => {
158
+ setDraggingId(null)
159
+ setDragOrder(null)
160
+ lastSwapWithRef.current = null
161
+ }, [])
162
+
163
+ const handleNewProject = useCallback((e: OtuiMouseEvent) => {
164
+ e.stopPropagation()
165
+ e.preventDefault()
166
+ dispatchGlobal({ returnToProjectPicker: false, type: 'open-create-project-modal' })
167
+ }, [])
168
+
169
+ // The settings screen's only mouse-reachable way in. It lives here because this
170
+ // header is on screen whenever the left bar is, and because the `+` beside it
171
+ // already taught the same gesture.
172
+ const handleOpenSettings = useCallback((e: OtuiMouseEvent) => {
173
+ e.stopPropagation()
174
+ e.preventDefault()
175
+ dispatchGlobal({ type: 'enter-settings' })
176
+ }, [])
177
+
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
+ const rule = RULE.repeat(Math.max(1, contentWidth))
186
+
187
+ return (
188
+ <box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
189
+ <box flexShrink={0}>
190
+ <text fg={t.border} selectable={false} wrapMode="none">
191
+ {rule}
192
+ </text>
193
+ </box>
194
+ <box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
195
+ <text fg={t.text} selectable={false} wrapMode="none">
196
+ {HEADER_TITLE}
197
+ </text>
198
+ <box flexGrow={1} flexShrink={1} />
199
+ <text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleNewProject}>
200
+ +
201
+ </text>
202
+ </box>
203
+ <scrollbox
204
+ ref={scrollRef}
205
+ scrollY
206
+ flexGrow={1}
207
+ flexShrink={1}
208
+ contentOptions={COLUMN_CONTENT_OPTIONS}
209
+ >
210
+ {(() => {
211
+ // Build a single flat list — each project's heading followed by every
212
+ // one of its workspaces. One map, one React keypath per visible row;
213
+ // transitions are a single atomic reconciliation.
214
+ const rows: ReactNode[] = []
215
+ for (const project of visibleProjects) {
216
+ const projectIndex = baselineOrder.indexOf(project.id) + 1
217
+ const isCurrentProject = project.id === currentProjectId
218
+ const workspaces = project.workspaces ?? []
219
+ // Every workspace gets a row, the checkout included. Folding it into
220
+ // the project row made one row mean two things — a project you
221
+ // switch to and a workspace you run tabs in — and left the checkout
222
+ // the only workspace with no branch and no churn on screen.
223
+ const activeWorkspaceId =
224
+ project.activeWorkspaceId != null && project.activeWorkspaceId !== ''
225
+ ? project.activeWorkspaceId
226
+ : getPrimaryWorkspace(workspaces)?.id
227
+ rows.push(
228
+ <ProjectRow
229
+ key={`ws:${project.id}`}
230
+ project={project}
231
+ inCurrentGroup={isCurrentProject}
232
+ projectIndex={projectIndex}
233
+ status={statusMap[project.id] ?? IDLE_PROJECT_STATUS}
234
+ dragging={draggingId === project.id}
235
+ 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
+ onDragStart={handleRowDragStart}
242
+ onDrag={handleRowDrag}
243
+ onDrop={commitDrop}
244
+ onDragCancel={cancelDrag}
245
+ />
246
+ )
247
+ for (const workspace of workspaces) {
248
+ rows.push(
249
+ <WorkspaceRow
250
+ key={`wt:${workspace.id}`}
251
+ project={project}
252
+ workspace={workspace}
253
+ projectIndex={projectIndex}
254
+ isActiveItem={isCurrentProject && workspace.id === activeWorkspaceId}
255
+ inCurrentGroup={isCurrentProject}
256
+ contentWidth={contentWidth}
257
+ />
258
+ )
259
+ }
260
+ }
261
+ return rows
262
+ })()}
263
+ </scrollbox>
264
+ <box flexShrink={0}>
265
+ <text fg={t.border} selectable={false} wrapMode="none">
266
+ {rule}
267
+ </text>
268
+ </box>
269
+ <box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
270
+ <text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenSettings}>
271
+ {`${SETTINGS_GLYPH} Settings`}
272
+ </text>
273
+ </box>
274
+ </box>
275
+ )
276
+ }
277
+
278
+ interface ProjectRowProps {
279
+ project: ProjectRecord
280
+ /**
281
+ * True when this row belongs to the current project (selection scope).
282
+ *
283
+ * There is deliberately no `isActiveItem`: the cursor lives on workspace
284
+ * rows, and this row is the heading they sit under.
285
+ */
286
+ inCurrentGroup: boolean
287
+ /** 1-based index in the visible order, so the "+" can switch projects first. */
288
+ projectIndex: number
289
+ status: ProjectStatus
290
+ dragging: boolean
291
+ contentWidth: number
292
+ /** Vertical spacing above this row — used to separate project blocks. */
293
+ marginTop: number
294
+ setRowRef: (id: string, ref: BoxRenderable | null) => void
295
+ onDragStart: (id: string) => void
296
+ onDrag: (event: OtuiMouseEvent) => void
297
+ onDrop: () => void
298
+ onDragCancel: () => void
299
+ }
300
+
301
+ const ProjectRow = memo(function ProjectRow({
302
+ contentWidth,
303
+ dragging,
304
+ inCurrentGroup,
305
+ marginTop,
306
+ onDrag,
307
+ onDragCancel,
308
+ onDragStart,
309
+ onDrop,
310
+ project,
311
+ projectIndex,
312
+ setRowRef,
313
+ status,
314
+ }: ProjectRowProps) {
315
+ const t = useTheme()
316
+ // Selection highlight must stay opaque in transparent mode — otherwise the
317
+ // cursor row visually disappears against the see-through chrome.
318
+ const base = useBaseTheme()
319
+ // State belongs on the workspace rows: they say *which* one is working or
320
+ // waiting, and this heading can only say "somewhere below". So the heading
321
+ // speaks only when none of its workspaces can — a tab whose workspace this
322
+ // client doesn't know, which today means a daemon still running a pre-v18
323
+ // protocol. Without that fallback the sidebar would go silent instead of
324
+ // degrading.
325
+ const workspacesSpeak = useAppStore((s) =>
326
+ (project.workspaces ?? []).some((workspace) => {
327
+ const activity = s.workspaceActivity[workspace.id]
328
+ return activity !== undefined && (activity.working || activity.waiting || activity.done)
329
+ })
330
+ )
331
+ const showWaiting = status.waiting && !workspacesSpeak
332
+ // Only the drag highlight is "selected"-strength here. A heading that lights
333
+ // up like a cursor row is what made the project look like a workspace.
334
+ let bgColor: string | undefined
335
+ if (dragging) {
336
+ bgColor = base.backgroundElement
337
+ } else if (inCurrentGroup) {
338
+ bgColor = base.backgroundPanel
339
+ }
340
+ const waitingColor = t.warning
341
+ const currentProjectId = useAppStore((s) => s.currentProjectId)
342
+
343
+ const handleRef = useCallback(
344
+ (r: BoxRenderable | null) => setRowRef(project.id, r),
345
+ [setRowRef, project.id]
346
+ )
347
+ const handleMouseDown = useCallback(
348
+ (e: OtuiMouseEvent) => {
349
+ e.preventDefault()
350
+ e.stopPropagation()
351
+ onDragStart(project.id)
352
+ },
353
+ [onDragStart, project.id]
354
+ )
355
+ const handleMouseUp = useCallback(
356
+ (e: OtuiMouseEvent) => {
357
+ e.preventDefault()
358
+ onDrop()
359
+ },
360
+ [onDrop]
361
+ )
362
+ const handleNewWorkspace = useCallback(
363
+ (e: OtuiMouseEvent) => {
364
+ e.preventDefault()
365
+ e.stopPropagation()
366
+ // The modal always targets the current project, so make this one current
367
+ // first. Both run through the store synchronously, so the branch load
368
+ // below already sees the switch.
369
+ if (project.id !== currentProjectId) {
370
+ runSideEffectGlobal({ index: projectIndex, type: 'switch-project-by-index' })
371
+ }
372
+ dispatchGlobal({ type: 'open-create-workspace-modal' })
373
+ runSideEffectGlobal({ type: 'load-create-workspace-base-branches' })
374
+ },
375
+ [currentProjectId, project.id, projectIndex]
376
+ )
377
+ const rightClickMenu = useMemo<[string, () => void][]>(
378
+ () => [
379
+ [
380
+ 'Rename project',
381
+ () =>
382
+ dispatchGlobal({
383
+ initialName: project.name,
384
+ projectTargetId: project.id,
385
+ returnToProjectPicker: false,
386
+ type: 'open-project-name-modal',
387
+ }),
388
+ ],
389
+ [
390
+ 'Delete project',
391
+ () => runSideEffectGlobal({ projectId: project.id, type: 'delete-project' }),
392
+ ],
393
+ ],
394
+ [project.id, project.name]
395
+ )
396
+
397
+ // Neutral muted marker, and a question when a workspace of this project needs
398
+ // an answer. No progress indicator: a heading can only say "somewhere below",
399
+ // and the row that actually knows is one line down. Keeping a glyph in the slot
400
+ // avoids name shifts when the question comes and goes — which is also why the
401
+ // gap after it is part of the glyph.
402
+ let leadingGlyph = '• '
403
+ let leadingColor = t.textMuted
404
+ if (showWaiting) {
405
+ leadingGlyph = '? '
406
+ leadingColor = waitingColor
407
+ }
408
+
409
+ // No branch line: the repo checkout is not somewhere aimux works, so naming
410
+ // it under every project only ever read as "you are on main".
411
+ const nameLabel = truncate(project.name, Math.max(0, contentWidth - 6))
412
+
413
+ return (
414
+ <ContextMenuBox
415
+ ref={handleRef}
416
+ id={`sidebar-ws-${project.id}`}
417
+ flexDirection="column"
418
+ flexShrink={0}
419
+ marginTop={marginTop}
420
+ paddingLeft={1}
421
+ paddingRight={1}
422
+ backgroundColor={bgColor}
423
+ rightClickMenu={rightClickMenu}
424
+ onMouseDown={handleMouseDown}
425
+ onMouseDrag={onDrag}
426
+ onMouseUp={handleMouseUp}
427
+ onMouseDragEnd={onDragCancel}
428
+ >
429
+ <box flexDirection="row" alignItems="center">
430
+ <text fg={leadingColor} selectable={false} wrapMode="none">
431
+ {leadingGlyph}
432
+ </text>
433
+ <FlashLabelBadge rowKey={`ws:${project.id}`} />
434
+ <text fg={t.text} selectable={false} wrapMode="none">
435
+ {nameLabel}
436
+ </text>
437
+ <box flexGrow={1} flexShrink={1} />
438
+ <text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleNewWorkspace}>
439
+ +
440
+ </text>
441
+ </box>
442
+ </ContextMenuBox>
443
+ )
444
+ })