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
870 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` and binds the host (currentOf: the
33 * latest start's) and asks nothing of the repository; `/diff` or the
34 * first edit pins the backend (backendOf) when it first needs it.
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 }
568
569 on('session.start', async ($, e, next) => {
570 await bind({
571 now: () => $.clock.now(),
572 after: (ms, fn) => $.clock.after(ms, fn),
573 every: (ms, fn) => $.clock.every(ms, fn),
574 run: (argv, init) => $.process.run(argv, init),
575 stat: path => $.fs.stat(path),
576 listDir: path => $.fs.list(path),
577 readFile: path => $.fs.read(path),
578 storeGet: key => $.store.get(key),
579 storeSet: (key, value) => $.store.set(key, value),
580 messages: () => $.session.messages(),
581 invalidate: () => $.ui.invalidate('ui.render'),
582 status: text => $.ui.status(text),
583 uiLog: text => $.ui.log(text),
584 openPane: pane => $.ui.open(pane),
585 closePane: pane => $.ui.close(pane),
586 registerCommand: spec => $.command.register(spec),
587 sessionId: () => $.session.id(),
588 mark: entry => $.telemetry.mark(entry),
589 log: entry => $.telemetry.log(entry),
590 })
591
592 return next(e)
593 })
594
595 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
596 if (isOnPaneSurface(e)) {
597 columns = e.viewport?.columns ?? columns
598 }
599
600 return next(e)
601 })
602
603 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
604 if (e.requestId !== Names.PANE_ID || !host || !isOnPaneSurface(e)) {
605 return next(e)
606 }
607
608 const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
609
610 columns = e.viewport?.columns ?? columns
611
612 model = {
613 ...model,
614 placement: e.props.placement,
615 place: {
616 ...model.place,
617 columns: Math.max(
618 1,
619 e.props.bodyColumns - Limits.PANE_RIGHT_PAD_COLUMNS,
620 ),
621 rows: e.props.scroll.bodyRows,
622 },
623 }
624
625 return Views.paneView(
626 {
627 ui: { Box, Text, Button, Select, Code },
628 actions: actionsOf(host),
629 columns: e.props.bodyColumns,
630 rows: e.props.scroll.bodyRows,
631 },
632 model,
633 { placement: e.props.placement, terminalColumns: columns },
634 )
635 })
636
637 on('command.run', { command: Names.COMMAND_NAME }, async ($, e, next) => {
638 if (!host) {
639 return next(e)
640 }
641
642 const isAnswered = (await pinBackend(host)) || (await pinBackend(host))
643
644 if (!backend) {
645 return {
646 text: isAnswered
647 ? Names.NOT_IN_REPOSITORY_TEXT
648 : Names.GIT_UNANSWERED_TEXT,
649 }
650 }
651
652 const { isFullscreen } = e.presentation
653
654 columns = e.presentation.columns
655 model = { ...model, isFullscreen }
656
657 const toggle = PaneToggle.paneToggleOf({
658 isOpen: isPaneOpen,
659 columns: isFullscreen ? columns : null,
660 })
661
662 if (toggle === 'too-narrow') {
663 return { text: Names.RESIZE_TERMINAL_TEXT }
664 }
665
666 const isOpening = toggle === 'open'
667 await (isOpening ? openPane(host, 'manual') : closePane(host))
668
669 if (!isFullscreen) {
670 return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
671 }
672
673 markTabSwitch(host, isOpening ? 'diff' : 'convo')
674 await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
675
676 return {
677 text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT,
678 }
679 })
680
681 on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
682 const isBack =
683 e.origin.kind === 'person' &&
684 model.placement === 'inline' &&
685 model.dialogView === 'detail'
686
687 if (isBack && host) {
688 model = { ...model, dialogView: 'list' }
689 redraw(host)
690
691 dialogRows = Views.dialogRowsOf(model)
692
693 void host
694 .openPane({ ...dialogPane(), focus: true })
695 .catch(() => undefined)
696
697 return { deny: 'back to the file list' }
698 }
699
700 const result = await next(e)
701 const isClosed = result.deny === undefined
702 const isPersons = isClosed && e.origin.kind === 'person'
703
704 if (isClosed) {
705 isPaneOpen = false
706 }
707
708 const isDialog = model.isFullscreen === false
709
710 if (isPersons && host && isDialog) {
711 host.uiLog(Names.DIALOG_DISMISSED_TEXT)
712 }
713
714 if (isPersons && host && !isDialog) {
715 markTabSwitch(host, 'convo')
716 await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined)
717 }
718
719 return result
720 })
721
722 on('ui.focus', { plugin: Names.PLUGIN_NAME }, ($, e, next) => {
723 const isListed =
724 model.placement === 'inline' && model.dialogView === 'list' && host
725
726 const focus = isListed ? Views.dialogFocusOf(model, e.element) : null
727
728 if (focus === 'stay') {
729 return {}
730 }
731
732 if (!focus || !host) {
733 return next(e)
734 }
735
736 model = { ...model, selectedPath: focus.selectedPath }
737 fitDialog(host)
738 host.invalidate()
739
740 return next({ ...e, element: focus.landing })
741 })
742
743 on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => {
744 const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock'
745
746 if (!isOwnBody || !host) {
747 return next(e)
748 }
749
750 const isOverList = Views.isWheelOverList(model, e)
751
752 model = {
753 ...model,
754 place: isOverList
755 ? Views.listScrolledBy(model, e.by)
756 : Views.bodyScrolledBy(model, e),
757 }
758
759 host.invalidate()
760
761 return {}
762 })
763
764 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
765 const result = await next(e)
766
767 if (host) {
768 if (isPaneOpen) {
769 await closePane(host).catch(() => undefined)
770 }
771
772 hasAutoOpened = false
773 bodyStamp = null
774 bodyBase = null
775 bodyLoads.clear()
776 disarm(host)
777 model = PaneState.afterNewSession(model)
778 }
779
780 return result
781 })
782
783 on('tool.call', { tool: [...Tools.EDITING_TOOLS] }, async ($, e, next) => {
784 let result: ResultOf['tool.call'] | undefined
785
786 try {
787 result = await next(e)
788
789 return result
790 } finally {
791 if (host) {
792 if (isPaneOpen) {
793 scheduleRefresh(host)
794 }
795
796 const isEditDone = result !== undefined && !('deny' in result)
797
798 if (isEditDone) {
799 void openOnFirstEdit(host).catch(() => undefined)
800 }
801 }
802 }
803 })
804
805 on('tool.call', { tool: [...Tools.SHELL_TOOLS] }, async ($, e, next) => {
806 try {
807 return await next(e)
808 } finally {
809 if (host && isPaneOpen) {
810 scheduleRefresh(host)
811 }
812 }
813 })
814
815 on('turn.complete', ($, e, next) => {
816 if (host && isPaneOpen) {
817 scheduleRefresh(host)
818 }
819
820 return next(e)
821 })
822
823 on('prompt.submit', async ($, e, next) => {
824 const asked = armed
825
826 if (!host || !asked || carrying === asked) {
827 return next(e)
828 }
829
830 const context = e.context ?? []
831
832 const text = Ask.fittedAskTextOf(
833 asked.text,
834 Limits.PROMPT_CONTEXT_MAX_CHARS -
835 context.reduce((sum, entry) => sum + entry.length, 0),
836 )
837
838 if (text === undefined) {
839 disarm(host)
840
841 host.status(
842 `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` +
843 `and was dropped`,
844 )
845
846 redraw(host)
847
848 return next(e)
849 }
850
851 carrying = asked
852
853 try {
854 const result = await next({ ...e, context: [...context, text] })
855
856 if (result.drop === undefined) {
857 Record.recorderOf(host).asked()
858
859 if (armed === asked) {
860 disarm(host)
861 redraw(host)
862 }
863 }
864
865 return result
866 } finally {
867 carrying = null
868 }
869 })
870}