Draws only the display math and Typst figures of Claude's answers as pictures in the terminal; the rest stays plain text, so pictures stay small.

A Claude Code mod that draws only the display math and the figures of Claude's answers as pictures in your terminal. Everything else — paragraphs, lists, tables, code — stays plain terminal text, so it scrolls, selects and searches as usual and the terminal holds few, small pictures.
| Part of an answer | Shown as |
|---|---|
Display math ($$ … $$, \[ … \]) | a typeset picture of that formula |
`typst blocks (diagrams, ER diagrams, charts, plots) | a picture of that drawing |
Inline math ($x^2$) | Unicode text (x², α, mₜ/(√vₜ+ε)) |
| Everything else | Claude Code's own Markdown text |
Pictures are typeset with Typst and sized to whole terminal cells. A long answer only draws the pictures within a screen of the view; the others turn into one dim line of the same height and come back when you scroll to them. This keeps a terminal's image memory small (some terminals cap it per pane, for example Orca at 32 MB).
Sample output, typeset by the mod's own template (dark theme):


brew install typstIn Claude Code:
/plugin install figure-view --marketplace Kenyan822/figure-view
| Command | What it does |
|---|---|
/figure status | Shows the state, size, Typst path and the cell size it found |
/figure on / off | Turns the pictures on or off |
/figure size <50–800> | Picture size in percent of the terminal text (default 300; figures are drawn at 60% of that) |
/figure dark / light | Colours for a dark or a light terminal |
/figure redraw | Sends every picture to the terminal again (when the terminal dropped them, as Orca does on a tab switch) |
/figure open | Writes the last answer to .figure-view/answer.md and opens it (in Orca's Markdown preview, or the default app) |
prompt.compose tells Claude that display math and `typst blocks are drawn as pictures (and which Typst packages and theme colours it can use).ui.render on AssistantMessage splits each answer into text, display math and Typst blocks. Only finished blocks are compiled, so a streaming block stays text.typst compile into a PNG whose size is a whole number of cells, and drawn with the engine's Image element.onScreen tells which rows of the answer are in view; pictures more than a screen away are replaced by text of the same height.Typst runs with no network (packages are vendored under render/packages/) and with its root set to a work folder in $TMPDIR, so a Typst block reads nothing else.
/copy for the Markdown./figure redraw brings back dropped ones.mermaid-view, in the same marketplace: Mermaid diagrams drawn as terminal text (/plugin install mermaid-view --marketplace Kenyan822/figure-view).
MIT. The vendored Typst packages keep their own licences: see THIRD-PARTY-NOTICES.md.
Claude Code の回答のうち、別行の数式と図だけを画像にしてターミナルに表示する Mod です。文章・箇条書き・表・コードは普通の文字のままなので軽く、スクロールやコピーもいつもどおりです。図は `typst ブロック(ER 図、構成図、グラフなど)で描かれます。
インストール:Typst を入れて(brew install typst)、Claude Code で /plugin install figure-view --marketplace Kenyan822/figure-view。使い方は上の Commands の表を見てください。
hooks/register.tsx 287 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { compile, setUp, type Env, type Look, type Picture } from './lib/render'
4import { estimateRows, nearView } from './lib/rows'
5import { splitSegments, type Segment } from './lib/segments'
6import { convertInlineMath, latexToUnicode } from './lib/unicode'
7
8type Piece = Exclude<Segment, { kind: 'md' }>
9
10const LOOKS: Record<'dark' | 'light', Look> = {
11 dark: { fg: '#f2f2f2', bg: '#1e1e1e', isDark: true },
12 light: { fg: '#24292f', bg: '#ffffff', isDark: false },
13}
14
15// How long a draw waits for a compile before it shows the source. Outside
16// fullscreen a finished answer goes to the terminal's scrollback and is not
17// drawn again, so the first draw waits for most compiles to land.
18const WAIT_MS = 3000
19// The compile worker's period while work is queued, and while idle (each
20// tick is a dispatch, so an idle worker ticks rarely), and how many Typst
21// processes it runs at once.
22const PUMP_MS = 50
23const IDLE_MS = 1000
24const MAX_RUNNING = 2
25// Pictures kept in memory (base64 PNGs, tens of kB each); the least recently
26// drawn go first and are compiled again if they come back.
27const MAX_PICTURES = 300
28// Pictures are drawn three times as large as the terminal text by default: at
29// the text's own size, subscripts and axis labels are too small to read.
30const DEFAULT_ZOOM = 3
31// Drawings carry their own text and are wide already: at the formulas' zoom a
32// plot fills the screen and several megabytes of picture memory.
33const DRAWING_SCALE = 0.6
34// Blank rows above and below each picture, apart from the text around it.
35const PICTURE_MARGIN = 1
36
37const PROMPT = `In this terminal, display math and Typst drawings in your replies are drawn as pictures; the rest of a reply stays plain terminal text.
38- Display math: put LaTeX between $$ lines (or $$ … $$ on one line); it is typeset.
39- Inline math ($…$) is shown as Unicode text (x², α, a/b), so keep inline math short and simple; use display math for fractions, sums, matrices or anything tall.
40- Diagrams, flow charts and plots: a fenced \`\`\`typst block (Typst 0.15, markup mode, code lines start with #). Offline packages: #import "@preview/cetz:0.5.2", #import "@preview/cetz-plot:0.1.4": plot, chart, #import "@preview/fletcher:0.5.8" as fletcher: diagram, node, edge. A \`theme\` dictionary is in scope: theme.fg, theme.muted, theme.bg, theme.border, theme.blue/green/red/orange/yellow/purple, theme.palette, theme.width, theme.height, theme.dark. Default strokes are black and invisible on a dark terminal: always pass theme.fg (fletcher node-stroke/edge-stroke, cetz set-style(stroke: theme.fg)). Fill shapes with the opaque theme.fill-blue / fill-green / fill-orange / fill-red / fill-purple / fill-yellow, never a transparentize()d colour (text on a see-through fill is hard to read). Keep drawings within theme.width x theme.height; #page and #set page are errors.
41- When explaining a mechanism, a structure, a flow, a comparison, a trend or a calculation, add typst drawings or display math wherever they convey it better than words alone (as many as help; not for simple factual answers).
42- Tables, lists and code stay normal markdown.`
43
44// The module's own state: a reload starts it over, which only recompiles.
45const state = {
46 isOn: true,
47 look: LOOKS.dark,
48 zoom: DEFAULT_ZOOM,
49 env: null as Promise<Env | { error: string }> | null,
50 envValue: null as Env | { error: string } | null,
51 pictures: new Map<string, Picture>(),
52 // Pieces waiting for the worker, by key, and the keys it is compiling.
53 queue: new Map<string, { seg: Piece; columns: number; zoom: number; look: Look }>(),
54 running: new Set<string>(),
55 lastAnswer: '',
56 // The worker's next tick, and what runs it at once (set at session start).
57 nextTick: null as Timer | null,
58 wake: null as (() => void) | null,
59 // Bumped by /figure redraw: new Image keys make the terminal get every
60 // picture again (Orca drops them when its tab is switched away).
61 epoch: 0,
62}
63
64export const register: Register = on => {
65 on('session.start', async ($, e, next) => {
66 await $.command.register({
67 name: 'figure',
68 description: '数式と図だけを画像にする表示: on / off / dark / light / size <倍率%> / redraw / open / status',
69 argumentHint: '[on|off|dark|light|size <%>|redraw|open|status]',
70 })
71 // A draw's own processes are stopped as soon as a newer draw replaces it
72 // (every scroll), so the compiles run on this timer, outside any draw.
73 // A newly queued piece runs the worker at once instead of at the idle
74 // tick, so it starts compiling without waiting up to IDLE_MS.
75 state.wake = () => {
76 state.nextTick?.cancel()
77 state.nextTick = $.clock.after(0, () => tick($))
78 }
79 tick($)
80 return next(e)
81 })
82
83 on('command.run', { command: 'figure' }, async ($, e) => {
84 const arg = e.args.trim()
85 if (arg === 'open') return { text: await openLastAnswer($) }
86 if (arg === 'redraw') {
87 state.epoch++
88 $.ui.invalidate('ui.render')
89 return { text: '画像を送り直しました' }
90 }
91 const size = /^size\s+(\d+)%?$/.exec(arg)
92 if (arg === 'on' || arg === 'off') state.isOn = arg === 'on'
93 else if (arg === 'dark' || arg === 'light') state.look = LOOKS[arg]
94 else if (size && Number(size[1]) >= 50 && Number(size[1]) <= 800) state.zoom = Number(size[1]) / 100
95 else if (arg && arg !== 'status') return { text: '使い方: /figure on | off | dark | light | size <50〜800> | redraw | open | status' }
96 $.ui.invalidate('ui.render')
97 return { text: await statusOf($) }
98 })
99
100 on('turn.complete', async ($, e, next) => {
101 const done = await next(e)
102 if (e.answer.trim()) state.lastAnswer = e.answer
103 return done
104 })
105
106 on('prompt.compose', async ($, e, next) => {
107 const composed = await next(e)
108 if (!state.isOn || !e.surfaces.includes('terminal')) return composed
109 return { sections: [...composed.sections, { id: 'figure-view:prompt', text: PROMPT, scope: 'session' as const }] }
110 })
111
112 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
113 if (!state.isOn || e.surface !== 'terminal' || e.props.isSummary) return next(e)
114 const segments = splitSegments(e.props.text)
115 const hasPicture = segments.some(s => s.kind !== 'md')
116 const hasInline = /\$[^$\n]+\$|\\\(/.test(e.props.text)
117 if (!hasPicture && !hasInline) return next(e)
118
119 const ready = state.envValue ?? { error: '準備中' }
120 const { Box, Image, Markdown, Text } = $.ui.resolve(e)
121 // Room beside the bullet column the engine draws before a reply.
122 const columns = Math.max(10, (e.viewport?.columns ?? 80) - 4)
123 type Part =
124 | { kind: 'md'; text: string }
125 | { kind: 'source'; seg: Piece; isDim: boolean }
126 | { kind: 'failed'; seg: Piece; error: string }
127 | { kind: 'picture'; seg: Piece; picture: Extract<Picture, { status: 'ready' }> }
128 const items: Part[] = []
129 for (const seg of segments) {
130 if (seg.kind === 'md') items.push({ kind: 'md', text: convertInlineMath(seg.text) })
131 else if ('error' in ready) items.push({ kind: 'source', seg, isDim: false })
132 else {
133 const picture = await pictureOf($, seg, columns)
134 if (!picture) items.push({ kind: 'source', seg, isDim: true })
135 else if (picture.status === 'failed') items.push({ kind: 'failed', seg, error: picture.error })
136 else items.push({ kind: 'picture', seg, picture })
137 }
138 }
139
140 // Only the pictures within a screen of the view are drawn as pictures; the
141 // others are text of the same height, so the terminal holds few pictures
142 // at a time and one it dropped is sent again when it comes back in view.
143 const heights = items.map(item =>
144 item.kind === 'md' ? estimateRows(item.text, columns)
145 : item.kind === 'picture' ? item.picture.rows + 2 * PICTURE_MARGIN
146 : estimateRows(sourceOf(item.seg), columns) + (item.kind === 'failed' ? 1 : 0),
147 )
148 const isNear = nearView(heights, e.props.onScreen)
149
150 const parts = items.map((item, i) => {
151 switch (item.kind) {
152 case 'md':
153 return <Markdown key={`md${i}`} text={item.text} />
154 case 'source':
155 return <Markdown key={`src${i}`} text={sourceOf(item.seg)} dimColor={item.isDim} />
156 case 'failed':
157 return (
158 <Box key={`err${i}`} flexDirection="column">
159 <Markdown text={sourceOf(item.seg)} />
160 <Text dimColor>figure-view: {item.error}</Text>
161 </Box>
162 )
163 case 'picture':
164 return isNear[i] ? (
165 <Box key={`img${i}`} marginY={PICTURE_MARGIN}>
166 <Image key={`img${i}-${state.epoch}`} source={{ png: item.picture.png }} columns={item.picture.columns} rows={item.picture.rows} alt={altOf(item.seg)} />
167 </Box>
168 ) : (
169 <Box key={`off${i}`} height={item.picture.rows} marginY={PICTURE_MARGIN}>
170 <Text dimColor wrap="truncate">{offscreenOf(item.seg)}</Text>
171 </Box>
172 )
173 }
174 })
175
176 const body = <Box flexDirection="column" flexGrow={1}>{parts}</Box>
177 if (!e.props.isFirstOfReply) return <Box paddingLeft={2}>{body}</Box>
178 return (
179 <Box flexDirection="row">
180 <Text>⏺ </Text>
181 {body}
182 </Box>
183 )
184 })
185}
186
187function keyOf(seg: Piece, columns: number): string {
188 return `${seg.kind}|${state.look.fg}|${state.zoom}|${columns}|${seg.src}`
189}
190
191// The picture of a finished piece from the cache; else the piece is queued
192// for the worker, and a draw waits up to WAIT_MS before showing the source
193// (the worker redraws the transcript when the picture lands).
194async function pictureOf($: EngineInterface, seg: Piece, columns: number): Promise<Picture | undefined> {
195 const key = keyOf(seg, columns)
196 const kept = state.pictures.get(key)
197 if (kept) {
198 state.pictures.delete(key)
199 state.pictures.set(key, kept)
200 return kept
201 }
202 if (!state.running.has(key)) {
203 state.queue.set(key, { seg, columns, zoom: seg.kind === 'typst' ? state.zoom * DRAWING_SCALE : state.zoom, look: state.look })
204 state.wake?.()
205 }
206 for (let waited = 0; !state.pictures.has(key) && waited < WAIT_MS; waited += 50) await $.clock.sleep(50)
207 return state.pictures.get(key)
208}
209
210// Runs the worker and schedules the next run: soon while pieces wait or
211// compile, rarely otherwise.
212function tick($: EngineInterface): void {
213 pump($)
214 const isBusy = state.queue.size > 0 || state.running.size > 0 || !state.envValue
215 state.nextTick = $.clock.after(isBusy ? PUMP_MS : IDLE_MS, () => tick($))
216}
217
218// The compile worker: sets up Typst once, then starts queued compiles, a few
219// at a time, and redraws the transcript as each picture lands.
220function pump($: EngineInterface): void {
221 if (!state.env) {
222 state.env = setUp($).then(env => {
223 state.envValue = env
224 $.ui.invalidate('ui.render')
225 return env
226 })
227 }
228 const env = state.envValue
229 if (!env || 'error' in env) return
230 for (const [key, job] of state.queue) {
231 if (state.running.size >= MAX_RUNNING) break
232 state.queue.delete(key)
233 state.running.add(key)
234 void compile($, env, job.seg.kind, job.seg.src, job.look, job.columns, job.zoom)
235 .catch((err: unknown): Picture => ({ status: 'failed', error: String(err) }))
236 .then(picture => {
237 state.pictures.set(key, picture)
238 for (const old of state.pictures.keys()) {
239 if (state.pictures.size <= MAX_PICTURES) break
240 state.pictures.delete(old)
241 }
242 state.running.delete(key)
243 $.ui.invalidate('ui.render')
244 })
245 }
246}
247
248async function statusOf($: EngineInterface): Promise<string> {
249 const ready = state.envValue ?? (state.env ? await state.env : { error: '準備中' })
250 const head = `${state.isOn ? 'on' : 'off'}(${state.look.isDark ? 'dark' : 'light'}、大きさ ${Math.round(state.zoom * 100)}%)`
251 if ('error' in ready) return `${head}\n使えません: ${ready.error}`
252 return `${head}\ntypst: ${ready.typst}\nセル: ${ready.cell.width.toFixed(1)} x ${ready.cell.height.toFixed(1)} px\n画像: ${state.pictures.size} 件`
253}
254
255function sourceOf(seg: Piece): string {
256 return seg.kind === 'math' ? `$$\n${seg.src}\n$$` : `\`\`\`typst\n${seg.src}\n\`\`\``
257}
258
259function altOf(seg: Piece): string {
260 return seg.kind === 'math' ? seg.src.replace(/\s+/g, ' ').slice(0, 200) : '[typst の図]'
261}
262
263function offscreenOf(seg: Piece): string {
264 return seg.kind === 'math' ? latexToUnicode(seg.src.replace(/\s+/g, ' ')) : '[図]'
265}
266
267// Writes the last answer to <session folder>/.figure-view/answer.md and opens it
268// in Orca's Markdown preview, which typesets its math (KaTeX) and Mermaid
269// without the terminal's picture memory. Orca opens files of its worktree only;
270// outside Orca the file goes to the system's default app for .md.
271async function openLastAnswer($: EngineInterface): Promise<string> {
272 if (!state.lastAnswer) return 'まだ回答がありません'
273 const cwd = await $.session.cwd()
274 const made = await $.process.run(['mkdir', '-p', `${cwd}/.figure-view`])
275 if (made.exitCode !== 0) return `フォルダを作れません: ${made.stderr.trim()}`
276 const path = `${cwd}/.figure-view/answer.md`
277 await $.fs.write(path, state.lastAnswer)
278 const inOrca = (await $.env.get('TERM_PROGRAM')) === 'Orca'
279 if (inOrca) {
280 const opened = await $.process.run(['sh', '-lc', 'orca file open "$1" --focus --json', 'sh', '.figure-view/answer.md'], { cwd, timeoutMs: 15000 })
281 if (opened.exitCode === 0 && opened.stdout.includes('"ok": true')) return `直前の回答を ${path} に書き出し、Orca で開きました`
282 }
283 const shown = await $.process.run(['open', path], { timeoutMs: 15000 }).catch(() => null)
284 if (shown?.exitCode === 0) return `直前の回答を ${path} に書き出し、既定のアプリで開きました`
285 return `直前の回答を ${path} に書き出しました(開くことはできませんでした)`
286}
287hooks/lib/render.ts 156 lines1import type { EngineInterface } from 'claude-code'
2
3import { PIECE_TYP } from './piece'
4
5// One finished formula or drawing, compiled once per source, theme and cell
6// size. `png` is base64; `columns` x `rows` is its box in terminal cells.
7export type Picture =
8 | { status: 'ready'; png: string; columns: number; rows: number }
9 | { status: 'failed'; error: string }
10
11export type Cell = { width: number; height: number }
12
13export type Look = { fg: string; bg: string; isDark: boolean }
14
15// Pictures are drawn at twice the terminal's css pixels, so they stay sharp on
16// a Retina screen while each formula stays well under a megabyte of memory.
17const SCALE = 2
18const COMPILE_TIMEOUT_MS = 20000
19// The terminal's own text is about 0.62 of a row tall.
20const TEXT_PER_ROW = 0.62
21
22// Typst must not fetch: a package that is not vendored fails at once.
23const NO_NETWORK = { HTTP_PROXY: 'http://127.0.0.1:9', HTTPS_PROXY: 'http://127.0.0.1:9', ALL_PROXY: 'http://127.0.0.1:9' }
24
25// What `$.process.run` reads from the terminal: rows, columns and its pixel
26// size (TIOCGWINSZ), for the size of one cell.
27const CELL_PL =
28 'open(T, "</dev/tty") or exit 1; my $w = " " x 8; ioctl(T, ($^O eq "darwin" ? 0x40087468 : 0x5413), $w) or exit 1; print join(" ", unpack("S4", $w))'
29
30export type Env = { typst: string; dir: string; packages: string; cell: Cell }
31
32export async function setUp($: EngineInterface): Promise<Env | { error: string }> {
33 const typst = await findTypst($)
34 if (!typst) return { error: 'typst が見つかりません(brew install typst)' }
35 const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')
36 const dir = `${tmp}/figure-view-${await $.session.id()}`
37 // Work folders of sessions that ended more than two days ago.
38 await $.process.run(['find', tmp, '-maxdepth', '1', '-name', 'figure-view-*', '-type', 'd', '-mtime', '+2', '-exec', 'rm', '-rf', '{}', '+'], { timeoutMs: 10000 }).catch(() => null)
39 const made = await $.process.run(['mkdir', '-p', dir])
40 if (made.exitCode !== 0) return { error: `作業フォルダを作れません: ${made.stderr.trim()}` }
41 await $.fs.write(`${dir}/piece.typ`, PIECE_TYP)
42 return { typst, dir, packages: `${$.plugin.root}/render/packages`, cell: await cellSize($) }
43}
44
45async function findTypst($: EngineInterface): Promise<string | null> {
46 for (const path of ['/opt/homebrew/bin/typst', '/usr/local/bin/typst']) {
47 const got = await $.fs.stat(path).catch(() => null)
48 if (got?.kind === 'file') return path
49 }
50 const found = await $.process.run(['sh', '-lc', 'command -v typst'], { timeoutMs: 5000 }).catch(() => null)
51 const path = found?.exitCode === 0 ? found.stdout.trim() : ''
52 return path || null
53}
54
55// The cell size in css pixels; a common 13px monospace cell when the terminal
56// does not report its pixels.
57async function cellSize($: EngineInterface): Promise<Cell> {
58 const got = await $.process.run(['perl', '-e', CELL_PL], { timeoutMs: 5000 }).catch(() => null)
59 const [rows = 0, cols = 0, xpix = 0, ypix = 0] = (got?.exitCode === 0 ? got.stdout : '').trim().split(/\s+/).map(Number)
60 if (rows > 0 && cols > 0 && xpix > 0 && ypix > 0) return { width: xpix / cols, height: ypix / rows }
61 return { width: 8, height: 17 }
62}
63
64export async function compile(
65 $: EngineInterface,
66 env: Env,
67 kind: 'math' | 'typst',
68 src: string,
69 look: Look,
70 maxColumns: number,
71 zoom: number,
72): Promise<Picture> {
73 const key = hash(`${kind}\n${src}\n${look.fg}\n${zoom}`)
74 const srcName = `${key}.${kind === 'math' ? 'tex' : 'typ'}`
75 const out = `${env.dir}/${key}-${maxColumns}.png`
76 await $.fs.write(`${env.dir}/${srcName}`, src)
77 const { width: cw, height: ch } = env.cell
78 const inputs = {
79 kind,
80 src: `/${srcName}`,
81 cw: String(cw),
82 ch: String(ch),
83 maxw: String(maxColumns * cw),
84 fs: String(ch * TEXT_PER_ROW),
85 zoom: String(zoom),
86 fg: look.fg,
87 bg: look.bg,
88 dark: look.isDark ? '1' : '0',
89 }
90 const argv = [
91 env.typst, 'compile', '--root', env.dir, '--package-path', env.packages, '--ppi', String(72 * SCALE),
92 ...Object.entries(inputs).flatMap(([k, v]) => ['--input', `${k}=${v}`]),
93 `${env.dir}/piece.typ`, out,
94 ]
95 const ran = await $.process.run(argv, { timeoutMs: COMPILE_TIMEOUT_MS, env: NO_NETWORK }).catch((err: unknown) => ({
96 exitCode: 1,
97 stdout: '',
98 stderr: String(err),
99 }))
100 if (ran.exitCode !== 0) return { status: 'failed', error: firstError(ran.stderr) }
101 const { base64 } = await $.fs.read(out, { as: 'bytes' })
102 const size = pngSize(base64)
103 if (!size) return { status: 'failed', error: 'PNG を読めませんでした' }
104 return {
105 status: 'ready',
106 png: base64,
107 columns: clamp(Math.round(size.width / SCALE / cw), 1, 255),
108 rows: clamp(Math.round(size.height / SCALE / ch), 1, 255),
109 }
110}
111
112function firstError(stderr: string): string {
113 const line = stderr.split('\n').find(l => l.startsWith('error:')) ?? stderr.split('\n')[0] ?? ''
114 return line.replace(/^error:\s*/, '').trim() || 'typst が失敗しました'
115}
116
117const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
118
119// Width and height from the IHDR of a base64 PNG.
120export function pngSize(base64: string): { width: number; height: number } | null {
121 const bytes = decodeBase64(base64.slice(0, 32))
122 if (bytes.length < 24 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) return null
123 const at = (o: number) => bytes[o] ?? 0
124 const u32 = (o: number) => ((at(o) << 24) | (at(o + 1) << 16) | (at(o + 2) << 8) | at(o + 3)) >>> 0
125 return { width: u32(16), height: u32(20) }
126}
127
128const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
129
130function decodeBase64(s: string): number[] {
131 const out: number[] = []
132 let buf = 0
133 let bits = 0
134 for (const c of s) {
135 const v = B64.indexOf(c)
136 if (v < 0) break
137 buf = (buf << 6) | v
138 bits += 6
139 if (bits >= 8) {
140 bits -= 8
141 out.push((buf >> bits) & 0xff)
142 }
143 }
144 return out
145}
146
147// FNV-1a, for file names and cache keys.
148export function hash(s: string): string {
149 let h = 0x811c9dc5
150 for (let i = 0; i < s.length; i++) {
151 h ^= s.charCodeAt(i)
152 h = Math.imul(h, 0x01000193) >>> 0
153 }
154 return h.toString(16).padStart(8, '0')
155}
156hooks/lib/rows.ts 54 lines1// Which pictures of a long answer are near the viewport. The engine reports
2// the rows of the answer in view (onScreen); the mod knows each picture's
3// height and estimates the text's, so each picture gets a row span and only
4// those near the view are drawn as pictures.
5
6export type View = { first: number; last: number; of: number }
7
8// Rows a markdown text takes at `columns`: one per line, more for a line
9// wider than the room (a wide CJK character takes two columns).
10export function estimateRows(text: string, columns: number): number {
11 let rows = 0
12 for (const line of text.split('\n')) {
13 let width = 0
14 for (const ch of line) width += isWide(ch) ? 2 : 1
15 rows += Math.max(1, Math.ceil(width / Math.max(1, columns)))
16 }
17 return rows
18}
19
20function isWide(ch: string): boolean {
21 const c = ch.codePointAt(0) ?? 0
22 return (
23 (c >= 0x1100 && c <= 0x115f) ||
24 (c >= 0x2e80 && c <= 0xa4cf) ||
25 (c >= 0xac00 && c <= 0xd7a3) ||
26 (c >= 0xf900 && c <= 0xfaff) ||
27 (c >= 0xfe30 && c <= 0xfe4f) ||
28 (c >= 0xff00 && c <= 0xff60) ||
29 (c >= 0xffe0 && c <= 0xffe6) ||
30 (c >= 0x1f300 && c <= 0x1faff) ||
31 (c >= 0x20000 && c <= 0x3fffd)
32 )
33}
34
35// For each part's height in rows, whether it is within one screen of the
36// view. The estimated heights are scaled to the answer's laid-out height, so
37// an estimate that is off by a few rows per paragraph does not add up.
38export function nearView(heights: readonly number[], view: View | null | undefined): boolean[] {
39 if (view === undefined) return heights.map(() => true)
40 if (view === null) return heights.map(() => false)
41 const total = heights.reduce((a, b) => a + b, 0)
42 const scale = total > 0 && view.of > 0 ? view.of / total : 1
43 const screen = view.last - view.first + 1
44 const from = view.first - screen
45 const to = view.last + screen
46 let top = 0
47 return heights.map(h => {
48 const start = top * scale
49 const end = (top + h) * scale
50 top += h
51 return end >= from && start <= to
52 })
53}
54hooks/lib/segments.ts 79 lines1// Splits an answer's markdown into the parts drawn as text and the parts
2// drawn as pictures: display math ($$…$$ or \[…\] on lines of their own) and
3// ```typst fences. A part still open at the end (the block is streaming) stays
4// text, so only finished sources are ever compiled.
5
6export type Segment =
7 | { kind: 'md'; text: string }
8 | { kind: 'math'; src: string }
9 | { kind: 'typst'; src: string }
10
11const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)/
12
13export function splitSegments(text: string): Segment[] {
14 const lines = text.split('\n')
15 const out: Segment[] = []
16 let md: string[] = []
17 const flushMd = () => {
18 if (md.length) out.push({ kind: 'md', text: md.join('\n') })
19 md = []
20 }
21
22 let i = 0
23 while (i < lines.length) {
24 const line = lines[i] ?? ''
25 const fence = FENCE.exec(line)
26 if (fence) {
27 const mark = fence[1] ?? '```'
28 const close = findClose(lines, i + 1, l => l.trimStart().startsWith(mark) && l.trim().replace(/[`~]/g, '') === '')
29 if (close < 0) {
30 md.push(...lines.slice(i))
31 break
32 }
33 if ((fence[2] ?? '').toLowerCase() === 'typst') {
34 flushMd()
35 out.push({ kind: 'typst', src: lines.slice(i + 1, close).join('\n') })
36 } else {
37 md.push(...lines.slice(i, close + 1))
38 }
39 i = close + 1
40 continue
41 }
42
43 const t = line.trim()
44 const open = t.startsWith('$$') ? '$$' : t.startsWith('\\[') ? '\\[' : ''
45 if (open) {
46 const shut = open === '$$' ? '$$' : '\\]'
47 const rest = t.slice(2)
48 // $$ x $$ on one line.
49 if (rest.trimEnd().endsWith(shut) && rest.trim().length >= 2) {
50 flushMd()
51 out.push({ kind: 'math', src: rest.trimEnd().slice(0, -2).trim() })
52 i++
53 continue
54 }
55 const close = findClose(lines, i + 1, l => l.trimEnd().endsWith(shut))
56 if (close < 0) {
57 md.push(...lines.slice(i))
58 break
59 }
60 const last = (lines[close] ?? '').trimEnd()
61 const body = [rest, ...lines.slice(i + 1, close), last.slice(0, -2)].join('\n').trim()
62 flushMd()
63 if (body) out.push({ kind: 'math', src: body })
64 i = close + 1
65 continue
66 }
67
68 md.push(line)
69 i++
70 }
71 flushMd()
72 return out
73}
74
75function findClose(lines: string[], from: number, isClose: (line: string) => boolean): number {
76 for (let j = from; j < lines.length; j++) if (isClose(lines[j] ?? '')) return j
77 return -1
78}
79hooks/lib/unicode.ts 149 lines1// Inline math ($…$, \(…\)) cannot sit inside a line of terminal text as a
2// picture, so it is written in Unicode: an approximation (x², α, √v, a/b) that
3// stays selectable and searchable. Code spans and fences are left as written.
4
5const SYMBOLS: Record<string, string> = {
6 alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', varepsilon: 'ε', zeta: 'ζ', eta: 'η',
7 theta: 'θ', vartheta: 'ϑ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π',
8 rho: 'ρ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω',
9 Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ', Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ', Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
10 sum: 'Σ', prod: 'Π', int: '∫', infty: '∞', partial: '∂', nabla: '∇',
11 cdot: '·', times: '×', div: '÷', pm: '±', mp: '∓', ast: '∗', circ: '∘',
12 le: '≤', leq: '≤', ge: '≥', geq: '≥', ne: '≠', neq: '≠', approx: '≈', sim: '∼', simeq: '≃', equiv: '≡',
13 propto: '∝', ll: '≪', gg: '≫',
14 leftarrow: '←', gets: '←', rightarrow: '→', to: '→', Rightarrow: '⇒', Leftarrow: '⇐', leftrightarrow: '↔',
15 Leftrightarrow: '⇔', mapsto: '↦', implies: '⇒', iff: '⇔',
16 in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆', cup: '∪', cap: '∩', emptyset: '∅',
17 forall: '∀', exists: '∃', neg: '¬', land: '∧', lor: '∨',
18 mid: '|', vert: '|', Vert: '‖', lVert: '‖', rVert: '‖', langle: '⟨', rangle: '⟩',
19 ldots: '…', cdots: '⋯', dots: '…', prime: '′', star: '⋆', dagger: '†',
20 log: 'log', ln: 'ln', exp: 'exp', max: 'max', min: 'min', arg: 'arg', argmax: 'argmax', argmin: 'argmin',
21 sin: 'sin', cos: 'cos', tan: 'tan', lim: 'lim', det: 'det', Pr: 'Pr', E: 'E',
22 ',': ' ', ';': ' ', ':': ' ', '!': '', quad: ' ', qquad: ' ', '{': '{', '}': '}', '%': '%', '$': '$',
23 '|': '‖', '\\': ' ',
24}
25
26const SUP: Record<string, string> = {
27 '0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹',
28 '+': '⁺', '-': '⁻', '=': '⁼', '(': '⁽', ')': '⁾', n: 'ⁿ', i: 'ⁱ', t: 'ᵗ', T: 'ᵀ', k: 'ᵏ', a: 'ᵃ', b: 'ᵇ',
29 c: 'ᶜ', d: 'ᵈ', e: 'ᵉ', m: 'ᵐ', o: 'ᵒ', p: 'ᵖ', r: 'ʳ', s: 'ˢ', u: 'ᵘ', v: 'ᵛ', x: 'ˣ', y: 'ʸ', '*': '*', '′': '′',
30}
31
32const SUB: Record<string, string> = {
33 '0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄', '5': '₅', '6': '₆', '7': '₇', '8': '₈', '9': '₉',
34 '+': '₊', '-': '₋', '=': '₌', '(': '₍', ')': '₎', a: 'ₐ', e: 'ₑ', h: 'ₕ', i: 'ᵢ', j: 'ⱼ', k: 'ₖ', l: 'ₗ',
35 m: 'ₘ', n: 'ₙ', o: 'ₒ', p: 'ₚ', r: 'ᵣ', s: 'ₛ', t: 'ₜ', u: 'ᵤ', v: 'ᵥ', x: 'ₓ',
36}
37
38const DROP = new Set(['left', 'right', 'big', 'Big', 'bigg', 'Bigg', 'bigl', 'bigr', 'Bigl', 'Bigr', 'displaystyle', 'textstyle', 'limits', 'nolimits'])
39const KEEP_ARG = new Set(['mathrm', 'text', 'textrm', 'mathbf', 'mathit', 'mathsf', 'mathtt', 'operatorname', 'boldsymbol', 'bm', 'mathcal', 'mathbb', 'textbf', 'textit'])
40const ACCENT: Record<string, string> = { hat: '̂', bar: '̄', overline: '̅', tilde: '̃', vec: '⃗', dot: '̇' }
41
42// Reads one argument: a {group} or a single token, from `s` at `i`.
43function arg(s: string, i: number): [string, number] {
44 while (s[i] === ' ') i++
45 if (s[i] === '{') {
46 let depth = 0
47 for (let j = i; j < s.length; j++) {
48 if (s[j] === '{') depth++
49 else if (s[j] === '}' && --depth === 0) return [s.slice(i + 1, j), j + 1] as [string, number]
50 }
51 return [s.slice(i + 1), s.length]
52 }
53 if (s[i] === '\\') {
54 const m = /^\\([A-Za-z]+|.)/.exec(s.slice(i))
55 if (m) return [m[0], i + m[0].length]
56 }
57 return [s[i] ?? '', i + 1]
58}
59
60// One symbol with its scripts (mₜ, x², 10) needs no parentheses around it.
61const wrap = (x: string) => ([...x].length > 1 && !/^[\p{L}\p{N}.′²³¹⁰-ᴀ-ᶿ]+$/u.test(x) ? `(${x})` : x)
62
63function script(x: string, table: Record<string, string>, mark: string): string {
64 const chars = [...x]
65 if (chars.length && chars.every(c => table[c])) return chars.map(c => table[c]).join('')
66 return `${mark}${wrap(x)}`
67}
68
69export function latexToUnicode(src: string): string {
70 let out = ''
71 let i = 0
72 while (i < src.length) {
73 const c = src[i] ?? ''
74 if (c === '\\') {
75 const m = /^\\([A-Za-z]+|.)/.exec(src.slice(i))
76 const name = m?.[1] ?? ''
77 i += m ? m[0].length : 1
78 if (DROP.has(name)) continue
79 if (name === 'frac' || name === 'dfrac' || name === 'tfrac') {
80 const [a, j] = arg(src, i)
81 const [b, k] = arg(src, j)
82 out += `${wrap(latexToUnicode(a))}/${wrap(latexToUnicode(b))}`
83 i = k
84 continue
85 }
86 if (name === 'sqrt') {
87 const [a, j] = arg(src, i)
88 out += `√${wrap(latexToUnicode(a))}`
89 i = j
90 continue
91 }
92 if (KEEP_ARG.has(name)) {
93 const [a, j] = arg(src, i)
94 out += latexToUnicode(a)
95 i = j
96 continue
97 }
98 if (ACCENT[name]) {
99 const [a, j] = arg(src, i)
100 const inner = latexToUnicode(a)
101 out += [...inner].length === 1 ? inner + ACCENT[name] : `${name}(${inner})`
102 i = j
103 continue
104 }
105 out += SYMBOLS[name] ?? name
106 continue
107 }
108 if (c === '^' || c === '_') {
109 const [a, j] = arg(src, i + 1)
110 const inner = latexToUnicode(a)
111 out += c === '^' ? script(inner, SUP, '^') : script(inner, SUB, '_')
112 i = j
113 continue
114 }
115 if (c === '{' || c === '}') {
116 i++
117 continue
118 }
119 out += c
120 i++
121 }
122 return out.replace(/ {2,}/g, ' ').trim()
123}
124
125// Rewrites the inline math of markdown text, outside code spans and fences.
126export function convertInlineMath(md: string): string {
127 const lines = md.split('\n')
128 let inFence = false
129 return lines
130 .map(line => {
131 if (/^ {0,3}(`{3,}|~{3,})/.test(line)) {
132 inFence = !inFence
133 return line
134 }
135 if (inFence) return line
136 return line
137 .split(/(`+[^`]*`+)/)
138 .map((part, k) => (k % 2 === 1 ? part : convertPart(part)))
139 .join('')
140 })
141 .join('\n')
142}
143
144function convertPart(s: string): string {
145 return s
146 .replace(/\\\((.+?)\\\)/g, (_, m: string) => latexToUnicode(m))
147 .replace(/(^|[^\\$\w])\$(?!\s)([^$\n]+?)(?<![\s\\])\$(?![\w$])/g, (_, pre: string, m: string) => pre + latexToUnicode(m))
148}
149hooks/lib/piece.ts 4 lines1// The template the compile writes to its work folder, so Typst's --root can
2// stay that folder.
3export const PIECE_TYP = "// figure-view: one display formula or one Typst drawing as a PNG whose size is\n// a whole number of terminal cells, so the Image box shows it unstretched.\n//\n// Inputs (all strings): kind = \"math\" | \"typst\"; src = the source file's path\n// (under --root); cw, ch = one cell's width and height in pt (1 pt = 1 css px\n// of the terminal); maxw = the most width the picture may use, in pt;\n// fs = the body text size in pt; zoom = how much larger than the terminal\n// text a picture is drawn; fg, bg = the theme's text and background.\n\n#import \"@preview/mitex:0.2.7\": mitex-convert, mitex-scope\n\n// The two fixes below (bars, cases) are adapted from razorback16/cc-intelligent-ui\n// render/md.typ (MIT), see THIRD-PARTY-NOTICES.md.\n// In LaTeX a bare | (and \\vert, \\|, \\Vert) is an ordinary symbol: |x| and\n// P(A|B) are tight. mitex gives Typst a bare `|`, which Typst spaces like a\n// relation (\"| x |\"). So each one that is not a delimiter of \\left, \\right\n// or \\middle becomes \\mathord{|}, and \\big| (\\Big, \\bigg, \\Bigg) becomes\n// \\mathord{\\big|} (\\bigl: \\mathopen, \\bigr: \\mathclose, \\bigm: \\mathrel), as\n// in LaTeX. Text arguments (\\text{a|b}, ...) are left as they are.\n#let bars = (\"|\", \"\\\\vert\", \"\\\\|\", \"\\\\Vert\")\n#let big-class = (l: \"mathopen\", r: \"mathclose\", m: \"mathrel\")\n#let latex-bars(src) = {\n if bars.all(b => not src.contains(b)) { return src }\n let out = \"\"\n let last = 0\n let delim = false // the token before was \\left, \\right or \\middle\n let big = none // the token before was \\big...: (start, class)\n let text-arg = false // the next { opens a text argument\n let depth = 0 // brace depth inside a text argument (0 = not in one)\n for m in src.matches(regex(\"\\\\\\\\([A-Za-z]+|[\\\\s\\\\S])|\\\\S\")) {\n let t = m.text\n if depth > 0 {\n if t == \"{\" { depth += 1 } else if t == \"}\" { depth -= 1 }\n continue\n }\n if t == \"{\" and text-arg { depth = 1 }\n if t in bars and big != none {\n out += src.slice(last, big.at(0)) + \"{\\\\\" + big.at(1) + \"{\" + src.slice(big.at(0), m.end) + \"}}\"\n last = m.end\n } else if t in bars and not delim {\n out += src.slice(last, m.start) + \"{\\\\mathord{\" + t + \"}}\"\n last = m.end\n }\n let name = if t.starts-with(\"\\\\\") { t.slice(1) } else { \"\" }\n delim = name in (\"left\", \"right\", \"middle\")\n let b = name.match(regex(\"^[bB]igg?([lrm]?)$\"))\n big = if b == none { none } else { (m.start, big-class.at(b.captures.at(0), default: \"mathord\")) }\n text-arg = name.match(regex(\"^(text[a-z]*|mbox|hbox|emph)$\")) != none\n }\n out + src.slice(last)\n}\n\n// LaTeX's cases is an array `@{}l@{\\quad}l@{}`: every column left-aligned,\n// a quad between columns. Typst's cases puts `&` columns right, left, right,\n// ... with no gap, so mitex's `cases(x & x >= 0, ...)` reads \"x x≥0\". Each\n// row x & y becomes `& x && quad y`: its cells in the left-aligned columns.\n#let amp = $&$.body // an alignment point (Typst has no constructor for it)\n#let latex-row(row) = {\n let kids = if type(row) == content and row.func() == [].func() { row.children } else { (row,) }\n let cells = ((),)\n for k in kids {\n if type(k) == content and k.func() == amp.func() { cells.push(()) } else { cells.last().push(k) }\n }\n let cells = cells.map(c => c.join())\n (amp, cells.first(), ..cells.slice(1).map(c => (amp, amp, h(1em), c)).flatten()).join()\n}\n#let latex-cases(..args) = math.cases(..args.named(), ..args.pos().map(latex-row))\n#let latex-scope = mitex-scope + (\n cases: latex-cases,\n rcases: latex-cases.with(reverse: true),\n // mitex draws \\lVert x \\rVert as two bars \"||\"; LaTeX draws one ‖.\n lVert: math.class(\"opening\", sym.bar.v.double),\n rVert: math.class(\"closing\", sym.bar.v.double),\n)\n\n#let inp = sys.inputs\n#let kind = inp.at(\"kind\")\n#let src = read(inp.at(\"src\"))\n#let cw = float(inp.at(\"cw\")) * 1pt\n#let ch = float(inp.at(\"ch\")) * 1pt\n#let maxw = float(inp.at(\"maxw\")) * 1pt\n#let fg = rgb(inp.at(\"fg\"))\n#let bg = rgb(inp.at(\"bg\"))\n#let dark = inp.at(\"dark\") == \"1\"\n#let zoom = float(inp.at(\"zoom\", default: \"1\"))\n\n#set page(width: auto, height: auto, margin: 0pt, fill: none)\n// A sans face at medium weight: the default serif's thin strokes look faint\n// once the terminal scales the picture. Faces missing on a machine are skipped.\n#set text(fill: fg, size: float(inp.at(\"fs\")) * 1pt, font: (\"Helvetica Neue\", \"Hiragino Sans\", \"Noto Sans CJK JP\", \"Noto Sans\"), weight: \"medium\")\n\n#let theme = (\n fg: fg,\n muted: fg.transparentize(45%),\n bg: bg,\n border: fg.transparentize(70%),\n accent: rgb(\"#8ab4f8\"),\n link: rgb(\"#8ab4f8\"),\n red: rgb(if dark { \"#f28b82\" } else { \"#c5221f\" }),\n green: rgb(if dark { \"#81c995\" } else { \"#188038\" }),\n blue: rgb(if dark { \"#8ab4f8\" } else { \"#1a73e8\" }),\n yellow: rgb(if dark { \"#fdd663\" } else { \"#b06000\" }),\n orange: rgb(if dark { \"#fcad70\" } else { \"#e8710a\" }),\n purple: rgb(if dark { \"#c58af9\" } else { \"#9334e6\" }),\n width: maxw,\n height: ch * 16,\n dark: dark,\n)\n#let theme = theme + (palette: (theme.blue, theme.orange, theme.green, theme.red, theme.purple, theme.yellow))\n// Opaque fills for shapes: a transparent fill over the terminal's background\n// leaves the text inside it on a murky, low-contrast colour.\n#let fill-of(c) = color.mix((c, if dark { 45% } else { 25% }), (bg, if dark { 55% } else { 75% }))\n#let theme = theme + (\n fill-blue: fill-of(theme.blue), fill-green: fill-of(theme.green), fill-red: fill-of(theme.red),\n fill-orange: fill-of(theme.orange), fill-yellow: fill-of(theme.yellow), fill-purple: fill-of(theme.purple),\n)\n\n#let body = if kind == \"math\" {\n set text(size: 1.15em)\n math.equation(block: true, eval(\"$\" + mitex-convert(latex-bars(src)) + \"$\", scope: latex-scope))\n} else {\n eval(src, mode: \"markup\", scope: (theme: theme))\n}\n\n#context {\n let m = measure(body)\n // The drawing at the zoom, then shrunk to the width if it is wider.\n let s = if m.width * zoom > maxw and m.width > 0pt { maxw / m.width } else { zoom }\n let w = calc.max(m.width * s, cw)\n let h = m.height * s\n // Whole cells, with a third of a row of air above and below.\n let cols = calc.ceil(w / cw - 0.001)\n let rows = calc.max(1, calc.ceil((h + ch * 0.66) / ch - 0.001))\n box(width: cols * cw, height: rows * ch, align(left + horizon, scale(s * 100%, reflow: true, body)))\n}\n"
4