SLOPSHOPPER

diff

The diff pane: /diff and its Pane drawing, a refresh on X-Coder's edits, shell commands and finished turns, the pane's opening on the first edit, and the ask…

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/rudragupta10191-x-coder-1-test-diff/hooks/ask/ask-cut-note/index.t
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 X-Coder 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 (X_CODER_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 (X_CODER_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

x-coder --plugin-dir /path/to/diff

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

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