SLOPSHOPPER

readable

Glow-style rendering of replies, with centered LaTeX math and Mermaid/PlantUML diagrams drawn as images

newrowspromptprocesstimer
★ 1v0.1.0MITupdated 2026-10-15isneru/flake/modules/claude/readable
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · readable
› 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. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Claude's reply
Done. I made refresh reject expired claims and added an audit call.
README

flake

NixOS + Home Manager configuration for victus, structured as a dendritic flake-parts tree: every .nix file under modules/ is a self-contained, self-registering module, auto-discovered by import-tree - there's no manual imports = [ ... ] list anywhere in modules/. A feature that spans both layers (e.g. Hyprland: compositor enable at the NixOS level, config + theme registration at the Home Manager level) declares both in one file instead of being split across a system/user directory boundary.

Hyprland under UWSM, SDDM wearing the same face as the lock screen, and a Quickshell shell that owns the desktop's notifications, lock screen, polkit prompts, credential prompts, screen-share picker and tray. It is deliberately plain: one status line along the bottom, a themed wmenu for every choice, and flat cards only where something needs an answer. The Omen key switches the whole desktop between a flat look (square, translucent, blurred, still layers) and a soft one (rounded, opaque, animated layers). Colours are runtime, not build-time: theme-set <name> repaints almost everything without a rebuild.

Structure

flake.nix                                      # entry point: inputs + flake-parts + import-tree ./modules
hosts/victus/                                  # host identity + hardware - not a pooled aspect
  default.nix                                  # hostName, networking, users, GPU workarounds
  hardware.nix                                 # nixos-generate-config output
modules/                                       # one directory per aspect: <name>/<name>.nix
                                               # plus whatever files it reads or symlinks
  flake/                                       # the flake's own assembly, not aspects
    system.nix                                 # nixosConfigurations.victus assembly
    checks.nix                                 # theme-render check (see below)
    formatter.nix                              # treefmt (nixfmt, deadnix, stylua, shfmt, keep-sorted)
    apps.nix, lib.nix                          # nix run .#<app> standalone previews
  boot/, locales/, nix/, coredump/, secrets/   # nixos-only aspects
  desktop/                                     # portals, power, bluetooth
  networking/                                  # NetworkManager, BBR, wireshark
  virtualisation/                              # libvirt + virt-manager (the Proxmox lab VM)
  sddm/                                        # SDDM (Xorg-hosted greeter) wearing a theme
                                               # built from the lock screen's own QML
  auth/                                        # bare-askpass: ssh, git and sudo prompts
                                               # routed into the shell (nixos + homeManager)
  screencast/                                  # bare-share-picker: the screen-share
                                               # picker xdg-desktop-portal-hyprland raises
  hyprland/                                    # compositor: nixos enable + homeManager
                                               # config (Lua, live-edited via symlink)
  theme-engine/                                # runtime theming system - theme-set CLI,
                                               # theme_engine.py, themes/*.toml
  quickshell/                                  # the shell (QML) - see below, plus its
                                               # lock screen's PAM service and hypridle
  bare/                                        # the menus: bare-menu (themed wmenu,
                                               # patched for image previews), bare-pick
                                               # (the menus), bare-look (flat / soft)
  gaming/                                      # RTX 3050 Ti under PRIME offload, Steam
                                               # and gamescope, the gamerun wrapper
  calendar/                                    # Google Calendar sync (gcal CLI + timer)
                                               # feeding the agenda, and course deadlines
                                               # pushed from Markdown files on save
  audio/, files/, localsend/                   # remaining service aspects
  cli/, packages/, appearance/, cursor/,
  icons/, scripts/                             # shell/fonts/cursor/icons/ad-hoc packages,
                                               # writeShellApplication wrappers
  neovim/                                      # nixvim, and the $EDITOR/$VISUAL contract
  music/                                       # mpd + rmpc, local files, MPRIS into the shell
  kitty/, browser/, gtk/, qt/, starship/,
  vesktop/                                     # remaining homeManager-only aspects
  utils/                                       # exposes `utils` module arg
                                               # (dotfiles path + symlink helper)

The shell

modules/quickshell/ is a Quickshell/QML shell with one status line along the bottom: workspaces, the focused app (or qutebrowser's and yazi's own status line while they have focus), and on the right whatever is going on - a recording, a transfer, a download, the next event, the timer, muted, do not disturb, night light, the mic in use - then weather, battery, network and the clock. Every segment is clickable. It owns the desktop's notification server, lock screen, polkit agent, screen-share picker and system tray, and every part of it is backed by a real service - PipeWire, MPRIS, UPower, NetworkManager, bluez, cliphist, StatusNotifier, open-meteo, PAM, polkit, xdg-desktop-portal, Hyprland's IPC - with nothing mocked.

  • Choices are menus. bare-pick <source> opens a themed wmenu for each of them: apps (or a web search), windows, clipboard, wallpaper, theme, media controls, power and profiles, Wi-Fi, Bluetooth, audio, displays, calendar, course deadlines, notes, the timer, the files shelf, LocalSend, the tray (an app, then its own right-click menu), and a calculator REPL. SUPER+B lists them all.
  • wmenu is patched to draw an image preview beside the list, to centre a list as a box, and to show a transcript without filtering it.
  • Things that need an answer are flat cards: password prompts, the screen-share picker and incoming files. The lock screen has its own face. Notifications and OSDs draw above fullscreen windows; there is no notification history.
  • Things with nothing to pick are popups above the bar: the clock, the weather, battery details, the system monitor, network stats. SUPER+? opens a cheatsheet card.
shell.qml       # ShellRoot: the bar per screen, the OSD and cards, the session lock, every IPC target
singletons/     # state, theme tokens, and the service wrappers
bare/           # the surfaces: bar, OSD, popup, prompt cards, cheatsheet
lock/           # the session lock surface and its face (also the SDDM greeter's)
components/     # the icon glyph the Moodle app shares

Driven by keybinds or over IPC:

KeysAction
SUPER+Bevery menu
SUPER+spacelauncher, or ?query to search
Calculatorcalculator
SUPER+V / SUPER+SHIFT+Vclipboard / files shelf
SUPER+Awallpaper
SUPER+SHIFT+Ttimer
SUPER+M / SUPER+Omedia controls / windows
SUPER+Escapepower and profiles
SUPER+Ttray
SUPER+?keybinding cheatsheet
SUPER+CTRL+Llock
Omenthe flat look and the soft one
SUPER+DDiscord and music pad
bare-pick                         # list every menu
bare-pick wifi                    # open one from a terminal
quickshell ipc call bare osd vol
quickshell ipc call look toggle
quickshell ipc call timer start 300

QML is symlinked from the repo, so edits apply on save with no rebuild.

Theming

The theme engine (theme-set <name>) renders each app's config from a template and reloads it in place. Hyprland, kitty, Neovim, vesktop, rmpc, GTK, Qt, SDDM and the shell all follow it without a rebuild.

A theme is a palette and a font, nothing else - no wallpaper, no radius, no light/dark flag. The wallpaper is your own pick (theme-set wallpaper <path>, or the wallpaper menu) and persists across theme switches, re-tinted to each theme's accent unless you pass --no-recolor. The wallpaper menu also lists Gustave Doré's engravings, which are always dithered to two tones of the current theme.

Adding a theme means dropping a .toml into modules/theme-engine/themes/; just check renders every theme against every template and fails on a missing key or a leftover {{token}}.

Installing on a new machine

The flake describes one machine (victus) and one user (neru). To install it somewhere else, rename those and delete the parts that only make sense here.

  1. Boot the NixOS installer, partition, and mount the target at /mnt.
  2. Clone into the new user's home - it has to be ~/flake, because the shell, Hyprland and the other live-edited configs are symlinks into that checkout: ``sh git clone https://github.com/isneru/flake /mnt/home/<user>/flake cd /mnt/home/<user>/flake ``
  3. Delete what is personal to this machine's owner - each is one directory: ``sh git rm -r modules/{calendar,isep-site,phone,moodle,java,mic,wireguard} modules/packages/personal.nix modules/packages/*-monitor.sh ` and, unless you set them up, modules/secureboot (needs sbctl create-keys and the keys enrolled in firmware) and modules/gaming (needs NVIDIA hybrid graphics). Without calendar and isep-site nothing reads secrets/, so no sops key is needed. modules/whatsapp (a WhatsApp client you link to your own account) and modules/claude (Claude Code skills) are optional - delete them if you do not use them. modules/packages/optional.nix is tools nothing else in the flake calls - keep, trim or empty it to taste; core.nix` beside it is what the shell and scripts need.
  4. Rename the user and the host. Every place that needs it: ``sh git mv hosts/victus hosts/<host> sed -i 's/\bneru\b/<user>/g' hosts/<host>/default.nix modules/flake/system.nix \ modules/sddm/sddm.nix modules/virtualisation/virtualisation.nix sed -i 's/\bvictus\b/<host>/g' hosts/<host>/default.nix modules/flake/system.nix \ modules/neovim/neovim.nix ` If you kept calendar, isep-site or phone, rename in modules/calendar/calendar.nix, modules/isep-site/isep-site.nix and modules/phone/phone.nix too. Other matches for neru (lua/neru/ in Neovim, --neru-surface` in the Vesktop theme) are internal names - leave them.
  5. Replace the hardware file, and edit hosts/<host>/default.nix: timezone, keyboard layout, and drop what belongs to this laptop (the hardware.nvidia.prime bus IDs, i915 and its enable_fbc=0, the nouveau blacklist): ``sh nixos-generate-config --root /mnt --show-hardware-config > hosts/<host>/hardware.nix ``
  6. git add -A (the flake cannot see untracked files), then install and give the user a password (SDDM logs in automatically, but the lock screen and sudo ask for it): ``sh nixos-install --flake .#<host> nixos-enter --root /mnt -c 'passwd <user>' nixos-enter --root /mnt -c 'chown -R <user>:users /home/<user>/flake' ``

Commands

just switch          # nixos-rebuild switch --flake . (needs a password - run it yourself)
just build           # nixos-rebuild build (no activation) - safe to verify a change
just check           # nix flake check
just fmt             # format all files (treefmt)
just update          # update flake inputs, commit the lockfile
just clean           # gc generations older than 7 days, optimise the store
just repair          # verify and repair the Nix store (alias: just fix)
just deploy <host>   # switch a remote host over SSH
just secrets         # decrypt/edit secrets/secrets.yaml with sops
just rotate          # rotate SOPS secrets (secrets/*.yaml)

Run just with no arguments to list all recipes.

Notes

  • Source files carry no comments at all.
  • App configs are symlinked directly from the repo via mkOutOfStoreSymlink (utils.create_symlink), so structural edits take effect immediately; Nix-generated and templated files still need a rebuild or theme-set reapply.
  • nix run github:isneru/flake#kitty (also #quickshell, #btop, #shell) starts either app with this config and a theme of your choice, with no checkout and nothing written to your $HOME.
  • The dGPU is opt-in per launcher: prefix a game with gamerun (or gamerun %command% in Steam). Nothing infers "this is a game", and the launcher deliberately wraps nothing.
  • Screen sharing is the shell's too: xdg-desktop-portal-hyprland is pointed at a picker that draws in the shell. It also absorbs the burst - a Chromium app opens two or three portal sessions for one share, so picking a source used to mean answering the picker two or three times over.
  • Vesktop uses a custom Vencord build patched at build time with userplugins pulled from the vesktop-plugins flake input (isneru/vesktop-custom-plugins).
  • Window frames, the Umber, Vellum, Orpiment and Cinnabar themes and the dithered Doré plates are modelled on athanor by Script Wizards (MIT). Its Hyprland plugin is kept in modules/hyprland/frame-plugin/ with its licence and built against the system's own Hyprland, so it never needs rebuilding by hand; the plates (public domain, sources in plates.toml) are in modules/theme-engine/plates/.
  • Secrets are SOPS-encrypted in secrets/. Rotate with just rotate.
Source 3 files
hooks/register.tsx 458 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Renders } from '../types'
5import { latexMath, unicodeMath } from './unicode-math'
6
7const renders = atom({ plugin: 'readable', key: 'renders' } as const, 0)
8
9const C = {
10  h1Fg: '#ffff87',
11  h1Bg: '#5f5fff',
12  heading: '#00afff',
13  code: '#ff5f5f',
14  codeBg: '#303030',
15  link: '#00af87',
16  url: '#585858',
17  math: '#d7afff',
18  rule: '#585858',
19  quote: '#808080',
20  bullet: '#00afff',
21}
22
23type Kind = 'math' | 'plantuml' | 'mermaid'
24
25type Block =
26  | { type: 'heading'; level: number; text: string }
27  | { type: 'para'; text: string }
28  | { type: 'code'; lang: string; src: string }
29  | { type: 'visual'; kind: Kind; src: string }
30  | { type: 'quote'; blocks: Block[] }
31  | { type: 'list'; ordered: boolean; start: number; items: Block[][] }
32  | { type: 'table'; raw: string; align: string[]; header: string[]; rows: string[][] }
33  | { type: 'hr' }
34
35type Span = {
36  text: string
37  bold?: boolean
38  italic?: boolean
39  strike?: boolean
40  code?: boolean
41  math?: boolean
42  link?: boolean
43  url?: boolean
44}
45
46const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+#.-]*)/
47const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
48const HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/
49const QUOTE = /^\s{0,3}>\s?/
50const LIST = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
51const TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
52
53const indentOf = (line: string) => line.length - line.trimStart().length
54
55const visualKind = (lang: string): Kind | undefined =>
56  lang === 'math' ? 'math'
57    : lang === 'mermaid' ? 'mermaid'
58      : ['plantuml', 'puml', 'uml'].includes(lang) ? 'plantuml'
59        : undefined
60
61const startsBlock = (line: string) => {
62  const t = line.trim()
63  return FENCE.test(line) || HEADING.test(line) || HR.test(line) || QUOTE.test(line)
64    || LIST.test(line) || t.startsWith('$$') || t.startsWith('\\[') || t.startsWith('@startuml')
65}
66
67const splitRow = (line: string) =>
68  line.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(c => c.trim())
69
70function untilClose(lines: string[], i: number, open: string, close: string) {
71  const first = lines[i].trim().slice(open.length)
72  if (first.trimEnd().endsWith(close) && first.trim().length >= close.length) {
73    return { src: first.trimEnd().slice(0, -close.length), next: i + 1 }
74  }
75  const body = [first]
76  for (let j = i + 1; j < lines.length; j++) {
77    const t = lines[j].trimEnd()
78    if (t.endsWith(close)) {
79      body.push(t.slice(0, -close.length))
80      return { src: body.join('\n'), next: j + 1 }
81    }
82    body.push(lines[j])
83  }
84  return undefined
85}
86
87function parseBlocks(lines: string[]): Block[] {
88  const out: Block[] = []
89  let i = 0
90  while (i < lines.length) {
91    const line = lines[i]
92    const t = line.trim()
93    if (!t) { i++; continue }
94
95    const fence = FENCE.exec(line)
96    if (fence) {
97      const body: string[] = []
98      let j = i + 1
99      while (j < lines.length && !lines[j].trim().startsWith(fence[1])) body.push(lines[j++])
100      const isClosed = j < lines.length
101      const lang = fence[2].toLowerCase()
102      const kind = visualKind(lang)
103      const src = body.join('\n')
104      out.push(kind && isClosed ? { type: 'visual', kind, src } : { type: 'code', lang, src })
105      i = j + 1
106      continue
107    }
108
109    if (t.startsWith('@startuml')) {
110      const j = lines.findIndex((l, k) => k > i && l.trim().startsWith('@enduml'))
111      if (j !== -1) {
112        out.push({ type: 'visual', kind: 'plantuml', src: lines.slice(i, j + 1).join('\n') })
113        i = j + 1
114        continue
115      }
116    }
117
118    const delims = t.startsWith('$$') ? ['$$', '$$'] : t.startsWith('\\[') ? ['\\[', '\\]'] : undefined
119    const math = delims && untilClose(lines, i, delims[0], delims[1])
120    if (math && math.src.trim()) {
121      out.push({ type: 'visual', kind: 'math', src: math.src.trim() })
122      i = math.next
123      continue
124    }
125
126    const heading = HEADING.exec(line)
127    if (heading) {
128      out.push({ type: 'heading', level: heading[1].length, text: heading[2] })
129      i++
130      continue
131    }
132
133    if (HR.test(line)) { out.push({ type: 'hr' }); i++; continue }
134
135    if (QUOTE.test(line)) {
136      const body: string[] = []
137      while (i < lines.length && QUOTE.test(lines[i])) body.push(lines[i++].replace(QUOTE, ''))
138      out.push({ type: 'quote', blocks: parseBlocks(body) })
139      continue
140    }
141
142    if (line.includes('|') && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]) && lines[i + 1].includes('-')) {
143      const start = i
144      const header = splitRow(line)
145      const align = splitRow(lines[i + 1]).map(c =>
146        c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left')
147      const rows: string[][] = []
148      i += 2
149      while (i < lines.length && lines[i].includes('|') && lines[i].trim()) rows.push(splitRow(lines[i++]))
150      out.push({ type: 'table', raw: lines.slice(start, i).join('\n'), align, header, rows })
151      continue
152    }
153
154    const item = LIST.exec(line)
155    if (item) {
156      const base = indentOf(line)
157      const ordered = /\d/.test(item[2])
158      const items: string[][] = []
159      let cur: string[] = []
160      let contentIndent = 0
161      while (i < lines.length) {
162        const l = lines[i]
163        const m = LIST.exec(l)
164        if (m && indentOf(l) === base) {
165          cur = [m[3]]
166          items.push(cur)
167          contentIndent = m[1].length + m[2].length + 1
168        } else if (!l.trim()) {
169          const nextLine = lines[i + 1]
170          if (nextLine === undefined || !nextLine.trim() || indentOf(nextLine) < base
171            || (indentOf(nextLine) === base && !LIST.test(nextLine))) break
172          cur.push('')
173        } else if (indentOf(l) > base) {
174          cur.push(l.slice(Math.min(contentIndent, indentOf(l))))
175        } else if (lines[i - 1]?.trim() && !startsBlock(l)) {
176          cur.push(l.trim())
177        } else {
178          break
179        }
180        i++
181      }
182      out.push({ type: 'list', ordered, start: ordered ? parseInt(item[2], 10) : 1, items: items.map(parseBlocks) })
183      continue
184    }
185
186    const body = [t]
187    i++
188    while (i < lines.length && lines[i].trim() && !startsBlock(lines[i])) body.push(lines[i++].trim())
189    out.push({ type: 'para', text: body.join(' ') })
190  }
191  return out
192}
193
194const INLINE = new RegExp([
195  '(`+)([\\s\\S]*?[^`])\\1(?!`)',
196  '\\$(?![\\s$])([^$\\n]*?[^\\s\\\\])\\$(?!\\d)',
197  '\\\\\\(([\\s\\S]+?)\\\\\\)',
198  '\\*\\*([\\s\\S]+?)\\*\\*|__([\\s\\S]+?)__',
199  '~~([\\s\\S]+?)~~',
200  '\\*(?!\\s)([\\s\\S]+?)\\*|\\b_(?!\\s)([\\s\\S]+?)_\\b',
201  '\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)',
202  '<?(https?://[^\\s>]+[^\\s>.,;:!?)])>?',
203].join('|'), 'g')
204
205function parseInline(text: string, style: Omit<Span, 'text'> = {}): Span[] {
206  const out: Span[] = []
207  let last = 0
208  for (const m of text.matchAll(INLINE)) {
209    const at = m.index ?? 0
210    if (at > last) out.push({ ...style, text: text.slice(last, at) })
211    last = at + m[0].length
212    if (m[2] !== undefined) out.push({ ...style, code: true, text: m[2].trim() })
213    else if (m[3] !== undefined || m[4] !== undefined) out.push({ ...style, math: true, text: unicodeMath((m[3] ?? m[4]).trim()) })
214    else if (m[5] !== undefined || m[6] !== undefined) out.push(...parseInline(m[5] ?? m[6], { ...style, bold: true }))
215    else if (m[7] !== undefined) out.push(...parseInline(m[7], { ...style, strike: true }))
216    else if (m[8] !== undefined || m[9] !== undefined) out.push(...parseInline(m[8] ?? m[9], { ...style, italic: true }))
217    else if (m[10] !== undefined) {
218      out.push(...parseInline(m[10], { ...style, link: true }))
219      if (m[11] !== m[10]) out.push({ text: ` ${m[11]}`, url: true })
220    } else out.push({ ...style, link: true, text: m[12] })
221  }
222  if (last < text.length) out.push({ ...style, text: text.slice(last) })
223  return out.map(s => ({ ...s, text: s.code || s.math ? s.text : s.text.replace(/\\([\\`*_{}[\]()#+\-.!|$~])/g, '$1') }))
224}
225
226const plain = (text: string) => parseInline(text).map(s => s.text).join('')
227
228const pending = new Set<string>()
229const failed = new Map<string, string>()
230let cache: { dir: string; version: string } | undefined
231
232function hashKey(s: string) {
233  let a = 0x811c9dc5
234  let b = 0x01000193
235  for (let i = 0; i < s.length; i++) {
236    const c = s.charCodeAt(i)
237    a = Math.imul(a ^ c, 0x01000193)
238    b = Math.imul(b ^ c, 0x5bd1e995)
239  }
240  return (a >>> 0).toString(16).padStart(8, '0') + (b >>> 0).toString(16).padStart(8, '0')
241}
242
243async function renderCache($: EngineInterface) {
244  if (!cache) {
245    const runtime = await $.env.get('XDG_RUNTIME_DIR')
246    const renderer = await $.process
247      .run(['sh', '-c', 'readlink -f "$(command -v readable-render)"'], { timeoutMs: 5_000 })
248      .catch(() => undefined)
249    cache = { dir: `${runtime || '/tmp'}/claude-readable`, version: renderer?.stdout?.trim() ?? '' }
250  }
251  return cache
252}
253
254function schedule($: EngineInterface, key: string, kind: Kind, src: string, color: string) {
255  if (pending.has(key)) return
256  pending.add(key)
257  $.clock.after(0, async () => {
258    try {
259      const { dir } = await renderCache($)
260      await $.fs.write(`${dir}/${key}.src`, src)
261      const run = await $.process.run(
262        ['readable-render', kind, `${dir}/${key}.src`, `${dir}/${key}.png`, color],
263        { timeoutMs: 60_000 },
264      )
265      if (run.exitCode === 0) {
266        const [w, h] = run.stdout.trim().split(/\s+/).map(Number)
267        await $.fs.write(`${dir}/${key}.json`, JSON.stringify({ w, h }))
268      } else {
269        failed.set(key, run.stderr.trim() || `render failed (${run.exitCode})`)
270      }
271    } catch (error) {
272      failed.set(key, String(error))
273    } finally {
274      pending.delete(key)
275      await update($, renders, (n: Renders) => n + 1)
276    }
277  })
278}
279
280export const register: Register = (on, options) => {
281  const cellW = Number(options.cellWidthPx) || 10
282  const cellH = Number(options.cellHeightPx) || 21
283  const mathColor = String(options.mathColor || '#e4e4e4')
284
285  on('prompt.compose', async ($, e, next) => {
286    const composed = await next(e)
287    if (!e.surfaces.includes('terminal')) return composed
288    const text = [
289      '# Readable output',
290      'Your replies are rendered as styled markdown in a kitty terminal. Make them easy to scan:',
291      '- Lead with the answer. Keep paragraphs short. Use headings only for multi-part answers, bullet lists for parallel items and tables for comparisons.',
292      '- Display math goes on its own lines between $$ and $$ (LaTeX with amsmath; use aligned for multi-line). It is drawn as a centered image. Keep inline $...$ math short and simple.',
293      '- When a flow, sequence, state machine, architecture or relationship is clearer as a picture, draw it as a ```mermaid or ```plantuml fenced block; it is drawn as an image.',
294      '- A simple chart of discrete data is a ```mermaid block too: xychart-beta for bars and lines, pie for shares. An xychart has no legend, so name each series in the title or the text.',
295    ].join('\n')
296    return { sections: [...composed.sections, { id: 'readable:format', text, scope: 'session' as const }] }
297  })
298
299  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
300    if (e.surface !== 'terminal' || e.props.isSummary) return next(e)
301    const { Box, Text, Code, Markdown, Image } = $.ui.resolve(e)
302    await read($, renders)
303    const width = Math.max(20, (e.viewport?.columns ?? 80) - 4)
304
305    const spans = (list: Span[], base: { bold?: boolean; color?: string } = {}) =>
306      list.map(s => (
307        <Text
308          color={s.code ? C.code : s.math ? C.math : s.link ? C.link : s.url ? C.url : base.color}
309          backgroundColor={s.code ? C.codeBg : undefined}
310          bold={s.bold || base.bold}
311          italic={s.italic || s.math}
312          strikethrough={s.strike}
313          underline={s.link}
314        >
315          {s.text}
316        </Text>
317      ))
318
319    const codeBlock = (src: string, lang: string, room: number) => {
320      const lines = (src || ' ').replace(/\t/g, '    ').split('\n').flatMap(line => {
321        const chunks: string[] = []
322        for (let k = 0; k < line.length; k += room) chunks.push(line.slice(k, k + room))
323        return chunks.length ? chunks : ['']
324      })
325      const label = lang && lang.length + 6 <= room ? lang : ''
326      return (
327        <Box flexDirection="column" width={room}>
328          <Text color={C.rule}>
329            {label ? '─ ' : ''}
330            {label ? <Text color={C.heading}>{label}</Text> : ''}
331            {(label ? ' ' : '') + '─'.repeat(room - (label ? label.length + 3 : 0))}
332          </Text>
333          <Code source={lines.join('\n')} language={lang || undefined} wrap="truncate-end" />
334          <Text color={C.rule}>{'─'.repeat(room)}</Text>
335        </Box>
336      )
337    }
338
339    const visual = async (b: Extract<Block, { type: 'visual' }>, room: number) => {
340      const color = b.kind === 'math' ? mathColor : ''
341      const src = b.kind === 'math' ? latexMath(b.src) : b.src
342      const { dir, version } = await renderCache($)
343      const key = hashKey(`${version}\0${b.kind}\0${color}\0${src}`)
344      if (await $.fs.exists(`${dir}/${key}.json`)) {
345        const { w, h: height } = JSON.parse(await $.fs.read(`${dir}/${key}.json`))
346        const scale = b.kind === 'plantuml' ? 1 : 2
347        let columns = (w / scale) / cellW
348        let rows = (height / scale) / cellH
349        const fit = Math.min(1, room / columns, 255 / columns, 255 / rows)
350        columns = Math.max(1, Math.round(columns * fit))
351        rows = Math.max(1, Math.round(rows * fit))
352        return (
353          <Box width={room} justifyContent="center">
354            <Image source={{ file: `${dir}/${key}.png`, format: 'png' }} columns={columns} rows={rows} alt={b.src} />
355          </Box>
356        )
357      }
358      const error = failed.get(key)?.split('\n')[0]
359      const note = error ? `⚠ ${error}` : 'rendering…'
360      if (!error) schedule($, key, b.kind, src, color)
361      return (
362        <Box flexDirection="column" width={room}>
363          {codeBlock(b.src, b.kind === 'math' ? 'latex' : b.kind, room)}
364          <Text dimColor>{note}</Text>
365        </Box>
366      )
367    }
368
369    const table = (b: Extract<Block, { type: 'table' }>, room: number) => {
370      const cells = [b.header, ...b.rows].map(r => b.header.map((_, k) => parseInline(r[k] ?? '')))
371      const widths = b.header.map((_, k) => Math.max(3, ...cells.map(r => r[k].map(s => s.text).join('').length)))
372      if (widths.reduce((sum, w) => sum + w + 3, 1) > room) return <Markdown text={b.raw} />
373      const line = (l: string, m: string, r: string) => (
374        <Text color={C.rule}>{l + widths.map(w => '─'.repeat(w + 2)).join(m) + r}</Text>
375      )
376      const row = (r: Span[][], isHeader: boolean) => (
377        <Text wrap="truncate">
378          {r.flatMap((c, k) => {
379            const gap = widths[k] - c.map(s => s.text).join('').length
380            const left = b.align[k] === 'right' ? gap : b.align[k] === 'center' ? Math.floor(gap / 2) : 0
381            return [
382              <Text color={C.rule}>{k === 0 ? '│ ' : ' │ '}</Text>,
383              ' '.repeat(left),
384              ...spans(c, { bold: isHeader }),
385              ' '.repeat(gap - left),
386            ]
387          })}
388          <Text color={C.rule}> │</Text>
389        </Text>
390      )
391      return (
392        <Box flexDirection="column">
393          {line('┌', '┬', '┐')}
394          {row(cells[0], true)}
395          {line('├', '┼', '┤')}
396          {cells.slice(1).map(r => row(r, false))}
397          {line('└', '┴', '┘')}
398        </Box>
399      )
400    }
401
402    const blocks = async (list: Block[], room: number, gap = 1): Promise<any> => (
403      <Box flexDirection="column" gap={gap} width={room}>
404        {await Promise.all(list.map(b => block(b, room)))}
405      </Box>
406    )
407
408    const block = async (b: Block, room: number): Promise<any> => {
409      switch (b.type) {
410        case 'heading':
411          return b.level === 1
412            ? <Text bold color={C.h1Fg} backgroundColor={C.h1Bg}> {plain(b.text)} </Text>
413            : <Text bold color={C.heading}>{'#'.repeat(b.level)} {spans(parseInline(b.text), { bold: true, color: C.heading })}</Text>
414        case 'para':
415          return <Text>{spans(parseInline(b.text))}</Text>
416        case 'code':
417          return codeBlock(b.src, b.lang, room)
418        case 'visual':
419          return visual(b, room)
420        case 'quote':
421          return (
422            <Box flexDirection="row" width={room}>
423              <Box width={2} flexShrink={0}><Text color={C.quote}>│</Text></Box>
424              <Box flexDirection="column">{await blocks(b.blocks, room - 2)}</Box>
425            </Box>
426          )
427        case 'list': {
428          const markerWidth = b.ordered ? String(b.start + b.items.length - 1).length + 2 : 2
429          return (
430            <Box flexDirection="column" width={room}>
431              {await Promise.all(b.items.map(async (item, k) => (
432                <Box flexDirection="row">
433                  <Box width={markerWidth} flexShrink={0}>
434                    <Text color={C.bullet}>{b.ordered ? `${b.start + k}.` : '•'}</Text>
435                  </Box>
436                  {await blocks(item, room - markerWidth, 0)}
437                </Box>
438              )))}
439            </Box>
440          )
441        }
442        case 'table':
443          return table(b, room)
444        case 'hr':
445          return <Text color={C.rule}>{'─'.repeat(Math.min(room, 40))}</Text>
446      }
447    }
448
449    const body = await blocks(parseBlocks(e.props.text.split('\n')), width)
450    return (
451      <Box flexDirection="row" marginTop={1}>
452        <Box width={2} flexShrink={0}><Text>{e.props.isFirstOfReply ? '●' : ' '}</Text></Box>
453        {body}
454      </Box>
455    )
456  })
457}
458
hooks/unicode-math.ts 421 lines
1
2const SYMBOLS: Record<string, string> = {
3  alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', varepsilon: 'ε', zeta: 'ζ', eta: 'η',
4  theta: 'θ', vartheta: 'ϑ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π',
5  varpi: 'ϖ', rho: 'ρ', varrho: 'ϱ', sigma: 'σ', varsigma: 'ς', tau: 'τ', upsilon: 'υ', phi: 'ϕ',
6  varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω', Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ',
7  Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ', Upsilon: 'Υ', Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
8  varkappa: 'ϰ', digamma: 'ϝ', varGamma: 'Γ', varDelta: 'Δ', varTheta: 'Θ', varLambda: 'Λ',
9  varXi: 'Ξ', varPi: 'Π', varSigma: 'Σ', varUpsilon: 'Υ', varPhi: 'Φ', varPsi: 'Ψ', varOmega: 'Ω',
10  nabla: '∇', partial: '∂', infty: '∞', sum: '∑', prod: '∏', int: '∫', oint: '∮', sqrt: '√',
11  coprod: '∐', iint: '∬', iiint: '∭', iiiint: '⨌', bigcup: '⋃', bigcap: '⋂', bigsqcup: '⨆',
12  bigvee: '⋁', bigwedge: '⋀', bigoplus: '⨁', bigotimes: '⨂', bigodot: '⨀', biguplus: '⨄',
13  cdot: '·', cdots: '⋯', ldots: '…', dots: '…', times: '×', div: '÷', pm: '±', mp: '∓', ast: '∗',
14  dotsc: '…', dotso: '…', dotsb: '⋯', dotsm: '⋯', dotsi: '⋯', hdots: '…', vdots: '⋮', ddots: '⋱',
15  cdotp: '·', ldotp: '.', colon: ':', centerdot: '·',
16  circ: '∘', bullet: '•', star: '⋆', prime: '′', le: '≤', leq: '≤', ge: '≥', geq: '≥', ne: '≠',
17  neq: '≠', approx: '≈', sim: '∼', simeq: '≃', cong: '≅', equiv: '≡', propto: '∝', ll: '≪', gg: '≫',
18  leqq: '≦', geqq: '≧', leqslant: '⩽', geqslant: '⩾', lll: '⋘', ggg: '⋙', lesssim: '≲', gtrsim: '≳',
19  lessapprox: '⪅', gtrapprox: '⪆', lessgtr: '≶', gtrless: '≷', lesseqgtr: '⋚', gtreqless: '⋛',
20  prec: '≺', succ: '≻', preceq: '⪯', succeq: '⪰', preccurlyeq: '≼', succcurlyeq: '≽', precsim: '≾',
21  succsim: '≿', nless: '≮', ngtr: '≯', nleq: '≰', ngeq: '≱', lneq: '⪇', gneq: '⪈', lneqq: '≨',
22  gneqq: '≩', nprec: '⊀', nsucc: '⊁', approxeq: '≊', nsim: '≁', ncong: '≇', doteq: '≐', doteqdot: '≑',
23  Doteq: '≑', triangleq: '≜', coloneqq: '≔', coloneq: '≔', eqqcolon: '≕', eqcirc: '≖', circeq: '≗',
24  bumpeq: '≏', Bumpeq: '≎', asymp: '≍', varpropto: '∝', backsim: '∽', backsimeq: '⋍', thicksim: '∼',
25  thickapprox: '≈', eqsim: '≂',
26  in: '∈', notin: '∉', ni: '∋', subset: '⊂', subseteq: '⊆', supset: '⊃', supseteq: '⊇', cup: '∪',
27  owns: '∋', nsubseteq: '⊈', nsupseteq: '⊉', subsetneq: '⊊', supsetneq: '⊋', subseteqq: '⫅',
28  supseteqq: '⫆', subsetneqq: '⫋', supsetneqq: '⫌', Subset: '⋐', Supset: '⋑', sqsubset: '⊏',
29  sqsupset: '⊐', sqsubseteq: '⊑', sqsupseteq: '⊒', setminus: '∖', smallsetminus: '∖', complement: '∁',
30  cap: '∩', emptyset: '∅', varnothing: '∅', forall: '∀', exists: '∃', neg: '¬', lnot: '¬', land: '∧',
31  wedge: '∧', lor: '∨', vee: '∨', oplus: '⊕', otimes: '⊗', to: '→', rightarrow: '→', leftarrow: '←',
32  nexists: '∄', uplus: '⊎', sqcap: '⊓', sqcup: '⊔', wr: '≀', diamond: '⋄', bigtriangleup: '△',
33  bigtriangledown: '▽', triangleleft: '◁', triangleright: '▷', lhd: '⊲', rhd: '⊳', unlhd: '⊴',
34  unrhd: '⊵', ominus: '⊖', oslash: '⊘', odot: '⊙', bigcirc: '◯', amalg: '⨿', dotplus: '∔',
35  ltimes: '⋉', rtimes: '⋊', leftthreetimes: '⋋', rightthreetimes: '⋌', Cap: '⋒', Cup: '⋓',
36  doublecap: '⋒', doublecup: '⋓', barwedge: '⊼', veebar: '⊻', doublebarwedge: '⩞', boxplus: '⊞',
37  boxminus: '⊟', boxtimes: '⊠', boxdot: '⊡', circledast: '⊛', circledcirc: '⊚', circleddash: '⊝',
38  intercal: '⊺', divideontimes: '⋇', curlyvee: '⋎', curlywedge: '⋏', dag: '†', dagger: '†',
39  ddag: '‡', ddagger: '‡',
40  gets: '←', Rightarrow: '⇒', Leftarrow: '⇐', implies: '⟹', iff: '⟺', leftrightarrow: '↔',
41  Leftrightarrow: '⇔', mapsto: '↦', uparrow: '↑', downarrow: '↓', langle: '⟨', rangle: '⟩',
42  impliedby: '⟸', longmapsto: '⟼', longrightarrow: '⟶', longleftarrow: '⟵', longleftrightarrow: '⟷',
43  Longrightarrow: '⟹', Longleftarrow: '⟸', Longleftrightarrow: '⟺', updownarrow: '↕', Uparrow: '⇑',
44  Downarrow: '⇓', Updownarrow: '⇕', nearrow: '↗', searrow: '↘', swarrow: '↙', nwarrow: '↖',
45  hookrightarrow: '↪', hookleftarrow: '↩', rightharpoonup: '⇀', rightharpoondown: '⇁',
46  leftharpoonup: '↼', leftharpoondown: '↽', rightleftharpoons: '⇌', leftrightharpoons: '⇋',
47  rightleftarrows: '⇄', leftrightarrows: '⇆', rightrightarrows: '⇉', leftleftarrows: '⇇',
48  twoheadrightarrow: '↠', twoheadleftarrow: '↞', rightarrowtail: '↣', leftarrowtail: '↢',
49  rightsquigarrow: '⇝', leadsto: '⇝', leftrightsquigarrow: '↭', nrightarrow: '↛', nleftarrow: '↚',
50  nRightarrow: '⇏', nLeftarrow: '⇍', nleftrightarrow: '↮', nLeftrightarrow: '⇎',
51  curvearrowright: '↷', curvearrowleft: '↶', circlearrowright: '↻', circlearrowleft: '↺',
52  upharpoonright: '↾', upharpoonleft: '↿', downharpoonright: '⇂', downharpoonleft: '⇃',
53  restriction: '↾', Lleftarrow: '⇚', Rrightarrow: '⇛', dashrightarrow: '⇢', dashleftarrow: '⇠',
54  looparrowright: '↬', looparrowleft: '↫', Lsh: '↰', Rsh: '↱', upuparrows: '⇈', downdownarrows: '⇊',
55  lfloor: '⌊', rfloor: '⌋', lceil: '⌈', rceil: '⌉', hbar: 'ℏ', ell: 'ℓ', Re: 'ℜ', Im: 'ℑ', aleph: 'ℵ',
56  lbrace: '{', rbrace: '}', lbrack: '[', rbrack: ']', backslash: '\\', ulcorner: '⌜', urcorner: '⌝',
57  llcorner: '⌞', lrcorner: '⌟', lgroup: '⟮', rgroup: '⟯', llbracket: '⟦', rrbracket: '⟧',
58  hslash: 'ℏ', wp: '℘', imath: 'ı', jmath: 'ȷ', beth: 'ℶ', gimel: 'ℷ', daleth: 'ℸ', eth: 'ð',
59  Bbbk: '𝕜', mho: '℧', Finv: 'Ⅎ', Game: '⅁', AA: 'Å',
60  angle: '∠', perp: '⊥', parallel: '∥', mid: '∣', top: '⊤', bot: '⊥', vdash: '⊢', models: '⊨',
61  measuredangle: '∡', sphericalangle: '∢', nmid: '∤', nparallel: '∦', shortmid: '∣',
62  shortparallel: '∥', dashv: '⊣', vDash: '⊨', Vdash: '⊩', Vvdash: '⊪', nvdash: '⊬', nvDash: '⊭',
63  nVdash: '⊮', nVDash: '⊯', bowtie: '⋈', Join: '⋈', smile: '⌣', frown: '⌢', between: '≬',
64  pitchfork: '⋔', vartriangle: '△', vartriangleleft: '⊲', vartriangleright: '⊳',
65  trianglelefteq: '⊴', trianglerighteq: '⊵', ntriangleleft: '⋪', ntriangleright: '⋫', multimap: '⊸',
66  triangle: '△', triangledown: '▽', blacktriangle: '▲', blacktriangledown: '▼', square: '□',
67  Box: '□', blacksquare: '■', lozenge: '◊', blacklozenge: '⧫', Diamond: '◇', diamondsuit: '♢',
68  heartsuit: '♡', clubsuit: '♣', spadesuit: '♠', flat: '♭', natural: '♮', sharp: '♯', surd: '√',
69  backprime: '‵', S: '§', P: '¶', copyright: '©', pounds: '£', yen: '¥', checkmark: '✓',
70  maltese: '✠', circledS: 'Ⓢ', circledR: '®', bigstar: '★', therefore: '∴',
71  because: '∵', diagup: '╱', diagdown: '╲',
72  quad: '  ', qquad: '    ', lvert: '|', rvert: '|', vert: '|', Vert: '‖', lVert: '‖', rVert: '‖',
73  enspace: ' ', enskip: ' ', thinspace: ' ', medspace: ' ', thickspace: ' ', negthinspace: '',
74  negmedspace: '', negthickspace: '', mod: 'mod', bmod: 'mod', hfill: ' ', hfil: ' ', space: ' ',
75  nobreakspace: ' ', over: '/', atop: '', choose: 'choose', relbar: '─', Relbar: '═', joinrel: '',
76  liminf: 'lim inf', limsup: 'lim sup', injlim: 'inj lim', projlim: 'proj lim', varliminf: 'lim inf',
77  varlimsup: 'lim sup', varinjlim: 'inj lim', varprojlim: 'proj lim', backepsilon: '϶',
78  blacktriangleleft: '◀', blacktriangleright: '▶', curlyeqprec: '⋞', curlyeqsucc: '⋟',
79  eqslantgtr: '⪖', eqslantless: '⪕', fallingdotseq: '≒', risingdotseq: '≓', gnapprox: '⪊',
80  lnapprox: '⪉', gnsim: '⋧', lnsim: '⋦', gtrdot: '⋗', lessdot: '⋖', gtreqqless: '⪌',
81  lesseqqgtr: '⪋', gvertneqq: '≩', lvertneqq: '≨', ngeqq: '≱', ngeqslant: '≱', nleqq: '≰',
82  nleqslant: '≰', npreceq: '⋠', nsucceq: '⋡', nshortmid: '∤', nshortparallel: '∦',
83  nsubseteqq: '⊈', nsupseteqq: '⊉', ntrianglelefteq: '⋬', ntrianglerighteq: '⋭', precapprox: '⪷',
84  succapprox: '⪸', precnapprox: '⪹', succnapprox: '⪺', precneqq: '⪵', succneqq: '⪶', precnsim: '⋨',
85  succnsim: '⋩', smallfrown: '⌢', smallsmile: '⌣', smallint: '∫', intop: '∫', ointop: '∮',
86  idotsint: '∫⋯∫', varsubsetneq: '⊊', varsubsetneqq: '⫋', varsupsetneq: '⊋', varsupsetneqq: '⫌',
87  varbigtriangledown: '▽', varbigtriangleup: '△', lmoustache: '⎰', rmoustache: '⎱', arrowvert: '|',
88  Arrowvert: '‖', bracevert: '⎪', mathdollar: '$', mathellipsis: '…', mathparagraph: '¶',
89  mathsection: '§', mathsterling: '£', mathunderscore: '_', And: '&', vcentcolon: ':',
90}
91
92const FUNCTIONS = new Set([
93  'arccos', 'arcsin', 'arctan', 'arg', 'cos', 'cosh', 'cot', 'coth', 'csc', 'deg', 'det', 'dim', 'exp',
94  'gcd', 'hom', 'inf', 'ker', 'lg', 'lim', 'ln', 'log', 'max', 'min', 'Pr', 'sec', 'sin', 'sinh',
95  'sup', 'tan', 'tanh', 'argmin', 'argmax',
96])
97
98const ACCENTS: Record<string, string> = {
99  hat: '\u0302', widehat: '\u0302', check: '\u030c', widecheck: '\u030c', tilde: '\u0303',
100  widetilde: '\u0303', acute: '\u0301', grave: '\u0300', breve: '\u0306', bar: '\u0304',
101  mathring: '\u030a', dot: '\u0307', ddot: '\u0308', dddot: '\u20db', ddddot: '\u20dc', vec: '\u20d7',
102  overrightarrow: '\u20d7', overleftarrow: '\u20d6', overleftrightarrow: '\u20e1', overline: '\u0305',
103  underline: '\u0332', underbar: '\u0332', cancel: '\u0338', bcancel: '\u0338', xcancel: '\u0338',
104  sout: '\u0336', underrightarrow: '\u20ef', underleftarrow: '\u20ee', underleftrightarrow: '\u034d',
105  Hat: '\u0302', Check: '\u030c', Tilde: '\u0303', Acute: '\u0301', Grave: '\u0300', Breve: '\u0306',
106  Bar: '\u0304', Dot: '\u0307', Ddot: '\u0308', Vec: '\u20d7',
107}
108
109const SPREAD = new Set(['overline', 'underline', 'underbar', 'cancel', 'bcancel', 'xcancel', 'sout'])
110
111type Alphabet = {
112  upper?: number
113  lower?: number
114  digit?: number
115  Greek?: number
116  greek?: number
117  holes?: Record<string, string>
118}
119
120const ALPHABETS: Record<string, Alphabet> = {
121  bb: {
122    upper: 0x1d538, lower: 0x1d552, digit: 0x1d7d8,
123    holes: { C: 'ℂ', H: 'ℍ', N: 'ℕ', P: 'ℙ', Q: 'ℚ', R: 'ℝ', Z: 'ℤ' },
124  },
125  cal: {
126    upper: 0x1d49c, lower: 0x1d4b6,
127    holes: { B: 'ℬ', E: 'ℰ', F: 'ℱ', H: 'ℋ', I: 'ℐ', L: 'ℒ', M: 'ℳ', R: 'ℛ', e: 'ℯ', g: 'ℊ', o: 'ℴ' },
128  },
129  frak: { upper: 0x1d504, lower: 0x1d51e, holes: { C: 'ℭ', H: 'ℌ', I: 'ℑ', R: 'ℜ', Z: 'ℨ' } },
130  bf: { upper: 0x1d400, lower: 0x1d41a, digit: 0x1d7ce, Greek: 0x1d6a8, greek: 0x1d6c2 },
131  bm: { upper: 0x1d468, lower: 0x1d482, digit: 0x1d7ce, Greek: 0x1d71c, greek: 0x1d736 },
132  sf: { upper: 0x1d5a0, lower: 0x1d5ba, digit: 0x1d7e2 },
133  tt: { upper: 0x1d670, lower: 0x1d68a, digit: 0x1d7f6 },
134}
135
136const FONTS: Record<string, string> = {
137  mathbb: 'bb', mathds: 'bb', mathbbm: 'bb', Bbb: 'bb', mathcal: 'cal', mathscr: 'cal', mathfrak: 'frak',
138  frak: 'frak', mathbf: 'bf', textbf: 'bf', bold: 'bf', boldsymbol: 'bm', bm: 'bm', pmb: 'bm', mathbfit: 'bm', mathsf: 'sf',
139  textsf: 'sf', mathtt: 'tt', texttt: 'tt',
140}
141
142const RAW = new Set([
143  'text', 'textrm', 'textit', 'textnormal', 'textup', 'textbf', 'textsf', 'texttt', 'mbox', 'hbox',
144  'textsc', 'emph', 'intertext', 'shortintertext',
145])
146
147const PASS = new Set([
148  'mathrm', 'mathit', 'mathnormal', 'operatorname', 'mathop', 'mathrel', 'mathbin', 'mathord',
149  'mathopen', 'mathclose', 'mathpunct', 'mathinner', 'ensuremath', 'smash', 'mathclap', 'mathllap', 'mathrlap', 'clap', 'llap', 'rlap',
150  'substack', 'sideset', 'operatornamewithlimits', 'shoveleft', 'shoveright', 'ref',
151])
152
153const NOOP = new Set([
154  'left', 'right', 'middle', 'big', 'Big', 'bigg', 'Bigg', 'bigl', 'bigr', 'bigm', 'Bigl', 'Bigr',
155  'Bigm', 'biggl', 'biggr', 'biggm', 'Biggl', 'Biggr', 'Biggm', 'displaystyle', 'textstyle',
156  'scriptstyle', 'scriptscriptstyle', 'limits', 'nolimits', 'nonumber', 'notag', 'mathstrut', 'strut',
157  'relax', 'protect', 'allowbreak', 'nobreak', 'rm', 'it', 'bf', 'sf', 'tt', 'cal', 'normalsize',
158  'tiny', 'scriptsize', 'footnotesize', 'small', 'large', 'Large', 'null', 'displaybreak',
159  'allowdisplaybreaks', 'nobreakdash',
160])
161
162const BRACES = new Set(['underbrace', 'overbrace', 'underbracket', 'overbracket'])
163
164const DROP = new Set(['label', 'vphantom', 'color', 'textcolor', 'colorbox', 'vspace', 'leftroot', 'uproot', 'skew', 'raisetag'])
165
166const FILLERS: Record<string, string> = { hspace: ' ', mspace: ' ', hdotsfor: '…' }
167
168const STARRED = new Set(['operatorname', 'hspace', 'vspace', 'tag'])
169
170const XARROWS: Record<string, [string, string]> = {
171  xrightarrow: ['─', '→'], xleftarrow: ['←', '─'], xRightarrow: ['═', '⇒'], xLeftarrow: ['⇐', '═'],
172  xleftrightarrow: ['←', '→'], xLeftrightarrow: ['⇐', '⇒'], xmapsto: ['├', '→'],
173  xhookrightarrow: ['↪', '→'], xhookleftarrow: ['←', '↩'],
174}
175
176const ENVS: Record<string, [string, string]> = {
177  pmatrix: ['(', ')'], bmatrix: ['[', ']'], Bmatrix: ['{', '}'], vmatrix: ['|', '|'],
178  Vmatrix: ['‖', '‖'], cases: ['{ ', ''], dcases: ['{ ', ''], rcases: ['', ' }'],
179}
180
181const SUP: Record<string, string> = {
182  0: '⁰', 1: '¹', 2: '²', 3: '³', 4: '⁴', 5: '⁵', 6: '⁶', 7: '⁷', 8: '⁸', 9: '⁹', '+': '⁺', '-': '⁻',
183  '=': '⁼', '(': '⁽', ')': '⁾', a: 'ᵃ', b: 'ᵇ', c: 'ᶜ', d: 'ᵈ', e: 'ᵉ', f: 'ᶠ', g: 'ᵍ', h: 'ʰ', i: 'ⁱ',
184  j: 'ʲ', k: 'ᵏ', l: 'ˡ', m: 'ᵐ', n: 'ⁿ', o: 'ᵒ', p: 'ᵖ', r: 'ʳ', s: 'ˢ', t: 'ᵗ', u: 'ᵘ', v: 'ᵛ',
185  w: 'ʷ', x: 'ˣ', y: 'ʸ', z: 'ᶻ', T: 'ᵀ', '∗': '*', '*': '*', '′': '′', 'θ': 'ᶿ', 'β': 'ᵝ', 'γ': 'ᵞ', 'δ': 'ᵟ',
186  'φ': 'ᵠ', 'χ': 'ᵡ', ' ': ' ',
187  A: 'ᴬ', B: 'ᴮ', D: 'ᴰ', E: 'ᴱ', G: 'ᴳ', H: 'ᴴ', I: 'ᴵ', J: 'ᴶ', K: 'ᴷ', L: 'ᴸ', M: 'ᴹ', N: 'ᴺ',
188  O: 'ᴼ', P: 'ᴾ', R: 'ᴿ', U: 'ᵁ', V: 'ⱽ', W: 'ᵂ', 'α': 'ᵅ', 'ε': 'ᵋ', 'ι': 'ᶥ', 'ϕ': 'ᶲ', '∘': '°',
189  '⊤': 'ᵀ', '⊥': '⊥', '†': '†', '‡': '‡', '⋆': '⋆',
190}
191
192const SUB: Record<string, string> = {
193  0: '₀', 1: '₁', 2: '₂', 3: '₃', 4: '₄', 5: '₅', 6: '₆', 7: '₇', 8: '₈', 9: '₉', '+': '₊', '-': '₋',
194  '=': '₌', '(': '₍', ')': '₎', a: 'ₐ', e: 'ₑ', h: 'ₕ', i: 'ᵢ', j: 'ⱼ', k: 'ₖ', l: 'ₗ', m: 'ₘ', n: 'ₙ',
195  o: 'ₒ', p: 'ₚ', r: 'ᵣ', s: 'ₛ', t: 'ₜ', u: 'ᵤ', v: 'ᵥ', x: 'ₓ', 'β': 'ᵦ', 'γ': 'ᵧ', 'ρ': 'ᵨ',
196  'φ': 'ᵩ', 'χ': 'ᵪ', ' ': ' ',
197}
198
199function arg(s: string, i: number): [string, number] {
200  while (s[i] === ' ') i++
201  if (s[i] === '{') {
202    let depth = 1
203    let j = i + 1
204    while (j < s.length && depth > 0) {
205      if (s[j] === '{') depth++
206      else if (s[j] === '}') depth--
207      j++
208    }
209    return [s.slice(i + 1, depth > 0 ? j : j - 1), j]
210  }
211  const command = /^\\[a-zA-Z]+/.exec(s.slice(i))
212  return command ? [command[0], i + command[0].length] : [s[i] ?? '', i + 1]
213}
214
215function opt(s: string, i: number): [string | undefined, number] {
216  const j = s[i] === '[' ? s.indexOf(']', i) : -1
217  return j < 0 ? [undefined, i] : [s.slice(i + 1, j), j + 1]
218}
219
220const script = (text: string, table: Record<string, string>, mark: string) => {
221  const chars = [...text]
222  return chars.every(c => table[c]) ? chars.map(c => table[c]).join('')
223    : `${mark}${chars.length > 1 ? `(${text})` : text}`
224}
225
226const GREEK_VARIANTS = '∂ϵϑϰϕϱϖ'
227
228function restyle(text: string, a: Alphabet): string {
229  const at = (ch: string, base: number | undefined, offset: number) =>
230    base === undefined ? ch : String.fromCodePoint(base + offset)
231  return [...text].map(ch => {
232    const code = ch.codePointAt(0) ?? 0
233    if (a.holes?.[ch]) return a.holes[ch]
234    if (ch >= 'A' && ch <= 'Z') return at(ch, a.upper, code - 0x41)
235    if (ch >= 'a' && ch <= 'z') return at(ch, a.lower, code - 0x61)
236    if (ch >= '0' && ch <= '9') return at(ch, a.digit, code - 0x30)
237    if (ch >= 'Α' && ch <= 'Ω') return at(ch, a.Greek, code - 0x391)
238    if (ch >= 'α' && ch <= 'ω') return at(ch, a.greek, code - 0x3b1)
239    if (ch === '∇') return at(ch, a.Greek, 25)
240    const variant = GREEK_VARIANTS.indexOf(ch)
241    return variant < 0 ? ch : at(ch, a.greek, 25 + variant)
242  }).join('')
243}
244
245function environment(name: string, body: string): string {
246  const env = name.replace('*', '')
247  const [open, close] = ENVS[env] ?? ['', '']
248  const rows = (/^(array|alignedat|subarray)$/.test(env) ? body.slice(arg(body, 0)[1]) : body)
249    .split('\\\\')
250    .map(row => row.split('&').map(cell => unicodeMath(cell.trim())).filter(Boolean).join(' '))
251    .filter(Boolean)
252  return `${open}${rows.join('; ')}${close}`
253}
254
255const LATEX: Record<string, string> = {
256  '−': '-', '′': "'", '″': "''", '°': '^{\\circ}', 'ε': '\\varepsilon', 'ϵ': '\\epsilon', '√': '\\surd',
257  'ο': 'o', '∎': '\\blacksquare', '≢': '\\not\\equiv', '≉': '\\not\\approx', '✗': '\\text{\\ding{55}}',
258  '✘': '\\text{\\ding{56}}', '✔': '\\text{\\ding{52}}', '⋅': '\\cdot', '∙': '\\bullet', '◦': '\\circ',
259  '∶': ':', '∕': '/', '⁄': '/', '〈': '\\langle', '〉': '\\rangle', '⌀': '\\varnothing', '∆': '\\Delta',
260  'µ': '\\mu', 'Ω': '\\Omega', '➝': '\\to', '➔': '\\to', '☆': '\\star', '‐': '-', '‑': '-', '–': '-',
261  '½': '\\tfrac{1}{2}', '⅓': '\\tfrac{1}{3}', '⅔': '\\tfrac{2}{3}', '¼': '\\tfrac{1}{4}',
262  '¾': '\\tfrac{3}{4}', '∛': '\\sqrt[3]{\\,}', '∜': '\\sqrt[4]{\\,}', '\u2009': '\\,', '\u200a': '\\,',
263  '\u202f': '\\,', '\u2005': '\\,', '\u2003': '\\quad', '\u200b': '',
264}
265for (const pair of 'ΑA ΒB ΕE ΖZ ΗH ΙI ΚK ΜM ΝN ΟO ΡP ΤT ΧX'.split(' ')) LATEX[pair[0]] = pair[1]
266for (const [name, ch] of Object.entries(SYMBOLS)) {
267  if (ch > '\u007f' && [...ch].length === 1) LATEX[ch] ??= `\\${name}`
268}
269for (const [font, key, ranges] of [
270  ['mathbb', 'bb', 'A'], ['mathcal', 'cal', 'A'], ['mathfrak', 'frak', 'Aa'], ['mathbf', 'bf', 'Aa0'],
271  ['boldsymbol', 'bm', 'Aa'], ['mathsf', 'sf', 'Aa0'], ['mathtt', 'tt', 'Aa0'],
272]) {
273  for (const first of ranges) {
274    for (let n = 0; n < (first === '0' ? 10 : 26); n++) {
275      const ch = String.fromCharCode(first.charCodeAt(0) + n)
276      LATEX[restyle(ch, ALPHABETS[key])] ??= `\\${font}{${ch}}`
277    }
278  }
279}
280
281const invert = (table: Record<string, string>) =>
282  Object.fromEntries(Object.entries(table).filter(([from, to]) => to !== from && to !== '*' && to !== '°').reverse().map(([from, to]) => [to, from]))
283const RAISED = invert(SUP)
284const LOWERED = invert(SUB)
285
286export function latexMath(src: string): string {
287  const scripts = (text: string, table: Record<string, string>, mark: string) =>
288    text.replace(new RegExp(`[${Object.keys(table).join('')}]+`, 'gu'), run => `${mark}{${[...run].map(ch => table[ch]).join('')}}`)
289  return [...scripts(scripts(src, RAISED, '^'), LOWERED, '_')]
290    .map(ch => (LATEX[ch] === undefined ? ch : LATEX[ch] && `\\ensuremath{${LATEX[ch]}}`))
291    .join('')
292}
293
294export function unicodeMath(src: string): string {
295  let out = ''
296  let i = 0
297  while (i < src.length) {
298    const c = src[i]
299    if (c === '^' || c === '_') {
300      const [a, next] = arg(src, i + 1)
301      out += script(unicodeMath(a), c === '^' ? SUP : SUB, c)
302      i = next
303    } else if (c === '{' || c === '}') {
304      i++
305    } else if (c === '\\') {
306      const name = /^\\([a-zA-Z]+|.)/.exec(src.slice(i))?.[1] ?? ''
307      i += name.length + 1
308      if (src[i] === '*' && STARRED.has(name)) i++
309      if (['frac', 'dfrac', 'tfrac', 'cfrac', 'genfrac'].includes(name)) {
310        if (name === 'genfrac') for (let skipped = 0; skipped < 4; skipped++) i = arg(src, i)[1]
311        const [num, a] = arg(src, i)
312        const [den, b] = arg(src, a)
313        const wrap = (t: string) => (t.length > 1 ? `(${t})` : t)
314        out += `${wrap(unicodeMath(num))}/${wrap(unicodeMath(den))}`
315        i = b
316      } else if (['binom', 'dbinom', 'tbinom'].includes(name)) {
317        const [n, a] = arg(src, i)
318        const [k, b] = arg(src, a)
319        out += `C(${unicodeMath(n)}, ${unicodeMath(k)})`
320        i = b
321      } else if (name === 'sqrt') {
322        const [n, start] = opt(src, i)
323        const [a, next] = arg(src, start)
324        const inner = unicodeMath(a)
325        const root = n === undefined || n === '2' ? '√' : n === '3' ? '∛' : n === '4' ? '∜'
326          : `${script(unicodeMath(n), SUP, '^')}√`
327        out += inner.length > 1 ? `${root}(${inner})` : `${root}${inner}`
328        i = next
329      } else if (['overset', 'underset', 'stackrel'].includes(name)) {
330        const [mark, a] = arg(src, i)
331        const [base, b] = arg(src, a)
332        out += unicodeMath(base) + (name === 'underset' ? script(unicodeMath(mark), SUB, '_') : script(unicodeMath(mark), SUP, '^'))
333        i = b
334      } else if (name === 'overunderset') {
335        const [over, a] = arg(src, i)
336        const [under, b] = arg(src, a)
337        const [base, next] = arg(src, b)
338        out += unicodeMath(base) + script(unicodeMath(over), SUP, '^') + script(unicodeMath(under), SUB, '_')
339        i = next
340      } else if (XARROWS[name]) {
341        const [a, next] = arg(src, opt(src, i)[1])
342        out += `${XARROWS[name][0]}${unicodeMath(a)}${XARROWS[name][1]}`
343        i = next
344      } else if (name === 'not') {
345        const [a, next] = arg(src, i)
346        const [first = '', ...rest] = [...unicodeMath(a)]
347        out += `${first}\u0338`.normalize('NFC') + rest.join('')
348        i = next
349      } else if (['pmod', 'pod', 'tag', 'eqref'].includes(name)) {
350        const [a, next] = arg(src, i)
351        out += name === 'pmod' ? `(mod ${unicodeMath(a)})` : `(${name === 'pod' ? unicodeMath(a) : a})`
352        i = next
353      } else if (name === 'textsuperscript' || name === 'textsubscript') {
354        const [a, next] = arg(src, i)
355        out += name === 'textsuperscript' ? script(a, SUP, '^') : script(a, SUB, '_')
356        i = next
357      } else if (name === 'boxed' || name === 'fbox') {
358        const [a, next] = arg(src, i)
359        out += `[${unicodeMath(a)}]`
360        i = next
361      } else if (BRACES.has(name)) {
362        const [a, next] = arg(src, i)
363        const inner = unicodeMath(a)
364        out += [...inner].length > 1 ? `(${inner})` : inner
365        i = next
366      } else if (name === 'phantom' || name === 'hphantom') {
367        const [a, next] = arg(src, i)
368        out += ' '.repeat([...unicodeMath(a)].length)
369        i = next
370      } else if (FILLERS[name]) {
371        out += FILLERS[name]
372        i = arg(src, i)[1]
373      } else if (name === 'begin') {
374        const [env, start] = arg(src, i)
375        const close = `\\end{${env}}`
376        const end = src.indexOf(close, start)
377        out += environment(env, src.slice(start, end < 0 ? src.length : end))
378        i = end < 0 ? src.length : end + close.length
379      } else if (FONTS[name] || RAW.has(name) || PASS.has(name)) {
380        const [a, next] = arg(src, i)
381        const inner = RAW.has(name) ? a : unicodeMath(a)
382        out += FONTS[name] ? restyle(inner, ALPHABETS[FONTS[name]]) : inner
383        i = next
384      } else if (ACCENTS[name]) {
385        const [a, next] = arg(src, i)
386        const inner = unicodeMath(a)
387        out += SPREAD.has(name) ? [...inner].map(ch => ch + ACCENTS[name]).join('') : inner + ACCENTS[name]
388        i = next
389      } else if (DROP.has(name)) {
390        i = arg(src, i)[1]
391      } else if (NOOP.has(name)) {
392        if (['left', 'right', 'middle'].includes(name) && src[i] === '.') i++
393      } else if ([',', ';', ':', ' ', '>', '\\'].includes(name)) {
394        out += ' '
395      } else if (name === '!' || name === '/' || name === '-') {
396      } else if (name === '|') {
397        out += '‖'
398      } else if (name === '{' || name === '}' || name === '$' || name === '%' || name === '#' || name === '&' || name === '_') {
399        out += name
400      } else {
401        out += SYMBOLS[name] ?? (FUNCTIONS.has(name) ? name : `\\${name}`)
402        while (SYMBOLS[name] === undefined && !FUNCTIONS.has(name) && src[i] === '{') {
403          const [a, next] = arg(src, i)
404          out += `{${a}}`
405          i = next
406        }
407      }
408    } else if (c === "'") {
409      out += '′'
410      i++
411    } else if (c === '~') {
412      out += ' '
413      i++
414    } else {
415      out += c
416      i++
417    }
418  }
419  return out
420}
421
types/index.d.ts 8 lines
1export type Renders = number
2
3declare module 'claude-code' {
4  interface PluginState {
5    readable: { renders: Renders }
6  }
7}
8