Click a .md path in Claude's reply to read it, rendered, in a pane beside the chat (fullscreen terminal).

Click a .md path in Claude's reply and the file opens, rendered, in a pane beside the chat. Made for reading the specs, plans and flow files Claude writes during a feature workflow without leaving Claude Code.
Pick it in the installer (see the main README), on its Tools page. It needs nothing else.
Turn on Claude Code's fullscreen renderer once (/tui fullscreen): only there do plain clicks reach a mod.
.md file becomes a link: bare (docs/x/flow.md), in backticks, or as a Markdown link. Relative paths count from the session's folder; ~/… and absolute paths work too, Windows ones included. A :12 or #section after the path is fine; the file still opens at the top.q, Esc, the frame's close mark or ctrl+x x. A new conversation (/clear, a resume) closes it..md files, only Claude's reply text: not tool rows, your own messages or fenced code blocks.hooks/register.tsx 203 lines1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, Register, Timer } from 'claude-code';
3
4import type { Doc } from '../types';
5import { findPaths, fromHref, linkify, resolvePath, toHref } from './paths';
6
7export const PANE = 'md-pane';
8// Under the engine's 100 000 characters per drawing; past it a Markdown's pressable links are dropped.
9const MAX_TEXT = 99_000;
10const MAX_LINKS = 256;
11const MAX_HREF = 2048;
12const MISS_MS = 2000;
13const MAX_FILE = 4 * 1024 * 1024;
14const REFRESH_MS = 1000;
15const CUT_NOTE = '\n\n_Cut at 99 000 characters; open the file elsewhere for the rest._';
16
17export const doc = atom({ plugin: 'md-pane', key: 'doc' } as const, null as Doc | null);
18
19type Stat = { size: number; mtimeMs: number };
20
21// Module state, reset by `register` so a reload (and each test) starts clean.
22let files = new Map<string, Promise<Stat | null>>();
23// Bumped by every press and close: a load that finishes after the counter moved is dropped.
24let generation = 0;
25let timer: Timer | null = null;
26let ticking = false;
27
28// Whether `path` is a file, shared while in flight; a file is remembered for the session, a miss for MISS_MS.
29export function isFile($: EngineInterface, path: string): Promise<Stat | null> {
30 let hit = files.get(path);
31 if (!hit) {
32 hit = $.fs.stat(path).then(
33 (s) => (s.kind === 'file' ? { size: s.size, mtimeMs: s.mtimeMs } : null),
34 () => null,
35 );
36 files.set(path, hit);
37 void hit.then((s) => {
38 if (!s) void $.clock.after(MISS_MS, () => files.delete(path));
39 });
40 }
41 return hit;
42}
43
44async function home($: EngineInterface): Promise<string | null> {
45 return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? null;
46}
47
48// `text` with every path to an existing .md file (tried against each base in turn) rewritten as a file: link.
49export async function linked(
50 $: EngineInterface,
51 text: string,
52 bases: string[],
53): Promise<{ text: string; hrefs: string[] }> {
54 const found = findPaths(text);
55 if (found.length === 0) return { text, hrefs: [] };
56 const h = await home($);
57 const links = [];
58 for (const f of found) {
59 for (const base of bases) {
60 const abs = resolvePath(f.raw, base, h);
61 if (!(await isFile($, abs))) continue;
62 const href = toHref(abs);
63 if (href.length <= MAX_HREF) links.push({ ...f, href });
64 break;
65 }
66 }
67 return { text: linkify(text, links), hrefs: [...new Set(links.map((l) => l.href))].slice(0, MAX_LINKS) };
68}
69
70const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1);
71const dirname = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/';
72const why = (err: unknown) =>
73 /ENOENT/.test(String(err)) ? 'not found' : err instanceof Error ? err.message : String(err);
74
75// The document at `path`, its links rewritten and cut to fit one drawing; never throws.
76export async function load($: EngineInterface, path: string): Promise<Doc> {
77 const empty: Doc = { path, shown: null, hrefs: [], error: null, mtimeMs: 0, size: 0 };
78 let stamp: Doc;
79 try {
80 const s = await $.fs.stat(path);
81 stamp = { ...empty, mtimeMs: s.mtimeMs, size: s.size };
82 if (s.kind !== 'file') return { ...stamp, error: 'not a file' };
83 if (s.size > MAX_FILE) return { ...stamp, error: 'larger than 4 MiB' };
84 } catch (err) {
85 return { ...empty, error: why(err) };
86 }
87 try {
88 const raw = await $.fs.read(path);
89 let { text, hrefs } = await linked($, raw, [dirname(path), await $.session.cwd()]);
90 if (text.length > MAX_TEXT) {
91 const line = text.lastIndexOf('\n', MAX_TEXT);
92 text = text.slice(0, line > 0 ? line : MAX_TEXT) + CUT_NOTE;
93 hrefs = hrefs.filter((h) => text.includes(h));
94 }
95 return { ...stamp, shown: text, hrefs };
96 } catch (err) {
97 return { ...stamp, error: why(err) };
98 }
99}
100
101export async function onPress($: EngineInterface, href: string): Promise<void> {
102 const path = fromHref(href);
103 const gen = ++generation;
104 const open = (await $.state.get({ plugin: 'md-pane', key: 'doc' })).value;
105 const swap = open?.path !== path;
106 if (swap) {
107 const fresh = await load($, path);
108 if (gen !== generation) return;
109 // Re-checked inside the write: a close that lands while it waits wins.
110 await update($, doc, (cur) => (gen === generation ? fresh : cur));
111 }
112 if (gen !== generation) return;
113 await $.ui.open({ id: PANE, title: basename(path), focus: true, closeOnEscape: true });
114 // The test kit has no ui.scroll; a swapped document should still start at its top.
115 if (swap) void $.ui.scroll({ in: PANE, to: 'start' }).catch(() => {});
116}
117
118function unwatch(): void {
119 timer?.cancel();
120 timer = null;
121}
122
123// One refresh: re-load the open document when its stat changed, unless a press or close moved on meanwhile.
124async function tick($: EngineInterface): Promise<void> {
125 if (ticking) return;
126 ticking = true;
127 try {
128 const open = (await $.state.get({ plugin: 'md-pane', key: 'doc' })).value;
129 if (!open) return unwatch();
130 const gen = generation;
131 const now = await $.fs.stat(open.path).then(
132 (s) => `${s.mtimeMs}:${s.size}`,
133 () => 'gone',
134 );
135 const was = open.error === 'not found' ? 'gone' : `${open.mtimeMs}:${open.size}`;
136 if (now === was) return;
137 const fresh = await load($, open.path);
138 if (gen === generation) await update($, doc, (cur) => (cur?.path === open.path ? fresh : cur));
139 } finally {
140 ticking = false;
141 }
142}
143
144export async function close($: EngineInterface): Promise<void> {
145 unwatch();
146 generation++;
147 await $.ui.close({ id: PANE });
148 await update($, doc, () => null);
149}
150
151export const register: Register = (on) => {
152 files = new Map();
153 generation = 0;
154 timer = null;
155 ticking = false;
156
157 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
158 if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true || e.props.isSummary) return next(e);
159 const { text, hrefs } = await linked($, e.props.text, [await $.session.cwd()]);
160 if (hrefs.length === 0 || text.length > MAX_TEXT) return next(e);
161 const { Markdown } = $.ui.resolve(e);
162 return <Markdown key="reply" text={text} onLinkPress={(link) => onPress($, link.href)} pressableLinks={hrefs} />;
163 });
164
165 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
166 const { Box, Text, Markdown, Button } = $.ui.resolve(e);
167 const open = await read($, doc);
168 if (!open) return <Text dimColor>Nothing open.</Text>;
169 timer ??= $.clock.every(REFRESH_MS, () => void tick($));
170 const name = basename(open.path);
171 return (
172 <Box flexDirection="column">
173 <Text key="title" bold>
174 {name}
175 </Text>
176 {open.shown === null ? (
177 <Text key="message" dimColor>{`Can't show ${name}: ${open.error}.`}</Text>
178 ) : (
179 <Markdown
180 key="doc"
181 text={open.shown}
182 onLinkPress={(link) => onPress($, link.href)}
183 pressableLinks={open.hrefs}
184 />
185 )}
186 <Button key="q" plain hotkey="q" label="close" onPress={() => close($)} />
187 </Box>
188 );
189 });
190
191 on('ui.close', { id: PANE }, async ($, e, next) => {
192 unwatch();
193 generation++;
194 await update($, doc, () => null);
195 return next(e);
196 });
197
198 on('session.end', async ($, e, next) => {
199 await close($);
200 return next(e);
201 });
202};
203hooks/paths.ts 118 lines1// Finding .md paths in reply text and turning them into file: links. Pure: no `$`, so the tests need no engine.
2
3export type Found = { start: number; end: number; raw: string; label: string; kind: 'bare' | 'code' | 'link' };
4
5const SEG = String.raw`[\p{L}\p{N}_.@+-]`;
6const SUFFIX = /(?::\d+){1,2}$/;
7const TOKEN = new RegExp(
8 [
9 String.raw`\[([^\]\n]*)\]\(([^)\s]+)\)`,
10 '`([^`\\n]+)`',
11 String.raw`(?<![\p{L}\p{N}_/\\.~:%-])((?:file:///)?(?:~[/\\]|[A-Za-z]:[/\\]|/)?(?:${SEG}+[/\\])*${SEG}*[\p{L}\p{N}_]\.md)((?::\d+){0,2})(?![\p{L}\p{N}_/\\])`,
12 ].join('|'),
13 'gu',
14);
15
16// A scheme other than a drive letter (`C:`); `file:` targets are paths, the rest are left alone.
17const scheme = (s: string) => /^[a-z][a-z0-9+.-]+:/i.exec(s)?.[0].toLowerCase();
18
19// The path a link target or code span names, or null when it names none.
20function pathOf(target: string): string | null {
21 let s = target.replace(/#.*$/, '').replace(SUFFIX, '');
22 const sch = scheme(s);
23 if (sch === 'file:') s = fromHref(s);
24 else if (sch) return null;
25 return s.endsWith('.md') && s.length > 3 ? s : null;
26}
27
28// Line ranges inside ``` or ~~~ fences (indented ones too, as in list items), where nothing is linked. A fence
29// closes only on a bare fence of its own character, at least as long.
30function fences(text: string): [number, number][] {
31 const out: [number, number][] = [];
32 let open = -1;
33 let mark = '';
34 let at = 0;
35 for (const line of text.split('\n')) {
36 const fence = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
37 if (fence && open < 0) {
38 open = at;
39 mark = fence;
40 } else if (fence && fence[0] === mark[0] && fence.length >= mark.length && /^\s*[`~]+\s*$/.test(line)) {
41 out.push([open, at + line.length]);
42 open = -1;
43 }
44 at += line.length + 1;
45 }
46 if (open >= 0) out.push([open, text.length]);
47 return out;
48}
49
50export function findPaths(text: string): Found[] {
51 const fenced = fences(text);
52 const out: Found[] = [];
53 for (const m of text.matchAll(TOKEN)) {
54 const start = m.index;
55 const end = start + m[0].length;
56 if (fenced.some(([a, b]) => start >= a && start < b)) continue;
57 const [, label, target, code, bare] = m;
58 if (target !== undefined) {
59 const raw = pathOf(target);
60 if (raw) out.push({ start, end, raw, label: label!, kind: 'link' });
61 } else if (code !== undefined) {
62 // A span with spaces is a path only when a folder comes before the first space: `glow docs/a.md` is a command.
63 const space = code.search(/\s/);
64 if (space >= 0 && !/[/\\]/.test(code.slice(0, space))) continue;
65 const raw = pathOf(code);
66 if (raw) out.push({ start, end, raw, label: m[0], kind: 'code' });
67 } else if (bare !== undefined) {
68 const raw = bare.startsWith('file:') ? fromHref(bare) : bare;
69 out.push({ start, end, raw, label: m[0], kind: 'bare' });
70 }
71 }
72 return out;
73}
74
75export function resolvePath(raw: string, base: string, home: string | null): string {
76 const slash = (s: string) => s.replace(/\\/g, '/');
77 let p = slash(raw);
78 if (p.startsWith('~/') && home !== null) p = `${slash(home)}/${p.slice(2)}`;
79 else if (!p.startsWith('/') && !/^[A-Za-z]:\//.test(p)) p = `${slash(base)}/${p}`;
80 const drive = /^[A-Za-z]:/.exec(p)?.[0] ?? '';
81 const segs: string[] = [];
82 for (const seg of p.slice(drive.length).split('/')) {
83 if (seg === '..') segs.pop();
84 else if (seg !== '' && seg !== '.') segs.push(seg);
85 }
86 return `${drive}/${segs.join('/')}`;
87}
88
89// Parentheses are encoded too: a raw one would end the Markdown link's target.
90const enc = (seg: string) =>
91 encodeURIComponent(seg).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
92
93export function toHref(abs: string): string {
94 const drive = /^[A-Za-z]:/.exec(abs)?.[0];
95 const rest = drive ? abs.slice(2) : abs;
96 return `file://${drive ? `/${drive}` : ''}${rest.split('/').map(enc).join('/')}`;
97}
98
99export function fromHref(href: string): string {
100 let p = href.replace(/^file:\/\/(localhost)?/i, '');
101 if (/^\/[A-Za-z]:/.test(p)) p = p.slice(1);
102 try {
103 return decodeURIComponent(p);
104 } catch {
105 return p;
106 }
107}
108
109export function linkify(text: string, links: ReadonlyArray<Found & { href: string }>): string {
110 let out = '';
111 let at = 0;
112 for (const l of [...links].sort((a, b) => a.start - b.start)) {
113 out += `${text.slice(at, l.start)}[${l.label}](${l.href})`;
114 at = l.end;
115 }
116 return out + text.slice(at);
117}
118types/index.d.ts 16 lines1// The open document: its absolute path, the text drawn (links rewritten, cut) or why it can't be shown.
2export type Doc = {
3 path: string;
4 shown: string | null;
5 hrefs: string[];
6 error: string | null;
7 mtimeMs: number;
8 size: number;
9};
10
11declare module 'claude-code' {
12 interface PluginState {
13 'md-pane': { doc: Doc | null };
14 }
15}
16