Review a pull/merge request draft (title, description, commits) in a pane before it is opened

A Claude Code mod that shows a pull/merge request draft in a pane before the PR exists: title (editable), description, the branch's size (+added −removed), and its commits. You can expand a commit to read its message and diff. While it is open you can change its conventional-commit type or topic in place, or tell Claude how to reword it. Approve when it reads right, and Claude pushes and opens the PR.
It does no polling: the pane redraws when Claude writes the draft and when a turn ends.
/plugin install praft --marketplace yarcod/praft
Or for local development: ln -s "$PWD" ~/.claude/skills/praft.
It doesn't depend on a forge. Approving asks Claude to push and open the request with whatever tool your session has (gh, glab, an MCP server). The pane says MR when origin is a GitLab remote and PR otherwise.
/praft [base] opens the pane for the checked-out branch. If there's no draft, it asks Claude to write one. base defaults to the draft's own base, then origin/main.$(git rev-parse --git-path praft)/<branch>.md, where / in the branch name becomes ~. They are never committed.<!-- base: <branch below> -->), and the pane shows a tab per drafted branch. Changing a commit's type or topic rebases with --update-refs, so the branches stacked on it move too.Draft format (Claude writes it; you can add a rule like this to your CLAUDE.md):
# <conventional title: the outcome>
<!-- base: origin/main -->
<1–3 sentences: what was wrong, what now holds>
Changes / Behaviour change / Tests / Gaps (short bullet sections)
## Commits (pane-only; dropped from the PR body)
- <sha8> — <which part of the PR this commit solves>
delta isn't embedded. "Copy delta command" copies git show <sha> | delta for a terminal.claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 459 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4// One draft per branch, in the git dir: `<git-dir>/praft/<branch>.md` ('/' in the
5// branch name stored as '~', which git refuses in ref names, so it round-trips).
6// Line 1 `# <title>`; an optional `<!-- base: <ref> -->` line names the branch the
7// PR targets (the branch below it, in a stack); then the description; then an
8// optional pane-only `## Commits` section of `- <sha> — <what part it solves>`.
9// Claude writes it; the pane reads it, and the title can be edited in place.
10const PANE = 'praft'
11const DIR = 'praft'
12const DEFAULT_BASE = 'origin/main'
13// One expanded commit draws at most this much diff; the pane's cap is 100k.
14const DIFF_BUDGET = 40_000
15
16// Base set by `/praft <base>`, per branch; wins over the draft's own base line.
17const bases = atom({ plugin: 'praft', key: 'bases' } as const, {} as Record<string, string>)
18// Branch shown in the pane; '' follows the checked-out branch.
19const viewing = atom({ plugin: 'praft', key: 'viewing' } as const, '')
20const expanded = atom({ plugin: 'praft', key: 'expanded' } as const, [] as string[])
21const rev = atom({ plugin: 'praft', key: 'rev' } as const, 0)
22
23type Commit = { sha: string; subject: string; body: string }
24type FileDiff = { path: string; hunks: string }
25
26const git = async ($: EngineInterface, args: string[]) => {
27 const r = await $.process.run(['git', ...args])
28 return r.exitCode === 0 ? r.stdout : ''
29}
30
31const draftDir = async ($: EngineInterface) => (await git($, ['rev-parse', '--git-path', DIR])).trim()
32const draftPath = async ($: EngineInterface, branch: string) => `${await draftDir($)}/${branch.replaceAll('/', '~')}.md`
33// GitLab calls it a merge request (MR); everything else here, a pull request (PR).
34const kindOf = async ($: EngineInterface) =>
35 /gitlab/i.test(await git($, ['remote', 'get-url', 'origin'])) ? 'MR' : 'PR'
36
37const currentBranch = async ($: EngineInterface) => (await git($, ['branch', '--show-current'])).trim()
38
39// Branches with a draft, oldest first, so a stack reads bottom-up.
40const draftedBranches = async ($: EngineInterface) => {
41 const entries = await $.fs.list(await draftDir($)).catch(() => [])
42 return entries
43 .filter(f => f.kind === 'file' && f.name.endsWith('.md'))
44 .sort((a, b) => a.mtimeMs - b.mtimeMs)
45 .map(f => f.name.slice(0, -3).replaceAll('~', '/'))
46}
47
48const BASE_LINE = /^<!--\s*base:\s*(\S+)\s*-->\s*$/m
49
50const readDraft = async ($: EngineInterface, branch: string) => {
51 const text = await $.fs.read(await draftPath($, branch)).catch(() => '')
52 const [first = '', ...rest] = text.split('\n')
53 const isTitled = first.startsWith('# ')
54 const [description = '', commitSection = ''] = (isTitled ? rest : [first, ...rest])
55 .join('\n')
56 .replace(BASE_LINE, '')
57 .split(/^## Commits\s*$/m)
58 const summaries = new Map<string, string>()
59 for (const m of commitSection.matchAll(/^-\s*`?([0-9a-f]{7,40})`?\s*(?:—|–|-|:)\s*(.+)$/gm)) {
60 summaries.set(m[1] ?? '', (m[2] ?? '').trim())
61 }
62 return {
63 exists: text !== '',
64 title: isTitled ? first.slice(2).trim() : '',
65 base: BASE_LINE.exec(text)?.[1],
66 description: description.trim(),
67 summaryOf: (sha: string) => [...summaries].find(([k]) => sha.startsWith(k))?.[1],
68 }
69}
70
71const baseOf = async ($: EngineInterface, branch: string) =>
72 (await read($, bases))[branch] ?? (await readDraft($, branch)).base ?? DEFAULT_BASE
73
74const commits = async ($: EngineInterface, ref: string, branch: string): Promise<Commit[]> => {
75 const out = await git($, ['log', '--reverse', '--format=%H%x1f%s%x1f%b%x1e', `${ref}..${branch || 'HEAD'}`])
76 return out
77 .split('\x1e')
78 .map(rec => rec.replace(/^\n/, ''))
79 .filter(rec => rec.includes('\x1f'))
80 .map(rec => {
81 const [sha = '', subject = '', body = ''] = rec.split('\x1f')
82 return { sha, subject, body: body.trim() }
83 })
84}
85
86// Code's diff format reads hunks only, so split the patch per file and keep
87// each file's lines from its first `@@`.
88const fileDiffs = (patch: string): FileDiff[] =>
89 patch
90 .split(/^diff --git /m)
91 .slice(1)
92 .map(chunk => {
93 const path = /^\+\+\+ b\/(.*)$/m.exec(chunk)?.[1] ?? /^a\/(\S+)/.exec(chunk)?.[1] ?? '?'
94 const at = chunk.indexOf('\n@@')
95 return { path, hunks: at < 0 ? '' : chunk.slice(at + 1).trimEnd() }
96 })
97
98// Conventional subject: `type(scope)!: text`.
99const SUBJECT = /^([a-z]+)(?:\(([^)]*)\))?(!?): (.*)$/
100const TYPES = ['feat', 'fix', 'docs', 'refactor', 'test', 'perf', 'chore', 'ci', 'build', 'style', 'revert']
101const NO_SCOPE = '(none)'
102
103const parseSubject = (subject: string) => {
104 const m = SUBJECT.exec(subject)
105 return m ? { type: m[1] ?? '', scope: m[2] ?? '', bang: m[3] ?? '', text: m[4] ?? '' } : undefined
106}
107
108// Scopes used on the base branch, most used first, so picks match history.
109const knownScopes = async ($: EngineInterface, ref: string) => {
110 const counts = new Map<string, number>()
111 for (const line of (await git($, ['log', '-500', '--format=%s', ref])).split('\n')) {
112 const scope = parseSubject(line)?.scope
113 if (scope) counts.set(scope, (counts.get(scope) ?? 0) + 1)
114 }
115 return [...counts].sort((a, b) => b[1] - a[1]).map(([k]) => k)
116}
117
118// Rewrites one commit's subject line in place (rebase from its parent), keeping
119// its body and content; `--update-refs` carries branches stacked inside the range
120// along. The new subject travels by env var, never through a shell string.
121// Refuses on a dirty tree; aborts the rebase on any failure.
122const rewordSubject = async ($: EngineInterface, sha: string, subject: string): Promise<string | undefined> => {
123 if ((await git($, ['status', '--porcelain', '--untracked-files=no'])).trim() !== '') {
124 $.ui.toast('praft: commit or stash your changes before changing a commit subject')
125 return undefined
126 }
127 const before = (await git($, ['rev-list', '--reverse', `${sha}^..HEAD`])).split('\n').filter(Boolean)
128 const r = await $.process.run(['git', 'rebase', '-i', '--quiet', '--update-refs', `${sha}^`], {
129 env: {
130 PRAFT_SUBJECT: subject,
131 GIT_SEQUENCE_EDITOR: `sed -i -E 's/^pick (${sha.slice(0, 7)}[0-9a-f]*)/reword \\1/'`,
132 GIT_EDITOR: `sh -c '{ printf "%s\\n" "$PRAFT_SUBJECT"; tail -n +2 "$1"; } > "$1.praft" && mv "$1.praft" "$1"' sh`,
133 },
134 })
135 if (r.exitCode !== 0) {
136 await $.process.run(['git', 'rebase', '--abort'])
137 $.ui.toast(`praft: subject not changed; git rebase failed: ${r.stderr.split('\n')[0] ?? ''}`)
138 return undefined
139 }
140 // The reword rewrote every commit after it too: carry every draft's sha refs over.
141 const after = (await git($, ['rev-list', '--reverse', `${sha}^..HEAD`])).split('\n').filter(Boolean)
142 for (const branch of await draftedBranches($)) {
143 const path = await draftPath($, branch)
144 const text = await $.fs.read(path).catch(() => '')
145 let next = text
146 before.forEach((old, i) => (next = next.replaceAll(old.slice(0, 8), (after[i] ?? old).slice(0, 8))))
147 if (next !== text) await $.fs.write(path, next)
148 }
149 return after[0]
150}
151
152const subjectOf = (p: { type: string; scope: string; bang: string; text: string }) =>
153 `${p.type}${p.scope ? `(${p.scope})` : ''}${p.bang}: ${p.text}`
154
155const openPane = async ($: EngineInterface) => $.ui.open({ id: PANE, title: `${await kindOf($)} draft` })
156
157const askForDraft = async ($: EngineInterface, branch: string, ref: string) => {
158 const path = await draftPath($, branch)
159 const kind = await kindOf($)
160 await $.prompt.submit({
161 text:
162 `Write the ${kind} draft for branch ${branch} (commits ${ref}..${branch}) to ${path}, ` +
163 'for the praft pane. Line 1 `# <conventional title: the outcome>`; line 2 ' +
164 `\`<!-- base: ${ref} -->\` (the branch it targets; the branch below it if stacked); ` +
165 'then 1–3 sentences (what was wrong, what now holds); ' +
166 'then short one-line-bullet sections Changes / Behaviour change / Tests / Gaps; ' +
167 `last \`## Commits\` with one \`- <sha8> — <which part of the ${kind} it solves>\` per commit. ` +
168 `Terse. Do not push or open the ${kind}.`,
169 })
170}
171
172// Pane handlers run detached from the draw; a throw there must not take the mod down.
173const guarded =
174 <A extends unknown[]>($: EngineInterface, what: string, f: (...a: A) => unknown) =>
175 (...a: A) =>
176 void Promise.resolve()
177 .then(() => f(...a))
178 .catch((err: unknown) => $.ui.toast(`praft: ${what} failed: ${err instanceof Error ? err.message : String(err)}`))
179
180export const register: Register = on => {
181 on('session.start', async ($, e, next) => {
182 await $.command.register({
183 name: 'praft',
184 description: 'Review the PR/MR draft and its commits in a pane',
185 argumentHint: '[base ref; default: the draft’s base, else origin/main]',
186 })
187 return next(e)
188 })
189
190 // No polling: redraw once per finished turn (commits, rewords, draft edits
191 // land in turns). Bumping rev only redraws an open pane; closed, nothing runs.
192 on('turn.complete', async ($, e, next) => {
193 const result = await next(e)
194 await update($, rev, n => n + 1)
195 return result
196 }).catch(($, e, next) => next(e))
197
198 // Claude writing a draft is the cue to show it.
199 on('tool.call', async ($, e, next) => {
200 const result = await next(e)
201 const path = e.tool === 'Write' || e.tool === 'Edit' ? e.file_path : ''
202 const m = /\/praft\/([^/]+)\.md$/.exec(path)
203 if (m) {
204 const branch = (m[1] ?? '').replaceAll('~', '/')
205 const current = await currentBranch($)
206 await update($, viewing, () => (current === branch ? '' : branch))
207 await update($, rev, n => n + 1)
208 void openPane($)
209 }
210 return result
211 }).catch(($, e, next) => next(e))
212
213 on('command.run', { command: 'praft' }, async ($, e) => {
214 const branch = await currentBranch($)
215 const kind = await kindOf($)
216 if (branch === '') return { text: `praft: HEAD is detached; check out the ${kind}’s branch first.` }
217 const arg = (e.args ?? '').trim()
218 if (arg !== '') await update($, bases, m => ({ ...m, [branch]: arg }))
219 await update($, viewing, () => '')
220 const ref = await baseOf($, branch)
221 await openPane($)
222 if ((await readDraft($, branch)).exists) return { text: `${kind} draft for ${branch} opened (base ${ref}).` }
223 // A command can't submit a prompt while it holds the turn; queue it just after.
224 $.clock.after(0, () => void askForDraft($, branch, ref).catch(() => undefined))
225 return { text: `No ${kind} draft for ${branch} yet; asked Claude to write one (base ${ref}).` }
226 })
227
228 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
229 if (e.surface === 'mobile') return next(e)
230 const { Box, Text, Button, Input, Select, Markdown, Code } = $.ui.resolve(e)
231 try {
232 await read($, rev)
233 const current = await currentBranch($)
234 const kind = await kindOf($)
235 const branch = (await read($, viewing)) || current
236 if (branch === '') return <Text dimColor>HEAD is detached. Check out the {kind}’s branch, then refresh.</Text>
237 // Rewording rebases HEAD, so it works on the checked-out branch and on any
238 // branch below it in a stack (`--update-refs` moves those along).
239 const isCurrent =
240 branch === current ||
241 (await $.process.run(['git', 'merge-base', '--is-ancestor', branch, 'HEAD'])).exitCode === 0
242 const tabs = [...new Set([...(await draftedBranches($)), current].filter(Boolean))]
243 const ref = await baseOf($, branch)
244 const opened = await read($, expanded)
245 const draft = await readDraft($, branch)
246 const list = await commits($, ref, branch)
247 // Whole-request size, as the forge will show it (merge-base to the branch).
248 const short = await git($, ['diff', '--shortstat', `${ref}...${branch}`])
249 const total = {
250 files: Number(/(\d+) files? changed/.exec(short)?.[1] ?? 0),
251 added: Number(/(\d+) insertion/.exec(short)?.[1] ?? 0),
252 removed: Number(/(\d+) deletion/.exec(short)?.[1] ?? 0),
253 }
254 const path = await draftPath($, branch)
255 const ask = guarded($, 'sending to Claude', (text: string) => $.prompt.submit({ text }))
256 // One commit open at a time.
257 const toggle = guarded($, 'expanding', (sha: string) =>
258 update($, expanded, xs => (xs.includes(sha) ? [] : [sha])),
259 )
260 const show = guarded($, 'switching branch', async (b: string) => {
261 await update($, viewing, () => (b === current ? '' : b))
262 await update($, expanded, () => [])
263 })
264 const reword = guarded($, 'changing the subject', async (sha: string, subject: string) => {
265 const now = await rewordSubject($, sha, subject)
266 if (now !== undefined) await update($, expanded, () => [now])
267 await update($, rev, n => n + 1)
268 })
269 const setTitle = guarded($, 'saving the title', async (title: string) => {
270 const text = await $.fs.read(path).catch(() => '')
271 const lines = text.split('\n')
272 const body = lines[0]?.startsWith('# ') ? lines.slice(1) : lines
273 await $.fs.write(path, [`# ${title.trim()}`, ...body].join('\n'))
274 await update($, rev, n => n + 1)
275 })
276 const scopes = opened.length > 0 && isCurrent ? await knownScopes($, ref) : []
277
278 const details = await Promise.all(
279 list
280 .filter(c => opened.includes(c.sha))
281 .map(async c => {
282 const stat = await git($, ['show', '--stat', '--format=', c.sha])
283 const patch = await git($, ['show', '--format=', '--patch', c.sha])
284 const files = fileDiffs(patch)
285 let budget = DIFF_BUDGET
286 const shown = files.filter(f => (budget -= f.hunks.length) >= 0)
287 return { sha: c.sha, stat: stat.trimEnd(), files: shown, hidden: files.length - shown.length }
288 }),
289 )
290
291 return (
292 <Box flexDirection="column" gap={1}>
293 {tabs.length > 1 && (
294 <Box gap={1} flexWrap="wrap">
295 {tabs.map(b => (
296 <Button key={`b-${b}`} plain label={b === branch ? `[${b}]` : b} onPress={() => show(b)} />
297 ))}
298 </Box>
299 )}
300
301 {draft.exists ? (
302 <Input key="title" value={draft.title} submitLabel="Save title" onSubmit={(v: string) => setTitle(v)} />
303 ) : (
304 <Box gap={1}>
305 <Text>
306 No {kind} draft for {branch} yet.
307 </Text>
308 <Button key="draft" label="Ask Claude to draft it" onPress={() => void askForDraft($, branch, ref)} />
309 </Box>
310 )}
311
312 <Box gap={1}>
313 <Text dimColor>
314 {branch} into {ref}, {list.length} commit{list.length === 1 ? '' : 's'}, {total.files} file
315 {total.files === 1 ? '' : 's'}
316 </Text>
317 <Text color="green">+{total.added}</Text>
318 <Text color="red">−{total.removed}</Text>
319 </Box>
320
321 {draft.exists && (
322 <Box flexDirection="column">
323 <Markdown key="desc" text={draft.description} />
324 <Input
325 key="desc-nudge"
326 placeholder="Tell Claude what to change in the title or description"
327 submitLabel="Send"
328 onSubmit={(v: string) => v.trim() && ask(`${kind} draft ${path}: ${v.trim()}`)}
329 />
330 </Box>
331 )}
332
333 <Box flexDirection="column">
334 {list.length === 0 && (
335 <Text dimColor>
336 No commits on {branch} beyond {ref}.
337 </Text>
338 )}
339 {list.map(c => {
340 const sha8 = c.sha.slice(0, 8)
341 const isOpen = opened.includes(c.sha)
342 const d = details.find(x => x.sha === c.sha)
343 const parsed = parseSubject(c.subject)
344 return (
345 <Box key={`c-${c.sha}`} flexDirection="column">
346 <Box gap={1}>
347 <Button
348 key={`t-${c.sha}`}
349 plain
350 label={`${isOpen ? '▾' : '▸'} ${sha8}`}
351 onPress={() => toggle(c.sha)}
352 />
353 <Text bold={isOpen}>{c.subject}</Text>
354 </Box>
355 {draft.summaryOf(c.sha) && (
356 <Box paddingLeft={2}>
357 <Text dimColor>{draft.summaryOf(c.sha)}</Text>
358 </Box>
359 )}
360 {isOpen && d && (
361 <Box flexDirection="column" paddingLeft={2} gap={1}>
362 {parsed && isCurrent && (
363 <Box gap={2}>
364 <Select
365 key={`type-${c.sha}`}
366 label="type"
367 value={parsed.type}
368 options={[...new Set([parsed.type, ...TYPES])].map(value => ({ value }))}
369 onSelect={(v: string) => {
370 if (v !== parsed.type) reword(c.sha, subjectOf({ ...parsed, type: v }))
371 }}
372 />
373 <Select
374 key={`scope-${c.sha}`}
375 label="topic"
376 value={parsed.scope || NO_SCOPE}
377 options={[...new Set([parsed.scope || NO_SCOPE, NO_SCOPE, ...scopes])].map(value => ({
378 value,
379 }))}
380 onSelect={(v: string) => {
381 const scope = v === NO_SCOPE ? '' : v
382 if (scope !== parsed.scope) reword(c.sha, subjectOf({ ...parsed, scope }))
383 }}
384 />
385 </Box>
386 )}
387 {!isCurrent && (
388 <Text dimColor>
389 Check out {branch} (or a branch stacked on it) to change type or topic here.
390 </Text>
391 )}
392 <Input
393 key={`n-${c.sha}`}
394 placeholder="Tell Claude how to reword this commit"
395 submitLabel="Send"
396 onSubmit={(v: string) =>
397 v.trim() &&
398 ask(`Reword commit ${sha8} "${c.subject}" on ${branch} (keep its content): ${v.trim()}`)
399 }
400 />
401 {c.body !== '' && <Text>{c.body}</Text>}
402 <Text dimColor>{d.stat}</Text>
403 {d.files.map((f, i) => (
404 <Box key={`f-${c.sha}-${i}`} flexDirection="column">
405 <Text bold>{f.path}</Text>
406 {f.hunks === '' ? (
407 <Text dimColor>Binary or mode change; no lines to show.</Text>
408 ) : (
409 <Code source={f.hunks} format="diff" path={f.path} />
410 )}
411 </Box>
412 ))}
413 {d.hidden > 0 && (
414 <Text dimColor>{d.hidden} more file(s) too large to draw here; use the delta command.</Text>
415 )}
416 <Box gap={1}>
417 <Button
418 key={`delta-${c.sha}`}
419 label="Copy delta command"
420 onPress={pe => void $.ui.copy({ text: `git show ${sha8} | delta`, surface: pe.surface })}
421 />
422 </Box>
423 </Box>
424 )}
425 </Box>
426 )
427 })}
428 </Box>
429
430 {draft.exists && (
431 <Box gap={2}>
432 <Button
433 key="approve"
434 variant="primary"
435 label={`Approve and open ${kind}`}
436 onPress={() =>
437 ask(
438 `${kind} draft for ${branch} approved in the praft pane (${path}). Push ${branch} and open ` +
439 `the ${kind} into ${ref} with that title and body (drop the ## Commits section ` +
440 'and the base comment).',
441 )
442 }
443 />
444 <Button key="refresh" label="Refresh" onPress={() => void update($, rev, n => n + 1)} />
445 </Box>
446 )}
447 </Box>
448 )
449 } catch (err) {
450 return (
451 <Text color="red">
452 praft could not draw this pane: {err instanceof Error ? err.message : String(err)}. Run /praft to retry; if it
453 repeats, check the repo for an unfinished rebase or merge.
454 </Text>
455 )
456 }
457 })
458}
459types/index.d.ts 17 lines1export type PraftBases = Record<string, string>
2
3declare module 'claude-code' {
4 interface PluginState {
5 praft: {
6 /** Base ref set by `/praft <base>`, per branch; wins over the draft's `<!-- base: -->` line. */
7 bases: PraftBases
8 /** Branch shown in the pane; '' follows the checked-out branch. */
9 viewing: string
10 /** Full SHA of the expanded commit (at most one). */
11 expanded: string[]
12 /** Bumped when the draft or HEAD may have changed, to redraw an open pane. */
13 rev: number
14 }
15 }
16}
17