@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
@@ -3,7 +3,7 @@ import type { ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
3
3
  import type { AppState } from '../state/types'
4
4
 
5
5
  import { describeBindings } from '../input/keymap/describe-bindings'
6
- import { getSessionProjectPath } from '../state/session-worktrees'
6
+ import { getActiveWorkspacePath } from '../state/project-workspaces'
7
7
  import { buildHintText } from './keymap-context'
8
8
  import { abbreviatePath } from './path-format'
9
9
 
@@ -16,26 +16,32 @@ export interface IdentitySegment {
16
16
  export interface StatusBarModel {
17
17
  help: string
18
18
  right: string
19
- sessionSegments: IdentitySegment[]
19
+ projectSegments: IdentitySegment[]
20
20
  }
21
21
 
22
22
  const HINT_LIMIT = 6
23
23
  const HELP_DESCRIPTION = 'Help'
24
24
  const SEP = ' · '
25
25
 
26
- function sessionSegments(
27
- sessionName: string,
28
- sessionPath: string | null | undefined
26
+ function projectSegments(
27
+ projectName: string,
28
+ projectPath: string | null | undefined
29
29
  ): IdentitySegment[] {
30
- const segs: IdentitySegment[] = [{ id: 'session', text: sessionName, tone: 'primary' }]
31
- if (sessionPath != null && sessionPath !== '') {
32
- segs.push({ id: 'sep-session-path', text: SEP, tone: 'muted' })
33
- segs.push({ id: 'path', text: abbreviatePath(sessionPath), tone: 'muted' })
30
+ const segs: IdentitySegment[] = [{ id: 'project', text: projectName, tone: 'primary' }]
31
+ if (projectPath != null && projectPath !== '') {
32
+ segs.push({ id: 'sep-project-path', text: SEP, tone: 'muted' })
33
+ segs.push({ id: 'path', text: abbreviatePath(projectPath), tone: 'muted' })
34
34
  }
35
35
  return segs
36
36
  }
37
37
 
38
38
  const STAGING_DESCRIPTIONS = ['Stage', 'Unstage/delete', 'Commit', 'Push']
39
+ /**
40
+ * Moving around a two-column screen is the one thing you can see from the screen,
41
+ * so the bar spends its six slots on what you cannot: searching, resetting, and
42
+ * how to change the thing under the cursor.
43
+ */
44
+ const SETTINGS_OBVIOUS_DESCRIPTIONS = ['Sections', 'Settings of the section', 'Next', 'Prev']
39
45
 
40
46
  function hintForMode(config: ResolvedKeymapConfig, modeId: ModeId): string {
41
47
  return buildHintText(config, modeId, HINT_LIMIT, { excludeDescriptions: [HELP_DESCRIPTION] })
@@ -57,27 +63,27 @@ function helpHintForMode(config: ResolvedKeymapConfig, modeId: ModeId): string {
57
63
  }
58
64
 
59
65
  export function getStatusBarModel(state: AppState, config: ResolvedKeymapConfig): StatusBarModel {
60
- const currentSession =
61
- state.currentSessionId != null && state.currentSessionId !== ''
62
- ? state.sessions.find((session) => session.id === state.currentSessionId)
66
+ const currentProject =
67
+ state.currentProjectId != null && state.currentProjectId !== ''
68
+ ? state.projects.find((project) => project.id === state.currentProjectId)
63
69
  : undefined
64
- const sessionName = currentSession?.name ?? 'no workspace'
65
- const sessionPath = getSessionProjectPath(currentSession)
66
- const sessionSegs = sessionSegments(sessionName, sessionPath)
70
+ const projectName = currentProject?.name ?? 'no project'
71
+ const projectPath = getActiveWorkspacePath(currentProject)
72
+ const projectSegs = projectSegments(projectName, projectPath)
67
73
 
68
74
  switch (state.focusMode) {
69
75
  case 'terminal-input':
70
76
  return {
71
77
  help: '',
78
+ projectSegments: projectSegs,
72
79
  right: hintForMode(config, 'terminal-input'),
73
- sessionSegments: sessionSegs,
74
80
  }
75
81
  case 'modal': {
76
82
  const modalMode = deriveModalModeId(state.modal.type)
77
83
  return {
78
84
  help: '',
85
+ projectSegments: projectSegs,
79
86
  right: modalMode ? hintForMode(config, modalMode) : '',
80
- sessionSegments: sessionSegs,
81
87
  }
82
88
  }
83
89
  case 'git': {
@@ -93,24 +99,32 @@ export function getStatusBarModel(state: AppState, config: ResolvedKeymapConfig)
93
99
  }
94
100
  return {
95
101
  help: helpHintForMode(config, 'git-mode'),
102
+ projectSegments: [...projectSegs, ...extras],
96
103
  right: hintForGitMode(config, headOffset),
97
- sessionSegments: [...sessionSegs, ...extras],
98
104
  }
99
105
  }
106
+ case 'settings':
107
+ return {
108
+ help: helpHintForMode(config, 'settings'),
109
+ projectSegments: projectSegs,
110
+ right: buildHintText(config, 'settings', HINT_LIMIT, {
111
+ excludeDescriptions: [HELP_DESCRIPTION, ...SETTINGS_OBVIOUS_DESCRIPTIONS],
112
+ }),
113
+ }
100
114
  case 'command-edit': {
101
115
  const commandEditMode = deriveCommandEditModeId(state.modal.type)
102
116
  return {
103
117
  help: '',
118
+ projectSegments: projectSegs,
104
119
  right: commandEditMode ? hintForMode(config, commandEditMode) : '',
105
- sessionSegments: sessionSegs,
106
120
  }
107
121
  }
108
122
  case 'navigation':
109
123
  default:
110
124
  return {
111
125
  help: helpHintForMode(config, 'navigation'),
126
+ projectSegments: projectSegs,
112
127
  right: hintForMode(config, 'navigation'),
113
- sessionSegments: sessionSegs,
114
128
  }
115
129
  }
116
130
  }
@@ -121,8 +135,8 @@ function deriveModalModeId(modalType: AppState['modal']['type']): ModeId | null
121
135
  return 'modal.help.filtering'
122
136
  case 'new-tab':
123
137
  return 'modal.new-tab.command-edit'
124
- case 'session-picker':
125
- return 'modal.session-picker.filtering'
138
+ case 'project-picker':
139
+ return 'modal.project-picker.filtering'
126
140
  case 'snippet-picker':
127
141
  return 'modal.snippet-picker.filtering'
128
142
  case 'split-picker':
@@ -131,10 +145,10 @@ function deriveModalModeId(modalType: AppState['modal']['type']): ModeId | null
131
145
  return 'modal.theme-picker.filtering'
132
146
  case 'update-available':
133
147
  return 'modal.update-available'
134
- case 'worktree-move':
135
- return 'modal.worktree-move'
136
- case 'worktree-move-confirm':
137
- return 'modal.worktree-move-confirm'
148
+ case 'workspace-move':
149
+ return 'modal.workspace-move'
150
+ case 'workspace-move-confirm':
151
+ return 'modal.workspace-move-confirm'
138
152
  default:
139
153
  return null
140
154
  }
@@ -142,20 +156,20 @@ function deriveModalModeId(modalType: AppState['modal']['type']): ModeId | null
142
156
 
143
157
  function deriveCommandEditModeId(modalType: AppState['modal']['type']): ModeId | null {
144
158
  switch (modalType) {
145
- case 'create-session':
146
- return 'modal.create-session'
159
+ case 'create-project':
160
+ return 'modal.create-project'
147
161
  case 'git-commit':
148
162
  return 'modal.git-commit'
149
163
  case 'new-tab':
150
164
  return 'modal.new-tab.command-edit'
151
165
  case 'rename-tab':
152
166
  return 'modal.rename-tab'
153
- case 'rename-worktree':
154
- return 'modal.rename-worktree'
155
- case 'session-name':
156
- return 'modal.session-name'
157
- case 'session-picker':
158
- return 'modal.session-picker.filtering'
167
+ case 'rename-workspace':
168
+ return 'modal.rename-workspace'
169
+ case 'project-name':
170
+ return 'modal.project-name'
171
+ case 'project-picker':
172
+ return 'modal.project-picker.filtering'
159
173
  case 'snippet-editor':
160
174
  return 'modal.snippet-editor'
161
175
  case 'snippet-picker':
@@ -16,7 +16,7 @@ let nextId = 0x100000
16
16
 
17
17
  export function nextImageId(): number {
18
18
  const id = nextId++
19
- // Wrap at 24-bit; we don't expect to leak more than ~16M IDs per session but
19
+ // Wrap at 24-bit; we don't expect to leak more than ~16M IDs per project but
20
20
  // be safe in case of a long-running daemon.
21
21
  if (nextId > 0xffffff) nextId = 0x100000
22
22
  return id
@@ -46,10 +46,16 @@ function chunkString(s: string, size: number): string[] {
46
46
 
47
47
  // Build the upload escape(s) for a PNG (f=100) image. Chunked per Kitty spec
48
48
  // recommendation (≤ 4096 base64 bytes per escape).
49
+ //
50
+ // The upload is preceded by a delete of the same id. Images outlive the
51
+ // process — they belong to the terminal window — and this process allocates
52
+ // ids from a fixed base, so a previous run may have left this very id carrying
53
+ // a *different* placement. An image with two virtual placements and nothing to
54
+ // disambiguate them draws at whichever size the terminal happens to pick.
49
55
  export function uploadPngEscape(pngBytes: Uint8Array, id: number): string {
50
56
  const b64 = encodeBase64(pngBytes)
51
57
  const chunks = chunkString(b64, MAX_BASE64_CHUNK)
52
- const parts: string[] = []
58
+ const parts: string[] = [`${ESC}_Ga=d,d=I,i=${id},q=2;${ST}`]
53
59
  for (let i = 0; i < chunks.length; i++) {
54
60
  const isLast = i === chunks.length - 1
55
61
  const m = isLast ? 0 : 1
@@ -60,6 +66,19 @@ export function uploadPngEscape(pngBytes: Uint8Array, id: number): string {
60
66
  return wrapForTmux(parts.join(''))
61
67
  }
62
68
 
69
+ // A virtual placement (U=1) is not painted over the screen: the terminal
70
+ // composites the image into whichever cells carry the Unicode placeholder,
71
+ // which is what lets an image survive redraws the app makes on its own. `cols`
72
+ // and `rows` size the placeholder box, and the image is scaled to fill it.
73
+ //
74
+ // Deliberately no source rectangle (`x`/`y`/`w`/`h`): terminals that speak the
75
+ // rest of this protocol do not all honour it on a virtual placement, and one
76
+ // that ignores it silently draws the whole image instead of the slice asked
77
+ // for. Callers upload one image per frame.
78
+ export function virtualPlacementEscape(id: number, cols: number, rows: number): string {
79
+ return wrapForTmux(`${ESC}_Ga=p,U=1,i=${id},c=${cols},r=${rows},q=2;${ST}`)
80
+ }
81
+
63
82
  export function deleteImageEscape(id: number): string {
64
83
  return wrapForTmux(`${ESC}_Ga=d,d=I,i=${id},q=2;${ST}`)
65
84
  }
@@ -68,6 +87,13 @@ export function imageIdToRgb(id: number): [number, number, number] {
68
87
  return idToRgb(id)
69
88
  }
70
89
 
90
+ // Same id, spelled the way a renderable's `fg` wants it. The colour is not a
91
+ // colour here: it is what tells the terminal which image a placeholder cell
92
+ // stands for, so it has to survive the round trip through the renderer intact.
93
+ export function imageIdToHex(id: number): string {
94
+ return `#${(id & 0xffffff).toString(16).padStart(6, '0')}`
95
+ }
96
+
71
97
  export function writeRaw(seq: string): void {
72
98
  // Synchronous write so we don't interleave with opentui frames.
73
99
  process.stdout.write(seq)
@@ -0,0 +1,246 @@
1
+ // Activity sprites: a small animation per agent state, drawn where the sidebar
2
+ // would otherwise put a spinner or a dot.
3
+ //
4
+ // Every frame is its own image, uploaded once and given a virtual placement, so
5
+ // animating is nothing but changing the foreground colour of two cells — no
6
+ // escape written per frame, and nothing an opentui redraw can wipe. One image
7
+ // per frame rather than one sheet cropped per placement: the source rectangle
8
+ // of a placement is not honoured by every terminal that speaks the protocol,
9
+ // and a sheet placed without it draws all its frames at once.
10
+ //
11
+ // Naming carries everything, so there is no manifest to keep in step with the
12
+ // files. Either form works:
13
+ // - a folder `<state>@<ms>/` of numbered PNGs, one per frame (`0.png`, `1.png`…)
14
+ // - a `<state>.gif`, which brings its own frames and delay, cut by ImageMagick
15
+
16
+ import { readdir } from 'node:fs/promises'
17
+
18
+ import { logDebug } from '../../debug/input-log'
19
+ import { getProfileConfigDir } from '../../profile-paths'
20
+ import {
21
+ imageIdToHex,
22
+ nextImageId,
23
+ uploadPngEscape,
24
+ virtualPlacementEscape,
25
+ writeRaw,
26
+ } from './kitty'
27
+
28
+ export const SPRITE_STATES = ['idle', 'working', 'waiting', 'done'] as const
29
+
30
+ export type SpriteState = (typeof SPRITE_STATES)[number]
31
+
32
+ export type SpriteSet = Partial<Record<SpriteState, Sprite>>
33
+
34
+ export interface Sprite {
35
+ /**
36
+ * One entry per frame. Not a colour: it is the 24-bit id of the image the
37
+ * placeholder cells stand for, spelled the way a renderable's `fg` wants it.
38
+ */
39
+ colors: readonly string[]
40
+ frameMs: number
41
+ }
42
+
43
+ /**
44
+ * The one shape a sprite is ever placed into: the two lines of a workspace row,
45
+ * three cells across.
46
+ *
47
+ * Deliberately the only one. A frame gets a single image and a single placement,
48
+ * so a cell cannot resolve to a box other than the one it was drawn for. Earlier
49
+ * revisions placed each frame at several sizes for the different rows that
50
+ * wanted them, and sprites drew at the wrong size — three placements of visually
51
+ * identical images stacked in one column is a resolution the protocol gives no
52
+ * way to make unambiguous from a cell alone.
53
+ *
54
+ * Three cells across because the terminal fits an image to its box keeping the
55
+ * aspect ratio: for a roughly square frame in cells twice as tall as they are
56
+ * wide, the width is what caps the sprite, and two rows of height buy nothing
57
+ * under two cells of width.
58
+ */
59
+ export const SPRITE_COLS = 3
60
+ const SPRITE_ROWS = 2
61
+
62
+ // Kitty encodes a placeholder cell's row and column as combining marks, the Nth
63
+ // entry of the spec's table standing for N. Three of each is all this needs.
64
+ const MARKS = ['̅', '̍', '̎'] as const
65
+ const DEFAULT_FRAME_MS = 150
66
+ const BUNDLED_DIR = `${import.meta.dir}/sprites`
67
+ const FOLDER_NAME = /^(idle|working|waiting|done)@(\d+)$/
68
+ const GIF_NAME = /^(idle|working|waiting|done)\.gif$/
69
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
70
+
71
+ /** U+10EEEE, the Kitty placeholder. */
72
+ const PLACEHOLDER = '\u{10EEEE}'
73
+
74
+ /**
75
+ * The cells of a sprite, one string per line of its box, top line first.
76
+ *
77
+ * Every cell names the row and the column it stands for, always — never a bare
78
+ * placeholder left for the terminal to work out. Its rule for an unmarked cell
79
+ * looks up as well as left: a placeholder sitting directly below another of the
80
+ * same image reads as the next row down. A sidebar is a column of rows drawing
81
+ * the same idle sprite, so each one would inherit the position of the row above
82
+ * it and draw a slice of the image rather than the image.
83
+ *
84
+ * The marks are zero-width, so a line is exactly `SPRITE_COLS` columns wide.
85
+ */
86
+ function placeholderLines(): string[] {
87
+ return Array.from({ length: SPRITE_ROWS }, (_, row) => {
88
+ const rowMark = MARKS[row] ?? MARKS[0]
89
+ let line = ''
90
+ for (let col = 0; col < SPRITE_COLS; col++) {
91
+ line += `${PLACEHOLDER}${rowMark}${MARKS[col] ?? MARKS[0]}`
92
+ }
93
+ return line
94
+ })
95
+ }
96
+
97
+ /** Resolved once: the same two strings every row of the sidebar draws. */
98
+ export const SPRITE_PLACEHOLDERS: readonly string[] = placeholderLines()
99
+
100
+ /** Where a user drops their own sprites. Anything here beats what aimux ships. */
101
+ export function userSpriteDir(): string {
102
+ return `${getProfileConfigDir()}/sprites`
103
+ }
104
+
105
+ export interface SpriteName {
106
+ frameMs: number
107
+ /** True when the frames come from a GIF and only ImageMagick can read them. */
108
+ gif: boolean
109
+ state: SpriteState
110
+ }
111
+
112
+ /**
113
+ * What an entry in a sprite folder declares, or null when it declares nothing —
114
+ * the folder is the user's, and a stray screenshot in it is not a configuration
115
+ * error.
116
+ */
117
+ export function parseSpriteName(entry: string): SpriteName | null {
118
+ const folder = FOLDER_NAME.exec(entry)
119
+ if (folder) {
120
+ const ms = Number.parseInt(folder[2] ?? '', 10)
121
+ return {
122
+ frameMs: Number.isFinite(ms) && ms > 0 ? ms : DEFAULT_FRAME_MS,
123
+ gif: false,
124
+ state: folder[1] as SpriteState,
125
+ }
126
+ }
127
+ const gif = GIF_NAME.exec(entry)
128
+ if (gif === null) return null
129
+ return { frameMs: DEFAULT_FRAME_MS, gif: true, state: gif[1] as SpriteState }
130
+ }
131
+
132
+ interface Frames {
133
+ frameMs: number
134
+ pngs: Uint8Array[]
135
+ }
136
+
137
+ async function runMagick(args: string[], timeoutMs: number): Promise<Uint8Array | null> {
138
+ for (const bin of ['magick', 'convert']) {
139
+ try {
140
+ const proc = Bun.spawn([bin, ...args], { stderr: 'ignore', stdin: 'ignore', stdout: 'pipe' })
141
+ const timer = setTimeout(() => proc.kill(), timeoutMs)
142
+ const [out, code] = await Promise.all([new Response(proc.stdout).bytes(), proc.exited])
143
+ clearTimeout(timer)
144
+ if (code === 0 && out.byteLength > 0) return out
145
+ } catch {
146
+ // Next binary, then give up: a missing ImageMagick is not an error, it
147
+ // only means GIF sprites are unavailable.
148
+ }
149
+ }
150
+ return null
151
+ }
152
+
153
+ /**
154
+ * ImageMagick writes one PNG per frame back to back on stdout. They are split on
155
+ * the signature rather than counted, so a frame count that disagrees with the
156
+ * stream cannot desynchronise the two.
157
+ */
158
+ function splitPngs(bytes: Uint8Array): Uint8Array[] {
159
+ const starts: number[] = []
160
+ for (let i = 0; i + PNG_SIGNATURE.length <= bytes.length; i++) {
161
+ if (PNG_SIGNATURE.every((byte, k) => bytes[i + k] === byte)) starts.push(i)
162
+ }
163
+ return starts.map((start, i) => bytes.subarray(start, starts[i + 1] ?? bytes.length))
164
+ }
165
+
166
+ /** A GIF's own frame count and delay are authoritative, so it names neither. */
167
+ async function gifFrames(path: string): Promise<Frames | null> {
168
+ const pngs = splitPngs((await runMagick([path, '-coalesce', 'png:-'], 5000)) ?? new Uint8Array())
169
+ if (pngs.length === 0) return null
170
+ const meta = await runMagick(['identify', '-format', '%T\\n', path], 2000)
171
+ const ticks = Number.parseInt(new TextDecoder().decode(meta ?? new Uint8Array()), 10)
172
+ // GIF delays are in hundredths of a second, and 0 is the "as fast as you can"
173
+ // some encoders write — which at this size would be a strobe.
174
+ const frameMs = Number.isFinite(ticks) && ticks > 0 ? ticks * 10 : DEFAULT_FRAME_MS
175
+ return { frameMs, pngs }
176
+ }
177
+
178
+ /** Frames in numeric order — `10.png` follows `9.png`, it does not precede it. */
179
+ async function folderFrames(dir: string, frameMs: number): Promise<Frames | null> {
180
+ const files = (await readdir(dir))
181
+ .filter((file) => file.endsWith('.png'))
182
+ .sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10))
183
+ if (files.length === 0) return null
184
+ return {
185
+ frameMs,
186
+ pngs: await Promise.all(files.map(async (file) => Bun.file(`${dir}/${file}`).bytes())),
187
+ }
188
+ }
189
+
190
+ /** Every sprite a directory declares, keyed by the state it stands for. */
191
+ async function scanDir(dir: string): Promise<Map<SpriteState, Frames>> {
192
+ const found = new Map<SpriteState, Frames>()
193
+ let entries: string[]
194
+ try {
195
+ entries = await readdir(dir)
196
+ } catch {
197
+ return found
198
+ }
199
+
200
+ for (const entry of entries.sort()) {
201
+ const name = parseSpriteName(entry)
202
+ if (name === null) continue
203
+ const frames = name.gif
204
+ ? await gifFrames(`${dir}/${entry}`)
205
+ : await folderFrames(`${dir}/${entry}`, name.frameMs)
206
+ if (frames) found.set(name.state, frames)
207
+ else if (name.gif) {
208
+ logDebug('sprites.gif.failed', { entry, reason: 'ImageMagick unavailable or failed' })
209
+ }
210
+ }
211
+ return found
212
+ }
213
+
214
+ function upload(frames: Frames): Sprite {
215
+ const colors = frames.pngs.map((png) => {
216
+ const id = nextImageId()
217
+ writeRaw(uploadPngEscape(png, id) + virtualPlacementEscape(id, SPRITE_COLS, SPRITE_ROWS))
218
+ return imageIdToHex(id)
219
+ })
220
+ return { colors, frameMs: Math.max(16, frames.frameMs) }
221
+ }
222
+
223
+ async function loadAll(): Promise<SpriteSet> {
224
+ // The user's folder is scanned second so a state it declares replaces the one
225
+ // aimux ships, rather than being merged with it.
226
+ const found = new Map([...(await scanDir(BUNDLED_DIR)), ...(await scanDir(userSpriteDir()))])
227
+ const set: SpriteSet = {}
228
+ for (const [state, frames] of found) set[state] = upload(frames)
229
+ return set
230
+ }
231
+
232
+ let loaded: Promise<SpriteSet> | null = null
233
+
234
+ /**
235
+ * Uploaded once per process — every row animates off the same images. Read from
236
+ * disk at first use, so a sprite dropped into the folder appears at the next
237
+ * launch, not mid-session.
238
+ */
239
+ export async function loadSprites(): Promise<SpriteSet> {
240
+ loaded ??= loadAll()
241
+ try {
242
+ return await loaded
243
+ } catch {
244
+ return {}
245
+ }
246
+ }
@@ -0,0 +1,7 @@
1
+ /** Fit a label into `max` columns, marking the cut with an ellipsis. */
2
+ export function truncate(label: string, max: number): string {
3
+ if (max <= 0) return ''
4
+ if (label.length <= max) return label
5
+ if (max === 1) return '…'
6
+ return `${label.slice(0, max - 1)}…`
7
+ }
@@ -1,7 +1,8 @@
1
1
  import type { ReactNode } from 'react'
2
2
 
3
3
  import { GitPaneWidget } from '../components/git/pane/git-pane-widget'
4
- import { WorkspaceList } from '../components/layout/sidebar/workspace-list'
4
+ import { ProjectList } from '../components/layout/sidebar/project-list'
5
+ import { SetupWidget } from '../components/setup/setup-widget'
5
6
 
6
7
  /**
7
8
  * Everything a bar can host. Adding a widget = one entry here plus its id in
@@ -9,10 +10,12 @@ import { WorkspaceList } from '../components/layout/sidebar/workspace-list'
9
10
  */
10
11
  export const WIDGET_RENDERERS: Record<string, (contentWidth: number) => ReactNode> = {
11
12
  git: (contentWidth) => <GitPaneWidget contentWidth={contentWidth} pollingEnabled />,
12
- workspaces: (contentWidth) => <WorkspaceList contentWidth={contentWidth} />,
13
+ projects: (contentWidth) => <ProjectList contentWidth={contentWidth} />,
14
+ setup: () => <SetupWidget />,
13
15
  }
14
16
 
15
17
  export const WIDGET_LABELS: Record<string, string> = {
16
18
  git: 'Git',
17
- workspaces: 'Workspaces',
19
+ projects: 'Projects',
20
+ setup: 'Setup',
18
21
  }
@@ -5,9 +5,30 @@ import { visibleWidgets } from '../../state/bars'
5
5
  import { dispatchGlobal } from '../../state/dispatch-ref'
6
6
  import { WIDGET_LABELS } from './registry'
7
7
 
8
+ /** `Show <label>` for every hidden widget in either bar. */
9
+ function showHiddenItems(bars: BarsState): ContextMenuItem[] {
10
+ const items: ContextMenuItem[] = []
11
+ for (const side of ['left', 'right'] as const) {
12
+ for (const widget of bars[side].widgets) {
13
+ if (widget.visible) continue
14
+ items.push([
15
+ `Show ${WIDGET_LABELS[widget.id] ?? widget.id}`,
16
+ () => dispatchGlobal({ type: 'toggle-widget', widgetId: widget.id }),
17
+ ])
18
+ }
19
+ }
20
+ return items
21
+ }
22
+
8
23
  /**
9
24
  * Right-click menu for a widget hosted in a bar: move it across, reorder it
10
- * within its bar, or hide it. Generic — a new widget gets this for free.
25
+ * within its bar, hide it, or bring back one that is hidden. Generic — a new
26
+ * widget gets this for free.
27
+ *
28
+ * The unhide entries live here and not only on the bar's own menu because the
29
+ * bar's menu is unreachable: widget slots cover the whole body and stop
30
+ * right-click propagation, and the edge and boundary handles swallow every
31
+ * button. Without this, hiding a widget is a one-way door for a mouse user.
11
32
  */
12
33
  export function buildWidgetContextMenu(
13
34
  bars: BarsState,
@@ -50,20 +71,15 @@ export function buildWidgetContextMenu(
50
71
  if (visibleWidgets(bars[side]).length > 1) {
51
72
  items.push(['Hide', () => dispatchGlobal({ type: 'toggle-widget', widgetId })])
52
73
  }
74
+
75
+ items.push(...showHiddenItems(bars))
53
76
  return items
54
77
  }
55
78
 
56
79
  /** Menu for the bar itself. Offers a way back for every hidden widget. */
57
80
  export function buildBarContextMenu(bars: BarsState, side: BarSide): ContextMenuItem[] {
58
- const items: ContextMenuItem[] = [
81
+ return [
59
82
  [`Hide ${side} bar`, () => dispatchGlobal({ side, type: 'toggle-bar' })],
83
+ ...showHiddenItems(bars),
60
84
  ]
61
- for (const widget of bars[side].widgets) {
62
- if (widget.visible) continue
63
- items.push([
64
- `Show ${WIDGET_LABELS[widget.id] ?? widget.id}`,
65
- () => dispatchGlobal({ type: 'toggle-widget', widgetId: widget.id }),
66
- ])
67
- }
68
- return items
69
85
  }
package/src/update.ts CHANGED
@@ -60,7 +60,7 @@ export async function runUpdate(): Promise<number> {
60
60
  }
61
61
 
62
62
  // The terminal-manager is intentionally NOT restarted here: doing so kills
63
- // live AI sessions. The flip side: the running TM keeps executing the
63
+ // live AI projects. The flip side: the running TM keeps executing the
64
64
  // previous version's code path, so any TM-side fix (perf, lifecycle) only
65
65
  // takes effect after a manual restart. Surface that explicitly so users
66
66
  // aren't silently stuck on stale behaviour.
@@ -73,7 +73,7 @@ export async function runUpdate(): Promise<number> {
73
73
  'TM-side fixes in this update apply to new TMs only — the running one',
74
74
  'keeps its old behaviour until restarted.',
75
75
  'Run `aimux restart-terminal-manager` when you can afford to lose your',
76
- 'current PTY sessions to upgrade it.',
76
+ 'current PTY projects to upgrade it.',
77
77
  '',
78
78
  ].join('\n')
79
79
  )