@brimveyn/aimux 1.2.5 → 1.3.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.
Files changed (59) hide show
  1. package/README.md +29 -8
  2. package/package.json +5 -5
  3. package/src/app-runtime/backend-runtime-events.ts +16 -1
  4. package/src/app-runtime/pty-write.ts +7 -4
  5. package/src/app-runtime/side-effects.ts +62 -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 +89 -30
  11. package/src/config/loader.ts +2 -2
  12. package/src/config.ts +14 -3
  13. package/src/daemon/daemon.ts +279 -118
  14. package/src/daemon/runtime-paths.ts +34 -2
  15. package/src/daemon/session-manager.ts +20 -5
  16. package/src/daemon/session-registry.ts +18 -11
  17. package/src/index.tsx +19 -4
  18. package/src/input/keymap/describe-bindings.ts +68 -0
  19. package/src/input/keymap/key-format.ts +67 -0
  20. package/src/input/modes/bridge.ts +1 -0
  21. package/src/input/modes/transitions.ts +2 -0
  22. package/src/input/modes/types.ts +2 -0
  23. package/src/ipc/manager-protocol.ts +396 -0
  24. package/src/ipc/protocol.ts +98 -5
  25. package/src/platform/daemon-control.ts +42 -11
  26. package/src/profile-paths.ts +27 -0
  27. package/src/pty/pty-manager.ts +53 -3
  28. package/src/restart-daemon.ts +10 -10
  29. package/src/session-backend/bootstrap.ts +197 -46
  30. package/src/session-backend/local-session-backend.ts +12 -5
  31. package/src/session-backend/remote-session-backend.ts +143 -63
  32. package/src/session-backend/types.ts +4 -2
  33. package/src/state/reducers/modal-state.ts +18 -1
  34. package/src/state/reducers/tab-state.ts +20 -2
  35. package/src/state/session-catalog.ts +5 -4
  36. package/src/state/session-persistence.ts +9 -2
  37. package/src/state/snippet-catalog.ts +4 -4
  38. package/src/state/types.ts +23 -0
  39. package/src/state/validation.ts +8 -0
  40. package/src/terminal-manager/manager-client.ts +384 -0
  41. package/src/terminal-manager/terminal-manager.ts +288 -0
  42. package/src/ui/components/create-session-modal.tsx +3 -5
  43. package/src/ui/components/git-commit-modal.tsx +3 -5
  44. package/src/ui/components/help-modal.tsx +49 -68
  45. package/src/ui/components/list-item.tsx +24 -5
  46. package/src/ui/components/new-tab-modal.tsx +8 -9
  47. package/src/ui/components/pending-chord-overlay.tsx +28 -0
  48. package/src/ui/components/session-name-modal.tsx +3 -5
  49. package/src/ui/components/session-picker-modal.tsx +3 -1
  50. package/src/ui/components/snippet-editor-modal.tsx +3 -1
  51. package/src/ui/components/snippet-picker-modal.tsx +3 -1
  52. package/src/ui/components/status-bar.tsx +6 -2
  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/keymap-context.ts +39 -0
  56. package/src/ui/root.tsx +12 -0
  57. package/src/ui/status-bar-model.ts +67 -39
  58. package/src/update/version-check.ts +67 -0
  59. package/src/update.ts +3 -3
package/README.md CHANGED
@@ -21,7 +21,7 @@ A terminal multiplexer for AI CLIs. Manage multiple AI assistant sessions (Claud
21
21
  - **Directory picker** — Fuzzy-search git repos and worktrees from `$HOME` using `fzf`
22
22
  - **Session persistence** — Workspace state (tabs, titles, layout) saved and restored on restart
23
23
  - **Git status panel** — Branch + diff summary in the sidebar
24
- - **Daemon mode** — Background daemon keeps sessions alive across terminal restarts
24
+ - **Seamless daemon updates** — A restartable IPC daemon now reconnects to a long-lived terminal manager, so updates and IPC changes do not kill live tabs
25
25
  - **Snippets** — Save and reuse prompt snippets across sessions
26
26
  - **Theme picker** — Switch between 11 built-in themes on the fly
27
27
  - **Pending-chord indicator** — Bottom-right overlay shows mid-sequence key state (like nvim's `which-key`)
@@ -58,20 +58,22 @@ aimux # start the TUI
58
58
  aimux version # print version
59
59
  aimux doctor # check setup
60
60
  aimux update # self-update
61
- aimux restart-daemon # restart background daemon
61
+ aimux restart-daemon # restart IPC daemon only
62
62
  ```
63
63
 
64
+ `aimux update` and `aimux restart-daemon` restart only the IPC daemon. Live PTYs and headless terminal state stay in the long-lived terminal-manager process, so active tabs can be reattached instead of being restarted.
65
+
64
66
  ## Configuration
65
67
 
66
- aimux reads `~/.config/aimux/aimux.config.ts` at startup. Set it up with:
68
+ aimux reads `~/.config/aimux/<profile>/aimux.config.ts` at startup. The default installed profile is `default`, while the repository dev scripts use `dev`. Set up the default profile with:
67
69
 
68
70
  ```bash
69
- mkdir -p ~/.config/aimux && cd ~/.config/aimux
71
+ mkdir -p ~/.config/aimux/default && cd ~/.config/aimux/default
70
72
  bun init -y
71
73
  bun add -d @brimveyn/aimux-config
72
74
  ```
73
75
 
74
- Then create `~/.config/aimux/aimux.config.ts`:
76
+ Then create `~/.config/aimux/default/aimux.config.ts`:
75
77
 
76
78
  ```ts
77
79
  import { defineConfig, actions, themes } from '@brimveyn/aimux-config'
@@ -158,6 +160,14 @@ Keystrokes pass through to the active tab's PTY. Configured shortcuts:
158
160
 
159
161
  ## Architecture
160
162
 
163
+ Runtime split:
164
+
165
+ - `aimux` UI connects to the IPC daemon over the app protocol.
166
+ - The IPC daemon owns the app-facing socket, protocol negotiation, and reconnect behavior.
167
+ - The terminal manager owns PTYs, xterm headless emulators, and live session state.
168
+
169
+ This split lets the app or IPC daemon change protocols without dropping live shells.
170
+
161
171
  ```
162
172
  aimux/
163
173
  ├── packages/
@@ -171,9 +181,10 @@ aimux/
171
181
  ├── ui/ # OpenTUI React components
172
182
  ├── state/ # reducers + app store
173
183
  ├── pty/ # PTY and terminal emulation
174
- ├── session-backend/ # local and daemon backends
175
- ├── daemon/ # background session daemon
176
- ├── ipc/ # daemon protocol
184
+ ├── session-backend/ # local and remote backends
185
+ ├── daemon/ # IPC daemon / broker
186
+ ├── terminal-manager/ # long-lived PTY/session owner
187
+ ├── ipc/ # app and manager protocols
177
188
  └── input/
178
189
  ├── modes/ # mode registry + transitions
179
190
  ├── keymap/ # prefix trie + sequence resolver
@@ -201,6 +212,16 @@ bun run check # typecheck
201
212
  bun run lint # oxlint
202
213
  ```
203
214
 
215
+ By default the app talks to the background IPC daemon. For explicit single-process debugging only, set `AIMUX_LOCAL_BACKEND=1` before starting aimux.
216
+
217
+ Profiles live under `~/.config/aimux/<profile>/`. Each profile gets its own config, session catalog, snippet catalog, and matching runtime namespace.
218
+
219
+ The repository `bun run dev`, `bun run start`, and `bun run restart-daemon` scripts set `AIMUX_PROFILE=dev`, so source builds use `~/.config/aimux/dev/` and their own IPC daemon / terminal-manager sockets instead of interfering with a globally installed `aimux` instance.
220
+
221
+ You can override the active profile manually with `AIMUX_PROFILE=<name>` when you need multiple isolated environments on the same machine. `AIMUX_RUNTIME_PROFILE` is still accepted as a fallback alias for runtime compatibility.
222
+
223
+ This profile move is intentionally breaking: aimux no longer reads legacy flat config or catalog files once profile directories are enabled.
224
+
204
225
  ## License
205
226
 
206
227
  MIT © BrimVeyn
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.2.5",
3
+ "version": "1.3.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",
@@ -42,8 +42,8 @@
42
42
  "access": "public"
43
43
  },
44
44
  "scripts": {
45
- "dev": "bun --watch src/index.tsx",
46
- "start": "bun run src/index.tsx",
45
+ "dev": "AIMUX_PROFILE=dev bun --watch src/index.tsx",
46
+ "start": "AIMUX_PROFILE=dev bun run src/index.tsx",
47
47
  "test": "bun test",
48
48
  "check": "tsc --noEmit",
49
49
  "demo": "vhs assets/demo.tape",
@@ -51,13 +51,13 @@
51
51
  "demo:tabs": "vhs assets/tabs.tape",
52
52
  "demo:splits": "vhs assets/splits.tape",
53
53
  "demo:themes": "vhs assets/themes.tape",
54
- "restart-daemon": "bun run src/index.tsx restart-daemon",
54
+ "restart-daemon": "AIMUX_PROFILE=dev bun run src/index.tsx restart-daemon",
55
55
  "lint": "oxlint .",
56
56
  "format": "oxfmt --write .",
57
57
  "format:check": "oxfmt --check ."
58
58
  },
59
59
  "dependencies": {
60
- "@brimveyn/aimux-config": "0.1.0",
60
+ "@brimveyn/aimux-config": "0.2.6",
61
61
  "@opentui/core": "^0.1.90",
62
62
  "@opentui/react": "^0.1.90",
63
63
  "@xterm/headless": "^6.0.0",
@@ -3,6 +3,7 @@ import type { MutableRefObject } from 'react'
3
3
  import type { SessionBackend } from '../session-backend/types'
4
4
  import type { AppAction, TabSession, TerminalModeState } from '../state/types'
5
5
 
6
+ import { logInputDebug } from '../debug/input-log'
6
7
  import { type TabRuntimeTimeouts } from './tab-runtime-timeouts'
7
8
 
8
9
  const IDLE_ACTIVITY_TIMEOUT_MS = 2_000
@@ -44,7 +45,19 @@ export function bindBackendRuntimeEvents({
44
45
  return
45
46
  }
46
47
 
47
- dispatch({ tabId, terminalModes, type: 'replace-tab-viewport', viewport })
48
+ logInputDebug('app.backend.event.render', {
49
+ lines: viewport.lines.length,
50
+ tabId,
51
+ viewportY: viewport.viewportY,
52
+ })
53
+
54
+ dispatch({
55
+ source: resizingRef.current ? 'resize' : 'data',
56
+ tabId,
57
+ terminalModes,
58
+ type: 'replace-tab-viewport',
59
+ viewport,
60
+ })
48
61
  if (timeouts.isStartupGraceActive(tabId) || resizingRef.current) {
49
62
  return
50
63
  }
@@ -54,12 +67,14 @@ export function bindBackendRuntimeEvents({
54
67
  }
55
68
 
56
69
  const handleExit = (tabId: string, exitCode: number) => {
70
+ logInputDebug('app.backend.event.exit', { exitCode, tabId })
57
71
  clearTabRuntimeState(timeouts, tabId)
58
72
  dispatch({ exitCode, status: 'exited', tabId, type: 'set-tab-status' })
59
73
  dispatch({ activity: undefined, tabId, type: 'set-tab-activity' })
60
74
  }
61
75
 
62
76
  const handleError = (tabId: string, message: string) => {
77
+ logInputDebug('app.backend.event.error', { message, tabId })
63
78
  clearTabRuntimeState(timeouts, tabId)
64
79
  dispatch({ message, tabId, type: 'set-tab-error' })
65
80
  }
@@ -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,
@@ -245,6 +252,14 @@ export function startTabSession(
245
252
  rows: number,
246
253
  cwd?: string
247
254
  ): void {
255
+ logInputDebug('app.tab.start.request', {
256
+ cols,
257
+ command: tab.command,
258
+ cwd: cwd ?? null,
259
+ rows,
260
+ tabId: tab.id,
261
+ title: tab.title,
262
+ })
248
263
  startStartupGrace(tab.id)
249
264
 
250
265
  const { args, executable } = parseCommand(tab.command)
@@ -385,7 +400,13 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
385
400
  )
386
401
  return
387
402
  case 'paste-selected-snippet': {
388
- pasteSnippetToTab(backend, state.activeTabId, ctx.activeTab, getSelectedSnippet(state))
403
+ pasteSnippetToTab(
404
+ backend,
405
+ state.activeTabId,
406
+ ctx.activeTab,
407
+ getSelectedSnippet(state),
408
+ dispatch
409
+ )
389
410
  return
390
411
  }
391
412
  case 'paste-snippet-to-group': {
@@ -463,11 +484,48 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
463
484
  void enqueueGitOp(() => runGitPush(ctx))
464
485
  return
465
486
  }
487
+ case 'confirm-update-selection': {
488
+ handleConfirmUpdateSelection(ctx)
489
+ return
490
+ }
466
491
  default:
467
492
  effect satisfies never
468
493
  }
469
494
  }
470
495
 
496
+ function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
497
+ const { state } = ctx
498
+ if (state.modal.type !== 'update-available') {
499
+ return
500
+ }
501
+ const latest = state.modal.latestVersion
502
+ if (state.modal.selectedIndex === 0) {
503
+ runUpdateFromTui(ctx, latest)
504
+ return
505
+ }
506
+ saveConfig({ ...loadConfig(), skippedUpdateVersion: latest })
507
+ }
508
+
509
+ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
510
+ saveCurrentWorkspace(ctx.state)
511
+ void ctx.backend.destroy(true)
512
+ ctx.renderer.destroy()
513
+ process.stdout.write(`\nUpdating aimux to ${latestVersion}...\n`)
514
+ const proc = Bun.spawn(['bun', 'update', '-g', '@brimveyn/aimux', '@brimveyn/aimux-config'], {
515
+ stderr: 'inherit',
516
+ stdin: 'inherit',
517
+ stdout: 'inherit',
518
+ })
519
+ void proc.exited.then((code) => {
520
+ if (code === 0) {
521
+ process.stdout.write(`\nUpdated. Run \`aimux\` to start the new version.\n`)
522
+ } else {
523
+ process.stderr.write(`\nUpdate failed (exit code ${code}).\n`)
524
+ }
525
+ process.exit(code ?? 1)
526
+ })
527
+ }
528
+
471
529
  async function runGitAction(
472
530
  ctx: SideEffectContext,
473
531
  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
34
  import { setActiveDispatch } 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>(() => {
@@ -59,6 +85,31 @@ export function App({ backend }: { backend: SessionBackend }) {
59
85
  return () => setActiveDispatch(null)
60
86
  }, [dispatch])
61
87
 
88
+ useEffect(() => {
89
+ if (process.env.AIMUX_DISABLE_UPDATE_CHECK === '1') return
90
+ if (getProfileName() === 'dev') return
91
+
92
+ let cancelled = false
93
+ void (async () => {
94
+ const [current, latest] = await Promise.all([
95
+ getCurrentPackageVersion(),
96
+ fetchLatestNpmVersion('@brimveyn/aimux'),
97
+ ])
98
+ if (cancelled || !latest) return
99
+ if (!isNewerVersion(latest, current)) return
100
+ if (loadConfig().skippedUpdateVersion === latest) return
101
+ dispatch({
102
+ currentVersion: current,
103
+ latestVersion: latest,
104
+ type: 'open-update-available-modal',
105
+ })
106
+ })()
107
+
108
+ return () => {
109
+ cancelled = true
110
+ }
111
+ }, [])
112
+
62
113
  const resizingRef = useRef(false)
63
114
  const layoutRef = useRef(state.layout)
64
115
  layoutRef.current = state.layout
@@ -81,6 +132,8 @@ export function App({ backend }: { backend: SessionBackend }) {
81
132
  activeTabIdRef.current = state.activeTabId
82
133
  const activeTabRef = useRef(activeTab)
83
134
  activeTabRef.current = activeTab
135
+ const activeTabScrollIntentRef = useRef(activeTab?.scrollIntent ?? null)
136
+ activeTabScrollIntentRef.current = activeTab?.scrollIntent ?? null
84
137
 
85
138
  const stateRef = useRef(state)
86
139
  stateRef.current = state
@@ -90,6 +143,7 @@ export function App({ backend }: { backend: SessionBackend }) {
90
143
 
91
144
  const { clearIdleTimer, clearStartupGrace, startStartupGrace } = useBackendRuntime({
92
145
  activeTabId: state.activeTabId,
146
+ activeTabScrollIntentRef,
93
147
  backend,
94
148
  currentSessionId: state.currentSessionId,
95
149
  currentSessionWorkspaceSnapshot,
@@ -132,15 +186,18 @@ export function App({ backend }: { backend: SessionBackend }) {
132
186
  // Allows handleTerminalShortcut (a stable callback) to reach the latest closure.
133
187
  const processKeyResultRef = useRef<(result: KeyResult, modeId: ModeId) => void>(() => {})
134
188
 
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
- }, [])
189
+ const handleTerminalShortcut = useCallback(
190
+ (chord: KeyChord): boolean => {
191
+ const terminalHandler = keymapHandlers.find((h) => h.id === 'terminal-input')
192
+ if (!terminalHandler) return false
193
+ const ctx: ModeContext = { state: stateRef.current }
194
+ const result = terminalHandler.handleChord(chord, ctx)
195
+ if (!result) return false
196
+ processKeyResultRef.current(result, 'terminal-input')
197
+ return true
198
+ },
199
+ [keymapHandlers]
200
+ )
144
201
 
145
202
  useRendererBindings({
146
203
  activeTabId: state.activeTabId,
@@ -233,21 +290,23 @@ export function App({ backend }: { backend: SessionBackend }) {
233
290
  })
234
291
 
235
292
  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
- />
293
+ <KeymapContext.Provider value={resolvedConfig.keymaps}>
294
+ <RootView
295
+ themeId={themeId}
296
+ contentOrigin={contentOriginRef.current}
297
+ mouseForwardingEnabled={activeMouseForwardingEnabled}
298
+ localScrollbackEnabled={activeLocalScrollbackEnabled}
299
+ onTerminalMouseEvent={handleTerminalMouseEvent}
300
+ onTerminalScrollEvent={handleTerminalScrollEvent}
301
+ onTerminalClick={handleTerminalClick}
302
+ onPaneActivate={handlePaneActivate}
303
+ onSplitResize={handleSplitResize}
304
+ onSeparatorDragStart={handleSeparatorDragStart}
305
+ onSeparatorDrag={handleSeparatorDrag}
306
+ onSeparatorDragEnd={handleSeparatorDragEnd}
307
+ terminalCols={terminalSize.cols}
308
+ terminalRows={terminalSize.rows}
309
+ />
310
+ </KeymapContext.Provider>
252
311
  )
253
312
  }
@@ -1,8 +1,8 @@
1
1
  import { type AimuxUserConfig, resolveConfig, type ResolvedConfig } from '@brimveyn/aimux-config'
2
- import { homedir } from 'node:os'
3
2
  import { join } from 'node:path'
4
3
 
5
4
  import { logDebug } from '../debug/input-log'
5
+ import { getProfileConfigDir } from '../profile-paths'
6
6
 
7
7
  const CONFIG_FILENAMES = ['aimux.config.ts', 'aimux.config.js']
8
8
 
@@ -11,7 +11,7 @@ const CONFIG_FILENAMES = ['aimux.config.ts', 'aimux.config.js']
11
11
  * a resolved config. Falls back to pure defaults if no config file exists.
12
12
  */
13
13
  export async function loadUserConfig(): Promise<ResolvedConfig> {
14
- const configDir = join(homedir(), '.config', 'aimux')
14
+ const configDir = getProfileConfigDir()
15
15
 
16
16
  for (const filename of CONFIG_FILENAMES) {
17
17
  const configPath = join(configDir, filename)