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