@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,99 @@
1
+ import type { SettingOption, SettingSection } from '../types'
2
+
3
+ import {
4
+ BUILTIN_SOUND_IDS,
5
+ DEFAULT_VOLUME,
6
+ getCustomSoundsDir,
7
+ listCustomSoundIds,
8
+ playSoundFile,
9
+ resolveSoundPath,
10
+ SOUND_OFF,
11
+ } from '../../platform/play-sound'
12
+ import { toast } from '../../state/toast-store'
13
+ import { settingsStore } from '../settings-store'
14
+
15
+ /** Spelled once, here, next to the rows that own them. */
16
+ const NOTIFICATION_SOUND = 'notifications.sound'
17
+ const NOTIFICATION_VOLUME = 'notifications.volume'
18
+
19
+ /**
20
+ * `off` plus the shipped three plus whatever is in the drop-in directory. Built
21
+ * once, at import: a settings row's options are part of the schema (hydration
22
+ * validates the stored value against them), so they cannot depend on a scan that
23
+ * happens per render. A newly dropped file therefore shows up on the next
24
+ * launch, which the row's description says out loud.
25
+ */
26
+ const SOUND_OPTIONS: SettingOption[] = [
27
+ { label: 'off', value: SOUND_OFF },
28
+ ...BUILTIN_SOUND_IDS.map((id) => ({ label: id, value: id })),
29
+ ...listCustomSoundIds().map((id) => ({ label: id, value: id })),
30
+ ]
31
+
32
+ /** The selected sound id, or `off`. */
33
+ function selectedSoundId(): string {
34
+ const value = settingsStore.getState().values[NOTIFICATION_SOUND]
35
+ return typeof value === 'string' ? value : SOUND_OFF
36
+ }
37
+
38
+ function selectedVolume(): number {
39
+ const value = settingsStore.getState().values[NOTIFICATION_VOLUME]
40
+ return typeof value === 'number' ? value : DEFAULT_VOLUME
41
+ }
42
+
43
+ /**
44
+ * Play the configured notification sound, if any. Silent — and cheap — when the
45
+ * user has set the row to `off`, which is what makes it safe to call on every
46
+ * status edge.
47
+ */
48
+ export function playNotificationSound(options?: { ignoreThrottle?: boolean }): boolean {
49
+ const path = resolveSoundPath(selectedSoundId())
50
+ if (path == null) return false
51
+ return playSoundFile(path, { ...options, volume: selectedVolume() })
52
+ }
53
+
54
+ export const NOTIFICATIONS_SECTION: SettingSection = {
55
+ description: 'Plays when an assistant needs an answer, or finishes a turn.',
56
+ id: 'notifications',
57
+ label: 'Notifications',
58
+ rows: [
59
+ {
60
+ description: `"off" disables it. Drop your own in ${getCustomSoundsDir()} — picked up at the next launch.`,
61
+ fallback: 'plane',
62
+ id: NOTIFICATION_SOUND,
63
+ kind: 'select',
64
+ label: 'Sound',
65
+ options: SOUND_OPTIONS,
66
+ storage: 'settings',
67
+ },
68
+ {
69
+ description:
70
+ "Percent of the file's own level. Ignored by `aplay` and by Windows, which play at the system volume.",
71
+ fallback: DEFAULT_VOLUME,
72
+ id: NOTIFICATION_VOLUME,
73
+ kind: 'number',
74
+ label: 'Volume',
75
+ max: 100,
76
+ min: 0,
77
+ step: 5,
78
+ storage: 'settings',
79
+ },
80
+ {
81
+ // The one way to find out that this machine has no audio player before a
82
+ // notification you actually cared about goes silent.
83
+ description: 'Play the selected sound now.',
84
+ id: 'notifications.test',
85
+ kind: 'action',
86
+ label: 'Test sound',
87
+ run: () => {
88
+ if (selectedSoundId() === SOUND_OFF) {
89
+ toast.info('Notification sound is off')
90
+ return
91
+ }
92
+ if (!playNotificationSound({ ignoreThrottle: true })) {
93
+ toast.error('Could not play that sound — no audio player found')
94
+ }
95
+ },
96
+ value: () => selectedSoundId(),
97
+ },
98
+ ],
99
+ }
@@ -0,0 +1,54 @@
1
+ import type { ProjectRecord } from '../../state/types'
2
+ import type { SettingRow, SettingSection } from '../types'
3
+
4
+ import { runSideEffectGlobal } from '../../state/dispatch-ref'
5
+ import { readSetupScriptLines, writeSetupCommand } from '../../state/project-data'
6
+
7
+ const SETUP_ROW_PREFIX = 'setup.script.'
8
+
9
+ /**
10
+ * One row per project, over the setup script at
11
+ * `~/.config/aimux/<profile>/projects/<id>/setup.sh`.
12
+ *
13
+ * A script whose work is a single command is editable here, which is the case
14
+ * this section exists for — `bun install && cp .env.example .env` should not
15
+ * require opening an editor. A script with more than one line of work is shown
16
+ * but not editable: a one-line field cannot round-trip it, and overwriting it
17
+ * from here would destroy whatever was there. Those hand over to the editor
18
+ * instead.
19
+ */
20
+ function setupRow(projectId: string, projectName: string): SettingRow {
21
+ const lines = readSetupScriptLines(projectId)
22
+
23
+ if (lines.length > 1) {
24
+ return {
25
+ description: `${String(lines.length)} lines — too much for one field. Opens your editor.`,
26
+ id: `${SETUP_ROW_PREFIX}${projectId}`,
27
+ kind: 'action',
28
+ label: projectName,
29
+ run: () => runSideEffectGlobal({ projectId, type: 'configure-setup-script' }),
30
+ value: () => `${String(lines.length)} lines ›`,
31
+ }
32
+ }
33
+
34
+ return {
35
+ description: 'Runs once in each new workspace, from its root.',
36
+ id: `${SETUP_ROW_PREFIX}${projectId}`,
37
+ kind: 'text',
38
+ label: projectName,
39
+ placeholder: 'nothing yet',
40
+ // The file, not the state: this is the only row whose value lives on disk,
41
+ // and the screen re-reads it after every write.
42
+ read: () => lines[0] ?? '',
43
+ storage: 'app',
44
+ write: (value) => writeSetupCommand(projectId, String(value)),
45
+ }
46
+ }
47
+
48
+ export const SETUP_SECTION: SettingSection = {
49
+ id: 'setup',
50
+ label: 'Setup',
51
+ rowCount: (projects) => projects.length,
52
+ rows: (projects: readonly ProjectRecord[]) =>
53
+ projects.map((project) => setupRow(project.id, project.name)),
54
+ }
@@ -0,0 +1,63 @@
1
+ import { setStatusBarSeparator, type StatusBarSeparator } from '@brimveyn/aimux-config'
2
+
3
+ import type { SettingSection } from '../types'
4
+
5
+ export const AI_USAGE_ENABLED = 'statusBar.aiUsage.enabled'
6
+ export const AI_USAGE_POLL_SECONDS = 'statusBar.aiUsage.pollSeconds'
7
+
8
+ const SEPARATORS: { value: StatusBarSeparator; label: string }[] = [
9
+ { label: 'arrow', value: 'arrow' },
10
+ { label: 'round', value: 'round' },
11
+ { label: 'slant', value: 'slant' },
12
+ { label: 'flame', value: 'flame' },
13
+ { label: 'none', value: 'none' },
14
+ ]
15
+
16
+ /**
17
+ * `statusBar.aiUsage.tools` is not here: it is a list, and this screen has no row
18
+ * kind for one. `claudePlan` and `codexWeeklyLimit` are not here either — they are
19
+ * declared in the config type but read nowhere, and a row that does nothing is
20
+ * worse than no row.
21
+ */
22
+ export const STATUS_BAR_SECTION: SettingSection = {
23
+ id: 'statusBar',
24
+ label: 'Status bar',
25
+ rows: [
26
+ {
27
+ apply: (value) => setStatusBarSeparator(value as StatusBarSeparator),
28
+ description: 'Glyph between sections. All but "none" need a nerd font.',
29
+ fallback: 'arrow',
30
+ fromConfig: (config) => config.statusBar?.separator,
31
+ id: 'statusBar.separator',
32
+ kind: 'select',
33
+ label: 'Separator',
34
+ options: SEPARATORS,
35
+ storage: 'settings',
36
+ },
37
+ {
38
+ description: 'Show how much of your Claude or Codex quota is left.',
39
+ fallback: false,
40
+ fromConfig: (config) => config.statusBar?.aiUsage?.enabled,
41
+ id: AI_USAGE_ENABLED,
42
+ kind: 'toggle',
43
+ label: 'AI usage indicator',
44
+ storage: 'settings',
45
+ },
46
+ {
47
+ // The floor is 180 rather than a warning at 180: Claude's endpoint answers
48
+ // a faster caller with a rate limit, and the symptom is an indicator that
49
+ // quietly stops updating. A setting whose own description tells you not to
50
+ // use half its range should not have that half.
51
+ description: "Seconds between checks. Claude's endpoint rate-limits anything faster.",
52
+ fallback: 180,
53
+ fromConfig: (config) => config.statusBar?.aiUsage?.pollSeconds,
54
+ id: AI_USAGE_POLL_SECONDS,
55
+ kind: 'number',
56
+ label: 'Refresh interval',
57
+ max: 3_600,
58
+ min: 180,
59
+ step: 60,
60
+ storage: 'settings',
61
+ },
62
+ ],
63
+ }
@@ -0,0 +1,207 @@
1
+ import type { AimuxUserConfig } from '@brimveyn/aimux-config'
2
+
3
+ import { useStore } from 'zustand'
4
+ import { createStore } from 'zustand/vanilla'
5
+
6
+ import type {
7
+ SettingCtx,
8
+ SettingRow,
9
+ SettingValue,
10
+ StoredSettingRow,
11
+ StoredSettings,
12
+ } from './types'
13
+
14
+ import { loadConfig, saveConfig } from '../config'
15
+ import { logDebug } from '../debug/input-log'
16
+ import { toast } from '../state/toast-store'
17
+
18
+ export interface SettingsState {
19
+ /** Bumped by every write. Rows whose value lives in a file have no other way
20
+ * to tell the screen to look again. */
21
+ revision: number
22
+ /** Effective value per row id, for the rows the settings screen owns. */
23
+ values: StoredSettings
24
+ /**
25
+ * Rows the user's `aimux.config.ts` declares. Their value comes back from that
26
+ * file at every launch, so an edit made here is good for this session only.
27
+ */
28
+ fromConfigFile: ReadonlySet<string>
29
+ /**
30
+ * Rows this screen has a value written for. Not the same as "differs from the
31
+ * default" — someone may have set a row back to its default value — but it is
32
+ * what "reset" removes, so it is what the marker has to mean.
33
+ */
34
+ touched: ReadonlySet<string>
35
+ /**
36
+ * What each row would hold if this screen had never touched it: the config
37
+ * file's value where it declares one, else the built-in default. Resolved once,
38
+ * at hydration, because that is when the config file was read — recomputing it
39
+ * later would read a file the rest of the app has not.
40
+ */
41
+ defaults: StoredSettings
42
+ }
43
+
44
+ const EMPTY_FROM_CONFIG: ReadonlySet<string> = new Set()
45
+
46
+ export const settingsStore = createStore<SettingsState>(() => ({
47
+ defaults: {},
48
+ fromConfigFile: EMPTY_FROM_CONFIG,
49
+ revision: 0,
50
+ touched: EMPTY_FROM_CONFIG,
51
+ values: {},
52
+ }))
53
+
54
+ export function useSettingsStore<T>(selector: (state: SettingsState) => T): T {
55
+ return useStore(settingsStore, selector)
56
+ }
57
+
58
+ /**
59
+ * A value a row publishes is one that row can actually hold, wherever it came
60
+ * from. Everything the screen writes is already in range; `aimux.config.ts` and a
61
+ * hand-edited (or older) `aimux.json` are not, and an unchecked one shows the row
62
+ * saying 60 while the code reading it uses its own floor of 180 — or shows a raw
63
+ * `wavy` where a separator name belongs.
64
+ *
65
+ * A number is clamped to its range. An option outside the list has no ordering to
66
+ * be clamped along, so the only value in the set that means anything is the row's
67
+ * own default, which `settings-schema.test.ts` keeps inside it.
68
+ *
69
+ * The range is the envelope for a setting this screen owns. The config file keeps
70
+ * its full reach over everything the screen has no row for.
71
+ */
72
+ function withinRowDomain(row: StoredSettingRow, value: SettingValue): SettingValue {
73
+ if (row.kind === 'number' && typeof value === 'number') {
74
+ return Math.min(row.max, Math.max(row.min, value))
75
+ }
76
+ if (row.kind === 'select' && !row.options.some((option) => option.value === value)) {
77
+ return row.fallback
78
+ }
79
+ return value
80
+ }
81
+
82
+ /**
83
+ * Resolve every stored row and publish the result: the config file wins, then
84
+ * whatever the settings screen last wrote, then the built-in default. The config
85
+ * file is re-read at every launch, which is what makes its value the one that
86
+ * comes back — see `docs/guide/settings.md`.
87
+ *
88
+ * The schema is a parameter rather than an import so this module stays downstream
89
+ * of the sections, which read from it.
90
+ */
91
+ export function hydrateSettings(rows: readonly SettingRow[], userConfig: AimuxUserConfig): void {
92
+ const stored = loadConfig().settings ?? {}
93
+ const values: StoredSettings = {}
94
+ const defaults: StoredSettings = {}
95
+ const fromConfigFile = new Set<string>()
96
+
97
+ for (const row of rows) {
98
+ const owned = storedRow(row)
99
+ if (!owned) continue
100
+ const declared = owned.fromConfig?.(userConfig)
101
+ if (declared !== undefined) fromConfigFile.add(owned.id)
102
+ defaults[owned.id] = withinRowDomain(owned, declared ?? owned.fallback)
103
+ const value = withinRowDomain(owned, declared ?? stored[owned.id] ?? owned.fallback)
104
+ values[owned.id] = value
105
+ // Unconditional: the caller has already applied the config file's own values
106
+ // (they are the baseline), and re-applying the same value is a no-op. What
107
+ // matters is that a value coming from `stored` reaches the running app.
108
+ owned.apply?.(value)
109
+ }
110
+
111
+ settingsStore.setState((state) => ({
112
+ defaults,
113
+ fromConfigFile,
114
+ revision: state.revision + 1,
115
+ touched: new Set(Object.keys(stored)),
116
+ values,
117
+ }))
118
+ }
119
+
120
+ /**
121
+ * Persist one value and apply it live. The merge reads the file rather than the
122
+ * store: the store holds resolved values, so merging from it would bake a value
123
+ * that came from `aimux.config.ts` into the JSON, where it would outlive the
124
+ * config-file line it came from.
125
+ */
126
+ function persist(id: string, value: SettingValue | undefined): boolean {
127
+ const config = loadConfig()
128
+ const settings: StoredSettings = { ...config.settings }
129
+ if (value === undefined) delete settings[id]
130
+ else settings[id] = value
131
+ if (saveConfig({ ...config, settings })) return true
132
+ // Loud, not just logged: a read-only config directory or a full disk otherwise
133
+ // shows up as a key that does nothing, with no explanation anywhere.
134
+ logDebug('settings.write.failed', { id })
135
+ toast.error('Could not save that setting — see `aimux doctor`')
136
+ return false
137
+ }
138
+
139
+ /**
140
+ * The row as something with a key of its own, or nothing. Every operation that
141
+ * touches the `settings` block — hydrating it, resetting a key, saying a value
142
+ * needs a restart — asks this rather than narrowing the union by hand for the
143
+ * fourth time.
144
+ */
145
+ export function storedRow(row: SettingRow): StoredSettingRow | null {
146
+ if (row.kind === 'info' || row.kind === 'action') return null
147
+ return row.storage === 'settings' ? row : null
148
+ }
149
+
150
+ export function readRow(row: SettingRow, ctx: SettingCtx): SettingValue {
151
+ if (row.kind === 'info' || row.kind === 'action') return row.value(ctx)
152
+ if (row.storage === 'app') return row.read(ctx)
153
+ return ctx.values[row.id] ?? row.fallback
154
+ }
155
+
156
+ /**
157
+ * Writing the value a row already has does nothing. That is not just an
158
+ * optimisation: an `app` row delegates to the action behind the keybinding, and
159
+ * several of those toggle rather than set — so handing one a value it already
160
+ * holds would flip it the wrong way. One rule here beats a guard per row.
161
+ */
162
+ export function writeRow(row: SettingRow, value: SettingValue, ctx: SettingCtx): void {
163
+ if (row.kind === 'info' || row.kind === 'action') return
164
+ if (readRow(row, ctx) === value) return
165
+ if (row.storage === 'app') {
166
+ row.write(value, ctx)
167
+ bumpRevision()
168
+ return
169
+ }
170
+ if (!persist(row.id, value)) return
171
+ settingsStore.setState((state) => ({
172
+ revision: state.revision + 1,
173
+ touched: new Set(state.touched).add(row.id),
174
+ values: { ...state.values, [row.id]: value },
175
+ }))
176
+ row.apply?.(value)
177
+ }
178
+
179
+ /**
180
+ * Forget what this screen wrote for a row and go back to what the row would have
181
+ * been without it: the config file's value if it declares one, else the built-in
182
+ * default. Only the rows this screen owns — an `app` row's value has its own home
183
+ * and its own idea of a default.
184
+ */
185
+ export function resetRow(row: SettingRow): void {
186
+ const owned = storedRow(row)
187
+ if (!owned) return
188
+ if (!settingsStore.getState().touched.has(owned.id)) return
189
+ if (!persist(owned.id, undefined)) return
190
+
191
+ const value = settingsStore.getState().defaults[owned.id] ?? owned.fallback
192
+ settingsStore.setState((state) => {
193
+ const touched = new Set(state.touched)
194
+ touched.delete(owned.id)
195
+ return { revision: state.revision + 1, touched, values: { ...state.values, [owned.id]: value } }
196
+ })
197
+ owned.apply?.(value)
198
+ }
199
+
200
+ /**
201
+ * Tells the screen that what it is showing may be out of date. Needed because a
202
+ * row's value can live outside both stores — the setup script is a file — and
203
+ * nothing else would make the list read it again.
204
+ */
205
+ function bumpRevision(): void {
206
+ settingsStore.setState((state) => ({ revision: state.revision + 1 }))
207
+ }
@@ -0,0 +1,108 @@
1
+ import type { AimuxUserConfig } from '@brimveyn/aimux-config'
2
+
3
+ import type { AppState, ProjectRecord } from '../state/types'
4
+
5
+ export type SettingValue = boolean | number | string
6
+
7
+ /**
8
+ * The values the settings screen owns, keyed by row id. Sparse on purpose: a
9
+ * missing key means "never touched", which is not the same as `false`.
10
+ */
11
+ export type StoredSettings = Record<string, SettingValue>
12
+
13
+ export interface SettingCtx {
14
+ state: AppState
15
+ values: StoredSettings
16
+ }
17
+
18
+ export interface SettingOption {
19
+ value: SettingValue
20
+ label: string
21
+ }
22
+
23
+ /** How a row draws itself, and what activating it does. */
24
+ type SettingKind =
25
+ | { kind: 'toggle' }
26
+ | { kind: 'select'; options: readonly SettingOption[] }
27
+ | { kind: 'number'; min: number; max: number; step: number }
28
+ /** Activating it opens a one-field modal. Empty means "unset", not "empty string". */
29
+ | { kind: 'text'; placeholder?: string }
30
+
31
+ interface SettingRowBase {
32
+ id: string
33
+ label: string
34
+ description?: string
35
+ }
36
+
37
+ /**
38
+ * A row with no other home for its value: it lives in the `settings` block of
39
+ * `aimux.json`, and `readRow`/`writeRow` reach it by id. Nothing here repeats
40
+ * the id, so a row can't read one key and write another.
41
+ */
42
+ interface StoredRow {
43
+ storage: 'settings'
44
+ /** Used when neither the config file nor the settings screen has a value. */
45
+ fallback: SettingValue
46
+ /**
47
+ * The value the user's `aimux.config.ts` declares for this row, or undefined
48
+ * when it doesn't declare it. Declared means it wins at every startup — a UI
49
+ * edit then lasts until the next launch, and the row says so.
50
+ */
51
+ fromConfig?: (config: AimuxUserConfig) => SettingValue | undefined
52
+ /**
53
+ * Hands a new value to whatever owns it in the running app. Not every live row
54
+ * needs one — a value whose only reader subscribes to this store is live on its
55
+ * own, which is why "needs a restart" is declared rather than inferred.
56
+ */
57
+ apply?: (value: SettingValue) => void
58
+ /** The running app won't see the new value; the row says so. */
59
+ restart?: true
60
+ }
61
+
62
+ /**
63
+ * A view over a value `AppState` already owns (git pane preferences, bar
64
+ * visibility, …). It delegates to that value's existing action or side effect
65
+ * rather than keeping a second copy.
66
+ */
67
+ interface DerivedRow {
68
+ storage: 'app'
69
+ read: (ctx: SettingCtx) => SettingValue
70
+ /** Same context `read` gets, so a write that has to merge into a record can. */
71
+ write: (value: SettingValue, ctx: SettingCtx) => void
72
+ }
73
+
74
+ /** A row whose value lives in the `settings` block of `aimux.json`. */
75
+ export type StoredSettingRow = SettingRowBase & SettingKind & StoredRow
76
+
77
+ export type SettingRow =
78
+ | StoredSettingRow
79
+ | (SettingRowBase & SettingKind & DerivedRow)
80
+ | (SettingRowBase & { kind: 'info'; value: (ctx: SettingCtx) => string })
81
+ /**
82
+ * Not a setting: a button. For the things a row cannot hold — a multi-line
83
+ * script, a list — where the honest move is to hand over to whatever can.
84
+ */
85
+ | (SettingRowBase & { kind: 'action'; value: (ctx: SettingCtx) => string; run: () => void })
86
+
87
+ export interface SettingSection {
88
+ id: string
89
+ label: string
90
+ /** Shown once under the section's title, for a caveat that covers every row. */
91
+ description?: string
92
+ /**
93
+ * A function when the rows depend on the projects — one row per project, say.
94
+ * The projects and nothing else: a builder handed the whole state would be free
95
+ * to depend on anything in it, and every caller would have to have all of it.
96
+ *
97
+ * Building a row may cost something (Setup reads a script off disk), so anything
98
+ * that only needs to know *how many* rows there are asks `rowCount` instead.
99
+ */
100
+ rows: SettingRow[] | ((projects: readonly ProjectRecord[]) => SettingRow[])
101
+ /**
102
+ * How many rows there will be, without building them. Required alongside a
103
+ * dynamic `rows`, and kept honest by `settings-schema.test.ts`: the reducer
104
+ * clamps the cursor with this, and a count that disagrees with the list is a
105
+ * cursor that stops one row short of the end, or one past it.
106
+ */
107
+ rowCount?: (projects: readonly ProjectRecord[]) => number
108
+ }
@@ -30,6 +30,13 @@ function buildShellArgv(cmd: string): string[] {
30
30
  export async function runShellVar(name: string, v: SnippetShellVar): Promise<string> {
31
31
  const requested = v.timeout ?? DEFAULT_TIMEOUT_MS
32
32
  const timeoutMs = Math.min(Math.max(requested, 0), MAX_TIMEOUT_MS)
33
+ const logFailure = (event: string, error: unknown): void => {
34
+ logDebug(event, {
35
+ cmd: v.sh,
36
+ error: error instanceof Error ? error.message : String(error),
37
+ name,
38
+ })
39
+ }
33
40
 
34
41
  let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
35
42
  try {
@@ -39,35 +46,55 @@ export async function runShellVar(name: string, v: SnippetShellVar): Promise<str
39
46
  stdout: 'pipe',
40
47
  })
41
48
  } catch (error) {
42
- logDebug('snippets.shellVar.spawnError', {
43
- cmd: v.sh,
44
- error: error instanceof Error ? error.message : String(error),
45
- name,
46
- })
49
+ logFailure('snippets.shellVar.spawnError', error)
47
50
  return ''
48
51
  }
49
52
 
53
+ // Racing the timeout rather than checking a flag after the reads: `proc.kill()`
54
+ // signals the shell, but a command it forked instead of exec'ing outlives it
55
+ // still holding the stdout pipe, so reading to EOF blocks past the deadline —
56
+ // on Linux `sleep 5` behind an 80ms timeout waited the full five seconds.
57
+ // ponytail: the orphan is left running. Kill the process group if a snippet
58
+ // ever spawns something expensive enough to care about.
50
59
  let timeoutFired = false
51
- const timeoutHandle = setTimeout(() => {
52
- timeoutFired = true
60
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined
61
+ const expired = new Promise<'timeout'>((resolve) => {
62
+ timeoutHandle = setTimeout(() => {
63
+ timeoutFired = true
64
+ try {
65
+ proc.kill()
66
+ } catch {
67
+ // process already gone
68
+ }
69
+ resolve('timeout')
70
+ }, timeoutMs)
71
+ })
72
+
73
+ // Swallow inside, not with a trailing `.catch`: the loser of the race below
74
+ // is still a live promise, and a late failure would otherwise surface as an
75
+ // unhandled rejection long after we returned.
76
+ const collected = (async (): Promise<[string, string, number] | 'failed'> => {
53
77
  try {
54
- proc.kill()
55
- } catch {
56
- // process already gone
78
+ return await Promise.all([
79
+ new Response(proc.stdout).text(),
80
+ new Response(proc.stderr).text(),
81
+ proc.exited,
82
+ ])
83
+ } catch (error) {
84
+ logFailure('snippets.shellVar.error', error)
85
+ return 'failed'
57
86
  }
58
- }, timeoutMs)
87
+ })()
59
88
 
60
89
  try {
61
- const [stdout, stderr, exitCode] = await Promise.all([
62
- new Response(proc.stdout).text(),
63
- new Response(proc.stderr).text(),
64
- proc.exited,
65
- ])
90
+ const outcome = await Promise.race([collected, expired])
66
91
 
67
- if (timeoutFired) {
92
+ if (outcome === 'timeout' || timeoutFired) {
68
93
  logDebug('snippets.shellVar.timeout', { cmd: v.sh, name, timeoutMs })
69
94
  return ''
70
95
  }
96
+ if (outcome === 'failed') return ''
97
+ const [stdout, stderr, exitCode] = outcome
71
98
 
72
99
  if (exitCode !== 0) {
73
100
  logDebug('snippets.shellVar.nonZeroExit', {
@@ -81,11 +108,10 @@ export async function runShellVar(name: string, v: SnippetShellVar): Promise<str
81
108
 
82
109
  return v.trim === false ? stdout : stdout.replace(/\s+$/, '')
83
110
  } catch (error) {
84
- logDebug('snippets.shellVar.error', {
85
- cmd: v.sh,
86
- error: error instanceof Error ? error.message : String(error),
87
- name,
88
- })
111
+ // Belt and braces: nothing in the try can reject today (`collected` swallows
112
+ // its own errors, `expired` never rejects), but this runs arbitrary user
113
+ // shell and the contract is that it never throws at its caller.
114
+ logFailure('snippets.shellVar.error', error)
89
115
  return ''
90
116
  } finally {
91
117
  clearTimeout(timeoutHandle)