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…

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