SLOPSHOPPER

diff-peek

Folds the diff an edit prints under its tool row into a hover: rest the pointer on the emoji to read it

newrowscommand
★ 1v0.12.0MITupdated 2026-10-23i3acksp4ce/cc-mods/plugins/diff-peek
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diff-peek
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /diff-peek ⎿ diff-peek: hover diff off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

diff-peek

Folds the diff Claude prints under an edit's tool row into a hover. The row keeps the engine's own drawing; the result block under it, where the diff prints, goes away. Rest the pointer on the 🔽 before the tool name and the diff unfolds beneath it; move the pointer off and it folds back.

How it works

  • A ui.render hook on ToolUse draws the edit row itself: a 🔽, the tool name in bold, the file's path from the workspace root in parentheses (absolute when it sits outside), and the line counts (+12 -3) in pinned bright hexes (#50c850, #dc5a5a), tuned to read like the hover diff's ink over its tinted background. The engine's own row would claim the full width, so drawing the line is what lets the hover zone shrink to the emoji; the emoji's box carries the hover scope.
  • The folded reveal beneath shares the scope (display: 'none', hover: { display: 'flex', scope }) and draws the diff with a Code element in format: 'diff', read from the call's structuredPatch; while the call still runs, it is rebuilt from the old_string and new_string the model asked to swap, and the counts are an estimate until the result lands. A write counts the lines it landed, in green alone.
  • A region inside the transcript has no scroll, so a long reveal reads one page of 24 lines at a time and a dim +N lines button opens the next page in place.
  • A write whose patch never came (the previous content was too large to diff, or the diff timed out) hover-reads the text that landed as plain source instead.
  • A ui.render hook on ToolResult folds the result block for the same rows, so the diff shows on hover and never on its own.
  • /diff-peek takes on, off, or nothing to toggle the hover, and keeps the choice in the mod's store.

Hover needs a pointer, so the terminal and the Desktop app get the reveal. In VS Code, whose panel ignores hover, rows stay native with their own diff. The expanded transcript (ctrl+o, --verbose) and rows that ended in an error or an interrupt also stay native.

Try it

claude --plugin-dir .cc-mods/plugins/diff-peek

Ask Claude to edit a file, then rest the pointer on the 🔽 at the start of the Edit row.

Install

claude plugin install diff-peek@cc-mods
Source 2 files
hooks/register.ts 171 lines
1// The hooks module: the one file the engine follows, so every call that
2// touches `$` lives here. diff.ts beside it holds the pure half: which rows
3// the mod touches and the text each one reads on hover.
4
5import type { Register } from 'claude-code'
6import {
7  ADDED_COLOR,
8  REMOVED_COLOR,
9  clip,
10  countsOf,
11  isEditTool,
12  pageOf,
13  pathOf,
14  popupView,
15  subjectOf,
16} from './diff'
17
18// The switch /diff-peek writes; the store keeps the choice across sessions.
19const ENABLED_KEY = 'enabled'
20
21// How many pages of a reveal the pointer has opened, by the tool call it
22// belongs to; a reload drops them, and the map trims like the transcript.
23const pagesOf = new Map<string, number>()
24const PAGE_MAP_LIMIT = 400
25
26function trimTo<V>(map: Map<string, V>, limit: number): void {
27  while (map.size > limit) {
28    const oldest = map.keys().next()
29    if (oldest.done === true) break
30    map.delete(oldest.value)
31  }
32}
33
34// The engine keeps a column clear at each transcript edge, and tool rows sit
35// deeper in it, so the row's budget gives that width up; the viewport is
36// absent until the terminal has been measured.
37const TOOL_GUTTER = 4
38const FALLBACK_COLUMNS = 120
39
40export const register: Register = on => {
41  // An edit row is the mod's own drawing, so the line hugs its words and
42  // the hover scope sits on the emoji before the tool name alone: the
43  // engine's own row would claim the full width, and a box around it can
44  // never be narrower than that. The folded reveal beneath shares the
45  // scope and unfolds while the pointer rests on the emoji. VS Code
46  // ignores hover, so there the row stays native with its own diff; so
47  // does the expanded transcript (ctrl+o, --verbose), and a call that
48  // ended in an error or an interrupt keeps its own ending.
49  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
50    const row = e.props
51    if (e.surface === 'vscode') return next(e)
52    if (row.isExpanded === true) return next(e)
53    if (!isEditTool(row.tool) || row.isErrored || row.isInterrupted) return next(e)
54    if ((await $.store.get(ENABLED_KEY)) === 'off') return next(e)
55    const view = popupView(row.input, row.output)
56    if (view === undefined) return next(e)
57    const counts = countsOf(row.tool, row.input, row.output)
58    // The count strings are built once: they draw as they are, and the
59    // width budget measures the same text.
60    const plusText = counts !== undefined && counts.added > 0 ? ` +${counts.added}` : ''
61    const minusText = counts?.removed ? ` -${counts.removed}` : ''
62    const root = await $.session.root()
63    // The subject reads from the workspace root when the file sits under
64    // it, and is the only part cut, so name and counts stay readable.
65    const spent =
66      plusText.length +
67      minusText.length +
68      (row.isRunning ? 2 : 0) +
69      // Parentheses hug the subject; the emoji reads two cells and a space.
70      5 +
71      4
72    const room = Math.max(
73      (e.viewport?.columns ?? FALLBACK_COLUMNS) - TOOL_GUTTER - row.tool.length - spent,
74      8,
75    )
76    const shown = clip(subjectOf(row.input, row.output, root), room)
77    // The reveal reads one page at a time: a region in the transcript has
78    // no scroll, so a long diff grows through the more button instead.
79    const pages = pagesOf.get(row.tool_use_id) ?? 1
80    const shownPage = pageOf(view.source, pages)
81    const path = pathOf(row.input, row.output)
82    const group = `diff-peek-${row.tool_use_id}`
83    const { Box, Button, Code, Text } = $.ui.resolve(e)
84    return Box({
85      flexDirection: 'column',
86      alignItems: 'flex-start',
87      children: [
88        Box({
89          flexDirection: 'row',
90          children: [
91            // The emoji alone is the hover target: the scope on its box
92            // lights only while the pointer rests on it.
93            Box({ hover: { scope: group }, children: [Text({ children: ['🔽 '] })] }),
94            Text({ weight: 'bold', children: [row.tool] }),
95            // The path draws as plain text inside parentheses: a file://
96            // link reads as a second, full-length line on the terminals
97            // that spell the URL out.
98            ...(shown === '' ? [] : [Text({ children: [`(${shown})`] })]),
99            ...(plusText === '' ? [] : [Text({ color: ADDED_COLOR, children: [plusText] })]),
100            ...(minusText === '' ? [] : [Text({ color: REMOVED_COLOR, children: [minusText] })]),
101            ...(row.isRunning ? [Text({ dimColor: true, children: [' …'] })] : []),
102          ],
103        }),
104        Box({
105          width: '100%',
106          display: 'none',
107          hover: { scope: group, display: 'flex' },
108          flexDirection: 'column',
109          paddingLeft: 4,
110          children: [
111            Code({
112              source: shownPage.source,
113              ...(view.kind === 'diff' ? { format: 'diff' as const } : {}),
114              ...(path === undefined ? {} : { path }),
115            }),
116            ...(shownPage.remaining === 0
117              ? []
118              : [
119                  Button({
120                    key: `more-${row.tool_use_id}`,
121                    label: `+${shownPage.remaining} lines`,
122                    plain: true,
123                    dimColor: true,
124                    onPress: () => {
125                      pagesOf.set(row.tool_use_id, pages + 1)
126                      trimTo(pagesOf, PAGE_MAP_LIMIT)
127                      $.ui.invalidate('ui.render')
128                    },
129                  }),
130                ]),
131          ],
132        }),
133      ],
134    })
135  })
136
137  // The result block under the row is where the diff prints; for the rows
138  // above it folds away, so the diff shows on hover and never on its own.
139  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
140    const row = e.props
141    if (e.surface === 'vscode') return next(e)
142    if (row.isExpanded === true) return next(e)
143    if (!isEditTool(row.tool) || row.isErrored) return next(e)
144    if ((await $.store.get(ENABLED_KEY)) === 'off') return next(e)
145    const { Box } = $.ui.resolve(e)
146    return Box({})
147  })
148
149  on('session.start', async ($, e, next) => {
150    await $.command.register({
151      name: 'diff-peek',
152      description: 'Show or hide the hover diff under edit tool rows',
153      argumentHint: '[on|off]',
154      immediate: true,
155    })
156    return next(e)
157  })
158
159  on('command.run', { command: 'diff-peek' }, async ($, e) => {
160    const wanted = (e.args ?? '').trim()
161    if (wanted !== '' && wanted !== 'on' && wanted !== 'off') {
162      return { text: 'diff-peek takes on, off, or nothing to toggle' }
163    }
164    const current = (await $.store.get(ENABLED_KEY)) === 'off' ? 'off' : 'on'
165    const value = wanted === '' ? (current === 'on' ? 'off' : 'on') : wanted
166    await $.store.set(ENABLED_KEY, value)
167    $.ui.invalidate('ui.render')
168    return { text: `hover diff ${value}` }
169  })
170}
171
hooks/diff.ts 221 lines
1// The pure half of the mod: which rows it touches, and the text each state
2// of a call reads on hover. Plain data in, plain data out; the drawing half
3// lives in register.ts. The result shapes are not declared, so every step
4// checks its own way in.
5
6export const EDIT_TOOLS: ReadonlyArray<string> = ['Edit', 'MultiEdit', 'Write', 'NotebookEdit']
7
8export const isEditTool = (tool: string): boolean => EDIT_TOOLS.includes(tool)
9
10// The count inks on the row: green for lines that arrive, red for lines
11// that go. These are pinned hexes, picked brighter than the theme's own
12// diffAddedWord ink (rgb(56,166,96) in the dark preset): the hover diff
13// draws the same ink over its dark green line background, which reads
14// brighter than bare text on the terminal background does. Swap these for
15// the diffAddedWord/diffRemovedWord theme keys to follow the person's
16// theme instead.
17export const ADDED_COLOR = '#50c850'
18export const REMOVED_COLOR = '#dc5a5a'
19
20const splitLines = (text: string): string[] => (text === '' ? [] : text.split('\n'))
21
22const intOf = (value: unknown, fallback: number): number =>
23  typeof value === 'number' && Number.isInteger(value) ? value : fallback
24
25const stringFieldOf = (source: unknown, key: string): string | undefined => {
26  if (typeof source !== 'object' || source === null) return undefined
27  const value = (source as Record<string, unknown>)[key]
28  return typeof value === 'string' && value !== '' ? value : undefined
29}
30
31// One unified-diff hunk, as the file tools carry them in `structuredPatch`.
32export interface Hunk {
33  oldStart: number
34  oldLines: number
35  newStart: number
36  newLines: number
37  lines: string[]
38}
39
40// One hunk from a result's patch, or nothing when the entry is not one.
41const hunkOf = (entry: unknown): Hunk | undefined => {
42  if (typeof entry !== 'object' || entry === null) return undefined
43  const fields = entry as Record<string, unknown>
44  if (!Array.isArray(fields.lines)) return undefined
45  const lines = fields.lines.filter((line): line is string => typeof line === 'string')
46  if (lines.length === 0) return undefined
47  return {
48    oldStart: intOf(fields.oldStart, 1),
49    oldLines: intOf(fields.oldLines, lines.length),
50    newStart: intOf(fields.newStart, 1),
51    newLines: intOf(fields.newLines, lines.length),
52    lines,
53  }
54}
55
56// The hunks a settled call stored in its result.
57export const hunksOfOutput = (output: unknown): Hunk[] => {
58  if (typeof output !== 'object' || output === null) return []
59  const patch = (output as Record<string, unknown>).structuredPatch
60  if (!Array.isArray(patch)) return []
61  return patch.map(hunkOf).filter((hunk): hunk is Hunk => hunk !== undefined)
62}
63
64// While a call still runs there is no result, so the strings the model asked
65// to swap stand in: every line of old_string goes, every line of new_string
66// arrives, from line 1 (the real numbers are unknowable yet).
67export const hunksOfInput = (input: unknown): Hunk[] => {
68  if (typeof input !== 'object' || input === null) return []
69  const fields = input as Record<string, unknown>
70  const edits: unknown[] = Array.isArray(fields.edits) ? fields.edits : [fields]
71  const hunks: Hunk[] = []
72  for (const edit of edits) {
73    if (typeof edit !== 'object' || edit === null) continue
74    const { old_string: oldText, new_string: newText } = edit as Record<string, unknown>
75    const gone = typeof oldText === 'string' ? splitLines(oldText) : []
76    const come = typeof newText === 'string' ? splitLines(newText) : []
77    if (gone.length === 0 && come.length === 0) continue
78    hunks.push({
79      oldStart: 1,
80      oldLines: gone.length,
81      newStart: 1,
82      newLines: come.length,
83      lines: [...gone.map((line) => `-${line}`), ...come.map((line) => `+${line}`)],
84    })
85  }
86  return hunks
87}
88
89// The hunks a row counts: the settled patch when it is there, else the swap
90// the call is still making. A row with no lines says nothing.
91export const countsOf = (tool: string, input: unknown, output: unknown): ChangeCounts | undefined => {
92  // A write lands a whole file, so the count is the lines it wrote, green
93  // alone: the text it replaced is a replacement, not a line edit.
94  if (tool === 'Write') {
95    const content = stringFieldOf(output, 'content') ?? stringFieldOf(input, 'content')
96    if (content === undefined) return undefined
97    const lines = splitLines(content)
98    return lines.length === 0 ? undefined : { added: lines.length }
99  }
100  const settled = hunksOfOutput(output)
101  const hunks = settled.length > 0 ? settled : hunksOfInput(input)
102  let added = 0
103  let removed = 0
104  for (const hunk of hunks) {
105    for (const line of hunk.lines) {
106      if (line.startsWith('+')) added += 1
107      else if (line.startsWith('-')) removed += 1
108    }
109  }
110  if (added === 0 && removed === 0) return undefined
111  return removed === 0 ? { added } : { added, removed }
112}
113
114export interface ChangeCounts {
115  added: number
116  /** Lines that go; absent on a write, which lands a whole file and removes nothing. */
117  removed?: number
118}
119
120// The reveal pages its text, and a tree draws its first 100,000 characters,
121// so anything past the cap cuts at a line end: the reader still parses the
122// last line.
123const MAX_CHARS = 60_000
124
125const cutAtLineEnd = (text: string): string => {
126  if (text.length <= MAX_CHARS) return text
127  const cut = text.slice(0, MAX_CHARS)
128  const lastLineEnd = cut.lastIndexOf('\n')
129  return lastLineEnd < 0 ? cut : cut.slice(0, lastLineEnd)
130}
131
132// The unified-diff text a Code element reads under `format: 'diff'`: one @@
133// header per hunk, then the hunk's marked lines. A cut says so as a context
134// line, which the reader still parses.
135const unifiedDiff = (hunks: ReadonlyArray<Hunk>): string | undefined => {
136  if (hunks.length === 0) return undefined
137  const whole = hunks
138    .map(
139      (hunk) =>
140        `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join('\n')}`,
141    )
142    .join('\n')
143  const text = cutAtLineEnd(whole)
144  return text === whole ? text : `${text}\n (diff-peek: truncated)`
145}
146
147// What a row reads on hover: the stored patch when the call has one, else
148// the strings the model asked to swap, else the text a write landed whose
149// patch never came (the previous content was too large to diff, or the
150// diff timed out).
151export interface PopupView {
152  kind: 'diff' | 'source'
153  source: string
154}
155
156export const popupView = (input: unknown, output: unknown): PopupView | undefined => {
157  const settled = unifiedDiff(hunksOfOutput(output))
158  if (settled !== undefined) return { kind: 'diff', source: settled }
159  const swapped = unifiedDiff(hunksOfInput(input))
160  if (swapped !== undefined) return { kind: 'diff', source: swapped }
161  const content = stringFieldOf(output, 'content') ?? stringFieldOf(input, 'content')
162  if (content !== undefined) return { kind: 'source', source: cutAtLineEnd(content) }
163  return undefined
164}
165
166// The reveal reads this many lines a page, and a more button opens the next
167// one: a region inside the transcript has no scroll of its own.
168export const PAGE_LINES = 24
169
170// The lines one page draws, and how many wait behind the button; the last
171// page reads to the end.
172export const pageOf = (source: string, pages: number): { source: string; remaining: number } => {
173  const lines = source.split('\n')
174  const shown = lines.slice(0, Math.max(1, pages) * PAGE_LINES)
175  return { source: shown.join('\n'), remaining: lines.length - shown.length }
176}
177
178// The file the row edited: the result's own field first, then the input's.
179export const pathOf = (input: unknown, output: unknown): string | undefined => {
180  for (const source of [output, input]) {
181    if (typeof source !== 'object' || source === null) continue
182    const fields = source as Record<string, unknown>
183    for (const key of ['filePath', 'file_path', 'notebook_path', 'path']) {
184      const value = fields[key]
185      if (typeof value === 'string' && value !== '') return value
186    }
187  }
188  return undefined
189}
190
191// What the row names as its subject: the path from the workspace root when
192// the file sits under it, else the absolute path, so a file outside never
193// reads as if it were inside. Both draw with forward slashes, the way a
194// path reads in a repo.
195export const subjectOf = (input: unknown, output: unknown, root: string | undefined): string => {
196  const path = pathOf(input, output)
197  if (path === undefined) return ''
198  return relativeOf(path, root) ?? forwardSlashes(path)
199}
200
201const forwardSlashes = (path: string): string => path.replace(/\\/g, '/')
202
203// The path from the workspace root, when the file sits under it. Case folds
204// on both sides: Windows spells one directory in any case.
205export const relativeOf = (path: string, root: string | undefined): string | undefined => {
206  if (root === undefined || root === '') return undefined
207  const parts = path.replace(/\\/g, '/')
208  const base = root.replace(/\\/g, '/').replace(/\/+$/, '')
209  const lower = parts.toLowerCase()
210  const lowerBase = base.toLowerCase()
211  if (lower === lowerBase || !lower.startsWith(`${lowerBase}/`)) return undefined
212  return parts.slice(base.length + 1)
213}
214
215// The subject is the only part cut, so the tool name and the counts stay
216// readable.
217export const clip = (text: string, room: number): string => {
218  if (text.length <= room) return text
219  return room <= 1 ? '…' : `${text.slice(0, room - 1)}…`
220}
221