@brimveyn/aimux 1.13.0 → 1.14.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.
- package/package.json +2 -2
- package/src/app-runtime/backend-attach-runtime.ts +12 -14
- 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 +65 -56
- package/src/app-runtime/snippet-actions.ts +2 -3
- 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 +6 -18
- package/src/app.tsx +0 -3
- package/src/daemon/daemon.ts +20 -27
- package/src/daemon/session-manager.ts +3 -15
- package/src/daemon/session-registry.ts +11 -30
- package/src/git/command-queue.ts +18 -2
- 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 +51 -63
- package/src/session-backend/types.ts +2 -15
- package/src/state/reducers/tab-state.ts +2 -20
- package/src/state/session-persistence.ts +2 -22
- package/src/state/types.ts +3 -11
- package/src/state/validation.ts +0 -8
- package/src/terminal-manager/manager-client.ts +5 -29
- package/src/terminal-manager/terminal-manager.ts +2 -17
- package/src/ui/components/git/diff-renderer/fold-strip.tsx +30 -23
- package/src/ui/components/git/diff-renderer/split-view.tsx +33 -7
- package/src/ui/components/git/diff-renderer/stacked-view.tsx +23 -3
- package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +9 -5
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +8 -6
- package/src/ui/components/git/git-panel.tsx +114 -75
- package/src/ui/components/git/git-view.tsx +26 -18
- package/src/ui/components/git/image-diff/terminal-image-pane.tsx +10 -10
- package/src/ui/components/layout/session-bar.tsx +134 -116
- package/src/ui/components/layout/sidebar/sidebar.tsx +89 -53
- package/src/ui/components/layout/sidebar/tab-item.tsx +65 -54
- package/src/ui/components/layout/split-layout.tsx +27 -20
- package/src/ui/components/layout/terminal-pane.tsx +154 -114
- package/src/ui/components/modals/app/help-modal.tsx +25 -18
- package/src/ui/components/modals/git/git-commit-modal.tsx +15 -8
- package/src/ui/components/modals/sessions/create-session-modal.tsx +13 -9
- package/src/ui/components/modals/sessions/session-picker-modal.tsx +35 -28
- package/src/ui/components/modals/shared/form.tsx +2 -1
- package/src/ui/components/modals/shared/picker.tsx +49 -33
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +42 -29
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +82 -66
- package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +23 -6
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +11 -6
- package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
- package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
- package/src/ui/components/primitives/list-item.tsx +20 -4
- package/src/ui/root.tsx +77 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
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.3",
|
|
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
|
|
|
@@ -109,14 +107,14 @@ export function attachCurrentSession({
|
|
|
109
107
|
attachRequestIdRef.current = attachRequestId
|
|
110
108
|
let cancelled = false
|
|
111
109
|
|
|
112
|
-
void
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
110
|
+
void (async () => {
|
|
111
|
+
try {
|
|
112
|
+
const result = await backend.attach({
|
|
113
|
+
cols: layoutRef.current.terminalCols,
|
|
114
|
+
rows: layoutRef.current.terminalRows,
|
|
115
|
+
sessionId: currentSessionId,
|
|
116
|
+
workspaceSnapshot: currentSessionWorkspaceSnapshot,
|
|
117
|
+
})
|
|
120
118
|
if (cancelled || attachRequestIdRef.current !== attachRequestId) {
|
|
121
119
|
return
|
|
122
120
|
}
|
|
@@ -134,8 +132,7 @@ export function attachCurrentSession({
|
|
|
134
132
|
layoutRef,
|
|
135
133
|
backend
|
|
136
134
|
)
|
|
137
|
-
})
|
|
138
|
-
.catch((error) => {
|
|
135
|
+
} catch (error) {
|
|
139
136
|
if (cancelled || attachRequestIdRef.current !== attachRequestId) {
|
|
140
137
|
return
|
|
141
138
|
}
|
|
@@ -151,7 +148,8 @@ export function attachCurrentSession({
|
|
|
151
148
|
layoutRef,
|
|
152
149
|
backend
|
|
153
150
|
)
|
|
154
|
-
}
|
|
151
|
+
}
|
|
152
|
+
})()
|
|
155
153
|
|
|
156
154
|
return () => {
|
|
157
155
|
cancelled = true
|
|
@@ -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'
|
|
@@ -59,14 +60,6 @@ import { getActiveWorktree, getSessionProjectPath } from '../state/session-workt
|
|
|
59
60
|
import { getSnippetsCatalogPath, isConfigSnippetId } from '../state/snippet-catalog'
|
|
60
61
|
import { createDefaultTerminalModes } from '../state/terminal-modes'
|
|
61
62
|
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
63
|
import { saveCurrentWorkspace } from '../state/workspace-save'
|
|
71
64
|
import { filterThemeIds } from '../ui/filter-themes'
|
|
72
65
|
import { scrollGitDiff } from '../ui/git-view-controls'
|
|
@@ -173,14 +166,14 @@ function pasteSnippetToActiveGroup(ctx: SideEffectContext): void {
|
|
|
173
166
|
const groupId = getGroupIdForTab(state.tabGroupMap, state.activeTabId)
|
|
174
167
|
const groupTree = groupId != null && groupId !== '' ? state.layoutTrees[groupId] : null
|
|
175
168
|
if (!groupTree) {
|
|
176
|
-
pasteSnippetToTab(backend, state.activeTabId, activeTab, snippet
|
|
169
|
+
pasteSnippetToTab(backend, state.activeTabId, activeTab, snippet)
|
|
177
170
|
return
|
|
178
171
|
}
|
|
179
172
|
|
|
180
173
|
for (const tabId of allLeafIds(groupTree)) {
|
|
181
174
|
const tab = state.tabs.find((entry) => entry.id === tabId)
|
|
182
175
|
if (tab) {
|
|
183
|
-
pasteSnippetToTab(backend, tabId, tab, snippet
|
|
176
|
+
pasteSnippetToTab(backend, tabId, tab, snippet)
|
|
184
177
|
}
|
|
185
178
|
}
|
|
186
179
|
}
|
|
@@ -294,7 +287,6 @@ export function createTabSession(
|
|
|
294
287
|
buffer: '',
|
|
295
288
|
command: customCommand ?? option.command,
|
|
296
289
|
id: createTabId(),
|
|
297
|
-
scrollIntent: DEFAULT_SCROLL_INTENT,
|
|
298
290
|
status: 'starting',
|
|
299
291
|
terminalModes: createDefaultTerminalModes(),
|
|
300
292
|
title: option.label,
|
|
@@ -508,9 +500,21 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
508
500
|
const worktreeName = state.modal.worktreeName
|
|
509
501
|
const branchName = state.modal.branchName
|
|
510
502
|
const sourceWorktreeId = getNewTabTargetWorktreeId(state)
|
|
511
|
-
void
|
|
512
|
-
|
|
513
|
-
|
|
503
|
+
void (async () => {
|
|
504
|
+
try {
|
|
505
|
+
await enqueueGitOp(async () =>
|
|
506
|
+
launchAssistantInNewWorktree(
|
|
507
|
+
ctx,
|
|
508
|
+
option.id,
|
|
509
|
+
worktreeName,
|
|
510
|
+
branchName,
|
|
511
|
+
sourceWorktreeId
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
} catch (error) {
|
|
515
|
+
toast.error(error instanceof Error ? error.message : String(error))
|
|
516
|
+
}
|
|
517
|
+
})()
|
|
514
518
|
return
|
|
515
519
|
}
|
|
516
520
|
launchAssistant(ctx, option.id, getNewTabTargetWorktreeId(state))
|
|
@@ -534,44 +538,54 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
534
538
|
return
|
|
535
539
|
}
|
|
536
540
|
case 'delete-worktree': {
|
|
537
|
-
void
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const
|
|
550
|
-
const
|
|
551
|
-
if (
|
|
552
|
-
|
|
553
|
-
|
|
541
|
+
void (async () => {
|
|
542
|
+
try {
|
|
543
|
+
await enqueueGitOp(async () =>
|
|
544
|
+
runDeleteWorktree(
|
|
545
|
+
{ ...ctx, state: ctx.getState() },
|
|
546
|
+
effect.sessionId,
|
|
547
|
+
effect.worktreeId,
|
|
548
|
+
!!(effect.force === true)
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
} catch (error) {
|
|
552
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
553
|
+
const forceable = isForceableWorktreeDeleteError(message)
|
|
554
|
+
const latest = ctx.getState()
|
|
555
|
+
if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
|
|
556
|
+
const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
|
|
557
|
+
const selected = session?.worktrees?.[latest.modal.selectedIndex]
|
|
558
|
+
if (selected && selected.id !== effect.worktreeId) {
|
|
559
|
+
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
560
|
+
return
|
|
561
|
+
}
|
|
554
562
|
}
|
|
563
|
+
ctx.dispatch({
|
|
564
|
+
confirmWorktreeId: forceable ? effect.worktreeId : null,
|
|
565
|
+
message: forceable ? message : `Could not delete worktree: ${message}`,
|
|
566
|
+
type: 'set-new-tab-worktree-delete-state',
|
|
567
|
+
})
|
|
568
|
+
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
555
569
|
}
|
|
556
|
-
|
|
557
|
-
confirmWorktreeId: forceable ? effect.worktreeId : null,
|
|
558
|
-
message: forceable ? message : `Could not delete worktree: ${message}`,
|
|
559
|
-
type: 'set-new-tab-worktree-delete-state',
|
|
560
|
-
})
|
|
561
|
-
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
562
|
-
})
|
|
570
|
+
})()
|
|
563
571
|
return
|
|
564
572
|
}
|
|
565
573
|
case 'move-worktree': {
|
|
566
|
-
void
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
574
|
+
void (async () => {
|
|
575
|
+
try {
|
|
576
|
+
await enqueueGitOp(async () =>
|
|
577
|
+
runMoveWorktree(
|
|
578
|
+
{ ...ctx, state: ctx.getState() },
|
|
579
|
+
effect.sessionId,
|
|
580
|
+
effect.sourceWorktreeId,
|
|
581
|
+
effect.targetWorktreeId,
|
|
582
|
+
effect.deleteSource === true
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
} catch (error) {
|
|
586
|
+
toast.error(error instanceof Error ? error.message : String(error))
|
|
587
|
+
}
|
|
588
|
+
})()
|
|
575
589
|
return
|
|
576
590
|
}
|
|
577
591
|
case 'open-rename-selected-session': {
|
|
@@ -598,13 +612,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
598
612
|
)
|
|
599
613
|
return
|
|
600
614
|
case 'paste-selected-snippet': {
|
|
601
|
-
pasteSnippetToTab(
|
|
602
|
-
backend,
|
|
603
|
-
state.activeTabId,
|
|
604
|
-
ctx.activeTab,
|
|
605
|
-
getSelectedSnippet(state),
|
|
606
|
-
dispatch
|
|
607
|
-
)
|
|
615
|
+
pasteSnippetToTab(backend, state.activeTabId, ctx.activeTab, getSelectedSnippet(state))
|
|
608
616
|
return
|
|
609
617
|
}
|
|
610
618
|
case 'paste-snippet-to-group': {
|
|
@@ -1364,14 +1372,15 @@ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
|
|
|
1364
1372
|
stdin: 'inherit',
|
|
1365
1373
|
stdout: 'inherit',
|
|
1366
1374
|
})
|
|
1367
|
-
void
|
|
1375
|
+
void (async () => {
|
|
1376
|
+
const code = await proc.exited
|
|
1368
1377
|
if (code === 0) {
|
|
1369
1378
|
process.stdout.write(`\nUpdated. Run \`aimux\` to start the new version.\n`)
|
|
1370
1379
|
} else {
|
|
1371
1380
|
process.stderr.write(`\nUpdate failed (exit code ${code}).\n`)
|
|
1372
1381
|
}
|
|
1373
1382
|
process.exit(code ?? 1)
|
|
1374
|
-
})
|
|
1383
|
+
})()
|
|
1375
1384
|
}
|
|
1376
1385
|
|
|
1377
1386
|
async function runGitAction(
|
|
@@ -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(
|
|
@@ -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) => {
|
|
@@ -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
|
|
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,
|
|
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,
|
|
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,
|
|
107
|
+
resizeSplitTabs(backend, layoutTrees, stableTabIds, cols, rows, options)
|
|
111
108
|
} else {
|
|
112
|
-
backend.resizeAll(cols, rows,
|
|
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
|
|
169
|
+
backend.resizeTab(tabId, cols, rows)
|
|
180
170
|
},
|
|
181
171
|
[backend, contentOriginRef]
|
|
182
172
|
)
|
|
@@ -230,7 +220,6 @@ export function useTerminalResize({
|
|
|
230
220
|
backend,
|
|
231
221
|
cols: terminalSize.cols,
|
|
232
222
|
dispatch,
|
|
233
|
-
intents: intentsRef.current,
|
|
234
223
|
layoutTrees: state.layoutTrees,
|
|
235
224
|
resizingRef,
|
|
236
225
|
resizingTimerRef,
|
|
@@ -259,7 +248,6 @@ export function useTerminalResize({
|
|
|
259
248
|
backend,
|
|
260
249
|
cols: terminalSize.cols,
|
|
261
250
|
dispatch,
|
|
262
|
-
intents: intentsRef.current,
|
|
263
251
|
layoutTrees: state.layoutTrees,
|
|
264
252
|
resizingRef,
|
|
265
253
|
resizingTimerRef,
|