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