@brimveyn/aimux 1.13.2 → 1.14.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 (54) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/backend-attach-runtime.ts +1 -3
  3. package/src/app-runtime/multi-click-clipboard-guard.ts +24 -12
  4. package/src/app-runtime/pty-write.ts +6 -9
  5. package/src/app-runtime/side-effects.ts +149 -19
  6. package/src/app-runtime/snippet-actions.ts +2 -3
  7. package/src/app-runtime/split-drag-controller.ts +3 -1
  8. package/src/app-runtime/use-backend-runtime.ts +4 -7
  9. package/src/app-runtime/use-mouse-handlers.ts +30 -1
  10. package/src/app-runtime/use-renderer-bindings.ts +4 -12
  11. package/src/app-runtime/use-terminal-resize.ts +7 -22
  12. package/src/app.tsx +0 -6
  13. package/src/config.ts +1 -12
  14. package/src/daemon/daemon.ts +2 -19
  15. package/src/daemon/session-manager.ts +3 -15
  16. package/src/daemon/session-registry.ts +11 -30
  17. package/src/git/worktree-branch-poller.ts +54 -0
  18. package/src/input/modes/types.ts +2 -0
  19. package/src/input/terminal-text-extraction.ts +7 -8
  20. package/src/ipc/manager-protocol.ts +6 -38
  21. package/src/ipc/protocol.ts +9 -37
  22. package/src/pty/pty-manager.ts +20 -31
  23. package/src/pty/terminal-snapshot.ts +43 -0
  24. package/src/session-backend/local-session-backend.ts +7 -31
  25. package/src/session-backend/remote-session-backend.ts +5 -32
  26. package/src/session-backend/types.ts +2 -15
  27. package/src/state/layout-tree.ts +114 -4
  28. package/src/state/reducers/session-state.ts +20 -0
  29. package/src/state/reducers/tab-state.ts +22 -22
  30. package/src/state/reducers/ui-state.ts +0 -3
  31. package/src/state/session-persistence.ts +2 -22
  32. package/src/state/session-worktrees.ts +45 -2
  33. package/src/state/store.ts +0 -3
  34. package/src/state/tab-entries.ts +74 -0
  35. package/src/state/types.ts +4 -15
  36. package/src/state/validation.ts +0 -8
  37. package/src/state/workspace-save.ts +0 -1
  38. package/src/terminal-manager/manager-client.ts +5 -29
  39. package/src/terminal-manager/terminal-manager.ts +2 -17
  40. package/src/ui/components/layout/sidebar/sidebar.tsx +9 -340
  41. package/src/ui/components/layout/sidebar/tab-item.tsx +51 -42
  42. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +10 -53
  43. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +30 -0
  44. package/src/ui/components/layout/sidebar/workspace-list.tsx +398 -0
  45. package/src/ui/components/layout/sidebar/worktree-row.tsx +92 -0
  46. package/src/ui/components/layout/split-layout.tsx +20 -39
  47. package/src/ui/components/layout/terminal-pane.tsx +30 -0
  48. package/src/ui/components/layout/top-tab-bar.tsx +304 -0
  49. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
  50. package/src/ui/root.tsx +68 -63
  51. package/src/ui/components/layout/session-bar.tsx +0 -296
  52. package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
  53. package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
  54. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +0 -36
@@ -10,7 +10,7 @@ import {
10
10
 
11
11
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
12
12
  import type { SessionBackend } from '../session-backend/types'
13
- import type { AppAction, AppState, ScrollIntent } from '../state/types'
13
+ import type { AppAction, AppState } from '../state/types'
14
14
  import type { MeasuredPaneRect } from './use-pane-size-report'
15
15
 
16
16
  import { getGitPaneWidthFromRatio } from '../state/git-pane-sizing'
@@ -44,7 +44,6 @@ function resizeSplitTabs(
44
44
  tabIds: string[],
45
45
  cols: number,
46
46
  rows: number,
47
- intents: Map<string, ScrollIntent>,
48
47
  options?: { sync?: boolean }
49
48
  ): void {
50
49
  const bounds = getTerminalBounds(cols, rows)
@@ -52,13 +51,13 @@ function resizeSplitTabs(
52
51
 
53
52
  forEachSplitPaneRect(Object.values(layoutTrees), bounds, (tabId, rect) => {
54
53
  const size = toTerminalContentSize(rect)
55
- backend.resizeTab(tabId, size.cols, size.rows, intents.get(tabId), options)
54
+ backend.resizeTab(tabId, size.cols, size.rows, options)
56
55
  resizedTabIds.add(tabId)
57
56
  })
58
57
 
59
58
  for (const id of tabIds) {
60
59
  if (!resizedTabIds.has(id)) {
61
- backend.resizeTab(id, cols, rows, intents.get(id), options)
60
+ backend.resizeTab(id, cols, rows, options)
62
61
  }
63
62
  }
64
63
  }
@@ -81,7 +80,6 @@ interface RunResizeCascadeArgs {
81
80
  rows: number
82
81
  layoutTrees: AppState['layoutTrees']
83
82
  stableTabIds: string[]
84
- intents: Map<string, ScrollIntent>
85
83
  sync: boolean
86
84
  }
87
85
 
@@ -89,7 +87,6 @@ function runResizeCascade({
89
87
  backend,
90
88
  cols,
91
89
  dispatch,
92
- intents,
93
90
  layoutTrees,
94
91
  resizingRef,
95
92
  resizingTimerRef,
@@ -107,9 +104,9 @@ function runResizeCascade({
107
104
  clearTimeout(resizingTimerRef.current)
108
105
  }
109
106
  if (hasSplits) {
110
- resizeSplitTabs(backend, layoutTrees, stableTabIds, cols, rows, intents, options)
107
+ resizeSplitTabs(backend, layoutTrees, stableTabIds, cols, rows, options)
111
108
  } else {
112
- backend.resizeAll(cols, rows, intents, options)
109
+ backend.resizeAll(cols, rows, options)
113
110
  }
114
111
  resizingTimerRef.current = setTimeout(() => {
115
112
  resizingRef.current = false
@@ -133,7 +130,6 @@ export function useTerminalResize({
133
130
  }: UseTerminalResizeOptions) {
134
131
  const resizingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
135
132
  const tabIdsRef = useRef<string[]>([])
136
- const intentsRef = useRef<Map<string, ScrollIntent>>(new Map())
137
133
  const handledBySyncRef = useRef(false)
138
134
  const sidebarMountedRef = useRef(false)
139
135
 
@@ -146,12 +142,6 @@ export function useTerminalResize({
146
142
  }
147
143
  const stableTabIds = tabIdsRef.current
148
144
 
149
- intentsRef.current = new Map(
150
- state.tabs
151
- .filter((t): t is typeof t & { scrollIntent: ScrollIntent } => t.scrollIntent !== undefined)
152
- .map((t) => [t.id, t.scrollIntent])
153
- )
154
-
155
145
  const activeTabIdRef = useRef(state.activeTabId)
156
146
  activeTabIdRef.current = state.activeTabId
157
147
  // Last size we pushed to the backend per tab, used to dedupe the measurement
@@ -176,7 +166,7 @@ export function useTerminalResize({
176
166
  return
177
167
  }
178
168
  measuredRef.current.set(tabId, { cols, rows })
179
- backend.resizeTab(tabId, cols, rows, intentsRef.current.get(tabId))
169
+ backend.resizeTab(tabId, cols, rows)
180
170
  },
181
171
  [backend, contentOriginRef]
182
172
  )
@@ -185,8 +175,7 @@ export function useTerminalResize({
185
175
  const terminalSize = useMemo(() => {
186
176
  const sidebarWidth = state.sidebar.visible ? state.sidebar.width : 0
187
177
  const sessionBarRows = state.sessionBar.visible ? 1 : 0
188
- const sessionBarTopOffset =
189
- state.sessionBar.visible && state.sessionBar.position === 'top' ? 1 : 0
178
+ const sessionBarTopOffset = sessionBarRows
190
179
  const reservedRows =
191
180
  MAIN_AREA_VERTICAL_PADDING +
192
181
  STATUS_BAR_HEIGHT +
@@ -215,7 +204,6 @@ export function useTerminalResize({
215
204
  state.sidebar.visible,
216
205
  state.sidebar.width,
217
206
  state.sessionBar.visible,
218
- state.sessionBar.position,
219
207
  gitPaneInPaneMode,
220
208
  state.gitPane.position,
221
209
  state.gitPane.paneRatio,
@@ -230,7 +218,6 @@ export function useTerminalResize({
230
218
  backend,
231
219
  cols: terminalSize.cols,
232
220
  dispatch,
233
- intents: intentsRef.current,
234
221
  layoutTrees: state.layoutTrees,
235
222
  resizingRef,
236
223
  resizingTimerRef,
@@ -244,7 +231,6 @@ export function useTerminalResize({
244
231
  state.sidebar.visible,
245
232
  state.sidebar.width,
246
233
  state.sessionBar.visible,
247
- state.sessionBar.position,
248
234
  gitPaneInPaneMode,
249
235
  state.gitPane.position,
250
236
  state.gitPane.paneRatio,
@@ -259,7 +245,6 @@ export function useTerminalResize({
259
245
  backend,
260
246
  cols: terminalSize.cols,
261
247
  dispatch,
262
- intents: intentsRef.current,
263
248
  layoutTrees: state.layoutTrees,
264
249
  resizingRef,
265
250
  resizingTimerRef,
package/src/app.tsx CHANGED
@@ -100,8 +100,6 @@ export function App({
100
100
  const json = loadConfig()
101
101
  const sessionBarVisible =
102
102
  resolvedConfig.sessionBar?.initialVisible ?? json.sessionBarVisible ?? true
103
- const sessionBarPosition =
104
- resolvedConfig.sessionBar?.initialPosition ?? json.sessionBarPosition ?? 'top'
105
103
  const sidebarOverrides = json.sidebar
106
104
 
107
105
  // Merge config-file gitPane (persisted prefs) with user's resolved gitPane
@@ -149,7 +147,6 @@ export function App({
149
147
  sessionCatalog.length === 0,
150
148
  {
151
149
  gitPane: gitPaneOverrides,
152
- sessionBarPosition,
153
150
  sessionBarVisible,
154
151
  sidebar: sidebarOverrides,
155
152
  }
@@ -258,8 +255,6 @@ export function App({
258
255
  activeTabIdRef.current = state.activeTabId
259
256
  const activeTabRef = useRef(activeTab)
260
257
  activeTabRef.current = activeTab
261
- const activeTabScrollIntentRef = useRef(activeTab?.scrollIntent ?? null)
262
- activeTabScrollIntentRef.current = activeTab?.scrollIntent ?? null
263
258
 
264
259
  const stateRef = useRef(state)
265
260
  stateRef.current = state
@@ -307,7 +302,6 @@ export function App({
307
302
 
308
303
  const { clearIdleTimer, clearStartupGrace, startStartupGrace } = useBackendRuntime({
309
304
  activeTabId: state.activeTabId,
310
- activeTabScrollIntentRef,
311
305
  backend,
312
306
  currentSessionId: state.currentSessionId,
313
307
  currentSessionWorkspaceSnapshot,
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
 
3
- import type { GitFileListMode, SessionBarPosition, WorkspaceSnapshotV1 } from './state/types'
3
+ import type { GitFileListMode, WorkspaceSnapshotV1 } from './state/types'
4
4
 
5
5
  import { logDebug } from './debug/input-log'
6
6
  import { getProfileConfigDir } from './profile-paths'
@@ -41,7 +41,6 @@ export interface AimuxConfig {
41
41
  gitPane?: PersistedGitPane
42
42
  sidebar?: PersistedSidebar
43
43
  sessionBarVisible?: boolean
44
- sessionBarPosition?: SessionBarPosition
45
44
  workspaceSnapshot?: WorkspaceSnapshotV1
46
45
  skippedUpdateVersion?: string
47
46
  }
@@ -161,7 +160,6 @@ export function loadConfigResult(): ConfigLoadResult {
161
160
  gitPanelVisible?: unknown
162
161
  gitPanelRatio?: unknown
163
162
  sessionBarVisible?: unknown
164
- sessionBarPosition?: unknown
165
163
  workspaceSnapshot?: unknown
166
164
  skippedUpdateVersion?: unknown
167
165
  }
@@ -233,14 +231,6 @@ export function loadConfigResult(): ConfigLoadResult {
233
231
  issues.push('ignored invalid sessionBarVisible')
234
232
  }
235
233
 
236
- const validSessionBarPosition =
237
- parsed.sessionBarPosition === 'top' || parsed.sessionBarPosition === 'bottom'
238
- ? parsed.sessionBarPosition
239
- : undefined
240
- if (parsed.sessionBarPosition !== undefined && validSessionBarPosition === undefined) {
241
- issues.push('ignored invalid sessionBarPosition')
242
- }
243
-
244
234
  if (
245
235
  parsed.workspaceSnapshot !== undefined &&
246
236
  !isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
@@ -264,7 +254,6 @@ export function loadConfigResult(): ConfigLoadResult {
264
254
  config: {
265
255
  customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
266
256
  gitPane: validGitPane,
267
- sessionBarPosition: validSessionBarPosition,
268
257
  sessionBarVisible: validSessionBarVisible,
269
258
  sidebar: validSidebar,
270
259
  skippedUpdateVersion: validSkippedUpdateVersion,
@@ -472,12 +472,7 @@ export async function runDaemon(): Promise<void> {
472
472
  case 'resizeClient': {
473
473
  const sessionId = requireSession(socket, attachedSessions)
474
474
  requireNegotiatedVersion(socket, negotiatedVersions)
475
- await manager.resize(
476
- sessionId,
477
- message.payload.cols,
478
- message.payload.rows,
479
- message.payload.intents
480
- )
475
+ await manager.resize(sessionId, message.payload.cols, message.payload.rows)
481
476
  sendOk(socket, message.id)
482
477
  break
483
478
  }
@@ -488,8 +483,7 @@ export async function runDaemon(): Promise<void> {
488
483
  sessionId,
489
484
  message.payload.tabId,
490
485
  message.payload.cols,
491
- message.payload.rows,
492
- message.payload.intent
486
+ message.payload.rows
493
487
  )
494
488
  sendOk(socket, message.id)
495
489
  break
@@ -508,17 +502,6 @@ export async function runDaemon(): Promise<void> {
508
502
  sendOk(socket, message.id)
509
503
  break
510
504
  }
511
- case 'reapplyScrollIntent': {
512
- const sessionId = requireSession(socket, attachedSessions)
513
- requireNegotiatedVersion(socket, negotiatedVersions)
514
- await manager.reapplyScrollIntent(
515
- sessionId,
516
- message.payload.tabId,
517
- message.payload.intent
518
- )
519
- sendOk(socket, message.id)
520
- break
521
- }
522
505
  case 'setActiveTab': {
523
506
  const sessionId = requireSession(socket, attachedSessions)
524
507
  requireNegotiatedVersion(socket, negotiatedVersions)
@@ -1,7 +1,6 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
 
3
3
  import type {
4
- ScrollIntent,
5
4
  TabSession,
6
5
  TerminalModeState,
7
6
  TerminalSnapshot,
@@ -68,14 +67,8 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
68
67
  this.getOrCreateRegistry(sessionId).write(tabId, data)
69
68
  }
70
69
 
71
- resize(
72
- sessionId: string,
73
- cols: number,
74
- rows: number,
75
- intents?: Map<string, ScrollIntent>,
76
- options?: { sync?: boolean }
77
- ): void {
78
- this.getOrCreateRegistry(sessionId).resizeAll(cols, rows, intents, options)
70
+ resize(sessionId: string, cols: number, rows: number, options?: { sync?: boolean }): void {
71
+ this.getOrCreateRegistry(sessionId).resizeAll(cols, rows, options)
79
72
  }
80
73
 
81
74
  resizeTab(
@@ -83,10 +76,9 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
83
76
  tabId: string,
84
77
  cols: number,
85
78
  rows: number,
86
- intent?: ScrollIntent,
87
79
  options?: { sync?: boolean }
88
80
  ): void {
89
- this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows, intent, options)
81
+ this.getOrCreateRegistry(sessionId).resizeTab(tabId, cols, rows, options)
90
82
  }
91
83
 
92
84
  scroll(sessionId: string, tabId: string, deltaLines: number): void {
@@ -97,10 +89,6 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
97
89
  this.getOrCreateRegistry(sessionId).scrollViewportToBottom(tabId)
98
90
  }
99
91
 
100
- reapplyScrollIntent(sessionId: string, tabId: string, intent: ScrollIntent): void {
101
- this.getOrCreateRegistry(sessionId).reapplyScrollIntent(tabId, intent)
102
- }
103
-
104
92
  setActiveTab(sessionId: string, tabId: string | null): void {
105
93
  this.getOrCreateRegistry(sessionId).setActiveTab(tabId)
106
94
  }
@@ -1,5 +1,12 @@
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
+
3
10
  import { logDebug } from '../debug/input-log'
4
11
  import { PtyManager } from '../pty/pty-manager'
5
12
  import {
@@ -8,14 +15,6 @@ import {
8
15
  restoreTabsFromWorkspace,
9
16
  } from '../state/session-persistence'
10
17
  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'
19
18
 
20
19
  interface SessionRegistryEvents {
21
20
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
@@ -91,7 +90,6 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
91
90
  const existing = this.tabs.get(persisted.id)
92
91
  if (existing) {
93
92
  existing.title = persisted.title
94
- existing.scrollIntent = persisted.scrollIntent ?? DEFAULT_SCROLL_INTENT
95
93
  existing.worktreeId = persisted.worktreeId
96
94
  }
97
95
  }
@@ -147,7 +145,6 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
147
145
  buffer: '',
148
146
  command: [options.command, ...(options.args ?? [])].join(' '),
149
147
  id: options.tabId,
150
- scrollIntent: DEFAULT_SCROLL_INTENT,
151
148
  status: 'starting',
152
149
  terminalModes: createDefaultTerminalModes(),
153
150
  title: options.title,
@@ -159,7 +156,6 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
159
156
  existing.exitCode = undefined
160
157
  existing.viewport = undefined
161
158
  existing.terminalModes = createDefaultTerminalModes()
162
- existing.scrollIntent = DEFAULT_SCROLL_INTENT
163
159
  existing.assistant = options.assistant
164
160
  existing.title = options.title
165
161
  existing.command = [options.command, ...(options.args ?? [])].join(' ')
@@ -173,23 +169,12 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
173
169
  this.ptyManager.write(tabId, data)
174
170
  }
175
171
 
176
- resizeAll(
177
- cols: number,
178
- rows: number,
179
- intents?: Map<string, ScrollIntent>,
180
- options?: { sync?: boolean }
181
- ): void {
182
- this.ptyManager.resizeAll(cols, rows, intents, options)
172
+ resizeAll(cols: number, rows: number, options?: { sync?: boolean }): void {
173
+ this.ptyManager.resizeAll(cols, rows, options)
183
174
  }
184
175
 
185
- resizeTab(
186
- tabId: string,
187
- cols: number,
188
- rows: number,
189
- intent?: ScrollIntent,
190
- options?: { sync?: boolean }
191
- ): void {
192
- this.ptyManager.resizeSession(tabId, cols, rows, intent, options)
176
+ resizeTab(tabId: string, cols: number, rows: number, options?: { sync?: boolean }): void {
177
+ this.ptyManager.resizeSession(tabId, cols, rows, options)
193
178
  }
194
179
 
195
180
  scrollViewport(tabId: string, deltaLines: number): void {
@@ -200,10 +185,6 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
200
185
  this.ptyManager.scrollViewportToBottom(tabId)
201
186
  }
202
187
 
203
- reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
204
- this.ptyManager.reapplyScrollIntent(tabId, intent)
205
- }
206
-
207
188
  setActiveTab(tabId: string | null): void {
208
189
  if (tabId === null) {
209
190
  this.activeTabId = null
@@ -0,0 +1,54 @@
1
+ import { useEffect } from 'react'
2
+
3
+ import { appStore } from '../state/app-store'
4
+ import { dispatchGlobal } from '../state/dispatch-ref'
5
+ import { getCurrentBranch } from '../ui/git-branch'
6
+
7
+ const INTERVAL_MS = 4000
8
+
9
+ // Single source of truth for "what git branch is each worktree on right now".
10
+ // Polls every known worktree's path and dispatches update-worktree-record
11
+ // when the live branch differs from the stored one. Components read
12
+ // `worktree.branch` from state — no per-component polling, no flickers.
13
+ //
14
+ // Runs once when enabled; reads sessions from the store on each tick so
15
+ // session updates do NOT re-create the effect (which would otherwise feedback
16
+ // off our own dispatches).
17
+ export function useWorktreeBranchPolling(enabled: boolean): void {
18
+ useEffect(() => {
19
+ if (!enabled) return
20
+
21
+ let cancelled = false
22
+ let timer: ReturnType<typeof setTimeout> | null = null
23
+
24
+ const tick = async () => {
25
+ const sessions = appStore.getState().sessions
26
+ await Promise.all(
27
+ sessions.flatMap((session) =>
28
+ (session.worktrees ?? []).map(async (worktree) => {
29
+ if (worktree.path == null || worktree.path === '') return
30
+ const branch = await getCurrentBranch(worktree.path)
31
+ if (cancelled) return
32
+ if (branch != null && branch !== worktree.branch) {
33
+ dispatchGlobal({
34
+ patch: { branch },
35
+ sessionId: session.id,
36
+ type: 'update-worktree-record',
37
+ worktreeId: worktree.id,
38
+ })
39
+ }
40
+ })
41
+ )
42
+ )
43
+ if (cancelled) return
44
+ timer = setTimeout(() => void tick(), INTERVAL_MS)
45
+ }
46
+
47
+ void tick()
48
+
49
+ return () => {
50
+ cancelled = true
51
+ if (timer != null) clearTimeout(timer)
52
+ }
53
+ }, [enabled])
54
+ }
@@ -68,6 +68,8 @@ export type SideEffect =
68
68
  | { type: 'git-push' }
69
69
  | { type: 'confirm-update-selection' }
70
70
  | { type: 'switch-session-by-index'; index: number }
71
+ | { type: 'cycle-sidebar-item'; direction: 1 | -1 }
72
+ | { type: 'switch-tab-by-index'; index: number }
71
73
  | { type: 'delete-session'; sessionId: string }
72
74
  | { type: 'delete-worktree'; sessionId: string; worktreeId: string; force?: boolean }
73
75
  | {
@@ -7,13 +7,12 @@ export function getLineText(line: TerminalLine): string {
7
7
  /**
8
8
  * Extract text from a range of terminal lines as a single string.
9
9
  *
10
- * Multi-row selections are lossy: trailing `[ \t]+` is stripped from each
11
- * joined segment to drop the viewport padding the snapshot layer fills blank
12
- * cells with. Without this, shell line continuations (`\` followed by padding
13
- * spaces then `\n`) paste as escaped-space sequences instead of continuations.
14
- *
15
- * Single-row selections are returned verbatim — trailing spaces the user
16
- * explicitly dragged over are preserved.
10
+ * Trailing `[ \t]+` is stripped from each joined segment to drop the viewport
11
+ * padding the snapshot layer fills blank cells with (every rendered row is now
12
+ * padded to the full terminal width to prevent ghosting). Without this, shell
13
+ * line continuations (`\` + padding spaces + `\n`) paste as escaped-space
14
+ * sequences, and a single-line copy dragged to/past end-of-line would pick up
15
+ * the synthetic padding spaces.
17
16
  */
18
17
  export function extractStreamText(
19
18
  lines: TerminalLine[],
@@ -38,7 +37,7 @@ export function extractStreamText(
38
37
  for (let row = clampedStart; row <= clampedEnd; row++) {
39
38
  const text = getLineText(lines[row] as TerminalLine)
40
39
  if (row === startRow && row === endRow) {
41
- parts.push(text.slice(Math.max(0, startCol), Math.max(0, endCol)))
40
+ parts.push(rtrim(text.slice(Math.max(0, startCol), Math.max(0, endCol))))
42
41
  } else if (row === startRow) {
43
42
  parts.push(rtrim(text.slice(Math.max(0, startCol))))
44
43
  } else if (row === endRow) {
@@ -1,5 +1,4 @@
1
1
  import type {
2
- ScrollIntent,
3
2
  TabSession,
4
3
  TerminalModeState,
5
4
  TerminalSnapshot,
@@ -14,8 +13,12 @@ import {
14
13
  negotiateProtocolVersion,
15
14
  } from './protocol'
16
15
 
17
- export const MANAGER_PROTOCOL_MIN_VERSION = 3
18
- export const MANAGER_PROTOCOL_VERSION = 4
16
+ // v5: backend owns scroll position end to end. Removed the per-tab scroll
17
+ // `intent`/`intents` from resize messages and the `reapplyScrollIntent`
18
+ // message. Min is raised in lockstep so a pre-v5 peer (which could still send
19
+ // the dropped message) can't negotiate a now-incompatible version.
20
+ export const MANAGER_PROTOCOL_MIN_VERSION = 5
21
+ export const MANAGER_PROTOCOL_VERSION = 5
19
22
  /**
20
23
  * Minimum version required to send `setBroadcastEnabled`. Older TMs (v3) will
21
24
  * not understand the message; the daemon must check the negotiated version
@@ -75,7 +78,6 @@ export type ManagerRequest =
75
78
  sessionId: string
76
79
  cols: number
77
80
  rows: number
78
- intents?: Record<string, ScrollIntent>
79
81
  }
80
82
  }
81
83
  | {
@@ -86,7 +88,6 @@ export type ManagerRequest =
86
88
  tabId: string
87
89
  cols: number
88
90
  rows: number
89
- intent?: ScrollIntent
90
91
  }
91
92
  }
92
93
  | {
@@ -99,11 +100,6 @@ export type ManagerRequest =
99
100
  type: 'scroll'
100
101
  payload: { sessionId: string; tabId: string; deltaLines: number }
101
102
  }
102
- | {
103
- id: string
104
- type: 'reapplyScrollIntent'
105
- payload: { sessionId: string; tabId: string; intent: ScrollIntent }
106
- }
107
103
  | { id: string; type: 'setActiveTab'; payload: { sessionId: string; tabId: string | null } }
108
104
  | { id: string; type: 'closeTab'; payload: { sessionId: string; tabId: string } }
109
105
  | { id: string; type: 'disposeSession'; payload: { sessionId: string } }
@@ -178,18 +174,6 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
178
174
  )
179
175
  }
180
176
 
181
- function isScrollIntent(value: unknown): value is ScrollIntent {
182
- if (!isObjectRecord(value)) return false
183
- if (value.kind === 'bottom') return true
184
- if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
185
- return false
186
- }
187
-
188
- function isScrollIntentRecord(value: unknown): value is Record<string, ScrollIntent> {
189
- if (!isObjectRecord(value)) return false
190
- return Object.values(value).every(isScrollIntent)
191
- }
192
-
193
177
  function isTerminalModeState(value: unknown): value is TerminalModeState {
194
178
  return (
195
179
  isObjectRecord(value) &&
@@ -303,20 +287,12 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
303
287
  assert(isString(value.payload.sessionId), 'resizeClient.sessionId must be a string')
304
288
  assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
305
289
  assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
306
- assert(
307
- value.payload.intents === undefined || isScrollIntentRecord(value.payload.intents),
308
- 'resizeClient.intents must be a scroll-intent record'
309
- )
310
290
  return value as ManagerRequest
311
291
  case 'resizeTab':
312
292
  assert(isString(value.payload.sessionId), 'resizeTab.sessionId must be a string')
313
293
  assert(isString(value.payload.tabId), 'resizeTab.tabId must be a string')
314
294
  assert(isFiniteNumber(value.payload.cols), 'resizeTab.cols must be a number')
315
295
  assert(isFiniteNumber(value.payload.rows), 'resizeTab.rows must be a number')
316
- assert(
317
- value.payload.intent === undefined || isScrollIntent(value.payload.intent),
318
- 'resizeTab.intent must be a scroll intent'
319
- )
320
296
  return value as ManagerRequest
321
297
  case 'scroll':
322
298
  assert(isString(value.payload.sessionId), 'scroll.sessionId must be a string')
@@ -327,14 +303,6 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
327
303
  assert(isString(value.payload.sessionId), 'scrollToBottom.sessionId must be a string')
328
304
  assert(isString(value.payload.tabId), 'scrollToBottom.tabId must be a string')
329
305
  return value as ManagerRequest
330
- case 'reapplyScrollIntent':
331
- assert(isString(value.payload.sessionId), 'reapplyScrollIntent.sessionId must be a string')
332
- assert(isString(value.payload.tabId), 'reapplyScrollIntent.tabId must be a string')
333
- assert(
334
- isScrollIntent(value.payload.intent),
335
- 'reapplyScrollIntent.intent must be a scroll intent'
336
- )
337
- return value as ManagerRequest
338
306
  case 'setActiveTab':
339
307
  assert(isString(value.payload.sessionId), 'setActiveTab.sessionId must be a string')
340
308
  assert(isNullableString(value.payload.tabId), 'setActiveTab.tabId must be a string or null')
@@ -1,5 +1,4 @@
1
1
  import type {
2
- ScrollIntent,
3
2
  SessionStatus,
4
3
  TabActivity,
5
4
  TabSession,
@@ -10,8 +9,13 @@ import type {
10
9
 
11
10
  import { isWorkspaceSnapshotV1 } from '../state/validation'
12
11
 
13
- export const IPC_PROTOCOL_MIN_VERSION = 8
14
- export const IPC_PROTOCOL_VERSION = 8
12
+ // v9: scroll position is owned entirely by the backend emulator. Dropped the
13
+ // per-tab scroll `intent`/`intents` from resize messages and removed the
14
+ // `reapplyScrollIntent` message — the frontend no longer derives or sends it.
15
+ // (v8 was the unfilled-viewport status-detector change; this is a further
16
+ // breaking wire change, so the version steps again.)
17
+ export const IPC_PROTOCOL_MIN_VERSION = 9
18
+ export const IPC_PROTOCOL_VERSION = 9
15
19
 
16
20
  export interface ProtocolHelloRequest {
17
21
  minVersion: number
@@ -67,20 +71,15 @@ export type ClientRequest =
67
71
  | {
68
72
  id: string
69
73
  type: 'resizeClient'
70
- payload: { cols: number; rows: number; intents?: Record<string, ScrollIntent> }
74
+ payload: { cols: number; rows: number }
71
75
  }
72
76
  | {
73
77
  id: string
74
78
  type: 'resizeTab'
75
- payload: { tabId: string; cols: number; rows: number; intent?: ScrollIntent }
79
+ payload: { tabId: string; cols: number; rows: number }
76
80
  }
77
81
  | { id: string; type: 'scrollToBottom'; payload: { tabId: string } }
78
82
  | { id: string; type: 'scroll'; payload: { tabId: string; deltaLines: number } }
79
- | {
80
- id: string
81
- type: 'reapplyScrollIntent'
82
- payload: { tabId: string; intent: ScrollIntent }
83
- }
84
83
  | { id: string; type: 'setActiveTab'; payload: { tabId: string | null } }
85
84
  | { id: string; type: 'closeTab'; payload: { tabId: string } }
86
85
  | { id: string; type: 'disposeAll'; payload: Record<string, never> }
@@ -180,18 +179,6 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
180
179
  )
181
180
  }
182
181
 
183
- function isScrollIntent(value: unknown): value is ScrollIntent {
184
- if (!isObjectRecord(value)) return false
185
- if (value.kind === 'bottom') return true
186
- if (value.kind === 'anchor') return isFiniteNumber(value.absoluteLine)
187
- return false
188
- }
189
-
190
- function isScrollIntentRecord(value: unknown): value is Record<string, ScrollIntent> {
191
- if (!isObjectRecord(value)) return false
192
- return Object.values(value).every(isScrollIntent)
193
- }
194
-
195
182
  function isTerminalModeState(value: unknown): value is TerminalModeState {
196
183
  return (
197
184
  isObjectRecord(value) &&
@@ -328,19 +315,11 @@ export function parseClientRequest(value: unknown): ClientRequest {
328
315
  case 'resizeClient':
329
316
  assert(isFiniteNumber(value.payload.cols), 'resizeClient.cols must be a number')
330
317
  assert(isFiniteNumber(value.payload.rows), 'resizeClient.rows must be a number')
331
- assert(
332
- value.payload.intents === undefined || isScrollIntentRecord(value.payload.intents),
333
- 'resizeClient.intents must be a scroll-intent record'
334
- )
335
318
  return value as ClientRequest
336
319
  case 'resizeTab':
337
320
  assert(isString(value.payload.tabId), 'resizeTab.tabId must be a string')
338
321
  assert(isFiniteNumber(value.payload.cols), 'resizeTab.cols must be a number')
339
322
  assert(isFiniteNumber(value.payload.rows), 'resizeTab.rows must be a number')
340
- assert(
341
- value.payload.intent === undefined || isScrollIntent(value.payload.intent),
342
- 'resizeTab.intent must be a scroll intent'
343
- )
344
323
  return value as ClientRequest
345
324
  case 'scroll':
346
325
  assert(isString(value.payload.tabId), 'scroll.tabId must be a string')
@@ -349,13 +328,6 @@ export function parseClientRequest(value: unknown): ClientRequest {
349
328
  case 'scrollToBottom':
350
329
  assert(isString(value.payload.tabId), 'scrollToBottom.tabId must be a string')
351
330
  return value as ClientRequest
352
- case 'reapplyScrollIntent':
353
- assert(isString(value.payload.tabId), 'reapplyScrollIntent.tabId must be a string')
354
- assert(
355
- isScrollIntent(value.payload.intent),
356
- 'reapplyScrollIntent.intent must be a scroll intent'
357
- )
358
- return value as ClientRequest
359
331
  case 'setActiveTab':
360
332
  assert(isNullableString(value.payload.tabId), 'setActiveTab.tabId must be a string or null')
361
333
  return value as ClientRequest