SLOPSHOPPER

last-turn-diff

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).

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-15RomuloDrumond/claude-last-turn-diff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · last-turn-diff
│ ┃ Last turn diff ✕ › fix the failing auth test and add an audit log call │ ┃ No file edits recorded in this session yet. │ ┃ Edits made through Edit, Write and ⏺ Read(src/auth.ts) │ ┃ NotebookEdit show here after each turn. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Refresh ] ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /lastturn │ ⎿ last-turn-diff: No file edits recorded in this session yet. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Last turn diff
No file edits recorded in this session yet. Edits made through Edit, Write and NotebookEdit show here after each turn. [ Refresh ]
README

last-turn-diff

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.

Version Claude Code 2.1.286+ Desktop and terminal MIT

<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">

Install

/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.

What you get

  • One card per file, GitHub style: old and new line numbers, green and red rows, the changed words marked inside a reworded line, and ⋯ 23 unchanged lines where the diff skips ahead.
  • Every turn, not just the last. Step back through earlier turns with the arrows or the turn picker. Each is labelled with the first words of its prompt.
  • Split or unified, wrapped or cut, collapsed or expanded. Your split and wrap choices are remembered across sessions.
  • Made for Desktop. Each file is drawn as a crisp SVG with syntax colours that follow your light or dark theme, plus file icons, diffstat squares and a New badge.
  • Works in the terminal too, with the same pane drawn in your theme's diff colours, with hotkeys for its buttons.
  • No git required. Diffs come from Claude's own Edit, Write and NotebookEdit calls, so you see only what Claude did, even in a dirty working tree or outside a repository.

Split view

<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">

A hint after every turn with edits

<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.

In the terminal

<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">

Controls

ButtonTerminal keyWhat it does
← → and the turn pickerp nStep through earlier turns with edits
Split view / Unified viewsOld 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 lineswCut long lines at the pane's edge, or wrap them
Collapse all / Expand allaFold every card. Click a card's path to fold just that one.
Jump to fileOpen a file and scroll to its card
RefreshrRead the transcript again and add any turn with edits that wasn't recorded live
Previous file / Next filek jShown 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.

Limits

  • Only Edit, Write and NotebookEdit calls are recorded. The mod doesn't see changes made through the shell (sed, formatters, git checkout, code generators), MCP tools, other plugins, or you.
  • Other changes can mix into the diff. If something else changes a file between two of Claude's edits to it in the same turn, that change also appears in the file's diff for that turn.
  • Subagent edits are lost on a reload. They are recorded while the session runs, but the main transcript doesn't store them, so they can't be read back.
  • The Desktop diff is an image. Its text can't be selected or copied, and a string or comment that spans lines is coloured line by line, so only its first line may be coloured right.
  • Notebook edits are shown per cell. The diff covers the cell source only, not the notebook JSON.
  • Size caps: the 30 newest turns with edits are kept; a file over 2 MB is listed without a diff; each file keeps about 150,000 characters of hunks, and each turn about 600,000.
  • A 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.
  • Edits are grouped by turn, from 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.
  • When a session starts, after /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.
  • The terminal pane draws 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.
  • A 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.
  • The picture is as wide as the card, drawn 1:1. The mod estimates the card's width from the pane's 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.

Credits

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.

Source 10 files
hooks/register.tsx 295 lines
1// 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}
295
hooks/icons.ts 111 lines
1// 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}
111
hooks/state.ts 12 lines
1// 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'
12
hooks/turns.ts 281 lines
1import 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}
281
hooks/view.tsx 546 lines
1// 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}
546
hooks/diff.ts 175 lines
1// 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}
175
hooks/rows.ts 161 lines
1// 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}
161
hooks/svg.ts 262 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
262
hooks/syntax.ts 187 lines
1// 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}
187
types/index.d.ts 65 lines
1// 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