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