@brimveyn/aimux 1.1.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/LICENSE +21 -0
- package/README.md +206 -0
- package/package.json +79 -0
- package/src/app-runtime/backend-attach-runtime.ts +135 -0
- package/src/app-runtime/backend-runtime-events.ts +78 -0
- package/src/app-runtime/click-selection-resolver.ts +130 -0
- package/src/app-runtime/pty-write.ts +32 -0
- package/src/app-runtime/render-invalidation.ts +20 -0
- package/src/app-runtime/selection-clipboard.ts +69 -0
- package/src/app-runtime/selection-scroll.ts +143 -0
- package/src/app-runtime/session-actions.ts +170 -0
- package/src/app-runtime/side-effects.ts +434 -0
- package/src/app-runtime/snippet-actions.ts +90 -0
- package/src/app-runtime/split-drag-controller.ts +15 -0
- package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
- package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
- package/src/app-runtime/use-backend-runtime.ts +99 -0
- package/src/app-runtime/use-directory-search.ts +52 -0
- package/src/app-runtime/use-mouse-handlers.ts +183 -0
- package/src/app-runtime/use-renderer-bindings.ts +163 -0
- package/src/app-runtime/use-terminal-resize.ts +141 -0
- package/src/app-runtime/use-workspace-autosave.ts +39 -0
- package/src/app.tsx +247 -0
- package/src/config/loader.ts +36 -0
- package/src/config.ts +146 -0
- package/src/daemon/daemon.ts +236 -0
- package/src/daemon/runtime-paths.ts +69 -0
- package/src/daemon/session-manager.ts +109 -0
- package/src/daemon/session-registry.ts +207 -0
- package/src/debug/input-log.ts +29 -0
- package/src/doctor.ts +106 -0
- package/src/git/git-poller.ts +49 -0
- package/src/git/git-status.ts +183 -0
- package/src/index.tsx +56 -0
- package/src/input/keymap/build-handlers.ts +34 -0
- package/src/input/keymap/key-chord.ts +201 -0
- package/src/input/keymap/keymap-mode-handler.ts +88 -0
- package/src/input/keymap/sequence-resolver.ts +117 -0
- package/src/input/keymap/trie.ts +103 -0
- package/src/input/modes/bridge.ts +58 -0
- package/src/input/modes/handlers/index.ts +18 -0
- package/src/input/modes/handlers/shared.ts +70 -0
- package/src/input/modes/registry.ts +34 -0
- package/src/input/modes/transitions.ts +37 -0
- package/src/input/modes/types.ts +63 -0
- package/src/input/mouse-forwarding.ts +98 -0
- package/src/input/multi-click-detector.ts +55 -0
- package/src/input/paste.ts +12 -0
- package/src/input/raw-input-handler.ts +191 -0
- package/src/input/terminal-text-extraction.ts +78 -0
- package/src/ipc/protocol.ts +341 -0
- package/src/platform/clipboard.ts +13 -0
- package/src/platform/daemon-control.ts +62 -0
- package/src/platform/id.ts +7 -0
- package/src/platform/project-search.ts +75 -0
- package/src/pty/command-registry.ts +69 -0
- package/src/pty/pty-manager.ts +268 -0
- package/src/pty/terminal-snapshot.ts +210 -0
- package/src/restart-daemon.ts +28 -0
- package/src/session-backend/bootstrap.ts +107 -0
- package/src/session-backend/local-session-backend.ts +164 -0
- package/src/session-backend/remote-session-backend.ts +373 -0
- package/src/session-backend/types.ts +47 -0
- package/src/state/app-store.ts +19 -0
- package/src/state/layout-resize.ts +56 -0
- package/src/state/layout-tree.ts +323 -0
- package/src/state/reducers/git-panel-state.ts +102 -0
- package/src/state/reducers/modal-state.ts +358 -0
- package/src/state/reducers/session-state.ts +86 -0
- package/src/state/reducers/tab-state.ts +531 -0
- package/src/state/reducers/ui-state.ts +29 -0
- package/src/state/selectors.ts +30 -0
- package/src/state/session-catalog.ts +96 -0
- package/src/state/session-persistence.ts +210 -0
- package/src/state/snippet-catalog.ts +90 -0
- package/src/state/store.ts +93 -0
- package/src/state/terminal-modes.ts +11 -0
- package/src/state/types.ts +367 -0
- package/src/state/validation.ts +154 -0
- package/src/state/workspace-save.ts +33 -0
- package/src/ui/components/create-session-modal.tsx +100 -0
- package/src/ui/components/git-panel.tsx +200 -0
- package/src/ui/components/help-modal.tsx +79 -0
- package/src/ui/components/input-field.tsx +18 -0
- package/src/ui/components/list-item.tsx +30 -0
- package/src/ui/components/modal-filter-bar.tsx +18 -0
- package/src/ui/components/modal-shell.tsx +47 -0
- package/src/ui/components/new-tab-modal.tsx +52 -0
- package/src/ui/components/pending-chord-indicator.tsx +41 -0
- package/src/ui/components/session-name-modal.tsx +15 -0
- package/src/ui/components/session-picker-modal.tsx +93 -0
- package/src/ui/components/sidebar-group-metadata.ts +44 -0
- package/src/ui/components/sidebar-scroll.ts +33 -0
- package/src/ui/components/sidebar.tsx +225 -0
- package/src/ui/components/snippet-editor-modal.tsx +39 -0
- package/src/ui/components/snippet-picker-modal.tsx +56 -0
- package/src/ui/components/split-layout.tsx +201 -0
- package/src/ui/components/status-bar.tsx +60 -0
- package/src/ui/components/surface.tsx +63 -0
- package/src/ui/components/tab-item.tsx +118 -0
- package/src/ui/components/terminal-pane.tsx +215 -0
- package/src/ui/components/theme-picker-modal.tsx +35 -0
- package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
- package/src/ui/components/use-sidebar-branch.ts +36 -0
- package/src/ui/directory-search.ts +1 -0
- package/src/ui/git-branch.ts +11 -0
- package/src/ui/path-format.ts +6 -0
- package/src/ui/root.tsx +261 -0
- package/src/ui/status-bar-model.ts +87 -0
- package/src/ui/theme.ts +9 -0
- package/src/ui/themes.ts +255 -0
- package/src/ui/ui-tokens.ts +11 -0
- package/src/update.ts +62 -0
package/src/app.tsx
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { getDefaultKeymapConfig } from '@brimveyn/aimux-config'
|
|
2
|
+
import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
|
|
3
|
+
import { useCallback, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { KeyChord } from './input/keymap/key-chord'
|
|
6
|
+
import type { TrieBinding } from './input/keymap/trie'
|
|
7
|
+
import type { KeyResult, ModeContext, ModeId } from './input/modes/types'
|
|
8
|
+
import type { SessionBackend } from './session-backend/types'
|
|
9
|
+
import type { ThemeId } from './ui/themes'
|
|
10
|
+
|
|
11
|
+
import { executeSideEffect, type SideEffectContext } from './app-runtime/side-effects'
|
|
12
|
+
import { useBackendRuntime } from './app-runtime/use-backend-runtime'
|
|
13
|
+
import { useDirectorySearch } from './app-runtime/use-directory-search'
|
|
14
|
+
import { useMouseHandlers } from './app-runtime/use-mouse-handlers'
|
|
15
|
+
import { useRendererBindings } from './app-runtime/use-renderer-bindings'
|
|
16
|
+
import { useTerminalResize } from './app-runtime/use-terminal-resize'
|
|
17
|
+
import { useWorkspaceAutosave } from './app-runtime/use-workspace-autosave'
|
|
18
|
+
import { loadConfig } from './config'
|
|
19
|
+
import { deriveModeId } from './input/modes/bridge'
|
|
20
|
+
import { registerAllModes } from './input/modes/handlers'
|
|
21
|
+
import { getHandler, transitionTo } from './input/modes/registry'
|
|
22
|
+
import { type TerminalContentOrigin } from './input/raw-input-handler'
|
|
23
|
+
import { appStore } from './state/app-store'
|
|
24
|
+
import { loadSessionCatalog } from './state/session-catalog'
|
|
25
|
+
import { loadSnippetCatalog } from './state/snippet-catalog'
|
|
26
|
+
import { appReducer, createInitialState } from './state/store'
|
|
27
|
+
import { RootView } from './ui/root'
|
|
28
|
+
import { applyTheme } from './ui/theme'
|
|
29
|
+
|
|
30
|
+
const keymapHandlers = registerAllModes(getDefaultKeymapConfig())
|
|
31
|
+
|
|
32
|
+
const WORKSPACE_SAVE_DEBOUNCE_MS = 250
|
|
33
|
+
|
|
34
|
+
export function App({ backend }: { backend: SessionBackend }) {
|
|
35
|
+
const renderer = useRenderer()
|
|
36
|
+
const dimensions = useTerminalDimensions()
|
|
37
|
+
const [themeId, setThemeId] = useState<ThemeId>(() => {
|
|
38
|
+
const config = loadConfig()
|
|
39
|
+
if (config.themeId) {
|
|
40
|
+
applyTheme(config.themeId)
|
|
41
|
+
}
|
|
42
|
+
return config.themeId ?? 'aimux'
|
|
43
|
+
})
|
|
44
|
+
const [state, dispatch] = useReducer(appReducer, undefined, () => {
|
|
45
|
+
const { customCommands, gitPanelRatio, gitPanelVisible } = loadConfig()
|
|
46
|
+
return createInitialState(customCommands, loadSessionCatalog(), loadSnippetCatalog(), true, {
|
|
47
|
+
gitPanelRatio,
|
|
48
|
+
gitPanelVisible,
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
useLayoutEffect(() => {
|
|
53
|
+
appStore.setState(state)
|
|
54
|
+
}, [state])
|
|
55
|
+
|
|
56
|
+
const resizingRef = useRef(false)
|
|
57
|
+
const layoutRef = useRef(state.layout)
|
|
58
|
+
layoutRef.current = state.layout
|
|
59
|
+
const activeTab = useMemo(
|
|
60
|
+
() => state.tabs.find((tab) => tab.id === state.activeTabId),
|
|
61
|
+
[state.activeTabId, state.tabs]
|
|
62
|
+
)
|
|
63
|
+
const currentSession = useMemo(
|
|
64
|
+
() => state.sessions.find((session) => session.id === state.currentSessionId),
|
|
65
|
+
[state.currentSessionId, state.sessions]
|
|
66
|
+
)
|
|
67
|
+
const activeMouseForwardingEnabled = activeTab?.terminalModes.mouseTrackingMode !== 'none'
|
|
68
|
+
const activeLocalScrollbackEnabled =
|
|
69
|
+
!!activeTab && !activeMouseForwardingEnabled && !activeTab.terminalModes.isAlternateBuffer
|
|
70
|
+
|
|
71
|
+
const focusModeRef = useRef(state.focusMode)
|
|
72
|
+
focusModeRef.current = state.focusMode
|
|
73
|
+
|
|
74
|
+
const activeTabIdRef = useRef(state.activeTabId)
|
|
75
|
+
activeTabIdRef.current = state.activeTabId
|
|
76
|
+
const activeTabRef = useRef(activeTab)
|
|
77
|
+
activeTabRef.current = activeTab
|
|
78
|
+
|
|
79
|
+
const stateRef = useRef(state)
|
|
80
|
+
stateRef.current = state
|
|
81
|
+
|
|
82
|
+
const contentOriginRef = useRef<TerminalContentOrigin>({ cols: 0, rows: 0, x: 0, y: 0 })
|
|
83
|
+
const currentSessionWorkspaceSnapshot = currentSession?.workspaceSnapshot
|
|
84
|
+
|
|
85
|
+
const { clearIdleTimer, clearStartupGrace, startStartupGrace } = useBackendRuntime({
|
|
86
|
+
activeTabId: state.activeTabId,
|
|
87
|
+
backend,
|
|
88
|
+
currentSessionId: state.currentSessionId,
|
|
89
|
+
currentSessionWorkspaceSnapshot,
|
|
90
|
+
dispatch,
|
|
91
|
+
layoutRef,
|
|
92
|
+
resizingRef,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
useWorkspaceAutosave(state, WORKSPACE_SAVE_DEBOUNCE_MS)
|
|
96
|
+
useDirectorySearch(state.modal, dispatch)
|
|
97
|
+
|
|
98
|
+
const terminalSize = useTerminalResize({
|
|
99
|
+
backend,
|
|
100
|
+
contentOriginRef,
|
|
101
|
+
dimensions,
|
|
102
|
+
dispatch,
|
|
103
|
+
resizingRef,
|
|
104
|
+
state,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const {
|
|
108
|
+
handlePaneActivate,
|
|
109
|
+
handleSeparatorDrag,
|
|
110
|
+
handleSeparatorDragEnd,
|
|
111
|
+
handleSeparatorDragStart,
|
|
112
|
+
handleSplitResize,
|
|
113
|
+
handleTerminalClick,
|
|
114
|
+
handleTerminalMouseEvent,
|
|
115
|
+
handleTerminalScrollEvent,
|
|
116
|
+
} = useMouseHandlers({
|
|
117
|
+
activeLocalScrollbackEnabled,
|
|
118
|
+
activeMouseForwardingEnabled,
|
|
119
|
+
backend,
|
|
120
|
+
dispatch,
|
|
121
|
+
renderer,
|
|
122
|
+
state,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// Stable ref to processKeyResult — populated after it is defined below.
|
|
126
|
+
// Allows handleTerminalShortcut (a stable callback) to reach the latest closure.
|
|
127
|
+
const processKeyResultRef = useRef<(result: KeyResult, modeId: ModeId) => void>(() => {})
|
|
128
|
+
|
|
129
|
+
const handleTerminalShortcut = useCallback((chord: KeyChord): boolean => {
|
|
130
|
+
const terminalHandler = keymapHandlers.find((h) => h.id === 'terminal-input')
|
|
131
|
+
if (!terminalHandler) return false
|
|
132
|
+
const ctx: ModeContext = { state: stateRef.current }
|
|
133
|
+
const result = terminalHandler.handleChord(chord, ctx)
|
|
134
|
+
if (!result) return false
|
|
135
|
+
processKeyResultRef.current(result, 'terminal-input')
|
|
136
|
+
return true
|
|
137
|
+
}, [])
|
|
138
|
+
|
|
139
|
+
useRendererBindings({
|
|
140
|
+
activeTabId: state.activeTabId,
|
|
141
|
+
activeTabIdRef,
|
|
142
|
+
activeTabRef,
|
|
143
|
+
activeTabViewportY: activeTab?.viewport?.viewportY ?? null,
|
|
144
|
+
backend,
|
|
145
|
+
dispatch,
|
|
146
|
+
focusMode: state.focusMode,
|
|
147
|
+
focusModeRef,
|
|
148
|
+
handleTerminalShortcut,
|
|
149
|
+
renderer,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const sideEffectCtx: SideEffectContext = {
|
|
153
|
+
activeTab,
|
|
154
|
+
backend,
|
|
155
|
+
clearIdleTimer,
|
|
156
|
+
clearStartupGrace,
|
|
157
|
+
dispatch,
|
|
158
|
+
getCurrentSessionProjectPath: () => {
|
|
159
|
+
if (!state.currentSessionId) return undefined
|
|
160
|
+
return state.sessions.find((s) => s.id === state.currentSessionId)?.projectPath
|
|
161
|
+
},
|
|
162
|
+
renderer,
|
|
163
|
+
setThemeId,
|
|
164
|
+
startStartupGrace,
|
|
165
|
+
state,
|
|
166
|
+
themeId,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function processKeyResult(result: KeyResult, modeId: ModeId): void {
|
|
170
|
+
for (const action of result.actions) {
|
|
171
|
+
dispatch(action)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (result.transition) {
|
|
175
|
+
const transResult = transitionTo(modeId, result.transition, { state })
|
|
176
|
+
for (const action of transResult.actions) {
|
|
177
|
+
dispatch(action)
|
|
178
|
+
}
|
|
179
|
+
for (const effect of transResult.effects) {
|
|
180
|
+
executeSideEffect(effect, sideEffectCtx)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const effect of result.effects) {
|
|
185
|
+
executeSideEffect(effect, sideEffectCtx)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Keep the ref pointing at the latest closure so stable callbacks can invoke it
|
|
190
|
+
processKeyResultRef.current = processKeyResult
|
|
191
|
+
|
|
192
|
+
useLayoutEffect(() => {
|
|
193
|
+
for (const handler of keymapHandlers) {
|
|
194
|
+
handler.setTimeoutCallback((binding: TrieBinding) => {
|
|
195
|
+
const currentState = stateRef.current
|
|
196
|
+
const modeId = deriveModeId(currentState)
|
|
197
|
+
if (modeId !== handler.id) return // mode changed during timeout
|
|
198
|
+
const ctx: ModeContext = { state: currentState }
|
|
199
|
+
const result = typeof binding.result === 'function' ? binding.result(ctx) : binding.result
|
|
200
|
+
if (result) processKeyResultRef.current(result, modeId)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
handler.setPendingChangeCallback((chords) => {
|
|
204
|
+
dispatch({ chords, type: 'set-pending-chords' })
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
208
|
+
|
|
209
|
+
useKeyboard((key) => {
|
|
210
|
+
// Global quit: Ctrl+C in any mode except terminal-input
|
|
211
|
+
if (key.ctrl && key.name === 'c' && state.focusMode !== 'terminal-input') {
|
|
212
|
+
key.preventDefault()
|
|
213
|
+
executeSideEffect({ state, type: 'quit' }, sideEffectCtx)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const modeId = deriveModeId(state)
|
|
218
|
+
const handler = getHandler(modeId)
|
|
219
|
+
if (!handler) return
|
|
220
|
+
|
|
221
|
+
const ctx: ModeContext = { state }
|
|
222
|
+
const result = handler.handleKey(key, ctx)
|
|
223
|
+
if (!result) return
|
|
224
|
+
|
|
225
|
+
key.preventDefault()
|
|
226
|
+
processKeyResult(result, modeId)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<RootView
|
|
231
|
+
themeId={themeId}
|
|
232
|
+
contentOrigin={contentOriginRef.current}
|
|
233
|
+
mouseForwardingEnabled={activeMouseForwardingEnabled}
|
|
234
|
+
localScrollbackEnabled={activeLocalScrollbackEnabled}
|
|
235
|
+
onTerminalMouseEvent={handleTerminalMouseEvent}
|
|
236
|
+
onTerminalScrollEvent={handleTerminalScrollEvent}
|
|
237
|
+
onTerminalClick={handleTerminalClick}
|
|
238
|
+
onPaneActivate={handlePaneActivate}
|
|
239
|
+
onSplitResize={handleSplitResize}
|
|
240
|
+
onSeparatorDragStart={handleSeparatorDragStart}
|
|
241
|
+
onSeparatorDrag={handleSeparatorDrag}
|
|
242
|
+
onSeparatorDragEnd={handleSeparatorDragEnd}
|
|
243
|
+
terminalCols={terminalSize.cols}
|
|
244
|
+
terminalRows={terminalSize.rows}
|
|
245
|
+
/>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type AimuxUserConfig, resolveConfig, type ResolvedConfig } from '@brimveyn/aimux-config'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { logDebug } from '../debug/input-log'
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILENAMES = ['aimux.config.ts', 'aimux.config.js']
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Load the user's aimux.config.ts (or .js), merge with defaults, and return
|
|
11
|
+
* a resolved config. Falls back to pure defaults if no config file exists.
|
|
12
|
+
*/
|
|
13
|
+
export async function loadUserConfig(): Promise<ResolvedConfig> {
|
|
14
|
+
const configDir = join(homedir(), '.config', 'aimux')
|
|
15
|
+
|
|
16
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
17
|
+
const configPath = join(configDir, filename)
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const file = Bun.file(configPath)
|
|
21
|
+
if (!(await file.exists())) continue
|
|
22
|
+
|
|
23
|
+
const mod = await import(configPath)
|
|
24
|
+
const userConfig: AimuxUserConfig = mod.default ?? mod
|
|
25
|
+
|
|
26
|
+
logDebug('config.user.loaded', { path: configPath })
|
|
27
|
+
return resolveConfig(userConfig)
|
|
28
|
+
} catch (error) {
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
30
|
+
logDebug('config.user.error', { error: message, path: configPath })
|
|
31
|
+
// Fall through to try next filename or use defaults
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return resolveConfig({})
|
|
36
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { WorkspaceSnapshotV1 } from './state/types'
|
|
5
|
+
|
|
6
|
+
import { logDebug } from './debug/input-log'
|
|
7
|
+
import { isWorkspaceSnapshotV1 } from './state/validation'
|
|
8
|
+
import { THEME_IDS, type ThemeId } from './ui/themes'
|
|
9
|
+
|
|
10
|
+
export const CONFIG_PATH = join(process.env.HOME ?? '~', '.config', 'aimux.json')
|
|
11
|
+
|
|
12
|
+
export interface AimuxConfig {
|
|
13
|
+
version: 2
|
|
14
|
+
customCommands: Record<string, string>
|
|
15
|
+
themeId?: ThemeId
|
|
16
|
+
gitPanelVisible?: boolean
|
|
17
|
+
gitPanelRatio?: number
|
|
18
|
+
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CONFIG: AimuxConfig = {
|
|
22
|
+
customCommands: {},
|
|
23
|
+
version: 2,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ConfigLoadResult {
|
|
27
|
+
config: AimuxConfig
|
|
28
|
+
source: 'defaults' | 'file'
|
|
29
|
+
issues: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isThemeId(value: unknown): value is ThemeId {
|
|
33
|
+
return typeof value === 'string' && THEME_IDS.includes(value as ThemeId)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isCustomCommandsRecord(value: unknown): value is Record<string, string> {
|
|
37
|
+
if (typeof value !== 'object' || value === null) {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return Object.entries(value).every(
|
|
42
|
+
([key, entryValue]) =>
|
|
43
|
+
typeof key === 'string' && key.length > 0 && typeof entryValue === 'string'
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadConfigResult(): ConfigLoadResult {
|
|
48
|
+
try {
|
|
49
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
50
|
+
return { config: DEFAULT_CONFIG, issues: [], source: 'defaults' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const raw = readFileSync(CONFIG_PATH, 'utf8')
|
|
54
|
+
const parsed = JSON.parse(raw) as {
|
|
55
|
+
version?: number
|
|
56
|
+
customCommands?: unknown
|
|
57
|
+
themeId?: unknown
|
|
58
|
+
gitPanelVisible?: unknown
|
|
59
|
+
gitPanelRatio?: unknown
|
|
60
|
+
workspaceSnapshot?: unknown
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const issues: string[] = []
|
|
64
|
+
|
|
65
|
+
if (parsed.version !== undefined && parsed.version !== 2) {
|
|
66
|
+
issues.push(`unsupported config version ${String(parsed.version)}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (parsed.customCommands !== undefined && !isCustomCommandsRecord(parsed.customCommands)) {
|
|
70
|
+
issues.push('ignored invalid customCommands')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (parsed.themeId !== undefined && !isThemeId(parsed.themeId)) {
|
|
74
|
+
issues.push('ignored invalid themeId')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const validGitPanelVisible =
|
|
78
|
+
typeof parsed.gitPanelVisible === 'boolean' ? parsed.gitPanelVisible : undefined
|
|
79
|
+
if (parsed.gitPanelVisible !== undefined && validGitPanelVisible === undefined) {
|
|
80
|
+
issues.push('ignored invalid gitPanelVisible')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const validGitPanelRatio =
|
|
84
|
+
typeof parsed.gitPanelRatio === 'number' &&
|
|
85
|
+
Number.isFinite(parsed.gitPanelRatio) &&
|
|
86
|
+
parsed.gitPanelRatio > 0 &&
|
|
87
|
+
parsed.gitPanelRatio < 1
|
|
88
|
+
? parsed.gitPanelRatio
|
|
89
|
+
: undefined
|
|
90
|
+
if (parsed.gitPanelRatio !== undefined && validGitPanelRatio === undefined) {
|
|
91
|
+
issues.push('ignored invalid gitPanelRatio')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (
|
|
95
|
+
parsed.workspaceSnapshot !== undefined &&
|
|
96
|
+
!isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
|
|
97
|
+
) {
|
|
98
|
+
issues.push('ignored invalid workspaceSnapshot')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (issues.length > 0) {
|
|
102
|
+
logDebug('config.load.validationIssue', { issues, path: CONFIG_PATH })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
config: {
|
|
107
|
+
customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
|
|
108
|
+
gitPanelRatio: validGitPanelRatio,
|
|
109
|
+
gitPanelVisible: validGitPanelVisible,
|
|
110
|
+
themeId: isThemeId(parsed.themeId) ? parsed.themeId : undefined,
|
|
111
|
+
version: 2,
|
|
112
|
+
workspaceSnapshot: isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
|
|
113
|
+
? parsed.workspaceSnapshot
|
|
114
|
+
: undefined,
|
|
115
|
+
},
|
|
116
|
+
issues,
|
|
117
|
+
source: 'file',
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
121
|
+
logDebug('config.load.error', { error: message, path: CONFIG_PATH })
|
|
122
|
+
return {
|
|
123
|
+
config: DEFAULT_CONFIG,
|
|
124
|
+
issues: [`failed to load config: ${message}`],
|
|
125
|
+
source: 'defaults',
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function loadConfig(): AimuxConfig {
|
|
131
|
+
return loadConfigResult().config
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function saveConfig(config: AimuxConfig): boolean {
|
|
135
|
+
try {
|
|
136
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true })
|
|
137
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`)
|
|
138
|
+
return true
|
|
139
|
+
} catch (error) {
|
|
140
|
+
logDebug('config.save.error', {
|
|
141
|
+
error: error instanceof Error ? error.message : String(error),
|
|
142
|
+
path: CONFIG_PATH,
|
|
143
|
+
})
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { createServer, type Socket } from 'node:net'
|
|
2
|
+
|
|
3
|
+
import { logDebug } from '../debug/input-log'
|
|
4
|
+
import {
|
|
5
|
+
type ClientRequest,
|
|
6
|
+
encodeMessage,
|
|
7
|
+
IPC_PROTOCOL_VERSION,
|
|
8
|
+
MessageDecoder,
|
|
9
|
+
parseClientRequest,
|
|
10
|
+
type ServerEvent,
|
|
11
|
+
type ServerResponse,
|
|
12
|
+
} from '../ipc/protocol'
|
|
13
|
+
import { findDaemonPid } from '../platform/daemon-control'
|
|
14
|
+
import {
|
|
15
|
+
getDaemonSocketPath,
|
|
16
|
+
removeDaemonSocketIfExists,
|
|
17
|
+
tightenDaemonSocketPermissions,
|
|
18
|
+
} from './runtime-paths'
|
|
19
|
+
import { SessionManager } from './session-manager'
|
|
20
|
+
|
|
21
|
+
function send(socket: Socket, message: ServerResponse | ServerEvent): void {
|
|
22
|
+
socket.write(encodeMessage(message))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sendOk(socket: Socket, id: string): void {
|
|
26
|
+
send(socket, { id, payload: {}, type: 'ok' })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requireSession(socket: Socket, attachedSessions: Map<Socket, string>): string {
|
|
30
|
+
const sessionId = attachedSessions.get(socket)
|
|
31
|
+
if (!sessionId) {
|
|
32
|
+
throw new Error('No session attached')
|
|
33
|
+
}
|
|
34
|
+
return sessionId
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runDaemon(): Promise<void> {
|
|
38
|
+
const socketPath = getDaemonSocketPath()
|
|
39
|
+
logDebug('daemon.start', { pid: process.pid, socketPath })
|
|
40
|
+
|
|
41
|
+
const existingPid = await findDaemonPid(socketPath)
|
|
42
|
+
if (existingPid !== null && existingPid !== process.pid) {
|
|
43
|
+
logDebug('daemon.alreadyRunning', { existingPid })
|
|
44
|
+
process.stderr.write(`aimux daemon already running (pid ${existingPid})\n`)
|
|
45
|
+
process.exit(1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
logDebug('daemon.removeStaleSocket', { socketPath })
|
|
49
|
+
removeDaemonSocketIfExists()
|
|
50
|
+
|
|
51
|
+
const sessionManager = new SessionManager()
|
|
52
|
+
const sockets = new Set<Socket>()
|
|
53
|
+
const attachedSessions = new Map<Socket, string>()
|
|
54
|
+
|
|
55
|
+
sessionManager.on('render', (sessionId, tabId, viewport, terminalModes) => {
|
|
56
|
+
const event: ServerEvent = { payload: { tabId, terminalModes, viewport }, type: 'tabRender' }
|
|
57
|
+
for (const socket of sockets) {
|
|
58
|
+
if (attachedSessions.get(socket) === sessionId) {
|
|
59
|
+
send(socket, event)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
sessionManager.on('exit', (sessionId, tabId, exitCode) => {
|
|
64
|
+
const event: ServerEvent = { payload: { exitCode, tabId }, type: 'tabExit' }
|
|
65
|
+
for (const socket of sockets) {
|
|
66
|
+
if (attachedSessions.get(socket) === sessionId) {
|
|
67
|
+
send(socket, event)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
sessionManager.on('error', (sessionId, tabId, message) => {
|
|
72
|
+
const event: ServerEvent = { payload: { message, tabId }, type: 'tabError' }
|
|
73
|
+
for (const socket of sockets) {
|
|
74
|
+
if (attachedSessions.get(socket) === sessionId) {
|
|
75
|
+
send(socket, event)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const server = createServer((socket) => {
|
|
81
|
+
logDebug('daemon.client.connected')
|
|
82
|
+
sockets.add(socket)
|
|
83
|
+
const decoder = new MessageDecoder<ClientRequest>(parseClientRequest)
|
|
84
|
+
|
|
85
|
+
socket.on('data', (chunk) => {
|
|
86
|
+
try {
|
|
87
|
+
for (const message of decoder.push(chunk)) {
|
|
88
|
+
try {
|
|
89
|
+
logDebug('daemon.request', { id: message.id, type: message.type })
|
|
90
|
+
switch (message.type) {
|
|
91
|
+
case 'attach': {
|
|
92
|
+
attachedSessions.set(socket, message.payload.sessionId)
|
|
93
|
+
sessionManager.resize(
|
|
94
|
+
message.payload.sessionId,
|
|
95
|
+
message.payload.cols,
|
|
96
|
+
message.payload.rows
|
|
97
|
+
)
|
|
98
|
+
const attachResult = sessionManager.attachSession(
|
|
99
|
+
message.payload.sessionId,
|
|
100
|
+
message.payload.workspaceSnapshot
|
|
101
|
+
)
|
|
102
|
+
send(socket, {
|
|
103
|
+
id: message.id,
|
|
104
|
+
payload: { protocolVersion: IPC_PROTOCOL_VERSION, ...attachResult },
|
|
105
|
+
type: 'attachResult',
|
|
106
|
+
})
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
case 'createTab': {
|
|
110
|
+
const sessionId = requireSession(socket, attachedSessions)
|
|
111
|
+
sessionManager.createTab(sessionId, message.payload)
|
|
112
|
+
sendOk(socket, message.id)
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
case 'write': {
|
|
116
|
+
const sessionId = requireSession(socket, attachedSessions)
|
|
117
|
+
sessionManager.write(sessionId, message.payload.tabId, message.payload.data)
|
|
118
|
+
sendOk(socket, message.id)
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
case 'resizeClient': {
|
|
122
|
+
const sessionId = requireSession(socket, attachedSessions)
|
|
123
|
+
sessionManager.resize(sessionId, message.payload.cols, message.payload.rows)
|
|
124
|
+
sendOk(socket, message.id)
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
case 'resizeTab': {
|
|
128
|
+
const sessionId = requireSession(socket, attachedSessions)
|
|
129
|
+
sessionManager.resizeTab(
|
|
130
|
+
sessionId,
|
|
131
|
+
message.payload.tabId,
|
|
132
|
+
message.payload.cols,
|
|
133
|
+
message.payload.rows
|
|
134
|
+
)
|
|
135
|
+
sendOk(socket, message.id)
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
case 'scroll': {
|
|
139
|
+
const sessionId = requireSession(socket, attachedSessions)
|
|
140
|
+
sessionManager.scroll(sessionId, message.payload.tabId, message.payload.deltaLines)
|
|
141
|
+
sendOk(socket, message.id)
|
|
142
|
+
break
|
|
143
|
+
}
|
|
144
|
+
case 'scrollToBottom': {
|
|
145
|
+
const sessionId = requireSession(socket, attachedSessions)
|
|
146
|
+
sessionManager.scrollToBottom(sessionId, message.payload.tabId)
|
|
147
|
+
sendOk(socket, message.id)
|
|
148
|
+
break
|
|
149
|
+
}
|
|
150
|
+
case 'setActiveTab': {
|
|
151
|
+
const sessionId = requireSession(socket, attachedSessions)
|
|
152
|
+
sessionManager.setActiveTab(sessionId, message.payload.tabId)
|
|
153
|
+
sendOk(socket, message.id)
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
case 'closeTab': {
|
|
157
|
+
const sessionId = requireSession(socket, attachedSessions)
|
|
158
|
+
sessionManager.closeTab(sessionId, message.payload.tabId)
|
|
159
|
+
sendOk(socket, message.id)
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
case 'disposeAll': {
|
|
163
|
+
const sessionId = attachedSessions.get(socket)
|
|
164
|
+
if (sessionId) {
|
|
165
|
+
sessionManager.disposeSession(sessionId)
|
|
166
|
+
}
|
|
167
|
+
sendOk(socket, message.id)
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
case 'ping':
|
|
171
|
+
sendOk(socket, message.id)
|
|
172
|
+
break
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
176
|
+
logDebug('daemon.request.error', { error: errorMessage, requestId: message.id })
|
|
177
|
+
send(socket, { id: message.id, payload: { message: errorMessage }, type: 'error' })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
182
|
+
logDebug('daemon.request.error', { error: message })
|
|
183
|
+
decoder.reset()
|
|
184
|
+
send(socket, { id: crypto.randomUUID(), payload: { message }, type: 'error' })
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
socket.on('close', () => {
|
|
189
|
+
logDebug('daemon.client.close')
|
|
190
|
+
sockets.delete(socket)
|
|
191
|
+
attachedSessions.delete(socket)
|
|
192
|
+
})
|
|
193
|
+
socket.on('error', () => {
|
|
194
|
+
logDebug('daemon.client.error')
|
|
195
|
+
sockets.delete(socket)
|
|
196
|
+
attachedSessions.delete(socket)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
await new Promise<void>((resolve, reject) => {
|
|
201
|
+
server.once('error', reject)
|
|
202
|
+
server.listen(socketPath, () => resolve())
|
|
203
|
+
})
|
|
204
|
+
tightenDaemonSocketPermissions(socketPath)
|
|
205
|
+
logDebug('daemon.listening', { socketPath })
|
|
206
|
+
|
|
207
|
+
const gracefulShutdown = (signal: string) => {
|
|
208
|
+
logDebug(`daemon.${signal}`)
|
|
209
|
+
sessionManager.disposeAll()
|
|
210
|
+
server.close()
|
|
211
|
+
process.exit(0)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
process.on('SIGTERM', () => gracefulShutdown('sigterm'))
|
|
215
|
+
process.on('SIGINT', () => gracefulShutdown('sigint'))
|
|
216
|
+
|
|
217
|
+
process.on('uncaughtException', (error) => {
|
|
218
|
+
logDebug('daemon.uncaughtException', { error: error.message, stack: error.stack })
|
|
219
|
+
sessionManager.disposeAll()
|
|
220
|
+
server.close()
|
|
221
|
+
process.exit(1)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
process.on('unhandledRejection', (reason) => {
|
|
225
|
+
const message = reason instanceof Error ? reason.message : String(reason)
|
|
226
|
+
const stack = reason instanceof Error ? reason.stack : undefined
|
|
227
|
+
logDebug('daemon.unhandledRejection', { error: message, stack })
|
|
228
|
+
sessionManager.disposeAll()
|
|
229
|
+
server.close()
|
|
230
|
+
process.exit(1)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
await new Promise<void>(() => {
|
|
234
|
+
// Keep the daemon process alive until it is terminated.
|
|
235
|
+
})
|
|
236
|
+
}
|