@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.
Files changed (59) hide show
  1. package/README.md +29 -8
  2. package/package.json +5 -5
  3. package/src/app-runtime/backend-runtime-events.ts +16 -1
  4. package/src/app-runtime/pty-write.ts +7 -4
  5. package/src/app-runtime/side-effects.ts +62 -4
  6. package/src/app-runtime/snippet-actions.ts +3 -2
  7. package/src/app-runtime/use-backend-runtime.ts +7 -2
  8. package/src/app-runtime/use-renderer-bindings.ts +2 -2
  9. package/src/app-runtime/use-terminal-resize.ts +15 -6
  10. package/src/app.tsx +89 -30
  11. package/src/config/loader.ts +2 -2
  12. package/src/config.ts +14 -3
  13. package/src/daemon/daemon.ts +279 -118
  14. package/src/daemon/runtime-paths.ts +34 -2
  15. package/src/daemon/session-manager.ts +20 -5
  16. package/src/daemon/session-registry.ts +18 -11
  17. package/src/index.tsx +19 -4
  18. package/src/input/keymap/describe-bindings.ts +68 -0
  19. package/src/input/keymap/key-format.ts +67 -0
  20. package/src/input/modes/bridge.ts +1 -0
  21. package/src/input/modes/transitions.ts +2 -0
  22. package/src/input/modes/types.ts +2 -0
  23. package/src/ipc/manager-protocol.ts +396 -0
  24. package/src/ipc/protocol.ts +98 -5
  25. package/src/platform/daemon-control.ts +42 -11
  26. package/src/profile-paths.ts +27 -0
  27. package/src/pty/pty-manager.ts +53 -3
  28. package/src/restart-daemon.ts +10 -10
  29. package/src/session-backend/bootstrap.ts +197 -46
  30. package/src/session-backend/local-session-backend.ts +12 -5
  31. package/src/session-backend/remote-session-backend.ts +143 -63
  32. package/src/session-backend/types.ts +4 -2
  33. package/src/state/reducers/modal-state.ts +18 -1
  34. package/src/state/reducers/tab-state.ts +20 -2
  35. package/src/state/session-catalog.ts +5 -4
  36. package/src/state/session-persistence.ts +9 -2
  37. package/src/state/snippet-catalog.ts +4 -4
  38. package/src/state/types.ts +23 -0
  39. package/src/state/validation.ts +8 -0
  40. package/src/terminal-manager/manager-client.ts +384 -0
  41. package/src/terminal-manager/terminal-manager.ts +288 -0
  42. package/src/ui/components/create-session-modal.tsx +3 -5
  43. package/src/ui/components/git-commit-modal.tsx +3 -5
  44. package/src/ui/components/help-modal.tsx +49 -68
  45. package/src/ui/components/list-item.tsx +24 -5
  46. package/src/ui/components/new-tab-modal.tsx +8 -9
  47. package/src/ui/components/pending-chord-overlay.tsx +28 -0
  48. package/src/ui/components/session-name-modal.tsx +3 -5
  49. package/src/ui/components/session-picker-modal.tsx +3 -1
  50. package/src/ui/components/snippet-editor-modal.tsx +3 -1
  51. package/src/ui/components/snippet-picker-modal.tsx +3 -1
  52. package/src/ui/components/status-bar.tsx +6 -2
  53. package/src/ui/components/theme-picker-modal.tsx +3 -6
  54. package/src/ui/components/update-available-modal.tsx +42 -0
  55. package/src/ui/keymap-context.ts +39 -0
  56. package/src/ui/root.tsx +12 -0
  57. package/src/ui/status-bar-model.ts +67 -39
  58. package/src/update/version-check.ts +67 -0
  59. 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 { getDaemonSocketPath } from '../daemon/runtime-paths'
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 resetConnection(reason: string): void {
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.currentSessionId = null
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 attach(options: {
144
- sessionId: string
145
- cols: number
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
- logDebug('backend.remote.socketError', { error: error.message })
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
- logDebug('backend.remote.socketClose')
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
- logDebug('backend.remote.socketError', { error: message })
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: { ...options, protocolVersion: IPC_PROTOCOL_VERSION },
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
- logDebug('backend.remote.attach.unexpected', { type: response.type })
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 !== IPC_PROTOCOL_VERSION) {
226
- this.resetConnection(
227
- `Protocol mismatch: client v${IPC_PROTOCOL_VERSION}, daemon v${response.payload.protocolVersion}`
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: response.payload.activeTabId,
317
+ activeTabId: result.activeTabId,
236
318
  sessionId: options.sessionId,
237
- tabs: response.payload.tabs.length,
319
+ tabs: result.tabs.length,
238
320
  })
239
321
 
240
- return response.payload
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
- if (!keepSessions) {
446
+ this.shouldReconnect = false
447
+ this.reconnectPromise = null
448
+ if (!keepSessions && this.attached) {
369
449
  this.disposeAll()
370
450
  }
371
- this.resetConnection('Remote backend destroyed')
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 { dirname, join } from 'node:path'
2
+ import { join } from 'node:path'
3
3
 
4
4
  import type { SessionRecord } from './types'
5
5
 
6
- import { CONFIG_PATH, loadConfig, saveConfig } from '../config'
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(dirname(CONFIG_PATH), 'aimux-sessions.json')
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(dirname(SESSIONS_PATH), { recursive: true })
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 { dirname, join } from 'node:path'
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(dirname(CONFIG_PATH), 'aimux-snippets.json')
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(dirname(SNIPPETS_PATH), { recursive: true })
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', {
@@ -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 }