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

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!
hooks/register.tsx 160 lines1import { 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 <file> 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}
160types/index.d.ts 8 lines1export type MdPath = string | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 md: { path: MdPath }
6 }
7}
8