Guided review of a GitHub PR, GitLab MR or local branch: the diff grouped into ordered steps you page through in a pane

A Claude Code mod for guided code review. It takes a GitHub pull request, a GitLab merge request or your current branch, groups the diff into a few ordered steps that tell the story of the change, and lets you page through them in a pane beside the conversation or in your browser.
Each step has a short title, a plain-English summary, a "Check" note on what deserves a careful look, and the real diff for that step. Claude only decides how the changes group and in what order; every line of code shown comes straight from git, never from the model.
In Claude Code (2.1.287 or newer), at the prompt:
/plugin install guided-mr --marketplace alexjacobs08/guided-mr
Answer y to add the marketplace, then pick a scope.
To run it from a local checkout instead:
claude --plugin-dir /path/to/guided-mr
or add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json to load it in every session.
| Command | Reviews |
|---|---|
/guided-mr | this branch against the repo's default branch |
/guided-mr 123 | PR or MR 123 on whichever forge origin points at |
/guided-mr #123 | GitHub PR 123 |
/guided-mr !123 | GitLab MR 123 |
/guided-mr <url> | a GitHub PR or GitLab MR link, from any repo |
/guided-mr some-ref | this branch against some-ref |
/guided-mr show | reopens the pane without rebuilding |
/guided-mr web | opens the current guide in your browser |
Building a guide takes about a minute. GitHub PRs need the GitHub CLI (gh) signed in; GitLab MRs need the GitLab CLI (glab). Local branches need only git.
n / p next and previous step, 1–9 jump to a step, o overview, x mark reviewed, s unified or before/after view, e expand long diffs, a ask Claude about the step, w open in the browser, r rebuild.
The guide is written to /tmp/guided-mr/ as a single HTML page: a step list, a diagram of how the changed parts connect, and side-by-side syntax-highlighted diffs. j / k move between steps, x marks one reviewed, s switches the diff layout. Reviewed steps are remembered in the browser.
The page loads diff2html and Mermaid from jsDelivr. The guide itself, code included, stays inside the file; offline, the page falls back to plain diffs.
git diff <base>...HEAD, gh pr diff or glab mr diff --raw, with the commit messages or the PR/MR description as background.claude plugin validate .
claude plugin test .hooks/register.tsx 478 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BuildStatus, Guide, Hunk } from '../types'
5import { assembleGuide, buildPrompt, parseDiff, parseTarget, sizeBar, splitHunk, SYSTEM, truncateHunk } from './guide'
6import { renderHtml } from './html'
7
8const PANE = 'guided-mr'
9const MODEL = 'opus'
10const SPLIT_MIN_COLUMNS = 110
11const COLLAPSED_LINES = 25
12const USAGE = [
13 ['/guided-mr', "this branch against the repo's default branch"],
14 ['/guided-mr 123', 'PR or MR 123 on the GitHub or GitLab repo origin points at'],
15 ['/guided-mr #123', 'GitHub PR 123'],
16 ['/guided-mr !123', 'GitLab MR 123'],
17 ['/guided-mr <url>', 'a GitHub PR or GitLab MR link'],
18 ['/guided-mr some-ref', 'this branch against that ref'],
19] as const
20
21const guideAtom = atom({ plugin: 'guided-mr', key: 'guide' } as const, null)
22const viewAtom = atom({ plugin: 'guided-mr', key: 'view' } as const, -1)
23const statusAtom = atom({ plugin: 'guided-mr', key: 'status' } as const, { kind: 'idle', message: '' })
24const splitAtom = atom({ plugin: 'guided-mr', key: 'isSplit' } as const, false)
25const argsAtom = atom({ plugin: 'guided-mr', key: 'lastArgs' } as const, '')
26const reviewedAtom = atom({ plugin: 'guided-mr', key: 'reviewed' } as const, [])
27const visitedAtom = atom({ plugin: 'guided-mr', key: 'visited' } as const, [])
28const expandedAtom = atom({ plugin: 'guided-mr', key: 'expanded' } as const, [])
29
30type Collected = { diff: string; source: string; background: string }
31
32const INSTALL_HINTS: Record<string, string> = {
33 gh: 'Install the GitHub CLI (https://cli.github.com) and run `gh auth login`.',
34 glab: 'Install the GitLab CLI (https://gitlab.com/gitlab-org/cli) and run `glab auth login`.',
35}
36
37async function run($: EngineInterface, argv: string[], timeoutMs = 60000): Promise<string> {
38 let result
39 try {
40 result = await $.process.run(argv, { timeoutMs })
41 } catch (error) {
42 const hint = INSTALL_HINTS[argv[0] ?? '']
43 throw new Error(`${argv[0]} could not run.${hint ? ` ${hint}` : ''} (${error instanceof Error ? error.message : String(error)})`)
44 }
45 if (result.exitCode !== 0) {
46 throw new Error(`${argv.slice(0, 3).join(' ')} failed: ${result.stderr.trim() || `exit ${result.exitCode}`}`)
47 }
48
49 return result.stdout
50}
51
52async function tryRun($: EngineInterface, argv: string[]): Promise<string | null> {
53 try {
54 return (await run($, argv)).trim()
55 } catch {
56 return null
57 }
58}
59
60async function defaultBase($: EngineInterface): Promise<string> {
61 const head = await tryRun($, ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
62 if (head) return head
63 for (const ref of ['origin/main', 'origin/master', 'main', 'master']) {
64 if ((await tryRun($, ['git', 'rev-parse', '--verify', '--quiet', ref])) !== null) return ref
65 }
66
67 return 'main'
68}
69
70async function collect($: EngineInterface, args: string): Promise<Collected> {
71 const target = parseTarget(args, (await tryRun($, ['git', 'remote', 'get-url', 'origin'])) ?? '')
72
73 if (target.kind === 'github') {
74 const view = JSON.parse(
75 await run($, ['gh', 'pr', 'view', target.ref, '--json', 'number,title,body,headRefName,baseRefName']),
76 ) as { number?: number; title?: string; body?: string; headRefName?: string; baseRefName?: string }
77 const diff = await run($, ['gh', 'pr', 'diff', target.ref, '--color=never'], 120000)
78
79 return {
80 diff,
81 source: `#${view.number ?? target.ref} ${view.title ?? ''} (${view.headRefName ?? '?'} into ${view.baseRefName ?? '?'})`,
82 background: view.body ?? '',
83 }
84 }
85
86 if (target.kind === 'gitlab') {
87 const repo = target.repo ? ['-R', target.repo] : []
88 const view = JSON.parse(await run($, ['glab', 'mr', 'view', target.ref, ...repo, '-F', 'json'])) as {
89 title?: string
90 description?: string
91 source_branch?: string
92 target_branch?: string
93 }
94 const diff = await run($, ['glab', 'mr', 'diff', target.ref, ...repo, '--raw', '--color=never'], 120000)
95
96 return {
97 diff,
98 source: `!${target.ref} ${view.title ?? ''} (${view.source_branch ?? '?'} into ${view.target_branch ?? '?'})`,
99 background: view.description ?? '',
100 }
101 }
102
103 const base = target.base || (await defaultBase($))
104 const branch = (await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'])).trim()
105 const diff = await run($, ['git', 'diff', '--no-color', '--no-ext-diff', '-U3', `${base}...HEAD`], 120000)
106 const log = await run($, ['git', 'log', '--no-merges', '--format=- %s%n%b', `${base}..HEAD`])
107
108 return { diff, source: `${branch} compared with ${base}`, background: log.trim() }
109}
110
111async function setStatus($: EngineInterface, status: BuildStatus) {
112 await update($, statusAtom, () => status)
113}
114
115async function build($: EngineInterface, args: string) {
116 await update($, argsAtom, () => args)
117 try {
118 await setStatus($, { kind: 'loading', message: 'Reading the diff...' })
119 const { diff, source, background } = await collect($, args)
120 const hunks = parseDiff(diff)
121 if (hunks.length === 0) throw new Error(`No changes found for ${source}.`)
122
123 await setStatus($, { kind: 'loading', message: `Grouping ${hunks.length} hunks into steps...` })
124 const result = await $.model.complete({
125 model: MODEL,
126 system: SYSTEM,
127 prompt: buildPrompt(hunks, source, background),
128 maxTokens: 8000,
129 timeoutMs: 300000,
130 })
131 if (!result.isAnswered) throw new Error(`The model call failed: ${result.reason}`)
132
133 const guide = assembleGuide(result.text, hunks, source)
134 await update($, guideAtom, () => guide)
135 await update($, viewAtom, () => -1)
136 await update($, reviewedAtom, () => [])
137 await update($, visitedAtom, () => [])
138 await update($, expandedAtom, () => [])
139 await setStatus($, { kind: 'idle', message: '' })
140 $.ui.toast(`Guided MR ready: ${guide.steps.length} steps`)
141 } catch (error) {
142 await setStatus($, { kind: 'error', message: error instanceof Error ? error.message : String(error) })
143 }
144}
145
146async function openInBrowser($: EngineInterface, guide: Guide): Promise<string> {
147 const slug = guide.source.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || 'guide'
148 const path = `/tmp/guided-mr/${slug}.html`
149 await run($, ['mkdir', '-p', '/tmp/guided-mr'])
150 await $.fs.write(path, renderHtml(guide))
151 await run($, ['open', path])
152
153 return path
154}
155
156function registerCommand($: EngineInterface) {
157 return $.command.register({
158 name: 'guided-mr',
159 description: 'Guided review: a GitHub PR, GitLab MR or branch diff as ordered steps in a pane',
160 argumentHint: '[PR/MR number or URL | base ref | show | web]',
161 })
162}
163
164function toggle<T>(list: readonly T[], item: T): T[] {
165 return list.includes(item) ? list.filter(one => one !== item) : [...list, item]
166}
167
168// A module variable, not $.state, so a build cut off by a reload never blocks the next one.
169let isBuilding = false
170
171async function rebuild($: EngineInterface, args: string) {
172 if (isBuilding) return
173 isBuilding = true
174 try {
175 await build($, args)
176 } finally {
177 isBuilding = false
178 }
179}
180
181export const register: Register = on => {
182
183 on('session.start', async ($, e, next) => {
184 await registerCommand($)
185
186 return next(e)
187 })
188
189 on('command.run', { command: 'guided-mr' }, async ($, e) => {
190 const args = e.args.trim()
191 await $.ui.open({ id: PANE, title: 'Guided MR', focus: true })
192 if (args === 'show') return { text: 'Guided MR pane opened.' }
193 if (args === 'web') {
194 const guide = await read($, guideAtom)
195 if (!guide) return { text: 'No guide yet. Run /guided-mr first.' }
196
197 return { text: `Opened ${await openInBrowser($, guide)} in your browser.` }
198 }
199 if (isBuilding) return { text: 'A guide is already being built.' }
200
201 await rebuild($, args)
202 const status = await read($, statusAtom)
203 if (status.kind === 'error') return { text: `Guided MR failed: ${status.message}` }
204 const guide = await read($, guideAtom)
205
206 return { text: `Guided MR ready: ${guide?.steps.length ?? 0} steps. Press n in the pane to start.` }
207 })
208
209 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
210 const { Box, Text, Button, Code, Markdown } = $.ui.resolve(e)
211 const guide: Guide | null = await read($, guideAtom)
212 const status = await read($, statusAtom)
213 const view = await read($, viewAtom)
214 const isSplit = await read($, splitAtom)
215 const reviewed = await read($, reviewedAtom)
216 const visited = await read($, visitedAtom)
217 const expanded = await read($, expandedAtom)
218 const lastArgs = await read($, argsAtom)
219 const columns = e.props.bodyColumns
220
221 const statusRow =
222 status.kind === 'loading' ? (
223 <Text color="warning">◌ {status.message}</Text>
224 ) : status.kind === 'error' ? (
225 <Text color="error">✗ {status.message}</Text>
226 ) : null
227
228 if (!guide) {
229 return (
230 <Box flexDirection="column" gap={1}>
231 {statusRow}
232 <Box flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1}>
233 <Text bold color="claude">
234 Guided MR
235 </Text>
236 <Text dimColor>A guided, step-by-step walk through a change.</Text>
237 <Text> </Text>
238 {USAGE.map(([command, what]) => (
239 <Text>
240 <Text color="suggestion">{command.padEnd(21)}</Text>
241 <Text dimColor>{what}</Text>
242 </Text>
243 ))}
244 </Box>
245 </Box>
246 )
247 }
248
249 const steps = guide.steps
250 const current = Math.min(view, steps.length - 1)
251 const byId = new Map(guide.hunks.map(hunk => [hunk.id, hunk] as const))
252 const hunksOf = (index: number) =>
253 (steps[index]?.hunkIds ?? []).map(id => byId.get(id)).filter((hunk): hunk is Hunk => hunk !== undefined)
254 const sizeOf = (hunks: Hunk[]) => hunks.reduce((sum, hunk) => sum + hunk.added + hunk.removed, 0)
255 const maxStepSize = Math.max(1, ...steps.map((_, index) => sizeOf(hunksOf(index))))
256 const maxHunkSize = Math.max(1, ...guide.hunks.map(hunk => hunk.added + hunk.removed))
257
258 const go = async (index: number) => {
259 await update($, viewAtom, () => index)
260 if (index >= 0) await update($, visitedAtom, list => (list.includes(index) ? list : [...list, index]))
261 }
262
263 const counts = (added: number, removed: number) => (
264 <Text>
265 <Text color="success">+{added}</Text> <Text color="error">−{removed}</Text>
266 </Text>
267 )
268
269 const tracker = (
270 <Box flexDirection="row" gap={1} flexWrap="wrap">
271 <Text>
272 {steps.map((_, index) => {
273 const glyph = index === current ? '◉' : reviewed.includes(index) ? '✓' : visited.includes(index) ? '●' : '○'
274 const color = index === current ? 'claude' : reviewed.includes(index) ? 'success' : visited.includes(index) ? 'text' : 'inactive'
275
276 return (
277 <Text color={color} bold={index === current}>
278 {glyph}{' '}
279 </Text>
280 )
281 })}
282 </Text>
283 <Text dimColor>{current < 0 ? 'Overview' : `Step ${current + 1} of ${steps.length}`}</Text>
284 <Text dimColor>·</Text>
285 <Text color={reviewed.length === steps.length ? 'success' : 'subtle'}>
286 {reviewed.length} of {steps.length} reviewed
287 </Text>
288 </Box>
289 )
290
291 const key = (hotkey: string, label: string, onPress: () => unknown) => (
292 <Button key={`key-${hotkey}`} plain dimColor hotkey={hotkey} label={label} onPress={onPress} />
293 )
294
295 if (current < 0) {
296 const added = guide.hunks.reduce((sum, hunk) => sum + hunk.added, 0)
297 const removed = guide.hunks.reduce((sum, hunk) => sum + hunk.removed, 0)
298 const files = new Set(guide.hunks.map(hunk => hunk.path)).size
299
300 return (
301 <Box flexDirection="column" gap={1}>
302 {statusRow}
303 {tracker}
304 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
305 <Text bold>{guide.title}</Text>
306 <Markdown text={guide.context} />
307 <Text dimColor>
308 {guide.source} · {files} files · {counts(added, removed)}
309 </Text>
310 </Box>
311 <Box flexDirection="column">
312 {steps.map((step, index) => {
313 const hunks = hunksOf(index)
314 const isReviewed = reviewed.includes(index)
315
316 return (
317 <Box key={`step-${index}`} flexDirection="column" marginBottom={1}>
318 <Box flexDirection="row" gap={1}>
319 <Text backgroundColor={isReviewed ? 'success' : 'claude'} color="inverseText" bold>
320 {` ${isReviewed ? '✓' : index + 1} `}
321 </Text>
322 <Button
323 key={`goto-${index}`}
324 plain
325 hotkey={index < 9 ? String(index + 1) : undefined}
326 label={step.title}
327 onPress={() => go(index)}
328 />
329 </Box>
330 <Box paddingLeft={4} flexDirection="column">
331 <Markdown dimColor text={step.summary} />
332 <Text dimColor>
333 {new Set(hunks.map(hunk => hunk.path)).size} files ·{' '}
334 {counts(
335 hunks.reduce((sum, hunk) => sum + hunk.added, 0),
336 hunks.reduce((sum, hunk) => sum + hunk.removed, 0),
337 )}{' '}
338 <Text color="suggestion">{sizeBar(sizeOf(hunks), maxStepSize)}</Text>
339 </Text>
340 </Box>
341 </Box>
342 )
343 })}
344 </Box>
345 <Box flexDirection="row" gap={2} flexWrap="wrap">
346 {key('n', 'start', () => go(0))}
347 {key('w', 'open in browser', () => openInBrowser($, guide))}
348 {key('r', 'rebuild', () => rebuild($, lastArgs))}
349 </Box>
350 </Box>
351 )
352 }
353
354 const step = steps[current]
355 if (!step) return <Box>{tracker}</Box>
356 const stepHunks = hunksOf(current)
357 const isReviewed = reviewed.includes(current)
358 const sideBySide = columns >= SPLIT_MIN_COLUMNS
359 const paths = [...new Set(stepHunks.map(hunk => hunk.path))]
360 const anyCollapsed = stepHunks.some(
361 hunk => !expanded.includes(hunk.id) && truncateHunk(hunk.source, COLLAPSED_LINES).hidden > 0,
362 )
363
364 const drawHunk = (hunk: Hunk) => {
365 const heading = (
366 <Box flexDirection="row" gap={2} flexWrap="wrap">
367 <Text bold color="suggestion">
368 {hunk.path}
369 </Text>
370 {counts(hunk.added, hunk.removed)}
371 <Text color="subtle">{sizeBar(hunk.added + hunk.removed, maxHunkSize)}</Text>
372 </Box>
373 )
374 if (hunk.note) {
375 return (
376 <Box key={hunk.id} flexDirection="column" marginBottom={1}>
377 {heading}
378 <Text dimColor italic>
379 {hunk.note}
380 </Text>
381 </Box>
382 )
383 }
384
385 const cut = expanded.includes(hunk.id) ? { source: hunk.source, hidden: 0 } : truncateHunk(hunk.source, COLLAPSED_LINES)
386 const more =
387 cut.hidden > 0 ? (
388 <Text dimColor italic>
389 … {cut.hidden} more lines (press e to expand)
390 </Text>
391 ) : null
392
393 if (!isSplit) {
394 return (
395 <Box key={hunk.id} flexDirection="column" marginBottom={1}>
396 {heading}
397 <Code format="diff" source={cut.source} path={hunk.path} />
398 {more}
399 </Box>
400 )
401 }
402 const parts = splitHunk(cut.source)
403
404 return (
405 <Box key={hunk.id} flexDirection="column" marginBottom={1}>
406 {heading}
407 <Box flexDirection={sideBySide ? 'row' : 'column'} gap={1}>
408 <Box flexDirection="column" width={sideBySide ? '50%' : '100%'} borderStyle="single" borderColor="diffRemovedDimmed" paddingX={1}>
409 <Text color="error" bold>
410 Before
411 </Text>
412 <Code source={parts.before || ' '} path={hunk.path} startLine={parts.beforeStart} />
413 </Box>
414 <Box flexDirection="column" width={sideBySide ? '50%' : '100%'} borderStyle="single" borderColor="diffAddedDimmed" paddingX={1}>
415 <Text color="success" bold>
416 After
417 </Text>
418 <Code source={parts.after || ' '} path={hunk.path} startLine={parts.afterStart} />
419 </Box>
420 </Box>
421 {more}
422 </Box>
423 )
424 }
425
426 return (
427 <Box flexDirection="column" gap={1}>
428 {statusRow}
429 {tracker}
430 <Box flexDirection="column" borderStyle="round" borderColor={isReviewed ? 'success' : 'claude'} paddingX={1}>
431 <Box flexDirection="row" gap={1}>
432 <Text backgroundColor={isReviewed ? 'success' : 'claude'} color="inverseText" bold>
433 {` ${current + 1} `}
434 </Text>
435 <Text bold>{step.title}</Text>
436 {isReviewed ? <Text color="success">✓ reviewed</Text> : null}
437 </Box>
438 <Markdown text={step.summary} />
439 {step.watch ? (
440 <Text color="warning">
441 <Text bold>⚠ Check: </Text>
442 {step.watch}
443 </Text>
444 ) : null}
445 <Text dimColor>
446 {paths.length} files · {counts(
447 stepHunks.reduce((sum, hunk) => sum + hunk.added, 0),
448 stepHunks.reduce((sum, hunk) => sum + hunk.removed, 0),
449 )}
450 </Text>
451 </Box>
452 <Box flexDirection="column">{stepHunks.map(drawHunk)}</Box>
453 <Box flexDirection="row" gap={2} flexWrap="wrap">
454 {key('n', 'next', () => go(Math.min(steps.length - 1, current + 1)))}
455 {key('p', 'prev', () => go(current - 1))}
456 {key('x', isReviewed ? 'unmark' : 'reviewed', () => update($, reviewedAtom, list => toggle(list, current)))}
457 {key('s', isSplit ? 'unified' : 'before/after', () => update($, splitAtom, value => !value))}
458 {key('e', anyCollapsed ? 'expand' : 'collapse', () =>
459 update($, expandedAtom, list => {
460 const ids = stepHunks.map(hunk => hunk.id)
461
462 return anyCollapsed ? [...new Set([...list, ...ids])] : list.filter(id => !ids.includes(id))
463 }),
464 )}
465 {key('a', 'ask', () =>
466 $.prompt.fill({
467 text: `About step ${current + 1} of the guided MR ("${step.title}", in ${paths.join(', ')}): `,
468 mode: 'replace',
469 }),
470 )}
471 {key('o', 'overview', () => go(-1))}
472 {key('w', 'browser', () => openInBrowser($, guide))}
473 </Box>
474 </Box>
475 )
476 })
477}
478hooks/guide.ts 227 lines1import type { Guide, Hunk, Step } from '../types'
2
3const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
4const PER_HUNK_CHARS = 6000
5const PROMPT_BUDGET_CHARS = 250000
6
7export function parseDiff(text: string): Hunk[] {
8 const hunks: Hunk[] = []
9 const lines = text.split('\n')
10 let path = ''
11 let note = ''
12 let fileHunks = 0
13 let current: Hunk | null = null
14 let oldLeft = 0
15 let newLeft = 0
16
17 const closeFile = () => {
18 if (path && fileHunks === 0) {
19 hunks.push({ id: `H${hunks.length + 1}`, path, source: '', note: note || 'No content changes', added: 0, removed: 0 })
20 }
21 }
22
23 for (const line of lines) {
24 if (current && (oldLeft > 0 || newLeft > 0)) {
25 const mark = line[0]
26 if (mark === '+') {
27 current.added += 1
28 newLeft -= 1
29 } else if (mark === '-') {
30 current.removed += 1
31 oldLeft -= 1
32 } else if (mark === ' ' || line === '') {
33 oldLeft -= 1
34 newLeft -= 1
35 } else if (mark !== '\\') {
36 current = null
37 }
38 if (current) {
39 current.source += `\n${line}`
40 continue
41 }
42 }
43 if (current && line.startsWith('\\')) {
44 current.source += `\n${line}`
45 continue
46 }
47
48 if (line.startsWith('diff --git ')) {
49 closeFile()
50 current = null
51 fileHunks = 0
52 note = ''
53 const match = / b\/(.*)$/.exec(line)
54 path = match?.[1] ?? line.slice('diff --git '.length)
55 } else if (line.startsWith('+++ b/')) {
56 path = line.slice('+++ b/'.length)
57 } else if (line.startsWith('rename from ')) {
58 note = `Renamed from ${line.slice('rename from '.length)}`
59 } else if (line.startsWith('new file mode')) {
60 note = 'New empty file'
61 } else if (line.startsWith('deleted file mode')) {
62 note = 'File deleted'
63 } else if (line.startsWith('Binary files')) {
64 note = 'Binary file changed'
65 } else {
66 const header = HUNK_HEADER.exec(line)
67 if (header && path) {
68 oldLeft = header[2] === undefined ? 1 : Number(header[2])
69 newLeft = header[4] === undefined ? 1 : Number(header[4])
70 current = { id: `H${hunks.length + 1}`, path, source: line, added: 0, removed: 0 }
71 hunks.push(current)
72 fileHunks += 1
73 }
74 }
75 }
76 closeFile()
77
78 return hunks
79}
80
81export function buildPrompt(hunks: Hunk[], source: string, background: string): string {
82 let budget = PROMPT_BUDGET_CHARS
83 const blocks = hunks.map(hunk => {
84 const head = `### ${hunk.id} ${hunk.path} (+${hunk.added} -${hunk.removed})`
85 if (hunk.note) return `${head}\n${hunk.note}`
86 const body = hunk.source.length > PER_HUNK_CHARS ? `${hunk.source.slice(0, PER_HUNK_CHARS)}\n[... hunk cut]` : hunk.source
87 if (body.length > budget) return `${head}\n[body omitted for length]`
88 budget -= body.length
89
90 return `${head}\n${body}`
91 })
92
93 return [
94 `Change under review: ${source}`,
95 background ? `Background from the author (MR description and commit messages):\n${background}` : '',
96 `The diff, split into hunks with ids:\n\n${blocks.join('\n\n')}`,
97 ]
98 .filter(Boolean)
99 .join('\n\n')
100}
101
102export const SYSTEM = `You write guided code reviews. A reviewer will read the change one step at a time, so turn the hunks into the story of the change.
103
104Group the hunks into 2 to 8 ordered steps, in the order a reviewer should read them: foundations first (schemas, models, types, config), then the core logic, then its callers (routes, UI, jobs), then tests and docs. A test belongs to the step it tests unless the tests are large enough to be a step of their own. Every hunk id must appear in exactly one step.
105
106For each step give:
107- "title": at most 8 words, saying what the step does.
108- "summary": 1 to 3 plain sentences on what changes and why.
109- "watch": one sentence on what the reviewer should check carefully in this step, or "" if nothing stands out.
110- "hunks": the hunk ids, in reading order.
111
112Also give the guide a "title" (at most 10 words) and a "context": one sentence on what the whole change does and why.
113
114Also give a "diagram": a Mermaid flowchart (starting "flowchart LR") of the main parts the change touches (modules, classes, functions, endpoints; at most 12 nodes) and how they call or feed each other. Label each node with a short name, and label edges or nodes with the step numbers that change them, like "(2)". Use plain alphanumeric node ids and put labels in double quotes.
115
116Reply with JSON only, no prose and no code fence:
117{"title": "...", "context": "...", "diagram": "flowchart LR\\n ...", "steps": [{"title": "...", "summary": "...", "watch": "...", "hunks": ["H1"]}]}`
118
119type RawStep = { title?: unknown; summary?: unknown; watch?: unknown; hunks?: unknown }
120
121export function assembleGuide(reply: string, hunks: Hunk[], source: string): Guide {
122 const start = reply.indexOf('{')
123 const end = reply.lastIndexOf('}')
124 if (start < 0 || end <= start) throw new Error('The model did not reply with JSON.')
125 const raw = JSON.parse(reply.slice(start, end + 1)) as { title?: unknown; context?: unknown; diagram?: unknown; steps?: unknown }
126
127 const known = new Set(hunks.map(hunk => hunk.id))
128 const placed = new Set<string>()
129 const steps: Step[] = []
130 for (const item of Array.isArray(raw.steps) ? (raw.steps as RawStep[]) : []) {
131 const ids = (Array.isArray(item.hunks) ? item.hunks : [])
132 .map(String)
133 .filter(id => known.has(id) && !placed.has(id))
134 ids.forEach(id => placed.add(id))
135 if (ids.length === 0) continue
136 steps.push({
137 title: String(item.title ?? 'Untitled step'),
138 summary: String(item.summary ?? ''),
139 watch: String(item.watch ?? ''),
140 hunkIds: ids,
141 })
142 }
143
144 const leftover = hunks.filter(hunk => !placed.has(hunk.id)).map(hunk => hunk.id)
145 if (leftover.length > 0) {
146 steps.push({ title: 'Other changes', summary: 'Hunks the guide did not place in a step.', watch: '', hunkIds: leftover })
147 }
148
149 return {
150 title: String(raw.title ?? 'Guided review'),
151 context: String(raw.context ?? ''),
152 source,
153 diagram: /^\s*(flowchart|graph)\b/.test(String(raw.diagram ?? '')) ? String(raw.diagram).trim() : '',
154 steps,
155 hunks,
156 }
157}
158
159export function splitHunk(source: string): { before: string; after: string; beforeStart: number; afterStart: number } {
160 const [header, ...lines] = source.split('\n')
161 const match = HUNK_HEADER.exec(header ?? '')
162 const before: string[] = []
163 const after: string[] = []
164 for (const line of lines) {
165 if (line.startsWith('\\')) continue
166 const text = line.slice(1)
167 if (line[0] !== '+') before.push(text)
168 if (line[0] !== '-') after.push(text)
169 }
170
171 return {
172 before: before.join('\n'),
173 after: after.join('\n'),
174 beforeStart: Math.max(1, Number(match?.[1] ?? 1)),
175 afterStart: Math.max(1, Number(match?.[3] ?? 1)),
176 }
177}
178
179// Keeps the first `maxLines` lines of a hunk and rewrites its header counts so it still parses as a diff.
180export function truncateHunk(source: string, maxLines: number): { source: string; hidden: number } {
181 const [header = '', ...lines] = source.split('\n')
182 if (lines.length <= maxLines) return { source, hidden: 0 }
183 const match = HUNK_HEADER.exec(header)
184 if (!match) return { source, hidden: 0 }
185
186 const kept = lines.slice(0, maxLines)
187 const oldCount = kept.filter(line => line[0] !== '+' && line[0] !== '\\').length
188 const newCount = kept.filter(line => line[0] !== '-' && line[0] !== '\\').length
189 const rest = header.slice(match[0].length)
190
191 return {
192 source: [`@@ -${match[1]},${oldCount} +${match[3]},${newCount} @@${rest}`, ...kept].join('\n'),
193 hidden: lines.length - maxLines,
194 }
195}
196
197export function sizeBar(size: number, max: number, width = 8): string {
198 const filled = max <= 0 ? 0 : Math.max(size > 0 ? 1 : 0, Math.round((size / max) * width))
199
200 return '█'.repeat(filled) + '░'.repeat(width - filled)
201}
202
203export type Target =
204 | { kind: 'local'; base: string }
205 | { kind: 'github'; ref: string }
206 | { kind: 'gitlab'; ref: string; repo?: string }
207
208// Reads what /guided-mr was given. A bare number goes to whichever forge `origin` points at.
209export function parseTarget(args: string, remoteUrl: string): Target {
210 const text = args.trim()
211 if (!text) return { kind: 'local', base: '' }
212
213 if (/^https?:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/.test(text)) return { kind: 'github', ref: text }
214 const gitlabUrl = /^(https?:\/\/[^/]+\/.+?)\/-\/merge_requests\/(\d+)/.exec(text)
215 if (gitlabUrl) return { kind: 'gitlab', ref: gitlabUrl[2] ?? '', repo: gitlabUrl[1] }
216
217 const github = /^#(\d+)$/.exec(text)
218 if (github) return { kind: 'github', ref: github[1] ?? '' }
219 const gitlab = /^!(\d+)$/.exec(text)
220 if (gitlab) return { kind: 'gitlab', ref: gitlab[1] ?? '' }
221 if (/^\d+$/.test(text)) {
222 return /(^|[@/.])github\.com[:/]/.test(remoteUrl) ? { kind: 'github', ref: text } : { kind: 'gitlab', ref: text }
223 }
224
225 return { kind: 'local', base: text }
226}
227hooks/html.ts 317 lines1import type { Guide } from '../types'
2
3// The page is static: the guide rides along as JSON and the script below draws it.
4// diff2html and Mermaid load from jsDelivr; without them the page falls back to plain diffs.
5export function renderHtml(guide: Guide): string {
6 const data = JSON.stringify({ ...guide, diagram: guide.diagram ?? '' }).replace(/</g, '\\u003c')
7 const title = guide.title.replace(/[&<>"]/g, ch => `&#${ch.charCodeAt(0)};`)
8
9 return `<!doctype html>
10<html lang="en">
11<head>
12<meta charset="utf-8">
13<meta name="viewport" content="width=device-width, initial-scale=1">
14<title>${title} · Guided MR</title>
15<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html@3/bundles/css/diff2html.min.css">
16<script src="https://cdn.jsdelivr.net/npm/diff2html@3/bundles/js/diff2html-ui.min.js"></script>
17<style>${CSS}</style>
18</head>
19<body>
20<header class="topbar">
21 <div class="brand"><span class="dot"></span>Guided MR</div>
22 <div class="progress"><div class="progress-fill" id="progress-fill"></div></div>
23 <div class="progress-label" id="progress-label"></div>
24 <div class="controls">
25 <button id="toggle-layout" title="s">Side by side</button>
26 <button id="expand-all">Expand all</button>
27 </div>
28</header>
29<div class="layout">
30 <nav class="rail" id="rail"></nav>
31 <main id="main"></main>
32</div>
33<div class="keys"><kbd>j</kbd>/<kbd>k</kbd> step <kbd>x</kbd> reviewed <kbd>s</kbd> layout</div>
34<script id="guide-data" type="application/json">${data}</script>
35<script>${SCRIPT}</script>
36<script type="module">
37 const el = document.getElementById('diagram');
38 if (el) {
39 try {
40 const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs');
41 const dark = matchMedia('(prefers-color-scheme: dark)').matches;
42 mermaid.initialize({ startOnLoad: false, theme: 'base', securityLevel: 'strict', themeVariables: dark
43 ? { background: '#171a23', primaryColor: '#232838', primaryTextColor: '#e6e8ef', primaryBorderColor: '#e8875b', lineColor: '#7aa2f7', fontFamily: 'Inter, system-ui, sans-serif' }
44 : { background: '#ffffff', primaryColor: '#fff4ee', primaryTextColor: '#1d2030', primaryBorderColor: '#d9703f', lineColor: '#3d6fd9', fontFamily: 'Inter, system-ui, sans-serif' } });
45 const { svg } = await mermaid.render('guide-diagram', el.dataset.source);
46 el.innerHTML = svg;
47 } catch (error) {
48 el.classList.add('diagram-fallback');
49 }
50 }
51</script>
52</body>
53</html>
54`
55}
56
57const CSS = `
58:root {
59 --bg: #0f1117; --panel: #171a23; --panel-2: #1e2230; --line: #2a2f40; --text: #e6e8ef; --muted: #8b91a7;
60 --accent: #e8875b; --accent-soft: rgba(232,135,91,.14); --blue: #7aa2f7; --green: #9ece6a; --red: #f7768e; --amber: #e0af68;
61 --radius: 14px; --mono: "JetBrains Mono", "SF Mono", ui-monospace, Menlo, monospace;
62 --sans: Inter, "SF Pro Text", system-ui, -apple-system, sans-serif;
63}
64@media (prefers-color-scheme: light) {
65 :root { --bg: #f6f5f2; --panel: #ffffff; --panel-2: #f1efe9; --line: #e3e0d8; --text: #1d2030; --muted: #6b6f80;
66 --accent: #d9703f; --accent-soft: rgba(217,112,63,.10); --blue: #3d6fd9; --green: #2f8a3a; --red: #c8364f; --amber: #a86b00; }
67}
68* { box-sizing: border-box; }
69html { scroll-behavior: smooth; scroll-padding-top: 76px; }
70body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 var(--sans); -webkit-font-smoothing: antialiased; }
71body::before { content: ""; position: fixed; inset: 0; pointer-events: none; z-index: -1;
72 background: radial-gradient(900px 500px at 85% -10%, var(--accent-soft), transparent 60%),
73 radial-gradient(700px 400px at -10% 20%, rgba(122,162,247,.08), transparent 60%); }
74code { font-family: var(--mono); font-size: .88em; background: var(--panel-2); border: 1px solid var(--line); padding: .05em .4em; border-radius: 6px; }
75
76.topbar { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 18px; padding: 12px 28px;
77 background: color-mix(in srgb, var(--bg) 82%, transparent); backdrop-filter: blur(12px); border-bottom: 1px solid var(--line); }
78.brand { font-weight: 700; letter-spacing: .02em; display: flex; align-items: center; gap: 8px; white-space: nowrap; }
79.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 12px var(--accent); }
80.progress { flex: 1; height: 6px; border-radius: 99px; background: var(--panel-2); overflow: hidden; }
81.progress-fill { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent), var(--amber)); transition: width .4s ease; }
82.progress-label { color: var(--muted); font-size: 13px; white-space: nowrap; font-variant-numeric: tabular-nums; }
83.controls { display: flex; gap: 8px; }
84button { font: inherit; font-size: 13px; color: var(--text); background: var(--panel); border: 1px solid var(--line);
85 border-radius: 9px; padding: 6px 12px; cursor: pointer; transition: border-color .15s, background .15s; }
86button:hover { border-color: var(--accent); }
87button.primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
88button.done { background: transparent; border-color: var(--green); color: var(--green); }
89
90.layout { display: grid; grid-template-columns: 290px minmax(0, 1fr); gap: 32px; max-width: 1500px; margin: 0 auto; padding: 28px; }
91.rail { position: sticky; top: 76px; align-self: start; max-height: calc(100vh - 100px); overflow: auto; }
92.rail a { display: grid; grid-template-columns: 30px 1fr; gap: 10px; align-items: start; padding: 10px 12px; margin-bottom: 4px;
93 border-radius: 12px; color: var(--text); text-decoration: none; border: 1px solid transparent; transition: background .15s; }
94.rail a:hover { background: var(--panel); }
95.rail a.active { background: var(--panel); border-color: var(--line); box-shadow: inset 3px 0 0 var(--accent); }
96.rail .t { font-size: 13.5px; line-height: 1.35; font-weight: 550; }
97.rail .meta { display: block; font-size: 12px; color: var(--muted); margin-top: 4px; }
98.badge { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 13px;
99 background: var(--accent-soft); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
100.reviewed .badge { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); border-color: color-mix(in srgb, var(--green) 45%, transparent); }
101.bar { display: inline-block; vertical-align: middle; width: 60px; height: 5px; border-radius: 99px; background: var(--panel-2); overflow: hidden; margin-left: 6px; }
102.bar i { display: block; height: 100%; background: var(--blue); border-radius: 99px; }
103
104.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 24px 28px; margin-bottom: 24px;
105 box-shadow: 0 1px 0 rgba(255,255,255,.03) inset, 0 10px 30px -18px rgba(0,0,0,.5); }
106.hero .kicker { text-transform: uppercase; letter-spacing: .14em; font-size: 12px; color: var(--accent); font-weight: 700; }
107.hero h1 { font-size: 30px; line-height: 1.2; margin: 8px 0 10px; letter-spacing: -.01em; }
108.hero p { font-size: 17px; color: var(--muted); margin: 0 0 18px; max-width: 75ch; }
109.chips { display: flex; flex-wrap: wrap; gap: 8px; }
110.chip { font-size: 12.5px; padding: 4px 10px; border-radius: 99px; background: var(--panel-2); border: 1px solid var(--line); color: var(--muted); }
111.chip.add { color: var(--green); } .chip.del { color: var(--red); }
112.section-title { font-size: 12px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); font-weight: 700; margin: 0 0 14px; }
113#diagram { display: flex; justify-content: center; overflow-x: auto; }
114#diagram svg { max-width: 100%; height: auto; }
115.diagram-fallback { white-space: pre; font-family: var(--mono); font-size: 13px; color: var(--muted); justify-content: flex-start !important; }
116
117.step { scroll-margin-top: 80px; }
118.step-head { display: flex; gap: 16px; align-items: flex-start; }
119.step-head .badge { width: 40px; height: 40px; font-size: 17px; flex: none; }
120.step-head h2 { font-size: 21px; line-height: 1.3; margin: 6px 0 0; flex: 1; letter-spacing: -.005em; }
121.summary { margin: 14px 0 0 56px; max-width: 80ch; }
122.watch { margin: 14px 0 0 56px; padding: 10px 14px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--amber) 40%, transparent);
123 background: color-mix(in srgb, var(--amber) 9%, transparent); color: var(--amber); font-size: 14px; }
124.watch b { margin-right: 6px; }
125.files { margin: 14px 0 0 56px; }
126.diffs { margin-top: 20px; }
127.diff-wrap { position: relative; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); margin-bottom: 14px; }
128.diff-wrap.collapsed { max-height: 520px; }
129.diff-wrap.collapsed::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 90px; background: linear-gradient(transparent, var(--panel)); }
130.expand { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); z-index: 2; }
131.note { margin-bottom: 14px; padding: 10px 14px; border-radius: 10px; background: var(--panel-2); color: var(--muted); font-style: italic; }
132.note b { font-style: normal; color: var(--blue); font-family: var(--mono); font-weight: 500; margin-right: 8px; }
133pre.plain { margin: 0; padding: 12px 0; font: 12.5px/1.55 var(--mono); overflow-x: auto; background: var(--panel-2); }
134pre.plain span { display: block; padding: 0 14px; white-space: pre; }
135pre.plain .a { background: color-mix(in srgb, var(--green) 14%, transparent); }
136pre.plain .d { background: color-mix(in srgb, var(--red) 14%, transparent); }
137pre.plain .h { color: var(--blue); }
138.d2h-wrapper .d2h-file-wrapper { border: 0; margin: 0; border-radius: 0; }
139.d2h-file-header { font-family: var(--mono); }
140.keys { position: fixed; right: 18px; bottom: 16px; font-size: 12px; color: var(--muted); background: var(--panel); border: 1px solid var(--line);
141 border-radius: 10px; padding: 6px 10px; }
142kbd { font-family: var(--mono); font-size: 11px; padding: 1px 6px; border-radius: 5px; border: 1px solid var(--line); background: var(--panel-2); margin: 0 2px; }
143@media (max-width: 960px) { .layout { grid-template-columns: 1fr; } .rail { position: static; max-height: none; } }
144`
145
146const SCRIPT = `
147(function () {
148 var guide = JSON.parse(document.getElementById('guide-data').textContent);
149 var storeKey = 'guided-mr:' + guide.source + ':' + guide.title;
150 var reviewed = new Set(JSON.parse(localStorage.getItem(storeKey) || '[]'));
151 var sideBySide = localStorage.getItem('guided-mr:layout') !== 'line';
152 var byId = {};
153 guide.hunks.forEach(function (h) { byId[h.id] = h; });
154
155 function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; }); }
156 function md(s) { return esc(s).replace(/\\x60([^\\x60]+)\\x60/g, '<code>$1</code>').replace(/\\*\\*([^*]+)\\*\\*/g, '<b>$1</b>'); }
157 function el(tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
158 function hunksOf(step) { return step.hunkIds.map(function (id) { return byId[id]; }).filter(Boolean); }
159 function sum(list, key) { return list.reduce(function (n, h) { return n + h[key]; }, 0); }
160
161 var sizes = guide.steps.map(function (s) { var h = hunksOf(s); return sum(h, 'added') + sum(h, 'removed'); });
162 var maxSize = Math.max.apply(null, [1].concat(sizes));
163 var main = document.getElementById('main');
164 var rail = document.getElementById('rail');
165
166 var files = new Set(guide.hunks.map(function (h) { return h.path; })).size;
167 var hero = el('section', 'card hero');
168 hero.innerHTML = '<div class="kicker">Guided review</div><h1>' + esc(guide.title) + '</h1><p>' + md(guide.context) + '</p>' +
169 '<div class="chips"><span class="chip">' + esc(guide.source) + '</span><span class="chip">' + guide.steps.length + ' steps</span>' +
170 '<span class="chip">' + files + ' files</span><span class="chip add">+' + sum(guide.hunks, 'added') + '</span>' +
171 '<span class="chip del">\\u2212' + sum(guide.hunks, 'removed') + '</span></div>';
172 main.appendChild(hero);
173
174 if (guide.diagram) {
175 var d = el('section', 'card');
176 d.appendChild(el('div', 'section-title', 'How the pieces connect'));
177 var holder = el('div');
178 holder.id = 'diagram';
179 holder.dataset.source = guide.diagram;
180 holder.textContent = guide.diagram;
181 d.appendChild(holder);
182 main.appendChild(d);
183 }
184
185 function diffText(hunks) {
186 var byPath = [], groups = {};
187 hunks.forEach(function (h) {
188 if (h.note) return;
189 if (!groups[h.path]) { groups[h.path] = []; byPath.push(h.path); }
190 groups[h.path].push(h);
191 });
192 return byPath.map(function (p) {
193 return 'diff --git a/' + p + ' b/' + p + '\\n--- a/' + p + '\\n+++ b/' + p + '\\n' +
194 groups[p].map(function (h) { return h.source; }).join('\\n');
195 });
196 }
197
198 function drawPlain(target, text) {
199 var pre = el('pre', 'plain');
200 text.split('\\n').forEach(function (line) {
201 var s = el('span', line[0] === '+' && !line.startsWith('+++') ? 'a' : line[0] === '-' && !line.startsWith('---') ? 'd' : line.startsWith('@@') ? 'h' : '');
202 s.textContent = line || ' ';
203 pre.appendChild(s);
204 });
205 target.appendChild(pre);
206 }
207
208 function drawDiffs(container, step) {
209 container.innerHTML = '';
210 hunksOf(step).filter(function (h) { return h.note; }).forEach(function (h) {
211 container.appendChild(el('div', 'note', '<b>' + esc(h.path) + '</b>' + esc(h.note)));
212 });
213 diffText(hunksOf(step)).forEach(function (text) {
214 var wrap = el('div', 'diff-wrap');
215 var target = el('div');
216 wrap.appendChild(target);
217 container.appendChild(wrap);
218 if (window.Diff2HtmlUI) {
219 var ui = new Diff2HtmlUI(target, text, { drawFileList: false, matching: 'lines', highlight: true, colorScheme: 'auto',
220 outputFormat: sideBySide ? 'side-by-side' : 'line-by-line', fileContentToggle: false, synchronisedScroll: true });
221 ui.draw();
222 ui.highlightCode();
223 } else {
224 drawPlain(target, text);
225 }
226 requestAnimationFrame(function () {
227 if (wrap.scrollHeight > 640) {
228 wrap.classList.add('collapsed');
229 var b = el('button', 'expand', 'Show all ' + text.split('\\n').length + ' lines');
230 b.onclick = function () { wrap.classList.remove('collapsed'); b.remove(); };
231 wrap.appendChild(b);
232 }
233 });
234 });
235 }
236
237 var sections = [];
238 guide.steps.forEach(function (step, i) {
239 var hunks = hunksOf(step);
240 var paths = Array.from(new Set(hunks.map(function (h) { return h.path; })));
241 var s = el('section', 'card step');
242 s.id = 'step-' + (i + 1);
243 s.innerHTML = '<div class="step-head"><div class="badge">' + (i + 1) + '</div><h2>' + esc(step.title) + '</h2></div>' +
244 '<div class="summary">' + md(step.summary) + '</div>' +
245 (step.watch ? '<div class="watch"><b>\\u26A0 Check</b>' + md(step.watch) + '</div>' : '') +
246 '<div class="files chips">' + paths.map(function (p) { return '<span class="chip"><code>' + esc(p) + '</code></span>'; }).join('') +
247 '<span class="chip add">+' + sum(hunks, 'added') + '</span><span class="chip del">\\u2212' + sum(hunks, 'removed') + '</span></div>';
248 var btn = el('button');
249 btn.onclick = function () { toggleReviewed(i); };
250 s.querySelector('.step-head').appendChild(btn);
251 var diffs = el('div', 'diffs');
252 s.appendChild(diffs);
253 main.appendChild(s);
254 drawDiffs(diffs, step);
255
256 var link = el('a');
257 link.href = '#step-' + (i + 1);
258 link.innerHTML = '<div class="badge">' + (i + 1) + '</div><div><span class="t">' + esc(step.title) + '</span>' +
259 '<span class="meta">' + paths.length + ' files<span class="bar"><i style="width:' + Math.max(6, Math.round(sizes[i] / maxSize * 100)) + '%"></i></span></span></div>';
260 rail.appendChild(link);
261 sections.push({ section: s, link: link, button: btn, diffs: diffs, step: step });
262 });
263
264 function paint() {
265 sections.forEach(function (x, i) {
266 var done = reviewed.has(i);
267 x.section.classList.toggle('reviewed', done);
268 x.link.classList.toggle('reviewed', done);
269 x.link.querySelector('.badge').textContent = done ? '\\u2713' : String(i + 1);
270 x.section.querySelector('.badge').textContent = done ? '\\u2713' : String(i + 1);
271 x.button.className = done ? 'done' : 'primary';
272 x.button.textContent = done ? '\\u2713 Reviewed' : 'Mark reviewed';
273 });
274 var n = guide.steps.length ? reviewed.size / guide.steps.length : 0;
275 document.getElementById('progress-fill').style.width = (n * 100) + '%';
276 document.getElementById('progress-label').textContent = reviewed.size + ' of ' + guide.steps.length + ' reviewed';
277 document.getElementById('toggle-layout').textContent = sideBySide ? 'Unified' : 'Side by side';
278 }
279 function toggleReviewed(i) {
280 if (reviewed.has(i)) reviewed.delete(i); else reviewed.add(i);
281 localStorage.setItem(storeKey, JSON.stringify(Array.from(reviewed)));
282 paint();
283 }
284 function toggleLayout() {
285 sideBySide = !sideBySide;
286 localStorage.setItem('guided-mr:layout', sideBySide ? 'side' : 'line');
287 sections.forEach(function (x) { drawDiffs(x.diffs, x.step); });
288 paint();
289 }
290 document.getElementById('toggle-layout').onclick = toggleLayout;
291 document.getElementById('expand-all').onclick = function () {
292 document.querySelectorAll('.diff-wrap.collapsed').forEach(function (w) { w.classList.remove('collapsed'); });
293 document.querySelectorAll('.expand').forEach(function (b) { b.remove(); });
294 };
295
296 var active = 0;
297 var observer = new IntersectionObserver(function (entries) {
298 entries.forEach(function (e) {
299 if (!e.isIntersecting) return;
300 active = sections.findIndex(function (x) { return x.section === e.target; });
301 sections.forEach(function (x, i) { x.link.classList.toggle('active', i === active); });
302 });
303 }, { rootMargin: '-30% 0px -60% 0px' });
304 sections.forEach(function (x) { observer.observe(x.section); });
305
306 document.addEventListener('keydown', function (e) {
307 if (e.metaKey || e.ctrlKey || e.altKey || /input|textarea/i.test(e.target.tagName)) return;
308 var go = function (i) { var x = sections[Math.max(0, Math.min(sections.length - 1, i))]; if (x) x.section.scrollIntoView(); };
309 if (e.key === 'j' || e.key === 'n') go(active + 1);
310 else if (e.key === 'k' || e.key === 'p') go(active - 1);
311 else if (e.key === 'x') toggleReviewed(active);
312 else if (e.key === 's') toggleLayout();
313 });
314 paint();
315})();
316`
317types/index.d.ts 51 lines1export type Hunk = {
2 id: string
3 path: string
4 // A unified-diff hunk: the @@ header and its lines. Empty for a file with no hunks.
5 source: string
6 // Set for a file with no hunks (binary, pure rename, mode change).
7 note?: string
8 added: number
9 removed: number
10}
11
12export type Step = {
13 title: string
14 summary: string
15 watch: string
16 hunkIds: string[]
17}
18
19export type Guide = {
20 title: string
21 context: string
22 source: string
23 // A Mermaid flowchart of the parts the change touches, or ''.
24 diagram: string
25 steps: Step[]
26 hunks: Hunk[]
27}
28
29export type BuildStatus = {
30 kind: 'idle' | 'loading' | 'error'
31 message: string
32}
33
34declare module 'claude-code' {
35 interface PluginState {
36 'guided-mr': {
37 guide: Guide | null
38 // -1 is the overview; 0..n-1 are steps.
39 view: number
40 status: BuildStatus
41 isSplit: boolean
42 lastArgs: string
43 // Step indexes.
44 reviewed: number[]
45 visited: number[]
46 // Hunk ids drawn in full instead of cut.
47 expanded: string[]
48 }
49 }
50}
51