@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
@@ -1,5 +1,6 @@
1
1
  import type { SessionBackend } from '../session-backend/types'
2
- import type { AppAction, AppState, SnippetRecord, TabSession } from '../state/types'
2
+ import type { AppAction } from '../state/actions'
3
+ import type { AppState, SnippetRecord, TabSession } from '../state/types'
3
4
 
4
5
  import { createPrefixedId } from '../platform/id'
5
6
  import { isConfigSnippetId, saveSnippetCatalog } from '../state/snippet-catalog'
@@ -40,7 +41,7 @@ export function saveSnippetEditorState(state: AppState): SnippetRecord[] | null
40
41
  return null
41
42
  }
42
43
 
43
- const snippetId = state.modal.sessionTargetId
44
+ const snippetId = state.modal.projectTargetId
44
45
  // Config-pinned snippets are sticky and read-only in the UI.
45
46
  if (snippetId != null && snippetId !== '' && isConfigSnippetId(snippetId)) {
46
47
  return null
@@ -0,0 +1,218 @@
1
+ import type { AssistantId, TabSession } from '../state/types'
2
+ import type { SideEffectContext } from './side-effect-context'
3
+
4
+ import { logInputDebug } from '../debug/input-log'
5
+ import { createPrefixedId } from '../platform/id'
6
+ import {
7
+ getAllAssistantOptions,
8
+ getAssistantOption,
9
+ isCommandAvailable,
10
+ parseCommand,
11
+ } from '../pty/command-registry'
12
+ import { createTerminalBounds } from '../state/layout-resize'
13
+ import {
14
+ computePaneRects,
15
+ createLeaf,
16
+ getTreeForTab,
17
+ PANE_BORDER,
18
+ type SplitDirection,
19
+ splitNode,
20
+ } from '../state/layout-tree'
21
+ import { getActiveWorkspace, getCurrentProject } from '../state/project-workspaces'
22
+ import { createDefaultTerminalModes } from '../state/terminal-modes'
23
+ import { getSelectedAssistantOption } from './selection'
24
+
25
+ /**
26
+ * How long a freshly spawned assistant is given to draw something before the
27
+ * tab stops being treated as "still starting up".
28
+ */
29
+ const STARTUP_GRACE_MS = 5_000
30
+
31
+ export function confirmSplitSelection(ctx: SideEffectContext): void {
32
+ const { dispatch, state } = ctx
33
+ const option = getSelectedAssistantOption(state)
34
+ const direction = state.modal.type === 'split-picker' ? state.modal.splitDirection : 'vertical'
35
+ const customCommand = state.customCommands[option.id]
36
+ const tab = createTabSession(
37
+ option.id,
38
+ customCommand,
39
+ state.customCommands,
40
+ getActiveWorkspace(getCurrentProject(state))?.id
41
+ )
42
+ dispatch({ type: 'close-modal' })
43
+ executeSplitPane(ctx, direction, tab)
44
+ dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
45
+ }
46
+
47
+ function createTabId(): string {
48
+ return createPrefixedId('tab')
49
+ }
50
+
51
+ export function createTabSession(
52
+ assistant: AssistantId,
53
+ customCommand?: string,
54
+ customCommands?: Record<string, string>,
55
+ workspaceId?: string
56
+ ): TabSession {
57
+ const allOptions = getAllAssistantOptions(customCommands ?? {})
58
+ const option = allOptions.find((o) => o.id === assistant) ?? getAssistantOption(0)
59
+
60
+ return {
61
+ activity: 'idle',
62
+ assistant,
63
+ buffer: '',
64
+ command: customCommand ?? option.command,
65
+ id: createTabId(),
66
+ status: 'starting',
67
+ terminalModes: createDefaultTerminalModes(),
68
+ title: option.label,
69
+ workspaceId,
70
+ }
71
+ }
72
+
73
+ /** Everything `startTabSession` needs from the side-effect context. */
74
+ type StartTabSessionContext = Pick<
75
+ SideEffectContext,
76
+ 'backend' | 'clearStartupGrace' | 'dispatch' | 'startStartupGrace'
77
+ >
78
+
79
+ export interface StartTabSessionOptions {
80
+ cols: number
81
+ rows: number
82
+ cwd?: string
83
+ /** Default true: only a tab the user did not just ask for opts out. */
84
+ autoRenameCandidate?: boolean
85
+ /**
86
+ * Appended after the command's own args. Kept out of `tab.command` on purpose:
87
+ * an initial prompt is up to a few thousand characters, and `command` is what
88
+ * the UI shows, what the snapshot persists, and what the custom-command editor
89
+ * round-trips.
90
+ */
91
+ extraArgs?: string[]
92
+ }
93
+
94
+ export function startTabSession(
95
+ ctx: StartTabSessionContext,
96
+ tab: Pick<TabSession, 'id' | 'assistant' | 'title' | 'command' | 'workspaceId'>,
97
+ { autoRenameCandidate = true, cols, cwd, extraArgs, rows }: StartTabSessionOptions
98
+ ): void {
99
+ const { backend, clearStartupGrace, dispatch } = ctx
100
+ logInputDebug('app.tab.start.request', {
101
+ cols,
102
+ command: tab.command,
103
+ cwd: cwd ?? null,
104
+ rows,
105
+ tabId: tab.id,
106
+ title: tab.title,
107
+ workspaceId: tab.workspaceId ?? null,
108
+ })
109
+ ctx.startStartupGrace(tab.id, STARTUP_GRACE_MS)
110
+
111
+ const { args, executable } = parseCommand(tab.command)
112
+
113
+ if (!isCommandAvailable(executable)) {
114
+ clearStartupGrace(tab.id)
115
+ dispatch({
116
+ message: `[command not found] ${executable} is not available in PATH.`,
117
+ tabId: tab.id,
118
+ type: 'set-tab-error',
119
+ })
120
+ return
121
+ }
122
+
123
+ backend.createSession({
124
+ args: extraArgs ? [...args, ...extraArgs] : args,
125
+ assistant: tab.assistant,
126
+ autoRenameCandidate,
127
+ cols,
128
+ command: executable,
129
+ cwd,
130
+ rows,
131
+ tabId: tab.id,
132
+ title: tab.title,
133
+ workspaceId: tab.workspaceId,
134
+ })
135
+ }
136
+
137
+ /**
138
+ * Returns the id of the tab it created, so a caller can write into it.
139
+ *
140
+ * `initialPromptArgs` hands the prompt to the CLI at spawn — see
141
+ * `assistantAcceptsPromptArg`. Callers pass it only for assistants that support
142
+ * it, and fall back to `injectPromptWhenReady` otherwise.
143
+ */
144
+ export function launchAssistant(
145
+ ctx: SideEffectContext,
146
+ assistant: AssistantId,
147
+ workspaceId?: string,
148
+ initialPromptArgs?: string[]
149
+ ): string {
150
+ const { dispatch, state } = ctx
151
+ const customCommand = state.customCommands[assistant]
152
+ const tab = createTabSession(
153
+ assistant,
154
+ customCommand,
155
+ state.customCommands,
156
+ workspaceId ?? getActiveWorkspace(getCurrentProject(state))?.id
157
+ )
158
+ logInputDebug('app.launchAssistant', {
159
+ assistant,
160
+ command: tab.command,
161
+ tabId: tab.id,
162
+ })
163
+ dispatch({ tab, type: 'add-tab' })
164
+ dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
165
+ startTabSession(ctx, tab, {
166
+ cols: state.layout.terminalCols,
167
+ cwd: getTabProjectPath(ctx, tab),
168
+ extraArgs: initialPromptArgs,
169
+ rows: state.layout.terminalRows,
170
+ })
171
+ return tab.id
172
+ }
173
+
174
+ export function getTabProjectPath(
175
+ ctx: SideEffectContext,
176
+ tab: Pick<TabSession, 'workspaceId'>
177
+ ): string | undefined {
178
+ // Scoped to the current project on purpose: a tab pinned to a workspace of
179
+ // some other project gets the current project's path, not that project's.
180
+ const project = getCurrentProject(ctx.state)
181
+ const workspace = project?.workspaces?.find((entry) => entry.id === tab.workspaceId)
182
+ if (workspace) return workspace.path
183
+ return ctx.getCurrentProjectProjectPath()
184
+ }
185
+
186
+ export function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
187
+ startTabSession(ctx, tab, {
188
+ autoRenameCandidate: false,
189
+ cols: ctx.state.layout.terminalCols,
190
+ cwd: getTabProjectPath(ctx, tab),
191
+ rows: ctx.state.layout.terminalRows,
192
+ })
193
+ }
194
+
195
+ export function executeSplitPane(
196
+ ctx: SideEffectContext,
197
+ direction: SplitDirection,
198
+ tab: TabSession
199
+ ): void {
200
+ const { dispatch, state } = ctx
201
+ const activeTabId = state.activeTabId
202
+ if (!(activeTabId != null && activeTabId !== '')) {
203
+ return
204
+ }
205
+
206
+ const existingTree = getTreeForTab(state.layoutTrees, state.tabGroupMap, activeTabId)
207
+ const baseTree = existingTree ?? createLeaf(activeTabId)
208
+ const newTree = splitNode(baseTree, activeTabId, direction, tab.id)
209
+ const bounds = createTerminalBounds(state.layout.terminalCols, state.layout.terminalRows)
210
+ const paneRect = computePaneRects(newTree, bounds).get(tab.id)
211
+
212
+ dispatch({ direction, newTab: tab, type: 'split-pane' })
213
+ startTabSession(ctx, tab, {
214
+ cols: Math.max(1, (paneRect?.cols ?? state.layout.terminalCols) - PANE_BORDER * 2),
215
+ cwd: getTabProjectPath(ctx, tab),
216
+ rows: Math.max(1, (paneRect?.rows ?? state.layout.terminalRows) - PANE_BORDER * 2),
217
+ })
218
+ }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useRef } from 'react'
2
2
 
3
- import type { AppAction } from '../state/types'
3
+ import type { AppAction } from '../state/actions'
4
4
 
5
5
  export interface TabRuntimeTimeouts {
6
6
  clearIdleTimer: (tabId: string) => void
@@ -1,6 +1,7 @@
1
1
  import { type MutableRefObject, useEffect, useRef } from 'react'
2
2
 
3
- import type { AppAction, AppState, GitRefreshPayload, TabActivity } from '../state/types'
3
+ import type { AppAction } from '../state/actions'
4
+ import type { AppState, GitRefreshPayload, TabActivity } from '../state/types'
4
5
 
5
6
  import {
6
7
  type AutoCommitConfigSnapshot,
@@ -61,7 +62,7 @@ export function useAutoCommitDriver({
61
62
  const prevActivityRef = useRef<Map<string, TabActivity | undefined>>(new Map())
62
63
 
63
64
  useEffect(() => {
64
- // Always refresh the activity map so re-enabling mid-session doesn't fire
65
+ // Always refresh the activity map so re-enabling mid-project doesn't fire
65
66
  // a stale "became idle" transition. Skip only the dispatch work.
66
67
  const prev = prevActivityRef.current
67
68
  const next = new Map<string, TabActivity | undefined>()
@@ -73,15 +74,15 @@ export function useAutoCommitDriver({
73
74
  const becameIdle =
74
75
  (before === 'working' || before === 'waiting-input') && tab.activity === 'idle'
75
76
  if (becameIdle) {
76
- const sessionId = state.currentSessionId
77
- if (!(sessionId != null && sessionId !== '')) continue
78
- const session = state.sessions.find((s) => s.id === sessionId)
77
+ const projectId = state.currentProjectId
78
+ if (!(projectId != null && projectId !== '')) continue
79
+ const project = state.projects.find((s) => s.id === projectId)
79
80
  const git = gitPayloadFromState(state)
80
81
  void onActivityTransition(depsRef.current, {
81
82
  assistant: tab.assistant,
82
83
  git,
83
- projectPath: session?.projectPath,
84
- sessionId,
84
+ projectId,
85
+ projectPath: project?.projectPath,
85
86
  tabId: tab.id,
86
87
  })
87
88
  }
@@ -94,14 +95,14 @@ export function useAutoCommitDriver({
94
95
 
95
96
  useEffect(() => {
96
97
  if (!configRef.current.enabled) return
97
- const sessionId = state.currentSessionId
98
- if (!(sessionId != null && sessionId !== '')) return
98
+ const projectId = state.currentProjectId
99
+ if (!(projectId != null && projectId !== '')) return
99
100
  const payload = gitPayloadFromState(state)
100
101
  if (!payload) return
101
102
  const cacheKey = JSON.stringify(payload)
102
103
  if (lastGitHashRef.current === cacheKey) return
103
104
  lastGitHashRef.current = cacheKey
104
- onGitRefresh(depsRef.current, sessionId, payload)
105
+ onGitRefresh(depsRef.current, projectId, payload)
105
106
 
106
107
  // Debounced trigger: when the working tree stays stable for
107
108
  // GIT_STABILIZATION_DEBOUNCE_MS, try to start generation. Covers cases
@@ -114,14 +115,14 @@ export function useAutoCommitDriver({
114
115
  ? state.tabs.find((tab) => tab.id === activeTabId)
115
116
  : undefined
116
117
  if (!activeTab) return
117
- const session = state.sessions.find((s) => s.id === sessionId)
118
+ const project = state.projects.find((s) => s.id === projectId)
118
119
  gitStabilizeTimerRef.current = setTimeout(() => {
119
120
  gitStabilizeTimerRef.current = null
120
121
  void onActivityTransition(depsRef.current, {
121
122
  assistant: activeTab.assistant,
122
123
  git: payload,
123
- projectPath: session?.projectPath,
124
- sessionId,
124
+ projectId,
125
+ projectPath: project?.projectPath,
125
126
  tabId: activeTab.id,
126
127
  })
127
128
  }, GIT_STABILIZATION_DEBOUNCE_MS)
@@ -1,7 +1,8 @@
1
1
  import { type MutableRefObject, useEffect, useRef } from 'react'
2
2
 
3
3
  import type { SessionBackend } from '../session-backend/types'
4
- import type { AppAction, LayoutState } from '../state/types'
4
+ import type { AppAction } from '../state/actions'
5
+ import type { LayoutState } from '../state/types'
5
6
 
6
7
  import { attachCurrentSession } from './backend-attach-runtime'
7
8
  import { bindBackendRuntimeEvents } from './backend-runtime-events'
@@ -11,10 +12,10 @@ interface BackendRuntimeOptions {
11
12
  backend: SessionBackend
12
13
  dispatch: (action: AppAction) => void
13
14
  activeTabId: string | null
14
- currentSessionId: string | null
15
+ currentProjectId: string | null
15
16
  layoutRef: MutableRefObject<LayoutState>
16
17
  resizingRef: MutableRefObject<boolean>
17
- currentSessionWorkspaceSnapshot: Parameters<SessionBackend['attach']>[0]['workspaceSnapshot']
18
+ currentProjectProjectSnapshot: Parameters<SessionBackend['attach']>[0]['projectSnapshot']
18
19
  syntaxOverlayEnabled: () => boolean
19
20
  }
20
21
 
@@ -27,8 +28,8 @@ export interface TabRuntimeControls {
27
28
  export function useBackendRuntime({
28
29
  activeTabId,
29
30
  backend,
30
- currentSessionId,
31
- currentSessionWorkspaceSnapshot,
31
+ currentProjectId,
32
+ currentProjectProjectSnapshot,
32
33
  dispatch,
33
34
  layoutRef,
34
35
  resizingRef,
@@ -39,7 +40,7 @@ export function useBackendRuntime({
39
40
  const { clearAllTimers, clearIdleTimer, clearStartupGrace, startStartupGrace } = timeouts
40
41
 
41
42
  useEffect(() => {
42
- if (!(currentSessionId != null && currentSessionId !== '')) {
43
+ if (!(currentProjectId != null && currentProjectId !== '')) {
43
44
  attachRequestIdRef.current += 1
44
45
  return
45
46
  }
@@ -47,22 +48,22 @@ export function useBackendRuntime({
47
48
  return attachCurrentSession({
48
49
  attachRequestIdRef,
49
50
  backend,
50
- currentSessionId,
51
- currentSessionWorkspaceSnapshot,
51
+ currentProjectId,
52
+ currentProjectProjectSnapshot,
52
53
  dispatch,
53
54
  layoutRef,
54
55
  })
55
- }, [backend, currentSessionId, currentSessionWorkspaceSnapshot, dispatch, layoutRef])
56
+ }, [backend, currentProjectId, currentProjectProjectSnapshot, dispatch, layoutRef])
56
57
 
57
58
  useEffect(() => {
58
- if (!(currentSessionId != null && currentSessionId !== '')) {
59
+ if (!(currentProjectId != null && currentProjectId !== '')) {
59
60
  return
60
61
  }
61
62
 
62
63
  // The backend owns each tab's scroll position; a backgrounded tab keeps it
63
64
  // in its own emulator, so switching back needs no re-anchor from here.
64
65
  backend.setActiveTab(activeTabId)
65
- }, [activeTabId, backend, currentSessionId])
66
+ }, [activeTabId, backend, currentProjectId])
66
67
 
67
68
  useEffect(() => {
68
69
  return bindBackendRuntimeEvents({
@@ -1,13 +1,14 @@
1
1
  import { useEffect } from 'react'
2
2
 
3
- import type { AppAction, ModalState } from '../state/types'
3
+ import type { AppAction } from '../state/actions'
4
+ import type { ModalState } from '../state/types'
4
5
 
5
6
  import { searchProjectDirectories, warmDirectoryCache } from '../platform/project-search'
6
7
 
7
8
  const DEFAULT_DIRECTORY_SEARCH_DEBOUNCE_MS = 200
8
9
 
9
10
  function getDirectoryQuery(modal: ModalState): string {
10
- if (modal.type !== 'create-session') {
11
+ if (modal.type !== 'create-project') {
11
12
  return ''
12
13
  }
13
14
 
@@ -26,12 +27,12 @@ export function useDirectorySearch(
26
27
  const directoryQuery = getDirectoryQuery(modal)
27
28
 
28
29
  useEffect(() => {
29
- if (modal.type !== 'create-session') return
30
+ if (modal.type !== 'create-project') return
30
31
  void warmDirectoryCache()
31
32
  }, [modal.type])
32
33
 
33
34
  useEffect(() => {
34
- if (modal.type !== 'create-session') {
35
+ if (modal.type !== 'create-project') {
35
36
  return
36
37
  }
37
38
 
@@ -4,8 +4,9 @@ import { useEffect, useRef } from 'react'
4
4
 
5
5
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
6
6
  import type { SessionBackend } from '../session-backend/types'
7
+ import type { AppAction } from '../state/actions'
7
8
  import type { SplitDirection } from '../state/layout-tree'
8
- import type { AppAction, AppState, BarSide, TabSession, TerminalLine } from '../state/types'
9
+ import type { AppState, BarSide, TabSession, TerminalLine } from '../state/types'
9
10
 
10
11
  import { logInputDebug } from '../debug/input-log'
11
12
  import { MultiClickDetector } from '../input/multi-click-detector'
@@ -2,9 +2,9 @@ import { useEffect, useRef } from 'react'
2
2
 
3
3
  import type { AppState } from '../state/types'
4
4
 
5
- import { saveCurrentWorkspace } from '../state/workspace-save'
5
+ import { saveCurrentProject } from '../state/project-save'
6
6
 
7
- export function useWorkspaceAutosave(state: AppState, debounceMs: number): void {
7
+ export function useProjectAutosave(state: AppState, debounceMs: number): void {
8
8
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
9
9
  const latestStateRef = useRef(state)
10
10
  latestStateRef.current = state
@@ -15,7 +15,7 @@ export function useWorkspaceAutosave(state: AppState, debounceMs: number): void
15
15
  }
16
16
 
17
17
  timeoutRef.current = setTimeout(() => {
18
- saveCurrentWorkspace(latestStateRef.current)
18
+ saveCurrentProject(latestStateRef.current)
19
19
  timeoutRef.current = null
20
20
  }, debounceMs)
21
21
 
@@ -33,7 +33,7 @@ export function useWorkspaceAutosave(state: AppState, debounceMs: number): void
33
33
  clearTimeout(timeoutRef.current)
34
34
  timeoutRef.current = null
35
35
  }
36
- saveCurrentWorkspace(latestStateRef.current)
36
+ saveCurrentProject(latestStateRef.current)
37
37
  }
38
38
  }, [])
39
39
  }
@@ -4,7 +4,8 @@ import { type MutableRefObject, useEffect, useRef } from 'react'
4
4
 
5
5
  import type { KeyChord } from '../input/keymap/key-chord'
6
6
  import type { SessionBackend } from '../session-backend/types'
7
- import type { AppAction, FocusMode, SnippetRecord, TabSession } from '../state/types'
7
+ import type { AppAction } from '../state/actions'
8
+ import type { FocusMode, SnippetRecord, TabSession } from '../state/types'
8
9
 
9
10
  import { INPUT_DEBUG_LOG_PATH, logInputDebug } from '../debug/input-log'
10
11
  import { createRawInputHandler } from '../input/raw-input-handler'
@@ -0,0 +1,96 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ import type { AppState, ProjectRecord, TabSession, WorkspaceRecord } from '../state/types'
4
+ import type { SideEffectContext } from './side-effect-context'
5
+
6
+ import { hasSetupScript } from '../state/project-data'
7
+ import { findSetupTab, runSetup } from './setup-actions'
8
+
9
+ export interface SetupCandidate {
10
+ projectId: string
11
+ workspace: WorkspaceRecord
12
+ }
13
+
14
+ /**
15
+ * Which workspaces should have their setup script run right now.
16
+ *
17
+ * The trigger is "a workspace id this session has not seen before", not "a
18
+ * workspace became active". Activation is the wrong signal: `j`/`k` cycles
19
+ * workspaces, so an activation-based rule would fire the script in every
20
+ * workspace the user scrolls past. Watching the id set also means one rule
21
+ * covers both creation paths — the TUI's `<C-p>` and `aimux workspace create`
22
+ * arriving over the daemon — instead of a hook per call site, which is how the
23
+ * CLI path ends up silently skipping setup.
24
+ *
25
+ * Mutates `seenByProject`: every id examined is marked seen, so a workspace is
26
+ * only ever a candidate once.
27
+ */
28
+ export function collectSetupCandidates(
29
+ projects: ProjectRecord[],
30
+ tabs: TabSession[],
31
+ seenByProject: Map<string, Set<string>>,
32
+ hasScript: (projectId: string) => boolean
33
+ ): SetupCandidate[] {
34
+ const candidates: SetupCandidate[] = []
35
+
36
+ for (const project of projects) {
37
+ const seen = seenByProject.get(project.id)
38
+ const workspaces = project.workspaces ?? []
39
+
40
+ // First sight of a project: record what it already has and run nothing.
41
+ // Without this, switching to a project surfaces N pre-existing workspaces
42
+ // at once and would fire N setups.
43
+ if (!seen) {
44
+ seenByProject.set(project.id, new Set(workspaces.map((w) => w.id)))
45
+ continue
46
+ }
47
+
48
+ for (const workspace of workspaces) {
49
+ if (seen.has(workspace.id)) continue
50
+ seen.add(workspace.id)
51
+
52
+ // The primary workspace *is* the user's real repo checkout. Running a
53
+ // script that does `cp .env.example .env` over it would clobber their
54
+ // actual .env.
55
+ if (workspace.source === 'primary') continue
56
+ if (!hasScript(project.id)) continue
57
+ if (findSetupTab(tabs, workspace.id)) continue
58
+
59
+ candidates.push({ projectId: project.id, workspace })
60
+ }
61
+ }
62
+
63
+ return candidates
64
+ }
65
+
66
+ /**
67
+ * Runs a project's setup script once per newly created workspace.
68
+ *
69
+ * ponytail: a workspace created by the CLI while the app is closed is seeded as
70
+ * pre-existing on the next boot and never auto-runs. The widget's Run button
71
+ * covers it; compare createdAt against setupRanAt if that turns out to matter.
72
+ *
73
+ * The outcome is recorded by `recordSetupExitIfHidden`, called from the
74
+ * backend's exit event, which already carries the exit code.
75
+ */
76
+ export function useSetupRunner(state: AppState, ctx: SideEffectContext): void {
77
+ const seenByProject = useRef(new Map<string, Set<string>>())
78
+ const ctxRef = useRef(ctx)
79
+ ctxRef.current = ctx
80
+
81
+ useEffect(() => {
82
+ // Tabs are read fresh rather than taken as a dependency: their array
83
+ // identity changes on every viewport frame, which would re-run this at the
84
+ // rate the PTYs print. The store is also the more correct source — the
85
+ // render snapshot can lag a setup tab that was just added.
86
+ const candidates = collectSetupCandidates(
87
+ state.projects,
88
+ ctxRef.current.getState().tabs,
89
+ seenByProject.current,
90
+ hasSetupScript
91
+ )
92
+ for (const { projectId, workspace } of candidates) {
93
+ runSetup(ctxRef.current, projectId, workspace)
94
+ }
95
+ }, [state.projects])
96
+ }
@@ -10,7 +10,8 @@ import {
10
10
 
11
11
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
12
12
  import type { SessionBackend } from '../session-backend/types'
13
- import type { AppAction, AppState } from '../state/types'
13
+ import type { AppAction } from '../state/actions'
14
+ import type { AppState } from '../state/types'
14
15
  import type { MeasuredPaneRect } from './use-pane-size-report'
15
16
 
16
17
  import { getBarWidth } from '../state/bars'
@@ -151,7 +152,10 @@ export function useTerminalResize({
151
152
  const handledBySyncRef = useRef(false)
152
153
  const sidebarMountedRef = useRef(false)
153
154
 
154
- const currentTabIds = state.tabs.map((t) => t.id)
155
+ // Hidden tabs are excluded from the open-loop cascade: their host (the setup
156
+ // widget) measures its own box, and the cascade would overwrite that with the
157
+ // main terminal area's size on every terminal or bar resize.
158
+ const currentTabIds = state.tabs.filter((t) => t.hidden !== true).map((t) => t.id)
155
159
  const tabIdsChanged =
156
160
  currentTabIds.length !== tabIdsRef.current.length ||
157
161
  currentTabIds.some((id, i) => id !== tabIdsRef.current[i])
@@ -179,15 +183,15 @@ export function useTerminalResize({
179
183
  // loop so an unchanged box never re-triggers a resize.
180
184
  const measuredRef = useRef(new Map<string, { cols: number; rows: number }>())
181
185
 
182
- // Drop the dedupe cache when the workspace changes. attach() re-gates every
186
+ // Drop the dedupe cache when the project changes. attach() re-gates every
183
187
  // restored tab's paneReady to false; if a tabId carries the same dimensions
184
- // across workspaces, handleMeasure's prev-match would short-circuit and
188
+ // across projects, handleMeasure's prev-match would short-circuit and
185
189
  // never call resizeTab({confirmedFromMeasurement:true}), leaving the gate
186
190
  // closed forever — the new pane then paints a stale buffered snapshot from
187
- // the old workspace, which is the 2–3 row offset users see.
188
- const lastSessionIdRef = useRef(state.currentSessionId)
189
- if (lastSessionIdRef.current !== state.currentSessionId) {
190
- lastSessionIdRef.current = state.currentSessionId
191
+ // the old project, which is the 2–3 row offset users see.
192
+ const lastProjectIdRef = useRef(state.currentProjectId)
193
+ if (lastProjectIdRef.current !== state.currentProjectId) {
194
+ lastProjectIdRef.current = state.currentProjectId
191
195
  measuredRef.current.clear()
192
196
  }
193
197
 
@@ -229,13 +233,13 @@ export function useTerminalResize({
229
233
  const leftBarWidth = getBarWidth(state.bars.left)
230
234
  const rightBarWidth = getBarWidth(state.bars.right)
231
235
  const terminalSize = useMemo(() => {
232
- const sessionBarRows = state.sessionBar.visible ? 1 : 0
233
- const sessionBarTopOffset = sessionBarRows
236
+ const projectBarRows = state.projectBar.visible ? 1 : 0
237
+ const projectBarTopOffset = projectBarRows
234
238
  const reservedRows =
235
239
  MAIN_AREA_VERTICAL_PADDING +
236
240
  STATUS_BAR_HEIGHT +
237
241
  TERMINAL_PANE_VERTICAL_CHROME +
238
- sessionBarRows
242
+ projectBarRows
239
243
  const cols = Math.max(
240
244
  MIN_TERMINAL_COLS,
241
245
  Math.floor(dimensions.width - leftBarWidth - rightBarWidth - MAIN_AREA_HORIZONTAL_CHROME)
@@ -246,7 +250,7 @@ export function useTerminalResize({
246
250
  cols,
247
251
  rows,
248
252
  x: leftBarWidth + 1,
249
- y: 1 + sessionBarTopOffset,
253
+ y: 1 + projectBarTopOffset,
250
254
  }
251
255
 
252
256
  return { cols, rows }
@@ -256,7 +260,7 @@ export function useTerminalResize({
256
260
  dimensions.width,
257
261
  leftBarWidth,
258
262
  rightBarWidth,
259
- state.sessionBar.visible,
263
+ state.projectBar.visible,
260
264
  ])
261
265
 
262
266
  useLayoutEffect(() => {
@@ -278,7 +282,7 @@ export function useTerminalResize({
278
282
  })
279
283
  handledBySyncRef.current = true
280
284
  // eslint-disable-next-line react-hooks/exhaustive-deps
281
- }, [leftBarWidth, rightBarWidth, state.sessionBar.visible])
285
+ }, [leftBarWidth, rightBarWidth, state.projectBar.visible])
282
286
 
283
287
  useEffect(() => {
284
288
  if (handledBySyncRef.current) {