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.

A Claude Code mod that brings mercat into the session.
`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.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.
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.
claude --plugin-dir mods/mercat # load from this checkout
claude plugin validate mods/mercat
claude plugin test mods/mercathooks/register.tsx 286 lines1import { 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}
286hooks/ansi.ts 117 lines1export 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}
117hooks/fences.ts 91 lines1export 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}
91hooks/mercat.ts 130 lines1import 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}
130types/index.d.ts 10 lines1export 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