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