Drafts of emails, replies and messages open in a side pane, ready to copy as rich text, markdown or plain text.

Claude writes the email. You are the meat proxy that sends it. This mod makes that part one key.
Ask Claude Code to draft an email, a reply, or a Slack message. The draft opens in a pane on the right side of the terminal, from top to bottom. Copy it as rich text, markdown, or plain text with one click or one key, then paste it into Gmail, Outlook, Mail, Slack, or Teams.
No more "put the email in an md file in /tmp", then cat, then select, then copy.
❯ Draft a reply to Begoña: nothing is missing, │ ✕
the payments are in the transfers of 13 and 14 Oct. │ Copy r: Rich text m: Markdown p: Plain s: Subject
│ k: ‹ Older j: Newer › Draft 2 of 2 ✓ Copied rich text.
⏺ meatproxy - draft (MCP)(to: "Begoña", │ ─────────────────────────────────────────────────────
subject: "RE: Facturación de octubre", │
body: "12 lines, shown in the Draft pane") │ To: Begoña
│ Subject: RE: Facturación de octubre
⏺ The draft is in the Draft pane. │
│ Hola Begoña:
│
│ No falta nada. Los pagos de Lucía están en las
│ transferencias del 13 y del 14 de octubre.
│
│ ┌───────────────┬───────┬─────────┐
│ │ Transferencia │ Fecha │ Importe │
│ ├───────────────┼───────┼─────────┤
│ │ T-101 │ 13/10 │ 120 EUR │
│ │ T-102 │ 14/10 │ 80 EUR │
│ └───────────────┴───────┴─────────┘
│
│ Un saludo,
│ Andrés
At the prompt of a Claude Code session in a terminal:
/plugin install meatproxy --marketplace rolottr/claude-mod-meatproxy
Or from a shell:
claude plugin install meatproxy --marketplace rolottr/claude-mod-meatproxy
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). The mod is active at once, with no restart.
In the Claude Desktop app, click + next to the prompt box, then Plugins, Add marketplace with rolottr/claude-mod-meatproxy, then Add plugin.
claude --version). Mods do not run in older versions.~/.claude/settings.json: { "tui": "fullscreen" }
In the default layout, the pane opens above the prompt, at full width.
Ask for a draft as you always do. You do not need a command or a keyword:
Claude puts the draft in the pane and does not repeat it in the chat. When you ask for a change ("make it shorter", "add a total row"), the new version opens as the next draft, and the older versions stay in the history.
| In the pane | What it does |
|---|---|
r Rich text | Copies the draft with its format: bold, links, lists, and tables. |
m Markdown | Copies the markdown source. |
p Plain | Copies plain text, with no markdown signs. |
s Subject | Copies the subject line (emails only). |
k ‹ Older / j Newer › | Steps through the drafts. |
✕ or Esc | Closes the pane. |
Click an item with the mouse, or run /draft to give the pane the keyboard and press the keys.
| Command | What it does |
|---|---|
/draft | Opens the pane with the last draft. |
/draft rich | Copies the last draft as rich text, with no pane. |
/draft md | Copies the last draft as markdown. |
/draft plain | Copies the last draft as plain text. |
/draft subject | Copies the subject of the last draft. |
/draft close | Closes the pane. |
The mod keeps your last 30 drafts on your computer, so /draft also finds a draft from an earlier session.
Rich text goes on the clipboard in the formats that mail and chat apps read:
| System | Tool | Formats |
|---|---|---|
| macOS | osascript (built in) | HTML, RTF, and plain text |
| Linux, Wayland | wl-copy (package wl-clipboard) | HTML |
| Linux, X11 | xclip | HTML |
| Windows | PowerShell Set-Clipboard -AsHtml | HTML |
Markdown and plain text use Claude Code's own clipboard, so they work everywhere.
The mod gives Claude a draft tool and tells Claude to use it for every text that you will send to another person. This rule applies also when you ask for an md file or /tmp. Plans, notes, and documents for yourself still go to files as usual.
Where nothing can show a pane (the VS Code extension's chat panel, or claude -p), the tool tells Claude to show the draft in the chat.
If your organization manages Claude Code, it can stop the mod's rule in the system prompt and its reminder on each request. The tool's own description still tells Claude to use the pane.
claude plugin uninstall meatproxy
Or run /plugin, open the Installed tab, and remove meatproxy.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
hooks/register.tsx 318 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Draft } from '../types'
5import { copyRich } from './clipboard'
6import { htmlDocument, toPlain } from './markdown'
7
8const PANE = 'draft'
9const TOOL = 'mcp__meatproxy__draft'
10const COMMAND = 'draft'
11// An email reads best at about 72 characters a line. The dock asks for
12// that width, but never more than about 42% of the terminal, so the
13// transcript keeps most of the screen.
14const WIDTH = 76
15const MIN_WIDTH = 48
16const KEPT = 30
17const STORE_KEY = 'drafts'
18
19const drafts = atom({ plugin: 'meatproxy', key: 'drafts' } as const, [] as Draft[])
20const shown = atom({ plugin: 'meatproxy', key: 'shown' } as const, -1)
21const notice = atom({ plugin: 'meatproxy', key: 'notice' } as const, null as string | null)
22
23const RULE = `# Drafts for other people (meatproxy)
24
25When the user asks you to draft, write, prepare, answer, reword or translate a text that they will send to someone else (an email, a reply, a chat message, a ticket, a comment, a review, a post), call the ${TOOL} tool with that text. The tool shows the draft in a pane beside the conversation, where the user copies it as rich text, markdown or plain text with one key.
26
27- Do not write the draft to a file. This holds also when the user asks for the draft "in an md file" or "in /tmp": that was their old way to get a draft they can copy, and the pane replaces it. Write a file as well only when they need the file itself, for example to attach it or to commit it.
28- Do not repeat the draft in your reply. After the call, write one short line at most: an open question, or an assumption you made.
29- Put only the text to send in \`body\`, as markdown. Put an email's subject in \`subject\` and the recipients in \`to\`, not in \`body\`.
30- When the user asks for a change, call the tool again with the full new text.
31- If the tool says that no pane is available, show the draft in your reply instead.`
32
33// Words that mark a request for a text to send, in English and Spanish.
34// A match adds a short reminder beside the prompt; the reminder itself
35// says that it applies only to texts for another person.
36const DRAFT_WORDS =
37 /\b(draft|e-?mails?|mails?|reply|replies|respond|message|slack|teams|ticket|letter|borrador|correos?|respuesta|respond[ea]r?|contesta(r)?|mensajes?|redact[ae]r?|carta)\b|\bmd\b|\/tmp\b/i
38
39const REMINDER = `meatproxy: If this prompt asks for a text that the user will send to another person (an email, a reply, a message, a ticket), show it with the ${TOOL} tool. Do not write it to a file, even if the prompt says "md file" or "/tmp": the Draft pane replaces that file. Plans, notes and documents that are not messages for another person are not drafts: handle them as usual.`
40
41const DESCRIPTION = `Shows a draft that the user will send to someone else (an email, a reply, a chat message, a ticket, a comment) in a pane where they copy it as rich text, markdown or plain text. Use it every time you draft such a text, instead of writing it to a file or into your reply. Call it again with the full text for each new version.`
42
43const INPUT_SCHEMA = {
44 type: 'object',
45 properties: {
46 body: {
47 type: 'string',
48 description: 'The text to send, in markdown: greeting, body and sign-off. Not the subject.',
49 },
50 subject: {
51 type: 'string',
52 description: "The email's subject line, for example 'RE: Payouts of 13 October'. Leave it out for chat messages.",
53 },
54 to: {
55 type: 'string',
56 description: 'Who the text is for, for example "Begoña" or "#payments channel".',
57 },
58 },
59 required: ['body'],
60 additionalProperties: false,
61}
62
63type Format = 'rich' | 'md' | 'plain' | 'subject'
64
65const LABELS: Record<Format, string> = {
66 rich: 'rich text',
67 md: 'markdown',
68 plain: 'plain text',
69 subject: 'the subject',
70}
71
72function current(list: readonly Draft[], at: number): { draft: Draft; index: number } | undefined {
73 if (list.length === 0) {
74 return undefined
75 }
76 const index = at < 0 || at >= list.length ? list.length - 1 : at
77 const draft = list[index]
78
79 return draft === undefined ? undefined : { draft, index }
80}
81
82function asText(value: unknown): string | undefined {
83 return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
84}
85
86async function say($: EngineInterface, text: string) {
87 await update($, notice, () => text)
88 $.clock.after(4000, () => {
89 void update($, notice, now => (now === text ? null : now))
90 })
91}
92
93async function copy($: EngineInterface, draft: Draft, format: Format, surface?: RenderSurface): Promise<string> {
94 let reason: string | undefined
95
96 if (format === 'rich') {
97 const done = await copyRich($, htmlDocument(draft.body), toPlain(draft.body))
98 reason = done.isCopied ? undefined : done.reason
99 } else if (format === 'subject' && !draft.subject) {
100 reason = 'this draft has no subject'
101 } else {
102 const text = format === 'md' ? draft.body : format === 'plain' ? toPlain(draft.body) : (draft.subject as string)
103 const done = await $.ui.copy({ text, surface })
104 reason = done.isCopied ? undefined : done.reason
105 }
106
107 const line = reason === undefined ? `Copied ${LABELS[format]}.` : `Could not copy ${LABELS[format]}: ${reason}`
108 await say($, line)
109
110 return line
111}
112
113// The terminal width, as the last drawing measured it.
114let terminalColumns: number | undefined
115
116function paneWidth(): number {
117 if (terminalColumns === undefined) {
118 return WIDTH
119 }
120
121 return Math.max(MIN_WIDTH, Math.min(WIDTH, Math.floor(terminalColumns * 0.42)))
122}
123
124// `focus` is only for the person's own /draft: a pane that took the keys
125// in the middle of a turn would eat what they type next.
126async function openPane($: EngineInterface, isFocused = false) {
127 return $.ui.open({ id: PANE, title: 'Draft', columns: paneWidth(), closeOnEscape: true, ...(isFocused ? { focus: true as const } : {}) })
128}
129
130export const register: Register = on => {
131 on('session.start', async ($, e, next) => {
132 await $.tool.register({ name: 'draft', description: DESCRIPTION, inputSchema: INPUT_SCHEMA, isDeferred: false })
133 await $.command.register({
134 name: COMMAND,
135 description: 'Open the draft pane, or copy the last draft: /draft rich | md | plain | subject',
136 argumentHint: '[rich|md|plain|subject|close]',
137 })
138
139 // Drafts from earlier sessions come back, so /draft still finds the
140 // email you wrote yesterday.
141 if ((await read($, drafts)).length === 0) {
142 const kept = await $.store.get(STORE_KEY)
143 if (Array.isArray(kept) && kept.length > 0) {
144 await update($, drafts, () => kept as Draft[])
145 }
146 }
147
148 return next(e)
149 })
150
151 on('prompt.compose', async ($, e, next) => {
152 const composed = await next(e)
153 if (!e.tools.includes(TOOL)) {
154 return composed
155 }
156
157 return { sections: [...composed.sections, { id: 'meatproxy:drafts', text: RULE, scope: 'session' as const }] }
158 })
159
160 on('prompt.submit', async ($, e, next) => {
161 if (e.command !== undefined || e.isMeta === true || !DRAFT_WORDS.test(e.text)) {
162 return next(e)
163 }
164
165 return next({ ...e, context: [...(e.context ?? []), REMINDER] })
166 })
167
168 on('tool.call', { tool: TOOL }, async ($, e) => {
169 const body = asText(e.body)
170 if (body === undefined) {
171 return { deny: 'The draft needs a non-empty `body`.' }
172 }
173
174 const draft: Draft = { id: 0, body, subject: asText(e.subject), to: asText(e.to), createdAt: await $.clock.now() }
175 const list = await update($, drafts, old => [...old, { ...draft, id: (old[old.length - 1]?.id ?? 0) + 1 }].slice(-KEPT))
176 await update($, shown, () => -1)
177 await $.store.set(STORE_KEY, list)
178
179 const opened = await openPane($)
180 if (opened.isPlaced) {
181 return { result: 'The draft is open in the Draft pane, where the user copies it. Do not repeat it in your reply.' }
182 }
183
184 const surfaces = await $.session.surfaces()
185 if (surfaces.includes('terminal') || surfaces.includes('desktop')) {
186 $.ui.toast('Draft ready. Run /draft to open it.', { timeoutMs: 8000 })
187
188 return {
189 result: 'The draft is saved, but the terminal is too narrow to open the pane by itself. Tell the user to run /draft to open it. Do not repeat the draft in your reply.',
190 }
191 }
192
193 return { result: 'No pane is available on this surface. Show the draft to the user in your reply, exactly as written.' }
194 })
195
196 on('command.run', { command: COMMAND }, async ($, e) => {
197 terminalColumns = e.presentation.columns
198 const arg = e.args.trim().toLowerCase()
199
200 if (arg === 'close') {
201 await $.ui.close({ id: PANE })
202
203 return { text: 'Closed the draft pane.' }
204 }
205
206 const formats: Record<string, Format> = { rich: 'rich', html: 'rich', md: 'md', markdown: 'md', plain: 'plain', text: 'plain', subject: 'subject' }
207 const format = formats[arg]
208 if (format !== undefined) {
209 const found = current(await read($, drafts), await read($, shown))
210 if (!found) {
211 return { text: 'There is no draft yet. Ask Claude to draft a reply.' }
212 }
213
214 return { text: await copy($, found.draft, format) }
215 }
216
217 if (arg !== '') {
218 return { text: `Unknown option "${arg}". Use /draft, or /draft rich | md | plain | subject | close.` }
219 }
220
221 const opened = await openPane($, true)
222
223 return { text: opened.isPlaced ? 'Opened the draft pane. Press r, m, p or s to copy, Esc to close.' : `The draft pane could not open: ${opened.reason}` }
224 })
225
226 // The pane shows the draft, so the transcript row shows a short line
227 // in place of the whole body. The model still reads the full text.
228 on('ui.render', { component: 'ToolUse', props: { tool: TOOL } }, async ($, e, next) => {
229 terminalColumns = e.viewport?.columns ?? terminalColumns
230 const input = (e.props.input ?? {}) as Record<string, unknown>
231 const body = asText(input.body)
232 if (body === undefined) {
233 return next(e)
234 }
235 const lines = body.split('\n').length
236 const short: Record<string, string> = {}
237 for (const key of ['to', 'subject'] as const) {
238 const value = asText(input[key])
239 if (value !== undefined) {
240 short[key] = value
241 }
242 }
243 short.body = `${lines} ${lines === 1 ? 'line' : 'lines'}, shown in the Draft pane (/draft opens it)`
244
245 return next({ ...e, props: { ...e.props, input: short } })
246 })
247
248 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
249 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
250 const list = await read($, drafts)
251 const at = await read($, shown)
252 const line = await read($, notice)
253 const found = current(list, at)
254
255 if (!found) {
256 return (
257 <Box flexDirection="column">
258 <Text dimColor>No drafts yet. Ask Claude to draft a reply, and it shows here.</Text>
259 </Box>
260 )
261 }
262
263 const { draft, index } = found
264 const press = (format: Format) => (p: { surface: RenderSurface }) => {
265 void copy($, draft, format, p.surface)
266 }
267 const go = (step: number) => () => update($, shown, () => Math.min(list.length - 1, Math.max(0, index + step)))
268
269 // Key hints, not boxes: each button draws its hotkey in the accent
270 // color, then its label ("r: Rich text"). The pane's ✕ and Esc
271 // close it, so the toolbar has no Close button.
272 return (
273 <Box flexDirection="column" gap={1}>
274 <Box flexDirection="column">
275 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
276 <Text dimColor>Copy</Text>
277 <Button key="rich" hotkey="r" plain label="Rich text" onPress={press('rich')} />
278 <Button key="md" hotkey="m" plain label="Markdown" onPress={press('md')} />
279 <Button key="plain" hotkey="p" plain label="Plain" onPress={press('plain')} />
280 {draft.subject !== undefined && <Button key="subject" hotkey="s" plain label="Subject" onPress={press('subject')} />}
281 </Box>
282 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
283 {list.length > 1 && <Button key="prev" hotkey="k" plain dimColor label="‹ Older" onPress={go(-1)} />}
284 {list.length > 1 && <Button key="next" hotkey="j" plain dimColor label="Newer ›" onPress={go(1)} />}
285 <Text dimColor>
286 Draft {index + 1} of {list.length}
287 </Text>
288 {line !== null && (
289 <Text color={line.startsWith('Copied') ? 'green' : 'red'}>
290 {line.startsWith('Copied') ? '✓ ' : '✗ '}
291 {line}
292 </Text>
293 )}
294 </Box>
295 <Text dimColor>{'─'.repeat(Math.max(1, e.props.bodyColumns))}</Text>
296 </Box>
297 {(draft.to !== undefined || draft.subject !== undefined) && (
298 <Box flexDirection="column">
299 {draft.to !== undefined && (
300 <Text>
301 <Text dimColor>To: </Text>
302 {draft.to}
303 </Text>
304 )}
305 {draft.subject !== undefined && (
306 <Text>
307 <Text dimColor>Subject: </Text>
308 <Text bold>{draft.subject}</Text>
309 </Text>
310 )}
311 </Box>
312 )}
313 <Markdown key="body" text={draft.body} />
314 </Box>
315 )
316 })
317}
318hooks/clipboard.ts 97 lines1import type { EngineInterface } from 'claude-code'
2
3// Puts rich text on the system clipboard. `$.ui.copy` writes plain text
4// only, so this runs the platform's own clipboard tool:
5// - macOS: osascript (JXA) writes HTML, RTF and plain text in one item,
6// and each app pastes the richest type it reads.
7// - Linux: wl-copy (Wayland) or xclip (X11), as text/html.
8// - Windows: PowerShell's Set-Clipboard -AsHtml.
9
10export type RichCopy = { isCopied: true } | { isCopied: false; reason: string }
11
12type Platform = 'mac' | 'linux' | 'windows'
13
14const MAC_SCRIPT = `
15ObjC.import('AppKit');
16function run() {
17 var input = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;
18 var draft = JSON.parse($.NSString.alloc.initWithDataEncoding(input, $.NSUTF8StringEncoding).js);
19 var html = $(draft.html);
20 var board = $.NSPasteboard.generalPasteboard;
21 board.clearContents;
22 var rich = $.NSAttributedString.alloc.initWithHTMLDocumentAttributes(html.dataUsingEncoding($.NSUTF8StringEncoding), null);
23 if (rich && !rich.isNil()) {
24 var rtf = rich.RTFFromRangeDocumentAttributes($.NSMakeRange(0, rich.length), $.NSDictionary.dictionary);
25 if (rtf && !rtf.isNil()) board.setDataForType(rtf, $.NSPasteboardTypeRTF);
26 }
27 board.setStringForType(html, $.NSPasteboardTypeHTML);
28 board.setStringForType($(draft.plain), $.NSPasteboardTypeString);
29 return 'ok';
30}
31`
32
33const WINDOWS_SCRIPT =
34 '[Console]::InputEncoding = [Text.Encoding]::UTF8; ' +
35 '$html = [Console]::In.ReadToEnd(); ' +
36 'Set-Clipboard -Value $html -AsHtml'
37
38let platform: Platform | undefined
39
40async function detect($: EngineInterface): Promise<Platform> {
41 if (platform) {
42 return platform
43 }
44 if ((await $.env.get('OS')) === 'Windows_NT') {
45 platform = 'windows'
46
47 return platform
48 }
49 try {
50 const { stdout } = await $.process.run(['uname', '-s'], { timeoutMs: 5000 })
51 platform = stdout.trim() === 'Darwin' ? 'mac' : 'linux'
52 } catch {
53 platform = 'windows'
54 }
55
56 return platform
57}
58
59function failed(stderr: string, fallback: string): RichCopy {
60 return { isCopied: false, reason: stderr.trim().split('\n')[0] || fallback }
61}
62
63export async function copyRich($: EngineInterface, html: string, plain: string): Promise<RichCopy> {
64 try {
65 switch (await detect($)) {
66 case 'mac': {
67 const ran = await $.process.run(['osascript', '-l', 'JavaScript', '-e', MAC_SCRIPT], {
68 stdin: JSON.stringify({ html, plain }),
69 timeoutMs: 15000,
70 })
71
72 return ran.exitCode === 0 ? { isCopied: true } : failed(ran.stderr, 'osascript failed')
73 }
74 case 'linux': {
75 // Both tools fork a child that keeps serving the clipboard. Its
76 // output goes to /dev/null, so the call does not wait for it.
77 const tool = (await $.env.get('WAYLAND_DISPLAY'))
78 ? 'wl-copy --type text/html'
79 : 'xclip -selection clipboard -t text/html -i'
80 const ran = await $.process.run(['sh', '-c', `${tool} >/dev/null 2>&1`], { stdin: html, timeoutMs: 15000 })
81
82 return ran.exitCode === 0 ? { isCopied: true } : failed(ran.stderr, 'install wl-clipboard or xclip')
83 }
84 case 'windows': {
85 const ran = await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', WINDOWS_SCRIPT], {
86 stdin: html,
87 timeoutMs: 15000,
88 })
89
90 return ran.exitCode === 0 ? { isCopied: true } : failed(ran.stderr, 'PowerShell failed')
91 }
92 }
93 } catch (error) {
94 return { isCopied: false, reason: error instanceof Error ? error.message : String(error) }
95 }
96}
97hooks/markdown.ts 277 lines1// Markdown to HTML and to plain text, for what Claude writes in a draft:
2// paragraphs, headings, emphasis, links, lists, quotes, tables and code.
3// A single newline is a line break, as in an email ("Saludos,\nAna").
4
5type Block =
6 | { kind: 'heading'; level: number; text: string }
7 | { kind: 'paragraph'; lines: string[] }
8 | { kind: 'list'; isOrdered: boolean; start: number; items: string[][] }
9 | { kind: 'quote'; blocks: Block[] }
10 | { kind: 'code'; lines: string[] }
11 | { kind: 'table'; head: string[]; rows: string[][] }
12 | { kind: 'rule' }
13
14const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
15const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/
16const FENCE = /^\s{0,3}(```|~~~)/
17const BULLET = /^(\s*)([-*+])\s+(.*)$/
18const NUMBERED = /^(\s*)(\d{1,9})[.)]\s+(.*)$/
19const QUOTE = /^\s{0,3}>\s?(.*)$/
20const TABLE_RULE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/
21
22function cells(line: string): string[] {
23 const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '')
24
25 return trimmed.split(/(?<!\\)\|/).map(cell => cell.trim().replace(/\\\|/g, '|'))
26}
27
28function isBlockStart(line: string, nextLine: string | undefined): boolean {
29 return (
30 HEADING.test(line) ||
31 RULE.test(line) ||
32 FENCE.test(line) ||
33 BULLET.test(line) ||
34 NUMBERED.test(line) ||
35 QUOTE.test(line) ||
36 (line.includes('|') && nextLine !== undefined && TABLE_RULE.test(nextLine))
37 )
38}
39
40export function parse(markdown: string): Block[] {
41 const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
42 const at = (n: number): string => lines[n] ?? ''
43 const isEnd = (n: number) => n >= lines.length
44 const blocks: Block[] = []
45 let i = 0
46
47 while (!isEnd(i)) {
48 const line = at(i)
49
50 if (line.trim() === '') {
51 i += 1
52 continue
53 }
54
55 const fence = line.match(FENCE)?.[1]
56 if (fence !== undefined) {
57 const body: string[] = []
58 i += 1
59 while (!isEnd(i) && !at(i).trim().startsWith(fence)) {
60 body.push(at(i))
61 i += 1
62 }
63 i += 1
64 blocks.push({ kind: 'code', lines: body })
65 continue
66 }
67
68 const heading = line.match(HEADING)
69 if (heading) {
70 blocks.push({ kind: 'heading', level: (heading[1] ?? '#').length, text: heading[2] ?? '' })
71 i += 1
72 continue
73 }
74
75 if (RULE.test(line)) {
76 blocks.push({ kind: 'rule' })
77 i += 1
78 continue
79 }
80
81 if (line.includes('|') && !isEnd(i + 1) && TABLE_RULE.test(at(i + 1))) {
82 const head = cells(line)
83 const rows: string[][] = []
84 i += 2
85 while (!isEnd(i) && at(i).includes('|') && at(i).trim() !== '') {
86 rows.push(cells(at(i)))
87 i += 1
88 }
89 blocks.push({ kind: 'table', head, rows })
90 continue
91 }
92
93 if (QUOTE.test(line)) {
94 const inner: string[] = []
95 while (!isEnd(i) && QUOTE.test(at(i))) {
96 inner.push(at(i).match(QUOTE)?.[1] ?? '')
97 i += 1
98 }
99 blocks.push({ kind: 'quote', blocks: parse(inner.join('\n')) })
100 continue
101 }
102
103 const bullet = line.match(BULLET)
104 const numbered = line.match(NUMBERED)
105 if (bullet || numbered) {
106 const isOrdered = !bullet
107 const pattern = isOrdered ? NUMBERED : BULLET
108 const indent = ((bullet ?? numbered)?.[1] ?? '').length
109 const items: string[][] = []
110 while (!isEnd(i)) {
111 const next = at(i)
112 const item = next.match(pattern)
113 const last = items[items.length - 1]
114 if (item && (item[1] ?? '').length <= indent) {
115 items.push([item[3] ?? ''])
116 } else if (next.trim() !== '' && last !== undefined && /^\s+/.test(next)) {
117 // An indented line continues the item above (or holds a sublist).
118 last.push(next.slice(Math.min(indent + 2, next.search(/\S/))))
119 } else {
120 break
121 }
122 i += 1
123 }
124 const start = isOrdered ? Number(numbered?.[2] ?? 1) : 1
125 blocks.push({ kind: 'list', isOrdered, start, items })
126 continue
127 }
128
129 const paragraph: string[] = []
130 while (!isEnd(i) && at(i).trim() !== '' && (paragraph.length === 0 || !isBlockStart(at(i), lines[i + 1]))) {
131 paragraph.push(at(i).trim())
132 i += 1
133 }
134 blocks.push({ kind: 'paragraph', lines: paragraph })
135 }
136
137 return blocks
138}
139
140function escapeHtml(text: string): string {
141 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
142}
143
144// Inline spans. Code spans and links are cut out first, so emphasis
145// markers inside them stay as written.
146export function inlineHtml(text: string): string {
147 const held: string[] = []
148 const hold = (html: string) => `\u0000${held.push(html) - 1}\u0000`
149
150 let out = text
151 .replace(/`([^`]+)`/g, (_, code: string) => hold(`<code>${escapeHtml(code)}</code>`))
152 .replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label: string, href: string) =>
153 hold(`<a href="${escapeHtml(href)}">${inlineHtml(label)}</a>`),
154 )
155 .replace(/<(https?:\/\/[^>\s]+)>/g, (_, href: string) => hold(`<a href="${escapeHtml(href)}">${escapeHtml(href)}</a>`))
156 .replace(/(^|[\s(])(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g, (_, lead: string, href: string) =>
157 lead + hold(`<a href="${escapeHtml(href)}">${escapeHtml(href)}</a>`),
158 )
159
160 out = escapeHtml(out)
161 .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>')
162 .replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?!\w)/g, '$1<strong>$2</strong>')
163 .replace(/(^|[^*\w])\*(?=[^\s*])([^*]*?[^\s*])\*(?!\*)/g, '$1<em>$2</em>')
164 .replace(/(^|[^\w])_(?=[^\s_])([^_]*?[^\s_])_(?!\w)/g, '$1<em>$2</em>')
165 .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<s>$1</s>')
166
167 return out.replace(/\u0000(\d+)\u0000/g, (_, n: string) => held[Number(n)] ?? '')
168}
169
170const CELL = 'border:1px solid #d0d7de;padding:4px 8px;text-align:left;vertical-align:top'
171
172function blockHtml(block: Block): string {
173 switch (block.kind) {
174 case 'heading':
175 return `<h${block.level}>${inlineHtml(block.text)}</h${block.level}>`
176 case 'paragraph':
177 return `<p>${block.lines.map(inlineHtml).join('<br>')}</p>`
178 case 'rule':
179 return '<hr>'
180 case 'code':
181 return `<pre style="font-family:Menlo,Consolas,monospace;font-size:90%;background:#f6f8fa;padding:8px"><code>${escapeHtml(block.lines.join('\n'))}</code></pre>`
182 case 'quote':
183 return `<blockquote style="margin:0 0 0 8px;padding-left:8px;border-left:3px solid #d0d7de;color:#57606a">${block.blocks.map(blockHtml).join('')}</blockquote>`
184 case 'table': {
185 const head = block.head.map(cell => `<th style="${CELL};background:#f6f8fa">${inlineHtml(cell)}</th>`).join('')
186 const rows = block.rows
187 .map(row => `<tr>${block.head.map((_, n) => `<td style="${CELL}">${inlineHtml(row[n] ?? '')}</td>`).join('')}</tr>`)
188 .join('')
189
190 return `<table style="border-collapse:collapse"><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table>`
191 }
192 case 'list': {
193 const tag = block.isOrdered ? 'ol' : 'ul'
194 const start = block.isOrdered && block.start !== 1 ? ` start="${block.start}"` : ''
195 const items = block.items
196 .map(item => {
197 const [first, ...rest] = item
198 const nested = rest.length > 0 ? parse(rest.join('\n')).map(blockHtml).join('') : ''
199
200 return `<li>${inlineHtml(first ?? '')}${nested}</li>`
201 })
202 .join('')
203
204 return `<${tag}${start}>${items}</${tag}>`
205 }
206 }
207}
208
209export function toHtml(markdown: string): string {
210 return parse(markdown).map(blockHtml).join('\n')
211}
212
213// A whole HTML document for the clipboard: the charset is declared, so
214// accented letters survive the trip into a mail client.
215export function htmlDocument(markdown: string): string {
216 return (
217 '<html><head><meta charset="utf-8"></head><body>' +
218 `<div style="font-family:-apple-system,'Segoe UI',Helvetica,Arial,sans-serif;font-size:14px;line-height:1.45">${toHtml(markdown)}</div>` +
219 '</body></html>'
220 )
221}
222
223export function inlinePlain(text: string): string {
224 return text
225 .replace(/`([^`]+)`/g, '$1')
226 .replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label: string, href: string) =>
227 label === href ? href : `${inlinePlain(label)} (${href})`,
228 )
229 .replace(/<(https?:\/\/[^>\s]+)>/g, '$1')
230 .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '$1')
231 .replace(/(^|[^\w])__(?=\S)([\s\S]*?\S)__(?!\w)/g, '$1$2')
232 .replace(/(^|[^*\w])\*(?=[^\s*])([^*]*?[^\s*])\*(?!\*)/g, '$1$2')
233 .replace(/(^|[^\w])_(?=[^\s_])([^_]*?[^\s_])_(?!\w)/g, '$1$2')
234 .replace(/~~(?=\S)([\s\S]*?\S)~~/g, '$1')
235}
236
237function blockPlain(block: Block): string {
238 switch (block.kind) {
239 case 'heading':
240 return inlinePlain(block.text)
241 case 'paragraph':
242 return block.lines.map(inlinePlain).join('\n')
243 case 'rule':
244 return '----------'
245 case 'code':
246 return block.lines.join('\n')
247 case 'quote':
248 return block.blocks
249 .map(blockPlain)
250 .join('\n\n')
251 .split('\n')
252 .map(line => `> ${line}`.trimEnd())
253 .join('\n')
254 case 'table': {
255 const rows = [block.head, ...block.rows].map(row => block.head.map((_, n) => inlinePlain(row[n] ?? '')))
256 const widths = block.head.map((_, n) => Math.max(...rows.map(row => (row[n] ?? '').length)))
257 const line = (row: string[]) => row.map((cell, n) => cell.padEnd(widths[n] ?? 0)).join(' ').trimEnd()
258
259 return [line(rows[0] ?? []), widths.map(w => '-'.repeat(w)).join(' '), ...rows.slice(1).map(line)].join('\n')
260 }
261 case 'list':
262 return block.items
263 .map((item, n) => {
264 const marker = block.isOrdered ? `${block.start + n}. ` : '- '
265 const [first, ...rest] = item
266 const nested = rest.length > 0 ? '\n' + parse(rest.join('\n')).map(blockPlain).join('\n').replace(/^/gm, ' ') : ''
267
268 return marker + inlinePlain(first ?? '') + nested
269 })
270 .join('\n')
271 }
272}
273
274export function toPlain(markdown: string): string {
275 return parse(markdown).map(blockPlain).join('\n\n')
276}
277types/index.d.ts 18 lines1export type Draft = {
2 id: number
3 body: string
4 subject?: string
5 to?: string
6 createdAt: number
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 meatproxy: {
12 drafts: Draft[]
13 shown: number
14 notice: string | null
15 }
16 }
17}
18