@brimveyn/aimux 1.2.5 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -8
- package/package.json +5 -5
- package/src/app-runtime/backend-runtime-events.ts +16 -1
- package/src/app-runtime/pty-write.ts +7 -4
- package/src/app-runtime/side-effects.ts +62 -4
- package/src/app-runtime/snippet-actions.ts +3 -2
- package/src/app-runtime/use-backend-runtime.ts +7 -2
- package/src/app-runtime/use-renderer-bindings.ts +2 -2
- package/src/app-runtime/use-terminal-resize.ts +15 -6
- package/src/app.tsx +89 -30
- package/src/config/loader.ts +2 -2
- package/src/config.ts +14 -3
- package/src/daemon/daemon.ts +279 -118
- package/src/daemon/runtime-paths.ts +34 -2
- package/src/daemon/session-manager.ts +20 -5
- package/src/daemon/session-registry.ts +18 -11
- package/src/index.tsx +19 -4
- package/src/input/keymap/describe-bindings.ts +68 -0
- package/src/input/keymap/key-format.ts +67 -0
- package/src/input/modes/bridge.ts +1 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +2 -0
- package/src/ipc/manager-protocol.ts +396 -0
- package/src/ipc/protocol.ts +98 -5
- package/src/platform/daemon-control.ts +42 -11
- package/src/profile-paths.ts +27 -0
- package/src/pty/pty-manager.ts +53 -3
- package/src/restart-daemon.ts +10 -10
- package/src/session-backend/bootstrap.ts +197 -46
- package/src/session-backend/local-session-backend.ts +12 -5
- package/src/session-backend/remote-session-backend.ts +143 -63
- package/src/session-backend/types.ts +4 -2
- package/src/state/reducers/modal-state.ts +18 -1
- package/src/state/reducers/tab-state.ts +20 -2
- package/src/state/session-catalog.ts +5 -4
- package/src/state/session-persistence.ts +9 -2
- package/src/state/snippet-catalog.ts +4 -4
- package/src/state/types.ts +23 -0
- package/src/state/validation.ts +8 -0
- package/src/terminal-manager/manager-client.ts +384 -0
- package/src/terminal-manager/terminal-manager.ts +288 -0
- package/src/ui/components/create-session-modal.tsx +3 -5
- package/src/ui/components/git-commit-modal.tsx +3 -5
- package/src/ui/components/help-modal.tsx +49 -68
- package/src/ui/components/list-item.tsx +24 -5
- package/src/ui/components/new-tab-modal.tsx +8 -9
- package/src/ui/components/pending-chord-overlay.tsx +28 -0
- package/src/ui/components/session-name-modal.tsx +3 -5
- package/src/ui/components/session-picker-modal.tsx +3 -1
- package/src/ui/components/snippet-editor-modal.tsx +3 -1
- package/src/ui/components/snippet-picker-modal.tsx +3 -1
- package/src/ui/components/status-bar.tsx +6 -2
- package/src/ui/components/theme-picker-modal.tsx +3 -6
- package/src/ui/components/update-available-modal.tsx +42 -0
- package/src/ui/keymap-context.ts +39 -0
- package/src/ui/root.tsx +12 -0
- package/src/ui/status-bar-model.ts +67 -39
- package/src/update/version-check.ts +67 -0
- package/src/update.ts +3 -3
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events'
|
|
2
2
|
import { connect, Socket } from 'node:net'
|
|
3
3
|
|
|
4
|
-
import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
|
|
4
|
+
import type { AssistantId, ScrollIntent, WorkspaceSnapshotV1 } from '../state/types'
|
|
5
5
|
import type { SessionBackend, SessionBackendEvents } from './types'
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { getIpcDaemonSocketPath } from '../daemon/runtime-paths'
|
|
8
8
|
import { logDebug } from '../debug/input-log'
|
|
9
9
|
import {
|
|
10
10
|
type AttachResult,
|
|
11
11
|
type ClientRequest,
|
|
12
12
|
encodeMessage,
|
|
13
|
+
IPC_PROTOCOL_MIN_VERSION,
|
|
13
14
|
IPC_PROTOCOL_VERSION,
|
|
14
15
|
MessageDecoder,
|
|
15
16
|
parseServerMessage,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
} from '../ipc/protocol'
|
|
20
21
|
|
|
21
22
|
const IPC_REQUEST_TIMEOUT_MS = 10_000
|
|
23
|
+
const RECONNECT_DELAY_MS = 250
|
|
22
24
|
|
|
23
25
|
export class RemoteSessionBackend
|
|
24
26
|
extends EventEmitter<SessionBackendEvents>
|
|
@@ -36,6 +38,15 @@ export class RemoteSessionBackend
|
|
|
36
38
|
private decoder = new MessageDecoder<ServerResponse | ServerEvent>(parseServerMessage)
|
|
37
39
|
private attached = false
|
|
38
40
|
private currentSessionId: string | null = null
|
|
41
|
+
private attachOptions: {
|
|
42
|
+
sessionId: string
|
|
43
|
+
cols: number
|
|
44
|
+
rows: number
|
|
45
|
+
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
46
|
+
} | null = null
|
|
47
|
+
private selectedProtocolVersion: number | null = null
|
|
48
|
+
private reconnectPromise: Promise<void> | null = null
|
|
49
|
+
private shouldReconnect = false
|
|
39
50
|
|
|
40
51
|
private rejectPendingRequests(error: Error): void {
|
|
41
52
|
for (const [id, pending] of this.pending.entries()) {
|
|
@@ -45,14 +56,19 @@ export class RemoteSessionBackend
|
|
|
45
56
|
}
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
private
|
|
59
|
+
private closeSocket(reason: string, preserveSession = false): void {
|
|
49
60
|
const socket = this.socket
|
|
50
61
|
this.socket = null
|
|
51
62
|
this.attached = false
|
|
52
|
-
this.
|
|
63
|
+
this.selectedProtocolVersion = null
|
|
53
64
|
this.decoder.reset()
|
|
54
65
|
this.rejectPendingRequests(new Error(reason))
|
|
55
66
|
|
|
67
|
+
if (!preserveSession) {
|
|
68
|
+
this.currentSessionId = null
|
|
69
|
+
this.attachOptions = null
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
if (!socket) {
|
|
57
73
|
return
|
|
58
74
|
}
|
|
@@ -64,6 +80,14 @@ export class RemoteSessionBackend
|
|
|
64
80
|
}
|
|
65
81
|
}
|
|
66
82
|
|
|
83
|
+
private handleConnectionLoss(reason: string): void {
|
|
84
|
+
logDebug('backend.remote.connectionLoss', { reason, sessionId: this.currentSessionId })
|
|
85
|
+
this.closeSocket(reason, true)
|
|
86
|
+
if (this.shouldReconnect && this.attachOptions) {
|
|
87
|
+
void this.scheduleReconnect()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
67
91
|
private getConnectedSocket(): Socket {
|
|
68
92
|
if (!this.socket || this.socket.destroyed) {
|
|
69
93
|
throw new Error('Remote backend socket is unavailable')
|
|
@@ -140,57 +164,37 @@ export class RemoteSessionBackend
|
|
|
140
164
|
}
|
|
141
165
|
}
|
|
142
166
|
|
|
143
|
-
async
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
rows: number
|
|
147
|
-
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
148
|
-
}): Promise<AttachResult> {
|
|
149
|
-
const socketPath = getDaemonSocketPath()
|
|
150
|
-
logDebug('backend.remote.attach.start', {
|
|
151
|
-
cols: options.cols,
|
|
152
|
-
rows: options.rows,
|
|
153
|
-
sessionId: options.sessionId,
|
|
154
|
-
snapshotTabs: options.workspaceSnapshot?.tabs.length ?? 0,
|
|
155
|
-
socketPath,
|
|
156
|
-
})
|
|
157
|
-
this.resetConnection('Connection replaced during attach')
|
|
167
|
+
private async connectAndHandshake(): Promise<void> {
|
|
168
|
+
const socketPath = getIpcDaemonSocketPath()
|
|
169
|
+
this.closeSocket('Connection replaced during attach', true)
|
|
158
170
|
|
|
159
171
|
const socket = connect(socketPath)
|
|
160
172
|
this.socket = socket
|
|
161
|
-
this.attached = false
|
|
162
|
-
this.currentSessionId = options.sessionId
|
|
163
173
|
|
|
164
174
|
await new Promise<void>((resolve, reject) => {
|
|
165
175
|
socket.once('connect', resolve)
|
|
166
176
|
socket.once('error', reject)
|
|
167
177
|
})
|
|
168
|
-
logDebug('backend.remote.attach.connected', { socketPath })
|
|
169
178
|
|
|
170
179
|
socket.on('error', (error) => {
|
|
171
180
|
if (this.socket !== socket) {
|
|
172
181
|
return
|
|
173
182
|
}
|
|
174
|
-
|
|
175
|
-
this.resetConnection(`Remote backend socket error: ${error.message}`)
|
|
183
|
+
this.handleConnectionLoss(`Remote backend socket error: ${error.message}`)
|
|
176
184
|
})
|
|
177
185
|
socket.on('close', () => {
|
|
178
186
|
if (this.socket !== socket) {
|
|
179
187
|
return
|
|
180
188
|
}
|
|
181
|
-
|
|
182
|
-
this.resetConnection('Remote backend socket closed')
|
|
189
|
+
this.handleConnectionLoss('Remote backend socket closed')
|
|
183
190
|
})
|
|
184
|
-
|
|
185
191
|
socket.on('data', (chunk) => {
|
|
186
192
|
if (this.socket !== socket) {
|
|
187
193
|
return
|
|
188
194
|
}
|
|
189
|
-
logDebug('backend.remote.data', { byteLength: chunk.length })
|
|
190
195
|
try {
|
|
191
196
|
for (const message of this.decoder.push(chunk)) {
|
|
192
197
|
if ('id' in message) {
|
|
193
|
-
logDebug('backend.remote.response', { id: message.id, type: message.type })
|
|
194
198
|
const pending = this.pending.get(message.id)
|
|
195
199
|
if (pending) {
|
|
196
200
|
clearTimeout(pending.timer)
|
|
@@ -203,41 +207,119 @@ export class RemoteSessionBackend
|
|
|
203
207
|
}
|
|
204
208
|
} catch (error) {
|
|
205
209
|
const message = error instanceof Error ? error.message : String(error)
|
|
206
|
-
|
|
207
|
-
this.resetConnection(`Remote backend parse error: ${message}`)
|
|
210
|
+
this.handleConnectionLoss(`Remote backend parse error: ${message}`)
|
|
208
211
|
}
|
|
209
212
|
})
|
|
210
213
|
|
|
211
214
|
const response = await this.send({
|
|
212
215
|
id: crypto.randomUUID(),
|
|
213
|
-
payload: {
|
|
216
|
+
payload: { maxVersion: IPC_PROTOCOL_VERSION, minVersion: IPC_PROTOCOL_MIN_VERSION },
|
|
217
|
+
type: 'hello',
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
if (response.type !== 'helloResult') {
|
|
221
|
+
this.closeSocket('Unexpected hello response', true)
|
|
222
|
+
throw new Error(
|
|
223
|
+
response.type === 'error' ? response.payload.message : 'Unexpected hello response'
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
this.selectedProtocolVersion = response.payload.selectedVersion
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async performAttach(options: {
|
|
231
|
+
sessionId: string
|
|
232
|
+
cols: number
|
|
233
|
+
rows: number
|
|
234
|
+
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
235
|
+
}): Promise<AttachResult> {
|
|
236
|
+
await this.connectAndHandshake()
|
|
237
|
+
|
|
238
|
+
const response = await this.send({
|
|
239
|
+
id: crypto.randomUUID(),
|
|
240
|
+
payload: {
|
|
241
|
+
...options,
|
|
242
|
+
protocolVersion: this.selectedProtocolVersion ?? IPC_PROTOCOL_VERSION,
|
|
243
|
+
},
|
|
214
244
|
type: 'attach',
|
|
215
245
|
})
|
|
216
246
|
|
|
217
247
|
if (response.type !== 'attachResult') {
|
|
218
|
-
|
|
219
|
-
this.resetConnection(`Unexpected attach response: ${response.type}`)
|
|
248
|
+
this.closeSocket(`Unexpected attach response: ${response.type}`, true)
|
|
220
249
|
throw new Error(
|
|
221
250
|
response.type === 'error' ? response.payload.message : 'Unexpected attach response'
|
|
222
251
|
)
|
|
223
252
|
}
|
|
224
253
|
|
|
225
|
-
if (response.payload.protocolVersion !==
|
|
226
|
-
this.
|
|
227
|
-
|
|
254
|
+
if (response.payload.protocolVersion !== this.selectedProtocolVersion) {
|
|
255
|
+
this.closeSocket('Attach protocol mismatch', true)
|
|
256
|
+
throw new ProtocolMismatchError(
|
|
257
|
+
this.selectedProtocolVersion ?? IPC_PROTOCOL_VERSION,
|
|
258
|
+
response.payload.protocolVersion
|
|
228
259
|
)
|
|
229
|
-
throw new ProtocolMismatchError(IPC_PROTOCOL_VERSION, response.payload.protocolVersion)
|
|
230
260
|
}
|
|
231
261
|
|
|
232
262
|
this.attached = true
|
|
263
|
+
return response.payload
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private async scheduleReconnect(): Promise<void> {
|
|
267
|
+
if (this.reconnectPromise) {
|
|
268
|
+
return this.reconnectPromise
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const reconnectOptions = this.attachOptions
|
|
272
|
+
this.reconnectPromise = (async () => {
|
|
273
|
+
while (this.shouldReconnect && reconnectOptions && this.attachOptions === reconnectOptions) {
|
|
274
|
+
try {
|
|
275
|
+
await this.performAttach(reconnectOptions)
|
|
276
|
+
logDebug('backend.remote.reconnect.success', {
|
|
277
|
+
sessionId: reconnectOptions.sessionId,
|
|
278
|
+
})
|
|
279
|
+
return
|
|
280
|
+
} catch (error) {
|
|
281
|
+
logDebug('backend.remote.reconnect.retry', {
|
|
282
|
+
error: error instanceof Error ? error.message : String(error),
|
|
283
|
+
sessionId: reconnectOptions.sessionId,
|
|
284
|
+
})
|
|
285
|
+
await Bun.sleep(RECONNECT_DELAY_MS)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
})()
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
await this.reconnectPromise
|
|
292
|
+
} finally {
|
|
293
|
+
this.reconnectPromise = null
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async attach(options: {
|
|
298
|
+
sessionId: string
|
|
299
|
+
cols: number
|
|
300
|
+
rows: number
|
|
301
|
+
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
302
|
+
}): Promise<AttachResult> {
|
|
303
|
+
this.shouldReconnect = true
|
|
304
|
+
this.currentSessionId = options.sessionId
|
|
305
|
+
this.attachOptions = options
|
|
306
|
+
|
|
307
|
+
logDebug('backend.remote.attach.start', {
|
|
308
|
+
cols: options.cols,
|
|
309
|
+
rows: options.rows,
|
|
310
|
+
sessionId: options.sessionId,
|
|
311
|
+
snapshotTabs: options.workspaceSnapshot?.tabs.length ?? 0,
|
|
312
|
+
socketPath: getIpcDaemonSocketPath(),
|
|
313
|
+
})
|
|
233
314
|
|
|
315
|
+
const result = await this.performAttach(options)
|
|
234
316
|
logDebug('backend.remote.attach.success', {
|
|
235
|
-
activeTabId:
|
|
317
|
+
activeTabId: result.activeTabId,
|
|
236
318
|
sessionId: options.sessionId,
|
|
237
|
-
tabs:
|
|
319
|
+
tabs: result.tabs.length,
|
|
238
320
|
})
|
|
239
321
|
|
|
240
|
-
return
|
|
322
|
+
return result
|
|
241
323
|
}
|
|
242
324
|
|
|
243
325
|
createSession(options: {
|
|
@@ -255,11 +337,6 @@ export class RemoteSessionBackend
|
|
|
255
337
|
return
|
|
256
338
|
}
|
|
257
339
|
|
|
258
|
-
logDebug('backend.remote.createSession', {
|
|
259
|
-
sessionId: this.currentSessionId,
|
|
260
|
-
tabId: options.tabId,
|
|
261
|
-
title: options.title,
|
|
262
|
-
})
|
|
263
340
|
void this.sendExpectOk({ id: crypto.randomUUID(), payload: options, type: 'createTab' }).catch(
|
|
264
341
|
(error) => this.reportCommandError('createTab', error, options.tabId)
|
|
265
342
|
)
|
|
@@ -270,11 +347,6 @@ export class RemoteSessionBackend
|
|
|
270
347
|
logDebug('backend.remote.skipWriteBeforeAttach', { inputLength: input.length, tabId })
|
|
271
348
|
return
|
|
272
349
|
}
|
|
273
|
-
logDebug('backend.remote.write', {
|
|
274
|
-
inputLength: input.length,
|
|
275
|
-
sessionId: this.currentSessionId,
|
|
276
|
-
tabId,
|
|
277
|
-
})
|
|
278
350
|
void this.sendExpectOk({
|
|
279
351
|
id: crypto.randomUUID(),
|
|
280
352
|
payload: { data: input, tabId },
|
|
@@ -286,7 +358,6 @@ export class RemoteSessionBackend
|
|
|
286
358
|
if (!this.attached) {
|
|
287
359
|
return
|
|
288
360
|
}
|
|
289
|
-
logDebug('backend.remote.scroll', { deltaLines, sessionId: this.currentSessionId, tabId })
|
|
290
361
|
void this.sendExpectOk({
|
|
291
362
|
id: crypto.randomUUID(),
|
|
292
363
|
payload: { deltaLines, tabId },
|
|
@@ -298,7 +369,6 @@ export class RemoteSessionBackend
|
|
|
298
369
|
if (!this.attached) {
|
|
299
370
|
return
|
|
300
371
|
}
|
|
301
|
-
logDebug('backend.remote.scrollToBottom', { sessionId: this.currentSessionId, tabId })
|
|
302
372
|
void this.sendExpectOk({
|
|
303
373
|
id: crypto.randomUUID(),
|
|
304
374
|
payload: { tabId },
|
|
@@ -306,11 +376,21 @@ export class RemoteSessionBackend
|
|
|
306
376
|
}).catch((error) => this.reportCommandError('scrollToBottom', error, tabId))
|
|
307
377
|
}
|
|
308
378
|
|
|
379
|
+
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
380
|
+
if (!this.attached) {
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
void this.sendExpectOk({
|
|
384
|
+
id: crypto.randomUUID(),
|
|
385
|
+
payload: { intent, tabId },
|
|
386
|
+
type: 'reapplyScrollIntent',
|
|
387
|
+
}).catch((error) => this.reportCommandError('reapplyScrollIntent', error, tabId))
|
|
388
|
+
}
|
|
389
|
+
|
|
309
390
|
setActiveTab(tabId: string | null): void {
|
|
310
391
|
if (!this.attached) {
|
|
311
392
|
return
|
|
312
393
|
}
|
|
313
|
-
logDebug('backend.remote.setActiveTab', { sessionId: this.currentSessionId, tabId })
|
|
314
394
|
void this.sendExpectOk({
|
|
315
395
|
id: crypto.randomUUID(),
|
|
316
396
|
payload: { tabId },
|
|
@@ -318,27 +398,27 @@ export class RemoteSessionBackend
|
|
|
318
398
|
}).catch((error) => this.reportCommandError('setActiveTab', error))
|
|
319
399
|
}
|
|
320
400
|
|
|
321
|
-
resizeAll(cols: number, rows: number): void {
|
|
401
|
+
resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
|
|
322
402
|
if (!this.attached) {
|
|
323
403
|
logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
|
|
324
404
|
return
|
|
325
405
|
}
|
|
326
406
|
logDebug('backend.remote.resize', { cols, rows, sessionId: this.currentSessionId })
|
|
407
|
+
const intentsRecord = intents ? Object.fromEntries(intents.entries()) : undefined
|
|
327
408
|
void this.sendExpectOk({
|
|
328
409
|
id: crypto.randomUUID(),
|
|
329
|
-
payload: { cols, rows },
|
|
410
|
+
payload: { cols, intents: intentsRecord, rows },
|
|
330
411
|
type: 'resizeClient',
|
|
331
412
|
}).catch((error) => this.reportCommandError('resizeClient', error))
|
|
332
413
|
}
|
|
333
414
|
|
|
334
|
-
resizeTab(tabId: string, cols: number, rows: number): void {
|
|
415
|
+
resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
|
|
335
416
|
if (!this.attached) {
|
|
336
417
|
return
|
|
337
418
|
}
|
|
338
|
-
logDebug('backend.remote.resizeTab', { cols, rows, sessionId: this.currentSessionId, tabId })
|
|
339
419
|
void this.sendExpectOk({
|
|
340
420
|
id: crypto.randomUUID(),
|
|
341
|
-
payload: { cols, rows, tabId },
|
|
421
|
+
payload: { cols, intent, rows, tabId },
|
|
342
422
|
type: 'resizeTab',
|
|
343
423
|
}).catch((error) => this.reportCommandError('resizeTab', error, tabId))
|
|
344
424
|
}
|
|
@@ -347,7 +427,6 @@ export class RemoteSessionBackend
|
|
|
347
427
|
if (!this.attached) {
|
|
348
428
|
return
|
|
349
429
|
}
|
|
350
|
-
logDebug('backend.remote.disposeSession', { sessionId: this.currentSessionId, tabId })
|
|
351
430
|
void this.sendExpectOk({ id: crypto.randomUUID(), payload: { tabId }, type: 'closeTab' }).catch(
|
|
352
431
|
(error) => this.reportCommandError('closeTab', error, tabId)
|
|
353
432
|
)
|
|
@@ -357,7 +436,6 @@ export class RemoteSessionBackend
|
|
|
357
436
|
if (!this.attached) {
|
|
358
437
|
return
|
|
359
438
|
}
|
|
360
|
-
logDebug('backend.remote.disposeAll', { sessionId: this.currentSessionId })
|
|
361
439
|
void this.sendExpectOk({ id: crypto.randomUUID(), payload: {}, type: 'disposeAll' }).catch(
|
|
362
440
|
(error) => this.reportCommandError('disposeAll', error)
|
|
363
441
|
)
|
|
@@ -365,9 +443,11 @@ export class RemoteSessionBackend
|
|
|
365
443
|
|
|
366
444
|
async destroy(keepSessions = true): Promise<void> {
|
|
367
445
|
logDebug('backend.remote.destroy', { keepSessions })
|
|
368
|
-
|
|
446
|
+
this.shouldReconnect = false
|
|
447
|
+
this.reconnectPromise = null
|
|
448
|
+
if (!keepSessions && this.attached) {
|
|
369
449
|
this.disposeAll()
|
|
370
450
|
}
|
|
371
|
-
this.
|
|
451
|
+
this.closeSocket('Remote backend destroyed')
|
|
372
452
|
}
|
|
373
453
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EventEmitter } from 'node:events'
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
|
+
ScrollIntent,
|
|
4
5
|
TabSession,
|
|
5
6
|
TerminalModeState,
|
|
6
7
|
TerminalSnapshot,
|
|
@@ -38,9 +39,10 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
|
|
|
38
39
|
write(tabId: string, input: string): void
|
|
39
40
|
scrollViewport(tabId: string, deltaLines: number): void
|
|
40
41
|
scrollViewportToBottom(tabId: string): void
|
|
42
|
+
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void
|
|
41
43
|
setActiveTab(tabId: string | null): void
|
|
42
|
-
resizeAll(cols: number, rows: number): void
|
|
43
|
-
resizeTab(tabId: string, cols: number, rows: number): void
|
|
44
|
+
resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void
|
|
45
|
+
resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void
|
|
44
46
|
disposeSession(tabId: string): void
|
|
45
47
|
disposeAll(): void
|
|
46
48
|
destroy(keepSessions?: boolean): Promise<void> | void
|
|
@@ -161,6 +161,20 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
161
161
|
type: 'theme-picker',
|
|
162
162
|
},
|
|
163
163
|
}
|
|
164
|
+
case 'open-update-available-modal':
|
|
165
|
+
return {
|
|
166
|
+
...state,
|
|
167
|
+
focusMode: 'modal',
|
|
168
|
+
modal: {
|
|
169
|
+
currentVersion: action.currentVersion,
|
|
170
|
+
cursorPos: 0,
|
|
171
|
+
editBuffer: null,
|
|
172
|
+
latestVersion: action.latestVersion,
|
|
173
|
+
selectedIndex: 0,
|
|
174
|
+
sessionTargetId: null,
|
|
175
|
+
type: 'update-available',
|
|
176
|
+
},
|
|
177
|
+
}
|
|
164
178
|
case 'open-git-commit-modal':
|
|
165
179
|
return {
|
|
166
180
|
...state,
|
|
@@ -198,7 +212,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
198
212
|
state.modal.type !== 'snippet-picker' &&
|
|
199
213
|
state.modal.type !== 'theme-picker' &&
|
|
200
214
|
state.modal.type !== 'create-session' &&
|
|
201
|
-
state.modal.type !== 'split-picker'
|
|
215
|
+
state.modal.type !== 'split-picker' &&
|
|
216
|
+
state.modal.type !== 'update-available'
|
|
202
217
|
) {
|
|
203
218
|
return state
|
|
204
219
|
}
|
|
@@ -215,6 +230,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
215
230
|
optionCount = filtered.length
|
|
216
231
|
} else if (state.modal.type === 'theme-picker') {
|
|
217
232
|
optionCount = THEME_COUNT
|
|
233
|
+
} else if (state.modal.type === 'update-available') {
|
|
234
|
+
optionCount = 2
|
|
218
235
|
} else {
|
|
219
236
|
const filtered = filterSessions(state.sessions, state.modal.editBuffer)
|
|
220
237
|
optionCount = Math.max(1, filtered.length + 1)
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { AppAction, AppState, TabSession } from '../types'
|
|
2
|
-
|
|
3
1
|
import {
|
|
4
2
|
allLeafIds,
|
|
5
3
|
createGroupId,
|
|
@@ -15,6 +13,13 @@ import {
|
|
|
15
13
|
} from '../layout-tree'
|
|
16
14
|
import { normalizeGroupedTabOrder } from '../session-persistence'
|
|
17
15
|
import { createDefaultTerminalModes } from '../terminal-modes'
|
|
16
|
+
import {
|
|
17
|
+
type AppAction,
|
|
18
|
+
type AppState,
|
|
19
|
+
DEFAULT_SCROLL_INTENT,
|
|
20
|
+
deriveScrollIntent,
|
|
21
|
+
type TabSession,
|
|
22
|
+
} from '../types'
|
|
18
23
|
|
|
19
24
|
const MAX_BUFFER_LENGTH = 50_000
|
|
20
25
|
|
|
@@ -385,6 +390,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
385
390
|
buffer: '',
|
|
386
391
|
errorMessage: undefined,
|
|
387
392
|
exitCode: undefined,
|
|
393
|
+
scrollIntent: DEFAULT_SCROLL_INTENT,
|
|
388
394
|
status: 'starting',
|
|
389
395
|
terminalModes: createDefaultTerminalModes(),
|
|
390
396
|
viewport: undefined,
|
|
@@ -404,11 +410,23 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
404
410
|
...state,
|
|
405
411
|
tabs: updateTab(state.tabs, action.tabId, (tab) => ({
|
|
406
412
|
...tab,
|
|
413
|
+
scrollIntent:
|
|
414
|
+
action.source === 'resize' || action.source === 'switch'
|
|
415
|
+
? (tab.scrollIntent ?? DEFAULT_SCROLL_INTENT)
|
|
416
|
+
: deriveScrollIntent(action.viewport),
|
|
407
417
|
status: tab.status === 'starting' ? 'running' : tab.status,
|
|
408
418
|
terminalModes: action.terminalModes,
|
|
409
419
|
viewport: action.viewport,
|
|
410
420
|
})),
|
|
411
421
|
}
|
|
422
|
+
case 'set-scroll-intent':
|
|
423
|
+
return {
|
|
424
|
+
...state,
|
|
425
|
+
tabs: updateTab(state.tabs, action.tabId, (tab) => ({
|
|
426
|
+
...tab,
|
|
427
|
+
scrollIntent: action.intent,
|
|
428
|
+
})),
|
|
429
|
+
}
|
|
412
430
|
case 'set-tab-activity':
|
|
413
431
|
return {
|
|
414
432
|
...state,
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import {
|
|
2
|
+
import { join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import type { SessionRecord } from './types'
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { loadConfig, saveConfig } from '../config'
|
|
7
7
|
import { logDebug } from '../debug/input-log'
|
|
8
|
+
import { getProfileConfigDir } from '../profile-paths'
|
|
8
9
|
import { isSessionRecord } from './validation'
|
|
9
10
|
|
|
10
11
|
interface SessionCatalogFile {
|
|
@@ -12,7 +13,7 @@ interface SessionCatalogFile {
|
|
|
12
13
|
sessions: SessionRecord[]
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
const SESSIONS_PATH = join(
|
|
16
|
+
const SESSIONS_PATH = join(getProfileConfigDir(), 'aimux-sessions.json')
|
|
16
17
|
|
|
17
18
|
function readCatalogFile(): { file: SessionCatalogFile | null; issue?: string } {
|
|
18
19
|
try {
|
|
@@ -79,7 +80,7 @@ export function loadSessionCatalog(): SessionRecord[] {
|
|
|
79
80
|
|
|
80
81
|
export function saveSessionCatalog(sessions: SessionRecord[]): void {
|
|
81
82
|
try {
|
|
82
|
-
mkdirSync(
|
|
83
|
+
mkdirSync(getProfileConfigDir(), { recursive: true })
|
|
83
84
|
writeFileSync(SESSIONS_PATH, `${JSON.stringify({ sessions, version: 1 }, null, 2)}\n`)
|
|
84
85
|
logDebug('sessions.catalog.save', { sessionCount: sessions.length })
|
|
85
86
|
} catch (error) {
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { AppState, TabSession, TabStatus, WorkspaceSnapshotV1 } from './types'
|
|
2
|
-
|
|
3
1
|
import {
|
|
4
2
|
allLeafIds,
|
|
5
3
|
createGroupId,
|
|
@@ -7,6 +5,13 @@ import {
|
|
|
7
5
|
type LayoutNode,
|
|
8
6
|
pruneLayoutTree,
|
|
9
7
|
} from './layout-tree'
|
|
8
|
+
import {
|
|
9
|
+
type AppState,
|
|
10
|
+
DEFAULT_SCROLL_INTENT,
|
|
11
|
+
type TabSession,
|
|
12
|
+
type TabStatus,
|
|
13
|
+
type WorkspaceSnapshotV1,
|
|
14
|
+
} from './types'
|
|
10
15
|
|
|
11
16
|
export function createEmptyWorkspaceSnapshot(): WorkspaceSnapshotV1 {
|
|
12
17
|
return {
|
|
@@ -47,6 +52,7 @@ export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
|
|
|
47
52
|
errorMessage: tab.errorMessage,
|
|
48
53
|
exitCode: tab.exitCode,
|
|
49
54
|
id: tab.id,
|
|
55
|
+
scrollIntent: tab.scrollIntent,
|
|
50
56
|
status: tab.status === 'disconnected' ? 'running' : tab.status,
|
|
51
57
|
terminalModes: tab.terminalModes,
|
|
52
58
|
title: tab.title,
|
|
@@ -69,6 +75,7 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
|
|
|
69
75
|
errorMessage: tab.errorMessage,
|
|
70
76
|
exitCode: tab.exitCode,
|
|
71
77
|
id: tab.id,
|
|
78
|
+
scrollIntent: tab.scrollIntent ?? DEFAULT_SCROLL_INTENT,
|
|
72
79
|
status: getDisconnectedStatus(tab.status),
|
|
73
80
|
terminalModes: tab.terminalModes,
|
|
74
81
|
title: tab.title,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import {
|
|
2
|
+
import { join } from 'node:path'
|
|
3
3
|
|
|
4
|
-
import { CONFIG_PATH } from '../config'
|
|
5
4
|
import { logDebug } from '../debug/input-log'
|
|
5
|
+
import { getProfileConfigDir } from '../profile-paths'
|
|
6
6
|
import { isSnippetRecord } from './validation'
|
|
7
7
|
|
|
8
8
|
export interface SnippetRecord {
|
|
@@ -11,7 +11,7 @@ export interface SnippetRecord {
|
|
|
11
11
|
content: string
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const SNIPPETS_PATH = join(
|
|
14
|
+
const SNIPPETS_PATH = join(getProfileConfigDir(), 'aimux-snippets.json')
|
|
15
15
|
|
|
16
16
|
const DEFAULT_SNIPPETS: SnippetRecord[] = [
|
|
17
17
|
{
|
|
@@ -78,7 +78,7 @@ export function loadSnippetCatalog(): SnippetRecord[] {
|
|
|
78
78
|
|
|
79
79
|
export function saveSnippetCatalog(snippets: SnippetRecord[]): void {
|
|
80
80
|
try {
|
|
81
|
-
mkdirSync(
|
|
81
|
+
mkdirSync(getProfileConfigDir(), { recursive: true })
|
|
82
82
|
writeFileSync(SNIPPETS_PATH, `${JSON.stringify({ snippets, version: 1 }, null, 2)}\n`)
|
|
83
83
|
} catch (error) {
|
|
84
84
|
logDebug('snippets.catalog.saveError', {
|
package/src/state/types.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type ModalType =
|
|
|
26
26
|
| 'help'
|
|
27
27
|
| 'split-picker'
|
|
28
28
|
| 'git-commit'
|
|
29
|
+
| 'update-available'
|
|
29
30
|
| null
|
|
30
31
|
|
|
31
32
|
export interface TerminalSpan {
|
|
@@ -49,6 +50,16 @@ export interface TerminalSnapshot {
|
|
|
49
50
|
cursorVisible: boolean
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
export type ScrollIntent = { kind: 'bottom' } | { absoluteLine: number; kind: 'anchor' }
|
|
54
|
+
|
|
55
|
+
export const DEFAULT_SCROLL_INTENT: ScrollIntent = { kind: 'bottom' }
|
|
56
|
+
|
|
57
|
+
export function deriveScrollIntent(viewport: TerminalSnapshot): ScrollIntent {
|
|
58
|
+
return viewport.viewportY >= viewport.baseY
|
|
59
|
+
? { kind: 'bottom' }
|
|
60
|
+
: { absoluteLine: viewport.viewportY, kind: 'anchor' }
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
export interface TerminalModeState {
|
|
53
64
|
mouseTrackingMode: 'none' | 'x10' | 'vt200' | 'drag' | 'any'
|
|
54
65
|
sendFocusMode: boolean
|
|
@@ -66,6 +77,7 @@ export interface PersistedTabSnapshot {
|
|
|
66
77
|
buffer: string
|
|
67
78
|
viewport?: TerminalSnapshot
|
|
68
79
|
terminalModes: TerminalModeState
|
|
80
|
+
scrollIntent?: ScrollIntent
|
|
69
81
|
errorMessage?: string
|
|
70
82
|
exitCode?: number
|
|
71
83
|
}
|
|
@@ -105,6 +117,7 @@ export interface TabSession {
|
|
|
105
117
|
buffer: string
|
|
106
118
|
viewport?: TerminalSnapshot
|
|
107
119
|
terminalModes: TerminalModeState
|
|
120
|
+
scrollIntent?: ScrollIntent
|
|
108
121
|
command: string
|
|
109
122
|
errorMessage?: string
|
|
110
123
|
exitCode?: number
|
|
@@ -228,6 +241,12 @@ export interface ModalSnippetEditor extends ModalBase {
|
|
|
228
241
|
contentBuffer: string
|
|
229
242
|
}
|
|
230
243
|
|
|
244
|
+
export interface ModalUpdateAvailable extends ModalBase {
|
|
245
|
+
type: 'update-available'
|
|
246
|
+
currentVersion: string
|
|
247
|
+
latestVersion: string
|
|
248
|
+
}
|
|
249
|
+
|
|
231
250
|
export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
|
|
232
251
|
|
|
233
252
|
export interface DirectoryResult {
|
|
@@ -248,6 +267,7 @@ export type ModalState =
|
|
|
248
267
|
| ModalCreateSession
|
|
249
268
|
| ModalSnippetEditor
|
|
250
269
|
| ModalGitCommit
|
|
270
|
+
| ModalUpdateAvailable
|
|
251
271
|
|
|
252
272
|
export interface LayoutState {
|
|
253
273
|
terminalCols: number
|
|
@@ -303,6 +323,7 @@ export type ModalAction =
|
|
|
303
323
|
| { type: 'open-snippet-editor'; snippetId?: string }
|
|
304
324
|
| { type: 'begin-snippet-filter' }
|
|
305
325
|
| { type: 'open-theme-picker' }
|
|
326
|
+
| { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
|
|
306
327
|
|
|
307
328
|
// -- Session actions --
|
|
308
329
|
export type SessionAction =
|
|
@@ -336,7 +357,9 @@ export type TabAction =
|
|
|
336
357
|
tabId: string
|
|
337
358
|
viewport: TerminalSnapshot
|
|
338
359
|
terminalModes: TerminalModeState
|
|
360
|
+
source?: 'resize' | 'scroll' | 'data' | 'switch'
|
|
339
361
|
}
|
|
362
|
+
| { type: 'set-scroll-intent'; tabId: string; intent: ScrollIntent }
|
|
340
363
|
| { type: 'set-tab-activity'; tabId: string; activity?: TabActivity }
|
|
341
364
|
| { type: 'set-tab-status'; tabId: string; status: TabStatus; exitCode?: number }
|
|
342
365
|
| { type: 'set-tab-error'; tabId: string; message: string }
|