SLOPSHOPPER

meatproxy

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

newpanerowsguardcommandtoast
v0.1.0MITupdated 2026-10-14rolottr/claude-mod-meatproxy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · meatproxy
│ ┃ Draft ✕ › fix the failing auth test and add an audit log call │ ┃ No drafts yet. Ask Claude to draft a reply, │ ┃ and it shows here. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /draft │ ⎿ meatproxy: Opened the draft pane. Press r, m, p or s to copy, Es │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Draft
No drafts yet. Ask Claude to draft a reply, and it shows here.
README

CLAUDE mod: meatproxy

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

Install

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.

Requirements

  • Claude Code v2.1.287 or later (claude --version). Mods do not run in older versions.
  • For the pane on the right side, use the fullscreen layout. Add this to ~/.claude/settings.json:
  { "tui": "fullscreen" }

In the default layout, the pane opens above the prompt, at full width.

Use

Ask for a draft as you always do. You do not need a command or a keyword:

  • "Draft a reply to Ana: thanks, I will check it tomorrow."
  • "Write an email in Spanish to Marta with the payment link. Put it in an md file in /tmp."
  • "Redacta una respuesta para Raúl sobre las transferencias."

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 paneWhat it does
r Rich textCopies the draft with its format: bold, links, lists, and tables.
m MarkdownCopies the markdown source.
p PlainCopies plain text, with no markdown signs.
s SubjectCopies the subject line (emails only).
k ‹ Older / j Newer ›Steps through the drafts.
✕ or EscCloses the pane.

Click an item with the mouse, or run /draft to give the pane the keyboard and press the keys.

CommandWhat it does
/draftOpens the pane with the last draft.
/draft richCopies the last draft as rich text, with no pane.
/draft mdCopies the last draft as markdown.
/draft plainCopies the last draft as plain text.
/draft subjectCopies the subject of the last draft.
/draft closeCloses the pane.

The mod keeps your last 30 drafts on your computer, so /draft also finds a draft from an earlier session.

Rich text on each system

Rich text goes on the clipboard in the formats that mail and chat apps read:

SystemToolFormats
macOSosascript (built in)HTML, RTF, and plain text
Linux, Waylandwl-copy (package wl-clipboard)HTML
Linux, X11xclipHTML
WindowsPowerShell Set-Clipboard -AsHtmlHTML

Markdown and plain text use Claude Code's own clipboard, so they work everywhere.

How it works

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.

Uninstall

claude plugin uninstall meatproxy

Or run /plugin, open the Installed tab, and remove meatproxy.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 4 files
hooks/register.tsx 318 lines
1import { 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}
318
hooks/clipboard.ts 97 lines
1import 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}
97
hooks/markdown.ts 277 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
277
types/index.d.ts 18 lines
1export 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