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