SLOPSHOPPER

tdiff

/tdiff: a live pane of every file Claude changed this session, with net diffs that survive resume.

newpaneguardcommandprocesstimer
v0.3.0MITupdated 2026-10-26shalom2552/agent-skills/mods/tdiff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tdiff
│ ┃ Tdiff ✕ › fix the failing auth test and add an audit log call │ ┃ No changes yet. │ ┃ ⏺ Read(src/auth.ts) │ ┃ Shell tracking limited: snapshots failed. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tdiff │ ⎿ tdiff: Tdiff opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tdiff
No changes yet. Shell tracking limited: snapshots failed.
README

Agent Skills

Skills and mods for Claude-Code.

Install

curl -fsSL shalom2552.github.io/agent-skills/install | bash

Or clone and run ./install. See ./install --help for options.

Skills

SkillDescription
docs-syncFind and fix drift between docs and code.
dotconfAnswer, change, and remember dotfile and app configs.
read-onlyLock the session to read-only; change nothing.
setup-sh25-skillsInstall, manage and wire up shalom2552's agent-skills assets.
system-auditRead-only health, security and cleanliness audit of this machine.
system-fixDiagnose, fix, and document a system or config fault on this machine.
to-promptWrite a handoff prompt for a fresh agent.
visualizeTurn any subject into a visual HTML page.
walk-with-meLearn languages and idioms by coding with a coach.

Mods

Claude Code plugins.

/plugin install <mod> --marketplace shalom2552/agent-skills
ModDescription
spinner-wordsSwap the spinner verbs for dev ones.
tape/tape pane of tool calls per turn.
frameRounded frame around your prompts.
beatTurn time and usage as pills.
statuslineBottom status line for claude.
badgesBadge above the prompt for active skill.
tdiffLive diff of session changes.

Settings

Small JSON files that ./install merges into the scope's settings.json.

./install settings --scope global -y
SettingDescription
tmux-truecolorTruecolor inside tmux, so mod hex colors render right.
Source 10 files
hooks/register.tsx 16 lines
1import type { Register } from 'claude-code'
2
3import { registerCore, startCore } from './core'
4import { registerPane, startPane } from './pane'
5
6export const register: Register = on => {
7  on('session.start', async ($, e, next) => {
8    const started = await next(e)
9    await startPane($).catch(() => undefined)
10    await startCore($)
11    return started
12  })
13  registerPane(on)
14  registerCore(on)
15}
16
hooks/core.ts 573 lines
1import { atom, read, update } from 'claude-code'
2import type { CoreEngineInterface, On } from 'claude-code'
3
4import type { TdiffKind, TdiffNoText, TdiffOpen, TdiffRow, TdiffShell } from '../types'
5import { diffText } from './diff'
6import { sha } from './hash'
7import { commandPaths } from './parse'
8import { isUnder, normalize, resolve } from './paths'
9import { blob, blobIdOf, changes, guard, prime, shadowFor, ShellOff, snapshot } from './shell'
10import type { Shadow } from './shell'
11
12export type Engine = Pick<CoreEngineInterface, 'state' | 'fs' | 'session' | 'clock' | 'env' | 'process'>
13
14type Snap = { state: 'absent' } | { state: 'text' | 'binary' | 'too-large'; sig: string }
15type Stat = { mtimeMs: number; size: number }
16type Seen = { snap: Snap; stat: Stat | null; text: string | null }
17
18type FileRecord = {
19  path: string
20  hash: string
21  base: Snap
22  latest: Snap | null
23  latestStat: Stat | null
24  adds: number
25  dels: number
26  changedAt: number
27  isEditedSince: boolean
28}
29
30type Live = {
31  id?: string
32  dir?: string
33  cacheDir?: string
34  isShellSlow: boolean
35  root: string
36  home: string
37  files: Map<string, FileRecord>
38  inflight: Map<string, number>
39  queue: Promise<unknown>
40  shadow?: Shadow
41  shellQueue: Promise<unknown>
42  primed: Promise<boolean>
43}
44
45type BaseLoader = () => Promise<Seen>
46type Ticket = { isCancelled: boolean; counted: string[] }
47type Waiter = { readonly signal: AbortSignal; readonly budget: { readonly remainingMs: number } }
48
49const rows = atom({ plugin: 'tdiff', key: 'rows' } as const, [], { shape: 'rows-1' })
50const open = atom({ plugin: 'tdiff', key: 'open' } as const, null, { shape: 'open-1' })
51const shell = atom(
52  { plugin: 'tdiff', key: 'shell' } as const,
53  { status: 'starting', reason: null },
54  { shape: 'shell-1' },
55)
56const where = atom({ plugin: 'tdiff', key: 'where' } as const, { root: '/', home: '' }, { shape: 'where-1' })
57
58const LEDGER_SHAPE = 'tdiff-ledger-1'
59const READ_CAP = 4 * 1024 * 1024
60const ABSENT: Seen = { snap: { state: 'absent' }, stat: null, text: null }
61export const MAX_WAIT_MS = 8_000
62const BUDGET_MARGIN_MS = 1_500
63export const SWITCH_POLL_MS = 200
64const SWITCH_POLLS = 25
65
66const live: Live = {
67  isShellSlow: false,
68  root: '/',
69  home: '',
70  files: new Map(),
71  inflight: new Map(),
72  queue: Promise.resolve(),
73  shellQueue: Promise.resolve(),
74  primed: Promise.resolve(false),
75}
76
77const noop = () => undefined
78
79function enqueue<T>(task: () => Promise<T>): Promise<T> {
80  const run = live.queue.then(task)
81  live.queue = run.catch(noop)
82  return run
83}
84
85function enqueueShell<T>(task: () => Promise<T>): Promise<T> {
86  const run = live.shellQueue.then(task)
87  live.shellQueue = run.catch(noop)
88  return run
89}
90
91async function within<T>($: Engine, next: Waiter, work: Promise<T>): Promise<{ value: T } | undefined> {
92  const settled = work.then(
93    value => ({ value }),
94    () => undefined,
95  )
96  const left = Math.min(MAX_WAIT_MS, next.budget.remainingMs - BUDGET_MARGIN_MS)
97  if (next.signal.aborted || !(left > 0)) return undefined
98  const stop = new AbortController()
99  const timeout = $.clock.sleep(left, { signal: stop.signal }).then(noop, noop)
100  try {
101    return await Promise.race([settled, timeout])
102  } finally {
103    stop.abort()
104  }
105}
106
107const isTextLike = (text: string) => !text.includes('\0') && !text.includes('�')
108
109async function binarySig($: Engine, path: string, stat: Stat) {
110  const shadow = live.shadow
111  const id = shadow === undefined ? undefined : await blobIdOf($, shadow, path).catch(() => undefined)
112  if (id !== undefined) return `git:${id}`
113  const bytes = await $.fs.read(path, { as: 'bytes' }).catch(() => undefined)
114  return bytes === undefined ? `${stat.size}:${stat.mtimeMs}` : `sha:${await sha(bytes.base64)}`
115}
116
117async function readDisk($: Engine, path: string): Promise<Seen> {
118  const stat = await $.fs.stat(path).catch(() => undefined)
119  if (stat === undefined || stat.kind !== 'file') return ABSENT
120  const at: Stat = { mtimeMs: stat.mtimeMs, size: stat.size }
121  if (stat.size > READ_CAP) return { snap: { state: 'too-large', sig: `${stat.size}:${stat.mtimeMs}` }, stat: at, text: null }
122  const text = await $.fs.read(path).catch(() => undefined)
123  if (text !== undefined && isTextLike(text)) return { snap: { state: 'text', sig: await sha(text) }, stat: at, text }
124  return { snap: { state: 'binary', sig: await binarySig($, path, at) }, stat: at, text: null }
125}
126
127const sameSnap = (a: Snap, b: Snap) => a.state === b.state && (a.state === 'absent' || a.sig === (b as { sig: string }).sig)
128const isChanged = (rec: FileRecord): rec is FileRecord & { latest: Snap } => rec.latest !== null && !sameSnap(rec.base, rec.latest)
129
130function noTextOf(rec: FileRecord): TdiffNoText | null {
131  const states = [rec.base.state, rec.latest?.state]
132  if (states.includes('too-large')) return 'too-large'
133  if (states.includes('binary')) return 'binary'
134  return null
135}
136
137function kindOf(rec: FileRecord): TdiffKind {
138  if (rec.base.state === 'absent') return 'added'
139  if (rec.latest?.state === 'absent') return 'deleted'
140  return 'modified'
141}
142
143const snapFile = (kind: 'base' | 'latest', rec: FileRecord) => `${live.dir}/${kind}/${rec.hash}`
144
145async function snapText($: Engine, kind: 'base' | 'latest', rec: FileRecord) {
146  const snap = kind === 'base' ? rec.base : rec.latest
147  if (snap?.state !== 'text') return ''
148  return $.fs.read(snapFile(kind, rec)).catch(() => '')
149}
150
151function toRows(): TdiffRow[] {
152  return [...live.files.values()]
153    .filter(isChanged)
154    .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
155    .map(rec => ({
156      path: rec.path,
157      kind: kindOf(rec),
158      adds: rec.adds,
159      dels: rec.dels,
160      noText: noTextOf(rec),
161      changedAt: rec.changedAt,
162      isEditedSince: rec.isEditedSince,
163    }))
164}
165
166async function diffOf($: Engine, path: string): Promise<TdiffOpen> {
167  const rec = live.files.get(path)
168  if (rec === undefined || !isChanged(rec)) return { path, diff: null, note: 'unchanged' }
169  const noText = noTextOf(rec)
170  if (noText !== null) return { path, diff: null, note: noText }
171  return { path, diff: diffText(await snapText($, 'base', rec), await snapText($, 'latest', rec)), note: null }
172}
173
174async function save($: Engine) {
175  if (live.dir === undefined) return
176  const files = [...live.files.values()]
177  await $.fs.write(`${live.dir}/ledger.json`, JSON.stringify({ shape: LEDGER_SHAPE, isShellSlow: live.isShellSlow, files }))
178}
179
180async function publish($: Engine, changed: readonly string[]) {
181  await update($, rows, () => toRows())
182  const current = await read($, open)
183  if (current !== null && changed.includes(current.path)) {
184    const next = await diffOf($, current.path)
185    await update($, open, () => next)
186  }
187}
188
189async function setShell($: Engine, next: TdiffShell) {
190  await update($, shell, () => next)
191}
192
193function isRecord(value: unknown): value is FileRecord {
194  if (typeof value !== 'object' || value === null) return false
195  const rec = value as Partial<FileRecord>
196  return typeof rec.path === 'string' && typeof rec.hash === 'string' && typeof rec.base === 'object'
197}
198
199type Ledger = { shape?: unknown; isShellSlow?: unknown; files?: unknown }
200
201async function load($: Engine, dir: string) {
202  const files = new Map<string, FileRecord>()
203  const text = await $.fs.read(`${dir}/ledger.json`).catch(() => undefined)
204  if (text === undefined) return { files, isShellSlow: false }
205  try {
206    const parsed = JSON.parse(text) as Ledger
207    if (parsed.shape !== LEDGER_SHAPE || !Array.isArray(parsed.files)) return { files, isShellSlow: false }
208    for (const rec of parsed.files) if (isRecord(rec)) files.set(rec.path, rec)
209    return { files, isShellSlow: parsed.isShellSlow === true }
210  } catch {
211    return { files, isShellSlow: false }
212  }
213}
214
215const safeId = (id: string) => id.replace(/[^A-Za-z0-9._-]/g, '_')
216
217async function adopt($: Engine, id: string) {
218  const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
219  const cache = (await $.env.get('XDG_CACHE_HOME')) || (home === '' ? '' : `${home}/.cache`)
220  live.id = id
221  live.home = home === '' ? '' : normalize(home)
222  live.root = normalize(await $.session.root())
223  live.cacheDir = cache === '' ? undefined : `${normalize(cache)}/claude-tdiff`
224  live.dir = live.cacheDir === undefined ? undefined : `${live.cacheDir}/${safeId(id)}`
225  const loaded = live.dir === undefined ? { files: new Map<string, FileRecord>(), isShellSlow: false } : await load($, live.dir)
226  live.files = loaded.files
227  live.isShellSlow = loaded.isShellSlow
228  live.inflight = new Map()
229  const isSaved = await save($).then(
230    () => true,
231    () => false,
232  )
233
234  await update($, where, () => ({ root: live.root, home: live.home }))
235  await update($, rows, () => toRows())
236  const current = await read($, open)
237  const reopened = current !== null && live.files.has(current.path) ? await diffOf($, current.path) : null
238  await update($, open, () => reopened)
239  await startShell($, isSaved)
240}
241
242async function startShell($: Engine, hasSessionDir: boolean) {
243  const reason = live.isShellSlow ? 'too-slow' : guard(live.root, live.home)
244  const { dir, cacheDir } = live
245  live.shadow = undefined
246  if (reason !== null || dir === undefined || cacheDir === undefined || !hasSessionDir) {
247    live.primed = Promise.resolve(false)
248    await setShell($, { status: 'limited', reason: reason ?? 'failed' })
249    return
250  }
251  const shadow = await shadowFor(cacheDir, dir, live.root)
252  live.shadow = shadow
253  await setShell($, { status: 'starting', reason: null })
254  live.primed = enqueueShell(async () => {
255    try {
256      await prime($, shadow)
257      if (live.shadow === shadow) await setShell($, { status: 'on', reason: null })
258      return true
259    } catch (error) {
260      await shellFailed($, shadow, error)
261      return false
262    }
263  })
264}
265
266async function shellFailed($: Engine, shadow: Shadow, error: unknown) {
267  if (live.shadow !== shadow) return
268  live.shadow = undefined
269  live.primed = Promise.resolve(false)
270  const reason = error instanceof ShellOff ? error.reason : 'failed'
271  if (reason === 'too-slow') {
272    live.isShellSlow = true
273    await enqueue(() => save($)).catch(noop)
274  }
275  await setShell($, { status: 'limited', reason }).catch(noop)
276}
277
278async function sync($: Engine) {
279  const id = await $.session.id()
280  if (id !== live.id) await adopt($, id)
281}
282
283export function ensure($: Engine) {
284  return enqueue(() => sync($))
285}
286
287type Touch = { path: string; load?: BaseLoader }
288
289const newTicket = (): Ticket => ({ isCancelled: false, counted: [] })
290
291function touch($: Engine, list: readonly Touch[], ticket: Ticket) {
292  return enqueue(async () => {
293    if (ticket.isCancelled) return undefined
294    await sync($)
295    for (const { path } of list) {
296      live.inflight.set(path, (live.inflight.get(path) ?? 0) + 1)
297      ticket.counted.push(path)
298    }
299    let isFresh = false
300    for (const { path, load } of list) {
301      if (live.files.has(path) || live.dir === undefined) continue
302      try {
303        const seen = await (load ?? (() => readDisk($, path)))()
304        const rec: FileRecord = {
305          path,
306          hash: (await sha(path)).slice(0, 40),
307          base: seen.snap,
308          latest: null,
309          latestStat: null,
310          adds: 0,
311          dels: 0,
312          changedAt: 0,
313          isEditedSince: false,
314        }
315        if (seen.text !== null) await $.fs.write(snapFile('base', rec), seen.text)
316        live.files.set(path, rec)
317        isFresh = true
318      } catch {
319        continue
320      }
321    }
322    if (isFresh) await save($).catch(noop)
323    return live.id
324  })
325}
326
327function releaseAll(ticket: Ticket) {
328  const released = new Set<string>()
329  for (const path of ticket.counted.splice(0)) {
330    const left = (live.inflight.get(path) ?? 1) - 1
331    if (left <= 0) {
332      live.inflight.delete(path)
333      released.add(path)
334    } else {
335      live.inflight.set(path, left)
336    }
337  }
338  return released
339}
340
341function forget($: Engine, ticket: Ticket, sessionId: string | undefined) {
342  return enqueue(async () => {
343    const released = releaseAll(ticket)
344    if (sessionId === undefined || sessionId !== live.id) return
345    let isDropped = false
346    for (const path of released) {
347      const rec = live.files.get(path)
348      if (rec !== undefined && rec.latest === null) {
349        live.files.delete(path)
350        isDropped = true
351      }
352    }
353    if (isDropped) await save($)
354  })
355}
356
357function settle($: Engine, paths: readonly string[], ticket: Ticket, sessionId: string | undefined) {
358  return enqueue(async () => {
359    releaseAll(ticket)
360    if (sessionId === undefined || sessionId !== live.id) return
361    const changed: string[] = []
362    for (const path of new Set(paths)) {
363      const rec = live.files.get(path)
364      if (rec === undefined) continue
365      const wasChanged = isChanged(rec)
366      const seen = await readDisk($, path)
367      if (rec.latest !== null && sameSnap(rec.latest, seen.snap)) {
368        rec.latestStat = seen.stat
369        continue
370      }
371      rec.latest = seen.snap
372      rec.latestStat = seen.stat
373      rec.isEditedSince = false
374      if (seen.text !== null) await $.fs.write(snapFile('latest', rec), seen.text)
375      if (isChanged(rec)) {
376        const counted = noTextOf(rec) === null ? diffText(await snapText($, 'base', rec), seen.text ?? '') : null
377        rec.adds = counted?.adds ?? 0
378        rec.dels = counted?.dels ?? 0
379        rec.changedAt = await $.clock.now()
380      } else {
381        rec.adds = 0
382        rec.dels = 0
383      }
384      if (wasChanged || isChanged(rec)) changed.push(path)
385    }
386    if (changed.length === 0) return
387    await save($)
388    await publish($, changed)
389  })
390}
391
392async function isEdited($: Engine, rec: FileRecord & { latest: Snap }) {
393  const stat = await $.fs.stat(rec.path).catch(() => undefined)
394  const isFile = stat !== undefined && stat.kind === 'file'
395  if (rec.latest.state === 'absent') return isFile
396  if (!isFile) return true
397  if (rec.latestStat !== null && stat.mtimeMs === rec.latestStat.mtimeMs && stat.size === rec.latestStat.size) return false
398  const seen = await readDisk($, rec.path)
399  if (!sameSnap(seen.snap, rec.latest)) return true
400  rec.latestStat = seen.stat
401  return false
402}
403
404export function recheckEdited($: Engine) {
405  return enqueue(async () => {
406    await sync($)
407    let isAny = false
408    for (const rec of live.files.values()) {
409      if (!isChanged(rec)) continue
410      const isNow = await isEdited($, rec).catch(() => rec.isEditedSince)
411      if (isNow !== rec.isEditedSince) {
412        rec.isEditedSince = isNow
413        isAny = true
414      }
415    }
416    if (!isAny) return
417    await save($)
418    await update($, rows, () => toRows())
419  })
420}
421
422export function openFile($: Engine, path: string) {
423  return enqueue(async () => {
424    await sync($)
425    const next = await diffOf($, path)
426    await update($, open, () => next)
427  })
428}
429
430export function closeFile($: Engine) {
431  return enqueue(async () => {
432    await update($, open, () => null)
433  })
434}
435
436async function shellBefore($: Engine, shadow: Shadow, ticket: Ticket) {
437  if (!(await live.primed) || live.shadow !== shadow) return undefined
438  return enqueueShell(async () => (ticket.isCancelled ? undefined : snapshot($, shadow))).catch(async error => {
439    await shellFailed($, shadow, error)
440    return undefined
441  })
442}
443
444async function shellAfter($: Engine, shadow: Shadow, before: string) {
445  return enqueueShell(async () => {
446    const after = await snapshot($, shadow)
447    return changes($, shadow, before, after)
448  }).catch(async error => {
449    await shellFailed($, shadow, error)
450    return []
451  })
452}
453
454function blobLoader($: Engine, shadow: Shadow, id: string | null): BaseLoader {
455  if (id === null) return async () => ABSENT
456  return async () => {
457    const read = await blob($, shadow, id)
458    if (read.isTruncated) return { snap: { state: 'too-large', sig: `git:${id}` }, stat: null, text: null }
459    if (!isTextLike(read.text)) return { snap: { state: 'binary', sig: `git:${id}` }, stat: null, text: null }
460    return { snap: { state: 'text', sig: await sha(read.text) }, stat: null, text: read.text }
461  }
462}
463
464async function onFileCall<R extends { deny?: string }>($: Engine, next: Waiter, path: string, run: () => Promise<R>) {
465  const ticket = newTicket()
466  const touched = await within($, next, touch($, [{ path }], ticket))
467  if (touched === undefined) ticket.isCancelled = true
468  const sessionId = touched?.value
469  let ran: R | undefined
470  try {
471    ran = await run()
472    return ran
473  } finally {
474    if (ticket.isCancelled) void forget($, ticket, sessionId).catch(noop)
475    else if (ran === undefined || ran.deny !== undefined) await within($, next, forget($, ticket, sessionId))
476    else await within($, next, settle($, [path], ticket, sessionId))
477  }
478}
479
480type Prepared = { shadow?: Shadow; before?: string; extra: string[]; sessionId?: string }
481
482async function prepareBash($: Engine, command: string, ticket: Ticket): Promise<Prepared> {
483  const cwd = normalize(await $.session.cwd())
484  await ensure($)
485  const shadow = live.shadow
486  const before = shadow === undefined ? undefined : await shellBefore($, shadow, ticket)
487  const isShadowed = before !== undefined
488  const extra = commandPaths(command, { cwd, home: live.home }).filter(
489    path => (live.cacheDir === undefined || !isUnder(path, live.cacheDir)) && !(isShadowed && isUnder(path, live.root)),
490  )
491  const sessionId = await touch($, extra.map(path => ({ path })), ticket)
492  return { shadow, before, extra, sessionId }
493}
494
495async function finishBash($: Engine, prepared: Prepared, ticket: Ticket) {
496  const { shadow, before, extra, sessionId } = prepared
497  const found = shadow !== undefined && before !== undefined ? await shellAfter($, shadow, before) : []
498  const inTree = found.filter(change => !extra.includes(change.path))
499  if (shadow !== undefined && inTree.length > 0) {
500    await touch($, inTree.map(change => ({ path: change.path, load: blobLoader($, shadow, change.baseBlob) })), ticket)
501  }
502  await settle($, [...extra, ...inTree.map(change => change.path)], ticket, sessionId)
503}
504
505async function onBash<R extends { deny?: string }>($: Engine, next: Waiter, command: string, run: () => Promise<R>) {
506  const ticket = newTicket()
507  const prepared = await within($, next, prepareBash($, command, ticket))
508  if (prepared === undefined) ticket.isCancelled = true
509  let ran: R | undefined
510  try {
511    ran = await run()
512    return ran
513  } finally {
514    if (prepared === undefined) void forget($, ticket, undefined).catch(noop)
515    else if (ran === undefined || ran.deny !== undefined) await within($, next, forget($, ticket, prepared.value.sessionId))
516    else await within($, next, finishBash($, prepared.value, ticket))
517  }
518}
519
520function followSwitch($: Engine, endedId: string) {
521  let polls = 0
522  const timer = $.clock.every(SWITCH_POLL_MS, () => {
523    polls += 1
524    void $.session
525      .id()
526      .then(async id => {
527        if (id === endedId && polls < SWITCH_POLLS) return
528        timer.cancel()
529        if (id !== endedId) await ensure($)
530      })
531      .catch(noop)
532  })
533}
534
535export async function startCore($: Engine) {
536  await ensure($).catch(noop)
537  await recheckEdited($).catch(noop)
538}
539
540export function registerCore(on: On) {
541  on('session.end', async ($, e, next) => {
542    const ended = await next(e)
543    if (e.reason === 'clear' || e.reason === 'resume') {
544      await ensure($).catch(noop)
545      if (live.id === e.sessionId) followSwitch($, e.sessionId)
546    }
547    return ended
548  })
549
550  on('turn.start', async ($, e, next) => {
551    await ensure($).catch(noop)
552    return next(e)
553  })
554
555  on('turn.complete', async ($, e, next) => {
556    const done = await next(e)
557    await recheckEdited($).catch(noop)
558    return done
559  })
560
561  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
562    const raw = e.tool === 'NotebookEdit' ? e.notebook_path : e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : ''
563    if (typeof raw !== 'string' || raw === '' || next.signal.aborted) return next(e)
564    const cwd = await $.session.cwd().catch(() => live.root)
565    return onFileCall($, next, resolve(cwd, raw), () => next(e))
566  }).catch(($, e, next) => next(e))
567
568  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
569    if (next.signal.aborted) return next(e)
570    return onBash($, next, typeof e.command === 'string' ? e.command : '', () => next(e))
571  }).catch(($, e, next) => next(e))
572}
573
hooks/pane.tsx 342 lines
1import { atom, read, update } from 'claude-code'
2import type { CoreEngineInterface, On } from 'claude-code'
3
4import type { TdiffKind, TdiffNoText, TdiffShellReason } from '../types'
5import { closeFile, openFile } from './core'
6import { displayPath } from './paths'
7import { buildTree, COLLAPSE_OVER, visibleLines } from './tree'
8import type { Line } from './tree'
9
10const PANE = 'tdiff'
11const TITLE = 'Tdiff'
12const ELSEWHERE = 'Elsewhere'
13
14const rows = atom({ plugin: 'tdiff', key: 'rows' } as const, [], { shape: 'rows-1' })
15const open = atom({ plugin: 'tdiff', key: 'open' } as const, null, { shape: 'open-1' })
16const shell = atom(
17  { plugin: 'tdiff', key: 'shell' } as const,
18  { status: 'starting', reason: null },
19  { shape: 'shell-1' },
20)
21const where = atom({ plugin: 'tdiff', key: 'where' } as const, { root: '/', home: '' }, { shape: 'where-1' })
22const folds = atom({ plugin: 'tdiff', key: 'folds' } as const, {}, { shape: 'folds-1' })
23const focused = atom({ plugin: 'tdiff', key: 'focused' } as const, null, { shape: 'focused-1' })
24
25const LETTER: Record<TdiffKind, { letter: string; color: string }> = {
26  added: { letter: 'A', color: 'success' },
27  modified: { letter: 'M', color: 'warning' },
28  deleted: { letter: 'D', color: 'error' },
29}
30
31const NO_TEXT: Record<TdiffNoText | 'unchanged', string> = {
32  binary: 'Binary file: no text diff.',
33  'too-large': 'Too large to read: no text diff.',
34  unchanged: 'No net change left in this file.',
35}
36
37const SHELL_REASON: Record<TdiffShellReason, string> = {
38  'root-is-home': 'the root is the home folder',
39  'root-is-fs-root': 'the root is /',
40  'no-git': 'git is not available',
41  'too-slow': 'snapshots took too long',
42  failed: 'snapshots failed',
43}
44
45const OUTSIDE_COLOR = 'suggestion'
46const FOCUS_COLOR = 'suggestion'
47const ROOT_COLOR = 'claude'
48const EDITED = 'edited since'
49const NO_DIFF = 'no text diff'
50const INDENT = 2
51const GLYPH = 2
52const LETTER_SLOT = 2
53const MIN_COLUMNS = 24
54const MAX_ROWS = 250
55const CHAR_BUDGET = 60_000
56const ROW_COST = 200
57
58const noop = () => undefined
59const width = (text: string) => Array.from(text).length
60const padStart = (text: string, room: number) => ' '.repeat(Math.max(0, room - width(text))) + text
61const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
62
63function middle(text: string, room: number) {
64  const chars = Array.from(text)
65  if (chars.length <= room) return text
66  if (room <= 1) return '…'
67  const head = Math.ceil((room - 1) / 2)
68  return `${chars.slice(0, head).join('')}…${chars.slice(chars.length - (room - 1 - head)).join('')}`
69}
70
71export function startPane($: CoreEngineInterface) {
72  return $.command.register({
73    name: 'tdiff',
74    description: 'Toggle a pane of the files Claude changed this session',
75    immediate: true,
76  })
77}
78
79export function registerPane(on: On) {
80  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit', 'Bash'] }, async ($, e, next) => {
81    const before = (await read($, rows)).length
82    const ran = await next(e)
83    if (before > 0 || (await read($, rows)).length === 0) return ran
84    if (!(await $.ui.panes()).some(pane => pane.id === PANE)) await $.ui.open({ id: PANE, title: TITLE }).catch(noop)
85    return ran
86  }).catch(($, e, next) => next(e))
87
88  on('command.run', { command: 'tdiff' }, async $ => {
89    const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
90    if (isOpen) {
91      await $.ui.close({ id: PANE })
92      return { text: 'Tdiff closed.' }
93    }
94    const opened = await $.ui.open({ id: PANE, title: TITLE })
95    return { text: opened.isPlaced ? 'Tdiff opened.' : `Tdiff is waiting: ${opened.reason}` }
96  })
97
98  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
99    const moved = await next(e)
100    if (moved.deny === undefined) await update($, focused, () => e.element ?? null)
101    return moved
102  })
103
104  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
105    const { Box, Text, Button, Code } = $.ui.resolve(e)
106    const list = await read($, rows)
107    const shown = await read($, open)
108    const place = await read($, where)
109    const tracking = await read($, shell)
110    const folded = await read($, folds)
111    const ring = e.props.isFocused ? await read($, focused) : null
112
113    const ink = (isLit: boolean, color?: string, isDim?: boolean) =>
114      isLit ? { color: FOCUS_COLOR } : { color, dimColor: isDim }
115
116    const tone = (text: string, n: number, color: string, isDim = false, isLit = false) => (
117      <Text {...ink(isLit, isDim || n === 0 ? undefined : color, isDim || n === 0)}>{text}</Text>
118    )
119
120    const counts = (adds: number, dels: number) => (
121      <Text>
122        {tone(`+${adds}`, adds, 'success')} {tone(`-${dels}`, dels, 'error')}
123      </Text>
124    )
125
126    if (shown !== null) {
127      const row = list.find(one => one.path === shown.path)
128      const { isUnderRoot, label } = displayPath(shown.path, place)
129      const adds = shown.diff?.adds ?? row?.adds ?? 0
130      const dels = shown.diff?.dels ?? row?.dels ?? 0
131      const back = {
132        key: 'back',
133        hotkey: 'b',
134        label: 'back',
135        onPress: async () => {
136          await closeFile($)
137          await $.ui.focus({ requestId: PANE, key: shown.path }).catch(noop)
138        },
139      }
140      return (
141        <Box flexDirection="column">
142          <Box flexDirection="row" justifyContent="space-between">
143            {ring === 'back' ? (
144              <Button {...back}>
145                <Text color={FOCUS_COLOR}>back</Text>
146              </Button>
147            ) : (
148              <Button {...back} />
149            )}
150            {counts(adds, dels)}
151          </Box>
152          <Text bold color={isUnderRoot ? undefined : OUTSIDE_COLOR} wrap="truncate-start">
153            {row === undefined ? '' : `${LETTER[row.kind].letter} `}
154            {label}
155          </Text>
156          {row?.isEditedSince === true && <Text color="warning">Edited since Claude's last change.</Text>}
157          {shown.note !== null && <Text dimColor>{NO_TEXT[shown.note]}</Text>}
158          {shown.note === null && shown.diff === null && <Text dimColor>Loading diff…</Text>}
159          {shown.diff !== null && <Code key="diff" source={shown.diff.text} format="diff" path={shown.path} />}
160          {shown.diff?.truncated === true && <Text dimColor>Diff cut short: too long to draw whole.</Text>}
161        </Box>
162      )
163    }
164
165    const footer =
166      tracking.status === 'limited' ? (
167        <Box marginTop={1}>
168          <Text dimColor>Shell tracking limited: {SHELL_REASON[tracking.reason]}.</Text>
169        </Box>
170      ) : null
171
172    if (list.length === 0) {
173      return (
174        <Box flexDirection="column">
175          <Text dimColor>No changes yet.</Text>
176          {footer}
177        </Box>
178      )
179    }
180
181    const labelled = list.map(row => ({ row, ...displayPath(row.path, place) }))
182    const sections = [
183      {
184        key: 'root',
185        heading: displayPath(place.root, place).label,
186        headingColor: ROOT_COLOR,
187        topColor: undefined,
188        lines: visibleLines(buildTree(labelled.filter(one => one.isUnderRoot), 'root:'), folded),
189      },
190      {
191        key: 'elsewhere',
192        heading: ELSEWHERE,
193        headingColor: OUTSIDE_COLOR,
194        topColor: ROOT_COLOR,
195        lines: visibleLines(buildTree(labelled.filter(one => !one.isUnderRoot), 'elsewhere:'), folded),
196      },
197    ].filter(section => section.lines.length > 0)
198
199    let budget = { rows: MAX_ROWS, chars: CHAR_BUDGET }
200    let hidden = 0
201    const drawn = sections.map(section => {
202      const lines = section.lines.filter(line => {
203        const cost = ROW_COST + 2 * width(line.node.kind === 'dir' ? line.node.key : line.node.row.path)
204        if (budget.rows <= 0 || budget.chars < cost) {
205          hidden += 1
206          return false
207        }
208        budget = { rows: budget.rows - 1, chars: budget.chars - cost }
209        return true
210      })
211      return { ...section, lines }
212    })
213
214    const adds = list.reduce((sum, row) => sum + row.adds, 0)
215    const dels = list.reduce((sum, row) => sum + row.dels, 0)
216    const totals = drawn
217      .flatMap(section => section.lines)
218      .flatMap((line): { adds: number; dels: number }[] =>
219        line.node.kind === 'dir'
220          ? 'isCollapsed' in line && line.isCollapsed
221            ? [line.node]
222            : []
223          : line.node.row.noText === null
224            ? [line.node.row]
225            : [],
226      )
227    const addWidth = Math.max(width(`+${adds}`), ...totals.map(one => width(`+${one.adds}`)))
228    const delWidth = Math.max(width(`-${dels}`), ...totals.map(one => width(`-${one.dels}`)))
229    const countWidth = addWidth + 1 + delWidth
230
231    const counted = (adds: number, dels: number, isDim: boolean, isLit: boolean) => (
232      <Text mono {...ink(isLit)}>
233        {' '}
234        {tone(padStart(`+${adds}`, addWidth), adds, 'success', isDim, isLit)}{' '}
235        {tone(padStart(`-${dels}`, delWidth), dels, 'error', isDim, isLit)}
236      </Text>
237    )
238    const fill = (room: number) => ' '.repeat(Math.max(0, room))
239    const columns = Math.max(MIN_COLUMNS, e.props.bodyColumns)
240    const countsRoom = 1 + countWidth
241
242    const toggle = (key: string, count: number) => async () => {
243      await update($, folds, now => ({ ...now, [key]: !(now[key] ?? count > COLLAPSE_OVER) }))
244      await $.ui.focus({ requestId: PANE, key: `dir:${key}` }).catch(noop)
245    }
246
247    const TreeRow = ({ line, topColor }: { line: Line; topColor: string | undefined }) => {
248      const indent = line.depth * INDENT
249      if (line.node.kind === 'dir') {
250        const dir = line.node
251        const isLit = ring === `dir:${dir.key}`
252        const isCollapsed = 'isCollapsed' in line && line.isCollapsed
253        const note = isCollapsed ? ` ${plural(dir.count, 'file')}` : ''
254        const tail = isCollapsed ? countsRoom : 0
255        const name = middle(dir.name, columns - indent - GLYPH - width(note) - tail)
256        return (
257          <Box key={`row:${dir.key}`} flexDirection="row" paddingLeft={indent}>
258            <Button key={`dir:${dir.key}`} plain onPress={() => void toggle(dir.key, dir.count)()}>
259              <Text {...ink(isLit, undefined, true)}>{isCollapsed ? '▸ ' : '▾ '}</Text>
260              <Text {...ink(isLit, line.depth === 0 ? topColor : undefined)} wrap="truncate-middle">
261                {name}
262              </Text>
263              <Text {...ink(isLit, undefined, true)}>
264                {note}
265                {fill(columns - indent - GLYPH - width(name) - width(note) - tail)}
266              </Text>
267              {isCollapsed ? counted(dir.adds, dir.dels, false, isLit) : <Text>{''}</Text>}
268            </Button>
269          </Box>
270        )
271      }
272      const { row } = line.node
273      const kind = LETTER[row.kind]
274      const isLit = ring === row.path
275      const right = (row.isEditedSince ? 1 + width(EDITED) : 0) + (row.noText === null ? countsRoom : 1 + Math.max(countWidth, width(NO_DIFF)))
276      const name = middle(line.node.name, columns - indent - GLYPH - LETTER_SLOT - right)
277      return (
278        <Box key={`row:${row.path}`} flexDirection="row" paddingLeft={indent}>
279          <Button
280            key={row.path}
281            plain
282            onPress={async () => {
283              await openFile($, row.path)
284              await $.ui.focus({ requestId: PANE, key: 'back' }).catch(noop)
285            }}
286          >
287            <Text {...ink(isLit)}>
288              {'  '}
289              <Text {...ink(isLit, kind.color)}>{kind.letter}</Text>{' '}
290            </Text>
291            <Text bold {...ink(isLit)} wrap="truncate-middle">
292              {name}
293            </Text>
294            <Text {...ink(isLit)}>
295              {fill(columns - indent - GLYPH - LETTER_SLOT - width(name) - right)}
296              <Text {...ink(isLit, 'warning')}>{row.isEditedSince ? ` ${EDITED}` : ''}</Text>
297            </Text>
298            {row.noText === null ? (
299              counted(row.adds, row.dels, false, isLit)
300            ) : (
301              <Text mono {...ink(isLit, undefined, true)}>
302                {' '}
303                {padStart(NO_DIFF, countWidth)}
304              </Text>
305            )}
306          </Button>
307        </Box>
308      )
309    }
310
311    return (
312      <Box flexDirection="column" width={e.props.bodyColumns}>
313        <Box flexDirection="row">
314          <Box flexGrow={1} flexShrink={1} minWidth={0}>
315            <Text bold>{plural(list.length, 'file')}</Text>
316          </Box>
317          <Box flexShrink={0}>
318            <Text mono>
319              {' '}
320              {tone(padStart(`+${adds}`, addWidth), adds, 'success')} {tone(padStart(`-${dels}`, delWidth), dels, 'error')}
321            </Text>
322          </Box>
323        </Box>
324        {drawn.map(section => (
325          <Box key={section.key} flexDirection="column" marginTop={1}>
326            <Text bold color={section.headingColor} wrap="truncate-start">
327              {section.heading}
328            </Text>
329            {section.lines.map(line => TreeRow({ line, topColor: section.topColor }))}
330          </Box>
331        ))}
332        {hidden > 0 && (
333          <Box marginTop={1}>
334            <Text dimColor>{plural(hidden, 'more row')}, collapse some folders.</Text>
335          </Box>
336        )}
337        {footer}
338      </Box>
339    )
340  })
341}
342
hooks/diff.ts 179 lines
1export type DiffResult = { text: string; adds: number; dels: number; truncated: boolean }
2
3type Change = { a0: number; a1: number; b0: number; b1: number }
4type Side = { lines: string[]; ids: Int32Array; hasEol: boolean }
5
6const CONTEXT = 3
7const MAX_TEXT = 100_000
8const MAX_EDITS = 2000
9const MAX_WORK = 30_000_000
10const NO_EOL = '\\ No newline at end of file'
11const CONTROL = /[\x00-\x08\x0b-\x1f\x7f]/g
12
13function sides(before: string, after: string): [Side, Side] {
14  const table = new Map<string, number>()
15  const side = (text: string): Side => {
16    const hasEol = text === '' || text.endsWith('\n')
17    const lines = text === '' ? [] : (hasEol ? text.slice(0, -1) : text).split('\n')
18    const ids = new Int32Array(lines.length)
19    lines.forEach((line, i) => {
20      const key = !hasEol && i === lines.length - 1 ? `\0${line}` : line
21      let id = table.get(key)
22      if (id === undefined) table.set(key, (id = table.size))
23      ids[i] = id
24    })
25    return { lines, ids, hasEol }
26  }
27  return [side(before), side(after)]
28}
29
30function myers(a: Int32Array, b: Int32Array): Change[] | null {
31  const n = a.length
32  const m = b.length
33  const offset = n + m + 1
34  const v = new Int32Array(2 * offset + 1)
35  const trace: Int32Array[] = []
36  let work = 0
37  let end = -1
38  for (let d = 0; d <= n + m && end < 0; d++) {
39    if (d > MAX_EDITS) return null
40    for (let k = -d; k <= d; k += 2) {
41      let x = k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!) ? v[offset + k + 1]! : v[offset + k - 1]! + 1
42      let y = x - k
43      const x0 = x
44      while (x < n && y < m && a[x] === b[y]) x++, y++
45      work += 1 + x - x0
46      v[offset + k] = x
47      if (x >= n && y >= m) end = d
48    }
49    if (work > MAX_WORK) return null
50    trace.push(v.slice(offset - d, offset + d + 1))
51  }
52
53  const del = new Uint8Array(n)
54  const ins = new Uint8Array(m)
55  let x = n
56  let y = m
57  for (let d = end; d > 0; d--) {
58    const prev = trace[d - 1]!
59    const at = (k: number) => prev[k + d - 1]!
60    const k = x - y
61    const down = k === -d || (k !== d && at(k - 1) < at(k + 1))
62    const pk = down ? k + 1 : k - 1
63    const px = at(pk)
64    const py = px - pk
65    if (down) ins[py] = 1
66    else del[px] = 1
67    x = px
68    y = py
69  }
70
71  const changes: Change[] = []
72  let i = 0
73  let j = 0
74  while (i < n || j < m) {
75    while (i < n && j < m && !del[i] && !ins[j]) i++, j++
76    const a0 = i
77    const b0 = j
78    while (i < n && del[i]) i++
79    while (j < m && ins[j]) j++
80    if (i > a0 || j > b0) changes.push({ a0, a1: i, b0, b1: j })
81    else break
82  }
83  return changes
84}
85
86function diffChanges(a: Int32Array, b: Int32Array): Change[] {
87  let pre = 0
88  const max = Math.min(a.length, b.length)
89  while (pre < max && a[pre] === b[pre]) pre++
90  let suf = 0
91  while (suf < max - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++
92  const midA = a.subarray(pre, a.length - suf)
93  const midB = b.subarray(pre, b.length - suf)
94  if (midA.length === 0 && midB.length === 0) return []
95  const found = midA.length && midB.length ? myers(midA, midB) : null
96  const changes = found ?? [{ a0: 0, a1: midA.length, b0: 0, b1: midB.length }]
97  return changes.map(c => ({ a0: c.a0 + pre, a1: c.a1 + pre, b0: c.b0 + pre, b1: c.b1 + pre }))
98}
99
100function group(changes: Change[]): Change[][] {
101  const groups: Change[][] = []
102  for (const c of changes) {
103    const last = groups.at(-1)?.at(-1)
104    if (last && c.a0 - last.a1 <= 2 * CONTEXT) groups.at(-1)!.push(c)
105    else groups.push([c])
106  }
107  return groups
108}
109
110const range = (start: number, count: number) => `${count ? start + 1 : start},${count}`
111const clean = (line: string) => line.replace(/\r$/, '').replace(CONTROL, '�')
112
113export function diffText(before: string, after: string): DiffResult {
114  const [a, b] = sides(before, after)
115  const changes = diffChanges(a.ids, b.ids)
116  let adds = 0
117  let dels = 0
118  for (const c of changes) {
119    adds += c.b1 - c.b0
120    dels += c.a1 - c.a0
121  }
122
123  const out: string[] = []
124  let size = 0
125  let truncated = false
126  for (const hunk of group(changes)) {
127    const first = hunk[0]!
128    const last = hunk[hunk.length - 1]!
129    const aStart = Math.max(0, first.a0 - CONTEXT)
130    const bStart = first.b0 - (first.a0 - aStart)
131    const aEnd = Math.min(a.lines.length, last.a1 + CONTEXT)
132    const bEnd = last.b1 + (aEnd - last.a1)
133    const header = `@@ -${range(aStart, aEnd - aStart)} +${range(bStart, bEnd - bStart)} @@`
134    const room = MAX_TEXT - size - (out.length ? 1 : 0) - header.length
135    if (room <= 0) {
136      truncated = true
137      break
138    }
139
140    const body: string[] = []
141    let used = 0
142    let aCount = 0
143    let bCount = 0
144    const push = (line: string, inA: boolean, inB: boolean) => {
145      if (truncated) return
146      if (used + 1 + line.length > room) {
147        truncated = true
148        return
149      }
150      body.push(line)
151      used += 1 + line.length
152      if (inA) aCount++
153      if (inB) bCount++
154    }
155    const mark = (side: Side, i: number) => {
156      if (!side.hasEol && i === side.lines.length - 1) push(NO_EOL, false, false)
157    }
158    const context = (from: number, to: number) => {
159      for (let i = from; i < to; i++) push(` ${clean(a.lines[i]!)}`, true, true), mark(a, i)
160    }
161
162    let at = aStart
163    for (const c of hunk) {
164      context(at, c.a0)
165      for (let i = c.a0; i < c.a1; i++) push(`-${clean(a.lines[i]!)}`, true, false), mark(a, i)
166      for (let i = c.b0; i < c.b1; i++) push(`+${clean(b.lines[i]!)}`, false, true), mark(b, i)
167      at = c.a1
168    }
169    context(at, aEnd)
170
171    if (!body.length) break
172    const head = truncated ? `@@ -${range(aStart, aCount)} +${range(bStart, bCount)} @@` : header
173    out.push(head, ...body)
174    size += (size ? 1 : 0) + head.length + used
175    if (truncated) break
176  }
177  return { text: out.join('\n'), adds, dels, truncated }
178}
179
hooks/hash.ts 5 lines
1export async function sha(text: string) {
2  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))
3  return Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('')
4}
5
hooks/parse.ts 375 lines
1import { resolve } from './paths'
2
3type Part = string | { ref: string }
4type Raw = { kind: 'word'; parts: Part[]; isDynamic: boolean }
5type Token = Raw | { kind: 'sep' } | { kind: 'redirect'; isWrite: boolean; toFd: boolean }
6type Word = { text: string; isDynamic: boolean }
7type Vars = Map<string, string | null>
8
9const MAX_PATHS = 50
10const PREFIXES = new Set(['sudo', 'env', 'nohup', 'time', 'command', 'exec', 'nice', 'builtin'])
11const KEYWORDS = new Set(['do', 'then', 'else', 'elif', 'if', 'while', 'until', '{', '!'])
12const DECLARERS = new Set(['export', 'declare', 'local', 'readonly', 'typeset'])
13const BINDERS = new Set(['for', 'select', 'read', 'unset'])
14const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
15const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
16const NAME_START = /^[A-Za-z_][A-Za-z0-9_]*/
17const BRACED = /^\{([A-Za-z_][A-Za-z0-9_]*)\}/
18
19const literal = (raw: Raw | null | undefined) =>
20  raw === null || raw === undefined || raw.isDynamic || raw.parts.some(part => typeof part !== 'string')
21    ? null
22    : (raw.parts as string[]).join('')
23
24function tokenize(command: string, home: string): Token[] {
25  const tokens: Token[] = []
26  let word: Raw | null = null
27  let quote: '' | "'" | '"' = ''
28  let heredocs: string[] = []
29  let i = 0
30
31  const start = () => (word ??= { kind: 'word', parts: [], isDynamic: false })
32  const add = (text: string) => {
33    const parts = start().parts
34    const last = parts[parts.length - 1]
35    if (typeof last === 'string') parts[parts.length - 1] = last + text
36    else parts.push(text)
37  }
38  const end = () => {
39    if (word !== null) tokens.push(word)
40    word = null
41  }
42  const dollar = () => {
43    const rest = command.slice(i + 1)
44    const name = BRACED.exec(rest)?.[1] ?? NAME_START.exec(rest)?.[0]
45    if (name === undefined) {
46      start().isDynamic = true
47      add('$')
48      i++
49      return
50    }
51    start().parts.push({ ref: name })
52    i += 1 + (rest.startsWith('{') ? name.length + 2 : name.length)
53  }
54  const skipHeredocs = () => {
55    for (const delimiter of heredocs) {
56      while (i < command.length) {
57        const nl = command.indexOf('\n', i)
58        const line = command.slice(i, nl < 0 ? command.length : nl)
59        i = nl < 0 ? command.length : nl + 1
60        if (line.trim() === delimiter) break
61      }
62    }
63    heredocs = []
64  }
65
66  while (i < command.length) {
67    const c = command[i] as string
68    if (quote === "'") {
69      if (c === "'") quote = ''
70      else add(c)
71      i++
72      continue
73    }
74    if (quote === '"') {
75      if (c === '"') quote = ''
76      else if (c === '\\' && i + 1 < command.length) add(command[++i] as string)
77      else if (c === '$') {
78        dollar()
79        continue
80      } else {
81        if (c === '`') start().isDynamic = true
82        add(c)
83      }
84      i++
85      continue
86    }
87    if (c === "'" || c === '"') {
88      quote = c
89      start()
90      i++
91      continue
92    }
93    if (c === '\\') {
94      if (command[i + 1] !== '\n') add(command[i + 1] ?? '')
95      i += 2
96      continue
97    }
98    if (c === '\n') {
99      end()
100      tokens.push({ kind: 'sep' })
101      i++
102      skipHeredocs()
103      continue
104    }
105    if (c === ' ' || c === '\t') {
106      end()
107      i++
108      continue
109    }
110    if (c === '#' && word === null) {
111      while (i < command.length && command[i] !== '\n') i++
112      continue
113    }
114    if (c === ';' || c === '|' || c === '&' || c === '(' || c === ')') {
115      if (c === '&' && command[i + 1] === '>') {
116        end()
117        i += command[i + 2] === '>' ? 3 : 2
118        tokens.push({ kind: 'redirect', isWrite: true, toFd: false })
119        continue
120      }
121      end()
122      tokens.push({ kind: 'sep' })
123      i += (c === '|' || c === '&') && command[i + 1] === c ? 2 : 1
124      continue
125    }
126    if (c === '>' || c === '<') {
127      const fd = /^\d+$/.test(literal(word as Raw | null) ?? '')
128      if (fd) word = null
129      else end()
130      if (c === '<' && command[i + 1] === '<') {
131        i += command[i + 2] === '-' ? 3 : 2
132        if (command[i] === '<') {
133          i++
134          tokens.push({ kind: 'redirect', isWrite: false, toFd: false })
135          continue
136        }
137        while (command[i] === ' ' || command[i] === '\t') i++
138        let delimiter = ''
139        while (i < command.length && !/[\s;|&<>()]/.test(command[i] as string)) {
140          const ch = command[i] as string
141          if (ch !== "'" && ch !== '"' && ch !== '\\') delimiter += ch
142          i++
143        }
144        if (delimiter !== '') heredocs.push(delimiter)
145        continue
146      }
147      i += c === '>' && (command[i + 1] === '>' || command[i + 1] === '|') ? 2 : 1
148      const toFd = command[i] === '&'
149      if (toFd) i++
150      tokens.push({ kind: 'redirect', isWrite: c === '>', toFd })
151      continue
152    }
153    if (c === '$') {
154      dollar()
155      continue
156    }
157    if (c === '`' || c === '*' || c === '?' || c === '[') start().isDynamic = true
158    const isWordStart = word === null || /^[A-Za-z_][A-Za-z0-9_]*=$/.test(literal(word as Raw | null) ?? '')
159    if (c === '~' && isWordStart && /^~(?=[/\s;|&<>)]|$)/.test(command.slice(i))) {
160      if (home === '') start().isDynamic = true
161      add(home === '' ? '~' : home)
162      i++
163      continue
164    }
165    add(c)
166    i++
167  }
168  end()
169  return tokens
170}
171
172function evaluate(raw: Raw, vars: Vars): Word {
173  if (raw.isDynamic) return { text: '', isDynamic: true }
174  let text = ''
175  for (const part of raw.parts) {
176    if (typeof part === 'string') {
177      text += part
178      continue
179    }
180    const value = vars.get(part.ref)
181    if (value === undefined || value === null) return { text: '', isDynamic: true }
182    text += value
183  }
184  return { text, isDynamic: false }
185}
186
187function assignmentOf(raw: Raw, vars: Vars) {
188  const first = raw.parts[0]
189  if (typeof first !== 'string') return undefined
190  const name = ASSIGNMENT.exec(first)?.[0].slice(0, -1)
191  if (name === undefined) return undefined
192  const rest: Raw = { ...raw, parts: [first.slice(name.length + 1), ...raw.parts.slice(1)] }
193  const value = evaluate(rest, vars)
194  return { name, value: value.isDynamic ? null : value.text }
195}
196
197function operands(args: Word[], withValue: Set<string>) {
198  const out: Word[] = []
199  let isOptionsDone = false
200  for (let j = 0; j < args.length; j++) {
201    const arg = args[j] as Word
202    if (!isOptionsDone && arg.text === '--') {
203      isOptionsDone = true
204      continue
205    }
206    if (!isOptionsDone && arg.text.startsWith('-') && arg.text.length > 1) {
207      if (withValue.has(arg.text)) j++
208      continue
209    }
210    out.push(arg)
211  }
212  return out
213}
214
215function inPlaceTargets(args: Word[], isSed: boolean) {
216  let isInPlace = false
217  let hasScriptOption = false
218  const files: Word[] = []
219  for (let j = 0; j < args.length; j++) {
220    const arg = args[j] as Word
221    const text = arg.text
222    if (text === '--in-place' || text.startsWith('--in-place=')) {
223      isInPlace = true
224      continue
225    }
226    if (text === '-e' || text === '-f' || text === '--expression' || text === '--file') {
227      hasScriptOption = true
228      j++
229      continue
230    }
231    if (text.startsWith('--expression=') || text.startsWith('--file=')) {
232      hasScriptOption = true
233      continue
234    }
235    if (/^-[A-Za-z]+/.test(text)) {
236      const flags = /^-([A-Za-z]+)/.exec(text)?.[1] ?? ''
237      if (flags.includes('i')) {
238        isInPlace = true
239        if (isSed && text === '-i' && args[j + 1]?.text === '') j++
240      }
241      if (flags.endsWith('e') && !flags.includes('i')) {
242        hasScriptOption = true
243        j++
244      }
245      continue
246    }
247    files.push(arg)
248  }
249  if (!isInPlace) return []
250  return hasScriptOption ? files : files.slice(1)
251}
252
253function targetsOf(words: Word[]): Word[] {
254  let at = 0
255  while (at < words.length) {
256    const text = (words[at] as Word).text
257    if (ASSIGNMENT.test(text) || PREFIXES.has(text)) at++
258    else break
259  }
260  const name = words[at]?.text.split('/').pop() ?? ''
261  const args = words.slice(at + 1)
262
263  switch (name) {
264    case 'tee':
265      return operands(args, new Set())
266    case 'sed':
267      return inPlaceTargets(args, true)
268    case 'perl':
269      return inPlaceTargets(args, false)
270    case 'cp':
271    case 'install':
272    case 'ln': {
273      const list = operands(args, new Set(['-t', '-S', '-m', '-o', '-g', '--target-directory', '--suffix']))
274      return list.length >= 2 ? [list[list.length - 1] as Word] : []
275    }
276    case 'mv': {
277      const list = operands(args, new Set(['-t', '-S', '--target-directory', '--suffix']))
278      return list.length >= 2 ? list : []
279    }
280    case 'rm':
281    case 'unlink':
282    case 'shred':
283      return operands(args, new Set(['-n', '-s', '--iterations', '--size']))
284    case 'touch':
285      return operands(args, new Set(['-d', '-t', '-r', '--date', '--reference']))
286    case 'truncate':
287      return operands(args, new Set(['-s', '-r', '--size', '--reference']))
288    case 'dd':
289      return args
290        .filter(arg => arg.text.startsWith('of='))
291        .map(arg => ({ ...arg, text: arg.text.slice(3) }))
292    case 'curl':
293    case 'wget': {
294      const flag = name === 'curl' ? ['-o', '--output'] : ['-O', '--output-document']
295      const out: Word[] = []
296      args.forEach((arg, j) => {
297        const next = args[j + 1]
298        if (flag.includes(arg.text) && next !== undefined) out.push(next)
299      })
300      return out
301    }
302    default:
303      return []
304  }
305}
306
307export function commandPaths(command: string, where: { cwd: string; home: string }) {
308  const found: { word: Word; cwd: string | null }[] = []
309  const vars: Vars = new Map(where.home === '' ? [] : [['HOME', where.home]])
310  let cwd: string | null = where.cwd
311  let raws: Raw[] = []
312  let pendingRedirect: { isWrite: boolean } | null = null
313
314  const bind = (list: readonly Raw[]) => {
315    for (const raw of list) {
316      const assigned = assignmentOf(raw, vars)
317      if (assigned !== undefined) vars.set(assigned.name, assigned.value)
318      else if (NAME.test(literal(raw) ?? '')) vars.set(literal(raw) as string, null)
319    }
320  }
321  const flush = () => {
322    let at = 0
323    while (at < raws.length && KEYWORDS.has(literal(raws[at]) ?? '')) at++
324    const body = raws.slice(at)
325    raws = []
326    if (body.length === 0) return
327    const head = literal(body[0])
328    if (head !== null && BINDERS.has(head)) return bind(head === 'for' || head === 'select' ? body.slice(1, 2) : body.slice(1))
329    if (head !== null && DECLARERS.has(head)) return bind(body.slice(1).filter(raw => assignmentOf(raw, vars) !== undefined))
330    if (body.every(raw => assignmentOf(raw, vars) !== undefined)) return bind(body)
331
332    let lead = 0
333    while (lead < body.length && assignmentOf(body[lead] as Raw, vars) !== undefined) lead++
334    const words = body.slice(lead).map(raw => evaluate(raw, vars))
335    if (words[0]?.text === 'cd' && !words[0].isDynamic) {
336      const target = words.slice(1).find(word => word.text !== '--')
337      if (target === undefined) cwd = where.home === '' ? null : where.home
338      else if (target.isDynamic || target.text === '-' || cwd === null) cwd = null
339      else cwd = resolve(cwd, target.text)
340      return
341    }
342    found.push(...targetsOf(words).map(word => ({ word, cwd })))
343  }
344
345  for (const token of tokenize(command, where.home)) {
346    if (token.kind === 'redirect') {
347      pendingRedirect = { isWrite: token.isWrite && !token.toFd }
348      continue
349    }
350    if (token.kind === 'sep') {
351      pendingRedirect = null
352      flush()
353      continue
354    }
355    if (pendingRedirect !== null) {
356      if (pendingRedirect.isWrite) found.push({ word: evaluate(token, vars), cwd })
357      pendingRedirect = null
358      continue
359    }
360    raws.push(token)
361  }
362  flush()
363
364  const paths = new Set<string>()
365  for (const { word, cwd: base } of found) {
366    if (word.isDynamic || word.text === '' || word.text.startsWith('-')) continue
367    if (base === null && !word.text.startsWith('/')) continue
368    const path = resolve(base ?? '/', word.text)
369    if (path.startsWith('/dev/') || path.startsWith('/proc/')) continue
370    paths.add(path)
371    if (paths.size >= MAX_PATHS) break
372  }
373  return [...paths]
374}
375
hooks/paths.ts 39 lines
1export function normalize(path: string) {
2  const isAbsolute = path.startsWith('/')
3  const parts: string[] = []
4  for (const part of path.split('/')) {
5    if (part === '' || part === '.') continue
6    if (part === '..') {
7      if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
8      else if (!isAbsolute) parts.push('..')
9      continue
10    }
11    parts.push(part)
12  }
13  const joined = parts.join('/')
14  return isAbsolute ? `/${joined}` : joined || '.'
15}
16
17export function resolve(base: string, path: string) {
18  return normalize(path.startsWith('/') ? path : `${base}/${path}`)
19}
20
21export function isUnder(path: string, root: string) {
22  return root === '/' ? path.startsWith('/') : path === root || path.startsWith(`${root}/`)
23}
24
25export function relative(path: string, root: string) {
26  if (path === root) return '.'
27  return root === '/' ? path.slice(1) : path.slice(root.length + 1)
28}
29
30export function displayPath(path: string, where: { root: string; home: string }) {
31  if (isUnder(path, where.root) && path !== where.root) {
32    return { isUnderRoot: true, label: relative(path, where.root) }
33  }
34  if (where.home !== '' && where.home !== '/' && isUnder(path, where.home)) {
35    return { isUnderRoot: false, label: path === where.home ? '~' : `~/${relative(path, where.home)}` }
36  }
37  return { isUnderRoot: false, label: path }
38}
39
hooks/shell.ts 151 lines
1import type { CoreEngineInterface } from 'claude-code'
2
3import type { TdiffShellReason } from '../types'
4import { sha } from './hash'
5import { isUnder, relative } from './paths'
6
7type Engine = Pick<CoreEngineInterface, 'process' | 'fs' | 'clock'>
8
9export type Shadow = { gitDir: string; indexFile: string; root: string; cacheDir: string }
10
11export type TreeChange = { path: string; baseBlob: string | null }
12
13export const SNAPSHOT_BUDGET_MS = 5_000
14const MAX_CHANGES = 500
15const SKIPPED_MODES = new Set(['160000', '120000'])
16const ATTRIBUTES = '* -text -eol -filter -ident -working-tree-encoding\n'
17
18const EXCLUDES = [
19  '.git/',
20  '.hg/',
21  '.svn/',
22  '.jj/',
23  'node_modules/',
24  '.venv/',
25  'venv/',
26  '__pycache__/',
27  '.mypy_cache/',
28  '.pytest_cache/',
29  '.ruff_cache/',
30  '.tox/',
31  '.next/',
32  '.turbo/',
33  '.gradle/',
34  '.terraform/',
35  '.DS_Store',
36]
37
38export class ShellOff extends Error {
39  constructor(readonly reason: TdiffShellReason) {
40    super(reason)
41  }
42}
43
44export async function shadowFor(cacheDir: string, sessionDir: string, root: string): Promise<Shadow> {
45  const id = (await sha(root)).slice(0, 40)
46  return { gitDir: `${cacheDir}/shadow/${id}`, indexFile: `${sessionDir}/index-${id}`, root, cacheDir }
47}
48
49function envOf(shadow: Shadow): Record<string, string> {
50  return {
51    GIT_DIR: shadow.gitDir,
52    GIT_WORK_TREE: shadow.root,
53    GIT_INDEX_FILE: shadow.indexFile,
54    GIT_OBJECT_DIRECTORY: `${shadow.gitDir}/objects`,
55    GIT_COMMON_DIR: shadow.gitDir,
56    GIT_ALTERNATE_OBJECT_DIRECTORIES: '',
57    GIT_CONFIG_NOSYSTEM: '1',
58    GIT_CONFIG_GLOBAL: `${shadow.gitDir}/empty-config`,
59    GIT_CONFIG_COUNT: '0',
60    GIT_CONFIG_PARAMETERS: '',
61    GIT_NAMESPACE: '',
62    GIT_ATTR_NOSYSTEM: '1',
63  }
64}
65
66function git($: Engine, shadow: Shadow, args: string[], timeoutMs?: number) {
67  const argv = [
68    'git',
69    `--git-dir=${shadow.gitDir}`,
70    `--work-tree=${shadow.root}`,
71    '-c',
72    'core.quotepath=off',
73    '-c',
74    'core.autocrlf=false',
75    '-c',
76    'core.fsmonitor=false',
77    '-c',
78    'gc.auto=0',
79    ...args,
80  ]
81  return $.process.run(argv, { cwd: shadow.root, env: envOf(shadow), ...(timeoutMs === undefined ? {} : { timeoutMs }) })
82}
83
84export function guard(root: string, home: string): TdiffShellReason | null {
85  if (root === '/') return 'root-is-fs-root'
86  if (home !== '' && isUnder(home, root)) return 'root-is-home'
87  return null
88}
89
90export async function prime($: Engine, shadow: Shadow) {
91  const version = await $.process.run(['git', '--version']).catch(() => undefined)
92  if (version === undefined || version.exitCode !== 0) throw new ShellOff('no-git')
93
94  const own = isUnder(shadow.cacheDir, shadow.root) ? [`/${relative(shadow.cacheDir, shadow.root)}/`] : []
95  await $.fs.write(`${shadow.gitDir}/empty-config`, '')
96  await $.fs.write(`${shadow.gitDir}/info/exclude`, `${[...EXCLUDES, ...own].join('\n')}\n`)
97  await $.fs.write(`${shadow.gitDir}/info/attributes`, ATTRIBUTES)
98  if (!(await $.fs.exists(`${shadow.gitDir}/HEAD`))) {
99    const made = await git($, shadow, ['init', '--quiet', '--template='])
100    if (made.exitCode !== 0) throw new ShellOff('failed')
101  }
102  await snapshot($, shadow)
103}
104
105async function timed($: Engine, shadow: Shadow, args: string[], startedAt: number) {
106  const left = SNAPSHOT_BUDGET_MS - ((await $.clock.now()) - startedAt)
107  if (left <= 0) throw new ShellOff('too-slow')
108  const ran = await git($, shadow, args, left).catch(() => undefined)
109  if (ran === undefined || (await $.clock.now()) - startedAt > SNAPSHOT_BUDGET_MS) throw new ShellOff('too-slow')
110  return ran
111}
112
113export async function snapshot($: Engine, shadow: Shadow) {
114  const startedAt = await $.clock.now()
115  const added = await timed($, shadow, ['add', '-A', '--ignore-errors'], startedAt)
116  if (added.exitCode > 1) throw new ShellOff('failed')
117  const written = await timed($, shadow, ['write-tree'], startedAt)
118  const tree = written.stdout.trim()
119  if (written.exitCode !== 0 || tree === '') throw new ShellOff('failed')
120  return tree
121}
122
123export async function changes($: Engine, shadow: Shadow, before: string, after: string): Promise<TreeChange[]> {
124  if (before === after) return []
125  const listed = await git($, shadow, ['diff-tree', '-r', '-z', '--no-renames', before, after])
126  if (listed.exitCode !== 0) throw new ShellOff('failed')
127  const parts = listed.stdout.split('\0')
128  const found: TreeChange[] = []
129  for (let i = 0; i + 1 < parts.length && found.length < MAX_CHANGES; i += 2) {
130    const meta = parts[i] as string
131    const rel = parts[i + 1] as string
132    if (!meta.startsWith(':')) break
133    const [fromMode = '', toMode = '', fromBlob = '', , status = ''] = meta.slice(1).split(' ')
134    if (SKIPPED_MODES.has(fromMode) || SKIPPED_MODES.has(toMode)) continue
135    found.push({ path: `${shadow.root}/${rel}`, baseBlob: status.startsWith('A') ? null : fromBlob })
136  }
137  return found
138}
139
140export async function blob($: Engine, shadow: Shadow, id: string) {
141  const read = await git($, shadow, ['cat-file', 'blob', id])
142  if (read.exitCode !== 0) throw new ShellOff('failed')
143  return { text: read.stdout, isTruncated: read.isStdoutTruncated }
144}
145
146export async function blobIdOf($: Engine, shadow: Shadow, path: string) {
147  const read = await git($, shadow, ['hash-object', '--no-filters', '--', path])
148  const id = read.stdout.trim()
149  return read.exitCode === 0 && id !== '' ? id : undefined
150}
151
hooks/tree.ts 78 lines
1import type { TdiffRow } from '../types'
2
3export const COLLAPSE_OVER = 20
4
5export type Dir = {
6  kind: 'dir'
7  key: string
8  name: string
9  adds: number
10  dels: number
11  count: number
12  children: TreeNode[]
13}
14
15export type Leaf = { kind: 'file'; name: string; row: TdiffRow }
16
17export type TreeNode = Dir | Leaf
18
19export type Line = { depth: number; node: Leaf } | { depth: number; node: Dir; isCollapsed: boolean }
20
21type Trie = { dirs: Map<string, Trie>; files: Leaf[] }
22
23const byName = (a: { name: string }, b: { name: string }) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
24
25function toNodes(trie: Trie, prefix: string): TreeNode[] {
26  const dirs = [...trie.dirs].map(([segment, sub]) => {
27    let name = `${segment}/`
28    let at = sub
29    while (at.files.length === 0 && at.dirs.size === 1) {
30      const [[next, only]] = [...at.dirs] as [[string, Trie]]
31      name += `${next}/`
32      at = only
33    }
34    const children = toNodes(at, prefix + name)
35    const files = children.flatMap(function leaves(node): Leaf[] {
36      return node.kind === 'file' ? [node] : node.children.flatMap(leaves)
37    })
38    const dir: Dir = {
39      kind: 'dir',
40      key: prefix + name,
41      name,
42      adds: files.reduce((sum, leaf) => sum + leaf.row.adds, 0),
43      dels: files.reduce((sum, leaf) => sum + leaf.row.dels, 0),
44      count: files.length,
45      children,
46    }
47    return dir
48  })
49  return [...dirs.sort(byName), ...[...trie.files].sort(byName)]
50}
51
52export function buildTree(items: readonly { row: TdiffRow; label: string }[], prefix: string): TreeNode[] {
53  const top: Trie = { dirs: new Map(), files: [] }
54  for (const { row, label } of items) {
55    const segments = label.split('/')
56    const name = segments.pop() ?? label
57    let at = top
58    for (const segment of segments) {
59      let sub = at.dirs.get(segment)
60      if (sub === undefined) {
61        sub = { dirs: new Map(), files: [] }
62        at.dirs.set(segment, sub)
63      }
64      at = sub
65    }
66    at.files.push({ kind: 'file', name, row })
67  }
68  return toNodes(top, prefix)
69}
70
71export function visibleLines(nodes: readonly TreeNode[], folds: Readonly<Record<string, boolean>>, depth = 0): Line[] {
72  return nodes.flatMap((node): Line[] => {
73    if (node.kind === 'file') return [{ depth, node }]
74    const isCollapsed = folds[node.key] ?? node.count > COLLAPSE_OVER
75    return [{ depth, node, isCollapsed }, ...(isCollapsed ? [] : visibleLines(node.children, folds, depth + 1))]
76  })
77}
78
types/index.d.ts 43 lines
1export type TdiffKind = 'added' | 'modified' | 'deleted'
2
3export type TdiffNoText = 'binary' | 'too-large'
4
5export type TdiffRow = {
6  path: string
7  kind: TdiffKind
8  adds: number
9  dels: number
10  noText: TdiffNoText | null
11  changedAt: number
12  isEditedSince: boolean
13}
14
15export type TdiffText = { text: string; adds: number; dels: number; truncated: boolean }
16
17export type TdiffOpen = {
18  path: string
19  diff: TdiffText | null
20  note: TdiffNoText | 'unchanged' | null
21}
22
23export type TdiffShellReason = 'root-is-home' | 'root-is-fs-root' | 'no-git' | 'too-slow' | 'failed'
24
25export type TdiffShell =
26  | { status: 'starting' | 'on'; reason: null }
27  | { status: 'limited'; reason: TdiffShellReason }
28
29export type TdiffWhere = { root: string; home: string }
30
31declare module 'claude-code' {
32  interface PluginState {
33    'tdiff': {
34      rows: Shaped<TdiffRow[]>
35      open: Shaped<TdiffOpen | null>
36      shell: Shaped<TdiffShell>
37      where: Shaped<TdiffWhere>
38      folds: Shaped<Record<string, boolean>>
39      focused: Shaped<string | null>
40    }
41  }
42}
43