SLOPSHOPPER

prismantis

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

newpanespinnerrowscommandtoast
★ 2v0.31.1MITupdated 2026-10-20Hanafubuki/ops-control/plugins/prismantis
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prismantis
│ ┃ prismantis-setup ✕ ╭─────────────────────────────────────────────────────────────────╮ │ ┃ Setup 1/7: Theme │ fix the failing auth test and add an audit log call │ │ ┃ ━━━━━━━━━━━━━━━━ ╰─────────────────────────────────────────────────────────────────╯ │ ┃ │ ┃ Each theme draws the same line. random picks ⏺ Read(src/auth.ts) │ ┃ a new dark one each time the mod loads. ⎿ Read 6 lines │ ┃ Edited src/auth.ts ● │ ┃ ↑↓ or Tab moves, Enter saves and shows the ⏺ Bash(bun test) │ ┃ next step, Esc closes. ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ catppuccin-mocha (now) ] 1. catppuccin- Done. I made refresh reject expired claims and added an audit call. │ ┃ [ catppuccin-latte ] │ ┃ [ dracula ] Release notes ✻ Worked for 42s │ ┃ [ nord ] │ ┃ [ tokyo-night ] Bold, inline c › /prismantis │ ┃ [ gruvbox-dark ] Commands │ ┃ [ gruvbox-light ] ┌─────────┬─── ━━━━━━━━ │ ┃ [ rose-pine ] │ Service │ St │ ┃ [ rose-pine-dawn ] ╞═════════╪═══ [ ⧉ md ] [ ⧉ art │ ┃ [ everforest ] │ api │ he ┌──────────────────────────┬─────────────────────────────────────── │ ┃ [ github-dark ] ├─────────┼─── ─────────────────────────────────────────────────────────────────── │ ┃ [ github-light ] │ billing │ de ──────┐ │ ┃ [ one-dark ] └─────────┴─── │ Command │ Does │ ┃ [ solarized-dark ] ╞══════════════════════════╪═══════════════════════════════════════ │ ┃ [ solarized-light ] ── ts ═══════════════════════════════════════════════════════════════════ │ ┃ [ mono ] const total ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · prismantis-setup
Setup 1/7: Theme ━━━━━━━━━━━━━━━━ Each theme draws the same line. random picks a new dark one each time the mod loads. ↑↓ or Tab moves, Enter saves and shows the next step, Esc closes. [ catppuccin-mocha (now) ] 1. catppuccin-mocha [ catppuccin-latte ] [ dracula ] Release notes [ nord ] [ tokyo-night ] Bold, inline code, 250ms and ~ [ gruvbox-dark ] [ gruvbox-light ] ┌─────────┬──────────┬───────┐ [ rose-pine ] │ Service │ Status │ p95 │ [ rose-pine-dawn ] ╞═════════╪══════════╪═══════╡ [ everforest ] │ api │ healthy │ 120ms │ [ github-dark ] ├─────────┼──────────┼───────┤ [ github-light ] │ billing │ degraded │ 840ms │ [ one-dark ] └─────────┴──────────┴───────┘ [ solarized-dark ] [ solarized-light ] ── ts [ mono ] const total = items.reduce(( [ random ] 0)
Turn line
✻ Worked for 42s
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
Edited src/auth.ts ●
Command output
Commands ━━━━━━━━ [ ⧉ md ] [ ⧉ art ] ┌──────────────────────────┬──────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────┐ │ Command │ Does ╞══════════════════════════╪════════════════════════════════════════════════════════════════════════ ════════════════════════════════════════════╡ │ /prismantis theme <name> │ Switch theme on the spot (/prismantis <name> works too), random for a n ├──────────────────────────┼──────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────┤ │ /prismantis copy │ Copy the last reply, or copy code for its last code block ├──────────────────────────┼──────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────┤ │ /prismantis setup │ Pick a theme, table, task, prompt, tool row and tool output style from ├──────────────────────────┼──────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────┤ │ /prismantis demo │ Full showcase, every element and diagram, plus a section in your langua ├──────────────────────────┼──────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────┤ │ /prismantis demo-rtl │ Hebrew right-to-left showcase └──────────────────────────┴──────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────┘ 16 themes [ ⧉ copy ] • Dark: catppuccin-mocha, 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 • Plain: mono, no color, only bold and dim Docs and issues: https://github.com/NahumLitvin/prismantis ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Tip │ │ Any color slot beats the theme. Set headingColor or numberColor to a hex value in /config. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ Task lists [ ⧉ copy ] [✓] Tables, diagrams and charts drawn in the terminal [✓] Copy tables as Markdown, art or HTML (macOS/Linux) [ ] Pick a taskStyle in /config: checks, ticks, box or progress [ ⧉ source ] [ ⧉ art ] ┌───────┐ ┌───────┐ ┌──────┐ ┌────────┐ │ Reply ├──►│ Parse ├──►│ Draw ├──►│ Screen │ └───────┘ └───────┘ └──────┘ └────────┘ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Caution │ │ Claude Code refuses trees over 20000 nodes. A code block past about 500 highlighted lines falls │ │ back to plain text. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

<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>

Plain Claude Code replies wiping into prismantis: a table, a bar chart and a mermaid flowchart

<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>

TL;DR

  • Claude Code prints mermaid as source and tool calls as walls of text. prismantis draws them: real charts and flowcharts, typeset LaTeX math in kitty and Ghostty, colored tables and code, and quiet tool rows.
  • 15 palettes plus mono, and copy buttons on everything.
  • Two commands to install, /plugin to turn it off. Claude Code's own renderer comes back the moment you do.
  • No network calls. It runs a program only when you click ⧉ 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.

Install

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.

Codex

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.

Features

FeatureWhat you get
Themes/prismantis theme <name> switches on the spot. 16 themes and 20 color slots you can override
Tablescolored headers, rules, column alignment, colored numbers, sized to the terminal
Codea language header and copy button, Prism highlighting in two dozen languages, shell lines colored like a prompt
Diagrams and chartsflowcharts, 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
Layoutback-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 rowsRan 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 commandscommand output (/cost, /context, plugin commands) gets the same tables and code styling
Diagram hintsa 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
Textbold, italic, strikethrough, inline code, links, versions, durations, percentages and paths in their own colors
Headings, lists, quotes4 heading styles, nested lists, task lists, quotes with an accent bar
Your promptswhat 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 leftHebrew 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

Themes

The same reply in all 15 color themes, ten dark and five light: a tip box, a task list, a flowchart and a bar chart

Random theme: plain Claude Code on the left, prismantis with theme random on the right

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.

Tables

Tables: plain Claude Code on the left, prismantis on the right

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.

Code

Code: plain Claude Code on the left, prismantis on the right

Code fold: plain Claude Code on the left, prismantis numbering and folding a 45 line block on the right

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.

  • Prism highlights JavaScript, TypeScript, JSX/TSX, Python, Go, Rust, Java, Kotlin, Swift, C, C++, C#, Ruby, JSON, YAML, TOML, SQL, HTML, CSS, Dockerfile, HCL, Lua and diff.
  • Shell blocks (bash, sh, zsh, fish, powershell, unlabeled) color the command word, --flags, quoted strings and # comments, and restart after |, && and ;.

Diagrams and charts

Diagrams and charts: plain Claude Code on the left, prismantis on the right

Diagrams and charts: plain Claude Code on the left, prismantis on the right

Diagram too wide: plain Claude Code on the left, prismantis falling back to the source with a note on the right

Code blocks tagged mermaid draw as colored text art:

  • flowcharts (graph LR, graph TD, decisions), sequence, state, class and ER diagrams
  • bar and line charts with xychart-beta, sized to the terminal width
  • pie 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 apart
  • each box and participant gets its own theme color, the same at both ends of a sequence diagram
  • bars in a multi-series chart get their own colors; a single-series chart prints each value above its bar and colors the tallest one
  • gridlines stay dim, axis numbers use the number color

pieStyle 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 legendone bar per slice, largest first, with its share
roundbars

A round pie of visitors by referrer with a colored legend, beside a gantt chart of a week of releases on a shared date axis

Diagrams too wide for the window, or over 80 lines, stay as code. mermaidAscii swaps box-drawing characters for + - |.

Figures

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:

A figure drawn by prismantis: a time axis with ticks at 0, 10 and 12 s, the provider bar timing out at 10 s, the handler bar finishing at 12 s, and bookings going from 0 to 1 to 2

{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 color
  • ▶ and ◀ draw as ► and ◄, as in diagrams, so no glyph turns into an emoji; in mono, warn draws bold
  • a figure stays a code block while it streams (so a reply that opens with a figure shows its markup until the block closes), when it has more than 80 lines, when a line is wider than the window or than 100 columns, and when a line would not draw as written: a glyph that may draw as an emoji or takes two columns, a tab, a control character, a direction mark or another character that draws no column (a zero-width space, a soft hyphen), or role markup in the wrong case, misspelled, nested or left open; once the block is closed, a dim line above it says why, such as figure line 4: "⚠" may draw as an emoji, use a text glyph
  • frames are not checked: a box that does not close or a side, corner or divider out of its column draws as written, as Claude typed it, so a frame that is slightly off still draws
  • ⧉ source copies the block as Claude wrote it, ⧉ art the drawn text
  • figures draw whether mermaid is on or off; only the diagram hints note follows mermaid
  • Claude learns about figures from the diagram hints note: prefer a figure over mermaid when position carries meaning (durations racing on a time axis, a screen layout, phases, before and after); when you ask 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; the prose does not retell the picture; then the roles, the width and the glyphs that draw cleanly; plain flows and charts stay mermaid

LaTeX math

LaTeX math: plain Claude Code on the left, prismantis on the right

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.

Layout

Layout: plain Claude Code on the left, prismantis on the right

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.

Copy buttons

Copy buttons: plain Claude Code on the left, prismantis on the right

  • Tables: ⧉ 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.
  • Code, lists and quotes: [ ⧉ copy ] on each. Code copies without its fences, lists as markdown, quotes without their > markers.
  • Diagrams and figures: ⧉ source copies the mermaid or figure source, ⧉ art the drawn art.
  • Whole replies: replies with more than one block, or any Hebrew or Arabic, end with ⧉ 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.
  • No mouse: /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.

copyButtonStyle hover: a table's ⧉ md and ⧉ art buttons floating over the top-right of its border while the pointer is on it

⧉ 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.

Tool rows

Tool rows: plain Claude Code on the left, prismantis on the right

Diff cards: plain Claude Code on the left, prismantis on the right

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-dimtree-boldclassic
dimmed on the right, capped at 60% of the widthtucked under the sentence with ⎿, dimmedtree-dim with one-line sentences in boldbold verb and status dot, full brightness
chattree-dimtree-boldclassic

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.

quiet: rows say what was read, nothing drawn under reads

Turn footer

Turn footer: plain Claude Code on the left, prismantis on the right

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.

Slash commands

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.

Diagram hints

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.

Text

Text: plain Claude Code on the left, prismantis on the right

Footnotes: plain Claude Code on the left, prismantis on the right

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.

Headings, lists, quotes

Headings, lists, quotes: plain Claude Code on the left, prismantis on the right

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.

A task list with done items struck through

Your prompts

Your prompts: plain Claude Code on the left, prismantis on the right

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)barchevron
a prompt in a rounded boxa prompt with an accent bara prompt with a bold chevron

clock adds the time: each prompt shows when you sent it, and the l

Source 17 files
hooks/register.tsx 694 lines
1import { 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}
694
hooks/clock.ts 39 lines
1import 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}
39
hooks/latex.ts 136 lines
1import 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}
136
hooks/markdown.ts 453 lines
1export 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}
453
hooks/clipboard.ts 70 lines
1export 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  }
70
hooks/figure.tsx 258 lines
1import 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}
258
hooks/mermaid.tsx 337 lines
1import 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}
337
hooks/render.tsx 1701 lines
1import 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 lines
1import { 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![prism band](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUAAAAAoCAIAAAAAIrR+AAACdElEQVR42u3TewcQBgAA8ZNkkkySJEmSJJOWrW3t/cyWJJlMkmQmSZIkSSaTJEmSJEmStLS0trX3smVLkiRJkiTJZCZJdrNvsP0xNu73Fc4hOBAfwyH4OA7HkTgax+J4nIiTcQpOw+k4A1/EV/FNfBtn41x8F9/DhbgY38eluBxX4mpci+vxQ/wIN+NW3I47cTfuxf14EA/jUTyOJ/EUfo3f44/4M57DC3gJr+A1vIG38A7+ir/hfXyIf/BIHsjvck/uym25KdflqlyWi3JezsoZOS3fyVfyuZyQY3JEDskB2Sd7ZJfskG2yRTbJRtkg62SNrJIVskw+kCWySBbIfJknc2SWzJQ35BV5QZ6Vp2SqPCGTZIKMkzEySkbIMBkqg2WQDPgrwAAchINxKA7DETgKx+A4nICT8Amcik/hM/g8voyv40ychXNwHs7HBbgIl+AHuAxX4Cpcg+twA27ETbgFt+EO3IV7cB8ewEN4BI/hCfwMv8Rv8TSewbN4Hi/iZbyK1/Em3sa7eA9/xwf4iD/kodyX3+RXuSO35IZckytySS7IOflFfpIf5Bs5JSfluByVw3JQ9ste2S07Zbtslc3ykXwo62WtrJaVslyWyvuyWBbKe/KuzJXZ8o68Ja/JSzJDpss0mSKTZaKMl7EyWkbKcHlchshjMvDvHvAkPo3P/TsHfIyf4Kf4xT88YABJ/rcaOGngJA2cpIGTBk7SwEkaOEkDJw2cpIGTNHDSwEkaOEkDJ2ngpIGTNHCSBk7SwEkDJ2ngJA2cNHCSBk7SwEkaOGngJA2cpIGTNHDSwEkaOEkDJw2cpIGTNHCSBk4aOMl/yZ8SRd1gPq93WQAAAABJRU5ErkJggg==)
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}
419
hooks/presets.ts 114 lines
1import 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[]
114
hooks/theme.ts 103 lines
1import 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}
103
hooks/rtl.ts 368 lines
1import 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