spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

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