SLOPSHOPPER

md-pane

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

panerowstimer
v0.6.0no licenseupdated 2026-10-23deadmade/dead-claude-skills/plugins/opt-in/md-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · md-pane
│ ┃ md-pane ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing open. │ ⏺ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · md-pane
Nothing open.
README

md-pane

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.

Install

Pick it in the installer (see the main README), on its Tools page. It needs nothing else.

Use

Turn on Claude Code's fullscreen renderer once (/tui fullscreen): only there do plain clicks reach a mod.

  • Any path in Claude's reply that names an existing .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.
  • Click opens it in the pane (docked beside the chat from 110 columns, above the prompt below that). Clicking another path swaps the document. Ctrl+click (cmd+click where your terminal keeps it) opens the file in your system app, as before.
  • Links and paths inside the open document work the same way: relative to the document's folder, falling back to the session's folder for repo-relative paths.
  • The pane follows the file: when Claude (or you) edits it, the pane redraws within a second.
  • Scroll with the wheel, arrows or page keys; close with q, Esc, the frame's close mark or ctrl+x x. A new conversation (/clear, a resume) closes it.

Limits

  • Only .md files, only Claude's reply text: not tool rows, your own messages or fenced code blocks.
  • A path becomes a link only if the file exists when the reply is drawn; a reply that named a file before Claude wrote it keeps it plain until that reply is drawn again.
  • Replies that contain a link are drawn by the mod, so they can differ slightly from Claude Code's own drawing.
  • Files over 4 MiB aren't opened; documents past 99 000 characters are cut at a line with a note.
  • Outside the fullscreen terminal (main-screen renderer, desktop, VS Code, mobile) nothing changes.
Source 3 files
hooks/register.tsx 203 lines
1import { 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};
203
hooks/paths.ts 118 lines
1// 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}
118
types/index.d.ts 16 lines
1// 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