SLOPSHOPPER

md

/md <file> opens a pane with the markdown file rendered by rich-cli

newpanerowscommandprocess
★ 3v0.1.0no licenseupdated 2026-10-06ewels/dotfiles/claude-mods/md
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · md
│ ┃ md ✕ › fix the failing auth test and add an audit log call │ ┃ Run /md <file> to render a markdown file. │ ⏺ 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 │ │ › /md │ ⎿ md: Usage: /md <file.md> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · md
Run /md <file> to render a markdown file.
README

dotfiles

Personal dotfiles for sharing setups and configs between machines.

Feel free to steal or use as you like.. Let me know if I'm missing any good tricks!

Source 2 files
hooks/register.tsx 160 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const PANE = 'md'
5const doc = atom({ plugin: 'md', key: 'path' } as const, null)
6
7type Style = { color?: string; backgroundColor?: string; bold?: boolean; dimColor?: boolean; italic?: boolean; underline?: boolean; strikethrough?: boolean; inverse?: boolean }
8type Span = Style & { text: string }
9
10const BASIC = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
11const CUBE = [0, 95, 135, 175, 215, 255]
12const hex = (...rgb: number[]) => '#' + rgb.map(n => n.toString(16).padStart(2, '0')).join('')
13
14function color256(n: number) {
15  if (n < 8) return BASIC[n]
16  if (n < 16) return `${BASIC[n - 8]}Bright`
17  if (n < 232) return hex(CUBE[Math.floor((n - 16) / 36)]!, CUBE[Math.floor((n - 16) / 6) % 6]!, CUBE[(n - 16) % 6]!)
18  const g = 8 + (n - 232) * 10
19  return hex(g, g, g)
20}
21
22function applySgr(style: Style, params: number[]): Style {
23  const s = { ...style }
24  for (let i = 0; i < params.length; i++) {
25    const p = params[i]!
26    if (p === 0) for (const k of Object.keys(s)) delete s[k as keyof Style]
27    else if (p === 1) s.bold = true
28    else if (p === 2) s.dimColor = true
29    else if (p === 3) s.italic = true
30    else if (p === 4) s.underline = true
31    else if (p === 7) s.inverse = true
32    else if (p === 9) s.strikethrough = true
33    else if (p === 22) s.bold = s.dimColor = undefined
34    else if (p === 23) s.italic = undefined
35    else if (p === 24) s.underline = undefined
36    else if (p === 27) s.inverse = undefined
37    else if (p === 29) s.strikethrough = undefined
38    else if (p === 39) s.color = undefined
39    else if (p === 49) s.backgroundColor = undefined
40    else if (p >= 30 && p <= 37) s.color = BASIC[p - 30]
41    else if (p >= 90 && p <= 97) s.color = `${BASIC[p - 90]}Bright`
42    else if (p >= 40 && p <= 47) s.backgroundColor = BASIC[p - 40]
43    else if (p >= 100 && p <= 107) s.backgroundColor = `${BASIC[p - 100]}Bright`
44    else if (p === 38 || p === 48) {
45      const key = p === 38 ? 'color' : 'backgroundColor'
46      if (params[i + 1] === 5) {
47        s[key] = color256(params[i + 2] ?? 0)
48        i += 2
49      } else if (params[i + 1] === 2) {
50        s[key] = hex(params[i + 2] ?? 0, params[i + 3] ?? 0, params[i + 4] ?? 0)
51        i += 4
52      }
53    }
54  }
55  return s
56}
57
58/** Splits rich's ANSI output into lines of styled spans. */
59export function parseAnsi(out: string): Span[][] {
60  const lines: Span[][] = []
61  let style: Style = {}
62  for (const raw of out.replace(/\n$/, '').split('\n')) {
63    const line: Span[] = []
64    for (const part of raw.split(/(\x1b\[[0-9;]*m)/)) {
65      const sgr = part.match(/^\x1b\[([0-9;]*)m$/)
66      if (sgr) style = applySgr(style, (sgr[1] || '0').split(';').map(Number))
67      else if (part) line.push({ ...style, text: part })
68    }
69    lines.push(line)
70  }
71  return lines
72}
73
74function absolute(path: string, cwd: string, home: string) {
75  const full = path.startsWith('~/') ? home + path.slice(1) : path.startsWith('/') ? path : `${cwd}/${path}`
76  const parts: string[] = []
77  for (const seg of full.split('/')) {
78    if (seg === '..') parts.pop()
79    else if (seg && seg !== '.') parts.push(seg)
80  }
81  return '/' + parts.join('/')
82}
83
84// Fenced code first so paths inside it are left alone; then [text](x.md), `x.md`, bare x.md.
85const MD_REF = /(```[\s\S]*?```)|\[([^\]\n]*)\]\(([^)\s]+\.md)\)|`([^`\s]+\.md)`|(?<![\w/.~\-[(`:])([\w.~/-]*\w\.md)(?![\w/])/g
86
87/** Turns .md paths in a chat message into file:// links; `links` lists the hrefs made. */
88export function linkMd(text: string, cwd: string, home: string) {
89  const links: string[] = []
90  const out = text.replace(MD_REF, (all, fence, label, href, code, bare) => {
91    if (fence || /^[a-z]+:/i.test(href ?? '')) return all
92    const url = 'file://' + encodeURI(absolute(href ?? code ?? bare, cwd, home))
93    links.push(url)
94    return `[${label ?? (code ? `\`${code}\`` : bare)}](${url})`
95  })
96  return { text: out, links }
97}
98
99const cache = new Map<string, Span[][]>()
100
101async function render($: EngineInterface, path: string, width: number) {
102  const key = `${width}:${path}`
103  const hit = cache.get(key)
104  if (hit) return hit
105  const run = await $.process.run(['env', 'COLORTERM=truecolor', 'rich', '--markdown', '--force-terminal', '--width', String(width), path])
106  const lines = run.exitCode === 0 ? parseAnsi(run.stdout) : [[{ text: run.stderr.trim() || `rich exited ${run.exitCode}`, color: 'error' }]]
107  // ponytail: no file watching; reopening the file re-renders it.
108  if (run.exitCode === 0) cache.set(key, lines)
109  return lines
110}
111
112async function show($: EngineInterface, path: string) {
113  for (const k of [...cache.keys()]) if (k.endsWith(`:${path}`)) cache.delete(k)
114  await update($, doc, () => path)
115  await $.ui.open({ id: PANE, title: path.split('/').pop() ?? 'Markdown' })
116}
117
118export const register: Register = on => {
119  on('session.start', async ($, e, next) => {
120    await $.command.register({ name: 'md', description: 'Render a markdown file in a pane with rich' })
121    return next(e)
122  })
123
124  on('command.run', { command: 'md' }, async ($, e) => {
125    const arg = e.args.trim().replace(/^['"]|['"]$/g, '')
126    if (!arg) return { text: 'Usage: /md <file.md>' }
127    const path = absolute(arg, await $.session.root(), (await $.env.get('HOME')) ?? '')
128    await show($, path)
129    return { text: `Rendering ${path}` }
130  })
131
132  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
133    const linked = linkMd(e.props.text, await $.session.root(), (await $.env.get('HOME')) ?? '')
134    if (!linked.links.length) return next(e)
135    const { Markdown } = $.ui.resolve(e)
136    return (
137      <Markdown
138        key={`md-${linked.links[0]}-${e.props.text.length}`}
139        text={linked.text}
140        pressableLinks={linked.links.slice(0, 256)}
141        onLinkPress={link => void show($, decodeURI(link.href.slice('file://'.length)))}
142      />
143    )
144  })
145
146  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
147    const { Box, Text } = $.ui.resolve(e)
148    const path = await read($, doc)
149    if (!path) return <Text dimColor>Run /md &lt;file&gt; to render a markdown file.</Text>
150    const lines = await render($, path, Math.max(20, (e.viewport?.columns ?? 80) - 4))
151    return (
152      <Box flexDirection="column">
153        {lines.map(line => (
154          <Text wrap="truncate-end">{line.length ? line.map(({ text, ...style }) => <Text {...style}>{text}</Text>) : ' '}</Text>
155        ))}
156      </Box>
157    )
158  })
159}
160
types/index.d.ts 8 lines
1export type MdPath = string | null
2
3declare module 'claude-code' {
4  interface PluginState {
5    md: { path: MdPath }
6  }
7}
8