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