Shows the file edits Claude made in the last turn, and earlier turns, in a pane, built from its own Edit, Write and NotebookEdit calls (no git).

See exactly what Claude just changed.<br> A GitHub-style diff of every file Claude edited in the last turn, right inside Claude Code.<br> In Claude Desktop's Code tab and in the terminal. No git needed.
<img alt="The Last turn diff pane in Claude Desktop: a turn header with 3 files changed +13 −2, a toolbar, and one card per file with line numbers, green and red rows and syntax colours" src="docs/images/pane-light.png" width="760">
/plugin install last-turn-diff --marketplace RomuloDrumond/claude-last-turn-diff
Run it in the terminal or in a Claude Desktop Code session (Claude Code 2.1.286 or later), then /reload-plugins. After Claude edits files, click View changes above the prompt or run /lastturn.
⋯ 23 unchanged lines where the diff skips ahead.<img alt="Split view: the old file on the left and the new one on the right, a long line wrapped, and the other two files collapsed" src="docs/images/split-light.png">
<img alt="Above the prompt: Last turn changed 3 files +13 −2, five diffstat squares and a View changes button" src="docs/images/band-light.png" width="560">
The hint disappears after a turn that edited nothing.
<img alt="The same pane in a terminal: bracketed buttons with hotkeys, and tinted diff rows inside rounded cards" src="docs/images/terminal.png" width="760">
| Button | Terminal key | What it does |
|---|---|---|
← → and the turn picker | p n | Step through earlier turns with edits |
Split view / Unified view | s | Old file beside the new one, or interleaved. Needs a pane 64 columns wide in Desktop, 90 in the terminal. New files are always unified. |
Unwrap lines / Wrap lines | w | Cut long lines at the pane's edge, or wrap them |
Collapse all / Expand all | a | Fold every card. Click a card's path to fold just that one. |
Jump to file | Open a file and scroll to its card | |
Refresh | r | Read the transcript again and add any turn with edits that wasn't recorded live |
Previous file / Next file | k j | Shown for large turns, which show one file at a time |
A turn whose files add up to more than 400 changed lines shows one file at a time, and a long file shows 150 lines at a time, which keeps the pane under the 100,000-character limit for what it can draw. Hotkeys work in the terminal only.
git checkout, code generators), MCP tools, other plugins, or you.tool.call hook reads the file before Edit or Write runs, lets the call through unchanged, and then reads the file again. Calls that were denied, failed, or held for review (staged) are skipped.turn.start to turn.complete. Each file's diff compares the file before its first edit in the turn with the file after its last edit in the turn. A small Myers diff in plain TypeScript computes it, with no dependencies./resume, or after a fork, the mod reads the turns back from the transcript ($.session.messages()). It uses the originalFile and content fields that Edit and Write store there, so the pane survives a reload. /clear empties the list.Box, Text, Button and Select. Desktop draws the same controls, but each file's lines are an Svg element (hooks/svg.ts), and its icons are small Svg elements beside the text (hooks/icons.ts). The Claude Code that Desktop bundles (2.1.295) has no monospaced Text (it refuses mono and size). The theme's diff colours are opaque fills made for a terminal, and they drown the text in a Desktop pane. The Code element's diff format draws a plain code block there, with no tints or line numbers.Button can hold only text: the engine refuses an Svg inside one. So Desktop's buttons start with a glyph (↻ Refresh, ◫ Split view, ⊟ Collapse all), and the icons sit beside them.bodyColumns (about 8 CSS pixels a column) only to decide where long lines wrap. Its tints are translucent and its text colours switch with prefers-color-scheme, so it reads on a light or a dark theme.hooks/syntax.ts colours keywords, strings, comments, numbers, and function and type names for a handful of language families (JS/TS, C-like, Python, shell, Ruby, SQL, CSS, YAML/TOML/JSON), one line at a time.Load the mod from a checkout. In the terminal, for one session:
claude --plugin-dir /path/to/claude-last-turn-diff
Claude Desktop doesn't accept --plugin-dir. Add the folder to CLAUDE_CODE_PLUGIN_DIRS under env in ~/.claude/settings.json instead (separate several folders with :), then run /reload-plugins or start a new Desktop session:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-last-turn-diff"
}
}
Validate and test:
claude plugin validate .
claude plugin test .
Desktop runs its own bundled Claude Code, which can be older than the claude on your PATH. Validate with that build too, because an older engine can refuse a module a newer one loads (2.1.295 refuses passing $ into a function imported from another file, which is why hooks/view.tsx never holds $; it also refuses Text props such as mono and size and a gap given as a name like 'xs'). In a Desktop session, $CLAUDE_CODE_EXECPATH names that build:
"$CLAUDE_CODE_EXECPATH" plugin validate .
The tests in tests/ simulate Edit and Write tool calls and turn boundaries, then read the drawn pane on both the terminal and desktop surfaces. On the terminal they check the rows, tints and word marks; on Desktop, the pictures' markup. Both cover split view, wrap, collapse all, jump to file, the large-turn mode, refresh and the turn picker. They also test the SVG renderer, the highlighter and the turn titles directly.
When the engine loads the mod, it writes .claude-plugin/types/ and a tsconfig.json. After that, tsc -p . type-checks the mod. Git ignores both generated files.
The images in this README are drawn from the mod's real output. docs/screenshots/fixture.ts mounts the pane on a small made-up project, and make.ts draws what it prints as pages styled like Claude Desktop and a terminal, then photographs them with headless Chrome. After changing the UI, rebuild them:
cd docs/screenshots && bun install && bun make.ts
To release, bump version in .claude-plugin/plugin.json and run claude plugin tag --push .. Installs are cached by version, so bump it for every release.
This mod borrows the idea of recording edits at tool.call and replaying them in a pane from Anthropic's replay-theater sample mod (Apache-2.0). No code is copied from it.
Released under the MIT License.
hooks/register.tsx 295 lines1// last-turn-diff: records the file edits Claude makes in each turn from its
2// own Edit, Write and NotebookEdit calls (no git), and shows them per turn in
3// a pane, with a one-line hint above the prompt after a turn that edited.
4//
5// The idea of recording edits at tool.call and replaying them in a pane comes
6// from Anthropic's replay-theater sample mod (Apache-2.0); no code is copied.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register, ResultOf } from 'claude-code'
10
11import type { BandHint, DiffMode, TurnDiff } from '../types'
12import { diffstat } from './icons'
13import { MODE_KEY, NEWEST, WRAP_KEY } from './state'
14import { EDIT_TOOLS, MAX_TEXT_CHARS, stepOf, turnOf, turnsFromMessages } from './turns'
15import type { Step } from './turns'
16import { PANE, paneTree, plural } from './view'
17import type { PaneActions } from './view'
18
19const TITLE = 'Last turn diff'
20// How many turns with edits are kept.
21const KEEP_TURNS = 30
22
23type $ = EngineInterface
24
25const turns = atom({ plugin: 'last-turn-diff', key: 'turns' } as const, [])
26const view = atom({ plugin: 'last-turn-diff', key: 'view' } as const, NEWEST)
27const band = atom({ plugin: 'last-turn-diff', key: 'band' } as const, null)
28const turnNo = atom({ plugin: 'last-turn-diff', key: 'turnNo' } as const, 0)
29
30async function cwdOf($: $): Promise<string> {
31 try {
32 return await $.session.cwd()
33 } catch {
34 return ''
35 }
36}
37
38async function readText($: $, path: string): Promise<string | null | undefined> {
39 try {
40 if (!(await $.fs.exists(path))) return null
41 const text = await $.fs.read(path)
42 return text.length > MAX_TEXT_CHARS ? text.slice(0, MAX_TEXT_CHARS + 1) : text
43 } catch {
44 return undefined
45 }
46}
47
48// Back to the newest turn, keeping the person's display choices.
49const toNewest = ($: $) =>
50 update($, view, was => ({
51 ...NEWEST,
52 ...(was.mode !== undefined ? { mode: was.mode } : {}),
53 ...(was.wrap !== undefined ? { wrap: was.wrap } : {}),
54 }))
55
56async function openPane($: $) {
57 await toNewest($)
58 return $.ui.open({ id: PANE, title: TITLE, focus: true })
59}
60
61async function fromTranscript($: $) {
62 try {
63 return turnsFromMessages(await $.session.messages(), await cwdOf($), KEEP_TURNS)
64 } catch {
65 return undefined
66 }
67}
68
69// Rebuilds the turns from the transcript: after a reload, /resume or a fork.
70async function rebuild($: $) {
71 const transcript = await fromTranscript($)
72 if (transcript === undefined) return
73 const { turns: found, count } = transcript
74 await update($, turns, () => found)
75 await update($, turnNo, () => count)
76 await toNewest($)
77 const newest = found[0]
78 await update($, band, () => (newest !== undefined && newest.n === count ? hintOf(newest) : null))
79}
80
81// The same turn read twice: the same files, and the same number or title
82// (a title from an older version of the mod may read differently).
83const sameTurn = (a: TurnDiff, b: TurnDiff) =>
84 (a.n === b.n || a.title === b.title) &&
85 a.files.map(file => file.key).join('\n') === b.files.map(file => file.key).join('\n')
86
87// The pane's Refresh: reads the transcript again and adds the turns it holds
88// that were not recorded live (before the mod loaded, or lost to a reload).
89// A turn recorded live is kept as recorded: it has its subagents' edits too.
90async function refresh($: $) {
91 const transcript = await fromTranscript($)
92 if (transcript === undefined) return
93 const live = await read($, turns)
94 const shown = live[(await read($, view)).at]
95 const matched = new Set<TurnDiff>()
96 const found = transcript.turns.map(turn => {
97 const free = live.filter(one => !matched.has(one) && sameTurn(one, turn))
98 const hit = free.find(one => one.n === turn.n) ?? free[0]
99 if (hit === undefined) return turn
100 matched.add(hit)
101 return { ...hit, n: turn.n }
102 })
103 const onlyLive = live.filter(one => !matched.has(one))
104 const merged = [...found, ...onlyLive].sort((a, b) => b.n - a.n).slice(0, KEEP_TURNS)
105 await update($, turns, () => merged)
106 await update($, turnNo, count => Math.max(count, transcript.count))
107 const at = shown === undefined ? 0 : Math.max(0, merged.findIndex(turn => turn.n === shown.n && turn.title === shown.title))
108 await update($, view, was => ({ ...was, at }))
109 try {
110 $.ui.toast(`Refreshed: ${plural(merged.length, 'turn')} with edits`)
111 } catch {
112 // The redraw shows it.
113 }
114}
115
116async function reset($: $) {
117 await update($, turns, () => [])
118 await update($, turnNo, () => 0)
119 await toNewest($)
120 await update($, band, () => null)
121}
122
123// The person's unified or split and wrap choices, kept across sessions.
124async function restoreChoices($: $) {
125 try {
126 const kept = await $.store.get(MODE_KEY)
127 if (kept === 'unified' || kept === 'split') {
128 const mode: DiffMode = kept
129 await update($, view, was => ({ ...was, mode }))
130 }
131 const wrap = await $.store.get(WRAP_KEY)
132 if (typeof wrap === 'boolean') await update($, view, was => ({ ...was, wrap }))
133 } catch {
134 // No store here: the pane opens unified, lines wrapped.
135 }
136}
137
138const hintOf = (turn: TurnDiff): BandHint => ({
139 n: turn.n,
140 files: turn.files.length,
141 added: turn.added,
142 removed: turn.removed,
143})
144
145export const register: Register = on => {
146 // The edits of the turn in flight, oldest first, and that turn's prompt.
147 // Module memory: a reload mid-turn loses them, and the turn is then read
148 // back from the transcript when it completes.
149 let pending: Step[] = []
150 let prompt = ''
151 let turnId: string | undefined
152
153 on('session.start', async ($, e, next) => {
154 const started = await next(e)
155 await $.command.register({
156 name: 'lastturn',
157 description: 'Show the file edits Claude made in the last turn, with earlier turns a step away',
158 immediate: true,
159 })
160 await restoreChoices($)
161 // A hot reload keeps $.state: only an empty session reads the transcript.
162 if ((await read($, turns)).length === 0 && (await read($, turnNo)) === 0) await rebuild($)
163 return started
164 })
165
166 // /clear starts over; /resume and a fork bring another transcript.
167 on('classic.SessionStart', async ($, e, next) => {
168 const answered = await next(e)
169 pending = []
170 turnId = undefined
171 if (e.source === 'clear') await reset($)
172 else if (e.source === 'resume' || e.source === 'fork') await rebuild($)
173 return answered
174 }).catch(($, e, next) => next(e))
175
176 on('command.run', { command: 'lastturn' }, async $ => {
177 const list = await read($, turns)
178 await openPane($)
179 const newest = list[0]
180 return {
181 text:
182 newest === undefined
183 ? 'No file edits recorded in this session yet.'
184 : `T${newest.n}: ${plural(newest.files.length, 'file')} changed, +${newest.added} -${newest.removed}`,
185 }
186 })
187
188 on('turn.start', ($, e, next) => {
189 prompt = e.text
190 turnId = e.turnId
191 void update($, turnNo, count => count + 1)
192 return next(e)
193 })
194
195 // Records each edit that landed. The call always goes on unchanged.
196 on('tool.call', async ($, e, next) => {
197 const tool = String(e.tool)
198 if (!EDIT_TOOLS.has(tool)) return next(e)
199 const input = e as unknown as Record<string, unknown>
200 const path = typeof input.file_path === 'string' ? input.file_path : undefined
201 const before = path !== undefined ? await readText($, path) : undefined
202
203 const ran: ResultOf['tool.call'] = await next(e)
204 if (ran.deny !== undefined || ran.isError === true) return ran
205
206 const after = path !== undefined && before !== undefined ? await readText($, path) : undefined
207 const step = stepOf(tool, input, ran.result, {
208 ...(before !== undefined ? { before } : {}),
209 ...(typeof after === 'string' ? { after } : {}),
210 })
211 if (step !== null) pending.push(step)
212 return ran
213 }).catch(($, e, next) => next(e))
214
215 on('turn.complete', async ($, e, next) => {
216 const done = await next(e)
217 if (e.agentId !== undefined) return done
218
219 if (turnId !== e.turnId) {
220 // This copy of the module did not see the turn start: read it back.
221 await rebuild($)
222 pending = []
223 return done
224 }
225 const steps = pending
226 pending = []
227 turnId = undefined
228 const n = await read($, turnNo)
229 const turn = turnOf(n, prompt, steps, await cwdOf($))
230 if (turn.files.length === 0) {
231 await update($, band, () => null)
232 return done
233 }
234 await update($, turns, list => [turn, ...list].slice(0, KEEP_TURNS))
235 await toNewest($)
236 await update($, band, () => hintOf(turn))
237 return done
238 })
239
240 // The hint above the prompt after a turn that edited files.
241 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
242 const hint = await read($, band)
243 if (hint === null || e.props.hasSurvey) return next(e)
244 const elements = $.ui.resolve(e)
245 const { Box, Text, Button } = elements
246 const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : undefined
247 const squares = diffstat(hint.added, hint.removed)
248 return (
249 <Box flexDirection="row" gap={1} alignItems="center">
250 <Text wrap="truncate-end">
251 <Text dimColor>Last turn changed {plural(hint.files, 'file')}</Text>{' '}
252 <Text color="success">+{hint.added}</Text> <Text color="error">−{hint.removed}</Text>
253 </Text>
254 {Svg !== undefined && (
255 <Svg
256 alt={`${hint.added} added, ${hint.removed} removed`}
257 source={squares.source}
258 width={squares.width}
259 height={squares.height}
260 />
261 )}
262 <Button key="open-last-turn-diff" label="View changes" hotkey="d" onPress={() => void openPane($)} />
263 </Box>
264 )
265 })
266
267 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
268 const actions: PaneActions = {
269 change: change => update($, view, change),
270 remember: async (key, value) => {
271 try {
272 await $.store.set(key, value)
273 } catch {
274 // Remembering a choice is a convenience: the pane changed anyway.
275 }
276 },
277 scrollTo: async key => {
278 try {
279 await $.ui.scroll({ to: { key }, in: PANE, block: 'start' })
280 } catch {
281 // The surface could not scroll: the file is open, further down.
282 }
283 },
284 refresh: () => refresh($),
285 }
286 const model = {
287 list: await read($, turns),
288 now: await read($, view),
289 surface: e.surface,
290 columns: e.props.bodyColumns,
291 }
292 return paneTree($.ui.resolve(e), model, actions)
293 })
294}
295hooks/icons.ts 111 lines1// The pane's small pictures on a graphical surface, as SVG markup for its
2// Svg element: a file's icon, the five diffstat squares, the New pill, a
3// warning sign and the empty pane's drawing, in GitHub's colours. Each is an
4// image of its own, so each carries its colours and switches them with
5// `prefers-color-scheme`. A Button holds only text, so these sit beside
6// the buttons, never inside one. Pure, so tests read it directly.
7
8export type Icon = { source: string; width: number; height: number }
9
10// Squares in a diffstat, as GitHub draws five.
11export const SQUARES = 5
12const SQUARE_PX = 8
13const SQUARE_GAP_PX = 2
14
15const STYLE =
16 '<style>' +
17 '.i{fill:none;stroke:#8b949e;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}' +
18 '.a{fill:#3fb950}.d{fill:#f85149}.z{fill:#6e768166}.w{fill:#d29922}' +
19 '.as{stroke:#3fb950}.ws{stroke:#d29922}.ms{fill:#d29922}' +
20 '.pill{fill:#2ea04326;stroke:#3fb95099}' +
21 '.label{font:600 11px -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;fill:#3fb950}' +
22 '@media (prefers-color-scheme:light){.i{stroke:#59636e}.a{fill:#1a7f37}.d{fill:#d1242f}.z{fill:#d1d9e0}' +
23 '.w{fill:#9a6700}.as{stroke:#1a7f37}.ws{stroke:#9a6700}.ms{fill:#9a6700}' +
24 '.pill{fill:#dafbe1;stroke:#1a7f3766}.label{fill:#1a7f37}}' +
25 '</style>'
26
27const svg = (width: number, height: number, body: string): Icon => ({
28 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${STYLE}${body}</svg>`,
29 width,
30 height,
31})
32
33// A sheet with its corner folded, as an outline.
34const SHEET =
35 '<path class="i" d="M4 1.75h5.25L12.75 5.25v8.5a.5.5 0 0 1-.5.5h-8.25a.5.5 0 0 1-.5-.5v-11.5a.5.5 0 0 1 .5-.5z"/>' +
36 '<path class="i" d="M9.25 1.75v3.5h3.5"/>'
37
38// A file's icon: a sheet, with a green plus when the turn made it and an
39// amber dot when it changed one.
40export function fileIcon(isNew: boolean): Icon {
41 const mark = isNew ? '<path class="i as" d="M8 7.75v4M6 9.75h4"/>' : '<circle class="ms" cx="8" cy="9.75" r="1.75"/>'
42 return svg(16, 16, SHEET + mark)
43}
44
45// How many of the five squares are added, removed and neither, as GitHub
46// rounds them: a change of fewer than five lines leaves some empty.
47export function squaresOf(added: number, removed: number): { added: number; removed: number; none: number } {
48 const total = added + removed
49 const filled = Math.min(SQUARES, total)
50 const green = total === 0 ? 0 : Math.round((filled * added) / total)
51 return { added: green, removed: filled - green, none: SQUARES - filled }
52}
53
54// The five squares, green for added, red for removed, grey for the rest.
55export function diffstat(added: number, removed: number): Icon {
56 const counts = squaresOf(added, removed)
57 const classes = [
58 ...Array<string>(counts.added).fill('a'),
59 ...Array<string>(counts.removed).fill('d'),
60 ...Array<string>(counts.none).fill('z'),
61 ]
62 const body = classes
63 .map(
64 (name, index) =>
65 `<rect class="${name}" x="${index * (SQUARE_PX + SQUARE_GAP_PX)}" y="0" width="${SQUARE_PX}" height="${SQUARE_PX}" rx="2"/>`,
66 )
67 .join('')
68 return svg(SQUARES * SQUARE_PX + (SQUARES - 1) * SQUARE_GAP_PX, SQUARE_PX, body)
69}
70
71// A green pill that reads "New".
72export function newPill(): Icon {
73 return svg(
74 36,
75 18,
76 '<rect class="pill" x=".5" y=".5" width="35" height="17" rx="8.5"/>' +
77 '<text class="label" x="18" y="12.75" text-anchor="middle">New</text>',
78 )
79}
80
81// A triangle with an exclamation mark, in amber.
82export function warningIcon(): Icon {
83 return svg(
84 16,
85 16,
86 '<path class="i ws" d="M8 2.25 14.5 13.5h-13z"/><path class="i ws" d="M8 6.5v3.25"/><circle class="w" cx="8" cy="11.6" r=".9"/>',
87 )
88}
89
90// The prompt that began a turn: a speech bubble.
91export function turnIcon(): Icon {
92 return svg(
93 16,
94 16,
95 '<path class="i" d="M2.75 3.75a1 1 0 0 1 1-1h8.5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H7.5L4.75 13.25v-2.5h-1a1 1 0 0 1-1-1z"/>',
96 )
97}
98
99// The empty pane: a sheet with a removed line and two added ones.
100export function emptyPicture(): Icon {
101 return svg(
102 48,
103 48,
104 '<path class="i" d="M12 4.75h16.5L37.25 13.5v29a1.25 1.25 0 0 1-1.25 1.25H12a1.25 1.25 0 0 1-1.25-1.25v-36.5A1.25 1.25 0 0 1 12 4.75z"/>' +
105 '<path class="i" d="M28.5 4.75v8.75h8.75"/>' +
106 '<rect class="d" x="15" y="19" width="14" height="3" rx="1.5"/>' +
107 '<rect class="a" x="15" y="26" width="18" height="3" rx="1.5"/>' +
108 '<rect class="a" x="15" y="33" width="11" height="3" rx="1.5"/>',
109 )
110}
111hooks/state.ts 12 lines1// What the hooks and the drawing share besides their $.state values (whose
2// atoms each file declares where it reads them, as the validator requires).
3
4import type { PaneView } from '../types'
5
6export const NEWEST: PaneView = { at: 0, toggled: [], pages: {} }
7
8// The $.store keys holding the person's unified or split choice and whether
9// long lines wrap.
10export const MODE_KEY = 'mode'
11export const WRAP_KEY = 'wrap'
12hooks/turns.ts 281 lines1import type { SessionMessage } from 'claude-code'
2
3import type { FileDiff, TurnDiff } from '../types'
4import { patchHunks, unifiedHunks } from './diff'
5import type { Hunks } from './diff'
6
7export const EDIT_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'NotebookEdit'])
8
9// Texts past this are not diffed: the file is listed as too large.
10export const MAX_TEXT_CHARS = 2_000_000
11// What one file keeps of its hunks, and one turn of all its files, so the
12// session's state stays well under the host's limit.
13const MAX_FILE_CHARS = 150_000
14const MAX_TURN_CHARS = 600_000
15const TITLE_WORDS = 8
16const TITLE_CHARS = 60
17
18// One landed edit: the text before and after it. `before` is null for a file
19// the edit created and undefined when unknown; `patch` is what the tool itself
20// reported, used when a text is missing.
21export type Step = {
22 key: string
23 path: string
24 cell?: string
25 before: string | null | undefined
26 after: string | undefined
27 patch?: Hunks
28}
29
30type Patch = Parameters<typeof patchHunks>[0]
31
32const isRecord = (value: unknown): value is Record<string, unknown> =>
33 typeof value === 'object' && value !== null && !Array.isArray(value)
34
35const str = (value: unknown): string | undefined =>
36 typeof value === 'string' ? value : undefined
37
38function patchOf(value: unknown): Hunks | undefined {
39 if (!Array.isArray(value) || value.length === 0) return undefined
40 const parts = value.filter(
41 (part): part is Patch[number] =>
42 isRecord(part) &&
43 typeof part.oldStart === 'number' &&
44 typeof part.newStart === 'number' &&
45 Array.isArray(part.lines),
46 )
47 return parts.length > 0
48 ? patchHunks(
49 parts.map(part => ({
50 ...part,
51 oldLines: typeof part.oldLines === 'number' ? part.oldLines : 0,
52 newLines: typeof part.newLines === 'number' ? part.newLines : 0,
53 lines: part.lines.filter((line): line is string => typeof line === 'string'),
54 })),
55 )
56 : undefined
57}
58
59function replaced(text: string, oldString: string, newString: string, all: boolean): string | undefined {
60 if (oldString === '') return undefined
61 if (all) return text.split(oldString).join(newString)
62 const at = text.indexOf(oldString)
63 return at < 0 ? undefined : text.slice(0, at) + newString + text.slice(at + oldString.length)
64}
65
66// One Edit, Write or NotebookEdit call as the step it took, from its input and
67// the result the tool reported (live, or as the transcript stored it). `read`
68// holds the file as read before and after the call, when the caller read it.
69export function stepOf(
70 tool: string,
71 input: Record<string, unknown>,
72 result: unknown,
73 read: { before?: string | null; after?: string } = {},
74): Step | null {
75 const out = isRecord(result) ? result : {}
76 if (out.staged === true || typeof out.error === 'string') return null
77
78 if (tool === 'NotebookEdit') {
79 const path = str(out.notebook_path) ?? str(input.notebook_path)
80 if (path === undefined) return null
81 const mode = str(out.edit_mode) ?? str(input.edit_mode) ?? 'replace'
82 const cell = str(out.cell_id) ?? str(input.cell_id) ?? 'new'
83 const newSource = str(out.new_source) ?? str(input.new_source) ?? ''
84 const before = mode === 'insert' ? null : str(out.old_source)
85 return {
86 key: `${path}#${cell}`,
87 path,
88 cell,
89 before,
90 after: mode === 'delete' ? '' : newSource,
91 }
92 }
93
94 const path = str(out.filePath) ?? str(input.file_path)
95 if (path === undefined) return null
96 const patch = patchOf(out.structuredPatch)
97
98 if (tool === 'Write') {
99 const before =
100 read.before !== undefined
101 ? read.before
102 : out.type === 'create'
103 ? null
104 : (str(out.originalFile) ?? undefined)
105 const after = read.after ?? str(out.content) ?? str(input.content)
106 return { key: path, path, before, after, patch }
107 }
108
109 if (tool === 'Edit') {
110 const before = read.before !== undefined ? read.before : str(out.originalFile)
111 const oldString = str(out.oldString) ?? str(input.old_string) ?? ''
112 const newString = str(out.newString) ?? str(input.new_string) ?? ''
113 const all = (out.replaceAll ?? input.replace_all) === true
114 const after =
115 read.after ??
116 (typeof before === 'string' ? replaced(before, oldString, newString, all) : undefined)
117 return { key: path, path, before, after, patch }
118 }
119
120 return null
121}
122
123export function displayName(path: string, cwd: string): string {
124 const root = cwd.endsWith('/') ? cwd : `${cwd}/`
125 return cwd !== '' && path.startsWith(root) ? path.slice(root.length) : path
126}
127
128// What a prompt the engine wrote reads as: a background task's notification
129// by its summary, a slash command by its name and arguments, any other
130// tagged prompt by its text without the tags.
131function readable(prompt: string): string {
132 const text = prompt.trim()
133 if (!text.startsWith('<')) return text
134 if (text.startsWith('<task-notification>')) {
135 const summary = /<summary>([\s\S]*?)<\/summary>/.exec(text)?.[1]
136 return `Background task: ${summary?.trim() || 'finished'}`
137 }
138 const command = /<command-name>([\s\S]*?)<\/command-name>/.exec(text)?.[1]
139 if (command !== undefined) {
140 const args = /<command-args>([\s\S]*?)<\/command-args>/.exec(text)?.[1] ?? ''
141 return `${command.trim()} ${args.trim()}`.trim()
142 }
143 return text.replace(/<[^>]*>/g, ' ')
144}
145
146export function titleOf(prompt: string): string {
147 const text = readable(prompt)
148 const words = text.split(/\s+/).filter(Boolean)
149 if (words.length === 0) return '(no prompt)'
150 let title = words.slice(0, TITLE_WORDS).join(' ')
151 if (title.length > TITLE_CHARS) title = title.slice(0, TITLE_CHARS - 1).trimEnd()
152 return title.length < words.join(' ').length ? `${title}…` : title
153}
154
155function keepUpTo(hunks: Hunks, budget: number): { hunks: string[]; dropped: number } {
156 const kept: string[] = []
157 let used = 0
158 for (const hunk of hunks.hunks) {
159 if (used + hunk.length > budget && kept.length > 0) break
160 kept.push(hunk)
161 used += hunk.length
162 }
163 return { hunks: kept, dropped: hunks.hunks.length - kept.length }
164}
165
166const isLarge = (text: string | null | undefined) =>
167 typeof text === 'string' && text.length > MAX_TEXT_CHARS
168
169// One file's steps of a turn, oldest first, as the file's diff: the text
170// before its first step against the text after its last when both are known,
171// else each step's own hunks in turn.
172function fileOf(steps: Step[], cwd: string): FileDiff | null {
173 const first = steps[0]!
174 const last = steps[steps.length - 1]!
175 const isNew = first.before === null
176 const name =
177 displayName(first.path, cwd) + (first.cell !== undefined ? ` · cell ${first.cell}` : '')
178 const isKnown = steps.every(step => step.before !== undefined && step.after !== undefined)
179 let note: string | undefined
180 let hunks: Hunks
181
182 if (steps.some(step => isLarge(step.before) || isLarge(step.after))) {
183 hunks = { hunks: [], added: 0, removed: 0 }
184 note = 'too large to diff'
185 } else if (isKnown) {
186 hunks = unifiedHunks(first.before ?? '', last.after ?? '')
187 } else {
188 hunks = { hunks: [], added: 0, removed: 0 }
189 for (const step of steps) {
190 const part =
191 step.before !== undefined && step.after !== undefined
192 ? unifiedHunks(step.before ?? '', step.after)
193 : step.patch
194 if (part === undefined) {
195 note = 'some edits could not be diffed'
196 continue
197 }
198 hunks = {
199 hunks: [...hunks.hunks, ...part.hunks],
200 added: hunks.added + part.added,
201 removed: hunks.removed + part.removed,
202 }
203 }
204 if (steps.length > 1 && note === undefined) note = 'each edit shown in turn'
205 }
206
207 if (hunks.hunks.length === 0 && note === undefined) {
208 if (isNew) note = 'new empty file'
209 else return null
210 }
211 const kept = keepUpTo(hunks, MAX_FILE_CHARS)
212 if (kept.dropped > 0) note = `${kept.dropped} more hunks not kept`
213 return {
214 key: first.key,
215 name,
216 path: first.path,
217 isNew,
218 added: hunks.added,
219 removed: hunks.removed,
220 hunks: kept.hunks,
221 ...(note !== undefined ? { note } : {}),
222 }
223}
224
225export function turnOf(n: number, prompt: string, steps: readonly Step[], cwd: string): TurnDiff {
226 const byKey = new Map<string, Step[]>()
227 for (const step of steps) byKey.set(step.key, [...(byKey.get(step.key) ?? []), step])
228
229 let budget = MAX_TURN_CHARS
230 const files: FileDiff[] = []
231 for (const group of byKey.values()) {
232 const file = fileOf(group, cwd)
233 if (file === null) continue
234 const size = file.hunks.reduce((sum, hunk) => sum + hunk.length, 0)
235 if (size > budget) {
236 files.push({ ...file, hunks: [], note: 'not kept: the turn changed too much' })
237 } else {
238 budget -= size
239 files.push(file)
240 }
241 }
242 return {
243 n,
244 title: titleOf(prompt),
245 files,
246 added: files.reduce((sum, file) => sum + file.added, 0),
247 removed: files.reduce((sum, file) => sum + file.removed, 0),
248 }
249}
250
251// A row that starts a turn: a user message with text and no tool results.
252const isPromptRow = (message: SessionMessage) =>
253 message.role === 'user' &&
254 (message.toolResults === undefined || message.toolResults.length === 0) &&
255 message.text !== ''
256
257// The session's turns with edits, newest first, read back from the
258// transcript, and how many turns it holds. Edits come from each Edit, Write
259// and NotebookEdit call's stored result; errored calls are left out.
260export function turnsFromMessages(
261 messages: readonly SessionMessage[],
262 cwd: string,
263 keep: number,
264): { turns: TurnDiff[]; count: number } {
265 const starts = messages.flatMap((message, at) => (isPromptRow(message) ? [at] : []))
266 const turns: TurnDiff[] = []
267
268 for (let at = starts.length - 1; at >= 0 && turns.length < keep; at--) {
269 const rows = messages.slice(starts[at], starts[at + 1])
270 const steps = rows
271 .flatMap(row => (row.role === 'assistant' ? row.toolUses : []))
272 .filter(use => EDIT_TOOLS.has(use.tool) && use.isError !== true && use.result !== undefined)
273 .map(use => stepOf(use.tool, use.input, use.result))
274 .filter((step): step is Step => step !== null)
275 if (steps.length === 0) continue
276 const turn = turnOf(at + 1, rows[0]?.text ?? '', steps, cwd)
277 if (turn.files.length > 0) turns.push(turn)
278 }
279 return { turns, count: starts.length }
280}
281hooks/view.tsx 546 lines1// The pane: one turn's files as GitHub-style cards. Each card has a header
2// with the path, the +/- counts and five squares for their mix; open, it
3// lists the lines in a monospaced face with both line numbers, tinted rows
4// for removed and added lines, the changed words marked, and a row for each
5// run of unchanged lines. Unified interleaves the lines; split puts the old
6// file beside the new one. A turn too large to draw at once shows one file
7// at a time.
8//
9// The terminal draws the lines as Text rows painted with the theme's diff
10// keys. A surface with an Svg element (the desktop) draws each file's lines
11// as GitHub-style pictures (svg.ts): its Text has no monospaced face on the
12// engine it ships (2.1.295 refuses `mono`), and the theme's diff keys are
13// opaque fills made for a terminal that drown the text there. The `Code`
14// element's diff format is no help: the desktop draws it as a plain code
15// block, with no tints or line numbers. There the header, the cards and
16// the warnings carry small pictures too (icons.ts), and the buttons a glyph
17// before their label: a Button holds only text, never a picture.
18//
19// It never holds `$`: the hooks module reads the state, resolves the
20// elements and hands in what the buttons do, since an engine before 2.1.300
21// refuses a module that passes `$` into a function of another file.
22
23import type { Color, Elements, RenderSurface } from 'claude-code'
24
25import type { DiffMode, FileDiff, PaneView, TurnDiff } from '../types'
26import { diffstat, emptyPicture, fileIcon, newPill, squaresOf, turnIcon, warningIcon } from './icons'
27import type { Icon } from './icons'
28import { keepSpaces, rowsOf, splitRowsOf } from './rows'
29import type { Gap, Line, LineKind, Row, Span, SplitRow } from './rows'
30import { MODE_KEY, WRAP_KEY } from './state'
31import { diffPictures } from './svg'
32import { titleOf } from './turns'
33
34type Kit = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button'>
35
36// How the terminal paints the diff: the theme's own diff colors.
37const ROW: Record<LineKind, Color | undefined> = { context: undefined, removed: 'diffRemoved', added: 'diffAdded' }
38const WORD: Record<LineKind, Color | undefined> = { context: undefined, removed: 'diffRemovedWord', added: 'diffAddedWord' }
39// A file card's header on a graphical surface, GitHub's muted tint.
40const HEADER = '#8b949e1a'
41
42type Look = { width: number; wrap: boolean }
43
44// What the pane's controls do, done by the hooks module, which holds `$`.
45export type PaneActions = {
46 // Writes the pane's view: `change` gets it as stored and returns the next.
47 change: (change: (was: PaneView) => PaneView) => Promise<unknown>
48 // Keeps a display choice across sessions.
49 remember: (key: string, value: boolean | string) => Promise<void>
50 // Scrolls the pane to one of its keyed elements.
51 scrollTo: (key: string) => Promise<void>
52 refresh: () => Promise<void>
53}
54
55// What the pane draws from: the turns, newest first, where it stands, and
56// the surface and width it is drawn at.
57export type PaneModel = {
58 list: readonly TurnDiff[]
59 now: PaneView
60 surface: RenderSurface
61 columns: number
62}
63
64export const PANE = 'last-turn-diff'
65const NBSP = ' '
66// A file shows this many rows a page at a time.
67const PAGE_ROWS = 150
68// What every open file draws at most: a tree draws its first 100,000
69// characters, and every row is several elements.
70const MAX_DRAWN_CHARS = 80_000
71const MAX_DRAWN_ROWS = 600
72// A turn of several files past either of these shows one file at a time.
73const MAX_TURN_ROWS = 400
74const MAX_LISTED_FILES = 60
75// Narrower than this, split view has no room and the pane draws unified.
76// Desktop's pictures wrap each half, so they need less: 64 columns leave
77// each half about 25 characters.
78const MIN_SPLIT_COLUMNS = 90
79const MIN_SPLIT_COLUMNS_DESKTOP = 64
80// Narrower than this, a card's header leaves the squares out.
81const MIN_SQUARES_COLUMNS = 60
82// The width of one of the pane's columns on a graphical surface, in CSS
83// pixels, and the columns a card's border takes.
84const CELL_PX = 8
85const CARD_COLUMNS = 4
86
87const MARK = { context: NBSP, removed: '−', added: '+' } as const
88
89export const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
90
91// A turn's title; one an older version stored with the engine's tags is
92// read again.
93const titleIn = (turn: TurnDiff) => (turn.title.startsWith('<') ? titleOf(turn.title) : turn.title)
94
95const lineCount = (rows: readonly (Row | SplitRow)[]) => rows.filter(row => row.kind !== 'gap').length
96
97function pagesOf<T>(rows: readonly T[]): T[][] {
98 const pages: T[][] = []
99 for (let at = 0; at < rows.length; at += PAGE_ROWS) pages.push(rows.slice(at, at + PAGE_ROWS))
100 return pages
101}
102
103function widthOf(rows: readonly Row[]): number {
104 const most = rows.reduce(
105 (top, row) => (row.kind === 'gap' ? top : Math.max(top, row.old ?? 0, row.new ?? 0)),
106 0,
107 )
108 return Math.max(2, String(most).length)
109}
110
111const gutter = (n: number | undefined, width: number) =>
112 (n === undefined ? '' : String(n)).padStart(width, NBSP) + NBSP
113
114// What a diffstat's squares say, for a reader that cannot see them.
115const diffstatAlt = (counts: { added: number; removed: number }) =>
116 `${plural(counts.added, 'line')} added, ${counts.removed} removed`
117
118// A path as its folder, drawn dim, and its file name.
119function splitPath(name: string): [string, string] {
120 const cell = name.indexOf(' · cell ')
121 const cut = name.lastIndexOf('/', cell < 0 ? name.length : cell)
122 return cut < 0 ? ['', name] : [name.slice(0, cut + 1), name.slice(cut + 1)]
123}
124
125function charsOf(rows: readonly (Row | SplitRow)[], width: number): number {
126 const lineChars = (line: Line | undefined) =>
127 line === undefined ? 0 : 2 * width + 6 + line.spans.reduce((sum, span) => sum + span.text.length, 0)
128 return rows.reduce((sum, row) => {
129 if (row.kind === 'gap') return sum + 2 * width + 40
130 if (row.kind === 'pair') return sum + lineChars(row.left) + lineChars(row.right)
131 return sum + lineChars(row)
132 }, 0)
133}
134
135function code(ui: Kit, line: Line, look: Look) {
136 const word = WORD[line.kind]
137 const spans = line.spans.map((span: Span) => ({ ...span, text: keepSpaces(span.text) }))
138 const shown = spans.every(span => span.text === '')
139 ? NBSP
140 : spans.map(span =>
141 span.isChanged && word !== undefined ? <ui.Text backgroundColor={word}>{span.text}</ui.Text> : span.text,
142 )
143 return (
144 <ui.Box flexGrow={1} flexShrink={1}>
145 <ui.Text wrap={look.wrap ? 'wrap' : 'truncate-end'}>{shown}</ui.Text>
146 </ui.Box>
147 )
148}
149
150const numbers = (ui: Kit, sides: (number | undefined)[], look: Look) =>
151 sides.map(n => <ui.Text dimColor>{gutter(n, look.width)}</ui.Text>)
152
153const mark = (ui: Kit, kind: LineKind) => (
154 <ui.Text dimColor={kind === 'context'}>
155 {MARK[kind]}
156 {NBSP}
157 </ui.Text>
158)
159
160function gapRow(ui: Kit, gap: Gap, look: Look) {
161 const where = gap.count === 1 ? `line ${gap.from}` : `lines ${gap.from}–${gap.from + gap.count - 1}`
162 return (
163 <ui.Box flexDirection="row">
164 <ui.Text dimColor wrap="truncate-end">
165 {NBSP.repeat(look.width)} ⋯ {plural(gap.count, 'unchanged line')} ({where})
166 </ui.Text>
167 </ui.Box>
168 )
169}
170
171function unifiedRow(ui: Kit, row: Row, look: Look) {
172 if (row.kind === 'gap') return gapRow(ui, row, look)
173 return (
174 <ui.Box flexDirection="row" backgroundColor={ROW[row.kind]}>
175 {numbers(ui, [row.old, row.new], look)}
176 {mark(ui, row.kind)}
177 {code(ui, row, look)}
178 </ui.Box>
179 )
180}
181
182function half(ui: Kit, line: Line | undefined, side: 'old' | 'new', look: Look) {
183 if (line === undefined) return <ui.Box width="50%" />
184 return (
185 <ui.Box width="50%" flexDirection="row" backgroundColor={ROW[line.kind]}>
186 {numbers(ui, [line[side]], look)}
187 {mark(ui, line.kind)}
188 {code(ui, line, look)}
189 </ui.Box>
190 )
191}
192
193function splitRow(ui: Kit, row: SplitRow, look: Look) {
194 if (row.kind === 'gap') return gapRow(ui, row, look)
195 return (
196 <ui.Box flexDirection="row" gap={1}>
197 {half(ui, row.left, 'old', look)}
198 {half(ui, row.right, 'new', look)}
199 </ui.Box>
200 )
201}
202
203// One file's rows, paged, with what its first page costs. A new file is
204// all additions: split would leave its old side empty.
205function prepare(file: FileDiff, isSplitView: boolean) {
206 const unified = rowsOf(file.hunks)
207 const isSplit = isSplitView && !file.isNew
208 const rows: (Row | SplitRow)[] = isSplit ? splitRowsOf(unified) : unified
209 const width = widthOf(unified)
210 const pages = pagesOf(rows)
211 const first = pages[0] ?? []
212 return { file, rows, isSplit, width, pages, firstChars: charsOf(first, width) }
213}
214
215export function paneTree(elements: Elements[RenderSurface], model: PaneModel, actions: PaneActions) {
216 const { list, now, surface, columns } = model
217 const { Box, Text, Button } = elements
218 const Select = 'Select' in elements ? elements.Select : undefined
219 const ui: Kit = { Box, Text, Button }
220 const isTerminal = surface === 'terminal'
221 // Every table names every element (one a surface lacks draws nothing), so
222 // the surface decides: the terminal draws rows, the rest pictures.
223 const Svg = !isTerminal && 'Svg' in elements ? elements.Svg : undefined
224 // Hotkeys answer on the terminal; a desktop draws each as a badge on its
225 // button, which crowds the toolbar.
226 const hotkey = (key: string) => (isTerminal ? { hotkey: key } : {})
227 // A button's label, a glyph before it where the pictures are drawn.
228 const named = (glyph: string, label: string) => (Svg === undefined ? label : `${glyph} ${label}`)
229 const picture = (icon: Icon, alt: string) =>
230 Svg === undefined ? null : <Svg alt={alt} source={icon.source} width={icon.width} height={icon.height} />
231 const at = Math.min(now.at, Math.max(0, list.length - 1))
232 const mode: DiffMode = now.mode ?? 'unified'
233 const canSplit = columns >= (isTerminal ? MIN_SPLIT_COLUMNS : MIN_SPLIT_COLUMNS_DESKTOP)
234 // The pictures span the card, inside its border.
235 const pictureWidth = Math.floor((columns - CARD_COLUMNS) * CELL_PX)
236 const wrap = now.wrap ?? true
237 const turn: TurnDiff | undefined = list[at]
238
239 const refreshButton = (
240 <Button key="refresh" label={named('↻', 'Refresh')} {...hotkey('r')} onPress={() => void actions.refresh()} />
241 )
242
243 if (turn === undefined) {
244 return (
245 <Box flexDirection="column" gap={1}>
246 <Box flexDirection="row" gap={2} alignItems="center">
247 {picture(emptyPicture(), 'No edits yet')}
248 <Box flexDirection="column" flexShrink={1}>
249 <Text dimColor>No file edits recorded in this session yet.</Text>
250 <Text dimColor>Edits made through Edit, Write and NotebookEdit show here after each turn.</Text>
251 </Box>
252 </Box>
253 <Box flexDirection="row">{refreshButton}</Box>
254 </Box>
255 )
256 }
257
258 const files = turn.files.map(file => prepare(file, mode === 'split' && canSplit))
259 const isTooLarge =
260 files.length > 1 &&
261 (files.length > MAX_LISTED_FILES ||
262 files.reduce((sum, one) => sum + lineCount(one.rows), 0) > MAX_TURN_ROWS ||
263 files.reduce((sum, one) => sum + one.firstChars, 0) > MAX_DRAWN_CHARS)
264 const current = Math.min(now.file ?? 0, files.length - 1)
265 const isOpen = (file: FileDiff) => !now.toggled.includes(file.key)
266 const isAllOpen = turn.files.every(isOpen)
267
268 const go = (to: number) =>
269 actions.change(was => ({
270 ...was,
271 at: Math.max(0, Math.min(to, list.length - 1)),
272 toggled: [],
273 pages: {},
274 file: 0,
275 }))
276 const toggle = (key: string) =>
277 actions.change(was => ({
278 ...was,
279 toggled: was.toggled.includes(key) ? was.toggled.filter(one => one !== key) : [...was.toggled, key],
280 }))
281 const turnPage = (key: string, to: number) =>
282 actions.change(was => ({ ...was, pages: { ...was.pages, [key]: to } }))
283 const switchMode = async () => {
284 const next: DiffMode = mode === 'unified' ? 'split' : 'unified'
285 await actions.change(was => ({ ...was, mode: next, pages: {} }))
286 await actions.remember(MODE_KEY, next)
287 }
288 const switchWrap = async () => {
289 await actions.change(was => ({ ...was, wrap: !wrap }))
290 await actions.remember(WRAP_KEY, !wrap)
291 }
292 const showFile = (to: number) =>
293 actions.change(was => ({ ...was, file: Math.max(0, Math.min(to, files.length - 1)) }))
294 const jumpTo = async (index: number) => {
295 const target = turn.files[index]
296 if (target === undefined) return
297 await actions.change(was => ({
298 ...was,
299 file: index,
300 toggled: was.toggled.filter(key => key !== target.key),
301 }))
302 if (!isTooLarge) await actions.scrollTo(`card-${index}`)
303 }
304
305 let chars = MAX_DRAWN_CHARS
306 let drawnRows = MAX_DRAWN_ROWS
307
308 const card = (index: number) => {
309 const { file, rows, isSplit, width, pages } = files[index]!
310 const open = isTooLarge || isOpen(file)
311 const page = Math.min(now.pages[file.key] ?? 0, Math.max(0, pages.length - 1))
312 const shown = pages[page] ?? []
313 const size = charsOf(shown, width)
314 const fits = size <= chars && shown.length <= drawnRows
315 if (open && fits) {
316 chars -= size
317 drawnRows -= shown.length
318 }
319 const look: Look = { width, wrap }
320 const squares = squaresOf(file.added, file.removed)
321 const [folder, base] = splitPath(file.name)
322 const fold = () => void (isTooLarge ? undefined : toggle(file.key))
323 // The terminal draws the chevron in the path's button; the desktop draws
324 // it as a button of its own, the file's icon between the two.
325 const path = [<Text dimColor>{folder}</Text>, <Text bold>{base}</Text>]
326
327 return (
328 <Box key={`card-${index}`} flexDirection="column" borderStyle="round" borderDimColor>
329 <Box
330 flexDirection="row"
331 justifyContent="space-between"
332 alignItems="center"
333 gap={1}
334 {...(isTerminal ? {} : { backgroundColor: HEADER })}
335 paddingX={isTerminal ? 0 : 1}
336 >
337 {Svg === undefined ? (
338 <Button key={`toggle-${index}`} plain onPress={fold}>
339 {open ? '▾ ' : '▸ '}
340 {path}
341 </Button>
342 ) : (
343 <Box flexDirection="row" alignItems="center" flexShrink={1}>
344 <Button key={`chevron-${index}`} plain label={open ? '⏷' : '⏵'} onPress={fold} />
345 {picture(fileIcon(file.isNew), file.isNew ? 'New file' : 'Changed file')}
346 <Button key={`toggle-${index}`} plain onPress={fold}>
347 {path}
348 </Button>
349 </Box>
350 )}
351 <Box flexDirection="row" flexShrink={0} gap={1} alignItems="center">
352 {file.isNew && (Svg === undefined ? <Text color="success">New</Text> : picture(newPill(), 'New'))}
353 {file.note !== undefined && <Text dimColor>{file.note}</Text>}
354 <Text>
355 <Text color="success">+{file.added}</Text> <Text color="error">−{file.removed}</Text>
356 {columns >= MIN_SQUARES_COLUMNS && Svg === undefined && (
357 <Text>
358 {' '}
359 <Text color="success">{'■'.repeat(squares.added)}</Text>
360 <Text color="error">{'■'.repeat(squares.removed)}</Text>
361 <Text dimColor>{'■'.repeat(squares.none)}</Text>
362 </Text>
363 )}
364 </Text>
365 {columns >= MIN_SQUARES_COLUMNS && picture(diffstat(file.added, file.removed), diffstatAlt(file))}
366 </Box>
367 </Box>
368 {open && shown.length > 0 && fits && Svg !== undefined && (
369 <Box flexDirection="column">
370 {diffPictures(shown, {
371 width: pictureWidth,
372 wrap,
373 path: file.path,
374 digits: width,
375 isSplit,
376 isNew: file.isNew,
377 }).map(picture => (
378 <Svg alt={`Diff of ${file.name}`} source={picture.source} height={picture.height} />
379 ))}
380 </Box>
381 )}
382 {open && shown.length > 0 && fits && Svg === undefined && (
383 <Box flexDirection="column">
384 {shown.map(row =>
385 isSplit ? splitRow(ui, row as SplitRow, look) : unifiedRow(ui, row as Row, look),
386 )}
387 </Box>
388 )}
389 {open && shown.length > 0 && !fits && (
390 <Text dimColor>Too much is open to draw this file here: collapse another file first.</Text>
391 )}
392 {open && pages.length > 1 && (
393 <Box flexDirection="row" gap={1} alignItems="center" paddingX={isTerminal ? 0 : 1}>
394 <Button
395 key={`earlier-${index}`}
396 label={Svg === undefined ? '◀ Earlier lines' : '↑ Earlier lines'}
397 onPress={() => void turnPage(file.key, page - 1)}
398 />
399 <Text dimColor>
400 {lineCount(pages.slice(0, page).flat()) + 1}–{lineCount(pages.slice(0, page + 1).flat())} of{' '}
401 {lineCount(rows)} lines
402 </Text>
403 <Button
404 key={`later-${index}`}
405 label={Svg === undefined ? 'Later lines ▶' : 'Later lines ↓'}
406 onPress={() => void turnPage(file.key, page + 1)}
407 />
408 </Box>
409 )}
410 </Box>
411 )
412 }
413
414 const picker =
415 Select !== undefined && list.length > 1 ? (
416 <Select
417 key="turn"
418 value={String(at)}
419 options={list.map((one, index) => ({
420 value: String(index),
421 label: `T${one.n} · ${titleIn(one)} · ${plural(one.files.length, 'file')}`,
422 }))}
423 onSelect={value => void go(Number(value))}
424 />
425 ) : null
426
427 const jump =
428 Select !== undefined && files.length > 1 ? (
429 <Select
430 key="jump"
431 label="Jump to file"
432 value={String(current)}
433 options={turn.files.map((file, index) => ({
434 value: String(index),
435 label: `${file.name} +${file.added} −${file.removed}`,
436 }))}
437 onSelect={value => void jumpTo(Number(value))}
438 />
439 ) : null
440
441 return (
442 <Box flexDirection="column" gap={1}>
443 <Box flexDirection="column">
444 <Box flexDirection="row" gap={1} alignItems="center">
445 {picture(turnIcon(), 'Prompt')}
446 <Text bold wrap="truncate-end">
447 T{turn.n} · {titleIn(turn)}
448 </Text>
449 </Box>
450 <Box flexDirection="row" gap={1} alignItems="center">
451 <Text>
452 {plural(turn.files.length, 'file')} changed{' '}
453 <Text color="success">+{turn.added}</Text> <Text color="error">−{turn.removed}</Text>
454 </Text>
455 {picture(diffstat(turn.added, turn.removed), diffstatAlt(turn))}
456 {list.length > 1 && (
457 <Text dimColor>
458 {Svg === undefined ? ' ' : ''}· turn {at + 1} of {list.length} with edits
459 </Text>
460 )}
461 </Box>
462 </Box>
463 <Box flexDirection="column" gap={isTerminal ? 0 : 1}>
464 {list.length > 1 && (
465 <Box flexDirection="row" gap={1} alignItems="center">
466 <Button
467 key="prev"
468 label={Svg === undefined ? (picker ? '◀' : '◀ Older') : picker ? '←' : '← Older'}
469 {...hotkey('p')}
470 onPress={() => void go(at + 1)}
471 />
472 {picker}
473 <Button
474 key="next"
475 label={Svg === undefined ? (picker ? '▶' : 'Newer ▶') : picker ? '→' : 'Newer →'}
476 {...hotkey('n')}
477 onPress={() => void go(at - 1)}
478 />
479 </Box>
480 )}
481 <Box flexDirection="row" gap={1} flexWrap="wrap" alignItems="center">
482 {canSplit && (
483 <Button
484 key="mode"
485 label={mode === 'unified' ? named('◫', 'Split view') : named('▤', 'Unified view')}
486 {...hotkey('s')}
487 onPress={() => void switchMode()}
488 />
489 )}
490 <Button
491 key="wrap"
492 label={wrap ? named('⇥', 'Unwrap lines') : named('↵', 'Wrap lines')}
493 {...hotkey('w')}
494 onPress={() => void switchWrap()}
495 />
496 {!isTooLarge && files.length > 1 && (
497 <Button
498 key="all"
499 label={isAllOpen ? named('⊟', 'Collapse all') : named('⊞', 'Expand all')}
500 {...hotkey('a')}
501 onPress={() =>
502 void actions.change(was => ({
503 ...was,
504 toggled: isAllOpen ? turn.files.map(file => file.key) : [],
505 }))
506 }
507 />
508 )}
509 {refreshButton}
510 </Box>
511 {jump}
512 </Box>
513 {isTooLarge && (
514 <Box flexDirection="column">
515 <Box flexDirection="row" gap={1} alignItems="center">
516 {picture(warningIcon(), 'Warning')}
517 <Text color="warning">
518 This turn's diff is too large to show at once, so it shows one file at a time.
519 </Text>
520 </Box>
521 <Box flexDirection="row" gap={1} alignItems="center">
522 <Button
523 key="prev-file"
524 label={Svg === undefined ? '◀ Previous file' : '← Previous file'}
525 {...hotkey('k')}
526 onPress={() => void showFile(current - 1)}
527 />
528 <Text dimColor>
529 File {current + 1} of {files.length}
530 </Text>
531 <Button
532 key="next-file"
533 label={Svg === undefined ? 'Next file ▶' : 'Next file →'}
534 {...hotkey('j')}
535 onPress={() => void showFile(current + 1)}
536 />
537 </Box>
538 </Box>
539 )}
540 <Box flexDirection="column" gap={1}>
541 {isTooLarge ? card(current) : files.map((_, index) => card(index))}
542 </Box>
543 </Box>
544 )
545}
546hooks/diff.ts 175 lines1// Unified line diffs in plain JS: Myers' O(ND) algorithm over the lines left
2// once the common head and tail are trimmed, then hunks with 3 lines of
3// context. No dependencies, so it runs in the hooks environment as is.
4
5export type Op = { t: ' ' | '-' | '+'; line: string }
6
7export type Hunks = { hunks: string[]; added: number; removed: number }
8
9const CONTEXT = 3
10// Past this many edits the trace would cost too much memory: the changed
11// middle is shown as all removed, then all added.
12const MAX_D = 1500
13// A hunk longer than this is split into several, each with its own header,
14// so one paged view never has to hold a whole rewritten file.
15const MAX_HUNK_LINES = 200
16// A drawn line is cut here (minified files would swamp the pane).
17const MAX_LINE_CHARS = 400
18
19export function splitLines(text: string): string[] {
20 if (text === '') return []
21 const lines = text.split('\n')
22 if (lines[lines.length - 1] === '') lines.pop()
23 return lines.map(line => (line.endsWith('\r') ? line.slice(0, -1) : line))
24}
25
26// The edit script turning a into b, or null when it needs more than maxD
27// insertions and deletions.
28function myers(a: string[], b: string[], maxD: number): Op[] | null {
29 const n = a.length
30 const m = b.length
31 const limit = Math.min(n + m, maxD)
32 const offset = limit + 1
33 const v = new Int32Array(2 * limit + 3)
34 const trace: Int32Array[] = []
35 let found = -1
36
37 outer: for (let d = 0; d <= limit; d++) {
38 // v as it stood after step d-1, for k in [-d-1, d+1].
39 trace.push(v.slice(offset - d - 1, offset + d + 2))
40 for (let k = -d; k <= d; k += 2) {
41 const down = k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!)
42 let x = down ? v[offset + k + 1]! : v[offset + k - 1]! + 1
43 let y = x - k
44 while (x < n && y < m && a[x] === b[y]) {
45 x++
46 y++
47 }
48 v[offset + k] = x
49 if (x >= n && y >= m) {
50 found = d
51 break outer
52 }
53 }
54 }
55 if (found < 0) return null
56
57 const ops: Op[] = []
58 let x = n
59 let y = m
60 for (let d = found; d >= 0; d--) {
61 const saved = trace[d]!
62 const at = (k: number) => saved[k + d + 1]!
63 const k = x - y
64 const down = k === -d || (k !== d && at(k - 1) < at(k + 1))
65 const prevK = down ? k + 1 : k - 1
66 const prevX = d === 0 ? 0 : at(prevK)
67 const prevY = prevX - prevK
68 while (x > prevX && y > prevY) {
69 ops.push({ t: ' ', line: a[--x]! })
70 y--
71 }
72 if (d > 0) {
73 if (x === prevX) ops.push({ t: '+', line: b[--y]! })
74 else ops.push({ t: '-', line: a[--x]! })
75 }
76 }
77 return ops.reverse()
78}
79
80export function diffLines(a: string[], b: string[]): Op[] {
81 let head = 0
82 while (head < a.length && head < b.length && a[head] === b[head]) head++
83 let tail = 0
84 while (
85 tail < a.length - head &&
86 tail < b.length - head &&
87 a[a.length - 1 - tail] === b[b.length - 1 - tail]
88 ) {
89 tail++
90 }
91 const midA = a.slice(head, a.length - tail)
92 const midB = b.slice(head, b.length - tail)
93 const middle = myers(midA, midB, MAX_D) ?? [
94 ...midA.map((line): Op => ({ t: '-', line })),
95 ...midB.map((line): Op => ({ t: '+', line })),
96 ]
97 return [
98 ...a.slice(0, head).map((line): Op => ({ t: ' ', line })),
99 ...middle,
100 ...a.slice(a.length - tail).map((line): Op => ({ t: ' ', line })),
101 ]
102}
103
104type Numbered = Op & { o: number; n: number }
105
106function header(ops: Numbered[]): string {
107 const first = ops[0]!
108 const oldCount = ops.filter(op => op.t !== '+').length
109 const newCount = ops.filter(op => op.t !== '-').length
110 const oldStart = oldCount > 0 ? first.o : first.o - 1
111 const newStart = newCount > 0 ? first.n : first.n - 1
112 return `@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`
113}
114
115function cut(line: string): string {
116 return line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line
117}
118
119// Hunk texts (header and body, no trailing newline) for the change from
120// `before` to `after`, with CONTEXT lines around each change.
121export function unifiedHunks(before: string, after: string): Hunks {
122 const ops = diffLines(splitLines(before), splitLines(after))
123 let o = 1
124 let n = 1
125 let added = 0
126 let removed = 0
127 const numbered: Numbered[] = ops.map(op => {
128 const row = { ...op, o, n }
129 if (op.t !== '+') o++
130 if (op.t !== '-') n++
131 if (op.t === '+') added++
132 if (op.t === '-') removed++
133 return row
134 })
135
136 const ranges: [number, number][] = []
137 numbered.forEach((op, at) => {
138 if (op.t === ' ') return
139 const start = Math.max(0, at - CONTEXT)
140 const end = Math.min(numbered.length - 1, at + CONTEXT)
141 const last = ranges[ranges.length - 1]
142 if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end)
143 else ranges.push([start, end])
144 })
145
146 const hunks: string[] = []
147 for (const [start, end] of ranges) {
148 const rows = numbered.slice(start, end + 1)
149 for (let at = 0; at < rows.length; at += MAX_HUNK_LINES) {
150 const part = rows.slice(at, at + MAX_HUNK_LINES)
151 hunks.push([header(part), ...part.map(op => op.t + cut(op.line))].join('\n'))
152 }
153 }
154 return { hunks, added, removed }
155}
156
157// Hunks a tool already computed (Edit's and Write's `structuredPatch`), as
158// hunk texts with the same caps.
159export function patchHunks(
160 patch: readonly { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: readonly string[] }[],
161): Hunks {
162 let added = 0
163 let removed = 0
164 const hunks = patch.map(part => {
165 const lines = part.lines.filter(line => !line.startsWith('\\'))
166 for (const line of lines) {
167 if (line.startsWith('+')) added++
168 else if (line.startsWith('-')) removed++
169 }
170 const head = `@@ -${part.oldStart},${part.oldLines} +${part.newStart},${part.newLines} @@`
171 return [head, ...lines.map(line => line.slice(0, 1) + cut(line.slice(1)))].join('\n')
172 })
173 return { hunks, added, removed }
174}
175hooks/rows.ts 161 lines1// A file's stored hunks as the rows a GitHub-style view draws: numbered
2// lines on both sides, the changed words of a reworded line, and the runs of
3// unchanged lines between hunks. Pure, so tests read it directly.
4
5import { diffLines } from './diff'
6
7export type Span = { text: string; isChanged: boolean }
8
9export type LineKind = 'context' | 'removed' | 'added'
10
11export type Line = {
12 kind: LineKind
13 old?: number
14 new?: number
15 spans: Span[]
16}
17
18export type Gap = { kind: 'gap'; count: number; from: number }
19
20// One row of the unified view: a line, or the unchanged lines skipped.
21export type Row = Gap | Line
22
23// One row of the split view: the old file's line beside the new file's.
24export type SplitRow = Gap | { kind: 'pair'; left?: Line; right?: Line }
25
26const HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
27// Reworded lines longer than this are marked whole.
28const MAX_WORD_DIFF_TOKENS = 300
29// Below this share of unchanged characters, word marks are noise.
30const MIN_KEPT_SHARE = 0.4
31
32const whole = (text: string): Span[] => [{ text, isChanged: false }]
33
34function tokens(text: string): string[] {
35 return text.match(/\w+|\s+|[^\w\s]/g) ?? []
36}
37
38function joined(parts: Span[]): Span[] {
39 const spans: Span[] = []
40 for (const part of parts) {
41 const last = spans[spans.length - 1]
42 if (last && last.isChanged === part.isChanged) last.text += part.text
43 else spans.push({ ...part })
44 }
45 return spans
46}
47
48// The removed and added versions of a reworded line, each split into the
49// words it shares with the other and the words that changed; null when the
50// lines differ too much for word marks to help.
51export function wordSpans(before: string, after: string): [Span[], Span[]] | null {
52 const a = tokens(before)
53 const b = tokens(after)
54 if (a.length > MAX_WORD_DIFF_TOKENS || b.length > MAX_WORD_DIFF_TOKENS) return null
55 const ops = diffLines(a, b)
56 const kept = ops.filter(op => op.t === ' ').reduce((sum, op) => sum + op.line.length, 0)
57 if (kept < MIN_KEPT_SHARE * Math.max(before.length, after.length)) return null
58 const left = joined(ops.filter(op => op.t !== '+').map(op => ({ text: op.line, isChanged: op.t === '-' })))
59 const right = joined(ops.filter(op => op.t !== '-').map(op => ({ text: op.line, isChanged: op.t === '+' })))
60 return [left, right]
61}
62
63type Hunk = { firstOld: number; oldLines: number; lines: Line[] }
64
65function parseHunk(text: string): Hunk | null {
66 const [head = '', ...body] = text.split('\n')
67 const match = HEADER.exec(head)
68 if (match === null) return null
69 const oldLines = match[2] === undefined ? 1 : Number(match[2])
70 const newLines = match[4] === undefined ? 1 : Number(match[4])
71 // A side with no lines names the line it comes after.
72 const firstOld = Number(match[1]) + (oldLines === 0 ? 1 : 0)
73 let old = firstOld
74 let next = Number(match[3]) + (newLines === 0 ? 1 : 0)
75 const lines: Line[] = []
76 for (const raw of body) {
77 const mark = raw.slice(0, 1)
78 const line = raw.slice(1)
79 if (mark === '-') lines.push({ kind: 'removed', old: old++, spans: whole(line) })
80 else if (mark === '+') lines.push({ kind: 'added', new: next++, spans: whole(line) })
81 else if (mark === ' ') lines.push({ kind: 'context', old: old++, new: next++, spans: whole(line) })
82 }
83 markWords(lines)
84 return { firstOld, oldLines, lines }
85}
86
87// Pairs each run of removed lines with the added run right after it and
88// marks the words that changed in each pair.
89function markWords(lines: Line[]) {
90 for (let at = 0; at < lines.length; ) {
91 if (lines[at]!.kind !== 'removed') {
92 at++
93 continue
94 }
95 const removedFrom = at
96 while (at < lines.length && lines[at]!.kind === 'removed') at++
97 const addedFrom = at
98 while (at < lines.length && lines[at]!.kind === 'added') at++
99 const pairs = Math.min(addedFrom - removedFrom, at - addedFrom)
100 for (let k = 0; k < pairs; k++) {
101 const removed = lines[removedFrom + k]!
102 const added = lines[addedFrom + k]!
103 const spans = wordSpans(removed.spans[0]!.text, added.spans[0]!.text)
104 if (spans !== null) [removed.spans, added.spans] = spans
105 }
106 }
107}
108
109// The unified rows of a file's hunks, a gap row wherever lines are skipped.
110export function rowsOf(hunks: readonly string[]): Row[] {
111 const rows: Row[] = []
112 let nextOld = 1
113 for (const text of hunks) {
114 const hunk = parseHunk(text)
115 if (hunk === null) continue
116 if (hunk.firstOld > nextOld) rows.push({ kind: 'gap', count: hunk.firstOld - nextOld, from: nextOld })
117 rows.push(...hunk.lines)
118 nextOld = hunk.firstOld + hunk.oldLines
119 }
120 return rows
121}
122
123// The same rows side by side: unchanged lines on both sides, each removed
124// run beside the added run that replaced it.
125export function splitRowsOf(rows: readonly Row[]): SplitRow[] {
126 const out: SplitRow[] = []
127 for (let at = 0; at < rows.length; ) {
128 const row = rows[at]!
129 if (row.kind === 'gap') {
130 out.push(row)
131 at++
132 continue
133 }
134 if (row.kind === 'context') {
135 out.push({ kind: 'pair', left: row, right: row })
136 at++
137 continue
138 }
139 const removed: Line[] = []
140 const added: Line[] = []
141 for (let next = rows[at]; next?.kind === 'removed'; next = rows[++at]) removed.push(next)
142 for (let next = rows[at]; next?.kind === 'added'; next = rows[++at]) added.push(next)
143 for (let k = 0; k < Math.max(removed.length, added.length); k++) {
144 const left = removed[k]
145 const right = added[k]
146 out.push({ kind: 'pair', ...(left ? { left } : {}), ...(right ? { right } : {}) })
147 }
148 }
149 return out
150}
151
152// Text a surface may lay out as prose keeps its indentation and inner runs
153// of spaces: tabs become spaces, and spaces that would collapse become
154// no-break spaces (drawn the same in a terminal).
155export function keepSpaces(text: string): string {
156 return text
157 .replace(/\t/g, ' ')
158 .replace(/^ +/, lead => ' '.repeat(lead.length))
159 .replace(/ {2,}/g, run => ' '.repeat(run.length - 1) + ' ')
160}
161hooks/svg.ts 262 lines1// A file's diff rows drawn as SVG pictures in GitHub's diff style: a 12px
2// monospaced face on 20px rows, line numbers on tinted gutters, tinted rows,
3// the changed words marked, and syntax colours. A surface whose Text has no
4// monospaced face (the desktop's engine before 2.1.300 refuses `mono`) shows
5// these through its Svg element. The tints are translucent and the text
6// colours switch with `prefers-color-scheme`, so a light or a dark theme
7// reads them. Pure, so tests read it directly.
8
9import type { Line, LineKind, Row, SplitRow } from './rows'
10import { languageOf, tokensOf } from './syntax'
11import type { TokenClass } from './syntax'
12
13// One cell of SF Mono or Menlo at 12px, the face's 0.6em advance.
14export const CHAR_PX = 7.22
15const ROW_PX = 20
16const BASELINE_PX = 14
17const NUMBER_PAD_PX = 8
18// The +/- column before the code.
19const SIGN_PX = 18
20const RIGHT_PAD_PX = 8
21// The fewest characters a wrapped line keeps, however narrow the pane.
22const MIN_CHARS = 16
23// One picture's markup stays under the Svg element's 131,072 characters.
24const MAX_SOURCE_CHARS = 120_000
25
26const STYLE =
27 '<style>' +
28 'text{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;font-size:12px;white-space:pre;fill:#e6edf3}' +
29 '.ra{fill:#2ea04326}.na{fill:#3fb9504d}.wa{fill:#2ea04366}' +
30 '.rd{fill:#f8514926}.nd{fill:#f851494d}.wd{fill:#f8514966}' +
31 '.rh{fill:#388bfd1a}.nh{fill:#388bfd33}.re{fill:#6e76811a}.sep{fill:#6e768166}' +
32 '.n{fill:#6e7681}.m{fill:#8b949e}' +
33 '.keyword{fill:#ff7b72}.string{fill:#a5d6ff}.comment{fill:#8b949e}.constant{fill:#79c0ff}.function{fill:#d2a8ff}.type{fill:#ffa657}' +
34 '@media (prefers-color-scheme:light){text{fill:#1f2328}.n,.m,.comment{fill:#59636e}' +
35 '.keyword{fill:#cf222e}.string{fill:#0a3069}.constant{fill:#0550ae}.function{fill:#6639ba}.type{fill:#953800}}' +
36 '</style>'
37
38const ROW_CLASS: Record<LineKind, string | undefined> = { context: undefined, removed: 'rd', added: 'ra' }
39const NUMBER_CLASS: Record<LineKind, string | undefined> = { context: undefined, removed: 'nd', added: 'na' }
40const WORD_CLASS: Record<LineKind, string | undefined> = { context: undefined, removed: 'wd', added: 'wa' }
41const SIGN: Record<LineKind, string> = { context: '', removed: '−', added: '+' }
42
43export type DiffPicture = { source: string; width: number; height: number }
44
45export type PictureOptions = {
46 // The picture's width in CSS pixels.
47 width: number
48 // Long lines continue on the rows below; else they are cut with an ellipsis.
49 wrap: boolean
50 // The file's path, which names its language.
51 path: string
52 // Digits of the largest line number.
53 digits: number
54 // Old lines beside new ones; else interleaved.
55 isSplit: boolean
56 // A new file has no old line numbers to show.
57 isNew: boolean
58}
59
60type Run = { text: string; at: number; isChanged: boolean; kind: TokenClass | undefined }
61
62// A side of the picture, in its own viewport: the whole picture unified,
63// one half of it split. `chars` is how many characters a row holds.
64type Side = { numberWidth: number; numbers: number; codeX: number; chars: number }
65
66const px = (n: number) => String(Math.round(n * 10) / 10)
67
68const escape = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
69
70// Characters XML refuses, lone surrogates among them, drawn as U+FFFD.
71const clean = (text: string) =>
72 text.replace(
73 /[\u0000-\u0008\u000b\u000c\u000e-\u001f]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/g,
74 '�',
75 )
76
77function sideOf(width: number, numbers: number, digits: number): Side {
78 const numberWidth = digits * CHAR_PX + 2 * NUMBER_PAD_PX
79 const codeX = numbers * numberWidth + SIGN_PX
80 const chars = Math.max(MIN_CHARS, Math.floor((width - codeX - RIGHT_PAD_PX) / CHAR_PX))
81 return { numberWidth, numbers, codeX, chars }
82}
83
84// A line's text with tabs as four spaces, each character marked changed or
85// not and with its syntax class, then cut into the rows it is drawn on.
86function rowsOfLine(line: Line, side: Side, wrap: boolean, language: ReturnType<typeof languageOf>): Run[][] {
87 let text = ''
88 const changed: boolean[] = []
89 for (const span of line.spans) {
90 const part = clean(span.text.replace(/\t/g, ' ').replace(/\r/g, ''))
91 text += part
92 for (let k = 0; k < part.length; k++) changed.push(span.isChanged)
93 }
94 const kinds: (TokenClass | undefined)[] = new Array(text.length).fill(undefined)
95 for (const token of tokensOf(text, language)) kinds.fill(token.kind, token.from, token.to)
96
97 const cuts: [number, number][] = []
98 if (!wrap || text.length <= side.chars) {
99 cuts.push([0, Math.min(text.length, wrap ? text.length : side.chars)])
100 } else {
101 for (let from = 0; from < text.length; ) {
102 let to = Math.min(text.length, from + side.chars)
103 if (to < text.length) {
104 // After the row's last space, as text wraps, unless that leaves
105 // the row less than half full.
106 const space = text.lastIndexOf(' ', to - 1)
107 if (space >= from + side.chars / 2) to = space + 1
108 // Never between the two halves of a surrogate pair.
109 else if (/[\ud800-\udbff]/.test(text[to - 1]!)) to--
110 }
111 cuts.push([from, to])
112 from = to
113 }
114 }
115
116 const rows = cuts.map(([from, to]) => {
117 const runs: Run[] = []
118 for (let at = from; at < to; at++) {
119 const last = runs[runs.length - 1]
120 if (last !== undefined && last.isChanged === changed[at] && last.kind === kinds[at]) last.text += text[at]
121 else runs.push({ text: text[at]!, at: at - from, isChanged: changed[at]!, kind: kinds[at] })
122 }
123 return runs
124 })
125 if (!wrap && text.length > side.chars) {
126 const runs = rows[0]!
127 const last = runs[runs.length - 1]
128 if (last !== undefined) last.text = last.text.slice(0, -1)
129 runs.push({ text: '…', at: side.chars - 1, isChanged: false, kind: 'comment' })
130 }
131 return rows
132}
133
134// A rectangle; a width given as a string is a share of the viewport ("100%").
135function rect(className: string, x: number, y: number, width: number | string, height: number) {
136 const across = typeof width === 'string' ? width : px(width)
137 return `<rect class="${className}" x="${px(x)}" y="${px(y)}" width="${across}" height="${px(height)}"/>`
138}
139
140// One side of one row: its tints, line numbers, sign and code.
141function lineMarkup(
142 side: Side,
143 line: Line,
144 numbers: (number | undefined)[],
145 rows: Run[][],
146 y: number,
147 height: number,
148): string {
149 let out = ''
150 const rowClass = ROW_CLASS[line.kind]
151 const numberClass = NUMBER_CLASS[line.kind]
152 const wordClass = WORD_CLASS[line.kind]
153 if (rowClass !== undefined) out += rect(rowClass, 0, y, '100%', height)
154 if (numberClass !== undefined) out += rect(numberClass, 0, y, side.numbers * side.numberWidth, height)
155 const base = y + BASELINE_PX
156 numbers.forEach((n, k) => {
157 if (n === undefined) return
158 const right = (k + 1) * side.numberWidth - NUMBER_PAD_PX
159 out += `<text class="n" x="${px(right)}" y="${px(base)}" text-anchor="end">${n}</text>`
160 })
161 const sign = SIGN[line.kind]
162 if (sign !== '') out += `<text x="${px(side.codeX - SIGN_PX + 5)}" y="${px(base)}">${sign}</text>`
163 rows.forEach((runs, k) => {
164 const top = y + k * ROW_PX
165 if (wordClass !== undefined) {
166 for (const run of runs) {
167 if (!run.isChanged || run.text.trim() === '') continue
168 out += `<rect class="${wordClass}" x="${px(side.codeX + run.at * CHAR_PX)}" y="${px(top + 1)}" width="${px(run.text.length * CHAR_PX)}" height="${ROW_PX - 2}" rx="2"/>`
169 }
170 }
171 const spans = runs
172 .filter(run => run.text !== '')
173 .map(run => {
174 const kind = run.kind !== undefined ? ` class="${run.kind}"` : ''
175 return `<tspan x="${px(side.codeX + run.at * CHAR_PX)}"${kind}>${escape(run.text)}</tspan>`
176 })
177 .join('')
178 if (spans !== '') out += `<text y="${px(top + BASELINE_PX)}">${spans}</text>`
179 })
180 return out
181}
182
183function gapMarkup(gap: Extract<Row, { kind: 'gap' }>, side: Side, y: number): string {
184 const where = gap.count === 1 ? `line ${gap.from}` : `lines ${gap.from}–${gap.from + gap.count - 1}`
185 const label = `⋯ ${gap.count} unchanged line${gap.count === 1 ? '' : 's'} (${where})`
186 return (
187 rect('rh', 0, y, '100%', ROW_PX) +
188 rect('nh', 0, y, side.numbers * side.numberWidth, ROW_PX) +
189 `<text class="m" x="${px(side.codeX - SIGN_PX + 5)}" y="${px(y + BASELINE_PX)}">${escape(label)}</text>`
190 )
191}
192
193// The rows of one file (one page of them) as pictures, each under the
194// element's size: one, unless the page is very long. A picture is as wide
195// as the room it is drawn in, its tints drawn across it at 1:1; `width`,
196// what the room is thought to be, only decides where long lines wrap.
197export function diffPictures(rows: readonly (Row | SplitRow)[], options: PictureOptions): DiffPicture[] {
198 const width = Math.max(200, Math.floor(options.width))
199 const language = languageOf(options.path)
200 const unified = sideOf(width, options.isNew ? 1 : 2, options.digits)
201 const half = sideOf(width / 2, 1, options.digits)
202
203 const pictures: DiffPicture[] = []
204 // Unified, everything is drawn in `body`; split, the gaps are, and each
205 // half's lines in a viewport of its own, which clips them at the middle.
206 let body = ''
207 let left = ''
208 let right = ''
209 let height = 0
210 const close = () => {
211 if (height === 0) return
212 const halves = options.isSplit
213 ? `<svg width="50%" height="${height}">${left}</svg>` +
214 `<svg x="50%" width="50%" height="${height}">${right}</svg>` +
215 `<rect class="sep" x="50%" y="0" width="1" height="${height}"/>`
216 : ''
217 pictures.push({
218 source:
219 `<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" height="${height}">` +
220 STYLE +
221 body +
222 halves +
223 '</svg>',
224 width,
225 height,
226 })
227 body = left = right = ''
228 height = 0
229 }
230
231 for (const row of rows) {
232 // The row's markup at `y`: for the whole picture, the left and the right half.
233 const draw = (y: number): [string, string, string, number] => {
234 if (row.kind === 'gap') return [gapMarkup(row, options.isSplit ? half : unified, y), '', '', ROW_PX]
235 if (row.kind === 'pair') {
236 const leftRows = row.left !== undefined ? rowsOfLine(row.left, half, options.wrap, language) : []
237 const rightRows = row.right !== undefined ? rowsOfLine(row.right, half, options.wrap, language) : []
238 const tall = Math.max(1, leftRows.length, rightRows.length) * ROW_PX
239 const one = (line: Line | undefined, lines: Run[][], number: number | undefined) =>
240 line === undefined ? rect('re', 0, y, '100%', tall) : lineMarkup(half, line, [number], lines, y, tall)
241 return ['', one(row.left, leftRows, row.left?.old), one(row.right, rightRows, row.right?.new), tall]
242 }
243 const lines = rowsOfLine(row, unified, options.wrap, language)
244 const tall = Math.max(1, lines.length) * ROW_PX
245 const numbers = options.isNew ? [row.new] : [row.old, row.new]
246 return [lineMarkup(unified, row, numbers, lines, y, tall), '', '', tall]
247 }
248 let drawn = draw(height)
249 const size = drawn[0].length + drawn[1].length + drawn[2].length
250 if (height > 0 && body.length + left.length + right.length + size > MAX_SOURCE_CHARS) {
251 close()
252 drawn = draw(0)
253 }
254 body += drawn[0]
255 left += drawn[1]
256 right += drawn[2]
257 height += drawn[3]
258 }
259 close()
260 return pictures
261}
262hooks/syntax.ts 187 lines1// A small line-at-a-time highlighter for the diff picture: keywords,
2// strings, comments, numbers and constants, function and type names. It
3// knows a handful of language families by file extension and reads each
4// line on its own, so a string or comment spanning lines is only guessed.
5
6export type TokenClass = 'keyword' | 'string' | 'comment' | 'constant' | 'function' | 'type'
7
8export type Token = { from: number; to: number; kind: TokenClass }
9
10type Language = {
11 keywords: ReadonlySet<string>
12 constants: ReadonlySet<string>
13 lineComments: readonly string[]
14 blockComment?: readonly [string, string]
15 quotes: string
16 isCaseBlind?: boolean
17}
18
19const words = (text: string): ReadonlySet<string> => new Set(text.split(/\s+/).filter(Boolean))
20
21// A keyword that names what the next word declares.
22const NAMES_TYPE = words('class interface type struct enum trait impl union module namespace')
23const NAMES_FUNCTION = words('function def func fn fun')
24
25const C_LIKE: Language = {
26 keywords: words(`abstract as async await break case catch chan char class const continue
27 debugger declare default defer delete do double else enum export extends extern final finally
28 float fn for from func function go goto if impl implements import in instanceof int interface is
29 keyof let long loop map match mod move mut namespace new of override package private protected
30 pub public range readonly return satisfies select short signed sizeof static struct super switch
31 this throw throws trait try type typedef typeof union unsafe unsigned use using var virtual void
32 volatile when where while with yield val fun object sealed data open lateinit`),
33 constants: words('true false null nil undefined NaN Infinity None Some Ok Err self Self iota'),
34 lineComments: ['//'],
35 blockComment: ['/*', '*/'],
36 quotes: `'"\``,
37}
38
39const PYTHON: Language = {
40 keywords: words(`and as assert async await break class continue def del elif else except
41 finally for from global if import in is lambda nonlocal not or pass raise return try while with
42 yield match case print`),
43 constants: words('True False None self cls'),
44 lineComments: ['#'],
45 quotes: `'"`,
46}
47
48const SHELL: Language = {
49 keywords: words(`if then else elif fi for while until do done case esac function in return local
50 export readonly declare source echo exit set unset shift`),
51 constants: words('true false'),
52 lineComments: ['#'],
53 quotes: `'"\``,
54}
55
56const RUBY: Language = {
57 keywords: words(`def end if elsif else unless while until for in do return class module begin
58 rescue ensure require require_relative yield then case when attr_reader attr_accessor private`),
59 constants: words('nil true false self'),
60 lineComments: ['#'],
61 quotes: `'"`,
62}
63
64const DATA: Language = {
65 keywords: words(''),
66 constants: words('true false null yes no on off'),
67 lineComments: ['#'],
68 quotes: `'"`,
69}
70
71const JSON_LIKE: Language = { ...DATA, lineComments: [] }
72
73const CSS: Language = {
74 keywords: words('import media supports keyframes font-face from to important'),
75 constants: words(''),
76 lineComments: [],
77 blockComment: ['/*', '*/'],
78 quotes: `'"`,
79}
80
81const SQL: Language = {
82 keywords: words(`select from where join left right inner outer on and or not insert into values
83 update set delete create table alter drop index primary key foreign references as group by
84 order having limit offset distinct union all case when then else end begin commit rollback
85 with returning if exists`),
86 constants: words('null true false'),
87 lineComments: ['--'],
88 blockComment: ['/*', '*/'],
89 quotes: `'"`,
90 isCaseBlind: true,
91}
92
93const BY_EXTENSION: Record<string, Language> = {
94 ts: C_LIKE, tsx: C_LIKE, mts: C_LIKE, cts: C_LIKE, js: C_LIKE, jsx: C_LIKE, mjs: C_LIKE, cjs: C_LIKE,
95 go: C_LIKE, rs: C_LIKE, java: C_LIKE, kt: C_LIKE, kts: C_LIKE, scala: C_LIKE, swift: C_LIKE,
96 c: C_LIKE, h: C_LIKE, cc: C_LIKE, cpp: C_LIKE, hpp: C_LIKE, cs: C_LIKE, dart: C_LIKE, php: C_LIKE,
97 py: PYTHON, pyi: PYTHON,
98 sh: SHELL, bash: SHELL, zsh: SHELL, fish: SHELL,
99 rb: RUBY, rake: RUBY,
100 yml: DATA, yaml: DATA, toml: DATA, ini: DATA, cfg: DATA, conf: DATA, env: DATA,
101 json: JSON_LIKE, jsonc: C_LIKE, json5: C_LIKE,
102 css: CSS, scss: { ...CSS, lineComments: ['//'] }, less: { ...CSS, lineComments: ['//'] },
103 sql: SQL,
104}
105
106const BY_NAME: Record<string, Language> = {
107 Dockerfile: SHELL, Makefile: SHELL, Gemfile: RUBY, Rakefile: RUBY, '.bashrc': SHELL, '.zshrc': SHELL,
108}
109
110export function languageOf(path: string): Language | undefined {
111 const name = path.slice(path.lastIndexOf('/') + 1).replace(/#.*$/, '')
112 const byName = BY_NAME[name]
113 if (byName !== undefined) return byName
114 const dot = name.lastIndexOf('.')
115 return dot < 0 ? undefined : BY_EXTENSION[name.slice(dot + 1).toLowerCase()]
116}
117
118const isWordStart = (ch: string) => /[A-Za-z_$]/.test(ch)
119const isWord = (ch: string) => /[\w$]/.test(ch)
120
121// The tokens of one line, in order, none overlapping.
122export function tokensOf(text: string, language: Language | undefined): Token[] {
123 if (language === undefined) return []
124 const tokens: Token[] = []
125 const trimmed = text.trimStart()
126 // A line inside a block comment, as most code writes one.
127 if (language.blockComment !== undefined && /^(\*|\/\*|\*\/)/.test(trimmed)) {
128 return [{ from: text.length - trimmed.length, to: text.length, kind: 'comment' }]
129 }
130 let names: TokenClass | undefined
131 let at = 0
132 while (at < text.length) {
133 const ch = text[at]!
134 const block = language.blockComment
135 if (block !== undefined && text.startsWith(block[0], at)) {
136 const end = text.indexOf(block[1], at + block[0].length)
137 const to = end < 0 ? text.length : end + block[1].length
138 tokens.push({ from: at, to, kind: 'comment' })
139 at = to
140 continue
141 }
142 if (language.lineComments.some(mark => text.startsWith(mark, at) && (mark !== '#' || at === 0 || /\s/.test(text[at - 1]!)))) {
143 tokens.push({ from: at, to: text.length, kind: 'comment' })
144 break
145 }
146 if (language.quotes.includes(ch)) {
147 let to = at + 1
148 while (to < text.length && text[to] !== ch) to += text[to] === '\\' ? 2 : 1
149 to = Math.min(text.length, to + 1)
150 tokens.push({ from: at, to, kind: 'string' })
151 at = to
152 continue
153 }
154 if (/\d/.test(ch) && (at === 0 || !isWord(text[at - 1]!))) {
155 const match = /^(0[xX][\da-fA-F_]+|\d[\d_]*(\.\d+)?([eE][+-]?\d+)?)/.exec(text.slice(at))
156 const to = at + (match?.[0].length ?? 1)
157 tokens.push({ from: at, to, kind: 'constant' })
158 at = to
159 continue
160 }
161 if (isWordStart(ch)) {
162 let to = at + 1
163 while (to < text.length && isWord(text[to]!)) to++
164 const word = text.slice(at, to)
165 const key = language.isCaseBlind ? word.toLowerCase() : word
166 let next = to
167 while (next < text.length && text[next] === ' ') next++
168 if (language.keywords.has(key)) {
169 tokens.push({ from: at, to, kind: 'keyword' })
170 names = NAMES_TYPE.has(key) ? 'type' : NAMES_FUNCTION.has(key) ? 'function' : undefined
171 } else if (language.constants.has(key)) {
172 tokens.push({ from: at, to, kind: 'constant' })
173 names = undefined
174 } else if (names !== undefined) {
175 tokens.push({ from: at, to, kind: names })
176 names = undefined
177 } else if (text[next] === '(') {
178 tokens.push({ from: at, to, kind: 'function' })
179 }
180 at = to
181 continue
182 }
183 at++
184 }
185 return tokens
186}
187types/index.d.ts 65 lines1// The values last-turn-diff keeps in $.state for the session.
2
3// One file (or one notebook cell) as a turn's edits left it.
4export type FileDiff = {
5 // What groups the edits: the absolute path, plus `#<cell>` for a notebook cell.
6 key: string
7 // The path as drawn, relative to the session's directory when inside it.
8 name: string
9 // The path the Code element infers a language from.
10 path: string
11 isNew: boolean
12 added: number
13 removed: number
14 // Unified-diff hunks, each its `@@` header and body.
15 hunks: string[]
16 // Something the list says beside the counts ("too large to diff").
17 note?: string
18}
19
20// The file edits of one turn.
21export type TurnDiff = {
22 // The turn's number in the session, from 1 (drawn T<n>).
23 n: number
24 // The prompt's first words.
25 title: string
26 files: FileDiff[]
27 added: number
28 removed: number
29}
30
31// Where the pane stands.
32export type PaneView = {
33 // Which turn, as an index into `turns` (0 the newest).
34 at: number
35 // Collapsed files, by key (every file starts open).
36 toggled: string[]
37 // Each file's page of rows, by key; 0 when absent.
38 pages: Record<string, number>
39 // Lines interleaved (unified) or old beside new (split); unified when absent.
40 mode?: DiffMode
41 // Whether long lines wrap; they do when absent.
42 wrap?: boolean
43 // The file shown or last jumped to, as an index into the turn's files.
44 file?: number
45}
46
47export type DiffMode = 'unified' | 'split'
48
49// The one-line hint above the prompt.
50export type BandHint = { n: number; files: number; added: number; removed: number }
51
52declare module 'claude-code' {
53 interface PluginState {
54 'last-turn-diff': {
55 // Newest first.
56 turns: TurnDiff[]
57 view: PaneView
58 // Null when the last turn edited nothing.
59 band: BandHint | null
60 // How many turns the session has had.
61 turnNo: number
62 }
63 }
64}
65