@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
@@ -1,45 +0,0 @@
1
- import type { AppState, SessionRecord } from './types'
2
-
3
- import { loadConfig, saveConfig } from '../config'
4
- import { saveSessionCatalog } from './session-catalog'
5
- import { serializeWorkspace } from './session-persistence'
6
-
7
- export function buildSessionsWithCurrentSnapshot(
8
- sessions: SessionRecord[],
9
- currentSessionId: string | null,
10
- state: AppState
11
- ): SessionRecord[] {
12
- return sessions.map((session) =>
13
- session.id === currentSessionId
14
- ? {
15
- ...session,
16
- updatedAt: new Date().toISOString(),
17
- workspaceSnapshot: serializeWorkspace(state),
18
- }
19
- : session
20
- )
21
- }
22
-
23
- export function saveCurrentWorkspace(state: AppState): void {
24
- saveConfig({
25
- ...loadConfig(),
26
- customCommands: state.customCommands,
27
- gitPane: {
28
- diffModeRatio: state.gitPane.diffModeRatio,
29
- embeddedRatio: state.gitPane.embeddedRatio,
30
- fileListMode: state.gitPane.fileListMode,
31
- mode: state.gitPane.mode,
32
- paneRatio: state.gitPane.paneRatio,
33
- position: state.gitPane.position,
34
- visible: state.gitPane.visible,
35
- },
36
- sessionBarVisible: state.sessionBar.visible,
37
- sidebar: {
38
- visible: state.sidebar.visible,
39
- width: state.sidebar.width,
40
- },
41
- })
42
- saveSessionCatalog(
43
- buildSessionsWithCurrentSnapshot(state.sessions, state.currentSessionId, state)
44
- )
45
- }
@@ -1,26 +0,0 @@
1
- import type { GitPaneMode, GitPanePosition, GitPaneState } from '../../../../state/types'
2
- import type { ContextMenuItem } from '../../../context-menu/controller'
3
-
4
- const GIT_PANE_MENU_OPTIONS: {
5
- label: string
6
- mode: GitPaneMode
7
- position: GitPanePosition
8
- }[] = [
9
- { label: 'Move to top', mode: 'embedded', position: 'top' },
10
- { label: 'Move to bottom', mode: 'embedded', position: 'bottom' },
11
- { label: 'Move to left', mode: 'pane', position: 'left' },
12
- { label: 'Move to right', mode: 'pane', position: 'right' },
13
- ]
14
-
15
- export function buildGitPaneContextMenu(
16
- gitPane: Pick<GitPaneState, 'mode' | 'position'>,
17
- onToggle: () => void,
18
- onMove: (mode: GitPaneMode, position: GitPanePosition) => void
19
- ): ContextMenuItem[] {
20
- return [
21
- ['Toggle', onToggle],
22
- ...GIT_PANE_MENU_OPTIONS.filter(
23
- ({ mode, position }) => !(gitPane.mode === mode && gitPane.position === position)
24
- ).map(({ label, mode, position }) => [label, () => onMove(mode, position)] as ContextMenuItem),
25
- ]
26
- }
@@ -1,163 +0,0 @@
1
- import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
-
3
- import { useCallback, useMemo, useRef } from 'react'
4
-
5
- import { useAppStore } from '../../../../state/app-store'
6
- import { dispatchGlobal } from '../../../../state/dispatch-ref'
7
- import { useTheme } from '../../../theme'
8
- import { buildGitPaneContextMenu } from '../../git/pane/git-pane-context-menu'
9
- import { GitPaneWidget } from '../../git/pane/git-pane-widget'
10
- import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
11
- import { WorkspaceList } from './workspace-list'
12
-
13
- interface SidebarProps {
14
- onResizeDrag?: (event: OtuiMouseEvent) => boolean
15
- onResizeDragEnd?: () => void
16
- onEmbeddedGitResizeStart?: (info: {
17
- containerStart: number
18
- position: 'top' | 'bottom'
19
- totalSize: number
20
- }) => void
21
- }
22
-
23
- const RESIZE_HANDLE = '─'
24
-
25
- export function Sidebar({ onEmbeddedGitResizeStart, onResizeDrag, onResizeDragEnd }: SidebarProps) {
26
- const t = useTheme()
27
- const sidebarBg = t.background
28
- const sidebarVisible = useAppStore((s) => s.sidebar.visible)
29
- const sidebarWidth = useAppStore((s) => s.sidebar.width)
30
- const gitPane = useAppStore((s) => s.gitPane)
31
- const focusMode = useAppStore((s) => s.focusMode)
32
- const bodyRef = useRef<BoxRenderable | null>(null)
33
-
34
- const handleSidebarMouseDown = useCallback(() => {
35
- if (focusMode === 'terminal-input') {
36
- dispatchGlobal({ focusMode: 'navigation', type: 'set-focus-mode' })
37
- }
38
- }, [focusMode])
39
- const handleSidebarMouseDrag = useCallback(
40
- (event: OtuiMouseEvent) => {
41
- if (onResizeDrag?.(event) === true) {
42
- event.preventDefault()
43
- event.stopPropagation()
44
- }
45
- },
46
- [onResizeDrag]
47
- )
48
- const handleSidebarMouseUp = useCallback(() => {
49
- onResizeDragEnd?.()
50
- }, [onResizeDragEnd])
51
- const handleEmbeddedHandleMouseDown = useCallback(
52
- (event: OtuiMouseEvent) => {
53
- const body = bodyRef.current
54
- if (!body) return
55
- event.preventDefault()
56
- event.stopPropagation()
57
- onEmbeddedGitResizeStart?.({
58
- containerStart: body.y,
59
- position: gitPane.position === 'top' ? 'top' : 'bottom',
60
- totalSize: Math.max(1, body.height),
61
- })
62
- },
63
- [gitPane.position, onEmbeddedGitResizeStart]
64
- )
65
- const sidebarMenu = useMemo<[string, () => void][]>(
66
- () => [
67
- ['Hide sidebar', () => dispatchGlobal({ type: 'toggle-sidebar' })],
68
- ['Toggle git pane', () => dispatchGlobal({ type: 'toggle-git-pane' })],
69
- [
70
- 'Toggle diff mode',
71
- () => {
72
- dispatchGlobal({ type: 'enter-git-mode' })
73
- dispatchGlobal({ type: 'git-mode-toggle-diff-view' })
74
- },
75
- ],
76
- ],
77
- []
78
- )
79
-
80
- if (!sidebarVisible) {
81
- return null
82
- }
83
-
84
- const gitEmbedded = gitPane.mode === 'embedded' && gitPane.visible
85
- const gitOnTop = gitEmbedded && gitPane.position === 'top'
86
- const gitOnBottom = gitEmbedded && gitPane.position === 'bottom'
87
- const contentWidth = Math.max(1, sidebarWidth - 1)
88
- const gitPaneMenu = buildGitPaneContextMenu(
89
- gitPane,
90
- () => dispatchGlobal({ type: 'toggle-git-pane' }),
91
- (mode, position) => {
92
- dispatchGlobal({ mode, type: 'set-git-pane-mode' })
93
- dispatchGlobal({ position, type: 'set-git-pane-position' })
94
- }
95
- )
96
-
97
- // flex-grow scaled by 100 (integer preferred); workspace list gets (1-ratio), git gets ratio.
98
- const listGrow = gitEmbedded ? Math.max(1, Math.round((1 - gitPane.embeddedRatio) * 100)) : 1
99
- const gitGrow = gitEmbedded ? Math.max(1, Math.round(gitPane.embeddedRatio * 100)) : 0
100
-
101
- const gitBody = gitEmbedded ? (
102
- <ContextMenuBox
103
- flexDirection="column"
104
- flexGrow={gitGrow}
105
- flexShrink={1}
106
- flexBasis={0}
107
- overflow="hidden"
108
- rightClickMenu={gitPaneMenu}
109
- >
110
- <GitPaneWidget pollingEnabled={gitPane.visible} />
111
- </ContextMenuBox>
112
- ) : null
113
- const embeddedHandle = gitEmbedded ? (
114
- <box minHeight={1} flexShrink={0} onMouseDown={handleEmbeddedHandleMouseDown}>
115
- <text fg={t.border} selectable={false}>
116
- {RESIZE_HANDLE.repeat(Math.max(1, contentWidth))}
117
- </text>
118
- </box>
119
- ) : null
120
-
121
- return (
122
- <ContextMenuBox
123
- width={sidebarWidth}
124
- padding={0}
125
- flexDirection="column"
126
- backgroundColor={sidebarBg}
127
- gap={0}
128
- overflow="hidden"
129
- rightClickMenu={sidebarMenu}
130
- onMouseDown={handleSidebarMouseDown}
131
- onMouseDrag={handleSidebarMouseDrag}
132
- onMouseUp={handleSidebarMouseUp}
133
- >
134
- <box flexDirection="row" width={sidebarWidth} flexGrow={1} overflow="hidden">
135
- <box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
136
- <box ref={bodyRef} flexDirection="column" flexGrow={1} overflow="hidden">
137
- {gitOnTop ? (
138
- <>
139
- {gitBody}
140
- {embeddedHandle}
141
- </>
142
- ) : null}
143
- <box
144
- flexDirection="column"
145
- flexGrow={listGrow}
146
- flexShrink={1}
147
- flexBasis={0}
148
- overflow="hidden"
149
- >
150
- <WorkspaceList contentWidth={contentWidth} />
151
- </box>
152
- {gitOnBottom ? (
153
- <>
154
- {embeddedHandle}
155
- {gitBody}
156
- </>
157
- ) : null}
158
- </box>
159
- </box>
160
- </box>
161
- </ContextMenuBox>
162
- )
163
- }
@@ -1,30 +0,0 @@
1
- import type { ScrollBoxRenderable } from '@opentui/core'
2
-
3
- import { useEffect } from 'react'
4
-
5
- interface Options {
6
- scrollRef: React.RefObject<ScrollBoxRenderable | null>
7
- visible: boolean
8
- activeTabId: string | null
9
- idPrefix: string
10
- }
11
-
12
- /**
13
- * Scroll the active tab into view whenever the active tab id changes (and on
14
- * first reveal). The top bar is a single horizontal row, so this is just a
15
- * thin wrapper around `scrollChildIntoView` — no wrap-around/edge heuristics.
16
- */
17
- export function useTopTabBarAutoScroll({
18
- activeTabId,
19
- idPrefix,
20
- scrollRef,
21
- visible,
22
- }: Options): void {
23
- useEffect(() => {
24
- if (!visible) return
25
- if (activeTabId == null || activeTabId === '') return
26
- const scrollbox = scrollRef.current
27
- if (!scrollbox) return
28
- scrollbox.scrollChildIntoView(`${idPrefix}${activeTabId}`)
29
- }, [activeTabId, idPrefix, scrollRef, visible])
30
- }
@@ -1,418 +0,0 @@
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 { SessionRecord, SessionStatus, WorktreeRecord } from '../../../../state/types'
10
-
11
- import { useAppStore } from '../../../../state/app-store'
12
- import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
13
- import { formatDivergence } from '../../../../state/session-worktrees'
14
- // eslint-disable-next-line no-duplicate-imports
15
- import { IDLE_SESSION_STATUS } from '../../../../state/types'
16
- import { useBusySpinner } from '../../../hooks/use-busy-spinner'
17
- import { moveIdToIdPosition, orderSessionsForDisplay } from '../../../session-ordering'
18
- import { useBaseTheme, useTheme } from '../../../theme'
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 { WorktreeRow } from './worktree-row'
23
-
24
- interface WorkspaceListProps {
25
- contentWidth: number
26
- }
27
-
28
- const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
29
-
30
- function arraysEqual(a: string[], b: string[]): boolean {
31
- if (a.length !== b.length) return false
32
- for (let i = 0; i < a.length; i++) {
33
- if (a[i] !== b[i]) return false
34
- }
35
- return true
36
- }
37
-
38
- function truncate(label: string, max: number): string {
39
- if (max <= 0) return ''
40
- if (label.length <= max) return label
41
- if (max === 1) return '…'
42
- return `${label.slice(0, max - 1)}…`
43
- }
44
-
45
- export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
46
- const t = useTheme()
47
- const sessions = useAppStore((s) => s.sessions)
48
- const currentSessionId = useAppStore((s) => s.currentSessionId)
49
- const statusMap = useAppStore((s) => s.sessionStatuses)
50
-
51
- const [draggingId, setDraggingId] = useState<string | null>(null)
52
- const [dragOrder, setDragOrder] = useState<string[] | null>(null)
53
- const lastSwapWithRef = useRef<string | null>(null)
54
- const rowRefs = useRef(new Map<string, BoxRenderable>())
55
- const scrollRef = useRef<ScrollBoxRenderable | null>(null)
56
-
57
- const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
58
- const baselineOrder = useMemo(() => ordered.map((s) => s.id), [ordered])
59
-
60
- const currentSession = useMemo(
61
- () =>
62
- currentSessionId != null && currentSessionId !== ''
63
- ? sessions.find((s) => s.id === currentSessionId)
64
- : undefined,
65
- [currentSessionId, sessions]
66
- )
67
- // The active row can be either a worktree row OR the workspace row
68
- // (when the primary worktree is active). Both must scroll into view —
69
- // otherwise the cursor visually "disappears" off-screen when crossing
70
- // a workspace boundary on a key press.
71
- const currentWorktrees = currentSession?.worktrees ?? []
72
- const currentPrimary = currentWorktrees.find((w) => w.source === 'primary') ?? currentWorktrees[0]
73
- const rawActiveWorktreeId = currentSession?.activeWorktreeId
74
- const activeOnNonPrimary =
75
- rawActiveWorktreeId != null &&
76
- rawActiveWorktreeId !== '' &&
77
- rawActiveWorktreeId !== currentPrimary?.id
78
- let activeRowId: string | null = null
79
- if (activeOnNonPrimary) {
80
- activeRowId = `sidebar-wt-${rawActiveWorktreeId}`
81
- } else if (currentSessionId != null && currentSessionId !== '') {
82
- activeRowId = `sidebar-ws-${currentSessionId}`
83
- }
84
-
85
- useSidebarAutoScroll({
86
- activeRowId,
87
- scrollRef,
88
- visible: true,
89
- })
90
-
91
- const setRowRef = useCallback((id: string, ref: BoxRenderable | null): void => {
92
- if (ref) rowRefs.current.set(id, ref)
93
- else rowRefs.current.delete(id)
94
- }, [])
95
-
96
- const findRowAtY = useCallback((y: number): string | null => {
97
- for (const [id, ref] of rowRefs.current) {
98
- if (y >= ref.y && y < ref.y + ref.height) return id
99
- }
100
- return null
101
- }, [])
102
-
103
- const handleRowDragStart = useCallback(
104
- (id: string) => {
105
- setDraggingId(id)
106
- setDragOrder(baselineOrder)
107
- lastSwapWithRef.current = null
108
- },
109
- [baselineOrder]
110
- )
111
-
112
- const handleRowDrag = useCallback(
113
- (event: OtuiMouseEvent) => {
114
- if (!(draggingId != null && draggingId !== '')) return
115
- const hit = findRowAtY(event.y)
116
- if (hit === null) {
117
- lastSwapWithRef.current = null
118
- return
119
- }
120
- if (hit === draggingId) {
121
- lastSwapWithRef.current = null
122
- return
123
- }
124
- if (hit === lastSwapWithRef.current) return
125
- setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
126
- lastSwapWithRef.current = hit
127
- },
128
- [draggingId, findRowAtY]
129
- )
130
-
131
- const commitDrop = useCallback(() => {
132
- const source = draggingId
133
- const finalOrder = dragOrder
134
- setDraggingId(null)
135
- setDragOrder(null)
136
- lastSwapWithRef.current = null
137
-
138
- if (source == null || source === '' || !finalOrder) return
139
-
140
- const changed = !arraysEqual(finalOrder, baselineOrder)
141
- if (changed) {
142
- dispatchGlobal({ orderedIds: finalOrder, type: 'reorder-sessions' })
143
- return
144
- }
145
-
146
- const idx = baselineOrder.indexOf(source)
147
- if (idx >= 0) {
148
- // The workspace row visually anchors the session's primary worktree
149
- // (its branch line shows the primary's branch). Clicking it should
150
- // land on the primary — same semantics as j/k cycling onto a workspace
151
- // item — instead of preserving whatever non-primary worktree happened
152
- // to be active last time we left this session.
153
- const sourceSession = ordered.find((s) => s.id === source)
154
- const sourceWorktrees = sourceSession?.worktrees ?? []
155
- const sourcePrimaryId = (
156
- sourceWorktrees.find((w) => w.source === 'primary') ?? sourceWorktrees[0]
157
- )?.id
158
- runSideEffectGlobal({
159
- index: idx + 1,
160
- type: 'switch-session-by-index',
161
- worktreeId: sourcePrimaryId,
162
- })
163
- }
164
- }, [baselineOrder, dragOrder, draggingId, ordered])
165
-
166
- const cancelDrag = useCallback(() => {
167
- setDraggingId(null)
168
- setDragOrder(null)
169
- lastSwapWithRef.current = null
170
- }, [])
171
-
172
- const handleNewSession = useCallback((e: OtuiMouseEvent) => {
173
- e.stopPropagation()
174
- e.preventDefault()
175
- dispatchGlobal({ returnToSessionPicker: false, type: 'open-create-session-modal' })
176
- }, [])
177
-
178
- const visibleSessions =
179
- dragOrder !== null
180
- ? dragOrder
181
- .map((id) => ordered.find((s) => s.id === id))
182
- .filter((s): s is SessionRecord => !!s)
183
- : ordered
184
-
185
- return (
186
- <box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
187
- <scrollbox
188
- ref={scrollRef}
189
- scrollY
190
- flexGrow={1}
191
- flexShrink={1}
192
- contentOptions={COLUMN_CONTENT_OPTIONS}
193
- >
194
- {(() => {
195
- // Build a single flat list of items — workspace rows interleaved
196
- // with their non-primary worktrees. One map, one React keypath per
197
- // visible row; transitions are a single atomic reconciliation.
198
- const rows: ReactNode[] = []
199
- for (const [visibleIdx, session] of visibleSessions.entries()) {
200
- const sessionIndex = baselineOrder.indexOf(session.id) + 1
201
- const isCurrentSession = session.id === currentSessionId
202
- const worktrees = session.worktrees ?? []
203
- const primaryWorktree = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
204
- const extraWorktrees = worktrees.filter((w) => w.id !== primaryWorktree?.id)
205
- const workspaceIsActiveItem =
206
- isCurrentSession &&
207
- (session.activeWorktreeId == null ||
208
- session.activeWorktreeId === '' ||
209
- session.activeWorktreeId === primaryWorktree?.id)
210
- rows.push(
211
- <WorkspaceRow
212
- key={`ws:${session.id}`}
213
- session={session}
214
- isActiveItem={workspaceIsActiveItem}
215
- inCurrentGroup={isCurrentSession}
216
- primaryWorktree={primaryWorktree}
217
- hasExtraWorktrees={extraWorktrees.length > 0}
218
- status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
219
- dragging={draggingId === session.id}
220
- contentWidth={contentWidth}
221
- marginTop={visibleIdx > 0 ? 1 : 0}
222
- setRowRef={setRowRef}
223
- onDragStart={handleRowDragStart}
224
- onDrag={handleRowDrag}
225
- onDrop={commitDrop}
226
- onDragCancel={cancelDrag}
227
- />
228
- )
229
- for (const [wtIdx, worktree] of extraWorktrees.entries()) {
230
- rows.push(
231
- <WorktreeRow
232
- key={`wt:${worktree.id}`}
233
- session={session}
234
- worktree={worktree}
235
- sessionIndex={sessionIndex}
236
- isActiveItem={isCurrentSession && worktree.id === session.activeWorktreeId}
237
- inCurrentGroup={isCurrentSession}
238
- isLast={wtIdx === extraWorktrees.length - 1}
239
- />
240
- )
241
- }
242
- }
243
- return rows
244
- })()}
245
- </scrollbox>
246
- <box
247
- flexDirection="row"
248
- flexShrink={0}
249
- marginTop={1}
250
- backgroundColor={t.backgroundPanel}
251
- justifyContent="center"
252
- onMouseDown={handleNewSession}
253
- >
254
- <text fg={t.text} selectable={false}>
255
- + New workspace
256
- </text>
257
- </box>
258
- </box>
259
- )
260
- }
261
-
262
- interface WorkspaceRowProps {
263
- session: SessionRecord
264
- /** True when this row is the active cursor item (workspace's primary active). */
265
- isActiveItem: boolean
266
- /** True when this row belongs to the current workspace (selection scope). */
267
- inCurrentGroup: boolean
268
- /** The session's primary worktree — its git branch is shown as the workspace's anchor identity. */
269
- primaryWorktree: WorktreeRecord | undefined
270
- /** True when at least one non-primary worktree follows — used to draw the tree continuator. */
271
- hasExtraWorktrees: boolean
272
- status: SessionStatus
273
- dragging: boolean
274
- contentWidth: number
275
- /** Vertical spacing above this row — used to separate workspace blocks. */
276
- marginTop: number
277
- setRowRef: (id: string, ref: BoxRenderable | null) => void
278
- onDragStart: (id: string) => void
279
- onDrag: (event: OtuiMouseEvent) => void
280
- onDrop: () => void
281
- onDragCancel: () => void
282
- }
283
-
284
- const WorkspaceRow = memo(function WorkspaceRow({
285
- contentWidth,
286
- dragging,
287
- hasExtraWorktrees,
288
- inCurrentGroup,
289
- isActiveItem,
290
- marginTop,
291
- onDrag,
292
- onDragCancel,
293
- onDragStart,
294
- onDrop,
295
- primaryWorktree,
296
- session,
297
- setRowRef,
298
- status,
299
- }: WorkspaceRowProps) {
300
- const t = useTheme()
301
- // Selection highlight must stay opaque in transparent mode — otherwise the
302
- // cursor row visually disappears against the see-through chrome.
303
- const base = useBaseTheme()
304
- const showSpinner = status.working
305
- const showWaiting = status.waiting
306
- const spinner = useBusySpinner(showSpinner)
307
- let bgColor: string | undefined
308
- if (dragging || isActiveItem) {
309
- bgColor = base.backgroundElement
310
- } else if (inCurrentGroup) {
311
- bgColor = base.backgroundPanel
312
- }
313
- const workingColor = t.primary
314
- const waitingColor = t.warning
315
- const divergence = useAppStore((s) =>
316
- primaryWorktree ? s.worktreeDivergence[primaryWorktree.id] : undefined
317
- )
318
-
319
- const handleRef = useCallback(
320
- (r: BoxRenderable | null) => setRowRef(session.id, r),
321
- [setRowRef, session.id]
322
- )
323
- const handleMouseDown = useCallback(
324
- (e: OtuiMouseEvent) => {
325
- e.preventDefault()
326
- e.stopPropagation()
327
- onDragStart(session.id)
328
- },
329
- [onDragStart, session.id]
330
- )
331
- const handleMouseUp = useCallback(
332
- (e: OtuiMouseEvent) => {
333
- e.preventDefault()
334
- onDrop()
335
- },
336
- [onDrop]
337
- )
338
- const rightClickMenu = useMemo<[string, () => void][]>(
339
- () => [
340
- [
341
- 'Rename workspace',
342
- () =>
343
- dispatchGlobal({
344
- initialName: session.name,
345
- returnToSessionPicker: false,
346
- sessionTargetId: session.id,
347
- type: 'open-session-name-modal',
348
- }),
349
- ],
350
- [
351
- 'Delete workspace',
352
- () => runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' }),
353
- ],
354
- ],
355
- [session.id, session.name]
356
- )
357
-
358
- // Neutral muted marker; working/waiting overrides it. Keeping a glyph
359
- // in the slot avoids name shifts when state indicators come and go.
360
- let leadingGlyph = '•'
361
- let leadingColor = t.textMuted
362
- if (showWaiting) {
363
- leadingGlyph = '?'
364
- leadingColor = waitingColor
365
- } else if (showSpinner) {
366
- leadingGlyph = spinner
367
- leadingColor = workingColor
368
- }
369
-
370
- const branchText = primaryWorktree?.branch ?? ''
371
- const divergenceText = formatDivergence(divergence)
372
- const showBranch = branchText !== ''
373
- const nameLabel = truncate(session.name, Math.max(0, contentWidth - 4))
374
- const branchLabel = truncate(
375
- branchText,
376
- Math.max(0, contentWidth - 5 - (divergenceText.length + 1))
377
- )
378
-
379
- return (
380
- <ContextMenuBox
381
- ref={handleRef}
382
- id={`sidebar-ws-${session.id}`}
383
- flexDirection="column"
384
- flexShrink={0}
385
- marginTop={marginTop}
386
- paddingLeft={1}
387
- paddingRight={1}
388
- backgroundColor={bgColor}
389
- rightClickMenu={rightClickMenu}
390
- onMouseDown={handleMouseDown}
391
- onMouseDrag={onDrag}
392
- onMouseUp={handleMouseUp}
393
- onMouseDragEnd={onDragCancel}
394
- >
395
- <box flexDirection="row" alignItems="center">
396
- <text fg={leadingColor} selectable={false} wrapMode="none">
397
- {leadingGlyph}
398
- </text>
399
- <text fg={t.text} selectable={false} wrapMode="none">
400
- {' '}
401
- </text>
402
- <FlashLabelBadge rowKey={`ws:${session.id}`} />
403
- <text fg={t.text} selectable={false} wrapMode="none">
404
- {nameLabel}
405
- </text>
406
- </box>
407
- {showBranch ? (
408
- <box flexDirection="row">
409
- <text fg={t.textMuted} selectable={false} wrapMode="none">
410
- {hasExtraWorktrees ? '│ ' : ' '}
411
- {'\u{e702}'} {branchLabel}
412
- {divergenceText !== '' ? ` ${divergenceText}` : ''}
413
- </text>
414
- </box>
415
- ) : null}
416
- </ContextMenuBox>
417
- )
418
- })