@brimveyn/aimux 1.21.0 → 1.22.2

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 (220) 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 +302 -1541
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/tab-actions.ts +218 -0
  19. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  20. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  21. package/src/app-runtime/use-backend-runtime.ts +12 -11
  22. package/src/app-runtime/use-directory-search.ts +5 -4
  23. package/src/app-runtime/use-mouse-handlers.ts +2 -1
  24. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  25. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  26. package/src/app-runtime/use-setup-runner.ts +96 -0
  27. package/src/app-runtime/use-terminal-resize.ts +18 -14
  28. package/src/app-runtime/workspace-actions.ts +366 -0
  29. package/src/app-runtime/workspace-activity.ts +138 -0
  30. package/src/app-runtime/workspace-naming.ts +90 -0
  31. package/src/app.tsx +93 -48
  32. package/src/assets/sounds/bell.wav +0 -0
  33. package/src/assets/sounds/ding.wav +0 -0
  34. package/src/assets/sounds/plane.wav +0 -0
  35. package/src/cli/client/daemon-client.ts +2 -2
  36. package/src/cli/client/project-resolver.ts +112 -0
  37. package/src/cli/commands/project/close.ts +32 -0
  38. package/src/cli/commands/project/create.ts +93 -0
  39. package/src/cli/commands/project/list.ts +25 -0
  40. package/src/cli/commands/project/show.ts +32 -0
  41. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  42. package/src/cli/commands/tab/await.ts +2 -2
  43. package/src/cli/commands/tab/close.ts +3 -3
  44. package/src/cli/commands/tab/create.ts +74 -73
  45. package/src/cli/commands/tab/focus.ts +3 -3
  46. package/src/cli/commands/tab/list.ts +6 -6
  47. package/src/cli/commands/tab/run.ts +2 -2
  48. package/src/cli/commands/tab/send.ts +2 -2
  49. package/src/cli/commands/tab/snapshot.ts +2 -2
  50. package/src/cli/commands/tab/tail.ts +2 -2
  51. package/src/cli/commands/tab/wait.ts +2 -2
  52. package/src/cli/commands/worker/await.ts +3 -3
  53. package/src/cli/commands/worker/doctor.ts +30 -30
  54. package/src/cli/commands/worker/list.ts +15 -15
  55. package/src/cli/commands/worker/prompt.ts +3 -3
  56. package/src/cli/commands/worker/run.ts +23 -23
  57. package/src/cli/commands/worker/shared.ts +53 -59
  58. package/src/cli/commands/worker/stop.ts +29 -29
  59. package/src/cli/commands/worker/submit.ts +3 -3
  60. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  61. package/src/cli/commands/workspace/create.ts +30 -68
  62. package/src/cli/commands/workspace/list.ts +52 -9
  63. package/src/cli/commands/workspace/remove.ts +81 -0
  64. package/src/cli/completion/plan.ts +2 -2
  65. package/src/cli/completion/sources.ts +18 -18
  66. package/src/cli/context.ts +13 -13
  67. package/src/cli/flags.ts +30 -10
  68. package/src/cli/index.ts +26 -16
  69. package/src/cli/output.ts +1 -1
  70. package/src/cli/registry.ts +12 -12
  71. package/src/config/loader.ts +16 -5
  72. package/src/config.ts +70 -112
  73. package/src/daemon/catalog-writer.ts +67 -67
  74. package/src/daemon/daemon.ts +205 -201
  75. package/src/daemon/session-manager.ts +39 -39
  76. package/src/daemon/session-registry.ts +14 -14
  77. package/src/git/divergence.ts +33 -2
  78. package/src/git/git-poller.ts +1 -1
  79. package/src/git/git-status.ts +2 -2
  80. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  81. package/src/git/pr-status.ts +1 -1
  82. package/src/git/repo-discovery.ts +1 -1
  83. package/src/git/use-repo-discovery.ts +1 -1
  84. package/src/git/workspace-branch-poller.ts +54 -0
  85. package/src/git/workspace-divergence-poller.ts +65 -0
  86. package/src/git/worktree.ts +29 -0
  87. package/src/index.tsx +4 -4
  88. package/src/input/keymap/help-entries.ts +7 -5
  89. package/src/input/modes/bridge.ts +13 -12
  90. package/src/input/modes/handlers/shared.ts +1 -1
  91. package/src/input/modes/transitions.ts +34 -21
  92. package/src/input/modes/types.ts +48 -31
  93. package/src/ipc/manager-protocol.ts +44 -44
  94. package/src/ipc/protocol.ts +192 -166
  95. package/src/platform/play-sound.ts +184 -0
  96. package/src/platform/project-search.ts +8 -8
  97. package/src/platform/worktree-paths.ts +8 -6
  98. package/src/pty/assistant-question-extractor.ts +1 -1
  99. package/src/pty/assistant-status-detection-loop.ts +79 -55
  100. package/src/pty/assistant-status-detector.ts +40 -15
  101. package/src/pty/command-registry.ts +81 -1
  102. package/src/restart-terminal-manager.ts +1 -1
  103. package/src/services/ai-usage/provider.ts +9 -2
  104. package/src/services/ai-usage/spawn.ts +19 -6
  105. package/src/session-backend/bootstrap.ts +4 -4
  106. package/src/session-backend/local-session-backend.ts +78 -75
  107. package/src/session-backend/remote-session-backend.ts +64 -52
  108. package/src/session-backend/types.ts +43 -32
  109. package/src/settings/live.ts +90 -0
  110. package/src/settings/search.ts +43 -0
  111. package/src/settings/sections/about.ts +49 -0
  112. package/src/settings/sections/appearance.ts +54 -0
  113. package/src/settings/sections/automation.ts +105 -0
  114. package/src/settings/sections/commands.ts +72 -0
  115. package/src/settings/sections/editor.ts +56 -0
  116. package/src/settings/sections/experimental.ts +57 -0
  117. package/src/settings/sections/git.ts +112 -0
  118. package/src/settings/sections/index.ts +95 -0
  119. package/src/settings/sections/integrations.ts +23 -0
  120. package/src/settings/sections/layout.ts +72 -0
  121. package/src/settings/sections/notifications.ts +99 -0
  122. package/src/settings/sections/setup.ts +54 -0
  123. package/src/settings/sections/status-bar.ts +63 -0
  124. package/src/settings/settings-store.ts +207 -0
  125. package/src/settings/types.ts +108 -0
  126. package/src/snippets/run-shell-var.ts +48 -22
  127. package/src/state/actions.ts +342 -0
  128. package/src/state/app-store.ts +2 -1
  129. package/src/state/bars.ts +1 -1
  130. package/src/state/dispatch-ref.ts +1 -1
  131. package/src/state/layout-resize.ts +2 -2
  132. package/src/state/project-catalog.ts +275 -0
  133. package/src/state/project-data.ts +143 -0
  134. package/src/state/{session-persistence.ts → project-persistence.ts} +74 -68
  135. package/src/state/project-save.ts +46 -0
  136. package/src/state/project-workspaces.ts +392 -0
  137. package/src/state/reducers/auto-commit-state.ts +11 -10
  138. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  139. package/src/state/reducers/git-mode-state.ts +1 -1
  140. package/src/state/reducers/git-panel-state.ts +10 -6
  141. package/src/state/reducers/modal-state.ts +290 -681
  142. package/src/state/reducers/multi-repo-state.ts +3 -6
  143. package/src/state/reducers/project-state.ts +312 -0
  144. package/src/state/reducers/settings-state.ts +80 -0
  145. package/src/state/reducers/tab-state.ts +107 -60
  146. package/src/state/reducers/ui-state.ts +4 -3
  147. package/src/state/selectors.ts +49 -37
  148. package/src/state/store.ts +57 -24
  149. package/src/state/types.ts +244 -405
  150. package/src/state/validation.ts +14 -12
  151. package/src/terminal-manager/manager-client.ts +32 -32
  152. package/src/terminal-manager/terminal-manager.ts +24 -24
  153. package/src/ui/components/git/git-panel.tsx +2 -2
  154. package/src/ui/components/git/git-view.tsx +12 -12
  155. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  156. package/src/ui/components/git/pane/git-pane-widget.tsx +7 -7
  157. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  158. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  159. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  160. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  161. package/src/ui/components/layout/status-bar.tsx +6 -2
  162. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  163. package/src/ui/components/layout/top-tab-bar.tsx +46 -36
  164. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  165. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  166. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  167. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  168. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  169. package/src/ui/components/modals/shared/form.tsx +34 -22
  170. package/src/ui/components/modals/shared/picker.tsx +10 -10
  171. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  172. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  173. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  174. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  175. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  176. package/src/ui/components/primitives/input-field.tsx +7 -3
  177. package/src/ui/components/primitives/surface.tsx +3 -0
  178. package/src/ui/components/settings/row-value.tsx +74 -0
  179. package/src/ui/components/settings/settings-row.tsx +78 -0
  180. package/src/ui/components/settings/settings-view.tsx +169 -0
  181. package/src/ui/components/setup/setup-widget.tsx +152 -0
  182. package/src/ui/flash/build-labels.ts +27 -27
  183. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  184. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  185. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  186. package/src/ui/root.tsx +139 -121
  187. package/src/ui/status-bar-model.ts +48 -34
  188. package/src/ui/terminal-graphics/kitty.ts +28 -2
  189. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  190. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  191. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  192. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  193. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  194. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  195. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  196. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  197. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  198. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  199. package/src/ui/terminal-graphics/sprites.ts +246 -0
  200. package/src/ui/truncate.ts +7 -0
  201. package/src/ui/widgets/registry.tsx +6 -3
  202. package/src/ui/widgets/widget-context-menu.ts +26 -10
  203. package/src/update.ts +2 -2
  204. package/src/app-runtime/session-actions.ts +0 -187
  205. package/src/cli/client/workspace-resolver.ts +0 -110
  206. package/src/cli/commands/workspace/close.ts +0 -32
  207. package/src/cli/commands/workspace/show.ts +0 -32
  208. package/src/cli/commands/worktree/create.ts +0 -55
  209. package/src/cli/commands/worktree/list.ts +0 -68
  210. package/src/cli/commands/worktree/remove.ts +0 -81
  211. package/src/git/worktree-branch-poller.ts +0 -54
  212. package/src/git/worktree-divergence-poller.ts +0 -59
  213. package/src/state/reducers/session-state.ts +0 -262
  214. package/src/state/session-catalog.ts +0 -143
  215. package/src/state/session-worktrees.ts +0 -260
  216. package/src/state/workspace-save.ts +0 -43
  217. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  218. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  219. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  220. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -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
+ })
@@ -16,8 +16,8 @@ interface TabItemProps {
16
16
  active: boolean
17
17
  focused: boolean
18
18
  inLayout?: boolean
19
- /** When set, this tab's worktree can be moved — adds a "Move worktree" entry. */
20
- moveWorktreeId?: string
19
+ /** When set, this tab's workspace can be moved — adds a "Move workspace" entry. */
20
+ moveWorkspaceId?: string
21
21
  /** 1-based position in the visible tab list — rendered as `[N]` and matches Leader+N. */
22
22
  indexLabel?: string
23
23
  /** Render the close × unconditionally instead of only on hover. */
@@ -57,11 +57,11 @@ function getIndicatorColor(active: boolean, focused: boolean, inLayout: boolean)
57
57
 
58
58
  function BusyGlyph() {
59
59
  const t = useTheme()
60
- const frame = useBusySpinner()
60
+ const spinner = useBusySpinner()
61
61
  return (
62
62
  <text fg={t.primary} selectable={false}>
63
63
  {' '}
64
- {frame}
64
+ {spinner}
65
65
  </text>
66
66
  )
67
67
  }
@@ -78,6 +78,8 @@ function WaitingGlyph() {
78
78
 
79
79
  function ActivityGlyph({ tab }: { tab: TabSession }) {
80
80
  const t = useTheme()
81
+ // No sprite here on purpose: a tab is one line, and a sprite placed at a
82
+ // second size would give a cell two images it could resolve to.
81
83
  if (tab.status === 'error') {
82
84
  return (
83
85
  <text fg={t.error} selectable={false}>
@@ -104,7 +106,10 @@ function ActivityGlyph({ tab }: { tab: TabSession }) {
104
106
  return <WaitingGlyph />
105
107
  }
106
108
 
107
- if (tab.activity === 'idle') {
109
+ // The tab that rang. There is deliberately no glyph for a plain idle tab:
110
+ // "not busy" is the resting state of every tab you are not watching, and a
111
+ // dot that is always on cannot also mean "read this one".
112
+ if (tab.unseen === true) {
108
113
  return (
109
114
  <text fg={t.success} selectable={false}>
110
115
  {' '}
@@ -113,6 +118,16 @@ function ActivityGlyph({ tab }: { tab: TabSession }) {
113
118
  )
114
119
  }
115
120
 
121
+ if (tab.activity === 'idle') {
122
+ // Blank, but the same width as every other state — a tab must not resize
123
+ // when its agent stops.
124
+ return (
125
+ <text fg={t.textMuted} selectable={false}>
126
+ {' '}
127
+ </text>
128
+ )
129
+ }
130
+
116
131
  return (
117
132
  <text fg={getStatusColor(tab.status)} selectable={false}>
118
133
  {' '}
@@ -128,7 +143,7 @@ export function TabItem({
128
143
  id,
129
144
  indexLabel,
130
145
  inLayout,
131
- moveWorktreeId,
146
+ moveWorkspaceId,
132
147
  tab,
133
148
  }: TabItemProps) {
134
149
  const t = useTheme()
@@ -167,22 +182,22 @@ export function TabItem({
167
182
  dispatchGlobal({ delta: 1, type: 'reorder-active-tab' })
168
183
  },
169
184
  ],
170
- ...(moveWorktreeId != null && moveWorktreeId !== ''
185
+ ...(moveWorkspaceId != null && moveWorkspaceId !== ''
171
186
  ? [
172
187
  [
173
- 'Move worktree',
188
+ 'Move workspace',
174
189
  () => {
175
190
  dispatchGlobal({
176
- sourceWorktreeId: moveWorktreeId,
177
- type: 'open-worktree-move-modal',
191
+ sourceWorkspaceId: moveWorkspaceId,
192
+ type: 'open-workspace-move-modal',
178
193
  })
179
- runSideEffectGlobal({ type: 'load-worktree-move-stats' })
194
+ runSideEffectGlobal({ type: 'load-workspace-move-stats' })
180
195
  },
181
196
  ] as [string, () => void],
182
197
  ]
183
198
  : []),
184
199
  ],
185
- [moveWorktreeId, tab.id]
200
+ [moveWorkspaceId, tab.id]
186
201
  )
187
202
  const handleMouseOver = useCallback(() => setHovered(true), [])
188
203
  const handleMouseOut = useCallback(() => setHovered(false), [])
@@ -5,7 +5,7 @@ import { useEffect } from 'react'
5
5
  interface Options {
6
6
  scrollRef: React.RefObject<ScrollBoxRenderable | null>
7
7
  visible: boolean
8
- /** Full id of the active row to bring into view (workspace OR worktree). */
8
+ /** Full id of the active row to bring into view (project OR workspace). */
9
9
  activeRowId: string | null
10
10
  }
11
11