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