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

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.
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.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.+N lines button opens the next page in place.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.
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.
claude plugin install diff-peek@cc-modshooks/register.ts 171 lines1// 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}
171hooks/diff.ts 221 lines1// 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