@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.
- package/README.md +29 -8
- package/package.json +5 -5
- package/src/app-runtime/backend-runtime-events.ts +16 -1
- package/src/app-runtime/pty-write.ts +7 -4
- package/src/app-runtime/side-effects.ts +62 -4
- package/src/app-runtime/snippet-actions.ts +3 -2
- package/src/app-runtime/use-backend-runtime.ts +7 -2
- package/src/app-runtime/use-renderer-bindings.ts +2 -2
- package/src/app-runtime/use-terminal-resize.ts +15 -6
- package/src/app.tsx +89 -30
- package/src/config/loader.ts +2 -2
- package/src/config.ts +14 -3
- package/src/daemon/daemon.ts +279 -118
- package/src/daemon/runtime-paths.ts +34 -2
- package/src/daemon/session-manager.ts +20 -5
- package/src/daemon/session-registry.ts +18 -11
- package/src/index.tsx +19 -4
- package/src/input/keymap/describe-bindings.ts +68 -0
- package/src/input/keymap/key-format.ts +67 -0
- package/src/input/modes/bridge.ts +1 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +2 -0
- package/src/ipc/manager-protocol.ts +396 -0
- package/src/ipc/protocol.ts +98 -5
- package/src/platform/daemon-control.ts +42 -11
- package/src/profile-paths.ts +27 -0
- package/src/pty/pty-manager.ts +53 -3
- package/src/restart-daemon.ts +10 -10
- package/src/session-backend/bootstrap.ts +197 -46
- package/src/session-backend/local-session-backend.ts +12 -5
- package/src/session-backend/remote-session-backend.ts +143 -63
- package/src/session-backend/types.ts +4 -2
- package/src/state/reducers/modal-state.ts +18 -1
- package/src/state/reducers/tab-state.ts +20 -2
- package/src/state/session-catalog.ts +5 -4
- package/src/state/session-persistence.ts +9 -2
- package/src/state/snippet-catalog.ts +4 -4
- package/src/state/types.ts +23 -0
- package/src/state/validation.ts +8 -0
- package/src/terminal-manager/manager-client.ts +384 -0
- package/src/terminal-manager/terminal-manager.ts +288 -0
- package/src/ui/components/create-session-modal.tsx +3 -5
- package/src/ui/components/git-commit-modal.tsx +3 -5
- package/src/ui/components/help-modal.tsx +49 -68
- package/src/ui/components/list-item.tsx +24 -5
- package/src/ui/components/new-tab-modal.tsx +8 -9
- package/src/ui/components/pending-chord-overlay.tsx +28 -0
- package/src/ui/components/session-name-modal.tsx +3 -5
- package/src/ui/components/session-picker-modal.tsx +3 -1
- package/src/ui/components/snippet-editor-modal.tsx +3 -1
- package/src/ui/components/snippet-picker-modal.tsx +3 -1
- package/src/ui/components/status-bar.tsx +6 -2
- package/src/ui/components/theme-picker-modal.tsx +3 -6
- package/src/ui/components/update-available-modal.tsx +42 -0
- package/src/ui/keymap-context.ts +39 -0
- package/src/ui/root.tsx +12 -0
- package/src/ui/status-bar-model.ts +67 -39
- package/src/update/version-check.ts +67 -0
- 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
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
175
|
-
├── daemon/ #
|
|
176
|
-
├──
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
1
|
+
import type { ResolvedConfig } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
2
3
|
import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
|
|
3
|
-
import {
|
|
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
|
-
|
|
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({
|
|
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(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
}
|
package/src/config/loader.ts
CHANGED
|
@@ -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 =
|
|
14
|
+
const configDir = getProfileConfigDir()
|
|
15
15
|
|
|
16
16
|
for (const filename of CONFIG_FILENAMES) {
|
|
17
17
|
const configPath = join(configDir, filename)
|