@brimveyn/aimux 1.3.0 → 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 (49) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/backend-runtime-events.ts +7 -1
  3. package/src/app-runtime/pty-write.ts +7 -4
  4. package/src/app-runtime/side-effects.ts +54 -4
  5. package/src/app-runtime/snippet-actions.ts +3 -2
  6. package/src/app-runtime/use-backend-runtime.ts +7 -2
  7. package/src/app-runtime/use-renderer-bindings.ts +2 -2
  8. package/src/app-runtime/use-terminal-resize.ts +15 -6
  9. package/src/app.tsx +89 -30
  10. package/src/config.ts +11 -0
  11. package/src/daemon/daemon.ts +19 -2
  12. package/src/daemon/session-manager.ts +20 -5
  13. package/src/daemon/session-registry.ts +18 -11
  14. package/src/index.tsx +8 -1
  15. package/src/input/keymap/describe-bindings.ts +68 -0
  16. package/src/input/keymap/key-format.ts +67 -0
  17. package/src/input/modes/bridge.ts +1 -0
  18. package/src/input/modes/transitions.ts +2 -0
  19. package/src/input/modes/types.ts +2 -0
  20. package/src/ipc/manager-protocol.ts +51 -2
  21. package/src/ipc/protocol.ts +43 -2
  22. package/src/pty/pty-manager.ts +29 -3
  23. package/src/session-backend/local-session-backend.ts +12 -5
  24. package/src/session-backend/remote-session-backend.ts +18 -5
  25. package/src/session-backend/types.ts +4 -2
  26. package/src/state/reducers/modal-state.ts +18 -1
  27. package/src/state/reducers/tab-state.ts +20 -2
  28. package/src/state/session-persistence.ts +9 -2
  29. package/src/state/types.ts +23 -0
  30. package/src/state/validation.ts +8 -0
  31. package/src/terminal-manager/manager-client.ts +29 -5
  32. package/src/terminal-manager/terminal-manager.ts +19 -3
  33. package/src/ui/components/create-session-modal.tsx +3 -5
  34. package/src/ui/components/git-commit-modal.tsx +3 -5
  35. package/src/ui/components/help-modal.tsx +49 -68
  36. package/src/ui/components/list-item.tsx +24 -5
  37. package/src/ui/components/new-tab-modal.tsx +8 -9
  38. package/src/ui/components/pending-chord-overlay.tsx +28 -0
  39. package/src/ui/components/session-name-modal.tsx +3 -5
  40. package/src/ui/components/session-picker-modal.tsx +3 -1
  41. package/src/ui/components/snippet-editor-modal.tsx +3 -1
  42. package/src/ui/components/snippet-picker-modal.tsx +3 -1
  43. package/src/ui/components/status-bar.tsx +6 -2
  44. package/src/ui/components/theme-picker-modal.tsx +3 -6
  45. package/src/ui/components/update-available-modal.tsx +42 -0
  46. package/src/ui/keymap-context.ts +39 -0
  47. package/src/ui/root.tsx +12 -0
  48. package/src/ui/status-bar-model.ts +67 -39
  49. package/src/update/version-check.ts +67 -0
@@ -1,12 +1,5 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
 
3
- import type {
4
- TabSession,
5
- TerminalModeState,
6
- TerminalSnapshot,
7
- WorkspaceSnapshotV1,
8
- } from '../state/types'
9
-
10
3
  import { logDebug } from '../debug/input-log'
11
4
  import { PtyManager } from '../pty/pty-manager'
12
5
  import {
@@ -15,6 +8,14 @@ import {
15
8
  restoreTabsFromWorkspace,
16
9
  } from '../state/session-persistence'
17
10
  import { createDefaultTerminalModes } from '../state/terminal-modes'
11
+ import {
12
+ DEFAULT_SCROLL_INTENT,
13
+ type ScrollIntent,
14
+ type TabSession,
15
+ type TerminalModeState,
16
+ type TerminalSnapshot,
17
+ type WorkspaceSnapshotV1,
18
+ } from '../state/types'
18
19
 
19
20
  type SessionRegistryEvents = {
20
21
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
@@ -138,6 +139,7 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
138
139
  buffer: '',
139
140
  command: [options.command, ...(options.args ?? [])].join(' '),
140
141
  id: options.tabId,
142
+ scrollIntent: DEFAULT_SCROLL_INTENT,
141
143
  status: 'starting',
142
144
  terminalModes: createDefaultTerminalModes(),
143
145
  title: options.title,
@@ -149,6 +151,7 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
149
151
  existing.exitCode = undefined
150
152
  existing.viewport = undefined
151
153
  existing.terminalModes = createDefaultTerminalModes()
154
+ existing.scrollIntent = DEFAULT_SCROLL_INTENT
152
155
  existing.assistant = options.assistant
153
156
  existing.title = options.title
154
157
  existing.command = [options.command, ...(options.args ?? [])].join(' ')
@@ -162,12 +165,12 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
162
165
  this.ptyManager.write(tabId, data)
163
166
  }
164
167
 
165
- resizeAll(cols: number, rows: number): void {
166
- this.ptyManager.resizeAll(cols, rows)
168
+ resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
169
+ this.ptyManager.resizeAll(cols, rows, intents)
167
170
  }
168
171
 
169
- resizeTab(tabId: string, cols: number, rows: number): void {
170
- this.ptyManager.resizeSession(tabId, cols, rows)
172
+ resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
173
+ this.ptyManager.resizeSession(tabId, cols, rows, intent)
171
174
  }
172
175
 
173
176
  scrollViewport(tabId: string, deltaLines: number): void {
@@ -178,6 +181,10 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
178
181
  this.ptyManager.scrollViewportToBottom(tabId)
179
182
  }
180
183
 
184
+ reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
185
+ this.ptyManager.reapplyScrollIntent(tabId, intent)
186
+ }
187
+
181
188
  setActiveTab(tabId: string | null): void {
182
189
  if (tabId === null) {
183
190
  this.activeTabId = null
package/src/index.tsx CHANGED
@@ -3,6 +3,7 @@ import { createCliRenderer } from '@opentui/core'
3
3
  import { createRoot } from '@opentui/react'
4
4
 
5
5
  import { App } from './app'
6
+ import { loadUserConfig } from './config/loader'
6
7
  import { runDaemon } from './daemon/daemon'
7
8
  import { getRuntimeProfile } from './daemon/runtime-paths'
8
9
  import { logDebug } from './debug/input-log'
@@ -61,4 +62,10 @@ const renderer = await createCliRenderer({
61
62
  const backend = await createSessionBackend()
62
63
  logDebug('index.backendReady', { backend: backend.constructor.name, runtimeProfile })
63
64
 
64
- createRoot(renderer).render(<App backend={backend} />)
65
+ const resolvedConfig = await loadUserConfig()
66
+ logDebug('index.userConfigLoaded', {
67
+ leader: resolvedConfig.keymaps.leader,
68
+ modeCount: resolvedConfig.keymaps.modes.size,
69
+ })
70
+
71
+ createRoot(renderer).render(<App backend={backend} resolvedConfig={resolvedConfig} />)
@@ -0,0 +1,68 @@
1
+ import type { ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
2
+
3
+ import { parseKeyNotation } from './key-chord'
4
+ import { formatNotationForDisplay } from './key-format'
5
+
6
+ export interface DescribedBinding {
7
+ keys: string
8
+ keysDisplay: string
9
+ description?: string
10
+ group?: string
11
+ }
12
+
13
+ export interface DescribedBindingGroup {
14
+ group?: string
15
+ bindings: DescribedBinding[]
16
+ }
17
+
18
+ interface DescribeOptions {
19
+ withDescriptionOnly?: boolean
20
+ dedupeByDescription?: boolean
21
+ }
22
+
23
+ export function describeBindings(
24
+ config: ResolvedKeymapConfig,
25
+ modeId: ModeId,
26
+ options: DescribeOptions = {}
27
+ ): DescribedBinding[] {
28
+ const mode = config.modes.get(modeId)
29
+ if (!mode) return []
30
+
31
+ const leaderChord = parseKeyNotation(config.leader)[0]
32
+ const seenDescriptions = new Set<string>()
33
+ const result: DescribedBinding[] = []
34
+
35
+ for (const binding of mode.bindings) {
36
+ if (options.withDescriptionOnly && !binding.description) continue
37
+ if (options.dedupeByDescription && binding.description) {
38
+ if (seenDescriptions.has(binding.description)) continue
39
+ seenDescriptions.add(binding.description)
40
+ }
41
+ result.push({
42
+ description: binding.description,
43
+ group: binding.group,
44
+ keys: binding.keys,
45
+ keysDisplay: formatNotationForDisplay(binding.keys, leaderChord),
46
+ })
47
+ }
48
+
49
+ return result
50
+ }
51
+
52
+ export function groupDescribedBindings(bindings: DescribedBinding[]): DescribedBindingGroup[] {
53
+ const groups: DescribedBindingGroup[] = []
54
+ const indexByLabel = new Map<string | undefined, number>()
55
+
56
+ for (const binding of bindings) {
57
+ const label = binding.group
58
+ const existing = indexByLabel.get(label)
59
+ if (existing === undefined) {
60
+ indexByLabel.set(label, groups.length)
61
+ groups.push({ bindings: [binding], group: label })
62
+ } else {
63
+ groups[existing]?.bindings.push(binding)
64
+ }
65
+ }
66
+
67
+ return groups
68
+ }
@@ -0,0 +1,67 @@
1
+ import { type KeyChord, parseKeyNotation } from './key-chord'
2
+
3
+ const SPECIAL_DISPLAY: Record<string, string> = {
4
+ backspace: 'Backspace',
5
+ down: '↓',
6
+ escape: 'Esc',
7
+ left: '←',
8
+ return: 'Enter',
9
+ right: '→',
10
+ space: 'Space',
11
+ tab: 'Tab',
12
+ up: '↑',
13
+ }
14
+
15
+ function formatModifierKey(baseName: string): string {
16
+ const special = SPECIAL_DISPLAY[baseName]
17
+ if (special) return special
18
+ if (baseName.length === 1) return baseName.toUpperCase()
19
+ return baseName.charAt(0).toUpperCase() + baseName.slice(1)
20
+ }
21
+
22
+ /** Convert a single canonical KeyChord back to a human-readable label. */
23
+ export function formatChord(chord: KeyChord, leader?: KeyChord): string {
24
+ if (leader && chord === leader) return '<leader>'
25
+
26
+ let ctrl = false
27
+ let meta = false
28
+ let rest = chord
29
+
30
+ while (true) {
31
+ if (rest.startsWith('C-')) {
32
+ ctrl = true
33
+ rest = rest.slice(2)
34
+ continue
35
+ }
36
+ if (rest.startsWith('M-')) {
37
+ meta = true
38
+ rest = rest.slice(2)
39
+ continue
40
+ }
41
+ break
42
+ }
43
+
44
+ if (!ctrl && !meta) {
45
+ if (rest.length === 1 && /^[A-Z]$/.test(rest)) {
46
+ return `Shift+${rest}`
47
+ }
48
+ return SPECIAL_DISPLAY[rest] ?? rest
49
+ }
50
+
51
+ const parts: string[] = []
52
+ if (ctrl) parts.push('Ctrl')
53
+ if (meta) parts.push('Alt')
54
+ parts.push(formatModifierKey(rest))
55
+ return parts.join('+')
56
+ }
57
+
58
+ /** Convert a raw key notation string (e.g. `<C-n>`, `dd`) to a human label. */
59
+ export function formatNotationForDisplay(notation: string, leader?: KeyChord): string {
60
+ let chords: KeyChord[]
61
+ try {
62
+ chords = parseKeyNotation(notation, leader)
63
+ } catch {
64
+ return notation
65
+ }
66
+ return chords.map((c) => formatChord(c, leader)).join(' ')
67
+ }
@@ -28,6 +28,7 @@ const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
28
28
  'snippet-picker': 'modal.snippet-picker',
29
29
  'split-picker': 'modal.split-picker',
30
30
  'theme-picker': 'modal.theme-picker',
31
+ 'update-available': 'modal.update-available',
31
32
  }
32
33
 
33
34
  export function deriveModeId(state: AppState): ModeId {
@@ -22,6 +22,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
22
22
  'modal.snippet-picker.filtering': ['modal.snippet-picker'],
23
23
  'modal.split-picker': ['navigation'],
24
24
  'modal.theme-picker': ['navigation'],
25
+ 'modal.update-available': ['navigation'],
25
26
  'navigation': [
26
27
  'terminal-input',
27
28
  'modal.new-tab',
@@ -30,6 +31,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
30
31
  'modal.snippet-picker',
31
32
  'modal.theme-picker',
32
33
  'modal.rename-tab',
34
+ 'modal.update-available',
33
35
  'git-mode',
34
36
  ],
35
37
  'terminal-input': ['navigation', 'layout'],
@@ -21,6 +21,7 @@ export type ModeId =
21
21
  | 'modal.help'
22
22
  | 'modal.split-picker'
23
23
  | 'modal.git-commit'
24
+ | 'modal.update-available'
24
25
 
25
26
  export type SideEffect =
26
27
  | { type: 'quit'; state: AppState }
@@ -51,6 +52,7 @@ export type SideEffect =
51
52
  | { type: 'git-rm'; path: string }
52
53
  | { type: 'git-commit'; title: string; body: string }
53
54
  | { type: 'git-push' }
55
+ | { type: 'confirm-update-selection' }
54
56
 
55
57
  export interface KeyResult {
56
58
  actions: AppAction[]
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ScrollIntent,
2
3
  TabSession,
3
4
  TerminalModeState,
4
5
  TerminalSnapshot,
@@ -61,11 +62,26 @@ export type ManagerRequest =
61
62
  }
62
63
  }
63
64
  | { id: string; type: 'write'; payload: { sessionId: string; tabId: string; data: string } }
64
- | { id: string; type: 'resizeClient'; payload: { sessionId: string; cols: number; rows: number } }
65
+ | {
66
+ id: string
67
+ type: 'resizeClient'
68
+ payload: {
69
+ sessionId: string
70
+ cols: number
71
+ rows: number
72
+ intents?: Record<string, ScrollIntent>
73
+ }
74
+ }
65
75
  | {
66
76
  id: string
67
77
  type: 'resizeTab'
68
- payload: { sessionId: string; tabId: string; cols: number; rows: number }
78
+ payload: {
79
+ sessionId: string
80
+ tabId: string
81
+ cols: number
82
+ rows: number
83
+ intent?: ScrollIntent
84
+ }
69
85
  }
70
86
  | {
71
87
  id: string
@@ -77,6 +93,11 @@ export type ManagerRequest =
77
93
  type: 'scroll'
78
94
  payload: { sessionId: string; tabId: string; deltaLines: number }
79
95
  }
96
+ | {
97
+ id: string
98
+ type: 'reapplyScrollIntent'
99
+ payload: { sessionId: string; tabId: string; intent: ScrollIntent }
100
+ }
80
101
  | { id: string; type: 'setActiveTab'; payload: { sessionId: string; tabId: string | null } }
81
102
  | { id: string; type: 'closeTab'; payload: { sessionId: string; tabId: string } }
82
103
  | { id: string; type: 'disposeSession'; payload: { sessionId: string } }
@@ -150,6 +171,18 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
150
171
  )
151
172
  }
152
173
 
174
+ function isScrollIntent(value: unknown): value is ScrollIntent {
175
+ if (!isObjectRecord(value)) return false
176
+ if (value.kind === 'bottom') return true
177
+ if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
178
+ return false
179
+ }
180
+
181
+ function isScrollIntentRecord(value: unknown): value is Record<string, ScrollIntent> {
182
+ if (!isObjectRecord(value)) return false
183
+ return Object.values(value).every(isScrollIntent)
184
+ }
185
+
153
186
  function isTerminalModeState(value: unknown): value is TerminalModeState {
154
187
  return (
155
188
  isObjectRecord(value) &&
@@ -263,12 +296,20 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
263
296
  assert(isString(value.payload.sessionId), 'resizeClient.sessionId must be a string')
264
297
  assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
265
298
  assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
299
+ assert(
300
+ value.payload.intents === undefined || isScrollIntentRecord(value.payload.intents),
301
+ 'resizeClient.intents must be a scroll-intent record'
302
+ )
266
303
  return value as ManagerRequest
267
304
  case 'resizeTab':
268
305
  assert(isString(value.payload.sessionId), 'resizeTab.sessionId must be a string')
269
306
  assert(isString(value.payload.tabId), 'resizeTab.tabId must be a string')
270
307
  assert(isFiniteNumber(value.payload.cols), 'resizeTab.cols must be a number')
271
308
  assert(isFiniteNumber(value.payload.rows), 'resizeTab.rows must be a number')
309
+ assert(
310
+ value.payload.intent === undefined || isScrollIntent(value.payload.intent),
311
+ 'resizeTab.intent must be a scroll intent'
312
+ )
272
313
  return value as ManagerRequest
273
314
  case 'scroll':
274
315
  assert(isString(value.payload.sessionId), 'scroll.sessionId must be a string')
@@ -279,6 +320,14 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
279
320
  assert(isString(value.payload.sessionId), 'scrollToBottom.sessionId must be a string')
280
321
  assert(isString(value.payload.tabId), 'scrollToBottom.tabId must be a string')
281
322
  return value as ManagerRequest
323
+ case 'reapplyScrollIntent':
324
+ assert(isString(value.payload.sessionId), 'reapplyScrollIntent.sessionId must be a string')
325
+ assert(isString(value.payload.tabId), 'reapplyScrollIntent.tabId must be a string')
326
+ assert(
327
+ isScrollIntent(value.payload.intent),
328
+ 'reapplyScrollIntent.intent must be a scroll intent'
329
+ )
330
+ return value as ManagerRequest
282
331
  case 'setActiveTab':
283
332
  assert(isString(value.payload.sessionId), 'setActiveTab.sessionId must be a string')
284
333
  assert(isNullableString(value.payload.tabId), 'setActiveTab.tabId must be a string or null')
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ScrollIntent,
2
3
  TabSession,
3
4
  TerminalModeState,
4
5
  TerminalSnapshot,
@@ -54,10 +55,23 @@ export type ClientRequest =
54
55
  }
55
56
  }
56
57
  | { id: string; type: 'write'; payload: { tabId: string; data: string } }
57
- | { id: string; type: 'resizeClient'; payload: { cols: number; rows: number } }
58
- | { id: string; type: 'resizeTab'; payload: { tabId: string; cols: number; rows: number } }
58
+ | {
59
+ id: string
60
+ type: 'resizeClient'
61
+ payload: { cols: number; rows: number; intents?: Record<string, ScrollIntent> }
62
+ }
63
+ | {
64
+ id: string
65
+ type: 'resizeTab'
66
+ payload: { tabId: string; cols: number; rows: number; intent?: ScrollIntent }
67
+ }
59
68
  | { id: string; type: 'scrollToBottom'; payload: { tabId: string } }
60
69
  | { id: string; type: 'scroll'; payload: { tabId: string; deltaLines: number } }
70
+ | {
71
+ id: string
72
+ type: 'reapplyScrollIntent'
73
+ payload: { tabId: string; intent: ScrollIntent }
74
+ }
61
75
  | { id: string; type: 'setActiveTab'; payload: { tabId: string | null } }
62
76
  | { id: string; type: 'closeTab'; payload: { tabId: string } }
63
77
  | { id: string; type: 'disposeAll'; payload: Record<string, never> }
@@ -143,6 +157,18 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
143
157
  )
144
158
  }
145
159
 
160
+ function isScrollIntent(value: unknown): value is ScrollIntent {
161
+ if (!isObjectRecord(value)) return false
162
+ if (value.kind === 'bottom') return true
163
+ if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
164
+ return false
165
+ }
166
+
167
+ function isScrollIntentRecord(value: unknown): value is Record<string, ScrollIntent> {
168
+ if (!isObjectRecord(value)) return false
169
+ return Object.values(value).every(isScrollIntent)
170
+ }
171
+
146
172
  function isTerminalModeState(value: unknown): value is TerminalModeState {
147
173
  return (
148
174
  isObjectRecord(value) &&
@@ -273,11 +299,19 @@ export function parseClientRequest(value: unknown): ClientRequest {
273
299
  case 'resizeClient':
274
300
  assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
275
301
  assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
302
+ assert(
303
+ value.payload.intents === undefined || isScrollIntentRecord(value.payload.intents),
304
+ 'resizeClient.intents must be a scroll-intent record'
305
+ )
276
306
  return value as ClientRequest
277
307
  case 'resizeTab':
278
308
  assert(isString(value.payload.tabId), 'resizeTab.tabId must be a string')
279
309
  assert(isFiniteNumber(value.payload.cols), 'resizeTab.cols must be a number')
280
310
  assert(isFiniteNumber(value.payload.rows), 'resizeTab.rows must be a number')
311
+ assert(
312
+ value.payload.intent === undefined || isScrollIntent(value.payload.intent),
313
+ 'resizeTab.intent must be a scroll intent'
314
+ )
281
315
  return value as ClientRequest
282
316
  case 'scroll':
283
317
  assert(isString(value.payload.tabId), 'scroll.tabId must be a string')
@@ -286,6 +320,13 @@ export function parseClientRequest(value: unknown): ClientRequest {
286
320
  case 'scrollToBottom':
287
321
  assert(isString(value.payload.tabId), 'scrollToBottom.tabId must be a string')
288
322
  return value as ClientRequest
323
+ case 'reapplyScrollIntent':
324
+ assert(isString(value.payload.tabId), 'reapplyScrollIntent.tabId must be a string')
325
+ assert(
326
+ isScrollIntent(value.payload.intent),
327
+ 'reapplyScrollIntent.intent must be a scroll intent'
328
+ )
329
+ return value as ClientRequest
289
330
  case 'setActiveTab':
290
331
  assert(isNullableString(value.payload.tabId), 'setActiveTab.tabId must be a string or null')
291
332
  return value as ClientRequest
@@ -2,7 +2,7 @@ import { Terminal as XTerm } from '@xterm/headless'
2
2
  import { type IPty, spawn } from 'bun-pty'
3
3
  import { EventEmitter } from 'node:events'
4
4
 
5
- import type { TerminalModeState, TerminalSnapshot } from '../state/types'
5
+ import type { ScrollIntent, TerminalModeState, TerminalSnapshot } from '../state/types'
6
6
 
7
7
  import { logDebug } from '../debug/input-log'
8
8
  import { areTerminalSnapshotsEqual, snapshotTerminal } from './terminal-snapshot'
@@ -247,18 +247,34 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
247
247
  this.emitRenderIfChanged(session)
248
248
  }
249
249
 
250
- resizeAll(cols: number, rows: number): void {
250
+ private applyScrollIntent(session: SessionHandle, intent: ScrollIntent | undefined): void {
251
+ if (!intent || intent.kind === 'bottom') {
252
+ session.emulator.scrollToBottom()
253
+ return
254
+ }
255
+
256
+ const baseY = session.emulator.buffer.active.baseY
257
+ if (intent.absoluteLine >= baseY) {
258
+ session.emulator.scrollToBottom()
259
+ return
260
+ }
261
+
262
+ session.emulator.scrollToLine(Math.max(0, intent.absoluteLine))
263
+ }
264
+
265
+ resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
251
266
  const safeCols = Math.max(20, cols)
252
267
  const safeRows = Math.max(8, rows)
253
268
 
254
269
  for (const session of this.sessions.values()) {
255
270
  session.pty.resize(safeCols, safeRows)
256
271
  session.emulator.resize(safeCols, safeRows)
272
+ this.applyScrollIntent(session, intents?.get(session.tabId))
257
273
  this.emitRenderIfChanged(session)
258
274
  }
259
275
  }
260
276
 
261
- resizeSession(tabId: string, cols: number, rows: number): void {
277
+ resizeSession(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
262
278
  const session = this.sessions.get(tabId)
263
279
  if (!session) {
264
280
  return
@@ -267,6 +283,16 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
267
283
  const safeRows = Math.max(8, rows)
268
284
  session.pty.resize(safeCols, safeRows)
269
285
  session.emulator.resize(safeCols, safeRows)
286
+ this.applyScrollIntent(session, intent)
287
+ this.emitRenderIfChanged(session)
288
+ }
289
+
290
+ reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
291
+ const session = this.sessions.get(tabId)
292
+ if (!session) {
293
+ return
294
+ }
295
+ this.applyScrollIntent(session, intent)
270
296
  this.emitRenderIfChanged(session)
271
297
  }
272
298
 
@@ -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'
@@ -114,6 +114,13 @@ export class LocalSessionBackend
114
114
  this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
115
115
  }
116
116
 
117
+ reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
118
+ if (!this.currentSessionId) {
119
+ return
120
+ }
121
+ this.sessionManager.reapplyScrollIntent(this.currentSessionId, tabId, intent)
122
+ }
123
+
117
124
  setActiveTab(tabId: string | null): void {
118
125
  if (!this.currentSessionId) {
119
126
  return
@@ -122,18 +129,18 @@ export class LocalSessionBackend
122
129
  this.sessionManager.setActiveTab(this.currentSessionId, tabId)
123
130
  }
124
131
 
125
- resizeAll(cols: number, rows: number): void {
132
+ resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
126
133
  if (!this.currentSessionId) {
127
134
  return
128
135
  }
129
- this.sessionManager.resize(this.currentSessionId, cols, rows)
136
+ this.sessionManager.resize(this.currentSessionId, cols, rows, intents)
130
137
  }
131
138
 
132
- resizeTab(tabId: string, cols: number, rows: number): void {
139
+ resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
133
140
  if (!this.currentSessionId) {
134
141
  return
135
142
  }
136
- this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows)
143
+ this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent)
137
144
  }
138
145
 
139
146
  disposeSession(tabId: string): void {
@@ -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,
@@ -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)