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