@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,227 @@
1
+ import { runCli } from '../services/ai-usage/spawn'
2
+
3
+ export type PrCheckState = 'pass' | 'fail' | 'pending' | 'skipping' | 'cancel'
4
+
5
+ export interface PrCheck {
6
+ name: string
7
+ workflow: string
8
+ state: PrCheckState
9
+ url: string
10
+ durationMs: number | null
11
+ }
12
+
13
+ export interface PrSummary {
14
+ number: number
15
+ title: string
16
+ body: string
17
+ state: string
18
+ isDraft: boolean
19
+ base: string
20
+ head: string
21
+ reviewDecision: string
22
+ /** MERGEABLE | CONFLICTING | UNKNOWN */
23
+ mergeable: string
24
+ /** CLEAN | BLOCKED | BEHIND | UNSTABLE | DIRTY | DRAFT | HAS_HOOKS | UNKNOWN */
25
+ mergeStateStatus: string
26
+ additions: number
27
+ deletions: number
28
+ changedFiles: number
29
+ url: string
30
+ }
31
+
32
+ export type PrStatusResult =
33
+ | { kind: 'ok'; pr: PrSummary; checks: PrCheck[] }
34
+ | { kind: 'no-pr' }
35
+ | { kind: 'no-gh' }
36
+ | { kind: 'error'; message: string }
37
+
38
+ const PR_VIEW_FIELDS = [
39
+ 'number',
40
+ 'title',
41
+ 'body',
42
+ 'state',
43
+ 'isDraft',
44
+ 'url',
45
+ 'baseRefName',
46
+ 'headRefName',
47
+ 'reviewDecision',
48
+ 'mergeable',
49
+ 'mergeStateStatus',
50
+ 'additions',
51
+ 'deletions',
52
+ 'changedFiles',
53
+ 'statusCheckRollup',
54
+ ].join(',')
55
+
56
+ const NOT_AN_ERROR = [
57
+ 'no pull requests found',
58
+ 'no git remotes',
59
+ 'not a git repository',
60
+ 'none of the git remotes',
61
+ 'no default remote',
62
+ ]
63
+
64
+ function str(value: unknown): string {
65
+ return typeof value === 'string' ? value : ''
66
+ }
67
+
68
+ function num(value: unknown): number {
69
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
70
+ }
71
+
72
+ function duration(startedAt: unknown, completedAt: unknown): number | null {
73
+ const start = Date.parse(str(startedAt))
74
+ const end = Date.parse(str(completedAt))
75
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) return null
76
+ return end - start
77
+ }
78
+
79
+ // `gh` documents these buckets for `pr checks --json bucket`; we derive the same
80
+ // classification from the raw rollup so a single `pr view` call covers both the
81
+ // summary and the checks.
82
+ function checkRunState(status: string, conclusion: string): PrCheckState {
83
+ if (status !== 'COMPLETED') return 'pending'
84
+ if (conclusion === 'SUCCESS' || conclusion === 'NEUTRAL') return 'pass'
85
+ if (conclusion === 'SKIPPED') return 'skipping'
86
+ if (conclusion === 'CANCELLED') return 'cancel'
87
+ return 'fail'
88
+ }
89
+
90
+ function statusContextState(state: string): PrCheckState {
91
+ if (state === 'SUCCESS') return 'pass'
92
+ if (state === 'PENDING' || state === 'EXPECTED') return 'pending'
93
+ return 'fail'
94
+ }
95
+
96
+ function toCheck(raw: unknown): PrCheck | null {
97
+ if (typeof raw !== 'object' || raw === null) return null
98
+ const entry = raw as Record<string, unknown>
99
+ if (entry.__typename === 'StatusContext') {
100
+ const name = str(entry.context)
101
+ if (name === '') return null
102
+ return {
103
+ durationMs: null,
104
+ name,
105
+ state: statusContextState(str(entry.state)),
106
+ url: str(entry.targetUrl),
107
+ workflow: '',
108
+ }
109
+ }
110
+ const name = str(entry.name)
111
+ if (name === '') return null
112
+ return {
113
+ durationMs: duration(entry.startedAt, entry.completedAt),
114
+ name,
115
+ state: checkRunState(str(entry.status), str(entry.conclusion)),
116
+ url: str(entry.detailsUrl),
117
+ workflow: str(entry.workflowName),
118
+ }
119
+ }
120
+
121
+ export function parsePrView(raw: unknown): PrStatusResult {
122
+ if (typeof raw !== 'object' || raw === null) return { kind: 'no-pr' }
123
+ const pr = raw as Record<string, unknown>
124
+ if (typeof pr.number !== 'number') return { kind: 'no-pr' }
125
+ const rollup = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : []
126
+ return {
127
+ checks: rollup.map(toCheck).filter((c): c is PrCheck => c !== null),
128
+ kind: 'ok',
129
+ pr: {
130
+ additions: num(pr.additions),
131
+ base: str(pr.baseRefName),
132
+ body: str(pr.body).trim(),
133
+ changedFiles: num(pr.changedFiles),
134
+ deletions: num(pr.deletions),
135
+ head: str(pr.headRefName),
136
+ isDraft: pr.isDraft === true,
137
+ mergeable: str(pr.mergeable),
138
+ mergeStateStatus: str(pr.mergeStateStatus),
139
+ number: pr.number,
140
+ reviewDecision: str(pr.reviewDecision),
141
+ state: str(pr.state),
142
+ title: str(pr.title),
143
+ url: str(pr.url),
144
+ },
145
+ }
146
+ }
147
+
148
+ export type PrAction = 'merge' | null
149
+
150
+ export interface PrActionState {
151
+ label: string
152
+ action: PrAction
153
+ tone: 'ok' | 'blocked' | 'neutral'
154
+ }
155
+
156
+ /**
157
+ * The headline GitHub puts on the merge box, and the one action worth wiring to
158
+ * it. Order matters: a terminal state beats everything, then a hard blocker
159
+ * (conflicts, draft), then whatever the checks are doing. Anything we can't
160
+ * offer an action for still gets an honest label rather than a dead button.
161
+ */
162
+ export function prActionState(pr: PrSummary, checks: PrCheck[]): PrActionState {
163
+ if (pr.state === 'MERGED') return { action: null, label: 'Merged', tone: 'neutral' }
164
+ if (pr.state === 'CLOSED') return { action: null, label: 'Closed', tone: 'blocked' }
165
+ if (pr.isDraft) return { action: null, label: 'Draft', tone: 'neutral' }
166
+ if (pr.mergeable === 'CONFLICTING') {
167
+ return { action: null, label: 'Merge conflicts', tone: 'blocked' }
168
+ }
169
+ if (checks.some((c) => c.state === 'pending')) {
170
+ return { action: null, label: 'Checks running', tone: 'neutral' }
171
+ }
172
+ if (pr.mergeStateStatus === 'BLOCKED') return { action: null, label: 'Blocked', tone: 'blocked' }
173
+ if (pr.mergeStateStatus === 'BEHIND')
174
+ return { action: null, label: 'Out of date', tone: 'blocked' }
175
+ // UNSTABLE means a non-required check failed; GitHub still lets you merge.
176
+ if (pr.mergeStateStatus === 'UNSTABLE') {
177
+ return { action: 'merge', label: 'Checks failing', tone: 'blocked' }
178
+ }
179
+ if (pr.mergeStateStatus === 'CLEAN') {
180
+ return { action: 'merge', label: 'Ready to merge', tone: 'ok' }
181
+ }
182
+ return { action: null, label: 'Checking…', tone: 'neutral' }
183
+ }
184
+
185
+ export interface ClampedBody {
186
+ text: string
187
+ truncated: boolean
188
+ }
189
+
190
+ /**
191
+ * A PR body is a whole document; a bar widget gets a preview of it. Clamping on
192
+ * lines alone breaks on a wall-of-text paragraph and clamping on characters
193
+ * alone breaks on a bullet list, so whichever limit bites first wins.
194
+ */
195
+ export function clampPrBody(body: string, maxLines = 5, maxChars = 260): ClampedBody {
196
+ const full = body.trimEnd()
197
+ const lines = full.split('\n')
198
+ let text = lines.slice(0, maxLines).join('\n')
199
+ if (text.length > maxChars) {
200
+ const space = text.lastIndexOf(' ', maxChars)
201
+ // Only respect a word boundary that isn't absurdly early, else hard-cut.
202
+ text = text.slice(0, space > maxChars / 2 ? space : maxChars)
203
+ }
204
+ text = text.trimEnd()
205
+ return { text, truncated: text.length < full.length }
206
+ }
207
+
208
+ export async function collectPrStatus(cwd: string): Promise<PrStatusResult> {
209
+ const gh = Bun.which('gh')
210
+ if (gh === null) return { kind: 'no-gh' }
211
+
212
+ const result = await runCli(gh, ['pr', 'view', '--json', PR_VIEW_FIELDS], 15_000, cwd)
213
+ if (!result.ok) {
214
+ // `gh` exits non-zero for every "there is simply nothing to show" case too:
215
+ // no PR on the branch, no GitHub remote, or a directory that isn't a repo
216
+ // at all (aimux projects can point anywhere). None of those is an error.
217
+ const stderr = result.stderr.toLowerCase()
218
+ if (NOT_AN_ERROR.some((needle) => stderr.includes(needle))) return { kind: 'no-pr' }
219
+ return { kind: 'error', message: (result.error ?? 'gh failed').slice(0, 200) }
220
+ }
221
+
222
+ try {
223
+ return parsePrView(JSON.parse(result.stdout))
224
+ } catch {
225
+ return { kind: 'no-pr' }
226
+ }
227
+ }
@@ -79,7 +79,7 @@ const cache = new Map<string, CacheEntry>()
79
79
  /**
80
80
  * Cached variant. The cache key is `projectPath` + `maxDepth`: a depth change
81
81
  * invalidates the entry. The cache persists until `invalidateRepoCache()` or
82
- * process exit — discovery is one-shot per session per depth setting.
82
+ * process exit — discovery is one-shot per project per depth setting.
83
83
  */
84
84
  export async function discoverRepos(projectPath: string, maxDepth = 1): Promise<DiscoveredRepo[]> {
85
85
  const existing = cache.get(projectPath)
@@ -5,7 +5,7 @@ import { dispatchGlobal } from '../state/dispatch-ref'
5
5
  import { discoverRepos } from './repo-discovery'
6
6
 
7
7
  /**
8
- * Discover nested git repos inside `projectPath` once per session and push
8
+ * Discover nested git repos inside `projectPath` once per project and push
9
9
  * the result into state. No-op when the multi-repo config flag is off.
10
10
  */
11
11
  export function useRepoDiscovery(projectPath: string | undefined): void {
@@ -0,0 +1,54 @@
1
+ import { useEffect } from 'react'
2
+
3
+ import { appStore } from '../state/app-store'
4
+ import { dispatchGlobal } from '../state/dispatch-ref'
5
+ import { getCurrentBranch } from '../ui/git-branch'
6
+
7
+ const INTERVAL_MS = 4000
8
+
9
+ // Single source of truth for "what git branch is each workspace on right now".
10
+ // Polls every known workspace's path and dispatches update-workspace-record
11
+ // when the live branch differs from the stored one. Components read
12
+ // `workspace.branch` from state — no per-component polling, no flickers.
13
+ //
14
+ // Runs once when enabled; reads projects from the store on each tick so
15
+ // project updates do NOT re-create the effect (which would otherwise feedback
16
+ // off our own dispatches).
17
+ export function useWorkspaceBranchPolling(enabled: boolean): void {
18
+ useEffect(() => {
19
+ if (!enabled) return
20
+
21
+ let cancelled = false
22
+ let timer: ReturnType<typeof setTimeout> | null = null
23
+
24
+ const tick = async () => {
25
+ const projects = appStore.getState().projects
26
+ await Promise.all(
27
+ projects.flatMap((project) =>
28
+ (project.workspaces ?? []).map(async (workspace) => {
29
+ if (workspace.path == null || workspace.path === '') return
30
+ const branch = await getCurrentBranch(workspace.path)
31
+ if (cancelled) return
32
+ if (branch != null && branch !== workspace.branch) {
33
+ dispatchGlobal({
34
+ patch: { branch },
35
+ projectId: project.id,
36
+ type: 'update-workspace-record',
37
+ workspaceId: workspace.id,
38
+ })
39
+ }
40
+ })
41
+ )
42
+ )
43
+ if (cancelled) return
44
+ timer = setTimeout(() => void tick(), INTERVAL_MS)
45
+ }
46
+
47
+ void tick()
48
+
49
+ return () => {
50
+ cancelled = true
51
+ if (timer != null) clearTimeout(timer)
52
+ }
53
+ }, [enabled])
54
+ }
@@ -0,0 +1,65 @@
1
+ import { useEffect } from 'react'
2
+
3
+ import type { BranchDivergence } from '../state/types'
4
+
5
+ import { useAppStore } from '../state/app-store'
6
+ import { dispatchGlobal } from '../state/dispatch-ref'
7
+ import { getBranchDivergence, getWorkspaceDiffStat } from './divergence'
8
+
9
+ const INTERVAL_MS = 4000
10
+
11
+ // Polls per-workspace base divergence for the current project while enabled and
12
+ // dispatches it into workspaceDivergence. Only workspaces that record a baseRef
13
+ // (the aimux-created ones) are measured; the primary and externally-discovered
14
+ // workspaces have no recorded base and are left out.
15
+ export function useWorkspaceDivergencePolling(enabled: boolean): void {
16
+ const currentProjectId = useAppStore((s) => s.currentProjectId)
17
+ const projects = useAppStore((s) => s.projects)
18
+
19
+ useEffect(() => {
20
+ if (!enabled) return
21
+ const project =
22
+ currentProjectId != null && currentProjectId !== ''
23
+ ? projects.find((s) => s.id === currentProjectId)
24
+ : undefined
25
+ const targets = (project?.workspaces ?? []).filter(
26
+ (w) => w.baseRef != null && w.baseRef !== '' && w.branch != null && w.branch !== ''
27
+ )
28
+ if (targets.length === 0) return
29
+
30
+ let cancelled = false
31
+ let timer: ReturnType<typeof setTimeout> | null = null
32
+
33
+ const tick = async () => {
34
+ const entries = await Promise.all(
35
+ targets.map(async (workspace) => {
36
+ const base = workspace.baseRef
37
+ const branch = workspace.branch
38
+ if (base == null || branch == null) return null
39
+ // Commits come from the repo root (comparing two refs); lines come
40
+ // from the workspace itself, so uncommitted work is counted too.
41
+ const [divergence, stat] = await Promise.all([
42
+ getBranchDivergence(workspace.repoRoot, base, branch),
43
+ getWorkspaceDiffStat(workspace.path, base),
44
+ ])
45
+ if (divergence == null) return null
46
+ return [workspace.id, { ...divergence, ...stat }] as const
47
+ })
48
+ )
49
+ if (cancelled) return
50
+ const next: Record<string, BranchDivergence> = {}
51
+ for (const entry of entries) {
52
+ if (entry != null) next[entry[0]] = entry[1]
53
+ }
54
+ dispatchGlobal({ divergence: next, type: 'set-workspace-divergence' })
55
+ timer = setTimeout(() => void tick(), INTERVAL_MS)
56
+ }
57
+
58
+ void tick()
59
+
60
+ return () => {
61
+ cancelled = true
62
+ if (timer != null) clearTimeout(timer)
63
+ }
64
+ }, [enabled, currentProjectId, projects])
65
+ }
@@ -69,6 +69,35 @@ export async function resolveGitRef(repoPath: string, ref: string): Promise<stri
69
69
  return result.text().trim() || undefined
70
70
  }
71
71
 
72
+ // The branch a new workspace forks from unless the user picks another base.
73
+ // `origin/HEAD` is the only authoritative answer; it is missing on repos cloned
74
+ // with --no-checkout or created locally, hence the name probes behind it.
75
+ export async function getDefaultBranch(repoPath: string): Promise<string | undefined> {
76
+ const head = await $`git -C ${repoPath} symbolic-ref --short refs/remotes/origin/HEAD`
77
+ .quiet()
78
+ .nothrow()
79
+ if (head.exitCode === 0) {
80
+ const ref = head.text().trim()
81
+ const short = ref.startsWith('origin/') ? ref.slice('origin/'.length) : ref
82
+ if (short !== '') return short
83
+ }
84
+ const locals = await listLocalBranches(repoPath)
85
+ return ['main', 'master'].find((name) => locals.includes(name))
86
+ }
87
+
88
+ // Rename a local branch. Returns false (without throwing) when git refuses —
89
+ // the target name already exists, most likely — so a failed rename leaves the
90
+ // workspace on the branch it was created with instead of losing it.
91
+ export async function renameGitBranch(
92
+ repoPath: string,
93
+ from: string,
94
+ to: string
95
+ ): Promise<boolean> {
96
+ if (from === '' || to === '' || from === to) return false
97
+ const result = await $`git -C ${repoPath} branch -m ${from} ${to}`.quiet().nothrow()
98
+ return result.exitCode === 0
99
+ }
100
+
72
101
  export async function createGitWorktree({
73
102
  baseRef,
74
103
  branchName,
package/src/index.tsx CHANGED
@@ -5,7 +5,7 @@
5
5
  const command = process.argv[2]
6
6
 
7
7
  // Shell completion. First branch and cheapest: it loads the CLI registry and
8
- // nothing else — no daemon client, no session backend, no renderer.
8
+ // nothing else — no daemon client, no project backend, no renderer.
9
9
  if (command === '__complete') {
10
10
  const { runComplete } = await import('./cli/completion/entry')
11
11
  process.exit(await runComplete(process.argv.slice(3)))
@@ -19,7 +19,7 @@ if (command === 'completion') {
19
19
  // CLI control plane (docs/reference/cli.md). Branch BEFORE the UI bootstrap so
20
20
  // `aimux tab list` from a non-TTY shell never spins up the React renderer.
21
21
  // Dynamic import keeps the CLI code out of the UI's cold-start cost.
22
- const CLI_GROUPS = new Set(['tab', 'workspace', 'worktree', 'worker'])
22
+ const CLI_GROUPS = new Set(['tab', 'project', 'workspace', 'worker'])
23
23
  if (typeof command === 'string' && CLI_GROUPS.has(command)) {
24
24
  const { runCli } = await import('./cli')
25
25
  process.exit(await runCli(process.argv.slice(2)))
@@ -72,8 +72,14 @@ if (command === '--help' || command === '-h' || command === 'help') {
72
72
  process.exit(await runCli([]))
73
73
  }
74
74
 
75
+ // Sequential ON PURPOSE: @opentui/react evaluates @opentui/core as part of its
76
+ // own module graph. Loading both concurrently races the two evaluations and
77
+ // react's chunk can hit `class X extends TextNodeRenderable` while core is
78
+ // still initializing (ReferenceError: cannot access before initialization).
79
+ // react has to wait on core either way, so this costs nothing.
80
+ const { createCliRenderer } = await import('@opentui/core')
81
+
75
82
  const [
76
- { createCliRenderer },
77
83
  { createRoot },
78
84
  { App },
79
85
  { loadUserConfig },
@@ -82,7 +88,6 @@ const [
82
88
  { createSessionBackend },
83
89
  { maybeAutoInstallCompletion },
84
90
  ] = await Promise.all([
85
- import('@opentui/core'),
86
91
  import('@opentui/react'),
87
92
  import('./app'),
88
93
  import('./config/loader'),
@@ -91,7 +96,7 @@ const [
91
96
  import('./session-backend/bootstrap'),
92
97
  import('./cli/completion/install'),
93
98
  ])
94
- const resolvedConfig = await loadUserConfig()
99
+ const { resolved: resolvedConfig, user: userConfig } = await loadUserConfig()
95
100
 
96
101
  // First launch (and after every upgrade): drop the shell completion script in
97
102
  // the conventional location for $SHELL. Silent and best-effort — it writes one
@@ -149,4 +154,4 @@ const backend = await createSessionBackend({
149
154
  })
150
155
  logDebug('index.backendReady', { backend: backend.constructor.name, runtimeProfile })
151
156
 
152
- root.render(<App backend={backend} resolvedConfig={resolvedConfig} />)
157
+ root.render(<App backend={backend} resolvedConfig={resolvedConfig} userConfig={userConfig} />)
@@ -11,15 +11,17 @@ export const HELP_MODE_LABELS: { modeId: ModeId; label: string }[] = [
11
11
  { label: 'Navigation', modeId: 'navigation' },
12
12
  { label: 'Terminal input', modeId: 'terminal-input' },
13
13
  { label: 'Git mode', modeId: 'git-mode' },
14
+ { label: 'Settings', modeId: 'settings' },
14
15
  { label: 'Git commit', modeId: 'modal.git-commit' },
15
16
  { label: 'New tab', modeId: 'modal.new-tab.command-edit' },
16
17
  { label: 'New tab — command', modeId: 'modal.new-tab.command-edit' },
17
- { label: 'Workspace picker', modeId: 'modal.session-picker.filtering' },
18
- { label: 'Workspace picker — filter', modeId: 'modal.session-picker.filtering' },
19
- { label: 'Workspace name', modeId: 'modal.session-name' },
20
- { label: 'Create workspace', modeId: 'modal.create-session' },
18
+ { label: 'Project picker', modeId: 'modal.project-picker.filtering' },
19
+ { label: 'Project picker — filter', modeId: 'modal.project-picker.filtering' },
20
+ { label: 'Project name', modeId: 'modal.project-name' },
21
+ { label: 'Create project', modeId: 'modal.create-project' },
22
+ { label: 'Create workspace', modeId: 'modal.create-workspace' },
21
23
  { label: 'Rename tab', modeId: 'modal.rename-tab' },
22
- { label: 'Rename worktree', modeId: 'modal.rename-worktree' },
24
+ { label: 'Rename workspace', modeId: 'modal.rename-workspace' },
23
25
  { label: 'Snippet picker', modeId: 'modal.snippet-picker.filtering' },
24
26
  { label: 'Snippet picker — filter', modeId: 'modal.snippet-picker.filtering' },
25
27
  { label: 'Snippet editor', modeId: 'modal.snippet-editor' },
@@ -6,18 +6,22 @@ type SupportedModalType = Exclude<ModalType, null>
6
6
  const DIRECT_FOCUS_MODE_IDS: Partial<Record<FocusMode, ModeId>> = {
7
7
  'git': 'git-mode',
8
8
  'navigation': 'navigation',
9
+ 'settings': 'settings',
9
10
  'terminal-input': 'terminal-input',
10
11
  }
11
12
 
12
13
  const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
13
- 'create-session': 'modal.create-session',
14
+ 'create-project': 'modal.create-project',
15
+ 'create-workspace': 'modal.create-workspace',
14
16
  'git-commit': 'modal.git-commit',
15
17
  'help': 'modal.help.filtering',
16
18
  'new-tab': 'modal.new-tab.command-edit',
19
+ 'project-name': 'modal.project-name',
20
+ 'project-picker': 'modal.project-picker.filtering',
17
21
  'rename-tab': 'modal.rename-tab',
18
- 'rename-worktree': 'modal.rename-worktree',
19
- 'session-name': 'modal.session-name',
20
- 'session-picker': 'modal.session-picker.filtering',
22
+ 'rename-workspace': 'modal.rename-workspace',
23
+ 'setting-text': 'modal.setting-text',
24
+ 'settings-search': 'modal.settings-search.filtering',
21
25
  'snippet-editor': 'modal.snippet-editor',
22
26
  'snippet-picker': 'modal.snippet-picker.filtering',
23
27
  'split-picker': 'modal.split-picker',
@@ -27,9 +31,9 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
27
31
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
28
32
  'ai-usage': 'modal.ai-usage',
29
33
  'update-available': 'modal.update-available',
30
- 'worktree-delete-confirm': 'modal.worktree-delete-confirm',
31
- 'worktree-move': 'modal.worktree-move',
32
- 'worktree-move-confirm': 'modal.worktree-move-confirm',
34
+ 'workspace-delete-confirm': 'modal.workspace-delete-confirm',
35
+ 'workspace-move': 'modal.workspace-move',
36
+ 'workspace-move-confirm': 'modal.workspace-move-confirm',
33
37
  }
34
38
 
35
39
  export function deriveModeId(state: AppState): ModeId {
@@ -41,8 +45,8 @@ export function deriveModeId(state: AppState): ModeId {
41
45
 
42
46
  // Overlay on top of git mode without flipping focusMode (like help) — route
43
47
  // input to the picker while it's open even though focus stays 'git'.
44
- if (state.modal.type === 'worktree-move') {
45
- return 'modal.worktree-move'
48
+ if (state.modal.type === 'workspace-move') {
49
+ return 'modal.workspace-move'
46
50
  }
47
51
 
48
52
  // Flash-jump overlay: same pattern — renders on top of navigation, all
@@ -60,9 +64,6 @@ export function deriveModeId(state: AppState): ModeId {
60
64
  if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
61
65
  return 'modal.new-tab.editing-command'
62
66
  }
63
- if (state.modal.type === 'new-tab' && state.modal.worktreeDeletePrompt !== null) {
64
- return 'modal.new-tab.worktree-delete-confirm'
65
- }
66
67
  if (state.modal.type === 'git-commit' && state.modal.stage === 'confirm') {
67
68
  return 'modal.git-commit.confirm'
68
69
  }
@@ -1,4 +1,4 @@
1
- import type { AppAction } from '../../../state/types'
1
+ import type { AppAction } from '../../../state/actions'
2
2
  import type { KeyInput, KeyResult, ModeId, SideEffect } from '../types'
3
3
 
4
4
  type SelectionDelta = -1 | 1
@@ -1,51 +1,64 @@
1
1
  import type { ModeId } from './types'
2
2
 
3
3
  const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
- 'git-mode': ['navigation', 'modal.git-commit', 'modal.worktree-move'],
4
+ 'git-mode': ['navigation', 'modal.git-commit', 'modal.workspace-move'],
5
5
  'modal.ai-usage': ['navigation', 'terminal-input'],
6
- 'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
6
+ 'modal.create-project': ['navigation', 'modal.project-picker.filtering'],
7
+ 'modal.create-workspace': ['navigation', 'terminal-input'],
7
8
  'modal.flash-jump': ['navigation'],
8
9
  'modal.git-commit': ['git-mode', 'modal.git-commit.confirm', 'modal.git-commit.generating'],
9
10
  'modal.git-commit.confirm': ['modal.git-commit', 'git-mode'],
10
11
  'modal.git-commit.generating': ['modal.git-commit', 'modal.git-commit.confirm', 'git-mode'],
11
12
  'modal.help.filtering': ['navigation'],
12
- 'modal.new-tab.command-edit': [
13
- 'navigation',
14
- 'modal.new-tab.editing-command',
15
- 'modal.new-tab.worktree-delete-confirm',
16
- ],
13
+ 'modal.new-tab.command-edit': ['navigation', 'modal.new-tab.editing-command'],
17
14
  'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
18
- 'modal.new-tab.worktree-delete-confirm': ['navigation', 'modal.new-tab.command-edit'],
15
+ 'modal.project-name': ['modal.project-picker.filtering', 'navigation'],
16
+ 'modal.project-picker.filtering': ['navigation', 'modal.project-name', 'modal.create-project'],
19
17
  'modal.rename-tab': ['navigation'],
20
- 'modal.rename-worktree': ['navigation'],
21
- 'modal.session-name': ['modal.session-picker.filtering', 'navigation'],
22
- 'modal.session-picker.filtering': ['navigation', 'modal.session-name', 'modal.create-session'],
18
+ 'modal.rename-workspace': ['navigation'],
19
+ 'modal.setting-text': ['settings'],
20
+ 'modal.settings-search.filtering': ['settings'],
23
21
  'modal.snippet-editor': ['navigation', 'modal.snippet-picker.filtering'],
24
- 'modal.snippet-picker.filtering': ['navigation', 'modal.snippet-editor'],
22
+ // Both pickers are reachable from the settings screen as well as from the
23
+ // panes, and `returnTo` sends each one back where it came from.
24
+ 'modal.snippet-picker.filtering': ['navigation', 'settings', 'modal.snippet-editor'],
25
25
  'modal.split-picker': ['navigation', 'terminal-input'],
26
- 'modal.theme-picker.filtering': ['navigation'],
26
+ 'modal.theme-picker.filtering': ['navigation', 'settings'],
27
27
  'modal.update-available': ['navigation'],
28
- 'modal.worktree-delete-confirm': ['navigation'],
29
- 'modal.worktree-move': ['git-mode', 'navigation'],
30
- 'modal.worktree-move-confirm': ['navigation'],
28
+ 'modal.workspace-delete-confirm': ['navigation'],
29
+ 'modal.workspace-move': ['git-mode', 'navigation'],
30
+ 'modal.workspace-move-confirm': ['navigation'],
31
31
  'navigation': [
32
32
  'terminal-input',
33
+ 'modal.create-workspace',
33
34
  'modal.new-tab.command-edit',
34
35
  'modal.new-tab.editing-command',
35
- 'modal.session-picker.filtering',
36
+ 'modal.project-picker.filtering',
36
37
  'modal.help.filtering',
37
38
  'modal.snippet-picker.filtering',
38
39
  'modal.theme-picker.filtering',
39
40
  'modal.rename-tab',
40
- 'modal.rename-worktree',
41
+ 'modal.rename-workspace',
41
42
  'modal.update-available',
42
43
  'modal.ai-usage',
43
- 'modal.worktree-delete-confirm',
44
- 'modal.worktree-move-confirm',
44
+ 'modal.workspace-delete-confirm',
45
+ 'modal.workspace-move-confirm',
45
46
  'modal.flash-jump',
46
47
  'git-mode',
48
+ 'settings',
49
+ ],
50
+ // The help overlay opens over settings without a transition (it leaves
51
+ // focusMode alone). The two pickers an action row hands over to are dispatched
52
+ // directly rather than through a KeyResult, but they are listed because that is
53
+ // what this table is for: saying where a mode can go.
54
+ 'settings': [
55
+ 'navigation',
56
+ 'modal.setting-text',
57
+ 'modal.settings-search.filtering',
58
+ 'modal.theme-picker.filtering',
59
+ 'modal.snippet-picker.filtering',
47
60
  ],
48
- 'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage'],
61
+ 'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage', 'settings'],
49
62
  }
50
63
 
51
64
  export function isValidTransition(from: ModeId, to: ModeId): boolean {