@brimveyn/aimux 1.3.1 → 1.4.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 +3 -0
- package/package.json +3 -2
- package/src/app-runtime/backend-runtime-events.ts +6 -0
- package/src/app-runtime/side-effects.ts +18 -0
- package/src/app-runtime/use-terminal-resize.ts +100 -30
- package/src/app.tsx +24 -7
- package/src/config.ts +21 -1
- package/src/daemon/session-manager.ts +11 -4
- package/src/daemon/session-registry.ts +15 -4
- package/src/index.tsx +6 -1
- package/src/input/modes/types.ts +1 -0
- package/src/pty/pty-manager.ts +127 -11
- package/src/restart-terminal-manager.ts +44 -0
- package/src/session-backend/local-session-backend.ts +42 -4
- package/src/session-backend/remote-session-backend.ts +13 -2
- package/src/session-backend/types.ts +14 -2
- package/src/state/dispatch-ref.ts +11 -0
- package/src/state/reducers/session-state.ts +28 -0
- package/src/state/reducers/ui-state.ts +8 -0
- package/src/state/session-catalog.ts +22 -1
- package/src/state/store.ts +8 -1
- package/src/state/types.ts +14 -0
- package/src/state/validation.ts +2 -0
- package/src/state/workspace-save.ts +2 -0
- package/src/ui/components/session-bar.tsx +208 -0
- package/src/ui/components/tab-item.tsx +3 -15
- package/src/ui/components/terminal-pane.tsx +17 -12
- package/src/ui/hooks/use-busy-spinner.ts +18 -0
- package/src/ui/root.tsx +4 -0
- package/src/ui/session-ordering.ts +34 -0
package/src/pty/pty-manager.ts
CHANGED
|
@@ -79,8 +79,98 @@ function getTerminalModes(emulator: XTerm, alternateScrollMode: boolean): Termin
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
function envInt(name: string, fallback: number): number {
|
|
83
|
+
const raw = process.env[name]
|
|
84
|
+
if (raw === undefined) return fallback
|
|
85
|
+
const parsed = Number(raw)
|
|
86
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const RENDER_COALESCE_MS = 16
|
|
90
|
+
const DATA_DEBOUNCE_MS = envInt('AIMUX_RENDER_DEBOUNCE_MS', 32)
|
|
91
|
+
const BURST_MAX_MS = envInt('AIMUX_RENDER_BURST_MS', 500)
|
|
92
|
+
|
|
82
93
|
export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
83
94
|
private sessions = new Map<string, SessionHandle>()
|
|
95
|
+
private pendingFlushes = new Map<string, ReturnType<typeof setTimeout>>()
|
|
96
|
+
private pendingBurstCaps = new Map<string, ReturnType<typeof setTimeout>>()
|
|
97
|
+
|
|
98
|
+
private clearTimers(tabId: string): void {
|
|
99
|
+
const flush = this.pendingFlushes.get(tabId)
|
|
100
|
+
if (flush) {
|
|
101
|
+
clearTimeout(flush)
|
|
102
|
+
this.pendingFlushes.delete(tabId)
|
|
103
|
+
}
|
|
104
|
+
const burst = this.pendingBurstCaps.get(tabId)
|
|
105
|
+
if (burst) {
|
|
106
|
+
clearTimeout(burst)
|
|
107
|
+
this.pendingBurstCaps.delete(tabId)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private scheduleRender(session: SessionHandle): void {
|
|
112
|
+
if (this.pendingFlushes.has(session.tabId)) {
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
const timer = setTimeout(() => {
|
|
116
|
+
this.pendingFlushes.delete(session.tabId)
|
|
117
|
+
if (this.sessions.get(session.tabId) !== session) {
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
const burst = this.pendingBurstCaps.get(session.tabId)
|
|
121
|
+
if (burst) {
|
|
122
|
+
clearTimeout(burst)
|
|
123
|
+
this.pendingBurstCaps.delete(session.tabId)
|
|
124
|
+
}
|
|
125
|
+
this.emitRenderIfChanged(session)
|
|
126
|
+
}, RENDER_COALESCE_MS)
|
|
127
|
+
this.pendingFlushes.set(session.tabId, timer)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private scheduleDataRender(session: SessionHandle): void {
|
|
131
|
+
const existingFlush = this.pendingFlushes.get(session.tabId)
|
|
132
|
+
if (existingFlush) {
|
|
133
|
+
clearTimeout(existingFlush)
|
|
134
|
+
}
|
|
135
|
+
const flushTimer = setTimeout(() => {
|
|
136
|
+
this.pendingFlushes.delete(session.tabId)
|
|
137
|
+
if (this.sessions.get(session.tabId) !== session) {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (session.pendingWrites > 0) {
|
|
141
|
+
this.scheduleDataRender(session)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const burst = this.pendingBurstCaps.get(session.tabId)
|
|
145
|
+
if (burst) {
|
|
146
|
+
clearTimeout(burst)
|
|
147
|
+
this.pendingBurstCaps.delete(session.tabId)
|
|
148
|
+
}
|
|
149
|
+
this.emitRenderIfChanged(session)
|
|
150
|
+
}, DATA_DEBOUNCE_MS)
|
|
151
|
+
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
|
+
}
|
|
169
|
+
|
|
170
|
+
private flushRenderNow(session: SessionHandle): void {
|
|
171
|
+
this.clearTimers(session.tabId)
|
|
172
|
+
this.emitRenderIfChanged(session)
|
|
173
|
+
}
|
|
84
174
|
|
|
85
175
|
private emitRenderIfChanged(session: SessionHandle): void {
|
|
86
176
|
const nextSnapshot = snapshotTerminal(session.emulator, session.cursorVisible)
|
|
@@ -116,7 +206,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
116
206
|
}
|
|
117
207
|
|
|
118
208
|
this.sessions.delete(session.tabId)
|
|
119
|
-
this.
|
|
209
|
+
this.flushRenderNow(session)
|
|
120
210
|
session.emulator.dispose()
|
|
121
211
|
logDebug('ptyManager.finalize', { exitCode, tabId: session.tabId })
|
|
122
212
|
this.emit('exit', session.tabId, exitCode)
|
|
@@ -188,9 +278,10 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
188
278
|
session.cursorVisible = trackedModes.cursorVisible
|
|
189
279
|
session.pendingModeSequence = trackedModes.pendingSequence
|
|
190
280
|
session.pendingWrites += 1
|
|
281
|
+
this.scheduleDataRender(session)
|
|
191
282
|
emulator.write(data, () => {
|
|
192
283
|
session.pendingWrites -= 1
|
|
193
|
-
this.
|
|
284
|
+
this.scheduleDataRender(session)
|
|
194
285
|
|
|
195
286
|
if (session.pendingWrites === 0 && session.pendingExitCode !== null) {
|
|
196
287
|
this.finalizeSession(session, session.pendingExitCode)
|
|
@@ -215,7 +306,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
215
306
|
|
|
216
307
|
this.sessions.set(options.tabId, session)
|
|
217
308
|
logDebug('ptyManager.create.success', { tabId: options.tabId })
|
|
218
|
-
this.
|
|
309
|
+
this.scheduleRender(session)
|
|
219
310
|
} catch (error) {
|
|
220
311
|
const message = error instanceof Error ? error.message : String(error)
|
|
221
312
|
logDebug('ptyManager.create.error', { error: message, tabId: options.tabId })
|
|
@@ -224,7 +315,9 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
224
315
|
}
|
|
225
316
|
|
|
226
317
|
write(tabId: string, input: string): void {
|
|
227
|
-
this.sessions.get(tabId)
|
|
318
|
+
const session = this.sessions.get(tabId)
|
|
319
|
+
if (!session) return
|
|
320
|
+
session.pty.write(input)
|
|
228
321
|
}
|
|
229
322
|
|
|
230
323
|
scrollViewport(tabId: string, deltaLines: number): void {
|
|
@@ -234,7 +327,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
234
327
|
}
|
|
235
328
|
|
|
236
329
|
session.emulator.scrollLines(deltaLines)
|
|
237
|
-
this.
|
|
330
|
+
this.scheduleRender(session)
|
|
238
331
|
}
|
|
239
332
|
|
|
240
333
|
scrollViewportToBottom(tabId: string): void {
|
|
@@ -244,7 +337,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
244
337
|
}
|
|
245
338
|
|
|
246
339
|
session.emulator.scrollToBottom()
|
|
247
|
-
this.
|
|
340
|
+
this.scheduleRender(session)
|
|
248
341
|
}
|
|
249
342
|
|
|
250
343
|
private applyScrollIntent(session: SessionHandle, intent: ScrollIntent | undefined): void {
|
|
@@ -262,7 +355,12 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
262
355
|
session.emulator.scrollToLine(Math.max(0, intent.absoluteLine))
|
|
263
356
|
}
|
|
264
357
|
|
|
265
|
-
resizeAll(
|
|
358
|
+
resizeAll(
|
|
359
|
+
cols: number,
|
|
360
|
+
rows: number,
|
|
361
|
+
intents?: Map<string, ScrollIntent>,
|
|
362
|
+
options?: { sync?: boolean }
|
|
363
|
+
): void {
|
|
266
364
|
const safeCols = Math.max(20, cols)
|
|
267
365
|
const safeRows = Math.max(8, rows)
|
|
268
366
|
|
|
@@ -270,11 +368,21 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
270
368
|
session.pty.resize(safeCols, safeRows)
|
|
271
369
|
session.emulator.resize(safeCols, safeRows)
|
|
272
370
|
this.applyScrollIntent(session, intents?.get(session.tabId))
|
|
273
|
-
|
|
371
|
+
if (options?.sync) {
|
|
372
|
+
this.flushRenderNow(session)
|
|
373
|
+
} else {
|
|
374
|
+
this.scheduleRender(session)
|
|
375
|
+
}
|
|
274
376
|
}
|
|
275
377
|
}
|
|
276
378
|
|
|
277
|
-
resizeSession(
|
|
379
|
+
resizeSession(
|
|
380
|
+
tabId: string,
|
|
381
|
+
cols: number,
|
|
382
|
+
rows: number,
|
|
383
|
+
intent?: ScrollIntent,
|
|
384
|
+
options?: { sync?: boolean }
|
|
385
|
+
): void {
|
|
278
386
|
const session = this.sessions.get(tabId)
|
|
279
387
|
if (!session) {
|
|
280
388
|
return
|
|
@@ -284,7 +392,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
284
392
|
session.pty.resize(safeCols, safeRows)
|
|
285
393
|
session.emulator.resize(safeCols, safeRows)
|
|
286
394
|
this.applyScrollIntent(session, intent)
|
|
287
|
-
|
|
395
|
+
if (options?.sync) {
|
|
396
|
+
this.flushRenderNow(session)
|
|
397
|
+
} else {
|
|
398
|
+
this.scheduleRender(session)
|
|
399
|
+
}
|
|
288
400
|
}
|
|
289
401
|
|
|
290
402
|
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
@@ -293,7 +405,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
293
405
|
return
|
|
294
406
|
}
|
|
295
407
|
this.applyScrollIntent(session, intent)
|
|
296
|
-
this.
|
|
408
|
+
this.scheduleRender(session)
|
|
297
409
|
}
|
|
298
410
|
|
|
299
411
|
disposeSession(tabId: string): void {
|
|
@@ -302,12 +414,16 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
302
414
|
return
|
|
303
415
|
}
|
|
304
416
|
|
|
417
|
+
this.clearTimers(tabId)
|
|
305
418
|
this.sessions.delete(tabId)
|
|
306
419
|
session.pty.kill()
|
|
307
420
|
session.emulator.dispose()
|
|
308
421
|
}
|
|
309
422
|
|
|
310
423
|
disposeAll(): void {
|
|
424
|
+
for (const tabId of this.sessions.keys()) {
|
|
425
|
+
this.clearTimers(tabId)
|
|
426
|
+
}
|
|
311
427
|
for (const session of this.sessions.values()) {
|
|
312
428
|
session.pty.kill()
|
|
313
429
|
session.emulator.dispose()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getTerminalManagerSocketPath,
|
|
3
|
+
removeTerminalManagerSocketIfExists,
|
|
4
|
+
} from './daemon/runtime-paths'
|
|
5
|
+
import {
|
|
6
|
+
findIpcDaemonPid,
|
|
7
|
+
findTerminalManagerPid,
|
|
8
|
+
killProcess,
|
|
9
|
+
spawnDetachedTerminalManager,
|
|
10
|
+
} from './platform/daemon-control'
|
|
11
|
+
|
|
12
|
+
export async function runRestartTerminalManager(): Promise<number> {
|
|
13
|
+
const socketPath = getTerminalManagerSocketPath()
|
|
14
|
+
|
|
15
|
+
const daemonPid = await findIpcDaemonPid()
|
|
16
|
+
if (daemonPid !== null) {
|
|
17
|
+
process.stdout.write(`Stopping IPC daemon (pid ${daemonPid})...\n`)
|
|
18
|
+
await killProcess(daemonPid)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const managerPid = await findTerminalManagerPid()
|
|
22
|
+
if (managerPid !== null) {
|
|
23
|
+
process.stdout.write(
|
|
24
|
+
`WARNING: killing terminal-manager (pid ${managerPid}) will kill live sessions.\n`
|
|
25
|
+
)
|
|
26
|
+
await killProcess(managerPid)
|
|
27
|
+
process.stdout.write('Terminal-manager stopped.\n')
|
|
28
|
+
} else {
|
|
29
|
+
process.stdout.write('No running terminal-manager found.\n')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
removeTerminalManagerSocketIfExists()
|
|
33
|
+
|
|
34
|
+
process.stdout.write('Starting terminal-manager...\n')
|
|
35
|
+
const ok = await spawnDetachedTerminalManager()
|
|
36
|
+
|
|
37
|
+
if (ok) {
|
|
38
|
+
process.stdout.write(`Terminal-manager started on ${socketPath}.\n`)
|
|
39
|
+
return 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.stderr.write('Failed to start terminal-manager.\n')
|
|
43
|
+
return 1
|
|
44
|
+
}
|
|
@@ -12,12 +12,16 @@ import {
|
|
|
12
12
|
toTerminalContentSize,
|
|
13
13
|
} from '../state/layout-resize'
|
|
14
14
|
|
|
15
|
+
const SESSION_IDLE_TIMEOUT_MS = 2_000
|
|
16
|
+
|
|
15
17
|
export class LocalSessionBackend
|
|
16
18
|
extends EventEmitter<SessionBackendEvents>
|
|
17
19
|
implements SessionBackend
|
|
18
20
|
{
|
|
19
21
|
private readonly sessionManager = new SessionManager()
|
|
20
22
|
private currentSessionId: string | null = null
|
|
23
|
+
private readonly sessionIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
24
|
+
private readonly sessionBusy = new Map<string, boolean>()
|
|
21
25
|
|
|
22
26
|
constructor() {
|
|
23
27
|
super()
|
|
@@ -25,6 +29,7 @@ export class LocalSessionBackend
|
|
|
25
29
|
if (sessionId === this.currentSessionId) {
|
|
26
30
|
this.emit('render', tabId, viewport, terminalModes)
|
|
27
31
|
}
|
|
32
|
+
this.markSessionBusy(sessionId)
|
|
28
33
|
})
|
|
29
34
|
this.sessionManager.on('exit', (sessionId, tabId, exitCode) => {
|
|
30
35
|
if (sessionId === this.currentSessionId) {
|
|
@@ -38,6 +43,23 @@ export class LocalSessionBackend
|
|
|
38
43
|
})
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
private markSessionBusy(sessionId: string): void {
|
|
47
|
+
if (this.sessionBusy.get(sessionId) !== true) {
|
|
48
|
+
this.sessionBusy.set(sessionId, true)
|
|
49
|
+
this.emit('sessionActivity', sessionId, true)
|
|
50
|
+
}
|
|
51
|
+
const existing = this.sessionIdleTimers.get(sessionId)
|
|
52
|
+
if (existing) clearTimeout(existing)
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
this.sessionIdleTimers.delete(sessionId)
|
|
55
|
+
if (this.sessionBusy.get(sessionId) === true) {
|
|
56
|
+
this.sessionBusy.set(sessionId, false)
|
|
57
|
+
this.emit('sessionActivity', sessionId, false)
|
|
58
|
+
}
|
|
59
|
+
}, SESSION_IDLE_TIMEOUT_MS)
|
|
60
|
+
this.sessionIdleTimers.set(sessionId, timer)
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
async attach(options: {
|
|
42
64
|
sessionId: string
|
|
43
65
|
cols: number
|
|
@@ -129,18 +151,29 @@ export class LocalSessionBackend
|
|
|
129
151
|
this.sessionManager.setActiveTab(this.currentSessionId, tabId)
|
|
130
152
|
}
|
|
131
153
|
|
|
132
|
-
resizeAll(
|
|
154
|
+
resizeAll(
|
|
155
|
+
cols: number,
|
|
156
|
+
rows: number,
|
|
157
|
+
intents?: Map<string, ScrollIntent>,
|
|
158
|
+
options?: { sync?: boolean }
|
|
159
|
+
): void {
|
|
133
160
|
if (!this.currentSessionId) {
|
|
134
161
|
return
|
|
135
162
|
}
|
|
136
|
-
this.sessionManager.resize(this.currentSessionId, cols, rows, intents)
|
|
163
|
+
this.sessionManager.resize(this.currentSessionId, cols, rows, intents, options)
|
|
137
164
|
}
|
|
138
165
|
|
|
139
|
-
resizeTab(
|
|
166
|
+
resizeTab(
|
|
167
|
+
tabId: string,
|
|
168
|
+
cols: number,
|
|
169
|
+
rows: number,
|
|
170
|
+
intent?: ScrollIntent,
|
|
171
|
+
options?: { sync?: boolean }
|
|
172
|
+
): void {
|
|
140
173
|
if (!this.currentSessionId) {
|
|
141
174
|
return
|
|
142
175
|
}
|
|
143
|
-
this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent)
|
|
176
|
+
this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent, options)
|
|
144
177
|
}
|
|
145
178
|
|
|
146
179
|
disposeSession(tabId: string): void {
|
|
@@ -166,6 +199,11 @@ export class LocalSessionBackend
|
|
|
166
199
|
this.sessionManager.disposeSession(this.currentSessionId)
|
|
167
200
|
}
|
|
168
201
|
}
|
|
202
|
+
for (const timer of this.sessionIdleTimers.values()) {
|
|
203
|
+
clearTimeout(timer)
|
|
204
|
+
}
|
|
205
|
+
this.sessionIdleTimers.clear()
|
|
206
|
+
this.sessionBusy.clear()
|
|
169
207
|
this.currentSessionId = null
|
|
170
208
|
}
|
|
171
209
|
}
|
|
@@ -398,7 +398,12 @@ export class RemoteSessionBackend
|
|
|
398
398
|
}).catch((error) => this.reportCommandError('setActiveTab', error))
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
-
resizeAll(
|
|
401
|
+
resizeAll(
|
|
402
|
+
cols: number,
|
|
403
|
+
rows: number,
|
|
404
|
+
intents?: Map<string, ScrollIntent>,
|
|
405
|
+
_options?: { sync?: boolean }
|
|
406
|
+
): void {
|
|
402
407
|
if (!this.attached) {
|
|
403
408
|
logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
|
|
404
409
|
return
|
|
@@ -412,7 +417,13 @@ export class RemoteSessionBackend
|
|
|
412
417
|
}).catch((error) => this.reportCommandError('resizeClient', error))
|
|
413
418
|
}
|
|
414
419
|
|
|
415
|
-
resizeTab(
|
|
420
|
+
resizeTab(
|
|
421
|
+
tabId: string,
|
|
422
|
+
cols: number,
|
|
423
|
+
rows: number,
|
|
424
|
+
intent?: ScrollIntent,
|
|
425
|
+
_options?: { sync?: boolean }
|
|
426
|
+
): void {
|
|
416
427
|
if (!this.attached) {
|
|
417
428
|
return
|
|
418
429
|
}
|
|
@@ -12,6 +12,7 @@ export type SessionBackendEvents = {
|
|
|
12
12
|
render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
|
|
13
13
|
exit: [tabId: string, exitCode: number]
|
|
14
14
|
error: [tabId: string, message: string]
|
|
15
|
+
sessionActivity: [sessionId: string, busy: boolean]
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export interface BackendAttachResult {
|
|
@@ -41,8 +42,19 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
|
|
|
41
42
|
scrollViewportToBottom(tabId: string): void
|
|
42
43
|
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void
|
|
43
44
|
setActiveTab(tabId: string | null): void
|
|
44
|
-
resizeAll(
|
|
45
|
-
|
|
45
|
+
resizeAll(
|
|
46
|
+
cols: number,
|
|
47
|
+
rows: number,
|
|
48
|
+
intents?: Map<string, ScrollIntent>,
|
|
49
|
+
options?: { sync?: boolean }
|
|
50
|
+
): void
|
|
51
|
+
resizeTab(
|
|
52
|
+
tabId: string,
|
|
53
|
+
cols: number,
|
|
54
|
+
rows: number,
|
|
55
|
+
intent?: ScrollIntent,
|
|
56
|
+
options?: { sync?: boolean }
|
|
57
|
+
): void
|
|
46
58
|
disposeSession(tabId: string): void
|
|
47
59
|
disposeAll(): void
|
|
48
60
|
destroy(keepSessions?: boolean): Promise<void> | void
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import type { SideEffect } from '../input/modes/types'
|
|
1
2
|
import type { AppAction } from './types'
|
|
2
3
|
|
|
3
4
|
type DispatchFn = (action: AppAction) => void
|
|
5
|
+
type SideEffectFn = (effect: SideEffect) => void
|
|
4
6
|
|
|
5
7
|
let activeDispatch: DispatchFn | null = null
|
|
8
|
+
let activeSideEffect: SideEffectFn | null = null
|
|
6
9
|
|
|
7
10
|
export function setActiveDispatch(dispatch: DispatchFn | null): void {
|
|
8
11
|
activeDispatch = dispatch
|
|
@@ -11,3 +14,11 @@ export function setActiveDispatch(dispatch: DispatchFn | null): void {
|
|
|
11
14
|
export function dispatchGlobal(action: AppAction): void {
|
|
12
15
|
activeDispatch?.(action)
|
|
13
16
|
}
|
|
17
|
+
|
|
18
|
+
export function setActiveSideEffectRunner(runner: SideEffectFn | null): void {
|
|
19
|
+
activeSideEffect = runner
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function runSideEffectGlobal(effect: SideEffect): void {
|
|
23
|
+
activeSideEffect?.(effect)
|
|
24
|
+
}
|
|
@@ -64,6 +64,8 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
|
|
|
64
64
|
const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
|
|
65
65
|
const maxIndex = filteredNew.length
|
|
66
66
|
const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
|
|
67
|
+
const nextBusy = { ...state.sessionsBusy }
|
|
68
|
+
delete nextBusy[action.sessionId]
|
|
67
69
|
return {
|
|
68
70
|
...state,
|
|
69
71
|
activeTabId: action.sessionId === state.currentSessionId ? null : state.activeTabId,
|
|
@@ -77,9 +79,35 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
|
|
|
77
79
|
type: 'session-picker',
|
|
78
80
|
},
|
|
79
81
|
sessions: newSessions,
|
|
82
|
+
sessionsBusy: nextBusy,
|
|
80
83
|
tabs: action.sessionId === state.currentSessionId ? [] : state.tabs,
|
|
81
84
|
}
|
|
82
85
|
}
|
|
86
|
+
case 'reorder-sessions': {
|
|
87
|
+
const byId = new Map(state.sessions.map((s) => [s.id, s]))
|
|
88
|
+
const ordered: typeof state.sessions = []
|
|
89
|
+
let idx = 0
|
|
90
|
+
for (const id of action.orderedIds) {
|
|
91
|
+
const s = byId.get(id)
|
|
92
|
+
if (s) {
|
|
93
|
+
ordered.push({ ...s, order: idx })
|
|
94
|
+
byId.delete(id)
|
|
95
|
+
}
|
|
96
|
+
idx++
|
|
97
|
+
}
|
|
98
|
+
let nextOrder = ordered.length
|
|
99
|
+
for (const s of byId.values()) {
|
|
100
|
+
ordered.push({ ...s, order: nextOrder++ })
|
|
101
|
+
}
|
|
102
|
+
return { ...state, sessions: ordered }
|
|
103
|
+
}
|
|
104
|
+
case 'set-session-busy': {
|
|
105
|
+
if ((state.sessionsBusy[action.sessionId] ?? false) === action.busy) return state
|
|
106
|
+
return {
|
|
107
|
+
...state,
|
|
108
|
+
sessionsBusy: { ...state.sessionsBusy, [action.sessionId]: action.busy },
|
|
109
|
+
}
|
|
110
|
+
}
|
|
83
111
|
default:
|
|
84
112
|
return null
|
|
85
113
|
}
|
|
@@ -23,6 +23,14 @@ export function reduceUIState(state: AppState, action: AppAction): AppState | nu
|
|
|
23
23
|
return state
|
|
24
24
|
}
|
|
25
25
|
return { ...state, pendingChords: action.chords }
|
|
26
|
+
case 'toggle-session-bar':
|
|
27
|
+
return {
|
|
28
|
+
...state,
|
|
29
|
+
sessionBar: { ...state.sessionBar, visible: !state.sessionBar.visible },
|
|
30
|
+
}
|
|
31
|
+
case 'set-session-bar-position':
|
|
32
|
+
if (state.sessionBar.position === action.position) return state
|
|
33
|
+
return { ...state, sessionBar: { ...state.sessionBar, position: action.position } }
|
|
26
34
|
default:
|
|
27
35
|
return null
|
|
28
36
|
}
|
|
@@ -46,7 +46,7 @@ export function loadSessionCatalog(): SessionRecord[] {
|
|
|
46
46
|
const { file, issue } = readCatalogFile()
|
|
47
47
|
if (file) {
|
|
48
48
|
logDebug('sessions.catalog.load', { sessionCount: file.sessions.length })
|
|
49
|
-
return file.sessions
|
|
49
|
+
return normalizeOrder(file.sessions)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
if (issue) {
|
|
@@ -95,3 +95,24 @@ export function saveSessionCatalog(sessions: SessionRecord[]): void {
|
|
|
95
95
|
export function getSessionCatalogPath(): string {
|
|
96
96
|
return SESSIONS_PATH
|
|
97
97
|
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Assign a stable `order` to every session. Records with an existing numeric
|
|
101
|
+
* `order` keep their slot (sorted ascending); the rest are appended by
|
|
102
|
+
* createdAt ascending so older sessions come first.
|
|
103
|
+
*/
|
|
104
|
+
function normalizeOrder(sessions: SessionRecord[]): SessionRecord[] {
|
|
105
|
+
const withOrder: SessionRecord[] = []
|
|
106
|
+
const withoutOrder: SessionRecord[] = []
|
|
107
|
+
for (const s of sessions) {
|
|
108
|
+
if (typeof s.order === 'number' && Number.isFinite(s.order)) {
|
|
109
|
+
withOrder.push(s)
|
|
110
|
+
} else {
|
|
111
|
+
withoutOrder.push(s)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
withOrder.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
115
|
+
withoutOrder.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
116
|
+
const merged = [...withOrder, ...withoutOrder]
|
|
117
|
+
return merged.map((s, i) => (s.order === i ? s : { ...s, order: i }))
|
|
118
|
+
}
|
package/src/state/store.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AppAction, AppState, SessionRecord, SnippetRecord } from './types'
|
|
1
|
+
import type { AppAction, AppState, SessionBarPosition, SessionRecord, SnippetRecord } from './types'
|
|
2
2
|
|
|
3
3
|
import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
|
|
4
4
|
import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
|
|
@@ -17,6 +17,8 @@ const DEFAULT_TERMINAL_ROWS = 24
|
|
|
17
17
|
export interface InitialStateOverrides {
|
|
18
18
|
gitPanelVisible?: boolean
|
|
19
19
|
gitPanelRatio?: number
|
|
20
|
+
sessionBarVisible?: boolean
|
|
21
|
+
sessionBarPosition?: SessionBarPosition
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export function createInitialState(
|
|
@@ -42,7 +44,12 @@ export function createInitialState(
|
|
|
42
44
|
? { editBuffer: null, selectedIndex: 0, sessionTargetId: null, type: 'session-picker' }
|
|
43
45
|
: emptyModal(),
|
|
44
46
|
pendingChords: null,
|
|
47
|
+
sessionBar: {
|
|
48
|
+
position: overrides.sessionBarPosition ?? 'top',
|
|
49
|
+
visible: overrides.sessionBarVisible ?? true,
|
|
50
|
+
},
|
|
45
51
|
sessions,
|
|
52
|
+
sessionsBusy: {},
|
|
46
53
|
sidebar: {
|
|
47
54
|
gitPanelRatio: overrides.gitPanelRatio ?? 0.5,
|
|
48
55
|
gitPanelVisible: overrides.gitPanelVisible ?? true,
|
package/src/state/types.ts
CHANGED
|
@@ -105,9 +105,17 @@ export interface SessionRecord {
|
|
|
105
105
|
createdAt: string
|
|
106
106
|
updatedAt: string
|
|
107
107
|
lastOpenedAt: string
|
|
108
|
+
order?: number
|
|
108
109
|
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
export type SessionBarPosition = 'top' | 'bottom'
|
|
113
|
+
|
|
114
|
+
export interface SessionBarState {
|
|
115
|
+
visible: boolean
|
|
116
|
+
position: SessionBarPosition
|
|
117
|
+
}
|
|
118
|
+
|
|
111
119
|
export interface TabSession {
|
|
112
120
|
id: string
|
|
113
121
|
assistant: AssistantId
|
|
@@ -287,6 +295,8 @@ export interface AppState {
|
|
|
287
295
|
tabGroupMap: Record<string, string>
|
|
288
296
|
sessions: SessionRecord[]
|
|
289
297
|
currentSessionId: string | null
|
|
298
|
+
sessionsBusy: Record<string, boolean>
|
|
299
|
+
sessionBar: SessionBarState
|
|
290
300
|
snippets: SnippetRecord[]
|
|
291
301
|
focusMode: FocusMode
|
|
292
302
|
sidebar: SidebarState
|
|
@@ -332,6 +342,8 @@ export type SessionAction =
|
|
|
332
342
|
| { type: 'create-session-record'; session: SessionRecord }
|
|
333
343
|
| { type: 'rename-session-record'; sessionId: string; name: string }
|
|
334
344
|
| { type: 'delete-session-record'; sessionId: string }
|
|
345
|
+
| { type: 'reorder-sessions'; orderedIds: string[] }
|
|
346
|
+
| { type: 'set-session-busy'; sessionId: string; busy: boolean }
|
|
335
347
|
|
|
336
348
|
// -- Tab actions --
|
|
337
349
|
export type TabAction =
|
|
@@ -398,6 +410,8 @@ export type UIAction =
|
|
|
398
410
|
| { type: 'toggle-git-panel' }
|
|
399
411
|
| { type: 'resize-git-panel'; delta: number }
|
|
400
412
|
| { type: 'set-pending-chords'; chords: string[] | null }
|
|
413
|
+
| { type: 'toggle-session-bar' }
|
|
414
|
+
| { type: 'set-session-bar-position'; position: SessionBarPosition }
|
|
401
415
|
|
|
402
416
|
// -- Git panel actions --
|
|
403
417
|
export interface GitRefreshPayload {
|
package/src/state/validation.ts
CHANGED
|
@@ -151,6 +151,8 @@ export function isSessionRecord(value: unknown): value is SessionRecord {
|
|
|
151
151
|
isString(value.createdAt) &&
|
|
152
152
|
isString(value.updatedAt) &&
|
|
153
153
|
isString(value.lastOpenedAt) &&
|
|
154
|
+
(value.order === undefined ||
|
|
155
|
+
(typeof value.order === 'number' && Number.isFinite(value.order))) &&
|
|
154
156
|
(value.workspaceSnapshot === undefined || isWorkspaceSnapshotV1(value.workspaceSnapshot))
|
|
155
157
|
)
|
|
156
158
|
}
|
|
@@ -26,6 +26,8 @@ export function saveCurrentWorkspace(state: AppState): void {
|
|
|
26
26
|
customCommands: state.customCommands,
|
|
27
27
|
gitPanelRatio: state.sidebar.gitPanelRatio,
|
|
28
28
|
gitPanelVisible: state.sidebar.gitPanelVisible,
|
|
29
|
+
sessionBarPosition: state.sessionBar.position,
|
|
30
|
+
sessionBarVisible: state.sessionBar.visible,
|
|
29
31
|
})
|
|
30
32
|
saveSessionCatalog(
|
|
31
33
|
buildSessionsWithCurrentSnapshot(state.sessions, state.currentSessionId, state)
|