A /check-docs pane with the markdown files written in this conversation, as tabs

My Claude Code mods, shared as a plugin marketplace. Mods need Claude Code v2.1.287 or later.
In a Claude Code session:
/plugin marketplace add DiegoHeer/claude-mods
/plugin install review-diff@claude-mods
/plugin install check-docs@claude-mods
/plugin install skills@claude-mods
/plugin install statusline@claude-mods
Update later with /plugin marketplace update claude-mods.
Upgrading from branch-diff: it was renamed, so the update won't carry it over. Run /plugin uninstall branch-diff@claude-mods, then /plugin install review-diff@claude-mods.
/review-diff opens a pane with every file changed on this branch compared to its base, committed, uncommitted and untracked work included, drawn like a pull request's "Files changed" view.
The base is git config review-diff.base if set, else origin/HEAD, else the first of origin/main, origin/master, main, master that exists. To pin it for a repo: git config review-diff.base develop.
Worktrees: the pane follows the worktree Claude last worked in (the one it entered with EnterWorktree, or the one holding the last file it edited), else the session's. When the repo has more than one worktree, a worktree picker in the header pins the pane to any of them; pick follow Claude to go back to following. Review comments stay with the worktree they were written in.
f switches between whole files and changed parts onlyr refreshes; the pane also refreshes on its own after Claude edits files or runs commandsw steps to the next worktree, then back to follow Claude (shown with two or more worktrees)● uncommitted tag● new, untracked tag, up to 50 of them (files over 1 MB are listed as binary, not drawn)Review comments (terminal and desktop, needs the mouse):
✎ marks its lines)s sends all drafts to Claude as one review message/check-docs opens a pane with the markdown files written in this conversation, such as specs and plans, one tab per file, newest first. The selected file is drawn as formatted markdown. Run /check-docs again to close it.
A file counts when Claude, or one of its subagents, wrote or edited a .md file with the Write or Edit tool.
n goes to the next tab, p to the previous one; press a tab to pick it/clear starts empty/skills:install copies skills from the plugin's skills/library/ folder. Claude asks which skills you want, then where: globally (~/.claude/skills/) or into this repo (.claude/skills/). If a skill is already there, it asks to overwrite or skip. Nothing is committed.
To add a skill to the library, put its folder (with SKILL.md) in skills/library/.
Nothing happens until you run /statusline:install. It sets up the cship statusline (Linux, x86_64 or aarch64):
cship-responsive.sh and cache-ttl.sh to ~/.claude/, and cship-full.toml and cship-compact.toml to ~/.config/. A different file of your own at one of those paths is first saved as <file>.statusline-backup.~/.local/bin, after asking: cship (latest release, only if none is on your PATH) and starship 1.26.0 (sha256-checked, only if missing or older)statusLine block to ~/.claude/settings.json, after backing it up to ~/.claude/settings.json.statusline-backup. If another statusLine is there, it asks first.It needs jq, curl, tar and coreutils, and prints the install command if one is missing. The line's files live in statusline/files/. After changing them and updating the plugin, run /statusline:install again to copy the new versions.
/statusline:uninstall removes the statusLine block (or puts back the one it replaced), backing up settings.json to ~/.claude/settings.json.statusline-uninstall-backup. It puts back your own saved files and deletes the rest. cship and starship stay in ~/.local/bin.
bun test statusline
claude plugin validate ./review-diff
claude plugin test ./review-diff
claude --plugin-dir ./review-diff
claude plugin validate ./check-docs
claude plugin test ./check-docs
claude --plugin-dir ./check-docshooks/register.js 126 lines1// /check-docs: a pane with the markdown files this conversation wrote, one tab
2// each, newest first; the selected one drawn as markdown.
3
4import { atom, read, update } from 'claude-code'
5import { collectDocPaths, keepKnown, knownPaths, rememberPaths, stepPath, tabLabels } from './docs.js'
6import { drawPane } from './view.js'
7
8const PANE = 'check-docs'
9const STORE_KEY = 'recent'
10const REFRESH_DELAY_MS = 300 // lets a burst of edits settle into one refresh
11
12const selected = atom({ plugin: 'check-docs', key: 'selected' }, null)
13let paths = []
14let pendingRefresh = null
15let refreshRun = 0
16const contents = new Map() // each file's content as last read, until something may change it
17
18const shownPath = (picked) => (paths.includes(picked) ? picked : (paths[0] ?? null))
19
20// The store is a convenience: a full or failing one must never stop the pane.
21async function readStore($) {
22 try {
23 return await $.store.get(STORE_KEY)
24 } catch {
25 return undefined
26 }
27}
28
29async function writeStore($, value) {
30 try {
31 await $.store.set(STORE_KEY, value)
32 } catch {}
33}
34
35async function refresh($) {
36 const run = ++refreshRun
37 const conversation = await $.session.id()
38 const written = await collectDocPaths((agentId) => (agentId ? $.session.messages({ agentId }) : $.session.messages()))
39 const recent = await readStore($)
40 // A newer refresh, or another conversation, took over meanwhile: this one's files are stale.
41 if (run !== refreshRun || (await $.session.id()) !== conversation) return
42 paths = keepKnown(written, knownPaths(recent, conversation))
43 contents.clear()
44 await writeStore($, rememberPaths(recent, conversation, paths))
45 $.ui.invalidate('ui.render')
46}
47
48function refreshSoon($) {
49 pendingRefresh?.cancel()
50 pendingRefresh = $.clock.after(REFRESH_DELAY_MS, () => refresh($))
51}
52
53// A shell command cannot add a tab, but may change the file on screen.
54function redraw($) {
55 contents.clear()
56 $.ui.invalidate('ui.render')
57}
58
59const findPane = async ($) => (await $.ui.panes()).find((pane) => pane.id === PANE)
60
61async function readContent($, path) {
62 if (!(await $.fs.exists(path))) return { kind: 'deleted' }
63 try {
64 return { kind: 'text', text: await $.fs.read(path) }
65 } catch {
66 return { kind: 'unreadable' }
67 }
68}
69
70async function contentOf($, path) {
71 if (!contents.has(path)) contents.set(path, await readContent($, path))
72 return contents.get(path)
73}
74
75export function register(on) {
76 on('session.start', async ($, e, next) => {
77 await $.command.register({ name: 'check-docs', description: "Show this conversation's markdown files in a pane" })
78 if (await findPane($)) await refresh($)
79 return next(e)
80 })
81
82 // A /clear, resume or fork changes the conversation, and with it the tabs; a
83 // compaction keeps the conversation, and the pick.
84 on('classic.SessionStart', async ($, e, next) => {
85 if (e.source !== 'compact') await update($, selected, () => null)
86 if (await findPane($)) await refresh($)
87 return next(e)
88 })
89
90 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
91 const result = await next(e)
92 if (await findPane($)) refreshSoon($)
93 return result
94 })
95
96 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
97 const result = await next(e)
98 if (await findPane($)) redraw($)
99 return result
100 })
101
102 on('command.run', { command: 'check-docs' }, async ($) => {
103 if ((await findPane($))?.isShown) {
104 await $.ui.close({ id: PANE })
105 return {}
106 }
107 await refresh($)
108 await $.ui.open({ id: PANE, title: 'Check docs', focus: true, closeOnEscape: true })
109 return {}
110 })
111
112 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
113 if (e.requestId !== PANE) return next(e)
114 const elements = await $.ui.resolve(e)
115 const path = shownPath(await read($, selected))
116 return drawPane(elements, {
117 paths,
118 labels: tabLabels(paths),
119 selected: path,
120 content: path ? await contentOf($, path) : null,
121 onSelect: (picked) => update($, selected, () => picked),
122 onStep: (step) => update($, selected, (picked) => stepPath(paths, shownPath(picked), step)),
123 })
124 })
125}
126hooks/docs.js 65 lines1// The markdown files a conversation wrote, rebuilt from its own history, so a
2// resume, fork, /clear or switch shows that conversation's files.
3
4const WRITING_TOOLS = ['Write', 'Edit']
5const KEPT_CONVERSATIONS = 20 // bounds the store, which holds 4 MiB in all
6const isMarkdown = (path) => typeof path === 'string' && path.toLowerCase().endsWith('.md')
7// A call still in flight has no text yet: it may still be denied or fail.
8const isAnswered = (use) => use.text !== undefined && !use.isError
9const isDocWrite = (use) => WRITING_TOOLS.includes(use.tool) && isAnswered(use) && isMarkdown(use.input?.file_path)
10
11// Newest first: every path in the order it was last written, a subagent's writes
12// placed where that agent ran. readRows(agentId?) answers the main history, or that
13// agent's; anything but an array (a refusal) counts as no rows.
14export async function collectDocPaths(readRows) {
15 const written = []
16 // A forked agent's history holds the call that spawned it: each agent is read once.
17 const seen = new Set()
18 const rowsOf = async (agentId) => {
19 const answer = await readRows(agentId)
20 return Array.isArray(answer) ? answer : []
21 }
22 const readSpawned = async (uses) => {
23 const spawned = [...new Set(uses.map((use) => use.agentId).filter((id) => id && !seen.has(id)))]
24 spawned.forEach((id) => seen.add(id))
25 return new Map(await Promise.all(spawned.map(async (id) => [id, await rowsOf(id)])))
26 }
27 const walk = async (rows) => {
28 const uses = rows.flatMap((row) => row.toolUses ?? [])
29 const agentRows = await readSpawned(uses)
30 for (const use of uses) {
31 if (agentRows.has(use.agentId)) await walk(agentRows.get(use.agentId))
32 else if (isDocWrite(use)) written.push(use.input.file_path)
33 }
34 }
35 await walk(await rowsOf())
36 return [...new Set(written.reverse())]
37}
38
39// A compaction, or a history past the engine's row cap, drops early writes; the
40// files known before stay, behind those the history still shows.
41export const keepKnown = (written, known) => [...written, ...known.filter((path) => !written.includes(path))]
42
43// The store keeps the latest conversations' files as [{ conversation, paths }], newest first.
44const entriesOf = (recent) => (Array.isArray(recent) ? recent : [])
45export const knownPaths = (recent, conversation) => entriesOf(recent).find((entry) => entry.conversation === conversation)?.paths ?? []
46export const rememberPaths = (recent, conversation, paths) =>
47 [{ conversation, paths }, ...entriesOf(recent).filter((entry) => entry.conversation !== conversation)].slice(0, KEPT_CONVERSATIONS)
48
49const pathParts = (path) => path.split(/[\\/]/)
50const baseName = (path) => pathParts(path).at(-1)
51const withParent = (path) => pathParts(path).slice(-2).join('/')
52
53export function tabLabels(paths) {
54 const count = new Map()
55 for (const path of paths) count.set(baseName(path), (count.get(baseName(path)) ?? 0) + 1)
56 return new Map(paths.map((path) => [path, count.get(baseName(path)) > 1 ? withParent(path) : baseName(path)]))
57}
58
59export function stepPath(paths, current, step) {
60 if (paths.length === 0) return null
61 const at = paths.indexOf(current)
62 if (at === -1) return paths[0]
63 return paths[(at + step + paths.length) % paths.length]
64}
65hooks/view.js 49 lines1// Draws the pane: a row of tabs, the key hint, then the selected file as markdown.
2
3const PANE_TEXT_LIMIT = 100_000 // the most text a pane draws, tabs and path included
4const HINT_ROOM = 200 // the key hint, with room to spare
5const CUT_NOTE = '\n\n_File cut: too long to show in full._'
6
7const MESSAGES = {
8 deleted: 'This file was deleted.',
9 unreadable: 'This file could not be read.',
10}
11
12function readerText(content, room) {
13 if (content.kind !== 'text') return MESSAGES[content.kind]
14 if (content.text.length <= room) return content.text
15 return content.text.slice(0, Math.max(0, room - CUT_NOTE.length)) + CUT_NOTE
16}
17
18const readerRoom = (labels, selected) => PANE_TEXT_LIMIT - HINT_ROOM - selected.length - [...labels.values()].join('').length
19
20export function drawPane({ Box, Text, Button, Markdown }, { paths, labels, selected, content, onSelect, onStep }) {
21 const dim = (text) => Text({ dimColor: true, children: [text] })
22 if (paths.length === 0) return dim('No markdown files written in this conversation yet.')
23
24 const tab = (path) =>
25 Button({
26 key: 'tab-' + path,
27 label: labels.get(path),
28 ...(path === selected ? { variant: 'primary' } : { dimColor: true }),
29 onPress: () => onSelect(path),
30 })
31 return Box({
32 flexDirection: 'column',
33 rowGap: 1,
34 children: [
35 Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, children: paths.map(tab) }),
36 Box({
37 flexDirection: 'row',
38 columnGap: 2,
39 children: [
40 Button({ key: 'next', label: 'next', hotkey: 'n', plain: true, onPress: () => onStep(1) }),
41 Button({ key: 'previous', label: 'previous', hotkey: 'p', plain: true, onPress: () => onStep(-1) }),
42 ],
43 }),
44 dim(selected),
45 Markdown({ key: 'reader', text: readerText(content, readerRoom(labels, selected)) }),
46 ],
47 })
48}
49types/index.d.ts 6 lines1declare module 'claude-code' {
2 interface PluginState {
3 'check-docs': { selected: string | null }
4 }
5}
6