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