@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.13.2",
3
+ "version": "1.14.1",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -60,7 +60,7 @@
60
60
  "bump": "bun run scripts/bump.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@brimveyn/aimux-config": "0.6.2",
63
+ "@brimveyn/aimux-config": "0.6.4",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@resvg/resvg-wasm": "^2.6.2",
@@ -10,7 +10,6 @@ import {
10
10
  getSnapshotTrees,
11
11
  toTerminalContentSize,
12
12
  } from '../state/layout-resize'
13
- import { getSnapshotScrollIntents } from '../state/session-persistence'
14
13
 
15
14
  export function resizeSnapshotPanes(
16
15
  snapshot: WorkspaceSnapshotV1 | undefined,
@@ -22,14 +21,13 @@ export function resizeSnapshotPanes(
22
21
  }
23
22
 
24
23
  const trees = getSnapshotTrees(snapshot)
25
- const intents = getSnapshotScrollIntents(snapshot)
26
24
  const bounds = createTerminalBounds(
27
25
  layoutRef.current.terminalCols,
28
26
  layoutRef.current.terminalRows
29
27
  )
30
28
  forEachSplitPaneRect(trees, bounds, (tabId, rect) => {
31
29
  const size = toTerminalContentSize(rect)
32
- backend.resizeTab(tabId, size.cols, size.rows, intents.get(tabId))
30
+ backend.resizeTab(tabId, size.cols, size.rows)
33
31
  })
34
32
  }
35
33
 
@@ -1,21 +1,33 @@
1
1
  // On multi-click selection mouseUp opentui's finishSelection() fires the
2
2
  // 'selection' event *after* our handleTerminalMouseUp has already written
3
- // the clipboard from drag.capturedLines (anchored at click time). The event
4
- // handler would then call computeStreamSelectedText against the *current*
5
- // tab.viewport.lines and overwrite our write with a value that can diverge
6
- // by a few rows when output landed between mouseDown and mouseUp.
3
+ // the clipboard from drag.capturedLines (anchored in absolute buffer rows).
4
+ // The event handler would then call computeStreamSelectedText against the
5
+ // *current* tab.viewport.lines and overwrite our write with a value that can
6
+ // diverge by a few rows when output/auto-scroll moved the viewport between
7
+ // mouseDown and mouseUp.
7
8
  //
8
- // To avoid that, handleTerminalMouseUp pings the guard right after it
9
- // copies; handleSelection consults the guard and skips its own copy for a
10
- // short window.
11
-
12
- const SUPPRESS_WINDOW_MS = 100
13
- let lastMultiClickWriteAt = 0
9
+ // finishSelection() emits exactly one 'selection' event, so we suppress
10
+ // exactly one a consume-once flag rather than a time window (which could
11
+ // either expire before the event arrived, letting a wrong value through, or
12
+ // swallow an unrelated selection that happened to land in the window).
13
+ //
14
+ // A short guard window bounds the flag: if the expected event never arrives
15
+ // (no finishSelection fired), the flag self-heals instead of silently eating
16
+ // the user's next genuine selection.
17
+ const ARM_WINDOW_MS = 250
18
+ let armedAt = 0
14
19
 
15
20
  export function recordMultiClickClipboardWrite(): void {
16
- lastMultiClickWriteAt = Date.now()
21
+ armedAt = Date.now()
17
22
  }
18
23
 
19
24
  export function shouldSuppressSelectionCopy(): boolean {
20
- return Date.now() - lastMultiClickWriteAt < SUPPRESS_WINDOW_MS
25
+ if (armedAt === 0) {
26
+ return false
27
+ }
28
+ const withinWindow = Date.now() - armedAt < ARM_WINDOW_MS
29
+ // Consume the arm regardless: the one finishSelection event is now handled,
30
+ // and a stale arm must not suppress a later, unrelated selection.
31
+ armedAt = 0
32
+ return withinWindow
21
33
  }
@@ -1,5 +1,5 @@
1
1
  import type { SessionBackend } from '../session-backend/types'
2
- import type { AppAction, TabSession } from '../state/types'
2
+ import type { TabSession } from '../state/types'
3
3
 
4
4
  import { logInputDebug } from '../debug/input-log'
5
5
  import { buildPtyPastePayload } from '../input/paste'
@@ -18,7 +18,6 @@ export function writeToTab(
18
18
  tabId: string,
19
19
  tab: TabSession | undefined,
20
20
  input: string,
21
- dispatch?: (action: AppAction) => void,
22
21
  options?: PtyWriteOptions
23
22
  ): void {
24
23
  logInputDebug('ptyWrite.writeToTab', {
@@ -28,8 +27,9 @@ export function writeToTab(
28
27
  tabId,
29
28
  })
30
29
  if ((options?.autoBottom ?? true) && tab && shouldScrollViewportToBottom(tab)) {
30
+ // Backend owns the scroll position; the snapshot it emits reflects the new
31
+ // bottom, so the frontend mirror updates without a separate dispatch.
31
32
  backend.scrollViewportToBottom(tabId)
32
- dispatch?.({ intent: { kind: 'bottom' }, tabId, type: 'set-scroll-intent' })
33
33
  }
34
34
 
35
35
  backend.write(tabId, input)
@@ -39,11 +39,10 @@ export function writePasteToTab(
39
39
  backend: SessionBackend,
40
40
  tabId: string,
41
41
  tab: TabSession | undefined,
42
- text: string,
43
- dispatch?: (action: AppAction) => void
42
+ text: string
44
43
  ): void {
45
44
  const payload = buildPtyPastePayload(text, tab?.terminalModes.bracketedPasteMode ?? false)
46
- writeToTab(backend, tabId, tab, payload, dispatch, { autoBottom: true })
45
+ writeToTab(backend, tabId, tab, payload, { autoBottom: true })
47
46
  }
48
47
 
49
48
  const DEL = '\x7f'
@@ -63,12 +62,10 @@ export function writeMacroExpansionToTab(
63
62
  tab: TabSession | undefined,
64
63
  eraseCount: number,
65
64
  expandedText: string,
66
- cursorOffset: number,
67
- dispatch?: (action: AppAction) => void
65
+ cursorOffset: number
68
66
  ): void {
69
67
  if (tab && shouldScrollViewportToBottom(tab)) {
70
68
  backend.scrollViewportToBottom(tabId)
71
- dispatch?.({ intent: { kind: 'bottom' }, tabId, type: 'set-scroll-intent' })
72
69
  }
73
70
 
74
71
  const erase = eraseCount > 0 ? DEL.repeat(eraseCount) : ''
@@ -13,6 +13,7 @@ import { dirname, join as joinPath, resolve as resolvePath } from 'node:path'
13
13
 
14
14
  import type { SideEffect } from '../input/modes/types'
15
15
  import type { SessionBackend } from '../session-backend/types'
16
+ import type { AppAction, AppState, AssistantId, TabSession, WorktreeRecord } from '../state/types'
16
17
  import type { ThemeId } from '../ui/themes'
17
18
 
18
19
  import { loadConfig, saveConfig } from '../config'
@@ -42,6 +43,7 @@ import {
42
43
  isCommandAvailable,
43
44
  parseCommand,
44
45
  } from '../pty/command-registry'
46
+ import { appStore } from '../state/app-store'
45
47
  import { createTerminalBounds } from '../state/layout-resize'
46
48
  import {
47
49
  allLeafIds,
@@ -55,18 +57,17 @@ import {
55
57
  } from '../state/layout-tree'
56
58
  import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
57
59
  import { saveSessionCatalog } from '../state/session-catalog'
58
- import { getActiveWorktree, getSessionProjectPath } from '../state/session-worktrees'
60
+ import {
61
+ filterTabsForActiveWorktree,
62
+ getActiveWorktree,
63
+ getSessionProjectPath,
64
+ withActiveWorktree,
65
+ } from '../state/session-worktrees'
59
66
  import { getSnippetsCatalogPath, isConfigSnippetId } from '../state/snippet-catalog'
67
+ import { appReducer } from '../state/store'
68
+ import { buildTabEntries } from '../state/tab-entries'
60
69
  import { createDefaultTerminalModes } from '../state/terminal-modes'
61
70
  import { toast } from '../state/toast-store'
62
- import {
63
- type AppAction,
64
- type AppState,
65
- type AssistantId,
66
- DEFAULT_SCROLL_INTENT,
67
- type TabSession,
68
- type WorktreeRecord,
69
- } from '../state/types'
70
71
  import { saveCurrentWorkspace } from '../state/workspace-save'
71
72
  import { filterThemeIds } from '../ui/filter-themes'
72
73
  import { scrollGitDiff } from '../ui/git-view-controls'
@@ -78,6 +79,7 @@ import {
78
79
  handleRenameSessionEffect,
79
80
  handleSwitchSessionEffect,
80
81
  restartTabSession,
82
+ switchSessionRecords,
81
83
  } from './session-actions'
82
84
  import {
83
85
  handleDeleteSnippetEffect,
@@ -173,14 +175,14 @@ function pasteSnippetToActiveGroup(ctx: SideEffectContext): void {
173
175
  const groupId = getGroupIdForTab(state.tabGroupMap, state.activeTabId)
174
176
  const groupTree = groupId != null && groupId !== '' ? state.layoutTrees[groupId] : null
175
177
  if (!groupTree) {
176
- pasteSnippetToTab(backend, state.activeTabId, activeTab, snippet, ctx.dispatch)
178
+ pasteSnippetToTab(backend, state.activeTabId, activeTab, snippet)
177
179
  return
178
180
  }
179
181
 
180
182
  for (const tabId of allLeafIds(groupTree)) {
181
183
  const tab = state.tabs.find((entry) => entry.id === tabId)
182
184
  if (tab) {
183
- pasteSnippetToTab(backend, tabId, tab, snippet, ctx.dispatch)
185
+ pasteSnippetToTab(backend, tabId, tab, snippet)
184
186
  }
185
187
  }
186
188
  }
@@ -294,7 +296,6 @@ export function createTabSession(
294
296
  buffer: '',
295
297
  command: customCommand ?? option.command,
296
298
  id: createTabId(),
297
- scrollIntent: DEFAULT_SCROLL_INTENT,
298
299
  status: 'starting',
299
300
  terminalModes: createDefaultTerminalModes(),
300
301
  title: option.label,
@@ -620,13 +621,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
620
621
  )
621
622
  return
622
623
  case 'paste-selected-snippet': {
623
- pasteSnippetToTab(
624
- backend,
625
- state.activeTabId,
626
- ctx.activeTab,
627
- getSelectedSnippet(state),
628
- dispatch
629
- )
624
+ pasteSnippetToTab(backend, state.activeTabId, ctx.activeTab, getSelectedSnippet(state))
630
625
  return
631
626
  }
632
627
  case 'paste-snippet-to-group': {
@@ -803,6 +798,14 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
803
798
  handleSwitchSessionByIndex(ctx, effect.index)
804
799
  return
805
800
  }
801
+ case 'cycle-sidebar-item': {
802
+ handleCycleSidebarItem(ctx, effect.direction)
803
+ return
804
+ }
805
+ case 'switch-tab-by-index': {
806
+ handleSwitchTabByIndex(ctx, effect.index)
807
+ return
808
+ }
806
809
  case 'toggle-transparent': {
807
810
  const next = !getTransparent()
808
811
  setTransparent(next)
@@ -1074,6 +1077,133 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
1074
1077
  handleSwitchSessionEffect(state, backend, dispatch, target)
1075
1078
  }
1076
1079
 
1080
+ interface SidebarItem {
1081
+ sessionId: string
1082
+ worktreeId: string | null
1083
+ }
1084
+
1085
+ function buildSidebarItems(state: AppState): SidebarItem[] {
1086
+ const ordered = [...state.sessions].sort(
1087
+ (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
1088
+ )
1089
+ const items: SidebarItem[] = []
1090
+ for (const session of ordered) {
1091
+ items.push({ sessionId: session.id, worktreeId: null })
1092
+ const worktrees = session.worktrees ?? []
1093
+ const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1094
+ for (const wt of worktrees) {
1095
+ if (wt.id === primary?.id) continue
1096
+ items.push({ sessionId: session.id, worktreeId: wt.id })
1097
+ }
1098
+ }
1099
+ return items
1100
+ }
1101
+
1102
+ function findCurrentSidebarItem(state: AppState, items: SidebarItem[]): number {
1103
+ const sessionId = state.currentSessionId
1104
+ if (sessionId == null || sessionId === '') return -1
1105
+ const session = state.sessions.find((s) => s.id === sessionId)
1106
+ const worktrees = session?.worktrees ?? []
1107
+ const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1108
+ const activeWtId = session?.activeWorktreeId ?? null
1109
+ // The workspace row IS the primary worktree (no separate row), so an active
1110
+ // primary or undefined active maps to the workspace-item.
1111
+ const targetWorktreeId = activeWtId == null || activeWtId === primary?.id ? null : activeWtId
1112
+ return items.findIndex(
1113
+ (item) => item.sessionId === sessionId && item.worktreeId === targetWorktreeId
1114
+ )
1115
+ }
1116
+
1117
+ function handleCycleSidebarItem(ctx: SideEffectContext, direction: 1 | -1): void {
1118
+ const { backend, dispatch } = ctx
1119
+ // Read fresh from the store, not ctx.state (which is a per-render
1120
+ // snapshot). Rapid key presses fire before React re-renders, so ctx.state
1121
+ // can lag the actual store.
1122
+ const state = appStore.getState()
1123
+ const items = buildSidebarItems(state)
1124
+ if (items.length === 0) return
1125
+ const currentIdx = findCurrentSidebarItem(state, items)
1126
+ // If we don't know the current, jump to first/last depending on direction.
1127
+ let startIdx: number
1128
+ if (currentIdx >= 0) {
1129
+ startIdx = currentIdx
1130
+ } else {
1131
+ startIdx = direction === 1 ? -1 : 0
1132
+ }
1133
+ const len = items.length
1134
+ const target = items[(((startIdx + direction) % len) + len) % len]
1135
+ if (!target) return
1136
+
1137
+ const session = state.sessions.find((s) => s.id === target.sessionId)
1138
+ if (!session) return
1139
+
1140
+ // Determine the worktree to activate. For workspace-items, that's the
1141
+ // primary; for worktree-items, the specific worktree.
1142
+ const worktrees = session.worktrees ?? []
1143
+ const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1144
+ const targetWorktreeId = target.worktreeId ?? primary?.id
1145
+
1146
+ const isCrossWorkspace = session.id !== state.currentSessionId
1147
+ const needsWorktreeChange =
1148
+ targetWorktreeId != null && targetWorktreeId !== session.activeWorktreeId
1149
+
1150
+ if (isCrossWorkspace) {
1151
+ // Bundle the worktree change into the session record AND fold the
1152
+ // session switch's two dispatches (set-sessions + load-session) into a
1153
+ // SINGLE Zustand setState call — otherwise each dispatch fires a
1154
+ // separate subscription notification and the @opentui/react reconciler
1155
+ // paints an intermediate frame where the new session is current but
1156
+ // the old activeWorktreeId still holds, producing the visible flicker.
1157
+ const patchedSession = needsWorktreeChange
1158
+ ? withActiveWorktree(session, targetWorktreeId)
1159
+ : session
1160
+ const patchedState: AppState = needsWorktreeChange
1161
+ ? {
1162
+ ...state,
1163
+ sessions: state.sessions.map((s) => (s.id === patchedSession.id ? patchedSession : s)),
1164
+ }
1165
+ : state
1166
+ const sessions = switchSessionRecords(patchedState, patchedSession)
1167
+ saveSessionCatalog(sessions)
1168
+ void backend.destroy(true)
1169
+ appStore.setState((current) => {
1170
+ const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
1171
+ return appReducer(afterSet, {
1172
+ sessionId: patchedSession.id,
1173
+ type: 'load-session',
1174
+ workspaceSnapshot: patchedSession.workspaceSnapshot,
1175
+ })
1176
+ })
1177
+ return
1178
+ }
1179
+
1180
+ if (needsWorktreeChange) {
1181
+ dispatch({
1182
+ sessionId: session.id,
1183
+ type: 'set-active-worktree',
1184
+ worktreeId: targetWorktreeId,
1185
+ })
1186
+ }
1187
+ }
1188
+
1189
+ function handleSwitchTabByIndex(ctx: SideEffectContext, index: number): void {
1190
+ const { dispatch, state } = ctx
1191
+ const currentSession =
1192
+ state.currentSessionId != null && state.currentSessionId !== ''
1193
+ ? state.sessions.find((s) => s.id === state.currentSessionId)
1194
+ : undefined
1195
+ const visible = filterTabsForActiveWorktree(state.tabs, currentSession)
1196
+ const entries = buildTabEntries(visible, state.layoutTrees, state.tabGroupMap, state.activeTabId)
1197
+ const target = entries[index - 1]
1198
+ if (!target) {
1199
+ logInputDebug('app.tabBar.switchOutOfRange', { index, total: entries.length })
1200
+ return
1201
+ }
1202
+ const targetTabId = target.kind === 'single' ? target.tab.id : target.activeLeafId
1203
+ if (targetTabId === state.activeTabId) return
1204
+ dispatch({ tabId: targetTabId, type: 'set-active-tab' })
1205
+ }
1206
+
1077
1207
  function replaceSession(
1078
1208
  state: AppState,
1079
1209
  sessionId: string,
@@ -71,14 +71,13 @@ export function pasteSnippetToTab(
71
71
  backend: SessionBackend,
72
72
  activeTabId: string | null,
73
73
  activeTab: TabSession | undefined,
74
- snippet: SnippetRecord | undefined,
75
- dispatch?: (action: AppAction) => void
74
+ snippet: SnippetRecord | undefined
76
75
  ): void {
77
76
  if (!snippet || activeTabId == null || activeTabId === '' || !activeTab) {
78
77
  return
79
78
  }
80
79
 
81
- writePasteToTab(backend, activeTabId, activeTab, snippet.content, dispatch)
80
+ writePasteToTab(backend, activeTabId, activeTab, snippet.content)
82
81
  }
83
82
 
84
83
  export function handleDeleteSnippetEffect(
@@ -29,7 +29,9 @@ function getScreenPosition(event: OtuiMouseEvent, axis: ScreenAxis): number {
29
29
 
30
30
  export function getSplitRatioFromDrag(event: OtuiMouseEvent, drag: SplitDragState): number {
31
31
  const position = getScreenPosition(event, drag.direction === 'vertical' ? 'x' : 'y')
32
- return (position - drag.screenStart) / drag.totalSize
32
+ const total = Math.max(1, drag.totalSize)
33
+ const cells = Math.round(position - drag.screenStart)
34
+ return cells / total
33
35
  }
34
36
 
35
37
  export function getAxisDeltaFromDrag(event: OtuiMouseEvent, drag: AxisDragState): number {
@@ -1,7 +1,7 @@
1
1
  import { type MutableRefObject, useEffect, useRef } from 'react'
2
2
 
3
3
  import type { SessionBackend } from '../session-backend/types'
4
- import type { AppAction, LayoutState, ScrollIntent } from '../state/types'
4
+ import type { AppAction, LayoutState } from '../state/types'
5
5
 
6
6
  import { attachCurrentSession } from './backend-attach-runtime'
7
7
  import { bindBackendRuntimeEvents } from './backend-runtime-events'
@@ -11,7 +11,6 @@ interface BackendRuntimeOptions {
11
11
  backend: SessionBackend
12
12
  dispatch: (action: AppAction) => void
13
13
  activeTabId: string | null
14
- activeTabScrollIntentRef: MutableRefObject<ScrollIntent | null>
15
14
  currentSessionId: string | null
16
15
  layoutRef: MutableRefObject<LayoutState>
17
16
  resizingRef: MutableRefObject<boolean>
@@ -27,7 +26,6 @@ export interface TabRuntimeControls {
27
26
 
28
27
  export function useBackendRuntime({
29
28
  activeTabId,
30
- activeTabScrollIntentRef,
31
29
  backend,
32
30
  currentSessionId,
33
31
  currentSessionWorkspaceSnapshot,
@@ -61,11 +59,10 @@ export function useBackendRuntime({
61
59
  return
62
60
  }
63
61
 
62
+ // The backend owns each tab's scroll position; a backgrounded tab keeps it
63
+ // in its own emulator, so switching back needs no re-anchor from here.
64
64
  backend.setActiveTab(activeTabId)
65
- if (activeTabId != null && activeTabId !== '' && activeTabScrollIntentRef.current) {
66
- backend.reapplyScrollIntent(activeTabId, activeTabScrollIntentRef.current)
67
- }
68
- }, [activeTabId, activeTabScrollIntentRef, backend, currentSessionId])
65
+ }, [activeTabId, backend, currentSessionId])
69
66
 
70
67
  useEffect(() => {
71
68
  return bindBackendRuntimeEvents({
@@ -60,6 +60,16 @@ interface MultiClickDragState {
60
60
  capturedLines: Map<number, TerminalLine>
61
61
  autoScrollTimer: ReturnType<typeof setInterval> | null
62
62
  autoScrollDir: -1 | 0 | 1
63
+ // A scroll command was sent to the backend and we are still waiting for the
64
+ // snapshot it produces. Gates the next auto-scroll tick so the backend's
65
+ // viewportY can never run ahead of the frontend mirror (which all the
66
+ // selection math reads) — the source of the copy-offset drift.
67
+ autoScrollPending: boolean
68
+ // The drag tab's viewport object last handled by the snapshot effect. Used
69
+ // to ignore snapshot churn from *other* tabs: updateTab keeps unchanged tabs
70
+ // by reference, so an unrelated tab's output must not clear autoScrollPending
71
+ // or re-extend the selection.
72
+ lastProcessedViewport: TabSession['viewport']
63
73
  }
64
74
 
65
75
  interface UseMouseHandlersOptions {
@@ -161,6 +171,7 @@ export function useMouseHandlers({
161
171
  }
162
172
  drag.autoScrollTimer = null
163
173
  drag.autoScrollDir = 0
174
+ drag.autoScrollPending = false
164
175
  }
165
176
 
166
177
  const canScroll = (tab: TabSession | undefined, dir: -1 | 1): boolean => {
@@ -174,14 +185,20 @@ export function useMouseHandlers({
174
185
  clearAutoScroll(drag)
175
186
  drag.autoScrollDir = dir
176
187
  drag.autoScrollTimer = setInterval(() => {
188
+ // Wait for the previous scroll's snapshot before issuing the next one, so
189
+ // the backend viewportY stays in lockstep with the frontend mirror.
190
+ if (drag.autoScrollPending) {
191
+ return
192
+ }
177
193
  const tab = stateRef.current.tabs.find((t: TabSession) => t.id === drag.tabId)
178
194
  if (!canScroll(tab, dir)) {
179
195
  clearAutoScroll(drag)
180
196
  return
181
197
  }
198
+ drag.autoScrollPending = true
182
199
  backend.scrollViewport(drag.tabId, dir)
183
200
  // The snapshot dispatch will trigger the viewport-change effect below,
184
- // which recaptures lines and re-extends the selection focus.
201
+ // which clears autoScrollPending, recaptures lines, and re-extends focus.
185
202
  }, AUTO_SCROLL_INTERVAL_MS)
186
203
  }
187
204
 
@@ -216,6 +233,16 @@ export function useMouseHandlers({
216
233
  const tab = stateRef.current.tabs.find((t: TabSession) => t.id === drag.tabId)
217
234
  if (!tab?.viewport) return
218
235
 
236
+ // The effect fires on any tab's snapshot (state.tabs is a fresh array), but
237
+ // the drag tab's viewport object only changes when *its* snapshot lands.
238
+ // Ignore churn from other tabs so the auto-scroll lockstep below stays 1:1.
239
+ if (tab.viewport === drag.lastProcessedViewport) return
240
+ drag.lastProcessedViewport = tab.viewport
241
+
242
+ // A fresh snapshot for the drag tab landed: the backend has caught up, so
243
+ // the auto-scroll loop may issue its next step.
244
+ drag.autoScrollPending = false
245
+
219
246
  captureViewportLines(drag, tab)
220
247
 
221
248
  const viewportRows = tab.viewport.lines.length
@@ -425,6 +452,7 @@ export function useMouseHandlers({
425
452
  anchorEndCol: selection.endCol,
426
453
  anchorStartCol: selection.startCol,
427
454
  autoScrollDir: 0,
455
+ autoScrollPending: false,
428
456
  autoScrollTimer: null,
429
457
  baseX: anchor.baseX,
430
458
  baseY: anchor.baseY,
@@ -433,6 +461,7 @@ export function useMouseHandlers({
433
461
  focusCol: selection.endCol,
434
462
  lastEventScreenX: event.x,
435
463
  lastEventScreenY: event.y,
464
+ lastProcessedViewport: tab.viewport,
436
465
  mode: selection.mode,
437
466
  tabId: targetTabId,
438
467
  target: selection.target,
@@ -121,15 +121,7 @@ export function useRendererBindings({
121
121
  cwd,
122
122
  now,
123
123
  })
124
- writeMacroExpansionToTab(
125
- backend,
126
- tabId,
127
- tab,
128
- match.triggerText.length,
129
- text,
130
- cursorOffset,
131
- dispatch
132
- )
124
+ writeMacroExpansionToTab(backend, tabId, tab, match.triggerText.length, text, cursorOffset)
133
125
  registerUndo(text, cursorOffset)
134
126
  return
135
127
  }
@@ -154,7 +146,7 @@ export function useRendererBindings({
154
146
  now,
155
147
  })
156
148
  // Trigger already erased above; eraseCount = 0 here.
157
- writeMacroExpansionToTab(backend, tabId, tab, 0, text, cursorOffset, dispatch)
149
+ writeMacroExpansionToTab(backend, tabId, tab, 0, text, cursorOffset)
158
150
  registerUndo(text, cursorOffset)
159
151
  } finally {
160
152
  inFlightAsyncExpansionRef.current.delete(tabId)
@@ -194,7 +186,7 @@ export function useRendererBindings({
194
186
  resetTrigger: (tabId) => triggerDetectorsRef.current.get(tabId)?.reset(),
195
187
  tryConsumeMacroUndo,
196
188
  writeToPty: (tabId, data, options) =>
197
- writeToTab(backend, tabId, activeTabRef.current, data, dispatch, options),
189
+ writeToTab(backend, tabId, activeTabRef.current, data, options),
198
190
  })
199
191
 
200
192
  const handlePasteEvent = (event: { bytes: Uint8Array; defaultPrevented?: boolean }) => {
@@ -230,7 +222,7 @@ export function useRendererBindings({
230
222
  return
231
223
  }
232
224
 
233
- writePasteToTab(backend, tabId, tab, payload, dispatch)
225
+ writePasteToTab(backend, tabId, tab, payload)
234
226
  }
235
227
 
236
228
  const handleSelection = (selection: OtuiSelection) => {