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