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