@brimveyn/aimux 1.9.9 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app-runtime/backend-runtime-events.ts +8 -1
- package/src/app-runtime/side-effects.ts +7 -1
- package/src/app-runtime/use-backend-runtime.ts +12 -1
- package/src/app.tsx +52 -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/terminal-pane.tsx +3 -2
- package/src/ui/components/modals/themes/theme-picker-modal.tsx +3 -1
- package/src/ui/theme-store.ts +18 -0
- package/src/ui/theme.ts +2 -0
- package/src/update.ts +21 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"bump": "bun run scripts/bump.ts"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@brimveyn/aimux-config": "0.5.
|
|
63
|
+
"@brimveyn/aimux-config": "0.5.9",
|
|
64
64
|
"@opentui/core": "^0.1.90",
|
|
65
65
|
"@opentui/react": "^0.1.90",
|
|
66
66
|
"@xterm/headless": "^6.0.0",
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
} from '../state/types'
|
|
11
11
|
|
|
12
12
|
import { logInputDebug } from '../debug/input-log'
|
|
13
|
+
import { clearTabSyntaxState, highlightSnapshot } from '../integrations/claude-syntax-overlay'
|
|
13
14
|
import { type TabRuntimeTimeouts } from './tab-runtime-timeouts'
|
|
14
15
|
|
|
15
16
|
interface BindBackendRuntimeEventsOptions {
|
|
@@ -17,6 +18,7 @@ interface BindBackendRuntimeEventsOptions {
|
|
|
17
18
|
dispatch: (action: AppAction) => void
|
|
18
19
|
resizingRef: MutableRefObject<boolean>
|
|
19
20
|
timeouts: Pick<TabRuntimeTimeouts, 'clearIdleTimer' | 'clearStartupGrace' | 'clearAllTimers'>
|
|
21
|
+
syntaxOverlayEnabled: () => boolean
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
function clearTabRuntimeState(
|
|
@@ -31,6 +33,7 @@ export function bindBackendRuntimeEvents({
|
|
|
31
33
|
backend,
|
|
32
34
|
dispatch,
|
|
33
35
|
resizingRef,
|
|
36
|
+
syntaxOverlayEnabled,
|
|
34
37
|
timeouts,
|
|
35
38
|
}: BindBackendRuntimeEventsOptions): () => void {
|
|
36
39
|
const handleRender = (
|
|
@@ -48,12 +51,14 @@ export function bindBackendRuntimeEvents({
|
|
|
48
51
|
viewportY: viewport.viewportY,
|
|
49
52
|
})
|
|
50
53
|
|
|
54
|
+
const transformed = syntaxOverlayEnabled() ? highlightSnapshot(viewport, tabId) : viewport
|
|
55
|
+
|
|
51
56
|
dispatch({
|
|
52
57
|
source: resizingRef.current ? 'resize' : 'data',
|
|
53
58
|
tabId,
|
|
54
59
|
terminalModes,
|
|
55
60
|
type: 'replace-tab-viewport',
|
|
56
|
-
viewport,
|
|
61
|
+
viewport: transformed,
|
|
57
62
|
})
|
|
58
63
|
// Per-tab activity is driven by the backend's status-detection loop via
|
|
59
64
|
// the `tabActivity` event — no client-side idle timer needed.
|
|
@@ -62,12 +67,14 @@ export function bindBackendRuntimeEvents({
|
|
|
62
67
|
const handleExit = (tabId: string, exitCode: number) => {
|
|
63
68
|
logInputDebug('app.backend.event.exit', { exitCode, tabId })
|
|
64
69
|
clearTabRuntimeState(timeouts, tabId)
|
|
70
|
+
clearTabSyntaxState(tabId)
|
|
65
71
|
dispatch({ tabId, type: 'close-tab' })
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
const handleError = (tabId: string, message: string) => {
|
|
69
75
|
logInputDebug('app.backend.event.error', { message, tabId })
|
|
70
76
|
clearTabRuntimeState(timeouts, tabId)
|
|
77
|
+
clearTabSyntaxState(tabId)
|
|
71
78
|
dispatch({ message, tabId, type: 'set-tab-error' })
|
|
72
79
|
}
|
|
73
80
|
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
import { saveCurrentWorkspace } from '../state/workspace-save'
|
|
38
38
|
import { filterThemeIds } from '../ui/filter-themes'
|
|
39
39
|
import { scrollGitDiff } from '../ui/git-view-controls'
|
|
40
|
-
import { applyTheme, getTransparent, setTransparent } from '../ui/theme'
|
|
40
|
+
import { applyTheme, getCurrentMode, getTransparent, setMode, setTransparent } from '../ui/theme'
|
|
41
41
|
import { type ThemeId } from '../ui/themes'
|
|
42
42
|
import { triggerAutoCommitNow } from './auto-commit-ref'
|
|
43
43
|
import {
|
|
@@ -600,6 +600,12 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
600
600
|
saveConfig({ ...loadConfig(), themeTransparent: next })
|
|
601
601
|
return
|
|
602
602
|
}
|
|
603
|
+
case 'toggle-mode': {
|
|
604
|
+
const next = getCurrentMode() === 'dark' ? 'light' : 'dark'
|
|
605
|
+
setMode(next)
|
|
606
|
+
saveConfig({ ...loadConfig(), themeMode: next })
|
|
607
|
+
return
|
|
608
|
+
}
|
|
603
609
|
default:
|
|
604
610
|
effect satisfies never
|
|
605
611
|
}
|
|
@@ -16,6 +16,7 @@ interface BackendRuntimeOptions {
|
|
|
16
16
|
layoutRef: MutableRefObject<LayoutState>
|
|
17
17
|
resizingRef: MutableRefObject<boolean>
|
|
18
18
|
currentSessionWorkspaceSnapshot: Parameters<SessionBackend['attach']>[0]['workspaceSnapshot']
|
|
19
|
+
syntaxOverlayEnabled: () => boolean
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export interface TabRuntimeControls {
|
|
@@ -33,6 +34,7 @@ export function useBackendRuntime({
|
|
|
33
34
|
dispatch,
|
|
34
35
|
layoutRef,
|
|
35
36
|
resizingRef,
|
|
37
|
+
syntaxOverlayEnabled,
|
|
36
38
|
}: BackendRuntimeOptions): TabRuntimeControls {
|
|
37
39
|
const attachRequestIdRef = useRef(0)
|
|
38
40
|
const timeouts = useTabRuntimeTimeouts(dispatch)
|
|
@@ -70,13 +72,22 @@ export function useBackendRuntime({
|
|
|
70
72
|
backend,
|
|
71
73
|
dispatch,
|
|
72
74
|
resizingRef,
|
|
75
|
+
syntaxOverlayEnabled,
|
|
73
76
|
timeouts: {
|
|
74
77
|
clearAllTimers,
|
|
75
78
|
clearIdleTimer,
|
|
76
79
|
clearStartupGrace,
|
|
77
80
|
},
|
|
78
81
|
})
|
|
79
|
-
}, [
|
|
82
|
+
}, [
|
|
83
|
+
backend,
|
|
84
|
+
clearAllTimers,
|
|
85
|
+
clearIdleTimer,
|
|
86
|
+
clearStartupGrace,
|
|
87
|
+
dispatch,
|
|
88
|
+
resizingRef,
|
|
89
|
+
syntaxOverlayEnabled,
|
|
90
|
+
])
|
|
80
91
|
|
|
81
92
|
return {
|
|
82
93
|
clearIdleTimer,
|
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)
|
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
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// Beta POC — re-tokenize Claude's tool-output code lines with shiki using
|
|
2
|
+
// the active aimux theme. Runs on the App side after each PTY snapshot
|
|
3
|
+
// arrives; shiki + theme stay in the React process.
|
|
4
|
+
//
|
|
5
|
+
// Two structural signals drive detection:
|
|
6
|
+
// 1. Tool header (e.g. `⏺ Update(src/foo.ts)`) → file path → language.
|
|
7
|
+
// 2. Code lines always start with a number prefix (` 42 `, ` 42 + `,
|
|
8
|
+
// ` 42 - `). That prefix marks where to slice + how long the block runs.
|
|
9
|
+
//
|
|
10
|
+
// Per-tab state remembers the last seen language so blocks whose header
|
|
11
|
+
// has scrolled off the viewport still get colored correctly.
|
|
12
|
+
//
|
|
13
|
+
// Limits: language inference relies on the header staying visible at least
|
|
14
|
+
// once; languages outside the pre-loaded set fall back to plain text.
|
|
15
|
+
|
|
16
|
+
import type { TerminalLine, TerminalSnapshot, TerminalSpan } from '../state/types'
|
|
17
|
+
|
|
18
|
+
import { logDebug } from '../debug/input-log'
|
|
19
|
+
import { ensureActiveShikiTheme, ensureShikiLang, getShikiHighlighter } from '../ui/shiki'
|
|
20
|
+
import { getCurrentTheme } from '../ui/theme'
|
|
21
|
+
|
|
22
|
+
const DEFAULT_LANG = 'typescript'
|
|
23
|
+
|
|
24
|
+
const PRELOAD_LANGS = [
|
|
25
|
+
'typescript',
|
|
26
|
+
'tsx',
|
|
27
|
+
'javascript',
|
|
28
|
+
'jsx',
|
|
29
|
+
'python',
|
|
30
|
+
'go',
|
|
31
|
+
'rust',
|
|
32
|
+
'bash',
|
|
33
|
+
'shellscript',
|
|
34
|
+
'json',
|
|
35
|
+
'yaml',
|
|
36
|
+
'markdown',
|
|
37
|
+
'html',
|
|
38
|
+
'css',
|
|
39
|
+
'scss',
|
|
40
|
+
'java',
|
|
41
|
+
'c',
|
|
42
|
+
'cpp',
|
|
43
|
+
'ruby',
|
|
44
|
+
'php',
|
|
45
|
+
'sql',
|
|
46
|
+
'lua',
|
|
47
|
+
'swift',
|
|
48
|
+
'kotlin',
|
|
49
|
+
'toml',
|
|
50
|
+
'xml',
|
|
51
|
+
] as const
|
|
52
|
+
|
|
53
|
+
const EXT_TO_LANG: Record<string, string> = {
|
|
54
|
+
c: 'c',
|
|
55
|
+
cc: 'cpp',
|
|
56
|
+
cjs: 'javascript',
|
|
57
|
+
cpp: 'cpp',
|
|
58
|
+
cs: 'csharp',
|
|
59
|
+
css: 'css',
|
|
60
|
+
cxx: 'cpp',
|
|
61
|
+
go: 'go',
|
|
62
|
+
h: 'c',
|
|
63
|
+
hpp: 'cpp',
|
|
64
|
+
hs: 'haskell',
|
|
65
|
+
htm: 'html',
|
|
66
|
+
html: 'html',
|
|
67
|
+
java: 'java',
|
|
68
|
+
js: 'javascript',
|
|
69
|
+
json: 'json',
|
|
70
|
+
jsx: 'jsx',
|
|
71
|
+
kt: 'kotlin',
|
|
72
|
+
kts: 'kotlin',
|
|
73
|
+
lua: 'lua',
|
|
74
|
+
md: 'markdown',
|
|
75
|
+
mjs: 'javascript',
|
|
76
|
+
php: 'php',
|
|
77
|
+
py: 'python',
|
|
78
|
+
rb: 'ruby',
|
|
79
|
+
rs: 'rust',
|
|
80
|
+
scss: 'scss',
|
|
81
|
+
sh: 'bash',
|
|
82
|
+
sql: 'sql',
|
|
83
|
+
swift: 'swift',
|
|
84
|
+
toml: 'toml',
|
|
85
|
+
ts: 'typescript',
|
|
86
|
+
tsx: 'tsx',
|
|
87
|
+
xml: 'xml',
|
|
88
|
+
yaml: 'yaml',
|
|
89
|
+
yml: 'yaml',
|
|
90
|
+
zsh: 'bash',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let ready = false
|
|
94
|
+
let activeThemeName: string | null = null
|
|
95
|
+
let warmedHighlighter: Awaited<ReturnType<typeof getShikiHighlighter>> | null = null
|
|
96
|
+
|
|
97
|
+
// Per-tab last seen language (header may have scrolled off the viewport).
|
|
98
|
+
const tabLang = new Map<string, string>()
|
|
99
|
+
|
|
100
|
+
export async function warmClaudeSyntaxOverlay(): Promise<void> {
|
|
101
|
+
try {
|
|
102
|
+
const h = await getShikiHighlighter()
|
|
103
|
+
warmedHighlighter = h
|
|
104
|
+
const themeName = await ensureActiveShikiTheme(h)
|
|
105
|
+
activeThemeName = themeName
|
|
106
|
+
await Promise.all(PRELOAD_LANGS.map((lang) => ensureShikiLang(h, lang)))
|
|
107
|
+
ready = true
|
|
108
|
+
} catch (err) {
|
|
109
|
+
logDebug('claude-syntax-overlay:warm-failed', { err: String(err) })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Free the per-tab language cache when a tab is closed. */
|
|
114
|
+
export function clearTabSyntaxState(tabId: string): void {
|
|
115
|
+
tabLang.delete(tabId)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// `⏺ Update(path/to/file.ts)`, `⏺ Read(path)`, `Edit(path)`, `Write(path)`...
|
|
119
|
+
// The leading bullet is sometimes a different glyph; we match a wider
|
|
120
|
+
// keyword set and look for `<Verb>(<path>)` as the anchor.
|
|
121
|
+
const TOOL_HEADER_RE = /\b(?:Read|Update|Edit|Write|MultiEdit|Create|NotebookEdit)\(([^)]+)\)/
|
|
122
|
+
|
|
123
|
+
// Code-line prefix used by Claude in tool output: ` <n> ` optionally
|
|
124
|
+
// followed by `+ ` / `- ` for diff lines.
|
|
125
|
+
// Group 1 = leading whitespace before the number (kept on the outer
|
|
126
|
+
// dark bg so the diff strip starts at the line number).
|
|
127
|
+
// Group 2 = the digits + spaces + optional diff marker (the gutter that
|
|
128
|
+
// carries the diff bg for `+`/`-` lines).
|
|
129
|
+
// Group 3 = the diff marker itself, present only on `+`/`-` rows.
|
|
130
|
+
const PREFIX_RE = /^(\s*)(\d+\s+([+-]\s+)?)/
|
|
131
|
+
|
|
132
|
+
interface ShikiToken {
|
|
133
|
+
content: string
|
|
134
|
+
color?: string
|
|
135
|
+
fontStyle?: number
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function inferLangFromPath(path: string): string | null {
|
|
139
|
+
const trimmed = path.trim()
|
|
140
|
+
const dot = trimmed.lastIndexOf('.')
|
|
141
|
+
if (dot <= 0) return null
|
|
142
|
+
const ext = trimmed.slice(dot + 1).toLowerCase()
|
|
143
|
+
return EXT_TO_LANG[ext] ?? null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function lineText(line: TerminalLine): string {
|
|
147
|
+
return line.spans.map((s) => s.text).join('')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function dominantBg(line: TerminalLine): string | undefined {
|
|
151
|
+
const counts = new Map<string, number>()
|
|
152
|
+
for (const span of line.spans) {
|
|
153
|
+
if (!span.bg) continue
|
|
154
|
+
if (span.text.trim().length === 0) continue
|
|
155
|
+
counts.set(span.bg, (counts.get(span.bg) ?? 0) + span.text.length)
|
|
156
|
+
}
|
|
157
|
+
let best: string | undefined
|
|
158
|
+
let bestCount = 0
|
|
159
|
+
for (const [bg, c] of counts) {
|
|
160
|
+
if (c > bestCount) {
|
|
161
|
+
best = bg
|
|
162
|
+
bestCount = c
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return best
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function tokenize(code: string, lang: string): ShikiToken[][] {
|
|
169
|
+
if (!ready || !activeThemeName || !warmedHighlighter) return []
|
|
170
|
+
try {
|
|
171
|
+
/* eslint-disable typescript-eslint/no-explicit-any */
|
|
172
|
+
return warmedHighlighter.codeToTokens(code, {
|
|
173
|
+
lang: lang as any,
|
|
174
|
+
theme: activeThemeName as any,
|
|
175
|
+
}).tokens as ShikiToken[][]
|
|
176
|
+
/* eslint-enable typescript-eslint/no-explicit-any */
|
|
177
|
+
} catch {
|
|
178
|
+
return []
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Calm palette: only color tokens that carry semantic weight (keywords,
|
|
183
|
+
// strings, comments, numbers, types, function names). Operators /
|
|
184
|
+
// punctuation / variables fall back to plain `text` so we don't end up
|
|
185
|
+
// with a rainbow where every identifier and brace is its own color.
|
|
186
|
+
function buildAccentSet(): Set<string> {
|
|
187
|
+
const t = getCurrentTheme()
|
|
188
|
+
return new Set(
|
|
189
|
+
[
|
|
190
|
+
t.syntaxKeyword,
|
|
191
|
+
t.syntaxString,
|
|
192
|
+
t.syntaxComment,
|
|
193
|
+
t.syntaxNumber,
|
|
194
|
+
t.syntaxType,
|
|
195
|
+
t.syntaxFunction,
|
|
196
|
+
]
|
|
197
|
+
.filter((c): c is string => typeof c === 'string')
|
|
198
|
+
.map((c) => c.toLowerCase())
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildSpans(
|
|
203
|
+
tokens: ShikiToken[],
|
|
204
|
+
bg: string | undefined,
|
|
205
|
+
fallbackFg: string,
|
|
206
|
+
accents: Set<string>
|
|
207
|
+
): TerminalSpan[] {
|
|
208
|
+
const spans: TerminalSpan[] = []
|
|
209
|
+
for (const tok of tokens) {
|
|
210
|
+
if (!tok.content) continue
|
|
211
|
+
const fs = tok.fontStyle ?? 0
|
|
212
|
+
const tokColor = tok.color?.toLowerCase()
|
|
213
|
+
const fg = tokColor && accents.has(tokColor) ? tok.color : fallbackFg
|
|
214
|
+
spans.push({
|
|
215
|
+
bg,
|
|
216
|
+
bold: (fs & 2) !== 0 || undefined,
|
|
217
|
+
fg,
|
|
218
|
+
italic: (fs & 1) !== 0 || undefined,
|
|
219
|
+
text: tok.content,
|
|
220
|
+
underline: (fs & 4) !== 0 || undefined,
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
return spans
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function rebuildLine(
|
|
227
|
+
line: TerminalLine,
|
|
228
|
+
leading: string,
|
|
229
|
+
gutter: string,
|
|
230
|
+
isDiff: boolean,
|
|
231
|
+
tokens: ShikiToken[],
|
|
232
|
+
fallbackFg: string,
|
|
233
|
+
codeBlockBg: string,
|
|
234
|
+
accents: Set<string>,
|
|
235
|
+
targetWidth: number
|
|
236
|
+
): TerminalLine {
|
|
237
|
+
// Two zones per row:
|
|
238
|
+
// - leading whitespace before the line number → outer code-block bg.
|
|
239
|
+
// - gutter + code + right padding → strip bg (diff color for `+`/`-`
|
|
240
|
+
// lines, code-block bg otherwise).
|
|
241
|
+
// For non-diff lines both zones use the same bg so the row reads as a
|
|
242
|
+
// single rectangle.
|
|
243
|
+
const stripBg = isDiff ? (dominantBg(line) ?? codeBlockBg) : codeBlockBg
|
|
244
|
+
|
|
245
|
+
const out: TerminalSpan[] = []
|
|
246
|
+
let consumed = 0
|
|
247
|
+
if (leading.length > 0) {
|
|
248
|
+
const leadingSpans = sliceLeading(line.spans, leading.length)
|
|
249
|
+
for (const span of leadingSpans) {
|
|
250
|
+
out.push({ ...span, bg: codeBlockBg })
|
|
251
|
+
}
|
|
252
|
+
consumed += leading.length
|
|
253
|
+
}
|
|
254
|
+
if (gutter.length > 0) {
|
|
255
|
+
const gutterSpans = sliceRange(line.spans, consumed, gutter.length)
|
|
256
|
+
for (const span of gutterSpans) {
|
|
257
|
+
// Preserve fg / bold / italic (line numbers + diff markers carry
|
|
258
|
+
// meaning); force bg to the strip color.
|
|
259
|
+
out.push({ ...span, bg: stripBg })
|
|
260
|
+
}
|
|
261
|
+
consumed += gutter.length
|
|
262
|
+
}
|
|
263
|
+
out.push(...buildSpans(tokens, stripBg, fallbackFg, accents))
|
|
264
|
+
|
|
265
|
+
// Pad the right edge with the strip bg out to `targetWidth`. We use the
|
|
266
|
+
// snapshot's max line width rather than this row's own span length:
|
|
267
|
+
// after a window grow, xterm hasn't yet filled the new columns on
|
|
268
|
+
// existing lines, so per-row width undershoots the viewport width.
|
|
269
|
+
const written = out.reduce((acc, span) => acc + span.text.length, 0)
|
|
270
|
+
if (targetWidth > written) {
|
|
271
|
+
out.push({ bg: stripBg, fg: fallbackFg, text: ' '.repeat(targetWidth - written) })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { spans: out }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Return a shallow copy of the spans covering [start, start+count) chars,
|
|
278
|
+
// splitting boundary spans as needed.
|
|
279
|
+
function sliceRange(spans: TerminalSpan[], start: number, count: number): TerminalSpan[] {
|
|
280
|
+
const out: TerminalSpan[] = []
|
|
281
|
+
let cursor = 0
|
|
282
|
+
let remaining = count
|
|
283
|
+
for (const span of spans) {
|
|
284
|
+
if (remaining <= 0) break
|
|
285
|
+
const next = cursor + span.text.length
|
|
286
|
+
if (next <= start) {
|
|
287
|
+
cursor = next
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
const localStart = Math.max(0, start - cursor)
|
|
291
|
+
const available = span.text.length - localStart
|
|
292
|
+
const take = Math.min(available, remaining)
|
|
293
|
+
out.push({ ...span, text: span.text.slice(localStart, localStart + take) })
|
|
294
|
+
remaining -= take
|
|
295
|
+
cursor = next
|
|
296
|
+
}
|
|
297
|
+
return out
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Take spans from the start of `spans` totalling `count` characters,
|
|
301
|
+
// splitting the boundary span if needed. Used to keep the gutter
|
|
302
|
+
// (line number + diff marker) intact while we replace the code portion.
|
|
303
|
+
function sliceLeading(spans: TerminalSpan[], count: number): TerminalSpan[] {
|
|
304
|
+
const out: TerminalSpan[] = []
|
|
305
|
+
let remaining = count
|
|
306
|
+
for (const span of spans) {
|
|
307
|
+
if (remaining <= 0) break
|
|
308
|
+
if (span.text.length <= remaining) {
|
|
309
|
+
out.push(span)
|
|
310
|
+
remaining -= span.text.length
|
|
311
|
+
continue
|
|
312
|
+
}
|
|
313
|
+
out.push({ ...span, text: span.text.slice(0, remaining) })
|
|
314
|
+
remaining = 0
|
|
315
|
+
}
|
|
316
|
+
return out
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
interface BlockContext {
|
|
320
|
+
lang: string
|
|
321
|
+
startIndex: number
|
|
322
|
+
leadings: string[]
|
|
323
|
+
gutters: string[]
|
|
324
|
+
isDiff: boolean[]
|
|
325
|
+
codes: string[]
|
|
326
|
+
lineRefs: TerminalLine[]
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function flushBlock(
|
|
330
|
+
block: BlockContext,
|
|
331
|
+
lines: TerminalLine[],
|
|
332
|
+
fallbackFg: string,
|
|
333
|
+
codeBlockBg: string,
|
|
334
|
+
accents: Set<string>,
|
|
335
|
+
targetWidth: number
|
|
336
|
+
): void {
|
|
337
|
+
if (block.codes.length === 0) return
|
|
338
|
+
const joined = block.codes.join('\n')
|
|
339
|
+
const tokenLines = tokenize(joined, block.lang)
|
|
340
|
+
if (tokenLines.length === 0) return
|
|
341
|
+
for (let i = 0; i < block.lineRefs.length; i += 1) {
|
|
342
|
+
const tokens = tokenLines[i]
|
|
343
|
+
if (!tokens) continue
|
|
344
|
+
const lineRef = block.lineRefs[i]
|
|
345
|
+
const leading = block.leadings[i]
|
|
346
|
+
const gutter = block.gutters[i]
|
|
347
|
+
const isDiff = block.isDiff[i]
|
|
348
|
+
if (!lineRef || leading === undefined || gutter === undefined || isDiff === undefined) {
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
const newLine = rebuildLine(
|
|
352
|
+
lineRef,
|
|
353
|
+
leading,
|
|
354
|
+
gutter,
|
|
355
|
+
isDiff,
|
|
356
|
+
tokens,
|
|
357
|
+
fallbackFg,
|
|
358
|
+
codeBlockBg,
|
|
359
|
+
accents,
|
|
360
|
+
targetWidth
|
|
361
|
+
)
|
|
362
|
+
lines[block.startIndex + i] = newLine
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function maxLineWidth(lines: TerminalLine[]): number {
|
|
367
|
+
let max = 0
|
|
368
|
+
for (const line of lines) {
|
|
369
|
+
let w = 0
|
|
370
|
+
for (const span of line.spans) w += span.text.length
|
|
371
|
+
if (w > max) max = w
|
|
372
|
+
}
|
|
373
|
+
return max
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function processLines(
|
|
377
|
+
lines: TerminalLine[],
|
|
378
|
+
tabId: string,
|
|
379
|
+
fallbackFg: string,
|
|
380
|
+
codeBlockBg: string,
|
|
381
|
+
accents: Set<string>,
|
|
382
|
+
targetWidth: number
|
|
383
|
+
): TerminalLine[] {
|
|
384
|
+
const out = lines.slice()
|
|
385
|
+
let block: BlockContext | null = null
|
|
386
|
+
|
|
387
|
+
for (let i = 0; i < out.length; i += 1) {
|
|
388
|
+
const line = out[i]
|
|
389
|
+
if (!line) continue
|
|
390
|
+
const text = lineText(line)
|
|
391
|
+
|
|
392
|
+
// Update per-tab language whenever a tool header appears.
|
|
393
|
+
const headerMatch = text.match(TOOL_HEADER_RE)
|
|
394
|
+
if (headerMatch) {
|
|
395
|
+
const lang = inferLangFromPath(headerMatch[1] ?? '')
|
|
396
|
+
if (lang) tabLang.set(tabId, lang)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const prefixMatch = text.match(PREFIX_RE)
|
|
400
|
+
if (prefixMatch) {
|
|
401
|
+
const leading = prefixMatch[1] ?? ''
|
|
402
|
+
const gutter = prefixMatch[2] ?? ''
|
|
403
|
+
const diffMarker = prefixMatch[3] ?? ''
|
|
404
|
+
const code = text.slice(leading.length + gutter.length)
|
|
405
|
+
if (!block) {
|
|
406
|
+
block = {
|
|
407
|
+
codes: [],
|
|
408
|
+
gutters: [],
|
|
409
|
+
isDiff: [],
|
|
410
|
+
lang: tabLang.get(tabId) ?? DEFAULT_LANG,
|
|
411
|
+
leadings: [],
|
|
412
|
+
lineRefs: [],
|
|
413
|
+
startIndex: i,
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
block.leadings.push(leading)
|
|
417
|
+
block.gutters.push(gutter)
|
|
418
|
+
block.isDiff.push(diffMarker.length > 0)
|
|
419
|
+
block.codes.push(code)
|
|
420
|
+
block.lineRefs.push(line)
|
|
421
|
+
continue
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (block) {
|
|
425
|
+
flushBlock(block, out, fallbackFg, codeBlockBg, accents, targetWidth)
|
|
426
|
+
block = null
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (block) flushBlock(block, out, fallbackFg, codeBlockBg, accents, targetWidth)
|
|
431
|
+
return out
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function highlightSnapshot(snapshot: TerminalSnapshot, tabId: string): TerminalSnapshot {
|
|
435
|
+
if (!ready || !warmedHighlighter) return snapshot
|
|
436
|
+
|
|
437
|
+
const theme = getCurrentTheme()
|
|
438
|
+
const fallbackFg = theme.text
|
|
439
|
+
const codeBlockBg = theme.backgroundElement
|
|
440
|
+
const accents = buildAccentSet()
|
|
441
|
+
|
|
442
|
+
// Use the longest non-empty row in the snapshot as the target width.
|
|
443
|
+
// Falls back to a sensible minimum if no row is wide enough yet (very
|
|
444
|
+
// early in render).
|
|
445
|
+
const targetWidth = Math.max(maxLineWidth(snapshot.lines), maxLineWidth(snapshot.tailLines ?? []))
|
|
446
|
+
if (targetWidth === 0) return snapshot
|
|
447
|
+
|
|
448
|
+
const nextLines = processLines(
|
|
449
|
+
snapshot.lines,
|
|
450
|
+
tabId,
|
|
451
|
+
fallbackFg,
|
|
452
|
+
codeBlockBg,
|
|
453
|
+
accents,
|
|
454
|
+
targetWidth
|
|
455
|
+
)
|
|
456
|
+
const nextTail = snapshot.tailLines
|
|
457
|
+
? processLines(snapshot.tailLines, tabId, fallbackFg, codeBlockBg, accents, targetWidth)
|
|
458
|
+
: snapshot.tailLines
|
|
459
|
+
return { ...snapshot, lines: nextLines, tailLines: nextTail }
|
|
460
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Beta — bridge the active aimux theme into Claude Code by writing a
|
|
2
|
+
// custom theme JSON to ~/.claude/themes/aimux.json and selecting it via
|
|
3
|
+
// `theme: "custom:aimux"` in ~/.claude/settings.json. Claude Code watches
|
|
4
|
+
// the themes dir, so writes propagate live without restarting the CLI.
|
|
5
|
+
//
|
|
6
|
+
// Spec: https://code.claude.com/docs/en/terminal-config#create-a-custom-theme
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
type ClaudeThemeFile,
|
|
10
|
+
resolveClaudeTheme,
|
|
11
|
+
type ResolvedTuiTheme,
|
|
12
|
+
type ThemeMode,
|
|
13
|
+
} from '@brimveyn/aimux-config'
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { homedir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
import { logDebug } from '../debug/input-log'
|
|
19
|
+
|
|
20
|
+
const THEME_SLUG = 'aimux'
|
|
21
|
+
const THEME_PREF_VALUE = `custom:${THEME_SLUG}`
|
|
22
|
+
|
|
23
|
+
function claudeDir(): string {
|
|
24
|
+
return join(homedir(), '.claude')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function themeFilePath(): string {
|
|
28
|
+
return join(claudeDir(), 'themes', `${THEME_SLUG}.json`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function settingsFilePath(): string {
|
|
32
|
+
return join(claudeDir(), 'settings.json')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeAtomic(target: string, contents: string): void {
|
|
36
|
+
const tmp = `${target}.aimux.tmp`
|
|
37
|
+
writeFileSync(tmp, contents, 'utf8')
|
|
38
|
+
try {
|
|
39
|
+
renameSync(tmp, target)
|
|
40
|
+
} catch (err) {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(tmp)
|
|
43
|
+
} catch {
|
|
44
|
+
/* ignore */
|
|
45
|
+
}
|
|
46
|
+
throw err
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function logSyncWarn(reason: string, details?: Record<string, unknown>): void {
|
|
51
|
+
logDebug('claude-theme-sync:warn', { reason, ...details })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Write `~/.claude/themes/aimux.json` from the active aimux theme.
|
|
56
|
+
* Idempotent — overwriting the same content is a no-op for Claude's watcher.
|
|
57
|
+
* Errors are swallowed (logged) so a failed sync never crashes aimux.
|
|
58
|
+
*/
|
|
59
|
+
export function syncClaudeTheme(resolved: ResolvedTuiTheme, mode: ThemeMode): void {
|
|
60
|
+
let theme: ClaudeThemeFile
|
|
61
|
+
try {
|
|
62
|
+
theme = resolveClaudeTheme(resolved, mode)
|
|
63
|
+
} catch (err) {
|
|
64
|
+
logSyncWarn('resolve-failed', { err: String(err) })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const target = themeFilePath()
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(join(claudeDir(), 'themes'), { recursive: true })
|
|
71
|
+
writeAtomic(target, `${JSON.stringify(theme, null, 2)}\n`)
|
|
72
|
+
} catch (err) {
|
|
73
|
+
logSyncWarn('write-failed', { err: String(err), path: target })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Patch `~/.claude/settings.json` once so Claude picks up the synced theme.
|
|
79
|
+
* Preserves all other fields. No-op if the preference already matches.
|
|
80
|
+
*/
|
|
81
|
+
export function ensureClaudeSettingsThemePref(): void {
|
|
82
|
+
const target = settingsFilePath()
|
|
83
|
+
|
|
84
|
+
let parsed: Record<string, unknown> = {}
|
|
85
|
+
if (existsSync(target)) {
|
|
86
|
+
let raw: string
|
|
87
|
+
try {
|
|
88
|
+
raw = readFileSync(target, 'utf8')
|
|
89
|
+
} catch (err) {
|
|
90
|
+
logSyncWarn('settings-read-failed', { err: String(err), path: target })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (raw.trim().length > 0) {
|
|
94
|
+
try {
|
|
95
|
+
const json = JSON.parse(raw) as unknown
|
|
96
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
97
|
+
logSyncWarn('settings-not-object', { path: target })
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
parsed = json as Record<string, unknown>
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logSyncWarn('settings-parse-failed', { err: String(err), path: target })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (parsed.theme === THEME_PREF_VALUE) return
|
|
109
|
+
|
|
110
|
+
parsed.theme = THEME_PREF_VALUE
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
mkdirSync(claudeDir(), { recursive: true })
|
|
114
|
+
writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
logSyncWarn('settings-write-failed', { err: String(err), path: target })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -15,7 +15,13 @@ import {
|
|
|
15
15
|
} from './protocol'
|
|
16
16
|
|
|
17
17
|
export const MANAGER_PROTOCOL_MIN_VERSION = 3
|
|
18
|
-
export const MANAGER_PROTOCOL_VERSION =
|
|
18
|
+
export const MANAGER_PROTOCOL_VERSION = 4
|
|
19
|
+
/**
|
|
20
|
+
* Minimum version required to send `setBroadcastEnabled`. Older TMs (v3) will
|
|
21
|
+
* not understand the message; the daemon must check the negotiated version
|
|
22
|
+
* before sending and fall back to always-on broadcast.
|
|
23
|
+
*/
|
|
24
|
+
export const MANAGER_PROTOCOL_BROADCAST_GATE_VERSION = 4
|
|
19
25
|
|
|
20
26
|
export interface ManagerHelloRequest {
|
|
21
27
|
minVersion: number
|
|
@@ -102,6 +108,7 @@ export type ManagerRequest =
|
|
|
102
108
|
| { id: string; type: 'closeTab'; payload: { sessionId: string; tabId: string } }
|
|
103
109
|
| { id: string; type: 'disposeSession'; payload: { sessionId: string } }
|
|
104
110
|
| { id: string; type: 'ping'; payload: Record<string, never> }
|
|
111
|
+
| { id: string; type: 'setBroadcastEnabled'; payload: { enabled: boolean } }
|
|
105
112
|
|
|
106
113
|
export type ManagerResponse =
|
|
107
114
|
| { id: string; type: 'helloResult'; payload: ManagerHelloResult }
|
|
@@ -341,6 +348,12 @@ export function parseManagerRequest(value: unknown): ManagerRequest {
|
|
|
341
348
|
return value as ManagerRequest
|
|
342
349
|
case 'ping':
|
|
343
350
|
return value as ManagerRequest
|
|
351
|
+
case 'setBroadcastEnabled':
|
|
352
|
+
assert(
|
|
353
|
+
typeof value.payload.enabled === 'boolean',
|
|
354
|
+
'setBroadcastEnabled.enabled must be a boolean'
|
|
355
|
+
)
|
|
356
|
+
return value as ManagerRequest
|
|
344
357
|
default:
|
|
345
358
|
throw new IpcProtocolError(`Unknown IPC request type: ${String(value.type)}`)
|
|
346
359
|
}
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -87,11 +87,44 @@ function envInt(name: string, fallback: number): number {
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
const RENDER_COALESCE_MS = 16
|
|
90
|
-
const DATA_DEBOUNCE_MS = envInt('AIMUX_RENDER_DEBOUNCE_MS',
|
|
90
|
+
const DATA_DEBOUNCE_MS = envInt('AIMUX_RENDER_DEBOUNCE_MS', 8)
|
|
91
91
|
|
|
92
92
|
export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
93
93
|
private sessions = new Map<string, SessionHandle>()
|
|
94
94
|
private pendingFlushes = new Map<string, ReturnType<typeof setTimeout>>()
|
|
95
|
+
/**
|
|
96
|
+
* When false, snapshot+emit work is suppressed because no UI client is
|
|
97
|
+
* watching. xterm.write still runs (the buffer must stay correct) — only
|
|
98
|
+
* the projection cost is gated. Re-enable triggers a full flush per session.
|
|
99
|
+
*/
|
|
100
|
+
private broadcastEnabled = true
|
|
101
|
+
|
|
102
|
+
setBroadcastEnabled(enabled: boolean): void {
|
|
103
|
+
if (enabled === this.broadcastEnabled) return
|
|
104
|
+
this.broadcastEnabled = enabled
|
|
105
|
+
logDebug('ptyManager.setBroadcastEnabled', { enabled, sessions: this.sessions.size })
|
|
106
|
+
if (enabled) {
|
|
107
|
+
// Force-flush every session: lastSnapshot is stale (or unset) so the
|
|
108
|
+
// change check inside emitRenderIfChanged will fire and the daemon
|
|
109
|
+
// gets the current viewport for each tab on resume.
|
|
110
|
+
for (const session of this.sessions.values()) {
|
|
111
|
+
this.flushRenderNow(session)
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// Drop pending timers — they would do snapshot work nobody is watching.
|
|
115
|
+
// Iterate values() first then clear; Map deletion during iteration is
|
|
116
|
+
// defined behaviour but capturing the timers up-front keeps it obvious.
|
|
117
|
+
for (const timer of this.pendingFlushes.values()) {
|
|
118
|
+
clearTimeout(timer)
|
|
119
|
+
}
|
|
120
|
+
this.pendingFlushes.clear()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** True iff there's at least one live PTY session. Used by lifecycle gates. */
|
|
125
|
+
hasSessions(): boolean {
|
|
126
|
+
return this.sessions.size > 0
|
|
127
|
+
}
|
|
95
128
|
|
|
96
129
|
private clearTimers(tabId: string): void {
|
|
97
130
|
const flush = this.pendingFlushes.get(tabId)
|
|
@@ -102,6 +135,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
102
135
|
}
|
|
103
136
|
|
|
104
137
|
private scheduleRender(session: SessionHandle): void {
|
|
138
|
+
if (!this.broadcastEnabled) return
|
|
105
139
|
if (this.pendingFlushes.has(session.tabId)) {
|
|
106
140
|
return
|
|
107
141
|
}
|
|
@@ -116,6 +150,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
116
150
|
}
|
|
117
151
|
|
|
118
152
|
private scheduleDataRender(session: SessionHandle): void {
|
|
153
|
+
if (!this.broadcastEnabled) return
|
|
119
154
|
if (this.pendingFlushes.has(session.tabId)) {
|
|
120
155
|
return
|
|
121
156
|
}
|
|
@@ -139,6 +174,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
139
174
|
}
|
|
140
175
|
|
|
141
176
|
private emitRenderIfChanged(session: SessionHandle): void {
|
|
177
|
+
if (!this.broadcastEnabled) return
|
|
142
178
|
const nextSnapshot = snapshotTerminal(session.emulator, session.cursorVisible)
|
|
143
179
|
const nextTerminalModes = getTerminalModes(session.emulator, session.alternateScrollMode)
|
|
144
180
|
const snapshotChanged = !areTerminalSnapshotsEqual(session.lastSnapshot, nextSnapshot)
|
|
@@ -12,6 +12,7 @@ import { getTerminalManagerSocketPath } from '../daemon/runtime-paths'
|
|
|
12
12
|
import { logDebug } from '../debug/input-log'
|
|
13
13
|
import {
|
|
14
14
|
encodeManagerMessage,
|
|
15
|
+
MANAGER_PROTOCOL_BROADCAST_GATE_VERSION,
|
|
15
16
|
MANAGER_PROTOCOL_MIN_VERSION,
|
|
16
17
|
MANAGER_PROTOCOL_VERSION,
|
|
17
18
|
type ManagerAttachResult,
|
|
@@ -378,6 +379,30 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
|
|
|
378
379
|
})
|
|
379
380
|
}
|
|
380
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Tell the TM whether to bother snapshotting and broadcasting renders.
|
|
384
|
+
* No-ops on TMs that negotiated a protocol version without the feature
|
|
385
|
+
* (older builds): the worst case is the daemon keeps receiving renders it
|
|
386
|
+
* doesn't strictly need, which matches the pre-fix behaviour.
|
|
387
|
+
*/
|
|
388
|
+
async setBroadcastEnabled(enabled: boolean): Promise<void> {
|
|
389
|
+
if (
|
|
390
|
+
this.selectedProtocolVersion === null ||
|
|
391
|
+
this.selectedProtocolVersion < MANAGER_PROTOCOL_BROADCAST_GATE_VERSION
|
|
392
|
+
) {
|
|
393
|
+
logDebug('managerClient.setBroadcastEnabled.skipped', {
|
|
394
|
+
enabled,
|
|
395
|
+
selectedVersion: this.selectedProtocolVersion,
|
|
396
|
+
})
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
await this.sendExpectOk({
|
|
400
|
+
id: crypto.randomUUID(),
|
|
401
|
+
payload: { enabled },
|
|
402
|
+
type: 'setBroadcastEnabled',
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
|
|
381
406
|
destroy(): void {
|
|
382
407
|
this.resetConnection('Terminal manager client destroyed')
|
|
383
408
|
}
|
|
@@ -52,6 +52,49 @@ export async function runTerminalManager(): Promise<void> {
|
|
|
52
52
|
const sockets = new Set<Socket>()
|
|
53
53
|
const negotiatedVersions = new Map<Socket, number>()
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Auto-exit when fully idle: no clients connected AND no live PTY sessions.
|
|
57
|
+
* Prevents the "zombie TM" pattern where a stale terminal-manager from an
|
|
58
|
+
* older install keeps consuming CPU after the user has closed everything.
|
|
59
|
+
* The grace window allows brief reconnects (e.g. daemon restart during
|
|
60
|
+
* `aimux update`) without killing the process.
|
|
61
|
+
*
|
|
62
|
+
* Set AIMUX_TM_IDLE_EXIT_MS=0 to disable.
|
|
63
|
+
*/
|
|
64
|
+
const idleExitMs = (() => {
|
|
65
|
+
const raw = process.env.AIMUX_TM_IDLE_EXIT_MS
|
|
66
|
+
if (raw === undefined) return 60_000
|
|
67
|
+
const parsed = Number(raw)
|
|
68
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 60_000
|
|
69
|
+
})()
|
|
70
|
+
let idleExitTimer: ReturnType<typeof setTimeout> | null = null
|
|
71
|
+
const scheduleIdleExitIfApplicable = (): void => {
|
|
72
|
+
if (idleExitMs === 0) return
|
|
73
|
+
if (idleExitTimer !== null) return
|
|
74
|
+
if (sockets.size > 0) return
|
|
75
|
+
if (sessionManager.hasAnySessions()) return
|
|
76
|
+
logDebug('terminalManager.idleExit.schedule', { idleExitMs })
|
|
77
|
+
idleExitTimer = setTimeout(() => {
|
|
78
|
+
idleExitTimer = null
|
|
79
|
+
if (sockets.size > 0 || sessionManager.hasAnySessions()) {
|
|
80
|
+
logDebug('terminalManager.idleExit.cancelled')
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
logDebug('terminalManager.idleExit.fire')
|
|
84
|
+
sessionManager.disposeAll()
|
|
85
|
+
// Graceful: drop the listening socket so the file is unlinked, matching
|
|
86
|
+
// SIGTERM/SIGINT shutdown. Bail-out timer guards against close() hanging
|
|
87
|
+
// on a bad socket.
|
|
88
|
+
server.close(() => process.exit(0))
|
|
89
|
+
setTimeout(() => process.exit(0), 1_000).unref?.()
|
|
90
|
+
}, idleExitMs)
|
|
91
|
+
idleExitTimer.unref?.()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A session can exit naturally (PTY child finishes) while no client is
|
|
95
|
+
// attached — re-evaluate idle state in that case too.
|
|
96
|
+
sessionManager.on('exit', () => scheduleIdleExitIfApplicable())
|
|
97
|
+
|
|
55
98
|
sessionManager.on('render', (sessionId, tabId, viewport, terminalModes) => {
|
|
56
99
|
const event: ManagerEvent = {
|
|
57
100
|
payload: { sessionId, tabId, terminalModes, viewport },
|
|
@@ -233,6 +276,11 @@ export async function runTerminalManager(): Promise<void> {
|
|
|
233
276
|
case 'ping':
|
|
234
277
|
sendOk(socket, message.id)
|
|
235
278
|
break
|
|
279
|
+
case 'setBroadcastEnabled':
|
|
280
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
281
|
+
sessionManager.setBroadcastEnabled(message.payload.enabled)
|
|
282
|
+
sendOk(socket, message.id)
|
|
283
|
+
break
|
|
236
284
|
}
|
|
237
285
|
} catch (error) {
|
|
238
286
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
@@ -256,11 +304,13 @@ export async function runTerminalManager(): Promise<void> {
|
|
|
256
304
|
logDebug('terminalManager.client.close')
|
|
257
305
|
sockets.delete(socket)
|
|
258
306
|
negotiatedVersions.delete(socket)
|
|
307
|
+
scheduleIdleExitIfApplicable()
|
|
259
308
|
})
|
|
260
309
|
socket.on('error', () => {
|
|
261
310
|
logDebug('terminalManager.client.error')
|
|
262
311
|
sockets.delete(socket)
|
|
263
312
|
negotiatedVersions.delete(socket)
|
|
313
|
+
scheduleIdleExitIfApplicable()
|
|
264
314
|
})
|
|
265
315
|
})
|
|
266
316
|
|
|
@@ -270,8 +320,12 @@ export async function runTerminalManager(): Promise<void> {
|
|
|
270
320
|
})
|
|
271
321
|
tightenSocketPermissions(socketPath)
|
|
272
322
|
|
|
323
|
+
// First idle eval: if no client connects within idleExitMs of startup, exit.
|
|
324
|
+
scheduleIdleExitIfApplicable()
|
|
325
|
+
|
|
273
326
|
const gracefulShutdown = (signal: string) => {
|
|
274
327
|
logDebug(`terminalManager.${signal}`)
|
|
328
|
+
if (idleExitTimer) clearTimeout(idleExitTimer)
|
|
275
329
|
sessionManager.disposeAll()
|
|
276
330
|
server.close()
|
|
277
331
|
process.exit(0)
|
|
@@ -47,9 +47,10 @@ function getTitle(
|
|
|
47
47
|
return `${tab.title} · ${tab.status}`
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function getBorderColor(isActive: boolean,
|
|
50
|
+
function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMode']): string {
|
|
51
51
|
const t = getCurrentTheme()
|
|
52
|
-
|
|
52
|
+
if (!isActive) return t.border
|
|
53
|
+
return focusMode === 'terminal-input' ? t.accent : t.primary
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function renderSpan(span: TerminalSpan, key: string): ReactNode {
|
|
@@ -4,7 +4,7 @@ import type { ThemeId } from '../../../themes'
|
|
|
4
4
|
|
|
5
5
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
6
6
|
import { filterThemeIds, themeDisplayName } from '../../../filter-themes'
|
|
7
|
-
import { useTheme, useTransparent } from '../../../theme'
|
|
7
|
+
import { useMode, useTheme, useTransparent } from '../../../theme'
|
|
8
8
|
import { uiTokens } from '../../../ui-tokens'
|
|
9
9
|
import { Picker, type PickerItem } from '../shared/picker'
|
|
10
10
|
|
|
@@ -28,6 +28,7 @@ export function ThemePickerModal({
|
|
|
28
28
|
}: ThemePickerModalProps) {
|
|
29
29
|
const t = useTheme()
|
|
30
30
|
const transparent = useTransparent()
|
|
31
|
+
const mode = useMode()
|
|
31
32
|
const filtered = useMemo(() => filterThemeIds(filter), [filter])
|
|
32
33
|
|
|
33
34
|
useLayoutEffect(() => {
|
|
@@ -63,6 +64,7 @@ export function ThemePickerModal({
|
|
|
63
64
|
{filtered.length === 0 ? '' : ` ${effectiveIndex + 1} / ${filtered.length}`}
|
|
64
65
|
</text>
|
|
65
66
|
<text fg={t.textMuted}>{` transparent: ${transparent ? 'on' : 'off'} (ctrl-t)`}</text>
|
|
67
|
+
<text fg={t.textMuted}>{` mode: ${mode} (ctrl-l)`}</text>
|
|
66
68
|
</box>
|
|
67
69
|
}
|
|
68
70
|
items={items}
|
package/src/ui/theme-store.ts
CHANGED
|
@@ -73,6 +73,10 @@ export function useTransparent(): boolean {
|
|
|
73
73
|
return useStore(themeStore, (s) => s.transparent)
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
export function useMode(): ThemeMode {
|
|
77
|
+
return useStore(themeStore, (s) => s.mode)
|
|
78
|
+
}
|
|
79
|
+
|
|
76
80
|
export function getTransparent(): boolean {
|
|
77
81
|
return themeStore.getState().transparent
|
|
78
82
|
}
|
|
@@ -81,3 +85,17 @@ export function setTransparent(value: boolean): void {
|
|
|
81
85
|
if (themeStore.getState().transparent === value) return
|
|
82
86
|
themeStore.setState({ transparent: value })
|
|
83
87
|
}
|
|
88
|
+
|
|
89
|
+
/** Subscribe to theme id/mode changes for non-React side-effects. */
|
|
90
|
+
export function subscribeThemeChanges(
|
|
91
|
+
listener: (resolved: ResolvedTuiTheme, mode: ThemeMode) => void
|
|
92
|
+
): () => void {
|
|
93
|
+
let lastId: ThemeId | null = null
|
|
94
|
+
let lastMode: ThemeMode | null = null
|
|
95
|
+
return themeStore.subscribe((s) => {
|
|
96
|
+
if (s.id === lastId && s.mode === lastMode) return
|
|
97
|
+
lastId = s.id
|
|
98
|
+
lastMode = s.mode
|
|
99
|
+
listener(derive(s.id, s.mode), s.mode)
|
|
100
|
+
})
|
|
101
|
+
}
|
package/src/ui/theme.ts
CHANGED
package/src/update.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getDaemonSocketPath } from './daemon/runtime-paths'
|
|
2
|
-
import { findIpcDaemonPid } from './platform/daemon-control'
|
|
2
|
+
import { findIpcDaemonPid, findTerminalManagerPid } from './platform/daemon-control'
|
|
3
3
|
import { runRestartDaemon } from './restart-daemon'
|
|
4
4
|
|
|
5
5
|
const REPO = 'BrimVeyn/aimux'
|
|
@@ -59,5 +59,25 @@ export async function runUpdate(): Promise<number> {
|
|
|
59
59
|
await runRestartDaemon()
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// The terminal-manager is intentionally NOT restarted here: doing so kills
|
|
63
|
+
// live AI sessions. The flip side: the running TM keeps executing the
|
|
64
|
+
// previous version's code path, so any TM-side fix (perf, lifecycle) only
|
|
65
|
+
// takes effect after a manual restart. Surface that explicitly so users
|
|
66
|
+
// aren't silently stuck on stale behaviour.
|
|
67
|
+
const tmPid = await findTerminalManagerPid()
|
|
68
|
+
if (tmPid !== null) {
|
|
69
|
+
process.stdout.write(
|
|
70
|
+
[
|
|
71
|
+
'',
|
|
72
|
+
`Note: terminal-manager (pid ${tmPid}) is still running the previous version.`,
|
|
73
|
+
'TM-side fixes in this update apply to new TMs only — the running one',
|
|
74
|
+
'keeps its old behaviour until restarted.',
|
|
75
|
+
'Run `aimux restart-terminal-manager` when you can afford to lose your',
|
|
76
|
+
'current PTY sessions to upgrade it.',
|
|
77
|
+
'',
|
|
78
|
+
].join('\n')
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
return 0
|
|
63
83
|
}
|