SLOPSHOPPER

mercat

Draws mermaid diagrams in Claude's replies with mercat, and /mercat renders markdown or mermaid files (or the ones Claude edited) in a side pane.

newpanerowsguardcommandtoast
★ 2v0.3.4GPL-3.0updated 2026-10-25tawago/mercat/mods/mercat
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mercat
│ ┃ mercat ✕ › fix the failing auth test and add an audit log call │ ┃ No files edited yet. │ ⏺ 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 │ │ › /mercat │ ⎿ mercat: No files edited yet. Try: /mercat <file> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mercat
No files edited yet.
README

mercat for Claude Code

A Claude Code mod that brings mercat into the session.

  • Diagrams in replies. When Claude writes a ``` `mermaid `` block, the reply shows it drawn as a text diagram. The raw block shows first, then the drawing replaces it once mercat finishes. If mercat can't draw it, the raw block stays. A diagram wider than the reply is drawn cut off at the »` marks, with a warning line in color right above it. A label mercat has no room for is left out, also with a warning line in color. Only the screen changes; the stored message stays as Claude wrote it (ctrl+o).
  • /mercat off shows mermaid blocks as written again; /mercat on brings the drawings back. Each new session starts on. To turn it off for good, uninstall the mod.
  • /mercat <file> opens a side pane with the file rendered by mercat, sized to the pane.
  • /mercat with no argument lists the .md, .markdown, .mmd and .mermaid files Claude edited this session, newest first. Pick one to read it; back (or b) returns to the list.

Requirements

mercat on your PATH:

brew install tawago/tap/mercat

If it is missing, the mod shows one hint per session and leaves diagrams as written.

The mod's version is mercat's release version. If your mercat is older than the mod, it shows one upgrade hint at start.

Tested with Claude Code 2.1.299. The plugin API is early access and may change between releases.

Install

At the prompt of a terminal session:

/plugin install mercat --marketplace tawago/mercat

Or in two steps:

/plugin marketplace add tawago/mercat
/plugin install mercat@mercat

From a shell: claude plugin install mercat --marketplace tawago/mercat.

Develop

claude --plugin-dir mods/mercat     # load from this checkout
claude plugin validate mods/mercat
claude plugin test mods/mercat
Source 5 files
hooks/register.tsx 286 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MercatEdited, MercatIsOff, MercatShown } from '../types'
5import { parseAnsi } from './ansi'
6import type { Line } from './ansi'
7import { mermaidFences, split } from './fences'
8import {
9  basename,
10  clampWidth,
11  diagramArgv,
12  diagramInput,
13  diagramOf,
14  documentArgv,
15  editedIn,
16  isDoc,
17  isOlder,
18  Lru,
19  MISSING_HINT,
20  MOD_VERSION,
21  newestFirst,
22  noteOf,
23  paneTitle,
24  printable,
25  relativeTo,
26  resolvePath,
27  transcriptWidth,
28  upgradeHint,
29  versionOf,
30} from './mercat'
31
32const PANE = 'mercat'
33const RUNNING_MAX = 3
34
35const edited = atom({ plugin: 'mercat', key: 'edited' } as const, [] as MercatEdited)
36const shown = atom({ plugin: 'mercat', key: 'shown' } as const, null as MercatShown)
37const isOff = atom({ plugin: 'mercat', key: 'isOff' } as const, false as MercatIsOff)
38
39type Doc = { lines: Line[] } | { error: string }
40
41type Diagram = { art: string; note?: string }
42
43const diagrams = new Lru<Diagram | null>(200)
44const everDrawn = new Lru<true>(5_000)
45const docs = new Lru<Doc>(20)
46const docGen = new Map<string, number>()
47const inFlight = new Set<string>()
48const queue: (() => Promise<void>)[] = []
49let running = 0
50let isMercatMissing = false
51
52function schedule(key: string, job: () => Promise<void>): void {
53  if (inFlight.has(key)) return
54  inFlight.add(key)
55  queue.push(() => job().finally(() => inFlight.delete(key)))
56  pump()
57}
58
59function pump(): void {
60  while (running < RUNNING_MAX && queue.length > 0) {
61    const job = queue.shift()!
62    running++
63    void job().finally(() => {
64      running--
65      pump()
66    })
67  }
68}
69
70const docKey = (width: number, path: string) => `f${width}:${docGen.get(path) ?? 0}\n${path}`
71
72function forgetDoc(path: string): void {
73  docGen.set(path, (docGen.get(path) ?? 0) + 1)
74  docs.dropWhere(isDocKeyOf(path))
75}
76const isDocKeyOf = (path: string) => (key: string) => key.endsWith(`\n${path}`)
77
78function markMissing($: EngineInterface): void {
79  if (!isMercatMissing) $.ui.toast(MISSING_HINT)
80  isMercatMissing = true
81}
82
83async function checkVersion($: EngineInterface): Promise<void> {
84  try {
85    const run = await $.process.run(['mercat', '--version'], { timeoutMs: 3_000 })
86    const have = versionOf(run.stdout)
87    if (have !== undefined && isOlder(have, MOD_VERSION)) $.ui.toast(upgradeHint(have))
88  } catch {
89    markMissing($)
90  }
91}
92
93async function isAbsent($: EngineInterface): Promise<boolean> {
94  try {
95    await $.process.run(['mercat', '--version'], { timeoutMs: 3_000 })
96    return false
97  } catch {
98    markMissing($)
99    return true
100  }
101}
102
103async function drawDiagram($: EngineInterface, key: string, source: string, width: number): Promise<void> {
104  try {
105    const run = await $.process.run(diagramArgv(width), { stdin: diagramInput(source), timeoutMs: 10_000 })
106    const art = diagramOf(run)
107    const note = noteOf(run.stderr)
108    diagrams.set(key, art === undefined ? null : note === undefined ? { art } : { art, note })
109    if (art !== undefined && !everDrawn.has(key)) {
110      everDrawn.set(key, true)
111      $.ui.invalidate('ui.render')
112    }
113  } catch {
114    diagrams.set(key, null)
115    await isAbsent($)
116  }
117}
118
119async function drawDoc($: EngineInterface, key: string, path: string, width: number): Promise<void> {
120  try {
121    const run = await $.process.run(documentArgv(width, path), { timeoutMs: 15_000 })
122    const error = run.stderr.trim() || `mercat exited with ${run.exitCode}`
123    docs.set(key, run.exitCode === 0 ? { lines: parseAnsi(run.stdout) } : { error })
124  } catch (error) {
125    docs.set(key, { error: (await isAbsent($)) ? MISSING_HINT : String(error) })
126  }
127  $.ui.invalidate('ui.render')
128}
129
130async function openPane($: EngineInterface, title: string): Promise<void> {
131  await $.ui.open({ id: PANE, title, focus: true, closeOnEscape: true })
132}
133
134async function showFile($: EngineInterface, path: string): Promise<void> {
135  forgetDoc(path)
136  await update($, shown, () => path)
137  await openPane($, paneTitle(path))
138}
139
140async function showList($: EngineInterface): Promise<void> {
141  await update($, shown, () => null)
142  await openPane($, 'mercat')
143}
144
145export const register: Register = on => {
146  on('session.start', async ($, e, next) => {
147    await $.command.register({
148      name: 'mercat',
149      description: 'Render a markdown or mermaid file with mercat in a side pane',
150      argumentHint: '[file | on | off]',
151      immediate: true,
152    })
153    await update($, isOff, () => false)
154    void checkVersion($)
155    return next(e)
156  })
157
158  on('session.end', async ($, e, next) => {
159    if (e.reason === 'clear') {
160      await update($, edited, () => [])
161      await update($, shown, () => null)
162    }
163    return next(e)
164  })
165
166  on('command.run', { command: 'mercat' }, async ($, e) => {
167    try {
168      const arg = e.args.trim()
169      if (arg === 'on' || arg === 'off') {
170        await update($, isOff, () => arg === 'off')
171        $.ui.invalidate('ui.render')
172        return { text: arg === 'off' ? 'Diagrams off: mermaid blocks show as written' : 'Diagrams on' }
173      }
174      if (arg !== '') {
175        const path = resolvePath(await $.session.cwd(), arg)
176        const stat = await $.fs.stat(path).catch(() => undefined)
177        if (stat?.kind !== 'file') return { text: `No such file: ${arg}` }
178        await showFile($, path)
179        return { text: `Showing ${basename(path)}` }
180      }
181      const list = newestFirst(await read($, edited), editedIn(await $.session.messages()))
182      if (list.length === 0) return { text: 'No files edited yet. Try: /mercat <file>' }
183      await update($, edited, () => list)
184      await showList($)
185      return { text: `${list.length} edited file${list.length === 1 ? '' : 's'}` }
186    } catch (error) {
187      return { text: String(error) }
188    }
189  })
190
191  on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
192    if (!isDoc(e.file_path)) return next(e)
193    const ran = await next(e)
194    if (ran.deny === undefined && ran.isError !== true) {
195      const path = e.file_path
196      await update($, edited, list => newestFirst([path], list))
197      forgetDoc(path)
198      if ((await read($, shown)) === path && (await $.ui.panes()).some(p => p.id === PANE)) $.ui.invalidate('ui.render')
199    }
200    return ran
201  }).catch(($, e, next) => next(e))
202
203  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
204    const text = e.props.text
205    if (e.props.isSummary === true || !/mermaid/i.test(text) || (await read($, isOff))) return next(e)
206    const fences = mermaidFences(text)
207    if (fences.length === 0) return next(e)
208    const width = transcriptWidth(e.viewport?.columns)
209    let isDrawn = false
210    const parts = split(text, fences, fence => {
211      const key = `d${width}\n${fence.source}`
212      const drawn = diagrams.get(key)
213      if (drawn === undefined && !isMercatMissing) schedule(key, () => drawDiagram($, key, fence.source, width))
214      if (drawn) isDrawn = true
215      return drawn ?? undefined
216    })
217    if (!isDrawn) return next(e)
218    const first = parts.findIndex(p => 'md' in p)
219    if (parts.every(p => 'md' in p) || first === -1)
220      return next({ ...e, props: { ...e.props, text: parts.map(p => ('md' in p ? p.md : '')).join('') } })
221    const { Box, Markdown, Text } = $.ui.resolve(e)
222    const head = parts[first] as { md: string }
223    const theirs = await next({ ...e, props: { ...e.props, text: head.md } })
224    return (
225      <Box flexDirection="column">
226        {parts.map((part, i) =>
227          i === first ? (
228            theirs
229          ) : 'md' in part ? (
230            <Box key={`md${i}`} paddingLeft={2}>
231              <Markdown text={part.md} />
232            </Box>
233          ) : (
234            <Box key={`note${i}`} paddingLeft={2}>
235              <Text color="warning">{part.note}</Text>
236            </Box>
237          ),
238        )}
239      </Box>
240    )
241  })
242
243  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
244    const { Box, Button, Text } = $.ui.resolve(e)
245    const cwd = await $.session.cwd()
246    const path = await read($, shown)
247
248    if (path === null) {
249      const list = await read($, edited)
250      return (
251        <Box flexDirection="column">
252          {list.length === 0 && <Text dimColor>No files edited yet.</Text>}
253          {list.map(file => (
254            <Button key={file} plain onPress={() => void showFile($, file).catch(() => undefined)}>
255              {printable(basename(file))} <Text dimColor>{printable(relativeTo(cwd, file))}</Text>
256            </Button>
257          ))}
258        </Box>
259      )
260    }
261
262    const width = clampWidth(e.props.bodyColumns - 1)
263    const key = docKey(width, path)
264    const doc = docs.get(key)
265    if (doc === undefined) schedule(key, () => drawDoc($, key, path, width))
266
267    return (
268      <Box flexDirection="column">
269        <Box flexDirection="row">
270          <Button key="back" label="back" hotkey="b" onPress={() => void showList($).catch(() => undefined)} />
271          <Text dimColor> {printable(relativeTo(cwd, path))}</Text>
272        </Box>
273        {doc === undefined && <Text dimColor>rendering…</Text>}
274        {doc !== undefined && 'error' in doc && <Text color="error">{doc.error}</Text>}
275        {doc !== undefined &&
276          'lines' in doc &&
277          doc.lines.map(line => (
278            <Text wrap="truncate">
279              {line.length === 0 ? ' ' : line.map(span => <Text {...span.style}>{span.text}</Text>)}
280            </Text>
281          ))}
282      </Box>
283    )
284  })
285}
286
hooks/ansi.ts 117 lines
1export type Style = {
2  color?: string
3  backgroundColor?: string
4  bold?: boolean
5  dimColor?: boolean
6  italic?: boolean
7  underline?: boolean
8  inverse?: boolean
9  strikethrough?: boolean
10}
11
12export type Span = { text: string; style: Style }
13export type Line = Span[]
14
15const BASE16 = [
16  '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5',
17  '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff',
18]
19
20const hex2 = (n: number) => Math.max(0, Math.min(255, n | 0)).toString(16).padStart(2, '0')
21const rgb = (r: number, g: number, b: number) => `#${hex2(r)}${hex2(g)}${hex2(b)}`
22
23export function xterm256(n: number): string | undefined {
24  if (!Number.isInteger(n) || n < 0 || n > 255) return undefined
25  if (n < 16) return BASE16[n]
26  if (n >= 232) {
27    const v = 8 + (n - 232) * 10
28    return rgb(v, v, v)
29  }
30  const i = n - 16
31  const step = (c: number) => (c === 0 ? 0 : 55 + c * 40)
32  return rgb(step(Math.floor(i / 36)), step(Math.floor(i / 6) % 6), step(i % 6))
33}
34
35function applySgr(style: Style, params: number[]): Style {
36  const s = { ...style }
37  for (let i = 0; i < params.length; i++) {
38    const p = params[i]!
39    if (p === 0) for (const k of Object.keys(s)) delete s[k as keyof Style]
40    else if (p === 1) s.bold = true
41    else if (p === 2) s.dimColor = true
42    else if (p === 3) s.italic = true
43    else if (p === 4) s.underline = true
44    else if (p === 7) s.inverse = true
45    else if (p === 9) s.strikethrough = true
46    else if (p === 22) (delete s.bold, delete s.dimColor)
47    else if (p === 23) delete s.italic
48    else if (p === 24) delete s.underline
49    else if (p === 27) delete s.inverse
50    else if (p === 29) delete s.strikethrough
51    else if (p >= 30 && p <= 37) s.color = BASE16[p - 30]
52    else if (p >= 90 && p <= 97) s.color = BASE16[p - 82]
53    else if (p >= 40 && p <= 47) s.backgroundColor = BASE16[p - 40]
54    else if (p >= 100 && p <= 107) s.backgroundColor = BASE16[p - 92]
55    else if (p === 39) delete s.color
56    else if (p === 49) delete s.backgroundColor
57    else if (p === 38 || p === 48) {
58      const key = p === 38 ? 'color' : 'backgroundColor'
59      let c: string | undefined
60      if (params[i + 1] === 5) {
61        c = xterm256(params[i + 2] ?? -1)
62        i += 2
63      } else if (params[i + 1] === 2) {
64        c = rgb(params[i + 2] ?? 0, params[i + 3] ?? 0, params[i + 4] ?? 0)
65        i += 4
66      }
67      if (c !== undefined) s[key] = c
68    }
69  }
70  return s
71}
72
73const num = (x: string) => (x === '' ? 0 : Number(x))
74
75function sgrGroup(group: string): number[] {
76  const sub = group.split(':').map(num)
77  if (sub.length > 1 && (sub[0] === 38 || sub[0] === 48) && sub[1] === 2 && sub.length >= 6)
78    return [sub[0], 2, ...sub.slice(3, 6)]
79  return sub
80}
81
82// CSI, OSC (BEL or ST), other two-byte escapes, stray control bytes.
83const ESCAPE = /\x1b\[([0-?]*)[ -/]*([@-~])|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[@-Z\\-_]|[\x00-\x08\x0b-\x1f\x7f]/g
84
85export function parseAnsi(input: string): Line[] {
86  const lines: Line[] = [[]]
87  let style: Style = {}
88  const push = (text: string) => {
89    const parts = text.split('\n')
90    parts.forEach((part, k) => {
91      if (k > 0) lines.push([])
92      if (part === '') return
93      const line = lines[lines.length - 1]!
94      const last = line[line.length - 1]
95      if (last !== undefined && last.style === style) last.text += part
96      else line.push({ text: part, style })
97    })
98  }
99  const text = input.replace(/\r\n?/g, '\n')
100  let at = 0
101  for (const m of text.matchAll(ESCAPE)) {
102    push(text.slice(at, m.index))
103    at = m.index + m[0].length
104    if (m[2] === 'm') {
105      const params = m[1] === '' ? [0] : m[1]!.split(';').flatMap(sgrGroup)
106      style = applySgr(style, params)
107    }
108  }
109  push(text.slice(at))
110  while (lines.length > 1 && lines[lines.length - 1]!.length === 0) lines.pop()
111  return lines
112}
113
114export function plainText(line: Line): string {
115  return line.map(s => s.text).join('')
116}
117
hooks/fences.ts 91 lines
1export type Fence = { start: number; end: number; indent: string; source: string }
2
3const OPEN = /^( *)(`{3,}|~{3,})([^\r]*)\r?$/
4const CLOSE = /^( *)(`{3,}|~{3,})[ \t]*\r?$/
5
6export function mermaidFences(text: string): Fence[] {
7  const fences: Fence[] = []
8  let offset = 0
9  let open: { start: number; indent: string; mark: string; isMermaid: boolean; body: string[] } | null = null
10
11  for (const line of text.split('\n')) {
12    const lineEnd = offset + line.length
13    if (open === null) {
14      const m = OPEN.exec(line)
15      const info = m?.[3]?.trim() ?? ''
16      if (m !== null && !(m[2]![0] === '`' && info.includes('`'))) {
17        const isMermaid = /^mermaid(?:[ \t]|$)/i.test(info)
18        open = { start: offset, indent: m[1]!, mark: m[2]!, isMermaid, body: [] }
19      }
20    } else {
21      const close = CLOSE.exec(line)
22      if (
23        close !== null &&
24        close[1]!.length <= open.indent.length + 3 &&
25        close[2]![0] === open.mark[0] &&
26        close[2]!.length >= open.mark.length
27      ) {
28        if (open.isMermaid)
29          fences.push({ start: open.start, end: lineEnd, indent: open.indent, source: open.body.join('\n') })
30        open = null
31      } else if (open.isMermaid) {
32        open.body.push(stripIndent(line.replace(/\r$/, ''), open.indent.length))
33      }
34    }
35    offset = lineEnd + 1
36  }
37  return fences
38}
39
40function stripIndent(line: string, n: number): string {
41  let i = 0
42  while (i < n && line[i] === ' ') i++
43  return line.slice(i)
44}
45
46function trimNewlines(text: string): string {
47  let end = text.length
48  while (end > 0 && text[end - 1] === '\n') end--
49  return text.slice(0, end)
50}
51
52export function textFence(rendered: string, indent = ''): string {
53  const runs = rendered.match(/`+/g) ?? []
54  const ticks = '`'.repeat(Math.max(3, ...runs.map(r => r.length + 1)))
55  const body = trimNewlines(rendered).split('\n').map(l => indent + l)
56  return [`${indent}${ticks}text`, ...body, `${indent}${ticks}`].join('\n')
57}
58
59export type Drawn = { art: string; note?: string }
60export type Part = { md: string } | { note: string }
61
62export function split(text: string, fences: readonly Fence[], drawn: (f: Fence) => Drawn | undefined): Part[] {
63  const parts: Part[] = []
64  let md = ''
65  let at = 0
66  for (const f of fences) {
67    const d = drawn(f)
68    if (d === undefined) continue
69    md += text.slice(at, f.start)
70    if (d.note !== undefined) {
71      if (md.trim() !== '') parts.push({ md })
72      parts.push({ note: d.note })
73      md = ''
74    }
75    md += textFence(d.art, f.indent)
76    at = f.end
77  }
78  md += text.slice(at)
79  if (md.trim() !== '') parts.push({ md })
80  return parts
81}
82
83export function rewrite(text: string, fences: readonly Fence[], drawn: (f: Fence) => string | undefined): string {
84  return split(text, fences, f => {
85    const art = drawn(f)
86    return art === undefined ? undefined : { art }
87  })
88    .map(p => ('md' in p ? p.md : ''))
89    .join('')
90}
91
hooks/mercat.ts 130 lines
1import type { ProcessRunResult, SessionMessage } from 'claude-code'
2
3export const MOD_VERSION = '0.3.4'
4export const MIN_WIDTH = 20
5export const MAX_WIDTH = 1000
6export const TRANSCRIPT_MARGIN = 1
7export const MISSING_HINT = 'mercat not found on PATH — brew install tawago/tap/mercat'
8
9export const clampWidth = (n: number) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.floor(n)))
10
11export const transcriptWidth = (columns: number | undefined) =>
12  columns === undefined ? 80 : clampWidth(columns - TRANSCRIPT_MARGIN)
13
14export const diagramArgv = (width: number) => ['mercat', '--format', 'plain', '-w', String(width), '-']
15
16export const documentArgv = (width: number, path: string) => ['mercat', '-w', String(width), path]
17
18export function diagramInput(source: string): string {
19  const runs = source.match(/`{3,}/g) ?? []
20  const ticks = '`'.repeat(Math.max(3, ...runs.map(r => r.length + 1)))
21  return `${ticks}mermaid\n${source}\n${ticks}\n`
22}
23
24export function diagramOf(run: ProcessRunResult): string | undefined {
25  const out = run.stdout.trimEnd()
26  const isDrawn =
27    run.exitCode === 0 &&
28    out.trim() !== '' &&
29    !run.stderr.split('\n').some(l => /warning:/.test(l) && !/diagram clipped|label ".*" on edge .* is not drawn/.test(l)) &&
30    !out.includes('<PARSE ERROR') &&
31    !/^[ \t]*`{3,}mermaid/im.test(out)
32  return isDrawn ? out : undefined
33}
34
35export const versionOf = (stdout: string) => /(\d+)\.(\d+)\.(\d+)/.exec(stdout)?.[0]
36
37export function isOlder(have: string, want: string): boolean {
38  const a = have.split('.').map(Number)
39  const b = want.split('.').map(Number)
40  for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i]! < b[i]!
41  return false
42}
43
44export const upgradeHint = (have: string) =>
45  `mercat ${have} is older than this mod (${MOD_VERSION}) — brew upgrade tawago/tap/mercat`
46
47export function noteOf(stderr: string): string | undefined {
48  const notes: string[] = []
49  const clip = /diagram clipped: true width (\d+) > budget (\d+)/.exec(stderr)
50  if (clip !== null)
51    notes.push(`⚠ mercat: diagram clipped, needs ${clip[1]} columns, has ${clip[2]}. Widen the terminal to see it whole.`)
52  for (const m of stderr.matchAll(/label "(.*)" on edge \d+ \((.+) -> (.+)\) .* is not drawn/g))
53    notes.push(`⚠ mercat: label "${printable(m[1]!)}" on ${printable(m[2]!)} → ${printable(m[3]!)} dropped, no room to place it.`)
54  return notes.length === 0 ? undefined : notes.join('\n')
55}
56
57export class Lru<V> {
58  private readonly map = new Map<string, V>()
59  constructor(private readonly limit: number) {}
60
61  get(key: string): V | undefined {
62    const value = this.map.get(key)
63    if (value !== undefined) {
64      this.map.delete(key)
65      this.map.set(key, value)
66    }
67    return value
68  }
69
70  has(key: string): boolean {
71    return this.map.has(key)
72  }
73
74  set(key: string, value: V): void {
75    this.map.delete(key)
76    this.map.set(key, value)
77    while (this.map.size > this.limit) this.map.delete(this.map.keys().next().value!)
78  }
79
80  dropWhere(test: (key: string) => boolean): boolean {
81    let dropped = false
82    for (const key of [...this.map.keys()]) if (test(key) && this.map.delete(key)) dropped = true
83    return dropped
84  }
85}
86
87const DOC = /\.(md|markdown|mmd|mermaid)$/i
88
89export const isDoc = (path: string) => DOC.test(path)
90
91export const basename = (path: string) => path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? path
92
93export const printable = (s: string) => s.replace(/[\x00-\x1f\x7f-\x9f]/g, '?')
94
95export const paneTitle = (path: string) => `mercat: ${printable(basename(path))}`
96
97export function resolvePath(cwd: string, arg: string): string {
98  const path = arg.trim().replace(/^(["'])(.*)\1$/, '$2')
99  if (/^([\\/]|[A-Za-z]:[\\/])/.test(path)) return path
100  const parts = `${cwd}/${path}`.split('/')
101  const out: string[] = []
102  for (const part of parts) {
103    if (part === '..') out.length > 1 && out.pop()
104    else if (part !== '.' && !(part === '' && out.length > 0)) out.push(part)
105  }
106  return out.join('/')
107}
108
109export function relativeTo(cwd: string, path: string): string {
110  const root = cwd.replace(/\/+$/, '') + '/'
111  return path.startsWith(root) ? path.slice(root.length) : path
112}
113
114export const EDIT_TOOLS = ['Edit', 'Write'] as const
115
116export function editedIn(messages: readonly SessionMessage[]): string[] {
117  const paths: string[] = []
118  for (const message of messages)
119    for (const use of message.toolUses)
120      if ((EDIT_TOOLS as readonly string[]).includes(use.tool) && use.isError !== true) {
121        const path = use.input.file_path
122        if (typeof path === 'string' && isDoc(path)) paths.push(path)
123      }
124  return paths.reverse()
125}
126
127export function newestFirst(...lists: readonly (readonly string[])[]): string[] {
128  return [...new Set(lists.flat())].slice(0, 100)
129}
130
types/index.d.ts 10 lines
1export type MercatEdited = string[]
2export type MercatShown = string | null
3export type MercatIsOff = boolean
4
5declare module 'claude-code' {
6  interface PluginState {
7    mercat: { edited: MercatEdited; shown: MercatShown; isOff: MercatIsOff }
8  }
9}
10