@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,253 @@
1
+ import { type MouseEvent as OtuiMouseEvent, TextAttributes } from '@opentui/core'
2
+ import { memo, useCallback, useMemo } from 'react'
3
+
4
+ import type { ProjectRecord, WorkspaceActivity, WorkspaceRecord } from '../../../../state/types'
5
+ import type { SpriteState } from '../../../terminal-graphics/sprites'
6
+
7
+ import { useAppStore } from '../../../../state/app-store'
8
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
9
+ import { formatDiffStat } from '../../../../state/project-workspaces'
10
+ // eslint-disable-next-line no-duplicate-imports
11
+ import { IDLE_WORKSPACE_ACTIVITY } from '../../../../state/types'
12
+ import { useActivitySprite } from '../../../hooks/use-activity-sprite'
13
+ import { useBusySpinner } from '../../../hooks/use-busy-spinner'
14
+ // eslint-disable-next-line no-duplicate-imports
15
+ import { SPRITE_COLS } from '../../../terminal-graphics/sprites'
16
+ import { useBaseTheme, useTheme } from '../../../theme'
17
+ import { truncate } from '../../../truncate'
18
+ import { FlashLabelBadge } from '../../flash/flash-label-badge'
19
+ import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
20
+
21
+ /** Same priority as the glyphs below, so a sprite says what the glyph would. */
22
+ function spriteStateFor(activity: WorkspaceActivity): SpriteState {
23
+ if (activity.waiting) return 'waiting'
24
+ if (activity.working) return 'working'
25
+ if (activity.done) return 'done'
26
+ return 'idle'
27
+ }
28
+
29
+ interface WorkspaceRowProps {
30
+ project: ProjectRecord
31
+ workspace: WorkspaceRecord
32
+ /** 1-based index of the project in the visible order (for switch-project-by-index). */
33
+ projectIndex: number
34
+ /** True when this row is the active cursor item. */
35
+ isActiveItem: boolean
36
+ /** True when this row's project is the current project (selection scope). */
37
+ inCurrentGroup: boolean
38
+ contentWidth: number
39
+ }
40
+
41
+ export const WorkspaceRow = memo(function WorkspaceRow({
42
+ contentWidth,
43
+ inCurrentGroup,
44
+ isActiveItem,
45
+ project,
46
+ projectIndex,
47
+ workspace,
48
+ }: WorkspaceRowProps) {
49
+ const t = useTheme()
50
+ // Selection highlight must stay opaque in transparent mode.
51
+ const base = useBaseTheme()
52
+ const currentProjectId = useAppStore((s) => s.currentProjectId)
53
+ const isCurrentProject = project.id === currentProjectId
54
+ const divergence = useAppStore((s) => s.workspaceDivergence[workspace.id])
55
+ const activity = useAppStore((s) => s.workspaceActivity[workspace.id]) ?? IDLE_WORKSPACE_ACTIVITY
56
+ // A sprite is placed across the two lines of the row, so only a row that has
57
+ // both — the branch line under the name — can carry one. A branchless checkout
58
+ // keeps the text marker rather than getting a second placement geometry of its
59
+ // own: one shape is what keeps a cell from resolving to the wrong image.
60
+ const hasBranch = workspace.branch != null && workspace.branch !== ''
61
+ const sprite = useActivitySprite(hasBranch ? spriteStateFor(activity) : null)
62
+ // Only the working case animates, so the timer is off for every other row —
63
+ // and off entirely when a sprite is drawing this row instead.
64
+ const spinner = useBusySpinner(activity.working && sprite === null)
65
+
66
+ const handleMouseDown = useCallback(
67
+ (event: OtuiMouseEvent) => {
68
+ if (event.button !== 0) return
69
+ event.preventDefault()
70
+ event.stopPropagation()
71
+ if (isCurrentProject) {
72
+ dispatchGlobal({
73
+ projectId: project.id,
74
+ type: 'set-active-workspace',
75
+ workspaceId: workspace.id,
76
+ })
77
+ return
78
+ }
79
+ // Cross-project click: send the workspace id along with the switch so
80
+ // the side effect can apply it atomically. Splitting it into a separate
81
+ // dispatch+switch leaves a window where subscribers/re-renders can
82
+ // re-assert the target project's last-persisted workspace.
83
+ runSideEffectGlobal({
84
+ index: projectIndex,
85
+ type: 'switch-project-by-index',
86
+ workspaceId: workspace.id,
87
+ })
88
+ },
89
+ [isCurrentProject, project.id, projectIndex, workspace.id]
90
+ )
91
+
92
+ const rightClickMenu = useMemo<[string, () => void][] | undefined>(() => {
93
+ const entries: [string, () => void][] = [
94
+ [
95
+ 'Rename',
96
+ () =>
97
+ dispatchGlobal({
98
+ initialName: workspace.name,
99
+ projectId: project.id,
100
+ type: 'open-rename-workspace-modal',
101
+ workspaceId: workspace.id,
102
+ }),
103
+ ],
104
+ ]
105
+ if (workspace.source !== 'primary') {
106
+ entries.push([
107
+ 'Remove workspace',
108
+ () =>
109
+ // Always confirm first. Confirming routes through the full delete side
110
+ // effect (closes the workspace's tabs, disposes their PTYs, prunes the
111
+ // snapshot, removes the git worktree). closeTabs (not force) cleans up
112
+ // the tabs while keeping the non-force `git worktree remove`, so
113
+ // uncommitted work in a temp workspace is still protected — a dirty
114
+ // workspace re-prompts for an explicit force-delete.
115
+ dispatchGlobal({
116
+ closeTabs: true,
117
+ force: false,
118
+ projectId: project.id,
119
+ reason: 'Its assistant tabs will be closed and the worktree removed.',
120
+ type: 'open-workspace-delete-confirm',
121
+ workspaceId: workspace.id,
122
+ workspaceLabel: workspace.branch ?? workspace.name,
123
+ }),
124
+ ])
125
+ }
126
+ return entries
127
+ }, [project.id, workspace.branch, workspace.id, workspace.name, workspace.source])
128
+
129
+ let bgColor: string | undefined
130
+ if (isActiveItem) {
131
+ bgColor = base.backgroundElement
132
+ } else if (inCurrentGroup) {
133
+ bgColor = base.backgroundPanel
134
+ }
135
+ // The cursor: a full-height accent bar down the left of the row, both lines
136
+ // of it. The background alone is one step of grey and gets lost among rows
137
+ // that carry colour of their own; a bar the height of the row is found
138
+ // without reading anything.
139
+ const cursorGlyph = isActiveItem ? '▌' : ' '
140
+
141
+ // The marker slot: two cells of text, or the wider box a sprite needs. The
142
+ // bundled set covers every state, so with sprites on, every two-line row uses
143
+ // the same width whatever its state — only a branchless row keeps the narrow
144
+ // slot, and its name sits one cell left of its siblings'. Accepted: the
145
+ // alternative is a second placement geometry, which is what mis-sized every
146
+ // sprite in the first place.
147
+ const markerCells = sprite === null ? 2 : SPRITE_COLS
148
+
149
+ const { added, removed } = formatDiffStat(divergence)
150
+ const statWidth = added.length + removed.length + (added !== '' && removed !== '' ? 1 : 0)
151
+ const nameLabel = truncate(
152
+ workspace.name,
153
+ Math.max(0, contentWidth - 2 - markerCells - (statWidth > 0 ? statWidth + 1 : 0))
154
+ )
155
+ // Second line, hanging under the name, and where the branch glyph lives — it
156
+ // labels the branch, not the workspace. Only workspaces that own a branch get
157
+ // one — an external checkout without one would otherwise be a row of a
158
+ // different height for no information.
159
+ // The glyph starts where the name above it starts, so the branch hangs one
160
+ // glyph-width in from it — the row reads as a title with a labelled line
161
+ // under it, not as two columns.
162
+ const branchLabel = !hasBranch
163
+ ? null
164
+ : truncate(workspace.branch ?? '', Math.max(0, contentWidth - 4 - markerCells))
165
+
166
+ // Same vocabulary as the project heading one level up, in the same priority:
167
+ // a question outranks work in progress, which outranks "it finished and you
168
+ // haven't looked". A space when there is nothing to say — the row keeps its
169
+ // width either way, so nothing shifts.
170
+ // Every text marker is two cells, the glyph and the gap after it, so nothing
171
+ // shifts when an agent starts or stops. The sprite is the one exception and
172
+ // carries its own width — see markerCells above.
173
+ let statusGlyph = ' '
174
+ let statusColor = t.textMuted
175
+ if (sprite) {
176
+ statusGlyph = sprite.glyphs[0] ?? ' '
177
+ statusColor = sprite.fg
178
+ } else if (activity.waiting) {
179
+ statusGlyph = '? '
180
+ statusColor = t.warning
181
+ } else if (activity.working) {
182
+ statusGlyph = `${spinner} `
183
+ statusColor = t.primary
184
+ } else if (activity.done) {
185
+ statusGlyph = '● '
186
+ statusColor = t.success
187
+ }
188
+
189
+ return (
190
+ <ContextMenuBox
191
+ id={`sidebar-wt-${workspace.id}`}
192
+ flexDirection="column"
193
+ flexShrink={0}
194
+ paddingRight={1}
195
+ backgroundColor={bgColor}
196
+ rightClickMenu={rightClickMenu}
197
+ onMouseDown={handleMouseDown}
198
+ >
199
+ <box flexDirection="row" alignItems="center">
200
+ <text fg={t.primary} selectable={false} wrapMode="none">
201
+ {cursorGlyph}
202
+ </text>
203
+ <text fg={statusColor} selectable={false} wrapMode="none">
204
+ {statusGlyph}
205
+ </text>
206
+ <FlashLabelBadge rowKey={`wt:${workspace.id}`} />
207
+ {/* Full strength only under the cursor. Every name at full strength
208
+ reads as a wall of white; every name muted leaves the selected row
209
+ with nothing but a shade of grey to say so. */}
210
+ <text
211
+ fg={isActiveItem ? t.text : t.textMuted}
212
+ attributes={isActiveItem ? TextAttributes.BOLD : undefined}
213
+ selectable={false}
214
+ wrapMode="none"
215
+ >
216
+ {nameLabel}
217
+ </text>
218
+ <box flexGrow={1} flexShrink={1} />
219
+ {added !== '' ? (
220
+ <text fg={t.success} selectable={false} wrapMode="none">
221
+ {added}
222
+ </text>
223
+ ) : null}
224
+ {added !== '' && removed !== '' ? (
225
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
226
+ {' '}
227
+ </text>
228
+ ) : null}
229
+ {removed !== '' ? (
230
+ <text fg={t.error} selectable={false} wrapMode="none">
231
+ {removed}
232
+ </text>
233
+ ) : null}
234
+ </box>
235
+ {branchLabel == null ? null : (
236
+ <box flexDirection="row" alignItems="center">
237
+ <text fg={t.primary} selectable={false} wrapMode="none">
238
+ {cursorGlyph}
239
+ </text>
240
+ {/* The marker slot again, so a sprite tall enough to want both lines
241
+ gets its bottom half. Two cells either way, which is what the
242
+ branch glyph has always hung off. */}
243
+ <text fg={statusColor} selectable={false} wrapMode="none">
244
+ {sprite?.glyphs[1] ?? ' '}
245
+ </text>
246
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
247
+ {'\u{e702}'} {branchLabel}
248
+ </text>
249
+ </box>
250
+ )}
251
+ </ContextMenuBox>
252
+ )
253
+ })
@@ -44,6 +44,8 @@ function getModeLabel(focusMode: AppState['focusMode']): string {
44
44
  return 'EDIT'
45
45
  case 'git':
46
46
  return 'GIT'
47
+ case 'settings':
48
+ return 'SET'
47
49
  case 'navigation':
48
50
  default:
49
51
  return 'NORMAL'
@@ -67,6 +69,8 @@ function getModeColor(focusMode: AppState['focusMode'], t: ResolvedTuiTheme): st
67
69
  return t.warning
68
70
  case 'git':
69
71
  return t.success
72
+ case 'settings':
73
+ return t.secondary
70
74
  case 'navigation':
71
75
  default:
72
76
  return t.text
@@ -128,7 +132,7 @@ export function StatusBar() {
128
132
  const tileX = t.backgroundElement
129
133
  const tileY = modeColor
130
134
 
131
- const hasB = model.sessionSegments.length > 0
135
+ const hasB = model.projectSegments.length > 0
132
136
  const hasX = aiEnabled
133
137
 
134
138
  return (
@@ -161,7 +165,7 @@ export function StatusBar() {
161
165
  flexShrink={1}
162
166
  overflow="hidden"
163
167
  >
164
- <Segments segments={model.sessionSegments} t={t} />
168
+ <Segments segments={model.projectSegments} t={t} />
165
169
  </box>
166
170
  <Separator glyph={glyphs.right} bg={tileFiller} fg={tileB} />
167
171
  </>
@@ -48,15 +48,15 @@ function getTitle(
48
48
  tab: TabSession | undefined,
49
49
  isActive: boolean,
50
50
  focusMode: TerminalPaneProps['focusMode'],
51
- emptyContext: { workspaceName: string; worktreeName: string }
51
+ emptyContext: { projectName: string; workspaceName: string }
52
52
  ): string {
53
53
  if (!tab) {
54
- const { workspaceName, worktreeName } = emptyContext
55
- if (workspaceName === '' && worktreeName === '') return 'No active workspace'
56
- if (worktreeName === '' || worktreeName === workspaceName) {
57
- return `${workspaceName} · no tabs`
54
+ const { projectName, workspaceName } = emptyContext
55
+ if (projectName === '' && workspaceName === '') return 'No active project'
56
+ if (workspaceName === '' || workspaceName === projectName) {
57
+ return `${projectName} · no tabs`
58
58
  }
59
- return `${workspaceName} / ${worktreeName} · no tabs`
59
+ return `${projectName} / ${workspaceName} · no tabs`
60
60
  }
61
61
 
62
62
  if (isActive && focusMode === 'terminal-input') {
@@ -115,7 +115,7 @@ function renderSpan(span: TerminalSpan, key: string, softCursor: boolean): React
115
115
  )
116
116
  }
117
117
 
118
- interface TerminalViewportProps {
118
+ export interface TerminalViewportProps {
119
119
  viewport: TerminalSnapshot | undefined
120
120
  buffer: string
121
121
  softCursor: boolean
@@ -179,7 +179,7 @@ function useHardwareCursor(
179
179
  // Cursor state only reaches the terminal with the next rendered frame
180
180
  // (same reason opentui's editor calls requestRender() in focus/blur).
181
181
  // Without it, a hide issued while the UI is static — e.g. switching to
182
- // a workspace with no live PTY — never flushes and the host cursor
182
+ // a project with no live PTY — never flushes and the host cursor
183
183
  // stays stranded at its old position.
184
184
  renderer.requestRender()
185
185
  // React runs all cleanups in a commit before all effects, so when focus
@@ -215,7 +215,7 @@ const pinTerminalScroll = (node: TextRenderable | null): void => {
215
215
  if (node.scrollY !== 0) node.scrollY = 0
216
216
  }
217
217
 
218
- const TerminalViewport = memo(function TerminalViewport({
218
+ export const TerminalViewport = memo(function TerminalViewport({
219
219
  buffer,
220
220
  softCursor,
221
221
  viewport,
@@ -239,7 +239,7 @@ const TerminalViewport = memo(function TerminalViewport({
239
239
 
240
240
  return (
241
241
  <text ref={pinTerminalScroll} fg={t.text} wrapMode="none">
242
- {buffer.length > 0 ? buffer : 'Waiting for workspace output...'}
242
+ {buffer.length > 0 ? buffer : 'Waiting for project output...'}
243
243
  </text>
244
244
  )
245
245
  })
@@ -279,24 +279,24 @@ export function TerminalPane({
279
279
  paneIsActive && focusMode === 'terminal-input' && tab?.status === 'running'
280
280
  )
281
281
  // These are only used when this pane is rendered without a tab (the
282
- // top-level pane on a worktree with zero tabs). Selectors return plain
282
+ // top-level pane on a workspace with zero tabs). Selectors return plain
283
283
  // strings so re-renders are cheap and bounded to actual name changes.
284
- const emptyWorkspaceName = useAppStore((s) => {
285
- const id = s.currentSessionId
284
+ const emptyProjectName = useAppStore((s) => {
285
+ const id = s.currentProjectId
286
286
  if (id == null || id === '') return ''
287
- return s.sessions.find((sess) => sess.id === id)?.name ?? ''
287
+ return s.projects.find((sess) => sess.id === id)?.name ?? ''
288
288
  })
289
- const emptyWorktreeName = useAppStore((s) => {
290
- const id = s.currentSessionId
289
+ const emptyWorkspaceName = useAppStore((s) => {
290
+ const id = s.currentProjectId
291
291
  if (id == null || id === '') return ''
292
- const session = s.sessions.find((sess) => sess.id === id)
293
- if (!session) return ''
292
+ const project = s.projects.find((sess) => sess.id === id)
293
+ if (!project) return ''
294
294
  const activeId =
295
- session.activeWorktreeId != null && session.activeWorktreeId !== ''
296
- ? session.activeWorktreeId
297
- : session.worktrees?.[0]?.id
295
+ project.activeWorkspaceId != null && project.activeWorkspaceId !== ''
296
+ ? project.activeWorkspaceId
297
+ : project.workspaces?.[0]?.id
298
298
  if (activeId == null || activeId === '') return ''
299
- return session.worktrees?.find((w) => w.id === activeId)?.name ?? ''
299
+ return project.workspaces?.find((w) => w.id === activeId)?.name ?? ''
300
300
  })
301
301
  const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
302
302
  const canUseLocalScrollback = focusMode === 'terminal-input' && !!tab && localScrollbackEnabled
@@ -510,7 +510,9 @@ export function TerminalPane({
510
510
  )
511
511
  const handleNoTabMouseDown = useCallback((event: OtuiMouseEvent) => {
512
512
  event.stopPropagation()
513
- dispatchGlobal({ type: 'open-new-tab-modal' })
513
+ // Opens the picker, or the create-workspace modal when there is no
514
+ // workspace to put a tab in — the effect owns that rule.
515
+ runSideEffectGlobal({ type: 'open-new-tab' })
514
516
  }, [])
515
517
  const handleContentMouseDown = useCallback(
516
518
  (e: OtuiMouseEvent) => {
@@ -525,8 +527,8 @@ export function TerminalPane({
525
527
  border
526
528
  borderColor={getBorderColor(paneIsActive, focusMode)}
527
529
  title={getTitle(tab, paneIsActive, focusMode, {
530
+ projectName: emptyProjectName,
528
531
  workspaceName: emptyWorkspaceName,
529
- worktreeName: emptyWorktreeName,
530
532
  })}
531
533
  padding={0}
532
534
  flexDirection="column"
@@ -542,19 +544,22 @@ export function TerminalPane({
542
544
  <box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column">
543
545
  <text fg={t.textMuted}>· · ·</text>
544
546
  <text fg={t.textMuted}> </text>
545
- {emptyWorkspaceName !== '' ? (
547
+ {emptyProjectName !== '' ? (
546
548
  <box flexDirection="row">
547
- <text fg={t.text}>{emptyWorkspaceName}</text>
548
- {emptyWorktreeName !== '' && emptyWorktreeName !== emptyWorkspaceName ? (
549
+ <text fg={t.text}>{emptyProjectName}</text>
550
+ {emptyWorkspaceName !== '' && emptyWorkspaceName !== emptyProjectName ? (
549
551
  <>
550
552
  <text fg={t.textMuted}> / </text>
551
- <text fg={t.text}>{emptyWorktreeName}</text>
553
+ <text fg={t.text}>{emptyWorkspaceName}</text>
552
554
  </>
553
555
  ) : null}
554
556
  </box>
555
557
  ) : null}
556
558
  <text fg={t.textMuted}>has no tabs</text>
557
559
  <text fg={t.textMuted}> </text>
560
+ {/* Keep this at a fixed row count: adding a line shifts everything
561
+ under a centred column, and the vacated cells are not repainted
562
+ — the old text bleeds through the new spaces. */}
558
563
  <box flexDirection="row">
559
564
  <text fg={t.textMuted}>Press </text>
560
565
  <text fg={t.primary}>Ctrl+n</text>
@@ -598,7 +603,7 @@ export function TerminalPane({
598
603
  // re-introducing the shifted-content / dead-row bug.
599
604
  <box position="absolute" bottom={0} left={0} backgroundColor={editorBg}>
600
605
  {tab?.status === 'disconnected' ? (
601
- <text fg={t.warning}>Restored snapshot. Press Ctrl+r to restart this workspace.</text>
606
+ <text fg={t.warning}>Restored snapshot. Press Ctrl+r to restart this project.</text>
602
607
  ) : null}
603
608
  {tab?.errorMessage != null && tab?.errorMessage !== '' ? (
604
609
  <text fg={t.error}>{tab.errorMessage}</text>
@@ -8,21 +8,26 @@ import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 're
8
8
 
9
9
  import type { FocusMode, TabSession } from '../../../state/types'
10
10
 
11
- import { useWorktreeDivergencePolling } from '../../../git/worktree-divergence-poller'
11
+ import { useWorkspaceDivergencePolling } from '../../../git/workspace-divergence-poller'
12
12
  import { useAppStore } from '../../../state/app-store'
13
13
  import { getBarWidth } from '../../../state/bars'
14
14
  import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
15
- import { filterTabsForActiveWorktree } from '../../../state/session-worktrees'
15
+ import { filterTabsForActiveWorkspace } from '../../../state/project-workspaces'
16
16
  import { buildTabEntries, type GroupEntry, type TabEntry } from '../../../state/tab-entries'
17
- import { moveIdToIdPosition } from '../../session-ordering'
17
+ import { useScrollActiveIntoView } from '../../hooks/use-scroll-active-into-view'
18
+ import { moveIdToIdPosition } from '../../project-ordering'
18
19
  import { useTheme } from '../../theme'
19
20
  import { FlashLabelBadge } from '../flash/flash-label-badge'
20
21
  import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
21
22
  import { TabItem } from './sidebar/tab-item'
22
- import { useTopTabBarAutoScroll } from './sidebar/use-top-tab-bar-auto-scroll'
23
23
 
24
24
  interface TopTabBarProps {
25
- forceVisible?: boolean
25
+ /**
26
+ * A full-screen view has replaced the pane tree (git mode, settings). The strip
27
+ * shows regardless of the user's own preference — it is the only orientation
28
+ * left — but it shows as a strip to read, not to act on.
29
+ */
30
+ panesReplaced?: boolean
26
31
  }
27
32
 
28
33
  const ROW_CONTENT_OPTIONS = {
@@ -192,34 +197,34 @@ function entryTabIds(entry: TabEntry): string[] {
192
197
  return entry.kind === 'single' ? [entry.tab.id] : entry.tabs.map((tab) => tab.id)
193
198
  }
194
199
 
195
- export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
200
+ export function TopTabBar({ panesReplaced = false }: TopTabBarProps) {
196
201
  const t = useTheme()
197
202
  const headerBg = t.backgroundPanel
198
203
  const tabs = useAppStore((s) => s.tabs)
199
204
  const activeTabId = useAppStore((s) => s.activeTabId)
200
- const bar = useAppStore((s) => s.sessionBar)
205
+ const bar = useAppStore((s) => s.projectBar)
201
206
  const leftBarVisible = useAppStore((s) => getBarWidth(s.bars.left) > 0)
202
- const currentSessionId = useAppStore((s) => s.currentSessionId)
203
- const sessions = useAppStore((s) => s.sessions)
207
+ const currentProjectId = useAppStore((s) => s.currentProjectId)
208
+ const projects = useAppStore((s) => s.projects)
204
209
  const focusMode: FocusMode = useAppStore((s) => s.focusMode)
205
210
  const layoutTrees = useAppStore((s) => s.layoutTrees)
206
211
  const tabGroupMap = useAppStore((s) => s.tabGroupMap)
207
212
 
208
- // Sidebar now also shows worktree chips with divergence — poll whenever
213
+ // Sidebar now also shows workspace chips with divergence — poll whenever
209
214
  // either surface is visible.
210
- useWorktreeDivergencePolling(bar.visible || leftBarVisible || forceVisible)
215
+ useWorkspaceDivergencePolling(bar.visible || leftBarVisible || panesReplaced)
211
216
 
212
- const currentSession = useMemo(
217
+ const currentProject = useMemo(
213
218
  () =>
214
- currentSessionId != null && currentSessionId !== ''
215
- ? sessions.find((s) => s.id === currentSessionId)
219
+ currentProjectId != null && currentProjectId !== ''
220
+ ? projects.find((s) => s.id === currentProjectId)
216
221
  : undefined,
217
- [currentSessionId, sessions]
222
+ [currentProjectId, projects]
218
223
  )
219
224
 
220
225
  const visibleTabs = useMemo(
221
- () => filterTabsForActiveWorktree(tabs, currentSession),
222
- [tabs, currentSession]
226
+ () => filterTabsForActiveWorkspace(tabs, currentProject),
227
+ [tabs, currentProject]
223
228
  )
224
229
 
225
230
  const entries = useMemo(
@@ -239,11 +244,11 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
239
244
  }, [entries, activeTabId])
240
245
 
241
246
  const scrollRef = useRef<ScrollBoxRenderable | null>(null)
242
- useTopTabBarAutoScroll({
243
- activeTabId: activeEntryId,
247
+ useScrollActiveIntoView({
248
+ activeId: activeEntryId,
244
249
  idPrefix: 'top-tab-',
245
250
  scrollRef,
246
- visible: bar.visible || forceVisible,
251
+ visible: bar.visible || panesReplaced,
247
252
  })
248
253
 
249
254
  const handleEntryActivate = useCallback(
@@ -260,7 +265,7 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
260
265
  )
261
266
 
262
267
  // --- Drag-and-drop reorder of the tab strip ---------------------------------
263
- // Mirrors the workspace-list drag, but on the horizontal axis: entries are
268
+ // Mirrors the project-list drag, but on the horizontal axis: entries are
264
269
  // laid out left-to-right inside a scrollX box, so hit-testing is on x/width.
265
270
  const [draggingId, setDraggingId] = useState<string | null>(null)
266
271
  const [dragOrder, setDragOrder] = useState<string[] | null>(null)
@@ -344,13 +349,13 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
344
349
 
345
350
  const handleNewTab = useCallback((e: OtuiMouseEvent) => {
346
351
  e.stopPropagation()
347
- dispatchGlobal({ type: 'open-new-tab-modal' })
352
+ runSideEffectGlobal({ type: 'open-new-tab' })
348
353
  }, [])
349
354
 
350
- if (!bar.visible && !forceVisible) return null
351
- // Keep the bar visible even with zero entries — when the active worktree
355
+ if (!bar.visible && !panesReplaced) return null
356
+ // Keep the bar visible even with zero entries — when the active workspace
352
357
  // has no tabs, the lone "+" affordance is what tells the user "you're in
353
- // an empty worktree, click here to start one".
358
+ // an empty workspace, click here to start one".
354
359
 
355
360
  const isFocused = focusMode === 'terminal-input' || focusMode === 'navigation'
356
361
 
@@ -426,17 +431,22 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
426
431
  </TopTabCell>
427
432
  )
428
433
  })}
429
- <box
430
- flexDirection="row"
431
- flexShrink={0}
432
- paddingLeft={1}
433
- paddingRight={1}
434
- onMouseDown={handleNewTab}
435
- >
436
- <text fg={t.textMuted} selectable={false}>
437
- +
438
- </text>
439
- </box>
434
+ {/* Not while a full-screen view is up: a `+` that quietly starts a tab
435
+ and drops you out of the screen you were reading is noise, and on the
436
+ settings screen it is not even about anything on it. */}
437
+ {panesReplaced ? null : (
438
+ <box
439
+ flexDirection="row"
440
+ flexShrink={0}
441
+ paddingLeft={1}
442
+ paddingRight={1}
443
+ onMouseDown={handleNewTab}
444
+ >
445
+ <text fg={t.textMuted} selectable={false}>
446
+ +
447
+ </text>
448
+ </box>
449
+ )}
440
450
  </scrollbox>
441
451
  </box>
442
452
  )
@@ -89,10 +89,10 @@ export function GitCommitModal({
89
89
  const bodyActive = activeField === 'body'
90
90
  const isConfirm = stage === 'confirm'
91
91
  const isGenerating = stage === 'generating'
92
- const currentSessionId = useAppStore((s) => s.currentSessionId)
92
+ const currentProjectId = useAppStore((s) => s.currentProjectId)
93
93
  const bgKind = useAppStore((s) => {
94
- const id = s.currentSessionId
95
- return id != null && id !== '' ? s.autoCommit.bySession[id]?.kind : undefined
94
+ const id = s.currentProjectId
95
+ return id != null && id !== '' ? s.autoCommit.byProject[id]?.kind : undefined
96
96
  })
97
97
  const isBgGenerating = bgKind === 'generating'
98
98
  const showBgSpinner = isBgGenerating && !isConfirm && !isGenerating
@@ -103,19 +103,19 @@ export function GitCommitModal({
103
103
 
104
104
  const onAutoCommitClick = useCallback((): void => {
105
105
  const hasTitle = title.trim().length > 0
106
- if (hasTitle || !(currentSessionId != null && currentSessionId !== '')) {
106
+ if (hasTitle || !(currentProjectId != null && currentProjectId !== '')) {
107
107
  dispatchGlobal({ type: 'git-commit-enter-confirm' })
108
108
  return
109
109
  }
110
110
  if (bgKind === 'ready') {
111
- dispatchGlobal({ sessionId: currentSessionId, type: 'git-commit-use-background-suggestion' })
111
+ dispatchGlobal({ projectId: currentProjectId, type: 'git-commit-use-background-suggestion' })
112
112
  return
113
113
  }
114
- dispatchGlobal({ sessionId: currentSessionId, type: 'git-commit-enter-generating' })
114
+ dispatchGlobal({ projectId: currentProjectId, type: 'git-commit-enter-generating' })
115
115
  if (bgKind !== 'generating') {
116
- runSideEffectGlobal({ sessionId: currentSessionId, type: 'generate-auto-commit-now' })
116
+ runSideEffectGlobal({ projectId: currentProjectId, type: 'generate-auto-commit-now' })
117
117
  }
118
- }, [bgKind, currentSessionId, title])
118
+ }, [bgKind, currentProjectId, title])
119
119
 
120
120
  const handleAutoCommitMouseDown = useCallback(
121
121
  (event: OtuiMouseEvent) => {