@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.9.9",
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.8",
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
- }, [backend, clearAllTimers, clearIdleTimer, clearStartupGrace, dispatch, resizingRef])
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 { 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)
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[]
@@ -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 = 3
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
  }
@@ -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', 0)
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, _focusMode: TerminalPaneProps['focusMode']): string {
50
+ function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMode']): string {
51
51
  const t = getCurrentTheme()
52
- return isActive ? t.borderActive : t.border
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}
@@ -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
@@ -11,6 +11,8 @@ export {
11
11
  getTransparent,
12
12
  setMode,
13
13
  setTransparent,
14
+ subscribeThemeChanges,
15
+ useMode,
14
16
  useTheme,
15
17
  useTransparent,
16
18
  } from './theme-store'
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
  }