@brimveyn/aimux 1.1.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/LICENSE +21 -0
- package/README.md +206 -0
- package/package.json +79 -0
- package/src/app-runtime/backend-attach-runtime.ts +135 -0
- package/src/app-runtime/backend-runtime-events.ts +78 -0
- package/src/app-runtime/click-selection-resolver.ts +130 -0
- package/src/app-runtime/pty-write.ts +32 -0
- package/src/app-runtime/render-invalidation.ts +20 -0
- package/src/app-runtime/selection-clipboard.ts +69 -0
- package/src/app-runtime/selection-scroll.ts +143 -0
- package/src/app-runtime/session-actions.ts +170 -0
- package/src/app-runtime/side-effects.ts +434 -0
- package/src/app-runtime/snippet-actions.ts +90 -0
- package/src/app-runtime/split-drag-controller.ts +15 -0
- package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
- package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
- package/src/app-runtime/use-backend-runtime.ts +99 -0
- package/src/app-runtime/use-directory-search.ts +52 -0
- package/src/app-runtime/use-mouse-handlers.ts +183 -0
- package/src/app-runtime/use-renderer-bindings.ts +163 -0
- package/src/app-runtime/use-terminal-resize.ts +141 -0
- package/src/app-runtime/use-workspace-autosave.ts +39 -0
- package/src/app.tsx +247 -0
- package/src/config/loader.ts +36 -0
- package/src/config.ts +146 -0
- package/src/daemon/daemon.ts +236 -0
- package/src/daemon/runtime-paths.ts +69 -0
- package/src/daemon/session-manager.ts +109 -0
- package/src/daemon/session-registry.ts +207 -0
- package/src/debug/input-log.ts +29 -0
- package/src/doctor.ts +106 -0
- package/src/git/git-poller.ts +49 -0
- package/src/git/git-status.ts +183 -0
- package/src/index.tsx +56 -0
- package/src/input/keymap/build-handlers.ts +34 -0
- package/src/input/keymap/key-chord.ts +201 -0
- package/src/input/keymap/keymap-mode-handler.ts +88 -0
- package/src/input/keymap/sequence-resolver.ts +117 -0
- package/src/input/keymap/trie.ts +103 -0
- package/src/input/modes/bridge.ts +58 -0
- package/src/input/modes/handlers/index.ts +18 -0
- package/src/input/modes/handlers/shared.ts +70 -0
- package/src/input/modes/registry.ts +34 -0
- package/src/input/modes/transitions.ts +37 -0
- package/src/input/modes/types.ts +63 -0
- package/src/input/mouse-forwarding.ts +98 -0
- package/src/input/multi-click-detector.ts +55 -0
- package/src/input/paste.ts +12 -0
- package/src/input/raw-input-handler.ts +191 -0
- package/src/input/terminal-text-extraction.ts +78 -0
- package/src/ipc/protocol.ts +341 -0
- package/src/platform/clipboard.ts +13 -0
- package/src/platform/daemon-control.ts +62 -0
- package/src/platform/id.ts +7 -0
- package/src/platform/project-search.ts +75 -0
- package/src/pty/command-registry.ts +69 -0
- package/src/pty/pty-manager.ts +268 -0
- package/src/pty/terminal-snapshot.ts +210 -0
- package/src/restart-daemon.ts +28 -0
- package/src/session-backend/bootstrap.ts +107 -0
- package/src/session-backend/local-session-backend.ts +164 -0
- package/src/session-backend/remote-session-backend.ts +373 -0
- package/src/session-backend/types.ts +47 -0
- package/src/state/app-store.ts +19 -0
- package/src/state/layout-resize.ts +56 -0
- package/src/state/layout-tree.ts +323 -0
- package/src/state/reducers/git-panel-state.ts +102 -0
- package/src/state/reducers/modal-state.ts +358 -0
- package/src/state/reducers/session-state.ts +86 -0
- package/src/state/reducers/tab-state.ts +531 -0
- package/src/state/reducers/ui-state.ts +29 -0
- package/src/state/selectors.ts +30 -0
- package/src/state/session-catalog.ts +96 -0
- package/src/state/session-persistence.ts +210 -0
- package/src/state/snippet-catalog.ts +90 -0
- package/src/state/store.ts +93 -0
- package/src/state/terminal-modes.ts +11 -0
- package/src/state/types.ts +367 -0
- package/src/state/validation.ts +154 -0
- package/src/state/workspace-save.ts +33 -0
- package/src/ui/components/create-session-modal.tsx +100 -0
- package/src/ui/components/git-panel.tsx +200 -0
- package/src/ui/components/help-modal.tsx +79 -0
- package/src/ui/components/input-field.tsx +18 -0
- package/src/ui/components/list-item.tsx +30 -0
- package/src/ui/components/modal-filter-bar.tsx +18 -0
- package/src/ui/components/modal-shell.tsx +47 -0
- package/src/ui/components/new-tab-modal.tsx +52 -0
- package/src/ui/components/pending-chord-indicator.tsx +41 -0
- package/src/ui/components/session-name-modal.tsx +15 -0
- package/src/ui/components/session-picker-modal.tsx +93 -0
- package/src/ui/components/sidebar-group-metadata.ts +44 -0
- package/src/ui/components/sidebar-scroll.ts +33 -0
- package/src/ui/components/sidebar.tsx +225 -0
- package/src/ui/components/snippet-editor-modal.tsx +39 -0
- package/src/ui/components/snippet-picker-modal.tsx +56 -0
- package/src/ui/components/split-layout.tsx +201 -0
- package/src/ui/components/status-bar.tsx +60 -0
- package/src/ui/components/surface.tsx +63 -0
- package/src/ui/components/tab-item.tsx +118 -0
- package/src/ui/components/terminal-pane.tsx +215 -0
- package/src/ui/components/theme-picker-modal.tsx +35 -0
- package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
- package/src/ui/components/use-sidebar-branch.ts +36 -0
- package/src/ui/directory-search.ts +1 -0
- package/src/ui/git-branch.ts +11 -0
- package/src/ui/path-format.ts +6 -0
- package/src/ui/root.tsx +261 -0
- package/src/ui/status-bar-model.ts +87 -0
- package/src/ui/theme.ts +9 -0
- package/src/ui/themes.ts +255 -0
- package/src/ui/ui-tokens.ts +11 -0
- package/src/update.ts +62 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type MutableRefObject, useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { SessionBackend } from '../session-backend/types'
|
|
4
|
+
import type { AppAction, LayoutState } from '../state/types'
|
|
5
|
+
|
|
6
|
+
import { attachCurrentSession } from './backend-attach-runtime'
|
|
7
|
+
import { bindBackendRuntimeEvents } from './backend-runtime-events'
|
|
8
|
+
import { useTabRuntimeTimeouts } from './tab-runtime-timeouts'
|
|
9
|
+
|
|
10
|
+
interface BackendRuntimeOptions {
|
|
11
|
+
backend: SessionBackend
|
|
12
|
+
dispatch: (action: AppAction) => void
|
|
13
|
+
activeTabId: string | null
|
|
14
|
+
currentSessionId: string | null
|
|
15
|
+
layoutRef: MutableRefObject<LayoutState>
|
|
16
|
+
resizingRef: MutableRefObject<boolean>
|
|
17
|
+
currentSessionWorkspaceSnapshot: Parameters<SessionBackend['attach']>[0]['workspaceSnapshot']
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TabRuntimeControls {
|
|
21
|
+
clearIdleTimer: (tabId: string) => void
|
|
22
|
+
clearStartupGrace: (tabId: string) => void
|
|
23
|
+
startStartupGrace: (tabId: string, timeoutMs: number) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useBackendRuntime({
|
|
27
|
+
activeTabId,
|
|
28
|
+
backend,
|
|
29
|
+
currentSessionId,
|
|
30
|
+
currentSessionWorkspaceSnapshot,
|
|
31
|
+
dispatch,
|
|
32
|
+
layoutRef,
|
|
33
|
+
resizingRef,
|
|
34
|
+
}: BackendRuntimeOptions): TabRuntimeControls {
|
|
35
|
+
const attachRequestIdRef = useRef(0)
|
|
36
|
+
const timeouts = useTabRuntimeTimeouts(dispatch)
|
|
37
|
+
const {
|
|
38
|
+
clearAllTimers,
|
|
39
|
+
clearIdleTimer,
|
|
40
|
+
clearStartupGrace,
|
|
41
|
+
isStartupGraceActive,
|
|
42
|
+
scheduleIdle,
|
|
43
|
+
startStartupGrace,
|
|
44
|
+
} = timeouts
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!currentSessionId) {
|
|
48
|
+
attachRequestIdRef.current += 1
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return attachCurrentSession({
|
|
53
|
+
attachRequestIdRef,
|
|
54
|
+
backend,
|
|
55
|
+
currentSessionId,
|
|
56
|
+
currentSessionWorkspaceSnapshot,
|
|
57
|
+
dispatch,
|
|
58
|
+
layoutRef,
|
|
59
|
+
})
|
|
60
|
+
}, [backend, currentSessionId, currentSessionWorkspaceSnapshot, dispatch, layoutRef])
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!currentSessionId) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
backend.setActiveTab(activeTabId)
|
|
68
|
+
}, [activeTabId, backend, currentSessionId])
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
return bindBackendRuntimeEvents({
|
|
72
|
+
backend,
|
|
73
|
+
dispatch,
|
|
74
|
+
resizingRef,
|
|
75
|
+
timeouts: {
|
|
76
|
+
clearAllTimers,
|
|
77
|
+
clearIdleTimer,
|
|
78
|
+
clearStartupGrace,
|
|
79
|
+
isStartupGraceActive,
|
|
80
|
+
scheduleIdle,
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
}, [
|
|
84
|
+
backend,
|
|
85
|
+
clearAllTimers,
|
|
86
|
+
clearIdleTimer,
|
|
87
|
+
clearStartupGrace,
|
|
88
|
+
dispatch,
|
|
89
|
+
isStartupGraceActive,
|
|
90
|
+
resizingRef,
|
|
91
|
+
scheduleIdle,
|
|
92
|
+
])
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
clearIdleTimer,
|
|
96
|
+
clearStartupGrace,
|
|
97
|
+
startStartupGrace,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { AppAction, ModalState } from '../state/types'
|
|
4
|
+
|
|
5
|
+
import { searchProjectDirectories } from '../platform/project-search'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_DIRECTORY_SEARCH_DEBOUNCE_MS = 200
|
|
8
|
+
|
|
9
|
+
function getDirectoryQuery(modal: ModalState): string {
|
|
10
|
+
if (modal.type !== 'create-session') {
|
|
11
|
+
return ''
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (modal.activeField === 'directory') {
|
|
15
|
+
return modal.editBuffer ?? ''
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return modal.nameBuffer
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useDirectorySearch(
|
|
22
|
+
modal: ModalState,
|
|
23
|
+
dispatch: (action: AppAction) => void,
|
|
24
|
+
debounceMs = DEFAULT_DIRECTORY_SEARCH_DEBOUNCE_MS
|
|
25
|
+
): void {
|
|
26
|
+
const directoryQuery = getDirectoryQuery(modal)
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (modal.type !== 'create-session') {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let isCurrent = true
|
|
34
|
+
|
|
35
|
+
if (!directoryQuery.trim()) {
|
|
36
|
+
dispatch({ results: [], type: 'set-directory-results' })
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const timer = setTimeout(async () => {
|
|
41
|
+
const results = await searchProjectDirectories(directoryQuery)
|
|
42
|
+
if (isCurrent) {
|
|
43
|
+
dispatch({ results, type: 'set-directory-results' })
|
|
44
|
+
}
|
|
45
|
+
}, debounceMs)
|
|
46
|
+
|
|
47
|
+
return () => {
|
|
48
|
+
isCurrent = false
|
|
49
|
+
clearTimeout(timer)
|
|
50
|
+
}
|
|
51
|
+
}, [debounceMs, directoryQuery, dispatch, modal.type])
|
|
52
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { TerminalContentOrigin } from '../input/raw-input-handler'
|
|
6
|
+
import type { SessionBackend } from '../session-backend/types'
|
|
7
|
+
import type { SplitDirection } from '../state/layout-tree'
|
|
8
|
+
import type { AppAction, AppState, TabSession } from '../state/types'
|
|
9
|
+
|
|
10
|
+
import { logInputDebug } from '../debug/input-log'
|
|
11
|
+
import { MultiClickDetector } from '../input/multi-click-detector'
|
|
12
|
+
import { copyToSystemClipboard } from '../platform/clipboard'
|
|
13
|
+
import {
|
|
14
|
+
type ClickSelectionResult,
|
|
15
|
+
isPositionedNode,
|
|
16
|
+
resolveClickSelection,
|
|
17
|
+
} from './click-selection-resolver'
|
|
18
|
+
import { requestRenderUpTree } from './render-invalidation'
|
|
19
|
+
import { getSplitRatioFromDrag, type SplitDragState } from './split-drag-controller'
|
|
20
|
+
import { getForwardedMouseSequence, getScrollViewportDelta } from './terminal-mouse-adapter'
|
|
21
|
+
|
|
22
|
+
interface UseMouseHandlersOptions {
|
|
23
|
+
state: AppState
|
|
24
|
+
dispatch: (action: AppAction) => void
|
|
25
|
+
backend: SessionBackend
|
|
26
|
+
renderer: {
|
|
27
|
+
clearSelection(): void
|
|
28
|
+
hasSelection?: boolean
|
|
29
|
+
startSelection(target: unknown, x: number, y: number): void
|
|
30
|
+
updateSelection(target: unknown, x: number, y: number, opts: { finishDragging: boolean }): void
|
|
31
|
+
}
|
|
32
|
+
activeMouseForwardingEnabled: boolean
|
|
33
|
+
activeLocalScrollbackEnabled: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MIN_MULTI_CLICK_SELECTION_COUNT = 2
|
|
37
|
+
|
|
38
|
+
function getTargetTerminalTabId(
|
|
39
|
+
focusMode: AppState['focusMode'],
|
|
40
|
+
activeTabId: string | null,
|
|
41
|
+
isEnabled: boolean
|
|
42
|
+
): string | null {
|
|
43
|
+
if (focusMode !== 'terminal-input' || !activeTabId || !isEnabled) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return activeTabId
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function applyResolvedSelection(
|
|
51
|
+
renderer: UseMouseHandlersOptions['renderer'],
|
|
52
|
+
selection: ClickSelectionResult
|
|
53
|
+
): void {
|
|
54
|
+
renderer.clearSelection()
|
|
55
|
+
renderer.startSelection(selection.target, selection.baseX + selection.startCol, selection.eventY)
|
|
56
|
+
renderer.updateSelection(selection.target, selection.baseX + selection.endCol, selection.eventY, {
|
|
57
|
+
finishDragging: true,
|
|
58
|
+
})
|
|
59
|
+
requestRenderUpTree(selection.target)
|
|
60
|
+
copyToSystemClipboard(selection.selectedText)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useMouseHandlers({
|
|
64
|
+
activeLocalScrollbackEnabled,
|
|
65
|
+
activeMouseForwardingEnabled,
|
|
66
|
+
backend,
|
|
67
|
+
dispatch,
|
|
68
|
+
renderer,
|
|
69
|
+
state,
|
|
70
|
+
}: UseMouseHandlersOptions) {
|
|
71
|
+
const separatorDragRef = useRef<SplitDragState | null>(null)
|
|
72
|
+
const multiClickRef = useRef(new MultiClickDetector())
|
|
73
|
+
|
|
74
|
+
const handleTerminalMouseEvent = (event: OtuiMouseEvent, origin: TerminalContentOrigin) => {
|
|
75
|
+
const targetTabId = getTargetTerminalTabId(
|
|
76
|
+
state.focusMode,
|
|
77
|
+
state.activeTabId,
|
|
78
|
+
activeMouseForwardingEnabled
|
|
79
|
+
)
|
|
80
|
+
if (!targetTabId) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sequence = getForwardedMouseSequence(event, origin)
|
|
85
|
+
if (!sequence) {
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
backend.write(targetTabId, sequence)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const handleTerminalScrollEvent = (event: OtuiMouseEvent) => {
|
|
93
|
+
const targetTabId = getTargetTerminalTabId(state.focusMode, state.activeTabId, true)
|
|
94
|
+
if (!targetTabId || activeMouseForwardingEnabled || !activeLocalScrollbackEnabled) {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const delta = getScrollViewportDelta(event)
|
|
99
|
+
if (delta === null) {
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
backend.scrollViewport(targetTabId, delta)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const handleSplitResize = (tabId: string, ratio: number, axis: SplitDirection) => {
|
|
107
|
+
dispatch({ axis, ratio, tabId, type: 'set-split-ratio' })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const handleSeparatorDragStart = (info: {
|
|
111
|
+
tabId: string
|
|
112
|
+
direction: SplitDirection
|
|
113
|
+
screenStart: number
|
|
114
|
+
totalSize: number
|
|
115
|
+
}) => {
|
|
116
|
+
separatorDragRef.current = info
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const handleSeparatorDrag = (event: OtuiMouseEvent): boolean => {
|
|
120
|
+
const drag = separatorDragRef.current
|
|
121
|
+
if (!drag) {
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const newRatio = getSplitRatioFromDrag(event, drag)
|
|
126
|
+
dispatch({ axis: drag.direction, ratio: newRatio, tabId: drag.tabId, type: 'set-split-ratio' })
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const handleSeparatorDragEnd = () => {
|
|
131
|
+
separatorDragRef.current = null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const handlePaneActivate = (tabId: string) => {
|
|
135
|
+
if (tabId !== state.activeTabId) {
|
|
136
|
+
dispatch({ tabId, type: 'set-active-tab' })
|
|
137
|
+
}
|
|
138
|
+
if (state.focusMode !== 'terminal-input') {
|
|
139
|
+
dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const handleTerminalClick = (
|
|
144
|
+
event: OtuiMouseEvent,
|
|
145
|
+
_origin: TerminalContentOrigin,
|
|
146
|
+
tabId?: string
|
|
147
|
+
) => {
|
|
148
|
+
const targetTabId = tabId ?? state.activeTabId
|
|
149
|
+
if (!targetTabId || !event.target) {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const clickCount = multiClickRef.current.track(event.x, event.y)
|
|
154
|
+
if (clickCount < MIN_MULTI_CLICK_SELECTION_COUNT) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const tab = state.tabs.find((t: TabSession) => t.id === targetTabId)
|
|
159
|
+
const selection = resolveClickSelection(event, targetTabId, tab, clickCount)
|
|
160
|
+
if (!selection) {
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
event.preventDefault()
|
|
165
|
+
applyResolvedSelection(renderer, selection)
|
|
166
|
+
|
|
167
|
+
logInputDebug('click.done', {
|
|
168
|
+
hasSelection: !!renderer.hasSelection,
|
|
169
|
+
targetSelectable: isPositionedNode(event.target) ? !!event.target.selectable : false,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
handlePaneActivate,
|
|
175
|
+
handleSeparatorDrag,
|
|
176
|
+
handleSeparatorDragEnd,
|
|
177
|
+
handleSeparatorDragStart,
|
|
178
|
+
handleSplitResize,
|
|
179
|
+
handleTerminalClick,
|
|
180
|
+
handleTerminalMouseEvent,
|
|
181
|
+
handleTerminalScrollEvent,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useRenderer } from '@opentui/react'
|
|
2
|
+
import { type MutableRefObject, useEffect, useRef } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { KeyChord } from '../input/keymap/key-chord'
|
|
5
|
+
import type { SessionBackend } from '../session-backend/types'
|
|
6
|
+
import type { AppAction, FocusMode, TabSession } from '../state/types'
|
|
7
|
+
|
|
8
|
+
import { INPUT_DEBUG_LOG_PATH, logInputDebug } from '../debug/input-log'
|
|
9
|
+
import { createRawInputHandler } from '../input/raw-input-handler'
|
|
10
|
+
import { copyToSystemClipboard } from '../platform/clipboard'
|
|
11
|
+
import { writePasteToTab, writeToTab } from './pty-write'
|
|
12
|
+
import { type OtuiSelection, resolveSelectionClipboardText } from './selection-clipboard'
|
|
13
|
+
import { applyViewportObservation, type ViewportObservation } from './selection-scroll'
|
|
14
|
+
|
|
15
|
+
const BRACKETED_PASTE_ENABLE_SEQUENCE = '\x1b[?2004h'
|
|
16
|
+
const BRACKETED_PASTE_DISABLE_SEQUENCE = '\x1b[?2004l'
|
|
17
|
+
const PASTE_DEBUG_PREVIEW_LENGTH = 120
|
|
18
|
+
const TEXT_DECODER = new TextDecoder()
|
|
19
|
+
|
|
20
|
+
interface UseRendererBindingsOptions {
|
|
21
|
+
backend: SessionBackend
|
|
22
|
+
renderer: ReturnType<typeof useRenderer>
|
|
23
|
+
dispatch: (action: AppAction) => void
|
|
24
|
+
focusMode: FocusMode
|
|
25
|
+
activeTabId: string | null
|
|
26
|
+
activeTabViewportY: number | null
|
|
27
|
+
focusModeRef: MutableRefObject<FocusMode>
|
|
28
|
+
activeTabIdRef: MutableRefObject<string | null>
|
|
29
|
+
activeTabRef: MutableRefObject<TabSession | undefined>
|
|
30
|
+
handleTerminalShortcut: (chord: KeyChord) => boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function decodeBytes(bytes: Uint8Array): string {
|
|
34
|
+
return TEXT_DECODER.decode(bytes)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useRendererBindings({
|
|
38
|
+
activeTabId,
|
|
39
|
+
activeTabIdRef,
|
|
40
|
+
activeTabRef,
|
|
41
|
+
activeTabViewportY,
|
|
42
|
+
backend,
|
|
43
|
+
dispatch,
|
|
44
|
+
focusMode,
|
|
45
|
+
focusModeRef,
|
|
46
|
+
handleTerminalShortcut,
|
|
47
|
+
renderer,
|
|
48
|
+
}: UseRendererBindingsOptions): void {
|
|
49
|
+
const lastViewportRef = useRef<ViewportObservation | null>(null)
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
renderer.useMouse = true
|
|
53
|
+
renderer.useConsole = false
|
|
54
|
+
renderer.console.hide()
|
|
55
|
+
renderer.console.show = () => {}
|
|
56
|
+
|
|
57
|
+
const handler = createRawInputHandler({
|
|
58
|
+
getActiveTabId: () => activeTabIdRef.current,
|
|
59
|
+
getBracketedPasteModeEnabled: () =>
|
|
60
|
+
activeTabRef.current?.terminalModes.bracketedPasteMode ?? false,
|
|
61
|
+
getFocusMode: () => focusModeRef.current,
|
|
62
|
+
handleTerminalShortcut,
|
|
63
|
+
writeToPty: (tabId, data) => writeToTab(backend, tabId, activeTabRef.current, data),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const handlePasteEvent = (event: { bytes: Uint8Array; defaultPrevented?: boolean }) => {
|
|
67
|
+
logInputDebug('app.rendererPaste', {
|
|
68
|
+
byteLength: event.bytes.length,
|
|
69
|
+
defaultPrevented: event.defaultPrevented ?? false,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (event.defaultPrevented) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tab = activeTabRef.current
|
|
77
|
+
const tabId = activeTabIdRef.current
|
|
78
|
+
const currentFocusMode = focusModeRef.current
|
|
79
|
+
const payload = decodeBytes(event.bytes)
|
|
80
|
+
|
|
81
|
+
logInputDebug('app.onTerminalPaste', {
|
|
82
|
+
activeTabId: tabId,
|
|
83
|
+
bracketedPasteMode: tab?.terminalModes.bracketedPasteMode ?? false,
|
|
84
|
+
byteLength: event.bytes.length,
|
|
85
|
+
decodedPreview: payload.slice(0, PASTE_DEBUG_PREVIEW_LENGTH),
|
|
86
|
+
focusMode: currentFocusMode,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
if (currentFocusMode !== 'terminal-input' || !tabId || !tab) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
writePasteToTab(backend, tabId, tab, payload)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const handleSelection = (selection: OtuiSelection) => {
|
|
97
|
+
const { fallbackLength, selectedText, streamLength } = resolveSelectionClipboardText(
|
|
98
|
+
selection,
|
|
99
|
+
activeTabRef.current
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
logInputDebug('app.selection', {
|
|
103
|
+
fallbackLength,
|
|
104
|
+
isDragging: selection.isDragging ?? false,
|
|
105
|
+
osc52Supported: renderer.isOsc52Supported(),
|
|
106
|
+
streamLength,
|
|
107
|
+
textLength: selectedText.length,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
if (selection.isDragging || selectedText.length === 0) {
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
renderer.copyToClipboardOSC52(selectedText)
|
|
115
|
+
copyToSystemClipboard(selectedText)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
renderer.prependInputHandler(handler)
|
|
119
|
+
renderer.keyInput.on('paste', handlePasteEvent)
|
|
120
|
+
renderer.on('selection', handleSelection)
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
renderer.removeInputHandler(handler)
|
|
124
|
+
renderer.keyInput.off('paste', handlePasteEvent)
|
|
125
|
+
renderer.off('selection', handleSelection)
|
|
126
|
+
}
|
|
127
|
+
}, [
|
|
128
|
+
activeTabIdRef,
|
|
129
|
+
activeTabRef,
|
|
130
|
+
backend,
|
|
131
|
+
dispatch,
|
|
132
|
+
focusModeRef,
|
|
133
|
+
handleTerminalShortcut,
|
|
134
|
+
renderer,
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const next: ViewportObservation | null =
|
|
139
|
+
activeTabId !== null && activeTabViewportY !== null
|
|
140
|
+
? { tabId: activeTabId, y: activeTabViewportY }
|
|
141
|
+
: null
|
|
142
|
+
lastViewportRef.current = applyViewportObservation(renderer, lastViewportRef.current, next)
|
|
143
|
+
}, [activeTabId, activeTabViewportY, renderer])
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
const shouldEnableBracketedPaste = focusMode === 'terminal-input' && activeTabId !== null
|
|
147
|
+
logInputDebug('app.bracketedPasteMode', {
|
|
148
|
+
activeTabId,
|
|
149
|
+
enabled: shouldEnableBracketedPaste,
|
|
150
|
+
focusMode,
|
|
151
|
+
logPath: INPUT_DEBUG_LOG_PATH,
|
|
152
|
+
})
|
|
153
|
+
process.stdout.write(
|
|
154
|
+
shouldEnableBracketedPaste
|
|
155
|
+
? BRACKETED_PASTE_ENABLE_SEQUENCE
|
|
156
|
+
: BRACKETED_PASTE_DISABLE_SEQUENCE
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return () => {
|
|
160
|
+
process.stdout.write(BRACKETED_PASTE_DISABLE_SEQUENCE)
|
|
161
|
+
}
|
|
162
|
+
}, [activeTabId, focusMode])
|
|
163
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { type MutableRefObject, useEffect, useMemo, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { TerminalContentOrigin } from '../input/raw-input-handler'
|
|
4
|
+
import type { SessionBackend } from '../session-backend/types'
|
|
5
|
+
import type { AppAction, AppState } from '../state/types'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createTerminalBounds,
|
|
9
|
+
forEachSplitPaneRect,
|
|
10
|
+
toTerminalContentSize,
|
|
11
|
+
} from '../state/layout-resize'
|
|
12
|
+
|
|
13
|
+
const MAIN_AREA_HORIZONTAL_CHROME = 2
|
|
14
|
+
const MAIN_AREA_VERTICAL_PADDING = 0
|
|
15
|
+
const STATUS_BAR_HEIGHT = 2
|
|
16
|
+
const TERMINAL_PANE_VERTICAL_CHROME = 2
|
|
17
|
+
const MIN_TERMINAL_ROWS = 1
|
|
18
|
+
const MIN_TERMINAL_COLS = 20
|
|
19
|
+
const RESIZE_ACTIVITY_SETTLE_MS = 500
|
|
20
|
+
|
|
21
|
+
function getTerminalBounds(cols: number, rows: number) {
|
|
22
|
+
return createTerminalBounds(cols, rows)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resizeSplitTabs(
|
|
26
|
+
backend: SessionBackend,
|
|
27
|
+
layoutTrees: AppState['layoutTrees'],
|
|
28
|
+
tabIds: string[],
|
|
29
|
+
cols: number,
|
|
30
|
+
rows: number
|
|
31
|
+
): void {
|
|
32
|
+
const bounds = getTerminalBounds(cols, rows)
|
|
33
|
+
const resizedTabIds = new Set<string>()
|
|
34
|
+
|
|
35
|
+
forEachSplitPaneRect(Object.values(layoutTrees), bounds, (tabId, rect) => {
|
|
36
|
+
const size = toTerminalContentSize(rect)
|
|
37
|
+
backend.resizeTab(tabId, size.cols, size.rows)
|
|
38
|
+
resizedTabIds.add(tabId)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
for (const id of tabIds) {
|
|
42
|
+
if (!resizedTabIds.has(id)) {
|
|
43
|
+
backend.resizeTab(id, cols, rows)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface UseTerminalResizeOptions {
|
|
49
|
+
state: AppState
|
|
50
|
+
dispatch: (action: AppAction) => void
|
|
51
|
+
backend: SessionBackend
|
|
52
|
+
dimensions: { width: number; height: number }
|
|
53
|
+
contentOriginRef: MutableRefObject<TerminalContentOrigin>
|
|
54
|
+
resizingRef: MutableRefObject<boolean>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useTerminalResize({
|
|
58
|
+
backend,
|
|
59
|
+
contentOriginRef,
|
|
60
|
+
dimensions,
|
|
61
|
+
dispatch,
|
|
62
|
+
resizingRef,
|
|
63
|
+
state,
|
|
64
|
+
}: UseTerminalResizeOptions) {
|
|
65
|
+
const resizingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
66
|
+
const tabIdsRef = useRef<string[]>([])
|
|
67
|
+
|
|
68
|
+
const currentTabIds = state.tabs.map((t) => t.id)
|
|
69
|
+
const tabIdsChanged =
|
|
70
|
+
currentTabIds.length !== tabIdsRef.current.length ||
|
|
71
|
+
currentTabIds.some((id, i) => id !== tabIdsRef.current[i])
|
|
72
|
+
if (tabIdsChanged) {
|
|
73
|
+
tabIdsRef.current = currentTabIds
|
|
74
|
+
}
|
|
75
|
+
const stableTabIds = tabIdsRef.current
|
|
76
|
+
|
|
77
|
+
const terminalSize = useMemo(() => {
|
|
78
|
+
const sidebarWidth = state.sidebar.visible ? state.sidebar.width + 1 : 0
|
|
79
|
+
const reservedRows =
|
|
80
|
+
MAIN_AREA_VERTICAL_PADDING + STATUS_BAR_HEIGHT + TERMINAL_PANE_VERTICAL_CHROME
|
|
81
|
+
const cols = Math.max(
|
|
82
|
+
MIN_TERMINAL_COLS,
|
|
83
|
+
Math.floor(dimensions.width - sidebarWidth - MAIN_AREA_HORIZONTAL_CHROME)
|
|
84
|
+
)
|
|
85
|
+
const rows = Math.max(MIN_TERMINAL_ROWS, Math.floor(dimensions.height - reservedRows))
|
|
86
|
+
|
|
87
|
+
contentOriginRef.current = {
|
|
88
|
+
cols,
|
|
89
|
+
rows,
|
|
90
|
+
x: sidebarWidth + 1,
|
|
91
|
+
y: 1,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { cols, rows }
|
|
95
|
+
}, [
|
|
96
|
+
contentOriginRef,
|
|
97
|
+
dimensions.height,
|
|
98
|
+
dimensions.width,
|
|
99
|
+
state.sidebar.visible,
|
|
100
|
+
state.sidebar.width,
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
dispatch({
|
|
105
|
+
cols: terminalSize.cols,
|
|
106
|
+
rows: terminalSize.rows,
|
|
107
|
+
type: 'set-terminal-size',
|
|
108
|
+
})
|
|
109
|
+
resizingRef.current = true
|
|
110
|
+
if (resizingTimerRef.current) {
|
|
111
|
+
clearTimeout(resizingTimerRef.current)
|
|
112
|
+
}
|
|
113
|
+
const trees = Object.values(state.layoutTrees)
|
|
114
|
+
const hasSplits = trees.some((t) => t.type === 'split')
|
|
115
|
+
if (hasSplits) {
|
|
116
|
+
resizeSplitTabs(
|
|
117
|
+
backend,
|
|
118
|
+
state.layoutTrees,
|
|
119
|
+
stableTabIds,
|
|
120
|
+
terminalSize.cols,
|
|
121
|
+
terminalSize.rows
|
|
122
|
+
)
|
|
123
|
+
} else {
|
|
124
|
+
backend.resizeAll(terminalSize.cols, terminalSize.rows)
|
|
125
|
+
}
|
|
126
|
+
resizingTimerRef.current = setTimeout(() => {
|
|
127
|
+
resizingRef.current = false
|
|
128
|
+
resizingTimerRef.current = null
|
|
129
|
+
}, RESIZE_ACTIVITY_SETTLE_MS)
|
|
130
|
+
}, [
|
|
131
|
+
backend,
|
|
132
|
+
dispatch,
|
|
133
|
+
resizingRef,
|
|
134
|
+
terminalSize.cols,
|
|
135
|
+
terminalSize.rows,
|
|
136
|
+
state.layoutTrees,
|
|
137
|
+
stableTabIds,
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
return terminalSize
|
|
141
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { AppState } from '../state/types'
|
|
4
|
+
|
|
5
|
+
import { saveCurrentWorkspace } from '../state/workspace-save'
|
|
6
|
+
|
|
7
|
+
export function useWorkspaceAutosave(state: AppState, debounceMs: number): void {
|
|
8
|
+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
9
|
+
const latestStateRef = useRef(state)
|
|
10
|
+
latestStateRef.current = state
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (timeoutRef.current) {
|
|
14
|
+
clearTimeout(timeoutRef.current)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
timeoutRef.current = setTimeout(() => {
|
|
18
|
+
saveCurrentWorkspace(latestStateRef.current)
|
|
19
|
+
timeoutRef.current = null
|
|
20
|
+
}, debounceMs)
|
|
21
|
+
|
|
22
|
+
return () => {
|
|
23
|
+
if (timeoutRef.current) {
|
|
24
|
+
clearTimeout(timeoutRef.current)
|
|
25
|
+
timeoutRef.current = null
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}, [debounceMs, state])
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
return () => {
|
|
32
|
+
if (timeoutRef.current) {
|
|
33
|
+
clearTimeout(timeoutRef.current)
|
|
34
|
+
timeoutRef.current = null
|
|
35
|
+
}
|
|
36
|
+
saveCurrentWorkspace(latestStateRef.current)
|
|
37
|
+
}
|
|
38
|
+
}, [])
|
|
39
|
+
}
|