@brimveyn/aimux 1.23.4 → 1.23.5

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.23.4",
3
+ "version": "1.23.5",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -66,7 +66,7 @@
66
66
  "bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
67
67
  },
68
68
  "dependencies": {
69
- "@brimveyn/aimux-config": "0.10.5",
69
+ "@brimveyn/aimux-config": "0.10.6",
70
70
  "@opentui/core": "^0.1.90",
71
71
  "@opentui/react": "^0.1.90",
72
72
  "@resvg/resvg-wasm": "^2.6.2",
@@ -7,6 +7,7 @@ import { saveProjectCatalog } from '../state/project-catalog'
7
7
  import {
8
8
  filterTabsForActiveWorkspace,
9
9
  getPrimaryWorkspace,
10
+ getSidebarWorkspaces,
10
11
  withActiveWorkspace,
11
12
  } from '../state/project-workspaces'
12
13
  import { appReducer } from '../state/store'
@@ -103,7 +104,9 @@ function buildSidebarItems(state: AppState): SidebarItem[] {
103
104
  )
104
105
  const items: SidebarItem[] = []
105
106
  for (const project of ordered) {
106
- for (const workspace of project.workspaces ?? []) {
107
+ // `true`, for every project: a folded one keeps exactly the row j/k would
108
+ // land on, and that row is on screen the moment the cursor gets there.
109
+ for (const workspace of getSidebarWorkspaces(project, true)) {
107
110
  items.push({ projectId: project.id, workspaceId: workspace.id })
108
111
  }
109
112
  }
@@ -69,6 +69,7 @@ import {
69
69
  runDeleteWorkspace,
70
70
  runMoveWorkspace,
71
71
  setProjectDefaultBaseRef,
72
+ toggleProjectCollapsed,
72
73
  } from './workspace-actions'
73
74
  import { launchPendingWorkspace, startWorkspaceCreation } from './workspace-launch'
74
75
 
@@ -586,6 +587,10 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
586
587
  setProjectDefaultBaseRef(ctx, effect.projectId, effect.baseRef)
587
588
  return
588
589
  }
590
+ case 'toggle-project-collapsed': {
591
+ toggleProjectCollapsed(ctx, effect.projectId)
592
+ return
593
+ }
589
594
  case 'ask-agent-for-setup-script': {
590
595
  handleAskAgentForSetupScriptEffect(ctx)
591
596
  return
@@ -87,6 +87,17 @@ export function setProjectDefaultBaseRef(
87
87
  ctx.dispatch({ projects, type: 'set-projects' })
88
88
  }
89
89
 
90
+ /** Fold or unfold a project's workspace rows in the sidebar. */
91
+ export function toggleProjectCollapsed(ctx: SideEffectContext, projectId: string): void {
92
+ const projects = replaceProject(ctx, projectId, (entry) => ({
93
+ ...entry,
94
+ collapsed: entry.collapsed !== true ? true : undefined,
95
+ updatedAt: new Date().toISOString(),
96
+ }))
97
+ saveProjectCatalog(projects)
98
+ ctx.dispatch({ projects, type: 'set-projects' })
99
+ }
100
+
90
101
  function normalizeBranchName(branch: string | undefined): string | undefined {
91
102
  return branch?.replace(/^refs\/heads\//, '').trim()
92
103
  }
@@ -116,6 +116,7 @@ export type SideEffect =
116
116
  | { type: 'stop-setup' }
117
117
  | { type: 'configure-setup-script'; projectId?: string }
118
118
  | { type: 'set-project-default-base-ref'; projectId: string; baseRef: string }
119
+ | { type: 'toggle-project-collapsed'; projectId: string }
119
120
  | { type: 'ask-agent-for-setup-script' }
120
121
  | { type: 'promote-setup-tab' }
121
122
  /** Toggle a checkbox, cycle an enum, run a row's action — whatever the row is. */
@@ -5,7 +5,7 @@ import { useMemo } from 'react'
5
5
  import { AUTO_COMMIT_ENABLED, AUTO_COMMIT_TIMEOUT } from './sections/automation'
6
6
  import { AUTO_COMMIT_MODEL_PREFIX, SNIPPET_TRIGGER_CHAR } from './sections/commands'
7
7
  import { ACTIVITY_SPRITES, HARMONIZE_CLAUDE_THEME } from './sections/experimental'
8
- import { AI_USAGE_ENABLED, AI_USAGE_POLL_SECONDS } from './sections/status-bar'
8
+ import { AI_USAGE_ENABLED, AI_USAGE_POLL_SECONDS, HINTS_ENABLED } from './sections/status-bar'
9
9
  import { useSettingsStore } from './settings-store'
10
10
 
11
11
  /**
@@ -88,3 +88,8 @@ export function useHarmonizeClaudeTheme(fromConfigFile: boolean | undefined): bo
88
88
  const stored = useSettingsStore((s) => s.values[HARMONIZE_CLAUDE_THEME])
89
89
  return typeof stored === 'boolean' ? stored : fromConfigFile === true
90
90
  }
91
+
92
+ /** Whether the status bar draws its second row of keybinding hints. */
93
+ export function useStatusBarHints(): boolean {
94
+ return useSettingsStore((s) => s.values[HINTS_ENABLED] !== false)
95
+ }
@@ -2,6 +2,7 @@ import { setStatusBarSeparator, type StatusBarSeparator } from '@brimveyn/aimux-
2
2
 
3
3
  import type { SettingSection } from '../types'
4
4
 
5
+ export const HINTS_ENABLED = 'statusBar.hints'
5
6
  export const AI_USAGE_ENABLED = 'statusBar.aiUsage.enabled'
6
7
  export const AI_USAGE_POLL_SECONDS = 'statusBar.aiUsage.pollSeconds'
7
8
 
@@ -35,6 +36,15 @@ export const STATUS_BAR_SECTION: SettingSection = {
35
36
  options: SEPARATORS,
36
37
  storage: 'settings',
37
38
  },
39
+ {
40
+ description: 'The keybinding row under the bar. Off frees a line.',
41
+ fallback: true,
42
+ fromConfig: (config) => config.statusBar?.hints,
43
+ id: HINTS_ENABLED,
44
+ kind: 'toggle',
45
+ label: 'Keybinding hints',
46
+ storage: 'settings',
47
+ },
38
48
  {
39
49
  description: 'Show how much of your Claude or Codex quota is left.',
40
50
  fallback: false,
@@ -293,13 +293,13 @@ export function findWorkspace(
293
293
  return undefined
294
294
  }
295
295
 
296
+ /** The workspace a project's cursor sits on: its active one, else the checkout. */
296
297
  export function getActiveWorkspace(
297
298
  project: ProjectRecord | undefined
298
299
  ): WorkspaceRecord | undefined {
299
- if (!(project?.workspaces?.length != null && project?.workspaces?.length !== 0)) return undefined
300
300
  return (
301
- project.workspaces.find((workspace) => workspace.id === project.activeWorkspaceId) ??
302
- project.workspaces[0]
301
+ project?.workspaces?.find((workspace) => workspace.id === project.activeWorkspaceId) ??
302
+ getPrimaryWorkspace(project?.workspaces)
303
303
  )
304
304
  }
305
305
 
@@ -390,3 +390,23 @@ export function orderTabsByWorkspace(
390
390
  })
391
391
  .map((entry) => entry.tab)
392
392
  }
393
+
394
+ /**
395
+ * The workspace rows a folded project still shows: just the one the cursor is
396
+ * on, so folding never hides the row you are standing on — and, for a project
397
+ * you are not in, nothing at all.
398
+ *
399
+ * Sidebar navigation calls this with `isCurrent: true` for every project: the
400
+ * moment j/k crosses into one it *is* current, and a project whose rows all
401
+ * vanished from the item list would be unreachable by keyboard.
402
+ */
403
+ export function getSidebarWorkspaces(
404
+ project: ProjectRecord,
405
+ isCurrent: boolean
406
+ ): WorkspaceRecord[] {
407
+ const workspaces = project.workspaces ?? []
408
+ if (project.collapsed !== true) return workspaces
409
+ if (!isCurrent) return []
410
+ const active = getActiveWorkspace(project)
411
+ return active ? [active] : []
412
+ }
@@ -42,8 +42,6 @@ export interface ProjectStatus {
42
42
  waiting: boolean
43
43
  }
44
44
 
45
- export const IDLE_PROJECT_STATUS: ProjectStatus = { waiting: false, working: false }
46
-
47
45
  /**
48
46
  * A workspace's status, as the sidebar draws it. `working`/`waiting` mirror
49
47
  * `ProjectStatus` one level down; `done` is a latch — an assistant here
@@ -226,6 +224,12 @@ export interface ProjectRecord {
226
224
  * answers. Unset means the repo's default branch.
227
225
  */
228
226
  defaultBaseRef?: string
227
+ /**
228
+ * Folded in the sidebar: the project's workspace rows are hidden, bar the one
229
+ * the cursor is on. Persisted, because a fold you have to redo on every start
230
+ * is worse than no fold at all.
231
+ */
232
+ collapsed?: boolean
229
233
  }
230
234
 
231
235
  export interface ProjectBarState {
@@ -181,7 +181,8 @@ export function isProjectRecord(value: unknown): value is ProjectRecord {
181
181
  (value.workspaces === undefined ||
182
182
  (Array.isArray(value.workspaces) && value.workspaces.every(isWorkspaceRecord))) &&
183
183
  (value.activeWorkspaceId === undefined || isString(value.activeWorkspaceId)) &&
184
- (value.defaultBaseRef === undefined || isString(value.defaultBaseRef))
184
+ (value.defaultBaseRef === undefined || isString(value.defaultBaseRef)) &&
185
+ (value.collapsed === undefined || isBoolean(value.collapsed))
185
186
  )
186
187
  }
187
188
 
@@ -0,0 +1,74 @@
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
3
+ import { useCallback } from 'react'
4
+
5
+ import { dispatchGlobal } from '../../../state/dispatch-ref'
6
+ import { useTheme } from '../../theme'
7
+
8
+ /**
9
+ * U+2699, not the nerd-font gear: its Emoji_Presentation is No, so a conforming
10
+ * terminal draws it text-style in one cell and no font has to be installed for
11
+ * the one button that opens the settings.
12
+ */
13
+ const SETTINGS_GLYPH = '⚙'
14
+ /**
15
+ * U+25A4, chosen on the same rule as the gear above: one cell, text presentation,
16
+ * present in the base fonts. A ▁▄█ mini bar chart reads better but is three cells
17
+ * wide, which pushes this row past a narrow sidebar.
18
+ */
19
+ const STATS_GLYPH = '▤'
20
+ const SETTINGS_LABEL = `${SETTINGS_GLYPH} Settings`
21
+ const STATS_LABEL = `${STATS_GLYPH} Stats`
22
+ /** The two entries and the gap between them, so a renamed label re-measures itself. */
23
+ const FOOTER_GAP = 2
24
+ const FOOTER_FULL_WIDTH = SETTINGS_LABEL.length + FOOTER_GAP + STATS_LABEL.length
25
+ /** The row's own left and right padding. */
26
+ const FOOTER_PAD = 2
27
+
28
+ /**
29
+ * The bar's bottom bar: settings and stats, pinned under every widget in the
30
+ * column rather than living inside whichever widget happens to be last. These
31
+ * are the only full-screen views the panes step aside for that a mouse can
32
+ * reach at all, so they need a slot that is on screen whenever the bar is.
33
+ */
34
+ export function BarFooter({ contentWidth }: { contentWidth: number }) {
35
+ const t = useTheme()
36
+
37
+ const handleOpenSettings = useCallback((e: OtuiMouseEvent) => {
38
+ e.stopPropagation()
39
+ e.preventDefault()
40
+ dispatchGlobal({ type: 'enter-settings' })
41
+ }, [])
42
+
43
+ const handleOpenStats = useCallback((e: OtuiMouseEvent) => {
44
+ e.stopPropagation()
45
+ e.preventDefault()
46
+ dispatchGlobal({ type: 'enter-stats' })
47
+ }, [])
48
+
49
+ // The bar clamps down to 18 columns, narrower than both labels together, so
50
+ // below that the second entry drops to its glyph rather than being sliced
51
+ // mid-word by the overflow.
52
+ const statsLabel = contentWidth - FOOTER_PAD >= FOOTER_FULL_WIDTH ? STATS_LABEL : STATS_GLYPH
53
+
54
+ return (
55
+ <box flexDirection="column" flexShrink={0}>
56
+ <box flexShrink={0}>
57
+ <text fg={t.border} selectable={false} wrapMode="none">
58
+ {'─'.repeat(Math.max(1, contentWidth))}
59
+ </text>
60
+ </box>
61
+ {/* Each entry is its own <text>, with a spacer box between them: one string
62
+ holding both would make the whole footer a single click target. */}
63
+ <box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
64
+ <text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenSettings}>
65
+ {SETTINGS_LABEL}
66
+ </text>
67
+ <box width={FOOTER_GAP} flexShrink={1} />
68
+ <text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenStats}>
69
+ {statsLabel}
70
+ </text>
71
+ </box>
72
+ </box>
73
+ )
74
+ }
@@ -11,6 +11,7 @@ import { useTheme } from '../../theme'
11
11
  import { WIDGET_RENDERERS } from '../../widgets/registry'
12
12
  import { buildBarContextMenu, buildWidgetContextMenu } from '../../widgets/widget-context-menu'
13
13
  import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
14
+ import { BarFooter } from './bar-footer'
14
15
 
15
16
  export interface BarBoundaryResizeInfo {
16
17
  containerStart: number
@@ -119,6 +120,7 @@ export function Bar({
119
120
  {side === 'right' ? edge : null}
120
121
  <box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
121
122
  {body}
123
+ {side === 'left' ? <BarFooter contentWidth={contentWidth} /> : null}
122
124
  </box>
123
125
  {side === 'left' ? edge : null}
124
126
  </ContextMenuBox>
@@ -6,13 +6,15 @@ import type {
6
6
 
7
7
  import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
8
8
 
9
- import type { ProjectRecord, ProjectStatus } from '../../../../state/types'
9
+ import type { ProjectRecord } from '../../../../state/types'
10
10
 
11
11
  import { useAppStore } from '../../../../state/app-store'
12
12
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
13
- import { getPrimaryWorkspace } from '../../../../state/project-workspaces'
14
- // eslint-disable-next-line no-duplicate-imports
15
- import { IDLE_PROJECT_STATUS } from '../../../../state/types'
13
+ import {
14
+ getActiveWorkspace,
15
+ getPrimaryWorkspace,
16
+ getSidebarWorkspaces,
17
+ } from '../../../../state/project-workspaces'
16
18
  import { moveIdToInsertIndex, orderProjectsForDisplay } from '../../../project-ordering'
17
19
  import { useBaseTheme, useTheme } from '../../../theme'
18
20
  import { truncate } from '../../../truncate'
@@ -31,25 +33,9 @@ const RULE = '─'
31
33
  /** Heavier than the chrome rules, so the drop preview never reads as a border. */
32
34
  const DROP_BAR = '━'
33
35
  const HEADER_TITLE = 'Projects'
34
- /**
35
- * U+2699, not the nerd-font gear: its Emoji_Presentation is No, so a conforming
36
- * terminal draws it text-style in one cell and no font has to be installed for
37
- * the one button that opens the settings.
38
- */
39
- const SETTINGS_GLYPH = '⚙'
40
- /**
41
- * U+25A4, chosen on the same rule as the gear above: one cell, text presentation,
42
- * present in the base fonts. A ▁▄█ mini bar chart reads better but is three cells
43
- * wide, which pushes this row past a narrow sidebar.
44
- */
45
- const STATS_GLYPH = '▤'
46
- const SETTINGS_LABEL = `${SETTINGS_GLYPH} Settings`
47
- const STATS_LABEL = `${STATS_GLYPH} Stats`
48
- /** The two entries and the gap between them, so a renamed label re-measures itself. */
49
- const FOOTER_GAP = 2
50
- const FOOTER_FULL_WIDTH = SETTINGS_LABEL.length + FOOTER_GAP + STATS_LABEL.length
51
- /** The row's own left and right padding. */
52
- const FOOTER_PAD = 2
36
+ /** Same pair the git panel folds its directories with, so the gesture reads once. */
37
+ const COLLAPSED_GLYPH = '▸ '
38
+ const EXPANDED_GLYPH = '▾ '
53
39
 
54
40
  interface DragState {
55
41
  id: string
@@ -61,7 +47,6 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
61
47
  const t = useTheme()
62
48
  const projects = useAppStore((s) => s.projects)
63
49
  const currentProjectId = useAppStore((s) => s.currentProjectId)
64
- const statusMap = useAppStore((s) => s.projectStatuses)
65
50
 
66
51
  // The drag lives in a ref because mouse events can arrive before React has
67
52
  // committed the state they set — reading `draggingId` out of a handler
@@ -90,11 +75,7 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
90
75
  // The cursor is always on a workspace row now, so there is one id to scroll
91
76
  // to instead of two — otherwise it visually "disappears" off-screen when a
92
77
  // key press crosses a project boundary.
93
- const rawActiveWorkspaceId = currentProject?.activeWorkspaceId
94
- const activeWorkspaceId =
95
- rawActiveWorkspaceId != null && rawActiveWorkspaceId !== ''
96
- ? rawActiveWorkspaceId
97
- : getPrimaryWorkspace(currentProject?.workspaces)?.id
78
+ const activeWorkspaceId = getActiveWorkspace(currentProject)?.id
98
79
  const activeRowId =
99
80
  activeWorkspaceId != null && activeWorkspaceId !== '' ? `sidebar-wt-${activeWorkspaceId}` : null
100
81
 
@@ -187,28 +168,7 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
187
168
  dispatchGlobal({ returnToProjectPicker: false, type: 'open-create-project-modal' })
188
169
  }, [])
189
170
 
190
- // The settings screen's only mouse-reachable way in. It lives here because this
191
- // header is on screen whenever the left bar is, and because the `+` beside it
192
- // already taught the same gesture.
193
- const handleOpenSettings = useCallback((e: OtuiMouseEvent) => {
194
- e.stopPropagation()
195
- e.preventDefault()
196
- dispatchGlobal({ type: 'enter-settings' })
197
- }, [])
198
-
199
- // Stats sits beside it for the same reason, and because the two are the only
200
- // full-screen views the panes step aside for that a mouse can reach at all.
201
- const handleOpenStats = useCallback((e: OtuiMouseEvent) => {
202
- e.stopPropagation()
203
- e.preventDefault()
204
- dispatchGlobal({ type: 'enter-stats' })
205
- }, [])
206
-
207
171
  const rule = RULE.repeat(Math.max(1, contentWidth))
208
- // The bar clamps down to 18 columns, narrower than both labels together, so
209
- // below that the second entry drops to its glyph rather than being sliced
210
- // mid-word by the overflow.
211
- const statsLabel = contentWidth - FOOTER_PAD >= FOOTER_FULL_WIDTH ? STATS_LABEL : STATS_GLYPH
212
172
 
213
173
  return (
214
174
  // Drag and release are handled here, not on the row that started them:
@@ -253,15 +213,11 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
253
213
  for (const [index, project] of ordered.entries()) {
254
214
  const projectIndex = index + 1
255
215
  const isCurrentProject = project.id === currentProjectId
256
- const workspaces = project.workspaces ?? []
257
216
  // Every workspace gets a row, the checkout included. Folding it into
258
217
  // the project row made one row mean two things — a project you
259
218
  // switch to and a workspace you run tabs in — and left the checkout
260
219
  // the only workspace with no branch and no churn on screen.
261
- const activeWorkspaceId =
262
- project.activeWorkspaceId != null && project.activeWorkspaceId !== ''
263
- ? project.activeWorkspaceId
264
- : getPrimaryWorkspace(workspaces)?.id
220
+ const activeWorkspaceId = getActiveWorkspace(project)?.id
265
221
  rows.push(
266
222
  // Every project already had a blank line above it, which doubles
267
223
  // as the gap under the header. The drop bar is drawn *in* that
@@ -280,13 +236,12 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
280
236
  project={project}
281
237
  inCurrentGroup={isCurrentProject}
282
238
  projectIndex={projectIndex}
283
- status={statusMap[project.id] ?? IDLE_PROJECT_STATUS}
284
239
  dragging={draggingId === project.id}
285
240
  contentWidth={contentWidth}
286
241
  onDragStart={handleRowDragStart}
287
242
  />
288
243
  )
289
- for (const workspace of workspaces) {
244
+ for (const workspace of getSidebarWorkspaces(project, isCurrentProject)) {
290
245
  rows.push(
291
246
  <WorkspaceRow
292
247
  key={`wt:${workspace.id}`}
@@ -313,22 +268,6 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
313
268
  return rows
314
269
  })()}
315
270
  </scrollbox>
316
- <box flexShrink={0}>
317
- <text fg={t.border} selectable={false} wrapMode="none">
318
- {rule}
319
- </text>
320
- </box>
321
- {/* Each entry is its own <text>, with a spacer box between them: one string
322
- holding both would make the whole footer a single click target. */}
323
- <box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
324
- <text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenSettings}>
325
- {SETTINGS_LABEL}
326
- </text>
327
- <box width={FOOTER_GAP} flexShrink={1} />
328
- <text fg={t.textMuted} selectable={false} wrapMode="none" onMouseDown={handleOpenStats}>
329
- {statsLabel}
330
- </text>
331
- </box>
332
271
  </box>
333
272
  )
334
273
  }
@@ -370,7 +309,6 @@ interface ProjectRowProps {
370
309
  inCurrentGroup: boolean
371
310
  /** 1-based index in the visible order, so the "+" can switch projects first. */
372
311
  projectIndex: number
373
- status: ProjectStatus
374
312
  dragging: boolean
375
313
  contentWidth: number
376
314
  /** Only the gesture's start lives here — the list owns drag and release. */
@@ -384,25 +322,11 @@ const ProjectRow = memo(function ProjectRow({
384
322
  onDragStart,
385
323
  project,
386
324
  projectIndex,
387
- status,
388
325
  }: ProjectRowProps) {
389
326
  const t = useTheme()
390
327
  // Selection highlight must stay opaque in transparent mode — otherwise the
391
328
  // cursor row visually disappears against the see-through chrome.
392
329
  const base = useBaseTheme()
393
- // State belongs on the workspace rows: they say *which* one is working or
394
- // waiting, and this heading can only say "somewhere below". So the heading
395
- // speaks only when none of its workspaces can — a tab whose workspace this
396
- // client doesn't know, which today means a daemon still running a pre-v18
397
- // protocol. Without that fallback the sidebar would go silent instead of
398
- // degrading.
399
- const workspacesSpeak = useAppStore((s) =>
400
- (project.workspaces ?? []).some((workspace) => {
401
- const activity = s.workspaceActivity[workspace.id]
402
- return activity !== undefined && (activity.working || activity.waiting || activity.done)
403
- })
404
- )
405
- const showWaiting = status.waiting && !workspacesSpeak
406
330
  // Only the drag highlight is "selected"-strength here. A heading that lights
407
331
  // up like a cursor row is what made the project look like a workspace.
408
332
  let bgColor: string | undefined
@@ -411,7 +335,6 @@ const ProjectRow = memo(function ProjectRow({
411
335
  } else if (inCurrentGroup) {
412
336
  bgColor = base.backgroundPanel
413
337
  }
414
- const waitingColor = t.warning
415
338
  const currentProjectId = useAppStore((s) => s.currentProjectId)
416
339
 
417
340
  const handleMouseDown = useCallback(
@@ -422,6 +345,16 @@ const ProjectRow = memo(function ProjectRow({
422
345
  },
423
346
  [onDragStart, project.id]
424
347
  )
348
+ // stopPropagation keeps the row's own mousedown from starting a drag, so the
349
+ // release never falls through to switching project — same trick as the "+".
350
+ const handleToggleCollapsed = useCallback(
351
+ (e: OtuiMouseEvent) => {
352
+ e.preventDefault()
353
+ e.stopPropagation()
354
+ runSideEffectGlobal({ projectId: project.id, type: 'toggle-project-collapsed' })
355
+ },
356
+ [project.id]
357
+ )
425
358
  const handleNewWorkspace = useCallback(
426
359
  (e: OtuiMouseEvent) => {
427
360
  e.preventDefault()
@@ -457,17 +390,11 @@ const ProjectRow = memo(function ProjectRow({
457
390
  [project.id, project.name]
458
391
  )
459
392
 
460
- // Neutral muted marker, and a question when a workspace of this project needs
461
- // an answer. No progress indicator: a heading can only say "somewhere below",
462
- // and the row that actually knows is one line down. Keeping a glyph in the slot
463
- // avoids name shifts when the question comes and goes — which is also why the
464
- // gap after it is part of the glyph.
465
- let leadingGlyph = '• '
466
- let leadingColor = t.textMuted
467
- if (showWaiting) {
468
- leadingGlyph = '? '
469
- leadingColor = waitingColor
470
- }
393
+ // The slot used to carry a status marker, which only ever said "somewhere
394
+ // below" the workspace rows one line down say it precisely. It now carries
395
+ // the fold arrow instead, and the trailing space is part of the glyph so the
396
+ // name never shifts.
397
+ const leadingGlyph = project.collapsed === true ? COLLAPSED_GLYPH : EXPANDED_GLYPH
471
398
 
472
399
  // No branch line: the repo checkout is not somewhere aimux works, so naming
473
400
  // it under every project only ever read as "you are on main".
@@ -485,7 +412,12 @@ const ProjectRow = memo(function ProjectRow({
485
412
  onMouseDown={handleMouseDown}
486
413
  >
487
414
  <box flexDirection="row" alignItems="center">
488
- <text fg={leadingColor} selectable={false} wrapMode="none">
415
+ <text
416
+ fg={t.textMuted}
417
+ selectable={false}
418
+ wrapMode="none"
419
+ onMouseDown={handleToggleCollapsed}
420
+ >
489
421
  {leadingGlyph}
490
422
  </text>
491
423
  <FlashLabelBadge rowKey={`ws:${project.id}`} />
@@ -7,6 +7,7 @@ import {
7
7
  import type { AppState } from '../../../state/types'
8
8
 
9
9
  import { version as APP_VERSION } from '../../../../package.json'
10
+ import { useStatusBarHints } from '../../../settings/live'
10
11
  import { useAIUsageStore } from '../../../state/ai-usage-store'
11
12
  import { useAppStore } from '../../../state/app-store'
12
13
  import { useKeymap } from '../../keymap-context'
@@ -124,6 +125,7 @@ export function StatusBar() {
124
125
  const modeColor = getModeColor(state.focusMode, t)
125
126
  const ambient = composeAmbient(model.right, model.help)
126
127
  const aiEnabled = useAIUsageStore((s) => s.enabled)
128
+ const showHints = useStatusBarHints()
127
129
 
128
130
  const glyphs = SEPARATOR_GLYPHS[getStatusBarSeparator()]
129
131
 
@@ -137,7 +139,7 @@ export function StatusBar() {
137
139
 
138
140
  return (
139
141
  <box
140
- height={2}
142
+ height={showHints ? 2 : 1}
141
143
  flexShrink={0}
142
144
  overflow="hidden"
143
145
  flexDirection="column"
@@ -201,20 +203,22 @@ export function StatusBar() {
201
203
  </box>
202
204
 
203
205
  {/* Row 2 — ambient hints */}
204
- <box
205
- height={1}
206
- flexShrink={0}
207
- flexDirection="row"
208
- paddingLeft={ROW2_INDENT}
209
- paddingRight={1}
210
- overflow="hidden"
211
- >
212
- {ambient !== '' ? (
213
- <text fg={t.textMuted} wrapMode="none" selectable={false}>
214
- {ambient}
215
- </text>
216
- ) : null}
217
- </box>
206
+ {showHints ? (
207
+ <box
208
+ height={1}
209
+ flexShrink={0}
210
+ flexDirection="row"
211
+ paddingLeft={ROW2_INDENT}
212
+ paddingRight={1}
213
+ overflow="hidden"
214
+ >
215
+ {ambient !== '' ? (
216
+ <text fg={t.textMuted} wrapMode="none" selectable={false}>
217
+ {ambient}
218
+ </text>
219
+ ) : null}
220
+ </box>
221
+ ) : null}
218
222
  </box>
219
223
  )
220
224
  }