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