Shows the LaTeX math in Claude's replies as real equations: images in kitty and Ghostty, text elsewhere

kittex typesets the LaTeX in Claude's replies right in your terminal. In kitty and Ghostty, display and inline math become real TeX images that follow your font and theme. While Claude writes, they show as readable Unicode, and nothing jumps when they land. Other terminals get clean Unicode math. With a local LaTeX install, TikZ, pgfplots, tikz-cd, circuitikz and chemfig diagrams are drawn as pictures too, compiled in a sandbox. Hover an equation to copy its LaTeX, and run /kittex-doctor to check your setup.
kittex runs entirely on your machine: it makes no network requests and sends nothing anywhere. It keeps rendered images in ~/.cache/kittex (the Cache option turns this off).
Options, setup for diagrams and the FAQ: https://github.com/brenocq/kittex
License: MIT.
Network. None. kittex never opens a connection, and nothing it reads leaves your machine: what it learns about your terminal stays in Claude Code's plugin storage and in the cache below.
Programs it runs, each with a time limit, only in a terminal session (not under claude -p or the SDK):
uname -s, once per session: macOS and Linux draw the reply bullet differently.perl -e …, or python3 -I -c … where perl is missing: asks the terminal Claude Code runs in for its cell size in pixels (the TIOCGWINSZ ioctl on that terminal, opened read-only), so images fit the text rows. On macOS the python probe runs ps to find that terminal. The scripts are fixed text in core/src/terminal/cell.ts.kitty +runpy …; in Ghostty, ghostty +show-config --changes-only=false: each prints the terminal's own colours and font from its config, so formulas take your text colour and weight. Without them kittex reads kitty.conf or Ghostty's config file itself.fc-match finds your terminal font's file, and od reads byte ranges of it where Claude Code can't read the file whole: the font's metrics set the size of the math.rm -f on old entries of kittex's own cache, when it passes its size limit (the mod's file API has no delete).auto and TeX installed: latex and dvisvgm (version checks, then the compile of each diagram, with shell escape off), kpsewhich, realpath and ldd (to find what TeX needs, so the sandbox can show it), prlimit (CPU time and file size limits) and on Linux bwrap (bubblewrap: TeX runs without your home folder or the temporary folders, only its own job folder), mktemp -d, mkdir -p, cp, mv, find … -delete and rm -rf on that job folder and on kittex's TeX cache. The commands are built in core/src/diagram/tex.ts and hooks/tex.ts./kittex-doctor runs the same checks again, plus du and find on kittex's cache folder to report its size.Files it writes, none of them read or run by any other tool:
~/.cache/kittex/v<n>/ (or under $XDG_CACHE_HOME): rendered equations, so a resumed session draws them at once. The Cache option turns it off.~/.cache/kittex/tex/: drawn diagrams, and in tex/fmt/ the LaTeX format kittex precompiles for them.$TMPDIR/kittex-tex.XXXXXXXXXX/: one job folder per compile, removed after it.What it reads. Environment variables, by name and never written: TERM, TERM_PROGRAM, TERM_PROGRAM_VERSION, LC_TERMINAL, VTE_VERSION, TERMINAL_EMULATOR, KITTY_WINDOW_ID, KITTY_PID, KITTY_INSTALLATION_DIR, KITTY_CONFIG_DIRECTORY, GHOSTTY_RESOURCES_DIR, GHOSTTY_BIN_DIR, WEZTERM_PANE, WEZTERM_EXECUTABLE, ITERM_SESSION_ID, WT_SESSION, TMUX, STY, ZELLIJ, ZELLIJ_SESSION_NAME (which terminal draws, and whether a multiplexer sits in between); SSH_CONNECTION, SSH_CLIENT, SSH_TTY (only whether they are set: over SSH the terminal's files are on another machine); HOME, XDG_CONFIG_HOME, XDG_CONFIG_DIRS, XDG_CACHE_HOME, TMPDIR, PATH, CLAUDE_CONFIG_DIR (where configs, the cache and TeX are); CLAUDE_CODE_FORCE_TERMINAL_IMAGES, CLAUDE_CODE_SESSION_KIND, FORCE_HYPERLINK, CI, TEAMCITY_VERSION, NETLIFY (what Claude Code itself decides by). Files: the terminal's config, your terminal font, a custom Claude theme's file (<config dir>/themes/<name>.json), and for the doctor /etc/os-release and kittex's own plugin.json. Settings: the theme and maxProseWidth values, kittex's own options, and (for the doctor) only whether managed settings exist. kittex reads no credentials, tokens or keys, and has none to ask for.
Settings and storage it sets. Once, if you set the Cache option to false in kittex 0.1.0 (when it was a switch), kittex writes it again as off, the value /config now offers, through Claude Code's own /config path. Nothing else in your settings, and no environment variable. In Claude Code's plugin storage it keeps the terminal facts it measured (for the next session's first drawing), where TeX was found, and which conversations already have its current instructions.
What it adds to the conversation. In a terminal where it draws math, a system prompt section (kittex:math) tells Claude to write math as LaTeX ($…$ inline, $$ on lines of their own) and, with TeX, diagrams as TikZ in a latex code block; the text is MATH_INSTRUCTIONS and DIAGRAM_INSTRUCTIONS in hooks/math.ts. Where that section can't be added (a policy plugin drops it) or a conversation holds an older version, the same text rides once as context with the next prompt you send. $.prompt.compose, which kittex calls once at start, is Claude Code's own call: it checks whether the section reached the system prompt.
Hooks. /kittex-doctor is kittex's own command, answered by its command.run hook; no other command is hooked, and kittex never decides a permission. The config.set hooks on theme and maxProseWidth pass each change on unchanged and only redraw the math to match. The reply hooks change what you see, not what Claude wrote: while a reply streams, its formulas show as Unicode, and once it lands they are drawn as images in the same place. The session hooks (start, end, compaction, appended replies) only note when a conversation starts over.
The bundled code. hooks/core.js and hooks/core-parts/ are built from the TypeScript in core/src by npm run build (core/scripts/build.mjs): esbuild, not minified, each statement under a comment naming its source file, cut into parts that each import the one before (so Claude Code reads the mod without holding up the prompt). It bundles MathJax 4 with its New Computer Modern font (Apache-2.0), marked and fflate (MIT), as THIRD-PARTY-NOTICES lists. The long JSON texts at the start of the parts (__kittexJson…) are that font's tables, glyph metrics and SVG outlines, parsed only when the first formula is drawn. The getters and the Object.getOwnPropertyNames and Object.getOwnPropertyDescriptor calls (__export, __copyProps) are esbuild's module helpers, which give import * as its live bindings; the setTimeout in MathJax's maction tooltips never runs in the mod.
hooks/register.tsx 2007 lines1// kittex's Claude Code mod: typesets the LaTeX math in Claude's replies, as TeX
2// images where the terminal draws them (kitty, Ghostty) and as Unicode
3// elsewhere. Every function that takes `$` lives here; the pure side (the
4// rewrite of streamed text, the plan of a landed reply, the constants that
5// encode guesses about the engine) is in math.ts.
6//
7// The flow (design notes, "Streaming"): classic.MessageDisplay rewrites the
8// reply while it streams, display formulas becoming Unicode previews padded to
9// the rows their images will take, and records each preview with its TeX; when
10// the block lands, ui.render on AssistantMessage draws the prose through the
11// engine and puts an Image where each preview was. Anything that fails falls
12// back to what the engine would have drawn.
13
14import type { EngineInterface, FsEntry, MatchedHook, Register, RenderElement, Timer } from 'claude-code'
15
16import {
17 cellProbes,
18 chooseInk,
19 createLineScanner,
20 claudeCustomThemePath,
21 claudeThemeScheme,
22 colorProbes,
23 detectTerminal,
24 drawsEmojiSequences,
25 fontFileArgv,
26 GHOSTTY_BUILTIN_FONT,
27 imageInkBackground,
28 imageInkCurve,
29 matchesFamily,
30 odArgv,
31 parseFontFile,
32 parseOd,
33 readFontMetrics,
34 measureDisplay,
35 measureDisplayResult,
36 previewDisplay,
37 readTerminalColors,
38 renderDisplay,
39 renderDisplayResult,
40 renderInline,
41 renderInlineResult,
42 renderPicture,
43 TexError,
44 strokeWeight,
45 toBase64,
46 BUILD_ID,
47} from './core.js'
48import type { ByteReader, CellSize, FontMetrics, InkPlace, InlineEnv, PictureEnv, RenderedImage, RenderEnv, TerminalColors, TerminalInfo, TexDocument } from './core.js'
49import {
50 BLANK_ALT,
51 BLOCK_LIMIT,
52 blockMatches,
53 BULLET,
54 bulletFor,
55 CELL_IDLE_MS,
56 CELL_REFRESH_MS,
57 cellOrFallback,
58 COPY_LABEL,
59 copiedFormula,
60 DIAGRAM_INSTRUCTIONS,
61 FALLBACK_COLUMNS,
62 graphicsFromBlit,
63 inlineAlt,
64 INK_PREFER,
65 IMAGE_LIMIT,
66 inlineEnvFor,
67 mathEmPxFor,
68 INSTRUCT_WITHOUT_IMAGES,
69 instructionsKey,
70 instructionsNeeded,
71 inlineFlow,
72 joinProse,
73 STREAMED_PATTERN,
74 linkEnv,
75 locatePreviews,
76 MessageStream,
77 MATH_INSTRUCTIONS,
78 mathOptions,
79 pictureEnvFor,
80 planLanded,
81 recordInstructed,
82 PROBE_TIMEOUT_MS,
83 proseWidthFor,
84 displayColumns,
85 PENDING_ROWS,
86 RECENT_BLOCKS,
87 renderEnvFor,
88 REPLY_INDENT,
89 RESIZE_SETTLE_MS,
90 SECTION_ID,
91 sourcePattern,
92 STREAM_LIMIT,
93 streamEnvFor,
94 UPDATED_INSTRUCTIONS,
95 withoutTextOverride,
96} from './math.ts'
97import { altText, fallbackLines, fitPictures, overBudget } from './budget.ts'
98import { CACHE_LIMIT_BYTES, CACHE_READ_MS, cacheDir, cacheFacts, decodeEntry, encodeEntry, entryKey, entryPath, ENTRY_NAME, pruneList, TEX_CACHE_LIMIT_BYTES, TEX_ENTRY_NAME } from './cache.ts'
99import { newestFirst } from './schedule.ts'
100import type { EngineGraphics, InlineSlot, InstructedSessions, KittexEnv, LandedPlan, MathOptions, Piece, PlanOptions, PreviewRecord, StreamedBlock, StreamEnv, StreamRewrite, TexUse } from './math.ts'
101import { diagramJob, hiddenDirs, mathJob, prepareFormat, probeTex, rememberedTex, TEX_BACKGROUND_MS, TEX_STREAM_BUDGET_MS, texBook, texCacheDir, texResult } from './tex.ts'
102import type { DiagramKind, TexHost } from './tex.ts'
103import { DOCTOR_DESCRIPTION, DOCTOR_PROBE_MS, formatDoctor, osFacts, plain, probeCache, probeDiagrams } from './doctor.ts'
104import type { DoctorFacts, DoctorHost, TerminalFacts } from './doctor.ts'
105
106type $ = EngineInterface
107
108/**
109 * A matcher for a render's `onScreen` once the engine reports it: a range,
110 * or null for a block laid out off screen; not a render that has none. A
111 * RegExp tests the value as a string, so a range reads `[object Object]`;
112 * an object matcher (`[{}, null]`) did the same but made the engine warn,
113 * on every render it didn't select, that `{}` can never match a null.
114 */
115const ON_SCREEN_REPORTED = /^(?:\[object Object\]|null)$/
116
117const ENV = { plugin: 'kittex', key: 'env' } as const
118const BLOCKS = { plugin: 'kittex', key: 'blocks' } as const
119const REQUESTS = { plugin: 'kittex', key: 'requests' } as const
120const RECENT = { plugin: 'kittex', key: 'recent' } as const
121/** How long a render waits for session.start's kittex.env before it draws without kittex. */
122const ENV_WAIT_MS = 1500
123
124// Module state that drawing never reads (a hot reload resets it, and
125// session.start runs again then).
126
127/** A message streaming through MessageDisplay (one text block), as kittex follows it. */
128interface Streaming {
129 /** Its stream; null once kittex gave up on it (the rest passes as written). */
130 stream: MessageStream | null
131 /** The model's text so far, every delta as it came: what the block's transcript row holds. */
132 source: string
133 /** How much of it the engine shows (every flush's text joined): where the next flush's previews start. */
134 shown: number
135 /** Its block is in kittex.blocks. */
136 stored: boolean
137 /** A row's uuid links it (kittex.requests). */
138 linked: boolean
139 /** Its final flush came. */
140 done: boolean
141}
142
143/** Per message, a gate per flush index that opens once that flush's rewrite is done (see the MessageDisplay hook). */
144const flushGates = new Map<string, Map<number, { promise: Promise<void>; resolve: () => void }>>()
145
146/** The gate of a message's flush `index` (open at once below 0: there is no flush before the first). */
147function flushGate(id: string, index: number): { promise: Promise<void>; resolve: () => void } {
148 if (index < 0) return { promise: Promise.resolve(), resolve: () => undefined }
149 let gates = flushGates.get(id)
150 if (!gates) {
151 gates = new Map()
152 flushGates.set(id, gates)
153 for (const key of flushGates.keys()) if (flushGates.size > STREAM_LIMIT) flushGates.delete(key)
154 }
155 let gate = gates.get(index)
156 if (!gate) {
157 let resolve!: () => void
158 const promise = new Promise<void>(r => (resolve = r))
159 gate = { promise, resolve }
160 gates.set(index, gate)
161 // Only the last few are ever waited on.
162 gates.delete(index - 8)
163 }
164 return gate
165}
166
167/** Messages streaming through MessageDisplay, and the last ones that did (their rows may be appended after their final flush). */
168const streams = new Map<string, Streaming>()
169/** The message_ids of the blocks stored, oldest first: past BLOCK_LIMIT the oldest is dropped. */
170const storedBlocks: string[] = []
171/** Response rows appended before any flush of their block (a one-line reply): linked once its stream shows up. */
172const pendingRows: { uuid: string; text: string }[] = []
173/** Claude Code's environment as the terminal helpers read it. */
174let processEnv: Record<string, string | undefined> = {}
175let terminal: TerminalInfo | undefined
176let terminalColors: TerminalColors | undefined
177/** The light/dark scheme terminalColors were read for (kitty's auto themes, Ghostty's pairs follow it). */
178let colorScheme: 'dark' | 'light' | undefined
179let theme: string | undefined
180/** A custom theme's file contents, when `theme` is `custom:<slug>`. */
181let customTheme: string | undefined
182/** prompt.compose's section did not reach the prompt: instructions ride prompt.submit's context. */
183let instructByContext = false
184/** setUp found the model is instructed here (a terminal kittex draws math on). */
185let instructWanted = false
186/** The next request composes its conversation's system prompt anew (/clear, compaction): the current instructions are in it. */
187let composesFresh = false
188/** InstructedSessions in $.store, and as this process last wrote it (where the store can't be read). */
189const INSTRUCTED = 'instructed'
190let instructedHere: InstructedSessions = {}
191/** The session's key in InstructedSessions where the host can't name it. */
192const THIS_SESSION = 'this'
193/** The cell probes, bound to the `$` session.start received. */
194let cells: Cells | undefined
195/** Runs a function on session.start's clock once the current dispatch resolves, or `ms` later (a hook's `$` belongs to its one dispatch). */
196let later: ((fn: () => void, ms?: number) => void) | undefined
197/** Writes a file with session.start's `$`: the cache's entries a render could not write itself. */
198let writeFile: ((path: string, text: string) => Promise<void>) | undefined
199/** Where the cache's entries go (cache.ts), from XDG_CACHE_HOME or HOME; undefined without either. */
200let cacheFolder: string | undefined
201/**
202 * Settles once session.start has stored kittex.env (or given up). After
203 * --resume the engine asks for the transcript's drawings before it raises
204 * session.start: a render that comes first waits for it.
205 */
206let envSettled: (() => void) | undefined
207/** session.start has run setUp this process (kittex.env is then the truth, null included). */
208let envKnown = false
209let envReady: Promise<void> = new Promise(resolve => {
210 envSettled = resolve
211})
212/** kittex.env as this process last stored it (writeEnv): what a new session in the process starts with (readEnv). */
213let lastEnv: KittexEnv | null | undefined
214/** kittex.env as the last session stored it, for renders before session.start (read once). */
215let rememberedEnv: Promise<KittexEnv | undefined> | undefined
216const REMEMBERED = 'env'
217/** The `cache` option: drawings of resumed blocks kept on disk (cache.ts). */
218let cacheOn = true
219/** session.start's dispatches in this process (a hot reload makes a second). */
220let sessions = 0
221/** The local TeX may draw here (the `latex` option on, block math not Unicode): an earlier session's TeX cache is read. */
222let texAllowed = false
223/** Where the setup TeX was found with is remembered for the next session ($.store): its cache's key. */
224const REMEMBERED_TEX = 'tex'
225/** `darwin`, `linux`... from `uname -s`, when it ran. */
226let platform: string | undefined
227/** The probe for the local TeX (the `latex` option), started after session.start; settles once texBook knows. */
228let texProbe: Promise<void> | undefined
229/** Redraws the landed blocks once a compile they asked for ends (session.start's `$`). */
230let redraw: (() => void) | undefined
231
232export const register: Register = (on, options) => {
233 /** The `block` and `inline` options: how each kind of math is shown (image, unicode or raw). */
234 const math = mathOptions(options)
235 // Both left as Claude wrote them: kittex does nothing (no rewrite, no instructions to the model) but its doctor.
236 const off = math.block === 'raw' && math.inline === 'raw'
237 cacheOn = options.cache !== 'off' && options.cache !== false
238 /** The `latex` option: `auto` draws diagrams and the math MathJax refuses with the local LaTeX where it is found; `off` never runs it. */
239 const latex = options.latex === 'off' ? 'off' : 'auto'
240 texAllowed = !off && latex === 'auto' && math.block !== 'unicode'
241
242 // /kittex-doctor (doctor.ts), whatever the options: kittex's own command,
243 // answered here (a registered command has no core); no other command is hooked.
244 on('command.run', { command: 'kittex-doctor' }, $ => runDoctor($, options))
245
246 // ─── Setup ─────────────────────────────────────────────────────────────────
247
248 on('session.start', async ($, e, next) => {
249 const started = await startDoctor($, e.surface, await next(e))
250 await migrateCacheOption($)
251 if (off) return started
252 if (e.surface !== 'terminal') {
253 // A -p run or the SDK (Claude Code for VS Code, the desktop app): no
254 // terminal draws here, and MessageDisplay's rewrite would become the
255 // reply's text in the SDK's output. Nothing is probed or rewritten.
256 cells?.stop()
257 cells = undefined
258 await writeEnv($, null).catch(() => undefined)
259 envKnown = true
260 envSettled?.()
261 return started
262 }
263 cells?.stop()
264 cells = cellsFor($)
265 later = laterFor($)
266 graphicsSoon = ms => void $.clock.after(ms, () => void checkGraphics($).catch(() => undefined))
267 // A drawing before session.start (a resume's) put Images on screen: asked now.
268 askSoon(GRAPHICS_ASK_MS)
269 writeFile = (path, text) => $.fs.write(path, text)
270 sessions += 1
271 let settled = false
272 void envReady.then(() => (settled = true))
273 await Promise.resolve()
274 if (settled) {
275 // A second session in this process (a hot reload): its own wait.
276 envReady = new Promise(resolve => {
277 envSettled = resolve
278 })
279 }
280 try {
281 await setUp($, e.surface)
282 } catch {
283 await writeEnv($, null).catch(() => undefined)
284 } finally {
285 envKnown = true
286 envSettled?.()
287 }
288 if (cacheOn) soon(() => void pruneCache($).catch(() => undefined))
289 if (unwritten.size > 0) soon(flushEntries)
290 // The local TeX, found after setup without holding it up (process.run: the terminal only).
291 // The first session keeps what renders before it read from TeX's cache (a resume's diagrams).
292 if (sessions > 1) texBook.reset()
293 texProbe = undefined
294 if (latex === 'auto' && math.block !== 'unicode') {
295 redraw = () => $.ui.invalidate('ui.render')
296 // Not awaited: a few short commands (each with its time limit) that settle meanwhile.
297 texProbe = setUpTex($)
298 }
299 return started
300 })
301 if (off) return
302
303 on('config.set', { key: 'theme' }, async ($, e, next) => {
304 const result = await next(e)
305 if (result.deny === undefined && typeof result.value === 'string') await refreshInk($, result.value).catch(() => undefined)
306 return result
307 })
308
309 // Prose wraps at maxProseWidth: inline images are placed by it.
310 on('config.set', { key: 'maxProseWidth' }, async ($, e, next) => {
311 const result = await next(e)
312 if (result.deny === undefined) await refreshProseWidth($).catch(() => undefined)
313 return result
314 })
315
316 // ─── Instructions to the model ─────────────────────────────────────────────
317
318 on('prompt.compose', async ($, e, next) => {
319 const composed = await next(e)
320 try {
321 const env = await readEnv($, true)
322 if (!e.surfaces.includes('terminal') || !instructs(env)) return composed
323 if (composed.sections.some(section => section.id === SECTION_ID)) return composed
324 return { sections: [...composed.sections, { id: SECTION_ID, text: await instructions(env, math), scope: 'session' as const }] }
325 } catch {
326 return composed
327 }
328 })
329
330 // The instructions as context, once, where the conversation lacks the
331 // current ones: the section was dropped (policy), or the system prompt the
332 // conversation was composed with holds older ones or none (a resume, a
333 // kittex reloaded or upgraded since, TeX found since).
334 on('prompt.submit', async ($, e, next) => {
335 let update: { record: InstructedSessions; session: string; key: string } | undefined
336 let note: string | undefined
337 try {
338 const env = await readEnv($, true)
339 if (instructWanted && instructs(env)) {
340 const text = await instructions(env, math)
341 const key = instructionsKey(text)
342 const session = await $.session.id().catch(() => THIS_SESSION)
343 const record = await readInstructed($)
344 // A conversation with nothing in it yet is composed with this text too (a new session, a hot reload before its first prompt).
345 const fresh = composesFresh || (record[session] !== key && (await $.session.messages().catch(() => [])).length === 0)
346 if (instructionsNeeded({ fresh, byContext: instructByContext, held: record[session], key })) note = fresh ? text : `${UPDATED_INSTRUCTIONS} ${text}`
347 update = { record, session, key }
348 }
349 } catch {
350 update = undefined
351 note = undefined
352 }
353 const entered = await next(note === undefined ? e : { ...e, context: [...(e.context ?? []), note] })
354 if (update && entered.drop === undefined) {
355 composesFresh = false
356 if (update.record[update.session] !== update.key) {
357 instructedHere = recordInstructed(update.record, update.session, update.key)
358 await $.store.set(INSTRUCTED, instructedHere).catch(() => undefined)
359 }
360 }
361 return entered
362 })
363
364 // A new conversation in the same process: /clear, and a compaction (which
365 // drops the turn that carried the instructions). The hooks only observe: a
366 // failure (here and at the other gating sites) lets the event go on as is.
367 on('session.end', ($, e, next) => {
368 if (e.reason === 'clear') composesFresh = true
369 return next(e)
370 })
371
372 on('classic.SessionStart', async ($, e, next) => {
373 const result = await next(e)
374 // A new session's state starts empty: kittex.env is stored again for it (readEnv).
375 await readEnv($, true).catch(() => null)
376 if (e.source === 'clear' || e.source === 'compact') composesFresh = true
377 // Another conversation resumed: it holds what it was composed with (recorded, or not).
378 if (e.source === 'resume') composesFresh = false
379 return result
380 })
381
382 on('session.compact', async ($, e, next) => {
383 const result = await next(e)
384 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) composesFresh = true
385 return result
386 })
387
388 // ─── Streaming ─────────────────────────────────────────────────────────────
389
390 on('classic.MessageDisplay', async ($, e, next) => {
391 // The engine dispatches a message's flushes without waiting for the hook on the one before (measured live:
392 // while a flush waited for TeX, the next two ran first). A stream reads them in order: each waits for the
393 // flush before it (its index - 1) to be done, then is done itself however it ends.
394 const done = flushGate(e.message_id, e.index)
395 try {
396 const below = await next(e)
397 await flushGate(e.message_id, e.index - 1).promise
398 return await rewriteFlush($, e, below, math)
399 } finally {
400 done.resolve()
401 if (e.final) flushGates.delete(e.message_id)
402 }
403 })
404
405
406 // A landed block is told from the others by its transcript row: the row a
407 // text block is appended as holds the model's text, which the block's
408 // stream started (MessageDisplay's message_id and AssistantMessage's
409 // requestId, the row's uuid, are unrelated ids). The link is what a landed
410 // block's drawing reads its previews by (fuzz FUZZ-7, FUZZ-14).
411 on('session.append', { door: 'response' }, async ($, e, next) => {
412 try {
413 const text = e.message.content.flatMap(block => (block.type === 'text' ? [block.text] : [])).join('')
414 if (text.trim() !== '') {
415 const id = streamOf(text)
416 if (id !== undefined) {
417 await linkRow($, e.uuid, id)
418 } else {
419 pendingRows.push({ uuid: e.uuid, text })
420 pendingRows.splice(0, Math.max(0, pendingRows.length - PENDING_ROWS))
421 }
422 }
423 } catch {
424 // drawn by its text
425 }
426 return next(e)
427 })
428
429 // ─── Landed replies ────────────────────────────────────────────────────────
430
431 // Registered with matchers on the text, so a block that holds no math and
432 // no kittex preview is drawn by the engine alone (no round trip here).
433 //
434 // The engine draws a hooked block as nothing until the hook's answer
435 // arrives (a hop to the worker and back, 15 to 50 ms), unless the block was
436 // drawn unhooked before: then its own drawing stays up meanwhile. So in the
437 // fullscreen layout a block kittex streamed is hooked only once its
438 // `onScreen` is reported, which it never is on a block's first render: the
439 // engine draws that one itself, and its drawing of a streamed text is the
440 // streaming preview row for row, so the landing shows no blank, only the
441 // images arriving over their previews. A block holding a preview mark is a
442 // streamed one whatever else it holds (a reply's `$100` stays as written in
443 // it). LaTeX as written (after --resume) is hooked from the first render (its
444 // own drawing would show the source), as is every block on the main screen,
445 // which reports no `onScreen`. The three matchers never select the same
446 // render, so kittex runs once per render. A remote surface (desktop, vscode,
447 // mobile) is not hooked: it has no kitty graphics and draws the text it
448 // holds as markdown (and math) its own way, where a formula's Unicode laid
449 // out in rows would run together on one line.
450 const landed = { component: 'AssistantMessage' } as const
451 on('ui.render', { ...landed, surface: 'terminal', viewport: { isFullscreen: true }, props: { text: STREAMED_PATTERN, onScreen: ON_SCREEN_REPORTED } }, ($, e, next) => drawLanded($, e, next, math))
452 on('ui.render', { ...landed, surface: 'terminal', viewport: { isFullscreen: false }, props: { text: STREAMED_PATTERN } }, ($, e, next) => drawLanded($, e, next, math))
453 on('ui.render', { ...landed, surface: 'terminal', props: { text: sourcePattern(math, latex === 'auto') } }, ($, e, next) => drawLanded($, e, next, math))
454}
455
456/** One flush's rewrite (the MessageDisplay hook's work, its flushes taken in order). */
457async function rewriteFlush<B extends { displayContent?: string }>($: $, e: { message_id: string; delta: string; final: boolean }, below: B, math: MathOptions): Promise<B> {
458 const delta = below.displayContent ?? e.delta
459 let entry: Streaming | undefined
460 let before = 0
461 try {
462 const env = await readEnv($, true)
463 if (!env) return below
464 entry = streams.get(e.message_id)
465 if (entry?.done) entry = undefined
466 if (entry?.stream === null) {
467 entry.source += e.delta
468 entry.shown += delta.length
469 if (e.final) entry.done = true
470 return below
471 }
472 const streamEnv: StreamEnv = { ...streamEnvFor(env, math), ...texUse(env, math) }
473 if (!entry) {
474 if (delta === '' && e.final) return below
475 // Diagrams are held for TeX where it draws them (a block streaming when TeX is found keeps its code).
476 entry = { stream: new MessageStream(createLineScanner({ diagrams: streamEnv.tex?.block === true })), source: '', shown: 0, stored: false, linked: false, done: false }
477 streams.delete(e.message_id)
478 streams.set(e.message_id, entry)
479 for (const id of streams.keys()) if (streams.size > STREAM_LIMIT) streams.delete(id)
480 }
481 before = entry.shown
482 entry.source += e.delta
483 const rewrite = await withTex(entry.stream!, entry.stream!.push(delta, e.final, streamEnv), streamEnv)
484 // Each preview where the engine will show it: a landed block maps its previews back by place, not by content.
485 const records = locatePreviews(rewrite.text, rewrite.records, before)
486 entry.shown = before + rewrite.text.length
487 if (e.final) entry.done = true
488 if (records.length > 0 || (!entry.stored && rewrite.text !== delta)) await storeBlock($, e.message_id, entry, records)
489 if (!entry.linked) await linkPending($, e.message_id, entry)
490 if (records.length > 0 && env.images) drawSoon(records, env)
491 return rewrite.text === delta ? below : { ...below, displayContent: rewrite.text }
492 } catch {
493 // Show whatever was held back, as written, and leave the rest of the message alone.
494 const unshown = entry?.stream?.unshown() ?? delta
495 if (entry) {
496 entry.stream = null
497 entry.shown = before + unshown.length
498 if (e.final) entry.done = true
499 // From here on the text is the model's as written: the landing may read its LaTeX.
500 await storeBlock($, e.message_id, entry, [], before).catch(() => undefined)
501 }
502 return unshown === delta ? below : { ...below, displayContent: unshown }
503 }
504}
505
506/**
507 * Draws a landed block (see the AssistantMessage registrations): its prose
508 * through the engine, an Image where each preview was; anything that fails
509 * falls back to the engine's drawing. `math`: the options (a kind set to
510 * `unicode` gets no image, one set to `raw` is left as written).
511 */
512async function drawLanded<E extends LandedEvent>($: $, e: E, next: (e: E) => Promise<RenderElement>, math: MathOptions): Promise<RenderElement> {
513 if (e.props.isSummary || e.surface !== 'terminal') return next(e)
514 try {
515 let env = await readEnv($)
516 if (!env) {
517 // A resumed session asks for its blocks before session.start has
518 // measured the terminal: draw with what the last session measured in
519 // this terminal, else wait for session.start, rather than draw nothing
520 // of kittex's now and everything later (which moves rows twice).
521 env = (envKnown ? undefined : await (rememberedEnv ??= readRemembered($))) ?? (envKnown ? null : (await within(envReady, ENV_WAIT_MS, clockOf($)), await readEnv($)))
522 if (!env) return next(e)
523 }
524 const seen = e.surface === 'terminal' ? e.viewport?.columns : undefined
525 if (seen !== undefined && seen !== env.columns) {
526 // The window changed width since the cells were measured, and a font
527 // zoom changes the cells too: measure before drawing, so no image goes
528 // out for cells that are gone, and once more when the resize settles.
529 // A render may not write state, so the settle timer stores what it finds.
530 const cell = await cells?.probe()
531 if (cell) env = { ...env, ...cellEnv(cell, env), columns: seen }
532 cells?.settle(seen)
533 }
534 const columns = e.viewport?.columns ?? env.columns
535 const terminalImages = e.surface === 'terminal' && env.images
536 const blockImages = terminalImages && math.block === 'image'
537 const inlineImages = terminalImages && math.inline === 'image'
538 const images = blockImages || inlineImages
539 // The text may lack the block's last flush (or be empty) on the first
540 // render: nothing here is final, and the render runs again when it lands
541 // (and when its block's previews or its row's link are stored: read here).
542 const found = e.surface === 'terminal' ? await landedBlock($, e) : { streamed: false }
543 const streamed = found.streamed || STREAMED_PATTERN.test(e.props.text)
544 const records = images ? (found.block?.records ?? []) : []
545 const renderEnv = renderEnvFor(env, columns)
546 const inlineEnv = inlineEnvFor(env, columns)
547 const planOptions: PlanOptions = {
548 ...(streamed ? { streamed: found.block?.raw === undefined ? {} : { raw: found.block.raw } } : {}),
549 mode: { hyperlinks: env.hyperlinks, emojiSequences: env.emojiSequences },
550 columns,
551 maxColumns: renderEnv.maxColumns,
552 draw: blockImages ? (tex, rows, maxColumns) => displayImage(tex, maxColumns === undefined ? renderEnv : { ...renderEnv, maxColumns }, rows) : undefined,
553 width: proseWidthFor(env, columns),
554 measure: (tex, maxColumns) => displayRows(tex, { ...renderEnv, maxColumns }),
555 math,
556 inline:
557 inlineImages
558 ? { env: inlineEnv, width: proseWidthFor(env, columns), columns, draw: (tex, cells, place) => inlineImage(tex, inlineEnv, cells, place), hyperlinks: env.hyperlinks, emojiSequences: env.emojiSequences }
559 : undefined,
560 }
561 // Diagrams where the local TeX draws (its pictures as wide as the prose).
562 const texOn = e.surface === 'terminal' && texUse(env, math).tex?.block === true
563 if (texOn) {
564 const pictureEnv = pictureEnvFor(env, columns)
565 planOptions.diagram = (source, kind, rows) => diagramImage(source, kind, pictureEnv, rows)
566 }
567 // A block read back as LaTeX (a resumed session) may be on disk already
568 // (cache.ts); one off screen in the fullscreen layout (`onScreen` null:
569 // the engine lays out blocks around the view) gets its rows only, its
570 // images once it is on screen; every other is typeset in its turn, the
571 // newest first (schedule.ts).
572 const resumed = e.surface === 'terminal' && images && !streamed && records.length === 0
573 const key = resumed && cacheOn && cacheFolder ? entryKey(e.props.text, cacheFacts(env, columns, math, `${BUILD_ID}|tex:${texOn}`)) : undefined
574 let plan: LandedPlan | undefined = key ? await readEntry($, key) : undefined
575 if (!plan) {
576 const offScreen = resumed && e.viewport?.isFullscreen === true && (e.props as { onScreen?: unknown }).onScreen === null
577 const options = offScreen ? reservedOptions(planOptions, renderEnv) : planOptions
578 let asked: ReturnType<typeof texBook.takeAsked> = []
579 const release = await turns.take(() => $.state.get(ENV))
580 try {
581 texBook.takeAsked()
582 plan = planLanded(e.props.text, records, options)
583 asked = texBook.takeAsked()
584 } finally {
585 release()
586 }
587 // What TeX drew in an earlier session is on disk: read, then planned again (no jump after --resume); the rest is compiled, then redrawn.
588 // Read with this render's own $ (a resume's first renders come before TeX is probed); a missing file asked first, so no failed read is logged.
589 const read = async (path: string) => ((await $.fs.exists(path).catch(() => false)) ? $.fs.read(path).then(text => (typeof text === 'string' ? text : undefined), () => undefined) : undefined)
590 if (asked.length > 0 && (await loadAsked(asked, read))) {
591 plan = planLanded(e.props.text, records, options)
592 asked = [...asked, ...texBook.takeAsked()]
593 }
594 compileAsked(asked)
595 // Kept only once final: no TeX document it waits for.
596 const final = asked.every(document => {
597 const known = texBook.known(document)
598 return known !== undefined && (known.ok || known.lasting)
599 })
600 if (key && !offScreen && final) keepEntry(key, plan, (path, text) => $.fs.write(path, text))
601 }
602 if (!plan.changed) return next(e)
603 if (e.surface !== 'terminal' || plan.pieces.every(piece => piece.kind === 'prose' && !piece.inline?.length)) {
604 return next({ ...e, props: { ...e.props, text: joinProse(plan.pieces) } })
605 }
606 // Drawn as the engine drew the preview, row for row (measured live): the
607 // first prose piece is the engine's own drawing with the block's bullet;
608 // later pieces are drawn without a bullet (each brings a one-row top
609 // margin) and indented to the reply column; images and notes sit in that
610 // column, a blank row above them where a blank line was. A block that
611 // opens with a formula gets the bullet beside the image's first row,
612 // where the preview's first line had it.
613 // One answer holds at most 2 MiB of Image source: past it, the rest keep their Unicode (budget.ts).
614 // Pictures drawn at fewer pixels first (same cells), so they fit rather than stay placeholders.
615 const pieces = fitPictures(plan.pieces, (image, density) => pictureRedraws.get(image)?.(density))
616 const over = overBudget(pieces)
617 /** An image not drawn: past the budget, or only its rows reserved (off screen). */
618 const left = { has: (image: RenderedImage) => image.png.length === 0 || over.has(image) }
619 if (images) cells?.watch()
620 // Each Image's alt: its formula for a screen reader where Claude Code draws the pictures (or hasn't
621 // said); where it doesn't, the text already under it, so the preview stays as it streamed (BLANK_ALT).
622 const shownAlt = graphics.state === 'yes' || graphics.state === 'unknown'
623 // The smallest Image drawn here, to ask Claude Code by whether it draws pictures (checkGraphics).
624 let smallest: { key: string; image: RenderedImage } | undefined
625 const drawnImage = (key: string, image: RenderedImage) => {
626 if (!smallest || image.png.length < smallest.image.png.length) smallest = { key, image }
627 return key
628 }
629 const { Box, Button, Image, Text } = $.ui.resolve(e as Extract<LandedEvent, { surface: 'terminal' }>)
630 const indentOf = (isFirstOfReply: boolean) => (isFirstOfReply ? REPLY_INDENT : 0)
631 const first = e.props.isFirstOfReply
632 const indent = first ? REPLY_INDENT : 0
633 // A selection over an image copies the terminal's placeholder cells, not the
634 // formula (the engine copies screen cells and offers no hook), so each image
635 // carries a copy button, shown while the pointer is over it (fullscreen), in
636 // its top-right corner: absolute, so it moves no row.
637 const copy = (tex: string, source?: string) => async () => {
638 const copied = await $.ui.copy({ text: source ?? copiedFormula(tex), surface: e.surface })
639 $.ui.toast(copied.isCopied ? (source === undefined ? 'Copied the formula as LaTeX' : 'Copied the LaTeX source') : 'Could not copy the formula')
640 }
641 const own = (piece: Exclude<Piece, { kind: 'prose' }>, i: number) =>
642 piece.kind === 'image' && left.has(piece.image) ? (
643 <Box key={`kittex-formula-${i}-text`} width={piece.image.columns} height={piece.image.rows} flexDirection="column">
644 <Text>{fallbackLines(previewDisplay(piece.tex, { maxColumns: piece.image.columns }, piece.image.rows), piece.tex, piece.image.columns, piece.image.rows).join('\n')}</Text>
645 </Box>
646 ) : piece.kind === 'image' ? (
647 <Box key={`kittex-formula-${i}-${signatureOf(piece.image.png)}`}>
648 <Image key={drawnImage(`kittex-image-${i}`, piece.image)} source={{ png: base64Of(piece.image.png) }} columns={piece.image.columns} rows={piece.image.rows} alt={shownAlt ? altText(piece.tex) : BLANK_ALT} />
649 <Box position="absolute" top={0} right={0} display="none" hover={{ display: 'flex' }}>
650 <Button key={`kittex-copy-${i}`} label={COPY_LABEL} plain dimColor onPress={copy(piece.tex, piece.copy)} />
651 </Box>
652 </Box>
653 ) : (
654 <Text dimColor>{piece.text}</Text>
655 )
656 // A prose piece is the engine's own drawing; the images of its formulas
657 // lie over their previews, each at the cell its preview starts in: a row
658 // under the piece's top margin, the column after the bullet's where the
659 // piece draws one (a display formula's over its preview's rows, with its
660 // copy button). Not absolute: the engine puts an absolute box that falls
661 // above the screen on its first row (fullscreen, a reply scrolled past
662 // the top), so the images go in the flow of an overlay column, as wide as
663 // nothing and as tall as the piece, beside the drawing in a row-reverse
664 // Box: it starts at the piece's top-left cell, is painted after the
665 // drawing, and takes no room (nothing moves). The drawing's wrapper grows
666 // to the width instead of naming one: the engine refuses its own drawing
667 // under a Box with a size, a position or an overflow.
668 const prose = async (piece: Extract<Piece, { kind: 'prose' }>, isFirstOfReply: boolean, at: number) => {
669 const text = await next({ ...e, props: { ...e.props, text: piece.text, isFirstOfReply } })
670 const inline = piece.inline?.filter(one => !left.has(one.image))
671 if (!inline?.length) return text
672 return (
673 <Box flexDirection="row-reverse">
674 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
675 {text}
676 </Box>
677 <Box flexDirection="column" width={0} flexShrink={0} alignItems="flex-start">
678 {inlineFlow(inline, indentOf(isFirstOfReply)).map(({ inline, marginTop, marginLeft }: InlineSlot, k: number) => (
679 <Box
680 key={`kittex-${inline.display ? 'formula' : 'inline'}-${k}-${signatureOf(inline.image.png)}`}
681 marginTop={marginTop}
682 marginLeft={marginLeft}
683 width={inline.image.columns}
684 height={inline.image.rows}
685 flexShrink={0}
686 >
687 <Image
688 key={drawnImage(`kittex-image-${at}-${k}`, inline.image)}
689 source={{ png: base64Of(inline.image.png) }}
690 columns={inline.image.columns}
691 rows={inline.image.rows}
692 alt={shownAlt ? altText(inline.tex) : inline.display ? BLANK_ALT : inlineAlt(inline.tex, inlineEnv, inline.image.columns)}
693 />
694 {inline.display ? (
695 <Box position="absolute" top={0} right={0} display="none" hover={{ display: 'flex' }}>
696 <Button key={`kittex-copy-${k}`} label={COPY_LABEL} plain dimColor onPress={copy(inline.tex, inline.copy)} />
697 </Box>
698 ) : null}
699 </Box>
700 ))}
701 </Box>
702 </Box>
703 )
704 }
705 // The prose pieces are drawn by the engine at once: each is a round trip, and a block may hold several.
706 const texts = await Promise.all(pieces.map((piece, i) => (piece.kind === 'prose' ? prose(piece, i === 0 ? first : false, i) : null)))
707 const drawn = []
708 for (const [i, piece] of pieces.entries()) {
709 if (i === 0) {
710 if (piece.kind === 'prose') {
711 drawn.push(texts[i])
712 } else {
713 drawn.push(
714 <Box flexDirection="row" marginTop={1}>
715 {first ? (
716 <Box minWidth={REPLY_INDENT}>
717 <Text color="text">{env.bullet ?? BULLET.other}</Text>
718 </Box>
719 ) : null}
720 {own(piece, i)}
721 </Box>,
722 )
723 }
724 } else if (piece.kind === 'prose') {
725 drawn.push(
726 <Box paddingLeft={indent} marginTop={piece.gap ? 0 : -1}>
727 {texts[i]}
728 </Box>,
729 )
730 } else if (piece.kind === 'image') {
731 drawn.push(
732 <Box marginLeft={indent} marginTop={piece.gap ? 1 : 0}>
733 {own(piece, i)}
734 </Box>,
735 )
736 } else {
737 drawn.push(
738 <Box paddingLeft={indent} marginTop={piece.gap ? 1 : 0}>
739 {own(piece, i)}
740 </Box>,
741 )
742 }
743 }
744 if (smallest) askGraphics({ requestId: e.requestId, key: smallest.key, source: { png: base64Of(smallest.image.png) }, columns: smallest.image.columns, rows: smallest.image.rows })
745 return <Box flexDirection="column">{drawn}</Box>
746 } catch {
747 return next(e)
748 }
749}
750
751/** A render of a landed block, as any of the AssistantMessage registrations receives it. */
752type LandedEvent = Parameters<MatchedHook<'ui.render', { component: 'AssistantMessage' }>>[1]
753
754// ─── Helpers that take $ ─────────────────────────────────────────────────────
755
756/**
757 * kittex.env as this conversation holds it. `$.state` is the session's, and a
758 * new session in this process (/clear, /resume of another conversation)
759 * starts without it while session.start does not run again: the env the
760 * setup stored last stands in, and with `reseed` (not from a render, which
761 * may not write) is stored again.
762 */
763async function readEnv($: $, reseed = false): Promise<KittexEnv | null> {
764 const held = (await $.state.get(ENV)).value
765 if (held !== undefined) return held
766 if (!envKnown || lastEnv === undefined) return null
767 if (reseed && lastEnv !== null) await $.state.set(ENV, lastEnv).catch(() => undefined)
768 return lastEnv
769}
770
771/** Stores kittex.env, and keeps it for a new session in this process (readEnv). */
772async function writeEnv($: $, env: KittexEnv | null): Promise<void> {
773 lastEnv = env
774 await $.state.set(ENV, env)
775}
776
777/** InstructedSessions as $.store holds it, else as this process last wrote it. */
778async function readInstructed($: $): Promise<InstructedSessions> {
779 const value = await $.store.get(INSTRUCTED).catch(() => undefined)
780 return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as InstructedSessions) : instructedHere
781}
782
783/** Whether the model is told to write LaTeX: kittex is set up and draws math on this terminal. */
784function instructs(env: KittexEnv | null): boolean {
785 return env !== null && (env.images || INSTRUCT_WITHOUT_IMAGES)
786}
787
788async function setUp($: $, surface: string | null): Promise<void> {
789 // MathJax is not loaded here: the first formula loads it (core's typeset is lazy).
790 processEnv = await readProcessEnv($)
791 cacheFolder = cacheDir(processEnv)
792 terminal = detectTerminal(processEnv)
793 const [cell, uname] = await Promise.all([probeCell($), probeSystem($), resolveTheme($)])
794 platform = uname?.trim().toLowerCase() || undefined
795 const cellAdjust = terminalColors?.cellAdjust
796 const kittyAdjust = terminalColors?.kittyAdjust
797 const env: KittexEnv = {
798 kind: terminal.kind,
799 // Off where Claude Code said it draws no pictures (checkGraphics), whatever the terminal is.
800 images: terminal.images && graphics.state !== 'no',
801 ...cellEnv(cell, { kind: terminal.kind, cellAdjust, kittyAdjust }),
802 ...(cellAdjust ? { cellAdjust } : {}),
803 ...(kittyAdjust ? { kittyAdjust } : {}),
804 columns: cell?.columns ?? FALLBACK_COLUMNS,
805 ink: inkNow(),
806 ...inkOverNow(),
807 ...backgroundNow(),
808 bullet: bulletFor(uname, processEnv.HOME),
809 maxProseWidth: await readProseWidth($),
810 ...linkEnv(processEnv),
811 emojiSequences: drawsEmojiSequences(terminal, terminalColors),
812 // Strokes as heavy as the terminal's text, when its font's weight is known.
813 ...(terminalColors?.fontWeight ? { weight: strokeWeight(terminalColors.fontWeight) } : {}),
814 }
815 // The text font's metrics as the last session read them in this terminal, at
816 // these cells: the first drawing has the size it keeps (loadFont reads them
817 // again and changes nothing when they match), as the renders before
818 // session.start had it from the remembered env.
819 const remembered = (await $.store.get(REMEMBERED).catch(() => undefined)) as { id?: unknown; env?: KittexEnv } | undefined
820 const known = remembered?.id === terminalId(processEnv) && remembered.env?.font && remembered.env.cellWidth === env.cellWidth && remembered.env.cellHeight === env.cellHeight ? remembered.env : undefined
821 const withFont = known ? { ...env, font: known.font, ...(env.weight === undefined && known.weight !== undefined ? { weight: known.weight } : {}) } : env
822 const start: KittexEnv = known ? { ...withFont, emPx: mathEmPxFor(withFont, withFont) } : env
823 await writeEnv($, start)
824 envSettled?.()
825 // For the next session's first renders, which come before its session.start.
826 await $.store.set(REMEMBERED, { id: terminalId(processEnv), env: start }).catch(() => undefined)
827 // The text font's metrics, read from its file after setup (the first drawing doesn't wait for them).
828 if (env.images) later?.(() => void loadFont($).catch(() => undefined))
829
830 // Self-check: a policy plugin (cc-plugin-sec-default on Team/Enterprise or
831 // managed machines) may skip installed plugins' prompt.compose hooks; then
832 // the instructions ride the first prompt's context instead.
833 const wanted = surface === 'terminal' && instructs(env)
834 let present = false
835 if (wanted) {
836 try {
837 const composed = await $.prompt.compose({ surfaces: ['terminal'] })
838 present = composed.sections.some(section => section.id === SECTION_ID)
839 } catch {
840 present = false
841 }
842 }
843 instructWanted = wanted
844 instructByContext = wanted && !present
845}
846
847/**
848 * The variables the terminal helpers read (detection, the colour probes'
849 * binaries, the config files' locations, Claude's config dir); names must be
850 * literals.
851 */
852async function readProcessEnv($: $): Promise<Record<string, string | undefined>> {
853 const values = await Promise.all([
854 $.env.get('TERM'),
855 $.env.get('TERM_PROGRAM'),
856 $.env.get('TERM_PROGRAM_VERSION'),
857 $.env.get('LC_TERMINAL'),
858 $.env.get('KITTY_WINDOW_ID'),
859 $.env.get('KITTY_PID'),
860 $.env.get('KITTY_INSTALLATION_DIR'),
861 $.env.get('KITTY_CONFIG_DIRECTORY'),
862 $.env.get('GHOSTTY_RESOURCES_DIR'),
863 $.env.get('GHOSTTY_BIN_DIR'),
864 $.env.get('WEZTERM_PANE'),
865 $.env.get('WEZTERM_EXECUTABLE'),
866 $.env.get('ITERM_SESSION_ID'),
867 $.env.get('TMUX'),
868 $.env.get('STY'),
869 $.env.get('ZELLIJ'),
870 $.env.get('ZELLIJ_SESSION_NAME'),
871 $.env.get('SSH_CONNECTION'),
872 $.env.get('SSH_CLIENT'),
873 $.env.get('SSH_TTY'),
874 $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
875 $.env.get('CLAUDE_CODE_SESSION_KIND'),
876 $.env.get('CLAUDE_CONFIG_DIR'),
877 $.env.get('HOME'),
878 $.env.get('XDG_CONFIG_HOME'),
879 $.env.get('XDG_CONFIG_DIRS'),
880 $.env.get('XDG_CACHE_HOME'),
881 $.env.get('FORCE_HYPERLINK'),
882 $.env.get('TERMINAL_EMULATOR'),
883 $.env.get('WT_SESSION'),
884 $.env.get('VTE_VERSION'),
885 $.env.get('CI'),
886 $.env.get('TEAMCITY_VERSION'),
887 $.env.get('NETLIFY'),
888 ])
889 const names = [
890 'TERM',
891 'TERM_PROGRAM',
892 'TERM_PROGRAM_VERSION',
893 'LC_TERMINAL',
894 'KITTY_WINDOW_ID',
895 'KITTY_PID',
896 'KITTY_INSTALLATION_DIR',
897 'KITTY_CONFIG_DIRECTORY',
898 'GHOSTTY_RESOURCES_DIR',
899 'GHOSTTY_BIN_DIR',
900 'WEZTERM_PANE',
901 'WEZTERM_EXECUTABLE',
902 'ITERM_SESSION_ID',
903 'TMUX',
904 'STY',
905 'ZELLIJ',
906 'ZELLIJ_SESSION_NAME',
907 'SSH_CONNECTION',
908 'SSH_CLIENT',
909 'SSH_TTY',
910 'CLAUDE_CODE_FORCE_TERMINAL_IMAGES',
911 'CLAUDE_CODE_SESSION_KIND',
912 'CLAUDE_CONFIG_DIR',
913 'HOME',
914 'XDG_CONFIG_HOME',
915 'XDG_CONFIG_DIRS',
916 'XDG_CACHE_HOME',
917 'FORCE_HYPERLINK',
918 'TERMINAL_EMULATOR',
919 'WT_SESSION',
920 'VTE_VERSION',
921 'CI',
922 'TEAMCITY_VERSION',
923 'NETLIFY',
924 ]
925 return Object.fromEntries(names.map((name, i) => [name, values[i]]))
926}
927
928/** `uname -s` (the engine's bullet differs on macOS), or undefined when it can't run. */
929async function probeSystem($: $): Promise<string | undefined> {
930 try {
931 const { exitCode, stdout } = await $.process.run(['uname', '-s'], { timeoutMs: PROBE_TIMEOUT_MS })
932 return exitCode === 0 ? stdout : undefined
933 } catch {
934 return undefined
935 }
936}
937
938/** The cell size from the first cell probe that answers (perl, then python3). */
939async function probeCell($: $): Promise<CellSize | undefined> {
940 for (const probe of cellProbes) {
941 try {
942 const { exitCode, stdout } = await $.process.run(probe.argv, { timeoutMs: PROBE_TIMEOUT_MS })
943 const cell = exitCode === 0 ? probe.parse(stdout) : undefined
944 if (cell) return cell
945 } catch {
946 // the next probe
947 }
948 }
949 return undefined
950}
951
952/** The terminal's configured colours: its probes, else its config files. */
953async function readColors($: $, info: TerminalInfo, scheme: 'dark' | 'light'): Promise<TerminalColors | undefined> {
954 for (const probe of colorProbes(info, { env: processEnv, scheme })) {
955 try {
956 const { exitCode, stdout } = await $.process.run(probe.argv, { timeoutMs: PROBE_TIMEOUT_MS })
957 const colors = exitCode === 0 ? probe.parse(stdout) : undefined
958 if (colors?.foreground) return colors
959 } catch {
960 // the next probe
961 }
962 }
963 try {
964 return await readTerminalColors(info, path => readText($, path), { env: processEnv, scheme })
965 } catch {
966 return undefined
967 }
968}
969
970async function readText($: $, path: string): Promise<string | undefined> {
971 try {
972 return await $.fs.read(path)
973 } catch {
974 return undefined
975 }
976}
977
978async function readThemeSetting($: $): Promise<string | undefined> {
979 try {
980 const row = (await $.config.list()).find(one => one.key === 'theme')
981 return typeof row?.value === 'string' ? row.value : undefined
982 } catch {
983 return undefined
984 }
985}
986
987/**
988 * Reads what the ink depends on: the theme setting (unless given), a custom
989 * theme's file, and the terminal's colours for the theme's light/dark scheme
990 * (read again only when the scheme changes).
991 */
992async function resolveTheme($: $, setting?: string): Promise<void> {
993 theme = setting ?? (await readThemeSetting($))
994 const configDir = processEnv.CLAUDE_CONFIG_DIR ?? (processEnv.HOME ? `${processEnv.HOME}/.claude` : undefined)
995 const path = configDir ? claudeCustomThemePath(theme, configDir) : undefined
996 customTheme = path ? await readText($, path) : undefined
997 // `auto` follows the terminal's background, so read the colours (dark first) before settling the scheme.
998 for (let pass = 0; pass < 2 && terminal; pass += 1) {
999 const scheme = claudeThemeScheme(theme, customTheme, terminalColors)
1000 if (scheme === colorScheme) break
1001 terminalColors = await readColors($, terminal, scheme)
1002 colorScheme = scheme
1003 }
1004}
1005
1006/** The formulas' ink: the terminal's foreground first; a custom theme's `text` colour (the bullet's) never. */
1007function inkNow() {
1008 return chooseInk({ theme, customTheme: withoutTextOverride(customTheme), terminal: terminalColors, prefer: INK_PREFER })
1009}
1010
1011/** The background the ink's alpha is corrected against (imageInkBackground), as kittex.env's `inkOver`: none where images blend as text does. */
1012function inkOverNow(): Pick<KittexEnv, 'inkOver' | 'inkCurve'> {
1013 const over = terminal ? imageInkBackground(terminal.kind, terminalColors, platform) : undefined
1014 const curve = terminal ? imageInkCurve(terminal.kind, terminalColors, platform) : undefined
1015 return over ? { inkOver: { r: over.r, g: over.g, b: over.b }, ...(curve ? { inkCurve: { gamma: curve.gamma, contrast: curve.contrast } } : {}) } : {}
1016}
1017
1018/** The terminal's background as kittex.env's `background`, when its colours were read (diagrams are drawn for it). */
1019function backgroundNow(): Pick<KittexEnv, 'background'> {
1020 const background = terminalColors?.background
1021 return background ? { background: { r: background.r, g: background.g, b: background.b } } : {}
1022}
1023
1024const sameColor = (a: KittexEnv['inkOver'], b: KittexEnv['inkOver']) => a === b || (!!a && !!b && a.r === b.r && a.g === b.g && a.b === b.b)
1025const sameCurve = (a: KittexEnv['inkCurve'], b: KittexEnv['inkCurve']) => a === b || (!!a && !!b && a.gamma === b.gamma && a.contrast === b.contrast)
1026
1027/** The `maxProseWidth` setting, when set: reply prose wraps at most this wide. */
1028async function readProseWidth($: $): Promise<number | undefined> {
1029 try {
1030 const value = (await $.settings.read()).maxProseWidth
1031 return typeof value === 'number' && Number.isFinite(value) && value >= 1 ? Math.floor(value) : undefined
1032 } catch {
1033 return undefined
1034 }
1035}
1036
1037/** maxProseWidth changed: inline images are placed at the new width. */
1038async function refreshProseWidth($: $): Promise<void> {
1039 const env = await readEnv($)
1040 if (!env) return
1041 const maxProseWidth = await readProseWidth($)
1042 if (maxProseWidth !== env.maxProseWidth) await writeEnv($, { ...env, maxProseWidth })
1043}
1044
1045/** The theme changed: the formulas' ink may follow it. */
1046async function refreshInk($: $, setting: string): Promise<void> {
1047 await resolveTheme($, setting)
1048 const env = await readEnv($)
1049 if (!env) return
1050 const ink = inkNow()
1051 const over = inkOverNow()
1052 const back = backgroundNow()
1053 if (sameColor(ink, env.ink) && sameColor(over.inkOver, env.inkOver) && sameCurve(over.inkCurve, env.inkCurve) && sameColor(back.background, env.background)) return
1054 const { inkOver: _, inkCurve: __, background: ___, ...rest } = env
1055 await writeEnv($, { ...rest, ink, ...over, ...back })
1056}
1057
1058/**
1059 * The cell probes after setup: for a render that sees a new width (a font
1060 * zoom changes the width in cells too), once a resize settles, for a drawing
1061 * with images when the last probe is a while old, and rarely while idle (a
1062 * move to a monitor of another scale may keep the width). Each measures the terminal's own
1063 * TIOCGWINSZ (its columns and pixels); the timers store what they find in
1064 * kittex.env when it changed, which redraws every block that read it.
1065 */
1066interface Cells {
1067 /** Probes now, for drawing: one probe at a time, shared by every render that asks meanwhile. Stores nothing (a render may not write state). */
1068 probe(): Promise<CellSize | undefined>
1069 /**
1070 * Probes and stores once the width has stopped changing: every call
1071 * restarts the wait, so a drag ends with the final size. `seen`: the width a
1072 * render saw, kept when the probe can't tell the columns.
1073 */
1074 settle(seen?: number): void
1075 /**
1076 * A drawing with images: probes (on the clock, not holding the drawing up)
1077 * when the last probe is older than CELL_REFRESH_MS, and starts the idle
1078 * probe every CELL_IDLE_MS (once): a change of the cells' pixels alone
1079 * draws nothing by itself.
1080 */
1081 watch(): void
1082 stop(): void
1083}
1084
1085/**
1086 * The probes on session.start's `$`: a render's `$` belongs to its one
1087 * dispatch, and the timers outlive it. A timer's callback is a dispatch of its
1088 * own, where a state write is allowed.
1089 */
1090function cellsFor($: $): Cells {
1091 let probing: Promise<CellSize | undefined> | undefined
1092 /** The stores, one after another (a settle never skipped for a periodic probe running). */
1093 let stores: Promise<void> = Promise.resolve()
1094 let settleTimer: Timer | undefined
1095 let pollTimer: Timer | undefined
1096 /** When the last probe that stores ran, on the clock ($.clock.now): setup's, made as these are (session.start). */
1097 let probed = -Infinity
1098 void $.clock.now().then(
1099 now => (probed = Math.max(probed, now)),
1100 () => undefined,
1101 )
1102 const store = (seen?: number) => {
1103 stores = stores
1104 .then(async () => {
1105 probed = await $.clock.now().catch(() => probed)
1106 await storeCells($, await probeCell($), seen)
1107 })
1108 .catch(() => undefined)
1109 }
1110 const refresh = async () => {
1111 const now = await $.clock.now()
1112 if (now - probed >= CELL_REFRESH_MS) store()
1113 }
1114 return {
1115 probe() {
1116 return (probing ??= probeCell($).finally(() => {
1117 probing = undefined
1118 }))
1119 },
1120 settle(seen) {
1121 settleTimer?.cancel()
1122 try {
1123 settleTimer = $.clock.after(RESIZE_SETTLE_MS, () => {
1124 settleTimer = undefined
1125 store(seen)
1126 })
1127 } catch {
1128 settleTimer = undefined
1129 }
1130 },
1131 watch() {
1132 try {
1133 $.clock.after(0, () => void refresh().catch(() => undefined))
1134 } catch {
1135 // no clock: the width change and the idle probe still measure
1136 }
1137 if (pollTimer) return
1138 try {
1139 pollTimer = $.clock.every(CELL_IDLE_MS, () => void refresh().catch(() => undefined))
1140 } catch {
1141 pollTimer = undefined
1142 }
1143 },
1144 stop() {
1145 settleTimer?.cancel()
1146 pollTimer?.cancel()
1147 },
1148 }
1149}
1150
1151/**
1152 * A measured cell as kittex.env holds it, with the math's em for it
1153 * (mathEmPxFor: from the text font's metrics once read, else the font's own
1154 * cell, the terminal's adjustments undone).
1155 */
1156function cellEnv(cell: CellSize | undefined, env: Pick<KittexEnv, 'kind' | 'cellAdjust' | 'kittyAdjust' | 'font'>): Pick<KittexEnv, 'cellWidth' | 'cellHeight' | 'measured' | 'emPx'> {
1157 const { cellWidth, cellHeight, measured } = cellOrFallback(cell)
1158 return { cellWidth, cellHeight, measured, emPx: mathEmPxFor({ cellWidth, cellHeight }, env) }
1159}
1160
1161/**
1162 * Reads the text font's metrics and stores them in kittex.env, with the em
1163 * they give the math, then redraws: the file kitty names (its probe), else
1164 * the one fontconfig finds for the configured family and style; Ghostty's
1165 * built-in JetBrains Mono when it names none or fontconfig has no such
1166 * family. Off the startup path: on session.start's clock after setup.
1167 */
1168async function loadFont($: $): Promise<void> {
1169 if (!terminal || (terminal.kind !== 'kitty' && terminal.kind !== 'ghostty') || terminal.ssh) return
1170 const named = terminalColors?.font
1171 let metrics: FontMetrics | undefined
1172 let file = named?.file
1173 let index = named?.index ?? 0
1174 if (!file && named?.family) {
1175 const found = await runProbe($, fontFileArgv(named.family, named.style), parseFontFile)
1176 if (found && matchesFamily(found, named.family)) [file, index] = [found.file, found.index]
1177 }
1178 if (file) metrics = await readFontMetrics(await fontBytes($, file), index)
1179 else if (terminal.kind === 'ghostty') metrics = GHOSTTY_BUILTIN_FONT
1180 if (!metrics) return
1181 const env = await readEnv($)
1182 if (!env) return
1183 const font: NonNullable<KittexEnv['font']> = {
1184 unitsPerEm: metrics.unitsPerEm,
1185 ascender: metrics.ascender,
1186 descender: metrics.descender,
1187 lineGap: metrics.lineGap,
1188 ...(metrics.xHeight ? { xHeight: metrics.xHeight } : {}),
1189 ...(metrics.advance ? { advance: metrics.advance } : {}),
1190 ...(named?.sizePt ? { sizePt: named.sizePt } : {}),
1191 ...(platform ? { platform } : {}),
1192 }
1193 // The font's weight (usWeightClass) when the config's names don't say one.
1194 const weight = env.weight === undefined && metrics.weight && metrics.weight !== 400 ? { weight: strokeWeight(metrics.weight) } : {}
1195 const next = { ...env, font, ...weight }
1196 const measured = { ...next, emPx: mathEmPxFor(next, next) }
1197 // As setUp started from (the last session's reading): nothing to redraw.
1198 if (JSON.stringify(env.font) === JSON.stringify(font) && env.weight === measured.weight && env.emPx === measured.emPx) return
1199 await writeEnv($, measured)
1200 // The next session's first renders draw with the font too (their cache keys hold it).hooks/core.js 110 lines1import{BUILD_ID,DIAGRAM_ENVS,FORMAT_SOURCE,GHOSTTY_BUILTIN_FONT,GlyphError,INLINE_OVERFLOW,JOB_NAME,LATEX_ARGV,MAX_IMAGE_BYTES,MAX_IMAGE_SIDE,MAX_PICTURE_ROWS,MAX_PIXELS,MAX_TEX_LENGTH,MAX_TEX_SOURCE,MIN_DISPLAY_SCALE,MIN_INLINE_SCALE,MIN_PICTURE_SCALE,PREAMBLE_VERSION,SvgError,TexError,XmlError,adaptColor,assumedBackground,blockParts,bwrapProbe,cellProbe,cellProbes,charsOf,chooseInk,claudeCustomThemePath,claudeThemeInk,claudeThemeScheme,colorProbes,confined,createLineScanner,detectTerminal,diagramDocument,diagramFence,drawsEmojiSequences,drawsPicture,dvisvgmArgv,emPxForCell,encodePng,engineHyperlinks,fontCell,fontFileArgv,formatArgv,formatName,imageColumns,imageInkBackground,imageInkCurve,init,initTypeset,inkAlpha,isJobDir,jobDirTemplate,latexArgv,layoutHeading,layoutList,layoutProse,layoutQuote,layoutTable,matchesFamily,mathDocument,mathEmPx,measure,measureDisplay,measureDisplayResult,measureInline,measureInlineResult,measurePicture2,odArgv,parseFontFile,parseOd,previewDisplay,previewInline,previewWidth,proseBlocks,rasterize,readFontMetrics,readTerminalColors,recolorPng,renderDisplay,renderDisplayResult,renderInline,renderInlineResult,renderPicture,scan,strokeWeight,texEnvironment,texError,texFormula,texPicture,texToMathML2,textBaseline,textLayout,textWidth,toBase64,toHex,toUnicode,typeset,typesetLoaded,unsafeTex,visibleProse,wrapLine,wrapRows}from'./core-parts/p35.js';
2export {
3 BUILD_ID,
4 DIAGRAM_ENVS,
5 FORMAT_SOURCE,
6 GHOSTTY_BUILTIN_FONT,
7 GlyphError,
8 INLINE_OVERFLOW,
9 JOB_NAME,
10 LATEX_ARGV,
11 MAX_IMAGE_BYTES,
12 MAX_IMAGE_SIDE,
13 MAX_PICTURE_ROWS,
14 MAX_PIXELS,
15 MAX_TEX_LENGTH,
16 MAX_TEX_SOURCE,
17 MIN_DISPLAY_SCALE,
18 MIN_INLINE_SCALE,
19 MIN_PICTURE_SCALE,
20 PREAMBLE_VERSION,
21 SvgError,
22 TexError,
23 XmlError,
24 adaptColor,
25 assumedBackground,
26 blockParts,
27 bwrapProbe,
28 cellProbe,
29 cellProbes,
30 charsOf,
31 chooseInk,
32 claudeCustomThemePath,
33 claudeThemeInk,
34 claudeThemeScheme,
35 colorProbes,
36 confined,
37 createLineScanner,
38 detectTerminal,
39 diagramDocument,
40 diagramFence,
41 drawsEmojiSequences,
42 drawsPicture,
43 dvisvgmArgv,
44 emPxForCell,
45 encodePng,
46 engineHyperlinks,
47 fontCell,
48 fontFileArgv,
49 formatArgv,
50 formatName,
51 imageColumns,
52 imageInkBackground,
53 imageInkCurve,
54 init,
55 initTypeset,
56 inkAlpha,
57 isJobDir,
58 jobDirTemplate,
59 latexArgv,
60 layoutHeading,
61 layoutList,
62 layoutProse,
63 layoutQuote,
64 layoutTable,
65 matchesFamily,
66 mathDocument,
67 mathEmPx,
68 measure,
69 measureDisplay,
70 measureDisplayResult,
71 measureInline,
72 measureInlineResult,
73 measurePicture2 as measurePicture,
74 odArgv,
75 parseFontFile,
76 parseOd,
77 previewDisplay,
78 previewInline,
79 previewWidth,
80 proseBlocks,
81 rasterize,
82 readFontMetrics,
83 readTerminalColors,
84 recolorPng,
85 renderDisplay,
86 renderDisplayResult,
87 renderInline,
88 renderInlineResult,
89 renderPicture,
90 scan,
91 strokeWeight,
92 texEnvironment,
93 texError,
94 texFormula,
95 texPicture,
96 texToMathML2 as texToMathML,
97 textBaseline,
98 textLayout,
99 textWidth,
100 toBase64,
101 toHex,
102 toUnicode,
103 typeset,
104 typesetLoaded,
105 unsafeTex,
106 visibleProse,
107 wrapLine,
108 wrapRows
109};
110hooks/math.ts 2976 lines1// kittex's pure side: the constants, the streaming rewrite and the plan of a
2// landed reply. Nothing here takes `$` (it cannot cross an import); register.tsx
3// does the I/O and hands these functions plain values.
4//
5// The layout rules below were measured on the live engine (Claude Code
6// 2.1.290 and 2.1.291); docs/engine-findings.md has the recordings.
7
8import {
9 createLineScanner,
10 blockParts,
11 engineHyperlinks,
12 layoutHeading,
13 layoutList,
14 layoutProse,
15 layoutQuote,
16 layoutTable,
17 measureDisplay,
18 measureDisplayResult,
19 measureInline,
20 measureInlineResult,
21 measurePicture,
22 MAX_PICTURE_ROWS,
23 MAX_TEX_LENGTH,
24 previewDisplay,
25 GlyphError,
26 previewInline,
27 scan,
28 TexError,
29 textBaseline,
30 textLayout,
31 textWidth,
32 typeset,
33 emPxForCell,
34 fontCell,
35 mathEmPx,
36} from './core.js'
37import type { BlockPart, CellSize, InkPlace, InlineEnv, LineScanner, LinkMode, PictureEnv, ProseBlock, ProseLayout, RenderedImage, RenderEnv, Segment, SourceSpan, SpanPlace, TexDocument } from './core.js'
38import type { KittexBlock, KittexEnv, KittexPreview } from '../types'
39import { diagramJob, diagramLabel, mathJob, texBook, texResult } from './tex.ts'
40import type { DiagramKind } from './tex.ts'
41
42// ─── The engine's layout (measured) ──────────────────────────────────────────
43
44/**
45 * Columns the engine draws reply text in from the left edge of a block that
46 * opens a reply: the bullet's box (`minWidth: 2`). A block drawn without the
47 * bullet (`isFirstOfReply: false`) starts at column 0.
48 */
49export const REPLY_INDENT = 2
50
51/**
52 * The bullet that opens a reply, as the engine draws it (in the theme's `text`
53 * colour): `⏺` on macOS, `●` elsewhere.
54 */
55export const BULLET = { macos: '⏺', other: '●' } as const
56
57/** The label of the copy button shown over a formula image while the pointer is on it. */
58export const COPY_LABEL = '⧉ copy LaTeX'
59
60/** What the copy button puts on the clipboard: the formula as a display block, ready to paste back into markdown. */
61export function copiedFormula(tex: string): string {
62 return `$$\n${tex.trim()}\n$$`
63}
64
65/**
66 * What stands for a space at the start of a preview line, and for a padding
67 * line: markdown would strip a leading space (or read four of them as code)
68 * and drop an empty line. The engine draws ` ` as one blank cell, and a
69 * text that holds it always goes through its markdown parser, so the escapes
70 * in a preview line are always read.
71 */
72export const PREVIEW_PAD = ' '
73
74/**
75 * What a padding line (a preview row with no text) ends with: U+2800, a blank
76 * cell that no trim takes for whitespace. The engine's streaming preview drops
77 * a line of pads alone at the end of a paragraph (its landed drawing keeps
78 * it), which would move everything below by a row at landing.
79 */
80export const BLANK_CELL = '\u2800'
81
82/** ASCII characters markdown may read as syntax inside a preview line; each is backslash-escaped. */
83export const MARKDOWN_SPECIALS = /[\\`*_[\]<>|~&!#]/g
84
85/** Whether the preview lines are centred in the reply column, as the image's formula is. */
86export const PREVIEW_CENTERED = true
87
88/**
89 * What a MessageDisplay flush shows while an open display block is held: the
90 * empty text, which the engine takes as "show nothing for this flush".
91 */
92export const HELD_DISPLAY = ''
93
94/**
95 * How a landed reply that MessageDisplay rewrote is mapped back to its TeX: by
96 * content. A preview is a pure function of the formula and the terminal, and
97 * each one kittex wrote is recorded with its TeX (in `$.state`, so the landed
98 * block redraws once the record arrives). MessageDisplay's `message_id` and
99 * AssistantMessage's `requestId` are unrelated ids, and after `--resume` the
100 * landed text is the original LaTeX again, typeset directly.
101 */
102export const MAP_BY = 'content' as const
103
104/**
105 * Which colour the formulas take: reply text is drawn in the terminal's
106 * default foreground under every theme (the theme's `text` colour paints only
107 * the bullet), so the terminal's configured foreground wins, and a custom
108 * theme's `text` override never applies.
109 */
110export const INK_PREFER: 'theme' | 'terminal' = 'terminal'
111
112/** The prefix of the line drawn under a formula MathJax refused. */
113export const NOT_RENDERED = 'not rendered: '
114
115/**
116 * Where inline images put the math baseline in a cell, from the top, as a
117 * fraction of the cell's height. The terminal font's own baseline is 21 px down
118 * a 13×26 px cell in kitty (DejaVu Sans Mono: glyphs reach 16 px above it and 4
119 * below); one pixel higher leaves room under it for brackets, bars and \ne at
120 * 0.91 of the display size, so every inline formula draws as an image at one
121 * size instead of bracketed ones staying Unicode. The pixel is not visible at
122 * normal size.
123 */
124export const TEXT_BASELINE = 20 / 26
125
126/**
127 * How far below its baseline an inline formula's ink reaches, in em of the
128 * math, as each terminal's text compares: the stroke weight's dilation and
129 * Computer Modern's overshoot, against the bottom edge of the terminal's own
130 * letters (kitty hints them onto the pixel row, Ghostty's linear-corrected
131 * blending draws their edge a little lower). Measured on kitty 0.49 and
132 * Ghostty 1.3 over eleven fonts at 9 to 18 pt (math `x` against text `x`):
133 * with the math on the text's own baseline its ink ended 0.55 to 0.9 px
134 * lower in kitty and 0.25 to 0.75 px lower in Ghostty, growing with the size.
135 * The math's baseline goes that much higher, to the nearest pixel.
136 */
137export const MATH_INK_DEPTH = { kitty: 0.04, ghostty: 0.026 } as const
138
139/**
140 * What joins the words of an inline preview: a no-break space, one cell that
141 * the engine's word wrap (Bun.wrapAnsi, which splits at U+0020 alone) never
142 * breaks at, and that marked and the engine draw as it is. Measured live.
143 */
144export const INLINE_JOIN = '\u00a0'
145
146/**
147 * What pads an inline preview to its image's width: U+2800, a blank cell that
148 * neither breaks a line nor counts as whitespace, so emphasis closing right
149 * after a formula (`*… $x$*`) still closes, and no trim takes it.
150 */
151export const INLINE_PAD = BLANK_CELL
152
153/**
154 * What marks an inline preview that holds neither a join nor a pad (its
155 * Unicode as wide as its image, one word): U+034F, a combining mark of no
156 * width that the engine, kitty and Ghostty draw as nothing, so the preview is
157 * found again by its content without a blank cell after the image.
158 */
159export const INLINE_MARK = '\u034f'
160
161/**
162 * The ASCII inline Unicode escapes, padded or not (markdown could read it as
163 * syntax: `_(…)` and `B*A*` as emphasis, a `|` as a table cell's end, `[a](b)`
164 * as a link). Each is one that makes the engine read the text as markdown at
165 * all, so the escape is always read as one, never drawn as a backslash; a `]`
166 * or a `<` is not (in a text the engine draws as written its escape would
167 * show), and needs none: `[` is escaped, so no link opens, and a `<` that
168 * could open a tag is kept apart from the letter after it (inlineUnicode).
169 */
170export const INLINE_SPECIALS = /[`*_[|~#>]/g
171
172/** A `<` the engine's markdown could read as the start of a tag. */
173const TAG_OPEN = /<[A-Za-z/!?]/
174
175/** Inline Unicode with its markdown syntax escaped (INLINE_SPECIALS). */
176export function escapeInline(text: string): string {
177 return text.replace(INLINE_SPECIALS, '\\$&')
178}
179
180/**
181 * An inline formula as the reader sees it where it stays text (no image: a
182 * terminal without images, a block the replay doesn't follow, math read back
183 * after `--resume`): one line of Unicode with TeX's spacing (a relation keeps
184 * its spaces, so a `<` never touches a letter), at any width (prose wraps),
185 * escaped; null where Unicode has no one-line form.
186 */
187export function inlineText(tex: string): string | null {
188 const unicode = previewInline(tex, undefined, { tight: false })
189 return unicode === null ? null : escapeInline(unicode)
190}
191
192/** The most cells of `not rendered: <reason>` a refused inline formula's note quotes. */
193const INLINE_NOTE_CELLS = 60
194
195/** An inline math segment: what the fallbacks below read. */
196type InlineMath = Pick<Extract<Segment, { kind: 'math' }>, 'tex' | 'raw' | 'delimiter'>
197
198/**
199 * An inline formula where it gets neither an image nor one line of Unicode
200 * (stress report F5): its source as a code span, where markdown reads none of
201 * it (no `\\` eaten, in a table cell neither), as the model wrote it (so a
202 * table cell's `\|` still reads as a pipe), and where MathJax refuses it an
203 * italic `not rendered: …` in parentheses after it, as a refused display formula has. Never
204 * the TeX as prose.
205 */
206export function inlineSource(math: InlineMath): string {
207 const open = math.delimiter === '$' ? 1 : 2
208 const source = math.raw.slice(open, math.raw.length - open).replace(/\s+/g, ' ').trim() || math.tex
209 const longest = Math.max(0, ...[...source.matchAll(/`+/g)].map(run => run[0].length))
210 const fence = '`'.repeat(longest + 1)
211 const code = fence + (/^`|`$/.test(source) ? ` ${source} ` : source) + fence
212 const reason = inlineTexError(math.tex)
213 return reason === undefined ? code : `${code} (*${escapeMarkdown(oneLine(NOT_RENDERED + reason, INLINE_NOTE_CELLS))}*)`
214}
215
216/** An inline formula as it is written where it gets no image: its one-line Unicode, or its source (inlineSource). */
217export function inlineFallback(math: InlineMath): string {
218 return inlineText(math.tex) ?? inlineSource(math)
219}
220
221/** Why MathJax refuses an inline formula, or undefined when it doesn't. */
222function inlineTexError(tex: string): string | undefined {
223 if (tex.length > MAX_TEX_LENGTH) return `formula longer than ${MAX_TEX_LENGTH} characters`
224 try {
225 typeset(tex, { display: false })
226 return undefined
227 } catch (error) {
228 if (error instanceof TexError) return reasonOf(error)
229 throw error
230 }
231}
232
233/**
234 * Rows from the top of a prose piece's drawing (a `next()` result) to its first
235 * row of text: the one-row top margin every AssistantMessage drawing brings.
236 */
237export const PIECE_TOP = 1
238
239// ─── Engine-independent settings ─────────────────────────────────────────────
240
241/** Cells assumed when the cell probe fails, at FALLBACK_PIXEL_SCALE resolution (the terminal scales the image to the cells). */
242export const FALLBACK_CELL = { cellWidth: 10, cellHeight: 20 } as const
243export const FALLBACK_PIXEL_SCALE = 2
244/** Terminal columns assumed until the probe or a render says. */
245export const FALLBACK_COLUMNS = 80
246/** How long a probe command may run. */
247export const PROBE_TIMEOUT_MS = 2000
248/**
249 * Wait after the last change of width before probing the cell size once more
250 * (a font zoom changes both); every change restarts it, so a drag ends with
251 * the final size.
252 */
253export const RESIZE_SETTLE_MS = 400
254/**
255 * A drawing with images probes the cell size when the last probe is older
256 * than this: a move to a monitor of another scale changes the cells' pixels
257 * and may keep the width, so no render says so, but the next reply does. (A
258 * font zoom changes the width in cells, which every render sees.)
259 */
260export const CELL_REFRESH_MS = 10_000
261/** How often the cell size is probed while images are on screen and nothing draws (one short process): a monitor move with no reply after it. */
262export const CELL_IDLE_MS = 60_000
263/** Formula images kept drawn, by formula and geometry (a long reply holds hundreds, inline ones included). */
264export const IMAGE_LIMIT = 1024
265/**
266 * Streamed blocks whose previews are kept for their landed drawings, each
267 * block its own (fuzz FUZZ-14: one store for the session, an early block
268 * redrawn after 512 later previews lost its images). A block past this is
269 * drawn as it streamed.
270 */
271export const BLOCK_LIMIT = 1024
272/** The newest blocks, looked through by their text for a landed block that no row id links to its stream (after a hot reload). */
273export const RECENT_BLOCKS = 8
274/** Appended rows remembered until a stream they start shows up (a reply whose only flush comes after its row). */
275export const PENDING_ROWS = 8
276/** Streaming messages tracked at once (a message that never sees `final` is dropped past this). */
277export const STREAM_LIMIT = 64
278/** Also instruct the model where the terminal shows no images (math then reads as Unicode). */
279export const INSTRUCT_WITHOUT_IMAGES = true
280
281export const SECTION_ID = 'kittex:math'
282
283/** The instructions to the model (design notes, "Instructions to the model"). */
284export const MATH_INSTRUCTIONS =
285 'Math in your replies is typeset in this terminal. Write every formula and mathematical symbol in LaTeX: ' +
286 'inline as `$...$` with no space just inside the dollars, and display math as `$$` on a line of its own, ' +
287 'the formula, then `$$` on a line of its own, with a blank line before and after. Inline math is shown as ' +
288 'Unicode text, so put tall formulas (stacked fractions, sums with limits, matrices) in display math. Use ' +
289 '`aligned`, `cases`, `pmatrix` and similar inside `$$`. Never put math in backticks or code blocks, and ' +
290 "don't write α, x² or ≤ in place of LaTeX. Put dollar amounts and shell variables in code spans, or write a " +
291 'literal dollar sign as `\\$`. In an answer to a side question (/btw), which is shown where math isn\'t ' +
292 'typeset, write math as Unicode text instead. This overrides the plain CommonMark note for math only.'
293
294/**
295 * The texts kittex may change once landed: math delimiters (a reply that never
296 * streamed through MessageDisplay, or one read back after `--resume`), a
297 * display preview's pad, an inline preview's join or pad, or a refused
298 * formula's source block. The AssistantMessage
299 * hook is registered with this as its `props.text` matcher (split as below on
300 * the terminal), so every other block is drawn by the engine without a round
301 * trip through kittex.
302 */
303export const LANDED_PATTERN = /\$|\\[([]|\\begin\{| |```latex|\u00a0|\u2800|\u034f|(?:^|\n)[ \t>]*(?:`{3,}|~{3,})[ \t]*[Mm][Aa][Tt][Hh](?![^\s])/
304
305/**
306 * What only kittex writes into a streamed block: a display preview's pad, an
307 * inline preview's join, pad or mark, a refused formula's note. A block
308 * holding one is one kittex streamed, whatever else it holds (a reply's
309 * currency, `$` in code, a `$` a preview shows).
310 */
311const PREVIEW_MARK = String.raw` |\u00a0|\u2800|\u034f|\*${NOT_RENDERED}`
312
313/**
314 * Inline math as written, by the scanner's dollar rules (scan/inline.ts): a
315 * single unescaped `$` not followed by whitespace, closed by the next `$` if
316 * that one is single, not after whitespace and not before a digit, within a
317 * paragraph and no code span. So a reply's currency (`$100`, `\$100`, `$25,000
318 * at 6.5% and $25,000`, `$5-$10`) is no math.
319 */
320const INLINE_DOLLARS = String.raw`(?<![\\$\`])\$(?![\s$])(?:[^$\`\\\n]|\\[^\n]|\n(?![ \t]*\n))*(?<![\s\\])\$(?![$\d])`
321
322/** Display math as written: `$$` (not escaped, not a longer run) up to the next `$$`. */
323const DISPLAY_DOLLARS = String.raw`(?<![\\$])\$\$(?!\$)[\s\S]*?\$\$`
324
325/**
326 * A ```math (or ~~~math) fence's opening line, as the scanner reads one
327 * (scan/blocks.ts: the info string's first word, any case): its body is
328 * display math (stress report F15, fuzz FUZZ-8).
329 */
330const MATH_FENCE = String.raw`(?:^|\n)[ \t>]*(?:\`{3,}|~{3,})[ \t]*[Mm][Aa][Tt][Hh](?![^\s])`
331
332/**
333 * What follows a backslash that the engine's markdown reads as an escape and
334 * drops (`\{` drawn `{`, `\,` drawn `,`): ASCII punctuation, and a line break
335 * (a hard break). `$` aside: an escaped dollar is kittex's scanner's too, and
336 * stays one.
337 */
338const ESCAPED = String.raw`(?:[!-#%-\/:-@\[-\`{-~]|\r?\n)`
339const ESCAPING = new RegExp(String.raw`\\(?=${ESCAPED})`, 'g')
340
341/**
342 * `$$…$$` with other text on its first or last line: inline math (a display
343 * formula has its lines to itself), within a paragraph (no blank line).
344 */
345const INLINE_DISPLAY_DOLLARS = (() => {
346 const one = String.raw`(?<![\\$])\$\$(?!\$)(?:(?!\$\$)(?!\n[ \t]*\n)[\s\S])*?\$\$`
347 return String.raw`[^\s>][^\n]*?${one}|${one}[ \t]*[^\s]`
348})()
349
350/**
351 * LaTeX as written: a reply that never streamed through MessageDisplay, or one
352 * read back after `--resume`. Never a block holding a preview mark (one kittex
353 * streamed: its `\[` is a bracket its previews escaped, `𝔼\[x\]`, and a `$`
354 * left in it is code or a preview's), and never for a currency dollar alone.
355 */
356export const SOURCE_PATTERN = sourcePattern({})
357
358/** A ```latex, ```tex or ```tikz fence's opening line. */
359const DIAGRAM_FENCE = String.raw`(?:^|\n)[ \t>]*(?:\`{3,}|~{3,})[ \t]*(?:latex|tex|tikz)\b`
360
361/**
362 * SOURCE_PATTERN for these options (and `diagrams`: where the local TeX may draw them): only the math of a kind kittex changes
363 * (a block whose math is all left raw stays the engine's, which would
364 * otherwise draw nothing until kittex answered). Math left raw counts where a
365 * backslash in it needs its mark (rawSource), as math read back after
366 * --resume does. With display math raw, `$$…$$` counts only where it is
367 * inline math.
368 */
369export function sourcePattern(math: Partial<MathOptions>, diagrams = false): RegExp {
370 const inline = math.inline !== 'raw'
371 const block = math.block !== 'raw'
372 const needs = String.raw`\\(?=${ESCAPED})`
373 const kinds = [
374 ...(inline ? [INLINE_DOLLARS] : [String.raw`(?<![\\$])\$(?![\s$])[^$\n]*?${needs}`]),
375 String.raw`\\\(`,
376 ...(block
377 ? [DISPLAY_DOLLARS, MATH_FENCE, String.raw`\\\[`, String.raw`\\begin\{`]
378 : [String.raw`(?<![\\$])\$\$(?:(?!\$\$)[\s\S])*?${needs}`, String.raw`\\\[`, String.raw`\\begin\{(?:(?!\\end\{)[\s\S])*?${needs}`, ...(inline ? [INLINE_DISPLAY_DOLLARS] : [])]),
379 // A diagram for the local TeX: a ```latex, ```tex or ```tikz block (`diagrams`, with display math drawn).
380 ...(diagrams && block ? [DIAGRAM_FENCE] : []),
381 ]
382 return new RegExp(String.raw`^(?![\s\S]*(?:${PREVIEW_MARK}))[\s\S]*?(?:${kinds.join('|')})`)
383}
384
385/**
386 * A block as kittex streamed it: one holding a preview mark. The engine's own
387 * drawing of such a text is the streaming preview row for row, so it may
388 * stand in while kittex's drawing is on its way (the AssistantMessage hook,
389 * in the fullscreen layout). Never matches a text SOURCE_PATTERN matches.
390 */
391export const STREAMED_PATTERN = new RegExp(PREVIEW_MARK)
392
393// ─── Shared state ────────────────────────────────────────────────────────────
394
395/** What drawing needs to know about the terminal (kittex.env in `$.state`). */
396export type { KittexEnv }
397/** One preview written while streaming, and the TeX it stands for. */
398export type PreviewRecord = KittexPreview
399/** What kittex keeps of one streamed block (kittex.blocks): its previews, each where it was written. */
400export type StreamedBlock = KittexBlock
401
402/** The environment for a cell size, falling back to FALLBACK_CELL. */
403export function cellOrFallback(cell: CellSize | undefined): { cellWidth: number; cellHeight: number; measured: boolean } {
404 if (cell && cell.cellWidth >= 1 && cell.cellHeight >= 1 && Number.isFinite(cell.cellWidth) && Number.isFinite(cell.cellHeight)) {
405 return { cellWidth: Math.round(cell.cellWidth), cellHeight: Math.round(cell.cellHeight), measured: true }
406 }
407 return {
408 cellWidth: FALLBACK_CELL.cellWidth * FALLBACK_PIXEL_SCALE,
409 cellHeight: FALLBACK_CELL.cellHeight * FALLBACK_PIXEL_SCALE,
410 measured: false,
411 }
412}
413
414/** Cells across the reply column for a viewport this wide (`columns - 2`), 1 to 255: what an image spans. */
415export function replyColumns(columns: number): number {
416 return Math.max(1, Math.min(255, Math.floor(columns) - REPLY_INDENT))
417}
418
419/** Cells a preview's own text may take: two fewer than the reply column, so a pad leads every line and none reaches the edge. */
420export function previewColumns(maxColumns: number): number {
421 return Math.max(1, maxColumns - 2)
422}
423
424/**
425 * Where formulas are drawn for a viewport this wide: a display formula's
426 * image (and its preview) spans the width prose wraps at, the reply column or
427 * `maxProseWidth` when that is narrower (fuzz FUZZ-2: centred in the whole
428 * column, a preview's lines wrapped at the prose width while streaming).
429 */
430export function renderEnvFor(env: KittexEnv, columns = env.columns): RenderEnv {
431 const renderEnv: RenderEnv = { cellWidth: env.cellWidth, cellHeight: env.cellHeight, maxColumns: proseWidthFor(env, columns), emPx: env.emPx, ink: env.ink }
432 if (env.inkOver) renderEnv.inkOver = env.inkOver
433 if (env.inkOver && env.inkCurve) renderEnv.inkCurve = env.inkCurve
434 if (env.weight !== undefined) renderEnv.weight = env.weight
435 return renderEnv
436}
437
438/** Where inline formulas are drawn: one text row, the math on the font's baseline. */
439export function inlineEnvFor(env: KittexEnv, columns = env.columns): InlineEnv {
440 return { ...renderEnvFor(env, columns), baselinePx: mathBaseline(env) }
441}
442
443/** The text font's layout in the env's cells (textLayout), when its metrics are known. */
444function fontLayout(env: Pick<KittexEnv, 'kind' | 'cellWidth' | 'cellHeight' | 'cellAdjust' | 'kittyAdjust' | 'font'>) {
445 const { font } = env
446 if (!font) return undefined
447 const { sizePt, platform, ...metrics } = font
448 return textLayout(env.kind, metrics, { cellWidth: env.cellWidth, cellHeight: env.cellHeight }, {
449 ...(sizePt ? { sizePt } : {}),
450 ...(platform ? { platform } : {}),
451 ...(env.cellAdjust ? { adjust: env.cellAdjust } : {}),
452 ...(env.kittyAdjust ? { kittyAdjust: env.kittyAdjust } : {}),
453 })
454}
455
456/**
457 * Where an inline formula's baseline goes, in pixels from the top of its
458 * cell: the text font's own baseline as the terminal sets it (from the font's
459 * metrics, adjustments included), raised by the math's ink depth
460 * (MATH_INK_DEPTH); before the font is read, TEXT_BASELINE of the cell.
461 */
462export function mathBaseline(env: Pick<KittexEnv, 'kind' | 'cellWidth' | 'cellHeight' | 'cellAdjust' | 'kittyAdjust' | 'font' | 'emPx'>): number {
463 const layout = fontLayout(env)
464 if (!layout) return textBaseline(env.cellHeight, TEXT_BASELINE, env.cellAdjust)
465 const depth = env.kind === 'kitty' || env.kind === 'ghostty' ? MATH_INK_DEPTH[env.kind] : MATH_INK_DEPTH.ghostty
466 return Math.max(1, layout.baselinePx - Math.round(depth * env.emPx))
467}
468
469/**
470 * Pixels per em of the math in these cells: its x-height against the text
471 * font's (mathEmPx) when the font's metrics are known, else from the cell's
472 * width (emPxForCell, the terminal's adjustments undone).
473 */
474export function mathEmPxFor(cell: Pick<CellSize, 'cellWidth' | 'cellHeight'>, env: Pick<KittexEnv, 'kind' | 'cellAdjust' | 'kittyAdjust' | 'font'>): number {
475 const own = fontCell(cell, env.cellAdjust)
476 const layout = fontLayout({ ...env, cellWidth: cell.cellWidth, cellHeight: cell.cellHeight })
477 return layout ? mathEmPx(layout, own) : emPxForCell(own)
478}
479
480/**
481 * The cells a display preview (and its image) takes in a blockquote `depth`
482 * deep: the engine draws a quote's text two cells in (a bar and a space),
483 * at most maxProseWidth wide.
484 */
485export function quoteColumns(env: KittexEnv, depth: number, columns = env.columns): number {
486 return Math.max(1, proseWidthFor(env, columns) - 2 * depth)
487}
488
489/**
490 * The cells a recorded display preview's image spans: the prose width (the
491 * reply column, or maxProseWidth), a quote's text, or a list item's text (see
492 * PreviewRecord's `quote`, `indent`).
493 */
494export function displayColumns(record: PreviewRecord, env: KittexEnv, columns = env.columns): number {
495 if (record.quote !== undefined) return quoteColumns(env, record.quote, columns)
496 if (record.indent !== undefined) return Math.max(1, proseWidthFor(env, columns) - record.indent)
497 return proseWidthFor(env, columns)
498}
499
500/**
501 * How the engine draws links in a terminal with these variables (as OSC 8
502 * hyperlinks, or as text with the url beside it), for KittexEnv; nothing when
503 * that isn't known, and links then keep their paragraph's math Unicode.
504 */
505export function linkEnv(variables: Readonly<Record<string, string | undefined>>): Pick<KittexEnv, 'hyperlinks'> {
506 const hyperlinks = engineHyperlinks(variables)
507 return hyperlinks === undefined ? {} : { hyperlinks }
508}
509
510/** The width reply prose wraps at: the reply column, or `maxProseWidth` when that is narrower. */
511export function proseWidthFor(env: KittexEnv, columns = env.columns): number {
512 const reply = replyColumns(columns)
513 return env.maxProseWidth !== undefined && env.maxProseWidth >= 1 ? Math.min(reply, Math.floor(env.maxProseWidth)) : reply
514}
515
516// ─── The options ─────────────────────────────────────────────────────────────
517
518/**
519 * How one kind of math is shown (the `block` and `inline` options): `image`,
520 * typeset images where the terminal draws them (Unicode elsewhere);
521 * `unicode`, Unicode text, never an image; `raw`, the LaTeX as Claude wrote it.
522 */
523export type MathMode = 'image' | 'unicode' | 'raw'
524
525export const MATH_MODES: readonly MathMode[] = ['image', 'unicode', 'raw']
526
527/** The two options: display math (`$$…$$`, `\[…\]`, bare environments, ```math fences) and inline math (`$…$`, `\(…\)`). */
528export interface MathOptions {
529 block: MathMode
530 inline: MathMode
531}
532
533/** The options as register receives them; a value that is no mode (unset, or stored by an older manifest) is `image`, the default. */
534export function mathOptions(options: Readonly<Record<string, unknown>>): MathOptions {
535 const mode = (value: unknown): MathMode => (MATH_MODES.includes(value as MathMode) ? (value as MathMode) : 'image')
536 return { block: mode(options.block), inline: mode(options.inline) }
537}
538
539/** What a streamed message is rewritten for, in this terminal with these options. */
540export function streamEnvFor(env: KittexEnv, math: MathOptions): StreamEnv {
541 return { ...env, inline: math.inline === 'image' && env.images, math }
542}
543
544/** What a streamed message is rewritten for: the terminal, whether inline math becomes images, and the options. */
545export type StreamEnv = KittexEnv & {
546 /** Inline math is drawn as images (the `inline` option, where the terminal draws images). */
547 inline?: boolean
548 /**
549 * The options (each kind `image` when absent): a kind set to `raw` is
550 * written as Claude wrote it, display math set to `unicode` as where the
551 * terminal draws no images.
552 */
553 math?: Partial<MathOptions>
554 /**
555 * Where the local LaTeX draws (the `latex` option, TeX found, images
556 * drawn): diagrams and display math MathJax refuses (`block`), inline math
557 * it refuses (`inline`). Absent: neither.
558 */
559 tex?: TexUse
560}
561
562/** Whether a math segment is of a kind these options leave as written. */
563function leftRaw(segment: { display: boolean }, math: Partial<MathOptions> | undefined): boolean {
564 return (segment.display ? math?.block : math?.inline) === 'raw'
565}
566
567
568/**
569 * Math left raw as the engine draws it as written: INLINE_MARK (no width,
570 * drawn as nothing) after every backslash its markdown would read as an escape,
571 * so the backslash is no escape and stays, whether the engine reads the text
572 * as markdown or draws it plain (it does either, part by part, while a reply
573 * streams). A ```math fence is code, where markdown reads no escapes; in a
574 * GFM table row `\|` is the row's escape of a pipe and stays one. Idempotent:
575 * the landing reads the streamed text again.
576 */
577export function rawSource(math: Pick<MathSegment, 'raw' | 'tex' | 'display' | 'delimiter'>): string {
578 if (math.delimiter === 'fence') return math.raw
579 const tableRow = !math.display && math.raw.includes('\\|') && !math.tex.includes('\\|')
580 return math.raw.replace(ESCAPING, (backslash: string, at: number) => (tableRow && math.raw[at + 1] === '|' ? backslash : backslash + INLINE_MARK))
581}
582
583/**
584 * An inline formula as it is written while it streams, when it will be drawn
585 * as an image once landed: its one-line Unicode, words joined by INLINE_JOIN,
586 * padded with INLINE_PAD to exactly the image's columns (the image is widened
587 * instead when the Unicode is wider; one that holds neither a join nor a pad
588 * ends with INLINE_MARK: a preview is found again by its content), markdown
589 * syntax escaped.
590 */
591export interface InlinePreview {
592 markdown: string
593 tex: string
594 columns: number
595}
596
597/**
598 * The preview of an inline formula drawn as an image, or null when it stays
599 * plain Unicode: too tall for a row (see measureInline), refused by MathJax,
600 * wider than a row of prose (`rowWidth`), no one-line Unicode, a character whose
601 * width isn't certain, or a backslash.
602 */
603export function inlinePreview(tex: string, env: InlineEnv, rowWidth = env.maxColumns): InlinePreview | null {
604 // Tight, so the text is no wider than the image; spaced where tight would put a `<` against a letter.
605 let unicode = previewInline(tex, env.maxColumns)?.trim()
606 if (unicode && TAG_OPEN.test(unicode)) unicode = previewInline(tex, env.maxColumns, { tight: false })?.trim()
607 if (unicode && TAG_OPEN.test(unicode)) return null
608 if (unicode) unicode = ungroupScripts(unicode, tex)
609 if (!unicode || /[\\\s]/.test(unicode.replaceAll(' ', ''))) return null
610 const width = textWidth(unicode)
611 if (width < 1) return null
612 const box = measureInline(tex, env)
613 if (!box) return null
614 const columns = Math.max(box.columns, width)
615 // The image is drawn on one row: wider than prose wraps, it could never be placed.
616 if (columns > Math.min(255, env.maxColumns, rowWidth)) return null
617 const body = escapeInline(unicode.replaceAll(' ', INLINE_JOIN))
618 const mark = columns === width && !unicode.includes(' ') ? INLINE_MARK : ''
619 return { markdown: body + mark + INLINE_PAD.repeat(columns - width), tex, columns }
620}
621
622/**
623 * A script written as letters or digits with no Unicode script form, which
624 * the Unicode renderer groups as `_(…)` or `^(…)`, when nothing that could
625 * read as part of it (a letter, a digit, a mark, another script) follows.
626 * Script characters (`^(xˣ)`, a script of its own) are no such letters.
627 */
628const GROUPED_SCRIPT = /([_^])\(((?:(?![\u00b2\u00b3\u00b9\u02b0-\u02ff\u1d2c-\u1dbf\u2070-\u209f\u2c7c\u2c7d])[\p{L}\p{N}])+)\)(?![\p{L}\p{N}\p{M}_^])/gu
629
630/**
631 * An inline preview's scripts without the parentheses the Unicode renderer
632 * groups them in (`π_(ref)` as `π_ref`, `D_(KL)` as `D_KL`), where they
633 * only take cells: the preview sets its image's slot, and every cell it is
634 * wider than the image is blank around the formula once it lands. Kept where
635 * the formula has parentheses of its own (one could be the group's).
636 */
637export function ungroupScripts(unicode: string, tex: string): string {
638 return /\(|\\lparen/.test(tex) ? unicode : unicode.replace(GROUPED_SCRIPT, '$1$2')
639}
640
641/** What may open a phrase right before a formula: an opening bracket, a quote or a dash (`non-$x$`). */
642const OPENS = /^[\p{Ps}\p{Pi}\p{Pd}"'`]$/u
643/** What may close one right after it: a closing bracket or quote, punctuation (`,`, `:`, `.`) or a dash (`$x$-axis`). */
644const CLOSES = /^[\p{Pe}\p{Pf}\p{Po}\p{Pd}]$/u
645
646/**
647 * Where an inline formula's ink goes in a slot wider than it, from the
648 * characters drawn in the cells right before and after the slot (undefined at
649 * the start or end of the row's text): flush with the text column at its
650 * start (`start`) and against the slot's right edge at its end (`end`, unless
651 * a bracket opens right before it); against the right edge when a space is
652 * before it and punctuation, a dash or a closing bracket after it (`y_l,`,
653 * `x-axis`: the blank joins the space before), against its left edge in the
654 * mirror case (`(x_k `), centred otherwise (`(y_w)`, `a x b`).
655 */
656export function inkPlaceBeside(before: string | undefined, after: string | undefined): InkPlace {
657 if (before === undefined) return 'start'
658 if (after === undefined) return OPENS.test(before) ? 'start' : 'end'
659 const blankBefore = /^\s$/u.test(before)
660 const blankAfter = /^\s$/u.test(after)
661 if (blankBefore && !blankAfter && CLOSES.test(after)) return 'end'
662 if (blankAfter && !blankBefore && OPENS.test(before)) return 'start'
663 return 'center'
664}
665
666/**
667 * What a row may hold before its text: indentation, quote bars, and a list
668 * item's marker (`-`, `•`, `1.`, `a.`, `iv.`) with the space after it.
669 */
670const TEXT_START = /^[\s▎]*(?:(?:[-*+•◦▪‣]|\d{1,9}[.)]|[a-z]{1,6}[.)])\s+)?$/u
671
672/**
673 * The characters drawn in the cell before column `col` of a row and in the
674 * cell after the `columns` from it (zero-width marks skipped; undefined past
675 * the ends of the row's text: before its first character, a list marker or
676 * quote bar not counted, and after its last).
677 */
678export function cellsBeside(line: string, col: number, columns: number): [string | undefined, string | undefined] {
679 const chars = [...line]
680 let at = 0
681 let before: string | undefined
682 let prefix = ''
683 for (const [i, char] of chars.entries()) {
684 const cells = cellsOf(char)
685 if (cells === 0) continue
686 if (at + cells <= col) {
687 before = char
688 prefix += char
689 } else if (at >= col + columns) {
690 // Blanks to the row's end are past its text.
691 const after = /^\s*$/u.test(chars.slice(i).join('')) ? undefined : char
692 return [TEXT_START.test(prefix) ? undefined : before, after]
693 }
694 at += cells
695 }
696 return [TEXT_START.test(prefix) ? undefined : before, undefined]
697}
698
699// ─── Markdown forms ──────────────────────────────────────────────────────────
700
701/** Terminal cells a line takes, counting common wide characters as two. */
702export function cellsOf(line: string): number {
703 let cells = 0
704 for (const char of line) {
705 const code = char.codePointAt(0)!
706 if (code >= 0x300 && code <= 0x36f) continue
707 cells += isWide(code) ? 2 : 1
708 }
709 return cells
710}
711
712function isWide(code: number): boolean {
713 return (
714 (code >= 0x1100 && code <= 0x115f) ||
715 (code >= 0x2e80 && code <= 0xa4cf) ||
716 (code >= 0xac00 && code <= 0xd7a3) ||
717 (code >= 0xf900 && code <= 0xfaff) ||
718 (code >= 0xfe30 && code <= 0xfe4f) ||
719 (code >= 0xff00 && code <= 0xff60) ||
720 (code >= 0xffe0 && code <= 0xffe6) ||
721 (code >= 0x1f300 && code <= 0x1faff)
722 )
723}
724
725/** Text with every character markdown could read as syntax escaped. */
726export function escapeMarkdown(text: string): string {
727 return text.replace(MARKDOWN_SPECIALS, '\\$&')
728}
729
730/**
731 * Preview lines as markdown that the engine draws one row per line, each
732 * exactly as given: leading spaces become pads (at least one, so no line starts
733 * with markdown syntax), the lines are centred in `maxColumns`, trailing
734 * spaces are dropped, a blank line keeps its pads and ends in BLANK_CELL,
735 * and syntax is escaped.
736 */
737export function previewMarkdownLines(lines: readonly string[], maxColumns: number): string[] {
738 const width = Math.max(0, ...lines.map(line => cellsOf(line.replace(/\s+$/, ''))))
739 const centre = PREVIEW_CENTERED ? Math.floor((maxColumns - width) / 2) : 0
740 const lead = Math.max(1, centre)
741 return lines.map(line => {
742 const body = line.replace(/\s+$/, '')
743 const content = body.trimStart()
744 if (content === '') return PREVIEW_PAD.repeat(lead) + BLANK_CELL
745 return PREVIEW_PAD.repeat(lead + body.length - content.length) + escapeMarkdown(content)
746 })
747}
748
749/**
750 * A display preview's markdown lines: exactly `rows` lines when given (the
751 * rows its image takes). Where no Unicode form fits the width (stress report
752 * F4: a wide formula in a narrow window), its one-line form wrapped at the
753 * width, cut with `…` past the rows; the TeX source (cut likewise) only where
754 * Unicode has no form at all.
755 */
756export function displayPreviewLines(tex: string, maxColumns: number, rows?: number): string[] | null {
757 const lines = previewDisplay(tex, { maxColumns: previewColumns(maxColumns) }, rows)
758 if (lines && lines.length > 0) return previewMarkdownLines(spreadRows(lines, tex), maxColumns)
759 if (rows !== undefined && rows < 1) return null
760 const flat = previewInline(tex, undefined, { tight: false })
761 if (flat === null && rows === undefined) return null
762 const width = previewColumns(maxColumns)
763 let text = flat === null ? [oneLine(tex, width)] : wrapCells(flat, width)
764 if (rows === undefined) return previewMarkdownLines(text, maxColumns)
765 if (text.length > rows) text = [...text.slice(0, rows - 1), oneLine(text.slice(rows - 1).join(' '), width)]
766 const above = Math.floor((rows - text.length) / 2)
767 const padded = [...Array<string>(above).fill(''), ...text, ...Array<string>(rows - text.length - above).fill('')]
768 return previewMarkdownLines(padded, maxColumns)
769}
770
771/** Text broken into lines at most `width` cells wide: at spaces, and inside a word only where it is wider than a line. */
772export function wrapCells(text: string, width: number): string[] {
773 const lines: string[] = []
774 let line = ''
775 for (const word of text.split(' ').filter(word => word !== '')) {
776 const joined = line === '' ? word : `${line} ${word}`
777 if (cellsOf(joined) <= width) {
778 line = joined
779 continue
780 }
781 if (line !== '') lines.push(line)
782 line = ''
783 for (const char of word) {
784 if (line !== '' && cellsOf(line + char) > width) {
785 lines.push(line)
786 line = ''
787 }
788 line += char
789 }
790 }
791 if (line !== '' || lines.length === 0) lines.push(line)
792 return lines
793}
794
795/**
796 * A preview with a line per row of a tall environment (`aligned`, `gathered`,
797 * a derivation), padded to its image's rows with more blank rows than it has
798 * lines, spread over the image's height: each line about where its row of the
799 * image will be, instead of a block in the middle with blank slabs above and
800 * below (stress report F18). Anything else is returned as it is.
801 */
802export function spreadRows(lines: readonly string[], tex: string): readonly string[] {
803 const blank = (line: string) => line.trim() === ''
804 const first = lines.findIndex(line => !blank(line))
805 const last = lines.findLastIndex(line => !blank(line))
806 if (first < 0) return lines
807 const content = lines.slice(first, last + 1)
808 const rows = lines.length
809 if (content.length < 3 || content.some(blank) || rows - content.length < content.length) return lines
810 // One line per row of the environment: as many lines as the formula has rows (its `\\`s, none nested).
811 const body = /^\s*\\begin\{(aligned|align\*?|gathered|gather\*?|split|eqnarray\*?|multline\*?)\}([\s\S]*)\\end\{\1\}\s*$/.exec(tex)?.[2]
812 if (body === undefined || /\\begin\{/.test(body)) return lines
813 if (body.replace(/\\\\\s*$/, '').split('\\\\').length !== content.length) return lines
814 const width = Math.max(...content.map(line => line.length))
815 const out = Array.from({ length: rows }, () => ' '.repeat(width))
816 for (const [i, line] of content.entries()) out[Math.floor(((i + 0.5) * rows) / content.length)] = line
817 return out
818}
819
820/** Text on one line (whitespace runs collapsed), cut with `…` to `max` cells. */
821export function oneLine(text: string, max: number): string {
822 const flat = text.replace(/\s+/g, ' ').trim()
823 if (cellsOf(flat) <= max) return flat
824 let out = ''
825 for (const char of flat) {
826 if (cellsOf(out + char) > max - 1) break
827 out += char
828 }
829 return out + '…'
830}
831
832/** The line drawn under a refused formula, as plain text. */
833export function notRenderedText(reason: string, maxColumns: number): string {
834 return oneLine(NOT_RENDERED + reason, previewColumns(maxColumns))
835}
836
837/**
838 * A formula MathJax refused: its source in a `latex` block, a blank line, and
839 * `not rendered: <reason>` (italic while streaming, drawn dim once landed).
840 * The note is a paragraph of its own: a fence followed directly by text takes
841 * an extra row while streaming.
842 */
843export function refusedMarkdownLines(tex: string, reason: string, maxColumns: number): string[] {
844 return [...sourceMarkdownLines(tex), '', '*' + escapeMarkdown(notRenderedText(reason, maxColumns)) + '*']
845}
846
847/** A formula's source in a `latex` block: where Unicode can't write it and no image is drawn. */
848export function sourceMarkdownLines(tex: string): string[] {
849 const longest = Math.max(0, ...[...tex.matchAll(/`+/g)].map(run => run[0].length))
850 const fence = '`'.repeat(Math.max(3, longest + 1))
851 return [fence + 'latex', ...tex.split('\n'), fence]
852}
853
854function reasonOf(error: TexError): string {
855 return error.message || 'TeX error'
856}
857
858/** A line's start that only opens blockquotes (`> `, `>> `, `> > `). */
859const QUOTE_LEAD = /^[ \t]{0,3}(?:>[ \t]?)+$/
860
861/**
862 * Where MarkdownWriter.block puts a block written after `written`: what it
863 * writes before the block's first line (a blank line, or in a quote a `>`
864 * line, unless one is there; a paragraph break after text), and the
865 * indentation (or blockquote prefix, `quote`) of its other lines. `started`:
866 * something other than whitespace was written.
867 */
868export function blockOpening(written: string, started = /\S/.test(written)): { before: string; indent: string; quote: string } {
869 const start = written.lastIndexOf('\n') + 1
870 const lead = written.slice(start)
871 const above = written.slice(0, start)
872 if (/^[ \t]*$/.test(lead)) return { before: started && !/\n[ \t]*\n$/.test(above) ? '\n' + lead : '', indent: lead, quote: '' }
873 // A paragraph of its own inside the quote: a `>` line above it unless one is there.
874 if (QUOTE_LEAD.test(lead)) return { before: started && !/(?:^|\n)[ \t]*(?:>[ \t]*)+\n$/.test(above) ? '\n' + lead : '', indent: lead, quote: lead }
875 return { before: '\n\n', indent: '', quote: '' }
876}
877
878/**
879 * Builds markdown out of source text and blocks. A block is a paragraph of its
880 * own: a blank line before and after it, each line at the indentation of the
881 * line it starts on (so it stays in its list item). A block always ends with a
882 * newline, as a streamed flush does.
883 */
884export class MarkdownWriter {
885 out = ''
886 /**
887 * What earlier takes wrote, all of it: the part being written is read from
888 * where it starts (a long list's outer items included), as the landing reads it.
889 */
890 private tail = ''
891 /** Something other than whitespace was written. */
892 private started = false
893 /** A block was the last thing written: the next text must leave a blank line. */
894 private afterBlock = false
895 /** The blockquote prefix the last block was written under ('' outside a quote). */
896 private quote = ''
897
898 text(text: string): void {
899 if (text === '') return
900 const written = this.prepare(text)
901 this.afterBlock = false
902 if (/\S/.test(text)) this.started = true
903 this.out += written
904 }
905
906 /**
907 * What text() writes for `text`: the text, after the blank line (in a quote,
908 * the `>` line) a block right before it needs. Only its start may differ.
909 */
910 prepare(text: string): string {
911 if (text === '' || !this.afterBlock) return text
912 if (this.quote !== '') {
913 // In a quote the blank line after a block is a `>` line: a bare one would end the quote.
914 if (/^[ \t]*\r?\n[ \t]*(?:>[ \t]*)+\r?\n/.test(text)) return text.replace(/^[ \t]*\r?\n/, '')
915 // One that opens with such a line already has it (a flush starting right after the block's closing line).
916 if (/^[ \t]*(?:>[ \t]*)+\r?\n/.test(text)) return text
917 return this.quote.trimEnd() + (/^[ \t]*\r?\n/.test(text) ? '' : '\n') + text
918 }
919 return /^[ \t]*\r?\n/.test(text) ? text : '\n' + text
920 }
921
922 /**
923 * Writes a block and returns it as written (lines after the first indented,
924 * or under the blockquote prefix the line it starts on opens with).
925 */
926 block(lines: readonly string[]): string {
927 const opening = blockOpening(this.tail + this.out, this.started)
928 this.quote = opening.quote
929 const block = lines.join('\n' + opening.indent)
930 this.out += opening.before + block + '\n'
931 this.started = true
932 this.afterBlock = true
933 return block
934 }
935
936 /** How many blockquotes deep the line being written is (its `>` prefix so far; 0 outside a quote). */
937 quoteDepth(): number {
938 const written = this.tail + this.out
939 const lead = written.slice(written.lastIndexOf('\n') + 1)
940 return QUOTE_LEAD.test(lead) ? (lead.match(/>/g) ?? []).length : 0
941 }
942
943 /** Everything written so far, earlier takes included. */
944 recent(): string {
945 return this.tail + this.out
946 }
947
948 /** Takes the text written so far (one flush, or one prose piece). */
949 take(): string {
950 const out = this.out
951 this.tail += out
952 this.out = ''
953 return out
954 }
955}
956
957// ─── Streaming (MessageDisplay) ──────────────────────────────────────────────
958
959export interface StreamRewrite {
960 text: string
961 records: PreviewRecord[]
962 /**
963 * Documents the stream waits for TeX to compile (TexBook): everything from
964 * the first segment that needs one is held, and comes out of resume() once
965 * their outcomes are known.
966 */
967 pending?: TexDocument[]
968}
969
970/**
971 * Where the landing will cut what is being streamed (planLanded): it draws a
972 * display image, or a refused formula's note, as an item of its own, and lays
973 * the text after it out as a piece of its own, part by part (blockParts). So
974 * the stream reads the part a formula is in as the landing reads it: from
975 * where its piece starts, all of it however long (a long list's outer items
976 * included). Offsets index everything the writer wrote.
977 */
978export class StreamPlan {
979 /** Where the piece being written starts: right after the last image's or note's block. */
980 piece = 0
981 /**
982 * Where the last block read in it so far starts (proseBlocks' block: what
983 * comes before it can't join a later part). blockParts reads the piece again
984 * from there, so a flush costs about as much as the block it ends in, not
985 * the whole reply.
986 */
987 anchor = 0
988
989 /** A new piece starts at `at`. */
990 cut(at: number): void {
991 this.piece = at
992 this.anchor = at
993 }
994}
995
996/**
997 * Rewrites the segments one flush completed: inline math as one line of
998 * Unicode (padded to its image's width, with a record, when inline images are
999 * on and the landing will place its image: see writeProse), a display formula
1000 * as its preview (exactly the rows its image will take when the terminal draws
1001 * images), a formula MathJax refuses as its source and a `not rendered` line.
1002 * `writer` carries the line state across the message's flushes, `plan` where
1003 * the landing cuts it.
1004 */
1005export function rewriteSegments(segments: readonly Segment[], env: StreamEnv, writer: MarkdownWriter, plan = new StreamPlan()): StreamRewrite {
1006 const records: PreviewRecord[] = []
1007 // Math its option leaves raw is text from here on (as written, its backslashes kept), as the landing reads it.
1008 segments = asWritten(segments, env.math)
1009 for (let k = 0; k < segments.length; ) {
1010 const segment = segments[k]!
1011 if (segment.kind === 'math' && segment.display) {
1012 if (segment.diagram !== undefined) writeDiagram(segment, env, writer, plan, records)
1013 else writeDisplay(segment, env, writer, plan, records)
1014 k++
1015 continue
1016 }
1017 let end = k + 1
1018 while (end < segments.length && !isDisplayMath(segments[end]!)) end++
1019 writeProse(segments.slice(k, end), segments[end], env, writer, plan, records)
1020 k = end
1021 }
1022 return { text: writer.take(), records }
1023}
1024
1025type MathSegment = Extract<Segment, { kind: 'math' }>
1026
1027/** The segments with the math of each kind its option leaves raw turned into text, as written (adjacent text joined). */
1028function asWritten(segments: readonly Segment[], math: Partial<MathOptions> | undefined): Segment[] {
1029 if (math?.block !== 'raw' && math?.inline !== 'raw') return [...segments]
1030 return joinText(segments.map(segment => (segment.kind === 'math' && leftRaw(segment, math) ? { kind: 'text', text: rawSource(segment), start: segment.start, end: segment.end } : segment)))
1031}
1032
1033function isDisplayMath(segment: Segment): boolean {
1034 return segment.kind === 'math' && segment.display
1035}
1036
1037/** How the engine draws links and the terminal emoji sequences, as the replays take it. */
1038function linkModeOf(env: KittexEnv): LinkMode {
1039 return { hyperlinks: env.hyperlinks, emojiSequences: env.emojiSequences }
1040}
1041
1042/**
1043 * Writes a display formula's preview. In a blockquote it stays inside it, as
1044 * wide as the quote's text (one quote deep, its image lies over it once
1045 * landed); in a list item likewise, as wide as the item's text, its image over
1046 * it in the item; anywhere else it is as wide as the reply column and lands as
1047 * an image of its own, after which a new piece starts (as after a refused
1048 * formula's note).
1049 */
1050function writeDisplay(segment: MathSegment, env: StreamEnv, writer: MarkdownWriter, plan: StreamPlan, records: PreviewRecord[]): void {
1051 const renderEnv = renderEnvFor(env)
1052 const { maxColumns } = renderEnv
1053 const quote = writer.quoteDepth()
1054 // Display math gets images where the terminal draws them, unless the `block` option says Unicode.
1055 const images = env.images && env.math?.block !== 'unicode'
1056 // The item's text is laid out from where its list starts, however far back that is.
1057 const indent = quote === 0 && images ? itemIndent(writer.recent().slice(plan.anchor), proseWidthFor(env), linkModeOf(env)) : undefined
1058 const width = quote > 0 ? quoteColumns(env, quote) : indent !== undefined ? Math.max(1, proseWidthFor(env) - indent) : maxColumns
1059 // In a quote at any depth (fuzz FUZZ-9), where the quote so far is one the landing lays out (else its image could never be placed).
1060 const drawn = images && (quote === 0 || placeable(writer.recent().slice(plan.anchor), proseWidthFor(env), env.columns, linkModeOf(env)))
1061 // The landing cuts its text only at a refused formula's note in the reply column (in a list item or a quote the
1062 // note stays in it: planLanded's nestedAt); a display formula's image lies over its preview in the text.
1063 const head = writer.recent().slice(plan.anchor)
1064 const nested = quote > 0 || nestedAt(head, head.length)
1065 let rows: number | undefined
1066 try {
1067 rows = drawn ? measureDisplay(segment.tex, { ...renderEnv, maxColumns: width }).rows : undefined
1068 } catch (error) {
1069 if (!(error instanceof TexError)) throw error
1070 // What MathJax refuses, the local TeX may draw.
1071 const viaTex = drawn && env.tex?.block ? texDisplay(segment.tex, { ...renderEnv, maxColumns: width }) : undefined
1072 if (viaTex && 'rows' in viaTex) return writeDisplayPreview(segment, renderEnv, viaTex.rows, width, quote, indent, writer, plan, records)
1073 const reason = viaTex?.error ?? reasonOf(error)
1074 const preview = writer.block(refusedMarkdownLines(segment.tex, reason, maxColumns))
1075 if (images) {
1076 records.push({ preview, tex: segment.tex, rows: 0, error: reason })
1077 if (!nested) plan.cut(writer.recent().length)
1078 }
1079 return
1080 }
1081 writeDisplayPreview(segment, renderEnv, rows, width, quote, indent, writer, plan, records)
1082}
1083
1084/** Writes a display formula's preview `rows` tall (none: as Unicode alone) and records it; its source where Unicode has no form. */
1085function writeDisplayPreview(
1086 segment: MathSegment,
1087 renderEnv: RenderEnv,
1088 rows: number | undefined,
1089 width: number,
1090 quote: number,
1091 indent: number | undefined,
1092 writer: MarkdownWriter,
1093 plan: StreamPlan,
1094 records: PreviewRecord[],
1095): void {
1096 const lines = displayPreviewLines(segment.tex, width, rows)
1097 if (lines) {
1098 const preview = writer.block(lines)
1099 if (rows === undefined) return
1100 records.push({ preview, tex: segment.tex, rows, ...(quote > 0 ? { quote } : {}), ...(indent !== undefined ? { indent } : {}) })
1101 return
1102 }
1103 const refused = texErrorOf(segment.tex, renderEnv)
1104 writer.block(refused ? refusedMarkdownLines(segment.tex, refused, renderEnv.maxColumns) : sourceMarkdownLines(segment.tex))
1105}
1106
1107/** Layouts of one run of prose a flush may take beyond the first before what is still undecided in it is written plain. */
1108const MAX_RELAYOUTS = 32
1109
1110/** An inline formula of a run being written: the forms it may take, padded first, and the one it has (`options.length`: plain Unicode). */
1111interface InlineChoice {
1112 segment: MathSegment
1113 options: InlinePreview[]
1114 choice: number
1115 /** Placed for good: a part's drawing up to a formula never depends on what comes after it (a table's does). */
1116 done: boolean
1117 /** Where it is in the text being laid out, as the forms chosen so far put it. */
1118 start: number
1119 end: number
1120}
1121
1122/**
1123 * Writes a run of prose (its text and inline math, up to a display formula).
1124 * Inline math is written padded to its image's width only where the landing
1125 * will draw the image over it: where its part, read as the landing reads it
1126 * (blockParts from where its piece starts) with the whole run in it, is laid
1127 * out with the preview whole on one row (the rule placeInline places images
1128 * by); else unpadded, its image in those cells, where that is placed
1129 * (narrowPreview); else as plain Unicode. Each part is laid out with all its
1130 * formulas at once; where one isn't placed it takes its next form and the
1131 * part is laid out again (its drawing up to a formula doesn't depend on what
1132 * follows, so those before it stay placed); past where the replay follows the
1133 * part, every formula is plain at once. `next`: the segment after the run.
1134 */
1135function writeProse(run: readonly Segment[], next: Segment | undefined, env: StreamEnv, writer: MarkdownWriter, plan: StreamPlan, records: PreviewRecord[]): void {
1136 const choices = new Map<number, InlineChoice>()
1137 if (env.inline && env.images && run.some(segment => segment.kind === 'math')) decideInline(run, next !== undefined && isDisplayMath(next), env, writer, plan, choices)
1138 let before = lastChar(writer.recent())
1139 for (const [at, segment] of run.entries()) {
1140 const choice = choices.get(at)
1141 const inline = choice?.options[choice.choice]
1142 const text = segment.kind === 'text' ? segment.text : inline ? inline.markdown : inlineFallback(segment)
1143 if (text === '') continue
1144 const written = writer.prepare(text)
1145 if (inline && segment.kind === 'math') {
1146 // Where its ink will go, as the source around it says (the landed rows decide; this draws it ahead).
1147 const after = run[at + 1] ?? next
1148 const first = after === undefined ? undefined : after.kind === 'text' ? [...after.text][0] : after.raw[0]
1149 const place = inkPlaceBeside((written.length > text.length ? lastChar(written.slice(0, -text.length)) : before)?.replace('\n', ' '), first)
1150 records.push({ preview: inline.markdown, tex: segment.tex, rows: 1, inline: true, columns: inline.columns, ...(place === 'center' ? {} : { place }) })
1151 }
1152 writer.text(text)
1153 before = lastChar(written) ?? before
1154 }
1155}
1156
1157/** The last character of a text (a surrogate pair whole), or undefined. */
1158function lastChar(text: string): string | undefined {
1159 if (text === '') return undefined
1160 const code = text.charCodeAt(text.length - 1)
1161 return code >= 0xdc00 && code <= 0xdfff && text.length > 1 ? text.slice(-2) : text.slice(-1)
1162}
1163
1164/**
1165 * Chooses the form of each inline formula in a run (see writeProse), keyed by
1166 * its index in the run. `display`: a display formula follows the run (its
1167 * block is read as a line of text where MarkdownWriter.block will put it).
1168 */
1169function decideInline(run: readonly Segment[], display: boolean, env: StreamEnv, writer: MarkdownWriter, plan: StreamPlan, choices: Map<number, InlineChoice>): void {
1170 const inlineEnv = inlineEnvFor(env)
1171 const width = proseWidthFor(env)
1172 const mode = linkModeOf(env)
1173 const head = writer.recent().slice(plan.anchor)
1174 // The run as written with the forms chosen so far; each formula's place in it.
1175 const build = (): string => {
1176 let body = ''
1177 let first = true
1178 for (const [at, segment] of run.entries()) {
1179 const choice = choices.get(at)
1180 const text = segment.kind === 'text' ? segment.text : choice && choice.choice < choice.options.length ? choice.options[choice.choice]!.markdown : inlineFallback(segment)
1181 if (text === '') continue
1182 body += first ? writer.prepare(text) : text
1183 first = false
1184 if (choice) {
1185 choice.end = head.length + body.length
1186 choice.start = choice.end - text.length
1187 }
1188 }
1189 return head + body
1190 }
1191 for (const [at, segment] of run.entries()) {
1192 if (segment.kind === 'math') choices.set(at, { segment, options: [], choice: 0, done: false, start: 0, end: 0 })
1193 }
1194 let source = build()
1195 for (const choice of choices.values()) {
1196 // In a quote the formula's row is the quote's text: two cells in, two more per quote nested in it.
1197 const line = source.slice(source.lastIndexOf('\n', choice.start - 1) + 1, choice.start)
1198 choice.options = inlineOptions(choice.segment.tex, inlineEnv, width - QUOTE_INDENT * quotesOpening(line))
1199 }
1200 const live = () => [...choices.values()].filter(choice => choice.choice < choice.options.length)hooks/budget.ts 101 lines1// The images one landed block may draw. Claude Code takes at most 2 MiB of
2// Image source (decoded PNG bytes) in one ui.render answer, all its Images
3// together; past it, it throws the whole answer away and draws its own text
4// (measured live on 2.1.291: "more than 2097152 bytes of Image source in one
5// tree"). A long block of large formulas could get there, so its images are
6// counted in reading order and those that no longer fit are left out: each
7// keeps the Unicode its rows already hold, so nothing moves.
8import { MAX_IMAGE_BYTES } from './core.js'
9import type { RenderedImage } from './core.js'
10import type { Piece } from './math.ts'
11
12/** The PNG bytes one drawing may hold: the engine's limit for all its Images together. */
13export const TREE_IMAGE_BYTES = MAX_IMAGE_BYTES
14
15/**
16 * The images of `pieces` that do not fit in `limit` bytes, in reading order
17 * (a piece's display image, then a prose piece's inline images row by row):
18 * each one taken while it fits, the rest left out.
19 */
20export function overBudget(pieces: readonly Piece[], limit = TREE_IMAGE_BYTES): Set<RenderedImage> {
21 const left = new Set<RenderedImage>()
22 let used = 0
23 const take = (image: RenderedImage) => {
24 if (used + image.png.length <= limit) used += image.png.length
25 else left.add(image)
26 }
27 for (const piece of pieces) {
28 if (piece.kind === 'image') take(piece.image)
29 else if (piece.kind === 'prose' && piece.inline) for (const one of [...piece.inline].sort((a, b) => a.row - b.row || a.col - b.col)) take(one.image)
30 }
31 return left
32}
33
34/**
35 * A display formula left out of the budget, as text in its image's box:
36 * `lines` (its Unicode preview, from core's previewDisplay) centred across
37 * `columns`, padded with blank lines to `rows`; its source on one line when
38 * there is no preview.
39 */
40export function fallbackLines(lines: readonly string[] | null, tex: string, columns: number, rows: number): string[] {
41 const body = lines && lines.length <= rows ? [...lines] : [oneLine(tex, columns)]
42 const width = Math.max(...body.map(line => [...line].length))
43 const pad = ' '.repeat(Math.max(0, Math.floor((columns - width) / 2)))
44 const above = Math.floor((rows - body.length) / 2)
45 const out = [...Array<string>(Math.max(0, above)).fill(''), ...body.map(line => (pad + line).trimEnd())]
46 while (out.length < rows) out.push('')
47 return out.slice(0, rows)
48}
49
50function oneLine(text: string, max: number): string {
51 const flat = text.replace(/\s+/g, ' ').trim()
52 const chars = [...flat]
53 return chars.length <= max ? flat : `${chars.slice(0, Math.max(1, max - 1)).join('')}…`
54}
55
56/**
57 * An Image's alt as the engine takes it: a control character in it (a form
58 * feed, a newline in a formula's source) fails the whole drawing, so each
59 * becomes a space; never empty.
60 */
61export function altText(text: string): string {
62 // eslint-disable-next-line no-control-regex
63 const clean = text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
64 return clean === '' ? ' ' : clean
65}
66
67/** The densities a picture is drawn again at, in turn, to fit a drawing's budget (its pixels each way: 1/2, then 1/3). */
68export const PICTURE_DENSITIES: readonly number[] = [0.5, 1 / 3]
69
70/**
71 * A drawing's pieces with its pictures (TeX diagrams) drawn at fewer pixels,
72 * largest first, while all its images together pass `limit`: a picture keeps
73 * its cells (the terminal scales an image to them), so nothing moves, and
74 * it is drawn after all instead of being left out. `redraw` gives a
75 * picture's image drawn again at a density (undefined for anything else: a
76 * formula is small and keeps its pixels).
77 */
78export function fitPictures(pieces: readonly Piece[], redraw: (image: RenderedImage, density: number) => RenderedImage | undefined, limit = TREE_IMAGE_BYTES): Piece[] {
79 const images = pieces.flatMap(piece => (piece.kind === 'image' ? [piece.image] : piece.kind === 'prose' ? (piece.inline ?? []).map(one => one.image) : []))
80 let total = images.reduce((sum, image) => sum + image.png.length, 0)
81 if (total <= limit) return [...pieces]
82 const swap = new Map<RenderedImage, RenderedImage>()
83 for (const density of PICTURE_DENSITIES) {
84 for (const image of [...images].sort((a, b) => b.png.length - a.png.length)) {
85 if (total <= limit) break
86 const now = swap.get(image) ?? image
87 const smaller = redraw(image, density)
88 if (!smaller || smaller.png.length >= now.png.length) continue
89 total += smaller.png.length - now.png.length
90 swap.set(image, smaller)
91 }
92 if (total <= limit) break
93 }
94 if (swap.size === 0) return [...pieces]
95 return pieces.map(piece => {
96 if (piece.kind === 'image') return swap.has(piece.image) ? { ...piece, image: swap.get(piece.image)! } : piece
97 if (piece.kind === 'prose' && piece.inline) return { ...piece, inline: piece.inline.map(one => (swap.has(one.image) ? { ...one, image: swap.get(one.image)! } : one)) }
98 return piece
99 })
100}
101hooks/cache.ts 200 lines1// kittex's image cache (the `cache` option): the drawing of a landed block
2// read back as LaTeX (a resumed session) kept on disk, so the next resume
3// draws it without typesetting: no MathJax at all when every block hits.
4//
5// Claude Code's $.store holds 4 MiB of JSON in all, too little, so the
6// entries are files: `<dir>/<key>.json` under
7// `${XDG_CACHE_HOME:-~/.cache}/kittex/v<CACHE_VERSION>/`, written with
8// $.fs.write (text: the PNGs go in base64) and read with $.fs.read. An entry's
9// key is a hash of everything that changes its pixels or its layout: the
10// block's text, the options, the cells and the reply's width, the ink and its
11// blending, the stroke weight, the baseline, how the engine draws links and
12// emoji, and kittex's build. Its text carries its own key and length, so a
13// file cut short by a concurrent write or a crash reads as a miss (and every
14// writer of one key writes the same bytes). This module is pure: register.tsx
15// does the I/O.
16import type { RenderedImage } from './core.js'
17import type { InlineImage, KittexEnv, MathOptions, Piece } from './math.ts'
18
19/** Bumped when an entry's layout changes: older entries live in another folder and are never read. */
20export const CACHE_VERSION = 1
21
22/** The cache's size cap, in bytes: past it, session.start removes the oldest entries. */
23export const CACHE_LIMIT_BYTES = 50 * 1024 * 1024
24
25/** What pruning keeps of the cap, so a session does not prune again at once. */
26export const CACHE_PRUNE_TO = 0.8
27
28/** How long a render waits for a cache read before it draws without it. */
29export const CACHE_READ_MS = 150
30
31/** An entry's file name: its key and `.json`; nothing else in the folder is ever read or removed. */
32export const ENTRY_NAME = /^[0-9a-z]{26}\.json$/
33/**
34 * What TeX drew (tex.ts): `<cache>/kittex/tex/<sha-256>.json`, pruned on its
35 * own past TEX_CACHE_LIMIT_BYTES. Its `fmt/` folder (the TeX format, about
36 * 11 MB) is neither counted nor removed here: prepareFormat replaces it when
37 * TeX's version changes.
38 */
39export const TEX_ENTRY_NAME = /^[0-9a-f]{64}\.json$/
40export const TEX_CACHE_LIMIT_BYTES = 20 * 1024 * 1024
41
42/** The folder entries go in: XDG_CACHE_HOME (absolute), else ~/.cache; undefined without either. */
43export function cacheDir(env: { XDG_CACHE_HOME?: string | undefined; HOME?: string | undefined }): string | undefined {
44 const xdg = env.XDG_CACHE_HOME
45 const base = xdg && xdg.startsWith('/') ? xdg : env.HOME && env.HOME.startsWith('/') ? `${env.HOME}/.cache` : undefined
46 return base ? `${base.replace(/\/+$/, '')}/kittex/v${CACHE_VERSION}` : undefined
47}
48
49/**
50 * The key of a block's drawing: a 128-bit hash (26 base-36 digits) of its
51 * text and of `facts`, everything else its drawing depends on (any JSON).
52 */
53export function entryKey(text: string, facts: unknown): string {
54 return hash128(`${CACHE_VERSION}\n${JSON.stringify(facts)}\n${text}`)
55}
56
57/** What a resumed block's drawing depends on besides its text (the key's facts): the build, the options, the reply's width, the cells and the text font, the ink and its blending, the weight, the prose width, links and emoji. */
58export function cacheFacts(env: KittexEnv, columns: number, math: MathOptions, build: string): unknown {
59 return {
60 build,
61 math,
62 columns,
63 cell: [env.cellWidth, env.cellHeight, env.emPx],
64 adjust: env.cellAdjust ?? null,
65 kitty: env.kittyAdjust ?? null,
66 font: env.font ?? null,
67 curve: env.inkCurve ?? null,
68 ink: [env.ink.r, env.ink.g, env.ink.b],
69 over: env.inkOver ? [env.inkOver.r, env.inkOver.g, env.inkOver.b] : null,
70 weight: env.weight ?? null,
71 prose: env.maxProseWidth ?? null,
72 links: env.hyperlinks ?? null,
73 emoji: env.emojiSequences ?? null,
74 }
75}
76
77/** An entry's file path. */
78export function entryPath(dir: string, key: string): string {
79 return `${dir}/${key}.json`
80}
81
82type StoredImage = { columns: number; rows: number; scale: number; png: string }
83type StoredPiece =
84 | { kind: 'prose'; text: string; gap: boolean; inline?: { tex: string; row: number; col: number; display?: true; image: StoredImage }[] }
85 | { kind: 'image'; tex: string; gap: boolean; image: StoredImage }
86 | { kind: 'note'; text: string; gap: boolean }
87
88/** A drawing as an entry's text. `base64` gives a PNG's base64 (the one the Image is sent with). */
89export function encodeEntry(key: string, pieces: readonly Piece[], base64: (png: Uint8Array) => string): string {
90 const image = (one: RenderedImage): StoredImage => ({ columns: one.columns, rows: one.rows, scale: one.scale, png: base64(one.png) })
91 const stored: StoredPiece[] = pieces.map(piece =>
92 piece.kind === 'image'
93 ? { kind: 'image', tex: piece.tex, gap: piece.gap, image: image(piece.image) }
94 : piece.kind === 'note'
95 ? { kind: 'note', text: piece.text, gap: piece.gap }
96 : { kind: 'prose', text: piece.text, gap: piece.gap, ...(piece.inline ? { inline: piece.inline.map(one => ({ tex: one.tex, row: one.row, col: one.col, ...(one.display ? { display: true as const } : {}), image: image(one.image) })) } : {}) },
97 )
98 const body = JSON.stringify(stored)
99 return `${JSON.stringify({ kittex: CACHE_VERSION, key, length: body.length })}\n${body}`
100}
101
102/**
103 * The drawing an entry's text holds, or null when the text is not a whole
104 * entry for `key` (another version, cut short, garbled). `remember` is told
105 * each PNG with its base64, so the Image is sent without encoding it again.
106 */
107export function decodeEntry(text: string, key: string, remember?: (png: Uint8Array, base64: string) => void): Piece[] | null {
108 try {
109 const cut = text.indexOf('\n')
110 if (cut < 0) return null
111 const head = JSON.parse(text.slice(0, cut)) as { kittex?: unknown; key?: unknown; length?: unknown }
112 const body = text.slice(cut + 1)
113 if (head.kittex !== CACHE_VERSION || head.key !== key || head.length !== body.length) return null
114 const stored = JSON.parse(body) as StoredPiece[]
115 if (!Array.isArray(stored)) return null
116 const image = (one: StoredImage): RenderedImage => {
117 if (!isCells(one.columns) || !isCells(one.rows) || typeof one.scale !== 'number' || typeof one.png !== 'string') throw new Error('bad image')
118 const png = fromBase64(one.png)
119 // The engine refuses a whole drawing over one PNG without its signature and IHDR.
120 if (!isPng(png)) throw new Error('not a PNG')
121 remember?.(png, one.png)
122 return { columns: one.columns, rows: one.rows, scale: one.scale, png }
123 }
124 return stored.map((piece): Piece => {
125 if (typeof piece?.gap !== 'boolean') throw new Error('bad piece')
126 if (piece.kind === 'image') {
127 if (typeof piece.tex !== 'string') throw new Error('bad piece')
128 return { kind: 'image', tex: piece.tex, gap: piece.gap, image: image(piece.image) }
129 }
130 if (typeof piece.text !== 'string') throw new Error('bad piece')
131 if (piece.kind === 'note') return { kind: 'note', text: piece.text, gap: piece.gap }
132 if (piece.kind !== 'prose') throw new Error('bad piece')
133 if (piece.inline === undefined) return { kind: 'prose', text: piece.text, gap: piece.gap }
134 if (!Array.isArray(piece.inline)) throw new Error('bad piece')
135 const inline: InlineImage[] = piece.inline.map(one => {
136 if (typeof one?.tex !== 'string' || !Number.isInteger(one.row) || !Number.isInteger(one.col)) throw new Error('bad inline')
137 return { tex: one.tex, row: one.row, col: one.col, ...(one.display === true ? { display: true as const } : {}), image: image(one.image) }
138 })
139 return { kind: 'prose', text: piece.text, gap: piece.gap, inline }
140 })
141 } catch {
142 return null
143 }
144}
145
146const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
147const isPng = (png: Uint8Array) => png.length >= 33 && SIGNATURE.every((byte, i) => png[i] === byte) && String.fromCharCode(...png.subarray(12, 16)) === 'IHDR'
148
149const isCells = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 255
150
151/** One file of the cache folder, as $.fs.list gives it. */
152export interface CacheFile {
153 name: string
154 kind: string
155 size: number
156 mtimeMs: number
157}
158
159/**
160 * The entries to remove so the folder holds at most `limit` bytes: none
161 * while it fits, else the oldest (by mtime) until it holds CACHE_PRUNE_TO of
162 * it. Only entry files (`name`: ENTRY_NAME, or TEX_ENTRY_NAME in TeX's
163 * folder) count and are ever named: never a folder, never the TeX format.
164 */
165export function pruneList(files: readonly CacheFile[], limit = CACHE_LIMIT_BYTES, name: RegExp = ENTRY_NAME): string[] {
166 const entries = files.filter(file => file.kind === 'file' && name.test(file.name))
167 let total = entries.reduce((sum, file) => sum + file.size, 0)
168 if (total <= limit) return []
169 const out: string[] = []
170 for (const file of [...entries].sort((a, b) => a.mtimeMs - b.mtimeMs || (a.name < b.name ? -1 : 1))) {
171 if (total <= limit * CACHE_PRUNE_TO) break
172 out.push(file.name)
173 total -= file.size
174 }
175 return out
176}
177
178/** A 128-bit hash of `text` as 26 base-36 digits: four 32-bit FNV-1a lanes, each seeded apart and mixed. */
179export function hash128(text: string): string {
180 const bytes = new TextEncoder().encode(text)
181 const seeds = [0x811c9dc5, 0x01000193 ^ 0x9e3779b9, 0x85ebca6b, 0xc2b2ae35]
182 const lanes = seeds.map(seed => {
183 let h = seed >>> 0
184 for (let i = 0; i < bytes.length; i++) h = Math.imul(h ^ bytes[i]!, 0x01000193)
185 // A final mix, so the lanes differ in every bit.
186 h ^= h >>> 16
187 h = Math.imul(h, 0x85ebca6b)
188 h ^= h >>> 13
189 h = Math.imul(h, seed | 1)
190 h ^= h >>> 16
191 return BigInt(h >>> 0)
192 })
193 const value = (lanes[0]! << 96n) | (lanes[1]! << 64n) | (lanes[2]! << 32n) | lanes[3]!
194 return value.toString(36).padStart(26, '0')
195}
196
197function fromBase64(text: string): Uint8Array {
198 return (Uint8Array as unknown as { fromBase64(text: string): Uint8Array }).fromBase64(text)
199}
200hooks/schedule.ts 63 lines1// The order landed blocks are typeset in. After --resume the engine asks for
2// a drawing of every block it lays out at once (the last 18 of a 100-reply
3// session in the fullscreen layout, the last 67 on the main screen), oldest
4// first, and the worker typesets one at a time: the newest blocks, the ones
5// on screen, came last. Here a block that has to be typeset waits its turn,
6// newest first, so the bottom of the screen is final first.
7//
8// Pure. A turn taken while the queue is idle first lets the rest of a wave
9// arrive: `gather` is a round trip the caller makes for that (register.tsx:
10// a state read, a hop to the engine and back), done GATHER_HOPS times, so no
11// clock is needed (a mocked or missing one would stall the queue).
12
13/** Round trips a turn taken on an idle queue waits for the rest of its wave. */
14export const GATHER_HOPS = 2
15
16export interface Turns {
17 /** Resolves when it is this caller's turn; call the release it gives once its work is done (a second call does nothing). */
18 take(gather?: () => Promise<unknown>): Promise<() => void>
19 /** Callers waiting (not counting the one at work). */
20 readonly waiting: number
21}
22
23export function newestFirst(): Turns {
24 const stack: (() => void)[] = []
25 let busy = false
26 let gathering = false
27 const pump = () => {
28 if (busy || gathering) return
29 const next = stack.pop()
30 if (!next) return
31 busy = true
32 next()
33 }
34 return {
35 async take(gather) {
36 const turn = new Promise<() => void>(resolve => {
37 let released = false
38 stack.push(() =>
39 resolve(() => {
40 if (released) return
41 released = true
42 busy = false
43 pump()
44 }),
45 )
46 })
47 if (!busy && !gathering && gather) {
48 gathering = true
49 try {
50 for (let hop = 0; hop < GATHER_HOPS; hop++) await gather().catch(() => undefined)
51 } finally {
52 gathering = false
53 }
54 }
55 pump()
56 return turn
57 },
58 get waiting() {
59 return stack.length
60 },
61 }
62}
63hooks/tex.ts 668 lines1// The local LaTeX, for what MathJax can't draw: diagrams (TikZ, pgfplots,
2// chemfig, circuitikz, tikz-cd) and math MathJax refuses (siunitx's \unit,
3// \qty...). Nothing here takes `$`: register.tsx hands a TexHost of closures
4// over session.start's `$` (a compile may outlive the dispatch that asked for
5// it). core/src/diagram/tex.ts has the documents, the commands and the threat
6// model; this file runs them, remembers what they gave, and caches it on disk.
7//
8// The order is the approved one: MathJax first for everything it draws (it
9// takes milliseconds and lets the stream reserve exact rows), the local TeX
10// only for what it refuses, and the source (or Unicode) when TeX fails too.
11
12import {
13 confined,
14 diagramDocument,
15 drawsPicture,
16 dvisvgmArgv,
17 FORMAT_SOURCE,
18 formatArgv,
19 formatName,
20 isJobDir,
21 latexArgv,
22 JOB_NAME,
23 jobDirTemplate,
24 LATEX_ARGV,
25 mathDocument,
26 PREAMBLE_VERSION,
27 SvgError,
28 texEnvironment,
29 texError,
30 texFormula,
31 texPicture,
32 unsafeTex,
33 XmlError,
34} from './core.js'
35import type { Confinement, Picture, TexDocument, TypesetResult } from './core.js'
36
37/** What a compile gave: the picture, or why there is none. */
38export type TexOutcome =
39 | { ok: true; picture: Picture }
40 /** `lasting`: TeX's own answer (an error in the source), kept on disk; else (a timeout, a missing command) it may be tried again. */
41 | { ok: false; error: string; lasting: boolean }
42
43/** What a TeX run needs from the host: commands, and text files (register.tsx backs these with `$`). */
44export interface TexHost {
45 run(argv: readonly string[], init: { cwd?: string; env?: Record<string, string>; timeoutMs: number }): Promise<{ exitCode: number; stdout: string; stderr: string; isStdoutTruncated: boolean }>
46 write(path: string, text: string): Promise<void>
47 read(path: string): Promise<string | undefined>
48 /** A file's bytes (the DVI, for its specials), or undefined when it can't be read; absent: never read. */
49 readBytes?(path: string): Promise<Uint8Array | undefined>
50}
51
52/** The host as found at session start: TeX's commands are there, and how jobs are confined. */
53export interface TexSetup {
54 /** `latex --version`'s and `dvisvgm --version`'s first lines: they key the disk cache with the preamble version. */
55 versions: string
56 confinement: Confinement
57 /** The host's temporary directory (TMPDIR), where each job gets a directory of its own. */
58 tmpdir: string | undefined
59 /** Where outcomes are cached across sessions; absent: not cached on disk. */
60 cacheDir?: string
61 /** The dumped fragment format (prepareFormat), once it is there: its name and the directory holding `<name>.fmt`. */
62 format?: { name: string; dir: string }
63 /**
64 * False where dvisvgm has no `--libgs` (built with Ghostscript linked in or
65 * left out, as Arch's, Debian's and Fedora's are): it refuses the option,
66 * so jobs run without it. Absent: dvisvgm takes it (TeX Live's own build).
67 */
68 libgs?: false
69 /**
70 * True where dvisvgm has Ghostscript linked in and can't be told to ignore
71 * PostScript (3.5 to 3.6.1 read `--no-specials=<list>` wrongly and ignore
72 * nothing): it runs every PostScript special through Ghostscript, which it
73 * starts with -dDELAYSAFER and never makes safe. kittex's own documents
74 * carry none; a document that does is refused unless bubblewrap confines
75 * the job.
76 */
77 postscript?: true
78 /** With bubblewrap: what its namespace must still show (the TeX trees and programs it hides) and the PATH jobs run with. */
79 sandbox?: Sandbox
80}
81
82/** What a job's namespace binds back read-only, and the PATH TeX is found by inside it. */
83export interface Sandbox {
84 /** Directories and files TeX needs that lie in a hidden directory (TeX Live installed into the home folder): bound read-only, nothing more. */
85 binds: string[]
86 /** PATH with latex's and dvisvgm's own directories first, so the namespace finds them where a hidden link would have; absent: the host's. */
87 path?: string
88}
89
90/** Whether a job's sandbox runs TeX here, and if not, why (the first line its trial printed). */
91export type SandboxProbe = { ok: true; sandbox: Sandbox } | { ok: false; error: string }
92
93/** How long a probe command may run. */
94const PROBE_MS = 3000
95/** How long a stream holds a diagram (or a formula MathJax refused) for TeX before it shows the source. */
96export const TEX_STREAM_BUDGET_MS = 3000
97/** How long a compile in the background (a landed block's, after --resume) may run. */
98export const TEX_BACKGROUND_MS = 20_000
99/** Compiles that run at once (each is one TeX process, then one dvisvgm). */
100const MAX_COMPILES = 3
101/** Outcomes kept in memory. */
102const BOOK_LIMIT = 256
103
104/**
105 * Finds TeX: `latex` and `dvisvgm` answering `--version`, and what jobs can
106 * be confined with: prlimit, and bubblewrap where TeX runs inside its
107 * namespace (probeSandbox: a real trial, not a bare `true`), else none.
108 * Undefined when either command is missing.
109 */
110export async function probeTex(host: TexHost, options: { tmpdir: string | undefined; hide: readonly string[]; cacheDir?: string; path?: string }): Promise<TexSetup | undefined> {
111 const ok = (argv: readonly string[]) =>
112 host.run(argv, { timeoutMs: PROBE_MS }).then(
113 result => (result.exitCode === 0 ? result.stdout : undefined),
114 () => undefined,
115 )
116 const [latex, dvisvgm, help, extended, prlimit] = await Promise.all([
117 ok(['latex', '--version']),
118 ok(['dvisvgm', '--version']),
119 ok(['dvisvgm', '--help']),
120 ok(['dvisvgm', '-V1']),
121 ok(['prlimit', '--version']),
122 ])
123 if (latex === undefined || dvisvgm === undefined) return undefined
124 const sandbox = options.hide.length > 0 ? await probeSandbox(host, { hide: options.hide, tmpdir: options.tmpdir, path: options.path, prlimit: prlimit !== undefined }) : undefined
125 const first = (text: string) => text.split('\n', 1)[0]!.trim()
126 return {
127 versions: `${first(latex)}\n${first(dvisvgm)}`,
128 confinement: { prlimit: prlimit !== undefined, ...(sandbox?.ok ? { bwrap: { hide: options.hide } } : {}) },
129 tmpdir: options.tmpdir,
130 ...(options.cacheDir !== undefined ? { cacheDir: options.cacheDir } : {}),
131 ...(help !== undefined && !takesLibgs(help) ? { libgs: false as const } : {}),
132 ...(help !== undefined && !takesLibgs(help) && runsPostScript(first(dvisvgm), extended) ? { postscript: true as const } : {}),
133 ...(sandbox?.ok ? { sandbox: sandbox.sandbox } : {}),
134 }
135}
136
137/**
138 * Whether a dvisvgm without --libgs hands PostScript specials to Ghostscript
139 * whatever --no-specials says: Ghostscript linked in (`-V1` names it) and a
140 * version whose list parsing ignores nothing (3.5 to 3.6.1, fixed after).
141 */
142export function runsPostScript(version: string, extended: string | undefined): boolean {
143 const m = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(version)
144 if (!m || !/^Ghostscript:/m.test(extended ?? '')) return false
145 const v = Number(m[1]) * 10_000 + Number(m[2]) * 100 + Number(m[3] ?? 0)
146 return v >= 30_500 && v <= 30_601
147}
148
149/** The special prefixes dvisvgm's PostScript handler takes (PsSpecialHandler::prefixes). */
150const POSTSCRIPT_PREFIXES = new Set(['header=', 'pdffile=', 'psfile=', 'PSfile=', 'ps:', 'ps::', '!', '"', 'pst:', 'PST:'])
151
152/** A special's prefix as dvisvgm reads it: letters and digits, then one punctuation character (`ps::` whole). */
153function specialPrefix(text: string): string {
154 const m = /^[A-Za-z0-9]*/.exec(text)!
155 let prefix = m[0]
156 const next = text[prefix.length]
157 if (next !== undefined && /[!-/:-@[-`{-~]/.test(next)) prefix += next
158 if (prefix === 'ps:' && text[3] === ':') prefix += ':'
159 return prefix
160}
161
162/**
163 * The specials a DVI file holds, in order (a walk over its commands from the
164 * preamble to the postamble); undefined when the bytes aren't a DVI file.
165 */
166export function dviSpecials(bytes: Uint8Array): string[] | undefined {
167 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
168 const n = (at: number, size: number) => (size === 1 ? view.getUint8(at) : size === 2 ? view.getUint16(at) : size === 3 ? (view.getUint16(at) << 8) | view.getUint8(at + 2) : view.getUint32(at))
169 const out: string[] = []
170 let at = 0
171 try {
172 if (view.getUint8(0) !== 247) return undefined
173 at = 15 + view.getUint8(14)
174 while (at < bytes.length) {
175 const op = view.getUint8(at++)
176 if (op <= 127 || (op >= 171 && op <= 234) || op === 138 || op === 140 || op === 141 || op === 142 || op === 147 || op === 152 || op === 161 || op === 166) continue
177 if (op >= 128 && op <= 131) at += op - 127
178 else if (op === 132 || op === 137) at += 8
179 else if (op >= 133 && op <= 136) at += op - 132
180 else if (op === 139) at += 44
181 else if (op >= 143 && op <= 146) at += op - 142
182 else if (op >= 148 && op <= 151) at += op - 147
183 else if (op >= 153 && op <= 156) at += op - 152
184 else if (op >= 157 && op <= 160) at += op - 156
185 else if (op >= 162 && op <= 165) at += op - 161
186 else if (op >= 167 && op <= 170) at += op - 166
187 else if (op >= 235 && op <= 238) at += op - 234
188 else if (op >= 239 && op <= 242) {
189 const size = op - 238
190 const length = n(at, size)
191 at += size
192 let text = ''
193 for (const byte of bytes.subarray(at, at + length)) text += String.fromCharCode(byte)
194 out.push(text)
195 at += length
196 } else if (op >= 243 && op <= 246) {
197 at += op - 242 + 12
198 const a = view.getUint8(at)
199 const l = view.getUint8(at + 1)
200 at += 2 + a + l
201 } else if (op === 248) return out
202 else return undefined
203 }
204 } catch {
205 return undefined
206 }
207 return undefined
208}
209
210/** Whether any of these specials is PostScript (dvisvgm would hand it to Ghostscript). */
211export function hasPostScript(specials: readonly string[]): boolean {
212 return specials.some(special => POSTSCRIPT_PREFIXES.has(specialPrefix(special.trimStart())))
213}
214
215/** The kpathsea variables naming every tree TeX reads: its root (programs, texmf.cnf), the search path, the config path. */
216const TEX_TREES = '-expand-braces=$TEXMFROOT:$TEXMF:$TEXMFCNF'
217
218/**
219 * Whether TeX runs inside bubblewrap's namespace here, and what that needs:
220 * the TeX trees (kpsewhich, as a job's environment sees them), latex's and
221 * dvisvgm's real files and the libraries they load, each bound read-only
222 * where it lies in a hidden directory (never a hidden directory itself), and
223 * latex's and dvisvgm's directories first on PATH. The trial runs latex
224 * (its format loaded) and dvisvgm in that namespace, as a job does.
225 */
226export async function probeSandbox(host: TexHost, options: { hide: readonly string[]; tmpdir: string | undefined; path?: string; prlimit: boolean }): Promise<SandboxProbe> {
227 const run = (argv: readonly string[], init: { cwd?: string; env?: Record<string, string> } = {}) =>
228 host.run(argv, { ...init, timeoutMs: PROBE_MS }).catch(() => undefined)
229 const lines = (text: string | undefined) => (text ?? '').split('\n').map(line => line.trim()).filter(Boolean)
230 const dirs = (options.path ?? '').split(':').filter(dir => dir.startsWith('/')).map(dir => dir.replace(/\/+$/, '') || '/')
231 // The first latex and dvisvgm on PATH, links resolved (realpath prints only the ones there, in order).
232 const find = async (name: string) => (dirs.length > 0 ? lines((await run(['realpath', '-e', '--', ...dirs.map(dir => `${dir}/${name}`)]))?.stdout)[0] : undefined)
233 const placeholder = `${jobDirTemplate(options.tmpdir).replace(/\.X+$/, '')}.probe`
234 const [latex, dvisvgm, trees, hidden] = await Promise.all([
235 find('latex'),
236 find('dvisvgm'),
237 run(['kpsewhich', TEX_TREES], { env: texEnvironment(placeholder) }),
238 run(['realpath', '-m', '--', ...options.hide]),
239 ])
240 const programs = [latex, dvisvgm].filter((one): one is string => one !== undefined)
241 const libraries = await Promise.all(programs.map(program => run(['ldd', program])))
242 const candidates = [
243 ...programs.map(program => program.replace(/\/[^/]+$/, '')),
244 ...(trees?.exitCode === 0 ? trees.stdout.trim().split(':') : []).map(tree => tree.replace(/^!!/, '').replace(/\/+$/, '')),
245 ...libraries.flatMap(result => lines(result?.stdout).flatMap(line => /(?:=>\s*)?(\/[^\s()]+)\s*\(0x/.exec(line)?.[1] ?? [])),
246 ].filter(path => path.startsWith('/'))
247 const real = candidates.length > 0 ? lines((await run(['realpath', '-e', '--', ...new Set(candidates)]))?.stdout) : []
248 const hide = [...new Set([...options.hide, ...lines(hidden?.stdout)])]
249 const binds = sandboxBinds(real, hide)
250 const bin = [...new Set(programs.map(program => program.replace(/\/[^/]+$/, '')))]
251 const sandbox: Sandbox = { binds, ...(options.path !== undefined ? { path: [...new Set([...bin, ...dirs])].join(':') } : {}) }
252 // The trial: latex with its format and dvisvgm, in the namespace a job gets.
253 const made = await run(['mktemp', '-d', jobDirTemplate(options.tmpdir)])
254 const job = made?.stdout.trim() ?? ''
255 if (made?.exitCode !== 0 || !isJobDir(job)) return { ok: false, error: 'no temporary directory for the trial' }
256 try {
257 const confinement: Confinement = { prlimit: options.prlimit, bwrap: { hide: options.hide } }
258 const env = { ...texEnvironment(job), ...(sandbox.path ? { PATH: sandbox.path } : {}) }
259 for (const argv of [['latex', '-no-shell-escape', '-interaction=nonstopmode', '-halt-on-error', '\\stop'], ['dvisvgm', '--version']]) {
260 const result = await run(confined(argv, job, confinement, binds), { cwd: job, env })
261 if (result?.exitCode !== 0) {
262 const said = lines(result?.stderr)[0] ?? lines(result?.stdout).find(line => line.startsWith('!'))
263 return { ok: false, error: `${argv[0]} in the sandbox: ${said ?? (result ? `exit ${result.exitCode}` : 'did not run')}`.slice(0, 200) }
264 }
265 }
266 return { ok: true, sandbox }
267 } finally {
268 await run(['rm', '-rf', '--', job])
269 }
270}
271
272/**
273 * The paths a job's namespace binds back: those of `paths` that lie inside a
274 * hidden directory (never one of them, nor anything holding one), each once
275 * (none inside another).
276 */
277export function sandboxBinds(paths: readonly string[], hide: readonly string[]): string[] {
278 const inside = (path: string, dir: string) => path.startsWith(`${dir.replace(/\/+$/, '')}/`)
279 const under = [...new Set(paths)].filter(path => hide.some(dir => inside(path, dir)) && !hide.some(dir => dir === path || inside(dir, path)))
280 return under.filter(path => !under.some(other => other !== path && inside(path, other))).sort()
281}
282
283/** The extra paths every confined command of a job binds back, and the variables it runs with (a sandbox's PATH). */
284function sandboxed(setup: Pick<TexSetup, 'confinement' | 'sandbox'>): { binds: string[]; env: Record<string, string> } {
285 const jailed = setup.confinement.bwrap !== undefined
286 return { binds: jailed ? (setup.sandbox?.binds ?? []) : [], env: jailed && setup.sandbox?.path ? { PATH: setup.sandbox.path } : {} }
287}
288
289/** Whether `dvisvgm --help` lists `--libgs`; a help that isn't dvisvgm's (no `--no-specials`) counts as yes. */
290export function takesLibgs(help: string): boolean {
291 return !/--no-specials\b/.test(help) || /--libgs\b/.test(help)
292}
293
294/** dvisvgm on a job in `dir`, as this host's dvisvgm takes it (without `--libgs` where it has none). */
295export function dvisvgmCommand(dir: string, setup: Pick<TexSetup, 'libgs'>): string[] {
296 const argv = dvisvgmArgv(dir)
297 return setup.libgs === false ? argv.filter(arg => !arg.startsWith('--libgs=')) : argv
298}
299
300/**
301 * The directories a confined job can't see: the home directory and the
302 * temporary ones (a job's own directory is bound back inside). Only absolute
303 * paths that are no system root.
304 */
305export function hiddenDirs(home: string | undefined, tmpdir: string | undefined): string[] {
306 const dirs = [home, '/tmp', '/var/tmp', '/run', tmpdir]
307 return [...new Set(dirs.filter((dir): dir is string => dir !== undefined && /^\/[^\s]+$/.test(dir) && !['/', '/usr', '/etc', '/opt', '/bin', '/lib', '/nix'].includes(dir.replace(/\/+$/, ''))).map(dir => dir.replace(/\/+$/, '')))]
308}
309
310/** The disk cache's directory: XDG_CACHE_HOME's (or ~/.cache's) kittex/tex. */
311export function texCacheDir(env: { XDG_CACHE_HOME?: string | undefined; HOME?: string | undefined }): string | undefined {
312 const base = env.XDG_CACHE_HOME?.startsWith('/') ? env.XDG_CACHE_HOME : env.HOME?.startsWith('/') ? `${env.HOME}/.cache` : undefined
313 return base === undefined ? undefined : `${base.replace(/\/+$/, '')}/kittex/tex`
314}
315
316/**
317 * Compiles documents and remembers what they gave, by document: the stream
318 * and the landing read outcomes from here synchronously (`known`), and ask
319 * for the ones missing (`compile`), MAX_COMPILES at a time, each document
320 * compiled once however many ask.
321 */
322export class TexBook {
323 private readonly outcomes = new Map<string, TexOutcome>()
324 private readonly running = new Map<string, Promise<TexOutcome>>()
325 /** Compiles running now, and those waiting for one of them to end (at most MAX_COMPILES at once). */
326 private active = 0
327 private readonly waiting: (() => void)[] = []
328 /** Documents the stream showed as source (TeX too slow): left as source when their block lands, though TeX finished later. */
329 private readonly shownAsSource = new Set<string>()
330 /** Documents a landing asked for that TeX hasn't drawn yet (drawn later, then the blocks redraw). */
331 private readonly asked = new Map<string, TexDocument>()
332 host: TexHost | undefined
333 setup: TexSetup | undefined
334 /**
335 * The setup an earlier session found (rememberedTex): its versions key the
336 * disk cache, so the pictures it compiled are read with it before TeX is
337 * probed (a resume draws them from its first render), and once TeX is gone.
338 */
339 cached: TexSetup | undefined
340
341 /** A landing wants this document drawn (a reply read back after --resume): kept until takeAsked. Not one shown as source. */
342 ask(document: TexDocument): void {
343 if (this.drawable && !this.shownAsSource.has(document.text) && this.asked.size < BOOK_LIMIT) this.asked.set(document.text, document)
344 }
345
346 /** The documents asked for since the last take. */
347 takeAsked(): TexDocument[] {
348 const documents = [...this.asked.values()]
349 this.asked.clear()
350 return documents
351 }
352
353 /** Forgets everything (a new host, tests). */
354 reset(host?: TexHost, setup?: TexSetup): void {
355 this.outcomes.clear()
356 this.running.clear()
357 this.shownAsSource.clear()
358 this.asked.clear()
359 this.active = 0
360 this.waiting.length = 0
361 this.host = host
362 this.setup = setup
363 this.cached = undefined
364 }
365
366 /** TeX is there to ask. */
367 get ready(): boolean {
368 return this.host !== undefined && this.setup !== undefined
369 }
370
371 /** Pictures can be shown: TeX is there, or the disk cache of an earlier session's TeX can be read. */
372 get drawable(): boolean {
373 return this.ready || this.cached?.cacheDir !== undefined
374 }
375
376 /** What a document gave, if it was compiled (or read from the disk cache) in this process; a passing failure (a timeout) included. */
377 known(document: TexDocument): TexOutcome | undefined {
378 const outcome = this.outcomes.get(document.text)
379 if (outcome) {
380 this.outcomes.delete(document.text)
381 this.outcomes.set(document.text, outcome)
382 }
383 return outcome
384 }
385
386 /** Records an outcome (tests, and what compile and load find). */
387 remember(document: TexDocument, outcome: TexOutcome): void {
388 this.outcomes.delete(document.text)
389 this.outcomes.set(document.text, outcome)
390 while (this.outcomes.size > BOOK_LIMIT) this.outcomes.delete(this.outcomes.keys().next().value!)
391 }
392
393 markShownAsSource(document: TexDocument): void {
394 this.shownAsSource.add(document.text)
395 if (this.shownAsSource.size > BOOK_LIMIT) this.shownAsSource.delete(this.shownAsSource.values().next().value!)
396 }
397
398 wasShownAsSource(document: TexDocument): boolean {
399 return this.shownAsSource.has(document.text)
400 }
401
402 /**
403 * Reads a document's outcome from the disk cache into the book, when it is
404 * there: with TeX's host, else with `read` (a render's own file reads) and
405 * the cached setup, before TeX is found or once it is gone.
406 */
407 async load(document: TexDocument, read?: (path: string) => Promise<string | undefined>): Promise<TexOutcome | undefined> {
408 const known = this.known(document)
409 if (known && (known.ok || known.lasting)) return known
410 const reader = this.host ? (path: string) => this.host!.read(path) : read
411 const setup = this.setup ?? this.cached
412 if (!reader || !setup?.cacheDir) return undefined
413 try {
414 const text = await reader(`${setup.cacheDir}/${await cacheKey(document, setup)}.json`)
415 if (text === undefined) return undefined
416 const stored = JSON.parse(text) as { ok?: unknown; svg?: unknown; error?: unknown }
417 const outcome: TexOutcome | undefined =
418 stored.ok === true && typeof stored.svg === 'string'
419 ? { ok: true, picture: texPicture(stored.svg, document) }
420 : stored.ok === false && typeof stored.error === 'string'
421 ? { ok: false, error: stored.error, lasting: true }
422 : undefined
423 if (outcome) this.remember(document, outcome)
424 return outcome
425 } catch {
426 return undefined
427 }
428 }
429
430 /**
431 * The document's outcome: known, cached on disk, or compiled now (after the
432 * compiles before it), within `timeoutMs` of TeX's own time. A timeout is
433 * an outcome that isn't kept (the next ask compiles again).
434 */
435 compile(document: TexDocument, timeoutMs: number): Promise<TexOutcome> {
436 const known = this.known(document)
437 if (known && (known.ok || known.lasting)) return Promise.resolve(known)
438 const running = this.running.get(document.text)
439 if (running) return running
440 const job = (async (): Promise<TexOutcome> => {
441 const cached = await this.load(document)
442 if (cached) return cached
443 const host = this.host
444 const setup = this.setup
445 if (!host || !setup) return { ok: false, error: 'no TeX', lasting: false }
446 const deadline = Date.now() + timeoutMs
447 if (this.active >= MAX_COMPILES) await new Promise<void>(resolve => this.waiting.push(resolve))
448 this.active++
449 const outcome = await compileOnce(host, setup, document, deadline, timeoutMs).catch((error: unknown): TexOutcome => ({ ok: false, error: String(error), lasting: false }))
450 this.active--
451 this.waiting.shift()?.()
452 // A passing failure is remembered too (the stream reads it and shows the source), but compiled again when asked.
453 this.remember(document, outcome)
454 if ((outcome.ok || outcome.lasting) && setup.cacheDir) await store(host, setup, document, outcome)
455 return outcome
456 })()
457 this.running.set(document.text, job)
458 void job.finally(() => this.running.delete(document.text))
459 return job
460 }
461}
462
463/** How long dumping the format may take. */
464const FORMAT_MS = 60_000
465
466/**
467 * Makes the fragment format available (setup.format): the one dumped
468 * earlier into the cache directory, else dumped now from FORMAT_SOURCE (no
469 * source of the model's in it), confined as every job is, and copied there.
470 * Without a cache directory, or where mylatexformat is missing, there is no
471 * format and fragments load their packages each time.
472 */
473export async function prepareFormat(host: TexHost, setup: TexSetup): Promise<void> {
474 if (!setup.cacheDir) return
475 const name = formatName(setup.versions)
476 const dir = `${setup.cacheDir}/fmt`
477 const path = `${dir}/${name}.fmt`
478 const run = (argv: readonly string[], init: { cwd?: string; env?: Record<string, string>; timeoutMs: number }) => host.run(argv, init).catch(() => ({ exitCode: 1, stdout: '', stderr: '', isStdoutTruncated: false }))
479 if ((await run(['test', '-s', path], { timeoutMs: PROBE_MS })).exitCode === 0) {
480 setup.format = { name, dir }
481 return
482 }
483 const made = await run(['mktemp', '-d', jobDirTemplate(setup.tmpdir)], { timeoutMs: PROBE_MS })
484 const job = made.stdout.trim()
485 if (made.exitCode !== 0 || !isJobDir(job)) return
486 try {
487 await host.write(`${job}/${name}.tex`, FORMAT_SOURCE)
488 const jail = sandboxed(setup)
489 const dumped = await run(confined(formatArgv(name), job, setup.confinement, jail.binds), { cwd: job, env: { ...texEnvironment(job), ...jail.env }, timeoutMs: FORMAT_MS })
490 if (dumped.exitCode !== 0) return
491 if ((await run(['mkdir', '-p', '--', dir], { timeoutMs: PROBE_MS })).exitCode !== 0) return
492 // Copied under a name of its own, then renamed: a session starting meanwhile never reads half a format.
493 const part = `${path}.${job.slice(-10)}`
494 if ((await run(['cp', '--', `${job}/${name}.fmt`, part], { timeoutMs: PROBE_MS })).exitCode !== 0) return
495 if ((await run(['mv', '-f', '--', part, path], { timeoutMs: PROBE_MS })).exitCode !== 0) return
496 setup.format = { name, dir }
497 // The formats of an earlier TeX (or kittex preamble) are superseded: removed, about 11 MB each.
498 await run(['find', dir, '-maxdepth', '1', '-type', 'f', '-name', 'kittex-*.fmt', '!', '-name', `${name}.fmt`, '-delete'], { timeoutMs: PROBE_MS })
499 } finally {
500 await run(['rm', '-rf', '--', job], { timeoutMs: PROBE_MS })
501 }
502}
503
504/** The process's book: register.tsx gives it its host and setup, math.ts reads it. */
505export const texBook = new TexBook()
506
507async function cacheKey(document: TexDocument, setup: TexSetup): Promise<string> {
508 const bytes = new TextEncoder().encode(`kittex-tex ${PREAMBLE_VERSION}\n${setup.versions}\n${document.baseline} ${document.fontSize}\n${document.text}`)
509 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
510 return [...digest].map(byte => byte.toString(16).padStart(2, '0')).join('')
511}
512
513async function store(host: TexHost, setup: TexSetup, document: TexDocument, outcome: TexOutcome): Promise<void> {
514 try {
515 const svg = outcomeSvgs.get(outcome)
516 const body = outcome.ok ? (svg === undefined ? undefined : { ok: true, svg }) : { ok: false, error: outcome.error }
517 if (body) await host.write(`${setup.cacheDir}/${await cacheKey(document, setup)}.json`, JSON.stringify(body))
518 } catch {
519 // not cached
520 }
521}
522
523/** The SVG each compiled outcome was read from, for the disk cache. */
524const outcomeSvgs = new WeakMap<TexOutcome, string>()
525
526/**
527 * One job: a fresh directory (mktemp), the document written into it, latex,
528 * dvisvgm (SVG on stdout), the SVG read into a Picture, the directory removed.
529 * latex and dvisvgm run with what is left until `deadline` (the time asked
530 * for, `timeoutMs`, counts from the ask: a job waiting behind another uses it).
531 */
532async function compileOnce(host: TexHost, setup: TexSetup, document: TexDocument, deadline: number, timeoutMs: number): Promise<TexOutcome> {
533 const left = () => deadline - Date.now()
534 const late: TexOutcome = { ok: false, error: `TeX took longer than ${(timeoutMs / 1000).toFixed(0)} s`, lasting: false }
535 if (left() < 50) return late
536 const made = await host.run(['mktemp', '-d', jobDirTemplate(setup.tmpdir)], { timeoutMs: PROBE_MS })
537 const dir = made.stdout.trim()
538 if (made.exitCode !== 0 || !isJobDir(dir)) return { ok: false, error: 'no temporary directory for TeX', lasting: false }
539 try {
540 await host.write(`${dir}/${JOB_NAME}.tex`, document.text)
541 const jail = sandboxed(setup)
542 const env = { ...texEnvironment(dir), ...jail.env }
543 let latex
544 // A fragment starts from the dumped format, read where the namespace hides the cache directory.
545 const format = document.format ? setup.format : undefined
546 try {
547 latex = format
548 ? await host.run(confined(latexArgv(format.name), dir, setup.confinement, [...jail.binds, format.dir]), { cwd: dir, env: { ...env, TEXFORMATS: `${format.dir}:` }, timeoutMs: Math.max(1, left()) })
549 : await host.run(confined(LATEX_ARGV, dir, setup.confinement, jail.binds), { cwd: dir, env, timeoutMs: Math.max(1, left()) })
550 // A format TeX can't read (a stale or broken file): the same job without it.
551 if (format && latex.exitCode !== 0 && /format file|\.fmt\b/i.test(latex.stdout)) {
552 latex = await host.run(confined(LATEX_ARGV, dir, setup.confinement, jail.binds), { cwd: dir, env, timeoutMs: Math.max(1, left()) })
553 }
554 } catch {
555 return late
556 }
557 if (latex.exitCode !== 0) {
558 const log = `${latex.stdout}\n${(await host.read(`${dir}/${JOB_NAME}.log`)) ?? ''}`
559 return { ok: false, error: texError(log, document.offset), lasting: true }
560 }
561 // A dvisvgm that would run PostScript through Ghostscript unconfined (setup.postscript): only a DVI without any.
562 if (setup.postscript && !setup.confinement.bwrap) {
563 const bytes = await host.readBytes?.(`${dir}/${JOB_NAME}.dvi`).catch(() => undefined)
564 const specials = bytes ? dviSpecials(bytes) : undefined
565 if (!specials || hasPostScript(specials)) return { ok: false, error: 'uses PostScript (a rotation or scaling, say), which this dvisvgm runs through Ghostscript outside any sandbox', lasting: true }
566 }
567 let svg
568 try {
569 if (left() < 1) return late
570 svg = await host.run(confined(dvisvgmCommand(dir, setup), dir, setup.confinement, jail.binds), { cwd: dir, env, timeoutMs: Math.max(1, left()) })
571 } catch {
572 return late
573 }
574 if (svg.exitCode !== 0 || svg.isStdoutTruncated || !svg.stdout.includes('<svg')) {
575 return { ok: false, error: svg.isStdoutTruncated ? 'the picture is too large' : firstLine(svg.stderr) || 'dvisvgm failed', lasting: true }
576 }
577 try {
578 const outcome: TexOutcome = { ok: true, picture: texPicture(svg.stdout, document) }
579 outcomeSvgs.set(outcome, svg.stdout)
580 return outcome
581 } catch (error) {
582 if (error instanceof SvgError || error instanceof XmlError) return { ok: false, error: error.message, lasting: true }
583 throw error
584 }
585 } finally {
586 await host.run(['rm', '-rf', '--', dir], { timeoutMs: PROBE_MS }).catch(() => undefined)
587 }
588}
589
590/**
591 * One compile of `document` outside the book: nothing remembered, nothing
592 * cached (/kittex-doctor's trial picture), within `timeoutMs`.
593 */
594export function compileTrial(host: TexHost, setup: TexSetup, document: TexDocument, timeoutMs: number): Promise<TexOutcome> {
595 return compileOnce(host, setup, document, Date.now() + timeoutMs, timeoutMs).catch((error: unknown): TexOutcome => ({ ok: false, error: String(error), lasting: false }))
596}
597
598function firstLine(text: string): string {
599 return (text.split('\n').find(line => line.trim() !== '') ?? '').replace(/^\s*(?:ERROR|WARNING):?\s*/i, '').trim().slice(0, 200)
600}
601
602// ─── Which segments go to TeX, as which document ─────────────────────────────
603
604/** A diagram segment's kind (the scanner's `diagram` mark). */
605export type DiagramKind = 'latex' | 'tikz' | 'env'
606
607/** What becomes of a diagram: compiled as `document`, refused (`refused`: why), or no drawing at all (left as code). */
608export type DiagramJob = { document: TexDocument; label: string } | { refused: string } | undefined
609
610/**
611 * The job of a diagram (a ```latex, ```tex or ```tikz fence's content, or a
612 * bare environment): undefined for LaTeX that draws nothing (a formula or a
613 * preamble shown as code stays code), refused for a source unsafeTex refuses.
614 */
615export function diagramJob(source: string, kind: DiagramKind): DiagramJob {
616 const lang = kind === 'env' ? 'env' : kind
617 if (!drawsPicture(source, lang)) return undefined
618 const refused = unsafeTex(source)
619 if (refused !== undefined) return { refused }
620 return { document: diagramDocument(source, lang), label: diagramLabel(source) }
621}
622
623/** The document a formula MathJax refused compiles as, or undefined when its source is refused. */
624export function mathJob(tex: string, display: boolean): TexDocument | undefined {
625 return unsafeTex(tex) === undefined ? mathDocument(tex, display) : undefined
626}
627
628/** A word for what a diagram draws, shown in its placeholder while it waits to land. */
629export function diagramLabel(source: string): string {
630 if (/\\begin\{(?:axis|semilogxaxis|semilogyaxis|loglogaxis|polaraxis)\}/.test(source)) return 'plot'
631 if (/\\begin\{circuitikz\}|\\ctikzset/.test(source)) return 'circuit'
632 if (/\\begin\{tikzcd\}/.test(source)) return 'commutative diagram'
633 if (/\\chemfig|\\schemestart/.test(source)) return 'molecule'
634 if (/\\documentclass/.test(source)) return 'LaTeX document'
635 return 'diagram'
636}
637
638const results = new WeakMap<Picture, TypesetResult>()
639
640/** A formula TeX drew, as one ink's draw ops (texFormula), worked out once per picture. */
641export function texResult(picture: Picture): TypesetResult {
642 let result = results.get(picture)
643 if (!result) {
644 result = texFormula(picture)
645 results.set(picture, result)
646 }
647 return result
648}
649
650/** A setup as it is remembered for the next session ($.store): plain data, or undefined when it isn't one. */
651export function rememberedTex(value: unknown): TexSetup | undefined {
652 if (typeof value !== 'object' || value === null) return undefined
653 const setup = value as Partial<TexSetup>
654 if (typeof setup.versions !== 'string' || typeof setup.cacheDir !== 'string' || typeof setup.confinement !== 'object' || setup.confinement === null) return undefined
655 return {
656 versions: setup.versions,
657 confinement: setup.confinement,
658 tmpdir: typeof setup.tmpdir === 'string' ? setup.tmpdir : undefined,
659 cacheDir: setup.cacheDir,
660 ...(setup.format && typeof setup.format.name === 'string' && typeof setup.format.dir === 'string' ? { format: setup.format } : {}),
661 ...(setup.libgs === false ? { libgs: false as const } : {}),
662 ...(setup.postscript === true ? { postscript: true as const } : {}),
663 ...(setup.sandbox && Array.isArray(setup.sandbox.binds) && setup.sandbox.binds.every(bind => typeof bind === 'string')
664 ? { sandbox: { binds: setup.sandbox.binds, ...(typeof setup.sandbox.path === 'string' ? { path: setup.sandbox.path } : {}) } }
665 : {}),
666 }
667}
668hooks/doctor.ts 787 lines1// /kittex-doctor: what kittex depends on, checked, and for what is missing,
2// what to install. Nothing here takes `$`: register.tsx gathers the facts it
3// holds (the terminal, the options, the streaming hooks seen) and hands a
4// DoctorHost of closures over `$` for the probes; this file runs the probes,
5// maps a missing piece to the package that ships it on the host's OS, and
6// writes the report.
7//
8// The report is a CommandOutput row, drawn as markdown (the engine's own
9// drawing: kittex hooks AssistantMessage only, so none of it is read as
10// math). It holds no `$`, and every name from the host (a path, a TeX error)
11// goes in code spans or through `plain`, so no markdown in it changes the row.
12
13import { diagramDocument, formatName } from './core.js'
14import { compileTrial, probeSandbox, runsPostScript, takesLibgs, texCacheDir } from './tex.ts'
15import { CACHE_LIMIT_BYTES, TEX_CACHE_LIMIT_BYTES } from './cache.ts'
16import type { Sandbox, SandboxProbe, TexHost, TexSetup } from './tex.ts'
17
18/** The command's name: typed as `/kittex-doctor`. */
19export const DOCTOR_COMMAND = 'kittex-doctor'
20export const DOCTOR_DESCRIPTION = "Check kittex's terminal, streaming and local TeX setup, and what to install for diagrams"
21
22/** How long one probe command may run. */
23export const DOCTOR_PROBE_MS = 2000
24/** How long the trial picture may take (TeX loading every picture package without the format takes about a second). */
25export const DOCTOR_TRIAL_MS = 8000
26
27/** The fixed picture the doctor compiles: the only source it ever gives TeX. */
28export const TRIAL_PICTURE = '\\draw[->] (0,0) -- (1,0.6) node[right] {$x$};\n\\draw (0,0) circle (0.3);'
29
30// ─── What TeX needs ──────────────────────────────────────────────────────────
31
32/** A command or a TeX file kittex's documents need, and what it costs when missing. */
33export interface Requirement {
34 name: string
35 kind: 'command' | 'file'
36 /**
37 * `pictures`: every diagram (one preamble loads every picture package);
38 * `math`: the formulas MathJax refuses; `speed`: the dumped format;
39 * `check`: this doctor's package check; `confine`: confinement (Linux).
40 */
41 need: 'pictures' | 'math' | 'speed' | 'check' | 'confine' | 'postscript'
42}
43
44/** The commands, in the order the report lists them. */
45export const TEX_COMMANDS: readonly Requirement[] = [
46 { name: 'latex', kind: 'command', need: 'pictures' },
47 { name: 'dvisvgm', kind: 'command', need: 'pictures' },
48 { name: 'kpsewhich', kind: 'command', need: 'check' },
49]
50
51/** What confines TeX on Linux. */
52export const CONFINE_COMMANDS: readonly Requirement[] = [
53 { name: 'bwrap', kind: 'command', need: 'confine' },
54 { name: 'prlimit', kind: 'command', need: 'confine' },
55]
56
57/**
58 * The files the documents load (core/src/diagram/tex.ts: the fragment
59 * preamble, the format's mylatexformat, the math preamble), and three a
60 * distribution may split off: chemfig's simplekv, the Computer Modern
61 * outlines dvisvgm draws the glyphs from, and dvips's PostScript header
62 * (tex.pro), which a dvisvgm that runs PostScript through Ghostscript reads
63 * first (kittex's own documents carry no PostScript; a whole document that
64 * rotates or scales with graphicx does).
65 */
66export const TEX_FILES: readonly Requirement[] = [
67 { name: 'standalone.cls', kind: 'file', need: 'pictures' },
68 { name: 'amsmath.sty', kind: 'file', need: 'pictures' },
69 { name: 'amssymb.sty', kind: 'file', need: 'pictures' },
70 { name: 'tikz.sty', kind: 'file', need: 'pictures' },
71 { name: 'pgfplots.sty', kind: 'file', need: 'pictures' },
72 { name: 'tikz-cd.sty', kind: 'file', need: 'pictures' },
73 { name: 'circuitikz.sty', kind: 'file', need: 'pictures' },
74 { name: 'chemfig.sty', kind: 'file', need: 'pictures' },
75 { name: 'simplekv.tex', kind: 'file', need: 'pictures' },
76 { name: 'siunitx.sty', kind: 'file', need: 'pictures' },
77 { name: 'cmr10.pfb', kind: 'file', need: 'pictures' },
78 { name: 'tex.pro', kind: 'file', need: 'postscript' },
79 { name: 'mathtools.sty', kind: 'file', need: 'math' },
80 { name: 'preview.sty', kind: 'file', need: 'math' },
81 { name: 'mylatexformat.ltx', kind: 'file', need: 'speed' },
82]
83
84// ─── Which OS, which packages ────────────────────────────────────────────────
85
86/** How the host installs TeX: its distribution's packages, TeX Live's own tlmgr (macOS, an installer's TeX Live), or unknown. */
87export type InstallFamily = 'arch' | 'debian' | 'fedora' | 'macos' | 'texlive'
88
89/** The OS as read from `uname -s` and /etc/os-release. */
90export interface OsFacts {
91 /** `linux`, `darwin`... (uname -s, lowercased). */
92 platform?: string
93 /** /etc/os-release's ID, ID_LIKE and PRETTY_NAME. */
94 id?: string
95 idLike?: readonly string[]
96 name?: string
97}
98
99/** /etc/os-release's variables (quotes removed). */
100export function parseOsRelease(text: string): Record<string, string> {
101 const out: Record<string, string> = {}
102 for (const line of text.split('\n')) {
103 const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim())
104 if (m) out[m[1]!] = m[2]!.replace(/^(["'])(.*)\1$/, '$2')
105 }
106 return out
107}
108
109/** The OS facts from uname's output and /etc/os-release's text (either may be missing). */
110export function osFacts(uname: string | undefined, osRelease: string | undefined): OsFacts {
111 const platform = uname?.trim().toLowerCase() || undefined
112 const vars = osRelease ? parseOsRelease(osRelease) : {}
113 const idLike = (vars.ID_LIKE ?? '').split(/\s+/).filter(Boolean)
114 return {
115 ...(platform ? { platform } : {}),
116 ...(vars.ID ? { id: vars.ID.toLowerCase() } : {}),
117 ...(idLike.length > 0 ? { idLike: idLike.map(one => one.toLowerCase()) } : {}),
118 ...(vars.PRETTY_NAME || vars.NAME ? { name: vars.PRETTY_NAME || vars.NAME } : {}),
119 }
120}
121
122/**
123 * How TeX is installed here: by the distribution's packages (Arch and its
124 * derivatives, Debian and Ubuntu and theirs, Fedora), by tlmgr on macOS and
125 * where `latex` comes from TeX Live's own installer (a `texlive/<year>/bin`
126 * or `/Library/TeX` path), else TeX Live's way. RHEL and its rebuilds lack
127 * several of the packages, so they get TeX Live's way too.
128 */
129export function installFamily(os: OsFacts, latexPath?: string): InstallFamily {
130 if (os.platform === 'darwin') return 'macos'
131 if (latexPath && (/\/texlive\/\d{4}\/bin\//.test(latexPath) || latexPath.startsWith('/Library/TeX/'))) return 'texlive'
132 const ids = [os.id ?? '', ...(os.idLike ?? [])]
133 if (ids.includes('arch')) return 'arch'
134 if (ids.includes('debian') || ids.includes('ubuntu')) return 'debian'
135 if (os.id === 'fedora' || (ids.includes('fedora') && !ids.includes('rhel'))) return 'fedora'
136 return 'texlive'
137}
138
139/**
140 * The package that ships each requirement, per family. Checked against each
141 * distribution's own index (Arch: pacman -F; Debian 13 and Ubuntu 24.04:
142 * apt-file; Fedora 44: dnf repoquery --whatprovides 'tex(<file>)'; TeX Live:
143 * tlmgr search --file) and installed end to end in a container of each, where
144 * kittex's format, pictures and math compiled.
145 */
146export const PACKAGES: Readonly<Record<InstallFamily, Readonly<Record<string, string>>>> = {
147 arch: {
148 latex: 'texlive-latex',
149 dvisvgm: 'dvisvgm',
150 kpsewhich: 'texlive-bin',
151 bwrap: 'bubblewrap',
152 prlimit: 'util-linux',
153 'standalone.cls': 'texlive-latexextra',
154 'amsmath.sty': 'texlive-latex',
155 'amssymb.sty': 'texlive-basic',
156 'tikz.sty': 'texlive-pictures',
157 'pgfplots.sty': 'texlive-pictures',
158 'tikz-cd.sty': 'texlive-pictures',
159 'circuitikz.sty': 'texlive-pictures',
160 'chemfig.sty': 'texlive-pictures',
161 'simplekv.tex': 'texlive-plaingeneric',
162 'siunitx.sty': 'texlive-mathscience',
163 'cmr10.pfb': 'texlive-basic',
164 'tex.pro': 'texlive-basic',
165 'mathtools.sty': 'texlive-latexrecommended',
166 'preview.sty': 'texlive-latexextra',
167 'mylatexformat.ltx': 'texlive-latexextra',
168 },
169 debian: {
170 latex: 'texlive-latex-base',
171 dvisvgm: 'dvisvgm',
172 kpsewhich: 'texlive-binaries',
173 bwrap: 'bubblewrap',
174 prlimit: 'util-linux',
175 'standalone.cls': 'texlive-latex-extra',
176 'amsmath.sty': 'texlive-latex-base',
177 'amssymb.sty': 'texlive-base',
178 'tikz.sty': 'texlive-pictures',
179 'pgfplots.sty': 'texlive-pictures',
180 'tikz-cd.sty': 'texlive-pictures',
181 'circuitikz.sty': 'texlive-pictures',
182 'chemfig.sty': 'texlive-pictures',
183 'simplekv.tex': 'texlive-plain-generic',
184 'siunitx.sty': 'texlive-science',
185 'cmr10.pfb': 'texlive-base',
186 'tex.pro': 'texlive-base',
187 'mathtools.sty': 'texlive-latex-recommended',
188 'preview.sty': 'preview-latex-style',
189 'mylatexformat.ltx': 'texlive-latex-extra',
190 },
191 fedora: {
192 latex: 'texlive-latex',
193 dvisvgm: 'texlive-dvisvgm',
194 kpsewhich: 'texlive-kpathsea',
195 bwrap: 'bubblewrap',
196 prlimit: 'util-linux',
197 'standalone.cls': 'texlive-standalone',
198 'amsmath.sty': 'texlive-amsmath',
199 'amssymb.sty': 'texlive-amsfonts',
200 'tikz.sty': 'texlive-pgf',
201 'pgfplots.sty': 'texlive-pgfplots',
202 'tikz-cd.sty': 'texlive-tikz-cd',
203 'circuitikz.sty': 'texlive-circuitikz',
204 'chemfig.sty': 'texlive-chemfig',
205 'simplekv.tex': 'texlive-simplekv',
206 'siunitx.sty': 'texlive-siunitx',
207 'cmr10.pfb': 'texlive-amsfonts',
208 'tex.pro': 'texlive-dvips',
209 'mathtools.sty': 'texlive-mathtools',
210 'preview.sty': 'texlive-preview',
211 'mylatexformat.ltx': 'texlive-mylatexformat',
212 },
213 macos: TEXLIVE(),
214 texlive: TEXLIVE(),
215}
216
217/** TeX Live's package names (tlmgr), from scheme-basic up (BasicTeX holds more). */
218function TEXLIVE(): Record<string, string> {
219 return {
220 dvisvgm: 'dvisvgm',
221 'standalone.cls': 'standalone',
222 'amsmath.sty': 'amsmath',
223 'amssymb.sty': 'amsfonts',
224 'tikz.sty': 'pgf',
225 'pgfplots.sty': 'pgfplots',
226 'tikz-cd.sty': 'tikz-cd',
227 'circuitikz.sty': 'circuitikz',
228 'chemfig.sty': 'chemfig',
229 'simplekv.tex': 'simplekv',
230 'siunitx.sty': 'siunitx',
231 'cmr10.pfb': 'amsfonts',
232 'tex.pro': 'dvips',
233 'mathtools.sty': 'mathtools',
234 'preview.sty': 'preview',
235 'mylatexformat.ltx': 'mylatexformat',
236 }
237}
238
239/** Every requirement a family installs, in order (the whole set: what a host with no TeX at all needs). */
240const ALL = [...TEX_COMMANDS, ...TEX_FILES].map(one => one.name)
241
242/**
243 * The whole set as a host with no TeX installs it, where a shorter list
244 * brings the rest as dependencies (each installed as is in a fresh container:
245 * Arch, Debian 13 and Ubuntu 24.04 without recommends; the README gives the
246 * same lines).
247 */
248const FULL: Partial<Record<InstallFamily, readonly string[]>> = {
249 arch: ['texlive-basic', 'texlive-latex', 'texlive-latexrecommended', 'texlive-latexextra', 'texlive-pictures', 'texlive-mathscience', 'texlive-plaingeneric', 'dvisvgm', 'bubblewrap'],
250 debian: ['texlive-latex-extra', 'texlive-pictures', 'texlive-science', 'texlive-plain-generic', 'preview-latex-style', 'dvisvgm', 'bubblewrap'],
251}
252
253/** The install advice: a heading and the commands, or nothing when nothing is missing. */
254export interface InstallAdvice {
255 family: InstallFamily
256 /** The OS as the heading names it. */
257 label: string
258 /** Only confinement is missing (diagrams already draw). */
259 confineOnly: boolean
260 /** Shell commands, one per line, in order. */
261 commands: string[]
262 /** A line after them (another way, a caveat), as markdown, when there is one. */
263 note?: string
264}
265
266/**
267 * What to install for the requirements `missing` (names from TEX_COMMANDS,
268 * CONFINE_COMMANDS, TEX_FILES): everything when `latex` is missing, else the
269 * packages that ship what is missing. Undefined when nothing is.
270 */
271export function installAdvice(os: OsFacts, missing: readonly string[], latexPath?: string): InstallAdvice | undefined {
272 if (missing.length === 0) return undefined
273 const family = installFamily(os, latexPath)
274 const linux = os.platform !== 'darwin'
275 const noTex = missing.includes('latex')
276 // With latex, everything (its own package brings kpsewhich); bubblewrap with it on Linux.
277 const names = noTex ? [...new Set([...ALL.filter(name => name !== 'kpsewhich'), ...missing, ...(linux ? ['bwrap'] : [])])] : [...missing]
278 const confineOnly = names.every(name => name === 'bwrap' || name === 'prlimit')
279 const map = PACKAGES[family]
280 const full = noTex ? FULL[family] : undefined
281 const packages = full ? [...full] : [...new Set(names.map(name => map[name]).filter((one): one is string => one !== undefined))]
282 const label = family === 'macos' ? 'macOS' : family === 'texlive' && !os.name ? 'TeX Live' : (os.name ?? family)
283 const base = { family, label, confineOnly }
284 switch (family) {
285 case 'arch':
286 return { ...base, commands: [`sudo pacman -S --needed ${packages.join(' ')}`] }
287 case 'debian':
288 return { ...base, commands: [`sudo apt install ${packages.join(' ')}`] }
289 case 'fedora':
290 return { ...base, commands: [`sudo dnf install ${packages.join(' ')}`] }
291 case 'macos': {
292 if (!noTex) return { ...base, commands: [`sudo tlmgr install ${packages.join(' ')}`] }
293 return {
294 ...base,
295 commands: ['brew install --cask basictex', 'sudo /Library/TeX/texbin/tlmgr update --self', `sudo /Library/TeX/texbin/tlmgr install ${packages.join(' ')}`],
296 note: 'Or the full MacTeX, everything included (about 6 GB): `brew install --cask mactex-no-gui`.',
297 }
298 }
299 case 'texlive': {
300 const confine = linux ? [...new Set(names.filter(name => name === 'bwrap' || name === 'prlimit').map(name => (name === 'bwrap' ? 'bubblewrap' : 'util-linux')))] : []
301 const notes = [
302 noTex ? 'Install TeX Live first (https://tug.org/texlive, its basic scheme is enough), then run the line above.' : packages.length > 0 ? 'With sudo where TeX Live is installed system-wide.' : '',
303 confine.length > 0 ? `Also install ${confine.map(code).join(' and ')} from your distribution, so TeX runs confined.` : '',
304 ].filter(Boolean)
305 return { ...base, commands: packages.length > 0 ? [`tlmgr install ${packages.join(' ')}`] : [], ...(notes.length > 0 ? { note: notes.join(' ') } : {}) }
306 }
307 }
308}
309
310// ─── The probes ──────────────────────────────────────────────────────────────
311
312/** What the probes need from the host: TexHost's commands and files, and a path's existence and size. */
313export interface DoctorHost extends TexHost {
314 exists(path: string): Promise<boolean>
315 /** A file's size in bytes, or undefined when it isn't there. */
316 size(path: string): Promise<number | undefined>
317}
318
319/** A command as found: where on PATH, and its `--version` line. */
320export interface ToolFacts {
321 path?: string
322 version?: string
323}
324
325/** The trial picture's compile. */
326export type TrialFacts = { ok: true; ms: number; format: boolean } | { ok: false; ms: number; error: string } | { skipped: string }
327
328export interface DiagramFacts {
329 /** The `latex` option. */
330 option: 'auto' | 'off'
331 /** Whether kittex draws diagrams here at all: images on this terminal, block math as images. */
332 drawn: boolean
333 /** Whether this session's kittex found TeX when it started (it looks once, at session start). */
334 found: boolean
335 latex: ToolFacts
336 dvisvgm: ToolFacts
337 kpsewhich: ToolFacts
338 tlmgr: ToolFacts
339 /** Each TEX_FILES name with the path kpsewhich gave; undefined when kpsewhich couldn't run. */
340 files?: { name: string; path?: string }[]
341 /** Linux only: bubblewrap found and whether its trial namespace runs (else its first error line). */
342 /** Linux only: bubblewrap found, whether TeX runs in its namespace (else why not), and what it binds back. */
343 bwrap?: ToolFacts & { usable: boolean; error?: string; sandbox?: Sandbox }
344 prlimit?: ToolFacts
345 /** The dumped format for this TeX (prepareFormat), when latex and dvisvgm answered. */
346 format?: { path: string; bytes?: number }
347 /** Whether dvisvgm takes --libgs (false: kittex leaves it out). */
348 libgs?: boolean
349 /** dvisvgm runs PostScript specials through a Ghostscript it links, whatever kittex asks (3.5 to 3.6.1). */
350 postscript?: boolean
351 trial: TrialFacts
352}
353
354export interface CacheFacts {
355 /** ~/.cache/kittex (XDG_CACHE_HOME's kittex). */
356 dir?: string
357 bytes?: number
358 files?: number
359 /** Its parts: the images of resumed replies (`v<n>/`), what TeX drew (`tex/`), the TeX format (`tex/fmt/`). */
360 images?: CachePart
361 tex?: CachePart
362 format?: CachePart
363}
364
365export interface CachePart {
366 bytes?: number
367 files: number
368}
369
370/** What the probes start from. */
371export interface ProbeInput {
372 option: 'auto' | 'off'
373 drawn: boolean
374 found: boolean
375 os: OsFacts
376 env: { PATH?: string; HOME?: string; TMPDIR?: string; XDG_CACHE_HOME?: string }
377 /** The directories a confined job can't see (tex.ts hiddenDirs). */
378 hide: readonly string[]
379}
380
381/** The first non-empty line of a command's output. */
382function firstLine(text: string | undefined): string | undefined {
383 return text?.split('\n').map(line => line.trim()).find(line => line !== '') || undefined
384}
385
386/** The first directory on PATH holding `name`. */
387export async function which(host: Pick<DoctorHost, 'exists'>, name: string, path: string | undefined): Promise<string | undefined> {
388 const dirs = (path ?? '').split(':').filter(dir => dir.startsWith('/'))
389 const found = await Promise.all(dirs.map(dir => host.exists(`${dir.replace(/\/+$/, '')}/${name}`).catch(() => false)))
390 const at = found.indexOf(true)
391 return at < 0 ? undefined : `${dirs[at]!.replace(/\/+$/, '')}/${name}`
392}
393
394/**
395 * Probes the local TeX: the commands (PATH and `--version`), the files
396 * (kpsewhich), the confinement (Linux), the format, and once latex and
397 * dvisvgm answer, the trial picture. The commands run at once; only the
398 * trial waits for them. TeX never sees anything but TRIAL_PICTURE.
399 */
400export async function probeDiagrams(host: DoctorHost, input: ProbeInput): Promise<DiagramFacts> {
401 const run = (argv: readonly string[], timeoutMs = DOCTOR_PROBE_MS) =>
402 host.run(argv, { timeoutMs }).then(
403 result => result,
404 () => undefined,
405 )
406 const version = async (name: string, path: string | undefined): Promise<ToolFacts> => {
407 if (!path) return {}
408 const out = await run([name, '--version'])
409 const line = out?.exitCode === 0 ? firstLine(out.stdout) : undefined
410 return { path, ...(line ? { version: line } : {}) }
411 }
412 const linux = input.os.platform !== 'darwin'
413 const PATH = input.env.PATH
414 const [latexAt, dvisvgmAt, kpsewhichAt, tlmgrAt, bwrapAt, prlimitAt] = await Promise.all(
415 ['latex', 'dvisvgm', 'kpsewhich', 'tlmgr', 'bwrap', 'prlimit'].map(name => (linux || (name !== 'bwrap' && name !== 'prlimit') ? which(host, name, PATH) : Promise.resolve(undefined))),
416 )
417 const [latex, dvisvgm, kpsewhich, prlimit, help, extended, kpse, bwrapRun] = await Promise.all([
418 version('latex', latexAt),
419 version('dvisvgm', dvisvgmAt),
420 version('kpsewhich', kpsewhichAt),
421 linux ? version('prlimit', prlimitAt) : Promise.resolve(undefined),
422 dvisvgmAt ? run(['dvisvgm', '--help']) : Promise.resolve(undefined),
423 dvisvgmAt ? run(['dvisvgm', '-V1']) : Promise.resolve(undefined),
424 kpsewhichAt ? run(['kpsewhich', ...TEX_FILES.map(file => file.name)]) : Promise.resolve(undefined),
425 // The sandbox's own trial (latex and dvisvgm inside it), not run where Local LaTeX is off.
426 linux && bwrapAt && latexAt && dvisvgmAt && input.hide.length > 0 && input.option === 'auto'
427 ? probeSandbox(host, { hide: input.hide, tmpdir: input.env.TMPDIR, path: PATH, prlimit: prlimitAt !== undefined })
428 : Promise.resolve(undefined as SandboxProbe | undefined),
429 ])
430 const tlmgr: ToolFacts = tlmgrAt ? { path: tlmgrAt } : {}
431 // kpsewhich prints the path of each file it finds (exit 1 when any is missing).
432 const found = kpse?.stdout.split('\n').map(line => line.trim()).filter(Boolean) ?? []
433 const files = kpse ? TEX_FILES.map(({ name }) => ({ name, path: found.find(path => path === name || path.endsWith(`/${name}`)) })).map(({ name, path }) => (path ? { name, path } : { name })) : undefined
434 const bwrap: DiagramFacts['bwrap'] = linux
435 ? {
436 ...(bwrapAt ? { path: bwrapAt } : {}),
437 usable: bwrapRun?.ok === true,
438 ...(bwrapRun?.ok ? { sandbox: bwrapRun.sandbox } : bwrapRun ? { error: bwrapRun.error } : bwrapAt && input.option === 'auto' && latexAt && dvisvgmAt ? { error: 'not tried' } : {}),
439 }
440 : undefined
441 const libgs = help?.exitCode === 0 ? takesLibgs(help.stdout) : undefined
442 const postscript = libgs === false && dvisvgm.version !== undefined && runsPostScript(dvisvgm.version, extended?.exitCode === 0 ? extended.stdout : undefined)
443 const facts: DiagramFacts = {
444 option: input.option,
445 drawn: input.drawn,
446 found: input.found,
447 latex,
448 dvisvgm,
449 kpsewhich,
450 tlmgr,
451 ...(files ? { files } : {}),
452 ...(bwrap ? { bwrap } : {}),
453 ...(prlimit ? { prlimit } : {}),
454 ...(libgs !== undefined ? { libgs } : {}),
455 ...(postscript ? { postscript } : {}),
456 trial: { skipped: '' },
457 }
458 // The format kittex dumps for this TeX (named by the versions, as tex.ts's probeTex reads them).
459 const cacheDir = texCacheDir(input.env)
460 let format: TexSetup['format']
461 if (latex.version && dvisvgm.version && cacheDir) {
462 const name = formatName(`${latex.version}\n${dvisvgm.version}`)
463 const path = `${cacheDir}/fmt/${name}.fmt`
464 const bytes = await host.size(path).catch(() => undefined)
465 facts.format = { path, ...(bytes !== undefined ? { bytes } : {}) }
466 if (bytes !== undefined && bytes > 0) format = { name, dir: `${cacheDir}/fmt` }
467 }
468 if (!latex.version || !dvisvgm.version) {
469 facts.trial = { skipped: !latex.path ? 'no latex' : !dvisvgm.path ? 'no dvisvgm' : `${latex.version ? 'dvisvgm' : 'latex'} does not answer` }
470 } else if (input.option === 'off') {
471 facts.trial = { skipped: 'Local LaTeX is off' }
472 } else {
473 const setup: TexSetup = {
474 versions: `${latex.version}\n${dvisvgm.version}`,
475 confinement: { prlimit: !linux ? false : prlimit?.path !== undefined, ...(bwrap?.usable ? { bwrap: { hide: input.hide } } : {}) },
476 tmpdir: input.env.TMPDIR,
477 ...(format ? { format } : {}),
478 ...(libgs === false ? { libgs: false as const } : {}),
479 ...(postscript ? { postscript: true as const } : {}),
480 ...(bwrap?.sandbox ? { sandbox: bwrap.sandbox } : {}),
481 }
482 const started = Date.now()
483 const outcome = await compileTrial(host, setup, diagramDocument(TRIAL_PICTURE, 'tikz'), DOCTOR_TRIAL_MS)
484 const ms = Date.now() - started
485 facts.trial = outcome.ok ? { ok: true, ms, format: format !== undefined } : { ok: false, ms, error: outcome.error }
486 }
487 return facts
488}
489
490/**
491 * The cache directory's size and file count (du, find), or just its path
492 * when it isn't there, with its parts apart: the images (pruned past 50 MB),
493 * what TeX drew (past 20 MB) and the TeX format (never pruned, replaced for
494 * a new TeX).
495 */
496export async function probeCache(host: DoctorHost, env: ProbeInput['env']): Promise<CacheFacts> {
497 const tex = texCacheDir(env)
498 if (!tex) return {}
499 const dir = tex.replace(/\/tex$/, '')
500 if (!(await host.exists(dir).catch(() => false))) return { dir }
501 const [du, find] = await Promise.all([
502 host.run(['du', '-sk', dir, `${dir}/tex`, `${dir}/tex/fmt`], { timeoutMs: DOCTOR_PROBE_MS }).catch(() => undefined),
503 host.run(['find', dir, '-type', 'f'], { timeoutMs: DOCTOR_PROBE_MS }).catch(() => undefined),
504 ])
505 // du prints a line per path it could read (a missing one only on stderr): kB, a tab, the path.
506 const kb = new Map<string, number>()
507 for (const line of du?.stdout.split('\n') ?? []) {
508 const match = /^(\d+)\s+(.+)$/.exec(line.trim())
509 if (match) kb.set(match[2]!.replace(/\/+$/, ''), Number(match[1]) * 1024)
510 }
511 const files = find?.exitCode === 0 || (find && find.stdout.trim() !== '') ? find.stdout.split('\n').filter(line => line.trim() !== '') : undefined
512 const all = kb.get(dir)
513 const texAll = kb.get(`${dir}/tex`)
514 const fmt = kb.get(`${dir}/tex/fmt`)
515 const facts: CacheFacts = { dir, ...(all !== undefined ? { bytes: all } : {}), ...(files ? { files: files.length } : {}) }
516 if (files) {
517 const formats = files.filter(path => path.startsWith(`${dir}/tex/fmt/`)).length
518 const drawn = files.filter(path => path.startsWith(`${dir}/tex/`)).length - formats
519 const images = files.length - formats - drawn
520 const texOnly = texAll !== undefined ? texAll - (fmt ?? 0) : undefined
521 facts.images = { files: images, ...(all !== undefined && texAll !== undefined ? { bytes: all - texAll } : {}) }
522 facts.tex = { files: drawn, ...(texOnly !== undefined ? { bytes: texOnly } : {}) }
523 facts.format = { files: formats, ...(fmt !== undefined ? { bytes: fmt } : {}) }
524 }
525 return facts
526}
527
528// ─── The report ──────────────────────────────────────────────────────────────
529
530export interface TerminalFacts {
531 kind: 'kitty' | 'ghostty' | 'wezterm' | 'iterm2' | 'other'
532 images: boolean
533 multiplexer?: string
534 ssh?: boolean
535 /** TERM, and TERM_PROGRAM with its version, as the environment says. */
536 term?: string
537 program?: string
538 /** The cell in pixels, and whether it was measured (else the fallback). */
539 cell?: { width: number; height: number; measured: boolean }
540 /** The text font as the terminal's config names it, and its metrics as kittex read them. */
541 font?: { family?: string; style?: string; sizePt?: number; file?: string; source?: 'kitty' | 'fontconfig' | 'ghostty'; metrics: boolean }
542 /** The formulas' ink and the background, as #rrggbb, and where they came from. */
543 ink?: string
544 background?: string
545 colors: 'terminal' | 'theme'
546 /**
547 * Claude Code's own decision on drawing pictures (its probe of the
548 * terminal), as a blit to one of kittex's Images read it: `unknown` before
549 * kittex drew one, `pending` while Claude Code hasn't asked the terminal.
550 */
551 claude?: { state: 'unknown' | 'pending' | 'yes' | 'no'; source?: string }
552}
553
554export interface StreamingFacts {
555 /** kittex is off (block and inline both raw): no hook rewrites anything. */
556 off: boolean
557 /** Text blocks that streamed through kittex's MessageDisplay hook this session (the last few). */
558 streamed: number
559 /** Replies that landed with no stream through that hook (the last few). */
560 unstreamed: number
561 /** Whether kittex's system-prompt section was in the composed prompt; undefined when not asked (kittex off, not a terminal). */
562 section?: boolean
563 /** Whether managed settings (policy) are present. */
564 managed: boolean
565}
566
567export interface DoctorFacts {
568 kittex: { version?: string; build?: string }
569 claudeCode?: string
570 /** Where the session draws: `terminal`, a remote surface, or `none` (a -p run or the SDK with no client attached). */
571 surface?: string
572 /** The remote surfaces drawing beside the terminal (a phone attached), when there are any. */
573 clients?: readonly string[]
574 terminal?: TerminalFacts
575 streaming: StreamingFacts
576 /** The options as set, by name, shown as given. */
577 options: Readonly<Record<string, string>>
578 diagrams: DiagramFacts
579 cache: CacheFacts
580 os: OsFacts
581 /** The home directory, written as ~ in paths. */
582 home?: string
583}
584
585const OK = '✓'
586const NO = '✗'
587const INFO = '–'
588
589/** Free text from the host (a font's name, a TeX error) as markdown that draws as written. */
590export function plain(text: string): string {
591 return text.replace(/`/g, "'").replace(/([\\*_[\]<>#|~])/g, '\\$1').replace(/\s+/g, ' ').trim()
592}
593
594/** A path or command as a code span (backticks in it, none expected, become quotes). */
595function code(text: string): string {
596 return `\`${text.replace(/`/g, "'")}\``
597}
598
599function bytes(n: number): string {
600 if (n < 1000) return `${n} B`
601 if (n < 1_000_000) return `${(n / 1000).toFixed(0)} kB`
602 return `${(n / 1_000_000).toFixed(1)} MB`
603}
604
605function seconds(ms: number): string {
606 return `${(ms / 1000).toFixed(2)} s`
607}
608
609const TERMINAL_NAMES: Record<TerminalFacts['kind'], string> = { kitty: 'kitty', ghostty: 'Ghostty', wezterm: 'WezTerm', iterm2: 'iTerm2', other: 'an unknown terminal' }
610
611/** The report, as the command's markdown text. */
612export function formatDoctor(facts: DoctorFacts): string {
613 const home = facts.home && facts.home !== '/' ? facts.home.replace(/\/+$/, '') : undefined
614 const path = (p: string) => code(home && (p === home || p.startsWith(`${home}/`)) ? `~${p.slice(home.length)}` : p)
615 const out: string[] = []
616 const line = (mark: string, text: string) => out.push(`${mark} ${text}`)
617 const section = (title: string) => {
618 if (out.length > 0) out.push('')
619 out.push(`**${title}**`)
620 }
621
622 // The row reads `kittex: ` first (the engine names the plugin that answered).
623 const head = [`${facts.kittex.version ?? 'version unknown'}${facts.kittex.build ? ` (build ${facts.kittex.build})` : ''}`]
624 if (facts.claudeCode) head.push(`Claude Code ${plain(facts.claudeCode)}`)
625 if (facts.surface) head.push(facts.surface === 'none' ? 'no surface' : `${facts.surface} surface`)
626 out.push(head.join(' · '))
627
628 // Terminal
629 section('Terminal')
630 const t = facts.terminal
631 const elsewhere = facts.surface !== undefined && facts.surface !== 'terminal'
632 if (facts.surface === 'none') {
633 line(INFO, 'no terminal (a -p run or the SDK): kittex leaves replies as Claude wrote them (images need kitty or Ghostty, in a terminal).')
634 } else if (elsewhere) {
635 line(INFO, `the ${facts.surface} surface: kittex leaves replies to its own drawing here (images need kitty or Ghostty, in a terminal).`)
636 } else if (t) {
637 const name = TERMINAL_NAMES[t.kind]
638 const program = t.program && t.program.toLowerCase() !== name.toLowerCase() ? (t.program.toLowerCase().startsWith(`${name.toLowerCase()} `) ? t.program.slice(name.length + 1) : t.program) : undefined
639 const where = [program ? plain(program) : t.kind === 'other' && t.term ? `TERM=${plain(t.term)}` : undefined, t.multiplexer ? `inside ${t.multiplexer}` : undefined, t.ssh ? 'over ssh' : undefined].filter(Boolean).join(', ')
640 if (t.images) {
641 line(OK, `${name}${where ? ` (${where})` : ''}: kitty graphics with Unicode placeholders`)
642 const c = t.claude
643 const said = c?.source ? ` (${plain(c.source)})` : ''
644 if (c?.state === 'yes') line(OK, 'Claude Code draws the pictures: kitty answered its graphics query')
645 else if (c?.state === 'no') {
646 line(NO, `Claude Code draws no pictures here${said}, so kittex shows math as Unicode text. Where this terminal does show kitty graphics and only answered late (ssh, a busy machine), start Claude Code with ${code('CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1')} to skip its check.`)
647 } else if (c?.state === 'pending') line(INFO, `Claude Code hasn't asked the terminal about pictures yet${said}: run this again in a moment`)
648 else if (c) line(INFO, "Claude Code's own check on pictures: known once kittex has drawn an image (run this again after a reply with math)")
649 } else {
650 const why = t.multiplexer ? `${t.multiplexer} doesn't pass kitty graphics through` : t.kind === 'other' ? 'no kitty graphics detected' : `${name} has no kitty Unicode placeholders`
651 line(NO, `${name}${where ? ` (${where})` : ''}: ${why}. kittex shows math as Unicode text instead; images work in kitty (0.28 or newer) and Ghostty${t.multiplexer ? `, outside ${t.multiplexer}` : ''}.`)
652 }
653 if (t.cell) line(t.cell.measured ? OK : NO, t.cell.measured ? `cell ${t.cell.width}×${t.cell.height} px, measured` : `cell not measured: drawing for ${t.cell.width}×${t.cell.height} px (perl or python3 reads the size from the terminal)`)
654 if (t.images || t.font) {
655 const f = t.font
656 const base = f?.file?.split('/').pop()
657 const named = [f?.family ? plain(f.family) : base ? plain(base) : undefined, f?.style ? plain(f.style) : undefined, f?.sizePt ? `${f.sizePt} pt` : undefined].filter(Boolean).join(' ')
658 const source = f?.source === 'kitty' ? 'kitty names it' : f?.source === 'fontconfig' ? 'fontconfig matched it' : undefined
659 const size = f?.sizePt ? ` ${f.sizePt} pt` : ''
660 if (f?.metrics && f.source === 'ghostty' && !f.file) {
661 if (f.family) line(INFO, `font ${plain(f.family)}${size}: fontconfig doesn't know it, so the math follows Ghostty's built-in JetBrains Mono`)
662 else line(OK, `font Ghostty's built-in JetBrains Mono${size}: metrics built in`)
663 }
664 else if (f?.metrics) line(OK, `font ${named || 'as configured'}: metrics read from ${f.file ? path(f.file) : 'its file'}${source ? ` (${source})` : ''}`)
665 else line(INFO, `font ${named || 'not named by the terminal'}: metrics not read${t.ssh ? ' (over ssh the font is on the other machine)' : ''}; math is sized from the cell`)
666 }
667 const colors = [t.ink ? `text ${t.ink}` : undefined, t.background ? `on ${t.background}` : undefined].filter(Boolean).join(' ')
668 if (colors) line(t.colors === 'terminal' ? OK : INFO, `colours: ${colors} (${t.colors === 'terminal' ? "the terminal's" : "the Claude theme's default: the terminal's colours couldn't be read"})`)
669 } else {
670 line(INFO, 'not set up (kittex is off)')
671 }
672 if (!elsewhere && facts.clients?.length) line(INFO, `also drawn on ${facts.clients.join(', ')}: kittex leaves replies to that surface's own drawing there`)
673
674 // Streaming
675 section('Drawing while streaming')
676 const s = facts.streaming
677 const fix = "Fix: ask an admin to deploy kittex through Claude Code's managed settings."
678 if (s.off) {
679 line(INFO, 'kittex is off: Block math and Inline math are both raw')
680 } else if (elsewhere) {
681 line(INFO, 'not a terminal: replies pass as Claude wrote them, and Claude gets no instructions about math')
682 } else if (s.streamed > 0) {
683 line(OK, 'live: replies reach kittex as they stream')
684 } else if (s.unstreamed > 0) {
685 line(NO, `bypassed: ${s.unstreamed === 1 ? 'a reply' : `${s.unstreamed} replies`} streamed without reaching kittex (cc-plugin-sec-default${s.managed ? ', seated by managed settings,' : ' on Team and Enterprise plans'} skips plugins' streaming hooks): equations stay LaTeX until each part lands. ${fix}`)
686 } else if (s.section === false) {
687 line(NO, `likely bypassed: kittex's system-prompt section is skipped here (cc-plugin-sec-default on Team and Enterprise plans${s.managed ? ', or managed settings' : ''}), which skips the streaming hook too. ${fix}`)
688 } else {
689 line(INFO, 'no reply has streamed yet this session: run this again after one to confirm')
690 }
691 if (!s.off && s.section === false && (s.streamed > 0 || s.unstreamed > 0)) line(INFO, "instructions to Claude ride your first message (the system-prompt hook is skipped here)")
692 else if (!s.off && s.section === true) line(OK, "instructions to Claude: in the system prompt")
693
694 // Options
695 section('Options')
696 out.push(Object.entries(facts.options).map(([key, value]) => `${key} ${code(value)}`).join(' · '))
697
698 // Diagrams
699 section('Diagrams (local TeX)')
700 const d = facts.diagrams
701 const os = facts.os
702 const linux = os.platform !== 'darwin'
703 if (d.option === 'off') line(INFO, 'Local LaTeX is off: TeX never runs, diagrams stay code blocks (set it to auto in /config)')
704 else if (!d.drawn) line(INFO, 'diagrams are drawn only where equations are images (kitty or Ghostty, Block math image)')
705 const tool = (name: string, facts: ToolFacts, missing: string) => {
706 if (facts.path) line(facts.version ? OK : NO, `${name}: ${path(facts.path)}${facts.version ? `, ${plain(facts.version)}` : ' does not answer --version'}`)
707 else line(name === 'kpsewhich' ? INFO : NO, `${name}: not found on PATH${missing}`)
708 }
709 tool('latex', d.latex, '')
710 tool('dvisvgm', d.dvisvgm, '')
711 tool('kpsewhich', d.kpsewhich, ": the packages can't be checked")
712 if (d.files) {
713 const missing = d.files.filter(file => !file.path).map(file => file.name)
714 if (missing.length === 0) {
715 line(OK, `packages: all ${d.files.length} found (TikZ, pgfplots, tikz-cd, circuitikz, chemfig, siunitx...)`)
716 } else {
717 const need = (name: string) => TEX_FILES.find(file => file.name === name)?.need
718 const pictures = missing.filter(name => need(name) === 'pictures')
719 const math = missing.filter(name => need(name) === 'math')
720 const speed = missing.filter(name => need(name) === 'speed')
721 if (pictures.length > 0) line(NO, `missing ${pictures.map(code).join(', ')}: no diagram compiles without ${pictures.length === 1 ? 'it' : 'them'} (one preamble loads every picture package)`)
722 if (math.length > 0) line(NO, `missing ${math.map(code).join(', ')}: math MathJax refuses can't be drawn by TeX`)
723 if (speed.length > 0) line(NO, `missing ${speed.map(code).join(', ')}: no format, so each picture loads its packages (about twice as slow)`)
724 const ps = missing.filter(name => need(name) === 'postscript')
725 if (ps.length > 0) line(INFO, `missing ${ps.map(code).join(', ')}: a whole document that rotates or scales (graphicx) can't be drawn where dvisvgm runs Ghostscript`)
726 const present = d.files.length - missing.length
727 if (present > 0) line(OK, `${present} of ${d.files.length} packages found`)
728 }
729 }
730 if (linux) {
731 const b = d.bwrap
732 const p = d.prlimit
733 const bound = b?.sandbox?.binds ?? []
734 if (b?.usable) line(OK, `bubblewrap: TeX runs with your home, /tmp and /run hidden${bound.length > 0 ? ` (but for ${bound.map(path).join(', ')}, bound read-only)` : ''}, and no network`)
735 else if (b?.path && d.option === 'auto' && b.error !== undefined) line(NO, `bubblewrap can't run TeX here (${plain(b.error)}): kittex runs TeX without it, so TeX can read any file you can (kittex still refuses diagrams that read a file by its path)`)
736 else if (b?.path) line(INFO, 'bubblewrap: found (not tried: Local LaTeX is off or TeX is missing)')
737 else line(NO, 'bubblewrap: not found: TeX can read any file you can (kittex still refuses diagrams that read a file by its path)')
738 if (p?.path) line(OK, 'prlimit: TeX runs under a CPU-time and a file-size limit')
739 else line(NO, 'prlimit: not found: no CPU or file-size limit, only the time limit')
740 } else {
741 line(INFO, "confinement: none on macOS (no bubblewrap, no prlimit). TeX runs as you, with its shell escape off, writes kept to its job folder and a time limit; kittex refuses diagrams that read a file by its path, but nothing hides your files from TeX.")
742 }
743 if (d.libgs === false) line(INFO, 'dvisvgm has no --libgs option here: kittex leaves it out')
744 if (d.postscript) line(INFO, `this dvisvgm runs PostScript through its own Ghostscript, without -dSAFER, whatever it is told (3.5 to 3.6.1): kittex's pictures hold none; a document that rotates or scales ${d.bwrap?.usable ? 'runs inside bubblewrap' : 'is refused, as there is no sandbox'}`)
745 if (d.format) {
746 if (d.format.bytes !== undefined && d.format.bytes > 0) line(OK, `format: built, ${path(d.format.path)} (${bytes(d.format.bytes)})`)
747 else line(INFO, `format: not built yet${d.files?.some(file => file.name === 'mylatexformat.ltx' && !file.path) ? ' (needs mylatexformat)' : ': kittex dumps it in the background once a session finds TeX'}`)
748 }
749 const trial = d.trial
750 if ('skipped' in trial) line(INFO, `trial picture: skipped${trial.skipped ? ` (${plain(trial.skipped)})` : ''}`)
751 else if (trial.ok) line(OK, `trial picture: drawn in ${seconds(trial.ms)}${trial.format ? '' : ' (without the format)'}`)
752 else line(NO, `trial picture: failed after ${seconds(trial.ms)}: ${plain(trial.error)}`)
753 const ready = d.latex.version !== undefined && d.dvisvgm.version !== undefined
754 if (ready && !d.found && d.option === 'auto' && d.drawn) line(INFO, 'this session started before TeX was there: restart Claude Code to draw diagrams')
755
756 // Cache
757 section('Cache')
758 const c = facts.cache
759 if (!c.dir) line(INFO, 'no cache directory (neither XDG_CACHE_HOME nor HOME is set)')
760 else if (c.bytes === undefined && c.files === undefined) line(INFO, `${path(c.dir)}: empty`)
761 else {
762 line(INFO, `${path(c.dir)}: ${c.bytes !== undefined ? bytes(c.bytes) : 'size unknown'}${c.files !== undefined ? ` in ${c.files} ${c.files === 1 ? 'file' : 'files'}` : ''}`)
763 const part = (p: CachePart) => `${p.bytes !== undefined ? bytes(p.bytes) : 'size unknown'} in ${p.files} ${p.files === 1 ? 'file' : 'files'}`
764 if (c.images) line(INFO, `images of resumed replies: ${c.images.files === 0 ? 'none' : part(c.images)} (the oldest go past ${CACHE_LIMIT_BYTES / 2 ** 20} MiB)`)
765 if (c.tex) line(INFO, `pictures TeX drew: ${c.tex.files === 0 ? 'none' : part(c.tex)} (the oldest go past ${TEX_CACHE_LIMIT_BYTES / 2 ** 20} MiB)`)
766 if (c.format) line(INFO, `TeX format: ${c.format.files === 0 ? 'none' : part(c.format)} (kept; replaced when TeX changes)`)
767 }
768
769 // What to install
770 const missing = [
771 ...(d.latex.path ? [] : ['latex']),
772 ...(d.dvisvgm.path ? [] : ['dvisvgm']),
773 ...(d.kpsewhich.path || !d.latex.path ? [] : ['kpsewhich']),
774 ...(d.files ?? []).filter(file => !file.path).map(file => file.name),
775 ...(linux && !d.bwrap?.path ? ['bwrap'] : []),
776 ...(linux && !d.prlimit?.path ? ['prlimit'] : []),
777 ]
778 const advice = d.option === 'off' ? undefined : installAdvice(os, missing, d.latex.path)
779 if (advice && advice.commands.length + (advice.note ? 1 : 0) > 0) {
780 section(`${advice.confineOnly ? 'To confine TeX' : 'To enable diagrams'} (${plain(advice.label)})`)
781 if (advice.commands.length > 0) out.push('```sh', ...advice.commands, '```')
782 if (advice.note) out.push(advice.note)
783 out.push('Then restart Claude Code and run /kittex-doctor again.')
784 }
785 return out.join('\n')
786}
787types/index.d.ts 125 lines1// kittex's contract: the values it keeps in `$.state` (PluginState), named in
2// plugin.json as "types". Self-contained (no import), types only.
3
4/** What drawing needs to know about the terminal (kittex.env). */
5export type KittexEnv = {
6 kind: 'kitty' | 'ghostty' | 'wezterm' | 'iterm2' | 'other'
7 /** Whether Image draws pictures here. */
8 images: boolean
9 /** One cell, in pixels. */
10 cellWidth: number
11 cellHeight: number
12 /** Terminal (viewport) columns as last known. */
13 columns: number
14 /** Pixels per em of the math font. */
15 emPx: number
16 /** The formulas' colour. */
17 ink: { r: number; g: number; b: number }
18 /** The background the ink's alpha is corrected against, as the terminal corrects its text (Ghostty's linear-corrected blending); absent where images and text blend alike. */
19 inkOver?: { r: number; g: number; b: number }
20 /** How the terminal set its cells off its font's (Ghostty's adjust-cell-width, adjust-cell-height, adjust-font-baseline); absent when none is set. */
21 cellAdjust?: {
22 width?: { factor: number } | { px: number }
23 height?: { factor: number } | { px: number }
24 baseline?: { factor: number } | { px: number }
25 }
26 /** Whether the cell size was measured (false: the fallback cell). */
27 measured: boolean
28 /** The glyph the engine opens a reply with (`⏺` on macOS, `●` elsewhere); `●` when absent. */
29 bullet?: string
30 /** The `maxProseWidth` setting: reply prose wraps at most this wide; absent when unset. */
31 maxProseWidth?: number
32 /** Whether the engine draws links as OSC 8 hyperlinks (their text alone) or as text with the url beside it; absent when unknown (links aren't followed). */
33 hyperlinks?: boolean
34 /** Whether the terminal draws emoji sequences (an emoji with U+FE0F or a skin tone, joiner chains, flags, keycaps) two cells wide, as the engine counts them; absent: they keep their paragraph's math Unicode. */
35 emojiSequences?: boolean
36 /** The stroke weight the math is drawn with, matched to the terminal font's weight (strokeWeight); absent when that weight isn't known (the default weight). */
37 weight?: number
38 /** The terminal's background, when its colours were read: TeX's white becomes it, and diagrams' colours are kept legible on it. */
39 background?: { r: number; g: number; b: number }
40 /** With `inkOver`: kitty's text curve (text_composition_strategy) the ink's alpha follows instead of the gamma-blend correction. */
41 inkCurve?: { gamma: number; contrast: number }
42 /** kitty's modify_font changes to its cells; absent when none is set. */
43 kittyAdjust?: {
44 cellWidth?: { value: number; unit: 'pt' | 'px' | '%' }
45 cellHeight?: { value: number; unit: 'pt' | 'px' | '%' }
46 baseline?: { value: number; unit: 'pt' | 'px' | '%' }
47 }
48 /**
49 * The text font's metrics (read from its file once kittex is set up), its
50 * configured size and the platform: the math's em and baseline follow the
51 * text's (textLayout); absent until read, or when they can't be.
52 */
53 font?: {
54 unitsPerEm: number
55 ascender: number
56 descender: number
57 lineGap: number
58 xHeight?: number
59 advance?: number
60 sizePt?: number
61 platform?: string
62 }
63}
64
65/** One preview written while a reply streamed (display, or inline when `inline`), and the TeX it stands for (kittex.records). */
66export type KittexPreview = {
67 /** The markdown exactly as written into the reply (lines after the first carry its indentation). */
68 preview: string
69 tex: string
70 /** Rows reserved on screen; the image is drawn at least this tall (0 for a refused formula). */
71 rows: number
72 /** MathJax refused the formula: why. The preview is its source and a `not rendered` line. */
73 error?: string
74 /** An inline formula's preview (one row, `columns` cells, drawn over by its image once landed). */
75 inline?: true
76 /** Inline: the cells the preview and its image take. */
77 columns?: number
78 /**
79 * Inline: where its ink goes in those cells, as the characters around it in
80 * the source suggest (an image drawn ahead of landing; the landed layout's
81 * rows decide).
82 */
83 place?: 'center' | 'start' | 'end'
84 /** Where the preview starts in the block's text as the engine shows it (every flush's displayContent joined): how a landed block finds it. */
85 at?: number
86 /** Display: written in a blockquote this deep (its lines carry the quote's `>`, its width the quote's text width). */
87 quote?: number
88 /**
89 * Display: written in a list item whose text is this many cells in from the
90 * reply column (its lines carry the item's indentation, its width the item's
91 * text width); its image lies over it in the item once landed.
92 */
93 indent?: number
94 /**
95 * A diagram for the local TeX (a ```latex, ```tex or ```tikz block, or a
96 * bare picture environment), `tex` its source: the preview is its
97 * placeholder, `rows` tall, drawn over by its picture once landed (with
98 * `error`: the block as written and its `not rendered` line).
99 */
100 diagram?: 'latex' | 'tikz' | 'env'
101}
102
103/** One text block kittex streamed (kittex.blocks, by MessageDisplay's message_id). */
104export type KittexBlock = {
105 /** Its previews, in the order written, each with `at`. */
106 records: KittexPreview[]
107 /** Where the stream gave up: from this offset of the shown text on, the model's text passed as written. */
108 raw?: number
109}
110
111declare module 'claude-code' {
112 interface PluginState {
113 kittex: {
114 /** The terminal as drawing sees it; null until session.start set it up, or when that failed. */
115 env: KittexEnv | null
116 /** Each streamed block's previews, by MessageDisplay's message_id; null once dropped (past BLOCK_LIMIT). */
117 blocks: StateFamily<KittexBlock | null>
118 /** The message_id each landed block streamed as, by its transcript row's uuid (AssistantMessage's requestId). */
119 requests: StateFamily<string>
120 /** The newest blocks' message_ids, newest last (a landed block that no row links is looked for among them). */
121 recent: string[]
122 }
123 }
124}
125