@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.
- package/package.json +2 -2
- package/src/app-runtime/backend-attach-runtime.ts +1 -3
- package/src/app-runtime/multi-click-clipboard-guard.ts +24 -12
- package/src/app-runtime/pty-write.ts +6 -9
- package/src/app-runtime/side-effects.ts +149 -19
- package/src/app-runtime/snippet-actions.ts +2 -3
- package/src/app-runtime/split-drag-controller.ts +3 -1
- package/src/app-runtime/use-backend-runtime.ts +4 -7
- package/src/app-runtime/use-mouse-handlers.ts +30 -1
- package/src/app-runtime/use-renderer-bindings.ts +4 -12
- package/src/app-runtime/use-terminal-resize.ts +7 -22
- package/src/app.tsx +0 -6
- package/src/config.ts +1 -12
- package/src/daemon/daemon.ts +2 -19
- package/src/daemon/session-manager.ts +3 -15
- package/src/daemon/session-registry.ts +11 -30
- package/src/git/worktree-branch-poller.ts +54 -0
- package/src/input/modes/types.ts +2 -0
- package/src/input/terminal-text-extraction.ts +7 -8
- package/src/ipc/manager-protocol.ts +6 -38
- package/src/ipc/protocol.ts +9 -37
- package/src/pty/pty-manager.ts +20 -31
- package/src/pty/terminal-snapshot.ts +43 -0
- package/src/session-backend/local-session-backend.ts +7 -31
- package/src/session-backend/remote-session-backend.ts +5 -32
- package/src/session-backend/types.ts +2 -15
- package/src/state/layout-tree.ts +114 -4
- package/src/state/reducers/session-state.ts +20 -0
- package/src/state/reducers/tab-state.ts +22 -22
- package/src/state/reducers/ui-state.ts +0 -3
- package/src/state/session-persistence.ts +2 -22
- package/src/state/session-worktrees.ts +45 -2
- package/src/state/store.ts +0 -3
- package/src/state/tab-entries.ts +74 -0
- package/src/state/types.ts +4 -15
- package/src/state/validation.ts +0 -8
- package/src/state/workspace-save.ts +0 -1
- package/src/terminal-manager/manager-client.ts +5 -29
- package/src/terminal-manager/terminal-manager.ts +2 -17
- package/src/ui/components/layout/sidebar/sidebar.tsx +9 -340
- package/src/ui/components/layout/sidebar/tab-item.tsx +51 -42
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +10 -53
- package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +30 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +398 -0
- package/src/ui/components/layout/sidebar/worktree-row.tsx +92 -0
- package/src/ui/components/layout/split-layout.tsx +20 -39
- package/src/ui/components/layout/terminal-pane.tsx +30 -0
- package/src/ui/components/layout/top-tab-bar.tsx +304 -0
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
- package/src/ui/root.tsx +68 -63
- package/src/ui/components/layout/session-bar.tsx +0 -296
- package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
- package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
- 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.
|
|
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.
|
|
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
|
|
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
|
|
4
|
-
// handler would then call computeStreamSelectedText against the
|
|
5
|
-
// tab.viewport.lines and overwrite our write with a value that can
|
|
6
|
-
// by a few rows when output
|
|
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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
21
|
+
armedAt = Date.now()
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export function shouldSuppressSelectionCopy(): boolean {
|
|
20
|
-
|
|
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 {
|
|
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,
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
225
|
+
writePasteToTab(backend, tabId, tab, payload)
|
|
234
226
|
}
|
|
235
227
|
|
|
236
228
|
const handleSelection = (selection: OtuiSelection) => {
|