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

Skills and mods for Claude-Code.
curl -fsSL shalom2552.github.io/agent-skills/install | bash
Or clone and run ./install. See ./install --help for options.
| Skill | Description |
|---|---|
docs-sync | Find and fix drift between docs and code. |
dotconf | Answer, change, and remember dotfile and app configs. |
read-only | Lock the session to read-only; change nothing. |
setup-sh25-skills | Install, manage and wire up shalom2552's agent-skills assets. |
system-audit | Read-only health, security and cleanliness audit of this machine. |
system-fix | Diagnose, fix, and document a system or config fault on this machine. |
to-prompt | Write a handoff prompt for a fresh agent. |
visualize | Turn any subject into a visual HTML page. |
walk-with-me | Learn languages and idioms by coding with a coach. |
Claude Code plugins.
/plugin install <mod> --marketplace shalom2552/agent-skills
| Mod | Description |
|---|---|
spinner-words | Swap the spinner verbs for dev ones. |
tape | /tape pane of tool calls per turn. |
frame | Rounded frame around your prompts. |
beat | Turn time and usage as pills. |
statusline | Bottom status line for claude. |
badges | Badge above the prompt for active skill. |
tdiff | Live diff of session changes. |
Small JSON files that ./install merges into the scope's settings.json.
./install settings --scope global -y
| Setting | Description |
|---|---|
tmux-truecolor | Truecolor inside tmux, so mod hex colors render right. |
hooks/register.tsx 16 lines1import 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}
16hooks/core.ts 573 lines1import { 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}
573hooks/pane.tsx 342 lines1import { 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}
342hooks/diff.ts 179 lines1export 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}
179hooks/hash.ts 5 lines1export 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}
5hooks/parse.ts 375 lines1import { 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}
375hooks/paths.ts 39 lines1export 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}
39hooks/shell.ts 151 lines1import 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}
151hooks/tree.ts 78 lines1import 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}
78types/index.d.ts 43 lines1export 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