SLOPSHOPPER

diff

The diff pane as a plugin: /diff opens the session's uncommitted changes beside the transcript, file by file with their hunks, refreshed as Claude edits and…

built-in
★ 149,837v0.1.0no licenseupdated 2026-10-08anthropics/claude-code/mods/diff
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (1 | import { ASK_CUT_NOTE } from './ask-cut-note' ^ error: No matching export in ".cache/src/anthropics-claude-code-diff/hooks/ask/ask-cut-note/index.ts" for i
README

diff

The diff pane as a plugin: /diff opens the session's uncommitted changes beside the transcript, one row per changed file and every file's hunks beneath, and closes it again; each toggle leaves Diff panel shown or Diff panel hidden in the transcript. The header, the file list and its toggles stay put while the wheel moves the hunks under them three rows a tick, or the list a file a tick while the wheel is over a list longer than its eight rows (the plugin answers the pane's ui.scroll itself); a row's click puts that file's hunks at the top; the list also scrolls under the built-in's list keys (ctrl+up/ctrl+down, opt+up/opt+down), and ctrl+x b moves the comparison base on, as the built-in's chord does: both through Buttons that declare the engine's own actions. The pane refreshes as Claude edits and runs shell commands, and while it is open it polls the repository's HEAD so a commit or checkout made elsewhere shows too. The main loop's first successful edit of a session opens the pane by itself, as the built-in panel opens on its first checkpoint: where the layout docks it beside the transcript (the fullscreen layout, which each drawing's viewport says), the terminal is wide enough (144 columns when the person never chose, 110 when they kept it open before; a person who closed it is left alone) and file checkpointing is on; a subagent's edit opens nothing, and where the surface does not say, nothing opens by itself. A docked pane fetches before it opens, as the built-in panel primes its data, so it never lands on Loading diff…; an open the engine leaves waiting undrawn is withdrawn, so no later resize seats it, and the next edit asks again. A session resumed or continued whose transcript already holds such an edit opens the pane on the same terms as soon as the width is known, as the built-in opens on the history it restores.

Under the fullscreen layout a terminal under 110 columns gets the built-in's line asking for a wider one and nothing opens. Without that layout (CLAUDE_CODE_NO_FLICKER=0, which /diff learns from the command's presentation) the pane opens inline at any width, focused and as tall as its content (the open's rows), in the built-in dialog's shape: the title, the count, five file rows at a time round the selected one (❯, where the focus ring starts; the plugin follows the ring's walk through ui.focus and re-centres the rows as the built-in does), the key hints; Enter shows that file's hunks alone, Escape backs out to the list and then closes, leaving Diff dialog dismissed; toasts show while it is up, as beside the docked pane. A file's ask button arms that file: its hunks ride the next prompt as context, once.

The pane compares the working tree against HEAD, split at the session's start (the default; the start the engine gives in $.session.usage(), so a resumed session keeps its first and /clear begins anew), against HEAD plainly, or against the merge-base with the default branch; the base line under the header names a base other than the session's, and the choice is kept per repository in the plugin's store. A picker shows one earlier turn's edits instead of the working tree, read from the session's messages. Files that changed before the session started (by their timestamp, among the paths already dirty when the pane first read the repository), and noise (lockfiles, generated and test files), are listed apart and folded until asked for; a rename lists as git prints it. Outside a git repository /diff says so and does nothing else.

Git runs when the built-in panel's would: nothing at the session's start; one git rev-parse, in the directory the session started in, when /diff or the first edit a pane has room to open on first needs the repository (an answer of no repository is kept too, until /clear or /resume forgets it); and the working tree is read only by a fetch for a pane that is open, after an edit that landed or a shell command that ran. The one read the built-in has no counterpart for is a git status at a pane's first fetch, which stands in for the change time the built-in dates a moved file by.

hooks/register.ts is the module; everything under hooks/ is its parts.

What it hooks

eventwhat the hook does
session.startBinds the engine once and registers /diff (a session where another /diff is listed leaves the plugin idle); asks nothing of the repository, which /diff or the first edit pins when it comes; off its dispatch, reads the transcript, and for a resumed session whose turns edited opens the pane as the first edit would.
ui.render of PromptHintReads the terminal's width and whether its layout docks a pane, which decide whether the first edit opens the pane.
ui.render of PaneDraws the pane: docked, the header, base line, source picker, file list and toggles over the window of hunks; inline, the dialog.
command.run of diffPins the repository when none is, opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice.
ui.close of the paneBacks out of the dialog's detail view instead of closing; else remembers the person's close as /diff's.
ui.scroll of the paneDocked, moves the hunks under the pinned header and list (three rows a wheel tick, a page a page key), or the list when the wheel is over it, and keeps the engine's window still.
ui.focus in the paneIn the dialog's list, selects the file the ring lands on, re-centres the five rows on it, and lands the ring where that row now sits.
command.run of clear, resumeCloses the pane and forgets the session's state, the pinned repository with it.
tool.call of Edit, Write, NotebookEditAfter an edit that landed (not refused, not failed), refreshes an open pane; the main loop's first such edit opens it, pinning the repository then if the terminal has the room and checkpointing is on.
tool.call of Bash, PowerShellAfter a command that was not refused, failed and interrupted ones too, refreshes an open pane.
prompt.submitAdds the armed file's hunks to the prompt's context and disarms.

What it calls on $

clock.after, clock.every, clock.now, command.register, env.get (CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING), fs.list, fs.read, fs.stat, process.run (git, read-only), session.id, session.messages, settings.read, store.get, store.set, telemetry.log, telemetry.mark, ui.close, ui.invalidate, ui.log, ui.open, ui.resolve, ui.status.

$.telemetry is the telemetry plugin's noun; where it is absent the rows are dropped and nothing else changes.

Try it

claude --plugin-dir /path/to/diff

then /diff inside a git repository with a modified file.

Source 90 files
hooks/register.ts 1036 lines
1import type {
2  Args,
3  On,
4  PaneOpenArgs,
5  ResultOf,
6  SessionMessage,
7  Timer,
8} from 'claude-code'
9
10import Ask from './ask'
11import Backend from './backend'
12import { COMMAND_SPEC } from './command-spec'
13import { drawnFilesOf } from './drawn-files-of'
14import { entryKindsOf } from './entry-kinds-of'
15import type Git from './git'
16import type { Host } from './host'
17import { isCheckpointing } from './is-checkpointing'
18import { isOnPaneSurface } from './is-on-pane-surface'
19import { isRecord } from './is-record'
20import Limits from './limits'
21import { messageOf } from './message-of'
22import { mtimeOf } from './mtime-of'
23import Names from './names'
24import PaneState from './pane-state'
25import PaneToggle from './pane-toggle'
26import Record from './record'
27import Tools from './tools'
28import Turns from './turns'
29import Views from './views'
30
31/**
32 * Registers the diff pane: `/diff` once the built-in stands down, the
33 * pane's drawing and refresh, its opening on Claude's first edit, the ask.
34 *
35 * Git runs when the built-in's would: none at the start; `/diff` or the main
36 * loop's first checkpointed edit with room pins the backend, until `/clear`,
37 * which reads afresh under a pane it leaves open.
38 *
39 * @param on the engine's registrar
40 */
41export function register(on: On) {
42  let host: Host | null = null
43  let backend: Backend.Backend | null = null
44  let probing: Promise<boolean> | null = null
45  let sessionStartMs = 0
46  let isPaneOpen = false
47  let dialogRows: number | null = null
48  let hasAutoOpened = false
49  let hasRestoredEdits = false
50  let columns: number | null = null
51  let shownSessionId: string | null = null
52  let armed: Ask.ArmedAsk | null = null
53  let carrying: Ask.ArmedAsk | null = null
54  let isRefreshing = false
55  let isRefreshQueued = false
56  let generation = 0
57  let landed = 0
58  let bodyStamp: string | null = null
59  let bodyBase: string | null = null
60
61  const bodyLoads = new Set<string>()
62
63  const polled = { toplevel: '', headKey: '' }
64  const pin = { cwd: '', isEmpty: false, epoch: 0 }
65
66  let model: PaneState.PaneModel = PaneState.INITIAL_MODEL
67
68  const timers = new Map<'refresh' | 'redraw' | 'poll', Timer>()
69  const loggedBaseKinds = new Set<'ok' | 'sad'>()
70
71  const currentOf = (engine: Host): Host => host ?? engine
72
73  const backendHostOf = (engine: Host): Backend.BackendHost => ({
74    run: (argv, init) =>
75      currentOf(engine).run(
76        argv,
77        pin.cwd === '' ? init : { cwd: pin.cwd, ...init },
78      ),
79    readFile: path => currentOf(engine).readFile(path),
80    mtimeOf: path => mtimeOf(currentOf(engine))(path),
81    entryKindsOf: dir => entryKindsOf(currentOf(engine))(dir),
82    nowMs: () => currentOf(engine).now(),
83    sessionStartMsOf: () => sessionStartMs,
84    onBranchBase: base => {
85      const isError = base.kind === 'error'
86
87      const outcome: Record.MarkOutcome = isError
88        ? { kind: 'sad', reason: base.reason }
89        : {
90            kind: 'ok',
91            props: {
92              outcome: { value: base.kind, of: Record.BASE_OUTCOMES },
93            },
94          }
95
96      if (!loggedBaseKinds.has(outcome.kind)) {
97        loggedBaseKinds.add(outcome.kind)
98
99        Record.recorderOf(currentOf(engine)).mark(
100          Record.FEATURES.baseResolve,
101          outcome,
102        )
103      }
104    },
105  })
106
107  function pinBackend(engine: Host): Promise<boolean> {
108    if (backend || pin.isEmpty) {
109      return Promise.resolve(true)
110    }
111
112    probing ??= probeBackend(engine).finally(() => {
113      probing = null
114    })
115
116    return probing
117  }
118
119  async function probeBackend(engine: Host): Promise<boolean> {
120    const asked = { isAnswered: true }
121    const probeHost = backendHostOf(engine)
122    const { epoch } = pin
123
124    const probed = await Backend.backendOf(
125      {
126        ...probeHost,
127        run: (argv, init) =>
128          probeHost.run(argv, init).catch((error: unknown) => {
129            asked.isAnswered &&=
130              argv[0] !== 'git' || !/\baborted\b/.test(messageOf(error))
131
132            throw error
133          }),
134      },
135      Backend.INSTALLED_BACKEND_PROBES,
136    )
137
138    if (epoch !== pin.epoch) {
139      return false
140    }
141
142    backend ??= probed
143    pin.isEmpty = backend === null && asked.isAnswered
144
145    if (!probed || backend !== probed) {
146      return asked.isAnswered || backend !== null
147    }
148
149    startPoll(engine, probed)
150
151    const stored = PaneState.baseModeOf(
152      await engine
153        .storeGet(Names.baseStoreKeyOf(probed.repository.toplevel))
154        .catch(() => undefined),
155    )
156
157    const mode = stored && probed.baseModes.includes(stored) ? stored : null
158
159    model = {
160      ...model,
161      words: probed.words,
162      baseModes: probed.baseModes,
163      ...(mode && { requestedMode: mode }),
164    }
165
166    return true
167  }
168
169  function unpin() {
170    backend = null
171    pin.isEmpty = false
172    pin.epoch += 1
173    polled.toplevel = ''
174    polled.headKey = ''
175    timers.get('poll')?.cancel()
176    timers.delete('poll')
177  }
178
179  function dialogPane(): PaneOpenArgs {
180    return {
181      id: Names.PANE_ID,
182      title: Names.PANE_TITLE,
183      closeOnEscape: true,
184      rows: Views.dialogRowsOf(model),
185    }
186  }
187
188  function fitDialog(engine: Host) {
189    const rows = Views.dialogRowsOf(model)
190
191    const isStale =
192      isPaneOpen && model.isFullscreen === false && rows !== dialogRows
193
194    if (isStale) {
195      dialogRows = rows
196      void engine.openPane(dialogPane()).catch(() => undefined)
197    }
198  }
199
200  function redraw(engine: Host) {
201    fitDialog(engine)
202
203    if (timers.has('redraw')) {
204      return
205    }
206
207    timers.set(
208      'redraw',
209      engine.after(Limits.REDRAW_COALESCE_MS, () => {
210        timers.delete('redraw')
211        engine.invalidate()
212      }),
213    )
214  }
215
216  async function loadBodies(engine: Host): Promise<boolean> {
217    const { data } = model
218    const pinned = backend
219
220    if (!data || !pinned) {
221      bodyStamp = null
222      bodyBase = null
223      bodyLoads.clear()
224      model = { ...model, bodies: PaneState.NO_BODIES }
225
226      return false
227    }
228
229    const stamp = bodyStampOf(data)
230    const isNewBase = data.baseRef !== bodyBase
231
232    if (stamp !== bodyStamp) {
233      bodyStamp = stamp
234      bodyLoads.clear()
235    }
236
237    if (isNewBase) {
238      bodyBase = data.baseRef
239      model = { ...model, bodies: PaneState.NO_BODIES }
240      redraw(engine)
241    }
242
243    return fetchBodies(engine, pinned, data)
244  }
245
246  const bodyStampOf = (data: Git.DiffData) => `${generation}|${data.baseRef}`
247
248  async function fetchBodies(
249    engine: Host,
250    pinned: Backend.Backend,
251    data: Git.DiffData,
252  ): Promise<boolean> {
253    const stamp = bodyStampOf(data)
254
255    const files = drawnFilesOf(model).filter(file => !bodyLoads.has(file.path))
256
257    for (const file of files) {
258      bodyLoads.add(file.path)
259    }
260
261    const read = await pinned.fetchHunks(data, files)
262    const isCurrent = bodyStamp === stamp && read.size > 0
263
264    if (isCurrent) {
265      model = { ...model, bodies: new Map([...model.bodies, ...read]) }
266      redraw(engine)
267    }
268
269    return [...read.values()].includes(null)
270  }
271
272  function keepBaseline() {
273    const pinned = backend
274
275    if (!pinned || polled.headKey !== '') {
276      return
277    }
278
279    void pinned
280      .headKeyOf()
281      .catch(() => '')
282      .then(key => {
283        const isFirst = backend === pinned && polled.headKey === ''
284
285        if (isFirst) {
286          polled.headKey = key
287        }
288      })
289  }
290
291  function startPoll(engine: Host, pinned: Backend.Backend) {
292    const readHeadKey = () => pinned.headKeyOf().catch(() => '')
293
294    if (polled.toplevel === pinned.repository.toplevel) {
295      return
296    }
297
298    timers.get('poll')?.cancel()
299    polled.toplevel = pinned.repository.toplevel
300    polled.headKey = ''
301
302    timers.set(
303      'poll',
304      engine.every(Limits.HEAD_POLL_MS, () => {
305        if (!isPaneOpen) {
306          return
307        }
308
309        void readHeadKey().then(key => {
310          const hasMoved = polled.headKey !== '' && key !== polled.headKey
311
312          polled.headKey = key
313
314          if (hasMoved) {
315            scheduleRefresh(engine)
316          }
317        })
318      }),
319    )
320  }
321
322  async function refresh(engine: Host): Promise<void> {
323    if (isRefreshing) {
324      isRefreshQueued = true
325
326      return
327    }
328
329    isRefreshing = true
330
331    const record = Record.recorderOf(engine)
332    const pinned = backend
333
334    const fetched = (): Promise<Git.FetchOutcome> =>
335      pinned
336        ? pinned.fetchDiff(model.requestedMode)
337        : Promise.resolve({ kind: 'no-repository' })
338
339    try {
340      model = { ...model, isLoading: model.data === null }
341
342      const [outcome, messages] = await Promise.all([
343        fetched(),
344        engine.messages().catch((): SessionMessage[] => []),
345      ])
346
347      model = PaneState.afterFetch(model, { outcome, messages })
348
349      switch (outcome.kind) {
350        case 'no-repository':
351          break
352        case 'unavailable':
353          record.mark(Record.FEATURES.read, {
354            kind: 'sad',
355            reason: 'git_diff_failed',
356          })
357
358          break
359        case 'data':
360          generation += 1
361
362          break
363      }
364
365      const hasHunksFailed = await loadBodies(engine)
366
367      if (outcome.kind === 'data') {
368        record.mark(
369          Record.FEATURES.read,
370          hasHunksFailed
371            ? { kind: 'sad', reason: 'git_hunks_failed' }
372            : { kind: 'ok' },
373        )
374      }
375    } catch (error) {
376      record.mark(Record.FEATURES.read, {
377        kind: 'sad',
378        reason: 'git_diff_threw',
379      })
380
381      throw error
382    } finally {
383      isRefreshing = false
384      redraw(engine)
385
386      if (isRefreshQueued) {
387        isRefreshQueued = false
388        scheduleRefresh(engine)
389      }
390    }
391  }
392
393  function scheduleRefresh(engine: Host): void {
394    timers.get('refresh')?.cancel()
395
396    timers.set(
397      'refresh',
398      engine.after(Limits.REFRESH_DEBOUNCE_MS, () => {
399        timers.delete('refresh')
400        void refresh(engine)
401      }),
402    )
403  }
404
405  async function markShown(
406    engine: Host,
407    trigger: (typeof Record.SHOWN_TRIGGERS)[number],
408  ): Promise<void> {
409    const sessionId = await engine.sessionId().catch(() => null)
410
411    if (sessionId !== null && sessionId !== shownSessionId) {
412      shownSessionId = sessionId
413      Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns))
414    }
415  }
416
417  async function openPane(
418    engine: Host,
419    trigger: (typeof Record.SHOWN_TRIGGERS)[number],
420  ): Promise<boolean> {
421    const isDialog = model.isFullscreen === false
422
423    model = {
424      ...model,
425      selectedPath: null,
426      dialogView: 'list',
427      place: { ...model.place, top: 0, listStart: 0 },
428    }
429
430    dialogRows = isDialog ? Views.dialogRowsOf(model) : null
431
432    const landedBefore = landed
433
434    if (!isDialog) {
435      await refresh(engine).catch(() => undefined)
436    }
437
438    const opened = await engine.openPane(
439      isDialog
440        ? { ...dialogPane(), focus: true }
441        : { id: Names.PANE_ID, title: Names.PANE_TITLE },
442    )
443
444    const isWaiting = isRecord(opened) && opened.isPlaced === false
445
446    if (isWaiting) {
447      await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined)
448
449      return false
450    }
451
452    isPaneOpen = true
453    keepBaseline()
454
455    await markShown(engine, trigger)
456
457    const isStale = isDialog || landed !== landedBefore
458
459    if (isStale) {
460      void refresh(engine)
461    }
462
463    return true
464  }
465
466  async function closePane(engine: Host): Promise<void> {
467    await engine.closePane({ id: Names.PANE_ID })
468    isPaneOpen = false
469  }
470
471  function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) {
472    Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, {
473      kind: 'ok',
474      props: { tab: { value: tab, of: Record.TABS } },
475    })
476  }
477
478  async function openOnFirstEdit(engine: Host): Promise<void> {
479    const isTaken = () => isPaneOpen || hasAutoOpened
480
481    if (isTaken()) {
482      return
483    }
484
485    const preference = await engine.storeGet(Names.STORE_OPEN_KEY)
486    const isKeptOpen = preference === true
487
488    const floor = isKeptOpen
489      ? Limits.OPEN_MIN_COLUMNS
490      : Limits.AUTO_OPEN_MIN_COLUMNS
491
492    const hasRoom =
493      preference !== false &&
494      model.isFullscreen === true &&
495      columns !== null &&
496      columns >= floor
497
498    if (!hasRoom || isTaken()) {
499      return
500    }
501
502    const isCheckpointed = await engine.isCheckpointing().catch(() => true)
503
504    if (!isCheckpointed || isTaken()) {
505      return
506    }
507
508    await pinBackend(engine)
509
510    if (!backend || isTaken()) {
511      return
512    }
513
514    hasAutoOpened = true
515    hasAutoOpened = await openPane(engine, 'auto_open')
516  }
517
518  async function openOnRestore(engine: Host): Promise<void> {
519    hasRestoredEdits =
520      Turns.turnDiffsOf(
521        await engine.messages().catch((): SessionMessage[] => []),
522      ).length > 0
523
524    if (hasRestoredEdits) {
525      await openOnFirstEdit(engine)
526    }
527  }
528
529  function disarm(engine: Host) {
530    armed = null
531    model = { ...model, armedPath: null }
532    engine.status(undefined)
533  }
534
535  const actionsOf = (engine: Host): Views.PaneActions => ({
536    selectFile: path => {
537      const isDocked = model.placement === 'dock'
538
539      model = {
540        ...model,
541        selectedPath: path,
542        dialogView: isDocked ? model.dialogView : 'detail',
543        place: isDocked ? Views.placeAtFile(model, path) : model.place,
544      }
545
546      void loadBodies(engine).catch(() => undefined)
547      redraw(engine)
548    },
549    scrollList: delta => {
550      model = { ...model, place: Views.listScrolledBy(model, delta) }
551      redraw(engine)
552    },
553    toggleNoise: () => {
554      model = { ...model, isNoiseShown: !model.isNoiseShown }
555      void loadBodies(engine)
556      redraw(engine)
557    },
558    togglePreSession: () => {
559      model = { ...model, isPreSessionShown: !model.isPreSessionShown }
560      void loadBodies(engine)
561      redraw(engine)
562    },
563    cycleBase: () => {
564      const { baseModes, requestedMode } = model
565
566      const mode =
567        baseModes[(baseModes.indexOf(requestedMode) + 1) % baseModes.length] ??
568        requestedMode
569
570      if (mode === requestedMode) {
571        return
572      }
573
574      model = { ...model, requestedMode: mode }
575
576      Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, {
577        kind: 'ok',
578        props: { mode: { value: mode, of: model.baseModes } },
579      })
580
581      const toplevel = model.data?.repository.toplevel
582
583      if (toplevel !== undefined) {
584        void engine
585          .storeSet(Names.baseStoreKeyOf(toplevel), mode)
586          .catch(() => undefined)
587      }
588
589      void refresh(engine)
590      redraw(engine)
591    },
592    chooseSource: value => {
593      const index = Number(value)
594      const isTurn = value !== 'current' && Number.isInteger(index)
595
596      const source: PaneState.Source = isTurn
597        ? { kind: 'turn', index }
598        : { kind: 'current' }
599
600      model = { ...model, source, selectedPath: null, dialogView: 'list' }
601      redraw(engine)
602    },
603    toggleAsk: path => {
604      if (armed?.path === path) {
605        disarm(engine)
606        redraw(engine)
607
608        return
609      }
610
611      arm(engine, path)
612    },
613  })
614
615  function arm(engine: Host, path: string) {
616    armed = Ask.armedAskOf(
617      path,
618      PaneState.pickedTurnOf(model)?.files.find(file => file.path === path)
619        ?.hunks ??
620        model.bodies.get(path)?.hunks ??
621        [],
622    )
623
624    model = { ...model, armedPath: path }
625
626    engine.status(
627      `${Views.sanitizeName(path)} rides your next prompt (press ` +
628        `asked ✓ to drop it)`,
629    )
630
631    redraw(engine)
632  }
633
634  async function startedAtOf(engine: Host): Promise<number | null> {
635    const startedAt: unknown = await engine.startedAt().catch(() => undefined)
636
637    return typeof startedAt === 'number' ? startedAt : null
638  }
639
640  async function bind(engine: Host, cwd: string): Promise<void> {
641    sessionStartMs = (await startedAtOf(engine)) ?? (await engine.now())
642    pin.cwd = cwd
643
644    try {
645      await engine.registerCommand(COMMAND_SPEC)
646      host = engine
647    } catch (error) {
648      const reason = messageOf(error)
649
650      if (!Names.BUILTIN_HOLDS_PATTERN.test(reason)) {
651        engine.uiLog(Names.registerFailedTextOf(Views.sanitizeName(reason)))
652      }
653    }
654  }
655
656  on('session.start', async ($, e, next) => {
657    await bind(
658      {
659        now: () => $.clock.now(),
660        after: (ms, fn) => $.clock.after(ms, fn),
661        every: (ms, fn) => $.clock.every(ms, fn),
662        run: (argv, init) => $.process.run(argv, init),
663        stat: path => $.fs.stat(path),
664        listDir: path => $.fs.list(path),
665        readFile: path => $.fs.read(path),
666        storeGet: key => $.store.get(key),
667        storeSet: (key, value) => $.store.set(key, value),
668        isCheckpointing: async () =>
669          isCheckpointing(
670            await $.settings.read(),
671            await $.env.get('CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING'),
672          ),
673        messages: () => $.session.messages(),
674        invalidate: () => $.ui.invalidate('ui.render'),
675        status: text => $.ui.status(text),
676        uiLog: text => $.ui.log(text),
677        openPane: pane => $.ui.open(pane),
678        closePane: pane => $.ui.close(pane),
679        registerCommand: spec => $.command.register(spec),
680        sessionId: () => $.session.id(),
681        startedAt: () =>
682          $.session
683            .usage()
684            .then((usage: unknown) =>
685              isRecord(usage) ? usage.startedAt : undefined,
686            ),
687        mark: entry => $.telemetry.mark(entry),
688        log: entry => $.telemetry.log(entry),
689      },
690      e.cwd,
691    )
692
693    if (host) {
694      void openOnRestore(host).catch(() => undefined)
695    }
696
697    return next(e)
698  })
699
700  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
701    if (isOnPaneSurface(e)) {
702      const { viewport } = e
703
704      const isFirstMeasure = columns === null && viewport?.columns !== undefined
705
706      columns = viewport?.columns ?? columns
707
708      model = {
709        ...model,
710        isFullscreen: viewport?.isFullscreen ?? model.isFullscreen,
711      }
712
713      if (isFirstMeasure && hasRestoredEdits && host) {
714        void openOnFirstEdit(host).catch(() => undefined)
715      }
716    }
717
718    return next(e)
719  })
720
721  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
722    if (e.requestId !== Names.PANE_ID || !host || !isOnPaneSurface(e)) {
723      return next(e)
724    }
725
726    const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
727
728    columns = e.viewport?.columns ?? columns
729
730    /**
731     * A seat that changed since the last drawing lists other rows, whose
732     * bodies are read once.
733     *
734     * The model takes the new seat in this same pass, so the drawing that
735     * read asks for finds the seat unchanged.
736     */
737    const isReseated = e.props.placement !== model.placement
738
739    model = {
740      ...model,
741      placement: e.props.placement,
742      place: {
743        ...model.place,
744        columns: Math.max(
745          1,
746          e.props.bodyColumns - Limits.PANE_RIGHT_PAD_COLUMNS,
747        ),
748        rows: e.props.scroll.bodyRows,
749      },
750    }
751
752    if (isReseated) {
753      void loadBodies(host).catch(() => undefined)
754    }
755
756    return Views.paneView(
757      {
758        ui: { Box, Text, Button, Select, Code },
759        actions: actionsOf(host),
760        columns: e.props.bodyColumns,
761        rows: e.props.scroll.bodyRows,
762      },
763      model,
764      { placement: e.props.placement, terminalColumns: columns },
765    )
766  })
767
768  on('command.run', { command: 'diff' }, async ($, e, next) => {
769    if (!host) {
770      return next(e)
771    }
772
773    const isAnswered = (await pinBackend(host)) || (await pinBackend(host))
774
775    if (!backend) {
776      return {
777        text: isAnswered
778          ? Names.NOT_IN_REPOSITORY_TEXT
779          : Names.GIT_UNANSWERED_TEXT,
780      }
781    }
782
783    const { isFullscreen } = e.presentation
784
785    columns = e.presentation.columns
786    model = { ...model, isFullscreen }
787
788    const toggle = PaneToggle.paneToggleOf({
789      isOpen: isPaneOpen,
790      columns: isFullscreen ? columns : null,
791    })
792
793    if (toggle === 'too-narrow') {
794      return { text: Names.RESIZE_TERMINAL_TEXT }
795    }
796
797    const isOpening = toggle === 'open'
798
799    const isDone = isOpening
800      ? await openPane(host, 'manual')
801      : await closePane(host).then(() => true)
802
803    if (!isDone) {
804      return { text: Names.RESIZE_TERMINAL_TEXT }
805    }
806
807    if (!isFullscreen) {
808      return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
809    }
810
811    markTabSwitch(host, isOpening ? 'diff' : 'convo')
812    await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
813
814    return {
815      text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT,
816    }
817  })
818
819  on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
820    const isBack =
821      e.origin.kind === 'person' &&
822      model.placement === 'inline' &&
823      model.dialogView === 'detail'
824
825    if (isBack && host) {
826      model = { ...model, dialogView: 'list' }
827      redraw(host)
828
829      dialogRows = Views.dialogRowsOf(model)
830
831      void host
832        .openPane({ ...dialogPane(), focus: true })
833        .catch(() => undefined)
834
835      return { deny: 'back to the file list' }
836    }
837
838    const result = await next(e)
839    const isClosed = result.deny === undefined
840    const isPersons = isClosed && e.origin.kind === 'person'
841
842    if (isClosed) {
843      isPaneOpen = false
844    }
845
846    const isDocking = model.isFullscreen !== false
847
848    if (isPersons && host && isDocking) {
849      markTabSwitch(host, 'convo')
850      await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined)
851    }
852
853    return result
854  })
855
856  on('ui.focus', { plugin: Names.PLUGIN_NAMES }, ($, e, next) => {
857    const isListed =
858      model.placement === 'inline' && model.dialogView === 'list' && host
859
860    const focus = isListed ? Views.dialogFocusOf(model, e.element) : null
861
862    if (focus === 'stay') {
863      return {}
864    }
865
866    if (!focus || !host) {
867      return next(e)
868    }
869
870    model = { ...model, selectedPath: focus.selectedPath }
871    void loadBodies(host).catch(() => undefined)
872    fitDialog(host)
873    host.invalidate()
874
875    return next({ ...e, element: focus.landing })
876  })
877
878  on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => {
879    const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock'
880
881    if (!isOwnBody || !host) {
882      return next(e)
883    }
884
885    const isOverList = Views.isWheelOverList(model, e)
886
887    model = {
888      ...model,
889      place: isOverList
890        ? Views.listScrolledBy(model, e.by)
891        : Views.bodyScrolledBy(model, e),
892    }
893
894    host.invalidate()
895
896    return {}
897  })
898
899  on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
900    const result = await next(e)
901
902    if (!host) {
903      return result
904    }
905
906    const isResume = e.command === 'resume'
907    const isKeptOpen = isPaneOpen && !isResume
908
909    if (isPaneOpen && isResume) {
910      await closePane(host).catch(() => undefined)
911    }
912
913    unpin()
914    hasAutoOpened = false
915    hasRestoredEdits = false
916    bodyStamp = null
917    bodyBase = null
918    bodyLoads.clear()
919    disarm(host)
920    model = PaneState.afterNewSession(model)
921
922    sessionStartMs =
923      (await startedAtOf(host)) ??
924      (isResume ? sessionStartMs : await host.now())
925
926    if (isKeptOpen) {
927      await markShown(host, 'manual')
928      await pinBackend(host)
929      keepBaseline()
930      void refresh(host)
931    }
932
933    if (isResume) {
934      void openOnRestore(host).catch(() => undefined)
935    }
936
937    return result
938  })
939
940  function afterTool(
941    engine: Host,
942    e: Args<'tool.call'>,
943    result: ResultOf['tool.call'] | undefined,
944  ) {
945    const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool)
946
947    const hasEdited =
948      isEdit &&
949      result !== undefined &&
950      result.deny === undefined &&
951      result.isError !== true
952
953    const hasLanded = isEdit ? hasEdited : Tools.mayHaveWritten(result)
954
955    if (hasLanded) {
956      landed += 1
957    }
958
959    if (hasLanded && isPaneOpen) {
960      scheduleRefresh(engine)
961    }
962
963    const isMainLoopEdit = hasEdited && e.agentId === undefined
964
965    if (isMainLoopEdit) {
966      void openOnFirstEdit(engine).catch(() => undefined)
967    }
968  }
969
970  on(
971    'tool.call',
972    { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] },
973    async ($, e, next) => {
974      let result: ResultOf['tool.call'] | undefined
975
976      try {
977        result = await next(e)
978
979        return result
980      } finally {
981        if (host) {
982          afterTool(host, e, result)
983        }
984      }
985    },
986  )
987
988  on('prompt.submit', async ($, e, next) => {
989    const asked = armed
990
991    if (!host || !asked || carrying === asked) {
992      return next(e)
993    }
994
995    const context = e.context ?? []
996
997    const text = Ask.fittedAskTextOf(
998      asked.text,
999      Limits.PROMPT_CONTEXT_MAX_CHARS -
1000        context.reduce((sum, entry) => sum + entry.length, 0),
1001    )
1002
1003    if (text === undefined) {
1004      disarm(host)
1005
1006      host.status(
1007        `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` +
1008          `and was dropped`,
1009      )
1010
1011      redraw(host)
1012
1013      return next(e)
1014    }
1015
1016    carrying = asked
1017
1018    try {
1019      const result = await next({ ...e, context: [...context, text] })
1020
1021      if (result.drop === undefined) {
1022        Record.recorderOf(host).asked()
1023
1024        if (armed === asked) {
1025          disarm(host)
1026          redraw(host)
1027        }
1028      }
1029
1030      return result
1031    } finally {
1032      carrying = null
1033    }
1034  })
1035}
1036
hooks/ask/index.ts 7 lines
1export * from './armed-ask'
2export * from './armed-ask-of.js'
3export * from './ask-cut-note'
4export * from './fitted-ask-text-of.js'
5
6export * as default from '.'
7
hooks/backend/index.ts 6 lines
1export * from './backend-of.js'
2export * from './installed-backend-probes.js'
3export * from './types'
4
5export * as default from '.'
6
hooks/command-spec/index.ts 4 lines
1export * from './command-spec.js'
2
3export * as default from '.'
4
hooks/drawn-files-of/index.ts 4 lines
1export * from './drawn-files-of.js'
2
3export * as default from '.'
4
hooks/entry-kinds-of/index.ts 4 lines
1export * from './entry-kinds-of.js'
2
3export * as default from '.'
4
hooks/git/index.ts 21 lines
1export * from './argv'
2export * from './cut-hunks'
3export * from './display-path-of'
4export * from './empty-file-hunks'
5export * from './failed-run'
6export * from './fetch-diff'
7export * from './fetch-hunks'
8export * from './git-backend-of.js'
9export * from './git-base-modes'
10export * from './git-words'
11export * from './hunk-after.js'
12export * from './hunk-of'
13export * from './large-file-hunks'
14export * from './parse'
15export * from './probes'
16export * from './rename-display-of'
17export * from './tiers'
18export * from './types'
19
20export * as default from '.'
21
hooks/host/index.ts 4 lines
1export type * from './host.js'
2
3export * as default from '.'
4
hooks/is-checkpointing/index.ts 4 lines
1export * from './is-checkpointing.js'
2
3export * as default from '.'
4
hooks/is-on-pane-surface/index.ts 4 lines
1export * from './is-on-pane-surface.js'
2
3export * as default from '.'
4
hooks/is-record/index.ts 4 lines
1export * from './is-record.js'
2
3export * as default from '.'
4
hooks/limits/index.ts 9 lines
1export * from './columns'
2export * from './dialog-path-floor.js'
3export * from './sizes'
4export * from './timing'
5export * from './todo-bar-cells.js'
6export * from './todo-bar-reserve.js'
7
8export * as default from '.'
9