@brimveyn/aimux 1.3.1 → 1.4.1

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/README.md CHANGED
@@ -11,6 +11,7 @@ A terminal multiplexer for AI CLIs. Manage multiple AI assistant sessions (Claud
11
11
  ## Features
12
12
 
13
13
  - **Multi-tab sessions** — Run Claude, Codex, and OpenCode in parallel with instant tab switching
14
+ - **Session bar** — Numbered session chips at the top (or bottom) of the screen. Click to switch, drag to reorder, busy spinner for non-focused sessions with live PTY output. Toggle with `<leader>b`; jump with `<leader>1..9`. Position persists via `aimux.config.ts` or `aimux.json`.
14
15
  - **Split panes** — Split vertically (`|`) or horizontally (`-`) to view multiple assistants at once
15
16
  - **Draggable separators** — Resize split panes by dragging with the mouse
16
17
  - **Click-to-focus** — Click any pane or sidebar tab to focus it instantly
@@ -134,6 +135,8 @@ Press `?` in navigation mode for the full, live keybinding list (reflects your c
134
135
  | `Ctrl+S` | Snippet picker |
135
136
  | `Ctrl+T` | Theme picker |
136
137
  | `G` | Toggle git panel |
138
+ | `<leader>b` | Toggle session bar |
139
+ | `<leader>1..9` | Switch to session N |
137
140
  | `?` | Show help |
138
141
  | `Ctrl+C` | Quit |
139
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
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",
@@ -52,12 +52,13 @@
52
52
  "demo:splits": "vhs assets/splits.tape",
53
53
  "demo:themes": "vhs assets/themes.tape",
54
54
  "restart-daemon": "AIMUX_PROFILE=dev bun run src/index.tsx restart-daemon",
55
+ "restart-terminal-manager": "AIMUX_PROFILE=dev bun run src/index.tsx restart-terminal-manager",
55
56
  "lint": "oxlint .",
56
57
  "format": "oxfmt --write .",
57
58
  "format:check": "oxfmt --check ."
58
59
  },
59
60
  "dependencies": {
60
- "@brimveyn/aimux-config": "0.2.6",
61
+ "@brimveyn/aimux-config": "0.3.1",
61
62
  "@opentui/core": "^0.1.90",
62
63
  "@opentui/react": "^0.1.90",
63
64
  "@xterm/headless": "^6.0.0",
@@ -79,15 +79,21 @@ export function bindBackendRuntimeEvents({
79
79
  dispatch({ message, tabId, type: 'set-tab-error' })
80
80
  }
81
81
 
82
+ const handleSessionActivity = (sessionId: string, busy: boolean) => {
83
+ dispatch({ busy, sessionId, type: 'set-session-busy' })
84
+ }
85
+
82
86
  backend.on('render', handleRender)
83
87
  backend.on('exit', handleExit)
84
88
  backend.on('error', handleError)
89
+ backend.on('sessionActivity', handleSessionActivity)
85
90
 
86
91
  return () => {
87
92
  timeouts.clearAllTimers()
88
93
  backend.off('render', handleRender)
89
94
  backend.off('exit', handleExit)
90
95
  backend.off('error', handleError)
96
+ backend.off('sessionActivity', handleSessionActivity)
91
97
  void backend.destroy(true)
92
98
  }
93
99
  }
@@ -488,11 +488,29 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
488
488
  handleConfirmUpdateSelection(ctx)
489
489
  return
490
490
  }
491
+ case 'switch-session-by-index': {
492
+ handleSwitchSessionByIndex(ctx, effect.index)
493
+ return
494
+ }
491
495
  default:
492
496
  effect satisfies never
493
497
  }
494
498
  }
495
499
 
500
+ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
501
+ const { backend, dispatch, state } = ctx
502
+ const ordered = state.sessions
503
+ .slice()
504
+ .sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER))
505
+ const target = ordered[index - 1]
506
+ if (!target) {
507
+ logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
508
+ return
509
+ }
510
+ if (target.id === state.currentSessionId) return
511
+ handleSwitchSessionEffect(state, backend, dispatch, target)
512
+ }
513
+
496
514
  function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
497
515
  const { state } = ctx
498
516
  if (state.modal.type !== 'update-available') {
@@ -1,4 +1,4 @@
1
- import { type MutableRefObject, useEffect, useMemo, useRef } from 'react'
1
+ import { type MutableRefObject, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
2
2
 
3
3
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
4
4
  import type { SessionBackend } from '../session-backend/types'
@@ -28,20 +28,21 @@ function resizeSplitTabs(
28
28
  tabIds: string[],
29
29
  cols: number,
30
30
  rows: number,
31
- intents: Map<string, ScrollIntent>
31
+ intents: Map<string, ScrollIntent>,
32
+ options?: { sync?: boolean }
32
33
  ): void {
33
34
  const bounds = getTerminalBounds(cols, rows)
34
35
  const resizedTabIds = new Set<string>()
35
36
 
36
37
  forEachSplitPaneRect(Object.values(layoutTrees), bounds, (tabId, rect) => {
37
38
  const size = toTerminalContentSize(rect)
38
- backend.resizeTab(tabId, size.cols, size.rows, intents.get(tabId))
39
+ backend.resizeTab(tabId, size.cols, size.rows, intents.get(tabId), options)
39
40
  resizedTabIds.add(tabId)
40
41
  })
41
42
 
42
43
  for (const id of tabIds) {
43
44
  if (!resizedTabIds.has(id)) {
44
- backend.resizeTab(id, cols, rows, intents.get(id))
45
+ backend.resizeTab(id, cols, rows, intents.get(id), options)
45
46
  }
46
47
  }
47
48
  }
@@ -55,6 +56,50 @@ interface UseTerminalResizeOptions {
55
56
  resizingRef: MutableRefObject<boolean>
56
57
  }
57
58
 
59
+ interface RunResizeCascadeArgs {
60
+ backend: SessionBackend
61
+ dispatch: (action: AppAction) => void
62
+ resizingRef: MutableRefObject<boolean>
63
+ resizingTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
64
+ cols: number
65
+ rows: number
66
+ layoutTrees: AppState['layoutTrees']
67
+ stableTabIds: string[]
68
+ intents: Map<string, ScrollIntent>
69
+ sync: boolean
70
+ }
71
+
72
+ function runResizeCascade({
73
+ backend,
74
+ cols,
75
+ dispatch,
76
+ intents,
77
+ layoutTrees,
78
+ resizingRef,
79
+ resizingTimerRef,
80
+ rows,
81
+ stableTabIds,
82
+ sync,
83
+ }: RunResizeCascadeArgs): void {
84
+ dispatch({ cols, rows, type: 'set-terminal-size' })
85
+ resizingRef.current = true
86
+ if (resizingTimerRef.current) {
87
+ clearTimeout(resizingTimerRef.current)
88
+ }
89
+ const trees = Object.values(layoutTrees)
90
+ const hasSplits = trees.some((t) => t.type === 'split')
91
+ const options = sync ? { sync: true } : undefined
92
+ if (hasSplits) {
93
+ resizeSplitTabs(backend, layoutTrees, stableTabIds, cols, rows, intents, options)
94
+ } else {
95
+ backend.resizeAll(cols, rows, intents, options)
96
+ }
97
+ resizingTimerRef.current = setTimeout(() => {
98
+ resizingRef.current = false
99
+ resizingTimerRef.current = null
100
+ }, RESIZE_ACTIVITY_SETTLE_MS)
101
+ }
102
+
58
103
  export function useTerminalResize({
59
104
  backend,
60
105
  contentOriginRef,
@@ -66,6 +111,8 @@ export function useTerminalResize({
66
111
  const resizingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
67
112
  const tabIdsRef = useRef<string[]>([])
68
113
  const intentsRef = useRef<Map<string, ScrollIntent>>(new Map())
114
+ const handledBySyncRef = useRef(false)
115
+ const sidebarMountedRef = useRef(false)
69
116
 
70
117
  const currentTabIds = state.tabs.map((t) => t.id)
71
118
  const tabIdsChanged =
@@ -84,8 +131,14 @@ export function useTerminalResize({
84
131
 
85
132
  const terminalSize = useMemo(() => {
86
133
  const sidebarWidth = state.sidebar.visible ? state.sidebar.width + 1 : 0
134
+ const sessionBarRows = state.sessionBar.visible ? 1 : 0
135
+ const sessionBarTopOffset =
136
+ state.sessionBar.visible && state.sessionBar.position === 'top' ? 1 : 0
87
137
  const reservedRows =
88
- MAIN_AREA_VERTICAL_PADDING + STATUS_BAR_HEIGHT + TERMINAL_PANE_VERTICAL_CHROME
138
+ MAIN_AREA_VERTICAL_PADDING +
139
+ STATUS_BAR_HEIGHT +
140
+ TERMINAL_PANE_VERTICAL_CHROME +
141
+ sessionBarRows
89
142
  const cols = Math.max(
90
143
  MIN_TERMINAL_COLS,
91
144
  Math.floor(dimensions.width - sidebarWidth - MAIN_AREA_HORIZONTAL_CHROME)
@@ -96,7 +149,7 @@ export function useTerminalResize({
96
149
  cols,
97
150
  rows,
98
151
  x: sidebarWidth + 1,
99
- y: 1,
152
+ y: 1 + sessionBarTopOffset,
100
153
  }
101
154
 
102
155
  return { cols, rows }
@@ -106,36 +159,53 @@ export function useTerminalResize({
106
159
  dimensions.width,
107
160
  state.sidebar.visible,
108
161
  state.sidebar.width,
162
+ state.sessionBar.visible,
163
+ state.sessionBar.position,
109
164
  ])
110
165
 
111
- useEffect(() => {
112
- dispatch({
166
+ useLayoutEffect(() => {
167
+ if (!sidebarMountedRef.current) {
168
+ sidebarMountedRef.current = true
169
+ return
170
+ }
171
+ runResizeCascade({
172
+ backend,
113
173
  cols: terminalSize.cols,
174
+ dispatch,
175
+ intents: intentsRef.current,
176
+ layoutTrees: state.layoutTrees,
177
+ resizingRef,
178
+ resizingTimerRef,
114
179
  rows: terminalSize.rows,
115
- type: 'set-terminal-size',
180
+ stableTabIds,
181
+ sync: true,
116
182
  })
117
- resizingRef.current = true
118
- if (resizingTimerRef.current) {
119
- clearTimeout(resizingTimerRef.current)
120
- }
121
- const trees = Object.values(state.layoutTrees)
122
- const hasSplits = trees.some((t) => t.type === 'split')
123
- if (hasSplits) {
124
- resizeSplitTabs(
125
- backend,
126
- state.layoutTrees,
127
- stableTabIds,
128
- terminalSize.cols,
129
- terminalSize.rows,
130
- intentsRef.current
131
- )
132
- } else {
133
- backend.resizeAll(terminalSize.cols, terminalSize.rows, intentsRef.current)
183
+ handledBySyncRef.current = true
184
+ // eslint-disable-next-line react-hooks/exhaustive-deps
185
+ }, [
186
+ state.sidebar.visible,
187
+ state.sidebar.width,
188
+ state.sessionBar.visible,
189
+ state.sessionBar.position,
190
+ ])
191
+
192
+ useEffect(() => {
193
+ if (handledBySyncRef.current) {
194
+ handledBySyncRef.current = false
195
+ return
134
196
  }
135
- resizingTimerRef.current = setTimeout(() => {
136
- resizingRef.current = false
137
- resizingTimerRef.current = null
138
- }, RESIZE_ACTIVITY_SETTLE_MS)
197
+ runResizeCascade({
198
+ backend,
199
+ cols: terminalSize.cols,
200
+ dispatch,
201
+ intents: intentsRef.current,
202
+ layoutTrees: state.layoutTrees,
203
+ resizingRef,
204
+ resizingTimerRef,
205
+ rows: terminalSize.rows,
206
+ stableTabIds,
207
+ sync: false,
208
+ })
139
209
  }, [
140
210
  backend,
141
211
  dispatch,
package/src/app.tsx CHANGED
@@ -31,7 +31,7 @@ import { getHandler, transitionTo } from './input/modes/registry'
31
31
  import { type TerminalContentOrigin } from './input/raw-input-handler'
32
32
  import { getProfileName } from './profile-paths'
33
33
  import { appStore } from './state/app-store'
34
- import { setActiveDispatch } from './state/dispatch-ref'
34
+ import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
35
35
  import { loadSessionCatalog } from './state/session-catalog'
36
36
  import { loadSnippetCatalog } from './state/snippet-catalog'
37
37
  import { appReducer, createInitialState } from './state/store'
@@ -69,11 +69,22 @@ export function App({
69
69
  return config.themeId ?? 'aimux'
70
70
  })
71
71
  const [state, dispatch] = useReducer(appReducer, undefined, () => {
72
- const { customCommands, gitPanelRatio, gitPanelVisible } = loadConfig()
73
- return createInitialState(customCommands, loadSessionCatalog(), loadSnippetCatalog(), true, {
74
- gitPanelRatio,
75
- gitPanelVisible,
76
- })
72
+ const json = loadConfig()
73
+ const sessionBarVisible = resolvedConfig.sessionBar?.visible ?? json.sessionBarVisible ?? true
74
+ const sessionBarPosition =
75
+ resolvedConfig.sessionBar?.position ?? json.sessionBarPosition ?? 'top'
76
+ return createInitialState(
77
+ json.customCommands,
78
+ loadSessionCatalog(),
79
+ loadSnippetCatalog(),
80
+ true,
81
+ {
82
+ gitPanelRatio: json.gitPanelRatio,
83
+ gitPanelVisible: json.gitPanelVisible,
84
+ sessionBarPosition,
85
+ sessionBarVisible,
86
+ }
87
+ )
77
88
  })
78
89
 
79
90
  useLayoutEffect(() => {
@@ -82,7 +93,10 @@ export function App({
82
93
 
83
94
  useLayoutEffect(() => {
84
95
  setActiveDispatch(dispatch)
85
- return () => setActiveDispatch(null)
96
+ return () => {
97
+ setActiveDispatch(null)
98
+ setActiveSideEffectRunner(null)
99
+ }
86
100
  }, [dispatch])
87
101
 
88
102
  useEffect(() => {
@@ -251,6 +265,9 @@ export function App({
251
265
 
252
266
  // Keep the ref pointing at the latest closure so stable callbacks can invoke it
253
267
  processKeyResultRef.current = processKeyResult
268
+ // Expose the side-effect runner so non-keyboard call sites (mouse, IPC) can
269
+ // invoke the same effect pipeline with the current context.
270
+ setActiveSideEffectRunner((effect) => executeSideEffect(effect, sideEffectCtx))
254
271
 
255
272
  useLayoutEffect(() => {
256
273
  for (const handler of keymapHandlers) {
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
 
3
- import type { WorkspaceSnapshotV1 } from './state/types'
3
+ import type { SessionBarPosition, WorkspaceSnapshotV1 } from './state/types'
4
4
 
5
5
  import { logDebug } from './debug/input-log'
6
6
  import { getProfileConfigDir } from './profile-paths'
@@ -15,6 +15,8 @@ export interface AimuxConfig {
15
15
  themeId?: ThemeId
16
16
  gitPanelVisible?: boolean
17
17
  gitPanelRatio?: number
18
+ sessionBarVisible?: boolean
19
+ sessionBarPosition?: SessionBarPosition
18
20
  workspaceSnapshot?: WorkspaceSnapshotV1
19
21
  skippedUpdateVersion?: string
20
22
  }
@@ -58,6 +60,8 @@ export function loadConfigResult(): ConfigLoadResult {
58
60
  themeId?: unknown
59
61
  gitPanelVisible?: unknown
60
62
  gitPanelRatio?: unknown
63
+ sessionBarVisible?: unknown
64
+ sessionBarPosition?: unknown
61
65
  workspaceSnapshot?: unknown
62
66
  skippedUpdateVersion?: unknown
63
67
  }
@@ -93,6 +97,20 @@ export function loadConfigResult(): ConfigLoadResult {
93
97
  issues.push('ignored invalid gitPanelRatio')
94
98
  }
95
99
 
100
+ const validSessionBarVisible =
101
+ typeof parsed.sessionBarVisible === 'boolean' ? parsed.sessionBarVisible : undefined
102
+ if (parsed.sessionBarVisible !== undefined && validSessionBarVisible === undefined) {
103
+ issues.push('ignored invalid sessionBarVisible')
104
+ }
105
+
106
+ const validSessionBarPosition =
107
+ parsed.sessionBarPosition === 'top' || parsed.sessionBarPosition === 'bottom'
108
+ ? parsed.sessionBarPosition
109
+ : undefined
110
+ if (parsed.sessionBarPosition !== undefined && validSessionBarPosition === undefined) {
111
+ issues.push('ignored invalid sessionBarPosition')
112
+ }
113
+
96
114
  if (
97
115
  parsed.workspaceSnapshot !== undefined &&
98
116
  !isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
@@ -117,6 +135,8 @@ export function loadConfigResult(): ConfigLoadResult {
117
135
  customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
118
136
  gitPanelRatio: validGitPanelRatio,
119
137
  gitPanelVisible: validGitPanelVisible,
138
+ sessionBarPosition: validSessionBarPosition,
139
+ sessionBarVisible: validSessionBarVisible,
120
140
  skippedUpdateVersion: validSkippedUpdateVersion,
121
141
  themeId: isThemeId(parsed.themeId) ? parsed.themeId : undefined,
122
142
  version: 2,
@@ -67,8 +67,14 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
67
67
  this.getOrCreateRegistry(sessionId).write(tabId, data)
68
68
  }
69
69
 
70
- resize(sessionId: string, cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
71
- this.getOrCreateRegistry(sessionId).resizeAll(cols, rows, intents)
70
+ resize(
71
+ sessionId: string,
72
+ cols: number,
73
+ rows: number,
74
+ intents?: Map<string, ScrollIntent>,
75
+ options?: { sync?: boolean }
76
+ ): void {
77
+ this.getOrCreateRegistry(sessionId).resizeAll(cols, rows, intents, options)
72
78
  }
73
79
 
74
80
  resizeTab(
@@ -76,9 +82,10 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
76
82
  tabId: string,
77
83
  cols: number,
78
84
  rows: number,
79
- intent?: ScrollIntent
85
+ intent?: ScrollIntent,
86
+ options?: { sync?: boolean }
80
87
  ): void {
81
- this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows, intent)
88
+ this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows, intent, options)
82
89
  }
83
90
 
84
91
  scroll(sessionId: string, tabId: string, deltaLines: number): void {
@@ -165,12 +165,23 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
165
165
  this.ptyManager.write(tabId, data)
166
166
  }
167
167
 
168
- resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
169
- this.ptyManager.resizeAll(cols, rows, intents)
168
+ resizeAll(
169
+ cols: number,
170
+ rows: number,
171
+ intents?: Map<string, ScrollIntent>,
172
+ options?: { sync?: boolean }
173
+ ): void {
174
+ this.ptyManager.resizeAll(cols, rows, intents, options)
170
175
  }
171
176
 
172
- resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
173
- this.ptyManager.resizeSession(tabId, cols, rows, intent)
177
+ resizeTab(
178
+ tabId: string,
179
+ cols: number,
180
+ rows: number,
181
+ intent?: ScrollIntent,
182
+ options?: { sync?: boolean }
183
+ ): void {
184
+ this.ptyManager.resizeSession(tabId, cols, rows, intent, options)
174
185
  }
175
186
 
176
187
  scrollViewport(tabId: string, deltaLines: number): void {
package/src/index.tsx CHANGED
@@ -9,6 +9,7 @@ import { getRuntimeProfile } from './daemon/runtime-paths'
9
9
  import { logDebug } from './debug/input-log'
10
10
  import { runDoctor } from './doctor'
11
11
  import { runRestartDaemon } from './restart-daemon'
12
+ import { runRestartTerminalManager } from './restart-terminal-manager'
12
13
  import { createSessionBackend } from './session-backend/bootstrap'
13
14
  import { runTerminalManager } from './terminal-manager/terminal-manager'
14
15
  import { runUpdate } from './update'
@@ -30,6 +31,10 @@ if (command === 'restart-daemon') {
30
31
  process.exit(await runRestartDaemon())
31
32
  }
32
33
 
34
+ if (command === 'restart-terminal-manager') {
35
+ process.exit(await runRestartTerminalManager())
36
+ }
37
+
33
38
  if (command === 'update') {
34
39
  process.exit(await runUpdate())
35
40
  }
@@ -46,7 +51,7 @@ if (command === 'terminal-manager') {
46
51
 
47
52
  if (command === '--help' || command === '-h') {
48
53
  process.stdout.write(
49
- 'aimux -- terminal multiplexer for AI CLIs\n\nUsage:\n aimux Start aimux\n aimux update Update to latest version\n aimux doctor Diagnose setup issues\n aimux restart-daemon Restart IPC daemon\n\n'
54
+ 'aimux -- terminal multiplexer for AI CLIs\n\nUsage:\n aimux Start aimux\n aimux update Update to latest version\n aimux doctor Diagnose setup issues\n aimux restart-daemon Restart IPC daemon\n aimux restart-terminal-manager Restart terminal-manager (kills live sessions)\n\n'
50
55
  )
51
56
  process.exit(0)
52
57
  }
@@ -53,6 +53,7 @@ export type SideEffect =
53
53
  | { type: 'git-commit'; title: string; body: string }
54
54
  | { type: 'git-push' }
55
55
  | { type: 'confirm-update-selection' }
56
+ | { type: 'switch-session-by-index'; index: number }
56
57
 
57
58
  export interface KeyResult {
58
59
  actions: AppAction[]