@brimveyn/aimux 1.9.9 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app-runtime/backend-runtime-events.ts +8 -1
- package/src/app-runtime/click-selection-resolver.ts +90 -56
- package/src/app-runtime/side-effects.ts +7 -1
- package/src/app-runtime/use-backend-runtime.ts +12 -1
- package/src/app-runtime/use-mouse-handlers.ts +299 -6
- package/src/app.tsx +56 -1
- package/src/config.ts +10 -1
- package/src/daemon/daemon.ts +26 -0
- package/src/daemon/session-manager.ts +13 -0
- package/src/daemon/session-registry.ts +8 -0
- package/src/index.tsx +14 -6
- package/src/input/modes/types.ts +1 -0
- package/src/integrations/claude-syntax-overlay.ts +460 -0
- package/src/integrations/claude-theme-sync.ts +118 -0
- package/src/ipc/manager-protocol.ts +14 -1
- package/src/pty/pty-manager.ts +37 -1
- package/src/terminal-manager/manager-client.ts +25 -0
- package/src/terminal-manager/terminal-manager.ts +54 -0
- package/src/ui/components/layout/split-layout.tsx +10 -0
- package/src/ui/components/layout/terminal-pane.tsx +19 -5
- package/src/ui/components/modals/themes/theme-picker-modal.tsx +3 -1
- package/src/ui/root.tsx +13 -1
- package/src/ui/theme-store.ts +18 -0
- package/src/ui/theme.ts +2 -0
- package/src/update.ts +21 -1
|
@@ -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)
|