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