@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.
@@ -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 applyResolvedSelection(
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: true,
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
- applyResolvedSelection(renderer, selection)
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 { applyTheme, setMode, setTransparent } from './ui/theme'
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)
@@ -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} />)
@@ -67,6 +67,7 @@ export type SideEffect =
67
67
  | { type: 'switch-session-by-index'; index: number }
68
68
  | { type: 'delete-session'; sessionId: string }
69
69
  | { type: 'toggle-transparent' }
70
+ | { type: 'toggle-mode' }
70
71
 
71
72
  export interface KeyResult {
72
73
  actions: AppAction[]