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…

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