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