@brimveyn/aimux 1.9.9 → 1.10.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-runtime-events.ts +8 -1
- package/src/app-runtime/click-selection-resolver.ts +90 -56
- package/src/app-runtime/side-effects.ts +7 -1
- package/src/app-runtime/use-backend-runtime.ts +12 -1
- package/src/app-runtime/use-mouse-handlers.ts +299 -6
- package/src/app.tsx +56 -1
- package/src/config.ts +10 -1
- package/src/daemon/daemon.ts +26 -0
- package/src/daemon/session-manager.ts +13 -0
- package/src/daemon/session-registry.ts +8 -0
- package/src/index.tsx +14 -6
- package/src/input/modes/types.ts +1 -0
- package/src/integrations/claude-syntax-overlay.ts +460 -0
- package/src/integrations/claude-theme-sync.ts +118 -0
- package/src/ipc/manager-protocol.ts +14 -1
- package/src/pty/pty-manager.ts +37 -1
- package/src/terminal-manager/manager-client.ts +25 -0
- package/src/terminal-manager/terminal-manager.ts +54 -0
- package/src/ui/components/layout/split-layout.tsx +10 -0
- package/src/ui/components/layout/terminal-pane.tsx +19 -5
- package/src/ui/components/modals/themes/theme-picker-modal.tsx +3 -1
- package/src/ui/root.tsx +13 -1
- package/src/ui/theme-store.ts +18 -0
- package/src/ui/theme.ts +2 -0
- package/src/update.ts +21 -1
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
2
2
|
|
|
3
|
-
import { useRef } from 'react'
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
4
|
|
|
5
5
|
import type { TerminalContentOrigin } from '../input/raw-input-handler'
|
|
6
6
|
import type { SessionBackend } from '../session-backend/types'
|
|
7
7
|
import type { SplitDirection } from '../state/layout-tree'
|
|
8
|
-
import type { AppAction, AppState, TabSession } from '../state/types'
|
|
8
|
+
import type { AppAction, AppState, TabSession, TerminalLine } from '../state/types'
|
|
9
9
|
|
|
10
10
|
import { logInputDebug } from '../debug/input-log'
|
|
11
11
|
import { MultiClickDetector } from '../input/multi-click-detector'
|
|
12
|
+
import { extractStreamText, getLineText } from '../input/terminal-text-extraction'
|
|
12
13
|
import { copyToSystemClipboard } from '../platform/clipboard'
|
|
13
14
|
import {
|
|
14
15
|
type ClickSelectionResult,
|
|
16
|
+
computeRangeFromLineText,
|
|
17
|
+
getViewportAnchor,
|
|
15
18
|
isPositionedNode,
|
|
19
|
+
type MultiClickMode,
|
|
16
20
|
resolveClickSelection,
|
|
17
21
|
} from './click-selection-resolver'
|
|
18
22
|
import { requestRenderUpTree } from './render-invalidation'
|
|
@@ -32,6 +36,31 @@ type ResizeDragState =
|
|
|
32
36
|
| ({ initialWidth: number; kind: 'git-pane'; side: 'left' | 'right' } & AxisDragState)
|
|
33
37
|
| ({ kind: 'embedded-git'; position: 'top' | 'bottom' } & AnchoredRatioDragState)
|
|
34
38
|
|
|
39
|
+
interface MultiClickDragState {
|
|
40
|
+
mode: MultiClickMode
|
|
41
|
+
tabId: string
|
|
42
|
+
target: unknown
|
|
43
|
+
baseX: number
|
|
44
|
+
baseY: number
|
|
45
|
+
// Absolute buffer rows (viewportY + viewportRow). Stable across scroll so
|
|
46
|
+
// auto-scroll can move the viewport without losing the anchor.
|
|
47
|
+
anchorAbsRow: number
|
|
48
|
+
anchorStartCol: number
|
|
49
|
+
anchorEndCol: number
|
|
50
|
+
focusAbsRow: number
|
|
51
|
+
focusCol: number
|
|
52
|
+
// Last raw screen position so the viewport-change effect can re-extend the
|
|
53
|
+
// selection even when no drag event has fired (mouse held still at the edge).
|
|
54
|
+
lastEventScreenY: number
|
|
55
|
+
lastEventScreenX: number
|
|
56
|
+
// Cache of every viewport row seen during the drag, indexed by absolute row.
|
|
57
|
+
// Source of truth for clipboard text when the selection spans rows that have
|
|
58
|
+
// scrolled out of view.
|
|
59
|
+
capturedLines: Map<number, TerminalLine>
|
|
60
|
+
autoScrollTimer: ReturnType<typeof setInterval> | null
|
|
61
|
+
autoScrollDir: -1 | 0 | 1
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
interface UseMouseHandlersOptions {
|
|
36
65
|
state: AppState
|
|
37
66
|
dispatch: (action: AppAction) => void
|
|
@@ -47,6 +76,7 @@ interface UseMouseHandlersOptions {
|
|
|
47
76
|
}
|
|
48
77
|
|
|
49
78
|
const MIN_MULTI_CLICK_SELECTION_COUNT = 2
|
|
79
|
+
const AUTO_SCROLL_INTERVAL_MS = 40
|
|
50
80
|
|
|
51
81
|
function getTargetTerminalTabId(
|
|
52
82
|
focusMode: AppState['focusMode'],
|
|
@@ -60,14 +90,49 @@ function getTargetTerminalTabId(
|
|
|
60
90
|
return activeTabId
|
|
61
91
|
}
|
|
62
92
|
|
|
63
|
-
function
|
|
93
|
+
function captureViewportLines(drag: MultiClickDragState, tab: TabSession): void {
|
|
94
|
+
if (!tab.viewport) return
|
|
95
|
+
const startAbs = tab.viewport.viewportY
|
|
96
|
+
for (let i = 0; i < tab.viewport.lines.length; i++) {
|
|
97
|
+
const line = tab.viewport.lines[i]
|
|
98
|
+
if (line) drag.capturedLines.set(startAbs + i, line)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isForwardSelection(drag: MultiClickDragState): boolean {
|
|
103
|
+
return (
|
|
104
|
+
drag.focusAbsRow > drag.anchorAbsRow ||
|
|
105
|
+
(drag.focusAbsRow === drag.anchorAbsRow && drag.focusCol >= drag.anchorEndCol)
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderMultiClickSelection(
|
|
110
|
+
renderer: UseMouseHandlersOptions['renderer'],
|
|
111
|
+
drag: MultiClickDragState,
|
|
112
|
+
viewportY: number,
|
|
113
|
+
finishDragging: boolean
|
|
114
|
+
): void {
|
|
115
|
+
const forward = isForwardSelection(drag)
|
|
116
|
+
const anchorCol = forward ? drag.anchorStartCol : drag.anchorEndCol
|
|
117
|
+
const anchorScreenY = drag.baseY + (drag.anchorAbsRow - viewportY)
|
|
118
|
+
const focusScreenY = drag.baseY + (drag.focusAbsRow - viewportY)
|
|
119
|
+
renderer.startSelection(drag.target, drag.baseX + anchorCol, anchorScreenY)
|
|
120
|
+
renderer.updateSelection(drag.target, drag.baseX + drag.focusCol, focusScreenY, {
|
|
121
|
+
finishDragging,
|
|
122
|
+
})
|
|
123
|
+
requestRenderUpTree(drag.target)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function applyMultiClickInitialSelection(
|
|
64
127
|
renderer: UseMouseHandlersOptions['renderer'],
|
|
65
128
|
selection: ClickSelectionResult
|
|
66
129
|
): void {
|
|
67
130
|
renderer.clearSelection()
|
|
68
131
|
renderer.startSelection(selection.target, selection.baseX + selection.startCol, selection.eventY)
|
|
132
|
+
// finishDragging:false keeps the renderer in drag mode so subsequent
|
|
133
|
+
// mouse-drag events can extend the selection through our drag handler.
|
|
69
134
|
renderer.updateSelection(selection.target, selection.baseX + selection.endCol, selection.eventY, {
|
|
70
|
-
finishDragging:
|
|
135
|
+
finishDragging: false,
|
|
71
136
|
})
|
|
72
137
|
requestRenderUpTree(selection.target)
|
|
73
138
|
copyToSystemClipboard(selection.selectedText)
|
|
@@ -83,6 +148,98 @@ export function useMouseHandlers({
|
|
|
83
148
|
}: UseMouseHandlersOptions) {
|
|
84
149
|
const separatorDragRef = useRef<ResizeDragState | null>(null)
|
|
85
150
|
const multiClickRef = useRef(new MultiClickDetector())
|
|
151
|
+
const multiClickDragRef = useRef<MultiClickDragState | null>(null)
|
|
152
|
+
// State is captured by reference in the React closure; we need a ref so
|
|
153
|
+
// drag/up handlers and the auto-scroll timer see the freshest tab viewport.
|
|
154
|
+
const stateRef = useRef(state)
|
|
155
|
+
stateRef.current = state
|
|
156
|
+
|
|
157
|
+
const clearAutoScroll = (drag: MultiClickDragState) => {
|
|
158
|
+
if (drag.autoScrollTimer) {
|
|
159
|
+
clearInterval(drag.autoScrollTimer)
|
|
160
|
+
}
|
|
161
|
+
drag.autoScrollTimer = null
|
|
162
|
+
drag.autoScrollDir = 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const canScroll = (tab: TabSession | undefined, dir: -1 | 1): boolean => {
|
|
166
|
+
if (!tab?.viewport) return false
|
|
167
|
+
if (dir < 0) return tab.viewport.viewportY > 0
|
|
168
|
+
return tab.viewport.viewportY < tab.viewport.baseY
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const startAutoScroll = (drag: MultiClickDragState, dir: -1 | 1) => {
|
|
172
|
+
if (drag.autoScrollDir === dir && drag.autoScrollTimer) return
|
|
173
|
+
clearAutoScroll(drag)
|
|
174
|
+
drag.autoScrollDir = dir
|
|
175
|
+
drag.autoScrollTimer = setInterval(() => {
|
|
176
|
+
const tab = stateRef.current.tabs.find((t: TabSession) => t.id === drag.tabId)
|
|
177
|
+
if (!canScroll(tab, dir)) {
|
|
178
|
+
clearAutoScroll(drag)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
backend.scrollViewport(drag.tabId, dir)
|
|
182
|
+
// The snapshot dispatch will trigger the viewport-change effect below,
|
|
183
|
+
// which recaptures lines and re-extends the selection focus.
|
|
184
|
+
}, AUTO_SCROLL_INTERVAL_MS)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Unmount safety: never leave a setInterval running past the hook's life.
|
|
188
|
+
useEffect(
|
|
189
|
+
() => () => {
|
|
190
|
+
const drag = multiClickDragRef.current
|
|
191
|
+
if (drag) clearAutoScroll(drag)
|
|
192
|
+
multiClickDragRef.current = null
|
|
193
|
+
},
|
|
194
|
+
[]
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
// Abort the drag (and its auto-scroll loop) when the user switches to a
|
|
198
|
+
// different tab mid-drag: the original tab is no longer visible and we'd
|
|
199
|
+
// otherwise keep pumping scroll commands into a background buffer.
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
const drag = multiClickDragRef.current
|
|
202
|
+
if (!drag) return
|
|
203
|
+
if (drag.tabId !== state.activeTabId) {
|
|
204
|
+
clearAutoScroll(drag)
|
|
205
|
+
multiClickDragRef.current = null
|
|
206
|
+
}
|
|
207
|
+
}, [state.activeTabId])
|
|
208
|
+
|
|
209
|
+
// Re-extend selection and recapture viewport lines on every snapshot change
|
|
210
|
+
// while a multi-click drag is in progress. This is what lets auto-scroll
|
|
211
|
+
// grow the selection even when the mouse hasn't moved.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
const drag = multiClickDragRef.current
|
|
214
|
+
if (!drag) return
|
|
215
|
+
const tab = stateRef.current.tabs.find((t: TabSession) => t.id === drag.tabId)
|
|
216
|
+
if (!tab?.viewport) return
|
|
217
|
+
|
|
218
|
+
captureViewportLines(drag, tab)
|
|
219
|
+
|
|
220
|
+
const viewportRows = tab.viewport.lines.length
|
|
221
|
+
if (viewportRows === 0) return
|
|
222
|
+
|
|
223
|
+
const dragViewportRow = Math.max(
|
|
224
|
+
0,
|
|
225
|
+
Math.min(viewportRows - 1, drag.lastEventScreenY - drag.baseY)
|
|
226
|
+
)
|
|
227
|
+
const newFocusAbs = tab.viewport.viewportY + dragViewportRow
|
|
228
|
+
const focusLine = drag.capturedLines.get(newFocusAbs)
|
|
229
|
+
const focusLineText = focusLine ? getLineText(focusLine) : ''
|
|
230
|
+
const focusDragCol = drag.lastEventScreenX - drag.baseX
|
|
231
|
+
const range = computeRangeFromLineText(focusLineText, focusDragCol, drag.mode)
|
|
232
|
+
const focusStart = range?.startCol ?? Math.max(0, focusDragCol)
|
|
233
|
+
const focusEnd = range?.endCol ?? Math.max(0, focusDragCol)
|
|
234
|
+
|
|
235
|
+
drag.focusAbsRow = newFocusAbs
|
|
236
|
+
const forward =
|
|
237
|
+
newFocusAbs > drag.anchorAbsRow ||
|
|
238
|
+
(newFocusAbs === drag.anchorAbsRow && focusEnd >= drag.anchorEndCol)
|
|
239
|
+
drag.focusCol = forward ? focusEnd : focusStart
|
|
240
|
+
|
|
241
|
+
renderMultiClickSelection(renderer, drag, tab.viewport.viewportY, false)
|
|
242
|
+
}, [state.tabs, renderer])
|
|
86
243
|
|
|
87
244
|
const handleTerminalMouseEvent = (event: OtuiMouseEvent, origin: TerminalContentOrigin) => {
|
|
88
245
|
const targetTabId = getTargetTerminalTabId(
|
|
@@ -237,24 +394,158 @@ export function useMouseHandlers({
|
|
|
237
394
|
|
|
238
395
|
const clickCount = multiClickRef.current.track(event.x, event.y)
|
|
239
396
|
if (clickCount < MIN_MULTI_CLICK_SELECTION_COUNT) {
|
|
397
|
+
// Single-click: clear any stale multi-click drag state so a fresh
|
|
398
|
+
// char-level drag from opentui's default handler isn't extended by us.
|
|
399
|
+
const stale = multiClickDragRef.current
|
|
400
|
+
if (stale) clearAutoScroll(stale)
|
|
401
|
+
multiClickDragRef.current = null
|
|
240
402
|
return
|
|
241
403
|
}
|
|
242
404
|
|
|
243
405
|
const tab = state.tabs.find((t: TabSession) => t.id === targetTabId)
|
|
244
406
|
const selection = resolveClickSelection(event, targetTabId, tab, clickCount)
|
|
245
|
-
if (!selection) {
|
|
407
|
+
if (!selection || !tab?.viewport) {
|
|
246
408
|
return
|
|
247
409
|
}
|
|
248
410
|
|
|
249
411
|
event.preventDefault()
|
|
250
|
-
|
|
412
|
+
applyMultiClickInitialSelection(renderer, selection)
|
|
413
|
+
|
|
414
|
+
const anchor = getViewportAnchor(event)
|
|
415
|
+
if (anchor) {
|
|
416
|
+
const anchorAbsRow = tab.viewport.viewportY + selection.row
|
|
417
|
+
const drag: MultiClickDragState = {
|
|
418
|
+
anchorAbsRow,
|
|
419
|
+
anchorEndCol: selection.endCol,
|
|
420
|
+
anchorStartCol: selection.startCol,
|
|
421
|
+
autoScrollDir: 0,
|
|
422
|
+
autoScrollTimer: null,
|
|
423
|
+
baseX: anchor.baseX,
|
|
424
|
+
baseY: anchor.baseY,
|
|
425
|
+
capturedLines: new Map(),
|
|
426
|
+
focusAbsRow: anchorAbsRow,
|
|
427
|
+
focusCol: selection.endCol,
|
|
428
|
+
lastEventScreenX: event.x,
|
|
429
|
+
lastEventScreenY: event.y,
|
|
430
|
+
mode: selection.mode,
|
|
431
|
+
tabId: targetTabId,
|
|
432
|
+
target: selection.target,
|
|
433
|
+
}
|
|
434
|
+
captureViewportLines(drag, tab)
|
|
435
|
+
multiClickDragRef.current = drag
|
|
436
|
+
}
|
|
251
437
|
|
|
252
438
|
logInputDebug('click.done', {
|
|
253
439
|
hasSelection: !!renderer.hasSelection,
|
|
440
|
+
mode: selection.mode,
|
|
254
441
|
targetSelectable: isPositionedNode(event.target) ? !!event.target.selectable : false,
|
|
255
442
|
})
|
|
256
443
|
}
|
|
257
444
|
|
|
445
|
+
const handleTerminalDrag = (
|
|
446
|
+
event: OtuiMouseEvent,
|
|
447
|
+
_origin: TerminalContentOrigin,
|
|
448
|
+
_tabId?: string
|
|
449
|
+
): boolean => {
|
|
450
|
+
const drag = multiClickDragRef.current
|
|
451
|
+
if (!drag) return false
|
|
452
|
+
|
|
453
|
+
const tab = stateRef.current.tabs.find((t: TabSession) => t.id === drag.tabId)
|
|
454
|
+
if (!tab?.viewport) return true
|
|
455
|
+
|
|
456
|
+
drag.lastEventScreenX = event.x
|
|
457
|
+
drag.lastEventScreenY = event.y
|
|
458
|
+
captureViewportLines(drag, tab)
|
|
459
|
+
|
|
460
|
+
const viewportRows = tab.viewport.lines.length
|
|
461
|
+
if (viewportRows === 0) return true
|
|
462
|
+
|
|
463
|
+
const rawRow = event.y - drag.baseY
|
|
464
|
+
const dragViewportRow = Math.max(0, Math.min(viewportRows - 1, rawRow))
|
|
465
|
+
const dragAbsRow = tab.viewport.viewportY + dragViewportRow
|
|
466
|
+
const dragCol = event.x - drag.baseX
|
|
467
|
+
|
|
468
|
+
const focusLine = drag.capturedLines.get(dragAbsRow)
|
|
469
|
+
const focusLineText = focusLine ? getLineText(focusLine) : ''
|
|
470
|
+
const range = computeRangeFromLineText(focusLineText, dragCol, drag.mode)
|
|
471
|
+
const focusStart = range?.startCol ?? Math.max(0, dragCol)
|
|
472
|
+
const focusEnd = range?.endCol ?? Math.max(0, dragCol)
|
|
473
|
+
|
|
474
|
+
drag.focusAbsRow = dragAbsRow
|
|
475
|
+
const forward =
|
|
476
|
+
dragAbsRow > drag.anchorAbsRow ||
|
|
477
|
+
(dragAbsRow === drag.anchorAbsRow && focusEnd >= drag.anchorEndCol)
|
|
478
|
+
drag.focusCol = forward ? focusEnd : focusStart
|
|
479
|
+
|
|
480
|
+
renderMultiClickSelection(renderer, drag, tab.viewport.viewportY, false)
|
|
481
|
+
|
|
482
|
+
// Edge detection: trigger auto-scroll when the pointer sits at (or beyond)
|
|
483
|
+
// the top/bottom row of the viewport.
|
|
484
|
+
let dir: -1 | 0 | 1 = 0
|
|
485
|
+
if (rawRow <= 0) dir = -1
|
|
486
|
+
else if (rawRow >= viewportRows - 1) dir = 1
|
|
487
|
+
|
|
488
|
+
if (dir === 0) {
|
|
489
|
+
clearAutoScroll(drag)
|
|
490
|
+
} else if (canScroll(tab, dir)) {
|
|
491
|
+
startAutoScroll(drag, dir)
|
|
492
|
+
} else {
|
|
493
|
+
clearAutoScroll(drag)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return true
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const handleTerminalMouseUp = (_event: OtuiMouseEvent): boolean => {
|
|
500
|
+
const drag = multiClickDragRef.current
|
|
501
|
+
if (!drag) return false
|
|
502
|
+
|
|
503
|
+
clearAutoScroll(drag)
|
|
504
|
+
|
|
505
|
+
const tab = stateRef.current.tabs.find((t: TabSession) => t.id === drag.tabId)
|
|
506
|
+
if (tab?.viewport) {
|
|
507
|
+
captureViewportLines(drag, tab)
|
|
508
|
+
renderMultiClickSelection(renderer, drag, tab.viewport.viewportY, true)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const forward = isForwardSelection(drag)
|
|
512
|
+
const anchorCol = forward ? drag.anchorStartCol : drag.anchorEndCol
|
|
513
|
+
|
|
514
|
+
const startAbs = Math.min(drag.anchorAbsRow, drag.focusAbsRow)
|
|
515
|
+
const endAbs = Math.max(drag.anchorAbsRow, drag.focusAbsRow)
|
|
516
|
+
const lines: TerminalLine[] = []
|
|
517
|
+
let missingRows = 0
|
|
518
|
+
for (let abs = startAbs; abs <= endAbs; abs++) {
|
|
519
|
+
const line = drag.capturedLines.get(abs)
|
|
520
|
+
if (line) {
|
|
521
|
+
lines.push(line)
|
|
522
|
+
} else {
|
|
523
|
+
missingRows += 1
|
|
524
|
+
lines.push({ spans: [] })
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (missingRows > 0) {
|
|
528
|
+
// Snapshot coalescing (remote backend, fast scroll bursts) can let the
|
|
529
|
+
// selection cover rows we never captured. The output gets blank lines
|
|
530
|
+
// here — surface it for debugging instead of silently corrupting paste.
|
|
531
|
+
logInputDebug('multiclick.clipboardGap', {
|
|
532
|
+
endAbs,
|
|
533
|
+
missingRows,
|
|
534
|
+
startAbs,
|
|
535
|
+
totalRows: endAbs - startAbs + 1,
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
const anchorIdx = drag.anchorAbsRow - startAbs
|
|
539
|
+
const focusIdx = drag.focusAbsRow - startAbs
|
|
540
|
+
const text = extractStreamText(lines, anchorIdx, anchorCol, focusIdx, drag.focusCol)
|
|
541
|
+
if (text.length > 0) {
|
|
542
|
+
copyToSystemClipboard(text)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
multiClickDragRef.current = null
|
|
546
|
+
return true
|
|
547
|
+
}
|
|
548
|
+
|
|
258
549
|
return {
|
|
259
550
|
handleEmbeddedGitResizeStart,
|
|
260
551
|
handleGitPaneResizeStart,
|
|
@@ -265,7 +556,9 @@ export function useMouseHandlers({
|
|
|
265
556
|
handleSidebarResizeStart,
|
|
266
557
|
handleSplitResize,
|
|
267
558
|
handleTerminalClick,
|
|
559
|
+
handleTerminalDrag,
|
|
268
560
|
handleTerminalMouseEvent,
|
|
561
|
+
handleTerminalMouseUp,
|
|
269
562
|
handleTerminalScrollEvent,
|
|
270
563
|
}
|
|
271
564
|
}
|
package/src/app.tsx
CHANGED
|
@@ -25,6 +25,8 @@ import { deriveModeId } from './input/modes/bridge'
|
|
|
25
25
|
import { registerAllModes } from './input/modes/handlers'
|
|
26
26
|
import { getHandler, transitionTo } from './input/modes/registry'
|
|
27
27
|
import { type TerminalContentOrigin } from './input/raw-input-handler'
|
|
28
|
+
import { highlightSnapshot, warmClaudeSyntaxOverlay } from './integrations/claude-syntax-overlay'
|
|
29
|
+
import { ensureClaudeSettingsThemePref, syncClaudeTheme } from './integrations/claude-theme-sync'
|
|
28
30
|
import { getProfileConfigDir, getProfileName } from './profile-paths'
|
|
29
31
|
import { startAIUsageService } from './services/ai-usage/provider'
|
|
30
32
|
import { aiUsageStore } from './state/ai-usage-store'
|
|
@@ -35,7 +37,14 @@ import { loadSnippetCatalog } from './state/snippet-catalog'
|
|
|
35
37
|
import { createInitialState } from './state/store'
|
|
36
38
|
import { KeymapContext } from './ui/keymap-context'
|
|
37
39
|
import { RootView } from './ui/root'
|
|
38
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
applyTheme,
|
|
42
|
+
getCurrentMode,
|
|
43
|
+
getCurrentTheme,
|
|
44
|
+
setMode,
|
|
45
|
+
setTransparent,
|
|
46
|
+
subscribeThemeChanges,
|
|
47
|
+
} from './ui/theme'
|
|
39
48
|
import { isKnownThemeId, type ThemeId } from './ui/themes'
|
|
40
49
|
import {
|
|
41
50
|
fetchLatestNpmVersion,
|
|
@@ -74,6 +83,7 @@ export function App({
|
|
|
74
83
|
const initial: ThemeId = persisted ?? resolvedConfig.theme?.initialId ?? 'aimux'
|
|
75
84
|
applyTheme(initial)
|
|
76
85
|
if (resolvedConfig.theme?.initialMode) setMode(resolvedConfig.theme.initialMode)
|
|
86
|
+
if (config.themeMode) setMode(config.themeMode)
|
|
77
87
|
setTransparent(config.themeTransparent ?? false)
|
|
78
88
|
return initial
|
|
79
89
|
})
|
|
@@ -157,6 +167,15 @@ export function App({
|
|
|
157
167
|
}
|
|
158
168
|
}, [dispatch])
|
|
159
169
|
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!resolvedConfig.theme?.beta?.harmonizeClaudeTheme) return
|
|
172
|
+
ensureClaudeSettingsThemePref()
|
|
173
|
+
syncClaudeTheme(getCurrentTheme(), getCurrentMode())
|
|
174
|
+
return subscribeThemeChanges((resolved, mode) => {
|
|
175
|
+
syncClaudeTheme(resolved, mode)
|
|
176
|
+
})
|
|
177
|
+
}, [resolvedConfig.theme?.beta?.harmonizeClaudeTheme])
|
|
178
|
+
|
|
160
179
|
useEffect(() => {
|
|
161
180
|
const aiUsage = resolvedConfig.statusBar?.aiUsage
|
|
162
181
|
if (!aiUsage?.enabled) {
|
|
@@ -232,6 +251,37 @@ export function App({
|
|
|
232
251
|
const contentOriginRef = useRef<TerminalContentOrigin>({ cols: 0, rows: 0, x: 0, y: 0 })
|
|
233
252
|
const currentSessionWorkspaceSnapshot = currentSession?.workspaceSnapshot
|
|
234
253
|
|
|
254
|
+
const syntaxOverlayFlag = resolvedConfig.theme?.beta?.experimentalSyntaxHighlight === true
|
|
255
|
+
const syntaxOverlayFlagRef = useRef(syntaxOverlayFlag)
|
|
256
|
+
syntaxOverlayFlagRef.current = syntaxOverlayFlag
|
|
257
|
+
const syntaxOverlayEnabled = useCallback(() => syntaxOverlayFlagRef.current, [])
|
|
258
|
+
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
if (!syntaxOverlayFlag) return
|
|
261
|
+
let cancelled = false
|
|
262
|
+
void (async () => {
|
|
263
|
+
await warmClaudeSyntaxOverlay()
|
|
264
|
+
if (cancelled) return
|
|
265
|
+
// Re-apply the overlay to viewports that were dispatched before shiki
|
|
266
|
+
// finished loading, so colors appear without waiting for the next
|
|
267
|
+
// PTY data event.
|
|
268
|
+
const snapshot = appStore.getState()
|
|
269
|
+
for (const tab of snapshot.tabs) {
|
|
270
|
+
if (!tab.viewport) continue
|
|
271
|
+
dispatch({
|
|
272
|
+
source: 'data',
|
|
273
|
+
tabId: tab.id,
|
|
274
|
+
terminalModes: tab.terminalModes,
|
|
275
|
+
type: 'replace-tab-viewport',
|
|
276
|
+
viewport: highlightSnapshot(tab.viewport, tab.id),
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
})()
|
|
280
|
+
return () => {
|
|
281
|
+
cancelled = true
|
|
282
|
+
}
|
|
283
|
+
}, [dispatch, syntaxOverlayFlag])
|
|
284
|
+
|
|
235
285
|
const { clearIdleTimer, clearStartupGrace, startStartupGrace } = useBackendRuntime({
|
|
236
286
|
activeTabId: state.activeTabId,
|
|
237
287
|
activeTabScrollIntentRef,
|
|
@@ -241,6 +291,7 @@ export function App({
|
|
|
241
291
|
dispatch,
|
|
242
292
|
layoutRef,
|
|
243
293
|
resizingRef,
|
|
294
|
+
syntaxOverlayEnabled,
|
|
244
295
|
})
|
|
245
296
|
|
|
246
297
|
useWorkspaceAutosave(state, WORKSPACE_SAVE_DEBOUNCE_MS)
|
|
@@ -272,7 +323,9 @@ export function App({
|
|
|
272
323
|
handleSidebarResizeStart,
|
|
273
324
|
handleSplitResize,
|
|
274
325
|
handleTerminalClick,
|
|
326
|
+
handleTerminalDrag,
|
|
275
327
|
handleTerminalMouseEvent,
|
|
328
|
+
handleTerminalMouseUp,
|
|
276
329
|
handleTerminalScrollEvent,
|
|
277
330
|
} = useMouseHandlers({
|
|
278
331
|
activeLocalScrollbackEnabled,
|
|
@@ -404,6 +457,8 @@ export function App({
|
|
|
404
457
|
onTerminalMouseEvent={handleTerminalMouseEvent}
|
|
405
458
|
onTerminalScrollEvent={handleTerminalScrollEvent}
|
|
406
459
|
onTerminalClick={handleTerminalClick}
|
|
460
|
+
onTerminalDrag={handleTerminalDrag}
|
|
461
|
+
onTerminalMouseUp={handleTerminalMouseUp}
|
|
407
462
|
onPaneActivate={handlePaneActivate}
|
|
408
463
|
onSplitResize={handleSplitResize}
|
|
409
464
|
onEmbeddedGitResizeStart={handleEmbeddedGitResizeStart}
|
package/src/config.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { GitFileListMode, SessionBarPosition, WorkspaceSnapshotV1 } from '.
|
|
|
5
5
|
import { logDebug } from './debug/input-log'
|
|
6
6
|
import { getProfileConfigDir } from './profile-paths'
|
|
7
7
|
import { isWorkspaceSnapshotV1 } from './state/validation'
|
|
8
|
-
import { migrateThemeId as resolveLegacyThemeId, type ThemeId } from './ui/themes'
|
|
8
|
+
import { migrateThemeId as resolveLegacyThemeId, type ThemeId, type ThemeMode } from './ui/themes'
|
|
9
9
|
|
|
10
10
|
function migrateThemeId(value: unknown): ThemeId | undefined {
|
|
11
11
|
if (typeof value !== 'string') return undefined
|
|
@@ -37,6 +37,7 @@ export interface AimuxConfig {
|
|
|
37
37
|
customCommands: Record<string, string>
|
|
38
38
|
themeId?: ThemeId
|
|
39
39
|
themeTransparent?: boolean
|
|
40
|
+
themeMode?: ThemeMode
|
|
40
41
|
gitPane?: PersistedGitPane
|
|
41
42
|
sidebar?: PersistedSidebar
|
|
42
43
|
sessionBarVisible?: boolean
|
|
@@ -154,6 +155,7 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
154
155
|
customCommands?: unknown
|
|
155
156
|
themeId?: unknown
|
|
156
157
|
themeTransparent?: unknown
|
|
158
|
+
themeMode?: unknown
|
|
157
159
|
gitPane?: unknown
|
|
158
160
|
sidebar?: unknown
|
|
159
161
|
gitPanelVisible?: unknown
|
|
@@ -184,6 +186,12 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
184
186
|
issues.push('ignored invalid themeTransparent')
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
const validThemeMode: ThemeMode | undefined =
|
|
190
|
+
parsed.themeMode === 'dark' || parsed.themeMode === 'light' ? parsed.themeMode : undefined
|
|
191
|
+
if (parsed.themeMode !== undefined && validThemeMode === undefined) {
|
|
192
|
+
issues.push('ignored invalid themeMode')
|
|
193
|
+
}
|
|
194
|
+
|
|
187
195
|
let validGitPane = isPersistedGitPane(parsed.gitPane) ? parsed.gitPane : undefined
|
|
188
196
|
if (parsed.gitPane !== undefined && validGitPane === undefined) {
|
|
189
197
|
issues.push('ignored invalid gitPane')
|
|
@@ -261,6 +269,7 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
261
269
|
sidebar: validSidebar,
|
|
262
270
|
skippedUpdateVersion: validSkippedUpdateVersion,
|
|
263
271
|
themeId: migrateThemeId(parsed.themeId),
|
|
272
|
+
themeMode: validThemeMode,
|
|
264
273
|
themeTransparent: validThemeTransparent,
|
|
265
274
|
version: 2,
|
|
266
275
|
workspaceSnapshot: isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -282,9 +282,33 @@ export async function runDaemon(): Promise<void> {
|
|
|
282
282
|
},
|
|
283
283
|
})
|
|
284
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Tell the TM whether to bother snapshotting + broadcasting. Toggled on
|
|
287
|
+
* 0↔1 transitions of the client socket count: when no UI is watching, the
|
|
288
|
+
* TM can skip per-chunk viewport diff/projection work entirely. The TM
|
|
289
|
+
* flushes a fresh snapshot per session on re-enable, so reattaching gives
|
|
290
|
+
* the client a current viewport.
|
|
291
|
+
*
|
|
292
|
+
* Fire-and-forget: failure to send isn't fatal (TM will just keep its
|
|
293
|
+
* previous broadcast state, matching pre-fix behaviour).
|
|
294
|
+
*/
|
|
295
|
+
const updateTmBroadcastForClientCount = (count: number): void => {
|
|
296
|
+
void manager.setBroadcastEnabled(count > 0).catch((error) => {
|
|
297
|
+
logDebug('daemon.setBroadcastEnabled.error', {
|
|
298
|
+
count,
|
|
299
|
+
error: error instanceof Error ? error.message : String(error),
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Initial state: no clients yet, ask the TM to suspend broadcast.
|
|
305
|
+
updateTmBroadcastForClientCount(0)
|
|
306
|
+
|
|
285
307
|
const server = createServer((socket) => {
|
|
286
308
|
logDebug('daemon.client.connected')
|
|
309
|
+
const wasEmpty = sockets.size === 0
|
|
287
310
|
sockets.add(socket)
|
|
311
|
+
if (wasEmpty) updateTmBroadcastForClientCount(sockets.size)
|
|
288
312
|
const decoder = new MessageDecoder<ClientRequest>(parseClientRequest)
|
|
289
313
|
// Serialize chunk processing per socket. Each async iteration crosses a
|
|
290
314
|
// microtask boundary, so without chaining, concurrent `data` callbacks
|
|
@@ -540,12 +564,14 @@ export async function runDaemon(): Promise<void> {
|
|
|
540
564
|
sockets.delete(socket)
|
|
541
565
|
attachedSessions.delete(socket)
|
|
542
566
|
negotiatedVersions.delete(socket)
|
|
567
|
+
if (sockets.size === 0) updateTmBroadcastForClientCount(0)
|
|
543
568
|
})
|
|
544
569
|
socket.on('error', () => {
|
|
545
570
|
logDebug('daemon.client.error', { sessionId: attachedSessions.get(socket) ?? null })
|
|
546
571
|
sockets.delete(socket)
|
|
547
572
|
attachedSessions.delete(socket)
|
|
548
573
|
negotiatedVersions.delete(socket)
|
|
574
|
+
if (sockets.size === 0) updateTmBroadcastForClientCount(0)
|
|
549
575
|
})
|
|
550
576
|
})
|
|
551
577
|
|
|
@@ -130,6 +130,19 @@ export class SessionManager extends EventEmitter<SessionManagerEvents> {
|
|
|
130
130
|
this.registries.clear()
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
setBroadcastEnabled(enabled: boolean): void {
|
|
134
|
+
for (const registry of this.registries.values()) {
|
|
135
|
+
registry.setBroadcastEnabled(enabled)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
hasAnySessions(): boolean {
|
|
140
|
+
for (const registry of this.registries.values()) {
|
|
141
|
+
if (registry.hasSessions()) return true
|
|
142
|
+
}
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
133
146
|
listSessionIds(): string[] {
|
|
134
147
|
return [...this.registries.keys()]
|
|
135
148
|
}
|
|
@@ -208,6 +208,14 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
setBroadcastEnabled(enabled: boolean): void {
|
|
212
|
+
this.ptyManager.setBroadcastEnabled(enabled)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
hasSessions(): boolean {
|
|
216
|
+
return this.ptyManager.hasSessions()
|
|
217
|
+
}
|
|
218
|
+
|
|
211
219
|
closeTab(tabId: string): void {
|
|
212
220
|
logDebug('daemon.registry.closeTab', { tabId })
|
|
213
221
|
this.ptyManager.disposeSession(tabId)
|
package/src/index.tsx
CHANGED
|
@@ -67,6 +67,20 @@ const renderer = await createCliRenderer({
|
|
|
67
67
|
|
|
68
68
|
const root = createRoot(renderer)
|
|
69
69
|
|
|
70
|
+
const resolvedConfig = await loadUserConfig()
|
|
71
|
+
logDebug('index.userConfigLoaded', {
|
|
72
|
+
leader: resolvedConfig.keymaps.leader,
|
|
73
|
+
modeCount: resolvedConfig.keymaps.modes.size,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// Beta — when experimental syntax highlight is on, ask Claude Code to emit
|
|
77
|
+
// plain code so we can re-tokenize on the snapshot. Set on process.env
|
|
78
|
+
// before backend bootstrap so the spawned daemon (and its child PTYs)
|
|
79
|
+
// inherit it.
|
|
80
|
+
if (resolvedConfig.theme?.beta?.experimentalSyntaxHighlight) {
|
|
81
|
+
process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT = 'false'
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
const backend = await createSessionBackend({
|
|
71
85
|
onBreakingUpdateRequired: () =>
|
|
72
86
|
new Promise<void>((resolve) => {
|
|
@@ -75,10 +89,4 @@ const backend = await createSessionBackend({
|
|
|
75
89
|
})
|
|
76
90
|
logDebug('index.backendReady', { backend: backend.constructor.name, runtimeProfile })
|
|
77
91
|
|
|
78
|
-
const resolvedConfig = await loadUserConfig()
|
|
79
|
-
logDebug('index.userConfigLoaded', {
|
|
80
|
-
leader: resolvedConfig.keymaps.leader,
|
|
81
|
-
modeCount: resolvedConfig.keymaps.modes.size,
|
|
82
|
-
})
|
|
83
|
-
|
|
84
92
|
root.render(<App backend={backend} resolvedConfig={resolvedConfig} />)
|
package/src/input/modes/types.ts
CHANGED