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