@brimveyn/aimux 1.9.9 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)