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 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.
| 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 1026 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 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}
1026hooks/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