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 { |
| 2 | Args, |
| 3 | On, |
| 4 | PaneOpenArgs, |
| 5 | ResultOf, |
| 6 | SessionMessage, |
| 7 | Timer, |
| 8 | } from 'claude-code' |
| 9 | |
| 10 | import Ask from './ask' |
| 11 | import Backend from './backend' |
| 12 | import { COMMAND_SPEC } from './command-spec' |
| 13 | import { drawnFilesOf } from './drawn-files-of' |
| 14 | import { entryKindsOf } from './entry-kinds-of' |
| 15 | import type Git from './git' |
| 16 | import type { Host } from './host' |
| 17 | import { isCheckpointing } from './is-checkpointing' |
| 18 | import { isOnPaneSurface } from './is-on-pane-surface' |
| 19 | import { isRecord } from './is-record' |
| 20 | import Limits from './limits' |
| 21 | import { messageOf } from './message-of' |
| 22 | import { mtimeOf } from './mtime-of' |
| 23 | import Names from './names' |
| 24 | import PaneState from './pane-state' |
| 25 | import PaneToggle from './pane-toggle' |
| 26 | import Record from './record' |
| 27 | import Tools from './tools' |
| 28 | import Turns from './turns' |
| 29 | import Views from './views' |
| 30 | |
| 31 | /** |
| 32 | * Registers the diff pane: `/diff` once the built-in stands down, the |
| 33 | * pane's drawing and refresh, its opening on Claude's first edit, the ask. |
| 34 | * |
| 35 | * Git runs when the built-in's would: none at the start; `/diff` or the main |
| 36 | * loop's first checkpointed edit with room pins the backend, until `/clear`, |
| 37 | * which reads afresh under a pane it leaves open. |
| 38 | * |
| 39 | * @param on the engine's registrar |
| 40 | */ |
| 41 | export function register(on: On) { |
| 42 | let host: Host | null = null |
| 43 | let backend: Backend.Backend | null = null |
| 44 | let probing: Promise<boolean> | null = null |
| 45 | let sessionStartMs = 0 |
| 46 | let isPaneOpen = false |
| 47 | let dialogRows: number | null = null |
| 48 | let hasAutoOpened = false |
| 49 | let hasRestoredEdits = false |
| 50 | let columns: number | null = null |
| 51 | let shownSessionId: string | null = null |
| 52 | let armed: Ask.ArmedAsk | null = null |
| 53 | let carrying: Ask.ArmedAsk | null = null |
| 54 | let isRefreshing = false |
| 55 | let isRefreshQueued = false |
| 56 | let generation = 0 |
| 57 | let landed = 0 |
| 58 | let bodyStamp: string | null = null |
| 59 | let bodyBase: string | null = null |
| 60 | |
| 61 | const bodyLoads = new Set<string>() |
| 62 | |
| 63 | const polled = { toplevel: '', headKey: '' } |
| 64 | const pin = { cwd: '', isEmpty: false, epoch: 0 } |
| 65 | |
| 66 | let model: PaneState.PaneModel = PaneState.INITIAL_MODEL |
| 67 | |
| 68 | const timers = new Map<'refresh' | 'redraw' | 'poll', Timer>() |
| 69 | const loggedBaseKinds = new Set<'ok' | 'sad'>() |
| 70 | |
| 71 | const currentOf = (engine: Host): Host => host ?? engine |
| 72 | |
| 73 | const backendHostOf = (engine: Host): Backend.BackendHost => ({ |
| 74 | run: (argv, init) => |
| 75 | currentOf(engine).run( |
| 76 | argv, |
| 77 | pin.cwd === '' ? init : { cwd: pin.cwd, ...init }, |
| 78 | ), |
| 79 | readFile: path => currentOf(engine).readFile(path), |
| 80 | mtimeOf: path => mtimeOf(currentOf(engine))(path), |
| 81 | entryKindsOf: dir => entryKindsOf(currentOf(engine))(dir), |
| 82 | nowMs: () => currentOf(engine).now(), |
| 83 | sessionStartMsOf: () => sessionStartMs, |
| 84 | onBranchBase: base => { |
| 85 | const isError = base.kind === 'error' |
| 86 | |
| 87 | const outcome: Record.MarkOutcome = isError |
| 88 | ? { kind: 'sad', reason: base.reason } |
| 89 | : { |
| 90 | kind: 'ok', |
| 91 | props: { |
| 92 | outcome: { value: base.kind, of: Record.BASE_OUTCOMES }, |
| 93 | }, |
| 94 | } |
| 95 | |
| 96 | if (!loggedBaseKinds.has(outcome.kind)) { |
| 97 | loggedBaseKinds.add(outcome.kind) |
| 98 | |
| 99 | Record.recorderOf(currentOf(engine)).mark( |
| 100 | Record.FEATURES.baseResolve, |
| 101 | outcome, |
| 102 | ) |
| 103 | } |
| 104 | }, |
| 105 | }) |
| 106 | |
| 107 | function pinBackend(engine: Host): Promise<boolean> { |
| 108 | if (backend || pin.isEmpty) { |
| 109 | return Promise.resolve(true) |
| 110 | } |
| 111 | |
| 112 | probing ??= probeBackend(engine).finally(() => { |
| 113 | probing = null |
| 114 | }) |
| 115 | |
| 116 | return probing |
| 117 | } |
| 118 | |
| 119 | async function probeBackend(engine: Host): Promise<boolean> { |
| 120 | const asked = { isAnswered: true } |
| 121 | const probeHost = backendHostOf(engine) |
| 122 | const { epoch } = pin |
| 123 | |
| 124 | const probed = await Backend.backendOf( |
| 125 | { |
| 126 | ...probeHost, |
| 127 | run: (argv, init) => |
| 128 | probeHost.run(argv, init).catch((error: unknown) => { |
| 129 | asked.isAnswered &&= |
| 130 | argv[0] !== 'git' || !/\baborted\b/.test(messageOf(error)) |
| 131 | |
| 132 | throw error |
| 133 | }), |
| 134 | }, |
| 135 | Backend.INSTALLED_BACKEND_PROBES, |
| 136 | ) |
| 137 | |
| 138 | if (epoch !== pin.epoch) { |
| 139 | return false |
| 140 | } |
| 141 | |
| 142 | backend ??= probed |
| 143 | pin.isEmpty = backend === null && asked.isAnswered |
| 144 | |
| 145 | if (!probed || backend !== probed) { |
| 146 | return asked.isAnswered || backend !== null |
| 147 | } |
| 148 | |
| 149 | startPoll(engine, probed) |
| 150 | |
| 151 | const stored = PaneState.baseModeOf( |
| 152 | await engine |
| 153 | .storeGet(Names.baseStoreKeyOf(probed.repository.toplevel)) |
| 154 | .catch(() => undefined), |
| 155 | ) |
| 156 | |
| 157 | const mode = stored && probed.baseModes.includes(stored) ? stored : null |
| 158 | |
| 159 | model = { |
| 160 | ...model, |
| 161 | words: probed.words, |
| 162 | baseModes: probed.baseModes, |
| 163 | ...(mode && { requestedMode: mode }), |
| 164 | } |
| 165 | |
| 166 | return true |
| 167 | } |
| 168 | |
| 169 | function unpin() { |
| 170 | backend = null |
| 171 | pin.isEmpty = false |
| 172 | pin.epoch += 1 |
| 173 | polled.toplevel = '' |
| 174 | polled.headKey = '' |
| 175 | timers.get('poll')?.cancel() |
| 176 | timers.delete('poll') |
| 177 | } |
| 178 | |
| 179 | function dialogPane(): PaneOpenArgs { |
| 180 | return { |
| 181 | id: Names.PANE_ID, |
| 182 | title: Names.PANE_TITLE, |
| 183 | holdToasts: true, |
| 184 | closeOnEscape: true, |
| 185 | rows: Views.dialogRowsOf(model), |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | function fitDialog(engine: Host) { |
| 190 | const rows = Views.dialogRowsOf(model) |
| 191 | |
| 192 | const isStale = |
| 193 | isPaneOpen && model.isFullscreen === false && rows !== dialogRows |
| 194 | |
| 195 | if (isStale) { |
| 196 | dialogRows = rows |
| 197 | void engine.openPane(dialogPane()).catch(() => undefined) |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | function redraw(engine: Host) { |
| 202 | fitDialog(engine) |
| 203 | |
| 204 | if (timers.has('redraw')) { |
| 205 | return |
| 206 | } |
| 207 | |
| 208 | timers.set( |
| 209 | 'redraw', |
| 210 | engine.after(Limits.REDRAW_COALESCE_MS, () => { |
| 211 | timers.delete('redraw') |
| 212 | engine.invalidate() |
| 213 | }), |
| 214 | ) |
| 215 | } |
| 216 | |
| 217 | async function loadBodies(engine: Host): Promise<boolean> { |
| 218 | const { data } = model |
| 219 | const pinned = backend |
| 220 | |
| 221 | if (!data || !pinned) { |
| 222 | bodyStamp = null |
| 223 | bodyBase = null |
| 224 | bodyLoads.clear() |
| 225 | model = { ...model, bodies: PaneState.NO_BODIES } |
| 226 | |
| 227 | return false |
| 228 | } |
| 229 | |
| 230 | const stamp = bodyStampOf(data) |
| 231 | const isNewBase = data.baseRef !== bodyBase |
| 232 | |
| 233 | if (stamp !== bodyStamp) { |
| 234 | bodyStamp = stamp |
| 235 | bodyLoads.clear() |
| 236 | } |
| 237 | |
| 238 | if (isNewBase) { |
| 239 | bodyBase = data.baseRef |
| 240 | model = { ...model, bodies: PaneState.NO_BODIES } |
| 241 | redraw(engine) |
| 242 | } |
| 243 | |
| 244 | return fetchBodies(engine, pinned, data) |
| 245 | } |
| 246 | |
| 247 | const bodyStampOf = (data: Git.DiffData) => `${generation}|${data.baseRef}` |
| 248 | |
| 249 | async function fetchBodies( |
| 250 | engine: Host, |
| 251 | pinned: Backend.Backend, |
| 252 | data: Git.DiffData, |
| 253 | ): Promise<boolean> { |
| 254 | const stamp = bodyStampOf(data) |
| 255 | |
| 256 | const files = drawnFilesOf(model).filter(file => !bodyLoads.has(file.path)) |
| 257 | |
| 258 | for (const file of files) { |
| 259 | bodyLoads.add(file.path) |
| 260 | } |
| 261 | |
| 262 | const read = await pinned.fetchHunks(data, files) |
| 263 | const isCurrent = bodyStamp === stamp && read.size > 0 |
| 264 | |
| 265 | if (isCurrent) { |
| 266 | model = { ...model, bodies: new Map([...model.bodies, ...read]) } |
| 267 | redraw(engine) |
| 268 | } |
| 269 | |
| 270 | return [...read.values()].includes(null) |
| 271 | } |
| 272 | |
| 273 | function keepBaseline() { |
| 274 | const pinned = backend |
| 275 | |
| 276 | if (!pinned || polled.headKey !== '') { |
| 277 | return |
| 278 | } |
| 279 | |
| 280 | void pinned |
| 281 | .headKeyOf() |
| 282 | .catch(() => '') |
| 283 | .then(key => { |
| 284 | const isFirst = backend === pinned && polled.headKey === '' |
| 285 | |
| 286 | if (isFirst) { |
| 287 | polled.headKey = key |
| 288 | } |
| 289 | }) |
| 290 | } |
| 291 | |
| 292 | function startPoll(engine: Host, pinned: Backend.Backend) { |
| 293 | const readHeadKey = () => pinned.headKeyOf().catch(() => '') |
| 294 | |
| 295 | if (polled.toplevel === pinned.repository.toplevel) { |
| 296 | return |
| 297 | } |
| 298 | |
| 299 | timers.get('poll')?.cancel() |
| 300 | polled.toplevel = pinned.repository.toplevel |
| 301 | polled.headKey = '' |
| 302 | |
| 303 | timers.set( |
| 304 | 'poll', |
| 305 | engine.every(Limits.HEAD_POLL_MS, () => { |
| 306 | if (!isPaneOpen) { |
| 307 | return |
| 308 | } |
| 309 | |
| 310 | void readHeadKey().then(key => { |
| 311 | const hasMoved = polled.headKey !== '' && key !== polled.headKey |
| 312 | |
| 313 | polled.headKey = key |
| 314 | |
| 315 | if (hasMoved) { |
| 316 | scheduleRefresh(engine) |
| 317 | } |
| 318 | }) |
| 319 | }), |
| 320 | ) |
| 321 | } |
| 322 | |
| 323 | async function refresh(engine: Host): Promise<void> { |
| 324 | if (isRefreshing) { |
| 325 | isRefreshQueued = true |
| 326 | |
| 327 | return |
| 328 | } |
| 329 | |
| 330 | isRefreshing = true |
| 331 | |
| 332 | const record = Record.recorderOf(engine) |
| 333 | const pinned = backend |
| 334 | |
| 335 | const fetched = (): Promise<Git.FetchOutcome> => |
| 336 | pinned |
| 337 | ? pinned.fetchDiff(model.requestedMode) |
| 338 | : Promise.resolve({ kind: 'no-repository' }) |
| 339 | |
| 340 | try { |
| 341 | model = { ...model, isLoading: model.data === null } |
| 342 | |
| 343 | const [outcome, messages] = await Promise.all([ |
| 344 | fetched(), |
| 345 | engine.messages().catch((): SessionMessage[] => []), |
| 346 | ]) |
| 347 | |
| 348 | model = PaneState.afterFetch(model, { outcome, messages }) |
| 349 | |
| 350 | switch (outcome.kind) { |
| 351 | case 'no-repository': |
| 352 | break |
| 353 | case 'unavailable': |
| 354 | record.mark(Record.FEATURES.read, { |
| 355 | kind: 'sad', |
| 356 | reason: 'git_diff_failed', |
| 357 | }) |
| 358 | |
| 359 | break |
| 360 | case 'data': |
| 361 | generation += 1 |
| 362 | |
| 363 | break |
| 364 | } |
| 365 | |
| 366 | const hasHunksFailed = await loadBodies(engine) |
| 367 | |
| 368 | if (outcome.kind === 'data') { |
| 369 | record.mark( |
| 370 | Record.FEATURES.read, |
| 371 | hasHunksFailed |
| 372 | ? { kind: 'sad', reason: 'git_hunks_failed' } |
| 373 | : { kind: 'ok' }, |
| 374 | ) |
| 375 | } |
| 376 | } catch (error) { |
| 377 | record.mark(Record.FEATURES.read, { |
| 378 | kind: 'sad', |
| 379 | reason: 'git_diff_threw', |
| 380 | }) |
| 381 | |
| 382 | throw error |
| 383 | } finally { |
| 384 | isRefreshing = false |
| 385 | redraw(engine) |
| 386 | |
| 387 | if (isRefreshQueued) { |
| 388 | isRefreshQueued = false |
| 389 | scheduleRefresh(engine) |
| 390 | } |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | function scheduleRefresh(engine: Host): void { |
| 395 | timers.get('refresh')?.cancel() |
| 396 | |
| 397 | timers.set( |
| 398 | 'refresh', |
| 399 | engine.after(Limits.REFRESH_DEBOUNCE_MS, () => { |
| 400 | timers.delete('refresh') |
| 401 | void refresh(engine) |
| 402 | }), |
| 403 | ) |
| 404 | } |
| 405 | |
| 406 | async function markShown( |
| 407 | engine: Host, |
| 408 | trigger: (typeof Record.SHOWN_TRIGGERS)[number], |
| 409 | ): Promise<void> { |
| 410 | const sessionId = await engine.sessionId().catch(() => null) |
| 411 | |
| 412 | if (sessionId !== null && sessionId !== shownSessionId) { |
| 413 | shownSessionId = sessionId |
| 414 | Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns)) |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | async function openPane( |
| 419 | engine: Host, |
| 420 | trigger: (typeof Record.SHOWN_TRIGGERS)[number], |
| 421 | ): Promise<boolean> { |
| 422 | const isDialog = model.isFullscreen === false |
| 423 | |
| 424 | model = { |
| 425 | ...model, |
| 426 | selectedPath: null, |
| 427 | dialogView: 'list', |
| 428 | place: { ...model.place, top: 0, listStart: 0 }, |
| 429 | } |
| 430 | |
| 431 | dialogRows = isDialog ? Views.dialogRowsOf(model) : null |
| 432 | |
| 433 | const landedBefore = landed |
| 434 | |
| 435 | if (!isDialog) { |
| 436 | await refresh(engine).catch(() => undefined) |
| 437 | } |
| 438 | |
| 439 | const opened = await engine.openPane( |
| 440 | isDialog |
| 441 | ? { ...dialogPane(), focus: true } |
| 442 | : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true }, |
| 443 | ) |
| 444 | |
| 445 | const isWaiting = isRecord(opened) && opened.isPlaced === false |
| 446 | |
| 447 | if (isWaiting) { |
| 448 | await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined) |
| 449 | |
| 450 | return false |
| 451 | } |
| 452 | |
| 453 | isPaneOpen = true |
| 454 | keepBaseline() |
| 455 | |
| 456 | await markShown(engine, trigger) |
| 457 | |
| 458 | const isStale = isDialog || landed !== landedBefore |
| 459 | |
| 460 | if (isStale) { |
| 461 | void refresh(engine) |
| 462 | } |
| 463 | |
| 464 | return true |
| 465 | } |
| 466 | |
| 467 | async function closePane(engine: Host): Promise<void> { |
| 468 | await engine.closePane({ id: Names.PANE_ID }) |
| 469 | isPaneOpen = false |
| 470 | } |
| 471 | |
| 472 | function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) { |
| 473 | Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, { |
| 474 | kind: 'ok', |
| 475 | props: { tab: { value: tab, of: Record.TABS } }, |
| 476 | }) |
| 477 | } |
| 478 | |
| 479 | async function openOnFirstEdit(engine: Host): Promise<void> { |
| 480 | const isTaken = () => isPaneOpen || hasAutoOpened |
| 481 | |
| 482 | if (isTaken()) { |
| 483 | return |
| 484 | } |
| 485 | |
| 486 | const preference = await engine.storeGet(Names.STORE_OPEN_KEY) |
| 487 | const isKeptOpen = preference === true |
| 488 | |
| 489 | const floor = isKeptOpen |
| 490 | ? Limits.OPEN_MIN_COLUMNS |
| 491 | : Limits.AUTO_OPEN_MIN_COLUMNS |
| 492 | |
| 493 | const hasRoom = |
| 494 | preference !== false && |
| 495 | model.isFullscreen === true && |
| 496 | columns !== null && |
| 497 | columns >= floor |
| 498 | |
| 499 | if (!hasRoom || isTaken()) { |
| 500 | return |
| 501 | } |
| 502 | |
| 503 | const isCheckpointed = await engine.isCheckpointing().catch(() => true) |
| 504 | |
| 505 | if (!isCheckpointed || isTaken()) { |
| 506 | return |
| 507 | } |
| 508 | |
| 509 | await pinBackend(engine) |
| 510 | |
| 511 | if (!backend || isTaken()) { |
| 512 | return |
| 513 | } |
| 514 | |
| 515 | hasAutoOpened = true |
| 516 | hasAutoOpened = await openPane(engine, 'auto_open') |
| 517 | } |
| 518 | |
| 519 | async function openOnRestore(engine: Host): Promise<void> { |
| 520 | hasRestoredEdits = |
| 521 | Turns.turnDiffsOf( |
| 522 | await engine.messages().catch((): SessionMessage[] => []), |
| 523 | ).length > 0 |
| 524 | |
| 525 | if (hasRestoredEdits) { |
| 526 | await openOnFirstEdit(engine) |
| 527 | } |
| 528 | } |
| 529 | |
| 530 | function disarm(engine: Host) { |
| 531 | armed = null |
| 532 | model = { ...model, armedPath: null } |
| 533 | engine.status(undefined) |
| 534 | } |
| 535 | |
| 536 | const actionsOf = (engine: Host): Views.PaneActions => ({ |
| 537 | selectFile: path => { |
| 538 | const isDocked = model.placement === 'dock' |
| 539 | |
| 540 | model = { |
| 541 | ...model, |
| 542 | selectedPath: path, |
| 543 | dialogView: isDocked ? model.dialogView : 'detail', |
| 544 | place: isDocked ? Views.placeAtFile(model, path) : model.place, |
| 545 | } |
| 546 | |
| 547 | void loadBodies(engine).catch(() => undefined) |
| 548 | redraw(engine) |
| 549 | }, |
| 550 | scrollList: delta => { |
| 551 | model = { ...model, place: Views.listScrolledBy(model, delta) } |
| 552 | redraw(engine) |
| 553 | }, |
| 554 | toggleNoise: () => { |
| 555 | model = { ...model, isNoiseShown: !model.isNoiseShown } |
| 556 | void loadBodies(engine) |
| 557 | redraw(engine) |
| 558 | }, |
| 559 | togglePreSession: () => { |
| 560 | model = { ...model, isPreSessionShown: !model.isPreSessionShown } |
| 561 | void loadBodies(engine) |
| 562 | redraw(engine) |
| 563 | }, |
| 564 | cycleBase: () => { |
| 565 | const { baseModes, requestedMode } = model |
| 566 | |
| 567 | const mode = |
| 568 | baseModes[(baseModes.indexOf(requestedMode) + 1) % baseModes.length] ?? |
| 569 | requestedMode |
| 570 | |
| 571 | if (mode === requestedMode) { |
| 572 | return |
| 573 | } |
| 574 | |
| 575 | model = { ...model, requestedMode: mode } |
| 576 | |
| 577 | Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, { |
| 578 | kind: 'ok', |
| 579 | props: { mode: { value: mode, of: model.baseModes } }, |
| 580 | }) |
| 581 | |
| 582 | const toplevel = model.data?.repository.toplevel |
| 583 | |
| 584 | if (toplevel !== undefined) { |
| 585 | void engine |
| 586 | .storeSet(Names.baseStoreKeyOf(toplevel), mode) |
| 587 | .catch(() => undefined) |
| 588 | } |
| 589 | |
| 590 | void refresh(engine) |
| 591 | redraw(engine) |
| 592 | }, |
| 593 | chooseSource: value => { |
| 594 | const index = Number(value) |
| 595 | const isTurn = value !== 'current' && Number.isInteger(index) |
| 596 | |
| 597 | const source: PaneState.Source = isTurn |
| 598 | ? { kind: 'turn', index } |
| 599 | : { kind: 'current' } |
| 600 | |
| 601 | model = { ...model, source, selectedPath: null, dialogView: 'list' } |
| 602 | redraw(engine) |
| 603 | }, |
| 604 | toggleAsk: path => { |
| 605 | if (armed?.path === path) { |
| 606 | disarm(engine) |
| 607 | redraw(engine) |
| 608 | |
| 609 | return |
| 610 | } |
| 611 | |
| 612 | arm(engine, path) |
| 613 | }, |
| 614 | }) |
| 615 | |
| 616 | function arm(engine: Host, path: string) { |
| 617 | armed = Ask.armedAskOf( |
| 618 | path, |
| 619 | PaneState.pickedTurnOf(model)?.files.find(file => file.path === path) |
| 620 | ?.hunks ?? |
| 621 | model.bodies.get(path)?.hunks ?? |
| 622 | [], |
| 623 | ) |
| 624 | |
| 625 | model = { ...model, armedPath: path } |
| 626 | |
| 627 | engine.status( |
| 628 | `${Views.sanitizeName(path)} rides your next prompt (press ` + |
| 629 | `asked ✓ to drop it)`, |
| 630 | ) |
| 631 | |
| 632 | redraw(engine) |
| 633 | } |
| 634 | |
| 635 | async function startedAtOf(engine: Host): Promise<number | null> { |
| 636 | const startedAt: unknown = await engine.startedAt().catch(() => undefined) |
| 637 | |
| 638 | return typeof startedAt === 'number' ? startedAt : null |
| 639 | } |
| 640 | |
| 641 | async function bind(engine: Host, cwd: string): Promise<void> { |
| 642 | sessionStartMs = (await startedAtOf(engine)) ?? (await engine.now()) |
| 643 | pin.cwd = cwd |
| 644 | |
| 645 | try { |
| 646 | await engine.registerCommand(COMMAND_SPEC) |
| 647 | host = engine |
| 648 | } catch (error) { |
| 649 | const reason = messageOf(error) |
| 650 | |
| 651 | if (!Names.BUILTIN_HOLDS_PATTERN.test(reason)) { |
| 652 | engine.uiLog(Names.registerFailedTextOf(Views.sanitizeName(reason))) |
| 653 | } |
| 654 | } |
| 655 | } |
| 656 | |
| 657 | on('session.start', async ($, e, next) => { |
| 658 | await bind( |
| 659 | { |
| 660 | now: () => $.clock.now(), |
| 661 | after: (ms, fn) => $.clock.after(ms, fn), |
| 662 | every: (ms, fn) => $.clock.every(ms, fn), |
| 663 | run: (argv, init) => $.process.run(argv, init), |
| 664 | stat: path => $.fs.stat(path), |
| 665 | listDir: path => $.fs.list(path), |
| 666 | readFile: path => $.fs.read(path), |
| 667 | storeGet: key => $.store.get(key), |
| 668 | storeSet: (key, value) => $.store.set(key, value), |
| 669 | isCheckpointing: async () => |
| 670 | isCheckpointing( |
| 671 | await $.settings.read(), |
| 672 | await $.env.get('CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING'), |
| 673 | ), |
| 674 | messages: () => $.session.messages(), |
| 675 | invalidate: () => $.ui.invalidate('ui.render'), |
| 676 | status: text => $.ui.status(text), |
| 677 | uiLog: text => $.ui.log(text), |
| 678 | openPane: pane => $.ui.open(pane), |
| 679 | closePane: pane => $.ui.close(pane), |
| 680 | registerCommand: spec => $.command.register(spec), |
| 681 | sessionId: () => $.session.id(), |
| 682 | startedAt: () => |
| 683 | $.session |
| 684 | .usage() |
| 685 | .then((usage: unknown) => |
| 686 | isRecord(usage) ? usage.startedAt : undefined, |
| 687 | ), |
| 688 | mark: entry => $.telemetry.mark(entry), |
| 689 | log: entry => $.telemetry.log(entry), |
| 690 | }, |
| 691 | e.cwd, |
| 692 | ) |
| 693 | |
| 694 | if (host) { |
| 695 | void openOnRestore(host).catch(() => undefined) |
| 696 | } |
| 697 | |
| 698 | return next(e) |
| 699 | }) |
| 700 | |
| 701 | on('ui.render', { component: 'PromptHint' }, ($, e, next) => { |
| 702 | if (isOnPaneSurface(e)) { |
| 703 | const { viewport } = e |
| 704 | |
| 705 | const isFirstMeasure = columns === null && viewport?.columns !== undefined |
| 706 | |
| 707 | columns = viewport?.columns ?? columns |
| 708 | |
| 709 | model = { |
| 710 | ...model, |
| 711 | isFullscreen: viewport?.isFullscreen ?? model.isFullscreen, |
| 712 | } |
| 713 | |
| 714 | if (isFirstMeasure && hasRestoredEdits && host) { |
| 715 | void openOnFirstEdit(host).catch(() => undefined) |
| 716 | } |
| 717 | } |
| 718 | |
| 719 | return next(e) |
| 720 | }) |
| 721 | |
| 722 | on('ui.render', { component: 'Pane' }, async ($, e, next) => { |
| 723 | if (e.requestId !== Names.PANE_ID || !host || !isOnPaneSurface(e)) { |
| 724 | return next(e) |
| 725 | } |
| 726 | |
| 727 | const { Box, Text, Button, Select, Code } = await $.ui.resolve(e) |
| 728 | |
| 729 | columns = e.viewport?.columns ?? columns |
| 730 | |
| 731 | /** |
| 732 | * A seat that changed since the last drawing lists other rows, whose |
| 733 | * bodies are read once. |
| 734 | * |
| 735 | * The model takes the new seat in this same pass, so the drawing that |
| 736 | * read asks for finds the seat unchanged. |
| 737 | */ |
| 738 | const isReseated = e.props.placement !== model.placement |
| 739 | |
| 740 | model = { |
| 741 | ...model, |
| 742 | placement: e.props.placement, |
| 743 | place: { |
| 744 | ...model.place, |
| 745 | columns: Math.max( |
| 746 | 1, |
| 747 | e.props.bodyColumns - Limits.PANE_RIGHT_PAD_COLUMNS, |
| 748 | ), |
| 749 | rows: e.props.scroll.bodyRows, |
| 750 | }, |
| 751 | } |
| 752 | |
| 753 | if (isReseated) { |
| 754 | void loadBodies(host).catch(() => undefined) |
| 755 | } |
| 756 | |
| 757 | return Views.paneView( |
| 758 | { |
| 759 | ui: { Box, Text, Button, Select, Code }, |
| 760 | actions: actionsOf(host), |
| 761 | columns: e.props.bodyColumns, |
| 762 | rows: e.props.scroll.bodyRows, |
| 763 | }, |
| 764 | model, |
| 765 | { placement: e.props.placement, terminalColumns: columns }, |
| 766 | ) |
| 767 | }) |
| 768 | |
| 769 | on('command.run', { command: 'diff' }, async ($, e, next) => { |
| 770 | if (!host) { |
| 771 | return next(e) |
| 772 | } |
| 773 | |
| 774 | const isAnswered = (await pinBackend(host)) || (await pinBackend(host)) |
| 775 | |
| 776 | if (!backend) { |
| 777 | return { |
| 778 | text: isAnswered |
| 779 | ? Names.NOT_IN_REPOSITORY_TEXT |
| 780 | : Names.GIT_UNANSWERED_TEXT, |
| 781 | } |
| 782 | } |
| 783 | |
| 784 | const { isFullscreen } = e.presentation |
| 785 | |
| 786 | columns = e.presentation.columns |
| 787 | model = { ...model, isFullscreen } |
| 788 | |
| 789 | const toggle = PaneToggle.paneToggleOf({ |
| 790 | isOpen: isPaneOpen, |
| 791 | columns: isFullscreen ? columns : null, |
| 792 | }) |
| 793 | |
| 794 | if (toggle === 'too-narrow') { |
| 795 | return { text: Names.RESIZE_TERMINAL_TEXT } |
| 796 | } |
| 797 | |
| 798 | const isOpening = toggle === 'open' |
| 799 | |
| 800 | const isDone = isOpening |
| 801 | ? await openPane(host, 'manual') |
| 802 | : await closePane(host).then(() => true) |
| 803 | |
| 804 | if (!isDone) { |
| 805 | return { text: Names.RESIZE_TERMINAL_TEXT } |
| 806 | } |
| 807 | |
| 808 | if (!isFullscreen) { |
| 809 | return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT } |
| 810 | } |
| 811 | |
| 812 | markTabSwitch(host, isOpening ? 'diff' : 'convo') |
| 813 | await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined) |
| 814 | |
| 815 | return { |
| 816 | text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT, |
| 817 | } |
| 818 | }) |
| 819 | |
| 820 | on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => { |
| 821 | const isBack = |
| 822 | e.origin.kind === 'person' && |
| 823 | model.placement === 'inline' && |
| 824 | model.dialogView === 'detail' |
| 825 | |
| 826 | if (isBack && host) { |
| 827 | model = { ...model, dialogView: 'list' } |
| 828 | redraw(host) |
| 829 | |
| 830 | dialogRows = Views.dialogRowsOf(model) |
| 831 | |
| 832 | void host |
| 833 | .openPane({ ...dialogPane(), focus: true }) |
| 834 | .catch(() => undefined) |
| 835 | |
| 836 | return { deny: 'back to the file list' } |
| 837 | } |
| 838 | |
| 839 | const result = await next(e) |
| 840 | const isClosed = result.deny === undefined |
| 841 | const isPersons = isClosed && e.origin.kind === 'person' |
| 842 | |
| 843 | if (isClosed) { |
| 844 | isPaneOpen = false |
| 845 | } |
| 846 | |
| 847 | const isDocking = model.isFullscreen !== false |
| 848 | |
| 849 | if (isPersons && host && isDocking) { |
| 850 | markTabSwitch(host, 'convo') |
| 851 | await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined) |
| 852 | } |
| 853 | |
| 854 | return result |
| 855 | }) |
| 856 | |
| 857 | on('ui.focus', { plugin: Names.PLUGIN_NAMES }, ($, e, next) => { |
| 858 | const isListed = |
| 859 | model.placement === 'inline' && model.dialogView === 'list' && host |
| 860 | |
| 861 | const focus = isListed ? Views.dialogFocusOf(model, e.element) : null |
| 862 | |
| 863 | if (focus === 'stay') { |
| 864 | return {} |
| 865 | } |
| 866 | |
| 867 | if (!focus || !host) { |
| 868 | return next(e) |
| 869 | } |
| 870 | |
| 871 | model = { ...model, selectedPath: focus.selectedPath } |
| 872 | void loadBodies(host).catch(() => undefined) |
| 873 | fitDialog(host) |
| 874 | host.invalidate() |
| 875 | |
| 876 | return next({ ...e, element: focus.landing }) |
| 877 | }) |
| 878 | |
| 879 | on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => { |
| 880 | const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock' |
| 881 | |
| 882 | if (!isOwnBody || !host) { |
| 883 | return next(e) |
| 884 | } |
| 885 | |
| 886 | const isOverList = Views.isWheelOverList(model, e) |
| 887 | |
| 888 | model = { |
| 889 | ...model, |
| 890 | place: isOverList |
| 891 | ? Views.listScrolledBy(model, e.by) |
| 892 | : Views.bodyScrolledBy(model, e), |
| 893 | } |
| 894 | |
| 895 | host.invalidate() |
| 896 | |
| 897 | return {} |
| 898 | }) |
| 899 | |
| 900 | on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => { |
| 901 | const result = await next(e) |
| 902 | |
| 903 | if (!host) { |
| 904 | return result |
| 905 | } |
| 906 | |
| 907 | const isResume = e.command === 'resume' |
| 908 | const isKeptOpen = isPaneOpen && !isResume |
| 909 | |
| 910 | if (isPaneOpen && isResume) { |
| 911 | await closePane(host).catch(() => undefined) |
| 912 | } |
| 913 | |
| 914 | unpin() |
| 915 | hasAutoOpened = false |
| 916 | hasRestoredEdits = false |
| 917 | bodyStamp = null |
| 918 | bodyBase = null |
| 919 | bodyLoads.clear() |
| 920 | disarm(host) |
| 921 | model = PaneState.afterNewSession(model) |
| 922 | |
| 923 | sessionStartMs = |
| 924 | (await startedAtOf(host)) ?? |
| 925 | (isResume ? sessionStartMs : await host.now()) |
| 926 | |
| 927 | if (isKeptOpen) { |
| 928 | await markShown(host, 'manual') |
| 929 | await pinBackend(host) |
| 930 | keepBaseline() |
| 931 | void refresh(host) |
| 932 | } |
| 933 | |
| 934 | if (isResume) { |
| 935 | void openOnRestore(host).catch(() => undefined) |
| 936 | } |
| 937 | |
| 938 | return result |
| 939 | }) |
| 940 | |
| 941 | function afterTool( |
| 942 | engine: Host, |
| 943 | e: Args<'tool.call'>, |
| 944 | result: ResultOf['tool.call'] | undefined, |
| 945 | ) { |
| 946 | const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool) |
| 947 | |
| 948 | const hasEdited = |
| 949 | isEdit && |
| 950 | result !== undefined && |
| 951 | result.deny === undefined && |
| 952 | result.isError !== true |
| 953 | |
| 954 | const hasLanded = isEdit ? hasEdited : Tools.mayHaveWritten(result) |
| 955 | |
| 956 | if (hasLanded) { |
| 957 | landed += 1 |
| 958 | } |
| 959 | |
| 960 | if (hasLanded && isPaneOpen) { |
| 961 | scheduleRefresh(engine) |
| 962 | } |
| 963 | |
| 964 | const isMainLoopEdit = hasEdited && e.agentId === undefined |
| 965 | |
| 966 | if (isMainLoopEdit) { |
| 967 | void openOnFirstEdit(engine).catch(() => undefined) |
| 968 | } |
| 969 | } |
| 970 | |
| 971 | on( |
| 972 | 'tool.call', |
| 973 | { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] }, |
| 974 | async ($, e, next) => { |
| 975 | let result: ResultOf['tool.call'] | undefined |
| 976 | |
| 977 | try { |
| 978 | result = await next(e) |
| 979 | |
| 980 | return result |
| 981 | } finally { |
| 982 | if (host) { |
| 983 | afterTool(host, e, result) |
| 984 | } |
| 985 | } |
| 986 | }, |
| 987 | ) |
| 988 | |
| 989 | on('prompt.submit', async ($, e, next) => { |
| 990 | const asked = armed |
| 991 | |
| 992 | if (!host || !asked || carrying === asked) { |
| 993 | return next(e) |
| 994 | } |
| 995 | |
| 996 | const context = e.context ?? [] |
| 997 | |
| 998 | const text = Ask.fittedAskTextOf( |
| 999 | asked.text, |
| 1000 | Limits.PROMPT_CONTEXT_MAX_CHARS - |
| 1001 | context.reduce((sum, entry) => sum + entry.length, 0), |
| 1002 | ) |
| 1003 | |
| 1004 | if (text === undefined) { |
| 1005 | disarm(host) |
| 1006 | |
| 1007 | host.status( |
| 1008 | `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` + |
| 1009 | `and was dropped`, |
| 1010 | ) |
| 1011 | |
| 1012 | redraw(host) |
| 1013 | |
| 1014 | return next(e) |
| 1015 | } |
| 1016 | |
| 1017 | carrying = asked |
| 1018 | |
| 1019 | try { |
| 1020 | const result = await next({ ...e, context: [...context, text] }) |
| 1021 | |
| 1022 | if (result.drop === undefined) { |
| 1023 | Record.recorderOf(host).asked() |
| 1024 | |
| 1025 | if (armed === asked) { |
| 1026 | disarm(host) |
| 1027 | redraw(host) |
| 1028 | } |
| 1029 | } |
| 1030 | |
| 1031 | return result |
| 1032 | } finally { |
| 1033 | carrying = null |
| 1034 | } |
| 1035 | }) |
| 1036 | } |