@brimveyn/aimux 1.21.0 → 1.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +27 -25
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +22 -27
  4. package/src/app-runtime/backend-attach-runtime.ts +43 -36
  5. package/src/app-runtime/backend-runtime-events.ts +107 -88
  6. package/src/app-runtime/editor-actions.ts +201 -0
  7. package/src/app-runtime/git-actions.ts +197 -0
  8. package/src/app-runtime/measure-ref.ts +19 -0
  9. package/src/app-runtime/navigation-actions.ts +213 -0
  10. package/src/app-runtime/project-actions.ts +188 -0
  11. package/src/app-runtime/prompt-injection.ts +123 -0
  12. package/src/app-runtime/selection.ts +29 -0
  13. package/src/app-runtime/settings-actions.ts +156 -0
  14. package/src/app-runtime/setup-actions.ts +237 -0
  15. package/src/app-runtime/side-effect-context.ts +33 -0
  16. package/src/app-runtime/side-effects.ts +302 -1541
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/tab-actions.ts +218 -0
  19. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  20. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  21. package/src/app-runtime/use-backend-runtime.ts +12 -11
  22. package/src/app-runtime/use-directory-search.ts +5 -4
  23. package/src/app-runtime/use-mouse-handlers.ts +2 -1
  24. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  25. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  26. package/src/app-runtime/use-setup-runner.ts +96 -0
  27. package/src/app-runtime/use-terminal-resize.ts +18 -14
  28. package/src/app-runtime/workspace-actions.ts +366 -0
  29. package/src/app-runtime/workspace-activity.ts +138 -0
  30. package/src/app-runtime/workspace-naming.ts +90 -0
  31. package/src/app.tsx +93 -48
  32. package/src/assets/sounds/bell.wav +0 -0
  33. package/src/assets/sounds/ding.wav +0 -0
  34. package/src/assets/sounds/plane.wav +0 -0
  35. package/src/cli/client/daemon-client.ts +2 -2
  36. package/src/cli/client/project-resolver.ts +112 -0
  37. package/src/cli/commands/project/close.ts +32 -0
  38. package/src/cli/commands/project/create.ts +93 -0
  39. package/src/cli/commands/project/list.ts +25 -0
  40. package/src/cli/commands/project/show.ts +32 -0
  41. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  42. package/src/cli/commands/tab/await.ts +2 -2
  43. package/src/cli/commands/tab/close.ts +3 -3
  44. package/src/cli/commands/tab/create.ts +74 -73
  45. package/src/cli/commands/tab/focus.ts +3 -3
  46. package/src/cli/commands/tab/list.ts +6 -6
  47. package/src/cli/commands/tab/run.ts +2 -2
  48. package/src/cli/commands/tab/send.ts +2 -2
  49. package/src/cli/commands/tab/snapshot.ts +2 -2
  50. package/src/cli/commands/tab/tail.ts +2 -2
  51. package/src/cli/commands/tab/wait.ts +2 -2
  52. package/src/cli/commands/worker/await.ts +3 -3
  53. package/src/cli/commands/worker/doctor.ts +30 -30
  54. package/src/cli/commands/worker/list.ts +15 -15
  55. package/src/cli/commands/worker/prompt.ts +3 -3
  56. package/src/cli/commands/worker/run.ts +23 -23
  57. package/src/cli/commands/worker/shared.ts +53 -59
  58. package/src/cli/commands/worker/stop.ts +29 -29
  59. package/src/cli/commands/worker/submit.ts +3 -3
  60. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  61. package/src/cli/commands/workspace/create.ts +30 -68
  62. package/src/cli/commands/workspace/list.ts +52 -9
  63. package/src/cli/commands/workspace/remove.ts +81 -0
  64. package/src/cli/completion/plan.ts +2 -2
  65. package/src/cli/completion/sources.ts +18 -18
  66. package/src/cli/context.ts +13 -13
  67. package/src/cli/flags.ts +30 -10
  68. package/src/cli/index.ts +26 -16
  69. package/src/cli/output.ts +1 -1
  70. package/src/cli/registry.ts +12 -12
  71. package/src/config/loader.ts +16 -5
  72. package/src/config.ts +70 -112
  73. package/src/daemon/catalog-writer.ts +67 -67
  74. package/src/daemon/daemon.ts +205 -201
  75. package/src/daemon/session-manager.ts +39 -39
  76. package/src/daemon/session-registry.ts +14 -14
  77. package/src/git/divergence.ts +33 -2
  78. package/src/git/git-poller.ts +1 -1
  79. package/src/git/git-status.ts +2 -2
  80. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  81. package/src/git/pr-status.ts +1 -1
  82. package/src/git/repo-discovery.ts +1 -1
  83. package/src/git/use-repo-discovery.ts +1 -1
  84. package/src/git/workspace-branch-poller.ts +54 -0
  85. package/src/git/workspace-divergence-poller.ts +65 -0
  86. package/src/git/worktree.ts +29 -0
  87. package/src/index.tsx +4 -4
  88. package/src/input/keymap/help-entries.ts +7 -5
  89. package/src/input/modes/bridge.ts +13 -12
  90. package/src/input/modes/handlers/shared.ts +1 -1
  91. package/src/input/modes/transitions.ts +34 -21
  92. package/src/input/modes/types.ts +48 -31
  93. package/src/ipc/manager-protocol.ts +44 -44
  94. package/src/ipc/protocol.ts +192 -166
  95. package/src/platform/play-sound.ts +184 -0
  96. package/src/platform/project-search.ts +8 -8
  97. package/src/platform/worktree-paths.ts +8 -6
  98. package/src/pty/assistant-question-extractor.ts +1 -1
  99. package/src/pty/assistant-status-detection-loop.ts +79 -55
  100. package/src/pty/assistant-status-detector.ts +40 -15
  101. package/src/pty/command-registry.ts +81 -1
  102. package/src/restart-terminal-manager.ts +1 -1
  103. package/src/services/ai-usage/provider.ts +9 -2
  104. package/src/services/ai-usage/spawn.ts +19 -6
  105. package/src/session-backend/bootstrap.ts +4 -4
  106. package/src/session-backend/local-session-backend.ts +78 -75
  107. package/src/session-backend/remote-session-backend.ts +64 -52
  108. package/src/session-backend/types.ts +43 -32
  109. package/src/settings/live.ts +90 -0
  110. package/src/settings/search.ts +43 -0
  111. package/src/settings/sections/about.ts +49 -0
  112. package/src/settings/sections/appearance.ts +54 -0
  113. package/src/settings/sections/automation.ts +105 -0
  114. package/src/settings/sections/commands.ts +72 -0
  115. package/src/settings/sections/editor.ts +56 -0
  116. package/src/settings/sections/experimental.ts +57 -0
  117. package/src/settings/sections/git.ts +112 -0
  118. package/src/settings/sections/index.ts +95 -0
  119. package/src/settings/sections/integrations.ts +23 -0
  120. package/src/settings/sections/layout.ts +72 -0
  121. package/src/settings/sections/notifications.ts +99 -0
  122. package/src/settings/sections/setup.ts +54 -0
  123. package/src/settings/sections/status-bar.ts +63 -0
  124. package/src/settings/settings-store.ts +207 -0
  125. package/src/settings/types.ts +108 -0
  126. package/src/snippets/run-shell-var.ts +48 -22
  127. package/src/state/actions.ts +342 -0
  128. package/src/state/app-store.ts +2 -1
  129. package/src/state/bars.ts +1 -1
  130. package/src/state/dispatch-ref.ts +1 -1
  131. package/src/state/layout-resize.ts +2 -2
  132. package/src/state/project-catalog.ts +275 -0
  133. package/src/state/project-data.ts +143 -0
  134. package/src/state/{session-persistence.ts → project-persistence.ts} +74 -68
  135. package/src/state/project-save.ts +46 -0
  136. package/src/state/project-workspaces.ts +392 -0
  137. package/src/state/reducers/auto-commit-state.ts +11 -10
  138. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  139. package/src/state/reducers/git-mode-state.ts +1 -1
  140. package/src/state/reducers/git-panel-state.ts +10 -6
  141. package/src/state/reducers/modal-state.ts +290 -681
  142. package/src/state/reducers/multi-repo-state.ts +3 -6
  143. package/src/state/reducers/project-state.ts +312 -0
  144. package/src/state/reducers/settings-state.ts +80 -0
  145. package/src/state/reducers/tab-state.ts +107 -60
  146. package/src/state/reducers/ui-state.ts +4 -3
  147. package/src/state/selectors.ts +49 -37
  148. package/src/state/store.ts +57 -24
  149. package/src/state/types.ts +244 -405
  150. package/src/state/validation.ts +14 -12
  151. package/src/terminal-manager/manager-client.ts +32 -32
  152. package/src/terminal-manager/terminal-manager.ts +24 -24
  153. package/src/ui/components/git/git-panel.tsx +2 -2
  154. package/src/ui/components/git/git-view.tsx +12 -12
  155. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  156. package/src/ui/components/git/pane/git-pane-widget.tsx +7 -7
  157. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  158. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  159. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  160. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  161. package/src/ui/components/layout/status-bar.tsx +6 -2
  162. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  163. package/src/ui/components/layout/top-tab-bar.tsx +46 -36
  164. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  165. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  166. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  167. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  168. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  169. package/src/ui/components/modals/shared/form.tsx +34 -22
  170. package/src/ui/components/modals/shared/picker.tsx +10 -10
  171. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  172. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  173. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  174. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  175. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  176. package/src/ui/components/primitives/input-field.tsx +7 -3
  177. package/src/ui/components/primitives/surface.tsx +3 -0
  178. package/src/ui/components/settings/row-value.tsx +74 -0
  179. package/src/ui/components/settings/settings-row.tsx +78 -0
  180. package/src/ui/components/settings/settings-view.tsx +169 -0
  181. package/src/ui/components/setup/setup-widget.tsx +152 -0
  182. package/src/ui/flash/build-labels.ts +27 -27
  183. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  184. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  185. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  186. package/src/ui/root.tsx +139 -121
  187. package/src/ui/status-bar-model.ts +48 -34
  188. package/src/ui/terminal-graphics/kitty.ts +28 -2
  189. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  190. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  191. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  192. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  193. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  194. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  195. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  196. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  197. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  198. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  199. package/src/ui/terminal-graphics/sprites.ts +246 -0
  200. package/src/ui/truncate.ts +7 -0
  201. package/src/ui/widgets/registry.tsx +6 -3
  202. package/src/ui/widgets/widget-context-menu.ts +26 -10
  203. package/src/update.ts +2 -2
  204. package/src/app-runtime/session-actions.ts +0 -187
  205. package/src/cli/client/workspace-resolver.ts +0 -110
  206. package/src/cli/commands/workspace/close.ts +0 -32
  207. package/src/cli/commands/workspace/show.ts +0 -32
  208. package/src/cli/commands/worktree/create.ts +0 -55
  209. package/src/cli/commands/worktree/list.ts +0 -68
  210. package/src/cli/commands/worktree/remove.ts +0 -81
  211. package/src/git/worktree-branch-poller.ts +0 -54
  212. package/src/git/worktree-divergence-poller.ts +0 -59
  213. package/src/state/reducers/session-state.ts +0 -262
  214. package/src/state/session-catalog.ts +0 -143
  215. package/src/state/session-worktrees.ts +0 -260
  216. package/src/state/workspace-save.ts +0 -43
  217. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  218. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  219. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  220. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -0,0 +1,123 @@
1
+ // Sends the create-workspace prompt into the assistant that was just launched.
2
+ //
3
+ // The hard part is knowing when the assistant will accept it. Measured against
4
+ // a real `claude` pty: it enables bracketed paste ~350ms after spawn but drops
5
+ // anything pasted before ~1s, and that gap is not a constant — the same binary
6
+ // in a different directory still swallowed a paste 1.5s after the flag.
7
+ //
8
+ // So no delay is trusted. Bracketed paste is only the coarse "the pty is in raw
9
+ // mode" gate; after it we paste and then *check the screen* for the text,
10
+ // retrying until it shows up. Enter is sent once, at the end, so a prompt is
11
+ // never half-submitted.
12
+
13
+ import type { SessionBackend } from '../session-backend/types'
14
+ import type { AppState, TerminalSnapshot } from '../state/types'
15
+
16
+ import { logInputDebug } from '../debug/input-log'
17
+ import { getLineText } from '../input/terminal-text-extraction'
18
+ import { writePasteToTab, writeToTab } from './pty-write'
19
+
20
+ const POLL_INTERVAL_MS = 100
21
+ /** Past this the command is either unusually slow or is not a TUI at all. */
22
+ const READY_TIMEOUT_MS = 20_000
23
+
24
+ /** Time for the assistant to redraw with the pasted text before we look. */
25
+ const ECHO_WAIT_MS = 500
26
+ const MAX_ATTEMPTS = 12
27
+
28
+ /** Ctrl+U. Clears whatever a previous attempt left behind so retries cannot stack. */
29
+ const CLEAR_INPUT = '\x15'
30
+ const SUBMIT = '\r'
31
+
32
+ /**
33
+ * Enough of the prompt to recognise on screen, short enough to survive the
34
+ * input box wrapping it onto the next row.
35
+ */
36
+ const PROBE_LENGTH = 16
37
+ const MIN_PROBE_LENGTH = 4
38
+
39
+ export interface InjectPromptOptions {
40
+ backend: SessionBackend
41
+ getState: () => AppState
42
+ tabId: string
43
+ prompt: string
44
+ pollIntervalMs?: number
45
+ timeoutMs?: number
46
+ echoWaitMs?: number
47
+ maxAttempts?: number
48
+ sleep?: (ms: number) => Promise<void>
49
+ }
50
+
51
+ async function defaultSleep(ms: number): Promise<void> {
52
+ await new Promise<void>((resolve) => setTimeout(resolve, ms))
53
+ }
54
+
55
+ function screenText(viewport: TerminalSnapshot | undefined): string {
56
+ if (!viewport) return ''
57
+ return viewport.lines.map((line) => getLineText(line)).join('\n')
58
+ }
59
+
60
+ /** The needle we look for on screen, or null when the prompt cannot be matched on. */
61
+ function echoProbe(prompt: string): string | null {
62
+ // A multi-line paste is folded into a "[Pasted text #1 +N lines]" placeholder
63
+ // by every assistant we ship, so there would be nothing on screen to find and
64
+ // every attempt would "fail" until the cap.
65
+ if (prompt.includes('\n')) return null
66
+ const probe = prompt.trim().slice(0, PROBE_LENGTH)
67
+ return probe.length >= MIN_PROBE_LENGTH ? probe : null
68
+ }
69
+
70
+ /**
71
+ * Paste the prompt into the tab's assistant and submit it.
72
+ *
73
+ * ponytail: a 100ms poll over state, because the pty layer exposes no "modes
74
+ * changed" event to await. Retries are bounded and always preceded by Ctrl+U,
75
+ * so the worst case is one unverified paste — never two stacked ones.
76
+ */
77
+ export async function injectPromptWhenReady(options: InjectPromptOptions): Promise<void> {
78
+ const { backend, getState, prompt, tabId } = options
79
+ if (prompt.trim() === '') return
80
+
81
+ const sleep = options.sleep ?? defaultSleep
82
+ const interval = options.pollIntervalMs ?? POLL_INTERVAL_MS
83
+ const deadline = options.timeoutMs ?? READY_TIMEOUT_MS
84
+ const echoWait = options.echoWaitMs ?? ECHO_WAIT_MS
85
+ const attempts = options.maxAttempts ?? MAX_ATTEMPTS
86
+ const probe = echoProbe(prompt)
87
+
88
+ const currentTab = () => getState().tabs.find((entry) => entry.id === tabId)
89
+
90
+ logInputDebug('app.promptInjection.start', { promptLength: prompt.length, tabId })
91
+
92
+ for (let waited = 0; waited < deadline; waited += interval) {
93
+ const tab = currentTab()
94
+ // The tab was closed, or the command died on startup: nothing to type into.
95
+ if (!tab || tab.status === 'error' || tab.status === 'disconnected') {
96
+ logInputDebug('app.promptInjection.abandoned', { status: tab?.status ?? 'gone', tabId })
97
+ return
98
+ }
99
+ if (tab.terminalModes.bracketedPasteMode) break
100
+ await sleep(interval)
101
+ }
102
+
103
+ let landed = false
104
+ for (let attempt = 0; attempt < attempts && !landed; attempt++) {
105
+ const tab = currentTab()
106
+ if (!tab) {
107
+ logInputDebug('app.promptInjection.abandoned', { status: 'gone', tabId })
108
+ return
109
+ }
110
+ if (attempt > 0) writeToTab(backend, tabId, tab, CLEAR_INPUT)
111
+ writePasteToTab(backend, tabId, tab, prompt)
112
+ await sleep(echoWait)
113
+ // No usable probe (a one-word prompt, or text the assistant folds into a
114
+ // "pasted N lines" placeholder): take the first paste on faith.
115
+ landed = probe == null || screenText(currentTab()?.viewport).includes(probe)
116
+ logInputDebug('app.promptInjection.attempt', { attempt: attempt + 1, landed, tabId })
117
+ }
118
+
119
+ const tab = currentTab()
120
+ if (!tab) return
121
+ writeToTab(backend, tabId, tab, SUBMIT)
122
+ logInputDebug('app.promptInjection.submitted', { landed, tabId })
123
+ }
@@ -0,0 +1,29 @@
1
+ import type { AppState, ProjectRecord, SnippetRecord } from '../state/types'
2
+
3
+ // eslint-disable-next-line no-duplicate-imports
4
+ import { type AssistantOption, getAssistantOption } from '../pty/command-registry'
5
+ import { filterProjects, filterSnippets, getNewTabAssistantOptions } from '../state/selectors'
6
+
7
+ /**
8
+ * The row a picker modal currently highlights. Each reads the same filtered
9
+ * list the modal renders, so the effect acts on the row the user is looking at.
10
+ */
11
+ export function getSelectedProject(state: AppState): ProjectRecord | undefined {
12
+ const filter = state.modal.type === 'project-picker' ? state.modal.editBuffer : null
13
+ return filterProjects(state.projects, filter)[state.modal.selectedIndex]
14
+ }
15
+
16
+ export function getSelectedSnippet(state: AppState): SnippetRecord | undefined {
17
+ const filter = state.modal.type === 'snippet-picker' ? state.modal.editBuffer : null
18
+ return filterSnippets(state.snippets, filter)[state.modal.selectedIndex]
19
+ }
20
+
21
+ export function getSelectedAssistantOption(state: AppState): AssistantOption {
22
+ const newTab = state.modal.type === 'new-tab' ? state.modal : null
23
+ const list = getNewTabAssistantOptions(
24
+ state.customCommands,
25
+ newTab?.editBuffer ?? null,
26
+ newTab?.pendingWorkspace != null
27
+ )
28
+ return list[state.modal.selectedIndex] ?? list[0] ?? getAssistantOption(0)
29
+ }
@@ -0,0 +1,156 @@
1
+ import type { SettingCtx, SettingRow, SettingValue } from '../settings/types'
2
+ import type { AppState } from '../state/types'
3
+ import type { SideEffectContext } from './side-effect-context'
4
+
5
+ import { filterSettingRows } from '../settings/search'
6
+ import { findSettingRow, getSectionRows } from '../settings/sections'
7
+ import { readRow, resetRow, settingsStore, writeRow } from '../settings/settings-store'
8
+ import { dispatchGlobal } from '../state/dispatch-ref'
9
+ import { toast } from '../state/toast-store'
10
+
11
+ type SettingsContext = Pick<SideEffectContext, 'getState'>
12
+
13
+ /** Steps like 0.05 otherwise accumulate binary noise into the persisted value. */
14
+ function round(value: number): number {
15
+ return Math.round(value * 10_000) / 10_000
16
+ }
17
+
18
+ function nextOptionValue(
19
+ options: readonly { value: SettingValue }[],
20
+ current: SettingValue,
21
+ delta: 1 | -1
22
+ ): SettingValue | undefined {
23
+ // -1 when the current value isn't one of the options. Hydration keeps that from
24
+ // happening to a row this screen stores, so what is left is a row reading a
25
+ // value that lives elsewhere; stepping forward from -1 lands on the first
26
+ // option, which is how it heals.
27
+ const index = options.findIndex((option) => option.value === current)
28
+ const size = options.length
29
+ if (size === 0) return undefined
30
+ return options[(index + delta + size) % size]?.value
31
+ }
32
+
33
+ function nextNumberValue(
34
+ row: Extract<SettingRow, { kind: 'number' }>,
35
+ current: SettingValue,
36
+ delta: 1 | -1
37
+ ): number {
38
+ const base = typeof current === 'number' ? current : row.min
39
+ // Clamped, never wrapped: `-`/`+` name a direction, and holding + on a width
40
+ // until it snaps to the narrowest is not what anyone meant.
41
+ return Math.min(row.max, Math.max(row.min, round(base + delta * row.step)))
42
+ }
43
+
44
+ /** Asks for a field, seeded with what the row holds. */
45
+ function openField(row: SettingRow, current: SettingValue): void {
46
+ dispatchGlobal({
47
+ label: row.label,
48
+ settingId: row.id,
49
+ type: 'open-setting-text-modal',
50
+ value: String(current),
51
+ })
52
+ }
53
+
54
+ function readCtx(state: AppState): SettingCtx {
55
+ return { state, values: settingsStore.getState().values }
56
+ }
57
+
58
+ /**
59
+ * One entry point for every kind of row. `delta` is the direction asked for
60
+ * (`-`/`+`); without it the row advances the way activating it should — a toggle
61
+ * flips, an enum steps forward, a number steps up, a text field opens.
62
+ */
63
+ export function changeSelectedSetting(runtime: SettingsContext, delta?: 1 | -1): void {
64
+ const state = runtime.getState()
65
+ if (state.focusMode !== 'settings' || state.settings.pane !== 'rows') return
66
+
67
+ const row = getSectionRows(state.settings.sectionId, state.projects)[state.settings.rowIndex]
68
+ if (!row || row.kind === 'info') return
69
+
70
+ const ctx = readCtx(state)
71
+ const current = readRow(row, ctx)
72
+
73
+ switch (row.kind) {
74
+ case 'toggle':
75
+ // `-`/`+` set the value rather than flipping it: holding + on a checkbox
76
+ // should settle on "on", not blink.
77
+ writeRow(row, delta === undefined ? current !== true : delta === 1, ctx)
78
+ return
79
+ case 'select': {
80
+ const next = nextOptionValue(row.options, current, delta ?? 1)
81
+ if (next !== undefined) writeRow(row, next, ctx)
82
+ return
83
+ }
84
+ case 'number':
85
+ // `-`/`+` step it; <CR> asks for the number. Stepping alone meant 59
86
+ // presses to cross the AI usage refresh interval.
87
+ if (delta === undefined) openField(row, current)
88
+ else writeRow(row, nextNumberValue(row, current, delta), ctx)
89
+ return
90
+ case 'text':
91
+ // Nothing a keystroke can advance, so activating hands over to a field.
92
+ // `-`/`+` have nothing to do on one.
93
+ if (delta === undefined) openField(row, current)
94
+ return
95
+ case 'action':
96
+ // Not a value, so a direction means nothing to it either.
97
+ if (delta === undefined) row.run()
98
+ return
99
+ default:
100
+ row satisfies never
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Move the screen's cursor onto the setting the search had highlighted, wherever
106
+ * it turned out to live. The list is recomputed from the same filter the picker
107
+ * drew, so the index still means what it meant.
108
+ */
109
+ export function confirmSettingsSearch(runtime: SettingsContext): void {
110
+ const state = runtime.getState()
111
+ if (state.modal.type !== 'settings-search') return
112
+ const hit = filterSettingRows(state.projects, state.modal.editBuffer)[state.modal.selectedIndex]
113
+ dispatchGlobal({ type: 'close-modal' })
114
+ if (!hit) return
115
+ dispatchGlobal({ sectionId: hit.sectionId, type: 'settings-select-section' })
116
+ dispatchGlobal({ rowIndex: hit.rowIndex, type: 'settings-select-row' })
117
+ }
118
+
119
+ /** Puts the selected row back to what it would be if this screen had never run. */
120
+ export function resetSelectedSetting(runtime: SettingsContext): void {
121
+ const state = runtime.getState()
122
+ if (state.focusMode !== 'settings' || state.settings.pane !== 'rows') return
123
+ const row = getSectionRows(state.settings.sectionId, state.projects)[state.settings.rowIndex]
124
+ if (row) resetRow(row)
125
+ }
126
+
127
+ /** Applies what the text field was holding when it was confirmed. */
128
+ export function commitSettingText(
129
+ runtime: SettingsContext,
130
+ settingId: string,
131
+ value: string
132
+ ): void {
133
+ const state = runtime.getState()
134
+ // Dynamic rows included: a per-project setup command is a text row that exists
135
+ // only while its project does.
136
+ const row = findSettingRow(settingId, state.projects)
137
+ if (!row) return
138
+ const ctx = readCtx(state)
139
+
140
+ if (row.kind === 'number') {
141
+ const parsed = Number(value.trim())
142
+ if (!Number.isFinite(parsed)) {
143
+ toast.error(`${row.label} takes a number`)
144
+ return
145
+ }
146
+ // Clamped rather than refused: someone typing 10 into a field whose floor is
147
+ // 60 meant "as low as it goes", and saying so beats doing nothing.
148
+ const clamped = Math.min(row.max, Math.max(row.min, parsed))
149
+ if (clamped !== parsed) toast.info(`${row.label} clamped to ${String(clamped)}`)
150
+ writeRow(row, clamped, ctx)
151
+ return
152
+ }
153
+
154
+ if (row.kind !== 'text') return
155
+ writeRow(row, value.trim(), ctx)
156
+ }
@@ -0,0 +1,237 @@
1
+ import type { AppAction } from '../state/actions'
2
+ import type { ProjectRecord, TabSession, WorkspaceRecord } from '../state/types'
3
+ import type { SideEffectContext } from './side-effect-context'
4
+
5
+ import { logInputDebug } from '../debug/input-log'
6
+ import { shellQuote } from '../pty/command-registry'
7
+ import { appStore } from '../state/app-store'
8
+ import { saveProjectCatalog } from '../state/project-catalog'
9
+ import { ensureSetupScriptStub, getProjectDataDir, getSetupScriptPath } from '../state/project-data'
10
+ import {
11
+ findWorkspace,
12
+ getActiveWorkspace,
13
+ getActiveWorkspacePath,
14
+ getCurrentProject,
15
+ } from '../state/project-workspaces'
16
+ import { toast } from '../state/toast-store'
17
+ import { launchEditorOnFile } from './editor-actions'
18
+ import { createTabSession, startTabSession } from './tab-actions'
19
+
20
+ /**
21
+ * The setup tab starts before its host is on screen (or without a host at all —
22
+ * the widget is opt-in), so it has no measured box yet. This bootstrap size also
23
+ * has to lift the backend's paneReady gate: while that gate is closed every
24
+ * render is buffered, and a setup running behind a hidden widget would produce
25
+ * no viewport ever. The widget's own measurement corrects the size later.
26
+ */
27
+ const BOOTSTRAP_COLS = 80
28
+ const BOOTSTRAP_ROWS = 24
29
+
30
+ /**
31
+ * The setup tab the widget owns for this workspace. Hidden is part of the
32
+ * predicate: a promoted tab is a normal tab in the main pane, so the widget must
33
+ * stop rendering and sizing it — two hosts reporting a measurement for one PTY
34
+ * never settle on a size.
35
+ */
36
+ export function findSetupTab(
37
+ tabs: TabSession[],
38
+ workspaceId: string | undefined
39
+ ): TabSession | undefined {
40
+ if (workspaceId == null || workspaceId === '') return undefined
41
+ return tabs.find(
42
+ (tab) => tab.role === 'setup' && tab.hidden === true && tab.workspaceId === workspaceId
43
+ )
44
+ }
45
+
46
+ /**
47
+ * Stamp a finished setup run onto its workspace record. Returns whether the tab
48
+ * was a setup tab, which is the caller's signal to leave it open.
49
+ *
50
+ * The workspace record — not the tab — is what tells the widget a run finished,
51
+ * so no new TabStatus is needed for "exited but still on screen".
52
+ */
53
+ export function recordSetupExit(
54
+ tabId: string,
55
+ exitCode: number,
56
+ dispatch: (action: AppAction) => void
57
+ ): boolean {
58
+ const { projects, tabs } = appStore.getState()
59
+ const tab = tabs.find((entry) => entry.id === tabId)
60
+ if (tab?.role !== 'setup') return false
61
+
62
+ // A promoted tab is no longer the one the widget owns, but its exit is still
63
+ // the latest thing that happened to this workspace — unless a newer run has
64
+ // started since, which is the only case where this result is stale news.
65
+ // Reporting is not optional: without it, promoting a setup to watch it fail
66
+ // means watching it fail in silence.
67
+ const superseded = tab.hidden !== true && findSetupTab(tabs, tab.workspaceId) !== undefined
68
+ const found = findWorkspace(projects, tab.workspaceId)
69
+ if (superseded || !found) return true
70
+ const { project, workspace } = found
71
+
72
+ dispatch({
73
+ patch: { setupExitCode: exitCode, setupRanAt: new Date().toISOString() },
74
+ projectId: project.id,
75
+ type: 'update-workspace-record',
76
+ workspaceId: workspace.id,
77
+ })
78
+ saveProjectCatalog(appStore.getState().projects)
79
+ logInputDebug('setup.exit', {
80
+ exitCode,
81
+ projectId: project.id,
82
+ tabId,
83
+ workspaceId: workspace.id,
84
+ })
85
+
86
+ // The widget is opt-in, so a failure has to reach a user who never added it.
87
+ // Success stays quiet.
88
+ if (exitCode !== 0) {
89
+ toast.error(`Setup failed in ${workspace.name} — exit ${exitCode}`)
90
+ }
91
+ return true
92
+ }
93
+
94
+ function resolveTarget(
95
+ ctx: SideEffectContext
96
+ ): { project: ProjectRecord; workspace: WorkspaceRecord } | undefined {
97
+ const project = getCurrentProject(ctx.getState())
98
+ const workspace = getActiveWorkspace(project)
99
+ if (!project || !workspace) return undefined
100
+ return { project, workspace }
101
+ }
102
+
103
+ /**
104
+ * Spawn the hidden PTY that runs a project's setup script in one workspace.
105
+ * Replaces any existing setup tab for that workspace, so Run and Re-run are the
106
+ * same path.
107
+ */
108
+ export function runSetup(ctx: SideEffectContext, projectId: string, workspace: WorkspaceRecord) {
109
+ const existing = findSetupTab(ctx.getState().tabs, workspace.id)
110
+ if (existing) {
111
+ ctx.backend.disposeSession(existing.id)
112
+ ctx.dispatch({ tabId: existing.id, type: 'close-tab' })
113
+ }
114
+
115
+ const scriptPath = getSetupScriptPath(projectId)
116
+ const tab: TabSession = {
117
+ // Quoted, not interpolated bare: the path sits under $HOME, which can hold a
118
+ // space or an apostrophe.
119
+ ...createTabSession('terminal', `bash ${shellQuote(scriptPath)}`, {}, workspace.id),
120
+ hidden: true,
121
+ role: 'setup',
122
+ title: 'Setup',
123
+ }
124
+
125
+ ctx.dispatch({
126
+ patch: { setupExitCode: undefined, setupRanAt: undefined },
127
+ projectId,
128
+ type: 'update-workspace-record',
129
+ workspaceId: workspace.id,
130
+ })
131
+ ctx.dispatch({ tab, type: 'add-tab' })
132
+ startTabSession(ctx, tab, {
133
+ autoRenameCandidate: false,
134
+ cols: BOOTSTRAP_COLS,
135
+ cwd: workspace.path,
136
+ rows: BOOTSTRAP_ROWS,
137
+ })
138
+ // Lifts the render gate immediately — see BOOTSTRAP_COLS.
139
+ ctx.backend.resizeTab(tab.id, BOOTSTRAP_COLS, BOOTSTRAP_ROWS, {
140
+ confirmedFromMeasurement: true,
141
+ })
142
+ logInputDebug('setup.run', { projectId, scriptPath, tabId: tab.id, workspaceId: workspace.id })
143
+ return tab.id
144
+ }
145
+
146
+ export function handleRunSetupEffect(ctx: SideEffectContext): void {
147
+ const target = resolveTarget(ctx)
148
+ if (!target) {
149
+ toast.error('Open a workspace first')
150
+ return
151
+ }
152
+ runSetup(ctx, target.project.id, target.workspace)
153
+ }
154
+
155
+ export function handleStopSetupEffect(ctx: SideEffectContext): void {
156
+ const target = resolveTarget(ctx)
157
+ if (!target) return
158
+ const tab = findSetupTab(ctx.getState().tabs, target.workspace.id)
159
+ if (!tab) return
160
+ ctx.backend.disposeSession(tab.id)
161
+ ctx.dispatch({ tabId: tab.id, type: 'close-tab' })
162
+ }
163
+
164
+ /**
165
+ * `projectId` names a project other than the current one — the settings screen
166
+ * lists every project, and a script too long for one field hands over to here.
167
+ * Without it, the current project's active workspace is the target.
168
+ */
169
+ export function handleConfigureSetupScriptEffect(ctx: SideEffectContext, projectId?: string): void {
170
+ const state = ctx.getState()
171
+ const project =
172
+ projectId != null && projectId !== ''
173
+ ? state.projects.find((entry) => entry.id === projectId)
174
+ : getCurrentProject(state)
175
+ if (!project) {
176
+ toast.error('Open a project first')
177
+ return
178
+ }
179
+ // The editor's working directory: this project's active workspace if it has
180
+ // one, else wherever the project itself lives, else the script's own home —
181
+ // a GUI editor ignores it, a TUI one needs somewhere to be.
182
+ const cwd =
183
+ getActiveWorkspacePath(project) ?? project.projectPath ?? getProjectDataDir(project.id)
184
+ launchEditorOnFile(ctx, ensureSetupScriptStub(project.id), cwd, (message) =>
185
+ toast.error(`editor: ${message}`)
186
+ )
187
+ }
188
+
189
+ /**
190
+ * Promote the setup tab to a normal focusable tab. A failing setup means reading
191
+ * a stack trace, which a bar 30 columns wide cannot do.
192
+ */
193
+ export function handlePromoteSetupTabEffect(ctx: SideEffectContext): void {
194
+ const target = resolveTarget(ctx)
195
+ if (!target) return
196
+ const tab = findSetupTab(ctx.getState().tabs, target.workspace.id)
197
+ if (!tab) return
198
+ ctx.dispatch({ hidden: false, tabId: tab.id, type: 'update-tab-metadata' })
199
+ ctx.dispatch({ tabId: tab.id, type: 'set-active-tab' })
200
+ }
201
+
202
+ export function buildSetupAgentPrompt(scriptPath: string): string {
203
+ return `Write the setup script for this project.
204
+
205
+ aimux creates each workspace as a fresh \`git worktree\`. That checkout contains only files tracked by git — no .env, no node_modules, no .venv, no build cache, no local database. The setup script's job is to take that bare checkout to a state where the project builds, tests, and runs.
206
+
207
+ Inspect this repository and work out what that takes: package manifests and their lockfiles, the package manager actually in use, .env.example or equivalent templates, database migrations, code generation steps, native builds, and whatever the README or CONTRIBUTING tells a new contributor to do.
208
+
209
+ A stub already exists at exactly this absolute path. Read it, then replace its contents:
210
+
211
+ ${scriptPath}
212
+
213
+ It lives outside the repository on purpose, so every workspace of this project shares one script. Do not move it into the repo.
214
+
215
+ Requirements:
216
+ - \`#!/usr/bin/env bash\` and \`set -euo pipefail\`.
217
+ - Runs with the working directory set to the workspace root. Use relative paths; never hardcode a worktree path, they differ per workspace.
218
+ - Idempotent: safe to run twice in a row.
219
+ - Non-interactive: no prompts, no sudo, no password reads, no browser logins. If a step needs a human, print a clear instruction and keep going.
220
+ - Never write a secret into the script. Copy .env.example to .env and print which values the user still has to fill in.
221
+ - Exit non-zero if a required step fails; aimux surfaces the exit code.
222
+ - Echo a short line before each step so the output is readable in a narrow pane.
223
+
224
+ When you are done, print the script's path and a one-line summary of what it does. Do not run it.`
225
+ }
226
+
227
+ export function handleAskAgentForSetupScriptEffect(ctx: SideEffectContext): void {
228
+ const target = resolveTarget(ctx)
229
+ if (!target) {
230
+ toast.error('Open a project first')
231
+ return
232
+ }
233
+ // Create the stub first: the agent then edits a file it can read, rather than
234
+ // creating one at an absolute path outside its working directory.
235
+ const path = ensureSetupScriptStub(target.project.id)
236
+ ctx.dispatch({ pendingPrompt: buildSetupAgentPrompt(path), type: 'open-new-tab-modal' })
237
+ }
@@ -0,0 +1,33 @@
1
+ import type { CliRenderer } from '@opentui/core'
2
+
3
+ import type { SessionBackend } from '../session-backend/types'
4
+ import type { AppAction } from '../state/actions'
5
+ import type { AppState, TabSession } from '../state/types'
6
+ import type { ThemeId } from '../ui/themes'
7
+
8
+ /**
9
+ * Everything a side effect is allowed to reach: the store, the pty backend, the
10
+ * renderer, and the few timers the tab lifecycle owns.
11
+ *
12
+ * Lives apart from `side-effects.ts` so the per-domain effect modules can take
13
+ * it without importing the dispatcher that calls them.
14
+ */
15
+ export interface SideEffectContext {
16
+ /**
17
+ * The render snapshot. Fine for anything read and used in the same turn;
18
+ * anything that awaits — git, a spawn, a model call — must read `getState()`
19
+ * instead, because by then this is stale.
20
+ */
21
+ state: AppState
22
+ dispatch: (action: AppAction) => void
23
+ backend: SessionBackend
24
+ renderer: CliRenderer
25
+ themeId: ThemeId
26
+ setThemeId: (id: ThemeId) => void
27
+ activeTab: TabSession | undefined
28
+ clearIdleTimer: (tabId: string) => void
29
+ clearStartupGrace: (tabId: string) => void
30
+ startStartupGrace: (tabId: string, timeoutMs: number) => void
31
+ getState: () => AppState
32
+ getCurrentProjectProjectPath: () => string | undefined
33
+ }