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