@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.
- package/README.md +3 -0
- package/package.json +2 -2
- package/src/app-runtime/backend-runtime-events.ts +13 -1
- package/src/app-runtime/pty-write.ts +7 -4
- package/src/app-runtime/side-effects.ts +72 -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 +113 -37
- package/src/config.ts +32 -1
- package/src/daemon/daemon.ts +19 -2
- package/src/daemon/session-manager.ts +20 -5
- package/src/daemon/session-registry.ts +18 -11
- package/src/index.tsx +8 -1
- 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 +3 -0
- package/src/ipc/manager-protocol.ts +51 -2
- package/src/ipc/protocol.ts +43 -2
- package/src/pty/pty-manager.ts +29 -3
- package/src/session-backend/local-session-backend.ts +39 -5
- package/src/session-backend/remote-session-backend.ts +18 -5
- package/src/session-backend/types.ts +5 -2
- package/src/state/dispatch-ref.ts +11 -0
- package/src/state/reducers/modal-state.ts +18 -1
- package/src/state/reducers/session-state.ts +28 -0
- package/src/state/reducers/tab-state.ts +20 -2
- package/src/state/reducers/ui-state.ts +8 -0
- package/src/state/session-catalog.ts +22 -1
- package/src/state/session-persistence.ts +9 -2
- package/src/state/store.ts +8 -1
- package/src/state/types.ts +37 -0
- package/src/state/validation.ts +10 -0
- package/src/state/workspace-save.ts +2 -0
- package/src/terminal-manager/manager-client.ts +29 -5
- package/src/terminal-manager/terminal-manager.ts +19 -3
- 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-bar.tsx +208 -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/tab-item.tsx +3 -15
- package/src/ui/components/theme-picker-modal.tsx +3 -6
- package/src/ui/components/update-available-modal.tsx +42 -0
- package/src/ui/hooks/use-busy-spinner.ts +18 -0
- package/src/ui/keymap-context.ts +39 -0
- package/src/ui/root.tsx +16 -0
- package/src/ui/session-ordering.ts +34 -0
- package/src/ui/status-bar-model.ts +67 -39
- 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
|
+
"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.
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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
|
-
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
|
-
|
|
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>(() => {
|
|
@@ -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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 () =>
|
|
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(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
<
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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)
|