SLOPSHOPPER

kittex

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

new
v0.1.0MITupdated 2026-10-08brenocq/kittex/plugin
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (1 | import{BUILD_ID,DIAGRAM_ENVS,FORMAT_SOURCE,GHOSTTY_BUILTIN_FONT,GlyphError,INLINE_OVERF ^ error: No matching export in "slop-empty:/home/runner/work/slopshop
README

kittex

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.

What kittex does on your machine

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.
  • In kitty, 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).
  • Diagrams, only with the Local LaTeX option on 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.

Source 9 files
hooks/register.tsx 2007 lines
1// 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 lines
1import{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};
110
hooks/math.ts 2976 lines
1// 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 `&nbsp;` 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 = '&nbsp;'
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\{|&nbsp;|```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`&nbsp;|\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 lines
1// 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}
101
hooks/cache.ts 200 lines
1// 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}
200
hooks/schedule.ts 63 lines
1// 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}
63
hooks/tex.ts 668 lines
1// 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}
668
hooks/doctor.ts 787 lines
1// /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}
787
types/index.d.ts 125 lines
1// 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