@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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assistant status detection.
|
|
3
|
+
*
|
|
4
|
+
* Per-CLI content heuristics are adapted from herdr by @ogulcancelik
|
|
5
|
+
* (https://github.com/ogulcancelik/herdr, MIT). Rule tables trace back to
|
|
6
|
+
* `src/detect.rs` in that repo.
|
|
7
|
+
*
|
|
8
|
+
* The detector classifies a terminal session as `working`, `waiting-input`,
|
|
9
|
+
* or `idle`. Built-in CLIs (claude, codex, opencode) use per-CLI substring
|
|
10
|
+
* tables. Custom CLIs fall back to a generic heuristic that (a) recognises
|
|
11
|
+
* common shells as always-idle and (b) uses pane-tail change velocity plus
|
|
12
|
+
* generic y/n / confirm prompt patterns.
|
|
13
|
+
*/
|
|
14
|
+
import type { AssistantId, TabActivity, TerminalSnapshot } from '../state/types'
|
|
15
|
+
|
|
16
|
+
import { getLineText } from '../input/terminal-text-extraction'
|
|
17
|
+
|
|
18
|
+
const TAIL_LINE_COUNT = 10
|
|
19
|
+
const ACTIVE_CHANGE_WINDOW_MS = 600
|
|
20
|
+
|
|
21
|
+
/** Spinner glyphs used by claude code's status lines. */
|
|
22
|
+
const CLAUDE_SPINNER_GLYPHS = '·✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆'
|
|
23
|
+
|
|
24
|
+
const SHELL_COMMAND_PATTERN =
|
|
25
|
+
/(^|\/)(bash|zsh|fish|sh|dash|ash|ksh|tcsh|csh|nu|pwsh|powershell|elvish|xonsh)(\.exe)?$/i
|
|
26
|
+
|
|
27
|
+
interface DetectorEntry {
|
|
28
|
+
tail: string
|
|
29
|
+
changedAt: number
|
|
30
|
+
status: TabActivity
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DetectStatusInput {
|
|
34
|
+
tabId: string
|
|
35
|
+
assistant: AssistantId
|
|
36
|
+
/** The raw command string (first token matters for shell detection). */
|
|
37
|
+
command?: string
|
|
38
|
+
viewport: TerminalSnapshot | undefined
|
|
39
|
+
/** Override the clock for tests. Defaults to Date.now. */
|
|
40
|
+
now?: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class AssistantStatusDetector {
|
|
44
|
+
private readonly entries = new Map<string, DetectorEntry>()
|
|
45
|
+
|
|
46
|
+
classify(input: DetectStatusInput): TabActivity {
|
|
47
|
+
const { assistant, command, tabId, viewport } = input
|
|
48
|
+
const now = input.now ?? Date.now()
|
|
49
|
+
|
|
50
|
+
if (!viewport) return this.remember(tabId, '', now, 'idle')
|
|
51
|
+
|
|
52
|
+
const tail = extractTailText(viewport, TAIL_LINE_COUNT)
|
|
53
|
+
const prev = this.entries.get(tabId)
|
|
54
|
+
const changedAt = prev && prev.tail === tail ? prev.changedAt : now
|
|
55
|
+
const haystack = tail.toLowerCase()
|
|
56
|
+
|
|
57
|
+
if (assistant === 'terminal' || isShellCommand(command)) {
|
|
58
|
+
return this.remember(tabId, tail, changedAt, 'idle')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const perCli = classifyBuiltin(assistant, haystack, tail)
|
|
62
|
+
if (perCli) return this.remember(tabId, tail, changedAt, perCli)
|
|
63
|
+
|
|
64
|
+
const generic = classifyGeneric(haystack, changedAt, now)
|
|
65
|
+
return this.remember(tabId, tail, changedAt, generic)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
forget(tabId: string): void {
|
|
69
|
+
this.entries.delete(tabId)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
clear(): void {
|
|
73
|
+
this.entries.clear()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private remember(
|
|
77
|
+
tabId: string,
|
|
78
|
+
tail: string,
|
|
79
|
+
changedAt: number,
|
|
80
|
+
status: TabActivity
|
|
81
|
+
): TabActivity {
|
|
82
|
+
this.entries.set(tabId, { changedAt, status, tail })
|
|
83
|
+
return status
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractTailText(viewport: TerminalSnapshot, lineCount: number): string {
|
|
88
|
+
// Full-screen TUIs (claude, opencode) paint in the alternate buffer and
|
|
89
|
+
// often leave the last rows blank, putting their status bar higher up.
|
|
90
|
+
// Skip trailing blank rows before taking the last `lineCount`.
|
|
91
|
+
const lines = viewport.lines
|
|
92
|
+
let end = lines.length
|
|
93
|
+
while (end > 0) {
|
|
94
|
+
const line = lines[end - 1]
|
|
95
|
+
if (!line) {
|
|
96
|
+
end--
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
const text = getLineText(line).trim()
|
|
100
|
+
if (text.length === 0) {
|
|
101
|
+
end--
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
const start = Math.max(0, end - lineCount)
|
|
107
|
+
const parts: string[] = []
|
|
108
|
+
for (let i = start; i < end; i++) {
|
|
109
|
+
const line = lines[i]
|
|
110
|
+
if (!line) continue
|
|
111
|
+
parts.push(getLineText(line).replace(/\s+$/u, ''))
|
|
112
|
+
}
|
|
113
|
+
return parts.join('\n')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function classifyBuiltin(
|
|
117
|
+
assistant: AssistantId,
|
|
118
|
+
haystack: string,
|
|
119
|
+
rawTail: string
|
|
120
|
+
): TabActivity | null {
|
|
121
|
+
switch (assistant) {
|
|
122
|
+
case 'claude':
|
|
123
|
+
return classifyClaude(haystack, rawTail)
|
|
124
|
+
case 'codex':
|
|
125
|
+
return classifyCodex(haystack)
|
|
126
|
+
case 'opencode':
|
|
127
|
+
return classifyOpencode(haystack)
|
|
128
|
+
default:
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function classifyClaude(haystack: string, rawTail: string): TabActivity {
|
|
134
|
+
if (
|
|
135
|
+
haystack.includes('do you want') ||
|
|
136
|
+
haystack.includes('would you like') ||
|
|
137
|
+
haystack.includes('tab to amend') ||
|
|
138
|
+
haystack.includes('enter to select') ||
|
|
139
|
+
(haystack.includes('esc to cancel') && haystack.includes('to navigate'))
|
|
140
|
+
) {
|
|
141
|
+
return 'waiting-input'
|
|
142
|
+
}
|
|
143
|
+
if (
|
|
144
|
+
haystack.includes('esc/ctrl+c to interrupt') ||
|
|
145
|
+
haystack.includes('esc to interrupt') ||
|
|
146
|
+
haystack.includes('ctrl+c to interrupt') ||
|
|
147
|
+
haystack.includes('esc interrupt')
|
|
148
|
+
) {
|
|
149
|
+
return 'working'
|
|
150
|
+
}
|
|
151
|
+
if (hasClaudeSpinner(rawTail)) return 'working'
|
|
152
|
+
return 'idle'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function hasClaudeSpinner(rawTail: string): boolean {
|
|
156
|
+
if (!rawTail.includes('…') && !rawTail.includes('...')) return false
|
|
157
|
+
for (const ch of CLAUDE_SPINNER_GLYPHS) {
|
|
158
|
+
if (rawTail.includes(ch)) return true
|
|
159
|
+
}
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function classifyCodex(haystack: string): TabActivity {
|
|
164
|
+
if (
|
|
165
|
+
haystack.includes('press enter to confirm') ||
|
|
166
|
+
haystack.includes('[y/n]') ||
|
|
167
|
+
haystack.includes('enter to submit answer')
|
|
168
|
+
) {
|
|
169
|
+
return 'waiting-input'
|
|
170
|
+
}
|
|
171
|
+
if (haystack.includes('esc to interrupt') || haystack.includes('• working (')) {
|
|
172
|
+
return 'working'
|
|
173
|
+
}
|
|
174
|
+
return 'idle'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function classifyOpencode(haystack: string): TabActivity {
|
|
178
|
+
if (
|
|
179
|
+
haystack.includes('permission required') ||
|
|
180
|
+
haystack.includes('△ permission') ||
|
|
181
|
+
(haystack.includes('enter submit') && haystack.includes('esc dismiss'))
|
|
182
|
+
) {
|
|
183
|
+
return 'waiting-input'
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
haystack.includes('esc interrupt') ||
|
|
187
|
+
haystack.includes('esc to interrupt') ||
|
|
188
|
+
haystack.includes('esc again to interrupt')
|
|
189
|
+
) {
|
|
190
|
+
return 'working'
|
|
191
|
+
}
|
|
192
|
+
return 'idle'
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const GENERIC_WAITING_PATTERNS: string[] = [
|
|
196
|
+
'[y/n]',
|
|
197
|
+
'(y/n)',
|
|
198
|
+
'yes/no',
|
|
199
|
+
'y/n?',
|
|
200
|
+
'confirm?',
|
|
201
|
+
'continue?',
|
|
202
|
+
'proceed?',
|
|
203
|
+
'press enter to continue',
|
|
204
|
+
'press any key',
|
|
205
|
+
'allow?',
|
|
206
|
+
'approve?',
|
|
207
|
+
'do you want',
|
|
208
|
+
'would you like',
|
|
209
|
+
'permission required',
|
|
210
|
+
'enter to select',
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
function classifyGeneric(haystack: string, changedAt: number, now: number): TabActivity {
|
|
214
|
+
for (const pattern of GENERIC_WAITING_PATTERNS) {
|
|
215
|
+
if (haystack.includes(pattern)) return 'waiting-input'
|
|
216
|
+
}
|
|
217
|
+
if (now - changedAt < ACTIVE_CHANGE_WINDOW_MS) return 'working'
|
|
218
|
+
return 'idle'
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function isShellCommand(command: string | undefined): boolean {
|
|
222
|
+
if (!command) return false
|
|
223
|
+
const first = command.trim().split(/\s+/u)[0]
|
|
224
|
+
if (!first) return false
|
|
225
|
+
return SHELL_COMMAND_PATTERN.test(first)
|
|
226
|
+
}
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -87,13 +87,11 @@ function envInt(name: string, fallback: number): number {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
const RENDER_COALESCE_MS = 16
|
|
90
|
-
const DATA_DEBOUNCE_MS = envInt('AIMUX_RENDER_DEBOUNCE_MS',
|
|
91
|
-
const BURST_MAX_MS = envInt('AIMUX_RENDER_BURST_MS', 500)
|
|
90
|
+
const DATA_DEBOUNCE_MS = envInt('AIMUX_RENDER_DEBOUNCE_MS', 0)
|
|
92
91
|
|
|
93
92
|
export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
94
93
|
private sessions = new Map<string, SessionHandle>()
|
|
95
94
|
private pendingFlushes = new Map<string, ReturnType<typeof setTimeout>>()
|
|
96
|
-
private pendingBurstCaps = new Map<string, ReturnType<typeof setTimeout>>()
|
|
97
95
|
|
|
98
96
|
private clearTimers(tabId: string): void {
|
|
99
97
|
const flush = this.pendingFlushes.get(tabId)
|
|
@@ -101,11 +99,6 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
101
99
|
clearTimeout(flush)
|
|
102
100
|
this.pendingFlushes.delete(tabId)
|
|
103
101
|
}
|
|
104
|
-
const burst = this.pendingBurstCaps.get(tabId)
|
|
105
|
-
if (burst) {
|
|
106
|
-
clearTimeout(burst)
|
|
107
|
-
this.pendingBurstCaps.delete(tabId)
|
|
108
|
-
}
|
|
109
102
|
}
|
|
110
103
|
|
|
111
104
|
private scheduleRender(session: SessionHandle): void {
|
|
@@ -117,20 +110,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
117
110
|
if (this.sessions.get(session.tabId) !== session) {
|
|
118
111
|
return
|
|
119
112
|
}
|
|
120
|
-
const burst = this.pendingBurstCaps.get(session.tabId)
|
|
121
|
-
if (burst) {
|
|
122
|
-
clearTimeout(burst)
|
|
123
|
-
this.pendingBurstCaps.delete(session.tabId)
|
|
124
|
-
}
|
|
125
113
|
this.emitRenderIfChanged(session)
|
|
126
114
|
}, RENDER_COALESCE_MS)
|
|
127
115
|
this.pendingFlushes.set(session.tabId, timer)
|
|
128
116
|
}
|
|
129
117
|
|
|
130
118
|
private scheduleDataRender(session: SessionHandle): void {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
clearTimeout(existingFlush)
|
|
119
|
+
if (this.pendingFlushes.has(session.tabId)) {
|
|
120
|
+
return
|
|
134
121
|
}
|
|
135
122
|
const flushTimer = setTimeout(() => {
|
|
136
123
|
this.pendingFlushes.delete(session.tabId)
|
|
@@ -141,30 +128,9 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
141
128
|
this.scheduleDataRender(session)
|
|
142
129
|
return
|
|
143
130
|
}
|
|
144
|
-
const burst = this.pendingBurstCaps.get(session.tabId)
|
|
145
|
-
if (burst) {
|
|
146
|
-
clearTimeout(burst)
|
|
147
|
-
this.pendingBurstCaps.delete(session.tabId)
|
|
148
|
-
}
|
|
149
131
|
this.emitRenderIfChanged(session)
|
|
150
132
|
}, DATA_DEBOUNCE_MS)
|
|
151
133
|
this.pendingFlushes.set(session.tabId, flushTimer)
|
|
152
|
-
|
|
153
|
-
if (!this.pendingBurstCaps.has(session.tabId)) {
|
|
154
|
-
const burstTimer = setTimeout(() => {
|
|
155
|
-
this.pendingBurstCaps.delete(session.tabId)
|
|
156
|
-
if (this.sessions.get(session.tabId) !== session) {
|
|
157
|
-
return
|
|
158
|
-
}
|
|
159
|
-
const flush = this.pendingFlushes.get(session.tabId)
|
|
160
|
-
if (flush) {
|
|
161
|
-
clearTimeout(flush)
|
|
162
|
-
this.pendingFlushes.delete(session.tabId)
|
|
163
|
-
}
|
|
164
|
-
this.emitRenderIfChanged(session)
|
|
165
|
-
}, BURST_MAX_MS)
|
|
166
|
-
this.pendingBurstCaps.set(session.tabId, burstTimer)
|
|
167
|
-
}
|
|
168
134
|
}
|
|
169
135
|
|
|
170
136
|
private flushRenderNow(session: SessionHandle): void {
|
|
@@ -204,7 +204,9 @@ async function restartDaemon(socketPath: string): Promise<void> {
|
|
|
204
204
|
await spawnDaemon()
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
export async function createSessionBackend(
|
|
207
|
+
export async function createSessionBackend(opts?: {
|
|
208
|
+
onBreakingUpdateRequired?: () => Promise<void>
|
|
209
|
+
}): Promise<SessionBackend> {
|
|
208
210
|
if (process.env.AIMUX_LOCAL_BACKEND === '1') {
|
|
209
211
|
logDebug('backend.create.localExplicit')
|
|
210
212
|
return new LocalSessionBackend()
|
|
@@ -237,6 +239,7 @@ export async function createSessionBackend(): Promise<SessionBackend> {
|
|
|
237
239
|
error: handshake.error ?? 'incompatible daemon handshake',
|
|
238
240
|
socketPath,
|
|
239
241
|
})
|
|
242
|
+
await opts?.onBreakingUpdateRequired?.()
|
|
240
243
|
await restartDaemon(socketPath)
|
|
241
244
|
const retriedHandshake = await probeDaemonProtocolCompatibility(socketPath)
|
|
242
245
|
logDebug('backend.create.handshakeAfterRestart', {
|
|
@@ -5,6 +5,7 @@ import type { SessionBackend, SessionBackendEvents } from './types'
|
|
|
5
5
|
|
|
6
6
|
import { SessionManager } from '../daemon/session-manager'
|
|
7
7
|
import { logDebug } from '../debug/input-log'
|
|
8
|
+
import { runStatusDetectionLoop } from '../pty/assistant-status-detection-loop'
|
|
8
9
|
import {
|
|
9
10
|
createTerminalBounds,
|
|
10
11
|
forEachSplitPaneRect,
|
|
@@ -12,16 +13,13 @@ import {
|
|
|
12
13
|
toTerminalContentSize,
|
|
13
14
|
} from '../state/layout-resize'
|
|
14
15
|
|
|
15
|
-
const SESSION_IDLE_TIMEOUT_MS = 2_000
|
|
16
|
-
|
|
17
16
|
export class LocalSessionBackend
|
|
18
17
|
extends EventEmitter<SessionBackendEvents>
|
|
19
18
|
implements SessionBackend
|
|
20
19
|
{
|
|
21
20
|
private readonly sessionManager = new SessionManager()
|
|
22
21
|
private currentSessionId: string | null = null
|
|
23
|
-
private readonly
|
|
24
|
-
private readonly sessionBusy = new Map<string, boolean>()
|
|
22
|
+
private readonly statusLoop: ReturnType<typeof runStatusDetectionLoop>
|
|
25
23
|
|
|
26
24
|
constructor() {
|
|
27
25
|
super()
|
|
@@ -29,7 +27,6 @@ export class LocalSessionBackend
|
|
|
29
27
|
if (sessionId === this.currentSessionId) {
|
|
30
28
|
this.emit('render', tabId, viewport, terminalModes)
|
|
31
29
|
}
|
|
32
|
-
this.markSessionBusy(sessionId)
|
|
33
30
|
})
|
|
34
31
|
this.sessionManager.on('exit', (sessionId, tabId, exitCode) => {
|
|
35
32
|
if (sessionId === this.currentSessionId) {
|
|
@@ -41,23 +38,18 @@ export class LocalSessionBackend
|
|
|
41
38
|
this.emit('error', tabId, message)
|
|
42
39
|
}
|
|
43
40
|
})
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.sessionBusy.set(sessionId, false)
|
|
57
|
-
this.emit('sessionActivity', sessionId, false)
|
|
58
|
-
}
|
|
59
|
-
}, SESSION_IDLE_TIMEOUT_MS)
|
|
60
|
-
this.sessionIdleTimers.set(sessionId, timer)
|
|
41
|
+
this.statusLoop = runStatusDetectionLoop({
|
|
42
|
+
listSessions: () => this.sessionManager.listSessionIds(),
|
|
43
|
+
listTabs: (sessionId) => this.sessionManager.listTabs(sessionId),
|
|
44
|
+
onSessionStatus: (sessionId, status) => {
|
|
45
|
+
this.emit('sessionActivity', sessionId, status)
|
|
46
|
+
},
|
|
47
|
+
onTabStatus: (tabId, status, sessionId) => {
|
|
48
|
+
if (sessionId === this.currentSessionId) {
|
|
49
|
+
this.emit('tabActivity', tabId, status)
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
})
|
|
61
53
|
}
|
|
62
54
|
|
|
63
55
|
async attach(options: {
|
|
@@ -84,7 +76,24 @@ export class LocalSessionBackend
|
|
|
84
76
|
} else {
|
|
85
77
|
this.sessionManager.resize(options.sessionId, options.cols, options.rows)
|
|
86
78
|
}
|
|
87
|
-
|
|
79
|
+
const attachResult = this.sessionManager.attachSession(
|
|
80
|
+
options.sessionId,
|
|
81
|
+
options.workspaceSnapshot
|
|
82
|
+
)
|
|
83
|
+
// Run a synchronous classification pass so every tab's activity and the
|
|
84
|
+
// session-status snapshot are available to embed in the reply — mirrors
|
|
85
|
+
// the remote backend's behavior and keeps hydrate dispatches atomic on
|
|
86
|
+
// the client side.
|
|
87
|
+
this.statusLoop.classifyNow(options.sessionId, this.sessionManager.listTabs(options.sessionId))
|
|
88
|
+
const tabsWithActivity = attachResult.tabs.map((tab) => ({
|
|
89
|
+
...tab,
|
|
90
|
+
activity: this.statusLoop.getTabStatus(tab.id) ?? tab.activity,
|
|
91
|
+
}))
|
|
92
|
+
return {
|
|
93
|
+
activeTabId: attachResult.activeTabId,
|
|
94
|
+
initialSessionStatuses: this.statusLoop.snapshotSessions(),
|
|
95
|
+
tabs: tabsWithActivity,
|
|
96
|
+
}
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
createSession(options: {
|
|
@@ -123,30 +132,22 @@ export class LocalSessionBackend
|
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
scrollViewport(tabId: string, deltaLines: number): void {
|
|
126
|
-
if (!this.currentSessionId)
|
|
127
|
-
return
|
|
128
|
-
}
|
|
135
|
+
if (!this.currentSessionId) return
|
|
129
136
|
this.sessionManager.scroll(this.currentSessionId, tabId, deltaLines)
|
|
130
137
|
}
|
|
131
138
|
|
|
132
139
|
scrollViewportToBottom(tabId: string): void {
|
|
133
|
-
if (!this.currentSessionId)
|
|
134
|
-
return
|
|
135
|
-
}
|
|
140
|
+
if (!this.currentSessionId) return
|
|
136
141
|
this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
|
|
137
142
|
}
|
|
138
143
|
|
|
139
144
|
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
140
|
-
if (!this.currentSessionId)
|
|
141
|
-
return
|
|
142
|
-
}
|
|
145
|
+
if (!this.currentSessionId) return
|
|
143
146
|
this.sessionManager.reapplyScrollIntent(this.currentSessionId, tabId, intent)
|
|
144
147
|
}
|
|
145
148
|
|
|
146
149
|
setActiveTab(tabId: string | null): void {
|
|
147
|
-
if (!this.currentSessionId)
|
|
148
|
-
return
|
|
149
|
-
}
|
|
150
|
+
if (!this.currentSessionId) return
|
|
150
151
|
logDebug('backend.local.setActiveTab', { sessionId: this.currentSessionId, tabId })
|
|
151
152
|
this.sessionManager.setActiveTab(this.currentSessionId, tabId)
|
|
152
153
|
}
|
|
@@ -157,9 +158,7 @@ export class LocalSessionBackend
|
|
|
157
158
|
intents?: Map<string, ScrollIntent>,
|
|
158
159
|
options?: { sync?: boolean }
|
|
159
160
|
): void {
|
|
160
|
-
if (!this.currentSessionId)
|
|
161
|
-
return
|
|
162
|
-
}
|
|
161
|
+
if (!this.currentSessionId) return
|
|
163
162
|
this.sessionManager.resize(this.currentSessionId, cols, rows, intents, options)
|
|
164
163
|
}
|
|
165
164
|
|
|
@@ -170,40 +169,28 @@ export class LocalSessionBackend
|
|
|
170
169
|
intent?: ScrollIntent,
|
|
171
170
|
options?: { sync?: boolean }
|
|
172
171
|
): void {
|
|
173
|
-
if (!this.currentSessionId)
|
|
174
|
-
return
|
|
175
|
-
}
|
|
172
|
+
if (!this.currentSessionId) return
|
|
176
173
|
this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent, options)
|
|
177
174
|
}
|
|
178
175
|
|
|
179
176
|
disposeSession(tabId: string): void {
|
|
180
|
-
if (!this.currentSessionId)
|
|
181
|
-
return
|
|
182
|
-
}
|
|
177
|
+
if (!this.currentSessionId) return
|
|
183
178
|
logDebug('backend.local.disposeSession', { sessionId: this.currentSessionId, tabId })
|
|
184
179
|
this.sessionManager.closeTab(this.currentSessionId, tabId)
|
|
185
180
|
}
|
|
186
181
|
|
|
187
182
|
disposeAll(): void {
|
|
188
|
-
if (!this.currentSessionId)
|
|
189
|
-
return
|
|
190
|
-
}
|
|
183
|
+
if (!this.currentSessionId) return
|
|
191
184
|
logDebug('backend.local.disposeAll', { sessionId: this.currentSessionId })
|
|
192
185
|
this.sessionManager.disposeSession(this.currentSessionId)
|
|
193
186
|
}
|
|
194
187
|
|
|
195
188
|
destroy(keepSessions = true): void {
|
|
196
189
|
logDebug('backend.local.destroy', { keepSessions, sessionId: this.currentSessionId })
|
|
197
|
-
if (!keepSessions) {
|
|
198
|
-
|
|
199
|
-
this.sessionManager.disposeSession(this.currentSessionId)
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
for (const timer of this.sessionIdleTimers.values()) {
|
|
203
|
-
clearTimeout(timer)
|
|
190
|
+
if (!keepSessions && this.currentSessionId) {
|
|
191
|
+
this.sessionManager.disposeSession(this.currentSessionId)
|
|
204
192
|
}
|
|
205
|
-
this.
|
|
206
|
-
this.sessionBusy.clear()
|
|
193
|
+
this.statusLoop.stop()
|
|
207
194
|
this.currentSessionId = null
|
|
208
195
|
}
|
|
209
196
|
}
|
|
@@ -161,6 +161,21 @@ export class RemoteSessionBackend
|
|
|
161
161
|
case 'tabError':
|
|
162
162
|
this.emit('error', message.payload.tabId, message.payload.message)
|
|
163
163
|
break
|
|
164
|
+
case 'tabStatus':
|
|
165
|
+
logDebug('backend.remote.tabStatus', {
|
|
166
|
+
sessionId: message.payload.sessionId,
|
|
167
|
+
status: message.payload.status,
|
|
168
|
+
tabId: message.payload.tabId,
|
|
169
|
+
})
|
|
170
|
+
this.emit('tabActivity', message.payload.tabId, message.payload.status)
|
|
171
|
+
break
|
|
172
|
+
case 'sessionStatus':
|
|
173
|
+
logDebug('backend.remote.sessionStatus', {
|
|
174
|
+
sessionId: message.payload.sessionId,
|
|
175
|
+
status: message.payload.status,
|
|
176
|
+
})
|
|
177
|
+
this.emit('sessionActivity', message.payload.sessionId, message.payload.status)
|
|
178
|
+
break
|
|
164
179
|
}
|
|
165
180
|
}
|
|
166
181
|
|
|
@@ -2,6 +2,8 @@ import type { EventEmitter } from 'node:events'
|
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
4
|
ScrollIntent,
|
|
5
|
+
SessionStatus,
|
|
6
|
+
TabActivity,
|
|
5
7
|
TabSession,
|
|
6
8
|
TerminalModeState,
|
|
7
9
|
TerminalSnapshot,
|
|
@@ -12,12 +14,19 @@ export type SessionBackendEvents = {
|
|
|
12
14
|
render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
|
|
13
15
|
exit: [tabId: string, exitCode: number]
|
|
14
16
|
error: [tabId: string, message: string]
|
|
15
|
-
sessionActivity: [sessionId: string,
|
|
17
|
+
sessionActivity: [sessionId: string, status: SessionStatus]
|
|
18
|
+
tabActivity: [tabId: string, activity: TabActivity]
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export interface BackendAttachResult {
|
|
19
22
|
tabs: TabSession[]
|
|
20
23
|
activeTabId: string | null
|
|
24
|
+
/**
|
|
25
|
+
* Per-session status snapshot taken at attach time and applied
|
|
26
|
+
* atomically with tab hydration to prevent an unknown-tab race on
|
|
27
|
+
* separate `sessionActivity` events.
|
|
28
|
+
*/
|
|
29
|
+
initialSessionStatuses: Array<{ sessionId: string; status: SessionStatus }>
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
|