@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
@@ -256,7 +256,12 @@ export async function runDaemon(): Promise<void> {
256
256
  case 'resizeClient': {
257
257
  const sessionId = requireSession(socket, attachedSessions)
258
258
  requireNegotiatedVersion(socket, negotiatedVersions)
259
- await manager.resize(sessionId, message.payload.cols, message.payload.rows)
259
+ await manager.resize(
260
+ sessionId,
261
+ message.payload.cols,
262
+ message.payload.rows,
263
+ message.payload.intents
264
+ )
260
265
  sendOk(socket, message.id)
261
266
  break
262
267
  }
@@ -267,7 +272,8 @@ export async function runDaemon(): Promise<void> {
267
272
  sessionId,
268
273
  message.payload.tabId,
269
274
  message.payload.cols,
270
- message.payload.rows
275
+ message.payload.rows,
276
+ message.payload.intent
271
277
  )
272
278
  sendOk(socket, message.id)
273
279
  break
@@ -286,6 +292,17 @@ export async function runDaemon(): Promise<void> {
286
292
  sendOk(socket, message.id)
287
293
  break
288
294
  }
295
+ case 'reapplyScrollIntent': {
296
+ const sessionId = requireSession(socket, attachedSessions)
297
+ requireNegotiatedVersion(socket, negotiatedVersions)
298
+ await manager.reapplyScrollIntent(
299
+ sessionId,
300
+ message.payload.tabId,
301
+ message.payload.intent
302
+ )
303
+ sendOk(socket, message.id)
304
+ break
305
+ }
289
306
  case 'setActiveTab': {
290
307
  const sessionId = requireSession(socket, attachedSessions)
291
308
  requireNegotiatedVersion(socket, negotiatedVersions)
@@ -1,6 +1,11 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
 
3
- import type { TerminalModeState, TerminalSnapshot, WorkspaceSnapshotV1 } from '../state/types'
3
+ import type {
4
+ ScrollIntent,
5
+ TerminalModeState,
6
+ TerminalSnapshot,
7
+ WorkspaceSnapshotV1,
8
+ } from '../state/types'
4
9
 
5
10
  import { logDebug } from '../debug/input-log'
6
11
  import { SessionRegistry } from './session-registry'
@@ -62,12 +67,18 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
62
67
  this.getOrCreateRegistry(sessionId).write(tabId, data)
63
68
  }
64
69
 
65
- resize(sessionId: string, cols: number, rows: number): void {
66
- this.getOrCreateRegistry(sessionId).resizeAll(cols, rows)
70
+ resize(sessionId: string, cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
71
+ this.getOrCreateRegistry(sessionId).resizeAll(cols, rows, intents)
67
72
  }
68
73
 
69
- resizeTab(sessionId: string, tabId: string, cols: number, rows: number): void {
70
- this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows)
74
+ resizeTab(
75
+ sessionId: string,
76
+ tabId: string,
77
+ cols: number,
78
+ rows: number,
79
+ intent?: ScrollIntent
80
+ ): void {
81
+ this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows, intent)
71
82
  }
72
83
 
73
84
  scroll(sessionId: string, tabId: string, deltaLines: number): void {
@@ -78,6 +89,10 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
78
89
  this.getOrCreateRegistry(sessionId).scrollViewportToBottom(tabId)
79
90
  }
80
91
 
92
+ reapplyScrollIntent(sessionId: string, tabId: string, intent: ScrollIntent): void {
93
+ this.getOrCreateRegistry(sessionId).reapplyScrollIntent(tabId, intent)
94
+ }
95
+
81
96
  setActiveTab(sessionId: string, tabId: string | null): void {
82
97
  this.getOrCreateRegistry(sessionId).setActiveTab(tabId)
83
98
  }
@@ -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,8 @@ 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' }
56
+ | { type: 'switch-session-by-index'; index: number }
54
57
 
55
58
  export interface KeyResult {
56
59
  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