@brimveyn/aimux 1.6.2 → 1.7.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-attach-runtime.ts +6 -0
- package/src/app-runtime/backend-runtime-events.ts +21 -21
- package/src/app-runtime/side-effects.ts +24 -12
- package/src/app-runtime/use-backend-runtime.ts +2 -20
- package/src/app-runtime/use-directory-search.ts +6 -1
- package/src/app.tsx +2 -1
- package/src/config.ts +9 -0
- package/src/daemon/daemon.ts +197 -15
- package/src/daemon/session-manager.ts +9 -0
- package/src/daemon/session-registry.ts +5 -5
- package/src/index.tsx +10 -2
- package/src/input/keymap/help-entries.ts +5 -5
- package/src/input/modes/bridge.ts +5 -8
- package/src/input/modes/transitions.ts +15 -23
- package/src/input/modes/types.ts +2 -5
- package/src/ipc/protocol.ts +49 -5
- package/src/platform/project-search.ts +45 -12
- package/src/pty/assistant-status-detection-loop.ts +192 -0
- package/src/pty/assistant-status-detector.ts +226 -0
- package/src/pty/pty-manager.ts +3 -37
- package/src/session-backend/bootstrap.ts +4 -1
- package/src/session-backend/local-session-backend.ts +43 -56
- package/src/session-backend/remote-session-backend.ts +15 -0
- package/src/session-backend/types.ts +10 -1
- package/src/state/reducers/modal-state.ts +91 -134
- package/src/state/reducers/session-state.ts +13 -6
- package/src/state/reducers/tab-state.ts +0 -10
- package/src/state/selectors.ts +12 -0
- package/src/state/session-persistence.ts +20 -15
- package/src/state/store.ts +11 -3
- package/src/state/types.ts +29 -13
- package/src/ui/breaking-update-screen.tsx +31 -0
- package/src/ui/components/bare-input.tsx +44 -0
- package/src/ui/components/create-session-modal.tsx +16 -8
- package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
- package/src/ui/components/diff-renderer/split-view.tsx +11 -17
- package/src/ui/components/diff-renderer/stacked-view.tsx +7 -14
- package/src/ui/components/git-panel.tsx +10 -3
- package/src/ui/components/git-view.tsx +4 -8
- package/src/ui/components/help-modal.tsx +45 -160
- package/src/ui/components/input-field.tsx +6 -2
- package/src/ui/components/list-item.tsx +36 -28
- package/src/ui/components/modal-shell.tsx +51 -13
- package/src/ui/components/new-tab-modal.tsx +78 -45
- package/src/ui/components/picker.tsx +179 -0
- package/src/ui/components/session-bar.tsx +47 -21
- package/src/ui/components/session-picker-modal.tsx +58 -56
- package/src/ui/components/sidebar.tsx +49 -22
- package/src/ui/components/snippet-picker-modal.tsx +43 -34
- package/src/ui/components/status-bar.tsx +3 -2
- package/src/ui/components/surface.tsx +11 -8
- package/src/ui/components/tab-item.tsx +38 -22
- package/src/ui/components/terminal-pane.tsx +8 -8
- package/src/ui/components/theme-picker-modal.tsx +51 -91
- package/src/ui/root.tsx +14 -23
- package/src/ui/status-bar-model.ts +5 -5
- package/src/ui/theme-store.ts +26 -2
- package/src/ui/theme.ts +9 -1
- package/src/ui/components/modal-filter-bar.tsx +0 -19
package/README.md
CHANGED
|
@@ -202,6 +202,9 @@ not collide with a globally installed `aimux` instance.
|
|
|
202
202
|
and focusing on the first change rather than the top of the file.
|
|
203
203
|
- [shiki](https://shiki.style) — syntax highlighting for the git diff view
|
|
204
204
|
and the source of the bundled theme catalog.
|
|
205
|
+
- [herdr](https://github.com/ogulcancelik/herdr) by @ogulcancelik — the
|
|
206
|
+
per-CLI assistant status heuristics (working / waiting-input / idle)
|
|
207
|
+
used in the session bar are adapted from herdr's `detect.rs` rule tables.
|
|
205
208
|
|
|
206
209
|
## License
|
|
207
210
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"generate-themes": "bun run scripts/generate-themes.ts"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@brimveyn/aimux-config": "0.4.
|
|
64
|
+
"@brimveyn/aimux-config": "0.4.3",
|
|
65
65
|
"@opentui/core": "^0.1.90",
|
|
66
66
|
"@opentui/react": "^0.1.90",
|
|
67
67
|
"@xterm/headless": "^6.0.0",
|
|
@@ -48,6 +48,12 @@ function hydrateAttachedSession(
|
|
|
48
48
|
tabs: result.tabs,
|
|
49
49
|
type: 'hydrate-workspace',
|
|
50
50
|
})
|
|
51
|
+
// Dispatch the session-status snapshot *after* hydrate-workspace so
|
|
52
|
+
// sidebar chips reflect per-session state on attach without waiting
|
|
53
|
+
// for the next daemon-side status transition.
|
|
54
|
+
for (const entry of result.initialSessionStatuses) {
|
|
55
|
+
dispatch({ sessionId: entry.sessionId, status: entry.status, type: 'set-session-status' })
|
|
56
|
+
}
|
|
51
57
|
resizeSnapshotPanes(workspaceSnapshot, layoutRef, backend)
|
|
52
58
|
return
|
|
53
59
|
}
|
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
import type { MutableRefObject } from 'react'
|
|
2
2
|
|
|
3
3
|
import type { SessionBackend } from '../session-backend/types'
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
AppAction,
|
|
6
|
+
SessionStatus,
|
|
7
|
+
TabActivity,
|
|
8
|
+
TabSession,
|
|
9
|
+
TerminalModeState,
|
|
10
|
+
} from '../state/types'
|
|
5
11
|
|
|
6
12
|
import { logInputDebug } from '../debug/input-log'
|
|
7
13
|
import { type TabRuntimeTimeouts } from './tab-runtime-timeouts'
|
|
8
14
|
|
|
9
|
-
const IDLE_ACTIVITY_TIMEOUT_MS = 2_000
|
|
10
|
-
|
|
11
15
|
interface BindBackendRuntimeEventsOptions {
|
|
12
16
|
backend: SessionBackend
|
|
13
17
|
dispatch: (action: AppAction) => void
|
|
14
18
|
resizingRef: MutableRefObject<boolean>
|
|
15
|
-
timeouts: Pick<
|
|
16
|
-
TabRuntimeTimeouts,
|
|
17
|
-
| 'clearIdleTimer'
|
|
18
|
-
| 'clearStartupGrace'
|
|
19
|
-
| 'isStartupGraceActive'
|
|
20
|
-
| 'scheduleIdle'
|
|
21
|
-
| 'clearAllTimers'
|
|
22
|
-
>
|
|
19
|
+
timeouts: Pick<TabRuntimeTimeouts, 'clearIdleTimer' | 'clearStartupGrace' | 'clearAllTimers'>
|
|
23
20
|
}
|
|
24
21
|
|
|
25
22
|
function clearTabRuntimeState(
|
|
@@ -58,19 +55,14 @@ export function bindBackendRuntimeEvents({
|
|
|
58
55
|
type: 'replace-tab-viewport',
|
|
59
56
|
viewport,
|
|
60
57
|
})
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
dispatch({ activity: 'busy', tabId, type: 'set-tab-activity' })
|
|
66
|
-
timeouts.scheduleIdle(tabId, IDLE_ACTIVITY_TIMEOUT_MS)
|
|
58
|
+
// Per-tab activity is driven by the backend's status-detection loop via
|
|
59
|
+
// the `tabActivity` event — no client-side idle timer needed.
|
|
67
60
|
}
|
|
68
61
|
|
|
69
62
|
const handleExit = (tabId: string, exitCode: number) => {
|
|
70
63
|
logInputDebug('app.backend.event.exit', { exitCode, tabId })
|
|
71
64
|
clearTabRuntimeState(timeouts, tabId)
|
|
72
|
-
dispatch({
|
|
73
|
-
dispatch({ activity: undefined, tabId, type: 'set-tab-activity' })
|
|
65
|
+
dispatch({ tabId, type: 'close-tab' })
|
|
74
66
|
}
|
|
75
67
|
|
|
76
68
|
const handleError = (tabId: string, message: string) => {
|
|
@@ -79,14 +71,21 @@ export function bindBackendRuntimeEvents({
|
|
|
79
71
|
dispatch({ message, tabId, type: 'set-tab-error' })
|
|
80
72
|
}
|
|
81
73
|
|
|
82
|
-
const handleSessionActivity = (sessionId: string,
|
|
83
|
-
|
|
74
|
+
const handleSessionActivity = (sessionId: string, status: SessionStatus) => {
|
|
75
|
+
logInputDebug('app.backend.event.sessionActivity', { sessionId, status })
|
|
76
|
+
dispatch({ sessionId, status, type: 'set-session-status' })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const handleTabActivity = (tabId: string, activity: TabActivity) => {
|
|
80
|
+
logInputDebug('app.backend.event.tabActivity', { activity, tabId })
|
|
81
|
+
dispatch({ activity, tabId, type: 'set-tab-activity' })
|
|
84
82
|
}
|
|
85
83
|
|
|
86
84
|
backend.on('render', handleRender)
|
|
87
85
|
backend.on('exit', handleExit)
|
|
88
86
|
backend.on('error', handleError)
|
|
89
87
|
backend.on('sessionActivity', handleSessionActivity)
|
|
88
|
+
backend.on('tabActivity', handleTabActivity)
|
|
90
89
|
|
|
91
90
|
return () => {
|
|
92
91
|
timeouts.clearAllTimers()
|
|
@@ -94,6 +93,7 @@ export function bindBackendRuntimeEvents({
|
|
|
94
93
|
backend.off('exit', handleExit)
|
|
95
94
|
backend.off('error', handleError)
|
|
96
95
|
backend.off('sessionActivity', handleSessionActivity)
|
|
96
|
+
backend.off('tabActivity', handleTabActivity)
|
|
97
97
|
void backend.destroy(true)
|
|
98
98
|
}
|
|
99
99
|
}
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
type SplitDirection,
|
|
25
25
|
splitNode,
|
|
26
26
|
} from '../state/layout-tree'
|
|
27
|
-
import { filterSessions, filterSnippets } from '../state/selectors'
|
|
27
|
+
import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
|
|
28
28
|
import { createDefaultTerminalModes } from '../state/terminal-modes'
|
|
29
29
|
import {
|
|
30
30
|
type AppAction,
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
import { saveCurrentWorkspace } from '../state/workspace-save'
|
|
37
37
|
import { filterThemeIds } from '../ui/filter-themes'
|
|
38
38
|
import { scrollGitDiff } from '../ui/git-view-controls'
|
|
39
|
-
import { applyTheme } from '../ui/theme'
|
|
39
|
+
import { applyTheme, getTransparent, setTransparent } from '../ui/theme'
|
|
40
40
|
import { type ThemeId } from '../ui/themes'
|
|
41
41
|
import {
|
|
42
42
|
handleCreateSessionEffect,
|
|
@@ -68,9 +68,10 @@ export interface SideEffectContext {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function getSelectedAssistantOption(state: AppState) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
71
|
+
const all = getAllAssistantOptions(state.customCommands)
|
|
72
|
+
const filter = state.modal.type === 'new-tab' ? state.modal.editBuffer : null
|
|
73
|
+
const list = filterAssistants(all, filter)
|
|
74
|
+
return list[state.modal.selectedIndex] ?? list[0] ?? getAssistantOption(0)
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
function handleSessionSelection(ctx: SideEffectContext): void {
|
|
@@ -87,7 +88,7 @@ function handleSessionSelection(ctx: SideEffectContext): void {
|
|
|
87
88
|
return
|
|
88
89
|
}
|
|
89
90
|
|
|
90
|
-
dispatch({ type: 'open-create-session-modal' })
|
|
91
|
+
dispatch({ returnToSessionPicker: true, type: 'open-create-session-modal' })
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
function handleSelectedSessionDelete(ctx: SideEffectContext): void {
|
|
@@ -143,24 +144,28 @@ function pasteSnippetToActiveGroup(ctx: SideEffectContext): void {
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
function saveCustomCommandSelection(
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
147
|
+
function saveCustomCommandSelection(ctx: SideEffectContext): void {
|
|
148
|
+
const { dispatch, state } = ctx
|
|
149
|
+
if (state.modal.type !== 'new-tab' || state.modal.editingCommand === null) {
|
|
149
150
|
return
|
|
150
151
|
}
|
|
152
|
+
const assistantId = state.modal.editingCommand
|
|
153
|
+
if (state.modal.editBuffer === null) return
|
|
151
154
|
|
|
152
155
|
const trimmed = state.modal.editBuffer.trim()
|
|
153
156
|
const newCustomCommands = { ...state.customCommands }
|
|
154
157
|
if (trimmed) {
|
|
155
|
-
newCustomCommands[
|
|
158
|
+
newCustomCommands[assistantId] = trimmed
|
|
156
159
|
} else {
|
|
157
|
-
delete newCustomCommands[
|
|
160
|
+
delete newCustomCommands[assistantId]
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
saveConfig({
|
|
161
164
|
...loadConfig(),
|
|
162
165
|
customCommands: newCustomCommands,
|
|
163
166
|
})
|
|
167
|
+
dispatch({ customCommands: newCustomCommands, type: 'set-custom-commands' })
|
|
168
|
+
dispatch({ type: 'cancel-command-edit' })
|
|
164
169
|
}
|
|
165
170
|
|
|
166
171
|
function applyThemeEffect(
|
|
@@ -299,6 +304,7 @@ function launchAssistant(ctx: SideEffectContext, assistant: AssistantId): void {
|
|
|
299
304
|
tabId: tab.id,
|
|
300
305
|
})
|
|
301
306
|
dispatch({ tab, type: 'add-tab' })
|
|
307
|
+
dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
|
|
302
308
|
startTabSession(
|
|
303
309
|
backend,
|
|
304
310
|
dispatch,
|
|
@@ -435,7 +441,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
435
441
|
return
|
|
436
442
|
}
|
|
437
443
|
case 'save-custom-command': {
|
|
438
|
-
saveCustomCommandSelection(
|
|
444
|
+
saveCustomCommandSelection(ctx)
|
|
439
445
|
return
|
|
440
446
|
}
|
|
441
447
|
case 'apply-theme': {
|
|
@@ -546,6 +552,12 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
546
552
|
handleSwitchSessionByIndex(ctx, effect.index)
|
|
547
553
|
return
|
|
548
554
|
}
|
|
555
|
+
case 'toggle-transparent': {
|
|
556
|
+
const next = !getTransparent()
|
|
557
|
+
setTransparent(next)
|
|
558
|
+
saveConfig({ ...loadConfig(), themeTransparent: next })
|
|
559
|
+
return
|
|
560
|
+
}
|
|
549
561
|
default:
|
|
550
562
|
effect satisfies never
|
|
551
563
|
}
|
|
@@ -36,14 +36,7 @@ export function useBackendRuntime({
|
|
|
36
36
|
}: BackendRuntimeOptions): TabRuntimeControls {
|
|
37
37
|
const attachRequestIdRef = useRef(0)
|
|
38
38
|
const timeouts = useTabRuntimeTimeouts(dispatch)
|
|
39
|
-
const {
|
|
40
|
-
clearAllTimers,
|
|
41
|
-
clearIdleTimer,
|
|
42
|
-
clearStartupGrace,
|
|
43
|
-
isStartupGraceActive,
|
|
44
|
-
scheduleIdle,
|
|
45
|
-
startStartupGrace,
|
|
46
|
-
} = timeouts
|
|
39
|
+
const { clearAllTimers, clearIdleTimer, clearStartupGrace, startStartupGrace } = timeouts
|
|
47
40
|
|
|
48
41
|
useEffect(() => {
|
|
49
42
|
if (!currentSessionId) {
|
|
@@ -81,20 +74,9 @@ export function useBackendRuntime({
|
|
|
81
74
|
clearAllTimers,
|
|
82
75
|
clearIdleTimer,
|
|
83
76
|
clearStartupGrace,
|
|
84
|
-
isStartupGraceActive,
|
|
85
|
-
scheduleIdle,
|
|
86
77
|
},
|
|
87
78
|
})
|
|
88
|
-
}, [
|
|
89
|
-
backend,
|
|
90
|
-
clearAllTimers,
|
|
91
|
-
clearIdleTimer,
|
|
92
|
-
clearStartupGrace,
|
|
93
|
-
dispatch,
|
|
94
|
-
isStartupGraceActive,
|
|
95
|
-
resizingRef,
|
|
96
|
-
scheduleIdle,
|
|
97
|
-
])
|
|
79
|
+
}, [backend, clearAllTimers, clearIdleTimer, clearStartupGrace, dispatch, resizingRef])
|
|
98
80
|
|
|
99
81
|
return {
|
|
100
82
|
clearIdleTimer,
|
|
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
|
|
|
2
2
|
|
|
3
3
|
import type { AppAction, ModalState } from '../state/types'
|
|
4
4
|
|
|
5
|
-
import { searchProjectDirectories } from '../platform/project-search'
|
|
5
|
+
import { searchProjectDirectories, warmDirectoryCache } from '../platform/project-search'
|
|
6
6
|
|
|
7
7
|
const DEFAULT_DIRECTORY_SEARCH_DEBOUNCE_MS = 200
|
|
8
8
|
|
|
@@ -25,6 +25,11 @@ export function useDirectorySearch(
|
|
|
25
25
|
): void {
|
|
26
26
|
const directoryQuery = getDirectoryQuery(modal)
|
|
27
27
|
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (modal.type !== 'create-session') return
|
|
30
|
+
void warmDirectoryCache()
|
|
31
|
+
}, [modal.type])
|
|
32
|
+
|
|
28
33
|
useEffect(() => {
|
|
29
34
|
if (modal.type !== 'create-session') {
|
|
30
35
|
return
|
package/src/app.tsx
CHANGED
|
@@ -37,7 +37,7 @@ import { loadSnippetCatalog } from './state/snippet-catalog'
|
|
|
37
37
|
import { appReducer, createInitialState } from './state/store'
|
|
38
38
|
import { KeymapContext } from './ui/keymap-context'
|
|
39
39
|
import { RootView } from './ui/root'
|
|
40
|
-
import { applyTheme } from './ui/theme'
|
|
40
|
+
import { applyTheme, setTransparent } from './ui/theme'
|
|
41
41
|
import { isKnownThemeId, registerUserThemes, type ThemeId } from './ui/themes'
|
|
42
42
|
import {
|
|
43
43
|
fetchLatestNpmVersion,
|
|
@@ -75,6 +75,7 @@ export function App({
|
|
|
75
75
|
: undefined
|
|
76
76
|
const initial = persisted ?? fromConfig ?? 'aimux'
|
|
77
77
|
applyTheme(initial)
|
|
78
|
+
setTransparent(config.themeTransparent ?? false)
|
|
78
79
|
return initial
|
|
79
80
|
})
|
|
80
81
|
const [state, dispatch] = useReducer(appReducer, undefined, () => {
|
package/src/config.ts
CHANGED
|
@@ -37,6 +37,7 @@ export interface AimuxConfig {
|
|
|
37
37
|
version: 2
|
|
38
38
|
customCommands: Record<string, string>
|
|
39
39
|
themeId?: ThemeId
|
|
40
|
+
themeTransparent?: boolean
|
|
40
41
|
gitPane?: PersistedGitPane
|
|
41
42
|
sessionBarVisible?: boolean
|
|
42
43
|
sessionBarPosition?: SessionBarPosition
|
|
@@ -126,6 +127,7 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
126
127
|
version?: number
|
|
127
128
|
customCommands?: unknown
|
|
128
129
|
themeId?: unknown
|
|
130
|
+
themeTransparent?: unknown
|
|
129
131
|
gitPane?: unknown
|
|
130
132
|
gitPanelVisible?: unknown
|
|
131
133
|
gitPanelRatio?: unknown
|
|
@@ -149,6 +151,12 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
149
151
|
issues.push('ignored invalid themeId')
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
const validThemeTransparent =
|
|
155
|
+
typeof parsed.themeTransparent === 'boolean' ? parsed.themeTransparent : undefined
|
|
156
|
+
if (parsed.themeTransparent !== undefined && validThemeTransparent === undefined) {
|
|
157
|
+
issues.push('ignored invalid themeTransparent')
|
|
158
|
+
}
|
|
159
|
+
|
|
152
160
|
let validGitPane = isPersistedGitPane(parsed.gitPane) ? parsed.gitPane : undefined
|
|
153
161
|
if (parsed.gitPane !== undefined && validGitPane === undefined) {
|
|
154
162
|
issues.push('ignored invalid gitPane')
|
|
@@ -218,6 +226,7 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
218
226
|
sessionBarVisible: validSessionBarVisible,
|
|
219
227
|
skippedUpdateVersion: validSkippedUpdateVersion,
|
|
220
228
|
themeId: migrateThemeId(parsed.themeId),
|
|
229
|
+
themeTransparent: validThemeTransparent,
|
|
221
230
|
version: 2,
|
|
222
231
|
workspaceSnapshot: isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
|
|
223
232
|
? parsed.workspaceSnapshot
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { connect, createServer, type Socket } from 'node:net'
|
|
2
2
|
|
|
3
|
+
import type { AssistantId, TerminalSnapshot } from '../state/types'
|
|
4
|
+
|
|
3
5
|
import { logDebug } from '../debug/input-log'
|
|
4
6
|
import {
|
|
5
7
|
type ClientRequest,
|
|
@@ -14,6 +16,7 @@ import {
|
|
|
14
16
|
type ServerResponse,
|
|
15
17
|
} from '../ipc/protocol'
|
|
16
18
|
import { findSocketProcessPid, spawnDetachedTerminalManager } from '../platform/daemon-control'
|
|
19
|
+
import { type LoopTabView, runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
|
|
17
20
|
import { TerminalManagerClient } from '../terminal-manager/manager-client'
|
|
18
21
|
import {
|
|
19
22
|
getIpcDaemonSocketPath,
|
|
@@ -24,6 +27,53 @@ import {
|
|
|
24
27
|
tightenSocketPermissions,
|
|
25
28
|
} from './runtime-paths'
|
|
26
29
|
|
|
30
|
+
export interface DaemonTabEntry {
|
|
31
|
+
sessionId: string
|
|
32
|
+
assistant: AssistantId
|
|
33
|
+
command: string
|
|
34
|
+
viewport: TerminalSnapshot | undefined
|
|
35
|
+
/**
|
|
36
|
+
* Monotonic viewport sequence. Incremented on every render-event write so
|
|
37
|
+
* stale writers (e.g. `attach` returning a pre-await snapshot) can refuse
|
|
38
|
+
* to clobber a fresher viewport that arrived via `render` while the
|
|
39
|
+
* `attachSession` call was still in flight.
|
|
40
|
+
*/
|
|
41
|
+
viewportSeq: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Merges an attach-time tab view into the registry without clobbering a
|
|
46
|
+
* viewport that arrived via a render event during the `attachSession` await.
|
|
47
|
+
*
|
|
48
|
+
* Returns the updated entry (also written into `registry`). `seq` is a
|
|
49
|
+
* monotonic counter scoped to the registry; pass the same counter into every
|
|
50
|
+
* call so the new entry slot gets a stable ordinal when we first observe it.
|
|
51
|
+
*
|
|
52
|
+
* Exported for unit tests — the attach path in `runDaemon` uses the same
|
|
53
|
+
* helper through a closure that threads the counter.
|
|
54
|
+
*/
|
|
55
|
+
export function mergeTabRegistryEntry(
|
|
56
|
+
registry: Map<string, DaemonTabEntry>,
|
|
57
|
+
sessionId: string,
|
|
58
|
+
tabId: string,
|
|
59
|
+
assistant: AssistantId,
|
|
60
|
+
command: string,
|
|
61
|
+
initialViewport: TerminalSnapshot | undefined,
|
|
62
|
+
allocateSeq: () => number
|
|
63
|
+
): DaemonTabEntry {
|
|
64
|
+
const existing = registry.get(tabId)
|
|
65
|
+
const viewport = existing?.viewport ?? initialViewport
|
|
66
|
+
const entry: DaemonTabEntry = {
|
|
67
|
+
assistant,
|
|
68
|
+
command,
|
|
69
|
+
sessionId,
|
|
70
|
+
viewport,
|
|
71
|
+
viewportSeq: existing?.viewportSeq ?? (viewport ? allocateSeq() : 0),
|
|
72
|
+
}
|
|
73
|
+
registry.set(tabId, entry)
|
|
74
|
+
return entry
|
|
75
|
+
}
|
|
76
|
+
|
|
27
77
|
function send(socket: Socket, message: ServerResponse | ServerEvent): void {
|
|
28
78
|
socket.write(encodeMessage(message))
|
|
29
79
|
}
|
|
@@ -115,37 +165,121 @@ export async function runDaemon(): Promise<void> {
|
|
|
115
165
|
const attachedSessions = new Map<Socket, string>()
|
|
116
166
|
const negotiatedVersions = new Map<Socket, number>()
|
|
117
167
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
168
|
+
// Per-tab registry so the status-detection loop can poll every terminal
|
|
169
|
+
// continuously, not just the one the UI is currently attached to.
|
|
170
|
+
const tabRegistry = new Map<string, DaemonTabEntry>()
|
|
171
|
+
let nextViewportSeq = 1
|
|
172
|
+
|
|
173
|
+
const allocateSeq = (): number => nextViewportSeq++
|
|
174
|
+
const rememberTab = (
|
|
175
|
+
sessionId: string,
|
|
176
|
+
tabId: string,
|
|
177
|
+
assistant: AssistantId,
|
|
178
|
+
command: string,
|
|
179
|
+
initialViewport?: TerminalSnapshot
|
|
180
|
+
): void => {
|
|
181
|
+
const before = tabRegistry.get(tabId)
|
|
182
|
+
const entry = mergeTabRegistryEntry(
|
|
183
|
+
tabRegistry,
|
|
184
|
+
sessionId,
|
|
185
|
+
tabId,
|
|
186
|
+
assistant,
|
|
187
|
+
command,
|
|
188
|
+
initialViewport,
|
|
189
|
+
allocateSeq
|
|
190
|
+
)
|
|
191
|
+
logDebug('daemon.rememberTab', {
|
|
192
|
+
hadExistingEntry: before !== undefined,
|
|
193
|
+
hadExistingViewport: before?.viewport !== undefined,
|
|
194
|
+
initialViewportProvided: initialViewport !== undefined,
|
|
195
|
+
preservedExistingViewport:
|
|
196
|
+
before?.viewport !== undefined && entry.viewport === before.viewport,
|
|
197
|
+
resultSeq: entry.viewportSeq,
|
|
121
198
|
sessionId,
|
|
122
199
|
tabId,
|
|
123
|
-
viewportLines: viewport.lines.length,
|
|
124
200
|
})
|
|
125
|
-
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const broadcastAll = (event: ServerEvent): void => {
|
|
204
|
+
for (const socket of sockets) {
|
|
205
|
+
send(socket, event)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const broadcastForSession = (sessionId: string, event: ServerEvent): void => {
|
|
126
210
|
for (const socket of sockets) {
|
|
127
211
|
if (attachedSessions.get(socket) === sessionId) {
|
|
128
212
|
send(socket, event)
|
|
129
213
|
}
|
|
130
214
|
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
manager.on('render', (sessionId, tabId, viewport, terminalModes) => {
|
|
218
|
+
const existing = tabRegistry.get(tabId)
|
|
219
|
+
let newSeq: number | null = null
|
|
220
|
+
if (existing) {
|
|
221
|
+
existing.viewport = viewport
|
|
222
|
+
existing.viewportSeq = nextViewportSeq++
|
|
223
|
+
existing.sessionId = sessionId
|
|
224
|
+
newSeq = existing.viewportSeq
|
|
225
|
+
}
|
|
226
|
+
logDebug('daemon.manager.render', {
|
|
227
|
+
attachedSocketCount: sockets.size,
|
|
228
|
+
hadRegistryEntry: existing !== undefined,
|
|
229
|
+
newSeq,
|
|
230
|
+
sessionId,
|
|
231
|
+
tabId,
|
|
232
|
+
viewportLines: viewport.lines.length,
|
|
233
|
+
})
|
|
234
|
+
const event: ServerEvent = { payload: { tabId, terminalModes, viewport }, type: 'tabRender' }
|
|
235
|
+
broadcastForSession(sessionId, event)
|
|
131
236
|
})
|
|
132
237
|
manager.on('exit', (sessionId, tabId, exitCode) => {
|
|
133
238
|
logDebug('daemon.manager.exit', { exitCode, sessionId, tabId })
|
|
239
|
+
tabRegistry.delete(tabId)
|
|
134
240
|
const event: ServerEvent = { payload: { exitCode, tabId }, type: 'tabExit' }
|
|
135
|
-
|
|
136
|
-
if (attachedSessions.get(socket) === sessionId) {
|
|
137
|
-
send(socket, event)
|
|
138
|
-
}
|
|
139
|
-
}
|
|
241
|
+
broadcastForSession(sessionId, event)
|
|
140
242
|
})
|
|
141
243
|
manager.on('error', (sessionId, tabId, message) => {
|
|
142
244
|
logDebug('daemon.manager.error', { message, sessionId, tabId })
|
|
143
245
|
const event: ServerEvent = { payload: { message, tabId }, type: 'tabError' }
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
246
|
+
broadcastForSession(sessionId, event)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const listTabsForSession = (sessionId: string): LoopTabView[] => {
|
|
250
|
+
const result: LoopTabView[] = []
|
|
251
|
+
for (const [tabId, entry] of tabRegistry) {
|
|
252
|
+
if (entry.sessionId !== sessionId) continue
|
|
253
|
+
result.push({
|
|
254
|
+
assistant: entry.assistant,
|
|
255
|
+
command: entry.command,
|
|
256
|
+
id: tabId,
|
|
257
|
+
viewport: entry.viewport,
|
|
258
|
+
})
|
|
148
259
|
}
|
|
260
|
+
return result
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const statusLoop = runStatusDetectionLoop({
|
|
264
|
+
listSessions: () => {
|
|
265
|
+
const seen = new Set<string>()
|
|
266
|
+
for (const entry of tabRegistry.values()) seen.add(entry.sessionId)
|
|
267
|
+
return [...seen]
|
|
268
|
+
},
|
|
269
|
+
listTabs: listTabsForSession,
|
|
270
|
+
onSessionStatus: (sessionId, status) => {
|
|
271
|
+
logDebug('daemon.status.session', { sessionId, status })
|
|
272
|
+
broadcastAll({ payload: { sessionId, status }, type: 'sessionStatus' })
|
|
273
|
+
},
|
|
274
|
+
onTabStatus: (tabId, status, sessionId) => {
|
|
275
|
+
logDebug('daemon.status.tab', { sessionId, status, tabId })
|
|
276
|
+
// Broadcast to every client. Clients silently ignore events for tabIds
|
|
277
|
+
// they don't know about, so there's no UI impact — this costs one
|
|
278
|
+
// extra socket write per tab per change, which is trivial, and
|
|
279
|
+
// removes a race where in-flight tab events could be dropped when a
|
|
280
|
+
// client tears down its socket to switch sessions.
|
|
281
|
+
broadcastAll({ payload: { sessionId, status, tabId }, type: 'tabStatus' })
|
|
282
|
+
},
|
|
149
283
|
})
|
|
150
284
|
|
|
151
285
|
const server = createServer((socket) => {
|
|
@@ -213,12 +347,49 @@ export async function runDaemon(): Promise<void> {
|
|
|
213
347
|
sessionId: message.payload.sessionId,
|
|
214
348
|
workspaceSnapshot: message.payload.workspaceSnapshot,
|
|
215
349
|
})
|
|
350
|
+
for (const tab of attachResult.tabs) {
|
|
351
|
+
rememberTab(
|
|
352
|
+
message.payload.sessionId,
|
|
353
|
+
tab.id,
|
|
354
|
+
tab.assistant,
|
|
355
|
+
tab.command,
|
|
356
|
+
tab.viewport
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
// Classify synchronously BEFORE sending attachResult so
|
|
360
|
+
// each tab's activity and the full session-status snapshot
|
|
361
|
+
// are available to embed in the reply. This makes the
|
|
362
|
+
// client apply them atomically with `hydrate-workspace`
|
|
363
|
+
// and closes the race where separate `tabStatus` events
|
|
364
|
+
// landed before the tab existed in client state.
|
|
365
|
+
const tabsForLoop = listTabsForSession(message.payload.sessionId)
|
|
366
|
+
logDebug('daemon.attach.preClassify', {
|
|
367
|
+
sessionId: message.payload.sessionId,
|
|
368
|
+
tabs: tabsForLoop.map((t) => ({
|
|
369
|
+
assistant: t.assistant,
|
|
370
|
+
hasViewport: t.viewport !== undefined,
|
|
371
|
+
id: t.id,
|
|
372
|
+
seq: tabRegistry.get(t.id)?.viewportSeq ?? null,
|
|
373
|
+
})),
|
|
374
|
+
})
|
|
375
|
+
statusLoop.classifyNow(message.payload.sessionId, tabsForLoop)
|
|
376
|
+
const tabsWithActivity = attachResult.tabs.map((tab) => ({
|
|
377
|
+
...tab,
|
|
378
|
+
activity: statusLoop.getTabStatus(tab.id) ?? tab.activity,
|
|
379
|
+
}))
|
|
380
|
+
const initialSessionStatuses = statusLoop.snapshotSessions()
|
|
381
|
+
logDebug('daemon.attach.replay', {
|
|
382
|
+
sessionId: message.payload.sessionId,
|
|
383
|
+
sessions: initialSessionStatuses,
|
|
384
|
+
tabs: tabsWithActivity.map((t) => ({ activity: t.activity, id: t.id })),
|
|
385
|
+
})
|
|
216
386
|
send(socket, {
|
|
217
387
|
id: message.id,
|
|
218
388
|
payload: {
|
|
219
389
|
activeTabId: attachResult.activeTabId,
|
|
390
|
+
initialSessionStatuses,
|
|
220
391
|
protocolVersion: negotiatedVersion,
|
|
221
|
-
tabs:
|
|
392
|
+
tabs: tabsWithActivity,
|
|
222
393
|
},
|
|
223
394
|
type: 'attachResult',
|
|
224
395
|
})
|
|
@@ -238,6 +409,12 @@ export async function runDaemon(): Promise<void> {
|
|
|
238
409
|
tabId: message.payload.tabId,
|
|
239
410
|
title: message.payload.title,
|
|
240
411
|
})
|
|
412
|
+
rememberTab(
|
|
413
|
+
sessionId,
|
|
414
|
+
message.payload.tabId,
|
|
415
|
+
message.payload.assistant,
|
|
416
|
+
[message.payload.command, ...(message.payload.args ?? [])].join(' ')
|
|
417
|
+
)
|
|
241
418
|
await manager.createTab({ ...message.payload, sessionId })
|
|
242
419
|
sendOk(socket, message.id)
|
|
243
420
|
logDebug('daemon.request.createTab.success', {
|
|
@@ -313,6 +490,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
313
490
|
case 'closeTab': {
|
|
314
491
|
const sessionId = requireSession(socket, attachedSessions)
|
|
315
492
|
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
493
|
+
tabRegistry.delete(message.payload.tabId)
|
|
316
494
|
await manager.closeTab(sessionId, message.payload.tabId)
|
|
317
495
|
sendOk(socket, message.id)
|
|
318
496
|
break
|
|
@@ -321,6 +499,9 @@ export async function runDaemon(): Promise<void> {
|
|
|
321
499
|
const sessionId = attachedSessions.get(socket)
|
|
322
500
|
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
323
501
|
if (sessionId) {
|
|
502
|
+
for (const [tabId, entry] of tabRegistry) {
|
|
503
|
+
if (entry.sessionId === sessionId) tabRegistry.delete(tabId)
|
|
504
|
+
}
|
|
324
505
|
await manager.disposeSession(sessionId)
|
|
325
506
|
}
|
|
326
507
|
sendOk(socket, message.id)
|
|
@@ -371,6 +552,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
371
552
|
|
|
372
553
|
const gracefulShutdown = (signal: string) => {
|
|
373
554
|
logDebug(`daemon.${signal}`)
|
|
555
|
+
statusLoop.stop()
|
|
374
556
|
manager.destroy()
|
|
375
557
|
server.close()
|
|
376
558
|
process.exit(0)
|
|
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'
|
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
4
|
ScrollIntent,
|
|
5
|
+
TabSession,
|
|
5
6
|
TerminalModeState,
|
|
6
7
|
TerminalSnapshot,
|
|
7
8
|
WorkspaceSnapshotV1,
|
|
@@ -128,4 +129,12 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
|
|
|
128
129
|
}
|
|
129
130
|
this.registries.clear()
|
|
130
131
|
}
|
|
132
|
+
|
|
133
|
+
listSessionIds(): string[] {
|
|
134
|
+
return [...this.registries.keys()]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
listTabs(sessionId: string): TabSession[] {
|
|
138
|
+
return this.registries.get(sessionId)?.listTabs() ?? []
|
|
139
|
+
}
|
|
131
140
|
}
|
|
@@ -44,13 +44,13 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
|
|
|
44
44
|
this.emit('render', tabId, viewport, terminalModes)
|
|
45
45
|
})
|
|
46
46
|
this.ptyManager.on('exit', (tabId, exitCode) => {
|
|
47
|
-
|
|
48
|
-
if (!tab) {
|
|
47
|
+
if (!this.tabs.has(tabId)) {
|
|
49
48
|
return
|
|
50
49
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
this.tabs.delete(tabId)
|
|
51
|
+
if (this.activeTabId === tabId) {
|
|
52
|
+
this.activeTabId = this.listTabs()[0]?.id ?? null
|
|
53
|
+
}
|
|
54
54
|
logDebug('daemon.registry.exit', { exitCode, tabId })
|
|
55
55
|
this.emit('exit', tabId, exitCode)
|
|
56
56
|
})
|