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
902 lines · 21.6 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 !== false &&
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 columns = e.viewport?.columns ?? columns
625 }
626
627 return next(e)
628 })
629
630 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
631 if (e.requestId !== Names.PANE_ID || !host || !isOnPaneSurface(e)) {
632 return next(e)
633 }
634
635 const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
636
637 columns = e.viewport?.columns ?? columns
638
639 model = {
640 ...model,
641 placement: e.props.placement,
642 place: {
643 ...model.place,
644 columns: Math.max(
645 1,
646 e.props.bodyColumns - Limits.PANE_RIGHT_PAD_COLUMNS,
647 ),
648 rows: e.props.scroll.bodyRows,
649 },
650 }
651
652 return Views.paneView(
653 {
654 ui: { Box, Text, Button, Select, Code },
655 actions: actionsOf(host),
656 columns: e.props.bodyColumns,
657 rows: e.props.scroll.bodyRows,
658 },
659 model,
660 { placement: e.props.placement, terminalColumns: columns },
661 )
662 })
663
664 on('command.run', { command: Names.COMMAND_NAME }, async ($, e, next) => {
665 if (!host) {
666 return next(e)
667 }
668
669 const isAnswered = (await pinBackend(host)) || (await pinBackend(host))
670
671 if (!backend) {
672 return {
673 text: isAnswered
674 ? Names.NOT_IN_REPOSITORY_TEXT
675 : Names.GIT_UNANSWERED_TEXT,
676 }
677 }
678
679 const { isFullscreen } = e.presentation
680
681 columns = e.presentation.columns
682 model = { ...model, isFullscreen }
683
684 const toggle = PaneToggle.paneToggleOf({
685 isOpen: isPaneOpen,
686 columns: isFullscreen ? columns : null,
687 })
688
689 if (toggle === 'too-narrow') {
690 return { text: Names.RESIZE_TERMINAL_TEXT }
691 }
692
693 const isOpening = toggle === 'open'
694 await (isOpening ? openPane(host, 'manual') : closePane(host))
695
696 if (!isFullscreen) {
697 return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
698 }
699
700 markTabSwitch(host, isOpening ? 'diff' : 'convo')
701 await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
702
703 return {
704 text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT,
705 }
706 })
707
708 on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
709 const isBack =
710 e.origin.kind === 'person' &&
711 model.placement === 'inline' &&
712 model.dialogView === 'detail'
713
714 if (isBack && host) {
715 model = { ...model, dialogView: 'list' }
716 redraw(host)
717
718 dialogRows = Views.dialogRowsOf(model)
719
720 void host
721 .openPane({ ...dialogPane(), focus: true })
722 .catch(() => undefined)
723
724 return { deny: 'back to the file list' }
725 }
726
727 const result = await next(e)
728 const isClosed = result.deny === undefined
729 const isPersons = isClosed && e.origin.kind === 'person'
730
731 if (isClosed) {
732 isPaneOpen = false
733 }
734
735 const isDialog = model.isFullscreen === false
736
737 if (isPersons && host && isDialog) {
738 host.uiLog(Names.DIALOG_DISMISSED_TEXT)
739 }
740
741 if (isPersons && host && !isDialog) {
742 markTabSwitch(host, 'convo')
743 await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined)
744 }
745
746 return result
747 })
748
749 on('ui.focus', { plugin: Names.PLUGIN_NAME }, ($, e, next) => {
750 const isListed =
751 model.placement === 'inline' && model.dialogView === 'list' && host
752
753 const focus = isListed ? Views.dialogFocusOf(model, e.element) : null
754
755 if (focus === 'stay') {
756 return {}
757 }
758
759 if (!focus || !host) {
760 return next(e)
761 }
762
763 model = { ...model, selectedPath: focus.selectedPath }
764 fitDialog(host)
765 host.invalidate()
766
767 return next({ ...e, element: focus.landing })
768 })
769
770 on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => {
771 const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock'
772
773 if (!isOwnBody || !host) {
774 return next(e)
775 }
776
777 const isOverList = Views.isWheelOverList(model, e)
778
779 model = {
780 ...model,
781 place: isOverList
782 ? Views.listScrolledBy(model, e.by)
783 : Views.bodyScrolledBy(model, e),
784 }
785
786 host.invalidate()
787
788 return {}
789 })
790
791 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
792 const result = await next(e)
793
794 if (host) {
795 if (isPaneOpen) {
796 await closePane(host).catch(() => undefined)
797 }
798
799 unpin()
800 hasAutoOpened = false
801 bodyStamp = null
802 bodyBase = null
803 bodyLoads.clear()
804 disarm(host)
805 model = PaneState.afterNewSession(model)
806 }
807
808 return result
809 })
810
811 function afterTool(
812 engine: Host,
813 tool: string,
814 result: ResultOf['tool.call'] | undefined,
815 ) {
816 const isEdit = Tools.EDITING_TOOLS.some(name => name === tool)
817
818 const hasEdited =
819 isEdit &&
820 result !== undefined &&
821 result.deny === undefined &&
822 result.isError !== true
823
824 const isStale =
825 isPaneOpen &&
826 (isEdit ? hasEdited : result === undefined || result.deny === undefined)
827
828 if (isStale) {
829 scheduleRefresh(engine)
830 }
831
832 if (hasEdited) {
833 void openOnFirstEdit(engine).catch(() => undefined)
834 }
835 }
836
837 on(
838 'tool.call',
839 { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] },
840 async ($, e, next) => {
841 let result: ResultOf['tool.call'] | undefined
842
843 try {
844 result = await next(e)
845
846 return result
847 } finally {
848 if (host) {
849 afterTool(host, e.tool, result)
850 }
851 }
852 },
853 )
854
855 on('prompt.submit', async ($, e, next) => {
856 const asked = armed
857
858 if (!host || !asked || carrying === asked) {
859 return next(e)
860 }
861
862 const context = e.context ?? []
863
864 const text = Ask.fittedAskTextOf(
865 asked.text,
866 Limits.PROMPT_CONTEXT_MAX_CHARS -
867 context.reduce((sum, entry) => sum + entry.length, 0),
868 )
869
870 if (text === undefined) {
871 disarm(host)
872
873 host.status(
874 `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` +
875 `and was dropped`,
876 )
877
878 redraw(host)
879
880 return next(e)
881 }
882
883 carrying = asked
884
885 try {
886 const result = await next({ ...e, context: [...context, text] })
887
888 if (result.drop === undefined) {
889 Record.recorderOf(host).asked()
890
891 if (armed === asked) {
892 disarm(host)
893 redraw(host)
894 }
895 }
896
897 return result
898 } finally {
899 carrying = null
900 }
901 })
902}