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