@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
@@ -16,8 +16,8 @@ interface TabItemProps {
16
16
  active: boolean
17
17
  focused: boolean
18
18
  inLayout?: boolean
19
- /** When set, this tab's worktree can be moved — adds a "Move worktree" entry. */
20
- moveWorktreeId?: string
19
+ /** When set, this tab's workspace can be moved — adds a "Move workspace" entry. */
20
+ moveWorkspaceId?: string
21
21
  /** 1-based position in the visible tab list — rendered as `[N]` and matches Leader+N. */
22
22
  indexLabel?: string
23
23
  /** Render the close × unconditionally instead of only on hover. */
@@ -57,11 +57,11 @@ function getIndicatorColor(active: boolean, focused: boolean, inLayout: boolean)
57
57
 
58
58
  function BusyGlyph() {
59
59
  const t = useTheme()
60
- const frame = useBusySpinner()
60
+ const spinner = useBusySpinner()
61
61
  return (
62
62
  <text fg={t.primary} selectable={false}>
63
63
  {' '}
64
- {frame}
64
+ {spinner}
65
65
  </text>
66
66
  )
67
67
  }
@@ -78,6 +78,8 @@ function WaitingGlyph() {
78
78
 
79
79
  function ActivityGlyph({ tab }: { tab: TabSession }) {
80
80
  const t = useTheme()
81
+ // No sprite here on purpose: a tab is one line, and a sprite placed at a
82
+ // second size would give a cell two images it could resolve to.
81
83
  if (tab.status === 'error') {
82
84
  return (
83
85
  <text fg={t.error} selectable={false}>
@@ -104,7 +106,10 @@ function ActivityGlyph({ tab }: { tab: TabSession }) {
104
106
  return <WaitingGlyph />
105
107
  }
106
108
 
107
- if (tab.activity === 'idle') {
109
+ // The tab that rang. There is deliberately no glyph for a plain idle tab:
110
+ // "not busy" is the resting state of every tab you are not watching, and a
111
+ // dot that is always on cannot also mean "read this one".
112
+ if (tab.unseen === true) {
108
113
  return (
109
114
  <text fg={t.success} selectable={false}>
110
115
  {' '}
@@ -113,6 +118,16 @@ function ActivityGlyph({ tab }: { tab: TabSession }) {
113
118
  )
114
119
  }
115
120
 
121
+ if (tab.activity === 'idle') {
122
+ // Blank, but the same width as every other state — a tab must not resize
123
+ // when its agent stops.
124
+ return (
125
+ <text fg={t.textMuted} selectable={false}>
126
+ {' '}
127
+ </text>
128
+ )
129
+ }
130
+
116
131
  return (
117
132
  <text fg={getStatusColor(tab.status)} selectable={false}>
118
133
  {' '}
@@ -128,7 +143,7 @@ export function TabItem({
128
143
  id,
129
144
  indexLabel,
130
145
  inLayout,
131
- moveWorktreeId,
146
+ moveWorkspaceId,
132
147
  tab,
133
148
  }: TabItemProps) {
134
149
  const t = useTheme()
@@ -167,22 +182,22 @@ export function TabItem({
167
182
  dispatchGlobal({ delta: 1, type: 'reorder-active-tab' })
168
183
  },
169
184
  ],
170
- ...(moveWorktreeId != null && moveWorktreeId !== ''
185
+ ...(moveWorkspaceId != null && moveWorkspaceId !== ''
171
186
  ? [
172
187
  [
173
- 'Move worktree',
188
+ 'Move workspace',
174
189
  () => {
175
190
  dispatchGlobal({
176
- sourceWorktreeId: moveWorktreeId,
177
- type: 'open-worktree-move-modal',
191
+ sourceWorkspaceId: moveWorkspaceId,
192
+ type: 'open-workspace-move-modal',
178
193
  })
179
- runSideEffectGlobal({ type: 'load-worktree-move-stats' })
194
+ runSideEffectGlobal({ type: 'load-workspace-move-stats' })
180
195
  },
181
196
  ] as [string, () => void],
182
197
  ]
183
198
  : []),
184
199
  ],
185
- [moveWorktreeId, tab.id]
200
+ [moveWorkspaceId, tab.id]
186
201
  )
187
202
  const handleMouseOver = useCallback(() => setHovered(true), [])
188
203
  const handleMouseOut = useCallback(() => setHovered(false), [])
@@ -5,7 +5,7 @@ import { useEffect } from 'react'
5
5
  interface Options {
6
6
  scrollRef: React.RefObject<ScrollBoxRenderable | null>
7
7
  visible: boolean
8
- /** Full id of the active row to bring into view (workspace OR worktree). */
8
+ /** Full id of the active row to bring into view (project OR workspace). */
9
9
  activeRowId: string | null
10
10
  }
11
11
 
@@ -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>