@brimveyn/aimux 1.3.0 → 1.4.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-runtime-events.ts +13 -1
- package/src/app-runtime/pty-write.ts +7 -4
- package/src/app-runtime/side-effects.ts +72 -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 +113 -37
- package/src/config.ts +32 -1
- package/src/daemon/daemon.ts +19 -2
- package/src/daemon/session-manager.ts +20 -5
- package/src/daemon/session-registry.ts +18 -11
- package/src/index.tsx +8 -1
- 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 +3 -0
- package/src/ipc/manager-protocol.ts +51 -2
- package/src/ipc/protocol.ts +43 -2
- package/src/pty/pty-manager.ts +29 -3
- package/src/session-backend/local-session-backend.ts +39 -5
- package/src/session-backend/remote-session-backend.ts +18 -5
- package/src/session-backend/types.ts +5 -2
- package/src/state/dispatch-ref.ts +11 -0
- package/src/state/reducers/modal-state.ts +18 -1
- package/src/state/reducers/session-state.ts +28 -0
- package/src/state/reducers/tab-state.ts +20 -2
- package/src/state/reducers/ui-state.ts +8 -0
- package/src/state/session-catalog.ts +22 -1
- package/src/state/session-persistence.ts +9 -2
- package/src/state/store.ts +8 -1
- package/src/state/types.ts +37 -0
- package/src/state/validation.ts +10 -0
- package/src/state/workspace-save.ts +2 -0
- package/src/terminal-manager/manager-client.ts +29 -5
- package/src/terminal-manager/terminal-manager.ts +19 -3
- 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-bar.tsx +208 -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/tab-item.tsx +3 -15
- package/src/ui/components/theme-picker-modal.tsx +3 -6
- package/src/ui/components/update-available-modal.tsx +42 -0
- package/src/ui/hooks/use-busy-spinner.ts +18 -0
- package/src/ui/keymap-context.ts +39 -0
- package/src/ui/root.tsx +16 -0
- package/src/ui/session-ordering.ts +34 -0
- package/src/ui/status-bar-model.ts +67 -39
- package/src/update/version-check.ts +67 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events'
|
|
2
2
|
|
|
3
|
-
import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
|
|
3
|
+
import type { AssistantId, ScrollIntent, WorkspaceSnapshotV1 } from '../state/types'
|
|
4
4
|
import type { SessionBackend, SessionBackendEvents } from './types'
|
|
5
5
|
|
|
6
6
|
import { SessionManager } from '../daemon/session-manager'
|
|
@@ -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
|
|
@@ -114,6 +136,13 @@ export class LocalSessionBackend
|
|
|
114
136
|
this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
|
|
115
137
|
}
|
|
116
138
|
|
|
139
|
+
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
140
|
+
if (!this.currentSessionId) {
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
this.sessionManager.reapplyScrollIntent(this.currentSessionId, tabId, intent)
|
|
144
|
+
}
|
|
145
|
+
|
|
117
146
|
setActiveTab(tabId: string | null): void {
|
|
118
147
|
if (!this.currentSessionId) {
|
|
119
148
|
return
|
|
@@ -122,18 +151,18 @@ export class LocalSessionBackend
|
|
|
122
151
|
this.sessionManager.setActiveTab(this.currentSessionId, tabId)
|
|
123
152
|
}
|
|
124
153
|
|
|
125
|
-
resizeAll(cols: number, rows: number): void {
|
|
154
|
+
resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
|
|
126
155
|
if (!this.currentSessionId) {
|
|
127
156
|
return
|
|
128
157
|
}
|
|
129
|
-
this.sessionManager.resize(this.currentSessionId, cols, rows)
|
|
158
|
+
this.sessionManager.resize(this.currentSessionId, cols, rows, intents)
|
|
130
159
|
}
|
|
131
160
|
|
|
132
|
-
resizeTab(tabId: string, cols: number, rows: number): void {
|
|
161
|
+
resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
|
|
133
162
|
if (!this.currentSessionId) {
|
|
134
163
|
return
|
|
135
164
|
}
|
|
136
|
-
this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows)
|
|
165
|
+
this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent)
|
|
137
166
|
}
|
|
138
167
|
|
|
139
168
|
disposeSession(tabId: string): void {
|
|
@@ -159,6 +188,11 @@ export class LocalSessionBackend
|
|
|
159
188
|
this.sessionManager.disposeSession(this.currentSessionId)
|
|
160
189
|
}
|
|
161
190
|
}
|
|
191
|
+
for (const timer of this.sessionIdleTimers.values()) {
|
|
192
|
+
clearTimeout(timer)
|
|
193
|
+
}
|
|
194
|
+
this.sessionIdleTimers.clear()
|
|
195
|
+
this.sessionBusy.clear()
|
|
162
196
|
this.currentSessionId = null
|
|
163
197
|
}
|
|
164
198
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
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
7
|
import { getIpcDaemonSocketPath } from '../daemon/runtime-paths'
|
|
@@ -376,6 +376,17 @@ export class RemoteSessionBackend
|
|
|
376
376
|
}).catch((error) => this.reportCommandError('scrollToBottom', error, tabId))
|
|
377
377
|
}
|
|
378
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
|
+
|
|
379
390
|
setActiveTab(tabId: string | null): void {
|
|
380
391
|
if (!this.attached) {
|
|
381
392
|
return
|
|
@@ -387,25 +398,27 @@ export class RemoteSessionBackend
|
|
|
387
398
|
}).catch((error) => this.reportCommandError('setActiveTab', error))
|
|
388
399
|
}
|
|
389
400
|
|
|
390
|
-
resizeAll(cols: number, rows: number): void {
|
|
401
|
+
resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
|
|
391
402
|
if (!this.attached) {
|
|
392
403
|
logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
|
|
393
404
|
return
|
|
394
405
|
}
|
|
406
|
+
logDebug('backend.remote.resize', { cols, rows, sessionId: this.currentSessionId })
|
|
407
|
+
const intentsRecord = intents ? Object.fromEntries(intents.entries()) : undefined
|
|
395
408
|
void this.sendExpectOk({
|
|
396
409
|
id: crypto.randomUUID(),
|
|
397
|
-
payload: { cols, rows },
|
|
410
|
+
payload: { cols, intents: intentsRecord, rows },
|
|
398
411
|
type: 'resizeClient',
|
|
399
412
|
}).catch((error) => this.reportCommandError('resizeClient', error))
|
|
400
413
|
}
|
|
401
414
|
|
|
402
|
-
resizeTab(tabId: string, cols: number, rows: number): void {
|
|
415
|
+
resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
|
|
403
416
|
if (!this.attached) {
|
|
404
417
|
return
|
|
405
418
|
}
|
|
406
419
|
void this.sendExpectOk({
|
|
407
420
|
id: crypto.randomUUID(),
|
|
408
|
-
payload: { cols, rows, tabId },
|
|
421
|
+
payload: { cols, intent, rows, tabId },
|
|
409
422
|
type: 'resizeTab',
|
|
410
423
|
}).catch((error) => this.reportCommandError('resizeTab', error, tabId))
|
|
411
424
|
}
|
|
@@ -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,
|
|
@@ -11,6 +12,7 @@ export type SessionBackendEvents = {
|
|
|
11
12
|
render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
|
|
12
13
|
exit: [tabId: string, exitCode: number]
|
|
13
14
|
error: [tabId: string, message: string]
|
|
15
|
+
sessionActivity: [sessionId: string, busy: boolean]
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
export interface BackendAttachResult {
|
|
@@ -38,9 +40,10 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
|
|
|
38
40
|
write(tabId: string, input: string): void
|
|
39
41
|
scrollViewport(tabId: string, deltaLines: number): void
|
|
40
42
|
scrollViewportToBottom(tabId: string): void
|
|
43
|
+
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void
|
|
41
44
|
setActiveTab(tabId: string | null): void
|
|
42
|
-
resizeAll(cols: number, rows: number): void
|
|
43
|
-
resizeTab(tabId: string, cols: number, rows: number): void
|
|
45
|
+
resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void
|
|
46
|
+
resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void
|
|
44
47
|
disposeSession(tabId: string): void
|
|
45
48
|
disposeAll(): void
|
|
46
49
|
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
|
+
}
|
|
@@ -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)
|
|
@@ -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
|
}
|
|
@@ -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,
|
|
@@ -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
|
+
}
|
|
@@ -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,
|
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
|
@@ -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
|
}
|
|
@@ -93,9 +105,17 @@ export interface SessionRecord {
|
|
|
93
105
|
createdAt: string
|
|
94
106
|
updatedAt: string
|
|
95
107
|
lastOpenedAt: string
|
|
108
|
+
order?: number
|
|
96
109
|
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
97
110
|
}
|
|
98
111
|
|
|
112
|
+
export type SessionBarPosition = 'top' | 'bottom'
|
|
113
|
+
|
|
114
|
+
export interface SessionBarState {
|
|
115
|
+
visible: boolean
|
|
116
|
+
position: SessionBarPosition
|
|
117
|
+
}
|
|
118
|
+
|
|
99
119
|
export interface TabSession {
|
|
100
120
|
id: string
|
|
101
121
|
assistant: AssistantId
|
|
@@ -105,6 +125,7 @@ export interface TabSession {
|
|
|
105
125
|
buffer: string
|
|
106
126
|
viewport?: TerminalSnapshot
|
|
107
127
|
terminalModes: TerminalModeState
|
|
128
|
+
scrollIntent?: ScrollIntent
|
|
108
129
|
command: string
|
|
109
130
|
errorMessage?: string
|
|
110
131
|
exitCode?: number
|
|
@@ -228,6 +249,12 @@ export interface ModalSnippetEditor extends ModalBase {
|
|
|
228
249
|
contentBuffer: string
|
|
229
250
|
}
|
|
230
251
|
|
|
252
|
+
export interface ModalUpdateAvailable extends ModalBase {
|
|
253
|
+
type: 'update-available'
|
|
254
|
+
currentVersion: string
|
|
255
|
+
latestVersion: string
|
|
256
|
+
}
|
|
257
|
+
|
|
231
258
|
export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
|
|
232
259
|
|
|
233
260
|
export interface DirectoryResult {
|
|
@@ -248,6 +275,7 @@ export type ModalState =
|
|
|
248
275
|
| ModalCreateSession
|
|
249
276
|
| ModalSnippetEditor
|
|
250
277
|
| ModalGitCommit
|
|
278
|
+
| ModalUpdateAvailable
|
|
251
279
|
|
|
252
280
|
export interface LayoutState {
|
|
253
281
|
terminalCols: number
|
|
@@ -267,6 +295,8 @@ export interface AppState {
|
|
|
267
295
|
tabGroupMap: Record<string, string>
|
|
268
296
|
sessions: SessionRecord[]
|
|
269
297
|
currentSessionId: string | null
|
|
298
|
+
sessionsBusy: Record<string, boolean>
|
|
299
|
+
sessionBar: SessionBarState
|
|
270
300
|
snippets: SnippetRecord[]
|
|
271
301
|
focusMode: FocusMode
|
|
272
302
|
sidebar: SidebarState
|
|
@@ -303,6 +333,7 @@ export type ModalAction =
|
|
|
303
333
|
| { type: 'open-snippet-editor'; snippetId?: string }
|
|
304
334
|
| { type: 'begin-snippet-filter' }
|
|
305
335
|
| { type: 'open-theme-picker' }
|
|
336
|
+
| { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
|
|
306
337
|
|
|
307
338
|
// -- Session actions --
|
|
308
339
|
export type SessionAction =
|
|
@@ -311,6 +342,8 @@ export type SessionAction =
|
|
|
311
342
|
| { type: 'create-session-record'; session: SessionRecord }
|
|
312
343
|
| { type: 'rename-session-record'; sessionId: string; name: string }
|
|
313
344
|
| { type: 'delete-session-record'; sessionId: string }
|
|
345
|
+
| { type: 'reorder-sessions'; orderedIds: string[] }
|
|
346
|
+
| { type: 'set-session-busy'; sessionId: string; busy: boolean }
|
|
314
347
|
|
|
315
348
|
// -- Tab actions --
|
|
316
349
|
export type TabAction =
|
|
@@ -336,7 +369,9 @@ export type TabAction =
|
|
|
336
369
|
tabId: string
|
|
337
370
|
viewport: TerminalSnapshot
|
|
338
371
|
terminalModes: TerminalModeState
|
|
372
|
+
source?: 'resize' | 'scroll' | 'data' | 'switch'
|
|
339
373
|
}
|
|
374
|
+
| { type: 'set-scroll-intent'; tabId: string; intent: ScrollIntent }
|
|
340
375
|
| { type: 'set-tab-activity'; tabId: string; activity?: TabActivity }
|
|
341
376
|
| { type: 'set-tab-status'; tabId: string; status: TabStatus; exitCode?: number }
|
|
342
377
|
| { type: 'set-tab-error'; tabId: string; message: string }
|
|
@@ -375,6 +410,8 @@ export type UIAction =
|
|
|
375
410
|
| { type: 'toggle-git-panel' }
|
|
376
411
|
| { type: 'resize-git-panel'; delta: number }
|
|
377
412
|
| { type: 'set-pending-chords'; chords: string[] | null }
|
|
413
|
+
| { type: 'toggle-session-bar' }
|
|
414
|
+
| { type: 'set-session-bar-position'; position: SessionBarPosition }
|
|
378
415
|
|
|
379
416
|
// -- Git panel actions --
|
|
380
417
|
export interface GitRefreshPayload {
|
package/src/state/validation.ts
CHANGED
|
@@ -55,6 +55,13 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
|
|
|
55
55
|
)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
function isScrollIntent(value: unknown): boolean {
|
|
59
|
+
if (!isObjectRecord(value)) return false
|
|
60
|
+
if (value.kind === 'bottom') return true
|
|
61
|
+
if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
58
65
|
function isTerminalModeState(value: unknown): value is TerminalModeState {
|
|
59
66
|
return (
|
|
60
67
|
isObjectRecord(value) &&
|
|
@@ -125,6 +132,7 @@ export function isWorkspaceSnapshotV1(value: unknown): value is WorkspaceSnapsho
|
|
|
125
132
|
isString(tab.buffer) &&
|
|
126
133
|
isTerminalModeState(tab.terminalModes) &&
|
|
127
134
|
(tab.viewport === undefined || isTerminalSnapshot(tab.viewport)) &&
|
|
135
|
+
(tab.scrollIntent === undefined || isScrollIntent(tab.scrollIntent)) &&
|
|
128
136
|
(tab.errorMessage === undefined || isString(tab.errorMessage)) &&
|
|
129
137
|
(tab.exitCode === undefined || isFiniteNumber(tab.exitCode))
|
|
130
138
|
) &&
|
|
@@ -143,6 +151,8 @@ export function isSessionRecord(value: unknown): value is SessionRecord {
|
|
|
143
151
|
isString(value.createdAt) &&
|
|
144
152
|
isString(value.updatedAt) &&
|
|
145
153
|
isString(value.lastOpenedAt) &&
|
|
154
|
+
(value.order === undefined ||
|
|
155
|
+
(typeof value.order === 'number' && Number.isFinite(value.order))) &&
|
|
146
156
|
(value.workspaceSnapshot === undefined || isWorkspaceSnapshotV1(value.workspaceSnapshot))
|
|
147
157
|
)
|
|
148
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)
|