Colorful, themeable rendering for Claude Code replies: tables, headings, code, numbers, paths and links.

<h1 align="center"><img src="docs/brand/banner.png" alt="prismantis" width="640"></h1>
<a href="https://github.com/NahumLitvin/prismantis/releases"><img src="https://img.shields.io/github/v/release/NahumLitvin/prismantis?style=flat-square&color=cba6f7" alt="Release"></a> <a href="https://github.com/NahumLitvin/prismantis/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/NahumLitvin/prismantis/ci.yml?branch=main&style=flat-square&label=ci" alt="CI"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/NahumLitvin/prismantis?style=flat-square&color=cba6f7" alt="License"></a> <img src="https://img.shields.io/badge/Claude%20Code-2.1.287%2B-cba6f7?style=flat-square" alt="Claude Code 2.1.287 or later"> <a href="https://github.com/hesreallyhim/awesome-claude-code"><img src="https://awesome.re/mentioned-badge.svg" alt="Mentioned in Awesome Claude Code"></a> <a href="https://statuslin.es/mods/prismantis"><img src="https://img.shields.io/badge/listed%20on-statuslin.es-c96442?style=flat-square" alt="Listed on statuslin.es"></a>
<a href="#install"><strong>Install</strong></a> · <a href="#themes">Themes</a> · <a href="#features">Features</a> · <a href="#configure">Configure</a> · <a href="docs/CHANGELOG.md">Changelog</a>

<sub>Same prompt, same model: plain Claude Code, then with prismantis. <a href="https://github.com/user-attachments/assets/7f4744fc-ca52-4ea3-adb3-43467190dc62">▶ Watch the full 44-second tour</a> (tables, charts, diagrams, diffs, callouts, right-to-left text).</sub>
mono, and copy buttons on everything./plugin to turn it off. Claude Code's own renderer comes back the moment you do.⧉ html (the clipboard helper) or turn on LaTeX (RaTeX's renderer)./plugin marketplace add NahumLitvin/prismantis
/plugin install prismantis@prismantis
Then run /prismantis setup to pick a theme and styles from drawn samples, and /prismantis demo for a tour of the main features. In a terminal, setup opens a pane: the arrows or Tab move through the styles of a step with a preview of the one in focus, Enter saves it and shows the next step. Where the session has no terminal, as in the desktop app, it walks the same steps in the transcript.
Requires Claude Code 2.1.287 or later. Run the two commands above.
To update, run this, then /reload in every open session. A session keeps the version it loaded until it reloads.
claude plugin marketplace update prismantis && claude plugin update prismantis@prismantis
To uninstall:
claude plugin uninstall prismantis@prismantis
For LaTeX math, install RaTeX's renderer and set latex to true; see LaTeX math. Until then formulas draw as text.
prismantis also runs in Codex, with themes for code, tables and diagrams and the same session note. Install, what works today, and the two Codex issues it waits on for parity: codex/README.md.
| Feature | What you get |
|---|---|
| Themes | /prismantis theme <name> switches on the spot. 16 themes and 20 color slots you can override |
| Tables | colored headers, rules, column alignment, colored numbers, sized to the terminal |
| Code | a language header and copy button, Prism highlighting in two dozen languages, shell lines colored like a prompt |
| Diagrams and charts | flowcharts, sequence, state, class and ER diagrams, bar and line charts, one color per box, participant and bar |
| Figures | `figure text pictures drawn as written in the theme's colors, for timelines, layouts and before and after; glyphs and markup checked before they are drawn |
| LaTeX math | $$ and `math formulas typeset as images in kitty and Ghostty by RaTeX, text everywhere else |
| Layout | back-to-back tables and diagrams sit side by side and wrap on narrow terminals |
| Copy buttons | ⧉ md, ⧉ art and ⧉ html on tables, [ ⧉ copy ] on code, lists and quotes, /prismantis copy without a mouse |
| Tool rows | Ran gh pr view 12, Read ~/src/app.ts, groups summed up as Ran 3 commands, read 2 files, with status dots; toolOutput: quiet hides what Claude only read and says in plain words what each call did |
| Turn footer | ✻ Baked for 6m 20s with the duration in the number color |
| Slash commands | command output (/cost, /context, plugin commands) gets the same tables and code styling |
| Diagram hints | a short model-only note on each prompt so Claude reaches for diagrams, charts and figures when they help, and builds explaining replies around the pictures |
| Text | bold, italic, strikethrough, inline code, links, versions, durations, percentages and paths in their own colors |
| Headings, lists, quotes | 4 heading styles, nested lists, task lists, quotes with an accent bar |
| Your prompts | what you type draws in a bubble, an accent bar or a chevron, so you find your turns when you scroll back, and clock adds when you sent it and when the turn finished |
| Right to left | Hebrew and Arabic read right to left, with bullets, quote bars and table columns mirrored |
| Other mods | $.prismantis.markdown draws any markdown the way replies are drawn, for mod authors |


Dark: catppuccin-mocha (default), dracula, nord, tokyo-night, gruvbox-dark, rose-pine, everforest, github-dark, one-dark, solarized-dark.
Light: catppuccin-latte, gruvbox-light, rose-pine-dawn, github-light, solarized-light.
mono drops the palette for bold and dim; alerts keep their colors. Switch with /prismantis theme nord, or run /prismantis for the help screen with every theme. Every palette is MIT licensed and credited in THIRD_PARTY_NOTICES.md.

Header cells take the tableHeader color, and the default box style puts a double line under the header. tableStyle picks box (the default, every cell boxed), rules, grid or minimal. :---: and ---: alignment is honored, numbers in cells get the number color, and columns shrink to fit the terminal.


Each code block gets a header row: the language on the left, a copy button on the right. The code sits indented below with no frame, so selecting it with the mouse copies only the code.
bash, sh, zsh, fish, powershell, unlabeled) color the command word, --flags, quoted strings and # comments, and restart after |, && and ;.


Code blocks tagged mermaid draw as colored text art:
graph LR, graph TD, decisions), sequence, state, class and ER diagramsxychart-beta, sized to the terminal widthpie charts as a circle with a colored legend, largest slice first, and gantt schedules as bars on a shared date or time axis, starting on a half cell and ending on an eighth so close durations draw apartpieStyle picks how a pie draws. round falls back to bars in ascii mode, with more slices than the theme has distinct colors (mono always) or in a narrow terminal:
round (default) | bars |
|---|---|
| a circle, each slice in its own color, with a legend | one bar per slice, largest first, with its share |
![]() | ![]() |

Diagrams too wide for the window, or over 80 lines, stay as code. mermaidAscii swaps box-drawing characters for + - |.
Some things read better as a picture than as a diagram: a timeout racing a retry, the layout of a screen, a system before and after a change. Code blocks tagged figure draw line by line as written, in the theme's colors:

{dim:timeout at 10 s, handler books at 12 s, retry books again} {note:0 s} {note:10 s} {note:12 s} {note:later} {accent:▏─────────────▏─────▏─────────────────▏} Provider {accent:██████████████}{warn:✗} timeout {accent:█} retry Handler {ok:████████████████████} 200, nobody waits Bookings 0 {ok:1} {warn:2 twice}
{role:text} colors a run: accent for frames, lines and arrows (diagram color), warn for the break or the risk, ok for what is healthy or done, note for time and effort, dim for side notes (comment color), strong for the one thing that matters (strong color); the rest takes the diagram text color. warn, ok and note take the theme's reddest, greenest and most amber accent color, each a different one and none the diagram color, so a risk never looks like a frame line in any theme; hex and rgb() colors you set take part in that pick, and a role whose own slot (flag, string or heading color) you set to a color name or ansi256() keeps that colormono, warn draws boldfigure line 4: "⚠" may draw as an emoji, use a text glyph⧉ source copies the block as Claude wrote it, ⧉ art the drawn textmermaid is on or off; only the diagram hints note follows mermaid
Display math, $$…$$ on lines of its own or a `math block, draws as a typeset image in the theme's diagram text color, one formula per line. With copy buttons on, its ⧉ copy button copies the formula's LaTeX, since selecting an image copies blank cells. It needs RaTeX's PNG renderer, a single binary that typesets KaTeX syntax in a few milliseconds without TeX, a browser or Node:
gh release download -R erweixin/RaTeX -p 'ratex-cli-*-aarch64-apple-darwin.tar.gz'
tar -xzf ratex-cli-*.tar.gz
mkdir -p ~/.local/bin
install ratex-cli-*/render ~/.local/bin/ratex-render
Pick the archive for your platform (x86_64-apple-darwin, x86_64-unknown-linux-musl, ...). Any directory on your PATH works, but the file must be named ratex-render.
LaTeX is off until you set latex to true. Then prismantis runs ratex-render once in kitty and Ghostty, outside tmux, or wherever CLAUDE_CODE_FORCE_TERMINAL_IMAGES makes Claude Code draw images, such as a background session viewed in one of them, and keeps LaTeX on only if that test formula comes back. If a later run fails, formulas already drawn stay and new ones stay text until you /reload. A reply shown before the test finishes draws its formulas as text, then redraws them as images. Anywhere else, without the renderer, or with latex off, formulas draw as text in a math code block. So does a formula the renderer rejects, or one too wide for the terminal even at one row. While LaTeX is on, the diagram hints note also tells Claude that $$ math renders. Inline $…$ draws as italic text without its dollar signs, with common commands such as \rho, \cdot and \le shown as symbols; amounts like $5 stay as written.
Formula text matches the reply text. A terminal image fills whole rows, so a formula that falls between two row counts is rendered again with more padding rather than stretched.

When tables, diagrams and figures follow each other, they share a row and wrap once the terminal runs out of width. A wide terminal shows a table, a flowchart and two charts side by side.

⧉ md copies the original Markdown and ⧉ art a boxed table that pastes well into Slack. ⧉ html copies a formatted table (headers, alignment, inline formatting) plus a tab-separated plain-text copy.[ ⧉ copy ] on each. Code copies without its fences, lists as markdown, quotes without their > markers.⧉ source copies the mermaid or figure source, ⧉ art the drawn art.⧉ copy reply, which copies the reply as Claude wrote it. Selecting right-to-left text on screen copies letters in drawn order, not reading order./prismantis copy copies the last reply, /prismantis copy code its last code block.copyButtonStyle picks where the buttons sit. inline, the default, is the look above. hover hides them until the pointer is on their block: a block's buttons float over its top-right corner, or from its left edge on a block narrower than they are, and the reply's over its bottom-right corner, so they take no line and nothing moves when they show. ctrl+x tab does not reach a hidden button, and the mobile app, which has no pointer, draws them inline.

⧉ html shows in a local macOS terminal or a Linux graphical session. macOS needs nothing extra. Linux needs CopyQ running (see its Wayland caveats). On Ubuntu:
sudo apt install -y copyq && copyq --start-server && copyq config autostart true
Clicking works where the terminal passes clicks through, as fullscreen mode does. Terminals with copy-on-select, such as Warp, may grab the word "copy" instead.


Each tool call draws as one line: a verb and its target, Ran with a colored shell command, Read and Edited with the path. A status dot shows done, running or failed, and output still draws below. Edits and new files draw as a diff card in the theme's colors, and so do files a shell command changed (sed -i, a script), one card per file headed by its path. diffStyle picks the look: card (default) frames the lines in a rounded box, full drops the box and tints each changed line across the width, marks drops the tint and colors the changed lines themselves, and bar puts a colored ▌ before each changed line.
Collapsed groups draw one line too: Ran 3 commands, read 2 files · last: npm test, with · N failed in the flag color when any call failed. Expand a group (ctrl+o or --verbose): shell calls keep their colored command with the output boxed below, and other calls use Claude Code's own rows. ctrl+o on a reply shows the original text.
toolStyle keeps tool rows apart from what Claude says:
chat (default) | tree-dim | tree-bold | classic |
|---|---|---|---|
| dimmed on the right, capped at 60% of the width | tucked under the sentence with ⎿, dimmed | tree-dim with one-line sentences in bold | bold verb and status dot, full brightness |
![]() | ![]() | ![]() | ![]() |
toolOutput: quiet shows what Claude did, not what it read: reads, searches and read-only shell commands draw one plain row like Read src/report.py lines 1–10 and nothing under it. Edits keep their diff and failures show their real error; the full rules are in docs/tool-output.md.


The line that closes a turn keeps Claude Code's word and colors the duration: ✻ Baked for 6m 20s. Terminal only, since that's the only surface that draws it.
Output from slash commands, built-in or from other plugins, is drawn like a reply, copy buttons included. Errors keep Claude Code's own red line.
Claude rarely writes a chart unless it knows the terminal can draw one. With diagramHints on (the default), each prompt you type or another plugin sends carries a short note that only the model reads. It says tables, alerts, code, mermaid diagrams and xychart-beta charts render here, and asks for commands in fenced blocks, since only those get a copy button. A second part describes figures: prefer one over mermaid when position carries meaning (durations racing on a time axis, a screen layout, phases, before and after), keep mermaid for plain flows and charts, and draw none for short answers, code changes or reports; the prose never retells the picture. When you ask Claude to explain, compare or plan something and pictures would make it easier to grasp, it builds the reply around them, with the core on the first screen as a picture or a sentence. Then come the role markup, at most 74 columns so a figure fits an 80-column window, glyphs one column wide, and edge ticks ▏ for a time axis so █ bars line up.
The note costs about 630 tokens per prompt: about 275 for the main part and about 360 for the figure part, plus about 80 while LaTeX math is on, when it also says $$ formulas render. All three are counted with Claude's tokenizer. It's off whenever mermaid is off, and skipped for the prompt a headless claude -p run starts with and for background notifications; a prompt another plugin sends inside such a run still carries it.


Bold, italic, strikethrough, inline code, links and bare URLs, clickable as terminal hyperlinks. Numbers, versions (v2.14.0), durations (250ms, 3h), sizes (16Gi) and percentages (99.9%) take the number color, and paths like ~/src/app.ts the path color.
Footnotes draw as superscript marks, with the notes under a dim rule at the end of the reply. Click a mark, in a sentence, a list, a quote or a table cell, to see its note in a toast. Point at a note to light every mark that cites it, or at a mark to light its note. Clicks and pointing work where the terminal passes the mouse through, as fullscreen mode does, and in the desktop app. They need Claude Code 2.1.295 or later; earlier versions draw the marks as before.

headingStyle picks banner (the default: a box around H1, a heavy rule under H2), bold, underline or uppercase. Terminals have one font size, so headings stand out through style and color.
Lists keep their numbers and nest with • and ◦. Quotes get an accent bar down every line and keep their paragraphs, lists, code, tables and nested quotes, and GitHub alerts (> [!NOTE], [!TIP], [!IMPORTANT], [!WARNING], [!CAUTION]) draw as colored boxes.
Task lists draw as [ ] and [✓], with done items dimmed and struck through. taskStyle switches to ticks (○ ✓), box (□ ✓) or progress, which adds a done-count bar above each list.


What you type, at the prompt or through Remote Control, draws in the theme's colors. promptStyle picks the look, and off keeps Claude Code's own. Task notifications and teammate messages are left alone.
bubble (default) | bar | chevron |
|---|---|---|
![]() | ![]() | ![]() |
clock adds the time: each prompt shows when you sent it, and the l
hooks/register.tsx 694 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, PromptOrigin, Register, RenderElement, RenderSurface } from 'claude-code'
3
4import { clockText, endedAt, promptKey, sentAt, withRecord } from './clock'
5import type { Formula } from './latex'
6import { LATEX_DPR, LATEX_FONT_PX, MAX_IMAGE_BYTES, fitImage, fitsImage, imageFile, imageOf, keepFormulas, mathOf, padFor, pickFormula, pngSize, ratexColor, renderedOf } from './latex'
7import type { Block } from './markdown'
8import { nestedBlocks, parse } from './markdown'
9import type { ClipboardBackend } from './clipboard'
10import { clipboardCommand } from './clipboard'
11import { FIGURE_HINT } from './figure'
12import { drawDiagrams } from './mermaid'
13import type { Fold } from './render'
14import { bashEdits, drawMath, editNotes, hasShellOutput, instanceKey, isReadOnlyCall, mirrored, noteTip, remember, rememberCall, renderBashEdits, renderBlocks, renderDiffCard, renderExpandedShell, renderFailure, renderShellResult, renderToolGroup, renderToolRow, renderTurnDuration, renderUserPrompt, takesClickableMarks, takesEditorLinks, takesHttpLinks, withoutBashEdits } from './render'
15import type { SetupStep } from './help'
16import { HINT, ISSUE_HINT, SETUP_DONE, SETUP_PANE_KEYS, SETUP_PANE_OPENED, THEME_PREVIEW, findSetup, helpText, languageShowcase, rtlShowcaseText, setupParts, setupSteps, setupText, showcaseText } from './help'
17import { PRESET_NAMES } from './presets'
18import type { Style } from './theme'
19import { RANDOM_THEMES, pickTheme, resolveStyle } from './theme'
20import type { Terminal } from './rtl'
21import { ROW_MARK_TEXT, TERMINALS, hasRtl, opensLatin } from './rtl'
22
23const HINTED_ORIGINS = ['composer', 'bridge', 'plugin'] as const satisfies readonly PromptOrigin['kind'][]
24
25const takesHint = (origin: PromptOrigin): boolean => HINTED_ORIGINS.some(kind => kind === origin.kind)
26
27const LATEX_HINT = 'This terminal typesets LaTeX math: a formula in $$…$$ on lines of its own, or in a ```math block, renders as an image (KaTeX syntax). Inline $…$ does not render, so write inline math as plain text or Unicode.'
28
29const detectTerminal = async ($: EngineInterface): Promise<Terminal | null> => {
30 const program = await $.env.get('TERM_PROGRAM')
31 const term = await $.env.get('TERM')
32 if ((await $.env.get('KITTY_WINDOW_ID')) || term === 'xterm-kitty') return 'kitty'
33 if (program === 'Apple_Terminal') return 'apple-terminal'
34 if (program === 'WarpTerminal') return 'warp'
35 if (program === 'ghostty') return 'ghostty'
36 if (program === 'WezTerm') return 'wezterm'
37 if (program === 'vscode') return 'vscode'
38 if (program === 'iTerm.app') return 'iterm'
39 if (term === 'alacritty' || (await $.env.get('ALACRITTY_WINDOW_ID'))) return 'alacritty'
40 if (await $.env.get('WT_SESSION')) return 'windows-terminal'
41 if (await $.env.get('VTE_VERSION')) return 'gnome'
42 if (await $.env.get('KONSOLE_VERSION')) return 'konsole'
43 return null
44}
45
46const applyRtl = async ($: EngineInterface, style: Style): Promise<Terminal | null> => {
47 if (style.rtl === 'off') return null
48 if (style.rtl !== 'auto') return style.rtl
49 const terminal = await detectTerminal($)
50 style.reorder = terminal !== null
51 if (terminal) style.shape = TERMINALS[terminal]
52 return terminal
53}
54
55const expandedCalls = new Set<string>()
56const readOnlyCalls = new Set<string>()
57
58const columnsOf = (viewport?: { columns?: number }) => Math.max(20, (viewport?.columns ?? 100) - 4)
59
60const formulas = atom({ plugin: 'prismantis', key: 'formulas' } as const, {})
61const latexDir = atom({ plugin: 'prismantis', key: 'dir' } as const, '')
62const unfolded = atom({ plugin: 'prismantis', key: 'unfolded' } as const, [] as string[])
63const sent = atom({ plugin: 'prismantis', key: 'sent' } as const, [])
64const turnEnds = atom({ plugin: 'prismantis', key: 'turnEnds' } as const, [])
65const setupAt = atom({ plugin: 'prismantis', key: 'setupAt' } as const, 0)
66const setupFocus = atom({ plugin: 'prismantis', key: 'setupFocus' } as const, '')
67const setupLand = atom({ plugin: 'prismantis', key: 'setupLand' } as const, '')
68
69const firstDrawn = async ($: EngineInterface, id: string): Promise<number> => {
70 const ref = { plugin: 'prismantis', key: 'drawn', id } as const
71 const { value } = await $.state.get(ref)
72 if (value !== undefined) return value
73 const now = await $.clock.now()
74 $.clock.after(0, () => void $.state.set(ref, now, { ifVersion: 0 }))
75 return now
76}
77
78type Typeset = Exclude<Formula, { error: true }> & { tex: string }
79
80const showsImages = async ($: EngineInterface): Promise<boolean> => {
81 if (await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')) return true
82 if (await $.env.get('TMUX')) return false
83 const terminal = await detectTerminal($)
84 return terminal === 'kitty' || terminal === 'ghostty'
85}
86
87type Latex = { command: string; dir: string }
88type LatexSession = { style: Style; color: string; pending: Set<string>; wanted: Set<string>; batch: string[]; engine?: Promise<Latex | null>; ready?: Latex | null; stopped?: boolean; queue: Promise<void> }
89
90const formulaKey = (color: string, tex: string) => `${color}\0${tex}`
91
92const typeset = async ($: EngineInterface, color: string, engine: Latex, texs: string[], fontSize = LATEX_FONT_PX, dpr = LATEX_DPR): Promise<Formula[]> => {
93 const { stdout } = await $.process.run(
94 [engine.command, '--output-dir', engine.dir, '--color', color, '--background-color', 'transparent', '--font-size', String(fontSize), '--dpr', String(dpr)],
95 { stdin: `${texs.join('\n')}\n`, timeoutMs: 2000 },
96 )
97 const rendered = renderedOf(stdout)
98 return Promise.all(texs.map(async (_, i): Promise<Formula> => {
99 if (!rendered.has(i + 1)) return { error: true }
100 const png = await $.fs.read(`${engine.dir}/${String(i + 1).padStart(4, '0')}.png`, { as: 'bytes' }).then(r => r.base64, () => '')
101 const size = png ? pngSize(png) : null
102 return size && fitsImage(png) ? { png, ...size } : { error: true }
103 }))
104}
105
106const withPadding = async ($: EngineInterface, color: string, engine: Latex, texs: string[], results: Formula[]): Promise<Formula[]> => {
107 const padded: Formula[] = []
108 for (const [i, result] of results.entries()) {
109 const pad = 'png' in result ? padFor(result) : null
110 const [again] = pad ? await typeset($, color, engine, [texs[i]!], pad.fontSize, pad.dpr).catch((): Formula[] => []) : []
111 padded.push(again && 'png' in again && 'png' in result ? { ...result, padded: again } : result)
112 }
113 return padded
114}
115
116const startLatex = async ($: EngineInterface, latex: LatexSession): Promise<Latex | null> => {
117 if (!latex.style.latex || !(await $.session.surfaces()).includes('terminal') || !(await showsImages($))) return null
118 const tmp = (await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? '/tmp'
119 const suffix = (await read($, latexDir)) || crypto.randomUUID()
120 await update($, latexDir, () => suffix)
121 const engine = { command: 'ratex-render', dir: `${tmp.replace(/[\\/]+$/, '')}/prismantis-latex-${await $.session.id()}-${suffix}` }
122 const [check] = await typeset($, latex.color, engine, ['x^2'])
123 return check && 'png' in check ? engine : null
124}
125
126const latexEngine = ($: EngineInterface, latex: LatexSession): Promise<Latex | null> =>
127 (latex.engine ??= startLatex($, latex)
128 .catch(() => null)
129 .then(engine => {
130 latex.ready = engine
131 if (engine) $.ui.invalidate('ui.render')
132 return engine
133 }))
134
135const stopLatex = (latex: LatexSession) => {
136 latex.stopped = true
137 latex.pending.clear()
138 latex.batch = []
139}
140
141const typesetLater = async ($: EngineInterface, latex: LatexSession, texs: string[]): Promise<void> => {
142 const color = latex.color
143 const engine = await latexEngine($, latex)
144 if (!engine || latex.stopped) return
145 const results = await typeset($, color, engine, texs).then(
146 done => withPadding($, color, engine, texs, done),
147 () => null,
148 )
149 if (!results) return stopLatex(latex)
150 const fresh = texs.map((tex, i) => [formulaKey(color, tex), results[i]!] as const)
151 await update($, formulas, store => keepFormulas(store, fresh, latex.wanted))
152 latex.wanted.clear()
153 for (const tex of texs) latex.pending.delete(formulaKey(color, tex))
154}
155
156const mathOfBlocks = async ($: EngineInterface, latex: LatexSession, surface: string, blocks: Block[]): Promise<Map<number, Typeset>> => {
157 const maths = [...blocks.entries()].flatMap(([i, block]) => {
158 const tex = mathOf(block)
159 return tex === null ? [] : [{ i, tex, key: formulaKey(latex.color, tex) }]
160 })
161 if (surface !== 'terminal' || maths.length === 0) return new Map()
162 void latexEngine($, latex)
163 if (!latex.ready) return new Map()
164 for (const { key } of maths) latex.wanted.add(key)
165 const store = (await read($, formulas)) as Record<string, Formula>
166 const missing = latex.stopped ? [] : [...new Map(maths.map(({ key, tex }) => [key, tex]))].filter(([key]) => !store[key] && !latex.pending.has(key))
167 if (missing.length) {
168 if (latex.batch.length === 0) {
169 $.clock.after(0, () => {
170 const texs = latex.batch
171 latex.batch = []
172 latex.queue = latex.queue.then(() => typesetLater($, latex, texs)).catch(() => stopLatex(latex))
173 })
174 }
175 for (const [key, tex] of missing) {
176 latex.pending.add(key)
177 latex.batch.push(tex)
178 }
179 }
180 return new Map(maths.flatMap(({ i, tex, key }) => {
181 const formula = store[key]
182 return formula && 'png' in formula ? [[i, { tex, ...formula }] as const] : []
183 }))
184}
185
186type Picture = { png: string; width: number; height: number; alt: string }
187
188const pictureOf = async ($: EngineInterface, src: string, home: string): Promise<Omit<Picture, 'alt'> | null> => {
189 const file = imageFile(src, home)
190 if (!file) return null
191 const stat = 'path' in file ? await $.fs.stat(file.path).catch(() => null) : null
192 if ('path' in file && (stat?.kind !== 'file' || stat.size > MAX_IMAGE_BYTES)) return null
193 const png = 'png' in file ? file.png : await $.fs.read(file.path, { as: 'bytes' }).then(r => r.base64, () => '')
194 const size = png ? pngSize(png) : null
195 return size && size.width > 0 && size.height > 0 && fitsImage(png) ? { png, ...size } : null
196}
197
198const loaded = new Map<string, Promise<Omit<Picture, 'alt'> | null>>()
199
200const picturesOf = async ($: EngineInterface, style: Style, probes: Probes, surface: string, blocks: Block[]): Promise<Map<number, Picture>> => {
201 const images = [...blocks.entries()].flatMap(([i, block]) => {
202 const image = imageOf(block)
203 return image ? [{ i, ...image }] : []
204 })
205 if (surface !== 'terminal' || images.length === 0 || !(await (probes.images ??= showsImages($).catch(() => false)))) return new Map()
206 await locate($, style)
207 const found = await Promise.all(images.map(async ({ i, alt, src }) => {
208 const picture = await remember(loaded, src, () => pictureOf($, src, style.home), 50)
209 return picture ? [[i, { ...picture, alt }] as const] : []
210 }))
211 return new Map(found.flat())
212}
213
214const locate = async ($: EngineInterface, style: Style) => {
215 style.cwd = await $.session.root().catch(() => style.cwd)
216 style.home ||= (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined)) ?? ''
217}
218
219const htmlBackend = async ($: EngineInterface): Promise<ClipboardBackend | null> => {
220 if (await $.env.get('SSH_CONNECTION') || await $.env.get('SSH_TTY')) return null
221 if (await $.env.get('OS') === 'Windows_NT') return 'windows'
222 const helper = await $.fs.stat('/usr/bin/osascript').catch(() => null)
223 if (helper?.kind === 'file') return 'macos'
224 return await $.env.get('WAYLAND_DISPLAY') || await $.env.get('DISPLAY') ? 'linux' : null
225}
226
227type LinkReach = { http: boolean; editor: boolean; marks: boolean }
228
229type Probes = { terminal?: Promise<Terminal | null>; htmlCopy?: Promise<ClipboardBackend | null>; links?: Promise<LinkReach>; images?: Promise<boolean> }
230
231const linkReach = ($: EngineInterface, probes: Probes): Promise<LinkReach> =>
232 (probes.links ??= $.session.version().then(v => ({ http: takesHttpLinks(v.base), editor: takesEditorLinks(v.base), marks: takesClickableMarks(v.base) }), () => ({ http: false, editor: false, marks: false })))
233
234const probe = async ($: EngineInterface, style: Style, probes: Probes): Promise<Terminal | null> => {
235 probes.terminal ??= applyRtl($, style)
236 const terminal = await probes.terminal
237 probes.htmlCopy ??= htmlBackend($)
238 style.htmlCopy = await probes.htmlCopy
239 return terminal
240}
241
242const copyTable = async ($: EngineInterface, backend: ClipboardBackend, html: string, text: string): Promise<string | null> => {
243 const systemRoot = backend === 'windows' ? await $.env.get('SystemRoot') : undefined
244 const command = clipboardCommand(backend, html, text, systemRoot)
245 if (backend === 'windows' && !systemRoot) return command.failure
246 const result = await $.process.run(command.argv, { stdin: command.stdin, timeoutMs: 5000 }).catch(() => null)
247 if (!result) return command.failure
248 return result.exitCode === 0 ? null : result.stderr.trim() || command.failure
249}
250
251const folder = ($: EngineInterface, el: ReturnType<EngineInterface['ui']['resolve']>, open?: string[]): Fold => (id, hidden, key) => {
252 if (!open) return null
253 const folded = !open.includes(id)
254 return { folded, element: <el.Button key={key} variant="secondary" label={folded ? `+${hidden} more lines` : 'show less'} onPress={() => update($, unfolded, ids => (folded ? [...ids, id].slice(-500) : ids.filter(x => x !== id)))} /> }
255}
256
257const drawMarkdown = ($: EngineInterface, el: ReturnType<EngineInterface['ui']['resolve']>, style: Style, blocks: ReturnType<typeof parse>, columns: number, math: Map<number, Typeset> = new Map(), pictures: Map<number, Picture> = new Map(), reply?: string, open?: string[], notes?: { id: string; press: boolean }): RenderElement[] => {
258 const { Button } = el
259 const copy = (text: string | (() => string), key: string, label = '⧉ copy', html?: () => string) => {
260 const copied = async (surface: RenderSurface): Promise<string> => {
261 const content = typeof text === 'function' ? text() : text
262 const backend = style.htmlCopy
263 const failure = html && backend ? await copyTable($, backend, html(), content) : undefined
264 if (failure === null) return 'Copied formatted table'
265 const result = await $.ui.copy({ text: content, surface })
266 if (!result.isCopied) return `Copy failed: ${result.reason}`
267 return failure ? `Copied as plain text (${failure})` : 'Copied'
268 }
269 return style.copyButtons && (!html || style.htmlCopy) ? (
270 <Button
271 key={key}
272 variant="primary"
273 label={label}
274 onPress={async press => $.ui.toast(await copied(press.surface).catch(() => 'Copy failed'))}
275 />
276 ) : null
277 }
278 const drawn = drawDiagrams(el, style, blocks, columns)
279 const Image = 'Image' in el ? el.Image : null
280 if (Image) {
281 for (const [i, formula] of math) {
282 const picked = pickFormula(formula, columns)
283 if (picked) drawn.set(i, { element: <Image key={`b${i}`} source={{ png: picked.picture.png }} columns={picked.fit.columns} rows={picked.fit.rows} alt={formula.tex} /> })
284 }
285 for (const [i, picture] of pictures) {
286 const fit = fitImage(picture, columns)
287 drawn.set(i, { element: <Image key={`b${i}`} source={{ png: picture.png }} columns={fit.columns} rows={fit.rows} alt={picture.alt} /> })
288 }
289 }
290 const tip = notes?.press ? noteTip(blocks, text => void $.ui.toast(text)) : undefined
291 const elements = renderBlocks(el, notes ? { ...style, noteKey: instanceKey(notes.id) } : style, blocks, columns, drawMath(el, style, blocks, drawn), copy, folder($, el, open), tip)
292 const button = reply === undefined ? null : copy(reply, 'reply', '⧉ copy reply')
293 if (!button) return elements
294 return style.copyButtonStyle === 'hover'
295 ? [...elements, <el.Box position="absolute" bottom={0} right={0} display="none" hover={{ display: 'flex' }}>{button}</el.Box>]
296 : [...elements, <el.Box key="reply" alignSelf="flex-end">{button}</el.Box>]
297}
298
299const THEME_ARGS = [...PRESET_NAMES, 'random']
300
301type Picked = { theme: string; options: PluginOptions; latex: LatexSession }
302
303const setTheme = async ($: EngineInterface, picked: Picked, name: string): Promise<string> => {
304 const result = await $.config.set({ key: `${$.plugin.name}.theme`, value: name })
305 if (result.deny) return `Could not switch theme: ${result.deny}`
306 if (name !== 'random' || picked.options.theme !== 'random') return `Theme set to ${name}.`
307 const others = RANDOM_THEMES.filter(t => t !== picked.theme)
308 picked.theme = others[Math.floor(Math.random() * others.length)]!
309 const style = picked.latex.style
310 style.theme = resolveStyle({ ...picked.options, theme: picked.theme }).theme
311 picked.latex.color = ratexColor(style.theme.diagramText ?? style.theme.codeText ?? '#808080')
312 $.ui.invalidate('ui.render')
313 return `Theme set to random: ${picked.theme} for now.\nkeep it: /prismantis theme ${picked.theme}`
314}
315
316const saveSetupPick = async ($: EngineInterface, picked: Picked, step: SetupStep, value: string): Promise<string> =>
317 step.key === 'theme' ? setTheme($, picked, value) : $.config.set({ key: `${$.plugin.name}.${step.key}`, value }).then(r => (r.deny ? `Could not save ${step.key}: ${r.deny}` : `${step.title} set to ${value}.`))
318
319const SETUP_PANE = 'prismantis-setup'
320
321const SETUP_PICK = 'pick:'
322const SETUP_LIST_COLUMNS = 30
323const SETUP_HEAD_ROWS = 8
324
325const openPane = ($: EngineInterface) => $.ui.open({ id: SETUP_PANE, title: 'prismantis setup', focus: true, closeOnEscape: true, rows: 40, columns: 90 })
326
327const openSetupPane = async ($: EngineInterface): Promise<string | undefined> => {
328 await update($, setupAt, () => 0)
329 await update($, setupFocus, () => '')
330 await update($, setupLand, () => '')
331 const opened = await openPane($)
332 if (opened.isPlaced) return SETUP_PANE_OPENED
333 await $.ui.close({ id: SETUP_PANE })
334 return undefined
335}
336
337const pickKey = (step: SetupStep, value: string) => `${SETUP_PICK}${step.name}:${value}`
338
339const showSetupStep = async ($: EngineInterface, at: number, step: SetupStep, value: string): Promise<void> => {
340 await update($, setupFocus, () => value)
341 await update($, setupLand, () => pickKey(step, value))
342 await update($, setupAt, () => at)
343}
344
345const pickInPane = async ($: EngineInterface, picked: Picked, at: number, value: string, currentOf: (step: SetupStep) => string): Promise<void> => {
346 const steps = setupSteps(PRESET_NAMES)
347 const step = steps[at]!
348 const nextStep = steps[at + 1]
349 const done = `${step.title} set to ${value}.`
350 if (nextStep) await showSetupStep($, at + 1, nextStep, currentOf(nextStep))
351 else await $.ui.close({ id: SETUP_PANE })
352 $.ui.toast(nextStep ? done : `${done} Setup done.`)
353 const saved = await saveSetupPick($, picked, step, value)
354 if (saved === done) return
355 $.ui.toast(saved)
356 if (!saved.startsWith('Could not')) return
357 await showSetupStep($, at, step, value)
358 if (!nextStep) await openPane($)
359}
360
361const landSetupFocus = ($: EngineInterface, key: string): void => {
362 $.clock.after(0, async () => {
363 await $.ui.focus({ requestId: SETUP_PANE, key }).catch(() => undefined)
364 await update($, setupLand, land => (land === key ? '' : land))
365 })
366}
367
368const setup = async ($: EngineInterface, picked: Picked, origin: PromptOrigin, stepName?: string, pick?: string): Promise<string> => {
369 const inTerminal = !stepName && origin.kind === 'composer' && (await $.session.surfaces()).includes('terminal')
370 const opened = inTerminal ? await openSetupPane($) : undefined
371 if (opened) return opened
372 const steps = setupSteps(PRESET_NAMES)
373 const at = stepName ? steps.findIndex(s => s.name === stepName) : 0
374 const step = steps[at]
375 if (!step) return `Unknown setup step "${stepName}". Steps: ${steps.map(s => s.name).join(', ')}`
376 if (!pick) return setupText(setupParts(PRESET_NAMES, at))
377 const choices = step.key === 'theme' ? THEME_ARGS : step.options
378 const value = /^\d+$/.test(pick) ? step.options[Number(pick) - 1] : pick
379 if (!value || !choices.includes(value)) return `Unknown ${step.name} pick "${pick}". Pick 1-${step.options.length} or one of: ${choices.join(', ')}`
380 const saved = await saveSetupPick($, picked, step, value)
381 if (saved.startsWith('Could not')) return saved
382 return `${saved}\n\n${at + 1 < steps.length ? setupText(setupParts(PRESET_NAMES, at + 1)) : SETUP_DONE}`
383}
384
385const SETUP_GIT_STATUS = [
386 'On branch main',
387 'Changes not staged for commit:',
388 ' (use "git add <file>..." to update what will be committed)',
389 ' modified: src/app.ts',
390 '',
391 'no changes added to commit (use "git add" and/or "git commit -a")',
392]
393const SETUP_BUILD_ERROR = [
394 '> app@1.4.0 build',
395 '> tsc',
396 ...[3, 7, 12, 18, 25, 31, 40, 44, 52, 60].map(line => `src/app.ts(${line},5): error TS2322: Type 'string' is not assignable to type 'number'.`),
397 'Found 10 errors in the same file, starting at: src/app.ts:3',
398]
399
400const toolOutputSample = (el: ReturnType<EngineInterface['ui']['resolve']>, style: Style, value: string, id: string, columns: number): RenderElement[] => {
401 const status = { tool: 'Bash', input: { command: 'git status' }, isRunning: false, isErrored: false, isInterrupted: false }
402 const build = { tool: 'Bash', input: { command: 'npm run build', description: 'Build the app' }, isRunning: false, isErrored: true, isInterrupted: false }
403 const statusRow = <el.Box key={`${id}s`}>{renderToolRow(el, style, status, columns)}</el.Box>
404 const buildRow = <el.Box key={`${id}r`}>{renderToolRow(el, style, build, columns)}</el.Box>
405 if (value === 'quiet') return [statusRow, buildRow, renderFailure(el, style, SETUP_BUILD_ERROR.join('\n'), `${id}e`) ?? <el.Box key={`${id}e`} />]
406 const folded = (lines: readonly string[], keep: number, color?: string) => [
407 ...lines.slice(0, keep).map((line, i) => <el.Text key={`l${i}`} color={color}>{`${i === 0 ? ' ⎿ ' : ' '}${line}`}</el.Text>),
408 <el.Text key="more" dimColor>{` … +${lines.length - keep} lines (ctrl+o to expand)`}</el.Text>,
409 ]
410 return [
411 statusRow,
412 <el.Box key={`${id}so`} flexDirection="column">
413 {folded(SETUP_GIT_STATUS, 3)}
414 <el.Text key="allowed" dimColor>{' ⎿ Allowed by auto mode classifier'}</el.Text>
415 </el.Box>,
416 buildRow,
417 <el.Box key={`${id}e`} flexDirection="column">{folded(['Error: Exit code 2', ...SETUP_BUILD_ERROR], 10, style.theme.codeFlag)}</el.Box>,
418 ]
419}
420
421type SetupView = { el: ReturnType<EngineInterface['ui']['resolve']>; base: Style; options: PluginOptions; at: number; step: SetupStep; current: string; columns: number; rows: number; pick: (value: string) => void }
422
423const setupKeyboardPane = ($: EngineInterface, v: SetupView, focused: string, randomTheme: string): RenderElement => {
424 const { el, step } = v
425 const choices = step.key === 'theme' ? THEME_ARGS : step.options
426 const shown = choices.includes(focused) ? focused : v.current
427 const sampled = shown === 'random' ? randomTheme : shown
428 const parts = setupParts(PRESET_NAMES, v.at)
429 const options = parts.options.filter(o => o.value === sampled).map(o => (step.key === 'theme' ? { ...o, text: `${o.text.split('\n\n')[0]}\n\n${THEME_PREVIEW}` } : o))
430 const drawn = drawSetup($, el, v.base, v.options, { at: v.at, before: '', parts: { ...parts, options, tail: '' } }, Math.max(40, v.columns - SETUP_LIST_COLUMNS))
431 const buttons = choices.map(value => <el.Button key={pickKey(step, value)} label={value === v.current ? `${value} (now)` : value} variant={value === shown ? 'primary' : 'secondary'} autoFocus={value === v.current ? true : undefined} onPress={() => v.pick(value)} />)
432 const fits = choices.length + SETUP_HEAD_ROWS <= v.rows
433 return (
434 <el.Box flexDirection="column" rowGap={1} {...(fits ? { height: v.rows, overflow: 'hidden' as const } : {})}>
435 <el.Box flexDirection="column" flexShrink={0}>{drawn[0]}</el.Box>
436 <el.Box flexShrink={0}><el.Text dimColor>{SETUP_PANE_KEYS}</el.Text></el.Box>
437 <el.Box flexDirection="row" columnGap={4} flexShrink={1} overflow="hidden">
438 <el.Box flexDirection="column" flexShrink={0}>{buttons}</el.Box>
439 <el.Box flexDirection="column" flexGrow={1} overflow="hidden">
440 <el.Box flexDirection="column" flexShrink={0}>{drawn.slice(1, -1)}</el.Box>
441 </el.Box>
442 </el.Box>
443 </el.Box>
444 )
445}
446
447const drawSetup = ($: EngineInterface, el: ReturnType<EngineInterface['ui']['resolve']>, base: Style, options: PluginOptions, found: NonNullable<ReturnType<typeof findSetup>>, columns: number): RenderElement[] => {
448 const key = setupSteps(PRESET_NAMES)[found.at]!.key
449 const md = (style: Style, text: string, id: string) => <el.Box key={id} flexDirection="column" rowGap={1}>{drawMarkdown($, el, style, parse(text, { numbers: style.highlightNumbers, paths: style.highlightPaths }), columns)}</el.Box>
450 const sample = (style: Style, value: string, id: string): RenderElement[] => {
451 if (key === 'promptStyle') return [value === 'off' ? <el.Text key={id} dimColor>{'> fix the failing test'}</el.Text> : <el.Box key={id}>{renderUserPrompt(el, style, 'fix the failing test', columns)}</el.Box>]
452 if (key === 'toolStyle') return [md({ ...style, narration: style.toolStyle === 'tree-bold' }, 'Reading the config.', `${id}s`), <el.Box key={id}>{renderToolRow(el, style, { tool: 'Read', input: { file_path: 'src/config.ts' }, isRunning: false, isErrored: false, isInterrupted: false }, columns)}</el.Box>]
453 if (key === 'toolOutput') return toolOutputSample(el, style, value, id, columns)
454 return []
455 }
456 const picks = found.parts.options.map(({ value, text }, i) => {
457 const style = key === 'theme' ? { ...base, theme: resolveStyle({ ...options, theme: value }).theme } : { ...base, [key]: value }
458 return <el.Box key={`o${i}`} flexDirection="column">{md(style, text, 'm')}{sample(style, value, 'x')}</el.Box>
459 })
460 return [...(found.before ? [md(base, found.before, 'before')] : []), md(base, found.parts.head, 'head'), ...picks, md(base, found.parts.tail, 'tail')]
461}
462
463const hintsFor = (style: Style, latex: LatexSession, nth: number): string[] => [
464 ...(style.diagramHints ? [HINT, FIGURE_HINT, ...(latex.ready && !latex.stopped ? [LATEX_HINT] : [])] : []),
465 ...(style.issueHint && nth % 10 === 0 ? [ISSUE_HINT] : []),
466]
467
468const localeOf = async ($: EngineInterface, asked?: string): Promise<string> => asked ?? (await $.env.get('LC_ALL').catch(() => undefined)) ?? (await $.env.get('LANG').catch(() => undefined)) ?? ''
469
470export const register: Register = (on, options) => {
471 on('engine.create', async (_$, e, next) => ({ ...(await next(e)), prismantis: { markdown: async () => undefined } }))
472 if (options.enabled === false) return
473 const firstTheme = pickTheme(options.theme)
474 const style = resolveStyle({ ...options, theme: firstTheme })
475 const parsed = new Map<string, ReturnType<typeof parse>>()
476 const parseCached = (text: string, cache = parsed, limit?: number) => remember(cache, text, () => parse(text, { numbers: style.highlightNumbers, paths: style.highlightPaths }), limit)
477 const shared = new Map<string, ReturnType<typeof parse>>()
478 let terminal: Terminal | null = null
479 const fit = (viewport?: { isFullscreen?: boolean }): Style => (terminal === 'apple-terminal' && viewport?.isFullscreen ? { ...style, shape: 'inverse' } : style)
480 let prompts = 0
481 const latex: LatexSession = { style, color: ratexColor(style.theme.diagramText ?? style.theme.codeText ?? '#808080'), pending: new Set(), wanted: new Set(), batch: [], queue: Promise.resolve() }
482 const picked: Picked = { theme: firstTheme, options, latex }
483 const probes: Probes = {}
484 const forSurface = (base: Style, surface: RenderSurface, links: LinkReach): Style => {
485 const drawn = surface === 'terminal' ? (links.http || links.editor ? { ...base, httpLinks: links.http, editorLinks: links.editor } : base) : { ...base, htmlCopy: null, nativeCode: true }
486 return surface === 'mobile' && drawn.copyButtonStyle === 'hover' ? { ...drawn, copyButtonStyle: 'inline' } : drawn
487 }
488 const fullWidth = (text: string) => (style.reorder && hasRtl(text) ? { width: '100%' as const } : {})
489
490 const quiet = style.toolOutput === 'quiet'
491 const shellCommands = new Map<string, string>()
492 const noteCommand = (id: string | undefined, tool: string, input: unknown) => {
493 const command = tool === 'Bash' && input !== null && typeof input === 'object' ? (input as { command?: unknown }).command : undefined
494 if (!id || typeof command !== 'string') return
495 shellCommands.delete(id)
496 shellCommands.set(id, command)
497 if (shellCommands.size > 500) shellCommands.delete(shellCommands.keys().next().value!)
498 }
499 let firstEdit: string | undefined
500 if (options.toolRows !== false) {
501 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
502 await locate($, style)
503 for (const call of e.props.calls) noteCommand(call.tool_use_id, call.tool, call.input)
504 if (quiet) for (const call of e.props.calls) if (call.tool_use_id && isReadOnlyCall(call.tool, call.input)) rememberCall(readOnlyCalls, call.tool_use_id)
505 if (e.props.isExpanded) {
506 for (const call of e.props.calls) if (call.tool_use_id) rememberCall(expandedCalls, call.tool_use_id, 500)
507 return next(e)
508 }
509 return renderToolGroup($.ui.resolve(e), fit(e.viewport), e.props.calls, e.props.isActive, e.viewport?.columns)
510 })
511 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
512 await locate($, style)
513 noteCommand(e.props.tool_use_id, e.props.tool, e.props.input)
514 if (quiet && isReadOnlyCall(e.props.tool, e.props.input)) rememberCall(readOnlyCalls, e.props.tool_use_id)
515 if (!expandedCalls.has(e.props.tool_use_id)) return renderToolRow($.ui.resolve(e), fit(e.viewport), e.props, e.viewport?.columns)
516 const shell = e.props.tool === 'Bash' || e.props.tool === 'PowerShell'
517 return shell && typeof e.props.output !== 'string' ? renderExpandedShell($.ui.resolve(e), fit(e.viewport), e.props) : next(e)
518 })
519 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
520 if ((e.props.tool === 'Edit' || e.props.tool === 'Write') && !e.props.isErrored) {
521 const el = $.ui.resolve(e)
522 return renderDiffCard(el, fit(e.viewport), e.props.output, folder($, el, await read($, unfolded)), e.props.tool_use_id) ?? next(e)
523 }
524 const edits = e.props.tool === 'Bash' && !e.props.isErrored ? bashEdits(e.props.output) : undefined
525 if (edits) {
526 await locate($, style)
527 const el = $.ui.resolve(e)
528 const output = withoutBashEdits(e.props.output)
529 const above = !hasShellOutput(output) ? null : quiet ? renderShellResult(el, fit(e.viewport), output) : await next({ ...e, props: { ...e.props, output } })
530 firstEdit ??= e.props.tool_use_id
531 const notes = editNotes(edits, firstEdit === e.props.tool_use_id, shellCommands.get(e.props.tool_use_id))
532 const cards = renderBashEdits(el, fit(e.viewport), edits, folder($, el, await read($, unfolded)), e.props.tool_use_id, above !== null, notes)
533 return above === null ? cards : <el.Box flexDirection="column">{above}{cards}</el.Box>
534 }
535 if (!quiet) return next(e)
536 await locate($, style)
537 const el = $.ui.resolve(e)
538 const shell = e.props.tool === 'Bash' || e.props.tool === 'PowerShell'
539 const out = (e.props.output ?? {}) as Record<string, unknown>
540 if (shell && (out.backgroundTaskId || out.isImage || out.interrupted)) return next(e)
541 if (e.props.isErrored) return renderFailure(el, fit(e.viewport), e.props.output) ?? next(e)
542 if (readOnlyCalls.has(e.props.tool_use_id)) return <el.Box />
543 return shell ? renderShellResult(el, fit(e.viewport), e.props.output) : next(e)
544 })
545 }
546
547 on('session.start', async ($, e, next) => {
548 terminal = await probe($, style, probes)
549 void latexEngine($, latex)
550 const started = await next(e)
551 await $.command
552 .register({ name: 'prismantis', description: 'Switch the prismantis theme, pick styles from samples, copy the last reply, or show the demo', argumentHint: '[theme [<name> | random] | setup [<step> [<pick>]] | copy [code] | demo]' })
553 .catch(() => undefined)
554 return started
555 })
556
557 on('command.run', { command: 'prismantis' }, async ($, e) => {
558 const [first, second, third] = e.args.trim().split(/\s+/)
559 const [sub, name] = THEME_ARGS.includes(first ?? '') ? ['theme', first] : [first, second]
560 if (sub === 'demo') return { text: showcaseText(PRESET_NAMES) + languageShowcase(await localeOf($, name)) }
561 if (sub === 'setup') return { text: await setup($, picked, e.origin, name, third) }
562 if (sub === 'copy') {
563 const reply = (await $.session.messages()).findLast(m => m.role === 'assistant' && m.text.trim())
564 if (!reply) return { text: 'Nothing to copy yet.' }
565 const code = name === 'code' ? nestedBlocks(parseCached(reply.text)).findLast(b => b.kind === 'code') : undefined
566 if (name === 'code' && code?.kind !== 'code') return { text: 'The last reply has no code block.' }
567 const result = await $.ui.copy({ text: code?.kind === 'code' ? code.lines.join('\n') : reply.text })
568 return { text: result.isCopied ? `Copied the last ${code ? 'code block' : 'reply'}.` : `Copy failed: ${result.reason}` }
569 }
570 if (sub === 'demo-rtl') {
571 terminal = await probe($, style, probes)
572 return { text: rtlShowcaseText() }
573 }
574 if (sub !== 'theme') return { text: helpText(PRESET_NAMES) }
575 if (!name) return { text: options.theme === 'random' ? `current: ${picked.theme} (random)\nkeep it: /prismantis theme ${picked.theme}` : `current: ${picked.theme}` }
576 if (!THEME_ARGS.includes(name)) return { text: `Unknown theme "${name}". Themes: ${PRESET_NAMES.join(', ')}, random` }
577 return { text: await setTheme($, picked, name) }
578 })
579
580 on('ui.render', { component: 'TurnDuration' }, async ($, e) => {
581 const at = style.clock === 'off' ? undefined : endedAt(await read($, turnEnds), e.props.durationMs, await firstDrawn($, e.requestId))
582 const time = at === undefined ? undefined : clockText(at, await $.clock.now(), style.clock)
583 return renderTurnDuration($.ui.resolve(e), style, e.props.word, e.props.durationMs, time)
584 })
585
586 on('turn.complete', async ($, e, next) => {
587 if (style.clock !== 'off' && e.agentId === undefined) {
588 const at = await $.clock.now()
589 await update($, turnEnds, list => withRecord(list, { durationMs: e.durationMs, at }))
590 }
591 return next(e)
592 })
593
594 on('prompt.submit', async ($, e, next) => {
595 if (style.clock !== 'off' && (e.origin.kind === 'composer' || e.origin.kind === 'bridge')) {
596 const at = await $.clock.now()
597 await update($, sent, list => withRecord(list, { text: promptKey(e.text), at }))
598 }
599 terminal = await probe($, style, probes)
600 if (!takesHint(e.origin)) return next(e)
601 if (style.diagramHints) void latexEngine($, latex)
602 const hints = hintsFor(style, latex, prompts++)
603 return hints.length ? next({ ...e, context: [...(e.context ?? []), ...hints] }) : next(e)
604 })
605
606 let landing = ''
607 const currentOf = (step: SetupStep): string => (step.key === 'theme' ? (options.theme === 'random' ? 'random' : picked.theme) : String(style[step.key]))
608
609 on('ui.focus', async ($, e, next) => {
610 if (e.component === 'Pane' && e.requestId === SETUP_PANE && e.element?.startsWith(SETUP_PICK)) await update($, setupFocus, () => e.element!.slice(e.element!.indexOf(':', SETUP_PICK.length) + 1))
611 return next(e)
612 })
613
614 on('ui.render', { component: 'Pane', requestId: SETUP_PANE }, async ($, e) => {
615 const steps = setupSteps(PRESET_NAMES)
616 const at = Math.min(await read($, setupAt), steps.length - 1)
617 const step = steps[at]!
618 const land = await read($, setupLand)
619 if (land !== landing) {
620 landing = land
621 if (land) landSetupFocus($, land)
622 }
623 const base = { ...forSurface(fit(e.viewport), e.surface, await linkReach($, probes)), copyButtons: false }
624 const view: SetupView = { el: $.ui.resolve(e), base, options, at, step, current: currentOf(step), columns: e.props.bodyColumns, rows: e.props.scroll.bodyRows, pick: value => void pickInPane($, picked, at, value, currentOf) }
625 return setupKeyboardPane($, view, await read($, setupFocus), picked.theme)
626 })
627
628 on('ui.render', { component: 'InfoNotice' }, async (_$, e, next) =>
629 mirrored(style) && hasRtl(e.props.text) && opensLatin(e.props.text) ? next({ ...e, props: { ...e.props, text: ROW_MARK_TEXT + e.props.text } }) : next(e))
630
631 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
632 if (e.props.isErrored) return next(e)
633 if (e.props.text.includes('\u001b')) return next(e)
634 const found = e.props.command === 'prismantis' ? findSetup(PRESET_NAMES, e.props.text) : undefined
635 if (found) {
636 const el = $.ui.resolve(e)
637 const base = { ...forSurface(fit(e.viewport), e.surface, await linkReach($, probes)), copyButtons: false }
638 return <el.Box flexDirection="column" rowGap={1}>{drawSetup($, el, base, options, found, columnsOf(e.viewport))}</el.Box>
639 }
640 const blocks = parseCached(e.props.text)
641 if (blocks.length === 0) return next(e)
642 const el = $.ui.resolve(e)
643 const { Box } = el
644 const math = await mathOfBlocks($, latex, e.surface, blocks)
645 const pictures = await picturesOf($, style, probes, e.surface, blocks)
646 const open = await read($, unfolded)
647 const reach = await linkReach($, probes)
648 const surfaceStyle = forSurface(fit(e.viewport), e.surface, reach)
649 return <Box flexDirection="column" rowGap={1} {...fullWidth(e.props.text)}>{drawMarkdown($, el, surfaceStyle, blocks, columnsOf(e.viewport), math, pictures, undefined, open, { id: e.requestId, press: reach.marks })}</Box>
650 })
651
652 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
653 const kind = e.props.origin.kind
654 const own = kind === 'composer' || kind === 'bridge' || (kind === 'unclassified' && !e.props.from && !e.props.task)
655 if (!own) return next(e)
656 const at = style.clock === 'off' ? undefined : sentAt(await read($, sent), e.props.text, await firstDrawn($, e.requestId))
657 const time = at === undefined ? undefined : clockText(at, await $.clock.now(), style.clock)
658 if (style.promptStyle === 'off') return time === undefined ? next(e) : next({ ...e, props: { ...e.props, text: `[${time}] ${e.props.text}` } })
659 return renderUserPrompt($.ui.resolve(e), fit(e.viewport), e.props.text, columnsOf(e.viewport), time)
660 })
661
662 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
663 const blocks = parseCached(e.props.text)
664 if (blocks.length === 0) return next(e)
665 const el = $.ui.resolve(e)
666 const { Box, Text } = el
667 const narration = style.toolStyle === 'tree-bold' && blocks.length === 1 && blocks[0]!.kind === 'paragraph'
668 const math = await mathOfBlocks($, latex, e.surface, blocks)
669 const pictures = await picturesOf($, style, probes, e.surface, blocks)
670 const reach = await linkReach($, probes)
671 const surfaceStyle = forSurface(fit(e.viewport), e.surface, reach)
672 const open = await read($, unfolded)
673 return (
674 <Box key="msg" flexDirection="row">
675 <Box width={2} flexShrink={0}>
676 <Text color={style.theme.accent}>{e.props.isFirstOfReply ? '●' : ' '}</Text>
677 </Box>
678 <Box flexDirection="column" rowGap={1} flexGrow={1}>
679 {drawMarkdown($, el, narration ? { ...surfaceStyle, narration } : surfaceStyle, blocks, columnsOf(e.viewport), math, pictures, blocks.length > 1 || hasRtl(e.props.text) ? e.props.text : undefined, open, { id: e.requestId, press: reach.marks })}
680 </Box>
681 </Box>
682 )
683 })
684
685 on('prismantis.markdown', async ($, e, next) => {
686 const blocks = parseCached(e.text, shared, 20)
687 if (blocks.length === 0) return next(e)
688 const el = $.ui.resolve({ surface: e.surface, component: 'AssistantMessage' })
689 const { Box } = el
690 const surfaceStyle = forSurface(style, e.surface, await linkReach($, probes))
691 return { value: <Box flexDirection="column" rowGap={1} {...fullWidth(e.text)}>{drawMarkdown($, el, { ...surfaceStyle, copyButtons: false }, blocks, Math.max(20, e.columns || 0))}</Box> }
692 })
693}
694hooks/clock.ts 39 lines1import type { Clock } from './theme'
2
3export type Sent = { text: string; at: number }
4export type TurnEnd = { durationMs: number; at: number }
5
6const KEEP = 500
7const SLACK_MS = 2000
8const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
9
10const two = (n: number) => String(n).padStart(2, '0')
11
12export const clockText = (at: number, now: number, clock: Clock): string => {
13 const d = new Date(at)
14 const h = d.getHours()
15 const time = clock === '12h' ? `${h % 12 === 0 ? 12 : h % 12}:${two(d.getMinutes())} ${h < 12 ? 'AM' : 'PM'}` : `${two(h)}:${two(d.getMinutes())}`
16 const today = new Date(now)
17 const isToday = d.getFullYear() === today.getFullYear() && d.getMonth() === today.getMonth() && d.getDate() === today.getDate()
18 return isToday ? time : `${MONTHS[d.getMonth()]} ${d.getDate()} ${time}`
19}
20
21export const promptKey = (text: string) => text.trim().replace(/\s+/g, ' ').slice(0, 300)
22
23export const withRecord = <T>(list: readonly T[], record: T): T[] => [...list, record].slice(-KEEP)
24
25export const sentAt = (sent: readonly Sent[], text: string, drawnAt: number): number | undefined => {
26 const key = promptKey(text)
27 return sent.findLast(one => one.text === key && one.at <= drawnAt)?.at
28}
29
30export const endedAt = (ends: readonly TurnEnd[], durationMs: number, drawnAt: number): number | undefined => {
31 let best: TurnEnd | undefined
32 for (const end of ends) {
33 if (end.at > drawnAt) continue
34 const gap = Math.abs(end.durationMs - durationMs)
35 if (gap <= SLACK_MS && (best === undefined || gap <= Math.abs(best.durationMs - durationMs))) best = end
36 }
37 return best?.at
38}
39hooks/latex.ts 136 lines1import type { Block } from './markdown'
2
3type Picture = { png: string; width: number; height: number }
4
5export type Formula = (Picture & { padded?: Picture }) | { error: true }
6
7type Size = { width: number; height: number }
8type Fit = { columns: number; rows: number }
9
10export const LATEX_FONT_PX = 80
11export const LATEX_DPR = 1
12const PX_PER_ROW = LATEX_FONT_PX * LATEX_DPR
13const CELL_RATIO = 0.5
14const RATEX_MARGIN_PX = 10
15export const MAX_IMAGE_BYTES = 2 * 1024 * 1024
16
17const ANSI16 = ['000000', 'cd0000', '00cd00', 'cdcd00', '0000ee', 'cd00cd', '00cdcd', 'e5e5e5', '7f7f7f', 'ff0000', '00ff00', 'ffff00', '5c5cff', 'ff00ff', '00ffff', 'ffffff']
18const ANSI_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
19
20const hex = (...rgb: number[]): string => `#${rgb.map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('')}`
21
22const ansi256 = (n: number): string => {
23 if (n < 16) return `#${ANSI16[n]}`
24 const level = (v: number) => (v ? 55 + v * 40 : 0)
25 if (n < 232) return hex(level(Math.floor((n - 16) / 36)), level(Math.floor((n - 16) / 6) % 6), level((n - 16) % 6))
26 const gray = 8 + (n - 232) * 10
27 return hex(gray, gray, gray)
28}
29
30export const ratexColor = (color: string): string => {
31 const c = color.trim()
32 const rgb = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i.exec(c)
33 if (rgb) return hex(...rgb.slice(1).map(Number))
34 const ansi = /^ansi256\((\d+)\)$/i.exec(c)
35 if (ansi) return ansi256(Number(ansi[1]))
36 const name = /^(black|red|green|yellow|blue|magenta|cyan|white|gray|grey)(bright)?$/i.exec(c)
37 if (!name?.[2]) return c
38 const base = name[1]!.toLowerCase()
39 return `#${ANSI16[base === 'gray' || base === 'grey' ? 8 : ANSI_NAMES.indexOf(base) + 8]}`
40}
41
42const MAX_FORMULAS = 50
43
44export const keepFormulas = <T,>(store: Record<string, T>, fresh: readonly (readonly [string, T])[], wanted: ReadonlySet<string>): Record<string, T> => {
45 const added = new Set(fresh.map(([key]) => key))
46 const entries = [...Object.entries(store).filter(([key]) => !added.has(key)), ...fresh]
47 const spare = entries.filter(([key]) => !wanted.has(key) && !added.has(key)).map(([key]) => key)
48 const dropped = new Set(spare.slice(0, Math.max(0, entries.length - MAX_FORMULAS)))
49 return Object.fromEntries(entries.filter(([key]) => !dropped.has(key)))
50}
51
52export const fitsImage = (base64: string): boolean => (base64.length * 3) / 4 <= MAX_IMAGE_BYTES
53
54const naturalRows = (size: Size): number => size.height / PX_PER_ROW
55
56export const padFor = (size: Size): { rows: number; fontSize: number; dpr: number } | null => {
57 const natural = naturalRows(size)
58 const below = Math.floor(natural)
59 const above = Math.max(1, Math.ceil(natural))
60 if ((below >= 1 && below / natural >= 0.9) || above / natural <= 1.05) return null
61 const dpr = LATEX_DPR + (above * PX_PER_ROW - size.height) / (2 * RATEX_MARGIN_PX)
62 return { rows: above, fontSize: PX_PER_ROW / dpr, dpr }
63}
64
65export const mathOf = (block: Block): string | null => {
66 if (block.kind !== 'code' || block.lang.toLowerCase() !== 'math' || block.isOpen) return null
67 const tex = block.lines.map(line => line.replace(/(^|[^\\])%.*$/, '$1')).join(' ').replace(/\s+/g, ' ').trim()
68 return tex === '' || tex.startsWith('#') ? null : tex
69}
70
71export const pngSize = (base64: string): Size | null => {
72 const head = atob(base64.slice(0, 32))
73 if (!head.startsWith('\x89PNG')) return null
74 const word = (at: number) => [0, 1, 2, 3].reduce((n, i) => n * 256 + head.charCodeAt(at + i), 0)
75 return { width: word(16), height: word(20) }
76}
77
78const rowsOf = (size: Size): number => Math.min(255, Math.max(1, Math.round(naturalRows(size))))
79
80const fitFormula = (size: Size, columns: number): Fit | null => {
81 const aspect = size.width / size.height / CELL_RATIO
82 const room = Math.min(255, columns - 2)
83 let rows = rowsOf(size)
84 while (rows > 1 && Math.round(aspect * rows) > room) rows--
85 const width = Math.max(1, Math.round(aspect * rows))
86 return width <= room ? { columns: width, rows } : null
87}
88
89export const pickFormula = (formula: Exclude<Formula, { error: true }>, columns: number): { picture: Picture; fit: Fit } | null => {
90 if (formula.padded) {
91 const fit = fitFormula(formula.padded, columns)
92 if (fit && fit.rows === rowsOf(formula.padded)) return { picture: formula.padded, fit }
93 }
94 const fit = fitFormula(formula, columns)
95 return fit ? { picture: formula, fit } : null
96}
97
98export const renderedOf = (stdout: string): Set<number> => new Set([...stdout.matchAll(/^OK\s+(\d+)\s/gm)].map(m => Number(m[1])))
99
100const IMAGE_PX_PER_COLUMN = 10
101const IMAGE_MAX_ROWS = 20
102
103export const imageOf = (block: Block): { alt: string; src: string } | null => {
104 if (block.kind !== 'paragraph') return null
105 const [only, ...rest] = block.inline.filter(n => n.kind !== 'text' || n.text.trim())
106 return only?.kind === 'image' && rest.length === 0 ? { alt: only.text, src: only.src } : null
107}
108
109const local = (path: string): { path: string } | null => (path.replace(/\\/g, '/').startsWith('//') ? null : { path })
110
111const decoded = (text: string): string | null => {
112 try {
113 return decodeURIComponent(text)
114 } catch {
115 return null
116 }
117}
118
119export const imageFile = (src: string, home: string): { png: string } | { path: string } | null => {
120 const data = /^data:image\/png;base64,([A-Za-z0-9+/]{32,}=*)$/i.exec(src)
121 if (data) return { png: data[1]! }
122 if (/^file:\/\//i.test(src)) {
123 const path = decoded(src.slice(7))?.replace(/^localhost(?=\/)/i, '')
124 return path?.startsWith('/') ? local(path) : null
125 }
126 if (src.startsWith('~/')) return home ? { path: `${home}${src.slice(1)}` } : null
127 return /^[a-z][a-z0-9+.-]*:/i.test(src) ? null : local(src)
128}
129
130export const fitImage = (size: Size, columns: number): Fit => {
131 const aspect = size.width / size.height / CELL_RATIO
132 const wide = Math.max(1, Math.min(255, columns - 2, Math.ceil(size.width / IMAGE_PX_PER_COLUMN)))
133 const rows = Math.max(1, Math.min(IMAGE_MAX_ROWS, Math.round(wide / aspect)))
134 return { columns: Math.max(1, Math.min(wide, Math.round(rows * aspect))), rows }
135}
136hooks/markdown.ts 453 lines1export type Inline =
2 | { kind: 'text'; text: string }
3 | { kind: 'strong'; children: Inline[] }
4 | { kind: 'emphasis'; children: Inline[] }
5 | { kind: 'strike'; children: Inline[] }
6 | { kind: 'code'; text: string }
7 | { kind: 'math'; text: string }
8 | { kind: 'link'; text: string; href: string }
9 | { kind: 'image'; text: string; src: string }
10 | { kind: 'number'; text: string }
11 | { kind: 'path'; text: string }
12 | { kind: 'dim'; text: string }
13 | { kind: 'footnote'; text: string; note?: number }
14
15export type Note = { mark: string; inline: Inline[]; note: number }
16
17export type Block = { raw: string } & (
18 | { kind: 'heading'; level: number; inline: Inline[] }
19 | { kind: 'paragraph'; inline: Inline[] }
20 | { kind: 'list'; ordered: boolean; items: ListItem[] }
21 | { kind: 'code'; lang: string; lines: string[]; isOpen?: true }
22 | { kind: 'quote'; blocks: Block[] }
23 | { kind: 'alert'; level: AlertLevel; blocks: Block[] }
24 | { kind: 'rule' }
25 | { kind: 'table'; header: Inline[][]; align: ('left' | 'right' | 'center')[]; rows: Inline[][][] }
26 | { kind: 'notes'; notes: Note[] }
27)
28
29export type ListItem = { marker: string; depth: number; task?: boolean; inline: Inline[]; blocks?: Block[] }
30
31type Draft = Block extends infer B ? (B extends unknown ? Omit<B, 'raw'> : never) : never
32
33export type AlertLevel = 'note' | 'tip' | 'important' | 'warning' | 'caution'
34const ALERT = /^\[!(note|tip|important|warning|caution)\]\s*(.*)$/i
35
36export type Highlight = { numbers: boolean; paths: boolean }
37
38const MAX_INLINE = 4000
39const MAX_QUOTE_DEPTH = 8
40const QUOTE = /^\s*>/
41const TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
42const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
43const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+-]*)/
44const NOTE_DEF = /^\[\^([^\]\s]+)\]:\s*(.*)$/
45const TAG_LINE = /^\s*<\/?[a-z][\w-]*>\s*$/i
46const SUPERSCRIPT = '⁰¹²³⁴⁵⁶⁷⁸⁹'
47
48const splitRow = (line: string): string[] => {
49 const trimmed = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
50 const cells: string[] = []
51 let cell = ''
52 for (let i = 0; i < trimmed.length; i++) {
53 const ch = trimmed[i]
54 if (ch === '\\' && trimmed[i + 1] === '|') {
55 cell += '|'
56 i++
57 } else if (ch === '|') {
58 cells.push(cell.trim())
59 cell = ''
60 } else {
61 cell += ch
62 }
63 }
64 cells.push(cell.trim())
65 return cells
66}
67
68const INLINE = /(`+)(?!`)(.+?)(?<!`)\1(?!`)|\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)|\*\*(?!\s)((?:[^*]|\*[^*\s][^*]*?(?<!\s)\*)+?)(?<!\s)\*\*|(?<![\w_])__(?!\s)([^_]+?)(?<!\s)__(?![\w_])|~~([^~]+?)~~|(?<![\w*])\*([^*\s][^*]*?)(?<!\s)\*(?!\w)|(?<![\w_])_([^_\s][^_]*?)(?<!\s)_(?!\w)|(https?:\/\/(?:[^\s<>()]|\([^\s<>()]*\))*(?:[^\s<>().,;:!?'"]|\([^\s<>()]*\)))|(?<![\\$\w])\$(?=\S)((?:\\.|[^$\n\\])+?)(?<=\S)\$(?![\w$])|\[\^([^\]\s]+)\]|!\[([^\]]*)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g
69const NUMBER = /(?<![\w.#/-])(v?\d+(?:[.,:]\d+)*(?:%|ms|s|m|h|d|Gi|Mi|GB|MB|KB|x)?)(?![\w/])/g
70const PATH = /(?<![\w/.:])((?:~|\.{1,2})?\/[\w.@+-]+(?:\/[\w.@+-]*)*)/g
71
72const decorate = (text: string, hl: Highlight): Inline[] => {
73 if (!hl.numbers && !hl.paths) return [{ kind: 'text', text }]
74 const marks: { start: number; end: number; kind: 'number' | 'path' }[] = []
75 if (hl.paths) for (const m of text.matchAll(PATH)) marks.push({ start: m.index, end: m.index + m[0].length, kind: 'path' })
76 if (hl.numbers) {
77 for (const m of text.matchAll(NUMBER)) {
78 const start = m.index
79 if (!marks.some(p => start < p.end && start + m[0].length > p.start)) {
80 marks.push({ start, end: start + m[0].length, kind: 'number' })
81 }
82 }
83 }
84 marks.sort((a, b) => a.start - b.start)
85 const out: Inline[] = []
86 let at = 0
87 for (const m of marks) {
88 if (m.start > at) out.push({ kind: 'text', text: text.slice(at, m.start) })
89 out.push({ kind: m.kind, text: text.slice(m.start, m.end) })
90 at = m.end
91 }
92 if (at < text.length) out.push({ kind: 'text', text: text.slice(at) })
93 return out
94}
95
96export const parseInline = (text: string, hl: Highlight): Inline[] => {
97 if (text.length > MAX_INLINE) return [{ kind: 'text', text }]
98 const out: Inline[] = []
99 let at = 0
100 for (const m of text.matchAll(INLINE)) {
101 if (m.index > at) out.push(...decorate(text.slice(at, m.index), hl))
102 if (m[2] !== undefined) out.push({ kind: 'code', text: m[2] })
103 else if (m[3] !== undefined) out.push({ kind: 'link', text: m[3].replace(/^`([^`]+)`$/, '$1'), href: m[4]! })
104 else if (m[5] !== undefined || m[6] !== undefined) out.push({ kind: 'strong', children: parseInline(m[5] ?? m[6] ?? "", hl) })
105 else if (m[7] !== undefined) out.push({ kind: 'strike', children: parseInline(m[7], hl) })
106 else if (m[8] !== undefined || m[9] !== undefined) out.push({ kind: 'emphasis', children: parseInline(m[8] ?? m[9] ?? "", hl) })
107 else if (m[10] !== undefined) out.push({ kind: 'link', text: m[10], href: m[10] })
108 else if (m[11] !== undefined) out.push(...(isTex(m[11]) ? [{ kind: 'math' as const, text: texText(m[11]) }] : decorate(m[0], hl)))
109 else if (m[12] !== undefined) out.push({ kind: 'footnote', text: m[12] })
110 else if (m[14] !== undefined) out.push({ kind: 'image', text: m[13] || 'image', src: m[14] })
111 at = m.index + m[0].length
112 }
113 if (at < text.length) out.push(...decorate(text.slice(at), hl))
114 return out
115}
116
117const TEX: Record<string, string> = {
118 alpha: 'α', beta: 'β', gamma: 'γ', Gamma: 'Γ', delta: 'δ', Delta: 'Δ', epsilon: 'ε', varepsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', Theta: 'Θ', iota: 'ι', kappa: 'κ', lambda: 'λ', Lambda: 'Λ', mu: 'μ', nu: 'ν', xi: 'ξ', Xi: 'Ξ', pi: 'π', Pi: 'Π', rho: 'ρ', sigma: 'σ', Sigma: 'Σ', tau: 'τ', upsilon: 'υ', phi: 'φ', varphi: 'φ', Phi: 'Φ', chi: 'χ', psi: 'ψ', Psi: 'Ψ', omega: 'ω', Omega: 'Ω',
119 cdot: '·', times: '×', div: '÷', pm: '±', mp: '∓', le: '≤', leq: '≤', ge: '≥', geq: '≥', neq: '≠', ne: '≠', approx: '≈', sim: '∼', equiv: '≡', propto: '∝', infty: '∞', to: '→', rightarrow: '→', leftarrow: '←', Rightarrow: '⇒', iff: '⇔', mapsto: '↦',
120 sum: 'Σ', prod: '∏', int: '∫', iint: '∬', oint: '∮', partial: '∂', nabla: '∇', in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆', cup: '∪', cap: '∩', forall: '∀', exists: '∃', neg: '¬', land: '∧', lor: '∨', emptyset: '∅',
121 ldots: '…', cdots: '⋯', dots: '…', ell: 'ℓ', hbar: 'ℏ', lceil: '⌈', rceil: '⌉', lfloor: '⌊', rfloor: '⌋', langle: '⟨', rangle: '⟩', mid: '∣', quad: ' ', qquad: ' ',
122}
123const FUNCTIONS = new Set(['ln', 'log', 'exp', 'min', 'max', 'sin', 'cos', 'tan', 'lim', 'det', 'sup', 'inf', 'arg', 'deg', 'gcd', 'Pr'])
124const PLAIN = new Set(['text', 'mathrm', 'mathit', 'mathbf', 'textbf', 'texttt', 'mathsf', 'operatorname', 'boldsymbol'])
125const SPACES: Record<string, string> = { ',': ' ', ';': ' ', ':': ' ', ' ': ' ', '!': '', '\\': ' ', '{': '{', '}': '}', '%': '%', '$': '$', '&': '&', '#': '#', '_': '_' }
126const scriptMap = (from: string, to: string) => new Map(Array.from(from).map((c, i) => [c, Array.from(to)[i]!]))
127const SUP = scriptMap('0123456789+-−=()niabcdefghjklmoprstuvwxyzABDEGHIJKLMNOPRTUVW', '⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁻⁼⁽⁾ⁿⁱᵃᵇᶜᵈᵉᶠᵍʰʲᵏˡᵐᵒᵖʳˢᵗᵘᵛʷˣʸᶻᴬᴮᴰᴱᴳᴴᴵᴶᴷᴸᴹᴺᴼᴾᴿᵀᵁⱽᵂ')
128const SUB = scriptMap('0123456789+-−=()aehijklmnoprstuvx', '₀₁₂₃₄₅₆₇₈₉₊₋₋₌₍₎ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ')
129const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
130const FONTS: Record<string, string> = { mathbb: '𝔸𝔹ℂ𝔻𝔼𝔽𝔾ℍ𝕀𝕁𝕂𝕃𝕄ℕ𝕆ℙℚℝ𝕊𝕋𝕌𝕍𝕎𝕏𝕐ℤ', mathcal: '𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵' }
131const FN = '\u0000'
132
133const script = (text: string, map: Map<string, string>, mark: string) => {
134 const chars = Array.from(text)
135 const values = new Set(map.values())
136 if (chars.length > 0 && chars.every(c => map.has(c) || values.has(c))) return chars.map(c => map.get(c) ?? c).join('')
137 return mark + (chars.length === 1 ? text : `(${text})`)
138}
139const atomic = (text: string) => /^√?[\p{L}\p{N}.,]+$/u.test(text) || Array.from(text).length === 1
140
141const texUnicode = (tex: string): string => {
142 let i = 0
143 const name = () => {
144 const m = /^[A-Za-z]+/.exec(tex.slice(i))
145 if (m) return (i += m[0].length, m[0])
146 return tex[i++] ?? ''
147 }
148 const arg = (): string => {
149 while (tex[i] === ' ') i++
150 if (tex[i] === '{') {
151 let depth = 0
152 const start = ++i
153 for (; i < tex.length; i++) {
154 if (tex[i] === '\\') i++
155 else if (tex[i] === '{') depth++
156 else if (tex[i] === '}' && depth-- === 0) break
157 }
158 return tex.slice(start, i++)
159 }
160 if (tex[i] === '\\') return (i++, `\\${name()}`)
161 return tex[i++] ?? ''
162 }
163 const out: string[] = []
164 const prev = () => out.join('').slice(-1)
165 while (i < tex.length) {
166 const c = tex[i++]!
167 if (c === '\\') {
168 const cmd = name()
169 if (Object.hasOwn(SPACES, cmd)) out.push(SPACES[cmd]!)
170 else if (cmd === 'frac' || cmd === 'dfrac' || cmd === 'tfrac') {
171 const top = texUnicode(arg())
172 const bottom = texUnicode(arg())
173 const frac = `${atomic(top) ? top : `(${top})`}/${atomic(bottom) ? bottom : `(${bottom})`}`
174 const loose = /^$|[\s(=,[]$/.test(prev()) && /^(\s|$|\\right|[)\],])/.test(tex.slice(i))
175 out.push(loose ? frac : `(${frac})`)
176 } else if (cmd === 'sqrt') {
177 const inner = texUnicode(arg())
178 out.push(`√${atomic(inner) ? inner : `(${inner})`}`)
179 } else if (PLAIN.has(cmd)) out.push(cmd === 'text' ? arg() : texUnicode(arg()))
180 else if (Object.hasOwn(FONTS, cmd)) out.push(Array.from(arg()).map(l => (LETTERS.includes(l) ? Array.from(FONTS[cmd]!)[LETTERS.indexOf(l)]! : l)).join(''))
181 else if (cmd === 'left' || cmd === 'right' || cmd === 'big' || cmd === 'Big' || cmd === 'bigg') {
182 const d = tex[i] === '\\' ? (i++, name()) : tex[i++] ?? ''
183 out.push(d === '.' ? '' : d === '{' || d === '}' ? d : Object.hasOwn(TEX, d) ? TEX[d]! : d)
184 } else if (FUNCTIONS.has(cmd)) out.push(cmd + FN)
185 else out.push(Object.hasOwn(TEX, cmd) ? TEX[cmd]! : `\\${cmd}`)
186 } else if (c === '^' || c === '_') out.push(script(texUnicode(arg()), c === '^' ? SUP : SUB, c))
187 else if (c === '{') {
188 i--
189 out.push(texUnicode(arg()))
190 } else if (c === '}' ) continue
191 else if (c === '~' || c === '&') out.push(' ')
192 else out.push(c)
193 }
194 return out.join('')
195}
196
197const isTex = (tex: string) => /[\\^_={}]/.test(tex) || /^[A-Za-z]$/.test(tex)
198
199export const texText = (tex: string): string =>
200 texUnicode(tex)
201 .replace(new RegExp(`${FN}(?=[\\p{L}\\p{N}])`, 'gu'), ' ')
202 .replaceAll(FN, '')
203 .replace(/\)\(/g, ')·(')
204 .replace(/ {2,}/g, ' ')
205 .trim()
206
207export const inlineText = (inline: Inline[]): string =>
208 inline.map(n => ('children' in n ? inlineText(n.children) : n.text)).join('')
209
210const mathAt = (lines: string[], start: number): { lines: string[]; end: number } | null => {
211 const trimmed = (n: number) => (lines[n] ?? '').trim()
212 const first = trimmed(start)
213 if (!first.startsWith('$$')) return null
214 const open = first.slice(2)
215 let end = start
216 if (!(open.length >= 2 && open.endsWith('$$'))) {
217 end = start + 1
218 while (end < lines.length && !trimmed(end).endsWith('$$')) {
219 if (trimmed(end) === '') return null
220 end++
221 }
222 if (end === lines.length) return null
223 }
224 const body = end === start ? [open.slice(0, -2)] : [open, ...lines.slice(start + 1, end), trimmed(end).slice(0, -2)]
225 const tex = body.filter(line => line.trim() !== '').map((line, i, all) => (i === 0 || i === all.length - 1 ? line.trim() : line))
226 return tex.length ? { lines: tex, end } : null
227}
228
229export const displayText = (inline: Inline[]): string =>
230 inline.map(n => (n.kind === 'link' && n.text !== n.href ? `${n.text} (${n.href})` : 'children' in n ? displayText(n.children) : n.text)).join('')
231
232export const isWebLink = (href: string): boolean => /^(https?:|mailto:)/i.test(href)
233
234const markOf = (n: number) => [...String(n)].map(d => SUPERSCRIPT[Number(d)]).join('')
235
236const numberNotes = (blocks: Block[], defs: Map<string, string>, hl: Highlight): Block[] => {
237 const order = new Map<string, number>()
238 const seen = (nodes: Inline[]): void => nodes.forEach(n => {
239 if (n.kind === 'footnote' && defs.has(n.text) && !order.has(n.text)) order.set(n.text, order.size + 1)
240 else if ('children' in n) seen(n.children)
241 })
242 const fix = (nodes: Inline[]): Inline[] => nodes.map(n =>
243 n.kind === 'footnote' ? (order.has(n.text) ? { kind: 'footnote', text: markOf(order.get(n.text)!), note: order.get(n.text)! } : { kind: 'text', text: `[^${n.text}]` })
244 : 'children' in n ? { ...n, children: fix(n.children) } : n)
245 const mapInlines = (b: Block, f: (nodes: Inline[]) => Inline[]): Block =>
246 'inline' in b ? { ...b, inline: f(b.inline) }
247 : b.kind === 'list' ? { ...b, items: b.items.map(it => ({ ...it, inline: f(it.inline), ...(it.blocks ? { blocks: it.blocks.map(c => mapInlines(c, f)) } : {}) })) }
248 : b.kind === 'table' ? { ...b, header: b.header.map(f), rows: b.rows.map(r => r.map(f)) }
249 : b.kind === 'quote' || b.kind === 'alert' ? { ...b, blocks: b.blocks.map(c => mapInlines(c, f)) }
250 : b
251 blocks.forEach(b => mapInlines(b, nodes => (seen(nodes), nodes)))
252 for (const label of defs.keys()) if (!order.has(label)) order.set(label, order.size + 1)
253 const out = blocks.map(b => mapInlines(b, fix))
254 const notes = [...defs].sort(([a], [b]) => order.get(a)! - order.get(b)!).map(([label, text]) => ({ mark: markOf(order.get(label)!), inline: fix(parseInline(text, hl)), note: order.get(label)! }))
255 return defs.size ? [...out, { kind: 'notes', notes, raw: '' }] : out
256}
257
258const CONTROL = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b\[[0-?]*[ -/]*[@-~]|\u001b[@-_]?|[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
259
260export const parse = (raw: string, hl: Highlight): Block[] => {
261 const source = raw.replace(CONTROL, '')
262 const blocks = parseBlocks(source, hl)
263 return source.includes('[^') ? numberNotes(blocks, notesOf(source), hl) : blocks
264}
265
266const notesOf = (source: string, defs = new Map<string, string>(), depth = 0): Map<string, string> => {
267 const lines = source.replace(/\r\n?/g, '\n').split('\n')
268 let closer: RegExp | undefined
269 let last: string | undefined
270 for (let i = 0; i < lines.length; i++) {
271 const line = lines[i]!
272 if (!closer && depth < MAX_QUOTE_DEPTH && QUOTE.test(line)) {
273 const body: string[] = []
274 while (i < lines.length && QUOTE.test(lines[i]!)) body.push(lines[i++]!.replace(/^\s*>\s?/, ''))
275 i--
276 notesOf(body.join('\n'), defs, depth + 1)
277 last = undefined
278 continue
279 }
280 const run = FENCE.exec(line)?.[1]
281 const fenced = closer !== undefined || run !== undefined
282 if (closer?.test(line)) closer = undefined
283 else if (!closer && run) closer = closerOf(run)
284 const def = fenced ? null : NOTE_DEF.exec(line)
285 if (def) {
286 last = def[1]!
287 if (!defs.has(last)) defs.set(last, def[2] ?? '')
288 } else if (last !== undefined && /^\s{2,}\S/.test(line)) defs.set(last, `${defs.get(last)} ${line.trim()}`)
289 else last = undefined
290 }
291 return defs
292}
293
294const indentOf = (line: string): number => (line.match(/^\s*/)?.[0] ?? '').replace(/\t/g, ' ').length
295const closerOf = (run: string) => new RegExp(`^\\s*\\${run[0]}{${run.length},}\\s*$`)
296
297export const nestedBlocks = (blocks: Block[]): Block[] =>
298 blocks.flatMap(b => [b, ...nestedBlocks(b.kind === 'list' ? b.items.flatMap(it => it.blocks ?? []) : b.kind === 'quote' || b.kind === 'alert' ? b.blocks : [])])
299
300const tableRows = (lines: string[], width: number, hl: Highlight): Inline[][][] => {
301 const end = lines.findIndex(l => !l.includes('|') || l.trim() === '')
302 return lines.slice(0, end < 0 ? lines.length : end).map(l => {
303 const cells = splitRow(l)
304 return Array.from({ length: width }, (_, c) => parseInline(cells[c] ?? '', hl))
305 })
306}
307
308const parseBlocks = (source: string, hl: Highlight, depth = 0): Block[] => {
309 const lines = source.replace(/\r\n?/g, '\n').split('\n')
310 const at = (n: number) => lines[n] ?? ''
311 const blocks: Block[] = []
312 const add = (block: Draft, from: number, to: number) => blocks.push({ ...block, raw: lines.slice(from, to).join('\n') } as Block)
313 let paraStart = 0
314 let para: string[] = []
315
316 const flush = (end: number) => {
317 if (para.length) add({ kind: 'paragraph', inline: parseInline(para.join(' '), hl) }, paraStart, end)
318 para = []
319 }
320
321 for (let i = 0; i < lines.length; i++) {
322 const line = at(i)
323 const fence = FENCE.exec(line)
324 if (fence) {
325 flush(i)
326 const start = i
327 const run = fence[1] ?? '```'
328 const closer = closerOf(run)
329 const body: string[] = []
330 i++
331 while (i < lines.length && !closer.test(at(i))) body.push(at(i++))
332 add({ kind: 'code', lang: fence[2] ?? '', lines: body, ...(i < lines.length ? {} : { isOpen: true as const }) }, start, i + 1)
333 continue
334 }
335 const math = mathAt(lines, i)
336 if (math) {
337 flush(i)
338 add({ kind: 'code', lang: 'math', lines: math.lines }, i, math.end + 1)
339 i = math.end
340 continue
341 }
342 if (line.trim() === '') {
343 flush(i)
344 continue
345 }
346 if (NOTE_DEF.test(line)) {
347 flush(i)
348 while (/^\s{2,}\S/.test(at(i + 1))) i++
349 continue
350 }
351 if (TAG_LINE.test(line)) {
352 flush(i)
353 add({ kind: 'paragraph', inline: [{ kind: 'dim', text: line.trim() }] }, i, i + 1)
354 continue
355 }
356 const heading = /^(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/.exec(line)
357 if (heading) {
358 flush(i)
359 add({ kind: 'heading', level: (heading[1] ?? '#').length, inline: parseInline(heading[2] ?? '', hl) }, i, i + 1)
360 continue
361 }
362 if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
363 flush(i)
364 add({ kind: 'rule' }, i, i + 1)
365 continue
366 }
367 if (line.includes('|') && i + 1 < lines.length && TABLE_SEP.test(at(i + 1))) {
368 flush(i)
369 const start = i
370 const header = splitRow(line)
371 const align = splitRow(at(i + 1)).map(c =>
372 c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left',
373 )
374 const rows = tableRows(lines.slice(i + 2), header.length, hl)
375 i += 2 + rows.length
376 add({ kind: 'table', header: header.map(c => parseInline(c, { numbers: false, paths: false })), align, rows }, start, i)
377 i--
378 continue
379 }
380 if (depth < MAX_QUOTE_DEPTH && QUOTE.test(line)) {
381 flush(i)
382 const start = i
383 const body: string[] = []
384 while (i < lines.length && QUOTE.test(at(i))) body.push(at(i++).replace(/^\s*>\s?/, ''))
385 const alert = ALERT.exec(body[0] ?? '')
386 const inner = (rows: string[]) => parseBlocks(rows.join('\n'), hl, depth + 1)
387 if (alert) add({ kind: 'alert', level: alert[1]!.toLowerCase() as AlertLevel, blocks: inner([alert[2]!, ...body.slice(1)]) }, start, i)
388 else {
389 const quoted = inner(body)
390 if (quoted.length) add({ kind: 'quote', blocks: quoted }, start, i)
391 }
392 i--
393 continue
394 }
395 const item = LIST_ITEM.exec(line)
396 if (item) {
397 flush(i)
398 const start = i
399 const ordered = /\d/.test(item[2] ?? '')
400 const items: ListItem[] = []
401 const contentIndent: number[] = []
402 while (i < lines.length) {
403 const it = LIST_ITEM.exec(at(i))
404 if (it) {
405 const lead = indentOf(it[1] ?? '')
406 contentIndent.push(lead + (it[2] ?? '').length + 1)
407 const task = /^\[([ xX])\]\s+(.*)$/.exec(it[3] ?? '')
408 items.push({ marker: it[2] ?? '-', depth: Math.floor(lead / 2), ...(task ? { task: task[1] !== ' ' } : {}), inline: parseInline(task ? task[2]! : it[3] ?? '', hl) })
409 } else if (/^\s{2,}\S/.test(at(i)) && items.length) {
410 const indent = indentOf(at(i))
411 let owner = items.length - 1
412 while (owner > 0 && contentIndent[owner]! > indent) owner--
413 const target = items[owner]!
414 const fence = FENCE.exec(at(i))
415 const last = target.blocks?.at(-1)
416 if (fence) {
417 const from = i
418 const run = fence[1] ?? '```'
419 const closer = closerOf(run)
420 const strip = new RegExp(`^ {0,${indent}}`)
421 const body: string[] = []
422 i++
423 while (i < lines.length && !closer.test(at(i)) && (at(i).trim() === '' || indentOf(at(i)) >= indent)) body.push(at(i++).replace(strip, ''))
424 const isOpen = i === lines.length
425 if (!isOpen && !closer.test(at(i))) {
426 while (body.at(-1)?.trim() === '') body.pop()
427 i--
428 }
429 target.blocks = [...(target.blocks ?? []), { kind: 'code', lang: fence[2] ?? '', lines: body, ...(isOpen ? { isOpen: true as const } : {}), raw: lines.slice(from, i + 1).join('\n') }]
430 } else if (last?.kind === 'paragraph') {
431 last.inline = [...last.inline, { kind: 'text', text: ' ' }, ...parseInline(at(i).trim(), hl)]
432 last.raw += `\n${at(i)}`
433 } else if (last) {
434 target.blocks!.push({ kind: 'paragraph', inline: parseInline(at(i).trim(), hl), raw: at(i) })
435 } else {
436 target.inline = [...target.inline, { kind: 'text', text: ' ' }, ...parseInline(at(i).trim(), hl)]
437 }
438 } else {
439 break
440 }
441 i++
442 }
443 add({ kind: 'list', ordered, items }, start, i)
444 i--
445 continue
446 }
447 if (!para.length) paraStart = i
448 para.push(line.trim())
449 }
450 flush(lines.length)
451 return blocks
452}
453hooks/clipboard.ts 70 lines1export const MAC_TABLE_COPY = `
2ObjC.import('AppKit')
3var input = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile
4var payload = JSON.parse(ObjC.unwrap($.NSString.alloc.initWithDataEncoding(input, $.NSUTF8StringEncoding)))
5var item = $.NSPasteboardItem.alloc.init
6if (!item.setStringForType(payload.html, $.NSPasteboardTypeHTML) ||
7 !item.setStringForType(payload.text, $.NSPasteboardTypeString)) throw new Error('Cannot encode table')
8var pasteboard = $.NSPasteboard.generalPasteboard
9if (!pasteboard || !pasteboard.writeObjects) throw new Error('macOS clipboard is unavailable to this session')
10pasteboard.clearContents
11if (!pasteboard.writeObjects($.NSArray.arrayWithObject(item))) throw new Error('Cannot write clipboard')
12if (ObjC.unwrap(pasteboard.stringForType($.NSPasteboardTypeHTML)) !== payload.html ||
13 ObjC.unwrap(pasteboard.stringForType($.NSPasteboardTypeString)) !== payload.text) throw new Error('Clipboard verification failed')
14`
15
16export const LINUX_TABLE_COPY = `
17var table = JSON.parse(str(arguments[1]))
18copy('text/html', table.html, 'text/plain', table.text)
19if (str(clipboard('text/html')) !== table.html ||
20 str(clipboard('text/plain')) !== table.text) throw new Error('Clipboard verification failed')
21`
22
23export const WIN_TABLE_COPY = `
24$ErrorActionPreference = 'Stop'
25try {
26 Add-Type -AssemblyName System.Windows.Forms
27 $payload = [Console]::In.ReadToEnd() | ConvertFrom-Json
28 $utf8 = New-Object Text.UTF8Encoding($false)
29 $fragment = [string]$payload.html
30 $lf = [string][char]10
31 $crlf = [string][char]13 + $lf
32 $template = 'Version:1.0' + $crlf + 'StartHTML:{0:D10}' + $crlf + 'EndHTML:{1:D10}' + $crlf + 'StartFragment:{2:D10}' + $crlf + 'EndFragment:{3:D10}' + $crlf
33 $headerLen = $utf8.GetByteCount(($template -f 0, 0, 0, 0))
34 $prefix = '<html>' + $lf + '<head>' + $lf + '<meta charset="utf-8">' + $lf + '</head>' + $lf + '<body>' + $lf + '<!--StartFragment-->' + $lf
35 $suffix = $lf + '<!--EndFragment-->' + $lf + '</body>' + $lf + '</html>'
36 $startFragment = $headerLen + $utf8.GetByteCount($prefix)
37 $endFragment = $startFragment + $utf8.GetByteCount($fragment)
38 $endHtml = $endFragment + $utf8.GetByteCount($suffix)
39 $cf = ($template -f $headerLen, $endHtml, $startFragment, $endFragment) + $prefix + $fragment + $suffix
40 $data = New-Object System.Windows.Forms.DataObject
41 $data.SetData('HTML Format', (New-Object IO.MemoryStream(, [byte[]]($utf8.GetBytes($cf) + [byte]0))))
42 $data.SetData([System.Windows.Forms.DataFormats]::UnicodeText, [string]$payload.text)
43 [System.Windows.Forms.Clipboard]::SetDataObject($data, $true)
44} catch {
45 [Console]::Error.WriteLine($_.Exception.Message)
46 exit 1
47}
48`
49
50export type ClipboardBackend = 'macos' | 'linux' | 'windows'
51
52const ascii = (json: string): string =>
53 Array.from({ length: json.length }, (_, i) => {
54 const unit = json.charCodeAt(i)
55 return unit > 0x7e ? `\\u${unit.toString(16).padStart(4, '0')}` : json[i]
56 }).join('')
57
58export const clipboardCommand = (backend: ClipboardBackend, html: string, text: string, systemRoot?: string) => backend === 'windows'
59 ? {
60 stdin: ascii(JSON.stringify({ html, text: text.replace(/\r?\n/g, '\r\n') })),
61 argv: [`${systemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, '-NoProfile', '-NonInteractive', '-STA', '-Command', WIN_TABLE_COPY],
62 failure: 'Windows clipboard helper failed',
63 }
64 : {
65 stdin: JSON.stringify({ html, text }),
66 ...backend === 'macos'
67 ? { argv: ['/usr/bin/osascript', '-l', 'JavaScript', '-e', MAC_TABLE_COPY], failure: 'macOS clipboard helper failed' }
68 : { argv: ['copyq', 'eval', LINUX_TABLE_COPY, '-'], failure: 'CopyQ failed; install and start CopyQ in the graphical session' },
69 }
70hooks/figure.tsx 258 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { graphemes, heldLtr, mirrored, remember, width } from './render'
4import { asIs, hasRtl, holdEngine, holdText, holdVisual, led } from './rtl'
5import type { Style, Theme } from './theme'
6
7export const FIGURE_ROLES = ['accent', 'warn', 'ok', 'note', 'dim', 'strong'] as const
8
9type FigureRole = (typeof FIGURE_ROLES)[number]
10
11type FigureRun = { text: string; role?: FigureRole }
12
13type FigureFinding = { line: number; message: string }
14
15export const FIGURE_MAX_COLUMNS = 100
16
17export const FIGURE_MAX_LINES = 80
18
19export const FIGURE_SAFE_COLUMNS = 74
20
21const RAW_MAX_LINE = FIGURE_MAX_COLUMNS * 4
22
23const LISTED_GLYPHS = 8
24
25export const FIGURE_GLYPHS = '─│┌┐└┘├┤┬┴┼═║→←►◄▲▼✓✗●○█▏■□·'
26
27export const FIGURE_HINT = [
28 'A fenced block whose opening line is exactly ```figure is drawn as a text picture, as written.',
29 'Prefer one over mermaid when position carries meaning: durations racing on a time axis, a screen layout, phases, before/after; mermaid stays for plain flows and charts; none for short answers, code changes or reports. The prose never retells the picture.',
30 'When the user asks you to explain, compare or plan something and pictures would make it easier to grasp, build the reply around them, with the core on the first screen as a picture or a sentence.',
31 'Color with {role:text}, not nested: accent for lines, warn for risk, ok for healthy, note for time, dim for asides, strong for emphasis.',
32 `At most ${FIGURE_SAFE_COLUMNS} columns, glyphs one column wide such as ${FIGURE_GLYPHS}, no emoji or tabs; markup takes no columns, so align on the drawn text. CJK letters take two columns, count them twice. Draw a time axis with edge ticks like ▏───▏───▏, not ├┼┤: █ fills its cell, ├┼┤ sit mid-cell.`,
33].join(' ')
34
35const MARKUP = new RegExp(`\\{(${FIGURE_ROLES.join('|')}):([^{}]*)\\}`, 'g')
36const MARKER = /\{([A-Za-z][\w-]*):/g
37const TEXT_FORMS: Record<string, string> = { '▶': '►', '◀': '◄' }
38const ROLE_TOKENS: Record<FigureRole, keyof Theme> = { accent: 'diagram', warn: 'codeFlag', ok: 'codeString', note: 'heading', dim: 'codeComment', strong: 'strong' }
39const EMOJI_CAPABLE = /\p{Extended_Pictographic}|\p{Emoji_Presentation}|\u20e3/u
40const CONTROL = /[\0-\x08\x0b-\x1f\x7f-\x9f]/
41const DIRECTION_MARK = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/
42const INVISIBLE = /[\u00ad\u200b-\u200d\u2028\u2029\ufeff]/
43const isRole = (name: string | undefined): name is FigureRole => FIGURE_ROLES.some(role => role === name)
44
45export const isFigureLang = (lang: string): boolean => lang.toLowerCase() === 'figure'
46
47const textForm = (text: string): string => text.replace(/[▶◀]/g, glyph => TEXT_FORMS[glyph] ?? glyph)
48
49export const figureRuns = (line: string): FigureRun[] => {
50 const runs: FigureRun[] = []
51 let at = 0
52 for (const match of line.matchAll(MARKUP)) {
53 const [whole, role, text = ''] = match
54 if (match.index > at) runs.push({ text: textForm(line.slice(at, match.index)) })
55 if (text && isRole(role)) runs.push({ text: textForm(text), role })
56 at = match.index + whole.length
57 }
58 if (at < line.length) runs.push({ text: textForm(line.slice(at)) })
59 return runs
60}
61
62export const figureText = (line: string): string => figureRuns(line).map(run => run.text).join('')
63
64const swapsTwoLetters = (name: string, role: string): boolean => {
65 if (name.length !== role.length) return false
66 const differ = [...name].flatMap((letter, i) => (letter === role[i] ? [] : [i]))
67 const [first = -1, second = -1] = differ
68 return differ.length === 2 && second === first + 1 && name[first] === role[second] && name[second] === role[first]
69}
70
71const markupMessage = (marker: RegExpExecArray, drawn: ReadonlySet<number>, lastClose: number): string | undefined => {
72 const [written, name = ''] = marker
73 if (drawn.has(marker.index)) return undefined
74 const lower = name.toLowerCase()
75 const role = FIGURE_ROLES.find(candidate => candidate === lower || swapsTwoLetters(lower, candidate))
76 if (!role) return undefined
77 if (name !== role) return `"${written}" is not a role, roles are ${FIGURE_ROLES.join(', ')} in lower case`
78 if (lastClose < marker.index + written.length) return `"${written}" is not closed, end it with }`
79 return `"${written}" holds braces, markup does not nest`
80}
81
82const markupMessages = (line: string): string[] => {
83 const drawn = new Set([...line.matchAll(MARKUP)].map(match => match.index))
84 const lastClose = line.lastIndexOf('}')
85 return [...line.matchAll(MARKER)].flatMap(marker => markupMessage(marker, drawn, lastClose) ?? [])
86}
87
88const quoted = (glyphs: readonly string[]): string => {
89 const distinct = [...new Set(glyphs)]
90 const listed = distinct.slice(0, LISTED_GLYPHS).map(glyph => `"${glyph}"`).join(', ')
91 return distinct.length > LISTED_GLYPHS ? `${listed} and ${distinct.length - LISTED_GLYPHS} more` : listed
92}
93
94const lineMessages = (line: string): string[] => {
95 const text = figureText(line)
96 const glyphs = graphemes(text)
97 const emoji = glyphs.filter(glyph => EMOJI_CAPABLE.test(glyph))
98 const columns = width(text)
99 return [
100 ...(columns > FIGURE_MAX_COLUMNS ? [`${columns} columns wide, at most ${FIGURE_MAX_COLUMNS}`] : []),
101 ...(text.includes('\t') ? ['tab, use spaces'] : []),
102 ...(CONTROL.test(text) ? ['control character, use plain text'] : []),
103 ...(DIRECTION_MARK.test(text) ? ['direction mark, use plain text'] : []),
104 ...(INVISIBLE.test(text) ? ['invisible character, use plain text'] : []),
105 ...(emoji.length ? [`${quoted(emoji)} may draw as an emoji, use a text glyph`] : []),
106 ...markupMessages(line),
107 ]
108}
109
110const withoutTrailingBlanks = (lines: readonly string[]): readonly string[] => {
111 let end = lines.length
112 while (end > 0 && !lines[end - 1]?.trim()) end--
113 return lines.slice(0, end)
114}
115
116export const checkFigure = (lines: readonly string[], first = 0): FigureFinding[] =>
117 lines.flatMap((line, index) => lineMessages(line).map(message => ({ line: first + index + 1, message })))
118
119const leadingBlanks = (lines: readonly string[]): number => {
120 const start = lines.findIndex(line => line.trim())
121 return start < 0 ? lines.length : start
122}
123
124const ROLE_HUES = { warn: 0, ok: 120, note: 45 } as const
125
126type HuedRole = keyof typeof ROLE_HUES
127
128const INK: readonly (keyof Theme)[] = ['accent', 'heading', 'tableHeader', 'emphasis', 'inlineCode', 'codeCommand', 'codeFlag', 'codeString', 'link', 'path', 'number', 'bullet', 'quote']
129
130const HEX = /^#(?:[0-9a-f]{3}){1,2}$/i
131
132const RGB = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i
133
134const channelsOf = (color: string): readonly [number, number, number] | undefined => {
135 const value = color.trim()
136 if (HEX.test(value)) {
137 const full = value.length === 4 ? [...value.slice(1)].map(digit => digit + digit).join('') : value.slice(1)
138 const [r = 0, g = 0, b = 0] = [0, 2, 4].map(at => parseInt(full.slice(at, at + 2), 16))
139 return [r, g, b]
140 }
141 const rgb = RGB.exec(value)
142 return rgb ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])] : undefined
143}
144
145const colorKey = (channels: readonly [number, number, number]): string => channels.join(',')
146
147const hueOf = (channels: readonly [number, number, number]): { hue: number; saturation: number } => {
148 const [r, g, b] = channels.map(channel => Math.min(channel, 255) / 255) as [number, number, number]
149 const max = Math.max(r, g, b)
150 const spread = max - Math.min(r, g, b)
151 const lightness = (max + max - spread) / 2
152 const saturation = spread === 0 ? 0 : spread / (1 - Math.abs(2 * lightness - 1))
153 const sector = spread === 0 ? 0 : max === r ? ((g - b) / spread) % 6 : max === g ? (b - r) / spread + 2 : (r - g) / spread + 4
154 return { hue: (sector * 60 + 360) % 360, saturation }
155}
156
157const hueDistance = (a: number, b: number): number => Math.min(Math.abs(a - b), 360 - Math.abs(a - b))
158
159const pickedRoles = new WeakMap<Theme, Partial<Record<HuedRole, string>>>()
160
161export const figureRoleColors = (theme: Theme): Partial<Record<HuedRole, string>> => {
162 const known = pickedRoles.get(theme)
163 if (known) return known
164 const inks = [...new Set(INK.map(token => theme[token]).filter((color): color is string => color !== undefined))]
165 .flatMap(color => {
166 const channels = channelsOf(color)
167 return channels ? [{ color, key: colorKey(channels), ...hueOf(channels) }] : []
168 })
169 .filter(ink => ink.saturation > 0.15)
170 const frame = theme.diagram === undefined ? undefined : channelsOf(theme.diagram)
171 const taken = new Set(frame ? [colorKey(frame)] : [])
172 const picked: Partial<Record<HuedRole, string>> = {}
173 for (const role of Object.keys(ROLE_HUES) as HuedRole[]) {
174 const [best] = inks.filter(ink => !taken.has(ink.key)).sort((a, b) => hueDistance(a.hue, ROLE_HUES[role]) - hueDistance(b.hue, ROLE_HUES[role]))
175 if (!best) continue
176 picked[role] = best.color
177 taken.add(best.key)
178 }
179 pickedRoles.set(theme, picked)
180 return picked
181}
182
183const isHued = (role: FigureRole): role is HuedRole => role in ROLE_HUES
184
185const runColor = (style: Style, role: FigureRole | undefined): string | undefined => {
186 if (!role) return style.theme.diagramText
187 const own = style.theme[ROLE_TOKENS[role]]
188 if (!isHued(role) || (own !== undefined && !channelsOf(own))) return own
189 return figureRoleColors(style.theme)[role] ?? own
190}
191
192const isBold = (style: Style, role: FigureRole | undefined): boolean => role === 'strong' || (role === 'warn' && !runColor(style, 'warn'))
193
194const heldRuns = (style: Style, line: string): FigureRun[] => {
195 const runs = figureRuns(line)
196 const hold = !hasRtl(line) ? null : mirrored(style) ? holdText : style.shape === 'engine' ? holdEngine : style.reorder && style.shape === 'visual' ? holdVisual : null
197 const chars = runs.flatMap(run => [...run.text])
198 const roles = runs.flatMap(run => [...run.text].map(() => run.role))
199 const base = hold ? hold(chars) : asIs(chars)
200 const { cells, from } = mirrored(style) ? led(chars, base) : base
201 const held: FigureRun[] = []
202 cells.forEach((ch, p) => {
203 const role = roles[from[p]!]
204 const last = held.at(-1)
205 if (last && last.role === role) last.text += ch
206 else held.push(role ? { text: ch, role } : { text: ch })
207 })
208 return held
209}
210
211const figureElement = ({ Box, Text }: ElementTable, style: Style, lines: readonly string[], key: string): RenderElement => (
212 <Box key={key} flexDirection="column" paddingLeft={mirrored(style) ? 0 : 2}>
213 {lines.map((line, i) => {
214 const runs = heldRuns(style, line)
215 return (
216 <Text key={`${key}.${i}`} {...heldLtr(style)}>
217 {runs.length
218 ? runs.map((run, r) => (
219 <Text key={`r${r}`} color={runColor(style, run.role)} bold={isBold(style, run.role)} dimColor={run.role === 'dim' && !style.theme.codeComment}>
220 {run.text}
221 </Text>
222 ))
223 : ' '}
224 </Text>
225 )
226 })}
227 </Box>
228)
229
230type FigureDecision = { lines: readonly string[]; texts: readonly string[]; widest: number } | { note: string }
231
232const decisions = new Map<string, FigureDecision>()
233
234const drawable = (lines: readonly string[]): FigureDecision => {
235 const texts = lines.map(figureText)
236 return { lines, texts, widest: Math.max(0, ...texts.map(width)) }
237}
238
239const decide = (raw: readonly string[]): FigureDecision => {
240 const tooLong = raw.findIndex(line => line.length > RAW_MAX_LINE)
241 if (tooLong >= 0) return { note: `figure line ${tooLong + 1} is ${raw[tooLong]!.length} characters long` }
242 if (raw.length > FIGURE_MAX_LINES * 2) return { note: `figure has ${raw.length} lines, at most ${FIGURE_MAX_LINES}` }
243 const first = leadingBlanks(raw)
244 const lines = withoutTrailingBlanks(raw.slice(first))
245 if (lines.length > FIGURE_MAX_LINES) return { note: `figure has ${lines.length} lines, at most ${FIGURE_MAX_LINES}` }
246 const [finding] = checkFigure(lines, first)
247 return finding ? { note: `figure line ${finding.line}: ${finding.message}` } : drawable(lines)
248}
249
250export const drawFigure = (el: ElementTable, style: Style, block: { lines: readonly string[]; isOpen?: true }, columns: number, key: string): { element?: RenderElement; art?: string; note?: string } | null => {
251 if (block.isOpen) return null
252 const decision = remember(decisions, block.lines.join('\n'), () => decide(block.lines))
253 if ('note' in decision) return decision
254 if (decision.lines.length === 0) return null
255 if (decision.widest > columns - 2) return { note: `figure is ${decision.widest} cols, terminal is ${columns}` }
256 return { element: figureElement(el, style, decision.lines, key), art: decision.texts.join('\n') }
257}
258hooks/mermaid.tsx 337 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type { ChartArt } from './charts'
4import { ganttText, pieText } from './charts'
5import { drawFigure, isFigureLang } from './figure'
6import type { Block } from './markdown'
7import type { Drawn } from './render'
8import { cells, graphemes, heldLtr, mirrored, remember, width } from './render'
9import { asIs, hasRtl, holdEngine, holdRow, holdVisual, led } from './rtl'
10import type { Style, Theme } from './theme'
11import { renderMermaidAscii, setChartSize } from './vendor/mermaid-text.js'
12
13const MAX_LINES = 80
14const PAD = '\uFDD0'
15const textCache = new Map<string, string | null>()
16
17export const chartSize = (columns: number, source = '') => {
18 const labels = (/^\s*x-axis\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1] ?? '').split(',').map(s => s.trim().replace(/^"|"$/g, ''))
19 const fit = labels.length * (Math.max(...labels.map(l => l.length)) + 2)
20 const width = Math.max(24, Math.min(60, Math.max(Math.floor(columns / 6), fit), columns - 12))
21 return { width, height: Math.max(8, Math.min(20, Math.round(width * 0.3))) }
22}
23
24const padWide = (source: string) => (/^[\x00-\x7F]*$/.test(source) ? source : graphemes(source).map(g => (g.length === 1 && width(g) === 2 ? g + PAD : g)).join(''))
25
26const unquoteCategories = (source: string) =>
27 source.replace(/^(\s*x-axis\b[^[\n]*\[)([^\]\n]*)\]/m, (_, head: string, items: string) => `${head}${items.replace(/"([^"]*)"/g, (_q, s: string) => s.replaceAll(',', ' '))}]`)
28
29const BLOCK = /[█▀▄]/
30
31const labelBars = (art: string, source: string): string => {
32 const series = source.match(/^\s*(bar|line)\b.*$/gm) ?? []
33 const values = /^\s*bar\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1]?.split(',').map(v => v.trim())
34 if (series.length !== 1 || !values || /^\s*xychart(-beta)?\s+horizontal/m.test(source)) return art
35 const grid = ['', ...art.split('\n')].map(l => [...l])
36 const gutter = (r: number) => /^\s*(-?[\d.]+)[┤┼]/.exec(grid[r]?.join('') ?? '')?.[1]
37 const axis = grid.findLastIndex((_, r) => gutter(r) !== undefined)
38 const ticks = [...(grid[axis]?.join('') ?? '').matchAll(/┬|▀+/g)].map(m => m.index + Math.floor(m[0].length / 2))
39 if (ticks.length !== values.length) return art
40 const zero = grid.findIndex((_, r) => gutter(r) === '0')
41 const top = grid.findIndex((_, r) => gutter(r) !== undefined)
42 const base = zero !== -1 ? zero : gutter(top)?.startsWith('-') ? top : axis
43 ticks.forEach((x, k) => {
44 const filled = grid.flatMap((row, r) => (BLOCK.test(row[x] ?? '') ? [r] : []))
45 const y = Number(values[k]) < 0 ? (filled.at(-1) ?? base) + 1 : (filled[0] ?? base) - 1
46 const text = [...values[k]!]
47 const bar = grid[filled[0] ?? -1] ?? []
48 let [left, right] = [x, x]
49 while (BLOCK.test(bar[left - 1] ?? '')) left--
50 while (BLOCK.test(bar[right + 1] ?? '')) right++
51 const from = left + Math.ceil((right - left + 1 - text.length) / 2)
52 const row = y < axis ? grid[y] : undefined
53 if (!row) return
54 while (row.length < from + text.length) row.push(' ')
55 if (!text.every((_, i) => /[ ·]/.test(row[from + i] ?? ' '))) return
56 text.forEach((ch, i) => (row[from + i] = ch))
57 })
58 return grid.map(row => row.join('').trimEnd()).join('\n').replace(/^\n/, '')
59}
60
61const SIDEWAYS = /^(\s*(?:flowchart|graph)[ \t]+)(LR|RL)\b/
62
63const SUPPORTED = /^(flowchart|graph|statediagram(-v2)?|xychart(-beta)?|sequencediagram|classdiagram|erdiagram|pie|gantt)$/i
64
65const kindOf = (source: string) => source.replace(/^(\s*%%[^\n]*\n)+/, '').trim().split(/\s/)[0] ?? ''
66
67const unsupportedKind = (source: string): string | undefined => {
68 const kind = kindOf(source)
69 return kind && !SUPPORTED.test(kind) ? kind : undefined
70}
71
72const OWN: Record<string, typeof pieText> = {
73 pie: pieText,
74 gantt: (source, ascii, columns) => {
75 const text = ganttText(source, ascii, columns)
76 return text === null ? null : { text }
77 },
78}
79
80const INLINE_TAG = /<\/?(?:b|i|u|s|em|strong|del|code|small|sub|sup|span|mark)\b[^<>\n]*>/gi
81
82type Mark = { italic?: boolean; bold?: boolean; underline?: boolean; strikethrough?: boolean }
83const TAG_MARK: Record<string, keyof Mark> = { i: 'italic', em: 'italic', b: 'bold', strong: 'bold', u: 'underline', s: 'strikethrough', del: 'strikethrough' }
84
85export const inlineMarks = (source: string): [string[], Mark][] =>
86 [...source.matchAll(/<(i|em|b|strong|u|s|del)\b[^<>\n]*>([^<\n]+)<\/\1\s*>/gi)]
87 .map(m => [cells(m[2]!.trim()), { [TAG_MARK[m[1]!.toLowerCase()]!]: true }] as [string[], Mark])
88
89const fitted = new Map<string, ChartArt | null>()
90const artWidths = new Map<string, number>()
91const artWidth = (art: string) => remember(artWidths, art, () => Math.max(...art.split('\n').map(width)))
92
93export const mermaidText = (source: string, ascii: boolean, columns: number, pieColors = 0): string | null => mermaidArt(source, ascii, columns, pieColors)?.text ?? null
94
95const mermaidArt = (source: string, ascii: boolean, columns: number, pieColors: number): ChartArt | null =>
96 remember(fitted, `${ascii}:${columns}:${pieColors}:${source}`, () => {
97 source = source.replace(INLINE_TAG, '')
98 if (source.length > 8000 || source.split('\n').length > MAX_LINES) return null
99 const own = OWN[kindOf(source).toLowerCase()]
100 if (own) return own(source, ascii, columns, pieColors)
101 const art = draw(source, ascii, columns)
102 const text = art === null || artWidth(art) <= columns - 2 || !SIDEWAYS.test(source) ? art : (draw(source.replace(SIDEWAYS, '$1TD'), ascii, columns) ?? art)
103 return text === null ? null : { text, series: chartSeries(source, ascii, columns) }
104 })
105
106const prepared = (padded: string) => unquoteCategories(padded.replace(/^(\s*%%[^\n]*\n)+/, '')).replace(/(-->|-\.->|==>|---|-\.-|===)[ \t]+\|/g, '$1|')
107
108const NEUTRAL = '#808080'
109const PROBE = { fg: NEUTRAL, border: NEUTRAL, line: NEUTRAL, arrow: NEUTRAL, corner: NEUTRAL, junction: NEUTRAL, accent: '#ff0000', bg: '#000000' }
110const SGR = /\x1b\[([\d;]*)m/g
111
112const seriesOf = (colored: string): number[][] => {
113 const hues = colored.split('\n').map(line => {
114 const row: (string | undefined)[] = []
115 let hue: string | undefined
116 let at = 0
117 const take = (text: string) => graphemes(text).forEach(() => row.push(hue))
118 for (const m of line.matchAll(SGR)) {
119 take(line.slice(at, m.index))
120 hue = m[1]!.startsWith('38;2;') && m[1] !== '38;2;128;128;128' ? m[1] : undefined
121 at = m.index + m[0].length
122 }
123 take(line.slice(at))
124 return row
125 })
126 const legend = hues.find(row => row.some(Boolean)) ?? []
127 const order = [...new Set(legend.filter((h): h is string => h !== undefined))]
128 return hues.map(row => row.map(h => (h === undefined ? -1 : order.indexOf(h))))
129}
130
131const seriesCache = new Map<string, number[][] | null>()
132
133const chartSeries = (source: string, ascii: boolean, columns: number): number[][] | undefined => {
134 if (!/^\s*xychart/.test(source) || (source.match(/^\s*(bar|line)\b/gm) ?? []).length < 2) return undefined
135 const padded = padWide(source)
136 const size = chartSize(columns, padded)
137 return remember(seriesCache, `${ascii}:${size.width}:${source}`, () => {
138 try {
139 setChartSize(size.width, size.height)
140 return seriesOf(renderMermaidAscii(prepared(padded), { useAscii: ascii, colorMode: 'truecolor', theme: PROBE, paddingX: 3, paddingY: 1 }))
141 } catch {
142 return null
143 }
144 }) ?? undefined
145}
146
147const draw = (source: string, ascii: boolean, columns: number): string | null => {
148 const isChart = /^\s*xychart/.test(source)
149 const padded = padWide(source)
150 const size = chartSize(columns, isChart ? padded : '')
151 const key = `${ascii}:${isChart ? size.width : 0}:${source}`
152 return remember(textCache, key, () => {
153 try {
154 if (isChart) setChartSize(size.width, size.height)
155 const art = renderMermaidAscii(prepared(padded), { useAscii: ascii, colorMode: 'none', paddingX: 3, paddingY: 1 }).replace(/[ \t]+$/gm, '').trimEnd().replace(/▶/g, '►').replace(/◀/g, '◄')
156 return (isChart ? labelBars(art, padded) : art.split('\n').filter(l => !/^[\s│|]*$/.test(l)).join('\n')).replaceAll(PAD, '')
157 } catch {
158 return null
159 }
160 })
161}
162
163const LINE = /[─-╿◇▏]/
164const ARROW = /[►◄▲▼]/
165const PART = /[▐▏▎▍▌▋▊▉]/
166
167const barStart = (row: string[], c: number): number => {
168 const ch = row[c] ?? ''
169 const solid = BLOCK.test(ch) ? ch : PART.test(ch) && row[c + 1] === '█' ? '█' : PART.test(ch) && row[c - 1] === '█' ? '█' : ''
170 if (!solid) return -1
171 let start = BLOCK.test(ch) ? c : row[c + 1] === '█' ? c + 1 : c - 1
172 while (row[start - 1] === solid) start--
173 return start
174}
175
176type Painted = { grid: string[][]; colors: (string | undefined)[][] }
177
178export const diagramPalette = (t: Theme): string[] => [...new Set([t.link, t.number, t.heading, t.emphasis, t.path, t.codeFlag, t.accent])].filter((c): c is string => c !== undefined)
179
180const paint = (art: string, style: Style, series?: number[][]): Painted => {
181 const t = style.theme
182 const grid = art.split('\n').map(cells)
183 const cell = (r: number, c: number) => grid[r]?.[c] ?? ''
184 const color: (string | undefined)[][] = grid.map(row => row.map(() => undefined))
185 const palette = diagramPalette(t)
186 const next = (i: number) => palette[i % palette.length]
187 const labels = new Map<string, string | undefined>()
188
189 const rects: { r: number; c: number; r2: number; c2: number }[] = []
190 for (let r = 0; r < grid.length; r++) {
191 for (let c = 0; c < (grid[r]?.length ?? 0); c++) {
192 if (!/[┌╭(]/.test(cell(r, c))) continue
193 let c2 = c + 1
194 while (/[─┬┴┼▲▼]/.test(cell(r, c2))) c2++
195 if (!/[┐╮)]/.test(cell(r, c2)) || c2 === c + 1) continue
196 let r2 = r + 1
197 while (/[│├┤┼►◄]/.test(cell(r2, c))) r2++
198 if (!/[└╰(]/.test(cell(r2, c)) || !/[┘╯)]/.test(cell(r2, c2))) continue
199 rects.push({ r, c, r2, c2 })
200 }
201 }
202 const inside = (a: (typeof rects)[number], b: (typeof rects)[number]) => a !== b && b.r > a.r && b.r2 < a.r2 && b.c > a.c && b.c2 < a.c2
203 for (const box of rects.filter(a => !rects.some(b => inside(a, b)))) {
204 const { r, c, r2, c2 } = box
205 const label = grid.slice(r + 1, r2).map(row => row.slice(c + 1, c2).join('')).join(' ').trim()
206 if (!labels.has(label)) labels.set(label, next(labels.size))
207 const hue = labels.get(label)
208 for (let y = r; y <= r2; y++) for (let x = c; x <= c2; x++) if (cell(y, x).trim()) color[y]![x] = hue
209 }
210
211 const bars = [...new Set(grid.flatMap(row => row.flatMap((ch, c) => (BLOCK.test(ch) && row[c - 1] !== ch ? [c] : []))))].sort((a, b) => a - b)
212 const ends = bars.map(x => {
213 const row = grid.find(row => BLOCK.test(row[x] ?? ''))!
214 let end = x
215 while (row[end + 1] === row[x]) end++
216 return end
217 })
218 const tops = bars.map(x => {
219 const r = grid.findIndex(row => BLOCK.test(row[x] ?? ''))
220 return r * 2 + (grid[r]![x] === '▄' ? 1 : 0)
221 })
222 const single = bars.length > 1 && bars.every((x, i) => i === 0 || x - ends[i - 1]! > 2)
223 const barColor = (i: number) => (single ? (tops[i] === Math.min(...tops) ? t.emphasis ?? t.accent : t.quote) : next(i))
224 series?.forEach((row, r) => row.forEach((owner, c) => owner >= 0 && grid[r]?.[c]?.trim() && (color[r]![c] = next(owner))))
225 grid.forEach((row, r) =>
226 row.forEach((ch, c) => {
227 if (color[r]![c] !== undefined) return
228 const start = barStart(row, c)
229 if (start >= 0) color[r]![c] = barColor(bars.indexOf(start))
230 else if (ch === '·') color[r]![c] = t.rule
231 else if (ARROW.test(ch)) color[r]![c] = t.accent
232 else if (LINE.test(ch)) color[r]![c] = t.diagram
233 else if (/^\d+[┤┼]/.test(row.slice(c).join(''))) color[r]![c] = t.number
234 else color[r]![c] = t.diagramText
235 }),
236 )
237 return { grid, colors: color }
238}
239
240const paintPie = (art: string, slices: number[][], style: Style): Painted => {
241 const t = style.theme
242 const palette = diagramPalette(t)
243 const grid = art.split('\n').map(cells)
244 const hue = (slice: number) => (slice < 0 ? t.diagramText : palette[slice % palette.length])
245 return { grid, colors: grid.map((row, r) => row.map((_, c) => hue(slices[r]?.[c] ?? -1))) }
246}
247
248const painted = new WeakMap<Theme, Map<string, Painted>>()
249
250const piePainted = new WeakMap<Theme, WeakMap<number[][], Painted>>()
251
252const painting = (style: Style, art: string, slices?: number[][], series?: number[][]): Painted => {
253 if (!slices) {
254 const cache = painted.get(style.theme) ?? new Map<string, Painted>()
255 painted.set(style.theme, cache)
256 return remember(cache, art, () => paint(art, style, series))
257 }
258 const cache = piePainted.get(style.theme) ?? new WeakMap<number[][], Painted>()
259 piePainted.set(style.theme, cache)
260 const done = cache.get(slices) ?? paintPie(art, slices, style)
261 cache.set(slices, done)
262 return done
263}
264
265export const boxArt = ({ Box, Text }: ElementTable, style: Style, art: string, key: string, marks: [string[], Mark][] = [], slices?: number[][], series?: number[][]): RenderElement => {
266 const { grid, colors } = painting(style, art, slices, series)
267 const visual = style.reorder && style.shape === 'visual' && hasRtl(art)
268 return (
269 <Box key={key} flexDirection="column" paddingLeft={mirrored(style) ? 0 : 2}>
270 {grid.map((row, i) => {
271 const { cells: chars, from } = mirrored(style) ? led(row, holdRow(row)) : style.shape === 'engine' ? holdEngine(row) : visual ? holdVisual(row) : asIs(row)
272 const marked: (Mark | undefined)[] = row.map(() => undefined)
273 for (const [phrase, mark] of marks)
274 for (let c = 0; c + phrase.length <= row.length; c++)
275 if (phrase.every((ch, k) => row[c + k] === ch)) for (let k = 0; k < phrase.length; k++) marked[c + k] = mark
276 const hueAt = (p: number) => colors[i]?.[from[p]!]
277 const markAt = (p: number) => marked[from[p]!]
278 const parts: RenderElement[] = []
279 let at = 0
280 while (at < chars.length) {
281 const hue = hueAt(at)
282 const mark = markAt(at)
283 let end = at + 1
284 while (end < chars.length && hueAt(end) === hue && markAt(end) === mark) end++
285 parts.push(<Text key={`t${parts.length}`} color={hue} {...mark}>{chars.slice(at, end).join('')}</Text>)
286 at = end
287 }
288 return <Text key={`${key}.${i}`} {...heldLtr(style)}>{parts.length ? parts : ' '}</Text>
289 })}
290 </Box>
291 )
292}
293
294const EDGE_LABEL = /(-[-.=]+\s*)"([^"\n]+)"(\s*[-.=]+>)|\|([^|\n]+)\|/g
295
296const cap = (label: string, max: number) => {
297 const flat = label.replace(/<br\s*\/?>/gi, ' ').replace(/\s+/g, ' ').trim()
298 const chars = graphemes(flat)
299 return chars.length > max ? `${chars.slice(0, max - 1).join('').trimEnd()}…` : flat
300}
301
302export const shortenEdgeLabels = (source: string, max: number): string =>
303 source.replace(EDGE_LABEL, (_, open, quoted, close, piped) =>
304 quoted !== undefined ? `${open}"${cap(quoted, max)}"${close}` : `|${cap(piped, max)}|`)
305
306const pieColorsOf = (style: Style) => (style.pieStyle === 'round' ? diagramPalette(style.theme).length : 0)
307
308export const drawDiagrams = (el: ElementTable, style: Style, blocks: Block[], columns: number): Drawn => {
309 const drawn: Drawn = new Map()
310 const pieColors = pieColorsOf(style)
311 if (style.mermaid) {
312 for (const [i, block] of blocks.entries()) {
313 if (block.kind !== 'code' || block.lang.toLowerCase() !== 'mermaid') continue
314 const source = block.lines.join('\n')
315 let narrowest = Infinity
316 for (const max of [Infinity, 24, 12]) {
317 const art = mermaidArt(max === Infinity ? source : shortenEdgeLabels(source, max), style.mermaidAscii, columns, pieColors)
318 const wide = art === null ? Infinity : artWidth(art.text)
319 narrowest = Math.min(narrowest, wide)
320 if (art === null || wide > columns - 2) continue
321 drawn.set(i, { element: boxArt(el, style, art.text, `b${i}`, inlineMarks(source), art.slices, art.series), art: art.text })
322 break
323 }
324 if (!drawn.has(i) && !block.isOpen) {
325 const kind = unsupportedKind(source)
326 drawn.set(i, { note: kind ? `${kind} diagrams draw as source for now` : narrowest === Infinity ? 'diagram failed to render' : `diagram is ${narrowest} cols, terminal is ${columns}` })
327 }
328 }
329 }
330 for (const [i, block] of blocks.entries()) {
331 if (block.kind !== 'code' || !isFigureLang(block.lang)) continue
332 const figure = drawFigure(el, style, block, columns, `b${i}`)
333 if (figure) drawn.set(i, figure)
334 }
335 return drawn
336}
337hooks/render.tsx 1701 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { tableHtml, tableText } from './html'
4import type { Block, Inline } from './markdown'
5import { mathOf } from './latex'
6import { displayText, inlineText, isWebLink, texText } from './markdown'
7import type { Flow } from './rtl'
8import { ROW_MARK, ROW_MARK_TEXT, WALL, commentTail, commentVisual, containsRtl, flow, hasRtl, listMarker, terminalLine } from './rtl'
9import type { Style, Theme } from './theme'
10import type { PrismToken } from './vendor/prism.js'
11import { languages, tokenize } from './vendor/prism.js'
12
13const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}\u20E3]|\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F/u
14const segmenter = new Intl.Segmenter()
15
16export const graphemes = (s: string): string[] => [...segmenter.segment(s)].map(g => g.segment)
17
18export const cells = (s: string): string[] => (/^[ -~]*$/.test(s) ? [...s] : graphemes(s).flatMap(g => (WIDE.test(g) ? [g, ''] : [g])))
19
20export const width = (s: string): number =>
21 /^[ -~]*$/.test(s) ? s.length
22 : s.length === 1 ? (/\p{M}/u.test(s) ? 0 : WIDE.test(s) ? 2 : 1)
23 : graphemes(s).reduce((w, g) => (/^\p{M}+$/u.test(g) ? w : w + (WIDE.test(g) ? 2 : 1)), 0)
24
25const wrapRanges = (text: string, w: number): [number, number][] => {
26 if (width(text) <= w) return [[0, text.length]]
27 const out: [number, number][] = []
28 let line: [number, number] | undefined
29 let used = 0
30 for (const word of text.matchAll(/\S+/g)) {
31 let start = word.index
32 const end = start + word[0].length
33 let size = width(word[0])
34 if (size > w) {
35 if (line) out.push(line)
36 line = undefined
37 let cut = start
38 let taken = 0
39 for (const g of graphemes(text.slice(start, end))) {
40 const step = width(g)
41 if (cut > start && taken + step > w) {
42 out.push([start, cut])
43 start = cut
44 size -= taken
45 taken = 0
46 if (size <= w) break
47 }
48 cut += g.length
49 taken += step
50 }
51 }
52 if (start === end) continue
53 const joined = line ? used + width(text.slice(line[1], start)) + size : Infinity
54 if (line && joined <= w) {
55 line = [line[0], end]
56 used = joined
57 } else {
58 if (line) out.push(line)
59 line = [start, end]
60 used = size
61 }
62 }
63 if (line) out.push(line)
64 return out.length ? out : [[0, 0]]
65}
66
67const sliceInline = (nodes: Inline[], from: number, to: number): Inline[] => {
68 const out: Inline[] = []
69 let at = 0
70 for (const n of nodes) {
71 const length = inlineText([n]).length
72 const a = Math.max(from - at, 0)
73 const b = Math.min(to - at, length)
74 at += length
75 if (a >= b) continue
76 if ('children' in n) out.push({ ...n, children: sliceInline(n.children, a, b) })
77 else out.push({ ...n, text: n.text.slice(a, b) })
78 }
79 return out
80}
81
82const hasLink = (nodes: Inline[]): boolean => nodes.some(n => n.kind === 'link' || ('children' in n && hasLink(n.children)))
83
84const headerRuns = (nodes: Inline[]): Inline[] =>
85 nodes.flatMap(n => (n.kind === 'footnote' ? [n] : 'children' in n ? headerRuns(n.children) : [{ kind: 'text' as const, text: n.text }]))
86 .reduce<Inline[]>((out, n) => {
87 const last = out[out.length - 1]
88 return n.kind === 'text' && last?.kind === 'text' ? [...out.slice(0, -1), { kind: 'text', text: last.text + n.text }] : [...out, n]
89 }, [])
90
91const HTTP_LINKS_FROM = [2, 1, 289]
92const EDITOR_LINKS_FROM = [2, 1, 294]
93const CLICKABLE_MARKS_FROM = [2, 1, 295]
94
95const releaseFrom = (from: readonly number[]) => (base?: string): boolean => {
96 const m = /^(\d+)\.(\d+)\.(\d+)/.exec(base ?? '')
97 if (!m) return false
98 const version = [Number(m[1]), Number(m[2]), Number(m[3])]
99 const at = version.findIndex((n, i) => n !== from[i])
100 return at === -1 || version[at]! > from[at]!
101}
102
103export const takesHttpLinks = releaseFrom(HTTP_LINKS_FROM)
104
105export const takesEditorLinks = releaseFrom(EDITOR_LINKS_FROM)
106
107export const takesClickableMarks = releaseFrom(CLICKABLE_MARKS_FROM)
108
109const isHyperlink = (href: string, http: boolean, editor: boolean): boolean =>
110 (http ? /^https?:/i : /^(https:|http:\/\/localhost(:\d+)?([/?#]|$))/i).test(href) || (editor && /^vscode(-insiders)?:\/\/file\//i.test(href))
111
112const plainLink = (text: string, href: string): string => {
113 const target = href.replace(/^mailto:/i, '')
114 return text === target || `http://${text}` === target ? target : `${text} (${target})`
115}
116
117const unlink = (nodes: Inline[], http: boolean, editor: boolean): Inline[] =>
118 nodes.map(node => {
119 const n: Inline = node.kind === 'image' && isWebLink(node.src) ? { kind: 'link', text: node.text, href: node.src } : node
120 return n.kind === 'link' && !isHyperlink(n.href, http, editor) ? { kind: 'text', text: plainLink(n.text, n.href) } : 'children' in n ? { ...n, children: unlink(n.children, http, editor) } : n
121 })
122
123const unlinkBlock = (block: Block, http: boolean, editor: boolean): Block => {
124 const plain = (nodes: Inline[]) => unlink(nodes, http, editor)
125 switch (block.kind) {
126 case 'heading':
127 case 'paragraph':
128 return { ...block, inline: plain(block.inline) }
129 case 'list':
130 return { ...block, items: block.items.map(item => ({ ...item, inline: plain(item.inline) })) }
131 case 'table':
132 return { ...block, header: block.header.map(plain), rows: block.rows.map(r => r.map(plain)) }
133 case 'notes':
134 return { ...block, notes: block.notes.map(note => ({ ...note, inline: plain(note.inline) })) }
135 default:
136 return block
137 }
138}
139
140const unlinked = new WeakMap<Block, Block[]>()
141
142const drawable = (block: Block, http: boolean, editor: boolean): Block => {
143 const cached = unlinked.get(block) ?? []
144 unlinked.set(block, cached)
145 return (cached[+http + 2 * +editor] ??= unlinkBlock(block, http, editor))
146}
147
148const mostLines = (text: string, w: number): number => (text.match(/\S+/g)?.length ?? 0) + Math.ceil(width(text) / w)
149
150const textRows = (text: string, w: number): number => (width(text) <= w ? 1 : mostLines(text, Math.max(1, w)))
151
152const heightOf = (blocks: Block[], w: number): number => blocks.reduce((sum, block, b) => sum + (b ? 1 : 0) + blockHeight(block, w), 0)
153
154const blockHeight = (block: Block, w: number): number => {
155 switch (block.kind) {
156 case 'heading':
157 return 2 * textRows(displayText(block.inline), w - 4) + 2
158 case 'paragraph':
159 return textRows(displayText(block.inline), w)
160 case 'list':
161 return block.items.reduce((sum, item) => {
162 const pad = item.depth * 2 + item.marker.length + 4
163 return sum + textRows(displayText(item.inline), w - pad) + (item.blocks ? heightOf(item.blocks, w - pad) + 1 : 0)
164 }, textRows(`${'━'.repeat(20)} ${block.items.length}/${block.items.length} done`, w))
165 case 'code':
166 return block.lines.reduce((sum, line) => sum + textRows(line, w - 8), 3)
167 case 'table': {
168 const { natural, words } = tableSize(block)
169 const widths = columnWidths(natural, w - 4, 3, words)
170 const tallest = (cells: Inline[][]) => Math.max(1, ...widths.map((cw, c) => textRows(displayText(cells[c] ?? []), cw)))
171 return block.rows.reduce((sum, row) => sum + 1 + tallest(row), 4 + tallest(block.header))
172 }
173 case 'quote':
174 return Math.max(1, heightOf(block.blocks, w - 2))
175 case 'alert':
176 return 3 + heightOf(block.blocks, w - 4)
177 case 'rule':
178 return 1
179 case 'notes':
180 return block.notes.reduce((sum, note) => sum + textRows(`${note.mark} ${displayText(note.inline)}`, w), 1)
181 }
182}
183
184const wrapInline = (nodes: Inline[], w: number): Inline[][] => {
185 const text = inlineText(nodes)
186 return width(text) <= w ? [nodes] : wrapRanges(text, w).map(([a, b]) => sliceInline(nodes, a, b))
187}
188
189export const mirrored = (style: Style) => style.shape === 'mirror'
190
191export const heldLtr = (style: Style) => (style.shape === 'engine' ? { dir: 'ltr' as const } : {})
192
193const flows = new Map<string, Flow | null>()
194const flowOf = (style: Style, nodes: Inline[], columns: number, mark = !(style.inside && mirrored(style))) =>
195 style.reorder && containsRtl(nodes) ? remember(flows, `${style.shape}\0${mark}\0${columns}\0${JSON.stringify(nodes)}`, () => flow(nodes, columns, width, style.shape, mark), 2000) : null
196
197const noteHover = (style: Style, note: number | undefined) =>
198 note === undefined || style.noteKey === undefined ? {} : { hover: { scope: `fn:${style.noteKey}:${note}`, inverse: true } }
199
200const renderInline = (el: ElementTable, style: Style, nodes: Inline[], keyBase: string): RenderElement[] => {
201 const { Text } = el
202 const t = style.theme
203 return nodes.map((n, i) => {
204 const key = `${keyBase}.${i}`
205 switch (n.kind) {
206 case 'text':
207 return <Text key={key}>{n.text}</Text>
208 case 'strong':
209 return <Text key={key} bold color={t.strong}>{renderInline(el, style, n.children, key)}</Text>
210 case 'emphasis':
211 return <Text key={key} italic color={t.emphasis}>{renderInline(el, style, n.children, key)}</Text>
212 case 'strike':
213 return <Text key={key} strikethrough dimColor>{renderInline(el, style, n.children, key)}</Text>
214 case 'code':
215 return <Text key={key} color={t.inlineCode}>{n.text}</Text>
216 case 'math':
217 return <Text key={key} italic color={t.emphasis}>{n.text}</Text>
218 case 'link':
219 return n.text === n.href ? <Text key={key} color={t.link}><el.Link href={n.href} /></Text> : <el.Link key={key} href={n.href}><Text color={t.link}>{n.text}</Text></el.Link>
220 case 'image':
221 return <Text key={key} color={t.link}>{n.text}</Text>
222 case 'number':
223 return <Text key={key} color={t.number}>{n.text}</Text>
224 case 'path':
225 return <Text key={key} color={t.path}>{n.text}</Text>
226 case 'dim':
227 return n === ROW_MARK ? <Text key={key} color={t.rule}>{n.text}</Text> : <Text key={key} dimColor>{n.text}</Text>
228 case 'footnote':
229 return <Text key={key} color={t.accent} {...noteHover(style, n.note)}>{n.text}</Text>
230 }
231 })
232}
233
234const renderFlow = (el: ElementTable, style: Style, lines: Inline[][], key: string, props: TextLook = {}) => {
235 const { Text } = el
236 return lines.map((line, i) => <Text key={`${key}.${i}`} {...props}>{renderInline(el, style, line, `${key}.${i}`)}</Text>)
237}
238
239type Mark = { start: number; end: number; text: string; note: number }
240type TextLook = { italic?: boolean; color?: string; bold?: boolean; dimColor?: boolean; strikethrough?: boolean }
241
242const marksIn = (nodes: Inline[], at = 0): Mark[] => {
243 const out: Mark[] = []
244 for (const n of nodes) {
245 const length = inlineText([n]).length
246 if (n.kind === 'footnote' && n.note !== undefined) out.push({ start: at, end: at + length, text: n.text, note: n.note })
247 else if ('children' in n) out.push(...marksIn(n.children, at))
248 at += length
249 }
250 return out
251}
252
253const piecesOf = (text: string, a: number, b: number, columns: number, marks: Mark[]): (readonly [number, number])[] => {
254 if (width(text.slice(a, b)) <= columns) return [[a, b]]
255 const out: [number, number][] = []
256 let start = a
257 let at = a
258 let taken = 0
259 for (const g of graphemes(text.slice(a, b))) {
260 const step = width(g)
261 if (at > start && taken + step > columns) {
262 const cut = marks.find(m => m.start < at && at < m.end)?.start ?? at
263 if (cut > start) {
264 out.push([start, cut])
265 taken = width(text.slice(cut, at))
266 start = cut
267 }
268 }
269 at += g.length
270 taken += step
271 }
272 out.push([start, b])
273 return out
274}
275
276const pressInline = (el: ElementTable, style: Style, nodes: Inline[], columns: number, key: string, tip: Tip | undefined, look: TextLook = {}, box: { justifyContent?: 'flex-start' | 'flex-end' | 'center' } = {}): RenderElement | null => {
277 const marks = tip ? marksIn(nodes).flatMap(m => {
278 const press = tip(m.note)
279 return press ? [{ ...m, press }] : []
280 }) : []
281 if (!marks.length) return null
282 const { Box, Text, Button } = el
283 const run = (a: number, b: number) => <Text key={`${key}.t${a}`} {...look}>{renderInline(el, style, sliceInline(nodes, a, b), `${key}.t${a}`)}</Text>
284 const text = inlineText(nodes)
285 const words = [...text.matchAll(/\s*\S+\s*/g)].flatMap(m => piecesOf(text, m.index, m.index + m[0].length, Math.max(1, columns), marks))
286 return (
287 <Box key={key} flexDirection="row" flexWrap="wrap" {...box}>
288 {words.map(([a, b]) => {
289 const inside = marks.filter(m => m.start >= a && m.end <= b)
290 if (!inside.length) return run(a, b)
291 const parts: RenderElement[] = []
292 let at = a
293 for (const m of inside) {
294 if (m.start > at) parts.push(run(at, m.start))
295 parts.push(
296 <Button key={`${key}.n${m.start}`} plain {...noteHover(style, m.note)} onPress={m.press}>
297 <Text color={style.theme.accent} bold={look.bold}>{m.text}</Text>
298 </Button>,
299 )
300 at = m.end
301 }
302 if (at < b) parts.push(run(at, b))
303 return <Box key={`${key}.w${a}`} flexDirection="row" flexShrink={0}>{parts}</Box>
304 })}
305 </Box>
306 )
307}
308
309export const PRISM_COLORS: Record<string, keyof Theme> = {
310 comment: 'codeComment', prolog: 'codeComment', doctype: 'codeComment', cdata: 'codeComment',
311 string: 'codeString', char: 'codeString', 'template-string': 'codeString', 'attr-value': 'codeString', url: 'codeString',
312 number: 'number', boolean: 'number', constant: 'number', symbol: 'number', inserted: 'number',
313 keyword: 'codeFlag', important: 'codeFlag', atrule: 'codeFlag', rule: 'codeFlag', deleted: 'codeFlag',
314 function: 'codeCommand', 'class-name': 'codeCommand', builtin: 'codeCommand', key: 'codeCommand', selector: 'codeCommand',
315 property: 'link', tag: 'link', 'attr-name': 'emphasis', variable: 'emphasis', regex: 'path',
316}
317
318type Segment = { text: string; color?: string; italic: boolean }
319
320const flatten = (tokens: PrismToken[], style: Style, color?: string, italic = false): Segment[] =>
321 tokens.flatMap(token => {
322 if (typeof token === 'string') return [{ text: token, color, italic }]
323 const names = [token.type, ...(Array.isArray(token.alias) ? token.alias : token.alias ? [token.alias] : [])]
324 const slot = names.map(n => PRISM_COLORS[n]).find(Boolean)
325 const inner = Array.isArray(token.content) ? token.content : [token.type === 'comment' && typeof token.content === 'string' && style.reorder ? commentVisual(token.content, style.shape) : token.content]
326 return flatten(inner, style, slot ? style.theme[slot] : color, italic || token.type === 'comment')
327 })
328
329export const remember = <T,>(cache: Map<string, T>, key: string, make: () => T, limit = 200): T => {
330 const hit = cache.get(key)
331 if (hit !== undefined) {
332 cache.delete(key)
333 cache.set(key, hit)
334 return hit
335 }
336 const value = make()
337 cache.set(key, value)
338 if (cache.size > limit) cache.delete(cache.keys().next().value!)
339 return value
340}
341
342const highlighted = new WeakMap<Theme, Map<string, Segment[][]>>()
343
344const grammarFor = (lang: string) => {
345 const name = lang.toLowerCase()
346 const grammar = Object.hasOwn(languages, name) ? languages[name] : undefined
347 return grammar !== null && typeof grammar === 'object' ? grammar : undefined
348}
349
350export const highlightBlock = ({ Text }: ElementTable, style: Style, lines: string[], lang: string, key: string): RenderElement[] | null => {
351 const grammar = grammarFor(lang)
352 if (!grammar) return null
353 const code = lines.join('\n')
354 const cache = highlighted.get(style.theme) ?? new Map<string, Segment[][]>()
355 highlighted.set(style.theme, cache)
356 const rows = remember(cache, `${style.reorder ? style.shape : ''}\0${lang}\0${code}`, () => {
357 const out: Segment[][] = [[]]
358 for (const seg of flatten(tokenize(code, grammar), style)) {
359 seg.text.split('\n').forEach((piece, i) => {
360 if (i > 0) out.push([])
361 if (piece) out[out.length - 1]!.push({ ...seg, text: piece })
362 })
363 }
364 return out
365 })
366 return rows.map((row, r) => (
367 <Text key={`${key}.${r}`} color={style.theme.codeText}>
368 {row.length ? row.map((s, i) => <Text key={`${key}.${r}.${i}`} color={s.color} italic={s.italic}>{s.text}</Text>) : ' '}
369 </Text>
370 ))
371}
372
373const isShellLang = (lang: string) => lang === '' || /^(sh|bash|zsh|shell|console|fish|powershell|ps1)$/i.test(lang)
374
375export const codeLine = (el: ElementTable, style: Style, line: string, lang: string, key: string): RenderElement => {
376 const { Text } = el
377 const t = style.theme
378 const isShell = isShellLang(lang)
379 if (/[\u2500-\u257F]/.test(line)) return <Text key={key} color={t.codeText}>{line}</Text>
380 const comment = style.reorder ? commentTail(line, style.shape) : null
381 if (comment) {
382 return (
383 <Text key={key} color={t.codeText}>
384 {comment.head ? codeLine(el, style, comment.head, lang, `${key}.h`) : null}
385 <Text color={t.codeComment}>{comment.marker + comment.tail}</Text>
386 </Text>
387 )
388 }
389 if (!isShell) return <Text key={key} color={t.codeText}>{line || ' '}</Text>
390 if (/^\s*#/.test(line)) return <Text key={key} color={t.codeComment}>{line}</Text>
391 const parts = line.split(/("[^"]*"|'[^']*'|\s+)/).filter(p => p !== '')
392 let seenCommand = false
393 return (
394 <Text key={key} color={t.codeText}>
395 {parts.map((p, i) => {
396 if (/^\s+$/.test(p)) return <Text key={`${key}.${i}`}>{p}</Text>
397 if (/^["']/.test(p)) return <Text key={`${key}.${i}`} color={t.codeString}>{p}</Text>
398 if (/^--?[\w-]/.test(p)) return <Text key={`${key}.${i}`} color={t.codeFlag}>{p}</Text>
399 if (!seenCommand && !/^[$>|&;]+$/.test(p)) {
400 seenCommand = true
401 return <Text key={`${key}.${i}`} color={t.codeCommand}>{p}</Text>
402 }
403 if (/^(\||&&|;|\|\|)$/.test(p)) seenCommand = false
404 return <Text key={`${key}.${i}`}>{p}</Text>
405 })}
406 </Text>
407 )
408}
409
410const longestWord = (text: string): number => Math.max(0, ...text.split(/\s+/).map(width))
411
412export const columnWidths = (natural: number[], available: number, gap: number, words: number[] = []): number[] => {
413 const room = Math.max(natural.length, available - gap * (natural.length - 1))
414 const total = natural.reduce((a, b) => a + b, 0)
415 if (total <= room) return natural
416 const minimum = natural.map((w, c) => Math.min(w, Math.max(1, words[c] ?? 1)))
417 const widths = natural.map(() => 0)
418 let open = natural.map((_, c) => c)
419 let left = room
420 for (let fixed = [-1]; fixed.length; ) {
421 const share = Math.floor(left / open.length)
422 const claim = (c: number) => (natural[c]! <= share ? natural[c]! : minimum[c]!)
423 const minimumsFit = open.reduce((a, c) => a + minimum[c]!, 0) <= left
424 const needed = open.reduce((a, c) => a + claim(c), 0)
425 fixed = open.filter(c => natural[c]! <= share)
426 if (minimumsFit && (!fixed.length || needed > left)) fixed = open.filter(c => minimum[c]! > share)
427 fixed.forEach(c => (widths[c] = claim(c), left -= widths[c]!))
428 open = open.filter(c => !fixed.includes(c))
429 }
430 open.forEach((c, i) => (widths[c] = Math.max(1, Math.floor(left / open.length) + (i < left % open.length ? 1 : 0))))
431 while (widths.reduce((a, b) => a + b, 0) > room) {
432 const widest = widths.indexOf(Math.max(...widths))
433 if (widths[widest]! <= 1) break
434 widths[widest]!--
435 }
436 return widths
437}
438
439const isRtlTable = (style: Style, block: Extract<Block, { kind: 'table' }>): boolean => {
440 const cells = [...block.header, ...block.rows.flat()].filter(cell => displayText(cell).trim() !== '')
441 return cells.filter(cell => flowOf(style, cell, Infinity)?.base === 'R').length * 2 > cells.length
442}
443
444const ART_WIDTH = 100
445
446const tableSize = (block: Extract<Block, { kind: 'table' }>) => {
447 const cells = [block.header, ...block.rows].map(r => block.header.map((_, c) => displayText(r[c] ?? [])))
448 const column = (measure: (s: string) => number) => block.header.map((_, c) => Math.max(...cells.map(r => measure(r[c]!))))
449 return { natural: column(width), words: column(longestWord) }
450}
451
452export const tableArt = (block: Extract<Block, { kind: 'table' }>): string => {
453 const cells = [block.header, ...block.rows].map(r => block.header.map((_, c) => displayText(r[c] ?? [])))
454 const { natural, words } = tableSize(block)
455 const widths = columnWidths(natural, ART_WIDTH - 4, 3, words)
456 const pad = (text: string, c: number, align: 'left' | 'right' | 'center') => {
457 const room = Math.max(0, widths[c]! - width(text))
458 const left = align === 'right' ? room : align === 'center' ? Math.floor(room / 2) : 0
459 return ' '.repeat(left) + text + ' '.repeat(room - left)
460 }
461 const line = (l: string, m: string, r: string) => l + widths.map(w => '─'.repeat(w + 2)).join(m) + r
462 const row = (r: string[], header: boolean) => {
463 const lines = r.map((text, c) => wrapRanges(text, widths[c]!).map(([a, b]) => text.slice(a, b)))
464 return Array.from({ length: Math.max(...lines.map(l => l.length)) }, (_, i) =>
465 `│ ${lines.map((l, c) => pad(l[i] ?? '', c, header ? 'center' : block.align[c] ?? 'left')).join(' │ ')} │`)
466 }
467 const art = [line('┌', '┬', '┐'), ...row(cells[0]!, true), ...cells.slice(1).flatMap(r => [line('├', '┼', '┤'), ...row(r, false)]), line('└', '┴', '┘')]
468 return ['```', ...art, '```'].join('\n')
469}
470
471const clip = ({ Box }: ElementTable, w: number, drawn: RenderElement, key?: string) => (
472 <Box key={key} flexDirection="column" minWidth={w}>
473 <Box height={0} flexGrow={1} overflow="hidden">
474 {drawn}
475 </Box>
476 </Box>
477)
478
479const renderTable = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'table' }>, columns: number, key: string, rtl: boolean, tip?: Tip) => {
480 const { Box, Text } = el
481 const t = style.theme
482 const box = style.tableStyle === 'box'
483 const gap = box ? 0 : style.tableStyle === 'grid' ? 3 : 2
484 const { natural, words } = tableSize(block)
485 const widths = box ? columnWidths(natural, columns - 4, 3, words) : columnWidths(natural, columns, gap, words)
486 const order = natural.map((_, c) => c)
487 const mirror = rtl && mirrored(style)
488 const flip = rtl && !mirror
489 if (flip) order.reverse()
490 const lead = (k: string) => (mirror ? <Text key={`${k}.m`} color={t.rule}>{ROW_MARK_TEXT}</Text> : null)
491 const ruleChar = style.tableStyle === 'grid' ? '━' : '─'
492 const justify = (c: number) =>
493 block.align[c] === 'right' ? 'flex-end' : block.align[c] === 'center' ? 'center' : 'flex-start'
494
495 const rule = (k: string, heavy: boolean) => (
496 <Box key={k} flexDirection="row" columnGap={gap}>
497 {lead(k)}
498 {order.map(c => (
499 <Text key={`${k}.${c}`} color={t.tableRule} dimColor={!heavy && !t.tableRule}>
500 {(heavy ? ruleChar : '─').repeat(widths[c]!)}
501 </Text>
502 ))}
503 </Box>
504 )
505
506 const bar = (k: string, text: string, lines = 1) => (
507 <Text key={k} color={t.tableRule} dimColor={!t.tableRule}>{Array.from({ length: lines }, () => text).join('\n')}</Text>
508 )
509 const wall = (text: string) => (mirror ? text.replace('│', WALL) : text)
510 const clipped = (lines: number) => (k: string, text: string) => clip(el, width(text), bar(`${k}.b`, text, lines), k)
511 const edge = (k: string, [left, fill, mid, right]: string) =>
512 bar(k, (mirror ? WALL : left) + order.map(c => fill!.repeat(widths[c]! + 2)).join(mid) + (mirror ? WALL : right))
513
514 const terminalRow = (cells: Inline[][], k: string, isHeader: boolean) => {
515 const parts: Inline[] = box ? [{ kind: 'dim', text: '│ ' }] : []
516 order.forEach((c, i) => {
517 if (i > 0) parts.push(box ? { kind: 'dim', text: ' │ ' } : { kind: 'text', text: ' '.repeat(gap) })
518 const cell = flowOf({ ...style, shape: 'visual' }, cells[c] ?? [], Infinity)
519 const content = cell ? cell.lines[0]! : (cells[c] ?? [])
520 const pad = Math.max(0, widths[c]! - width(inlineText(content)))
521 const side = cell?.base === 'R' && block.align[c] !== 'center' ? 'flex-end' : justify(c)
522 const before = side === 'flex-end' ? pad : side === 'center' ? Math.floor(pad / 2) : 0
523 parts.push({ kind: 'text', text: ' '.repeat(before) }, ...(isHeader ? [{ kind: 'strong' as const, children: content }] : content), { kind: 'text', text: ' '.repeat(pad - before) })
524 })
525 if (box) parts.push({ kind: 'dim', text: ' │' })
526 return <Text key={k}>{renderInline(el, style, terminalLine(parts), k)}</Text>
527 }
528
529 const row = (cells: Inline[][], k: string, isHeader: boolean, border: (k: string, text: string) => RenderElement = bar) => rtl && style.shape === 'inverse' ? terminalRow(cells, k, isHeader) : (
530 <Box key={k} flexDirection="row" columnGap={gap} alignItems="stretch">
531 {!box && lead(k)}
532 {box && border(`${k}.l`, wall('│ '))}
533 {order.map((c, i) => {
534 const w = widths[c]!
535 const cell = flowOf(style, cells[c] ?? [], Infinity, !mirror)
536 const content = cell ? cell.lines[0]! : (cells[c] ?? [])
537 const side = mirror ? (block.align[c] === 'center' ? 'center' : 'flex-start') : cell?.base === 'R' && block.align[c] !== 'center' ? 'flex-end' : justify(c)
538 const runs = isHeader ? headerRuns(content) : content
539 const cellBox = (
540 <Box key={`${k}.${c}`} width={w} flexShrink={0} justifyContent={side}>
541 {pressInline(el, style, runs, w, `${k}.${c}.p`, tip, isHeader ? { bold: true, color: t.tableHeader } : {}, { justifyContent: side })
542 ?? (isHeader
543 ? <Text bold color={t.tableHeader}>{runs.some(n => n.kind === 'footnote') ? renderInline(el, style, runs, `${k}.${c}`) : inlineText(runs)}</Text>
544 : mirror && inlineText(content).trim() === '' ? <Text>{' '.repeat(w)}</Text>
545 : <Text>{renderInline(el, style, content, `${k}.${c}`)}</Text>)}
546 </Box>
547 )
548 return box && i > 0 ? [border(`${k}.${c}s`, wall(' │ ')), cellBox] : cellBox
549 })}
550 {box && border(`${k}.r`, wall(' │'))}
551 </Box>
552 )
553
554 const wrappedRow = (cells: Inline[][], k: string, isHeader: boolean) => {
555 if (!isHeader && cells.some(hasLink)) return [row(cells, k, isHeader, clipped(Math.max(1, ...widths.map((w, c) => mostLines(displayText(cells[c] ?? []), w)))))]
556 const wrapped = widths.map((w, c) => {
557 const content = cells[c] ?? []
558 if (natural[c]! <= w) return [content]
559 return wrapInline(isHeader ? headerRuns(content) : content, w)
560 })
561 return Array.from({ length: Math.max(...wrapped.map(l => l.length)) }, (_, i) =>
562 row(wrapped.map(l => l[i] ?? []), `${k}.${i}`, isHeader))
563 }
564
565 if (box) {
566 const lines: RenderElement[] = [edge(`${key}.t`, '┌─┬┐'), ...wrappedRow(block.header, `${key}.h`, true), edge(`${key}.hr`, '╞═╪╡')]
567 block.rows.forEach((r, i) => {
568 if (i > 0) lines.push(edge(`${key}.r${i}r`, '├─┼┤'))
569 lines.push(...wrappedRow(r, `${key}.r${i}`, false))
570 })
571 lines.push(edge(`${key}.b`, '└─┴┘'))
572 return <Box key={key} flexDirection="column" {...(flip ? { alignSelf: 'flex-end' as const } : {})}>{lines}</Box>
573 }
574
575 const body: RenderElement[] = [row(block.header, `${key}.h`, true), rule(`${key}.hr`, true)]
576 block.rows.forEach((r, i) => {
577 body.push(row(r, `${key}.r${i}`, false))
578 if (style.tableStyle !== 'minimal' && i < block.rows.length - 1) body.push(rule(`${key}.r${i}r`, false))
579 })
580 return <Box key={key} flexDirection="column" {...(flip ? { alignSelf: 'flex-end' as const } : {})}>{body}</Box>
581}
582
583const renderHeading = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'heading' }>, key: string) => {
584 const rtl = flowOf(style, block.inline, Infinity)
585 if (!rtl) return drawHeading(el, style, block, block.inline, key)
586 if (rtl.base === 'R' && mirrored(style) && style.headingStyle === 'banner' && block.level <= 2) return mirroredBanner(el, style, block.level, rtl.lines[0]!, key)
587 const heading = drawHeading(el, style, block, rtl.lines[0]!, key)
588 return rtl.base === 'R' && !mirrored(style) ? <el.Box key={key} alignSelf="flex-end">{heading}</el.Box> : heading
589}
590
591const mirroredBanner = (el: ElementTable, style: Style, level: number, inline: Inline[], key: string) => {
592 const { Box, Text } = el
593 const color = style.theme.heading
594 const text = <Text key={`${key}.h`} bold underline={level === 2} color={color}>{level === 1 ? `${WALL} ` : ''}{renderInline(el, style, inline, key)}{level === 1 ? ` ${WALL}` : ''}</Text>
595 if (level === 2) return text
596 const edge = (k: string) => <Text key={k} color={color}>{WALL + '━'.repeat(width(inlineText(inline)) + 2) + WALL}</Text>
597 return <Box key={key} flexDirection="column">{edge(`${key}.t`)}{text}{edge(`${key}.b`)}</Box>
598}
599
600const drawHeading = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'heading' }>, inline: Inline[], key: string) => {
601 const { Box, Text } = el
602 const t = style.theme
603 const color = block.level <= 2 ? t.heading : t.accent ?? t.heading
604 const label = inlineText(inline)
605 switch (style.headingStyle) {
606 case 'uppercase':
607 return <Text key={key} bold color={color}>{block.level === 1 ? label.toUpperCase() : label}</Text>
608 case 'underline':
609 return <Text key={key} bold underline={block.level <= 2} color={color}>{label}</Text>
610 case 'banner':
611 if (block.level === 1) return <Box key={key} alignSelf="flex-start" borderStyle="bold" borderColor={color} paddingX={1}><Text bold color={color}>{renderInline(el, style, inline, key)}</Text></Box>
612 return block.level === 2
613 ? <Box key={key} flexDirection="column" alignSelf="flex-start"><Text bold color={color}>{renderInline(el, style, inline, key)}</Text><Text color={color}>{'━'.repeat(width(label))}</Text></Box>
614 : <Text key={key} bold color={block.level === 3 ? color : t.strong}>{renderInline(el, style, inline, key)}</Text>
615 default:
616 return <Text key={key} bold color={color}>{renderInline(el, style, inline, key)}</Text>
617 }
618}
619
620const ALERT_COLOR = { note: 'blue', tip: 'green', important: 'magenta', warning: 'yellow', caution: 'red' } as const
621
622const prose = (style: Style) => (style.inside === 'quote' ? { italic: true, color: style.theme.quote } : {})
623
624const renderParagraph = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'paragraph' }>, columns: number, key: string, tip?: Tip) => {
625 const { Box, Text } = el
626 const rtl = flowOf(style, block.inline, columns)
627 if (rtl?.base === 'R') return <Box key={key} flexDirection="column" {...(mirrored(style) ? {} : { alignItems: 'flex-end' as const })}>{renderFlow(el, style, rtl.lines, key, prose(style))}</Box>
628 const inline = rtl ? rtl.lines[0]! : block.inline
629 return pressInline(el, style, inline, columns, key, tip, { ...(style.narration ? { bold: true } : {}), ...prose(style) }) ?? <Text key={key} bold={style.narration} {...prose(style)}>{renderInline(el, style, inline, key)}</Text>
630}
631
632const leadInline = (blocks: Block[]): Inline[] => {
633 const first = blocks[0]
634 if (first?.kind === 'paragraph' || first?.kind === 'heading') return first.inline
635 if (first?.kind === 'list') return first.items[0]?.inline ?? []
636 return first?.kind === 'quote' || first?.kind === 'alert' ? leadInline(first.blocks) : []
637}
638
639const renderNested = (el: ElementTable, style: Style, blocks: Block[], columns: number, key: string, copy?: CopyButton, fold?: Fold, tip?: Tip) =>
640 renderBlocks(el, style, blocks, columns, drawMath(el, style, blocks), copy && ((text, id, label, html) => copy(text, `${key}.${id}`, label, html)), fold && ((id, hidden, k) => fold(id, hidden, `${key}.${k}`)), tip)
641
642const quoteBar = (el: ElementTable, text: string, color: string | undefined, lines: number) =>
643 lines <= 1 ? <el.Text color={color}>{text}</el.Text> : clip(el, width(text), <el.Text color={color}>{Array.from({ length: lines }, () => text).join('\n')}</el.Text>)
644
645const renderQuote = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'quote' }>, columns: number, key: string, copy?: CopyButton, fold?: Fold, tip?: Tip) => {
646 const { Box } = el
647 const t = style.theme
648 const inner = columns - 2
649 const lines = heightOf(block.blocks, inner)
650 const body = <Box flexDirection="column" rowGap={1}>{renderNested(el, { ...style, inside: 'quote' }, block.blocks, inner, key, copy, fold, tip)}</Box>
651 if (!mirrored(style) && flowOf(style, leadInline(block.blocks), inner)?.base === 'R') {
652 return (
653 <Box key={key} flexDirection="row" justifyContent="flex-end" alignItems="stretch">
654 {body}
655 {quoteBar(el, ' │', t.accent, lines)}
656 </Box>
657 )
658 }
659 return (
660 <Box key={key} flexDirection="row" alignItems="stretch">
661 {quoteBar(el, mirrored(style) ? ROW_MARK_TEXT : '│ ', t.accent, lines)}
662 {body}
663 </Box>
664 )
665}
666
667const renderAlert = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'alert' }>, columns: number, key: string, copy?: CopyButton, fold?: Fold, tip?: Tip) => {
668 const { Box, Text } = el
669 const color = ALERT_COLOR[block.level]
670 const inner = columns - 4
671 const hebrew = flowOf(style, leadInline(block.blocks), inner)?.base === 'R'
672 const label = block.level[0]!.toUpperCase() + block.level.slice(1)
673 const body = block.blocks.length ? <Box flexDirection="column" rowGap={1}>{renderNested(el, { ...style, inside: 'alert' }, block.blocks, inner, key, copy, fold, tip)}</Box> : null
674 if (hebrew && mirrored(style)) {
675 const lines = 1 + (block.blocks.length ? 1 + heightOf(block.blocks, inner) : 0)
676 const edge = (k: string) => <Text key={k} color={color}>{WALL + '─'.repeat(inner + 2) + WALL}</Text>
677 return (
678 <Box key={key} flexDirection="column" alignSelf="flex-start">
679 {edge(`${key}.t`)}
680 <Box flexDirection="row" alignItems="stretch">
681 {quoteBar(el, ROW_MARK_TEXT, color, lines)}
682 <Box flexDirection="column" width={inner}>
683 <Text bold color={color}>{label}</Text>
684 {body}
685 </Box>
686 {quoteBar(el, ` ${WALL}`, color, lines)}
687 </Box>
688 {edge(`${key}.b`)}
689 </Box>
690 )
691 }
692 return (
693 <Box key={key} flexDirection="column" alignSelf={hebrew ? 'flex-end' : 'flex-start'} {...(hebrew ? { alignItems: 'flex-end' as const } : {})} borderStyle="round" borderColor={color} paddingX={1}>
694 <Text bold color={color}>{label}</Text>
695 {body}
696 </Box>
697 )
698}
699
700const TASK_GLYPHS = { checks: ['[ ]', '[✓]'], ticks: ['○', '✓'], box: ['□', '✓'], progress: ['○', '✓'] } as const
701
702const renderList = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'list' }>, columns: number, key: string, copy?: CopyButton, fold?: Fold, tip?: Tip) => {
703 const { Box, Text } = el
704 const t = style.theme
705 const tasks = block.items.filter(item => item.task !== undefined)
706 const done = tasks.filter(item => item.task).length
707 const filled = tasks.length ? Math.round((done / tasks.length) * 20) : 0
708 const strike = style.taskStyle === 'checks' || style.taskStyle === 'box'
709 const tick = style.taskStyle === 'ticks' || style.taskStyle === 'progress'
710 return (
711 <Box key={key} flexDirection="column">
712 {style.taskStyle === 'progress' && tasks.length > 0 && (
713 <Text>
714 <Text color={t.accent}>{'━'.repeat(filled)}</Text>
715 <Text color={t.bullet} dimColor>{'─'.repeat(20 - filled)}</Text>
716 <Text color={t.bullet}>{` ${done}/${tasks.length} done`}</Text>
717 </Text>
718 )}
719 {block.items.map((item, i) => {
720 const k = `${key}.${i}`
721 const glyph = item.task !== undefined ? TASK_GLYPHS[style.taskStyle][item.task ? 1 : 0] : /\d/.test(item.marker) ? item.marker : item.depth ? '◦' : '•'
722 const glyphColor = item.task && tick ? t.accent : t.bullet
723 const pad = item.depth * 2 + glyph.length + 1
724 const rtl = flowOf(style, item.inline, columns - pad)
725 const look = { ...(item.task === true ? { dimColor: true, strikethrough: strike } : {}), ...prose(style) }
726 const row = rtl?.base === 'R' && mirrored(style) ? (
727 <Box key={k} flexDirection="row">
728 <Text color={glyphColor}>{`${' '.repeat(item.depth * 2)}${glyph} `}</Text>
729 <Box flexDirection="column">{renderFlow(el, style, rtl.lines, k, look)}</Box>
730 </Box>
731 ) : rtl?.base === 'R' ? (
732 <Box key={k} flexDirection="row" justifyContent="flex-end" paddingRight={item.depth * 2}>
733 <Box flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, k, look)}</Box>
734 <Text color={glyphColor}>{` ${listMarker(glyph, style.shape)}`}</Text>
735 </Box>
736 ) : (
737 <Box key={k} flexDirection="row" paddingLeft={item.depth * 2}>
738 <Text color={glyphColor}>{`${glyph} `}</Text>
739 {pressInline(el, style, rtl ? rtl.lines[0]! : item.inline, columns - pad, `${k}.i`, tip, look) ?? <Text dimColor={item.task === true} strikethrough={item.task === true && strike} {...prose(style)}>{renderInline(el, style, rtl ? rtl.lines[0]! : item.inline, k)}</Text>}
740 </Box>
741 )
742 if (!item.blocks) return row
743 return (
744 <Box key={k} flexDirection="column">
745 {row}
746 <Box flexDirection="column" paddingLeft={pad}>{renderNested(el, style, item.blocks, columns - pad, k, copy, fold, tip)}</Box>
747 </Box>
748 )
749 })}
750 </Box>
751 )
752}
753
754export type CopyButton = (text: string | (() => string), key: string, label?: string, html?: () => string) => RenderElement | null
755export type Drawn = Map<number, { element?: RenderElement; art?: string; note?: string }>
756export type Fold = (id: string, hidden: number, key: string) => { folded: boolean; element: RenderElement } | null
757export type Tip = (note: number) => (() => void) | undefined
758
759export const instanceKey = (id: string): string => {
760 let h = 5381
761 for (let i = 0; i < id.length; i++) h = (h * 33 + id.charCodeAt(i)) | 0
762 return (h >>> 0).toString(36)
763}
764
765export const noteTip = (blocks: Block[], toast: (text: string) => void): Tip | undefined => {
766 const notes = blocks.find(block => block.kind === 'notes')
767 if (notes?.kind !== 'notes') return undefined
768 const text = new Map(notes.notes.map(note => [note.note, `${note.mark} ${inlineText(note.inline)}`]))
769 return note => {
770 const line = text.get(note)
771 return line === undefined ? undefined : () => toast(line)
772 }
773}
774
775const NUMBER_AT = 10
776const NUMBER_MAX = 400
777const FOLD_AT = 30
778const FOLD_SHOW = 20
779
780const codeId = (lines: string[]): string => {
781 let h = 5381
782 for (const line of lines) {
783 for (let i = 0; i < line.length; i++) h = (h * 33 + line.charCodeAt(i)) | 0
784 h = (h * 33 + 10) | 0
785 }
786 return `${lines.length}:${(h >>> 0).toString(36)}`
787}
788
789const copySource = (style: Style, block: Block): string | undefined =>
790 block.kind === 'code' ? block.lines.join('\n')
791 : block.kind === 'table' ? block.raw
792 : style.inside ? undefined
793 : block.kind === 'list' ? block.raw
794 : block.kind === 'quote' || block.kind === 'alert' ? block.raw.split('\n').map(line => line.replace(/^\s*>\s?/, '')).join('\n')
795 : undefined
796
797export const drawMath = (el: ElementTable, style: Style, blocks: Block[], drawn: Drawn = new Map()): Drawn => {
798 for (const [i, block] of blocks.entries()) {
799 const tex = drawn.has(i) ? null : mathOf(block)
800 const text = tex === null ? null : texText(tex)
801 if (text && !text.includes('\\')) drawn.set(i, { element: <el.Box key={`b${i}`} paddingLeft={2}><el.Text italic color={style.theme.emphasis}>{text}</el.Text></el.Box> })
802 }
803 return drawn
804}
805
806const opensWithCopy = (block: Block): boolean =>
807 block.kind === 'quote' && (block.blocks[0]?.kind === 'code' || block.blocks[0]?.kind === 'table' || (block.blocks[0] !== undefined && opensWithCopy(block.blocks[0])))
808
809const holdsOnlyEmptyCode = (block: Block): boolean =>
810 block.kind === 'quote' && block.blocks.length === 1 && ((block.blocks[0]!.kind === 'code' && block.blocks[0]!.lines.length === 0) || holdsOnlyEmptyCode(block.blocks[0]!))
811
812export const renderBlocks = (el: ElementTable, style: Style, blocks: Block[], columns: number, drawn: Drawn = new Map(), copy?: CopyButton, fold?: Fold, tip?: Tip): RenderElement[] => {
813 const { Box, Text } = el
814 const t = style.theme
815 const hover = style.copyButtonStyle === 'hover'
816 const reserved = (buttons: (RenderElement | null | undefined)[]): number =>
817 buttons.reduce<number>((w, button) => (button?.type === 'Button' ? w + (w ? 1 : 0) + width(`[ ${button.props.label} ]`) : w), 0)
818 const floating = (button: RenderElement | null | undefined, parts: (RenderElement | null | undefined)[], low = false, start = false) => (button ? {
819 minWidth: reserved(parts),
820 element: <Box position="absolute" {...(low ? { bottom: 0 } : { top: 0 })} {...(start ? { left: 0 } : { right: 0 })} display="none" hover={{ display: 'flex' }}>{button}</Box>,
821 } : null)
822 const shown = blocks.map(block => drawable(block, style.httpLinks, style.editorLinks))
823 const rtlTables = shown.map(block => style.reorder && block.kind === 'table' && isRtlTable(style, block))
824 const buttons = blocks.map((block, b) => {
825 const text = hover && holdsOnlyEmptyCode(block) ? undefined : copySource(style, block)
826 const isPlainCode = block.kind === 'code' && !drawn.get(b)?.element
827 const art = drawn.get(b)?.art ?? (block.kind === 'table' ? () => tableArt(block) : undefined)
828 const first = text === undefined || isPlainCode ? null : copy?.(text, `copy${b}`, block.kind === 'table' ? '⧉ md' : art === undefined ? undefined : '⧉ source')
829 const second = first && art !== undefined ? copy?.(art, `art${b}`, '⧉ art') : null
830 const html = first && block.kind === 'table' ? copy?.(() => tableText(block), `html${b}`, '⧉ html', () => tableHtml(block)) : null
831 return { first, second, html }
832 })
833 const beside = (b: number) => (hover || !buttons[b]!.first ? columns : columns - reserved([buttons[b]!.first]) - 2)
834 const rendered = shown.map((block, b) => {
835 const key = `b${b}`
836 switch (block.kind) {
837 case 'heading':
838 return renderHeading(el, style, block, key)
839 case 'paragraph':
840 return drawn.get(b)?.element ?? renderParagraph(el, style, block, columns, key, tip)
841 case 'quote':
842 return renderQuote(el, style, block, beside(b), key, copy, fold, tip)
843 case 'alert':
844 return renderAlert(el, style, block, beside(b), key, copy, fold, tip)
845 case 'rule':
846 return <Text key={key} color={t.rule} dimColor={!t.rule}>{'─'.repeat(Math.max(8, Math.min(columns, 80)))}</Text>
847 case 'code': {
848 const done = drawn.get(b)?.element
849 if (done) return done
850 const note = drawn.get(b)?.note
851 const lines = block.lines
852 const toggle = lines.length > FOLD_AT ? fold?.(codeId(lines), lines.length - FOLD_SHOW, `fold${b}`) : null
853 const shown = toggle?.folded ? FOLD_SHOW : lines.length
854 const numbered = lines.length >= NUMBER_AT && lines.length <= NUMBER_MAX
855 const drawnRows = (isShellLang(block.lang) ? null : highlightBlock(el, style, lines, block.lang, key))?.slice(0, shown) ?? lines.slice(0, shown).map((line, i) => codeLine(el, style, line, block.lang, `${key}.${i}`))
856 const rows = style.nativeCode ? [<el.Code key="code" source={lines.slice(0, shown).join('\n') || ' '} {...(block.lang ? { language: block.lang } : {})} {...(numbered ? { startLine: 1 } : {})} />]
857 : style.shape === 'engine' ? drawnRows.map((row, i) => <Text key={`${key}.ltr${i}`} {...heldLtr(style)}>{row}</Text>) : drawnRows
858 const gutter = String(lines.length).length
859 const button = copy?.(lines.join('\n'), `copy${b}`) ?? null
860 const float = hover ? floating(button, [button]) : null
861 return (
862 <Box key={key} flexDirection="column" alignSelf="flex-start" minWidth={float?.minWidth}>
863 {note ? <Text color={t.codeComment}>{note}</Text> : null}
864 <Box flexDirection="row" justifyContent="space-between" columnGap={4}>
865 <Text color={t.codeComment}>{`── ${block.lang || 'code'}`}</Text>
866 {float ? float.element : button}
867 </Box>
868 <Box flexDirection="column" paddingLeft={2}>
869 {!numbered || style.nativeCode ? rows : rows.map((row, i) => row.type === 'Text' ? <Box key={`n${i}`} flexDirection="row"><Text color={t.codeComment} dimColor>{`${String(i + 1).padStart(gutter)} `}</Text>{row}</Box> : row)}
870 {toggle?.element ?? null}
871 </Box>
872 </Box>
873 )
874 }
875 case 'list':
876 return renderList(el, style, block, columns, key, copy, fold, tip)
877 case 'table':
878 return renderTable(el, style, block, columns, key, rtlTables[b]!, tip)
879 case 'notes':
880 return (
881 <Box key={key} flexDirection="column">
882 <Text dimColor>{'─'.repeat(Math.min(columns, 12))}</Text>
883 {block.notes.map((note, i) => <Text key={`${key}.${i}`} dimColor {...noteHover(style, note.note)}>{note.mark} {renderInline(el, style, note.inline, `${key}.${i}`)}</Text>)}
884 </Box>
885 )
886 }
887 })
888 const copied = rendered.map((element, b) => {
889 const block = blocks[b]!
890 const { first, second, html } = buttons[b]!
891 const button = second ? (
892 <el.Box key={`copies${b}`} flexDirection="row" columnGap={1}>
893 {first}
894 {second}
895 {html}
896 </el.Box>
897 ) : first
898 if (!button) return element
899 const { Box } = el
900 const hebrew = style.reorder && hasRtl(block.raw)
901 const rtl = hebrew && !mirrored(style)
902 const mirror = hebrew && mirrored(style)
903 if (hover) {
904 const wide = rtl && (block.kind === 'list' || block.kind === 'quote' || block.kind === 'alert' || rtlTables[b])
905 const start = wide && (rtlTables[b] || flowOf(style, leadInline([block]), columns)?.base === 'R')
906 const float = floating(button, second ? [first, second, html] : [first], opensWithCopy(block), start)!
907 return (
908 <Box key={`c${b}`} flexDirection="column" minWidth={float.minWidth} {...(start ? {} : { alignSelf: 'flex-start' as const })}>
909 {element}
910 {float.element}
911 </Box>
912 )
913 }
914 return (block.kind === 'quote' || block.kind === 'alert') && !mirrored(style) ? (
915 <Box key={`c${b}`} flexDirection="row" columnGap={2} {...(rtl ? { justifyContent: 'flex-end' as const } : {})}>
916 {element}
917 {opensWithCopy(block) ? <Box alignSelf="flex-end">{button}</Box> : button}
918 </Box>
919 ) : (
920 <Box key={`c${b}`} flexDirection="column" {...(mirror ? { width: '100%' } : rtl && (block.kind === 'list' || rtlTables[b]) ? {} : { alignSelf: 'flex-start' as const })}>
921 <Box justifyContent="flex-end">{button}</Box>
922 {element}
923 </Box>
924 )
925 })
926 const isFigure = (b: number) => blocks[b]?.kind === 'table' || drawn.get(b)?.art !== undefined
927 const out: RenderElement[] = []
928 for (let b = 0; b < rendered.length; b++) {
929 if (!isFigure(b) || !isFigure(b + 1)) {
930 out.push(copied[b]!)
931 continue
932 }
933 const start = b
934 while (isFigure(b + 1)) b++
935 out.push(
936 <Box key={`row${start}`} flexDirection="row" flexWrap="wrap" columnGap={4} rowGap={1}>
937 {copied.slice(start, b + 1).map((figure, i) => <Box key={`f${start + i}`} flexShrink={0}>{figure}</Box>)}
938 </Box>,
939 )
940 }
941 return out
942}
943
944export type ToolRow = { tool: string; input: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean }
945
946const VERBS: Record<string, string> = {
947 Bash: 'Ran', PowerShell: 'Ran', Read: 'Read', Write: 'Wrote', Edit: 'Edited', MultiEdit: 'Edited', NotebookEdit: 'Edited',
948 Grep: 'Searched', Glob: 'Listed', WebFetch: 'Fetched', WebSearch: 'Searched the web for', Agent: 'Delegated', Task: 'Delegated',
949}
950
951const field = (input: unknown, ...keys: string[]): string | undefined => {
952 if (input === null || typeof input !== 'object') return undefined
953 for (const k of keys) {
954 const v = (input as Record<string, unknown>)[k]
955 if (typeof v === 'string' && v.trim() !== '') return v
956 }
957 return undefined
958}
959
960const TARGET_FIELDS = ['file_path', 'notebook_path', 'path', 'pattern', 'url', 'query', 'description']
961
962const statusColor = (t: Theme, row: ToolRow) =>
963 row.isErrored ? t.codeFlag : row.isInterrupted ? t.codeComment : row.isRunning ? t.accent : t.number
964
965const toolDim = (style: Style) => style.toolStyle !== "classic"
966
967const toolGutter = ({ Box, Text }: ElementTable, style: Style, color: string | undefined, running: boolean) =>
968 style.toolStyle.startsWith("tree")
969 ? <Box width={4} flexShrink={0}><Text color={color}>{' ⎿ '}</Text></Box>
970 : <Box width={2} flexShrink={0}><Text color={color}>{running ? '◌' : '●'}</Text></Box>
971
972const toolLayout = (el: ElementTable, style: Style, columns: number, label: string, color: string | undefined, running: boolean, text: RenderElement) => {
973 const { Box, Text } = el
974 if (style.toolStyle !== "chat") return <Box flexDirection="row">{toolGutter(el, style, color, running)}{text}</Box>
975 const w = Math.min(width(label) + 2, Math.max(20, Math.floor(columns * 0.6)))
976 return (
977 <Box flexDirection="row" justifyContent="flex-end" width="100%">
978 <Box width={w} flexDirection="row">{text}<Text color={color}>{running ? " ◌" : " ●"}</Text></Box>
979 </Box>
980 )
981}
982
983type Places = Pick<Style, 'cwd' | 'home'>
984
985const slashes = (path: string): string => path.replace(/\\/g, '/').replace(/\/+$/, '')
986
987const folded = (path: string): string => (/^[a-z]:(\/|$)/i.test(path) ? path.toLowerCase() : path)
988
989export const shortPath = (style: Places, path: string): string => {
990 const target = folded(slashes(path))
991 for (const [root, home] of [[style.cwd, false], [style.home, true]] as const) {
992 const base = folded(slashes(root))
993 if (base !== '' && target.startsWith(`${base}/`)) return home ? `~${path.slice(base.length)}` : path.slice(base.length + 1)
994 }
995 return path
996}
997
998const compactPath = (path: string): string => {
999 const parts = path.split('/')
1000 if (!path.startsWith('/') || parts.length <= 6) return path
1001 return [...parts.slice(0, 3), '…', ...parts.slice(-2)].join('/')
1002}
1003
1004const sameDir = (a: string, b: string): boolean => a !== '' && folded(slashes(a)) === folded(slashes(b))
1005
1006export const shortTarget = (places: Places, path: string): string => (sameDir(places.cwd, path) ? '.' : sameDir(places.home, path) ? '~' : compactPath(shortPath(places, path)))
1007
1008
1009const PATH_FIELDS = ['file_path', 'notebook_path', 'path']
1010
1011const targetOf = (style: Style, input: unknown, fields: string[]): { target?: string; isPathField: boolean } => {
1012 const target = field(input, ...fields)
1013 const isPathField = target !== undefined && PATH_FIELDS.some(k => field(input, k) === target)
1014 return { target: isPathField ? shortPath(style, target!) : target, isPathField }
1015}
1016
1017const RESULT_LINES = 3
1018
1019export const renderShellResult = (el: ElementTable, style: Style, output: unknown): RenderElement => {
1020 const { Box, Text } = el
1021 const out = output !== null && typeof output === 'object' ? (output as Record<string, unknown>) : {}
1022 const shown = [...lines(out.stdout), ...lines(out.stderr)]
1023 const head = shown.slice(0, RESULT_LINES).map(l => shortenPaths(l, style))
1024 return (
1025 <Box flexDirection="row">
1026 <Text dimColor>{' ⎿ '}</Text>
1027 <Box flexDirection="column">
1028 {head.length === 0 ? <Text dimColor>(No output)</Text> : head.map((l, i) => <Text key={`l${i}`} wrap="truncate-end">{l === '' ? ' ' : l}</Text>)}
1029 {shown.length > head.length ? <Text dimColor>{`… +${shown.length - head.length} lines`}</Text> : null}
1030 </Box>
1031 </Box>
1032 )
1033}
1034
1035const DIFF_LINES = 20
1036const DIFF_MAX = 400
1037
1038type DiffLine = { mark: '+' | '-' | ' '; num: number; text: string }
1039
1040const HEX = /^#[0-9a-f]{6}$/i
1041
1042const channels = (hex: string): number[] => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
1043
1044export const tint = (base: string | undefined, color: string | undefined, share: number): string | undefined => {
1045 if (!base || !color || !HEX.test(base) || !HEX.test(color)) return undefined
1046 const [b, c] = [channels(base), channels(color)]
1047 return `#${b.map((v, i) => Math.round(v * (1 - share) + c[i]! * share).toString(16).padStart(2, '0')).join('')}`
1048}
1049
1050const luminance = (hex: string): number => {
1051 const [r, g, b] = channels(hex).map(v => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4))
1052 return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
1053}
1054
1055export const contrast = (a: string, b: string): number => {
1056 const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x)
1057 return (hi! + 0.05) / (lo! + 0.05)
1058}
1059
1060const TEXT_CONTRAST = 4.5
1061
1062export const fullHue = (hex: string): string => {
1063 const [r, g, b] = channels(hex).map(v => v / 255) as [number, number, number]
1064 const max = Math.max(r, g, b)
1065 const spread = max - Math.min(r, g, b)
1066 const lightness = (max + max - spread) / 2
1067 if (spread === 0 || spread / (1 - Math.abs(2 * lightness - 1)) < 0.1) return hex
1068 const sector = max === r ? ((g - b) / spread + 6) % 6 : max === g ? (b - r) / spread + 2 : (r - g) / spread + 4
1069 const x = 1 - Math.abs((sector % 2) - 1)
1070 const pure = [[1, x, 0], [x, 1, 0], [0, 1, x], [0, x, 1], [x, 0, 1], [1, 0, x]][Math.floor(sector)]!
1071 return `#${pure.map(v => Math.round(v! * 255).toString(16).padStart(2, '0')).join('')}`
1072}
1073
1074export const diffTint = (text: string | undefined, color: string | undefined): string | undefined => {
1075 if (!text || !color || !HEX.test(text) || !HEX.test(color)) return undefined
1076 const dark = contrast(text, '#000000') >= contrast(text, '#ffffff')
1077 const base = dark ? '#000000' : '#ffffff'
1078 const hue = fullHue(color)
1079 let share = dark ? 0.3 : 0.2
1080 while (share > 0.05 && contrast(text, tint(base, hue, share)!) < TEXT_CONTRAST) share -= 0.01
1081 return tint(base, hue, share)
1082}
1083
1084export const diffLines = (output: unknown): DiffLine[] | undefined => {
1085 if (output === null || typeof output !== 'object') return undefined
1086 const out = output as { type?: unknown; content?: unknown; structuredPatch?: unknown }
1087 if (out.type === 'create' && typeof out.content === 'string') return lines(out.content).map((text, i) => ({ mark: '+', num: i + 1, text }))
1088 if (!Array.isArray(out.structuredPatch) || out.structuredPatch.length === 0) return undefined
1089 const rows: DiffLine[] = []
1090 for (const hunk of out.structuredPatch as { oldStart?: unknown; newStart?: unknown; lines?: unknown }[]) {
1091 if (typeof hunk?.oldStart !== 'number' || typeof hunk.newStart !== 'number' || !Array.isArray(hunk.lines)) return undefined
1092 let [o, n] = [hunk.oldStart, hunk.newStart]
1093 for (const l of hunk.lines) {
1094 if (typeof l !== 'string' || l.startsWith('\\')) continue
1095 const mark = l[0] === '+' || l[0] === '-' ? l[0] : ' '
1096 let num: number
1097 if (mark === '-') num = o++
1098 else {
1099 if (mark === ' ') o++
1100 num = n++
1101 }
1102 rows.push({ mark, num, text: l.slice(1) })
1103 }
1104 }
1105 return rows
1106}
1107
1108const diffBox = (el: ElementTable, style: Style, rows: DiffLine[], fold: Fold, id: string, key: string, title?: string): RenderElement => {
1109 const { Box, Text } = el
1110 const t = style.theme
1111 const added = rows.filter(r => r.mark === '+').length
1112 const removed = rows.filter(r => r.mark === '-').length
1113 const toggle = rows.length > DIFF_LINES ? fold?.(id, rows.length - DIFF_LINES, `fold${key}`) : null
1114 const shown = rows.slice(0, toggle?.folded === false ? DIFF_MAX : DIFF_LINES)
1115 const gutter = String(Math.max(0, ...shown.map(r => r.num))).length
1116 const [addColor, removeColor] = [t.diffAdded ?? t.number, t.diffRemoved ?? t.codeFlag]
1117 const color = (mark: DiffLine['mark']) => (mark === '+' ? addColor : mark === '-' ? removeColor : undefined)
1118 const look = style.diffStyle
1119 const tinted = look === 'card' || look === 'full'
1120 const tints = { '+': tinted ? diffTint(t.codeText, addColor) : undefined, '-': tinted ? diffTint(t.codeText, removeColor) : undefined, ' ': undefined }
1121 const frame = look === 'card' ? { borderStyle: 'round', borderColor: t.rule, paddingX: 1 } : {}
1122 return (
1123 <Box key={`card${key}`} flexDirection="column" flexShrink={1} {...frame}>
1124 <Text>
1125 {title === undefined ? null : <Text color={t.path}>{`${title} `}</Text>}
1126 <Text color={addColor} dimColor={added === 0}>{`+${added}`}</Text>
1127 <Text> </Text>
1128 <Text color={removeColor} dimColor={removed === 0}>{`−${removed}`}</Text>
1129 </Text>
1130 {shown.map((r, i) => {
1131 const bg = tints[r.mark]
1132 return (
1133 <Box key={`d${i}`} flexDirection="row" {...(bg ? { backgroundColor: bg } : {})}>
1134 {look === 'bar' ? <Text color={color(r.mark)}>{r.mark === ' ' ? ' ' : '▌ '}</Text> : null}
1135 <Text dimColor>{`${String(r.num).padStart(gutter)} `}</Text>
1136 <Text color={color(r.mark)} dimColor={r.mark === ' '}>{`${r.mark} `}</Text>
1137 <Text color={bg || look === 'bar' ? t.codeText : color(r.mark)} dimColor={r.mark === ' '}>{r.text === '' ? ' ' : r.text}</Text>
1138 </Box>
1139 )
1140 })}
1141 {rows.length > shown.length && toggle?.folded !== true ? <Text dimColor>{`… +${rows.length - shown.length} lines`}</Text> : null}
1142 {toggle?.element ?? null}
1143 </Box>
1144 )
1145}
1146
1147const diffRow = (el: ElementTable, style: Style, cards: RenderElement[], below: boolean): RenderElement => (
1148 <el.Box flexDirection="row">
1149 <el.Text dimColor>{below ? ' ' : ' ⎿ '}</el.Text>
1150 <el.Box flexDirection="column" flexShrink={1} {...(style.diffStyle === 'full' ? { flexGrow: 1 } : { alignItems: 'flex-start' as const })}>{cards}</el.Box>
1151 </el.Box>
1152)
1153
1154export const renderDiffCard = (el: ElementTable, style: Style, output: unknown, fold: Fold, id: string): RenderElement | undefined => {
1155 const rows = diffLines(output)
1156 return rows === undefined ? undefined : diffRow(el, style, [diffBox(el, style, rows, fold, id, '')], false)
1157}
1158
1159type EditedFile = { path: string; rows: DiffLine[] }
1160
1161export type BashEdits = { files: EditedFile[]; more: number; shared: boolean }
1162
1163export const EDIT_NOTES = {
1164 first: 'every file that changed while this ran, by this command or anything else: a view of the changes, not an audit of the command',
1165 shared: 'another command ran in this repository at the same time, so changes by either may show under either one',
1166 tree: 'a git step in this command can move the working tree, so these may be its changes rather than edits',
1167} as const
1168
1169export const bashEdits = (output: unknown): BashEdits | undefined => {
1170 if (output === null || typeof output !== 'object') return undefined
1171 const diff = (output as { bashEditDiff?: unknown }).bashEditDiff
1172 if (diff === null || typeof diff !== 'object') return undefined
1173 const { files, moreFiles, shared } = diff as { files?: unknown; moreFiles?: unknown; shared?: unknown }
1174 if (!Array.isArray(files) || files.length === 0) return undefined
1175 const edited: EditedFile[] = []
1176 for (const file of files as ({ filePath?: unknown; hunks?: unknown } | null)[]) {
1177 const rows = diffLines({ structuredPatch: file?.hunks })
1178 if (typeof file?.filePath !== 'string' || rows === undefined) return undefined
1179 edited.push({ path: file.filePath, rows })
1180 }
1181 return { files: edited, more: typeof moreFiles === 'number' && moreFiles > 0 ? moreFiles : 0, shared: shared === true }
1182}
1183
1184export const withoutBashEdits = (output: unknown): unknown => {
1185 if (output === null || typeof output !== 'object') return output
1186 const { bashEditDiff: _, ...rest } = output as Record<string, unknown>
1187 return rest
1188}
1189
1190export const hasShellOutput = (output: unknown): boolean => {
1191 const out = output !== null && typeof output === 'object' ? (output as Record<string, unknown>) : {}
1192 return lines(out.stdout).length + lines(out.stderr).length > 0
1193}
1194
1195export const renderBashEdits = (el: ElementTable, style: Style, edits: BashEdits, fold: Fold, id: string, below = false, notes: readonly string[] = []): RenderElement => {
1196 const cards = edits.files.map((file, i) => diffBox(el, style, file.rows, fold, `${id}:${i}`, String(i), shortPath(style, file.path)))
1197 const more = edits.more ? [<el.Text key="more" dimColor>{`… +${edits.more} more ${edits.more === 1 ? 'file' : 'files'}`}</el.Text>] : []
1198 const said = notes.map((note, i) => <el.Text key={`note${i}`} dimColor>{`(${note})`}</el.Text>)
1199 return diffRow(el, style, [...cards, ...more, ...said], below)
1200}hooks/help.ts 419 lines1import { RANDOM_THEMES } from './theme'
2
3export const HINT = [
4 'Replies in this session are drawn by the prismantis mod, which runs inside Claude Code and is not a command or tool to call: when the user asks to show something with prismantis, write it as markdown in the reply.',
5 'Markdown tables, GitHub alerts (> [!WARNING], > [!NOTE]), fenced code with a language tag, and ```mermaid blocks render as colored terminal graphics:',
6 'flowcharts, sequence diagrams, xychart-beta bar or line charts (name each series for the legend: line "east" [..]), pie charts and gantt schedules.',
7 'When a reply carries a numeric series or a flow that is easier to see than read, add a small diagram or chart with short labels, not a second picture of what a figure already shows.',
8 'Skip diagrams and pictures for short or simple answers.',
9 'Put any command or snippet the user may run or copy in a fenced block with a language tag, never inline code: fenced blocks get a copy button, inline code does not.',
10].join(' ')
11
12export const showcaseText = (themes: readonly string[]): string => `
13# prismantis
14
15Colored markdown for Claude Code replies: **bold**, *italic*, ~~struck~~, \`inline code\`, a [link](https://github.com/NahumLitvin/prismantis) and a bare URL https://github.com/NahumLitvin/prismantis/issues, both clickable, numbers like 99.9% and 250ms, paths like ~/src/app.ts.
16
17Code-shaped prose stays as written: f(*args, **kwargs), a link with parentheses [Foo_(bar)](https://en.wikipedia.org/wiki/Foo_(bar)), a file link [\`render.tsx\`](https://github.com/NahumLitvin/prismantis/blob/main/hooks/render.tsx) and **bold with *italic* inside**.
18
19## Commands
20
21| Command | Does |
22|---------|------|
23| \`/prismantis\` | This screen |
24| \`/prismantis theme <name>\` | Switch theme on the spot (\`/prismantis <name>\` works too), \`random\` for a new dark one now and each time the mod loads |
25| \`/prismantis copy\` | Copy the last reply, or \`copy code\` for its last code block |
26| \`/prismantis setup\` | Pick a theme, table, task, prompt, tool row and tool output style from drawn samples |
27| \`/config\` | Edit any option |
28
29> [!NOTE]
30> ${themes.length} themes ship with it. \`mono\` uses no color, only bold and dim.
31
32### Pick a theme
33
34\`\`\`bash
35/prismantis theme nord
36/prismantis theme github-light
37/prismantis theme random
38\`\`\`
39
401. Dark: ${themes.filter(t => RANDOM_THEMES.includes(t)).join(', ')}
412. Light: ${themes.filter(t => /latte|light|dawn/.test(t)).join(', ')}
423. Plain
43 - mono
44
45> [!TIP]
46> Any color slot beats the theme. Set \`headingColor\` or \`numberColor\` to a hex value in \`/config\`.
47
48## Everything it draws
49
50### Copy tables
51
52Use \`⧉ md\` for Markdown, \`⧉ art\` for chat, or \`⧉ html\` for a formatted table. \`⧉ html\` shows in a local macOS, Linux or Windows terminal and writes HTML and tab-separated plain text together; Linux needs CopyQ running. Pasted into Slack it makes a native table; Slack keeps cell text but drops bold, links and alignment. If that fails, it copies the plain text alone. Set \`copyButtonStyle\` to \`hover\` in \`/config\` and every copy button hides until the pointer is on its block.
53
54| Item | Quantity[^count] | Status |
55|:-----|---------:|:------:|
56| **Apples** | 3 | *Ready* |
57| Oranges | 5 | Pending |
58
59### Alerts
60
61> [!IMPORTANT]
62> After updating the plugin, open sessions need \`/reload\`.
63
64> [!WARNING]
65> Terminals that copy on select (Warp) can turn a click on a copy button into a selection. Use the keyboard shortcut.
66> Selecting a typeset formula copies blank cells. Its copy button copies the LaTeX.
67
68> [!CAUTION]
69> Claude Code refuses trees over 20000 nodes. A code block past about 500 highlighted lines falls back to plain text.
70
71### Task lists
72
73- [x] Parse the reply
74- [x] Draw it
75 - [x] Tables and code
76 - [ ] Diagrams on the desktop app
77- [ ] Ship the next release
78
79### Your prompts
80
81Scroll up: what you typed draws in a bubble. \`promptStyle\` switches to \`bar\`, \`chevron\` or \`off\` in \`/config\`. Set \`clock\` to \`24h\` or \`12h\` and each prompt shows when you sent it, and each turn's last line when it finished.
82
83### Tool rows
84
85Tool calls draw as one line, dimmed on the right by default. Set \`toolStyle\` in \`/config\` to \`chat\`, \`tree-dim\`, \`tree-bold\` or \`classic\`. \`toolOutput: quiet\` in \`/config\` hides what Claude only read and says in plain words what each call did: \`Read src/app.ts lines 1–40\`, \`Searched src/ for "TODO"\`.
86
87Each edit or new file draws as a diff card headed \`+N −M\`: added and removed lines on a tint of the theme's own green and red, kept readable in every theme, long lines wrapped, the first 20 lines and a \`+K more lines\` button that opens the rest. Files a shell command changes (\`sed -i\`, a script) get the same card, one per file, headed by its path. Set \`diffStyle\` in \`/config\` for the look: \`card\`, \`full\`, \`marks\` or \`bar\`.
88
89### Quotes and rules
90
91> A quote keeps its text when you copy it, without the \`> \` markers.
92>
93> It keeps its paragraphs and lists too:
94> - **bold** and \`inline code\` stay styled
95> - each item gets its own row
96>
97> > A nested quote draws inside the bar.
98
99<summary>
100A line that holds only a tag draws dim, so wrapper tags show the reply's structure.
101</summary>
102
103---
104
105### Code
106
107\`\`\`json
108{ "theme": "dracula", "headingStyle": "banner", "mermaid": true }
109\`\`\`
110
111Blocks of 10 lines or more get line numbers. Past 30 lines they fold to the first 20 and a \`+M more lines\` button, \`show less\` folds them back; \`⧉ copy\` still copies the whole block.
112
113\`\`\`ts
114type Item = { name: string; price: number; qty: number }
115
116const basket: Item[] = [
117 { name: 'apples', price: 1.2, qty: 3 },
118 { name: 'oranges', price: 0.8, qty: 5 },
119 { name: 'pears', price: 1.5, qty: 2 },
120 { name: 'plums', price: 2.1, qty: 4 },
121 { name: 'grapes', price: 3.4, qty: 1 },
122 { name: 'lemons', price: 0.6, qty: 6 },
123 { name: 'melons', price: 4.0, qty: 1 },
124 { name: 'cherries', price: 5.5, qty: 2 },
125 { name: 'figs', price: 2.8, qty: 3 },
126 { name: 'limes', price: 0.5, qty: 8 },
127 { name: 'peaches', price: 1.9, qty: 4 },
128 { name: 'kiwis', price: 0.9, qty: 6 },
129]
130
131const total = (items: Item[]): number =>
132 items.reduce((sum, item) => sum + item.price * item.qty, 0)
133
134const cheapest = (items: Item[]): Item | undefined =>
135 [...items].sort((a, b) => a.price - b.price)[0]
136
137const byName = (items: Item[]): Item[] =>
138 [...items].sort((a, b) => a.name.localeCompare(b.name))
139
140const receipt = (items: Item[]): string[] =>
141 byName(items).map(item => \`\${item.name.padEnd(10)} \${item.qty} x \${item.price.toFixed(2)}\`)
142
143console.log(receipt(basket).join('\\n'))
144console.log('total', total(basket).toFixed(2))
145console.log('cheapest', cheapest(basket)?.name)
146\`\`\`
147
148#### Math
149
150$$
151\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2}
152$$
153
154#### Images
155
156In kitty and Ghostty a PNG on a line of its own draws as a picture: a local path, \`~/\`, \`file://\` or a \`data:\` URL. Web images stay links, prismantis downloads nothing. Other terminals show the alt text in link color.
157
158
159
160#### Footnotes
161
162Latency fell to 120ms[^lat] after the cache change[^cache].
163
164Click a mark to see its note. Point at a note to light the marks that cite it. Both need Claude Code 2.1.295 or later.
165
166[^count]: Counted at close.
167[^lat]: Median of 50 runs.
168[^cache]: Entries expire after 10m.
169
170#### Diagrams
171
172\`\`\`mermaid
173flowchart LR
174 R["<i>Reply</i>"] --> P["<b>Parse</b>"]
175 P --> D["<u>Draw</u>"]
176 D --> S["<s>Screen</s>"]
177\`\`\`
178
179\`\`\`mermaid
180sequenceDiagram
181 participant U as You
182 participant C as Claude
183 participant P as prismantis
184 U->>C: prompt
185 C->>P: markdown
186 P-->>U: colored reply
187\`\`\`
188
189\`\`\`mermaid
190xychart-beta
191 title "Color options per group"
192 x-axis [text, head, num, code, diag]
193 y-axis "options" 0 --> 8
194 bar [7, 3, 2, 6, 2]
195\`\`\`
196
197Name each series and the legend says which line is which:
198
199\`\`\`mermaid
200xychart-beta
201 title "Replies per hour"
202 x-axis [09, 10, 11, 12, 13]
203 y-axis 0 --> 40
204 line "east" [30, 34, 22, 26, 31]
205 line "west" [12, 18, 25, 20, 14]
206\`\`\`
207
208A pie draws round with a legend. Set \`pieStyle\` to \`bars\` in \`/config\` for one bar per slice:
209
210\`\`\`mermaid
211pie showData title Time per reply stage
212 "Parse" : 3
213 "Draw" : 9
214 "Paint" : 5
215\`\`\`
216
217\`\`\`mermaid
218gantt
219 title Release week
220 dateFormat YYYY-MM-DD
221 section Build
222 Parser :p, 2026-10-05, 2d
223 Renderer :r, after p, 3d
224 section Ship
225 Review :after r, 2d
226 Release :milestone, 2026-10-12, 0d
227\`\`\`
228
229A diagram wider than the terminal stays source, with a dim line saying why:
230
231\`\`\`mermaid
232flowchart TD
233 Q[Queue] --> W1[Worker 1] & W2[Worker 2] & W3[Worker 3] & W4[Worker 4] & W5[Worker 5] & W6[Worker 6] & W7[Worker 7] & W8[Worker 8] & W9[Worker 9] & W10[Worker 10] & W11[Worker 11] & W12[Worker 12] & W13[Worker 13] & W14[Worker 14] & W15[Worker 15] & W16[Worker 16]
234\`\`\`
235
236A figure draws as written, its markup colored by role:
237
238\`\`\`figure
239{dim:timeout at 10 s, handler books at 12 s, retry books again}
240 {note:0 s} {note:10 s} {note:12 s} {note:later}
241 {accent:▏─────────────▏─────▏─────────────────▏}
242Provider {accent:██████████████}{warn:✗} timeout {accent:█} retry
243Handler {ok:████████████████████} 200, nobody waits
244Bookings 0 {ok:1} {warn:2 twice}
245\`\`\`
246`
247
248export const helpText = (themes: readonly string[]): string => `
249## Commands
250
251| Command | Does |
252|---------|------|
253| \`/prismantis theme <name>\` | Switch theme on the spot (\`/prismantis <name>\` works too), \`random\` for a new dark one now and each time the mod loads |
254| \`/prismantis copy\` | Copy the last reply, or \`copy code\` for its last code block |
255| \`/prismantis setup\` | Pick a theme, table, task, prompt, tool row and tool output style from drawn samples |
256| \`/prismantis demo\` | Full showcase, every element and diagram, plus a section in your language (Korean so far, or \`demo ko\`) |
257| \`/prismantis demo-rtl\` | Hebrew right-to-left showcase |
258
259### ${themes.length} themes
260
261- Dark: ${themes.filter(t => RANDOM_THEMES.includes(t)).join(', ')}
262- Light: ${themes.filter(t => /latte|light|dawn/.test(t)).join(', ')}
263- Plain: mono, no color, only bold and dim
264
265Docs and issues: https://github.com/NahumLitvin/prismantis
266
267> [!TIP]
268> Any color slot beats the theme. Set \`headingColor\` or \`numberColor\` to a hex value in \`/config\`.
269
270### Task lists
271
272- [x] Tables, diagrams and charts drawn in the terminal
273- [x] Copy tables as Markdown, art or HTML (macOS/Linux)
274- [ ] Pick a \`taskStyle\` in \`/config\`: checks, ticks, box or progress
275
276\`\`\`mermaid
277flowchart LR
278 R[Reply] --> P[Parse]
279 P --> D[Draw]
280 D --> S[Screen]
281\`\`\`
282
283\`\`\`mermaid
284xychart-beta
285 title "Color options per group"
286 x-axis [text, head, num, code, diag]
287 y-axis "options" 0 --> 8
288 bar [7, 3, 2, 6, 2]
289\`\`\`
290
291> [!CAUTION]
292> Claude Code refuses trees over 20000 nodes. A code block past about 500 highlighted lines falls back to plain text.
293`
294
295export const rtlShowcaseText = (): string => `
296# עברית מימין לשמאל
297
298**שלום חברים**, זו הדגמה של עברית עם מונחים באנגלית כמו \`kubectl\`, מספרים כמו 99.9% ו-250ms, נתיב כמו ~/src/app.ts וגם [קישור](https://github.com/NahumLitvin/prismantis).
299
300## רשימות
301
302- פרוסים בשני אזורים (us-east ו-eu-west)
303- Python מתאים גם לסקריפטים קצרים
304- מחליפים ערכת נושא עם \`/prismantis theme nord\`
305
3061. מתקינים את התוסף
3072. שואלים שאלה בעברית
308
309> עברית נקראת מימין לשמאל, גם בטרמינל בלי תמיכה בכיווניות
310
311> [!TIP]
312> כל ערכת נושא עובדת גם בעברית
313
314| שירות | אזור | גרסה |
315| :--- | :--- | ---: |
316| שער | us-east | 2.14.0 |
317| חיוב | eu-west | 1.8.3 |
318
319\`\`\`bash
320ls -la # רשימת הקבצים בתיקייה
321\`\`\`
322
323\`\`\`mermaid
324flowchart LR
325 A[בקשה חדשה] --> B{האם יש cache?}
326 B -->|כן| C[מחזירים מה-cache]
327 B -->|לא| D[פונים לשרת]
328\`\`\`
329`
330
331export const ISSUE_HINT = 'If the user says prismantis drew something wrong, offer (ask first) a minimal repro issue at https://github.com/NahumLitvin/prismantis/issues.'
332const KOREAN_SHOWCASE = `
333## 한국어
334
335**안녕하세요**, prismantis는 한국어 답변도 그대로 보여 줍니다. \`kubectl\` 같은 영문 명령어, 숫자 99.9%와 250ms, 경로 ~/src/app.ts도 함께 표시됩니다.
336
337- 서울 리전과 도쿄 리전에 배포
338- \`/prismantis theme nord\`로 테마 변경
339
340| 서비스 | 리전 | 버전 |
341| :--- | :--- | ---: |
342| 게이트웨이 | ap-northeast-2 | 2.14.0 |
343| 결제 | ap-northeast-1 | 1.8.3 |
344
345\`\`\`figure
346{accent:┌─ 빌드 ─────────────┐}
347{accent:│} 이미지 생성 완료 {accent:│}
348{accent:│} {ok:✓ 테스트 통과} {accent:│}
349{accent:│} {warn:▲ 배포 대기} {accent:│}
350{accent:└────────────────────┘}
351\`\`\`
352
353\`\`\`mermaid
354flowchart LR
355 A[새 요청] --> B{캐시 있음?}
356 B -->|예| C[캐시에서 반환]
357 B -->|아니오| D[서버 호출]
358\`\`\`
359`
360
361export const languageShowcase = (lang: string): string => (/^ko/i.test(lang) ? KOREAN_SHOWCASE : '')
362
363export type SetupStep = { name: string; key: 'theme' | 'tableStyle' | 'taskStyle' | 'promptStyle' | 'toolStyle' | 'toolOutput' | 'pieStyle'; title: string; note: string; options: readonly string[]; sample: string }
364
365export const setupSteps = (themes: readonly string[]): SetupStep[] => [
366 { name: 'theme', key: 'theme', title: 'Theme', note: 'Each theme draws the same line. `random` picks a new dark one each time the mod loads.', options: themes, sample: '**Bold**, `inline code`, 250ms and ~/src/app.ts.' },
367 { name: 'tables', key: 'tableStyle', title: 'Tables', note: 'How markdown tables draw.', options: ['box', 'rules', 'grid', 'minimal'], sample: '| Item | Qty |\n|:-----|----:|\n| Apples | 3 |\n| Pears | 5 |' },
368 { name: 'tasks', key: 'taskStyle', title: 'Tasks', note: 'How `- [ ]` and `- [x]` items draw.', options: ['checks', 'ticks', 'box', 'progress'], sample: '- [x] Parse the reply\n- [x] Draw it\n- [ ] Ship it' },
369 { name: 'prompts', key: 'promptStyle', title: 'Your prompts', note: 'How what you type draws in the transcript. `off` keeps Claude Code\'s own look.', options: ['bubble', 'bar', 'chevron', 'off'], sample: '' },
370 { name: 'tools', key: 'toolStyle', title: 'Tool rows', note: 'How tool rows sit next to Claude\'s sentences. Sharing sessions as screenshots? `chat` and `tree-dim` draw tool rows in dim gray, the first thing to blur when an image gets resized, so pick `tree-bold` or `classic`.', options: ['chat', 'tree-dim', 'tree-bold', 'classic'], sample: '' },
371 { name: 'output', key: 'toolOutput', title: 'Tool output', note: 'How much of a tool call shows. `full` keeps Claude Code\'s own results under each row. `quiet` names a shell call by what it does and shows a failure\'s last real line instead of its whole output; it also shows reads, searches and listings as their row only and drops approval lines. Edits keep their diff either way. Needs `toolRows` on.', options: ['full', 'quiet'], sample: '' },
372 { name: 'pies', key: 'pieStyle', title: 'Pie charts', note: 'How mermaid `pie` charts draw. `round` falls back to bars in ascii mode, with more slices than the theme has distinct colors (mono always) or in a narrow terminal.', options: ['round', 'bars'], sample: '```mermaid\npie title Time per reply stage\n "Draw" : 9\n "Paint" : 5\n "Parse" : 3\n```' },
373]
374
375export type SetupParts = { head: string; options: { value: string; text: string }[]; tail: string }
376
377export const setupParts = (themes: readonly string[], at: number): SetupParts => {
378 const steps = setupSteps(themes)
379 const step = steps[at]!
380 const next = steps[at + 1]
381 return {
382 head: `## Setup ${at + 1}/${steps.length}: ${step.title}\n\n${step.note}`,
383 options: step.options.map((value, i) => ({ value, text: `### ${i + 1}. ${value}${step.key === 'theme' && value !== 'mono' && !RANDOM_THEMES.includes(value) ? ' (for a light terminal)' : ''}${step.sample ? `\n\n${step.sample}` : ''}` })),
384 tail: `Pick with \`/prismantis setup ${step.name} <number or name>\`${step.key === 'theme' ? ' or `random`' : ''}.${next ? ` Skip with \`/prismantis setup ${next.name}\`.` : ''} Stop any time, \`/prismantis setup\` starts over.`,
385 }
386}
387
388export const setupText = (parts: SetupParts): string => [parts.head, ...parts.options.map(o => o.text), parts.tail].join('\n\n')
389
390export const THEME_PREVIEW = [
391 '#### Release notes',
392 '',
393 '**Bold**, `inline code`, 250ms and ~/src/app.ts.',
394 '',
395 '| Service | Status | p95 |',
396 '|:--|:--|--:|',
397 '| api | healthy | 120ms |',
398 '| billing | degraded | 840ms |',
399 '',
400 '```ts',
401 'const total = items.reduce((sum, item) => sum + item.qty, 0)',
402 '```',
403].join('\n')
404
405export const SETUP_PANE_OPENED = 'Setup opened in a pane: ↑↓ or Tab moves through the styles with a preview, Enter saves it and shows the next step, Esc closes the pane. `/prismantis setup theme` walks the steps here instead.'
406
407export const SETUP_PANE_KEYS = '↑↓ or Tab moves, Enter saves and shows the next step, Esc closes.'
408
409export const SETUP_DONE = 'Setup done. Run `/prismantis setup` again any time, and `/config` has every other option.'
410
411export const findSetup = (themes: readonly string[], text: string): { at: number; before: string; parts: SetupParts } | undefined => {
412 for (let at = 0; at < setupSteps(themes).length; at++) {
413 const parts = setupParts(themes, at)
414 const whole = setupText(parts)
415 if (text.endsWith(whole)) return { at, before: text.slice(0, -whole.length).trim(), parts }
416 }
417 return undefined
418}
419hooks/presets.ts 114 lines1import type { Theme } from './theme'
2
3export const PRESETS = {
4 'catppuccin-mocha': {
5 accent: '#cba6f7', heading: '#f9e2af', tableHeader: '#f9e2af', strong: '#ffffff', emphasis: '#f5c2e7',
6 inlineCode: '#89dceb', codeText: '#cdd6f4', codeCommand: '#89b4fa', codeFlag: '#f38ba8', codeString: '#a6e3a1',
7 codeComment: '#6c7086', link: '#89b4fa', path: '#89dceb', number: '#a6e3a1', quote: '#9399b2',
8 rule: '#45475a', tableRule: '#585b70', bullet: '#cba6f7', diagram: '#89b4fa', diagramText: '#cdd6f4',
9 diffAdded: '#a6e3a1', diffRemoved: '#f38ba8',
10 },
11 'catppuccin-latte': {
12 accent: '#8839ef', heading: '#df8e1d', tableHeader: '#df8e1d', strong: '#11111b', emphasis: '#ea76cb',
13 inlineCode: '#04a5e5', codeText: '#4c4f69', codeCommand: '#1e66f5', codeFlag: '#d20f39', codeString: '#40a02b',
14 codeComment: '#9ca0b0', link: '#1e66f5', path: '#209fb5', number: '#40a02b', quote: '#6c6f85',
15 rule: '#bcc0cc', tableRule: '#acb0be', bullet: '#8839ef', diagram: '#1e66f5', diagramText: '#4c4f69',
16 diffAdded: '#40a02b', diffRemoved: '#d20f39',
17 },
18 dracula: {
19 accent: '#bd93f9', heading: '#bd93f9', tableHeader: '#f1fa8c', strong: '#f8f8f2', emphasis: '#ff79c6',
20 inlineCode: '#8be9fd', codeText: '#f8f8f2', codeCommand: '#50fa7b', codeFlag: '#ff79c6', codeString: '#f1fa8c',
21 codeComment: '#6272a4', link: '#8be9fd', path: '#ffb86c', number: '#bd93f9', quote: '#6272a4',
22 rule: '#44475a', tableRule: '#6272a4', bullet: '#ff79c6', diagram: '#bd93f9', diagramText: '#f8f8f2',
23 diffAdded: '#50fa7b', diffRemoved: '#ff5555',
24 },
25 nord: {
26 accent: '#88c0d0', heading: '#88c0d0', tableHeader: '#ebcb8b', strong: '#eceff4', emphasis: '#b48ead',
27 inlineCode: '#8fbcbb', codeText: '#d8dee9', codeCommand: '#88c0d0', codeFlag: '#81a1c1', codeString: '#a3be8c',
28 codeComment: '#4c566a', link: '#88c0d0', path: '#8fbcbb', number: '#b48ead', quote: '#81a1c1',
29 rule: '#3b4252', tableRule: '#4c566a', bullet: '#88c0d0', diagram: '#81a1c1', diagramText: '#d8dee9',
30 diffAdded: '#a3be8c', diffRemoved: '#bf616a',
31 },
32 'tokyo-night': {
33 accent: '#bb9af7', heading: '#7dcfff', tableHeader: '#e0af68', strong: '#c0caf5', emphasis: '#f7768e',
34 inlineCode: '#7dcfff', codeText: '#a9b1d6', codeCommand: '#7aa2f7', codeFlag: '#bb9af7', codeString: '#9ece6a',
35 codeComment: '#565f89', link: '#73daca', path: '#2ac3de', number: '#ff9e64', quote: '#9aa5ce',
36 rule: '#414868', tableRule: '#565f89', bullet: '#bb9af7', diagram: '#7aa2f7', diagramText: '#c0caf5',
37 diffAdded: '#9ece6a', diffRemoved: '#f7768e',
38 },
39 'gruvbox-dark': {
40 accent: '#d3869b', heading: '#fabd2f', tableHeader: '#fabd2f', strong: '#fbf1c7', emphasis: '#d3869b',
41 inlineCode: '#8ec07c', codeText: '#ebdbb2', codeCommand: '#83a598', codeFlag: '#fe8019', codeString: '#b8bb26',
42 codeComment: '#928374', link: '#83a598', path: '#8ec07c', number: '#d3869b', quote: '#a89984',
43 rule: '#3c3836', tableRule: '#504945', bullet: '#fe8019', diagram: '#83a598', diagramText: '#ebdbb2',
44 diffAdded: '#b8bb26', diffRemoved: '#fb4934',
45 },
46 'gruvbox-light': {
47 accent: '#8f3f71', heading: '#b57614', tableHeader: '#b57614', strong: '#282828', emphasis: '#8f3f71',
48 inlineCode: '#427b58', codeText: '#3c3836', codeCommand: '#076678', codeFlag: '#af3a03', codeString: '#79740e',
49 codeComment: '#928374', link: '#076678', path: '#427b58', number: '#8f3f71', quote: '#7c6f64',
50 rule: '#ebdbb2', tableRule: '#a89984', bullet: '#af3a03', diagram: '#076678', diagramText: '#3c3836',
51 diffAdded: '#79740e', diffRemoved: '#9d0006',
52 },
53 'rose-pine': {
54 accent: '#c4a7e7', heading: '#c4a7e7', tableHeader: '#f6c177', strong: '#e0def4', emphasis: '#ebbcba',
55 inlineCode: '#9ccfd8', codeText: '#e0def4', codeCommand: '#9ccfd8', codeFlag: '#eb6f92', codeString: '#f6c177',
56 codeComment: '#6e6a86', link: '#c4a7e7', path: '#9ccfd8', number: '#ebbcba', quote: '#908caa',
57 rule: '#26233a', tableRule: '#6e6a86', bullet: '#eb6f92', diagram: '#c4a7e7', diagramText: '#e0def4',
58 diffAdded: '#9ccfd8', diffRemoved: '#eb6f92',
59 },
60 'rose-pine-dawn': {
61 accent: '#907aa9', heading: '#907aa9', tableHeader: '#ea9d34', strong: '#464261', emphasis: '#d7827e',
62 inlineCode: '#56949f', codeText: '#464261', codeCommand: '#56949f', codeFlag: '#b4637a', codeString: '#ea9d34',
63 codeComment: '#9893a5', link: '#907aa9', path: '#286983', number: '#d7827e', quote: '#797593',
64 rule: '#f2e9e1', tableRule: '#9893a5', bullet: '#b4637a', diagram: '#907aa9', diagramText: '#464261',
65 diffAdded: '#56949f', diffRemoved: '#b4637a',
66 },
67 everforest: {
68 accent: '#a7c080', heading: '#e69875', tableHeader: '#dbbc7f', strong: '#d3c6aa', emphasis: '#d699b6',
69 inlineCode: '#83c092', codeText: '#d3c6aa', codeCommand: '#7fbbb3', codeFlag: '#e67e80', codeString: '#a7c080',
70 codeComment: '#859289', link: '#7fbbb3', path: '#83c092', number: '#d699b6', quote: '#9da9a0',
71 rule: '#475258', tableRule: '#7a8478', bullet: '#e69875', diagram: '#7fbbb3', diagramText: '#d3c6aa',
72 diffAdded: '#a7c080', diffRemoved: '#e67e80',
73 },
74 'github-dark': {
75 accent: '#d2a8ff', heading: '#79c0ff', tableHeader: '#ffa657', strong: '#e6edf3', emphasis: '#d2a8ff',
76 inlineCode: '#79c0ff', codeText: '#e6edf3', codeCommand: '#d2a8ff', codeFlag: '#ff7b72', codeString: '#a5d6ff',
77 codeComment: '#7d8590', link: '#58a6ff', path: '#7ee787', number: '#79c0ff', quote: '#7d8590',
78 rule: '#30363d', tableRule: '#8b949e', bullet: '#ff7b72', diagram: '#58a6ff', diagramText: '#e6edf3',
79 diffAdded: '#3fb950', diffRemoved: '#f85149',
80 },
81 'github-light': {
82 accent: '#8250df', heading: '#0550ae', tableHeader: '#953800', strong: '#1f2328', emphasis: '#8250df',
83 inlineCode: '#0550ae', codeText: '#1f2328', codeCommand: '#8250df', codeFlag: '#cf222e', codeString: '#0a3069',
84 codeComment: '#6e7781', link: '#0969da', path: '#116329', number: '#0550ae', quote: '#656d76',
85 rule: '#d0d7de', tableRule: '#6e7781', bullet: '#cf222e', diagram: '#0969da', diagramText: '#1f2328',
86 diffAdded: '#1a7f37', diffRemoved: '#cf222e',
87 },
88 'one-dark': {
89 accent: '#c678dd', heading: '#e06c75', tableHeader: '#e5c07b', strong: '#d19a66', emphasis: '#c678dd',
90 inlineCode: '#56b6c2', codeText: '#abb2bf', codeCommand: '#61afef', codeFlag: '#e06c75', codeString: '#98c379',
91 codeComment: '#5c6370', link: '#61afef', path: '#56b6c2', number: '#d19a66', quote: '#828997',
92 rule: '#3a3f4b', tableRule: '#5c6370', bullet: '#c678dd', diagram: '#61afef', diagramText: '#abb2bf',
93 diffAdded: '#98c379', diffRemoved: '#e06c75',
94 },
95 'solarized-dark': {
96 accent: '#6c71c4', heading: '#b58900', tableHeader: '#b58900', strong: '#eee8d5', emphasis: '#d33682',
97 inlineCode: '#2aa198', codeText: '#93a1a1', codeCommand: '#268bd2', codeFlag: '#cb4b16', codeString: '#859900',
98 codeComment: '#586e75', link: '#268bd2', path: '#2aa198', number: '#859900', quote: '#657b83',
99 rule: '#073642', tableRule: '#586e75', bullet: '#6c71c4', diagram: '#268bd2', diagramText: '#93a1a1',
100 diffAdded: '#859900', diffRemoved: '#dc322f',
101 },
102 'solarized-light': {
103 accent: '#6c71c4', heading: '#b58900', tableHeader: '#b58900', strong: '#073642', emphasis: '#d33682',
104 inlineCode: '#2aa198', codeText: '#586e75', codeCommand: '#268bd2', codeFlag: '#cb4b16', codeString: '#859900',
105 codeComment: '#93a1a1', link: '#268bd2', path: '#2aa198', number: '#859900', quote: '#839496',
106 rule: '#eee8d5', tableRule: '#93a1a1', bullet: '#6c71c4', diagram: '#268bd2', diagramText: '#586e75',
107 diffAdded: '#859900', diffRemoved: '#dc322f',
108 },
109 mono: {} as Theme,
110} satisfies Record<string, Theme>
111
112export type PresetName = keyof typeof PRESETS
113export const PRESET_NAMES = Object.keys(PRESETS) as PresetName[]
114hooks/theme.ts 103 lines1import type { PluginOptions } from 'claude-code'
2
3import type { ClipboardBackend } from './clipboard'
4import { PRESET_NAMES, PRESETS } from './presets'
5import type { Shape, Terminal } from './rtl'
6import { TERMINALS } from './rtl'
7
8export const TOKENS = [
9 'accent', 'heading', 'strong', 'emphasis', 'inlineCode', 'codeText', 'codeCommand', 'codeFlag', 'codeString', 'codeComment',
10 'link', 'path', 'number', 'quote', 'rule', 'tableHeader', 'tableRule', 'bullet', 'diagram', 'diagramText', 'diffAdded', 'diffRemoved',
11] as const
12
13export type Theme = Partial<Record<(typeof TOKENS)[number], string>>
14
15export type Clock = 'off' | '24h' | '12h'
16
17export type Style = {
18 theme: Theme
19 headingStyle: 'bold' | 'underline' | 'uppercase' | 'banner'
20 tableStyle: 'box' | 'rules' | 'grid' | 'minimal'
21 taskStyle: 'checks' | 'ticks' | 'box' | 'progress'
22 promptStyle: 'bubble' | 'bar' | 'chevron' | 'off'
23 clock: Clock
24 toolStyle: 'chat' | 'tree-dim' | 'tree-bold' | 'classic'
25 toolOutput: 'full' | 'quiet'
26 narration?: boolean
27 inside?: 'quote' | 'alert'
28 noteKey?: string
29 diffStyle: 'card' | 'full' | 'marks' | 'bar'
30 pieStyle: 'round' | 'bars'
31 highlightNumbers: boolean
32 highlightPaths: boolean
33 mermaid: boolean
34 mermaidAscii: boolean
35 copyButtons: boolean
36 copyButtonStyle: 'inline' | 'hover'
37 htmlCopy: ClipboardBackend | null
38 nativeCode?: boolean
39 httpLinks: boolean
40 editorLinks: boolean
41 diagramHints: boolean
42 issueHint: boolean
43 rtl: 'auto' | Terminal | 'off'
44 reorder: boolean
45 shape: Shape
46 latex: boolean
47 cwd: string
48 home: string
49}
50
51const COLOR = /^(#[0-9a-f]{3}|#[0-9a-f]{6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|ansi256\(\d{1,3}\)|(black|red|green|yellow|blue|magenta|cyan|white|gray|grey)(Bright)?)$/i
52
53export const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR.test(value.trim())
54
55const pick = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T =>
56 allowed.includes(value as T) ? (value as T) : fallback
57
58export const RANDOM_THEMES: readonly string[] = PRESET_NAMES.filter(t => !/latte|light|dawn/.test(t) && t !== 'mono')
59
60export const pickTheme = (value: unknown, random = Math.random): string => {
61 const name = value === 'random' ? RANDOM_THEMES[Math.floor(random() * RANDOM_THEMES.length)] : value
62 return (PRESET_NAMES as readonly unknown[]).includes(name) ? String(name) : 'catppuccin-mocha'
63}
64
65export const resolveStyle = (options: PluginOptions): Style => {
66 const base: Theme = (PRESETS as Record<string, Theme>)[String(options.theme)] ?? PRESETS['catppuccin-mocha']
67 const fromFields = Object.fromEntries(
68 TOKENS.filter(k => isColor(options[`${k}Color`])).map(k => [k, String(options[`${k}Color`]).trim()]),
69 )
70
71 const rtl = pick(options.rtl, ['auto', 'off', ...(Object.keys(TERMINALS) as Terminal[])], 'auto')
72
73 return {
74 theme: { ...base, ...fromFields },
75 headingStyle: pick(options.headingStyle, ['bold', 'underline', 'uppercase', 'banner'] as const, 'banner'),
76 tableStyle: pick(options.tableStyle, ['box', 'rules', 'grid', 'minimal'] as const, 'box'),
77 taskStyle: pick(options.taskStyle, ['checks', 'ticks', 'box', 'progress'] as const, 'checks'),
78 promptStyle: pick(options.promptStyle, ['bubble', 'bar', 'chevron', 'off'] as const, 'bubble'),
79 clock: pick(options.clock, ['off', '24h', '12h'] as const, 'off'),
80 toolStyle: pick(options.toolStyle, ['chat', 'tree-dim', 'tree-bold', 'classic'] as const, 'chat'),
81 toolOutput: pick(options.toolOutput, ['full', 'quiet'] as const, 'full'),
82 diffStyle: pick(options.diffStyle, ['card', 'full', 'marks', 'bar'] as const, 'card'),
83 pieStyle: pick(options.pieStyle, ['round', 'bars'] as const, 'round'),
84 highlightNumbers: options.highlightNumbers !== false,
85 highlightPaths: options.highlightPaths !== false,
86 mermaid: options.mermaid !== false,
87 mermaidAscii: options.mermaidAscii === true,
88 copyButtons: options.copyButtons !== false,
89 copyButtonStyle: pick(options.copyButtonStyle, ['inline', 'hover'] as const, 'inline'),
90 htmlCopy: null,
91 httpLinks: false,
92 editorLinks: false,
93 diagramHints: options.diagramHints !== false && options.mermaid !== false,
94 issueHint: options.issueHint !== false,
95 rtl,
96 reorder: rtl !== 'auto' && rtl !== 'off',
97 shape: rtl === 'auto' || rtl === 'off' ? 'visual' : TERMINALS[rtl],
98 latex: options.latex === true,
99 cwd: '',
100 home: '',
101 }
102}
103hooks/rtl.ts 368 lines1import type { Inline } from './markdown'
2
3type Dir = 'R' | 'L'
4export type Flow = { base: Dir; lines: Inline[][] }
5export type Shape = 'visual' | 'words' | 'logical' | 'mirror' | 'inverse' | 'engine'
6
7export const TERMINALS = {
8 warp: 'visual',
9 ghostty: 'visual',
10 wezterm: 'visual',
11 vscode: 'visual',
12 alacritty: 'visual',
13 'windows-terminal': 'engine',
14 'apple-terminal': 'logical',
15 kitty: 'words',
16 iterm: 'mirror',
17 gnome: 'logical',
18 konsole: 'logical',
19} as const satisfies Record<string, Shape>
20
21export type Terminal = keyof typeof TERMINALS
22
23type Fmt = { wrap: ('strong' | 'emphasis' | 'strike')[]; leaf: 'text' | 'code' | 'math' | 'number' | 'path' | 'link' | 'dim' | 'footnote'; href?: string }
24type Unit = { ch: string; fmt: Fmt }
25
26const R = /[--ٟ٪-ۯۺ-ݿࢠ-ࣿיִ-﷿ﹰ-]/
27const L = /[A-Za-z0-9À-ɏ٠-٩۰-۹]/
28const LATIN = /[A-Za-zÀ-ɏ]/
29const MARK = /\p{M}/u
30const MIRROR: Record<string, string> = { '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<' }
31const DIGIT = /[0-9\u0660-\u0669\u06F0-\u06F9]/
32const TERMINATOR = /[%\u2030\u00B0$\u20AC\u00A3\u00A5\u20AA]/
33const COMMENT = /^(.*?)((?:^|\s)(?:#|\/\/)\s+)(.*)$/
34
35const classify = (ch: string): Dir | null => (R.test(ch) ? 'R' : L.test(ch) ? 'L' : null)
36
37const units = (text: string, fmt: Fmt): Unit[] => {
38 const out: Unit[] = []
39 for (const cp of text) {
40 const last = out.at(-1)
41 if (last && MARK.test(cp)) last.ch += cp
42 else out.push({ ch: cp, fmt })
43 }
44 return out
45}
46
47const flatten = (nodes: Inline[], wrap: Fmt['wrap'] = []): Unit[] =>
48 nodes.flatMap(n => {
49 switch (n.kind) {
50 case 'strong':
51 case 'emphasis':
52 case 'strike':
53 return flatten(n.children, [...wrap, n.kind])
54 case 'link':
55 return n.text === n.href
56 ? units(n.text, { wrap, leaf: 'link', href: n.href })
57 : [...units(n.text, { wrap, leaf: 'link', href: n.href }), ...units(` (${n.href})`, { wrap, leaf: 'dim' })]
58 case 'image':
59 return units(n.text, { wrap, leaf: 'text' })
60 default:
61 return units(n.text, { wrap, leaf: n.kind })
62 }
63 })
64
65const sameFmt = (a: Fmt, b: Fmt) => a.leaf === b.leaf && a.href === b.href && a.wrap.length === b.wrap.length && a.wrap.every((k, i) => k === b.wrap[i])
66
67const leafNode = ({ leaf, href }: Fmt, text: string): Inline => (leaf === 'link' ? { kind: 'link', text, href: href! } : { kind: leaf, text })
68
69const rebuild = (all: Unit[]): Inline[] => {
70 const out: Inline[] = []
71 let i = 0
72 while (i < all.length) {
73 const fmt = all[i]!.fmt
74 let text = ''
75 let j = i
76 while (j < all.length && sameFmt(all[j]!.fmt, fmt)) text += all[j++]!.ch
77 out.push(fmt.wrap.reduceRight<Inline>((inner, kind) => ({ kind, children: [inner] }), leafNode(fmt, text)))
78 i = j
79 }
80 return out
81}
82
83const baseOf = (all: Unit[]): Dir => {
84 const words = all.map(u => u.ch).join('').split(/\s+/)
85 const rw = words.filter(w => R.test(w)).length
86 const lw = words.filter(w => LATIN.test(w) && !R.test(w)).length
87 if (rw !== lw) return rw > lw ? 'R' : 'L'
88 return all.map(u => classify(u.ch)).find(Boolean) ?? 'R'
89}
90
91const reorder = <U extends Unit>(all: U[], base: Dir): U[] => {
92 const strong = all.map((u, i) => {
93 const own = classify(u.ch)
94 if (own) return own
95 if (u.fmt.leaf === 'code' || u.fmt.leaf === 'math' || u.fmt.leaf === 'path') return 'L'
96 return TERMINATOR.test(u.ch) && (DIGIT.test(all[i - 1]?.ch ?? '') || DIGIT.test(all[i + 1]?.ch ?? '')) ? 'L' : null
97 })
98 const prev: (Dir | null)[] = []
99 let seen: Dir | null = null
100 strong.forEach((s, i) => {
101 seen = s ?? seen
102 prev[i] = seen
103 })
104 const next: (Dir | null)[] = []
105 seen = null
106 for (let i = strong.length - 1; i >= 0; i--) {
107 seen = strong[i] ?? seen
108 next[i] = seen
109 }
110 const dir = strong.map((s, i): Dir => s ?? ((prev[i] ?? base) === (next[i] ?? base) ? (prev[i] ?? base) : base))
111 const runs: { d: Dir; u: U[] }[] = []
112 all.forEach((u, i) => {
113 const last = runs.at(-1)
114 if (last && last.d === dir[i]) last.u.push(u)
115 else runs.push({ d: dir[i]!, u: [u] })
116 })
117 const out = runs.map(r => (r.d === 'R' ? r.u.map(u => ({ ...u, ch: MIRROR[u.ch] ?? u.ch })).reverse() : r.u))
118 return (base === 'R' ? out.reverse() : out).flat()
119}
120
121const LRM = '\u200E'
122
123const bidiClass = (ch: string): string =>
124 ch === LRM ? 'L' : R.test(ch) ? 'R' : /[٠-٩]/.test(ch) ? 'AN' : /[0-9۰-۹]/.test(ch) ? 'EN' : /\p{L}/u.test(ch) ? 'L' : /[+-]/.test(ch) ? 'ES' : /[#$%°£€¥₪‰¢]/.test(ch) ? 'ET' : /[,.:/]/.test(ch) ? 'CS' : 'N'
125
126const levelsOf = (all: Unit[], base: Dir): number[] => {
127 const t = all.map(u => bidiClass(u.ch))
128 t.forEach((c, i) => {
129 if ((c === 'ES' || c === 'CS') && t[i - 1] === 'EN' && t[i + 1] === 'EN') t[i] = 'EN'
130 else if (c === 'CS' && t[i - 1] === 'AN' && t[i + 1] === 'AN') t[i] = 'AN'
131 })
132 for (let i = 1; i < t.length; i++) if (t[i] === 'ET' && t[i - 1] === 'EN') t[i] = 'EN'
133 for (let i = t.length - 2; i >= 0; i--) if (t[i] === 'ET' && t[i + 1] === 'EN') t[i] = 'EN'
134 let strong: string = base
135 const w = t.map(c => {
136 if (c === 'L' || c === 'R') strong = c
137 return c === 'EN' && strong === 'L' ? 'L' : c === 'ES' || c === 'ET' || c === 'CS' ? 'N' : c
138 })
139 const rtlSide = (c?: string) => (c ?? base) !== 'L'
140 const ltr = base === 'L' ? 0 : 2
141 const rtl = 1
142 return w.map((c, i) => {
143 if (c !== 'N') return c === 'L' ? ltr : c === 'R' ? rtl : 2
144 let a = i
145 while (w[a] === 'N') a--
146 let b = i
147 while (w[b] === 'N') b++
148 const [before, after] = [rtlSide(w[a]), rtlSide(w[b])]
149 return before === after ? (before ? rtl : ltr) : base === 'L' ? 0 : 1
150 })
151}
152
153const terminalBidi = <U extends Unit>(all: U[], mirror = true): U[] => {
154 const levels = levelsOf(all, 'L')
155 let out = all.map((u, i) => ({ u: mirror && levels[i]! % 2 ? { ...u, ch: MIRROR[u.ch] ?? u.ch } : u, l: levels[i]! }))
156 for (let l = 2; l >= 1; l--) {
157 const done: typeof out = []
158 let run: typeof out = []
159 for (const x of out) {
160 if (x.l >= l) run.push(x)
161 else {
162 done.push(...run.reverse(), x)
163 run = []
164 }
165 }
166 out = [...done, ...run.reverse()]
167 }
168 return out.map(x => x.u)
169}
170
171export const terminalLine = (nodes: Inline[]): Inline[] => rebuild(terminalBidi(flatten(nodes)))
172
173const wrapUnits = (all: Unit[], max: number, measure: (s: string) => number): Unit[][] => {
174 const tokens: Unit[][] = []
175 all.forEach((u, i) => {
176 if (i === 0 || (u.ch !== ' ' && all[i - 1]!.ch === ' ')) tokens.push([])
177 tokens.at(-1)!.push(u)
178 })
179 const lines: Unit[][] = [[]]
180 let used = 0
181 const trim = (line: Unit[]) => {
182 while (line.at(-1)?.ch === ' ') line.pop()
183 }
184 for (const token of tokens) {
185 let core = token.length
186 while (core > 0 && token[core - 1]!.ch === ' ') core--
187 const w = token.slice(0, core).reduce((sum, u) => sum + measure(u.ch), 0)
188 if (lines.at(-1)!.length && used + w > max) {
189 trim(lines.at(-1)!)
190 lines.push([])
191 used = 0
192 }
193 lines.at(-1)!.push(...token)
194 used += w + (token.length - core)
195 }
196 trim(lines.at(-1)!)
197 return lines
198}
199
200const clusters = (text: string): string[] => units(text, { wrap: [], leaf: 'text' }).map(u => u.ch)
201
202const RUN = new RegExp(`(?:${R.source}|\\p{M})+`, 'gu')
203
204const shapeText = (text: string, shape: Shape): string =>
205 !R.test(text) ? text : shape === 'words' ? text.replace(RUN, run => clusters(run).reverse().join('')) : shape === 'inverse' ? terminalBidi(units(text, { wrap: [], leaf: 'text' })).map(u => u.ch).join('') : text
206
207const shapeNodes = (nodes: Inline[], shape: Shape): Inline[] =>
208 nodes.map(n => {
209 if ('children' in n) return { ...n, children: shapeNodes(n.children, shape) }
210 return { ...n, text: shapeText(n.text, shape) }
211 })
212
213export const containsRtl = (nodes: Inline[]): boolean => nodes.some(n => ('children' in n ? containsRtl(n.children) : R.test(n.text)))
214
215export const WALL = '׀'
216
217export const ROW_MARK_TEXT = `${WALL} `
218
219export const ROW_MARK: Inline = { kind: 'dim', text: ROW_MARK_TEXT }
220
221const asWritten = (shape: Shape) => shape === 'logical' || shape === 'mirror' || shape === 'engine'
222
223const ISOLATED: Fmt['leaf'][] = ['code', 'math', 'path']
224
225const isolate = (all: Unit[]): Unit[] =>
226 all.flatMap((u, i) => {
227 if (!ISOLATED.includes(u.fmt.leaf)) return [u]
228 const mark = { ch: LRM, fmt: u.fmt }
229 const opens = i === 0 || !sameFmt(all[i - 1]!.fmt, u.fmt)
230 const closes = i === all.length - 1 || !sameFmt(all[i + 1]!.fmt, u.fmt)
231 return [...(opens ? [mark] : []), u, ...(closes ? [mark] : [])]
232 })
233
234const forEngine = (line: Unit[], lead: boolean): Unit[] => {
235 const all = isolate(line)
236 const first = all.map(u => bidiClass(u.ch)).find(c => c === 'L' || c === 'R')
237 const levels = levelsOf(all, lead || first === 'R' ? 'R' : 'L')
238 return all.map((u, i) => (levels[i]! % 2 ? { ...u, ch: MIRROR[u.ch] ?? u.ch } : u))
239}
240
241const startsLatin = (line: Unit[]): boolean => line.map(u => bidiClass(u.ch)).find(c => c === 'L' || c === 'R') === 'L'
242
243export const opensLatin = (text: string): boolean => startsLatin(units(text, { wrap: [], leaf: 'text' }))
244
245export const flow = (nodes: Inline[], columns: number, measure: (s: string) => number, shape: Shape = 'visual', mark = true): Flow | null => {
246 if (!containsRtl(nodes)) return null
247 const all = flatten(nodes)
248 const base = shape === 'mirror' ? 'R' : baseOf(all)
249 const marked = mark && asWritten(shape) && base === 'R' && all.some(u => bidiClass(u.ch) === 'L')
250 const logical = base === 'R' ? wrapUnits(all, columns - (marked ? measure(ROW_MARK_TEXT) : 0), measure) : [all]
251 const lines = logical.map(line => {
252 const lead = marked && startsLatin(line)
253 const built = rebuild(shape === 'engine' ? forEngine(line, lead) : asWritten(shape) ? line : shape === 'inverse' ? terminalBidi(reorder(line, base)) : reorder(line, base))
254 return lead ? [ROW_MARK, ...built] : built
255 })
256 return { base, lines: shape === 'words' ? lines.map(line => shapeNodes(line, shape)) : lines }
257}
258
259const visualText = (text: string): string => {
260 if (!R.test(text)) return text
261 const all = units(text, { wrap: [], leaf: 'text' })
262 return reorder(all, baseOf(all)).map(u => u.ch).join('')
263}
264
265export const commentTail = (line: string, shape: Shape = 'visual'): { head: string; marker: string; tail: string } | null => {
266 if (asWritten(shape) || !R.test(line)) return null
267 const m = COMMENT.exec(line)
268 return m && R.test(m[3]!) ? { head: m[1]!, marker: m[2]!, tail: shapeText(visualText(m[3]!), shape) } : null
269}
270
271export const commentVisual = (text: string, shape: Shape = 'visual'): string => {
272 const c = commentTail(text, shape)
273 return c ? c.head + c.marker + c.tail : text
274}
275
276export const hasRtl = (text: string): boolean => R.test(text)
277
278export const LTR_BLANK = '⠀'
279
280const WORD = /[\p{L}\p{N}\p{P}]/u
281
282const DRAWN = /[←-⇿─-◿]/u
283
284const ASCII_DRAWN = /[-+|<>]/
285
286const LETTER = /[\p{L}\p{N}]/u
287
288const drawnAt = (all: Cell[], i: number) => {
289 const ch = all[i]!.ch
290 return DRAWN.test(ch) || (ASCII_DRAWN.test(ch) && !(LETTER.test(all[i - 1]?.ch ?? '') && LETTER.test(all[i + 1]?.ch ?? '')))
291}
292
293const inLabel = (chars: string[], c: number) => {
294 const [a = '', b = ''] = [chars[c - 1], chars[c + 1]]
295 return WORD.test(a) && WORD.test(b) && (R.test(a) || R.test(b))
296}
297
298type Cell = Unit & { at: number }
299
300export type Held = { cells: string[]; from: number[] }
301
302const PLAIN: Fmt = { wrap: [], leaf: 'text' }
303
304const rightToLeft = (all: Cell[]): Cell[] => (baseOf(all) === 'R' && all.some(u => bidiClass(u.ch) === 'L') ? terminalBidi(reorder(all, 'R')) : all)
305
306const byWords = (label: (words: Cell[]) => Cell[]) => (all: Cell[]): Cell[] => {
307 const out: Cell[] = []
308 let words: Cell[] = []
309 const flush = () => {
310 out.push(...(words.some(u => R.test(u.ch)) ? label(words) : words))
311 words = []
312 }
313 all.forEach((u, i) => {
314 if (!drawnAt(all, i)) words.push(u)
315 else {
316 flush()
317 out.push(u)
318 }
319 })
320 flush()
321 return out
322}
323
324const engineLabel = byWords(words => (baseOf(words) === 'R' ? terminalBidi(reorder(words, 'R'), false) : words))
325
326const visualLabel = byWords(words => reorder(words, baseOf(words)))
327
328const hold = (chars: string[], label: (all: Cell[]) => Cell[], blank: string[]): Held => {
329 const held: Held = { cells: [], from: [] }
330 let run: Cell[] = []
331 const flush = () => {
332 for (const u of label(run)) {
333 held.cells.push(u.ch)
334 held.from.push(u.at)
335 }
336 run = []
337 }
338 chars.forEach((ch, c) => {
339 if (ch === ' ' && !inLabel(chars, c)) {
340 flush()
341 for (const b of blank) {
342 held.cells.push(b)
343 held.from.push(c)
344 }
345 } else if (MARK.test(ch) && run.length) run.at(-1)!.ch += ch
346 else run.push({ ch, fmt: PLAIN, at: c })
347 })
348 flush()
349 return held
350}
351
352export const asIs = (chars: string[]): Held => ({ cells: chars, from: chars.map((_, c) => c) })
353
354export const holdText = (chars: string[]): Held => hold(chars, rightToLeft, [LTR_BLANK])
355
356export const listMarker = (marker: string, shape: Shape): string => (shape === 'logical' ? marker : marker.replace(/^(\d+)([.)])$/, (_, n: string, p: string) => (MIRROR[p] ?? p) + n))
357
358export const holdEngine = (chars: string[]): Held => (chars.some(ch => R.test(ch)) ? hold(chars, engineLabel, [LRM, ' ']) : asIs(chars))
359
360export const holdVisual = (chars: string[]): Held => (chars.some(ch => R.test(ch)) ? hold(chars, visualLabel, [' ']) : asIs(chars))
361
362export const holdRow = (chars: string[]): Held => (chars.some(ch => R.test(ch)) ? holdText(chars) : asIs(chars))
363
364export const led = (chars: string[], held: Held): Held => {
365 const blank = chars.some(ch => R.test(ch)) ? LTR_BLANK : ' '
366 return { cells: [blank, blank, ...held.cells], from: [-1, -1, ...held.from] }
367}
368