@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.
Files changed (60) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -2
  3. package/src/app-runtime/backend-runtime-events.ts +13 -1
  4. package/src/app-runtime/pty-write.ts +7 -4
  5. package/src/app-runtime/side-effects.ts +72 -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 +113 -37
  11. package/src/config.ts +32 -1
  12. package/src/daemon/daemon.ts +19 -2
  13. package/src/daemon/session-manager.ts +20 -5
  14. package/src/daemon/session-registry.ts +18 -11
  15. package/src/index.tsx +8 -1
  16. package/src/input/keymap/describe-bindings.ts +68 -0
  17. package/src/input/keymap/key-format.ts +67 -0
  18. package/src/input/modes/bridge.ts +1 -0
  19. package/src/input/modes/transitions.ts +2 -0
  20. package/src/input/modes/types.ts +3 -0
  21. package/src/ipc/manager-protocol.ts +51 -2
  22. package/src/ipc/protocol.ts +43 -2
  23. package/src/pty/pty-manager.ts +29 -3
  24. package/src/session-backend/local-session-backend.ts +39 -5
  25. package/src/session-backend/remote-session-backend.ts +18 -5
  26. package/src/session-backend/types.ts +5 -2
  27. package/src/state/dispatch-ref.ts +11 -0
  28. package/src/state/reducers/modal-state.ts +18 -1
  29. package/src/state/reducers/session-state.ts +28 -0
  30. package/src/state/reducers/tab-state.ts +20 -2
  31. package/src/state/reducers/ui-state.ts +8 -0
  32. package/src/state/session-catalog.ts +22 -1
  33. package/src/state/session-persistence.ts +9 -2
  34. package/src/state/store.ts +8 -1
  35. package/src/state/types.ts +37 -0
  36. package/src/state/validation.ts +10 -0
  37. package/src/state/workspace-save.ts +2 -0
  38. package/src/terminal-manager/manager-client.ts +29 -5
  39. package/src/terminal-manager/terminal-manager.ts +19 -3
  40. package/src/ui/components/create-session-modal.tsx +3 -5
  41. package/src/ui/components/git-commit-modal.tsx +3 -5
  42. package/src/ui/components/help-modal.tsx +49 -68
  43. package/src/ui/components/list-item.tsx +24 -5
  44. package/src/ui/components/new-tab-modal.tsx +8 -9
  45. package/src/ui/components/pending-chord-overlay.tsx +28 -0
  46. package/src/ui/components/session-bar.tsx +208 -0
  47. package/src/ui/components/session-name-modal.tsx +3 -5
  48. package/src/ui/components/session-picker-modal.tsx +3 -1
  49. package/src/ui/components/snippet-editor-modal.tsx +3 -1
  50. package/src/ui/components/snippet-picker-modal.tsx +3 -1
  51. package/src/ui/components/status-bar.tsx +6 -2
  52. package/src/ui/components/tab-item.tsx +3 -15
  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/hooks/use-busy-spinner.ts +18 -0
  56. package/src/ui/keymap-context.ts +39 -0
  57. package/src/ui/root.tsx +16 -0
  58. package/src/ui/session-ordering.ts +34 -0
  59. package/src/ui/status-bar-model.ts +67 -39
  60. 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,
@@ -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,
@@ -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 {
@@ -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)