@brimveyn/aimux 1.14.4 → 1.14.6

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.14.4",
3
+ "version": "1.14.6",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -1059,7 +1059,13 @@ async function openEditorInline(
1059
1059
  }
1060
1060
 
1061
1061
  function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
1062
- const { backend, dispatch, state } = ctx
1062
+ const { backend, dispatch } = ctx
1063
+ // Read fresh state. ctx.state is the snapshot from the previous render and
1064
+ // lags behind dispatches that happened in the same JS turn (a worktree-row
1065
+ // click first dispatches set-active-worktree then fires this side effect —
1066
+ // we need to see that just-applied activeWorktreeId so the new session
1067
+ // lands on the right worktree, not its last-saved one).
1068
+ const state = ctx.getState()
1063
1069
  const ordered = [...state.sessions].sort(
1064
1070
  (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
1065
1071
  )
@@ -165,6 +165,18 @@ export function useTerminalResize({
165
165
  // loop so an unchanged box never re-triggers a resize.
166
166
  const measuredRef = useRef(new Map<string, { cols: number; rows: number }>())
167
167
 
168
+ // Drop the dedupe cache when the workspace changes. attach() re-gates every
169
+ // restored tab's paneReady to false; if a tabId carries the same dimensions
170
+ // across workspaces, handleMeasure's prev-match would short-circuit and
171
+ // never call resizeTab({confirmedFromMeasurement:true}), leaving the gate
172
+ // closed forever — the new pane then paints a stale buffered snapshot from
173
+ // the old workspace, which is the 2–3 row offset users see.
174
+ const lastSessionIdRef = useRef(state.currentSessionId)
175
+ if (lastSessionIdRef.current !== state.currentSessionId) {
176
+ lastSessionIdRef.current = state.currentSessionId
177
+ measuredRef.current.clear()
178
+ }
179
+
168
180
  // Closed measurement loop: the rendered terminal content box reports its
169
181
  // real geometry; that — not the hardcoded chrome model below — is the
170
182
  // authority for the PTY/xterm size and the mouse-mapping origin. The model
package/src/app.tsx CHANGED
@@ -409,7 +409,11 @@ export function App({
409
409
  if (!(state.currentSessionId != null && state.currentSessionId !== '')) return
410
410
  return getSessionProjectPath(state.sessions.find((s) => s.id === state.currentSessionId))
411
411
  },
412
- getState: () => stateRef.current,
412
+ // Read straight from the store, not stateRef. stateRef is only refreshed
413
+ // on render, so within a single JS turn (a click handler that dispatches
414
+ // then fires a side effect) it lags one step behind. appStore.getState
415
+ // reflects every dispatch synchronously.
416
+ getState: () => appStore.getState(),
413
417
  renderer,
414
418
  setThemeId,
415
419
  startStartupGrace,
@@ -3,7 +3,7 @@ import type { AppAction, AppState } from '../types'
3
3
  import { moveIdToIdPosition, orderSessionsForDisplay } from '../../ui/session-ordering'
4
4
  import { filterSessions } from '../selectors'
5
5
  import { restoreWorkspaceState } from '../session-persistence'
6
- import { withActiveWorktree } from '../session-worktrees'
6
+ import { filterTabsForActiveWorktree, withActiveWorktree } from '../session-worktrees'
7
7
 
8
8
  const CLOSED_MODAL = {
9
9
  editBuffer: null,
@@ -18,9 +18,24 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
18
18
  const snapshot =
19
19
  action.workspaceSnapshot ??
20
20
  state.sessions.find((entry) => entry.id === action.sessionId)?.workspaceSnapshot
21
+ const restored = restoreWorkspaceState(state, snapshot)
22
+ // The session's activeWorktreeId may have been patched right before
23
+ // this load (e.g. the cross-workspace branch of handleCycleSidebarItem
24
+ // sets it to the worktree the user just clicked). The snapshot's
25
+ // activeTabId still reflects the *last* worktree they were on, so
26
+ // honor the patched worktree by filtering the restored tab list.
27
+ const loadedSession = state.sessions.find((entry) => entry.id === action.sessionId)
28
+ const visible = filterTabsForActiveWorktree(restored.tabs, loadedSession)
29
+ const activeTabId =
30
+ restored.activeTabId != null &&
31
+ restored.activeTabId !== '' &&
32
+ visible.some((t) => t.id === restored.activeTabId)
33
+ ? restored.activeTabId
34
+ : (visible[0]?.id ?? null)
21
35
  return {
22
36
  ...state,
23
- ...restoreWorkspaceState(state, snapshot),
37
+ ...restored,
38
+ activeTabId,
24
39
  currentSessionId: action.sessionId,
25
40
  focusMode: 'navigation',
26
41
  modal: CLOSED_MODAL,
@@ -175,13 +190,30 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
175
190
  )
176
191
  }),
177
192
  }
178
- case 'set-active-worktree':
179
- return {
180
- ...state,
181
- sessions: state.sessions.map((session) =>
182
- session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
183
- ),
193
+ case 'set-active-worktree': {
194
+ const sessions = state.sessions.map((session) =>
195
+ session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
196
+ )
197
+ // Changing the worktree of a non-current session has no effect on
198
+ // which tab the user is looking at — leave activeTabId alone.
199
+ if (action.sessionId !== state.currentSessionId) {
200
+ return { ...state, sessions }
201
+ }
202
+ // For the current session: if the existing activeTabId belongs to
203
+ // the new worktree, keep it. Otherwise hop to the first tab visible
204
+ // under that worktree (or clear it if the worktree is empty — the
205
+ // pane then renders the "this worktree has no tabs" placeholder).
206
+ const updated = sessions.find((s) => s.id === action.sessionId)
207
+ const visible = filterTabsForActiveWorktree(state.tabs, updated)
208
+ const currentStillVisible =
209
+ state.activeTabId != null &&
210
+ state.activeTabId !== '' &&
211
+ visible.some((t) => t.id === state.activeTabId)
212
+ if (currentStillVisible) {
213
+ return { ...state, sessions }
184
214
  }
215
+ return { ...state, activeTabId: visible[0]?.id ?? null, sessions }
216
+ }
185
217
  case 'update-worktree-record':
186
218
  return {
187
219
  ...state,
@@ -15,7 +15,11 @@ import {
15
15
  splitNode,
16
16
  } from '../layout-tree'
17
17
  import { normalizeGroupedTabOrder } from '../session-persistence'
18
- import { orderTabsByWorktree, withActiveWorktree } from '../session-worktrees'
18
+ import {
19
+ filterTabsForActiveWorktree,
20
+ orderTabsByWorktree,
21
+ withActiveWorktree,
22
+ } from '../session-worktrees'
19
23
  import { createDefaultTerminalModes } from '../terminal-modes'
20
24
 
21
25
  const MAX_BUFFER_LENGTH = 50_000
@@ -317,12 +321,23 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
317
321
  )
318
322
  }
319
323
  case 'hydrate-workspace': {
324
+ // The session's activeWorktreeId may have just been switched by the
325
+ // user (j/k cycle, sidebar click) before the backend attached. Honor
326
+ // that choice by only considering tabs visible in that worktree —
327
+ // otherwise we'd land the active tab on whatever the backend picked
328
+ // (typically the worktree we *last* persisted, not the current one).
329
+ const currentSession =
330
+ state.currentSessionId != null && state.currentSessionId !== ''
331
+ ? state.sessions.find((s) => s.id === state.currentSessionId)
332
+ : undefined
333
+ const visibleForWorktree = filterTabsForActiveWorktree(action.tabs, currentSession)
334
+ const visibleIds = new Set(visibleForWorktree.map((t) => t.id))
320
335
  const hydratedActiveTabId =
321
336
  action.activeTabId != null &&
322
337
  action.activeTabId !== '' &&
323
- action.tabs.some((tab) => tab.id === action.activeTabId)
338
+ visibleIds.has(action.activeTabId)
324
339
  ? action.activeTabId
325
- : (action.tabs[0]?.id ?? null)
340
+ : (visibleForWorktree[0]?.id ?? null)
326
341
  const tabIds = new Set(action.tabs.map((t) => t.id))
327
342
 
328
343
  // Restore from new multi-tree format or migrate from legacy single tree
@@ -8,8 +8,6 @@ import type { BranchDivergence, SessionRecord, TabSession, WorktreeRecord } from
8
8
  import { createPrefixedId } from '../platform/id'
9
9
  import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
10
10
 
11
- const WORKTREE_COLORS = ['#7dd3fc', '#86efac', '#facc15', '#f0abfc', '#fb7185', '#c4b5fd']
12
-
13
11
  export function formatDivergence(divergence: BranchDivergence | undefined): string {
14
12
  if (divergence == null) return ''
15
13
  const parts: string[] = []
@@ -18,14 +16,6 @@ export function formatDivergence(divergence: BranchDivergence | undefined): stri
18
16
  return parts.join(' ')
19
17
  }
20
18
 
21
- export function getWorktreeColor(worktreeId: string): string {
22
- let hash = 0
23
- for (let i = 0; i < worktreeId.length; i++) {
24
- hash = (hash * 31 + worktreeId.charCodeAt(i)) >>> 0
25
- }
26
- return WORKTREE_COLORS[hash % WORKTREE_COLORS.length] ?? '#7dd3fc'
27
- }
28
-
29
19
  export function createPrimaryWorktree(projectPath: string, now: string): WorktreeRecord {
30
20
  const id = createPrefixedId('worktree')
31
21
  return {
@@ -76,7 +76,7 @@ export const GitPaneHeader = memo(function GitPaneHeader({
76
76
  </box>
77
77
  ) : null}
78
78
  {showToggle ? (
79
- <box flexDirection="row" gap={1} onMouseDown={toggleListMode}>
79
+ <box flexDirection="row" gap={1} paddingLeft={1} onMouseDown={toggleListMode}>
80
80
  <text
81
81
  selectable={false}
82
82
  fg={fileListMode === 'tree' ? t.primary : t.textMuted}
@@ -10,7 +10,7 @@ import type { SessionRecord, SessionStatus, WorktreeRecord } from '../../../../s
10
10
 
11
11
  import { useAppStore } from '../../../../state/app-store'
12
12
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
13
- import { formatDivergence, getWorktreeColor } from '../../../../state/session-worktrees'
13
+ import { formatDivergence } from '../../../../state/session-worktrees'
14
14
  // eslint-disable-next-line no-duplicate-imports
15
15
  import { IDLE_SESSION_STATUS } from '../../../../state/types'
16
16
  import { useBusySpinner } from '../../../hooks/use-busy-spinner'
@@ -199,6 +199,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
199
199
  isActiveItem={workspaceIsActiveItem}
200
200
  inCurrentGroup={isCurrentSession}
201
201
  primaryWorktree={primaryWorktree}
202
+ hasExtraWorktrees={extraWorktrees.length > 0}
202
203
  status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
203
204
  dragging={draggingId === session.id}
204
205
  contentWidth={contentWidth}
@@ -210,7 +211,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
210
211
  onDragCancel={cancelDrag}
211
212
  />
212
213
  )
213
- for (const worktree of extraWorktrees) {
214
+ for (const [wtIdx, worktree] of extraWorktrees.entries()) {
214
215
  rows.push(
215
216
  <WorktreeRow
216
217
  key={`wt:${worktree.id}`}
@@ -219,6 +220,7 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
219
220
  sessionIndex={sessionIndex}
220
221
  isActiveItem={isCurrentSession && worktree.id === session.activeWorktreeId}
221
222
  inCurrentGroup={isCurrentSession}
223
+ isLast={wtIdx === extraWorktrees.length - 1}
222
224
  />
223
225
  )
224
226
  }
@@ -250,6 +252,8 @@ interface WorkspaceRowProps {
250
252
  inCurrentGroup: boolean
251
253
  /** The session's primary worktree — its git branch is shown as the workspace's anchor identity. */
252
254
  primaryWorktree: WorktreeRecord | undefined
255
+ /** True when at least one non-primary worktree follows — used to draw the tree continuator. */
256
+ hasExtraWorktrees: boolean
253
257
  status: SessionStatus
254
258
  dragging: boolean
255
259
  contentWidth: number
@@ -265,6 +269,7 @@ interface WorkspaceRowProps {
265
269
  const WorkspaceRow = memo(function WorkspaceRow({
266
270
  contentWidth,
267
271
  dragging,
272
+ hasExtraWorktrees,
268
273
  inCurrentGroup,
269
274
  isActiveItem,
270
275
  marginTop,
@@ -332,16 +337,10 @@ const WorkspaceRow = memo(function WorkspaceRow({
332
337
  [session.id, session.name]
333
338
  )
334
339
 
335
- // Color of the left vertical bar the workspace's stable accent (derived
336
- // from its primary worktree id). Falls back to a tint for unhydrated sessions.
337
- const barColor =
338
- primaryWorktree != null
339
- ? (primaryWorktree.color ?? getWorktreeColor(primaryWorktree.id))
340
- : t.textMuted
341
- // Working/waiting indicator overrides the colored bar — the user needs to
342
- // see assistant activity from across the room.
343
- let leadingGlyph = '▍'
344
- let leadingColor = barColor
340
+ // Neutral muted marker; working/waiting overrides it. Keeping a glyph
341
+ // in the slot avoids name shifts when state indicators come and go.
342
+ let leadingGlyph = '•'
343
+ let leadingColor = t.textMuted
345
344
  if (showWaiting) {
346
345
  leadingGlyph = '?'
347
346
  leadingColor = waitingColor
@@ -379,7 +378,7 @@ const WorkspaceRow = memo(function WorkspaceRow({
379
378
  <text fg={leadingColor} selectable={false} wrapMode="none">
380
379
  {leadingGlyph}
381
380
  </text>
382
- <text fg={isActiveItem ? t.text : t.textMuted} selectable={false} wrapMode="none">
381
+ <text fg={t.text} selectable={false} wrapMode="none">
383
382
  {' '}
384
383
  {nameLabel}
385
384
  </text>
@@ -387,7 +386,7 @@ const WorkspaceRow = memo(function WorkspaceRow({
387
386
  {showBranch ? (
388
387
  <box flexDirection="row">
389
388
  <text fg={t.textMuted} selectable={false} wrapMode="none">
390
- {' '}
389
+ {hasExtraWorktrees ? '│ ' : ' '}
391
390
  {'\u{e702}'} {branchLabel}
392
391
  {divergenceText !== '' ? ` ${divergenceText}` : ''}
393
392
  </text>
@@ -6,6 +6,7 @@ import type { SessionRecord, WorktreeRecord } from '../../../../state/types'
6
6
 
7
7
  import { useAppStore } from '../../../../state/app-store'
8
8
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
9
+ import { formatDivergence } from '../../../../state/session-worktrees'
9
10
  import { useTheme } from '../../../theme'
10
11
  import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
11
12
 
@@ -18,11 +19,14 @@ interface WorktreeRowProps {
18
19
  isActiveItem: boolean
19
20
  /** True when this row's workspace is the current session (selection scope). */
20
21
  inCurrentGroup: boolean
22
+ /** True when this is the last non-primary worktree of its workspace. Drives └─ vs ├─. */
23
+ isLast: boolean
21
24
  }
22
25
 
23
26
  export const WorktreeRow = memo(function WorktreeRow({
24
27
  inCurrentGroup,
25
28
  isActiveItem,
29
+ isLast,
26
30
  session,
27
31
  sessionIndex,
28
32
  worktree,
@@ -30,6 +34,7 @@ export const WorktreeRow = memo(function WorktreeRow({
30
34
  const t = useTheme()
31
35
  const currentSessionId = useAppStore((s) => s.currentSessionId)
32
36
  const isCurrentSession = session.id === currentSessionId
37
+ const divergence = useAppStore((s) => s.worktreeDivergence[worktree.id])
33
38
 
34
39
  const handleMouseDown = useCallback(
35
40
  (event: OtuiMouseEvent) => {
@@ -70,23 +75,40 @@ export const WorktreeRow = memo(function WorktreeRow({
70
75
  bgColor = t.backgroundPanel
71
76
  }
72
77
 
78
+ const connector = isLast ? '└─' : '├─'
79
+ const branchCont = isLast ? ' ' : '│ '
80
+ const branchText = worktree.branch ?? ''
81
+ const showBranch = branchText !== ''
82
+ const divergenceText = formatDivergence(divergence)
83
+
73
84
  return (
74
85
  <ContextMenuBox
75
86
  id={`sidebar-wt-${worktree.id}`}
76
- flexDirection="row"
87
+ flexDirection="column"
88
+ flexShrink={0}
77
89
  paddingLeft={1}
78
90
  paddingRight={1}
79
- alignItems="center"
80
91
  backgroundColor={bgColor}
81
92
  rightClickMenu={rightClickMenu}
82
93
  onMouseDown={handleMouseDown}
83
94
  >
84
- <text fg={t.textMuted} selectable={false} wrapMode="none">
85
- {' > '}
86
- </text>
87
- <text fg={isActiveItem ? t.text : t.textMuted} selectable={false} wrapMode="none">
88
- {worktree.name}
89
- </text>
95
+ <box flexDirection="row" alignItems="center">
96
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
97
+ {connector}{' '}
98
+ </text>
99
+ <text fg={t.text} selectable={false} wrapMode="none">
100
+ {worktree.name}
101
+ </text>
102
+ </box>
103
+ {showBranch ? (
104
+ <box flexDirection="row">
105
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
106
+ {branchCont}
107
+ {'\u{e702}'} {branchText}
108
+ {divergenceText !== '' ? ` ${divergenceText}` : ''}
109
+ </text>
110
+ </box>
111
+ ) : null}
90
112
  </ContextMenuBox>
91
113
  )
92
114
  })
@@ -8,6 +8,7 @@ import type { FocusMode, TabSession, TerminalSnapshot, TerminalSpan } from '../.
8
8
 
9
9
  import { type MeasuredPaneRect, usePaneSizeReport } from '../../../app-runtime/use-pane-size-report'
10
10
  import { logInputDebug } from '../../../debug/input-log'
11
+ import { useAppStore } from '../../../state/app-store'
11
12
  import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
12
13
  import { type ContextMenuItem, openContextMenu } from '../../context-menu/controller'
13
14
  import { resolvePaletteIndex } from '../../host-palette'
@@ -39,10 +40,16 @@ interface TerminalPaneProps {
39
40
  function getTitle(
40
41
  tab: TabSession | undefined,
41
42
  isActive: boolean,
42
- focusMode: TerminalPaneProps['focusMode']
43
+ focusMode: TerminalPaneProps['focusMode'],
44
+ emptyContext: { workspaceName: string; worktreeName: string }
43
45
  ): string {
44
46
  if (!tab) {
45
- return 'No active workspace'
47
+ const { workspaceName, worktreeName } = emptyContext
48
+ if (workspaceName === '' && worktreeName === '') return 'No active workspace'
49
+ if (worktreeName === '' || worktreeName === workspaceName) {
50
+ return `${workspaceName} · no tabs`
51
+ }
52
+ return `${workspaceName} / ${worktreeName} · no tabs`
46
53
  }
47
54
 
48
55
  if (isActive && focusMode === 'terminal-input') {
@@ -146,6 +153,26 @@ export function TerminalPane({
146
153
  const setContentBox = usePaneSizeReport(tabId, !!tab, onMeasure)
147
154
  const editorBg = t.background
148
155
  const paneIsActive = isActive ?? true
156
+ // These are only used when this pane is rendered without a tab (the
157
+ // top-level pane on a worktree with zero tabs). Selectors return plain
158
+ // strings so re-renders are cheap and bounded to actual name changes.
159
+ const emptyWorkspaceName = useAppStore((s) => {
160
+ const id = s.currentSessionId
161
+ if (id == null || id === '') return ''
162
+ return s.sessions.find((sess) => sess.id === id)?.name ?? ''
163
+ })
164
+ const emptyWorktreeName = useAppStore((s) => {
165
+ const id = s.currentSessionId
166
+ if (id == null || id === '') return ''
167
+ const session = s.sessions.find((sess) => sess.id === id)
168
+ if (!session) return ''
169
+ const activeId =
170
+ session.activeWorktreeId != null && session.activeWorktreeId !== ''
171
+ ? session.activeWorktreeId
172
+ : session.worktrees?.[0]?.id
173
+ if (activeId == null || activeId === '') return ''
174
+ return session.worktrees?.find((w) => w.id === activeId)?.name ?? ''
175
+ })
149
176
  const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
150
177
  const canUseLocalScrollback = focusMode === 'terminal-input' && !!tab && localScrollbackEnabled
151
178
  const rightClickMenu = useMemo<ContextMenuItem[] | undefined>(
@@ -341,7 +368,10 @@ export function TerminalPane({
341
368
  <ContextMenuBox
342
369
  border
343
370
  borderColor={getBorderColor(paneIsActive, focusMode)}
344
- title={getTitle(tab, paneIsActive, focusMode)}
371
+ title={getTitle(tab, paneIsActive, focusMode, {
372
+ workspaceName: emptyWorkspaceName,
373
+ worktreeName: emptyWorktreeName,
374
+ })}
345
375
  padding={0}
346
376
  flexDirection="column"
347
377
  flexGrow={1}
@@ -356,6 +386,19 @@ export function TerminalPane({
356
386
  <box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column">
357
387
  <text fg={t.textMuted}>· · ·</text>
358
388
  <text fg={t.textMuted}> </text>
389
+ {emptyWorkspaceName !== '' ? (
390
+ <box flexDirection="row">
391
+ <text fg={t.text}>{emptyWorkspaceName}</text>
392
+ {emptyWorktreeName !== '' && emptyWorktreeName !== emptyWorkspaceName ? (
393
+ <>
394
+ <text fg={t.textMuted}> / </text>
395
+ <text fg={t.text}>{emptyWorktreeName}</text>
396
+ </>
397
+ ) : null}
398
+ </box>
399
+ ) : null}
400
+ <text fg={t.textMuted}>has no tabs</text>
401
+ <text fg={t.textMuted}> </text>
359
402
  <box flexDirection="row">
360
403
  <text fg={t.textMuted}>Press </text>
361
404
  <text fg={t.primary}>Ctrl+n</text>
@@ -220,7 +220,9 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
220
220
  }, [])
221
221
 
222
222
  if (!bar.visible && !forceVisible) return null
223
- if (entries.length === 0) return null
223
+ // Keep the bar visible even with zero entries when the active worktree
224
+ // has no tabs, the lone "+" affordance is what tells the user "you're in
225
+ // an empty worktree, click here to start one".
224
226
 
225
227
  const isFocused = focusMode === 'terminal-input' || focusMode === 'navigation'
226
228