@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,184 @@
1
+ // Notification sounds: the shipped three, plus whatever the user drops in
2
+ // their profile's `sounds/` directory. Shaped like `open-url.ts` — a pure
3
+ // command builder the tests can read, and one impure function that spawns it.
4
+ //
5
+ // Everything here fails quietly. A missing player or an unreadable file is a
6
+ // silent notification, never a toast per event.
7
+
8
+ import { existsSync, readdirSync } from 'node:fs'
9
+ import { dirname, extname, join, resolve } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+
12
+ import { logDebug } from '../debug/input-log'
13
+ import { getProfileConfigDir } from '../profile-paths'
14
+
15
+ /** The value `notifications.sound` holds when the user wants silence. */
16
+ export const SOUND_OFF = 'off'
17
+
18
+ /** Shipped sounds, in the order the settings row offers them. */
19
+ export const BUILTIN_SOUND_IDS = ['plane', 'bell', 'ding'] as const
20
+
21
+ /**
22
+ * Extensions offered from the drop-in directory. `afplay` and `ffplay` handle
23
+ * all of them; `paplay`/`aplay` only really handle wav — which is why the
24
+ * shipped three are wav.
25
+ */
26
+ const SOUND_EXTENSIONS = new Set(['.wav', '.m4a', '.mp3', '.aiff', '.aif', '.ogg', '.flac'])
27
+
28
+ /** No more than one sound per window, however many tabs finish at once. */
29
+ const THROTTLE_MS = 700
30
+
31
+ /**
32
+ * Percent of the file's own level. These are notifications for someone sitting
33
+ * in front of the terminal, not an alarm across the room, and the shipped three
34
+ * are loud at their own scale — hence a default well under full.
35
+ */
36
+ export const DEFAULT_VOLUME = 35
37
+
38
+ /** Where a user drops their own sounds. Named in the settings row. */
39
+ export function getCustomSoundsDir(): string {
40
+ return join(getProfileConfigDir(), 'sounds')
41
+ }
42
+
43
+ /**
44
+ * Absolute path to a shipped sound. Resolved from this module's URL, the same
45
+ * walk-up `resolveHookScriptPath` does, so it works wherever aimux is installed.
46
+ */
47
+ function builtinSoundPath(id: string): string | null {
48
+ const here = dirname(fileURLToPath(import.meta.url))
49
+ const candidates = [
50
+ resolve(here, '..', 'assets', 'sounds', `${id}.wav`),
51
+ resolve(here, '..', '..', 'assets', 'sounds', `${id}.wav`),
52
+ ]
53
+ return candidates.find((candidate) => existsSync(candidate)) ?? null
54
+ }
55
+
56
+ /**
57
+ * Ids of the files the user has dropped in, without their extension. Deduped:
58
+ * `bell-2.wav` and `bell-2.mp3` are one option, and `resolveSoundPath` picks
59
+ * between them the same way every time.
60
+ */
61
+ export function listCustomSoundIds(): string[] {
62
+ try {
63
+ const ids = readdirSync(getCustomSoundsDir())
64
+ .filter((name) => SOUND_EXTENSIONS.has(extname(name).toLowerCase()))
65
+ .map((name) => name.slice(0, -extname(name).length))
66
+ .filter(
67
+ (id) => id !== '' && !BUILTIN_SOUND_IDS.includes(id as (typeof BUILTIN_SOUND_IDS)[number])
68
+ )
69
+ return [...new Set(ids)].sort()
70
+ } catch {
71
+ // No directory is the common case, not an error.
72
+ return []
73
+ }
74
+ }
75
+
76
+ /** The file behind a sound id, shipped or dropped in, or null when there is none. */
77
+ export function resolveSoundPath(id: string): string | null {
78
+ if (id === '' || id === SOUND_OFF) return null
79
+ if (BUILTIN_SOUND_IDS.includes(id as (typeof BUILTIN_SOUND_IDS)[number])) {
80
+ return builtinSoundPath(id)
81
+ }
82
+ const dir = getCustomSoundsDir()
83
+ for (const extension of SOUND_EXTENSIONS) {
84
+ const candidate = join(dir, `${id}${extension}`)
85
+ if (existsSync(candidate)) return candidate
86
+ }
87
+ return null
88
+ }
89
+
90
+ /**
91
+ * How to invoke one player on one file, at `volume` percent. `bin` is whichever
92
+ * player was found; the caller does the looking, so this stays pure.
93
+ *
94
+ * Every player spells volume differently, and two of them cannot say it at all
95
+ * — `aplay` and PowerShell's `SoundPlayer` play at the system level whatever we
96
+ * ask, which the setting's description admits rather than pretending otherwise.
97
+ */
98
+ export function soundPlayerArgv(
99
+ platform: string,
100
+ bin: string,
101
+ path: string,
102
+ volume: number
103
+ ): string[] {
104
+ const percent = Math.min(100, Math.max(0, Math.round(volume)))
105
+ if (platform === 'win32') {
106
+ // The path is a filename the user chose. PowerShell escapes a quote inside
107
+ // a single-quoted string by doubling it; without this a name containing one
108
+ // ends the string and the rest of it is executed.
109
+ const quoted = path.replaceAll("'", "''")
110
+ return [
111
+ bin,
112
+ '-NoProfile',
113
+ '-NonInteractive',
114
+ '-Command',
115
+ `(New-Object Media.SoundPlayer '${quoted}').PlaySync()`,
116
+ ]
117
+ }
118
+ if (bin.endsWith('afplay')) return [bin, '-v', String(percent / 100), path]
119
+ if (bin.endsWith('paplay')) return [bin, `--volume=${Math.round((percent / 100) * 65_536)}`, path]
120
+ if (bin.endsWith('ffplay')) {
121
+ return [bin, '-nodisp', '-autoexit', '-loglevel', 'quiet', '-volume', String(percent), path]
122
+ }
123
+ if (bin.endsWith('mpv')) return [bin, '--no-video', '--really-quiet', `--volume=${percent}`, path]
124
+ return [bin, path]
125
+ }
126
+
127
+ /**
128
+ * Players in preference order. macOS ships afplay; on Linux nothing is
129
+ * guaranteed, so we take the first one installed. `Bun.which` is the lookup —
130
+ * spawning a missing binary would throw once per notification.
131
+ */
132
+ function findPlayer(platform: string): string | null {
133
+ let candidates = ['paplay', 'aplay', 'ffplay', 'mpv']
134
+ if (platform === 'darwin') candidates = ['afplay']
135
+ if (platform === 'win32') candidates = ['powershell.exe', 'powershell']
136
+ for (const candidate of candidates) {
137
+ const found = Bun.which(candidate)
138
+ if (found != null && found !== '') return found
139
+ }
140
+ return null
141
+ }
142
+
143
+ let lastPlayedAt = 0
144
+
145
+ /**
146
+ * Whether enough time has passed since the last sound. Separate from the play
147
+ * itself so the window is testable without a clock.
148
+ */
149
+ export function shouldPlayNow(now: number, previous: number): boolean {
150
+ return now - previous >= THROTTLE_MS
151
+ }
152
+
153
+ /**
154
+ * Play a sound file. Returns whether a player was actually spawned, which is
155
+ * what the settings screen's "Test sound" row reports.
156
+ */
157
+ export function playSoundFile(
158
+ path: string,
159
+ options?: { ignoreThrottle?: boolean; volume?: number }
160
+ ): boolean {
161
+ const now = Date.now()
162
+ const throttled = options?.ignoreThrottle !== true
163
+ if (throttled && !shouldPlayNow(now, lastPlayedAt)) return false
164
+ const platform = process.platform
165
+ const bin = findPlayer(platform)
166
+ if (bin == null) {
167
+ logDebug('platform.playSound.noPlayer', { platform })
168
+ return false
169
+ }
170
+ const argv = soundPlayerArgv(platform, bin, path, options?.volume ?? DEFAULT_VOLUME)
171
+ try {
172
+ Bun.spawn(argv, { stderr: 'ignore', stdin: 'ignore', stdout: 'ignore' })
173
+ // A test press does not start the window: it would swallow a real
174
+ // notification arriving in the next few hundred milliseconds.
175
+ if (throttled) lastPlayedAt = now
176
+ return true
177
+ } catch (error) {
178
+ logDebug('platform.playSound.error', {
179
+ argv,
180
+ error: error instanceof Error ? error.message : String(error),
181
+ })
182
+ return false
183
+ }
184
+ }
@@ -9,7 +9,7 @@ import { logDebug } from '../debug/input-log'
9
9
 
10
10
  interface DirectoryCache {
11
11
  repoPaths: string[]
12
- workspaceSet: Set<string>
12
+ projectSet: Set<string>
13
13
  cachedAt: number
14
14
  }
15
15
 
@@ -38,16 +38,16 @@ async function buildCache(): Promise<DirectoryCache> {
38
38
  parentCount.set(parent, (parentCount.get(parent) ?? 0) + 1)
39
39
  }
40
40
  }
41
- const workspacePaths = [...parentCount.entries()]
41
+ const projectPaths = [...parentCount.entries()]
42
42
  .filter(([, count]) => count >= 2)
43
43
  .map(([p]) => p)
44
44
 
45
- return { cachedAt: Date.now(), repoPaths, workspaceSet: new Set(workspacePaths) }
45
+ return { cachedAt: Date.now(), projectSet: new Set(projectPaths), repoPaths }
46
46
  } catch (error) {
47
47
  logDebug('platform.projectSearch.buildCache.error', {
48
48
  error: error instanceof Error ? error.message : String(error),
49
49
  })
50
- return { cachedAt: Date.now(), repoPaths: [], workspaceSet: new Set() }
50
+ return { cachedAt: Date.now(), projectSet: new Set(), repoPaths: [] }
51
51
  }
52
52
  }
53
53
 
@@ -70,7 +70,7 @@ export async function searchProjectDirectories(query: string): Promise<Directory
70
70
 
71
71
  try {
72
72
  const cache = await getOrBuildCache()
73
- const allPaths = [...cache.repoPaths, ...cache.workspaceSet].join('\n')
73
+ const allPaths = [...cache.repoPaths, ...cache.projectSet].join('\n')
74
74
 
75
75
  const filtered =
76
76
  await $`printf '%s' ${allPaths} | fzf --filter=${query} --no-sort | head -50`.quiet()
@@ -89,12 +89,12 @@ export async function searchProjectDirectories(query: string): Promise<Directory
89
89
 
90
90
  return Promise.all(
91
91
  resultPaths.map(async (path) => {
92
- if (cache.workspaceSet.has(path)) {
93
- return { path, type: 'workspace' as const }
92
+ if (cache.projectSet.has(path)) {
93
+ return { path, type: 'project' as const }
94
94
  }
95
95
  const gitPath = join(path, '.git')
96
96
  const st = await stat(gitPath).catch(() => null)
97
- const type = st !== null && st.isFile() ? 'worktree' : 'git-repo'
97
+ const type = st !== null && st.isFile() ? 'workspace' : 'git-repo'
98
98
  return { path, type } as const
99
99
  })
100
100
  )
@@ -23,18 +23,20 @@ function shortHash(input: string, length = 8): string {
23
23
  return createHash('sha1').update(input).digest('hex').slice(0, length)
24
24
  }
25
25
 
26
+ // Params speak the app's vocabulary (a workspace); the directory layout keeps
27
+ // git's, so existing worktrees on disk stay exactly where they are.
26
28
  export function makeWorktreePath({
27
29
  repoRoot,
28
- worktreeId,
29
- worktreeName,
30
+ workspaceId,
31
+ workspaceName,
30
32
  }: {
31
33
  repoRoot: string
32
- worktreeName: string
33
- worktreeId: string
34
+ workspaceName: string
35
+ workspaceId: string
34
36
  }): string {
35
37
  const repoKey = `r-${shortHash(resolve(repoRoot))}`
36
- const idSuffix = shortHash(worktreeId, 5)
37
- const slug = `${sanitizePathSegment(worktreeName)}-${idSuffix}`
38
+ const idSuffix = shortHash(workspaceId, 5)
39
+ const slug = `${sanitizePathSegment(workspaceName)}-${idSuffix}`
38
40
  return join(getAimuxWorktreeRoot(), repoKey, slug)
39
41
  }
40
42
 
@@ -49,7 +49,7 @@ const PERMISSION_SIGNALS: readonly string[] = [
49
49
  'write this file?',
50
50
  'apply these edits?',
51
51
  'ready to build with this plan?',
52
- 'approve for session',
52
+ 'approve for project',
53
53
  'stop this task?',
54
54
  ]
55
55
 
@@ -3,17 +3,17 @@
3
3
  *
4
4
  * Backends drive this loop to monitor every known terminal at a fixed cadence:
5
5
  * every tick we pull each tab's current viewport, run the detector on the last
6
- * ~10 non-blank lines, and report per-tab + per-session statuses to the
6
+ * ~10 non-blank lines, and report per-tab + per-project statuses to the
7
7
  * backend's transport. The loop is the single source of truth so that idle
8
8
  * tabs can't erase a sibling's waiting-input, and so that clients receive
9
- * status for every session the backend owns, not just the attached one.
9
+ * status for every project the backend owns, not just the attached one.
10
10
  *
11
- * Per-session status is a pair of independent booleans:
11
+ * Per-project status is a pair of independent booleans:
12
12
  * - `working`: at least one tab is working.
13
13
  * - `waiting`: at least one tab is waiting for user input.
14
14
  * Both can be true at the same time — the chip renders one glyph per flag.
15
15
  */
16
- import type { AssistantId, SessionStatus, TabActivity, TerminalSnapshot } from '../state/types'
16
+ import type { AssistantId, ProjectStatus, TabActivity, TerminalSnapshot } from '../state/types'
17
17
 
18
18
  import { logDebug } from '../debug/input-log'
19
19
  import { getLineText } from '../input/terminal-text-extraction'
@@ -50,22 +50,36 @@ export interface LoopTabView {
50
50
  assistant: AssistantId
51
51
  command: string
52
52
  viewport?: TerminalSnapshot
53
+ /** The workspace this tab belongs to, when its owner knows one. Passed
54
+ * straight through to the status callbacks so a client can group tabs it
55
+ * does not itself hold — see the `workspaceId` note in `src/ipc/protocol.ts`. */
56
+ workspaceId?: string
53
57
  }
54
58
 
55
59
  export interface StatusDetectionLoopOptions {
56
- listSessions: () => string[]
57
- listTabs: (sessionId: string) => LoopTabView[]
60
+ listProjects: () => string[]
61
+ listTabs: (projectId: string) => LoopTabView[]
58
62
  /** Emitted when a tab's status changes. */
59
- onTabStatus: (tabId: string, status: TabActivity, sessionId: string) => void
60
- /** Emitted when either flag on a session changes. */
61
- onSessionStatus: (sessionId: string, status: SessionStatus) => void
63
+ onTabStatus: (
64
+ tabId: string,
65
+ status: TabActivity,
66
+ projectId: string,
67
+ workspaceId: string | undefined
68
+ ) => void
69
+ /** Emitted when either flag on a project changes. */
70
+ onProjectStatus: (projectId: string, status: ProjectStatus) => void
62
71
  /**
63
72
  * Emitted once per turn, when a tab's `idle` activity has held continuously
64
73
  * for `turnSettleMs`. Edge-triggered: re-armed only after the tab leaves
65
74
  * `idle`, so a driver receives exactly one signal per turn. `idleMs` is how
66
75
  * long idle had held when the signal fired.
67
76
  */
68
- onTurnComplete?: (tabId: string, sessionId: string, idleMs: number) => void
77
+ onTurnComplete?: (
78
+ tabId: string,
79
+ projectId: string,
80
+ idleMs: number,
81
+ workspaceId: string | undefined
82
+ ) => void
69
83
  /**
70
84
  * Emitted when a tab transitions into `waiting-input`. Edge-triggered: fires
71
85
  * once per transition, carrying the captured prompt text and any parsed
@@ -74,7 +88,7 @@ export interface StatusDetectionLoopOptions {
74
88
  */
75
89
  onTabQuestion?: (
76
90
  tabId: string,
77
- sessionId: string,
91
+ projectId: string,
78
92
  detail: { kind: 'question' | 'permission'; prompt: string; options?: string[] }
79
93
  ) => void
80
94
  tickMs?: number
@@ -89,18 +103,18 @@ export interface StatusDetectionLoopHandle {
89
103
  stop: () => void
90
104
  /** Last classified status for a tab, for on-attach replay. */
91
105
  getTabStatus: (tabId: string) => TabActivity | undefined
92
- /** Last session flags, for on-attach replay. */
93
- getSessionStatus: (sessionId: string) => SessionStatus | undefined
94
- /** Snapshot of every known session's flags. */
95
- snapshotSessions: () => { sessionId: string; status: SessionStatus }[]
96
- /** Snapshot of every known tab's status plus its session. */
97
- snapshotTabs: () => { tabId: string; sessionId: string; status: TabActivity }[]
106
+ /** Last project flags, for on-attach replay. */
107
+ getProjectStatus: (projectId: string) => ProjectStatus | undefined
108
+ /** Snapshot of every known project's flags. */
109
+ snapshotProjects: () => { projectId: string; status: ProjectStatus }[]
110
+ /** Snapshot of every known tab's status plus its project. */
111
+ snapshotTabs: () => { tabId: string; projectId: string; status: TabActivity }[]
98
112
  /**
99
- * Synchronously run classification for a single session. Used on client
113
+ * Synchronously run classification for a single project. Used on client
100
114
  * attach so the replay snapshot is populated *before* the client reads it,
101
115
  * rather than relying on the next scheduled tick.
102
116
  */
103
- classifyNow: (sessionId: string, tabs: LoopTabView[]) => void
117
+ classifyNow: (projectId: string, tabs: LoopTabView[]) => void
104
118
  /**
105
119
  * Feed a Claude Code hook event into the arbiter. The next tick will
106
120
  * combine it with the visual detector's verdict. Called by the daemon's
@@ -117,8 +131,11 @@ export function runStatusDetectionLoop(
117
131
  const now = options.nowFn ?? Date.now
118
132
  const detector = new AssistantStatusDetector()
119
133
  const arbiter = new AssistantStatusArbiter()
120
- const lastTabStatus = new Map<string, { status: TabActivity; sessionId: string }>()
121
- const lastSessionStatus = new Map<string, SessionStatus>()
134
+ const lastTabStatus = new Map<
135
+ string,
136
+ { status: TabActivity; projectId: string; workspaceId?: string }
137
+ >()
138
+ const lastProjectStatus = new Map<string, ProjectStatus>()
122
139
  // Timestamp when a tab most recently entered `idle`. Cleared when it leaves
123
140
  // idle. `turnEmitted` guards against re-firing `onTurnComplete` within the
124
141
  // same idle episode; it's cleared alongside `idleSince` so the next turn
@@ -138,7 +155,7 @@ export function runStatusDetectionLoop(
138
155
  timer.unref?.()
139
156
 
140
157
  function classifySession(
141
- sessionId: string,
158
+ projectId: string,
142
159
  tabs: LoopTabView[],
143
160
  now: number,
144
161
  source: 'tick' | 'classifyNow',
@@ -159,21 +176,21 @@ export function runStatusDetectionLoop(
159
176
  if (status === 'working') working = true
160
177
  if (status === 'waiting-input') waiting = true
161
178
  const prev = lastTabStatus.get(tab.id)
162
- const changed = !prev || prev.status !== status || prev.sessionId !== sessionId
179
+ const changed = !prev || prev.status !== status || prev.projectId !== projectId
163
180
  logDebug('statusLoop.classify', {
164
181
  assistant: tab.assistant,
165
182
  changed,
166
- prevSessionId: prev?.sessionId,
183
+ prevProjectId: prev?.projectId,
167
184
  prevStatus: prev?.status,
168
- sessionId,
185
+ projectId,
169
186
  source,
170
187
  status,
171
188
  tabId: tab.id,
172
189
  tailPreview: tailPreview(tab.viewport),
173
190
  })
174
191
  if (changed) {
175
- lastTabStatus.set(tab.id, { sessionId, status })
176
- options.onTabStatus(tab.id, status, sessionId)
192
+ lastTabStatus.set(tab.id, { projectId, status, workspaceId: tab.workspaceId })
193
+ options.onTabStatus(tab.id, status, projectId, tab.workspaceId)
177
194
  }
178
195
 
179
196
  // Turn-complete bookkeeping. The emission itself is gated to `tick` so a
@@ -189,7 +206,7 @@ export function runStatusDetectionLoop(
189
206
  }
190
207
  if (source === 'tick' && !turnEmitted.has(tab.id) && now - since >= turnSettleMs) {
191
208
  turnEmitted.add(tab.id)
192
- options.onTurnComplete?.(tab.id, sessionId, now - since)
209
+ options.onTurnComplete?.(tab.id, projectId, now - since, tab.workspaceId)
193
210
  }
194
211
  } else {
195
212
  idleSince.delete(tab.id)
@@ -205,59 +222,66 @@ export function runStatusDetectionLoop(
205
222
  options.onTabQuestion
206
223
  ) {
207
224
  const detail = extractQuestion(tab.assistant, tab.viewport)
208
- if (detail) options.onTabQuestion(tab.id, sessionId, detail)
225
+ if (detail) options.onTabQuestion(tab.id, projectId, detail)
209
226
  }
210
227
  }
211
- const next: SessionStatus = { waiting, working }
212
- const prevSession = lastSessionStatus.get(sessionId)
213
- const sessionChanged =
214
- !prevSession || prevSession.working !== working || prevSession.waiting !== waiting
228
+ const next: ProjectStatus = { waiting, working }
229
+ const prevProject = lastProjectStatus.get(projectId)
230
+ const projectChanged =
231
+ !prevProject || prevProject.working !== working || prevProject.waiting !== waiting
215
232
  logDebug('statusLoop.classifySession', {
216
- prev: prevSession,
217
- sessionChanged,
218
- sessionId,
233
+ prev: prevProject,
234
+ projectChanged,
235
+ projectId,
219
236
  source,
220
237
  status: next,
221
238
  tabCount: tabs.length,
222
239
  })
223
- if (sessionChanged) {
224
- lastSessionStatus.set(sessionId, next)
225
- options.onSessionStatus(sessionId, next)
240
+ if (projectChanged) {
241
+ lastProjectStatus.set(projectId, next)
242
+ options.onProjectStatus(projectId, next)
226
243
  }
227
244
  }
228
245
 
229
246
  function tick(): void {
230
247
  const ts = now()
231
- const sessionIds = options.listSessions()
232
- const seenSessions = new Set<string>()
248
+ const projectIds = options.listProjects()
249
+ const seenProjects = new Set<string>()
233
250
  const seenTabs = new Set<string>()
234
251
 
235
- for (const sessionId of sessionIds) {
236
- seenSessions.add(sessionId)
237
- classifySession(sessionId, options.listTabs(sessionId), ts, 'tick', seenTabs)
252
+ for (const projectId of projectIds) {
253
+ seenProjects.add(projectId)
254
+ classifySession(projectId, options.listTabs(projectId), ts, 'tick', seenTabs)
238
255
  }
239
256
 
240
- for (const tabId of lastTabStatus.keys()) {
257
+ for (const [tabId, entry] of lastTabStatus) {
241
258
  if (!seenTabs.has(tabId)) {
242
259
  detector.forget(tabId)
243
260
  arbiter.forget(tabId)
244
261
  lastTabStatus.delete(tabId)
245
262
  idleSince.delete(tabId)
246
263
  turnEmitted.delete(tabId)
264
+ // A gone tab is no longer working or waiting on anyone. Only clients
265
+ // attached to its project hear its `tabExit`, so without this last
266
+ // word a tab that died mid-turn leaves every other client holding a
267
+ // status that will never be corrected.
268
+ if (entry.status !== 'idle') {
269
+ options.onTabStatus(tabId, 'idle', entry.projectId, entry.workspaceId)
270
+ }
247
271
  }
248
272
  }
249
- for (const sessionId of lastSessionStatus.keys()) {
250
- if (!seenSessions.has(sessionId)) {
251
- lastSessionStatus.delete(sessionId)
273
+ for (const projectId of lastProjectStatus.keys()) {
274
+ if (!seenProjects.has(projectId)) {
275
+ lastProjectStatus.delete(projectId)
252
276
  }
253
277
  }
254
278
  }
255
279
 
256
280
  return {
257
- classifyNow: (sessionId, tabs) => {
258
- classifySession(sessionId, tabs, now(), 'classifyNow')
281
+ classifyNow: (projectId, tabs) => {
282
+ classifySession(projectId, tabs, now(), 'classifyNow')
259
283
  },
260
- getSessionStatus: (sessionId) => lastSessionStatus.get(sessionId),
284
+ getProjectStatus: (projectId) => lastProjectStatus.get(projectId),
261
285
  getTabStatus: (tabId) => lastTabStatus.get(tabId)?.status,
262
286
  recordHookEvent: (input) => {
263
287
  const mapped = arbiter.recordHookEvent(input)
@@ -267,11 +291,11 @@ export function runStatusDetectionLoop(
267
291
  paneId: input.paneId,
268
292
  })
269
293
  },
270
- snapshotSessions: () =>
271
- [...lastSessionStatus.entries()].map(([sessionId, status]) => ({ sessionId, status })),
294
+ snapshotProjects: () =>
295
+ [...lastProjectStatus.entries()].map(([projectId, status]) => ({ projectId, status })),
272
296
  snapshotTabs: () =>
273
297
  [...lastTabStatus.entries()].map(([tabId, entry]) => ({
274
- sessionId: entry.sessionId,
298
+ projectId: entry.projectId,
275
299
  status: entry.status,
276
300
  tabId,
277
301
  })),
@@ -280,7 +304,7 @@ export function runStatusDetectionLoop(
280
304
  detector.clear()
281
305
  arbiter.clear()
282
306
  lastTabStatus.clear()
283
- lastSessionStatus.clear()
307
+ lastProjectStatus.clear()
284
308
  idleSince.clear()
285
309
  turnEmitted.clear()
286
310
  },
@@ -5,7 +5,7 @@
5
5
  * (https://github.com/ogulcancelik/herdr, MIT). Rule tables trace back to
6
6
  * `src/detect.rs` in that repo.
7
7
  *
8
- * The detector classifies a terminal session as `working`, `waiting-input`,
8
+ * The detector classifies a terminal project as `working`, `waiting-input`,
9
9
  * or `idle`. Built-in CLIs (claude, codex, opencode, grok, kimi) have dedicated
10
10
  * classify* functions. Custom CLIs fall back to a generic heuristic that
11
11
  * (a) recognises common shells as always-idle and (b) uses pane-tail change
@@ -51,6 +51,13 @@ export class AssistantStatusDetector {
51
51
  if (!viewport) return this.remember(tabId, '', now, 'idle')
52
52
 
53
53
  const tail = extractTailText(viewport, TAIL_LINE_COUNT)
54
+ // Whole visible screen. Claude's Ctrl+T todo overlay pushes the spinner
55
+ // status line above the 10-line tail, so "is it working" has to look at
56
+ // the full frame. "Is it waiting on me" stays tail-scoped.
57
+ // ponytail: a scrolled-away pane falls back to `viewport.tailLines`, capped
58
+ // at SNAPSHOT_TAIL_LINE_COUNT (10) — the overlay still hides the spinner
59
+ // there. Raise that constant if it bites; it grows every render payload.
60
+ const screen = extractTailText(viewport, viewport.lines.length)
54
61
  const prev = this.entries.get(tabId)
55
62
  const changedAt = prev && prev.tail === tail ? prev.changedAt : now
56
63
  const haystack = tail.toLowerCase()
@@ -59,7 +66,7 @@ export class AssistantStatusDetector {
59
66
  return this.remember(tabId, tail, changedAt, 'idle')
60
67
  }
61
68
 
62
- const perCli = classifyBuiltin(assistant, haystack, tail)
69
+ const perCli = classifyBuiltin(assistant, haystack, tail, screen)
63
70
  if (perCli) return this.remember(tabId, tail, changedAt, perCli)
64
71
 
65
72
  const generic = classifyGeneric(haystack, changedAt, now)
@@ -129,11 +136,12 @@ function extractTailText(viewport: TerminalSnapshot, lineCount: number): string
129
136
  function classifyBuiltin(
130
137
  assistant: AssistantId,
131
138
  haystack: string,
132
- rawTail: string
139
+ rawTail: string,
140
+ rawScreen: string
133
141
  ): TabActivity | null {
134
142
  switch (assistant) {
135
143
  case 'claude':
136
- return classifyClaude(haystack, rawTail)
144
+ return classifyClaude(haystack, rawScreen)
137
145
  case 'codex':
138
146
  return classifyCodex(haystack)
139
147
  case 'opencode':
@@ -147,7 +155,15 @@ function classifyBuiltin(
147
155
  }
148
156
  }
149
157
 
150
- function classifyClaude(haystack: string, rawTail: string): TabActivity {
158
+ /**
159
+ * `haystack` is the lowercased 10-line tail, `rawScreen` the whole visible
160
+ * frame. Prompts always land at the bottom, so waiting-input stays on the tail
161
+ * — widening it would let a "Do you want…" from the transcript above fake a
162
+ * pending question. Working markers get the full frame: with the Ctrl+T todo
163
+ * overlay open, Claude drops the `esc to interrupt` hint entirely and the
164
+ * spinner line sits above however many todos are listed.
165
+ */
166
+ function classifyClaude(haystack: string, rawScreen: string): TabActivity {
151
167
  if (
152
168
  haystack.includes('do you want') ||
153
169
  haystack.includes('would you like') ||
@@ -157,15 +173,16 @@ function classifyClaude(haystack: string, rawTail: string): TabActivity {
157
173
  ) {
158
174
  return 'waiting-input'
159
175
  }
176
+ const screen = rawScreen.toLowerCase()
160
177
  if (
161
- haystack.includes('esc/ctrl+c to interrupt') ||
162
- haystack.includes('esc to interrupt') ||
163
- haystack.includes('ctrl+c to interrupt') ||
164
- haystack.includes('esc interrupt')
178
+ screen.includes('esc/ctrl+c to interrupt') ||
179
+ screen.includes('esc to interrupt') ||
180
+ screen.includes('ctrl+c to interrupt') ||
181
+ screen.includes('esc interrupt')
165
182
  ) {
166
183
  return 'working'
167
184
  }
168
- if (hasClaudeSpinner(rawTail)) return 'working'
185
+ if (hasClaudeSpinner(rawScreen)) return 'working'
169
186
  return 'idle'
170
187
  }
171
188
 
@@ -174,8 +191,16 @@ function classifyClaude(haystack: string, rawTail: string): TabActivity {
174
191
  // separators Claude scatters mid-line through its header/footer (e.g.
175
192
  // `Opus 4.7 … · Claude Max`, `… · ← for agents`), which would otherwise pair
176
193
  // with a stray `...` placeholder and mark an idle home screen as working.
177
- function hasClaudeSpinner(rawTail: string): boolean {
178
- for (const line of rawTail.split('\n')) {
194
+ //
195
+ // The ellipsis is also what separates a live status line (`✱ Flowing… (3s · …)`)
196
+ // from a finished one left in the transcript (`✱ Sautéed for 10s`) — that's why
197
+ // scanning the whole screen instead of the tail doesn't resurrect past turns.
198
+ // ponytail: a spinner line frozen mid-`…` (interrupted turn) would pin the pane
199
+ // to `working` until it scrolls off. Chosen failure direction — a false
200
+ // `working` notifies nobody, a false `idle` fires a turn-complete. Cross-check
201
+ // against screen-text velocity (the `(3s` timer ticks) if it ever shows up.
202
+ function hasClaudeSpinner(rawText: string): boolean {
203
+ for (const line of rawText.split('\n')) {
179
204
  const trimmed = line.trimStart()
180
205
  const first = trimmed[0]
181
206
  if (first == null || !CLAUDE_SPINNER_GLYPH_SET.has(first)) continue
@@ -275,8 +300,8 @@ function classifyKimi(haystack: string, rawTail: string): TabActivity {
275
300
  haystack.includes('apply these edits?') ||
276
301
  haystack.includes('stop this task?') ||
277
302
  haystack.includes('ready to build with this plan?') ||
278
- haystack.includes('approve for session') ||
279
- haystack.includes('approve for this session') ||
303
+ haystack.includes('approve for project') ||
304
+ haystack.includes('approve for this project') ||
280
305
  haystack.includes('type feedback') ||
281
306
  haystack.includes('waiting for authorization') ||
282
307
  haystack.includes('sign in to kimi') ||
@@ -297,7 +322,7 @@ function classifyKimi(haystack: string, rawTail: string): TabActivity {
297
322
 
298
323
  // Agent actively streaming / calling tools.
299
324
  // Anchor thinking on ellipsis so the footer model suffix "thinking" alone
300
- // (e.g. "kimi-k2 thinking") does not mark an idle session as working.
325
+ // (e.g. "kimi-k2 thinking") does not mark an idle project as working.
301
326
  if (
302
327
  haystack.includes('working...') ||
303
328
  haystack.includes('working…') ||