SLOPSHOPPER

figure-view

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.

newrowscommandpromptprocesstimer
v0.1.0MITupdated 2026-10-20Kenyan822/figure-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · figure-view
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /figure ⎿ figure-view: on(dark、大きさ 300%) ⎿ figure-view: 使えません: typst が見つかりません(brew install typst) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

figure-view

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 answerShown 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 elseClaude 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).

What it looks like

Sample output, typeset by the mod's own template (dark theme):

Display math rendered by figure-view

Figures rendered by figure-view: a data-flow diagram, an ER diagram, a radar chart, a confusion matrix, a mind map and a Transformer block

Requirements

  • Claude Code 2.1.298 or later (mods)
  • Typst 0.15: brew install typst
  • A terminal that shows Kitty Unicode-placeholder images: kitty, Ghostty, cmux, Orca 1.4.229+. In other terminals the pictures show as text.

Install

In Claude Code:

/plugin install figure-view --marketplace Kenyan822/figure-view

Commands

CommandWhat it does
/figure statusShows the state, size, Typst path and the cell size it found
/figure on / offTurns 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 / lightColours for a dark or a light terminal
/figure redrawSends every picture to the terminal again (when the terminal dropped them, as Orca does on a tab switch)
/figure openWrites the last answer to .figure-view/answer.md and opens it (in Orca's Markdown preview, or the default app)

How it works

  1. prompt.compose tells Claude that display math and `typst blocks are drawn as pictures (and which Typst packages and theme colours it can use).
  2. 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.
  3. Each piece is compiled once with typst compile into a PNG whose size is a whole number of cells, and drawn with the engine's Image element.
  4. 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.

Limits

  • Inline math is a Unicode approximation; write tall math as display math.
  • Copying a picture with the mouse copies the image's placeholder characters; use Claude Code's /copy for the Markdown.
  • Scrolling over pictures is not smooth: pictures are swapped in and out as the view moves.
  • Orca keeps at most 32 MB of decoded pictures per pane (stablyai/orca#23547) and drops them on a tab switch (#30383). The mod keeps pictures small and only near the view; /figure redraw brings back dropped ones.

See also

mermaid-view, in the same marketplace: Mermaid diagrams drawn as terminal text (/plugin install mermaid-view --marketplace Kenyan822/figure-view).

Licence

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 の表を見てください。

Source 6 files
hooks/register.tsx 287 lines
1import 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}
287
hooks/lib/render.ts 156 lines
1import 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}
156
hooks/lib/rows.ts 54 lines
1// 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}
54
hooks/lib/segments.ts 79 lines
1// 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}
79
hooks/lib/unicode.ts 149 lines
1// 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}
149
hooks/lib/piece.ts 4 lines
1// 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