@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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/package.json +79 -0
  4. package/src/app-runtime/backend-attach-runtime.ts +135 -0
  5. package/src/app-runtime/backend-runtime-events.ts +78 -0
  6. package/src/app-runtime/click-selection-resolver.ts +130 -0
  7. package/src/app-runtime/pty-write.ts +32 -0
  8. package/src/app-runtime/render-invalidation.ts +20 -0
  9. package/src/app-runtime/selection-clipboard.ts +69 -0
  10. package/src/app-runtime/selection-scroll.ts +143 -0
  11. package/src/app-runtime/session-actions.ts +170 -0
  12. package/src/app-runtime/side-effects.ts +434 -0
  13. package/src/app-runtime/snippet-actions.ts +90 -0
  14. package/src/app-runtime/split-drag-controller.ts +15 -0
  15. package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
  16. package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
  17. package/src/app-runtime/use-backend-runtime.ts +99 -0
  18. package/src/app-runtime/use-directory-search.ts +52 -0
  19. package/src/app-runtime/use-mouse-handlers.ts +183 -0
  20. package/src/app-runtime/use-renderer-bindings.ts +163 -0
  21. package/src/app-runtime/use-terminal-resize.ts +141 -0
  22. package/src/app-runtime/use-workspace-autosave.ts +39 -0
  23. package/src/app.tsx +247 -0
  24. package/src/config/loader.ts +36 -0
  25. package/src/config.ts +146 -0
  26. package/src/daemon/daemon.ts +236 -0
  27. package/src/daemon/runtime-paths.ts +69 -0
  28. package/src/daemon/session-manager.ts +109 -0
  29. package/src/daemon/session-registry.ts +207 -0
  30. package/src/debug/input-log.ts +29 -0
  31. package/src/doctor.ts +106 -0
  32. package/src/git/git-poller.ts +49 -0
  33. package/src/git/git-status.ts +183 -0
  34. package/src/index.tsx +56 -0
  35. package/src/input/keymap/build-handlers.ts +34 -0
  36. package/src/input/keymap/key-chord.ts +201 -0
  37. package/src/input/keymap/keymap-mode-handler.ts +88 -0
  38. package/src/input/keymap/sequence-resolver.ts +117 -0
  39. package/src/input/keymap/trie.ts +103 -0
  40. package/src/input/modes/bridge.ts +58 -0
  41. package/src/input/modes/handlers/index.ts +18 -0
  42. package/src/input/modes/handlers/shared.ts +70 -0
  43. package/src/input/modes/registry.ts +34 -0
  44. package/src/input/modes/transitions.ts +37 -0
  45. package/src/input/modes/types.ts +63 -0
  46. package/src/input/mouse-forwarding.ts +98 -0
  47. package/src/input/multi-click-detector.ts +55 -0
  48. package/src/input/paste.ts +12 -0
  49. package/src/input/raw-input-handler.ts +191 -0
  50. package/src/input/terminal-text-extraction.ts +78 -0
  51. package/src/ipc/protocol.ts +341 -0
  52. package/src/platform/clipboard.ts +13 -0
  53. package/src/platform/daemon-control.ts +62 -0
  54. package/src/platform/id.ts +7 -0
  55. package/src/platform/project-search.ts +75 -0
  56. package/src/pty/command-registry.ts +69 -0
  57. package/src/pty/pty-manager.ts +268 -0
  58. package/src/pty/terminal-snapshot.ts +210 -0
  59. package/src/restart-daemon.ts +28 -0
  60. package/src/session-backend/bootstrap.ts +107 -0
  61. package/src/session-backend/local-session-backend.ts +164 -0
  62. package/src/session-backend/remote-session-backend.ts +373 -0
  63. package/src/session-backend/types.ts +47 -0
  64. package/src/state/app-store.ts +19 -0
  65. package/src/state/layout-resize.ts +56 -0
  66. package/src/state/layout-tree.ts +323 -0
  67. package/src/state/reducers/git-panel-state.ts +102 -0
  68. package/src/state/reducers/modal-state.ts +358 -0
  69. package/src/state/reducers/session-state.ts +86 -0
  70. package/src/state/reducers/tab-state.ts +531 -0
  71. package/src/state/reducers/ui-state.ts +29 -0
  72. package/src/state/selectors.ts +30 -0
  73. package/src/state/session-catalog.ts +96 -0
  74. package/src/state/session-persistence.ts +210 -0
  75. package/src/state/snippet-catalog.ts +90 -0
  76. package/src/state/store.ts +93 -0
  77. package/src/state/terminal-modes.ts +11 -0
  78. package/src/state/types.ts +367 -0
  79. package/src/state/validation.ts +154 -0
  80. package/src/state/workspace-save.ts +33 -0
  81. package/src/ui/components/create-session-modal.tsx +100 -0
  82. package/src/ui/components/git-panel.tsx +200 -0
  83. package/src/ui/components/help-modal.tsx +79 -0
  84. package/src/ui/components/input-field.tsx +18 -0
  85. package/src/ui/components/list-item.tsx +30 -0
  86. package/src/ui/components/modal-filter-bar.tsx +18 -0
  87. package/src/ui/components/modal-shell.tsx +47 -0
  88. package/src/ui/components/new-tab-modal.tsx +52 -0
  89. package/src/ui/components/pending-chord-indicator.tsx +41 -0
  90. package/src/ui/components/session-name-modal.tsx +15 -0
  91. package/src/ui/components/session-picker-modal.tsx +93 -0
  92. package/src/ui/components/sidebar-group-metadata.ts +44 -0
  93. package/src/ui/components/sidebar-scroll.ts +33 -0
  94. package/src/ui/components/sidebar.tsx +225 -0
  95. package/src/ui/components/snippet-editor-modal.tsx +39 -0
  96. package/src/ui/components/snippet-picker-modal.tsx +56 -0
  97. package/src/ui/components/split-layout.tsx +201 -0
  98. package/src/ui/components/status-bar.tsx +60 -0
  99. package/src/ui/components/surface.tsx +63 -0
  100. package/src/ui/components/tab-item.tsx +118 -0
  101. package/src/ui/components/terminal-pane.tsx +215 -0
  102. package/src/ui/components/theme-picker-modal.tsx +35 -0
  103. package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
  104. package/src/ui/components/use-sidebar-branch.ts +36 -0
  105. package/src/ui/directory-search.ts +1 -0
  106. package/src/ui/git-branch.ts +11 -0
  107. package/src/ui/path-format.ts +6 -0
  108. package/src/ui/root.tsx +261 -0
  109. package/src/ui/status-bar-model.ts +87 -0
  110. package/src/ui/theme.ts +9 -0
  111. package/src/ui/themes.ts +255 -0
  112. package/src/ui/ui-tokens.ts +11 -0
  113. 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
+ }