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/hatan4ik-prompts-and-agents-diff/hooks/ask/ask-cut-note/index.ts"
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 are held while it is up. 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 40 files
hooks/register.ts 1026 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      holdToasts: true,
184      closeOnEscape: true,
185      rows: Views.dialogRowsOf(model),
186    }
187  }
188
189  function fitDialog(engine: Host) {
190    const rows = Views.dialogRowsOf(model)
191
192    const isStale =
193      isPaneOpen && model.isFullscreen === false && rows !== dialogRows
194
195    if (isStale) {
196      dialogRows = rows
197      void engine.openPane(dialogPane()).catch(() => undefined)
198    }
199  }
200
201  function redraw(engine: Host) {
202    fitDialog(engine)
203
204    if (timers.has('redraw')) {
205      return
206    }
207
208    timers.set(
209      'redraw',
210      engine.after(Limits.REDRAW_COALESCE_MS, () => {
211        timers.delete('redraw')
212        engine.invalidate()
213      }),
214    )
215  }
216
217  async function loadBodies(engine: Host): Promise<boolean> {
218    const { data } = model
219    const pinned = backend
220
221    if (!data || !pinned) {
222      bodyStamp = null
223      bodyBase = null
224      bodyLoads.clear()
225      model = { ...model, bodies: PaneState.NO_BODIES }
226
227      return false
228    }
229
230    const stamp = bodyStampOf(data)
231    const isNewBase = data.baseRef !== bodyBase
232
233    if (stamp !== bodyStamp) {
234      bodyStamp = stamp
235      bodyLoads.clear()
236    }
237
238    if (isNewBase) {
239      bodyBase = data.baseRef
240      model = { ...model, bodies: PaneState.NO_BODIES }
241      redraw(engine)
242    }
243
244    return fetchBodies(engine, pinned, data)
245  }
246
247  const bodyStampOf = (data: Git.DiffData) => `${generation}|${data.baseRef}`
248
249  async function fetchBodies(
250    engine: Host,
251    pinned: Backend.Backend,
252    data: Git.DiffData,
253  ): Promise<boolean> {
254    const stamp = bodyStampOf(data)
255
256    const files = drawnFilesOf(model).filter(file => !bodyLoads.has(file.path))
257
258    for (const file of files) {
259      bodyLoads.add(file.path)
260    }
261
262    const read = await pinned.fetchHunks(data, files)
263    const isCurrent = bodyStamp === stamp && read.size > 0
264
265    if (isCurrent) {
266      model = { ...model, bodies: new Map([...model.bodies, ...read]) }
267      redraw(engine)
268    }
269
270    return [...read.values()].includes(null)
271  }
272
273  function keepBaseline() {
274    const pinned = backend
275
276    if (!pinned || polled.headKey !== '') {
277      return
278    }
279
280    void pinned
281      .headKeyOf()
282      .catch(() => '')
283      .then(key => {
284        const isFirst = backend === pinned && polled.headKey === ''
285
286        if (isFirst) {
287          polled.headKey = key
288        }
289      })
290  }
291
292  function startPoll(engine: Host, pinned: Backend.Backend) {
293    const readHeadKey = () => pinned.headKeyOf().catch(() => '')
294
295    if (polled.toplevel === pinned.repository.toplevel) {
296      return
297    }
298
299    timers.get('poll')?.cancel()
300    polled.toplevel = pinned.repository.toplevel
301    polled.headKey = ''
302
303    timers.set(
304      'poll',
305      engine.every(Limits.HEAD_POLL_MS, () => {
306        if (!isPaneOpen) {
307          return
308        }
309
310        void readHeadKey().then(key => {
311          const hasMoved = polled.headKey !== '' && key !== polled.headKey
312
313          polled.headKey = key
314
315          if (hasMoved) {
316            scheduleRefresh(engine)
317          }
318        })
319      }),
320    )
321  }
322
323  async function refresh(engine: Host): Promise<void> {
324    if (isRefreshing) {
325      isRefreshQueued = true
326
327      return
328    }
329
330    isRefreshing = true
331
332    const record = Record.recorderOf(engine)
333    const pinned = backend
334
335    const fetched = (): Promise<Git.FetchOutcome> =>
336      pinned
337        ? pinned.fetchDiff(model.requestedMode)
338        : Promise.resolve({ kind: 'no-repository' })
339
340    try {
341      model = { ...model, isLoading: model.data === null }
342
343      const [outcome, messages] = await Promise.all([
344        fetched(),
345        engine.messages().catch((): SessionMessage[] => []),
346      ])
347
348      model = PaneState.afterFetch(model, { outcome, messages })
349
350      switch (outcome.kind) {
351        case 'no-repository':
352          break
353        case 'unavailable':
354          record.mark(Record.FEATURES.read, {
355            kind: 'sad',
356            reason: 'git_diff_failed',
357          })
358
359          break
360        case 'data':
361          generation += 1
362
363          break
364      }
365
366      const hasHunksFailed = await loadBodies(engine)
367
368      if (outcome.kind === 'data') {
369        record.mark(
370          Record.FEATURES.read,
371          hasHunksFailed
372            ? { kind: 'sad', reason: 'git_hunks_failed' }
373            : { kind: 'ok' },
374        )
375      }
376    } catch (error) {
377      record.mark(Record.FEATURES.read, {
378        kind: 'sad',
379        reason: 'git_diff_threw',
380      })
381
382      throw error
383    } finally {
384      isRefreshing = false
385      redraw(engine)
386
387      if (isRefreshQueued) {
388        isRefreshQueued = false
389        scheduleRefresh(engine)
390      }
391    }
392  }
393
394  function scheduleRefresh(engine: Host): void {
395    timers.get('refresh')?.cancel()
396
397    timers.set(
398      'refresh',
399      engine.after(Limits.REFRESH_DEBOUNCE_MS, () => {
400        timers.delete('refresh')
401        void refresh(engine)
402      }),
403    )
404  }
405
406  async function markShown(
407    engine: Host,
408    trigger: (typeof Record.SHOWN_TRIGGERS)[number],
409  ): Promise<void> {
410    const sessionId = await engine.sessionId().catch(() => null)
411
412    if (sessionId !== null && sessionId !== shownSessionId) {
413      shownSessionId = sessionId
414      Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns))
415    }
416  }
417
418  async function openPane(
419    engine: Host,
420    trigger: (typeof Record.SHOWN_TRIGGERS)[number],
421  ): Promise<boolean> {
422    const isDialog = model.isFullscreen === false
423
424    model = {
425      ...model,
426      selectedPath: null,
427      dialogView: 'list',
428      place: { ...model.place, top: 0, listStart: 0 },
429    }
430
431    dialogRows = isDialog ? Views.dialogRowsOf(model) : null
432
433    const landedBefore = landed
434
435    if (!isDialog) {
436      await refresh(engine).catch(() => undefined)
437    }
438
439    const opened = await engine.openPane(
440      isDialog
441        ? { ...dialogPane(), focus: true }
442        : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true },
443    )
444
445    const isWaiting = isRecord(opened) && opened.isPlaced === false
446
447    if (isWaiting) {
448      await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined)
449
450      return false
451    }
452
453    isPaneOpen = true
454    keepBaseline()
455
456    await markShown(engine, trigger)
457
458    const isStale = isDialog || landed !== landedBefore
459
460    if (isStale) {
461      void refresh(engine)
462    }
463
464    return true
465  }
466
467  async function closePane(engine: Host): Promise<void> {
468    await engine.closePane({ id: Names.PANE_ID })
469    isPaneOpen = false
470  }
471
472  function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) {
473    Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, {
474      kind: 'ok',
475      props: { tab: { value: tab, of: Record.TABS } },
476    })
477  }
478
479  async function openOnFirstEdit(engine: Host): Promise<void> {
480    const isTaken = () => isPaneOpen || hasAutoOpened
481
482    if (isTaken()) {
483      return
484    }
485
486    const preference = await engine.storeGet(Names.STORE_OPEN_KEY)
487    const isKeptOpen = preference === true
488
489    const floor = isKeptOpen
490      ? Limits.OPEN_MIN_COLUMNS
491      : Limits.AUTO_OPEN_MIN_COLUMNS
492
493    const hasRoom =
494      preference !== false &&
495      model.isFullscreen === true &&
496      columns !== null &&
497      columns >= floor
498
499    if (!hasRoom || isTaken()) {
500      return
501    }
502
503    const isCheckpointed = await engine.isCheckpointing().catch(() => true)
504
505    if (!isCheckpointed || isTaken()) {
506      return
507    }
508
509    await pinBackend(engine)
510
511    if (!backend || isTaken()) {
512      return
513    }
514
515    hasAutoOpened = true
516    hasAutoOpened = await openPane(engine, 'auto_open')
517  }
518
519  async function openOnRestore(engine: Host): Promise<void> {
520    hasRestoredEdits =
521      Turns.turnDiffsOf(
522        await engine.messages().catch((): SessionMessage[] => []),
523      ).length > 0
524
525    if (hasRestoredEdits) {
526      await openOnFirstEdit(engine)
527    }
528  }
529
530  function disarm(engine: Host) {
531    armed = null
532    model = { ...model, armedPath: null }
533    engine.status(undefined)
534  }
535
536  const actionsOf = (engine: Host): Views.PaneActions => ({
537    selectFile: path => {
538      const isDocked = model.placement === 'dock'
539
540      model = {
541        ...model,
542        selectedPath: path,
543        dialogView: isDocked ? model.dialogView : 'detail',
544        place: isDocked ? Views.placeAtFile(model, path) : model.place,
545      }
546
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    model = {
731      ...model,
732      placement: e.props.placement,
733      place: {
734        ...model.place,
735        columns: Math.max(
736          1,
737          e.props.bodyColumns - Limits.PANE_RIGHT_PAD_COLUMNS,
738        ),
739        rows: e.props.scroll.bodyRows,
740      },
741    }
742
743    return Views.paneView(
744      {
745        ui: { Box, Text, Button, Select, Code },
746        actions: actionsOf(host),
747        columns: e.props.bodyColumns,
748        rows: e.props.scroll.bodyRows,
749      },
750      model,
751      { placement: e.props.placement, terminalColumns: columns },
752    )
753  })
754
755  on('command.run', { command: 'diff' }, async ($, e, next) => {
756    if (!host) {
757      return next(e)
758    }
759
760    const isAnswered = (await pinBackend(host)) || (await pinBackend(host))
761
762    if (!backend) {
763      return {
764        text: isAnswered
765          ? Names.NOT_IN_REPOSITORY_TEXT
766          : Names.GIT_UNANSWERED_TEXT,
767      }
768    }
769
770    const { isFullscreen } = e.presentation
771
772    columns = e.presentation.columns
773    model = { ...model, isFullscreen }
774
775    const toggle = PaneToggle.paneToggleOf({
776      isOpen: isPaneOpen,
777      columns: isFullscreen ? columns : null,
778    })
779
780    if (toggle === 'too-narrow') {
781      return { text: Names.RESIZE_TERMINAL_TEXT }
782    }
783
784    const isOpening = toggle === 'open'
785
786    const isDone = isOpening
787      ? await openPane(host, 'manual')
788      : await closePane(host).then(() => true)
789
790    if (!isDone) {
791      return { text: Names.RESIZE_TERMINAL_TEXT }
792    }
793
794    if (!isFullscreen) {
795      return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
796    }
797
798    markTabSwitch(host, isOpening ? 'diff' : 'convo')
799    await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
800
801    return {
802      text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT,
803    }
804  })
805
806  on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
807    const isBack =
808      e.origin.kind === 'person' &&
809      model.placement === 'inline' &&
810      model.dialogView === 'detail'
811
812    if (isBack && host) {
813      model = { ...model, dialogView: 'list' }
814      redraw(host)
815
816      dialogRows = Views.dialogRowsOf(model)
817
818      void host
819        .openPane({ ...dialogPane(), focus: true })
820        .catch(() => undefined)
821
822      return { deny: 'back to the file list' }
823    }
824
825    const result = await next(e)
826    const isClosed = result.deny === undefined
827    const isPersons = isClosed && e.origin.kind === 'person'
828
829    if (isClosed) {
830      isPaneOpen = false
831    }
832
833    const isDialog = model.isFullscreen === false
834
835    if (isPersons && host && isDialog) {
836      host.uiLog(Names.DIALOG_DISMISSED_TEXT)
837    }
838
839    if (isPersons && host && !isDialog) {
840      markTabSwitch(host, 'convo')
841      await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined)
842    }
843
844    return result
845  })
846
847  on('ui.focus', { plugin: Names.PLUGIN_NAMES }, ($, e, next) => {
848    const isListed =
849      model.placement === 'inline' && model.dialogView === 'list' && host
850
851    const focus = isListed ? Views.dialogFocusOf(model, e.element) : null
852
853    if (focus === 'stay') {
854      return {}
855    }
856
857    if (!focus || !host) {
858      return next(e)
859    }
860
861    model = { ...model, selectedPath: focus.selectedPath }
862    fitDialog(host)
863    host.invalidate()
864
865    return next({ ...e, element: focus.landing })
866  })
867
868  on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => {
869    const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock'
870
871    if (!isOwnBody || !host) {
872      return next(e)
873    }
874
875    const isOverList = Views.isWheelOverList(model, e)
876
877    model = {
878      ...model,
879      place: isOverList
880        ? Views.listScrolledBy(model, e.by)
881        : Views.bodyScrolledBy(model, e),
882    }
883
884    host.invalidate()
885
886    return {}
887  })
888
889  on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
890    const result = await next(e)
891
892    if (!host) {
893      return result
894    }
895
896    const isResume = e.command === 'resume'
897    const isKeptOpen = isPaneOpen && !isResume
898
899    if (isPaneOpen && isResume) {
900      await closePane(host).catch(() => undefined)
901    }
902
903    unpin()
904    hasAutoOpened = false
905    hasRestoredEdits = false
906    bodyStamp = null
907    bodyBase = null
908    bodyLoads.clear()
909    disarm(host)
910    model = PaneState.afterNewSession(model)
911
912    sessionStartMs =
913      (await startedAtOf(host)) ??
914      (isResume ? sessionStartMs : await host.now())
915
916    if (isKeptOpen) {
917      await markShown(host, 'manual')
918      await pinBackend(host)
919      keepBaseline()
920      void refresh(host)
921    }
922
923    if (isResume) {
924      void openOnRestore(host).catch(() => undefined)
925    }
926
927    return result
928  })
929
930  function afterTool(
931    engine: Host,
932    e: Args<'tool.call'>,
933    result: ResultOf['tool.call'] | undefined,
934  ) {
935    const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool)
936
937    const hasEdited =
938      isEdit &&
939      result !== undefined &&
940      result.deny === undefined &&
941      result.isError !== true
942
943    const hasLanded = isEdit ? hasEdited : Tools.mayHaveWritten(result)
944
945    if (hasLanded) {
946      landed += 1
947    }
948
949    if (hasLanded && isPaneOpen) {
950      scheduleRefresh(engine)
951    }
952
953    const isMainLoopEdit = hasEdited && e.agentId === undefined
954
955    if (isMainLoopEdit) {
956      void openOnFirstEdit(engine).catch(() => undefined)
957    }
958  }
959
960  on(
961    'tool.call',
962    { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] },
963    async ($, e, next) => {
964      let result: ResultOf['tool.call'] | undefined
965
966      try {
967        result = await next(e)
968
969        return result
970      } finally {
971        if (host) {
972          afterTool(host, e, result)
973        }
974      }
975    },
976  )
977
978  on('prompt.submit', async ($, e, next) => {
979    const asked = armed
980
981    if (!host || !asked || carrying === asked) {
982      return next(e)
983    }
984
985    const context = e.context ?? []
986
987    const text = Ask.fittedAskTextOf(
988      asked.text,
989      Limits.PROMPT_CONTEXT_MAX_CHARS -
990        context.reduce((sum, entry) => sum + entry.length, 0),
991    )
992
993    if (text === undefined) {
994      disarm(host)
995
996      host.status(
997        `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` +
998          `and was dropped`,
999      )
1000
1001      redraw(host)
1002
1003      return next(e)
1004    }
1005
1006    carrying = asked
1007
1008    try {
1009      const result = await next({ ...e, context: [...context, text] })
1010
1011      if (result.drop === undefined) {
1012        Record.recorderOf(host).asked()
1013
1014        if (armed === asked) {
1015          disarm(host)
1016          redraw(host)
1017        }
1018      }
1019
1020      return result
1021    } finally {
1022      carrying = null
1023    }
1024  })
1025}
1026
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