@brimveyn/aimux 1.14.8 → 1.14.9

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.8",
3
+ "version": "1.14.9",
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",
@@ -795,7 +795,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
795
795
  return
796
796
  }
797
797
  case 'switch-session-by-index': {
798
- handleSwitchSessionByIndex(ctx, effect.index)
798
+ handleSwitchSessionByIndex(ctx, effect.index, effect.worktreeId)
799
799
  return
800
800
  }
801
801
  case 'cycle-sidebar-item': {
@@ -1058,13 +1058,14 @@ async function openEditorInline(
1058
1058
  }
1059
1059
  }
1060
1060
 
1061
- function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
1061
+ function handleSwitchSessionByIndex(
1062
+ ctx: SideEffectContext,
1063
+ index: number,
1064
+ worktreeId?: string
1065
+ ): void {
1062
1066
  const { backend, dispatch } = ctx
1063
1067
  // 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
+ // lags behind dispatches that happened in the same JS turn.
1068
1069
  const state = ctx.getState()
1069
1070
  const ordered = [...state.sessions].sort(
1070
1071
  (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
@@ -1074,13 +1075,60 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
1074
1075
  logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
1075
1076
  return
1076
1077
  }
1078
+
1079
+ // Resolve which worktree to land on. If the caller passed an explicit
1080
+ // `worktreeId` (workspace-row tap → its primary, worktree-row tap → that
1081
+ // worktree), honor it; otherwise let the target session keep its persisted
1082
+ // activeWorktreeId.
1083
+ const resolvedWorktreeId =
1084
+ worktreeId != null &&
1085
+ worktreeId !== '' &&
1086
+ (target.worktrees?.some((w) => w.id === worktreeId) ?? false)
1087
+ ? worktreeId
1088
+ : undefined
1089
+ const needsWorktreeChange =
1090
+ resolvedWorktreeId != null && resolvedWorktreeId !== target.activeWorktreeId
1091
+
1077
1092
  if (target.id === state.currentSessionId) {
1093
+ if (needsWorktreeChange) {
1094
+ dispatch({
1095
+ sessionId: target.id,
1096
+ type: 'set-active-worktree',
1097
+ worktreeId: resolvedWorktreeId,
1098
+ })
1099
+ }
1078
1100
  if (state.focusMode === 'git') {
1079
1101
  dispatch({ type: 'exit-git-mode' })
1080
1102
  }
1081
1103
  return
1082
1104
  }
1083
- handleSwitchSessionEffect(state, backend, dispatch, target)
1105
+
1106
+ // Cross-workspace: bundle the worktree change into the session record AND
1107
+ // fold set-sessions + load-session into a SINGLE setState call. Otherwise
1108
+ // any subscriber notification (re-render, useEffect, backend re-attach)
1109
+ // between dispatches can re-assert the session's previously-persisted
1110
+ // activeWorktreeId, dropping the user back on the last-visited worktree.
1111
+ const patchedSession = needsWorktreeChange
1112
+ ? withActiveWorktree(target, resolvedWorktreeId)
1113
+ : target
1114
+ const patchedState: AppState = needsWorktreeChange
1115
+ ? {
1116
+ ...state,
1117
+ sessions: state.sessions.map((s) => (s.id === patchedSession.id ? patchedSession : s)),
1118
+ }
1119
+ : state
1120
+ const sessions = switchSessionRecords(patchedState, patchedSession)
1121
+ saveSessionCatalog(sessions)
1122
+ void backend.destroy(true)
1123
+ appStore.setState((current) => {
1124
+ const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
1125
+ return appReducer(afterSet, {
1126
+ forceDisconnected: false,
1127
+ sessionId: patchedSession.id,
1128
+ type: 'load-session',
1129
+ workspaceSnapshot: patchedSession.workspaceSnapshot,
1130
+ })
1131
+ })
1084
1132
  }
1085
1133
 
1086
1134
  interface SidebarItem {
@@ -1175,6 +1223,10 @@ function handleCycleSidebarItem(ctx: SideEffectContext, direction: 1 | -1): void
1175
1223
  appStore.setState((current) => {
1176
1224
  const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
1177
1225
  return appReducer(afterSet, {
1226
+ // Daemon is alive and attach() will hydrate real statuses within a
1227
+ // frame, so skip the snapshot's running→disconnected downgrade —
1228
+ // otherwise the "Restored snapshot" hint flashes on every j/k cycle.
1229
+ forceDisconnected: false,
1178
1230
  sessionId: patchedSession.id,
1179
1231
  type: 'load-session',
1180
1232
  workspaceSnapshot: patchedSession.workspaceSnapshot,
package/src/index.tsx CHANGED
@@ -60,6 +60,11 @@ if (command === '--help' || command === '-h') {
60
60
 
61
61
  const renderer = await createCliRenderer({
62
62
  autoFocus: true,
63
+ // Transparent clear color so cells untouched by BoxRenderable paints (e.g.
64
+ // when transparent mode overrides all chrome bg tokens to alpha=0) flush to
65
+ // the terminal with no bg, letting the host terminal's background show
66
+ // through. With an opaque clear color, transparent mode is a no-op.
67
+ backgroundColor: '#00000000',
63
68
  consoleMode: 'disabled',
64
69
  exitOnCtrlC: false,
65
70
  screenMode: 'alternate-screen',
@@ -67,7 +67,7 @@ export type SideEffect =
67
67
  | { type: 'generate-auto-commit-now'; sessionId: string }
68
68
  | { type: 'git-push' }
69
69
  | { type: 'confirm-update-selection' }
70
- | { type: 'switch-session-by-index'; index: number }
70
+ | { type: 'switch-session-by-index'; index: number; worktreeId?: string }
71
71
  | { type: 'cycle-sidebar-item'; direction: 1 | -1 }
72
72
  | { type: 'switch-tab-by-index'; index: number }
73
73
  | { type: 'delete-session'; sessionId: string }
@@ -18,7 +18,9 @@ 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)
21
+ const restored = restoreWorkspaceState(state, snapshot, {
22
+ forceDisconnected: action.forceDisconnected ?? true,
23
+ })
22
24
  // The session's activeWorktreeId may have been patched right before
23
25
  // this load (e.g. the cross-workspace branch of handleCycleSidebarItem
24
26
  // sets it to the worktree the user just clicked). The snapshot's
@@ -51,11 +51,26 @@ export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
51
51
  }
52
52
  }
53
53
 
54
- export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefined): TabSession[] {
54
+ export interface RestoreOptions {
55
+ // Force any running/starting tab to 'disconnected' on restore. True for
56
+ // cold-start (daemon may not own the sessions yet) and the daemon's own
57
+ // catalog hydration. False for live in-app workspace switches where an
58
+ // attach() is guaranteed to follow within a frame and hydrate-workspace
59
+ // will overwrite the status with daemon truth — leaving the flag on would
60
+ // briefly flash the "Restored snapshot" hint on every j/k cycle.
61
+ forceDisconnected?: boolean
62
+ }
63
+
64
+ export function restoreTabsFromWorkspace(
65
+ snapshot: WorkspaceSnapshotV1 | undefined,
66
+ options: RestoreOptions = {}
67
+ ): TabSession[] {
55
68
  if (!snapshot || snapshot.version !== 1) {
56
69
  return []
57
70
  }
58
71
 
72
+ const forceDisconnected = options.forceDisconnected ?? true
73
+
59
74
  return snapshot.tabs
60
75
  .filter(
61
76
  (tab): tab is typeof tab & { status: Exclude<typeof tab.status, 'exited'> } =>
@@ -69,7 +84,7 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
69
84
  errorMessage: tab.errorMessage,
70
85
  exitCode: tab.exitCode,
71
86
  id: tab.id,
72
- status: getDisconnectedStatus(tab.status),
87
+ status: forceDisconnected ? getDisconnectedStatus(tab.status) : tab.status,
73
88
  terminalModes: tab.terminalModes,
74
89
  title: tab.title,
75
90
  viewport: tab.viewport,
@@ -169,12 +184,13 @@ export function normalizeGroupedTabOrder(
169
184
 
170
185
  export function restoreWorkspaceState(
171
186
  state: AppState,
172
- workspaceSnapshot: WorkspaceSnapshotV1 | undefined
187
+ workspaceSnapshot: WorkspaceSnapshotV1 | undefined,
188
+ options: RestoreOptions = {}
173
189
  ): Pick<
174
190
  AppState,
175
191
  'tabs' | 'activeTabId' | 'focusMode' | 'sidebar' | 'layoutTrees' | 'tabGroupMap'
176
192
  > {
177
- const tabs = restoreTabsFromWorkspace(workspaceSnapshot)
193
+ const tabs = restoreTabsFromWorkspace(workspaceSnapshot, options)
178
194
  const activeTabId =
179
195
  workspaceSnapshot?.activeTabId != null &&
180
196
  workspaceSnapshot?.activeTabId !== '' &&
@@ -538,7 +538,12 @@ export type ModalAction =
538
538
 
539
539
  // -- Session actions --
540
540
  export type SessionAction =
541
- | { type: 'load-session'; sessionId: string; workspaceSnapshot?: WorkspaceSnapshotV1 }
541
+ | {
542
+ type: 'load-session'
543
+ sessionId: string
544
+ workspaceSnapshot?: WorkspaceSnapshotV1
545
+ forceDisconnected?: boolean
546
+ }
542
547
  | { type: 'set-sessions'; sessions: SessionRecord[] }
543
548
  | { type: 'create-session-record'; session: SessionRecord }
544
549
  | { type: 'rename-session-record'; sessionId: string; name: string }
@@ -144,9 +144,23 @@ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
144
144
 
145
145
  const idx = baselineOrder.indexOf(source)
146
146
  if (idx >= 0) {
147
- runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
147
+ // The workspace row visually anchors the session's primary worktree
148
+ // (its branch line shows the primary's branch). Clicking it should
149
+ // land on the primary — same semantics as j/k cycling onto a workspace
150
+ // item — instead of preserving whatever non-primary worktree happened
151
+ // to be active last time we left this session.
152
+ const sourceSession = ordered.find((s) => s.id === source)
153
+ const sourceWorktrees = sourceSession?.worktrees ?? []
154
+ const sourcePrimaryId = (
155
+ sourceWorktrees.find((w) => w.source === 'primary') ?? sourceWorktrees[0]
156
+ )?.id
157
+ runSideEffectGlobal({
158
+ index: idx + 1,
159
+ type: 'switch-session-by-index',
160
+ worktreeId: sourcePrimaryId,
161
+ })
148
162
  }
149
- }, [baselineOrder, dragOrder, draggingId])
163
+ }, [baselineOrder, dragOrder, draggingId, ordered])
150
164
 
151
165
  const cancelDrag = useCallback(() => {
152
166
  setDraggingId(null)
@@ -41,14 +41,23 @@ export const WorktreeRow = memo(function WorktreeRow({
41
41
  if (event.button !== 0) return
42
42
  event.preventDefault()
43
43
  event.stopPropagation()
44
- dispatchGlobal({
45
- sessionId: session.id,
46
- type: 'set-active-worktree',
44
+ if (isCurrentSession) {
45
+ dispatchGlobal({
46
+ sessionId: session.id,
47
+ type: 'set-active-worktree',
48
+ worktreeId: worktree.id,
49
+ })
50
+ return
51
+ }
52
+ // Cross-workspace click: send the worktree id along with the switch so
53
+ // the side effect can apply it atomically. Splitting it into a separate
54
+ // dispatch+switch leaves a window where subscribers/re-renders can
55
+ // re-assert the target session's last-persisted worktree.
56
+ runSideEffectGlobal({
57
+ index: sessionIndex,
58
+ type: 'switch-session-by-index',
47
59
  worktreeId: worktree.id,
48
60
  })
49
- if (!isCurrentSession) {
50
- runSideEffectGlobal({ index: sessionIndex, type: 'switch-session-by-index' })
51
- }
52
61
  },
53
62
  [isCurrentSession, session.id, sessionIndex, worktree.id]
54
63
  )
@@ -1,12 +1,32 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
2
 
3
+ import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
4
+
3
5
  import { describeBindings } from '../../../../input/keymap/describe-bindings'
4
6
  import { useKeymap } from '../../../keymap-context'
5
- import { useTheme } from '../../../theme'
6
- import { Surface } from '../../primitives/surface'
7
+ import { useTheme, useTransparent } from '../../../theme'
7
8
 
8
9
  const KEYS_COLUMN_WIDTH = 12
9
10
 
11
+ const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
12
+
13
+ // Same trick as modal-shell: when our bg is transparent, BoxRenderable skips
14
+ // its fill so the chrome chars underneath leak through. setCell bypasses alpha
15
+ // blending and char-preservation, so we can wipe every interior cell to
16
+ // (' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA). No border on this overlay, so
17
+ // fill the full bounds (no 1-cell inset).
18
+ function fillBoxInteriorWithSpaces(this: BoxRenderable, buffer: OptimizedBuffer): void {
19
+ const x0 = this.screenX
20
+ const y0 = this.screenY
21
+ const endX = x0 + this.width
22
+ const endY = y0 + this.height
23
+ for (let y = y0; y < endY; y++) {
24
+ for (let x = x0; x < endX; x++) {
25
+ buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
26
+ }
27
+ }
28
+ }
29
+
10
30
  interface ModalKeybindsOverlayProps {
11
31
  modeId: ModeId
12
32
  limit?: number
@@ -14,6 +34,7 @@ interface ModalKeybindsOverlayProps {
14
34
 
15
35
  export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProps) {
16
36
  const t = useTheme()
37
+ const transparent = useTransparent()
17
38
  const config = useKeymap()
18
39
  const bindings = describeBindings(config, modeId, {
19
40
  mergeAlternativesByDescription: true,
@@ -25,7 +46,14 @@ export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProp
25
46
 
26
47
  return (
27
48
  <box position="absolute" bottom={0} right={0}>
28
- <Surface tone="elevated" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
49
+ <box
50
+ backgroundColor={t.backgroundElement}
51
+ paddingLeft={2}
52
+ paddingRight={2}
53
+ paddingTop={1}
54
+ paddingBottom={1}
55
+ renderAfter={transparent ? fillBoxInteriorWithSpaces : undefined}
56
+ >
29
57
  {entries.map((binding) => (
30
58
  <box key={binding.description ?? binding.keys} flexDirection="row">
31
59
  <box width={KEYS_COLUMN_WIDTH}>
@@ -34,7 +62,7 @@ export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProp
34
62
  <text fg={t.textMuted}>{binding.description ?? ''}</text>
35
63
  </box>
36
64
  ))}
37
- </Surface>
65
+ </box>
38
66
  </box>
39
67
  )
40
68
  }
@@ -25,27 +25,54 @@ const themeStore = createStore<ThemeStore>(() => ({
25
25
 
26
26
  let cachedId: ThemeId | null = null
27
27
  let cachedMode: ThemeMode | null = null
28
- let cachedResolved: ResolvedTuiTheme | null = null
29
-
30
- function derive(id: ThemeId, mode: ThemeMode): ResolvedTuiTheme {
31
- if (cachedResolved && cachedId === id && cachedMode === mode) return cachedResolved
28
+ let cachedBase: ResolvedTuiTheme | null = null
29
+ let cachedTransparent: boolean | null = null
30
+ let cachedFinal: ResolvedTuiTheme | null = null
31
+
32
+ // Chrome surface tokens: when transparent mode is on we replace these with
33
+ // 'transparent' so opentui's BoxRenderable skips its fill (alpha=0 early-returns
34
+ // in setCellWithAlphaBlending) and the host terminal background shows through
35
+ // every chrome site (root, sidebar, tabs, status bar, modals, …) without
36
+ // patching ~30 components one-by-one.
37
+ const CHROME_BG_TOKENS = [
38
+ 'background',
39
+ 'backgroundPanel',
40
+ 'backgroundElement',
41
+ 'backgroundMenu',
42
+ ] as const
43
+
44
+ function derive(id: ThemeId, mode: ThemeMode, transparent: boolean): ResolvedTuiTheme {
45
+ if (cachedFinal && cachedId === id && cachedMode === mode && cachedTransparent === transparent)
46
+ return cachedFinal
47
+
48
+ if (!cachedBase || cachedId !== id || cachedMode !== mode) {
49
+ const json = TUI_THEMES[id] ?? TUI_THEMES.aimux
50
+ if (!json) throw new Error(`No theme JSON for ${id}`)
51
+ cachedBase = resolveTuiTheme(json, mode)
52
+ }
32
53
  cachedId = id
33
54
  cachedMode = mode
34
- const json = TUI_THEMES[id] ?? TUI_THEMES.aimux
35
- if (!json) throw new Error(`No theme JSON for ${id}`)
36
- cachedResolved = resolveTuiTheme(json, mode)
37
- return cachedResolved
55
+ cachedTransparent = transparent
56
+
57
+ if (transparent) {
58
+ const overlay: ResolvedTuiTheme = { ...cachedBase }
59
+ for (const key of CHROME_BG_TOKENS) overlay[key] = 'transparent'
60
+ cachedFinal = overlay
61
+ } else {
62
+ cachedFinal = cachedBase
63
+ }
64
+ return cachedFinal
38
65
  }
39
66
 
40
- /** Subscribe to the resolved TUI theme for the active id+mode. */
67
+ /** Subscribe to the resolved TUI theme for the active id+mode (+transparent overlay). */
41
68
  export function useTheme(): ResolvedTuiTheme {
42
- return useStore(themeStore, (s) => derive(s.id, s.mode))
69
+ return useStore(themeStore, (s) => derive(s.id, s.mode, s.transparent))
43
70
  }
44
71
 
45
72
  /** Synchronous snapshot of the resolved theme for non-React callers. */
46
73
  export function getCurrentTheme(): ResolvedTuiTheme {
47
74
  const s = themeStore.getState()
48
- return derive(s.id, s.mode)
75
+ return derive(s.id, s.mode, s.transparent)
49
76
  }
50
77
 
51
78
  export function getCurrentThemeId(): ThemeId {
@@ -96,6 +123,9 @@ export function subscribeThemeChanges(
96
123
  if (s.id === lastId && s.mode === lastMode) return
97
124
  lastId = s.id
98
125
  lastMode = s.mode
99
- listener(derive(s.id, s.mode), s.mode)
126
+ // Pass base theme (transparent=false): the only subscriber today is the
127
+ // Claude Code theme bridge, which has no transparency concept and wants
128
+ // the resolved palette.
129
+ listener(derive(s.id, s.mode, false), s.mode)
100
130
  })
101
131
  }