@brimveyn/aimux 1.3.0 → 1.4.0

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 (60) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -2
  3. package/src/app-runtime/backend-runtime-events.ts +13 -1
  4. package/src/app-runtime/pty-write.ts +7 -4
  5. package/src/app-runtime/side-effects.ts +72 -4
  6. package/src/app-runtime/snippet-actions.ts +3 -2
  7. package/src/app-runtime/use-backend-runtime.ts +7 -2
  8. package/src/app-runtime/use-renderer-bindings.ts +2 -2
  9. package/src/app-runtime/use-terminal-resize.ts +15 -6
  10. package/src/app.tsx +113 -37
  11. package/src/config.ts +32 -1
  12. package/src/daemon/daemon.ts +19 -2
  13. package/src/daemon/session-manager.ts +20 -5
  14. package/src/daemon/session-registry.ts +18 -11
  15. package/src/index.tsx +8 -1
  16. package/src/input/keymap/describe-bindings.ts +68 -0
  17. package/src/input/keymap/key-format.ts +67 -0
  18. package/src/input/modes/bridge.ts +1 -0
  19. package/src/input/modes/transitions.ts +2 -0
  20. package/src/input/modes/types.ts +3 -0
  21. package/src/ipc/manager-protocol.ts +51 -2
  22. package/src/ipc/protocol.ts +43 -2
  23. package/src/pty/pty-manager.ts +29 -3
  24. package/src/session-backend/local-session-backend.ts +39 -5
  25. package/src/session-backend/remote-session-backend.ts +18 -5
  26. package/src/session-backend/types.ts +5 -2
  27. package/src/state/dispatch-ref.ts +11 -0
  28. package/src/state/reducers/modal-state.ts +18 -1
  29. package/src/state/reducers/session-state.ts +28 -0
  30. package/src/state/reducers/tab-state.ts +20 -2
  31. package/src/state/reducers/ui-state.ts +8 -0
  32. package/src/state/session-catalog.ts +22 -1
  33. package/src/state/session-persistence.ts +9 -2
  34. package/src/state/store.ts +8 -1
  35. package/src/state/types.ts +37 -0
  36. package/src/state/validation.ts +10 -0
  37. package/src/state/workspace-save.ts +2 -0
  38. package/src/terminal-manager/manager-client.ts +29 -5
  39. package/src/terminal-manager/terminal-manager.ts +19 -3
  40. package/src/ui/components/create-session-modal.tsx +3 -5
  41. package/src/ui/components/git-commit-modal.tsx +3 -5
  42. package/src/ui/components/help-modal.tsx +49 -68
  43. package/src/ui/components/list-item.tsx +24 -5
  44. package/src/ui/components/new-tab-modal.tsx +8 -9
  45. package/src/ui/components/pending-chord-overlay.tsx +28 -0
  46. package/src/ui/components/session-bar.tsx +208 -0
  47. package/src/ui/components/session-name-modal.tsx +3 -5
  48. package/src/ui/components/session-picker-modal.tsx +3 -1
  49. package/src/ui/components/snippet-editor-modal.tsx +3 -1
  50. package/src/ui/components/snippet-picker-modal.tsx +3 -1
  51. package/src/ui/components/status-bar.tsx +6 -2
  52. package/src/ui/components/tab-item.tsx +3 -15
  53. package/src/ui/components/theme-picker-modal.tsx +3 -6
  54. package/src/ui/components/update-available-modal.tsx +42 -0
  55. package/src/ui/hooks/use-busy-spinner.ts +18 -0
  56. package/src/ui/keymap-context.ts +39 -0
  57. package/src/ui/root.tsx +16 -0
  58. package/src/ui/session-ordering.ts +34 -0
  59. package/src/ui/status-bar-model.ts +67 -39
  60. package/src/update/version-check.ts +67 -0
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.0",
3
+ "version": "1.4.0",
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",
@@ -57,7 +57,7 @@
57
57
  "format:check": "oxfmt --check ."
58
58
  },
59
59
  "dependencies": {
60
- "@brimveyn/aimux-config": "0.1.0",
60
+ "@brimveyn/aimux-config": "0.3.0",
61
61
  "@opentui/core": "^0.1.90",
62
62
  "@opentui/react": "^0.1.90",
63
63
  "@xterm/headless": "^6.0.0",
@@ -51,7 +51,13 @@ export function bindBackendRuntimeEvents({
51
51
  viewportY: viewport.viewportY,
52
52
  })
53
53
 
54
- dispatch({ tabId, terminalModes, type: 'replace-tab-viewport', viewport })
54
+ dispatch({
55
+ source: resizingRef.current ? 'resize' : 'data',
56
+ tabId,
57
+ terminalModes,
58
+ type: 'replace-tab-viewport',
59
+ viewport,
60
+ })
55
61
  if (timeouts.isStartupGraceActive(tabId) || resizingRef.current) {
56
62
  return
57
63
  }
@@ -73,15 +79,21 @@ export function bindBackendRuntimeEvents({
73
79
  dispatch({ message, tabId, type: 'set-tab-error' })
74
80
  }
75
81
 
82
+ const handleSessionActivity = (sessionId: string, busy: boolean) => {
83
+ dispatch({ busy, sessionId, type: 'set-session-busy' })
84
+ }
85
+
76
86
  backend.on('render', handleRender)
77
87
  backend.on('exit', handleExit)
78
88
  backend.on('error', handleError)
89
+ backend.on('sessionActivity', handleSessionActivity)
79
90
 
80
91
  return () => {
81
92
  timeouts.clearAllTimers()
82
93
  backend.off('render', handleRender)
83
94
  backend.off('exit', handleExit)
84
95
  backend.off('error', handleError)
96
+ backend.off('sessionActivity', handleSessionActivity)
85
97
  void backend.destroy(true)
86
98
  }
87
99
  }
@@ -1,5 +1,5 @@
1
1
  import type { SessionBackend } from '../session-backend/types'
2
- import type { TabSession } from '../state/types'
2
+ import type { AppAction, TabSession } from '../state/types'
3
3
 
4
4
  import { buildPtyPastePayload } from '../input/paste'
5
5
 
@@ -12,10 +12,12 @@ export function writeToTab(
12
12
  backend: SessionBackend,
13
13
  tabId: string,
14
14
  tab: TabSession | undefined,
15
- input: string
15
+ input: string,
16
+ dispatch?: (action: AppAction) => void
16
17
  ): void {
17
18
  if (tab && shouldScrollViewportToBottom(tab)) {
18
19
  backend.scrollViewportToBottom(tabId)
20
+ dispatch?.({ intent: { kind: 'bottom' }, tabId, type: 'set-scroll-intent' })
19
21
  }
20
22
 
21
23
  backend.write(tabId, input)
@@ -25,8 +27,9 @@ export function writePasteToTab(
25
27
  backend: SessionBackend,
26
28
  tabId: string,
27
29
  tab: TabSession | undefined,
28
- text: string
30
+ text: string,
31
+ dispatch?: (action: AppAction) => void
29
32
  ): void {
30
33
  const payload = buildPtyPastePayload(text, tab?.terminalModes.bracketedPasteMode ?? false)
31
- writeToTab(backend, tabId, tab, payload)
34
+ writeToTab(backend, tabId, tab, payload, dispatch)
32
35
  }
@@ -2,7 +2,6 @@ import { $ } from 'bun'
2
2
 
3
3
  import type { SideEffect } from '../input/modes/types'
4
4
  import type { SessionBackend } from '../session-backend/types'
5
- import type { AppAction, AppState, AssistantId, TabSession } from '../state/types'
6
5
 
7
6
  import { loadConfig, saveConfig } from '../config'
8
7
  import { logInputDebug } from '../debug/input-log'
@@ -27,6 +26,13 @@ import {
27
26
  } from '../state/layout-tree'
28
27
  import { filterSessions, filterSnippets } from '../state/selectors'
29
28
  import { createDefaultTerminalModes } from '../state/terminal-modes'
29
+ import {
30
+ type AppAction,
31
+ type AppState,
32
+ type AssistantId,
33
+ DEFAULT_SCROLL_INTENT,
34
+ type TabSession,
35
+ } from '../state/types'
30
36
  import { saveCurrentWorkspace } from '../state/workspace-save'
31
37
  import { scrollGitDiff } from '../ui/git-view-controls'
32
38
  import { applyTheme } from '../ui/theme'
@@ -124,14 +130,14 @@ function pasteSnippetToActiveGroup(ctx: SideEffectContext): void {
124
130
  const groupId = getGroupIdForTab(state.tabGroupMap, state.activeTabId)
125
131
  const groupTree = groupId ? state.layoutTrees[groupId] : null
126
132
  if (!groupTree) {
127
- pasteSnippetToTab(backend, state.activeTabId, activeTab, snippet)
133
+ pasteSnippetToTab(backend, state.activeTabId, activeTab, snippet, ctx.dispatch)
128
134
  return
129
135
  }
130
136
 
131
137
  for (const tabId of allLeafIds(groupTree)) {
132
138
  const tab = state.tabs.find((entry) => entry.id === tabId)
133
139
  if (tab) {
134
- pasteSnippetToTab(backend, tabId, tab, snippet)
140
+ pasteSnippetToTab(backend, tabId, tab, snippet, ctx.dispatch)
135
141
  }
136
142
  }
137
143
  }
@@ -229,6 +235,7 @@ export function createTabSession(
229
235
  buffer: '',
230
236
  command: customCommand ?? option.command,
231
237
  id: createTabId(),
238
+ scrollIntent: DEFAULT_SCROLL_INTENT,
232
239
  status: 'starting',
233
240
  terminalModes: createDefaultTerminalModes(),
234
241
  title: option.label,
@@ -393,7 +400,13 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
393
400
  )
394
401
  return
395
402
  case 'paste-selected-snippet': {
396
- pasteSnippetToTab(backend, state.activeTabId, ctx.activeTab, getSelectedSnippet(state))
403
+ pasteSnippetToTab(
404
+ backend,
405
+ state.activeTabId,
406
+ ctx.activeTab,
407
+ getSelectedSnippet(state),
408
+ dispatch
409
+ )
397
410
  return
398
411
  }
399
412
  case 'paste-snippet-to-group': {
@@ -471,11 +484,66 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
471
484
  void enqueueGitOp(() => runGitPush(ctx))
472
485
  return
473
486
  }
487
+ case 'confirm-update-selection': {
488
+ handleConfirmUpdateSelection(ctx)
489
+ return
490
+ }
491
+ case 'switch-session-by-index': {
492
+ handleSwitchSessionByIndex(ctx, effect.index)
493
+ return
494
+ }
474
495
  default:
475
496
  effect satisfies never
476
497
  }
477
498
  }
478
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
+
514
+ function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
515
+ const { state } = ctx
516
+ if (state.modal.type !== 'update-available') {
517
+ return
518
+ }
519
+ const latest = state.modal.latestVersion
520
+ if (state.modal.selectedIndex === 0) {
521
+ runUpdateFromTui(ctx, latest)
522
+ return
523
+ }
524
+ saveConfig({ ...loadConfig(), skippedUpdateVersion: latest })
525
+ }
526
+
527
+ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
528
+ saveCurrentWorkspace(ctx.state)
529
+ void ctx.backend.destroy(true)
530
+ ctx.renderer.destroy()
531
+ process.stdout.write(`\nUpdating aimux to ${latestVersion}...\n`)
532
+ const proc = Bun.spawn(['bun', 'update', '-g', '@brimveyn/aimux', '@brimveyn/aimux-config'], {
533
+ stderr: 'inherit',
534
+ stdin: 'inherit',
535
+ stdout: 'inherit',
536
+ })
537
+ void proc.exited.then((code) => {
538
+ if (code === 0) {
539
+ process.stdout.write(`\nUpdated. Run \`aimux\` to start the new version.\n`)
540
+ } else {
541
+ process.stderr.write(`\nUpdate failed (exit code ${code}).\n`)
542
+ }
543
+ process.exit(code ?? 1)
544
+ })
545
+ }
546
+
479
547
  async function runGitAction(
480
548
  ctx: SideEffectContext,
481
549
  args: string[],
@@ -57,13 +57,14 @@ export function pasteSnippetToTab(
57
57
  backend: SessionBackend,
58
58
  activeTabId: string | null,
59
59
  activeTab: TabSession | undefined,
60
- snippet: SnippetRecord | undefined
60
+ snippet: SnippetRecord | undefined,
61
+ dispatch?: (action: AppAction) => void
61
62
  ): void {
62
63
  if (!snippet || !activeTabId || !activeTab) {
63
64
  return
64
65
  }
65
66
 
66
- writePasteToTab(backend, activeTabId, activeTab, snippet.content)
67
+ writePasteToTab(backend, activeTabId, activeTab, snippet.content, dispatch)
67
68
  }
68
69
 
69
70
  export function handleDeleteSnippetEffect(
@@ -1,7 +1,7 @@
1
1
  import { type MutableRefObject, useEffect, useRef } from 'react'
2
2
 
3
3
  import type { SessionBackend } from '../session-backend/types'
4
- import type { AppAction, LayoutState } from '../state/types'
4
+ import type { AppAction, LayoutState, ScrollIntent } from '../state/types'
5
5
 
6
6
  import { attachCurrentSession } from './backend-attach-runtime'
7
7
  import { bindBackendRuntimeEvents } from './backend-runtime-events'
@@ -11,6 +11,7 @@ interface BackendRuntimeOptions {
11
11
  backend: SessionBackend
12
12
  dispatch: (action: AppAction) => void
13
13
  activeTabId: string | null
14
+ activeTabScrollIntentRef: MutableRefObject<ScrollIntent | null>
14
15
  currentSessionId: string | null
15
16
  layoutRef: MutableRefObject<LayoutState>
16
17
  resizingRef: MutableRefObject<boolean>
@@ -25,6 +26,7 @@ export interface TabRuntimeControls {
25
26
 
26
27
  export function useBackendRuntime({
27
28
  activeTabId,
29
+ activeTabScrollIntentRef,
28
30
  backend,
29
31
  currentSessionId,
30
32
  currentSessionWorkspaceSnapshot,
@@ -65,7 +67,10 @@ export function useBackendRuntime({
65
67
  }
66
68
 
67
69
  backend.setActiveTab(activeTabId)
68
- }, [activeTabId, backend, currentSessionId])
70
+ if (activeTabId && activeTabScrollIntentRef.current) {
71
+ backend.reapplyScrollIntent(activeTabId, activeTabScrollIntentRef.current)
72
+ }
73
+ }, [activeTabId, activeTabScrollIntentRef, backend, currentSessionId])
69
74
 
70
75
  useEffect(() => {
71
76
  return bindBackendRuntimeEvents({
@@ -60,7 +60,7 @@ export function useRendererBindings({
60
60
  activeTabRef.current?.terminalModes.bracketedPasteMode ?? false,
61
61
  getFocusMode: () => focusModeRef.current,
62
62
  handleTerminalShortcut,
63
- writeToPty: (tabId, data) => writeToTab(backend, tabId, activeTabRef.current, data),
63
+ writeToPty: (tabId, data) => writeToTab(backend, tabId, activeTabRef.current, data, dispatch),
64
64
  })
65
65
 
66
66
  const handlePasteEvent = (event: { bytes: Uint8Array; defaultPrevented?: boolean }) => {
@@ -96,7 +96,7 @@ export function useRendererBindings({
96
96
  return
97
97
  }
98
98
 
99
- writePasteToTab(backend, tabId, tab, payload)
99
+ writePasteToTab(backend, tabId, tab, payload, dispatch)
100
100
  }
101
101
 
102
102
  const handleSelection = (selection: OtuiSelection) => {
@@ -2,7 +2,7 @@ import { type MutableRefObject, useEffect, 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'
5
- import type { AppAction, AppState } from '../state/types'
5
+ import type { AppAction, AppState, ScrollIntent } from '../state/types'
6
6
 
7
7
  import {
8
8
  createTerminalBounds,
@@ -27,20 +27,21 @@ function resizeSplitTabs(
27
27
  layoutTrees: AppState['layoutTrees'],
28
28
  tabIds: string[],
29
29
  cols: number,
30
- rows: number
30
+ rows: number,
31
+ intents: Map<string, ScrollIntent>
31
32
  ): void {
32
33
  const bounds = getTerminalBounds(cols, rows)
33
34
  const resizedTabIds = new Set<string>()
34
35
 
35
36
  forEachSplitPaneRect(Object.values(layoutTrees), bounds, (tabId, rect) => {
36
37
  const size = toTerminalContentSize(rect)
37
- backend.resizeTab(tabId, size.cols, size.rows)
38
+ backend.resizeTab(tabId, size.cols, size.rows, intents.get(tabId))
38
39
  resizedTabIds.add(tabId)
39
40
  })
40
41
 
41
42
  for (const id of tabIds) {
42
43
  if (!resizedTabIds.has(id)) {
43
- backend.resizeTab(id, cols, rows)
44
+ backend.resizeTab(id, cols, rows, intents.get(id))
44
45
  }
45
46
  }
46
47
  }
@@ -64,6 +65,7 @@ export function useTerminalResize({
64
65
  }: UseTerminalResizeOptions) {
65
66
  const resizingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
66
67
  const tabIdsRef = useRef<string[]>([])
68
+ const intentsRef = useRef<Map<string, ScrollIntent>>(new Map())
67
69
 
68
70
  const currentTabIds = state.tabs.map((t) => t.id)
69
71
  const tabIdsChanged =
@@ -74,6 +76,12 @@ export function useTerminalResize({
74
76
  }
75
77
  const stableTabIds = tabIdsRef.current
76
78
 
79
+ intentsRef.current = new Map(
80
+ state.tabs
81
+ .filter((t): t is typeof t & { scrollIntent: ScrollIntent } => t.scrollIntent !== undefined)
82
+ .map((t) => [t.id, t.scrollIntent])
83
+ )
84
+
77
85
  const terminalSize = useMemo(() => {
78
86
  const sidebarWidth = state.sidebar.visible ? state.sidebar.width + 1 : 0
79
87
  const reservedRows =
@@ -118,10 +126,11 @@ export function useTerminalResize({
118
126
  state.layoutTrees,
119
127
  stableTabIds,
120
128
  terminalSize.cols,
121
- terminalSize.rows
129
+ terminalSize.rows,
130
+ intentsRef.current
122
131
  )
123
132
  } else {
124
- backend.resizeAll(terminalSize.cols, terminalSize.rows)
133
+ backend.resizeAll(terminalSize.cols, terminalSize.rows, intentsRef.current)
125
134
  }
126
135
  resizingTimerRef.current = setTimeout(() => {
127
136
  resizingRef.current = false
package/src/app.tsx CHANGED
@@ -1,6 +1,15 @@
1
- import { getDefaultKeymapConfig } from '@brimveyn/aimux-config'
1
+ import type { ResolvedConfig } from '@brimveyn/aimux-config'
2
+
2
3
  import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
3
- import { useCallback, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react'
4
+ import {
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ useReducer,
10
+ useRef,
11
+ useState,
12
+ } from 'react'
4
13
 
5
14
  import type { KeyChord } from './input/keymap/key-chord'
6
15
  import type { TrieBinding } from './input/keymap/trie'
@@ -20,19 +29,36 @@ import { deriveModeId } from './input/modes/bridge'
20
29
  import { registerAllModes } from './input/modes/handlers'
21
30
  import { getHandler, transitionTo } from './input/modes/registry'
22
31
  import { type TerminalContentOrigin } from './input/raw-input-handler'
32
+ import { getProfileName } from './profile-paths'
23
33
  import { appStore } from './state/app-store'
24
- import { setActiveDispatch } from './state/dispatch-ref'
34
+ import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
25
35
  import { loadSessionCatalog } from './state/session-catalog'
26
36
  import { loadSnippetCatalog } from './state/snippet-catalog'
27
37
  import { appReducer, createInitialState } from './state/store'
38
+ import { KeymapContext } from './ui/keymap-context'
28
39
  import { RootView } from './ui/root'
29
40
  import { applyTheme } from './ui/theme'
30
-
31
- const keymapHandlers = registerAllModes(getDefaultKeymapConfig())
41
+ import {
42
+ fetchLatestNpmVersion,
43
+ getCurrentPackageVersion,
44
+ isNewerVersion,
45
+ } from './update/version-check'
32
46
 
33
47
  const WORKSPACE_SAVE_DEBOUNCE_MS = 250
34
48
 
35
- export function App({ backend }: { backend: SessionBackend }) {
49
+ export function App({
50
+ backend,
51
+ resolvedConfig,
52
+ }: {
53
+ backend: SessionBackend
54
+ resolvedConfig: ResolvedConfig
55
+ }) {
56
+ const keymapHandlers = useMemo(
57
+ () => registerAllModes(resolvedConfig.keymaps),
58
+ // Registration has side effects in a global mode registry — run once per app instance.
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ []
61
+ )
36
62
  const renderer = useRenderer()
37
63
  const dimensions = useTerminalDimensions()
38
64
  const [themeId, setThemeId] = useState<ThemeId>(() => {
@@ -43,11 +69,22 @@ export function App({ backend }: { backend: SessionBackend }) {
43
69
  return config.themeId ?? 'aimux'
44
70
  })
45
71
  const [state, dispatch] = useReducer(appReducer, undefined, () => {
46
- const { customCommands, gitPanelRatio, gitPanelVisible } = loadConfig()
47
- return createInitialState(customCommands, loadSessionCatalog(), loadSnippetCatalog(), true, {
48
- gitPanelRatio,
49
- gitPanelVisible,
50
- })
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
+ )
51
88
  })
52
89
 
53
90
  useLayoutEffect(() => {
@@ -56,9 +93,37 @@ export function App({ backend }: { backend: SessionBackend }) {
56
93
 
57
94
  useLayoutEffect(() => {
58
95
  setActiveDispatch(dispatch)
59
- return () => setActiveDispatch(null)
96
+ return () => {
97
+ setActiveDispatch(null)
98
+ setActiveSideEffectRunner(null)
99
+ }
60
100
  }, [dispatch])
61
101
 
102
+ useEffect(() => {
103
+ if (process.env.AIMUX_DISABLE_UPDATE_CHECK === '1') return
104
+ if (getProfileName() === 'dev') return
105
+
106
+ let cancelled = false
107
+ void (async () => {
108
+ const [current, latest] = await Promise.all([
109
+ getCurrentPackageVersion(),
110
+ fetchLatestNpmVersion('@brimveyn/aimux'),
111
+ ])
112
+ if (cancelled || !latest) return
113
+ if (!isNewerVersion(latest, current)) return
114
+ if (loadConfig().skippedUpdateVersion === latest) return
115
+ dispatch({
116
+ currentVersion: current,
117
+ latestVersion: latest,
118
+ type: 'open-update-available-modal',
119
+ })
120
+ })()
121
+
122
+ return () => {
123
+ cancelled = true
124
+ }
125
+ }, [])
126
+
62
127
  const resizingRef = useRef(false)
63
128
  const layoutRef = useRef(state.layout)
64
129
  layoutRef.current = state.layout
@@ -81,6 +146,8 @@ export function App({ backend }: { backend: SessionBackend }) {
81
146
  activeTabIdRef.current = state.activeTabId
82
147
  const activeTabRef = useRef(activeTab)
83
148
  activeTabRef.current = activeTab
149
+ const activeTabScrollIntentRef = useRef(activeTab?.scrollIntent ?? null)
150
+ activeTabScrollIntentRef.current = activeTab?.scrollIntent ?? null
84
151
 
85
152
  const stateRef = useRef(state)
86
153
  stateRef.current = state
@@ -90,6 +157,7 @@ export function App({ backend }: { backend: SessionBackend }) {
90
157
 
91
158
  const { clearIdleTimer, clearStartupGrace, startStartupGrace } = useBackendRuntime({
92
159
  activeTabId: state.activeTabId,
160
+ activeTabScrollIntentRef,
93
161
  backend,
94
162
  currentSessionId: state.currentSessionId,
95
163
  currentSessionWorkspaceSnapshot,
@@ -132,15 +200,18 @@ export function App({ backend }: { backend: SessionBackend }) {
132
200
  // Allows handleTerminalShortcut (a stable callback) to reach the latest closure.
133
201
  const processKeyResultRef = useRef<(result: KeyResult, modeId: ModeId) => void>(() => {})
134
202
 
135
- const handleTerminalShortcut = useCallback((chord: KeyChord): boolean => {
136
- const terminalHandler = keymapHandlers.find((h) => h.id === 'terminal-input')
137
- if (!terminalHandler) return false
138
- const ctx: ModeContext = { state: stateRef.current }
139
- const result = terminalHandler.handleChord(chord, ctx)
140
- if (!result) return false
141
- processKeyResultRef.current(result, 'terminal-input')
142
- return true
143
- }, [])
203
+ const handleTerminalShortcut = useCallback(
204
+ (chord: KeyChord): boolean => {
205
+ const terminalHandler = keymapHandlers.find((h) => h.id === 'terminal-input')
206
+ if (!terminalHandler) return false
207
+ const ctx: ModeContext = { state: stateRef.current }
208
+ const result = terminalHandler.handleChord(chord, ctx)
209
+ if (!result) return false
210
+ processKeyResultRef.current(result, 'terminal-input')
211
+ return true
212
+ },
213
+ [keymapHandlers]
214
+ )
144
215
 
145
216
  useRendererBindings({
146
217
  activeTabId: state.activeTabId,
@@ -194,6 +265,9 @@ export function App({ backend }: { backend: SessionBackend }) {
194
265
 
195
266
  // Keep the ref pointing at the latest closure so stable callbacks can invoke it
196
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))
197
271
 
198
272
  useLayoutEffect(() => {
199
273
  for (const handler of keymapHandlers) {
@@ -233,21 +307,23 @@ export function App({ backend }: { backend: SessionBackend }) {
233
307
  })
234
308
 
235
309
  return (
236
- <RootView
237
- themeId={themeId}
238
- contentOrigin={contentOriginRef.current}
239
- mouseForwardingEnabled={activeMouseForwardingEnabled}
240
- localScrollbackEnabled={activeLocalScrollbackEnabled}
241
- onTerminalMouseEvent={handleTerminalMouseEvent}
242
- onTerminalScrollEvent={handleTerminalScrollEvent}
243
- onTerminalClick={handleTerminalClick}
244
- onPaneActivate={handlePaneActivate}
245
- onSplitResize={handleSplitResize}
246
- onSeparatorDragStart={handleSeparatorDragStart}
247
- onSeparatorDrag={handleSeparatorDrag}
248
- onSeparatorDragEnd={handleSeparatorDragEnd}
249
- terminalCols={terminalSize.cols}
250
- terminalRows={terminalSize.rows}
251
- />
310
+ <KeymapContext.Provider value={resolvedConfig.keymaps}>
311
+ <RootView
312
+ themeId={themeId}
313
+ contentOrigin={contentOriginRef.current}
314
+ mouseForwardingEnabled={activeMouseForwardingEnabled}
315
+ localScrollbackEnabled={activeLocalScrollbackEnabled}
316
+ onTerminalMouseEvent={handleTerminalMouseEvent}
317
+ onTerminalScrollEvent={handleTerminalScrollEvent}
318
+ onTerminalClick={handleTerminalClick}
319
+ onPaneActivate={handlePaneActivate}
320
+ onSplitResize={handleSplitResize}
321
+ onSeparatorDragStart={handleSeparatorDragStart}
322
+ onSeparatorDrag={handleSeparatorDrag}
323
+ onSeparatorDragEnd={handleSeparatorDragEnd}
324
+ terminalCols={terminalSize.cols}
325
+ terminalRows={terminalSize.rows}
326
+ />
327
+ </KeymapContext.Provider>
252
328
  )
253
329
  }
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,7 +15,10 @@ 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
21
+ skippedUpdateVersion?: string
19
22
  }
20
23
 
21
24
  const DEFAULT_CONFIG: AimuxConfig = {
@@ -57,7 +60,10 @@ export function loadConfigResult(): ConfigLoadResult {
57
60
  themeId?: unknown
58
61
  gitPanelVisible?: unknown
59
62
  gitPanelRatio?: unknown
63
+ sessionBarVisible?: unknown
64
+ sessionBarPosition?: unknown
60
65
  workspaceSnapshot?: unknown
66
+ skippedUpdateVersion?: unknown
61
67
  }
62
68
 
63
69
  const issues: string[] = []
@@ -91,6 +97,20 @@ export function loadConfigResult(): ConfigLoadResult {
91
97
  issues.push('ignored invalid gitPanelRatio')
92
98
  }
93
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
+
94
114
  if (
95
115
  parsed.workspaceSnapshot !== undefined &&
96
116
  !isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
@@ -98,6 +118,14 @@ export function loadConfigResult(): ConfigLoadResult {
98
118
  issues.push('ignored invalid workspaceSnapshot')
99
119
  }
100
120
 
121
+ const validSkippedUpdateVersion =
122
+ typeof parsed.skippedUpdateVersion === 'string' && parsed.skippedUpdateVersion.length > 0
123
+ ? parsed.skippedUpdateVersion
124
+ : undefined
125
+ if (parsed.skippedUpdateVersion !== undefined && validSkippedUpdateVersion === undefined) {
126
+ issues.push('ignored invalid skippedUpdateVersion')
127
+ }
128
+
101
129
  if (issues.length > 0) {
102
130
  logDebug('config.load.validationIssue', { issues, path: CONFIG_PATH })
103
131
  }
@@ -107,6 +135,9 @@ export function loadConfigResult(): ConfigLoadResult {
107
135
  customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
108
136
  gitPanelRatio: validGitPanelRatio,
109
137
  gitPanelVisible: validGitPanelVisible,
138
+ sessionBarPosition: validSessionBarPosition,
139
+ sessionBarVisible: validSessionBarVisible,
140
+ skippedUpdateVersion: validSkippedUpdateVersion,
110
141
  themeId: isThemeId(parsed.themeId) ? parsed.themeId : undefined,
111
142
  version: 2,
112
143
  workspaceSnapshot: isWorkspaceSnapshotV1(parsed.workspaceSnapshot)