SLOPSHOPPER

change-highlights

After a turn that changed code, shows the non-standard changes worth a developer's attention and separates them from pattern reuse

newpaneguardcommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · change-highlights
│ ┃ Change highlights ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 1/1 · auth.ts, audit.ts, cache.ts · [ Close │ change-highlights │ │ ┃ reviewing new changes… ⏺ Read(src/auth.ts) │ change-highlights: non-standard changes │ │ ┃ OK ⎿ Read 6 lines │ found │ │ ⏺ 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 │ │ › /highlights │ ⎿ change-highlights: No code changes analysed yet in this session. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Change highlights
1/1 · auth.ts, audit.ts, cache.ts · reviewing new changes… [ OK
README

change-highlights

A mod for Claude Code, written as a plugin of function hooks: after a turn that changed code, it shows only the changes worth a developer's attention — the ones that do something new or non-obvious — and skips the ones that just reuse an existing pattern.

Install

At the prompt of a Claude Code session:

/plugin install change-highlights --marketplace cherniaiev04/change-highlights

Answer y to add the marketplace, then pick a scope (user scope = every session).

The function-hooks plugin API is early access and changes between Claude Code releases. This mod was built and tested on Claude Code 2.1.293.

How it works

When a turn ends with at least 3 changed lines of code (Edit / Write / NotebookEdit; .md, .txt, .canvas, .base files are skipped), a reviewer sorts the changes into:

  • pattern — reuses something the codebase already does (a copied controller shape, config wiring, renames);
  • specific — new or non-obvious: an algorithm, an edge case, a workaround, a caching/security/concurrency decision.

Only the specific ones are shown, in a pane, at most three, each as path:line, the key lines of code, and — when the code doesn't speak for itself — a sentence on what it gains. When everything is routine you get a toast instead.

/highlights          open the pane (Older / Newer to browse the last 20)
/highlights sonnet   reviewer: Sonnet with the changes + full text of the changed files (default, cheap)
/highlights fork     reviewer: the session's own model over the whole conversation (sees patterns
                     in other files; more accurate, costs more)

The review runs in the background after the turn and costs one extra model call per turn that changed code.

Develop

Run the mod from its folder instead of installing it, so edits reload while the session runs:

claude --plugin-dir ./change-highlights

or, for sessions the desktop app starts, list the folder in ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/path/change-highlights/change-highlights" } }

Check and test:

claude plugin validate ./change-highlights
claude plugin test ./change-highlights

License

MIT

Source 2 files
hooks/register.tsx 237 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Highlight } from '../types'
5
6const PANE = 'change-highlights'
7const TITLE = 'Change highlights'
8const history = atom({ plugin: 'change-highlights', key: 'history' } as const, [])
9const cursor = atom({ plugin: 'change-highlights', key: 'cursor' } as const, -1)
10const isBusy = atom({ plugin: 'change-highlights', key: 'isBusy' } as const, false)
11
12type Change = { file: string; before: string; after: string }
13
14// Notes and prose are not code: the vault, memory files, docs.
15const SKIP = /\.(md|mdx|txt|canvas|base)$|\/Obsidian Vault\//i
16const MIN_LINES = 3
17const PER_CHANGE = 1500
18const ALL_CHANGES = 24000
19
20const lines = (s: string) => (s === '' ? 0 : s.split('\n').length)
21const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}\n…(cut)` : s)
22
23const describe = (changes: Change[]) => {
24  let out = ''
25  for (const c of changes) {
26    const block =
27      `### ${c.file}\n` +
28      (c.before ? `--- before\n${clip(c.before, PER_CHANGE)}\n` : '(new file)\n') +
29      `+++ after\n${clip(c.after, PER_CHANGE)}\n\n`
30    if (out.length + block.length > ALL_CHANGES) {
31      out += `…and ${changes.length - changes.indexOf(c)} more changes left out.\n`
32      break
33    }
34    out += block
35  }
36  return out
37}
38
39type Mode = 'sonnet' | 'fork'
40const MODES: readonly Mode[] = ['sonnet', 'fork']
41const MODE_KEY = 'mode'
42const PER_FILE = 30000
43const ALL_FILES = 90000
44
45const RULES = `Sort every change into one of two kinds:
46- PATTERN: reuses something that already existed in this codebase (an existing controller/service/DTO/component shape, a copied test layout, config wiring, renames, mechanical follow-ups).
47- SPECIFIC: does something the codebase did not do before, or does it in a non-obvious way: a new algorithm or rule, a tricky edge case, a workaround, a concurrency/security/caching decision, a surprising API use, a deliberate deviation from the existing pattern.
48
49Write markdown, nothing before it, and keep it SHORT: the code carries the message, not prose.
50
51For each SPECIFIC change, most important first (at most 3):
52
53\`path:line\`
54a fenced code block with only the interesting lines (at most ~10)
55then, only if the code does not speak for itself, one or two plain sentences: what it does and what it gains (faster, safer, more reliable, simpler, ...). Leave the sentence out when the code is self-explanatory. Only in rare cases, when there is a real risk or a non-obvious trade-off, write a longer explanation (one short paragraph at most).
56
57No headings, no titles, no bold labels, no summary, no list of the routine changes. Separate entries with a blank line.
58
59If there is no SPECIFIC change, write only: "Nothing non-standard: every change follows an existing pattern."`
60
61// Fork: the whole conversation is the context, so patterns from other files count too.
62const FORK_PROMPT = (changes: string) => `You are reviewing the code changes made in the turn that just ended, for the developer who owns this code. Do not continue the task; only write this review. You have seen the codebase in this conversation; use it to judge what already existed.
63
64${RULES} Use the language the developer writes in this conversation.
65
66The changes (as given to the edit tools):
67
68${changes}`
69
70// Sonnet: no conversation, so the changed files themselves stand in for the codebase.
71const SONNET_PROMPT = (task: string, changes: string, files: string) => `You are reviewing code changes an AI assistant just made, for the developer who owns this code. You see the developer's request, each change, and the full current text of the changed files. Judge PATTERN by what the rest of these files already did; when a change looks copied from code you cannot see, say "probably follows an existing pattern" rather than guessing.
72
73${RULES} Write in the language of the developer's request.
74
75<request>
76${task || '(no text)'}
77</request>
78
79<changes>
80${changes}
81</changes>
82
83<files>
84${files}
85</files>`
86
87async function readFiles($: EngineInterface, done: Change[]) {
88  let out = ''
89  for (const file of [...new Set(done.map(c => c.file))]) {
90    const text = await $.fs.read(file).catch(() => undefined)
91    if (typeof text !== 'string') continue
92    const block = `### ${file}\n${clip(text, PER_FILE)}\n\n`
93    if (out.length + block.length > ALL_FILES) break
94    out += block
95  }
96  return out
97}
98
99async function analyse($: EngineInterface, done: Change[], task: string) {
100  await update($, isBusy, () => true)
101  $.ui.status('change-highlights: reviewing changes…')
102  try {
103    const mode = ((await $.store.get(MODE_KEY)) as Mode | undefined) ?? 'sonnet'
104    const changes = describe(done)
105    let reply = mode === 'fork' ? await $.model.fork({ prompt: FORK_PROMPT(changes) }) : undefined
106    if (!reply?.isAnswered) {
107      const prompt = SONNET_PROMPT(task, changes, await readFiles($, done))
108      reply = await $.model.complete({ model: 'sonnet', prompt, maxTokens: 2500, timeoutMs: 120000 })
109    }
110    if (!reply.isAnswered) {
111      $.ui.toast(`change-highlights: no review (${reply.reason})`)
112      return
113    }
114
115    const files = [...new Set(done.map(c => c.file.split('/').pop() ?? c.file))]
116    const entry: Highlight = { at: await $.clock.now(), files, text: reply.text.trim() }
117    await update($, history, list => [...list, entry].slice(-20))
118    await update($, cursor, () => -1)
119
120    const isRoutine = /Nothing non-standard/i.test(entry.text)
121    if (isRoutine) {
122      $.ui.toast('change-highlights: all changes follow existing patterns (/highlights)')
123    } else {
124      const opened = await $.ui.open({ id: PANE, title: TITLE })
125      $.ui.toast(
126        opened.isPlaced
127          ? 'change-highlights: non-standard changes found'
128          : 'change-highlights: non-standard changes found — /highlights to view',
129      )
130    }
131  } catch (err) {
132    $.ui.toast(`change-highlights failed: ${String(err)}`)
133  } finally {
134    $.ui.status(undefined)
135    await update($, isBusy, () => false)
136  }
137}
138
139export const register: Register = on => {
140  let changes: Change[] = []
141  let task = ''
142
143  on('session.start', async ($, e, next) => {
144    await $.command.register({
145      name: 'highlights',
146      description: 'Show the non-standard code changes of recent turns; "sonnet" or "fork" picks the reviewer',
147      argumentHint: '[sonnet|fork]',
148    })
149    return next(e)
150  })
151
152  on('command.run', { command: 'highlights' }, async ($, e) => {
153    const asked = e.args.trim().toLowerCase()
154    if (asked) {
155      if (!MODES.includes(asked as Mode)) return { text: 'Usage: /highlights [sonnet|fork]' }
156      await $.store.set(MODE_KEY, asked)
157      return {
158        text:
159          asked === 'fork'
160            ? 'Reviews now run on the session model over the whole conversation (more accurate, costlier).'
161            : 'Reviews now run on Sonnet with the changed files only (cheap).',
162      }
163    }
164    await update($, cursor, () => -1)
165    await $.ui.open({ id: PANE, title: TITLE })
166    const list = await read($, history)
167    return { text: list.length ? 'Change highlights opened.' : 'No code changes analysed yet in this session.' }
168  })
169
170  on('turn.start', ($, e, next) => {
171    changes = []
172    task = e.text
173    return next(e)
174  })
175
176  on('tool.call', async ($, e, next) => {
177    const ran = await next(e)
178    if (ran.deny !== undefined || ran.isError) return ran
179
180    let change: Change | undefined
181    if (e.tool === 'Edit') change = { file: e.file_path, before: e.old_string, after: e.new_string }
182    else if (e.tool === 'Write') change = { file: e.file_path, before: '', after: e.content }
183    else if (e.tool === 'NotebookEdit') change = { file: e.notebook_path, before: '', after: e.new_source }
184
185    if (change && !SKIP.test(change.file)) changes.push(change)
186    return ran
187  })
188
189  on('turn.complete', async ($, e, next) => {
190    const result = await next(e)
191    if (e.agentId !== undefined || e.reason !== 'answer') return result
192
193    const done = changes
194    changes = []
195    const size = done.reduce((n, c) => n + Math.max(lines(c.before), lines(c.after)), 0)
196    if (done.length === 0 || size < MIN_LINES) return result
197
198    void analyse($, done, task)
199    return result
200  })
201
202  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
203    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
204    const list = await read($, history)
205    const busy = await read($, isBusy)
206    const at = await read($, cursor)
207    const i = at < 0 || at >= list.length ? list.length - 1 : at
208    const shown = list[i]
209
210    if (!shown) {
211      return (
212        <Box flexDirection="column">
213          <Text dimColor>{busy ? 'Reviewing the changes…' : 'No code changes analysed yet.'}</Text>
214        </Box>
215      )
216    }
217
218    return (
219      <Box flexDirection="column">
220        <Box flexDirection="row" gap={1}>
221          <Text dimColor>
222            {i + 1}/{list.length} · {shown.files.slice(0, 4).join(', ')}
223            {shown.files.length > 4 ? ` +${shown.files.length - 4}` : ''}
224            {busy ? ' · reviewing new changes…' : ''}
225          </Text>
226          {i > 0 && <Button key="older" label="Older" onPress={() => update($, cursor, () => i - 1)} />}
227          {i < list.length - 1 && (
228            <Button key="newer" label="Newer" onPress={() => update($, cursor, () => i + 1)} />
229          )}
230          <Button key="close" label="Close" onPress={() => $.ui.close({ id: PANE })} />
231        </Box>
232        <Markdown text={shown.text} />
233      </Box>
234    )
235  })
236}
237
types/index.d.ts 20 lines
1export type Highlight = {
2  /** When the turn ended, ms since epoch. */
3  at: number
4  /** Files the turn changed. */
5  files: string[]
6  /** The model's write-up, markdown. */
7  text: string
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'change-highlights': {
13      history: Highlight[]
14      /** Index into history being shown; -1 = latest. */
15      cursor: number
16      isBusy: boolean
17    }
18  }
19}
20