@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,197 @@
1
+ import { $ } from 'bun'
2
+
3
+ import type { SideEffectContext } from './side-effect-context'
4
+
5
+ import { toast } from '../state/toast-store'
6
+ import { triggerAutoCommitNow } from './auto-commit-ref'
7
+
8
+ export async function runGitAction(
9
+ ctx: SideEffectContext,
10
+ args: string[],
11
+ pathToInvalidate?: string
12
+ ): Promise<void> {
13
+ const fallback = ctx.getCurrentProjectProjectPath()
14
+ const repoPath =
15
+ pathToInvalidate != null && pathToInvalidate !== ''
16
+ ? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
17
+ : undefined
18
+ const cwd = repoPath ?? fallback
19
+ if (!(cwd != null && cwd !== '')) return
20
+ const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
21
+ if (result.exitCode !== 0) {
22
+ const stderr = result.stderr.toString().trim()
23
+ ctx.dispatch({ message: stderr || 'git action failed', type: 'git-mode-set-message' })
24
+ return
25
+ }
26
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
27
+ if (pathToInvalidate != null && pathToInvalidate !== '') {
28
+ ctx.dispatch({ path: pathToInvalidate, type: 'git-mode-clear-diff-cache' })
29
+ }
30
+ }
31
+
32
+ export async function runGitActionAll(
33
+ ctx: SideEffectContext,
34
+ args: string[],
35
+ pathsToInvalidate: string[]
36
+ ): Promise<void> {
37
+ const cwd = ctx.getCurrentProjectProjectPath()
38
+ if (!(cwd != null && cwd !== '')) return
39
+ const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
40
+ if (result.exitCode !== 0) {
41
+ const stderr = result.stderr.toString().trim()
42
+ ctx.dispatch({ message: stderr || 'git action failed', type: 'git-mode-set-message' })
43
+ return
44
+ }
45
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
46
+ if (pathsToInvalidate.length > 0) {
47
+ ctx.dispatch({ paths: pathsToInvalidate, type: 'git-mode-invalidate-diffs' })
48
+ }
49
+ }
50
+
51
+ export async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
52
+ const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
53
+ const cwd = repoPath ?? ctx.getCurrentProjectProjectPath()
54
+ if (!(cwd != null && cwd !== '')) return
55
+ const absolute = `${cwd}/${path}`
56
+ try {
57
+ const stat = await Bun.file(absolute).stat()
58
+ await (stat.isDirectory()
59
+ ? Bun.$`rm -rf -- ${absolute}`.quiet().nothrow()
60
+ : Bun.file(absolute).unlink())
61
+ } catch (error) {
62
+ const message = error instanceof Error ? error.message : 'failed to delete file'
63
+ ctx.dispatch({ message, type: 'git-mode-set-message' })
64
+ return
65
+ }
66
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
67
+ ctx.dispatch({ path, type: 'git-mode-clear-diff-cache' })
68
+ }
69
+
70
+ export async function runGitCommit(
71
+ ctx: SideEffectContext,
72
+ title: string,
73
+ body: string
74
+ ): Promise<void> {
75
+ const cwd = ctx.getCurrentProjectProjectPath()
76
+ if (!(cwd != null && cwd !== '')) return
77
+ if (!title) {
78
+ ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
79
+ return
80
+ }
81
+ const result = body
82
+ ? await $`git -C ${cwd} commit -m ${title} -m ${body}`.quiet().nothrow()
83
+ : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
84
+ if (result.exitCode !== 0) {
85
+ const stderr = result.stderr.toString().trim()
86
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
87
+ toast.error(stderr || 'Commit failed')
88
+ return
89
+ }
90
+ clearAutoCommitForCurrentProject(ctx)
91
+ // Match the push flow: clear any inline git-pane message and surface the
92
+ // result as a toast so it's seen even after leaving git mode.
93
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
94
+ toast.success(`Committed: ${title}`)
95
+ }
96
+
97
+ export async function runGitCommitAuto(
98
+ ctx: SideEffectContext,
99
+ title: string,
100
+ body: string
101
+ ): Promise<void> {
102
+ if (!title) {
103
+ ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
104
+ return
105
+ }
106
+ const cwd = ctx.getCurrentProjectProjectPath()
107
+ if (!(cwd != null && cwd !== '')) return
108
+
109
+ // If the user has manually staged files, respect that intent and commit
110
+ // only the staged set — don't run `git add -A` which would sweep up
111
+ // unrelated unstaged/untracked changes. With nothing staged, `add -A`
112
+ // keeps the "commit everything" behaviour the user expects from auto-commit.
113
+ const hasStaged = ctx.state.gitPanel.files.some((f) => f.section === 'staged')
114
+ if (!hasStaged) {
115
+ const addArgs = ['add', '-A']
116
+ const addResult = await $`git -C ${cwd} ${addArgs}`.quiet().nothrow()
117
+ if (addResult.exitCode !== 0) {
118
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
119
+ toast.error(addResult.stderr.toString().trim() || 'Auto-commit: git add failed')
120
+ return
121
+ }
122
+ }
123
+
124
+ const commitResult = body
125
+ ? await $`git -C ${cwd} commit -m ${title} -m ${body}`.quiet().nothrow()
126
+ : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
127
+
128
+ if (commitResult.exitCode !== 0) {
129
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
130
+ toast.error(commitResult.stderr.toString().trim() || 'Auto-commit: commit failed')
131
+ return
132
+ }
133
+
134
+ clearAutoCommitForCurrentProject(ctx)
135
+ ctx.dispatch({ message: `committed: ${title}`, type: 'git-mode-set-message' })
136
+ }
137
+
138
+ export function clearAutoCommitForCurrentProject(ctx: SideEffectContext): void {
139
+ const projectId = ctx.state.currentProjectId
140
+ if (!(projectId != null && projectId !== '')) return
141
+ ctx.dispatch({ projectId, type: 'auto-commit-clear' })
142
+ }
143
+
144
+ export async function runGenerateAutoCommitNow(
145
+ ctx: SideEffectContext,
146
+ projectId: string
147
+ ): Promise<void> {
148
+ const project = ctx.state.projects.find((s) => s.id === projectId)
149
+ const panel = ctx.state.gitPanel
150
+ if (panel.error !== null) {
151
+ toast.warning('Auto-commit: git panel unavailable')
152
+ ctx.dispatch({ projectId, type: 'auto-commit-clear' })
153
+ return
154
+ }
155
+ const tab = ctx.activeTab
156
+ if (!tab) {
157
+ toast.warning('Auto-commit: no active assistant tab — open a claude/codex tab first')
158
+ ctx.dispatch({ projectId, type: 'auto-commit-clear' })
159
+ return
160
+ }
161
+ await triggerAutoCommitNow({
162
+ assistant: tab.assistant,
163
+ git: {
164
+ ahead: panel.ahead,
165
+ behind: panel.behind,
166
+ branch: panel.branch,
167
+ files: panel.files,
168
+ },
169
+ projectId,
170
+ projectPath: project?.projectPath,
171
+ tabId: tab.id,
172
+ })
173
+ }
174
+
175
+ export async function runGitPush(ctx: SideEffectContext): Promise<void> {
176
+ const cwd = ctx.getCurrentProjectProjectPath()
177
+ if (!(cwd != null && cwd !== '')) return
178
+ ctx.dispatch({ message: 'pushing…', type: 'git-mode-set-message' })
179
+
180
+ const upstream = await $`git -C ${cwd} rev-parse --abbrev-ref --symbolic-full-name @{u}`
181
+ .quiet()
182
+ .nothrow()
183
+ const hasUpstream = upstream.exitCode === 0
184
+
185
+ const result = hasUpstream
186
+ ? await $`git -C ${cwd} push`.quiet().nothrow()
187
+ : await $`git -C ${cwd} push --set-upstream origin HEAD`.quiet().nothrow()
188
+
189
+ // Clear the inline "pushing…" progress; surface the result as a toast so it's
190
+ // visible even after leaving git mode (and so push failures aren't missed).
191
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
192
+ if (result.exitCode !== 0) {
193
+ toast.error(result.stderr.toString().trim() || 'Push failed')
194
+ return
195
+ }
196
+ toast.success('Pushed')
197
+ }
@@ -0,0 +1,19 @@
1
+ import type { MeasuredPaneRect } from './use-pane-size-report'
2
+
3
+ // `onMeasure` reaches TerminalPane as a prop chain from app.tsx through
4
+ // root.tsx. A widget hosting its own PTY sits in the bar tree, which that chain
5
+ // never touches — same problem `dispatchGlobal` solves for actions, same shape
6
+ // of answer. Going straight to backend.resizeTab instead would mean
7
+ // re-implementing the PTY_MIN clamps and the per-tab dedupe that handleMeasure
8
+ // owns, and a mismatch there makes the resize loop never settle.
9
+ type MeasureFn = (tabId: string, rect: MeasuredPaneRect) => void
10
+
11
+ let activeMeasure: MeasureFn | null = null
12
+
13
+ export function setActiveMeasure(measure: MeasureFn | null): void {
14
+ activeMeasure = measure
15
+ }
16
+
17
+ export function measureGlobal(tabId: string, rect: MeasuredPaneRect): void {
18
+ activeMeasure?.(tabId, rect)
19
+ }
@@ -0,0 +1,213 @@
1
+ import type { AppState } from '../state/types'
2
+ import type { SideEffectContext } from './side-effect-context'
3
+
4
+ import { logInputDebug } from '../debug/input-log'
5
+ import { appStore } from '../state/app-store'
6
+ import { saveProjectCatalog } from '../state/project-catalog'
7
+ import {
8
+ filterTabsForActiveWorkspace,
9
+ getPrimaryWorkspace,
10
+ withActiveWorkspace,
11
+ } from '../state/project-workspaces'
12
+ import { appReducer } from '../state/store'
13
+ import { buildTabEntries } from '../state/tab-entries'
14
+ import { switchProjectRecords } from './project-actions'
15
+
16
+ export function handleSwitchProjectByIndex(
17
+ ctx: SideEffectContext,
18
+ index: number,
19
+ workspaceId?: string
20
+ ): void {
21
+ const { backend, dispatch } = ctx
22
+ // Read fresh state. ctx.state is the snapshot from the previous render and
23
+ // lags behind dispatches that happened in the same JS turn.
24
+ const state = ctx.getState()
25
+ const ordered = [...state.projects].sort(
26
+ (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
27
+ )
28
+ const target = ordered[index - 1]
29
+ if (!target) {
30
+ logInputDebug('app.projectBar.switchOutOfRange', { index, total: ordered.length })
31
+ return
32
+ }
33
+
34
+ // Resolve which workspace to land on. If the caller passed an explicit
35
+ // `workspaceId` (project-row tap → its primary, workspace-row tap → that
36
+ // workspace), honor it; otherwise let the target project keep its persisted
37
+ // activeWorkspaceId.
38
+ const resolvedWorkspaceId =
39
+ workspaceId != null &&
40
+ workspaceId !== '' &&
41
+ (target.workspaces?.some((w) => w.id === workspaceId) ?? false)
42
+ ? workspaceId
43
+ : undefined
44
+ const needsWorkspaceChange =
45
+ resolvedWorkspaceId != null && resolvedWorkspaceId !== target.activeWorkspaceId
46
+
47
+ if (target.id === state.currentProjectId) {
48
+ if (needsWorkspaceChange) {
49
+ dispatch({
50
+ projectId: target.id,
51
+ type: 'set-active-workspace',
52
+ workspaceId: resolvedWorkspaceId,
53
+ })
54
+ }
55
+ if (state.focusMode === 'git') {
56
+ dispatch({ type: 'exit-git-mode' })
57
+ }
58
+ return
59
+ }
60
+
61
+ // Cross-project: bundle the workspace change into the project record AND
62
+ // fold set-projects + load-project into a SINGLE setState call. Otherwise
63
+ // any subscriber notification (re-render, useEffect, backend re-attach)
64
+ // between dispatches can re-assert the project's previously-persisted
65
+ // activeWorkspaceId, dropping the user back on the last-visited workspace.
66
+ const patchedProject = needsWorkspaceChange
67
+ ? withActiveWorkspace(target, resolvedWorkspaceId)
68
+ : target
69
+ const patchedState: AppState = needsWorkspaceChange
70
+ ? {
71
+ ...state,
72
+ projects: state.projects.map((s) => (s.id === patchedProject.id ? patchedProject : s)),
73
+ }
74
+ : state
75
+ const projects = switchProjectRecords(patchedState, patchedProject)
76
+ saveProjectCatalog(projects)
77
+ void backend.destroy(true)
78
+ appStore.setState((current) => {
79
+ const afterSet = appReducer(current, { projects, type: 'set-projects' })
80
+ return appReducer(afterSet, {
81
+ forceDisconnected: false,
82
+ projectId: patchedProject.id,
83
+ projectSnapshot: patchedProject.projectSnapshot,
84
+ type: 'load-project',
85
+ })
86
+ })
87
+ }
88
+
89
+ /**
90
+ * One stop for `j`/`k`, and it is always a workspace — the project's own row is
91
+ * a heading, not a destination. Every project owns a primary (every path into
92
+ * the catalog runs `ensureProjectWorkspaces`), so no project can be skipped by
93
+ * having nothing to land on.
94
+ */
95
+ interface SidebarItem {
96
+ projectId: string
97
+ workspaceId: string
98
+ }
99
+
100
+ function buildSidebarItems(state: AppState): SidebarItem[] {
101
+ const ordered = [...state.projects].sort(
102
+ (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
103
+ )
104
+ const items: SidebarItem[] = []
105
+ for (const project of ordered) {
106
+ for (const workspace of project.workspaces ?? []) {
107
+ items.push({ projectId: project.id, workspaceId: workspace.id })
108
+ }
109
+ }
110
+ return items
111
+ }
112
+
113
+ function findCurrentSidebarItem(state: AppState, items: SidebarItem[]): number {
114
+ const projectId = state.currentProjectId
115
+ if (projectId == null || projectId === '') return -1
116
+ const project = state.projects.find((s) => s.id === projectId)
117
+ // An unset active workspace means the project sits on its checkout, which now
118
+ // has a row of its own to land on.
119
+ const activeWorkspaceId =
120
+ project?.activeWorkspaceId ?? getPrimaryWorkspace(project?.workspaces)?.id
121
+ if (activeWorkspaceId == null || activeWorkspaceId === '') return -1
122
+ return items.findIndex(
123
+ (item) => item.projectId === projectId && item.workspaceId === activeWorkspaceId
124
+ )
125
+ }
126
+
127
+ export function handleCycleSidebarItem(ctx: SideEffectContext, direction: 1 | -1): void {
128
+ const { backend, dispatch } = ctx
129
+ // Read fresh from the store, not ctx.state (which is a per-render
130
+ // snapshot). Rapid key presses fire before React re-renders, so ctx.state
131
+ // can lag the actual store.
132
+ const state = appStore.getState()
133
+ const items = buildSidebarItems(state)
134
+ if (items.length === 0) return
135
+ const currentIdx = findCurrentSidebarItem(state, items)
136
+ // If we don't know the current, jump to first/last depending on direction.
137
+ let startIdx: number
138
+ if (currentIdx >= 0) {
139
+ startIdx = currentIdx
140
+ } else {
141
+ startIdx = direction === 1 ? -1 : 0
142
+ }
143
+ const len = items.length
144
+ const target = items[(((startIdx + direction) % len) + len) % len]
145
+ if (!target) return
146
+
147
+ const project = state.projects.find((s) => s.id === target.projectId)
148
+ if (!project) return
149
+
150
+ const targetWorkspaceId = target.workspaceId
151
+ const isCrossProject = project.id !== state.currentProjectId
152
+ const needsWorkspaceChange = targetWorkspaceId !== project.activeWorkspaceId
153
+
154
+ if (isCrossProject) {
155
+ // Bundle the workspace change into the project record AND fold the
156
+ // project switch's two dispatches (set-projects + load-project) into a
157
+ // SINGLE Zustand setState call — otherwise each dispatch fires a
158
+ // separate subscription notification and the @opentui/react reconciler
159
+ // paints an intermediate frame where the new project is current but
160
+ // the old activeWorkspaceId still holds, producing the visible flicker.
161
+ const patchedProject = needsWorkspaceChange
162
+ ? withActiveWorkspace(project, targetWorkspaceId)
163
+ : project
164
+ const patchedState: AppState = needsWorkspaceChange
165
+ ? {
166
+ ...state,
167
+ projects: state.projects.map((s) => (s.id === patchedProject.id ? patchedProject : s)),
168
+ }
169
+ : state
170
+ const projects = switchProjectRecords(patchedState, patchedProject)
171
+ saveProjectCatalog(projects)
172
+ void backend.destroy(true)
173
+ appStore.setState((current) => {
174
+ const afterSet = appReducer(current, { projects, type: 'set-projects' })
175
+ return appReducer(afterSet, {
176
+ // Daemon is alive and attach() will hydrate real statuses within a
177
+ // frame, so skip the snapshot's running→disconnected downgrade —
178
+ // otherwise the "Restored snapshot" hint flashes on every j/k cycle.
179
+ forceDisconnected: false,
180
+ projectId: patchedProject.id,
181
+ projectSnapshot: patchedProject.projectSnapshot,
182
+ type: 'load-project',
183
+ })
184
+ })
185
+ return
186
+ }
187
+
188
+ if (needsWorkspaceChange) {
189
+ dispatch({
190
+ projectId: project.id,
191
+ type: 'set-active-workspace',
192
+ workspaceId: targetWorkspaceId,
193
+ })
194
+ }
195
+ }
196
+
197
+ export function handleSwitchTabByIndex(ctx: SideEffectContext, index: number): void {
198
+ const { dispatch, state } = ctx
199
+ const currentProject =
200
+ state.currentProjectId != null && state.currentProjectId !== ''
201
+ ? state.projects.find((s) => s.id === state.currentProjectId)
202
+ : undefined
203
+ const visible = filterTabsForActiveWorkspace(state.tabs, currentProject)
204
+ const entries = buildTabEntries(visible, state.layoutTrees, state.tabGroupMap, state.activeTabId)
205
+ const target = entries[index - 1]
206
+ if (!target) {
207
+ logInputDebug('app.tabBar.switchOutOfRange', { index, total: entries.length })
208
+ return
209
+ }
210
+ const targetTabId = target.kind === 'single' ? target.tab.id : target.activeLeafId
211
+ if (targetTabId === state.activeTabId) return
212
+ dispatch({ tabId: targetTabId, type: 'set-active-tab' })
213
+ }
@@ -0,0 +1,188 @@
1
+ import type { SessionBackend } from '../session-backend/types'
2
+ import type { AppAction } from '../state/actions'
3
+ import type { AppState, ProjectRecord, TabSession } from '../state/types'
4
+
5
+ import { logInputDebug } from '../debug/input-log'
6
+ import { createPrefixedId } from '../platform/id'
7
+ import { saveProjectCatalog } from '../state/project-catalog'
8
+ import { createEmptyProjectSnapshot, serializeProject } from '../state/project-persistence'
9
+ import { buildProjectsWithCurrentSnapshot } from '../state/project-save'
10
+ import { createPrimaryWorkspace, ensureProjectWorkspaces } from '../state/project-workspaces'
11
+ import { toast } from '../state/toast-store'
12
+
13
+ export function createProjectFromCurrentState(
14
+ state: AppState,
15
+ name: string,
16
+ projectPath?: string
17
+ ): { project: ProjectRecord; projects: ProjectRecord[] } {
18
+ const now = new Date().toISOString()
19
+ const projectSnapshot =
20
+ (state.currentProjectId != null && state.currentProjectId !== '') || state.tabs.length === 0
21
+ ? createEmptyProjectSnapshot()
22
+ : serializeProject(state)
23
+ const project: ProjectRecord = {
24
+ activeWorkspaceId: undefined,
25
+ createdAt: now,
26
+ id: createPrefixedId('project'),
27
+ lastOpenedAt: now,
28
+ name,
29
+ projectPath,
30
+ projectSnapshot,
31
+ updatedAt: now,
32
+ workspaces: undefined,
33
+ }
34
+ if (projectPath != null && projectPath !== '') {
35
+ const workspace = createPrimaryWorkspace(projectPath, now)
36
+ project.activeWorkspaceId = workspace.id
37
+ project.workspaces = [workspace]
38
+ }
39
+
40
+ let updatedProjects = state.projects
41
+ if (state.currentProjectId != null && state.currentProjectId !== '') {
42
+ const currentSnapshot = serializeProject(state)
43
+ updatedProjects = state.projects.map((entry) =>
44
+ entry.id === state.currentProjectId
45
+ ? { ...entry, projectSnapshot: currentSnapshot, updatedAt: now }
46
+ : entry
47
+ )
48
+ }
49
+
50
+ return {
51
+ project,
52
+ projects: [...updatedProjects, ensureProjectWorkspaces(project)],
53
+ }
54
+ }
55
+
56
+ export function renameProjectRecords(
57
+ projects: ProjectRecord[],
58
+ projectId: string,
59
+ name: string
60
+ ): ProjectRecord[] {
61
+ return projects.map((project) =>
62
+ project.id === projectId ? { ...project, name, updatedAt: new Date().toISOString() } : project
63
+ )
64
+ }
65
+
66
+ export function switchProjectRecords(state: AppState, project: ProjectRecord): ProjectRecord[] {
67
+ const projectsWithSnapshot = buildProjectsWithCurrentSnapshot(
68
+ state.projects,
69
+ state.currentProjectId,
70
+ state
71
+ )
72
+ return projectsWithSnapshot.map((entry) =>
73
+ entry.id === project.id ? { ...entry, lastOpenedAt: new Date().toISOString() } : entry
74
+ )
75
+ }
76
+
77
+ export function deleteProjectRecords(
78
+ projects: ProjectRecord[],
79
+ projectId: string
80
+ ): ProjectRecord[] {
81
+ return projects.filter((project) => project.id !== projectId)
82
+ }
83
+
84
+ export function handleCreateProjectEffect(
85
+ state: AppState,
86
+ dispatch: (action: AppAction) => void,
87
+ name: string,
88
+ projectPath?: string
89
+ ): void {
90
+ const { project, projects } = createProjectFromCurrentState(state, name, projectPath)
91
+ logInputDebug('app.project.create', {
92
+ fromCurrentProject:
93
+ (state.currentProjectId == null || state.currentProjectId === '') && state.tabs.length > 0,
94
+ name,
95
+ projectId: project.id,
96
+ tabCount: project.projectSnapshot?.tabs.length ?? 0,
97
+ })
98
+ saveProjectCatalog(projects)
99
+ dispatch({ projects, type: 'set-projects' })
100
+ dispatch({
101
+ projectId: project.id,
102
+ projectSnapshot: project.projectSnapshot,
103
+ type: 'load-project',
104
+ })
105
+ }
106
+
107
+ export function handleRenameProjectEffect(
108
+ projects: ProjectRecord[],
109
+ dispatch: (action: AppAction) => void,
110
+ projectId: string,
111
+ name: string
112
+ ): void {
113
+ logInputDebug('app.project.rename', { name, projectId })
114
+ const renamed = renameProjectRecords(projects, projectId, name)
115
+ saveProjectCatalog(renamed)
116
+ dispatch({ name, projectId, type: 'rename-project-record' })
117
+ }
118
+
119
+ export function handleSwitchProjectEffect(
120
+ state: AppState,
121
+ backend: SessionBackend,
122
+ dispatch: (action: AppAction) => void,
123
+ project: ProjectRecord
124
+ ): void {
125
+ logInputDebug('app.project.switch.start', {
126
+ currentTabCount: state.tabs.length,
127
+ fromProjectId: state.currentProjectId,
128
+ restoredTabCount: project.projectSnapshot?.tabs.length ?? 0,
129
+ toName: project.name,
130
+ toProjectId: project.id,
131
+ })
132
+ const projects = switchProjectRecords(state, project)
133
+ saveProjectCatalog(projects)
134
+ void backend.destroy(true)
135
+ dispatch({ projects, type: 'set-projects' })
136
+ dispatch({
137
+ projectId: project.id,
138
+ projectSnapshot: project.projectSnapshot,
139
+ type: 'load-project',
140
+ })
141
+ logInputDebug('app.project.switch.dispatched', { toProjectId: project.id })
142
+ }
143
+
144
+ export function handleDeleteProjectEffect(
145
+ state: AppState,
146
+ backend: SessionBackend,
147
+ dispatch: (action: AppAction) => void,
148
+ projectId: string,
149
+ options?: { openProjectPicker?: boolean }
150
+ ): void {
151
+ const name = state.projects.find((entry) => entry.id === projectId)?.name
152
+ const remaining = deleteProjectRecords(state.projects, projectId)
153
+ logInputDebug('app.project.delete', {
154
+ projectId,
155
+ remainingCount: remaining.length,
156
+ wasCurrent: projectId === state.currentProjectId,
157
+ })
158
+ saveProjectCatalog(remaining)
159
+ if (projectId === state.currentProjectId) {
160
+ void backend.destroy(true)
161
+ }
162
+ dispatch({
163
+ openProjectPicker: options?.openProjectPicker,
164
+ projectId,
165
+ type: 'delete-project-record',
166
+ })
167
+ toast.success(name != null && name !== '' ? `Deleted project "${name}"` : 'Project deleted')
168
+ }
169
+
170
+ export function restartTabSession(
171
+ backend: SessionBackend,
172
+ dispatch: (action: AppAction) => void,
173
+ clearIdleTimer: (tabId: string) => void,
174
+ clearStartupGrace: (tabId: string) => void,
175
+ startTabSession: (tab: TabSession) => void,
176
+ tab: TabSession
177
+ ): void {
178
+ logInputDebug('app.restartTab', {
179
+ command: tab.command,
180
+ status: tab.status,
181
+ tabId: tab.id,
182
+ })
183
+ clearIdleTimer(tab.id)
184
+ clearStartupGrace(tab.id)
185
+ backend.disposeSession(tab.id)
186
+ dispatch({ tabId: tab.id, type: 'reset-tab-project' })
187
+ startTabSession(tab)
188
+ }