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
| 1 | import type { On, ResultOf, SessionMessage, Timer } from 'claude-code' |
| 2 | |
| 3 | import Ask from './ask' |
| 4 | import Backend from './backend' |
| 5 | import { COMMAND_SPEC } from './command-spec' |
| 6 | import { entryKindsOf } from './entry-kinds-of' |
| 7 | import type Git from './git' |
| 8 | import type { Host } from './host' |
| 9 | import { isOnPaneSurface } from './is-on-pane-surface' |
| 10 | import Limits from './limits' |
| 11 | import { messageOf } from './message-of' |
| 12 | import { mtimeOf } from './mtime-of' |
| 13 | import Names from './names' |
| 14 | import PaneState from './pane-state' |
| 15 | import PaneToggle from './pane-toggle' |
| 16 | import Record from './record' |
| 17 | import Tools from './tools' |
| 18 | import 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 | */ |
| 32 | export 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 | } |