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
639 lines · 16.7 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 shownSessionId: string | null = null
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 const sessionId = await engine.sessionId().catch(() => null)
292
293 if (sessionId !== null && sessionId !== shownSessionId) {
294 shownSessionId = sessionId
295 record.shown(trigger, Record.widthBucketOf(columns))
296 }
297
298 void refresh(engine)
299 }
300
301 async function closePane(engine: Host): Promise<void> {
302 await engine.closePane({ id: Names.PANE_ID })
303 isPaneOpen = false
304 Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, { kind: 'ok' })
305 }
306
307 async function wasDrawnWhenProbed(engine: Host): Promise<boolean> {
308 wasDrawnSinceProbe = false
309 engine.invalidate()
310 await engine.sleep(Limits.OPEN_PROBE_MS)
311
312 return wasDrawnSinceProbe
313 }
314
315 async function openOnFirstEdit(engine: Host): Promise<void> {
316 const isTaken = () => isPaneOpen || hasAutoOpened
317
318 if (isTaken()) {
319 return
320 }
321
322 const preference = await engine.storeGet(Names.STORE_OPEN_KEY)
323 await pinBackend(engine)
324 const isKeptOpen = preference === true
325 const floor = isKeptOpen
326 ? Limits.OPEN_MIN_COLUMNS
327 : Limits.AUTO_OPEN_MIN_COLUMNS
328 const isEligible =
329 preference !== false &&
330 columns !== null &&
331 columns >= floor &&
332 backend !== null
333
334 if (!isEligible || isTaken()) {
335 return
336 }
337
338 hasAutoOpened = true
339 await openPane(engine, 'auto_open')
340 }
341
342 function disarm(engine: Host) {
343 armed = null
344 model = { ...model, armedPath: null }
345 engine.status(undefined)
346 }
347
348 const actionsOf = (engine: Host): Views.PaneActions => ({
349 selectFile: path => {
350 model = { ...model, selectedPath: path }
351 void loadBody(engine)
352 redraw(engine)
353 },
354 toggleNoise: () => {
355 model = { ...model, isNoiseShown: !model.isNoiseShown }
356 void loadBody(engine)
357 redraw(engine)
358 },
359 togglePreSession: () => {
360 model = { ...model, isPreSessionShown: !model.isPreSessionShown }
361 void loadBody(engine)
362 redraw(engine)
363 },
364 chooseBase: value => {
365 const mode = PaneState.baseModeOf(value)
366
367 if (!mode || mode === model.requestedMode) {
368 return
369 }
370
371 bodyKey = null
372 model = { ...model, requestedMode: mode, body: null, bodyState: 'idle' }
373 Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, {
374 kind: 'ok',
375 })
376 const toplevel = model.data?.repository.toplevel
377
378 if (toplevel !== undefined) {
379 void engine
380 .storeSet(Names.baseStoreKeyOf(toplevel), mode)
381 .catch(() => undefined)
382 }
383
384 void refresh(engine)
385 redraw(engine)
386 },
387 chooseSource: value => {
388 const index = Number(value)
389 const isTurn = value !== 'current' && Number.isInteger(index)
390 const source: PaneState.Source = isTurn
391 ? { kind: 'turn', index }
392 : { kind: 'current' }
393 model = { ...model, source, selectedPath: null }
394 void loadBody(engine)
395 redraw(engine)
396 },
397 toggleAsk: path => {
398 if (armed?.path === path) {
399 disarm(engine)
400 redraw(engine)
401
402 return
403 }
404
405 arm(engine, path)
406 },
407 close: () => {
408 void closePane(engine)
409 .then(() => engine.storeSet(Names.STORE_OPEN_KEY, false))
410 .catch(() => undefined)
411 },
412 })
413
414 function arm(engine: Host, path: string) {
415 armed = Ask.armedAskOf(
416 path,
417 PaneState.pickedTurnOf(model)?.files.find(file => file.path === path)
418 ?.hunks ??
419 model.body?.hunks ??
420 [],
421 )
422 model = { ...model, armedPath: path }
423 engine.status(
424 `${Views.sanitizeName(path)} rides your next prompt (press ` +
425 `asked ✓ to drop it)`,
426 )
427 redraw(engine)
428 }
429
430 async function bind(engine: Host): Promise<void> {
431 sessionStartMs = engine.now()
432
433 try {
434 await engine.registerCommand(COMMAND_SPEC)
435 host = engine
436 } catch (error) {
437 const reason = messageOf(error)
438
439 if (!Names.BUILTIN_HOLDS_PATTERN.test(reason)) {
440 engine.uiLog(Names.registerFailedTextOf(Views.sanitizeName(reason)))
441 }
442
443 return
444 }
445
446 await pinBackend(engine)
447 }
448
449 on('session.start', async ($, e, next) => {
450 await bind({
451 now: () => $.clock.now(),
452 after: (ms, fn) => $.clock.after(ms, fn),
453 every: (ms, fn) => $.clock.every(ms, fn),
454 sleep: ms => $.clock.sleep(ms),
455 run: (argv, init) => $.process.run(argv, init),
456 stat: path => $.fs.stat(path),
457 listDir: path => $.fs.list(path),
458 readFile: path => $.fs.read(path),
459 storeGet: key => $.store.get(key),
460 storeSet: (key, value) => $.store.set(key, value),
461 messages: () => $.session.messages(),
462 invalidate: () => $.ui.invalidate('ui.render'),
463 status: text => $.ui.status(text),
464 uiLog: text => $.ui.log(text),
465 openPane: pane => $.ui.open(pane),
466 closePane: pane => $.ui.close(pane),
467 registerCommand: spec => $.command.register(spec),
468 sessionId: () => $.session.id(),
469 mark: entry => $.telemetry.mark(entry),
470 log: entry => $.telemetry.log(entry),
471 })
472
473 return next(e)
474 })
475
476 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
477 if (isOnPaneSurface(e)) {
478 columns = e.viewport?.columns ?? columns
479 }
480
481 return next(e)
482 })
483
484 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
485 if (e.requestId !== Names.PANE_ID || !host || !isOnPaneSurface(e)) {
486 return next(e)
487 }
488
489 const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
490 wasDrawnSinceProbe = true
491 columns = e.viewport?.columns ?? columns
492 model = { ...model, isFocused: e.props.isFocused }
493
494 return Views.paneView(
495 {
496 ui: { Box, Text, Button, Select, Code },
497 actions: actionsOf(host),
498 columns: e.props.bodyColumns,
499 rows: e.props.scroll.bodyRows,
500 },
501 model,
502 e.props.placement,
503 )
504 })
505
506 on('command.run', { command: Names.COMMAND_NAME }, async ($, e, next) => {
507 if (!host) {
508 return next(e)
509 }
510
511 const isAnswered = (await pinBackend(host)) || (await pinBackend(host))
512
513 if (!backend) {
514 return {
515 text: isAnswered
516 ? Names.NOT_IN_REPOSITORY_TEXT
517 : Names.GIT_UNANSWERED_TEXT,
518 }
519 }
520
521 const toggle = PaneToggle.paneToggleOf({
522 isBelievedOpen: isPaneOpen,
523 wasDrawnWhenProbed: isPaneOpen && (await wasDrawnWhenProbed(host)),
524 columns,
525 })
526
527 if (toggle === 'too-narrow') {
528 return { text: Names.RESIZE_TERMINAL_TEXT }
529 }
530
531 const isOpening = toggle === 'open'
532 await (isOpening ? openPane(host, 'manual') : closePane(host))
533 await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
534
535 return {}
536 })
537
538 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
539 const result = await next(e)
540
541 if (host) {
542 if (isPaneOpen) {
543 await closePane(host).catch(() => undefined)
544 }
545
546 hasAutoOpened = false
547 bodyKey = null
548 disarm(host)
549 model = PaneState.afterNewSession(model)
550 }
551
552 return result
553 })
554
555 on('tool.call', { tool: [...Tools.EDITING_TOOLS] }, async ($, e, next) => {
556 let result: ResultOf['tool.call'] | undefined
557
558 try {
559 result = await next(e)
560
561 return result
562 } finally {
563 if (host) {
564 if (isPaneOpen) {
565 scheduleRefresh(host)
566 }
567
568 const isEditDone = result !== undefined && !('deny' in result)
569
570 if (isEditDone) {
571 void openOnFirstEdit(host).catch(() => undefined)
572 }
573 }
574 }
575 })
576
577 on('tool.call', { tool: [...Tools.SHELL_TOOLS] }, async ($, e, next) => {
578 try {
579 return await next(e)
580 } finally {
581 if (host && isPaneOpen) {
582 scheduleRefresh(host)
583 }
584 }
585 })
586
587 on('turn.complete', ($, e, next) => {
588 if (host && isPaneOpen) {
589 scheduleRefresh(host)
590 }
591
592 return next(e)
593 })
594
595 on('prompt.submit', async ($, e, next) => {
596 const asked = armed
597
598 if (!host || !asked || carrying === asked) {
599 return next(e)
600 }
601
602 const context = e.context ?? []
603 const used = context.reduce((sum, entry) => sum + entry.length, 0)
604 const text = Ask.fittedAskTextOf(
605 asked.text,
606 Limits.PROMPT_CONTEXT_MAX_CHARS - used,
607 )
608
609 if (text === undefined) {
610 disarm(host)
611 host.status(
612 `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` +
613 `and was dropped`,
614 )
615 redraw(host)
616
617 return next(e)
618 }
619
620 carrying = asked
621
622 try {
623 const result = await next({ ...e, context: [...context, text] })
624
625 if (result.drop === undefined) {
626 Record.recorderOf(host).asked()
627
628 if (armed === asked) {
629 disarm(host)
630 redraw(host)
631 }
632 }
633
634 return result
635 } finally {
636 carrying = null
637 }
638 })
639}