@brimveyn/aimux 1.20.4 → 1.22.0

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 (233) 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 +305 -1589
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/split-drag-controller.ts +4 -8
  19. package/src/app-runtime/tab-actions.ts +218 -0
  20. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  21. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  22. package/src/app-runtime/use-backend-runtime.ts +12 -11
  23. package/src/app-runtime/use-directory-search.ts +5 -4
  24. package/src/app-runtime/use-mouse-handlers.ts +28 -42
  25. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  26. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  27. package/src/app-runtime/use-setup-runner.ts +96 -0
  28. package/src/app-runtime/use-terminal-resize.ts +28 -33
  29. package/src/app-runtime/workspace-actions.ts +366 -0
  30. package/src/app-runtime/workspace-activity.ts +138 -0
  31. package/src/app-runtime/workspace-naming.ts +90 -0
  32. package/src/app.tsx +102 -85
  33. package/src/assets/sounds/bell.wav +0 -0
  34. package/src/assets/sounds/ding.wav +0 -0
  35. package/src/assets/sounds/plane.wav +0 -0
  36. package/src/cli/client/daemon-client.ts +2 -2
  37. package/src/cli/client/project-resolver.ts +112 -0
  38. package/src/cli/commands/project/close.ts +32 -0
  39. package/src/cli/commands/project/create.ts +93 -0
  40. package/src/cli/commands/project/list.ts +25 -0
  41. package/src/cli/commands/project/show.ts +32 -0
  42. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  43. package/src/cli/commands/tab/await.ts +2 -2
  44. package/src/cli/commands/tab/close.ts +3 -3
  45. package/src/cli/commands/tab/create.ts +74 -73
  46. package/src/cli/commands/tab/focus.ts +3 -3
  47. package/src/cli/commands/tab/list.ts +6 -6
  48. package/src/cli/commands/tab/run.ts +2 -2
  49. package/src/cli/commands/tab/send.ts +2 -2
  50. package/src/cli/commands/tab/snapshot.ts +2 -2
  51. package/src/cli/commands/tab/tail.ts +2 -2
  52. package/src/cli/commands/tab/wait.ts +2 -2
  53. package/src/cli/commands/worker/await.ts +3 -3
  54. package/src/cli/commands/worker/doctor.ts +30 -30
  55. package/src/cli/commands/worker/list.ts +15 -15
  56. package/src/cli/commands/worker/prompt.ts +3 -3
  57. package/src/cli/commands/worker/run.ts +23 -23
  58. package/src/cli/commands/worker/shared.ts +53 -59
  59. package/src/cli/commands/worker/stop.ts +29 -29
  60. package/src/cli/commands/worker/submit.ts +3 -3
  61. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  62. package/src/cli/commands/workspace/create.ts +30 -68
  63. package/src/cli/commands/workspace/list.ts +52 -9
  64. package/src/cli/commands/workspace/remove.ts +81 -0
  65. package/src/cli/completion/plan.ts +2 -2
  66. package/src/cli/completion/sources.ts +18 -18
  67. package/src/cli/context.ts +13 -13
  68. package/src/cli/flags.ts +30 -10
  69. package/src/cli/index.ts +26 -16
  70. package/src/cli/output.ts +1 -1
  71. package/src/cli/registry.ts +12 -12
  72. package/src/config/loader.ts +16 -5
  73. package/src/config.ts +149 -101
  74. package/src/daemon/catalog-writer.ts +67 -67
  75. package/src/daemon/daemon.ts +205 -201
  76. package/src/daemon/session-manager.ts +39 -39
  77. package/src/daemon/session-registry.ts +14 -14
  78. package/src/git/divergence.ts +33 -2
  79. package/src/git/git-poller.ts +1 -1
  80. package/src/git/git-status.ts +2 -2
  81. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  82. package/src/git/pr-merge.ts +64 -0
  83. package/src/git/pr-status-poller.ts +57 -0
  84. package/src/git/pr-status.ts +227 -0
  85. package/src/git/repo-discovery.ts +1 -1
  86. package/src/git/use-repo-discovery.ts +1 -1
  87. package/src/git/workspace-branch-poller.ts +54 -0
  88. package/src/git/workspace-divergence-poller.ts +65 -0
  89. package/src/git/worktree.ts +29 -0
  90. package/src/index.tsx +11 -6
  91. package/src/input/keymap/help-entries.ts +7 -5
  92. package/src/input/modes/bridge.ts +13 -12
  93. package/src/input/modes/handlers/shared.ts +1 -1
  94. package/src/input/modes/transitions.ts +34 -21
  95. package/src/input/modes/types.ts +48 -31
  96. package/src/ipc/manager-protocol.ts +44 -44
  97. package/src/ipc/protocol.ts +192 -166
  98. package/src/platform/open-url.ts +39 -0
  99. package/src/platform/play-sound.ts +184 -0
  100. package/src/platform/project-search.ts +8 -8
  101. package/src/platform/worktree-paths.ts +8 -6
  102. package/src/pty/assistant-question-extractor.ts +1 -1
  103. package/src/pty/assistant-status-detection-loop.ts +79 -55
  104. package/src/pty/assistant-status-detector.ts +40 -15
  105. package/src/pty/command-registry.ts +81 -1
  106. package/src/restart-terminal-manager.ts +1 -1
  107. package/src/services/ai-usage/provider.ts +9 -2
  108. package/src/services/ai-usage/spawn.ts +3 -1
  109. package/src/session-backend/bootstrap.ts +4 -4
  110. package/src/session-backend/local-session-backend.ts +78 -75
  111. package/src/session-backend/remote-session-backend.ts +64 -52
  112. package/src/session-backend/types.ts +43 -32
  113. package/src/settings/live.ts +90 -0
  114. package/src/settings/search.ts +43 -0
  115. package/src/settings/sections/about.ts +49 -0
  116. package/src/settings/sections/appearance.ts +54 -0
  117. package/src/settings/sections/automation.ts +105 -0
  118. package/src/settings/sections/commands.ts +72 -0
  119. package/src/settings/sections/editor.ts +56 -0
  120. package/src/settings/sections/experimental.ts +57 -0
  121. package/src/settings/sections/git.ts +112 -0
  122. package/src/settings/sections/index.ts +95 -0
  123. package/src/settings/sections/integrations.ts +23 -0
  124. package/src/settings/sections/layout.ts +72 -0
  125. package/src/settings/sections/notifications.ts +99 -0
  126. package/src/settings/sections/setup.ts +54 -0
  127. package/src/settings/sections/status-bar.ts +63 -0
  128. package/src/settings/settings-store.ts +207 -0
  129. package/src/settings/types.ts +108 -0
  130. package/src/snippets/run-shell-var.ts +48 -22
  131. package/src/state/actions.ts +342 -0
  132. package/src/state/app-store.ts +2 -1
  133. package/src/state/bars.ts +75 -0
  134. package/src/state/dispatch-ref.ts +1 -1
  135. package/src/state/git-pane-sizing.ts +0 -9
  136. package/src/state/layout-resize.ts +2 -2
  137. package/src/state/pr-status-store.ts +39 -0
  138. package/src/state/project-catalog.ts +275 -0
  139. package/src/state/project-data.ts +143 -0
  140. package/src/state/{session-persistence.ts → project-persistence.ts} +77 -73
  141. package/src/state/project-save.ts +46 -0
  142. package/src/state/project-workspaces.ts +392 -0
  143. package/src/state/reducers/auto-commit-state.ts +11 -10
  144. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  145. package/src/state/reducers/git-mode-state.ts +1 -1
  146. package/src/state/reducers/git-panel-state.ts +10 -53
  147. package/src/state/reducers/modal-state.ts +290 -681
  148. package/src/state/reducers/multi-repo-state.ts +3 -6
  149. package/src/state/reducers/project-state.ts +312 -0
  150. package/src/state/reducers/settings-state.ts +80 -0
  151. package/src/state/reducers/tab-state.ts +107 -60
  152. package/src/state/reducers/ui-state.ts +86 -15
  153. package/src/state/selectors.ts +49 -37
  154. package/src/state/store.ts +83 -57
  155. package/src/state/types.ts +261 -417
  156. package/src/state/validation.ts +14 -12
  157. package/src/terminal-manager/manager-client.ts +32 -32
  158. package/src/terminal-manager/terminal-manager.ts +24 -24
  159. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
  160. package/src/ui/components/git/git-panel.tsx +2 -2
  161. package/src/ui/components/git/git-view.tsx +12 -12
  162. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  163. package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
  164. package/src/ui/components/git/pane/git-pane-widget.tsx +37 -23
  165. package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
  166. package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
  167. package/src/ui/components/layout/bar.tsx +191 -0
  168. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  169. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  170. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  171. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  172. package/src/ui/components/layout/status-bar.tsx +6 -2
  173. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  174. package/src/ui/components/layout/top-tab-bar.tsx +48 -37
  175. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  176. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  177. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  178. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  179. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  180. package/src/ui/components/modals/shared/form.tsx +34 -22
  181. package/src/ui/components/modals/shared/picker.tsx +10 -10
  182. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  183. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  184. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  185. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  186. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  187. package/src/ui/components/primitives/input-field.tsx +7 -3
  188. package/src/ui/components/primitives/surface.tsx +3 -0
  189. package/src/ui/components/settings/row-value.tsx +74 -0
  190. package/src/ui/components/settings/settings-row.tsx +78 -0
  191. package/src/ui/components/settings/settings-view.tsx +169 -0
  192. package/src/ui/components/setup/setup-widget.tsx +152 -0
  193. package/src/ui/flash/build-labels.ts +27 -27
  194. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  195. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  196. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  197. package/src/ui/root.tsx +164 -230
  198. package/src/ui/status-bar-model.ts +48 -34
  199. package/src/ui/terminal-graphics/kitty.ts +28 -2
  200. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  201. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  202. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  203. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  204. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  205. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  206. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  207. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  208. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  209. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  210. package/src/ui/terminal-graphics/sprites.ts +246 -0
  211. package/src/ui/truncate.ts +7 -0
  212. package/src/ui/widgets/registry.tsx +21 -0
  213. package/src/ui/widgets/widget-context-menu.ts +85 -0
  214. package/src/update.ts +2 -2
  215. package/src/app-runtime/session-actions.ts +0 -187
  216. package/src/cli/client/workspace-resolver.ts +0 -110
  217. package/src/cli/commands/workspace/close.ts +0 -32
  218. package/src/cli/commands/workspace/show.ts +0 -32
  219. package/src/cli/commands/worktree/create.ts +0 -55
  220. package/src/cli/commands/worktree/list.ts +0 -68
  221. package/src/cli/commands/worktree/remove.ts +0 -81
  222. package/src/git/worktree-branch-poller.ts +0 -54
  223. package/src/git/worktree-divergence-poller.ts +0 -59
  224. package/src/state/reducers/session-state.ts +0 -262
  225. package/src/state/session-catalog.ts +0 -143
  226. package/src/state/session-worktrees.ts +0 -260
  227. package/src/state/workspace-save.ts +0 -45
  228. package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
  229. package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
  230. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  231. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  232. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  233. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Per-workspace activity, and the notification sound that goes with it.
3
+ *
4
+ * The store only holds tabs for the project the client is attached to, but the
5
+ * daemon broadcasts every tab's status with its `workspaceId` (v18). So the
6
+ * table of "which tab is doing what" lives here, module-owned, and only the
7
+ * aggregate per workspace is dispatched into `AppState.workspaceActivity` —
8
+ * which is what the sidebar draws for every project, current or not.
9
+ *
10
+ * Both notification sounds are triggered from here so the glyph and the sound
11
+ * can never disagree about what happened:
12
+ * - a tab entering `waiting-input` (the agent is asking something),
13
+ * - a tab completing a turn (the daemon's settled end-of-turn signal).
14
+ * Neither fires for the tab you are looking at.
15
+ */
16
+ import type { TabActivity } from '../state/types'
17
+
18
+ import { playNotificationSound } from '../settings/sections/notifications'
19
+ import { appStore } from '../state/app-store'
20
+ import { dispatchGlobal } from '../state/dispatch-ref'
21
+
22
+ interface Entry {
23
+ status: TabActivity
24
+ workspaceId: string | undefined
25
+ /**
26
+ * Whether this client has seen the tab do something since the last turn it
27
+ * announced. The daemon's end-of-turn timer is per-process: restart it (an
28
+ * update, `restart-daemon`) and every surviving idle PTY produces a
29
+ * turn-complete a settle window later. Requiring an observed `working` /
30
+ * `waiting-input` first is what keeps that restart from ticking every
31
+ * workspace and ringing.
32
+ */
33
+ armed: boolean
34
+ }
35
+
36
+ // A tab in another project never sends this client its `tabExit`, so an entry
37
+ // here can outlive its PTY. The daemon emits a final `idle` for a tab it stops
38
+ // seeing (the tick cleanup in `assistant-status-detection-loop.ts`), which is
39
+ // what keeps a tab that died mid-turn from pinning a spinner to a workspace
40
+ // row forever; the entry itself is left behind, contributing nothing.
41
+ const entries = new Map<string, Entry>()
42
+
43
+ /** The two flags a set of tabs adds up to. Pure, and the reason it is exported. */
44
+ export function aggregate(statuses: readonly TabActivity[]): {
45
+ working: boolean
46
+ waiting: boolean
47
+ } {
48
+ return {
49
+ waiting: statuses.includes('waiting-input'),
50
+ working: statuses.includes('working'),
51
+ }
52
+ }
53
+
54
+ function publish(workspaceId: string | undefined): void {
55
+ if (workspaceId == null || workspaceId === '') return
56
+ const statuses: TabActivity[] = []
57
+ for (const entry of entries.values()) {
58
+ if (entry.workspaceId === workspaceId) statuses.push(entry.status)
59
+ }
60
+ const { waiting, working } = aggregate(statuses)
61
+ dispatchGlobal({ type: 'set-workspace-activity', waiting, working, workspaceId })
62
+ }
63
+
64
+ /** True when this tab is the one on screen — no sound, no unseen-tick, for it. */
65
+ function isBeingWatched(tabId: string): boolean {
66
+ return appStore.getState().activeTabId === tabId
67
+ }
68
+
69
+ export function recordTabStatus(
70
+ tabId: string,
71
+ status: TabActivity,
72
+ workspaceId: string | undefined,
73
+ options?: { silent?: boolean }
74
+ ): void {
75
+ const previous = entries.get(tabId)
76
+ const armed = status === 'working' || status === 'waiting-input' || (previous?.armed ?? false)
77
+ // An event that carries no workspace does not mean the tab lost one. A tab
78
+ // created before the daemon recorded workspaces has none in its registry,
79
+ // but the client's own snapshot does — and the attach seeded us with it, so
80
+ // keep it rather than blanking the row on the next status change.
81
+ const known = workspaceId ?? previous?.workspaceId
82
+ entries.set(tabId, { armed, status, workspaceId: known })
83
+ // A tab can be reassigned to another workspace (workspace move); the one it
84
+ // left has one tab fewer and has to be recomputed too.
85
+ if (previous && previous.workspaceId !== known) publish(previous.workspaceId)
86
+ publish(known)
87
+
88
+ if (
89
+ options?.silent !== true &&
90
+ status === 'waiting-input' &&
91
+ previous?.status !== 'waiting-input' &&
92
+ !isBeingWatched(tabId)
93
+ ) {
94
+ playNotificationSound()
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Seed from the tabs an attach handed us, so a workspace whose agent has been
100
+ * waiting since before this client started says so on the first paint instead
101
+ * of waiting for a transition that already happened. Silent by definition:
102
+ * nothing here is news.
103
+ */
104
+ export function seedTabActivity(
105
+ tabs: readonly { id: string; activity?: TabActivity; workspaceId?: string }[]
106
+ ): void {
107
+ for (const tab of tabs) {
108
+ recordTabStatus(tab.id, tab.activity ?? 'idle', tab.workspaceId, { silent: true })
109
+ }
110
+ }
111
+
112
+ export function recordTurnComplete(tabId: string, workspaceId: string | undefined): void {
113
+ const entry = entries.get(tabId)
114
+ // Nothing observed since the last turn — this is a restarted daemon
115
+ // re-announcing the end of a turn that ended before we were listening.
116
+ if (entry?.armed !== true) return
117
+ entry.armed = false
118
+ if (isBeingWatched(tabId)) return
119
+ if (workspaceId != null && workspaceId !== '') {
120
+ dispatchGlobal({ type: 'mark-workspace-done', workspaceId })
121
+ }
122
+ // The workspace tick says where to look; this says which tab rang. A no-op
123
+ // for a tab this client doesn't hold — its workspace row is the answer there.
124
+ dispatchGlobal({ tabId, type: 'mark-tab-unseen' })
125
+ playNotificationSound()
126
+ }
127
+
128
+ export function forgetTabActivity(tabId: string): void {
129
+ const entry = entries.get(tabId)
130
+ if (!entry) return
131
+ entries.delete(tabId)
132
+ publish(entry.workspaceId)
133
+ }
134
+
135
+ /** Test seam: drop every remembered tab. */
136
+ export function resetWorkspaceActivity(): void {
137
+ entries.clear()
138
+ }
@@ -0,0 +1,90 @@
1
+ // Names a workspace after the prompt that created it.
2
+ //
3
+ // Two stages, because a model call takes seconds and workspace creation must
4
+ // not wait on the network: a local heuristic name is applied at creation, then
5
+ // the generated one replaces it in place when it arrives. The heuristic result
6
+ // is not a stopgap to be embarrassed about — when no headless CLI is installed
7
+ // it is the final name, and it is still derived from what the user asked for.
8
+
9
+ import type { AssistantId, WorkspaceRecord } from '../state/types'
10
+
11
+ import { heuristicTitle } from '../auto-rename/heuristic-title'
12
+ import { generateTabTitle, type TitleSpawnFn } from '../auto-rename/title-runner'
13
+ import { renameGitBranch } from '../git/worktree'
14
+ import { sanitizePathSegment } from '../platform/worktree-paths'
15
+
16
+ /** Model naming is best-effort background work; it must never outlive the app. */
17
+ const NAMING_TIMEOUT_MS = 30_000
18
+
19
+ export const BRANCH_PREFIX = 'aimux/'
20
+ const BRANCH_SLUG_MAX = 40
21
+
22
+ export function branchNameFor(name: string): string {
23
+ return `${BRANCH_PREFIX}${sanitizePathSegment(name, BRANCH_SLUG_MAX).toLowerCase()}`
24
+ }
25
+
26
+ /**
27
+ * The name a workspace carries until the model answers. Undefined when the
28
+ * prompt yields nothing usable, which lets `createAimuxTempWorkspace` fall back
29
+ * to its own `wt-<project>` default.
30
+ */
31
+ export function placeholderWorkspaceName(prompt: string): string | undefined {
32
+ return heuristicTitle(prompt) ?? undefined
33
+ }
34
+
35
+ export interface WorkspaceNamingTarget {
36
+ projectId: string
37
+ workspace: WorkspaceRecord
38
+ prompt: string
39
+ /** Provider used for the naming call — the assistant the user just picked. */
40
+ provider: AssistantId
41
+ }
42
+
43
+ export interface WorkspaceNamingDeps {
44
+ applyName: (projectId: string, workspaceId: string, patch: Partial<WorkspaceRecord>) => void
45
+ renameBranch?: (repoRoot: string, from: string, to: string) => Promise<boolean>
46
+ spawn?: TitleSpawnFn
47
+ signal?: AbortSignal
48
+ }
49
+
50
+ /**
51
+ * Replace a workspace's placeholder name and branch with ones generated from
52
+ * the prompt. Silent on every failure: a missing CLI, a timeout or a branch
53
+ * collision all leave the placeholder standing, which is a perfectly usable
54
+ * outcome and not worth a toast.
55
+ *
56
+ * ponytail: fixed timeout, no model override. Read `autoRename.models` here if
57
+ * anyone wants naming on a cheaper model than the tab's assistant.
58
+ */
59
+ export async function renameWorkspaceFromPrompt(
60
+ target: WorkspaceNamingTarget,
61
+ deps: WorkspaceNamingDeps
62
+ ): Promise<void> {
63
+ const result = await generateTabTitle({
64
+ firstPrompt: target.prompt,
65
+ provider: target.provider,
66
+ signal: deps.signal ?? new AbortController().signal,
67
+ spawn: deps.spawn,
68
+ timeoutMs: NAMING_TIMEOUT_MS,
69
+ })
70
+ if (result.status !== 'ok') return
71
+
72
+ const { workspace } = target
73
+ if (result.title === workspace.name) return
74
+
75
+ const branch = branchNameFor(result.title)
76
+ const rename = deps.renameBranch ?? renameGitBranch
77
+ // Renamed from inside the workspace, not the main checkout: the branch is
78
+ // that worktree's current branch, which is the form git always accepts.
79
+ // Renaming first means a refusal (the name is taken) leaves the record
80
+ // pointing at the branch that actually exists.
81
+ const branchRenamed =
82
+ workspace.branch != null && workspace.branch !== '' && branch !== workspace.branch
83
+ ? await rename(workspace.path, workspace.branch, branch)
84
+ : false
85
+
86
+ deps.applyName(target.projectId, workspace.id, {
87
+ name: result.title,
88
+ ...(branchRenamed ? { branch } : null),
89
+ })
90
+ }
package/src/app.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type AimuxUserConfig,
2
3
  type ResolvedConfig,
3
4
  setAutoCommitEnabled,
4
5
  setExternalEditorConfig,
@@ -8,20 +9,23 @@ import {
8
9
  import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
9
10
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
10
11
 
12
+ import type { SideEffectContext } from './app-runtime/side-effect-context'
11
13
  import type { KeyChord } from './input/keymap/key-chord'
12
14
  import type { TrieBinding } from './input/keymap/trie'
13
15
  import type { KeyResult, ModeContext, ModeId } from './input/modes/types'
14
16
  import type { TerminalContentOrigin } from './input/raw-input-handler'
15
17
  import type { SessionBackend } from './session-backend/types'
16
18
 
17
- import { executeSideEffect, type SideEffectContext } from './app-runtime/side-effects'
19
+ import { setActiveMeasure } from './app-runtime/measure-ref'
20
+ import { executeSideEffect } from './app-runtime/side-effects'
18
21
  import { useAutoCommitDriver } from './app-runtime/use-auto-commit-driver'
19
22
  import { useBackendRuntime } from './app-runtime/use-backend-runtime'
20
23
  import { useDirectorySearch } from './app-runtime/use-directory-search'
21
24
  import { useMouseHandlers } from './app-runtime/use-mouse-handlers'
25
+ import { useProjectAutosave } from './app-runtime/use-project-autosave'
22
26
  import { useRendererBindings } from './app-runtime/use-renderer-bindings'
27
+ import { useSetupRunner } from './app-runtime/use-setup-runner'
23
28
  import { useTerminalResize } from './app-runtime/use-terminal-resize'
24
- import { useWorkspaceAutosave } from './app-runtime/use-workspace-autosave'
25
29
  import { loadConfig, saveConfig } from './config'
26
30
  import { enqueueGitOp } from './git/command-queue'
27
31
  import { pruneOrphanAimuxBranches } from './git/worktree'
@@ -34,11 +38,19 @@ import { highlightSnapshot, warmClaudeSyntaxOverlay } from './integrations/claud
34
38
  import { ensureClaudeSettingsThemePref, syncClaudeTheme } from './integrations/claude-theme-sync'
35
39
  import { getProfileConfigDir, getProfileName } from './profile-paths'
36
40
  import { startAIUsageService } from './services/ai-usage/provider'
41
+ import {
42
+ useAIUsageConfig,
43
+ useAutoCommitConfig,
44
+ useHarmonizeClaudeTheme,
45
+ useSnippetTriggerChar,
46
+ } from './settings/live'
47
+ import { ALL_SETTING_ROWS } from './settings/sections'
48
+ import { hydrateSettings } from './settings/settings-store'
37
49
  import { aiUsageStore } from './state/ai-usage-store'
38
50
  import { appStore, useAppStore } from './state/app-store'
39
51
  import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
40
- import { findMostRecentSession, loadSessionCatalog } from './state/session-catalog'
41
- import { getSessionProjectPath } from './state/session-worktrees'
52
+ import { findMostRecentProject, loadProjectCatalog } from './state/project-catalog'
53
+ import { getActiveWorkspacePath } from './state/project-workspaces'
42
54
  import { loadSnippetCatalog, mergeConfigSnippets } from './state/snippet-catalog'
43
55
  import { createInitialState } from './state/store'
44
56
  import { toast } from './state/toast-store'
@@ -59,14 +71,16 @@ import {
59
71
  isNewerVersion,
60
72
  } from './update/version-check'
61
73
 
62
- const WORKSPACE_SAVE_DEBOUNCE_MS = 250
74
+ const PROJECT_SAVE_DEBOUNCE_MS = 250
63
75
 
64
76
  export function App({
65
77
  backend,
66
78
  resolvedConfig,
79
+ userConfig,
67
80
  }: {
68
81
  backend: SessionBackend
69
82
  resolvedConfig: ResolvedConfig
83
+ userConfig: AimuxUserConfig
70
84
  }) {
71
85
  // Publish the auto-commit enabled flag before any children render so
72
86
  // actions (which live outside React) can read it synchronously.
@@ -104,72 +118,42 @@ export function App({
104
118
  // selector reads. We discard the value — the store is the source of truth.
105
119
  useState(() => {
106
120
  const json = loadConfig()
107
- const sessionBarVisible =
108
- resolvedConfig.sessionBar?.initialVisible ?? json.sessionBarVisible ?? true
109
- const sidebarOverrides = json.sidebar
110
-
121
+ const projectBarVisible =
122
+ resolvedConfig.projectBar?.initialVisible ?? json.projectBarVisible ?? true
111
123
  // Merge config-file gitPane (persisted prefs) with user's resolved gitPane
112
124
  // (programmatic config). User config wins; file provides persisted prior state.
113
125
  const userGitPane = resolvedConfig.gitPane
114
- const fileListMode = userGitPane?.initialFileListMode ?? json.gitPane?.fileListMode ?? 'tree'
115
- const diffModeRatio = userGitPane?.initialDiffModeRatio ?? json.gitPane?.diffModeRatio ?? 0.35
116
- const treeCompaction =
117
- userGitPane?.initialTreeCompaction ?? json.gitPane?.treeCompaction ?? true
118
- const prefetchRadius = userGitPane?.prefetchRadius ?? json.gitPane?.prefetchRadius ?? 5
119
- const persistedPaneRatio = json.gitPane?.paneRatio ?? json.gitPane?.ratio ?? 0.5
120
- const persistedEmbeddedRatio = json.gitPane?.embeddedRatio ?? json.gitPane?.ratio ?? 0.5
121
126
  const gitPaneOverrides = {
122
- ...json.gitPane,
123
- diffModeRatio,
124
- embeddedRatio:
125
- userGitPane?.initialMode === 'embedded' && userGitPane?.initialRatio !== undefined
126
- ? userGitPane.initialRatio
127
- : persistedEmbeddedRatio,
128
- fileListMode,
129
- paneRatio:
130
- userGitPane?.initialMode === 'pane' && userGitPane?.initialRatio !== undefined
131
- ? userGitPane.initialRatio
132
- : persistedPaneRatio,
133
- prefetchRadius,
134
- treeCompaction,
135
- ...(userGitPane?.initialVisible !== undefined ? { visible: userGitPane.initialVisible } : {}),
136
- ...(userGitPane?.initialMode !== undefined ? { mode: userGitPane.initialMode } : {}),
137
- ...(userGitPane?.initialPosition !== undefined
138
- ? { position: userGitPane.initialPosition }
139
- : {}),
140
- ...(userGitPane?.initialDiffModeRatio !== undefined
141
- ? { diffModeRatio: userGitPane.initialDiffModeRatio }
142
- : {}),
127
+ diffModeRatio: userGitPane?.initialDiffModeRatio ?? json.gitPane?.diffModeRatio ?? 0.35,
128
+ fileListMode: userGitPane?.initialFileListMode ?? json.gitPane?.fileListMode ?? 'tree',
129
+ prefetchRadius: userGitPane?.prefetchRadius ?? json.gitPane?.prefetchRadius ?? 5,
130
+ treeCompaction: userGitPane?.initialTreeCompaction ?? json.gitPane?.treeCompaction ?? true,
143
131
  ...(userGitPane?.path !== undefined ? { path: userGitPane.path } : {}),
144
132
  ...(userGitPane?.diffCount !== undefined ? { diffCount: userGitPane.diffCount } : {}),
145
133
  }
146
134
 
147
- const sessionCatalog = loadSessionCatalog()
135
+ const projectCatalog = loadProjectCatalog()
148
136
  const mergedSnippets = mergeConfigSnippets(loadSnippetCatalog(), resolvedConfig.snippets)
149
137
  const initial = createInitialState(
150
138
  json.customCommands,
151
- sessionCatalog,
139
+ projectCatalog,
152
140
  mergedSnippets,
153
- sessionCatalog.length === 0,
141
+ projectCatalog.length === 0,
154
142
  {
143
+ bars: json.bars,
155
144
  gitPane: gitPaneOverrides,
156
- sessionBarVisible,
157
- sidebar: sidebarOverrides,
158
- worktreeTemplates:
159
- resolvedConfig.worktreeTemplates.length > 0
160
- ? resolvedConfig.worktreeTemplates
161
- : json.worktreeTemplates,
145
+ projectBarVisible,
162
146
  }
163
147
  )
164
148
  // Replace the module-level default with the fully-resolved initial state.
165
149
  // Preserves the dispatch baked into the store by app-store.ts.
166
150
  appStore.setState(initial)
167
- const mostRecent = findMostRecentSession(sessionCatalog)
151
+ const mostRecent = findMostRecentProject(projectCatalog)
168
152
  if (mostRecent) {
169
153
  appStore.getState().dispatch({
170
- sessionId: mostRecent.id,
171
- type: 'load-session',
172
- workspaceSnapshot: mostRecent.workspaceSnapshot,
154
+ projectId: mostRecent.id,
155
+ projectSnapshot: mostRecent.projectSnapshot,
156
+ type: 'load-project',
173
157
  })
174
158
  }
175
159
  return null
@@ -182,6 +166,15 @@ export function App({
182
166
  const state = useAppStore((s) => s)
183
167
  const dispatch = state.dispatch
184
168
 
169
+ // Config the settings screen can change under us. The resolved config file is
170
+ // the baseline each of these falls back to.
171
+ const autoCommitConfig = useAutoCommitConfig(resolvedConfig.autoCommit)
172
+ const aiUsageConfig = useAIUsageConfig(resolvedConfig.statusBar?.aiUsage)
173
+ const harmonizeClaudeTheme = useHarmonizeClaudeTheme(
174
+ resolvedConfig.theme?.beta?.harmonizeClaudeTheme
175
+ )
176
+ const snippetTriggerChar = useSnippetTriggerChar(resolvedConfig.snippetTriggerChar)
177
+
185
178
  useLayoutEffect(() => {
186
179
  setActiveDispatch(dispatch)
187
180
  return () => {
@@ -190,14 +183,26 @@ export function App({
190
183
  }
191
184
  }, [dispatch])
192
185
 
186
+ // Replays what the settings screen has written on top of the config file (the
187
+ // setters at the top of this component are its baseline). Deliberately after
188
+ // the effect above and never during render: a row applies its value through
189
+ // whatever owns it, and some of those owners are reached by dispatching.
190
+ // Layout effect, so it lands before the first paint.
191
+ useLayoutEffect(() => {
192
+ hydrateSettings(ALL_SETTING_ROWS, userConfig)
193
+ // Once per launch: the config file is read at startup and does not change
194
+ // under us, and every later write goes through `writeRow`.
195
+ // eslint-disable-next-line react-hooks/exhaustive-deps
196
+ }, [])
197
+
193
198
  useEffect(() => {
194
- if (!(resolvedConfig.theme?.beta?.harmonizeClaudeTheme === true)) return
199
+ if (!harmonizeClaudeTheme) return
195
200
  ensureClaudeSettingsThemePref()
196
201
  syncClaudeTheme(getCurrentTheme(), getCurrentMode())
197
202
  return subscribeThemeChanges((resolved, mode) => {
198
203
  syncClaudeTheme(resolved, mode)
199
204
  })
200
- }, [resolvedConfig.theme?.beta?.harmonizeClaudeTheme])
205
+ }, [harmonizeClaudeTheme])
201
206
 
202
207
  useEffect(() => {
203
208
  // Opt-in via `integrations.claudeHooks` in aimux.config.ts. When enabled,
@@ -210,13 +215,12 @@ export function App({
210
215
  }, [resolvedConfig.integrations.claudeHooks])
211
216
 
212
217
  useEffect(() => {
213
- const aiUsage = resolvedConfig.statusBar?.aiUsage
214
- if (!(aiUsage?.enabled === true)) {
218
+ if (!(aiUsageConfig.enabled === true)) {
215
219
  aiUsageStore.getState().setEnabled(false)
216
220
  return
217
221
  }
218
222
  aiUsageStore.getState().setEnabled(true)
219
- const handle = startAIUsageService(aiUsage, (snap) => {
223
+ const handle = startAIUsageService(aiUsageConfig, (snap) => {
220
224
  aiUsageStore.getState().setSnapshot(snap)
221
225
  })
222
226
  return () => {
@@ -224,7 +228,9 @@ export function App({
224
228
  aiUsageStore.getState().clear()
225
229
  aiUsageStore.getState().setEnabled(false)
226
230
  }
227
- }, [resolvedConfig.statusBar?.aiUsage])
231
+ // Memoized by `useAIUsageConfig`, so toggling the indicator restarts the
232
+ // service and nothing else does.
233
+ }, [aiUsageConfig])
228
234
 
229
235
  useEffect(() => {
230
236
  if (process.env.AIMUX_DISABLE_UPDATE_CHECK === '1') return
@@ -254,16 +260,16 @@ export function App({
254
260
  }, [])
255
261
 
256
262
  useEffect(() => {
257
- // One-shot cleanup: prune `aimux/` branches orphaned by temp worktrees that
263
+ // One-shot cleanup: prune `aimux/` branches orphaned by temp workspaces that
258
264
  // were deleted before delete-time branch cleanup existed. Gated by a config
259
265
  // flag so it runs once per machine. All git calls are best-effort (nothrow)
260
- // and git protects branches still checked out in a live worktree.
266
+ // and git protects branches still checked out in a live workspace.
261
267
  if (loadConfig().prunedOrphanAimuxBranches === true) return
262
268
  void (async () => {
263
269
  const repoRoots = new Set<string>()
264
- for (const session of loadSessionCatalog()) {
265
- for (const worktree of session.worktrees ?? []) {
266
- if (worktree.repoRoot !== '') repoRoots.add(worktree.repoRoot)
270
+ for (const project of loadProjectCatalog()) {
271
+ for (const workspace of project.workspaces ?? []) {
272
+ if (workspace.repoRoot !== '') repoRoots.add(workspace.repoRoot)
267
273
  }
268
274
  }
269
275
  let removed = 0
@@ -282,15 +288,15 @@ export function App({
282
288
  // still the bootstrap default, and useBackendRuntime's attach effect would
283
289
  // then hand 80x24 to the backend, resizing the daemon-persisted PTYs (or
284
290
  // newly-spawned ones) to the wrong size and stranding the assistant's
285
- // already-drawn content in the top-left until a workspace switch.
291
+ // already-drawn content in the top-left until a project switch.
286
292
  const layoutRef = useRef(state.layout)
287
293
  const activeTab = useMemo(
288
294
  () => state.tabs.find((tab) => tab.id === state.activeTabId),
289
295
  [state.activeTabId, state.tabs]
290
296
  )
291
- const currentSession = useMemo(
292
- () => state.sessions.find((session) => session.id === state.currentSessionId),
293
- [state.currentSessionId, state.sessions]
297
+ const currentProject = useMemo(
298
+ () => state.projects.find((project) => project.id === state.currentProjectId),
299
+ [state.currentProjectId, state.projects]
294
300
  )
295
301
  const activeMouseForwardingEnabled = activeTab?.terminalModes.mouseTrackingMode !== 'none'
296
302
  const activeLocalScrollbackEnabled =
@@ -311,11 +317,11 @@ export function App({
311
317
  snippetsRef.current = state.snippets
312
318
  const branchRef = useRef(state.gitPanel.branch)
313
319
  branchRef.current = state.gitPanel.branch
314
- const triggerCharRef = useRef(resolvedConfig.snippetTriggerChar)
315
- triggerCharRef.current = resolvedConfig.snippetTriggerChar
320
+ const triggerCharRef = useRef(snippetTriggerChar)
321
+ triggerCharRef.current = snippetTriggerChar
316
322
 
317
323
  const contentOriginRef = useRef<TerminalContentOrigin>({ cols: 0, rows: 0, x: 0, y: 0 })
318
- const currentSessionWorkspaceSnapshot = currentSession?.workspaceSnapshot
324
+ const currentProjectProjectSnapshot = currentProject?.projectSnapshot
319
325
 
320
326
  // Must run before useBackendRuntime so its open-loop terminalSize seeds
321
327
  // layoutRef before the attach effect reads it. See the comment on layoutRef.
@@ -329,6 +335,15 @@ export function App({
329
335
  })
330
336
  layoutRef.current = { terminalCols: terminalSize.cols, terminalRows: terminalSize.rows }
331
337
 
338
+ // Reaches the setup widget, which hosts its own PTY outside root.tsx's pane
339
+ // tree and so never sees the onMeasure prop chain.
340
+ useLayoutEffect(() => {
341
+ setActiveMeasure(terminalSize.onMeasure)
342
+ return () => {
343
+ setActiveMeasure(null)
344
+ }
345
+ }, [terminalSize.onMeasure])
346
+
332
347
  const syntaxOverlayFlag = resolvedConfig.theme?.beta?.experimentalSyntaxHighlight === true
333
348
  const syntaxOverlayFlagRef = useRef(syntaxOverlayFlag)
334
349
  syntaxOverlayFlagRef.current = syntaxOverlayFlag
@@ -363,18 +378,20 @@ export function App({
363
378
  const { clearIdleTimer, clearStartupGrace, startStartupGrace } = useBackendRuntime({
364
379
  activeTabId: state.activeTabId,
365
380
  backend,
366
- currentSessionId: state.currentSessionId,
367
- currentSessionWorkspaceSnapshot,
381
+ currentProjectId: state.currentProjectId,
382
+ currentProjectProjectSnapshot,
368
383
  dispatch,
369
384
  layoutRef,
370
385
  resizingRef,
371
386
  syntaxOverlayEnabled,
372
387
  })
373
388
 
374
- useWorkspaceAutosave(state, WORKSPACE_SAVE_DEBOUNCE_MS)
389
+ useProjectAutosave(state, PROJECT_SAVE_DEBOUNCE_MS)
375
390
  useDirectorySearch(state.modal, dispatch)
376
391
  useAutoCommitDriver({
377
- config: resolvedConfig.autoCommit,
392
+ // Through the settings screen, so a change lands without a restart. The
393
+ // resolved config is the baseline it falls back to.
394
+ config: autoCommitConfig,
378
395
  dispatch,
379
396
  getProfileConfigRoot: getProfileConfigDir,
380
397
  state,
@@ -382,13 +399,12 @@ export function App({
382
399
  })
383
400
 
384
401
  const {
385
- handleEmbeddedGitResizeStart,
386
- handleGitPaneResizeStart,
402
+ handleBarBoundaryResizeStart,
403
+ handleBarResizeStart,
387
404
  handlePaneActivate,
388
405
  handleSeparatorDrag,
389
406
  handleSeparatorDragEnd,
390
407
  handleSeparatorDragStart,
391
- handleSidebarResizeStart,
392
408
  handleSplitResize,
393
409
  handleTerminalClick,
394
410
  handleTerminalDrag,
@@ -443,9 +459,9 @@ export function App({
443
459
  clearIdleTimer,
444
460
  clearStartupGrace,
445
461
  dispatch,
446
- getCurrentSessionProjectPath: () => {
447
- if (!(state.currentSessionId != null && state.currentSessionId !== '')) return
448
- return getSessionProjectPath(state.sessions.find((s) => s.id === state.currentSessionId))
462
+ getCurrentProjectProjectPath: () => {
463
+ if (!(state.currentProjectId != null && state.currentProjectId !== '')) return
464
+ return getActiveWorkspacePath(state.projects.find((s) => s.id === state.currentProjectId))
449
465
  },
450
466
  // Read straight from the store, not stateRef. stateRef is only refreshed
451
467
  // on render, so within a single JS turn (a click handler that dispatches
@@ -459,6 +475,8 @@ export function App({
459
475
  themeId,
460
476
  }
461
477
 
478
+ useSetupRunner(state, sideEffectCtx)
479
+
462
480
  function processKeyResult(result: KeyResult, modeId: ModeId): void {
463
481
  for (const action of result.actions) {
464
482
  dispatch(action)
@@ -515,12 +533,12 @@ export function App({
515
533
  dispatch({ tabId: target.tabId, type: 'set-active-tab' })
516
534
  return
517
535
  }
518
- if (target.kind === 'workspace' || target.kind === 'worktree') {
536
+ if (target.kind === 'project' || target.kind === 'workspace') {
519
537
  executeSideEffect(
520
538
  {
521
- index: target.sessionIndex,
522
- type: 'switch-session-by-index',
523
- worktreeId: target.worktreeId,
539
+ index: target.projectIndex,
540
+ type: 'switch-project-by-index',
541
+ workspaceId: target.workspaceId,
524
542
  },
525
543
  sideEffectCtx
526
544
  )
@@ -563,12 +581,11 @@ export function App({
563
581
  onTerminalMouseUp={handleTerminalMouseUp}
564
582
  onPaneActivate={handlePaneActivate}
565
583
  onSplitResize={handleSplitResize}
566
- onEmbeddedGitResizeStart={handleEmbeddedGitResizeStart}
567
- onGitPaneResizeStart={handleGitPaneResizeStart}
584
+ onBarBoundaryResizeStart={handleBarBoundaryResizeStart}
585
+ onBarResizeStart={handleBarResizeStart}
568
586
  onSeparatorDragStart={handleSeparatorDragStart}
569
587
  onSeparatorDrag={handleSeparatorDrag}
570
588
  onSeparatorDragEnd={handleSeparatorDragEnd}
571
- onSidebarResizeStart={handleSidebarResizeStart}
572
589
  onMeasure={terminalSize.onMeasure}
573
590
  terminalCols={terminalSize.cols}
574
591
  terminalRows={terminalSize.rows}
Binary file
Binary file
Binary file
@@ -205,10 +205,10 @@ export class DaemonClient {
205
205
  return response.payload
206
206
  }
207
207
 
208
- async listTabs(sessionId: string): Promise<ListTabsResult> {
208
+ async listTabs(projectId: string): Promise<ListTabsResult> {
209
209
  const response = await this.send({
210
210
  id: crypto.randomUUID(),
211
- payload: { sessionId },
211
+ payload: { projectId },
212
212
  type: 'listTabs',
213
213
  })
214
214
  if (response.type !== 'listTabsResult') {