@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,28 +1,29 @@
1
1
  import { basename } from 'node:path'
2
2
 
3
- import type { AppAction, AppState } from '../types'
3
+ import type { AppAction } from '../actions'
4
+ import type { AppState } from '../types'
4
5
 
5
6
  import { collectHelpEntries } from '../../input/keymap/help-entries'
6
7
  import { getActiveKeymap } from '../../input/keymap/keymap-ref'
7
8
  import { getAllAssistantOptions } from '../../pty/command-registry'
9
+ import { filterSettingRows } from '../../settings/search'
8
10
  import { filterThemeIds } from '../../ui/filter-themes'
9
11
  import { buildFlashJumpLabels } from '../../ui/flash/build-labels'
10
12
  import {
11
13
  type BaseRefOption,
12
14
  buildBaseRefOptions,
13
- filterAssistants,
14
- filterSessions,
15
+ filterProjects,
15
16
  filterSnippets,
16
- getTemplateNoneOffset,
17
+ getNewTabAssistantOptions,
17
18
  } from '../selectors'
18
- import { reduceAutoCommitState } from './auto-commit-state'
19
+ import { reduceGitCommitModalState } from './git-commit-modal-state'
19
20
 
20
21
  function emptyModal() {
21
22
  return {
22
23
  cursorPos: 0,
23
24
  editBuffer: null,
25
+ projectTargetId: null,
24
26
  selectedIndex: 0,
25
- sessionTargetId: null,
26
27
  type: null,
27
28
  } as const
28
29
  }
@@ -33,236 +34,97 @@ function clampCursor(value: number, max: number): number {
33
34
  return Math.max(0, Math.min(max, value))
34
35
  }
35
36
 
36
- function getCurrentWorktreeCount(state: AppState): number {
37
- if (!(state.currentSessionId != null && state.currentSessionId !== '')) return 0
38
- return state.sessions.find((entry) => entry.id === state.currentSessionId)?.worktrees?.length ?? 0
39
- }
40
-
41
- function getCurrentWorktreeIndex(state: AppState): number {
42
- if (!(state.currentSessionId != null && state.currentSessionId !== '')) return 0
43
- const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
44
- return Math.max(
45
- 0,
46
- (session?.worktrees ?? []).findIndex((worktree) => worktree.id === session?.activeWorktreeId)
37
+ function getCurrentWorkspaceCount(state: AppState): number {
38
+ if (!(state.currentProjectId != null && state.currentProjectId !== '')) return 0
39
+ return (
40
+ state.projects.find((entry) => entry.id === state.currentProjectId)?.workspaces?.length ?? 0
47
41
  )
48
42
  }
49
43
 
50
- function getNewTabBaseOptions(state: AppState, queryOverride?: string): BaseRefOption[] {
51
- if (state.modal.type !== 'new-tab') return []
52
- const worktrees =
53
- state.sessions.find((entry) => entry.id === state.currentSessionId)?.worktrees ?? []
44
+ function getCreateWorkspaceBaseOptions(state: AppState, queryOverride?: string): BaseRefOption[] {
45
+ if (state.modal.type !== 'create-workspace') return []
46
+ const workspaces =
47
+ state.projects.find((entry) => entry.id === state.currentProjectId)?.workspaces ?? []
54
48
  return buildBaseRefOptions(
55
- worktrees,
49
+ workspaces,
56
50
  state.modal.baseBranches,
57
51
  queryOverride ?? state.modal.baseQuery
58
52
  )
59
53
  }
60
54
 
61
- function getSelectedNewTabAssistant(state: AppState, assistantId?: string) {
62
- if (assistantId != null && assistantId !== '') {
63
- return getAllAssistantOptions(state.customCommands).find((entry) => entry.id === assistantId)
55
+ /**
56
+ * How many rows the open modal's list has, for the two cases that move the
57
+ * selection through it. They read the same list, so they read it from here:
58
+ * two copies of this chain drift apart the moment one modal's list changes.
59
+ * Modals whose list is not indexed this way (create-workspace's own fields,
60
+ * flash-jump) return 0 and are handled by their case before it gets here.
61
+ */
62
+ function getModalOptionCount(state: AppState): number {
63
+ const { modal } = state
64
+ switch (modal.type) {
65
+ case 'create-project':
66
+ return modal.directoryResults.length
67
+ case 'help':
68
+ return modal.entryCount
69
+ case 'new-tab':
70
+ return getNewTabAssistantOptions(
71
+ state.customCommands,
72
+ modal.editBuffer,
73
+ modal.pendingWorkspace != null
74
+ ).length
75
+ case 'project-picker':
76
+ // `+1` for the "create a project" row the picker appends, and at least
77
+ // that row exists even when nothing matches the filter.
78
+ return Math.max(1, filterProjects(state.projects, modal.editBuffer).length + 1)
79
+ case 'settings-search':
80
+ return filterSettingRows(state.projects, modal.editBuffer).length
81
+ case 'snippet-picker':
82
+ return filterSnippets(state.snippets, modal.editBuffer).length
83
+ case 'split-picker':
84
+ return getAllAssistantOptions(state.customCommands).length
85
+ case 'theme-picker':
86
+ return filterThemeIds(modal.editBuffer).length
87
+ case 'update-available':
88
+ return 2
89
+ case 'workspace-move':
90
+ // Every workspace but the one being moved.
91
+ return Math.max(0, getCurrentWorkspaceCount(state) - 1)
92
+ default:
93
+ return 0
64
94
  }
65
- if (state.modal.type !== 'new-tab') return
66
- return filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)[
67
- state.modal.selectedIndex
68
- ]
95
+ }
96
+
97
+ const CREATE_WORKSPACE_FIELDS = ['prompt', 'base'] as const
98
+
99
+ type CreateWorkspaceField = (typeof CREATE_WORKSPACE_FIELDS)[number]
100
+
101
+ /** The buffer a create-workspace field edits, so cursor math has one source. */
102
+ function getCreateWorkspaceFieldValue(
103
+ modal: { prompt: string; baseQuery: string },
104
+ field: CreateWorkspaceField
105
+ ): string {
106
+ return field === 'prompt' ? modal.prompt : modal.baseQuery
69
107
  }
70
108
 
71
109
  export function reduceModalState(state: AppState, action: AppAction): AppState | null {
110
+ // The one modal with a stage machine of its own, in its own file.
111
+ const gitCommit = reduceGitCommitModalState(state, action)
112
+ if (gitCommit) return gitCommit
113
+
72
114
  switch (action.type) {
73
115
  case 'open-new-tab-modal': {
74
- const targetWorktreeIndex = getCurrentWorktreeIndex(state)
75
116
  return {
76
117
  ...state,
77
118
  focusMode: 'command-edit',
78
119
  modal: {
79
- activeField: 'assistant',
80
- baseBranches: [],
81
- baseQuery: '',
82
- baseRef: '',
83
- branchError: null,
84
- branchName: '',
85
- createWorktree: false,
86
120
  cursorPos: 0,
87
121
  editBuffer: '',
88
122
  editingCommand: null,
89
- selectedAssistantId: null,
123
+ pendingPrompt: action.pendingPrompt,
124
+ pendingWorkspace: action.pendingWorkspace,
125
+ projectTargetId: null,
90
126
  selectedIndex: 0,
91
- sessionTargetId: null,
92
- step: 'assistant',
93
- targetWorktreeIndex,
94
127
  type: 'new-tab',
95
- worktreeDeletePrompt: null,
96
- worktreeName: '',
97
- },
98
- }
99
- }
100
- case 'enter-new-tab-worktree-create': {
101
- if (state.modal.type !== 'new-tab') return state
102
- // Default the base to the branch of the worktree we're forking from (the
103
- // current target), preserving the previous always-fork-from-source
104
- // behaviour until the user picks another base.
105
- const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
106
- const sourceWorktree = session?.worktrees?.[state.modal.targetWorktreeIndex]
107
- return {
108
- ...state,
109
- modal: {
110
- ...state.modal,
111
- activeField: 'worktree-name',
112
- baseQuery: '',
113
- baseRef: sourceWorktree?.branch ?? '',
114
- createWorktree: true,
115
- cursorPos: state.modal.worktreeName.length,
116
- selectedIndex: 0,
117
- step: 'worktree-create',
118
- },
119
- }
120
- }
121
- case 'set-new-tab-base-branches': {
122
- if (state.modal.type !== 'new-tab') return state
123
- const modalWithBranches = { ...state.modal, baseBranches: action.branches }
124
- const withBranches: AppState = { ...state, modal: modalWithBranches }
125
- // Backfill a default base if none resolved yet (e.g. detached source).
126
- if (state.modal.baseRef !== '') return withBranches
127
- const firstOption = getNewTabBaseOptions(withBranches)[0]
128
- return {
129
- ...withBranches,
130
- modal: { ...modalWithBranches, baseRef: firstOption?.ref ?? '' },
131
- }
132
- }
133
- case 'enter-new-tab-template-pick': {
134
- if (state.modal.type !== 'new-tab') return state
135
- return {
136
- ...state,
137
- modal: {
138
- ...state.modal,
139
- activeField: 'target-worktree',
140
- cursorPos: 0,
141
- selectedIndex: 0,
142
- step: 'template',
143
- },
144
- }
145
- }
146
- case 'enter-new-tab-template-shortcut': {
147
- if (state.modal.type !== 'new-tab') return state
148
- const defaultName = state.modal.worktreeName || 'wt-template'
149
- return {
150
- ...state,
151
- modal: {
152
- ...state.modal,
153
- activeField: 'worktree-name',
154
- createWorktree: true,
155
- cursorPos: defaultName.length,
156
- selectedAssistantId: null,
157
- selectedIndex: 0,
158
- step: 'worktree-create',
159
- worktreeName: defaultName,
160
- },
161
- }
162
- }
163
- case 'set-new-tab-worktree-delete-prompt': {
164
- if (state.modal.type !== 'new-tab') return state
165
- return {
166
- ...state,
167
- modal: {
168
- ...state.modal,
169
- worktreeDeletePrompt: action.prompt,
170
- },
171
- }
172
- }
173
- case 'set-new-tab-branch-error': {
174
- if (state.modal.type !== 'new-tab') return state
175
- return {
176
- ...state,
177
- modal: {
178
- ...state.modal,
179
- activeField: 'branch-name',
180
- branchError: action.message,
181
- cursorPos: state.modal.branchName.length,
182
- },
183
- }
184
- }
185
- case 'select-new-tab-assistant': {
186
- if (state.modal.type !== 'new-tab' || state.modal.editingCommand !== null) return state
187
- if (
188
- action.assistantId === undefined &&
189
- state.modal.step === 'assistant' &&
190
- state.worktreeTemplates.length > 0
191
- ) {
192
- const filtered = filterAssistants(
193
- getAllAssistantOptions(state.customCommands),
194
- state.modal.editBuffer
195
- )
196
- if (state.modal.selectedIndex >= filtered.length) {
197
- const defaultName = state.modal.worktreeName || 'wt-template'
198
- return {
199
- ...state,
200
- modal: {
201
- ...state.modal,
202
- activeField: 'worktree-name',
203
- createWorktree: true,
204
- cursorPos: defaultName.length,
205
- selectedAssistantId: null,
206
- selectedIndex: 0,
207
- step: 'worktree-create',
208
- worktreeName: defaultName,
209
- },
210
- }
211
- }
212
- }
213
- const option = getSelectedNewTabAssistant(state, action.assistantId)
214
- if (!option) return state
215
- const targetWorktreeIndex = getCurrentWorktreeIndex(state)
216
- const worktreeCount = getCurrentWorktreeCount(state)
217
- return {
218
- ...state,
219
- modal: {
220
- ...state.modal,
221
- activeField: 'target-worktree',
222
- branchError: null,
223
- createWorktree: worktreeCount === 0,
224
- cursorPos: 0,
225
- selectedAssistantId: option.id,
226
- selectedIndex: worktreeCount === 0 ? 0 : targetWorktreeIndex,
227
- step: 'worktree',
228
- targetWorktreeIndex,
229
- worktreeDeletePrompt: null,
230
- worktreeName: state.modal.worktreeName || `wt-${option.label}`,
231
- },
232
- }
233
- }
234
- case 'toggle-new-tab-worktree': {
235
- if (state.modal.type !== 'new-tab' || state.modal.editingCommand !== null) return state
236
- const createWorktree = !state.modal.createWorktree
237
- const option = getSelectedNewTabAssistant(state, action.assistantId)
238
- const defaultName = state.modal.worktreeName || `wt-${option?.label ?? 'assistant'}`
239
- const worktreeCount = getCurrentWorktreeCount(state)
240
- const targetWorktreeIndex = getCurrentWorktreeIndex(state)
241
- let activeField = state.modal.activeField
242
- if (createWorktree) {
243
- activeField = state.modal.step === 'worktree' || option ? 'target-worktree' : activeField
244
- } else if (activeField === 'worktree-name') {
245
- activeField = 'target-worktree'
246
- }
247
- const cursorPos =
248
- createWorktree && worktreeCount <= 0
249
- ? defaultName.length
250
- : (state.modal.editBuffer?.length ?? 0)
251
- return {
252
- ...state,
253
- modal: {
254
- ...state.modal,
255
- activeField,
256
- createWorktree,
257
- cursorPos,
258
- selectedAssistantId: option?.id ?? state.modal.selectedAssistantId,
259
- selectedIndex: createWorktree
260
- ? worktreeCount
261
- : Math.min(state.modal.selectedIndex, Math.max(0, worktreeCount - 1)),
262
- step: option && createWorktree ? 'worktree' : state.modal.step,
263
- targetWorktreeIndex,
264
- worktreeDeletePrompt: null,
265
- worktreeName: defaultName,
266
128
  },
267
129
  }
268
130
  }
@@ -287,13 +149,13 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
287
149
  modal: {
288
150
  cursorPos: 0,
289
151
  editBuffer: '',
152
+ projectTargetId: null,
290
153
  selectedIndex: 0,
291
- sessionTargetId: null,
292
154
  type: 'ai-usage',
293
155
  },
294
156
  }
295
157
  }
296
- case 'open-worktree-move-modal': {
158
+ case 'open-workspace-move-modal': {
297
159
  // Overlay: keep focusMode (git when opened via `m`, navigation when opened
298
160
  // from a tab menu) so the view underneath stays mounted, like the help
299
161
  // modal. deriveModeId routes input to the picker while open.
@@ -302,29 +164,29 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
302
164
  modal: {
303
165
  deleteSource: false,
304
166
  editBuffer: null,
167
+ projectTargetId: null,
305
168
  selectedIndex: 0,
306
- sessionTargetId: null,
307
- sourceWorktreeId: action.sourceWorktreeId,
169
+ sourceWorkspaceId: action.sourceWorkspaceId,
308
170
  stats: { kind: 'loading' },
309
- type: 'worktree-move',
171
+ type: 'workspace-move',
310
172
  },
311
173
  }
312
174
  }
313
- case 'toggle-worktree-move-delete': {
314
- if (state.modal.type !== 'worktree-move') return state
175
+ case 'toggle-workspace-move-delete': {
176
+ if (state.modal.type !== 'workspace-move') return state
315
177
  return {
316
178
  ...state,
317
179
  modal: { ...state.modal, deleteSource: !state.modal.deleteSource },
318
180
  }
319
181
  }
320
- case 'set-worktree-move-stats': {
321
- if (state.modal.type !== 'worktree-move') return state
182
+ case 'set-workspace-move-stats': {
183
+ if (state.modal.type !== 'workspace-move') return state
322
184
  return {
323
185
  ...state,
324
186
  modal: { ...state.modal, stats: { dirtyFiles: action.dirtyFiles, kind: 'ready' } },
325
187
  }
326
188
  }
327
- case 'open-worktree-move-confirm': {
189
+ case 'open-workspace-move-confirm': {
328
190
  return {
329
191
  ...state,
330
192
  focusMode: 'modal',
@@ -332,19 +194,19 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
332
194
  deleteSource: action.deleteSource,
333
195
  editBuffer: null,
334
196
  files: action.files,
197
+ projectId: action.projectId,
198
+ projectTargetId: null,
335
199
  selectedIndex: 0,
336
- sessionId: action.sessionId,
337
- sessionTargetId: null,
338
200
  sourceLabel: action.sourceLabel,
339
- sourceWorktreeId: action.sourceWorktreeId,
201
+ sourceWorkspaceId: action.sourceWorkspaceId,
340
202
  targetLabel: action.targetLabel,
341
- targetWorktreeId: action.targetWorktreeId,
342
- type: 'worktree-move-confirm',
203
+ targetWorkspaceId: action.targetWorkspaceId,
204
+ type: 'workspace-move-confirm',
343
205
  variant: action.variant,
344
206
  },
345
207
  }
346
208
  }
347
- case 'open-worktree-delete-confirm': {
209
+ case 'open-workspace-delete-confirm': {
348
210
  return {
349
211
  ...state,
350
212
  focusMode: 'modal',
@@ -352,13 +214,13 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
352
214
  closeTabs: action.closeTabs,
353
215
  editBuffer: null,
354
216
  force: action.force,
217
+ projectId: action.projectId,
218
+ projectTargetId: null,
355
219
  reason: action.reason,
356
220
  selectedIndex: 0,
357
- sessionId: action.sessionId,
358
- sessionTargetId: null,
359
- type: 'worktree-delete-confirm',
360
- worktreeId: action.worktreeId,
361
- worktreeLabel: action.worktreeLabel,
221
+ type: 'workspace-delete-confirm',
222
+ workspaceId: action.workspaceId,
223
+ workspaceLabel: action.workspaceLabel,
362
224
  },
363
225
  }
364
226
  }
@@ -375,8 +237,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
375
237
  editBuffer: '',
376
238
  labels,
377
239
  pendingJump: null,
240
+ projectTargetId: null,
378
241
  selectedIndex: 0,
379
- sessionTargetId: null,
380
242
  type: 'flash-jump',
381
243
  },
382
244
  }
@@ -397,9 +259,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
397
259
  cursorPos: 0,
398
260
  editBuffer: '',
399
261
  entryCount: scopedEntries.length,
262
+ projectTargetId: null,
400
263
  scope,
401
264
  selectedIndex: 0,
402
- sessionTargetId: null,
403
265
  type: 'help',
404
266
  },
405
267
  }
@@ -411,25 +273,25 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
411
273
  modal: {
412
274
  cursorPos: 0,
413
275
  editBuffer: '',
276
+ projectTargetId: null,
414
277
  selectedIndex: 0,
415
- sessionTargetId: null,
416
278
  splitDirection: action.direction,
417
279
  type: 'split-picker',
418
280
  },
419
281
  }
420
- case 'open-session-picker':
282
+ case 'open-project-picker':
421
283
  return {
422
284
  ...state,
423
285
  focusMode: 'command-edit',
424
286
  modal: {
425
287
  cursorPos: 0,
426
288
  editBuffer: '',
289
+ projectTargetId: null,
427
290
  selectedIndex: 0,
428
- sessionTargetId: null,
429
- type: 'session-picker',
291
+ type: 'project-picker',
430
292
  },
431
293
  }
432
- case 'open-session-name-modal': {
294
+ case 'open-project-name-modal': {
433
295
  const initialName = action.initialName ?? ''
434
296
  return {
435
297
  ...state,
@@ -437,14 +299,14 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
437
299
  modal: {
438
300
  cursorPos: initialName.length,
439
301
  editBuffer: initialName,
440
- returnToSessionPicker: action.returnToSessionPicker ?? true,
302
+ projectTargetId: action.projectTargetId ?? null,
303
+ returnToProjectPicker: action.returnToProjectPicker ?? true,
441
304
  selectedIndex: 0,
442
- sessionTargetId: action.sessionTargetId ?? null,
443
- type: 'session-name',
305
+ type: 'project-name',
444
306
  },
445
307
  }
446
308
  }
447
- case 'open-create-session-modal':
309
+ case 'open-create-project-modal':
448
310
  return {
449
311
  ...state,
450
312
  focusMode: 'command-edit',
@@ -455,14 +317,14 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
455
317
  editBuffer: '',
456
318
  nameBuffer: '',
457
319
  pendingProjectPath: null,
458
- returnToSessionPicker: action.returnToSessionPicker,
320
+ projectTargetId: null,
321
+ returnToProjectPicker: action.returnToProjectPicker,
459
322
  selectedIndex: 0,
460
- sessionTargetId: null,
461
- type: 'create-session',
323
+ type: 'create-project',
462
324
  },
463
325
  }
464
326
  case 'set-directory-results': {
465
- if (state.modal.type !== 'create-session') {
327
+ if (state.modal.type !== 'create-project') {
466
328
  return state
467
329
  }
468
330
  return {
@@ -474,6 +336,88 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
474
336
  },
475
337
  }
476
338
  }
339
+ case 'open-create-workspace-modal':
340
+ return {
341
+ ...state,
342
+ focusMode: 'command-edit',
343
+ modal: {
344
+ activeField: 'prompt',
345
+ baseBranches: [],
346
+ baseQuery: '',
347
+ // Left empty on purpose: `load-create-workspace-base-branches`
348
+ // resolves the repo's default branch and backfills it below. Seeding
349
+ // the active workspace's branch here would win over that.
350
+ baseRef: '',
351
+ branchError: null,
352
+ cursorPos: 0,
353
+ editBuffer: '',
354
+ projectTargetId: null,
355
+ prompt: '',
356
+ selectedIndex: 0,
357
+ type: 'create-workspace',
358
+ },
359
+ }
360
+ case 'switch-create-workspace-field': {
361
+ if (state.modal.type !== 'create-workspace') return state
362
+ const next =
363
+ CREATE_WORKSPACE_FIELDS[
364
+ (CREATE_WORKSPACE_FIELDS.indexOf(state.modal.activeField) + 1) %
365
+ CREATE_WORKSPACE_FIELDS.length
366
+ ] ?? 'prompt'
367
+ const buffer = getCreateWorkspaceFieldValue(state.modal, next)
368
+ // Entering the base field highlights the row matching the resolved ref,
369
+ // so the picker opens on what the form already says it will fork from.
370
+ const baseIndex =
371
+ next === 'base'
372
+ ? Math.max(
373
+ 0,
374
+ getCreateWorkspaceBaseOptions(state).findIndex(
375
+ (option) => option.ref === (state.modal as { baseRef: string }).baseRef
376
+ )
377
+ )
378
+ : 0
379
+ return {
380
+ ...state,
381
+ modal: {
382
+ ...state.modal,
383
+ activeField: next,
384
+ cursorPos: buffer.length,
385
+ selectedIndex: baseIndex,
386
+ },
387
+ }
388
+ }
389
+ case 'set-create-workspace-base-branches': {
390
+ if (state.modal.type !== 'create-workspace') return state
391
+ const modalWithBranches = { ...state.modal, baseBranches: action.branches }
392
+ const withBranches: AppState = { ...state, modal: modalWithBranches }
393
+ // The user may already have typed a base while this was in flight.
394
+ if (state.modal.baseRef !== '') return withBranches
395
+ // Fork from the repo's default branch, falling back to the most recently
396
+ // committed one when the repo has neither an origin/HEAD nor a main.
397
+ const fallback = getCreateWorkspaceBaseOptions(withBranches)[0]?.ref ?? ''
398
+ const defaultBranch =
399
+ action.defaultBranch != null && action.branches.includes(action.defaultBranch)
400
+ ? action.defaultBranch
401
+ : fallback
402
+ return {
403
+ ...withBranches,
404
+ modal: { ...modalWithBranches, baseRef: defaultBranch },
405
+ }
406
+ }
407
+ case 'set-create-workspace-branch-error': {
408
+ if (state.modal.type !== 'create-workspace') return state
409
+ return {
410
+ ...state,
411
+ modal: {
412
+ ...state.modal,
413
+ // The branch is generated now, so there is no branch field to focus:
414
+ // send the user back to the prompt, which is what produced the name.
415
+ activeField: 'prompt',
416
+ branchError: action.message,
417
+ cursorPos: state.modal.prompt.length,
418
+ },
419
+ }
420
+ }
477
421
  case 'open-snippet-picker':
478
422
  return {
479
423
  ...state,
@@ -482,8 +426,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
482
426
  actionMessage: null,
483
427
  cursorPos: 0,
484
428
  editBuffer: '',
429
+ projectTargetId: null,
430
+ returnTo: action.returnTo,
485
431
  selectedIndex: 0,
486
- sessionTargetId: null,
487
432
  type: 'snippet-picker',
488
433
  },
489
434
  }
@@ -507,8 +452,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
507
452
  cursorPos: initialName.length,
508
453
  editBuffer: initialName,
509
454
  nameBuffer: initialName,
455
+ projectTargetId: snippet?.id ?? null,
510
456
  selectedIndex: 0,
511
- sessionTargetId: snippet?.id ?? null,
512
457
  triggerBuffer: snippet?.trigger ?? '',
513
458
  type: 'snippet-editor',
514
459
  },
@@ -522,8 +467,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
522
467
  cursorPos: 0,
523
468
  editBuffer: '',
524
469
  entryCount: 0,
470
+ projectTargetId: null,
471
+ returnTo: action.returnTo,
525
472
  selectedIndex: 0,
526
- sessionTargetId: null,
527
473
  type: 'theme-picker',
528
474
  },
529
475
  }
@@ -536,128 +482,11 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
536
482
  cursorPos: 0,
537
483
  editBuffer: null,
538
484
  latestVersion: action.latestVersion,
485
+ projectTargetId: null,
539
486
  selectedIndex: 0,
540
- sessionTargetId: null,
541
487
  type: 'update-available',
542
488
  },
543
489
  }
544
- case 'open-git-commit-modal': {
545
- const sessionId = action.sessionId
546
- return {
547
- ...state,
548
- focusMode: 'command-edit',
549
- modal: {
550
- activeField: 'title',
551
- contentBuffer: '',
552
- cursorPos: 0,
553
- editBuffer: '',
554
- selectedIndex: 0,
555
- sessionTargetId: sessionId ?? null,
556
- stage: 'edit',
557
- type: 'git-commit',
558
- },
559
- }
560
- }
561
- case 'git-commit-enter-confirm': {
562
- if (state.modal.type !== 'git-commit') return state
563
- return {
564
- ...state,
565
- focusMode: 'command-edit',
566
- modal: { ...state.modal, stage: 'confirm' },
567
- }
568
- }
569
- case 'git-commit-leave-confirm': {
570
- if (state.modal.type !== 'git-commit') return state
571
- return {
572
- ...state,
573
- focusMode: 'command-edit',
574
- modal: { ...state.modal, stage: 'edit' },
575
- }
576
- }
577
- case 'git-commit-enter-generating': {
578
- if (state.modal.type !== 'git-commit') return state
579
- return {
580
- ...state,
581
- focusMode: 'modal',
582
- modal: { ...state.modal, sessionTargetId: action.sessionId, stage: 'generating' },
583
- }
584
- }
585
- case 'git-commit-leave-generating': {
586
- if (state.modal.type !== 'git-commit') return state
587
- return {
588
- ...state,
589
- focusMode: 'command-edit',
590
- modal: { ...state.modal, stage: 'edit' },
591
- }
592
- }
593
- case 'auto-commit-generation-ready': {
594
- if (
595
- state.modal.type !== 'git-commit' ||
596
- state.modal.stage !== 'generating' ||
597
- state.modal.sessionTargetId !== action.sessionId
598
- ) {
599
- return null
600
- }
601
- const nextAutoCommit = reduceAutoCommitState(state.autoCommit, action)
602
- if (!nextAutoCommit) {
603
- // Stale result (hash mismatch or slice was cleared mid-flight): don't
604
- // strand the modal in `generating` — flip back to edit so the user
605
- // isn't stuck staring at a spinner that will never resolve.
606
- return {
607
- ...state,
608
- focusMode: 'command-edit',
609
- modal: { ...state.modal, stage: 'edit' },
610
- }
611
- }
612
- return {
613
- ...state,
614
- autoCommit: nextAutoCommit,
615
- focusMode: 'command-edit',
616
- modal: {
617
- ...state.modal,
618
- activeField: 'title',
619
- contentBuffer: action.body,
620
- cursorPos: action.title.length,
621
- editBuffer: action.title,
622
- stage: 'confirm',
623
- },
624
- }
625
- }
626
- case 'git-commit-use-background-suggestion': {
627
- if (state.modal.type !== 'git-commit' || state.modal.sessionTargetId !== action.sessionId) {
628
- return null
629
- }
630
- const suggestion = state.autoCommit.bySession[action.sessionId]
631
- if (!suggestion || suggestion.kind !== 'ready') return null
632
- return {
633
- ...state,
634
- focusMode: 'command-edit',
635
- modal: {
636
- ...state.modal,
637
- activeField: 'title',
638
- contentBuffer: suggestion.body,
639
- cursorPos: suggestion.title.length,
640
- editBuffer: suggestion.title,
641
- stage: 'confirm',
642
- },
643
- }
644
- }
645
- case 'auto-commit-clear': {
646
- if (
647
- state.modal.type !== 'git-commit' ||
648
- state.modal.stage !== 'generating' ||
649
- state.modal.sessionTargetId !== action.sessionId
650
- ) {
651
- return null
652
- }
653
- const nextAutoCommit = reduceAutoCommitState(state.autoCommit, action)
654
- return {
655
- ...state,
656
- autoCommit: nextAutoCommit ?? state.autoCommit,
657
- focusMode: 'command-edit',
658
- modal: { ...state.modal, stage: 'edit' },
659
- }
660
- }
661
490
  case 'set-help-entry-count': {
662
491
  if (state.modal.type !== 'help') return state
663
492
  if (state.modal.entryCount === action.count) {
@@ -689,13 +518,14 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
689
518
  // so leave it alone — closing returns to whatever was underneath.
690
519
  if (
691
520
  closingType === 'help' ||
692
- closingType === 'worktree-move' ||
521
+ closingType === 'workspace-move' ||
693
522
  closingType === 'flash-jump'
694
523
  ) {
695
524
  return { ...state, modal: emptyModal() }
696
525
  }
697
- const nextFocus: AppState['focusMode'] = closingType === 'git-commit' ? 'git' : 'navigation'
698
- return { ...state, focusMode: nextFocus, modal: emptyModal() }
526
+ // Whoever opened it said where to go back to, because that is who knows
527
+ // what is drawn behind it. Everything else came from the panes.
528
+ return { ...state, focusMode: state.modal.returnTo ?? 'navigation', modal: emptyModal() }
699
529
  }
700
530
  case 'move-modal-selection': {
701
531
  if (state.modal.type === 'help') {
@@ -708,22 +538,23 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
708
538
  }
709
539
  if (
710
540
  state.modal.type !== 'new-tab' &&
711
- state.modal.type !== 'session-picker' &&
541
+ state.modal.type !== 'project-picker' &&
712
542
  state.modal.type !== 'snippet-picker' &&
713
543
  state.modal.type !== 'theme-picker' &&
714
- state.modal.type !== 'create-session' &&
544
+ state.modal.type !== 'create-project' &&
545
+ state.modal.type !== 'create-workspace' &&
715
546
  state.modal.type !== 'split-picker' &&
716
547
  state.modal.type !== 'update-available' &&
717
- state.modal.type !== 'worktree-move'
548
+ state.modal.type !== 'workspace-move'
718
549
  ) {
719
550
  return state
720
551
  }
721
- if (state.modal.type === 'create-session' && state.modal.activeField !== 'directory') {
552
+ if (state.modal.type === 'create-project' && state.modal.activeField !== 'directory') {
722
553
  return state
723
554
  }
724
- if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
555
+ if (state.modal.type === 'create-workspace') {
725
556
  if (state.modal.activeField !== 'base') return state
726
- const options = getNewTabBaseOptions(state)
557
+ const options = getCreateWorkspaceBaseOptions(state)
727
558
  if (options.length === 0) return state
728
559
  const next = (state.modal.selectedIndex + action.delta + options.length) % options.length
729
560
  return {
@@ -735,36 +566,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
735
566
  },
736
567
  }
737
568
  }
738
- let optionCount: number
739
- if (state.modal.type === 'new-tab') {
740
- if (state.modal.step === 'worktree') {
741
- if (state.modal.activeField === 'worktree-name') return state
742
- optionCount = getCurrentWorktreeCount(state) + 1
743
- } else if (state.modal.step === 'template') {
744
- optionCount =
745
- state.worktreeTemplates.length + getTemplateNoneOffset(state.modal.selectedAssistantId)
746
- } else {
747
- optionCount =
748
- filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)
749
- .length + (state.worktreeTemplates.length > 0 ? 1 : 0)
750
- }
751
- } else if (state.modal.type === 'split-picker') {
752
- optionCount = getAllAssistantOptions(state.customCommands).length
753
- } else if (state.modal.type === 'create-session') {
754
- optionCount = state.modal.directoryResults.length
755
- } else if (state.modal.type === 'snippet-picker') {
756
- const filtered = filterSnippets(state.snippets, state.modal.editBuffer)
757
- optionCount = filtered.length
758
- } else if (state.modal.type === 'theme-picker') {
759
- optionCount = filterThemeIds(state.modal.editBuffer).length
760
- } else if (state.modal.type === 'update-available') {
761
- optionCount = 2
762
- } else if (state.modal.type === 'worktree-move') {
763
- optionCount = Math.max(0, getCurrentWorktreeCount(state) - 1)
764
- } else {
765
- const filtered = filterSessions(state.sessions, state.modal.editBuffer)
766
- optionCount = Math.max(1, filtered.length + 1)
767
- }
569
+ const optionCount = getModalOptionCount(state)
768
570
  if (optionCount === 0) {
769
571
  return state
770
572
  }
@@ -773,34 +575,17 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
773
575
  modal: {
774
576
  ...state.modal,
775
577
  selectedIndex: (state.modal.selectedIndex + action.delta + optionCount) % optionCount,
776
- ...(state.modal.type === 'new-tab' && state.modal.step === 'worktree'
777
- ? {
778
- createWorktree:
779
- (state.modal.selectedIndex + action.delta + optionCount) % optionCount ===
780
- optionCount - 1,
781
- worktreeDeletePrompt: null,
782
- }
783
- : null),
784
578
  },
785
579
  }
786
580
  }
787
581
  case 'set-modal-selection-index': {
788
- if (
789
- state.modal.type !== 'new-tab' &&
790
- state.modal.type !== 'session-picker' &&
791
- state.modal.type !== 'snippet-picker' &&
792
- state.modal.type !== 'theme-picker' &&
793
- state.modal.type !== 'create-session' &&
794
- state.modal.type !== 'split-picker' &&
795
- state.modal.type !== 'update-available' &&
796
- state.modal.type !== 'worktree-move' &&
797
- state.modal.type !== 'help'
798
- ) {
799
- return state
800
- }
801
- if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
582
+ // Which modals have an indexed list is `getModalOptionCount`'s question,
583
+ // and it answers 0 for the ones that don't. It used to be asked twice —
584
+ // here as a list of types to keep in step by hand, which the settings
585
+ // search promptly wasn't on, leaving its rows unhoverable.
586
+ if (state.modal.type === 'create-workspace') {
802
587
  if (state.modal.activeField !== 'base') return state
803
- const options = getNewTabBaseOptions(state)
588
+ const options = getCreateWorkspaceBaseOptions(state)
804
589
  if (options.length === 0) return state
805
590
  const clamped = Math.max(0, Math.min(options.length - 1, action.index))
806
591
  if (clamped === state.modal.selectedIndex) return state
@@ -813,71 +598,15 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
813
598
  },
814
599
  }
815
600
  }
816
- let optionCount: number
817
- if (state.modal.type === 'help') {
818
- optionCount = state.modal.entryCount
819
- } else if (state.modal.type === 'new-tab') {
820
- if (state.modal.step === 'worktree') {
821
- optionCount = getCurrentWorktreeCount(state) + 1
822
- } else if (state.modal.step === 'template') {
823
- optionCount =
824
- state.worktreeTemplates.length + getTemplateNoneOffset(state.modal.selectedAssistantId)
825
- } else {
826
- optionCount =
827
- filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)
828
- .length + (state.worktreeTemplates.length > 0 ? 1 : 0)
829
- }
830
- } else if (state.modal.type === 'split-picker') {
831
- optionCount = getAllAssistantOptions(state.customCommands).length
832
- } else if (state.modal.type === 'create-session') {
833
- optionCount = state.modal.directoryResults.length
834
- } else if (state.modal.type === 'snippet-picker') {
835
- optionCount = filterSnippets(state.snippets, state.modal.editBuffer).length
836
- } else if (state.modal.type === 'theme-picker') {
837
- optionCount = filterThemeIds(state.modal.editBuffer).length
838
- } else if (state.modal.type === 'update-available') {
839
- optionCount = 2
840
- } else if (state.modal.type === 'worktree-move') {
841
- optionCount = Math.max(0, getCurrentWorktreeCount(state) - 1)
842
- } else {
843
- optionCount = Math.max(1, filterSessions(state.sessions, state.modal.editBuffer).length + 1)
844
- }
601
+ const optionCount = getModalOptionCount(state)
845
602
  if (optionCount === 0) return state
846
603
  const clamped = Math.max(0, Math.min(optionCount - 1, action.index))
847
- if (state.modal.type === 'new-tab' && state.modal.step === 'worktree') {
848
- if (clamped === state.modal.selectedIndex) return state
849
- return {
850
- ...state,
851
- modal: {
852
- ...state.modal,
853
- createWorktree: clamped === optionCount - 1,
854
- selectedIndex: clamped,
855
- worktreeDeletePrompt: null,
856
- },
857
- }
858
- }
859
604
  if (clamped === state.modal.selectedIndex) return state
860
605
  return { ...state, modal: { ...state.modal, selectedIndex: clamped } }
861
606
  }
862
607
  case 'move-modal-cursor': {
863
608
  if (state.modal.editBuffer === null) return state
864
- let editableValue = state.modal.editBuffer
865
- if (state.modal.type === 'new-tab' && state.modal.editingCommand === null) {
866
- if (state.modal.activeField === 'worktree-name') {
867
- editableValue = state.modal.worktreeName
868
- } else if (state.modal.activeField === 'branch-name') {
869
- editableValue = state.modal.branchName
870
- }
871
- }
872
- if (
873
- state.modal.type === 'new-tab' &&
874
- state.modal.editingCommand === null &&
875
- (state.modal.step === 'template' ||
876
- (state.modal.step === 'worktree' && state.modal.activeField === 'target-worktree'))
877
- ) {
878
- return state
879
- }
880
- const len = editableValue.length
609
+ const len = state.modal.editBuffer.length
881
610
  const current = state.modal.cursorPos ?? len
882
611
  let next = current
883
612
  if (action.to === 'home') next = 0
@@ -924,27 +653,43 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
924
653
  }
925
654
  return { ...state, modal: { ...state.modal, buffer: nextBuffer, pendingJump: null } }
926
655
  }
927
- if (state.modal.editBuffer === null) {
928
- return state
929
- }
930
- let buffer = state.modal.editBuffer
931
- if (state.modal.type === 'new-tab' && state.modal.editingCommand === null) {
932
- if (state.modal.activeField === 'worktree-name') {
933
- buffer = state.modal.worktreeName
934
- } else if (state.modal.activeField === 'branch-name') {
935
- buffer = state.modal.branchName
936
- } else if (state.modal.activeField === 'base') {
937
- buffer = state.modal.baseQuery
656
+ if (state.modal.type === 'create-workspace') {
657
+ const field = state.modal.activeField
658
+ const current = getCreateWorkspaceFieldValue(state.modal, field)
659
+ const at = clampCursor(state.modal.cursorPos ?? current.length, current.length)
660
+ let text: string
661
+ let pos: number
662
+ if (action.char === '\b') {
663
+ if (at === 0) return state
664
+ text = current.slice(0, at - 1) + current.slice(at)
665
+ pos = at - 1
666
+ } else {
667
+ text = current.slice(0, at) + action.char + current.slice(at)
668
+ pos = at + action.char.length
669
+ }
670
+ if (field === 'prompt') {
671
+ return {
672
+ ...state,
673
+ modal: { ...state.modal, branchError: null, cursorPos: pos, prompt: text },
674
+ }
675
+ }
676
+ // Re-filter and snap the base to the top match as the query changes.
677
+ const topOption = getCreateWorkspaceBaseOptions(state, text)[0]
678
+ return {
679
+ ...state,
680
+ modal: {
681
+ ...state.modal,
682
+ baseQuery: text,
683
+ baseRef: topOption?.ref ?? state.modal.baseRef,
684
+ cursorPos: pos,
685
+ selectedIndex: 0,
686
+ },
938
687
  }
939
688
  }
940
- if (
941
- state.modal.type === 'new-tab' &&
942
- state.modal.editingCommand === null &&
943
- (state.modal.step === 'template' ||
944
- (state.modal.step === 'worktree' && state.modal.activeField === 'target-worktree'))
945
- ) {
689
+ if (state.modal.editBuffer === null) {
946
690
  return state
947
691
  }
692
+ const buffer = state.modal.editBuffer
948
693
  const cursor = clampCursor(state.modal.cursorPos ?? buffer.length, buffer.length)
949
694
  let nextBuffer: string
950
695
  let nextCursor: number
@@ -957,56 +702,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
957
702
  nextCursor = cursor + action.char.length
958
703
  }
959
704
  const isNewTabEditing = state.modal.type === 'new-tab' && state.modal.editingCommand !== null
960
- if (
961
- state.modal.type === 'new-tab' &&
962
- state.modal.editingCommand === null &&
963
- state.modal.activeField === 'worktree-name'
964
- ) {
965
- return {
966
- ...state,
967
- modal: {
968
- ...state.modal,
969
- cursorPos: nextCursor,
970
- worktreeName: nextBuffer,
971
- },
972
- }
973
- }
974
- if (
975
- state.modal.type === 'new-tab' &&
976
- state.modal.editingCommand === null &&
977
- state.modal.activeField === 'branch-name'
978
- ) {
979
- return {
980
- ...state,
981
- modal: {
982
- ...state.modal,
983
- branchError: null,
984
- branchName: nextBuffer,
985
- cursorPos: nextCursor,
986
- },
987
- }
988
- }
989
- if (
990
- state.modal.type === 'new-tab' &&
991
- state.modal.editingCommand === null &&
992
- state.modal.activeField === 'base'
993
- ) {
994
- // Re-filter and snap the selection/base to the top match as the query changes.
995
- const topOption = getNewTabBaseOptions(state, nextBuffer)[0]
996
- return {
997
- ...state,
998
- modal: {
999
- ...state.modal,
1000
- baseQuery: nextBuffer,
1001
- baseRef: topOption?.ref ?? state.modal.baseRef,
1002
- cursorPos: nextCursor,
1003
- selectedIndex: 0,
1004
- },
1005
- }
1006
- }
1007
705
  const resetIndex =
1008
706
  !isNewTabEditing &&
1009
- (state.modal.type === 'session-picker' ||
707
+ (state.modal.type === 'project-picker' ||
1010
708
  state.modal.type === 'snippet-picker' ||
1011
709
  state.modal.type === 'theme-picker' ||
1012
710
  state.modal.type === 'new-tab' ||
@@ -1035,67 +733,12 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
1035
733
  },
1036
734
  }
1037
735
  }
1038
- if (state.modal.type === 'new-tab') {
1039
- if (state.modal.step === 'template') {
1040
- return {
1041
- ...state,
1042
- modal: {
1043
- ...state.modal,
1044
- activeField: 'worktree-name',
1045
- cursorPos: state.modal.worktreeName.length,
1046
- selectedIndex: 0,
1047
- step: 'worktree-create',
1048
- },
1049
- }
1050
- }
1051
- if (state.modal.step === 'worktree-create') {
1052
- const optionCount = getCurrentWorktreeCount(state) + 1
1053
- return {
1054
- ...state,
1055
- modal: {
1056
- ...state.modal,
1057
- activeField: 'target-worktree',
1058
- branchError: null,
1059
- createWorktree: false,
1060
- cursorPos: 0,
1061
- selectedIndex: Math.min(
1062
- state.modal.targetWorktreeIndex,
1063
- Math.max(0, optionCount - 1)
1064
- ),
1065
- step: 'worktree',
1066
- },
1067
- }
1068
- }
1069
- if (state.modal.step === 'worktree') {
1070
- const selectedAssistantId = state.modal.selectedAssistantId
1071
- const assistants = filterAssistants(
1072
- getAllAssistantOptions(state.customCommands),
1073
- state.modal.editBuffer
1074
- )
1075
- const assistantIndex = assistants.findIndex(
1076
- (assistant) => assistant.id === selectedAssistantId
1077
- )
1078
- return {
1079
- ...state,
1080
- modal: {
1081
- ...state.modal,
1082
- activeField: 'assistant',
1083
- createWorktree: false,
1084
- cursorPos: state.modal.editBuffer?.length ?? 0,
1085
- selectedIndex: Math.max(0, assistantIndex),
1086
- step: 'assistant',
1087
- worktreeDeletePrompt: null,
1088
- },
1089
- }
1090
- }
1091
- return { ...state, focusMode: 'navigation', modal: emptyModal() }
1092
- }
1093
- if (state.modal.type === 'create-session' || state.modal.type === 'snippet-editor') {
736
+ if (state.modal.type === 'create-project' || state.modal.type === 'snippet-editor') {
1094
737
  return { ...state, focusMode: 'navigation', modal: emptyModal() }
1095
738
  }
1096
739
  // Pickers and overlays with auto-filter — Esc closes the modal entirely.
1097
740
  if (
1098
- state.modal.type === 'session-picker' ||
741
+ state.modal.type === 'project-picker' ||
1099
742
  state.modal.type === 'snippet-picker' ||
1100
743
  state.modal.type === 'theme-picker' ||
1101
744
  state.modal.type === 'help'
@@ -1108,8 +751,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
1108
751
  modal: { ...state.modal, cursorPos: 0, editBuffer: null },
1109
752
  }
1110
753
  }
1111
- case 'switch-create-session-field': {
1112
- if (state.modal.type === 'create-session') {
754
+ case 'switch-create-project-field': {
755
+ if (state.modal.type === 'create-project') {
1113
756
  const nextField = state.modal.activeField === 'directory' ? 'name' : 'directory'
1114
757
  const nextEdit = state.modal.nameBuffer
1115
758
  return {
@@ -1170,72 +813,10 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
1170
813
  },
1171
814
  }
1172
815
  }
1173
- if (state.modal.type === 'new-tab') {
1174
- if (state.modal.step === 'assistant') return state
1175
- if (state.modal.step === 'worktree-create') {
1176
- const cycle: Record<
1177
- 'worktree-name' | 'branch-name' | 'base',
1178
- typeof state.modal.activeField
1179
- > = {
1180
- 'base': 'worktree-name',
1181
- 'branch-name': 'base',
1182
- 'worktree-name': 'branch-name',
1183
- }
1184
- const nextField =
1185
- state.modal.activeField === 'worktree-name' ||
1186
- state.modal.activeField === 'branch-name' ||
1187
- state.modal.activeField === 'base'
1188
- ? cycle[state.modal.activeField]
1189
- : 'worktree-name'
1190
- if (nextField === 'base') {
1191
- // Highlight the row matching the resolved base ref when entering it.
1192
- const currentBaseRef = state.modal.baseRef
1193
- const options = getNewTabBaseOptions(state)
1194
- const baseIndex = Math.max(
1195
- 0,
1196
- options.findIndex((option) => option.ref === currentBaseRef)
1197
- )
1198
- return {
1199
- ...state,
1200
- modal: {
1201
- ...state.modal,
1202
- activeField: nextField,
1203
- cursorPos: state.modal.baseQuery.length,
1204
- selectedIndex: baseIndex,
1205
- },
1206
- }
1207
- }
1208
- const nextValue =
1209
- nextField === 'branch-name' ? state.modal.branchName : state.modal.worktreeName
1210
- return {
1211
- ...state,
1212
- modal: {
1213
- ...state.modal,
1214
- activeField: nextField,
1215
- cursorPos: nextValue.length,
1216
- selectedIndex: 0,
1217
- },
1218
- }
1219
- }
1220
- let nextField: typeof state.modal.activeField = 'target-worktree'
1221
- if (state.modal.activeField === 'target-worktree' && state.modal.createWorktree) {
1222
- nextField = 'worktree-name'
1223
- }
1224
- const nextValue =
1225
- nextField === 'worktree-name' ? state.modal.worktreeName : (state.modal.editBuffer ?? '')
1226
- return {
1227
- ...state,
1228
- modal: {
1229
- ...state.modal,
1230
- activeField: nextField,
1231
- cursorPos: nextValue.length,
1232
- },
1233
- }
1234
- }
1235
816
  return state
1236
817
  }
1237
818
  case 'select-directory': {
1238
- if (state.modal.type !== 'create-session') {
819
+ if (state.modal.type !== 'create-project') {
1239
820
  return state
1240
821
  }
1241
822
  const selected = state.modal.directoryResults[state.modal.selectedIndex]
@@ -1262,6 +843,34 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
1262
843
  },
1263
844
  }
1264
845
  }
846
+ case 'open-settings-search':
847
+ return {
848
+ ...state,
849
+ focusMode: 'command-edit',
850
+ modal: {
851
+ cursorPos: 0,
852
+ editBuffer: '',
853
+ projectTargetId: null,
854
+ returnTo: 'settings',
855
+ selectedIndex: 0,
856
+ type: 'settings-search',
857
+ },
858
+ }
859
+ case 'open-setting-text-modal':
860
+ return {
861
+ ...state,
862
+ focusMode: 'command-edit',
863
+ modal: {
864
+ cursorPos: action.value.length,
865
+ editBuffer: action.value,
866
+ projectTargetId: null,
867
+ returnTo: 'settings',
868
+ selectedIndex: 0,
869
+ settingId: action.settingId,
870
+ settingLabel: action.label,
871
+ type: 'setting-text',
872
+ },
873
+ }
1265
874
  case 'open-rename-tab-modal': {
1266
875
  const activeTab =
1267
876
  state.activeTabId != null && state.activeTabId !== ''
@@ -1276,23 +885,23 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
1276
885
  modal: {
1277
886
  cursorPos: activeTab.title.length,
1278
887
  editBuffer: activeTab.title,
888
+ projectTargetId: activeTab.id,
1279
889
  selectedIndex: 0,
1280
- sessionTargetId: activeTab.id,
1281
890
  type: 'rename-tab',
1282
891
  },
1283
892
  }
1284
893
  }
1285
- case 'open-rename-worktree-modal': {
894
+ case 'open-rename-workspace-modal': {
1286
895
  return {
1287
896
  ...state,
1288
897
  focusMode: 'command-edit',
1289
898
  modal: {
1290
899
  cursorPos: action.initialName.length,
1291
900
  editBuffer: action.initialName,
901
+ projectTargetId: action.workspaceId,
1292
902
  selectedIndex: 0,
1293
- sessionTargetId: action.worktreeId,
1294
- type: 'rename-worktree',
1295
- worktreeSessionId: action.sessionId,
903
+ type: 'rename-workspace',
904
+ workspaceProjectId: action.projectId,
1296
905
  },
1297
906
  }
1298
907
  }