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
The pane opened on the session's first successful Edit, Write or NotebookEdit to any path, and opened before it fetched, so a write outside the repository or to an ignored file put up an empty pane. Two gates on that path now; /diff is as it was: - The edited path rides in from the tool.call event. Once the repository is pinned, a path spelled outside its working tree opens nothing and leaves the opening to a later edit. Where the event names no path, or the session's own directory is not spelled under the top (a symbolic link on the way), the second gate decides alone. - The diff is fetched first and the pane opens only on a fetch that settled with a file the header counts, already listing it, from that one fetch. No repository, a diff git could not read, or no session file opens nothing, and the next edit tries again. One attempt runs at a time: an edit or command that lands while it reads is read again, and a read that /clear overtakes is dropped. An attempt also gives way when the pane is opened or hidden, or the terminal loses the room for it, while it reads.
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "diff", |
| 3 | 3 | "version": "0.1.0", |
| 4 | - "description": "The diff pane as a plugin: /diff opens the session's uncommitted changes beside the transcript, file by file with their hunks, refreshed as Claude edits and runs commands; the first edit opens it where the terminal is wide enough, and a file's ask button rides its hunks on the next prompt.", | |
| 4 | + "description": "The diff pane as a plugin: /diff opens the session's uncommitted changes beside the transcript, file by file with their hunks, refreshed as Claude edits and runs commands; the first edit it has a change to list for opens it where the terminal is wide enough, and a file's ask button rides its hunks on the next prompt.", | |
| 5 | 5 | "author": { |
| 6 | 6 | "name": "Anthropic" |
| 7 | 7 | } |
| @@ -13,12 +13,14 @@ | ||
| 13 | 13 | through Buttons that declare the engine's own actions. The pane refreshes |
| 14 | 14 | as Claude edits and runs shell commands, and while it is open it polls |
| 15 | 15 | the repository's HEAD so a commit or checkout made elsewhere shows too. |
| 16 | -The first successful edit of a session opens the pane by itself where the | |
| 17 | -layout docks it beside the transcript (the fullscreen layout, which each | |
| 18 | -drawing's `viewport` says) and the terminal is wide enough (144 columns | |
| 19 | -when the person never chose, 110 when they kept it open before; a person | |
| 20 | -who closed it is left alone); where the surface does not say, nothing | |
| 21 | -opens by itself. | |
| 16 | +The first successful edit of a session the diff has a file to list for | |
| 17 | +opens the pane by itself where the layout docks it beside the transcript | |
| 18 | +(the fullscreen layout, which each drawing's `viewport` says) and the | |
| 19 | +terminal is wide enough (144 columns when the person never chose, 110 when | |
| 20 | +they kept it open before; a person who closed it is left alone); where the | |
| 21 | +surface does not say, nothing opens by itself. An edit to a file outside | |
| 22 | +the repository, or one after which the diff lists nothing or cannot be | |
| 23 | +read, opens nothing and leaves the opening to a later edit. | |
| 22 | 24 | |
| 23 | 25 | Under the fullscreen layout a terminal under 110 columns gets the |
| 24 | 26 | built-in's line asking for a wider one and nothing opens. Without that |
| @@ -49,10 +51,11 @@ | ||
| 49 | 51 | or the first edit a pane has room to open on first needs the repository |
| 50 | 52 | (an answer of no repository is kept too, until `/clear` or `/resume` |
| 51 | 53 | forgets it); and the working tree is read only by a fetch for a pane that |
| 52 | -is open, after an edit that landed or a shell command that ran. The one | |
| 53 | -read the built-in has no counterpart for is a `git status` at a pane's | |
| 54 | -first fetch, which stands in for the change time the built-in dates a | |
| 55 | -moved file by. | |
| 54 | +is open, after an edit that landed or a shell command that ran, or by the | |
| 55 | +fetch each edit inside the repository, with room for a pane, makes until | |
| 56 | +one lists a file to open on. The one read the built-in has no counterpart | |
| 57 | +for is a `git status` at a pane's first fetch, which stands in for the | |
| 58 | +change time the built-in dates a moved file by. | |
| 56 | 59 | |
| 57 | 60 | `hooks/register.ts` is the module; everything under `hooks/` is its parts. |
| 58 | 61 | |
| @@ -61,14 +64,14 @@ | ||
| 61 | 64 | | event | what the hook does | |
| 62 | 65 | | --- | --- | |
| 63 | 66 | | `session.start` | Binds the engine once and registers `/diff` (a session where another `/diff` is listed leaves the plugin idle); asks nothing of the repository, which `/diff` or the first edit pins when it comes. | |
| 64 | -| `ui.render` of `PromptHint` | Reads the terminal's width and whether its layout docks a pane, which decide whether the first edit opens the pane. | | |
| 67 | +| `ui.render` of `PromptHint` | Reads the terminal's width and whether its layout docks a pane, which decide whether the first edit may open the pane. | | |
| 65 | 68 | | `ui.render` of `Pane` | Draws the pane: docked, the header, base line, source picker, file list and toggles over the window of hunks; inline, the dialog. | |
| 66 | 69 | | `command.run` of `diff` | Pins the repository when none is, opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. | |
| 67 | 70 | | `ui.close` of the pane | Backs out of the dialog's detail view instead of closing; else remembers the person's close as `/diff`'s. | |
| 68 | 71 | | `ui.scroll` of the pane | Docked, moves the hunks under the pinned header and list (three rows a wheel tick, a page a page key), or the list when the wheel is over it, and keeps the engine's window still. | |
| 69 | 72 | | `ui.focus` in the pane | In the dialog's list, selects the file the ring lands on, re-centres the five rows on it, and lands the ring where that row now sits. | |
| 70 | 73 | | `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state, the pinned repository with it. | |
| 71 | -| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the session's first such edit opens it, pinning the repository then if the terminal has the room. | | |
| 74 | +| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the session's first such edit inside the repository whose fetch lists a file opens it on that fetch, pinning the repository then if the terminal has the room. | | |
| 72 | 75 | | `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, failed and interrupted ones too, refreshes an open pane. | |
| 73 | 76 | | `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. | |
| 74 | 77 |
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | { |
| 2 | - "description": "The diff pane: /diff and its Pane drawing, a refresh on Claude's edits, shell commands and finished turns, the pane's opening on the first edit, and the ask that rides a file's hunks on the next prompt", | |
| 2 | + "description": "The diff pane: /diff and its Pane drawing, a refresh on Claude's edits, shell commands and finished turns, the pane's opening on the first edit with a change to list, and the ask that rides a file's hunks on the next prompt", | |
| 3 | 3 | "modules": ["./register.ts"] |
| 4 | 4 | } |
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | export * from './git' |
| 10 | 10 | export * from './host' |
| 11 | 11 | export * from './is-on-pane-surface' |
| 12 | +export * from './is-outside-working-tree' | |
| 12 | 13 | export * from './is-record' |
| 13 | 14 | export * from './kept-of' |
| 14 | 15 | export * from './limits' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './is-outside-working-tree.js' | |
| 2 | +export * from './normal-path-of' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,29 @@ | ||
| 1 | +import Git from '../git' | |
| 2 | +import { normalPathOf } from './normal-path-of' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Whether a file a tool edited lies outside the pinned working tree, by | |
| 6 | + * how the paths are spelled alone (normalPathOf): no row could show it. | |
| 7 | + * | |
| 8 | + * A relative path is read from the session's directory. Where that | |
| 9 | + * directory is itself not spelled under the top (a symbolic link on the | |
| 10 | + * way to it), spelling proves nothing and nothing is outside. | |
| 11 | + * | |
| 12 | + * @param path the path the tool was called with | |
| 13 | + * @param tree the session's directory and the working tree's top | |
| 14 | + * @returns true only for a path surely outside the tree | |
| 15 | + */ | |
| 16 | +export function isOutsideWorkingTree( | |
| 17 | + path: string, | |
| 18 | + tree: { cwd: string; toplevel: string }, | |
| 19 | +): boolean { | |
| 20 | + const top = normalPathOf(tree.toplevel) | |
| 21 | + const absolute = Git.isAbsolutePath(path) ? path : `${tree.cwd}/${path}` | |
| 22 | + | |
| 23 | + const isUnderTop = (spelled: string) => | |
| 24 | + top === '' || spelled === top || spelled.startsWith(`${top}/`) | |
| 25 | + | |
| 26 | + return ( | |
| 27 | + isUnderTop(normalPathOf(tree.cwd)) && !isUnderTop(normalPathOf(absolute)) | |
| 28 | + ) | |
| 29 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './normal-path-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,25 @@ | ||
| 1 | +/** | |
| 2 | + * An absolute path spelled one way, its names joined by `/` with no | |
| 3 | + * leading or trailing one: either separator splits, `.` and empty names | |
| 4 | + * drop, `..` drops the name before it. No disk is read, no link followed. | |
| 5 | + * | |
| 6 | + * A drive-letter path is lowercased, as its file system compares names. | |
| 7 | + * | |
| 8 | + * @param path an absolute path, POSIX or drive-letter | |
| 9 | + * @returns the names joined, empty for the root | |
| 10 | + */ | |
| 11 | +export function normalPathOf(path: string): string { | |
| 12 | + const names: string[] = [] | |
| 13 | + | |
| 14 | + for (const name of path.split(/[\\/]/)) { | |
| 15 | + if (name === '..') { | |
| 16 | + names.pop() | |
| 17 | + } else if (name !== '' && name !== '.') { | |
| 18 | + names.push(name) | |
| 19 | + } | |
| 20 | + } | |
| 21 | + | |
| 22 | + const spelled = names.join('/') | |
| 23 | + | |
| 24 | + return /^[A-Za-z]:/.test(spelled) ? spelled.toLowerCase() : spelled | |
| 25 | +} |
| @@ -1,0 +1,22 @@ | ||
| 1 | +import type Git from '../../git' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Whether a fetch settled with a file the header would count, so a pane | |
| 5 | + * opened on it lists something where emptyStateOf would find a headline. | |
| 6 | + * | |
| 7 | + * No repository, a diff git could not read, and rows that all predate the | |
| 8 | + * session count nothing. | |
| 9 | + * | |
| 10 | + * @param outcome how the fetch ended | |
| 11 | + * @returns whether the header's session file count is above zero | |
| 12 | + */ | |
| 13 | +export function hasSessionFiles(outcome: Git.FetchOutcome): boolean { | |
| 14 | + if (outcome.kind !== 'data') { | |
| 15 | + return false | |
| 16 | + } | |
| 17 | + | |
| 18 | + const { stats, files } = outcome.data | |
| 19 | + const before = files.filter(file => file.isPreSession) | |
| 20 | + | |
| 21 | + return stats.filesCount - before.length > 0 | |
| 22 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './has-session-files.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | export * from './dialog-title-of.js' |
| 8 | 8 | export * from './empty-state' |
| 9 | 9 | export * from './empty-state-of.js' |
| 10 | +export * from './has-session-files' | |
| 10 | 11 | export * from './header-totals' |
| 11 | 12 | export * from './header-totals-of.js' |
| 12 | 13 | export * from './initial-model' |
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | ResultOf, |
| 5 | 5 | SessionMessage, |
| 6 | 6 | Timer, |
| 7 | + ToolCallInput, | |
| 7 | 8 | } from 'claude-code' |
| 8 | 9 | |
| 9 | 10 | import Ask from './ask' |
| @@ -14,6 +15,7 @@ | ||
| 14 | 15 | import type Git from './git' |
| 15 | 16 | import type { Host } from './host' |
| 16 | 17 | import { isOnPaneSurface } from './is-on-pane-surface' |
| 18 | +import { isOutsideWorkingTree } from './is-outside-working-tree' | |
| 17 | 19 | import Limits from './limits' |
| 18 | 20 | import { mapLimited } from './map-limited' |
| 19 | 21 | import { messageOf } from './message-of' |
| @@ -31,7 +33,8 @@ | ||
| 31 | 33 | * |
| 32 | 34 | * Git runs when the built-in's would: `session.start` binds the host and |
| 33 | 35 | * registers `/diff`; `/diff` or the first edit with room pins the backend |
| 34 | - * where the session started, until `/clear`; an open pane alone fetches. | |
| 36 | + * where the session started, until `/clear`; an open pane fetches, and so | |
| 37 | + * does an edit with room, inside the tree, until one lists a file to open on. | |
| 35 | 38 | * |
| 36 | 39 | * @param on the engine's registrar |
| 37 | 40 | */ |
| @@ -43,6 +46,9 @@ | ||
| 43 | 46 | let isPaneOpen = false |
| 44 | 47 | let dialogRows: number | null = null |
| 45 | 48 | let hasAutoOpened = false |
| 49 | + let isAutoOpening = false | |
| 50 | + let landed = 0 | |
| 51 | + let opens = 0 | |
| 46 | 52 | let columns: number | null = null |
| 47 | 53 | let shownSessionId: string | null = null |
| 48 | 54 | let armed: Ask.ArmedAsk | null = null |
| @@ -304,7 +310,27 @@ | ||
| 304 | 310 | ) |
| 305 | 311 | } |
| 306 | 312 | |
| 307 | - async function refresh(engine: Host): Promise<void> { | |
| 313 | + async function readOf( | |
| 314 | + engine: Host, | |
| 315 | + pinned: Backend.Backend | null, | |
| 316 | + ): Promise<PaneState.Fetched> { | |
| 317 | + const fetched = (): Promise<Git.FetchOutcome> => | |
| 318 | + pinned | |
| 319 | + ? pinned.fetchDiff(model.requestedMode) | |
| 320 | + : Promise.resolve({ kind: 'no-repository' }) | |
| 321 | + | |
| 322 | + const [outcome, messages] = await Promise.all([ | |
| 323 | + fetched(), | |
| 324 | + engine.messages().catch((): SessionMessage[] => []), | |
| 325 | + ]) | |
| 326 | + | |
| 327 | + return { outcome, messages } | |
| 328 | + } | |
| 329 | + | |
| 330 | + async function refresh( | |
| 331 | + engine: Host, | |
| 332 | + read: PaneState.Fetched | null = null, | |
| 333 | + ): Promise<void> { | |
| 308 | 334 | if (isRefreshing) { |
| 309 | 335 | isRefreshQueued = true |
| 310 | 336 | |
| @@ -316,20 +342,13 @@ | ||
| 316 | 342 | const record = Record.recorderOf(engine) |
| 317 | 343 | const pinned = backend |
| 318 | 344 | |
| 319 | - const fetched = (): Promise<Git.FetchOutcome> => | |
| 320 | - pinned | |
| 321 | - ? pinned.fetchDiff(model.requestedMode) | |
| 322 | - : Promise.resolve({ kind: 'no-repository' }) | |
| 323 | - | |
| 324 | 345 | try { |
| 325 | 346 | model = { ...model, isLoading: model.data === null } |
| 326 | 347 | |
| 327 | - const [outcome, messages] = await Promise.all([ | |
| 328 | - fetched(), | |
| 329 | - engine.messages().catch((): SessionMessage[] => []), | |
| 330 | - ]) | |
| 348 | + const fetched = read ?? (await readOf(engine, pinned)) | |
| 349 | + const { outcome } = fetched | |
| 331 | 350 | |
| 332 | - model = PaneState.afterFetch(model, { outcome, messages }) | |
| 351 | + model = PaneState.afterFetch(model, fetched) | |
| 333 | 352 | |
| 334 | 353 | switch (outcome.kind) { |
| 335 | 354 | case 'no-repository': |
| @@ -394,9 +413,12 @@ | ||
| 394 | 413 | async function openPane( |
| 395 | 414 | engine: Host, |
| 396 | 415 | trigger: (typeof Record.SHOWN_TRIGGERS)[number], |
| 416 | + read: PaneState.Fetched | null = null, | |
| 397 | 417 | ): Promise<void> { |
| 398 | 418 | const isDialog = model.isFullscreen === false |
| 399 | 419 | |
| 420 | + opens += 1 | |
| 421 | + | |
| 400 | 422 | model = { |
| 401 | 423 | ...model, |
| 402 | 424 | selectedPath: null, |
| @@ -406,6 +428,8 @@ | ||
| 406 | 428 | |
| 407 | 429 | dialogRows = isDialog ? Views.dialogRowsOf(model) : null |
| 408 | 430 | |
| 431 | + const settling = read ? refresh(engine, read) : null | |
| 432 | + | |
| 409 | 433 | await engine.openPane( |
| 410 | 434 | isDialog |
| 411 | 435 | ? { ...dialogPane(), focus: true } |
| @@ -421,7 +445,7 @@ | ||
| 421 | 445 | Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns)) |
| 422 | 446 | } |
| 423 | 447 | |
| 424 | - void refresh(engine) | |
| 448 | + void (settling ?? refresh(engine)) | |
| 425 | 449 | } |
| 426 | 450 | |
| 427 | 451 | async function closePane(engine: Host): Promise<void> { |
| @@ -436,9 +460,56 @@ | ||
| 436 | 460 | }) |
| 437 | 461 | } |
| 438 | 462 | |
| 439 | - async function openOnFirstEdit(engine: Host): Promise<void> { | |
| 440 | - const isTaken = () => isPaneOpen || hasAutoOpened | |
| 463 | + const isTaken = () => isPaneOpen || hasAutoOpened | |
| 441 | 464 | |
| 465 | + const hasRoomFor = (floor: number) => | |
| 466 | + model.isFullscreen === true && columns !== null && columns >= floor | |
| 467 | + | |
| 468 | + async function openOnFetchedFiles( | |
| 469 | + engine: Host, | |
| 470 | + floor: number, | |
| 471 | + ): Promise<void> { | |
| 472 | + if (isAutoOpening) { | |
| 473 | + return | |
| 474 | + } | |
| 475 | + | |
| 476 | + isAutoOpening = true | |
| 477 | + | |
| 478 | + try { | |
| 479 | + while (!isTaken()) { | |
| 480 | + const { epoch } = pin | |
| 481 | + const seen = landed | |
| 482 | + const opened = opens | |
| 483 | + const read = await readOf(engine, backend) | |
| 484 | + const isOvertaken = opened !== opens | |
| 485 | + | |
| 486 | + const isCurrent = | |
| 487 | + epoch === pin.epoch && !isOvertaken && hasRoomFor(floor) && !isTaken() | |
| 488 | + | |
| 489 | + const isListing = isCurrent && PaneState.hasSessionFiles(read.outcome) | |
| 490 | + | |
| 491 | + if (isListing) { | |
| 492 | + hasAutoOpened = true | |
| 493 | + await openPane(engine, 'auto_open', read) | |
| 494 | + } | |
| 495 | + | |
| 496 | + if (seen === landed || isOvertaken) { | |
| 497 | + return | |
| 498 | + } | |
| 499 | + | |
| 500 | + if (isListing) { | |
| 501 | + scheduleRefresh(engine) | |
| 502 | + } | |
| 503 | + } | |
| 504 | + } finally { | |
| 505 | + isAutoOpening = false | |
| 506 | + } | |
| 507 | + } | |
| 508 | + | |
| 509 | + async function openOnFirstEdit( | |
| 510 | + engine: Host, | |
| 511 | + path: string | null, | |
| 512 | + ): Promise<void> { | |
| 442 | 513 | if (isTaken()) { |
| 443 | 514 | return |
| 444 | 515 | } |
| @@ -450,11 +521,7 @@ | ||
| 450 | 521 | ? Limits.OPEN_MIN_COLUMNS |
| 451 | 522 | : Limits.AUTO_OPEN_MIN_COLUMNS |
| 452 | 523 | |
| 453 | - const hasRoom = | |
| 454 | - preference !== false && | |
| 455 | - model.isFullscreen === true && | |
| 456 | - columns !== null && | |
| 457 | - columns >= floor | |
| 524 | + const hasRoom = preference !== false && hasRoomFor(floor) | |
| 458 | 525 | |
| 459 | 526 | if (!hasRoom || isTaken()) { |
| 460 | 527 | return |
| @@ -466,8 +533,16 @@ | ||
| 466 | 533 | return |
| 467 | 534 | } |
| 468 | 535 | |
| 469 | - hasAutoOpened = true | |
| 470 | - await openPane(engine, 'auto_open') | |
| 536 | + const isOutside = | |
| 537 | + path !== null && | |
| 538 | + isOutsideWorkingTree(path, { | |
| 539 | + cwd: pin.cwd, | |
| 540 | + toplevel: backend.repository.toplevel, | |
| 541 | + }) | |
| 542 | + | |
| 543 | + if (!isOutside) { | |
| 544 | + await openOnFetchedFiles(engine, floor) | |
| 545 | + } | |
| 471 | 546 | } |
| 472 | 547 | |
| 473 | 548 | function disarm(engine: Host) { |
| @@ -818,10 +893,10 @@ | ||
| 818 | 893 | |
| 819 | 894 | function afterTool( |
| 820 | 895 | engine: Host, |
| 821 | - tool: string, | |
| 896 | + e: ToolCallInput, | |
| 822 | 897 | result: ResultOf['tool.call'] | undefined, |
| 823 | 898 | ) { |
| 824 | - const isEdit = Tools.EDITING_TOOLS.some(name => name === tool) | |
| 899 | + const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool) | |
| 825 | 900 | |
| 826 | 901 | const hasEdited = |
| 827 | 902 | isEdit && |
| @@ -829,16 +904,20 @@ | ||
| 829 | 904 | result.deny === undefined && |
| 830 | 905 | result.isError !== true |
| 831 | 906 | |
| 832 | - const isStale = | |
| 833 | - isPaneOpen && | |
| 834 | - (isEdit ? hasEdited : result === undefined || result.deny === undefined) | |
| 907 | + const hasLanded = isEdit | |
| 908 | + ? hasEdited | |
| 909 | + : result === undefined || result.deny === undefined | |
| 835 | 910 | |
| 836 | - if (isStale) { | |
| 911 | + if (hasLanded) { | |
| 912 | + landed += 1 | |
| 913 | + } | |
| 914 | + | |
| 915 | + if (isPaneOpen && hasLanded) { | |
| 837 | 916 | scheduleRefresh(engine) |
| 838 | 917 | } |
| 839 | 918 | |
| 840 | 919 | if (hasEdited) { |
| 841 | - void openOnFirstEdit(engine).catch(() => undefined) | |
| 920 | + void openOnFirstEdit(engine, Tools.editedPathOf(e)).catch(() => undefined) | |
| 842 | 921 | } |
| 843 | 922 | } |
| 844 | 923 | |
| @@ -854,7 +933,7 @@ | ||
| 854 | 933 | return result |
| 855 | 934 | } finally { |
| 856 | 935 | if (host) { |
| 857 | - afterTool(host, e.tool, result) | |
| 936 | + afterTool(host, e, result) | |
| 858 | 937 | } |
| 859 | 938 | } |
| 860 | 939 | }, |
| @@ -1,0 +1,21 @@ | ||
| 1 | +import type { ToolCallInput } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The file an editing tool was called on, as its input names it: | |
| 5 | + * `file_path` for Edit and Write, `notebook_path` for NotebookEdit. | |
| 6 | + * | |
| 7 | + * @param e the `tool.call` event | |
| 8 | + * @returns the path as given, or null when the input names none | |
| 9 | + */ | |
| 10 | +export function editedPathOf(e: ToolCallInput): string | null { | |
| 11 | + const isFileEdit = e.tool === 'Edit' || e.tool === 'Write' | |
| 12 | + const isNotebookEdit = e.tool === 'NotebookEdit' | |
| 13 | + | |
| 14 | + const path = isFileEdit | |
| 15 | + ? e.file_path | |
| 16 | + : isNotebookEdit | |
| 17 | + ? e.notebook_path | |
| 18 | + : null | |
| 19 | + | |
| 20 | + return typeof path === 'string' && path !== '' ? path : null | |
| 21 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './edited-path-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,3 +1,4 @@ | ||
| 1 | +export * from './edited-path-of' | |
| 1 | 2 | export * from './editing-tools.js' |
| 2 | 3 | export * from './shell-tools.js' |
| 3 | 4 | export * from './todo-tool.js' |
| @@ -1,0 +1,51 @@ | ||
| 1 | +import type { Args, On } from 'claude-code' | |
| 2 | +import { mock } from 'claude-code/testing' | |
| 3 | + | |
| 4 | +import { gitIn } from './git-in.js' | |
| 5 | +import { HINT_DRAWN } from './hint-drawn.js' | |
| 6 | +import { keeping } from './keeping.js' | |
| 7 | +import { REPOSITORY } from './repository.js' | |
| 8 | +import { startsSession } from './starts-session.js' | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * A session in a repository as inRepository keeps one, where git takes a | |
| 12 | + * millisecond of the clock to answer each `--numstat`: time for a test to | |
| 13 | + * land an edit or a `/clear` while the diff is being read. | |
| 14 | + * | |
| 15 | + * What git answers is settled as the read starts, from the script as it | |
| 16 | + * stands then. | |
| 17 | + * | |
| 18 | + * @param on the test's `on` | |
| 19 | + * @param script git's output for each invocation whose line holds the key | |
| 20 | + * @returns the clock's time as each read started, the panes opened, the | |
| 21 | + * clock | |
| 22 | + */ | |
| 23 | +export function inSlowRepository( | |
| 24 | + on: On, | |
| 25 | + script: Readonly<Record<string, string>> = REPOSITORY, | |
| 26 | +) { | |
| 27 | + const reads: number[] = [] | |
| 28 | + const opened = keeping<Args<'ui.open'>>() | |
| 29 | + const clock = startsSession(on) | |
| 30 | + | |
| 31 | + on('process.run', async ($, e) => { | |
| 32 | + const value = gitIn(e.argv, script) | |
| 33 | + | |
| 34 | + if (e.argv.includes('--numstat')) { | |
| 35 | + reads.push(await clock.now()) | |
| 36 | + await clock.sleep(1) | |
| 37 | + } | |
| 38 | + | |
| 39 | + return { value } | |
| 40 | + }) | |
| 41 | + | |
| 42 | + on('ui.open', opened.hook) | |
| 43 | + on('ui.close', () => ({ value: undefined })) | |
| 44 | + on('ui.status', () => ({ value: undefined })) | |
| 45 | + on('ui.invalidate', () => ({ value: undefined })) | |
| 46 | + on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN) | |
| 47 | + on('session.messages', () => ({ value: [] })) | |
| 48 | + mock.store(on, {}) | |
| 49 | + | |
| 50 | + return { reads, opened: opened.kept, clock } | |
| 51 | +} |
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | export * from './hint-at.js' |
| 23 | 23 | export * from './hint-drawn.js' |
| 24 | 24 | export * from './in-repository.js' |
| 25 | +export * from './in-slow-repository.js' | |
| 25 | 26 | export * from './inline-pane.js' |
| 26 | 27 | export * from './keeping.js' |
| 27 | 28 | export * from './kind-walk-of' |
| @@ -1,0 +1,60 @@ | ||
| 1 | +import { describe, expect, test, tier } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +import { isOutsideWorkingTree } from '../hooks/is-outside-working-tree' | |
| 4 | + | |
| 5 | +tier('builtin') | |
| 6 | + | |
| 7 | +describe('is-outside-working-tree', () => { | |
| 8 | + const WORK = { cwd: '/work/src', toplevel: '/work' } | |
| 9 | + | |
| 10 | + test('the top and what is spelled under it are inside', () => { | |
| 11 | + for (const path of [ | |
| 12 | + '/work', | |
| 13 | + '/work/', | |
| 14 | + '/work/app.ts', | |
| 15 | + '/work//src/./deep/../a.ts', | |
| 16 | + 'a.ts', | |
| 17 | + './deep/a.ts', | |
| 18 | + '../app.ts', | |
| 19 | + ]) { | |
| 20 | + expect(isOutsideWorkingTree(path, WORK), path).toBe(false) | |
| 21 | + } | |
| 22 | + }) | |
| 23 | + | |
| 24 | + test('elsewhere, climbed out, or a sibling sharing its spelling: outside', () => { | |
| 25 | + for (const path of [ | |
| 26 | + '/', | |
| 27 | + '/tmp/notes.md', | |
| 28 | + '/workshop/a.ts', | |
| 29 | + '/work-old/a.ts', | |
| 30 | + '/work/../etc/hosts', | |
| 31 | + '../../elsewhere/a.ts', | |
| 32 | + ]) { | |
| 33 | + expect(isOutsideWorkingTree(path, WORK), path).toBe(true) | |
| 34 | + } | |
| 35 | + }) | |
| 36 | + | |
| 37 | + test('a drive-letter tree: either separator, any case', () => { | |
| 38 | + const tree = { cwd: 'C:\\Users\\me\\work', toplevel: 'C:/Users/me/work' } | |
| 39 | + | |
| 40 | + expect(isOutsideWorkingTree('c:\\users\\me\\work\\a.ts', tree)).toBe(false) | |
| 41 | + expect(isOutsideWorkingTree('src\\a.ts', tree)).toBe(false) | |
| 42 | + expect(isOutsideWorkingTree('C:\\Users\\me\\workshop\\a.ts', tree)).toBe( | |
| 43 | + true, | |
| 44 | + ) | |
| 45 | + expect(isOutsideWorkingTree('D:\\Users\\me\\work\\a.ts', tree)).toBe(true) | |
| 46 | + }) | |
| 47 | + | |
| 48 | + test('a tree at the root holds every path', () => { | |
| 49 | + expect(isOutsideWorkingTree('/tmp/a.ts', { cwd: '/', toplevel: '/' })).toBe( | |
| 50 | + false, | |
| 51 | + ) | |
| 52 | + }) | |
| 53 | + | |
| 54 | + test("a session's directory spelled outside its own top proves nothing", () => { | |
| 55 | + const linked = { cwd: '/tmp/work', toplevel: '/private/tmp/work' } | |
| 56 | + | |
| 57 | + expect(isOutsideWorkingTree('/tmp/work/a.ts', linked)).toBe(false) | |
| 58 | + expect(isOutsideWorkingTree('/elsewhere/a.ts', linked)).toBe(false) | |
| 59 | + }) | |
| 60 | +}) |
| @@ -1,0 +1,65 @@ | ||
| 1 | +import { describe, expect, test, tier } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +import type Git from '../../hooks/git' | |
| 4 | +import PaneState from '../../hooks/pane-state' | |
| 5 | +import Fixtures from '../fixtures' | |
| 6 | + | |
| 7 | +tier('builtin') | |
| 8 | + | |
| 9 | +describe('has-session-files', () => { | |
| 10 | + const dataOf = ( | |
| 11 | + files: readonly Git.FileStat[], | |
| 12 | + overrides: Partial<Git.DiffData> = {}, | |
| 13 | + ): Git.FetchOutcome => ({ | |
| 14 | + kind: 'data', | |
| 15 | + data: { | |
| 16 | + repository: Fixtures.CHECKOUT, | |
| 17 | + mode: 'session', | |
| 18 | + stats: { ...Fixtures.NO_STATS, filesCount: files.length }, | |
| 19 | + files, | |
| 20 | + source: { kind: 'working-tree', base: 'HEAD' }, | |
| 21 | + isUnborn: false, | |
| 22 | + baseRef: 'HEAD', | |
| 23 | + stalePaths: [], | |
| 24 | + isUntrackedWithheld: false, | |
| 25 | + ...overrides, | |
| 26 | + }, | |
| 27 | + }) | |
| 28 | + | |
| 29 | + test("a file the header counts, a test among them: there's one", () => { | |
| 30 | + expect(PaneState.hasSessionFiles(dataOf([Fixtures.SESSION_ROW]))).toBe(true) | |
| 31 | + expect(PaneState.hasSessionFiles(dataOf([Fixtures.TEST_ROW]))).toBe(true) | |
| 32 | + | |
| 33 | + expect( | |
| 34 | + PaneState.hasSessionFiles( | |
| 35 | + dataOf([Fixtures.PRE_SESSION_ROW, Fixtures.SESSION_ROW]), | |
| 36 | + ), | |
| 37 | + ).toBe(true) | |
| 38 | + }) | |
| 39 | + | |
| 40 | + test('files counted past the row cap, none stored, still count', () => { | |
| 41 | + expect( | |
| 42 | + PaneState.hasSessionFiles( | |
| 43 | + dataOf([], { | |
| 44 | + stats: { ...Fixtures.NO_STATS, filesCount: Fixtures.PAST_CAP_COUNT }, | |
| 45 | + }), | |
| 46 | + ), | |
| 47 | + ).toBe(true) | |
| 48 | + }) | |
| 49 | + | |
| 50 | + test('wherever the pane would headline an empty state: none', () => { | |
| 51 | + const withheld = { isUntrackedWithheld: true } | |
| 52 | + | |
| 53 | + expect(PaneState.hasSessionFiles({ kind: 'no-repository' })).toBe(false) | |
| 54 | + expect(PaneState.hasSessionFiles({ kind: 'unavailable' })).toBe(false) | |
| 55 | + expect(PaneState.hasSessionFiles(dataOf([]))).toBe(false) | |
| 56 | + expect(PaneState.hasSessionFiles(dataOf([], withheld))).toBe(false) | |
| 57 | + expect(PaneState.hasSessionFiles(dataOf([], { isUnborn: true }))).toBe( | |
| 58 | + false, | |
| 59 | + ) | |
| 60 | + | |
| 61 | + expect(PaneState.hasSessionFiles(dataOf([Fixtures.PRE_SESSION_ROW]))).toBe( | |
| 62 | + false, | |
| 63 | + ) | |
| 64 | + }) | |
| 65 | +}) |
| @@ -448,6 +448,433 @@ | ||
| 448 | 448 | ).toEqual(['diff']) |
| 449 | 449 | }) |
| 450 | 450 | |
| 451 | + test('an edit outside the repository opens nothing, the next one inside does', async ($, on) => { | |
| 452 | + const world = Fixtures.inRepository(on) | |
| 453 | + | |
| 454 | + on('tool.call', () => ({ result: 'written' })) | |
| 455 | + | |
| 456 | + await $.session.start(Fixtures.SESSION) | |
| 457 | + await $.ui.render(Fixtures.HINT) | |
| 458 | + | |
| 459 | + await $.tool.call({ | |
| 460 | + tool: 'Write', | |
| 461 | + file_path: '/tmp/notes.md', | |
| 462 | + content: 'a note', | |
| 463 | + }) | |
| 464 | + | |
| 465 | + await $.tool.call({ | |
| 466 | + tool: 'NotebookEdit', | |
| 467 | + notebook_path: '../elsewhere/plot.ipynb', | |
| 468 | + new_source: '1', | |
| 469 | + }) | |
| 470 | + | |
| 471 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 472 | + | |
| 473 | + expect(world.opened, 'no row could show either file').toEqual([]) | |
| 474 | + | |
| 475 | + expect( | |
| 476 | + world.runs.map(run => Fixtures.gitWordOf(run.argv)), | |
| 477 | + 'the repository was found, its working tree never read', | |
| 478 | + ).toEqual(['rev-parse --show-toplevel']) | |
| 479 | + | |
| 480 | + await $.tool.call({ | |
| 481 | + tool: 'Write', | |
| 482 | + file_path: 'app.ts', | |
| 483 | + content: 'const a = 2', | |
| 484 | + }) | |
| 485 | + | |
| 486 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 487 | + | |
| 488 | + expect( | |
| 489 | + world.opened.map(pane => pane.id), | |
| 490 | + "a path from the session's directory, inside: the first edit still", | |
| 491 | + ).toEqual(['diff']) | |
| 492 | + }) | |
| 493 | + | |
| 494 | + test('a directory beside the repository that starts with its name is outside', async ($, on) => { | |
| 495 | + const world = Fixtures.inRepository(on) | |
| 496 | + | |
| 497 | + on('tool.call', () => ({ result: 'written' })) | |
| 498 | + | |
| 499 | + await $.session.start(Fixtures.SESSION) | |
| 500 | + await $.ui.render(Fixtures.HINT) | |
| 501 | + | |
| 502 | + await $.tool.call({ | |
| 503 | + tool: 'Write', | |
| 504 | + file_path: '/workshop/a.ts', | |
| 505 | + content: 'const a = 1', | |
| 506 | + }) | |
| 507 | + | |
| 508 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 509 | + | |
| 510 | + expect(world.opened, '/workshop is not under /work').toEqual([]) | |
| 511 | + }) | |
| 512 | + | |
| 513 | + test('an edit the diff lists no row for opens nothing, a later one it lists does', async ($, on) => { | |
| 514 | + const script: Record<string, string> = { | |
| 515 | + ...Fixtures.REPOSITORY, | |
| 516 | + 'HEAD --shortstat': '', | |
| 517 | + 'HEAD --numstat': '', | |
| 518 | + } | |
| 519 | + | |
| 520 | + const world = Fixtures.inRepository(on, script) | |
| 521 | + | |
| 522 | + const fetches = () => | |
| 523 | + world.runs.filter(run => run.argv.includes('--numstat')).length | |
| 524 | + | |
| 525 | + const edit = (path: string) => | |
| 526 | + $.tool.call({ | |
| 527 | + tool: 'Edit', | |
| 528 | + file_path: path, | |
| 529 | + old_string: '1', | |
| 530 | + new_string: '2', | |
| 531 | + }) | |
| 532 | + | |
| 533 | + on('tool.call', () => ({ result: 'edited' })) | |
| 534 | + | |
| 535 | + await $.session.start(Fixtures.SESSION) | |
| 536 | + await $.ui.render(Fixtures.HINT) | |
| 537 | + await edit('/work/ignored/cache.json') | |
| 538 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 539 | + | |
| 540 | + expect(world.opened, 'it would say "No changes this session"').toEqual([]) | |
| 541 | + expect(fetches(), 'one fetch said so').toBe(1) | |
| 542 | + | |
| 543 | + await edit('/work/ignored/cache.json') | |
| 544 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 545 | + | |
| 546 | + expect(world.opened, 'nor the second time').toEqual([]) | |
| 547 | + expect(fetches(), 'a fetch an edit, no more').toBe(2) | |
| 548 | + | |
| 549 | + Object.assign(script, Fixtures.REPOSITORY) | |
| 550 | + | |
| 551 | + await edit('/work/app.ts') | |
| 552 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 553 | + | |
| 554 | + expect( | |
| 555 | + world.opened.map(pane => pane.id), | |
| 556 | + 'the first edit with a row to list', | |
| 557 | + ).toEqual(['diff']) | |
| 558 | + | |
| 559 | + expect(fetches(), 'opened on the fetch that found it').toBe(3) | |
| 560 | + }) | |
| 561 | + | |
| 562 | + test('a diff git could not read, or no tracked row with the untracked withheld, opens nothing', async ($, on) => { | |
| 563 | + const script: Record<string, string> = { ...Fixtures.REPOSITORY } | |
| 564 | + const world = Fixtures.inRepository(on, script) | |
| 565 | + | |
| 566 | + const edit = () => | |
| 567 | + $.tool.call({ | |
| 568 | + tool: 'Edit', | |
| 569 | + file_path: '/work/app.ts', | |
| 570 | + old_string: '1', | |
| 571 | + new_string: '2', | |
| 572 | + }) | |
| 573 | + | |
| 574 | + on('tool.call', () => ({ result: 'edited' })) | |
| 575 | + | |
| 576 | + delete script['HEAD --numstat'] | |
| 577 | + delete script['ls-files'] | |
| 578 | + | |
| 579 | + await $.session.start(Fixtures.SESSION) | |
| 580 | + await $.ui.render(Fixtures.HINT) | |
| 581 | + await edit() | |
| 582 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 583 | + | |
| 584 | + expect(world.opened, 'it would say "Diff unavailable"').toEqual([]) | |
| 585 | + | |
| 586 | + script['HEAD --shortstat'] = '' | |
| 587 | + script['HEAD --numstat'] = '' | |
| 588 | + | |
| 589 | + await edit() | |
| 590 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 591 | + | |
| 592 | + expect(world.opened, 'it would say "No tracked changes"').toEqual([]) | |
| 593 | + | |
| 594 | + Object.assign(script, Fixtures.REPOSITORY) | |
| 595 | + | |
| 596 | + await edit() | |
| 597 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 598 | + | |
| 599 | + expect( | |
| 600 | + world.opened.map(pane => pane.id), | |
| 601 | + 'read whole, with a row: open', | |
| 602 | + ).toEqual(['diff']) | |
| 603 | + }) | |
| 604 | + | |
| 605 | + test('the first edit opens the pane on the one fetch that found its rows', async ($, on) => { | |
| 606 | + const runs: Args<'process.run'>[] = [] | |
| 607 | + const drawnAtOpen: string[] = [] | |
| 608 | + const clock = Fixtures.startsSession(on) | |
| 609 | + | |
| 610 | + on('process.run', (_engine, e) => { | |
| 611 | + runs.push(e) | |
| 612 | + | |
| 613 | + return { value: Fixtures.gitIn(e.argv) } | |
| 614 | + }) | |
| 615 | + | |
| 616 | + on('ui.open', async () => { | |
| 617 | + drawnAtOpen.push(Fixtures.textOf(await $.ui.render(Fixtures.PANE))) | |
| 618 | + | |
| 619 | + return { value: undefined } | |
| 620 | + }) | |
| 621 | + | |
| 622 | + on('ui.invalidate', () => ({ value: undefined })) | |
| 623 | + on('ui.render', { component: 'PromptHint' }, () => Fixtures.HINT_DRAWN) | |
| 624 | + on('session.messages', () => ({ value: [] })) | |
| 625 | + on('tool.call', () => ({ result: 'edited' })) | |
| 626 | + mock.store(on, {}) | |
| 627 | + | |
| 628 | + await $.session.start(Fixtures.SESSION) | |
| 629 | + await $.ui.render(Fixtures.HINT) | |
| 630 | + | |
| 631 | + await $.tool.call({ | |
| 632 | + tool: 'Edit', | |
| 633 | + file_path: '/work/app.ts', | |
| 634 | + old_string: '1', | |
| 635 | + new_string: '2', | |
| 636 | + }) | |
| 637 | + | |
| 638 | + await clock.advance(Fixtures.SETTLE_MS) | |
| 639 | + | |
| 640 | + expect(drawnAtOpen, 'one pane, listing as it opens').toEqual([ | |
| 641 | + expect.stringContaining('1 file changed'), | |
| 642 | + ]) | |
| 643 | + | |
| 644 | + expect( | |
| 645 | + runs | |
| 646 | + .map(run => Fixtures.gitWordOf(run.argv)) | |
| 647 | + .filter(word => word !== Fixtures.POLL_WORD), | |
| 648 | + "/diff's spawns, once", | |
| 649 | + ).toEqual([ | |
| 650 | + 'rev-parse --show-toplevel', | |
| 651 | + 'status', | |
| 652 | + 'diff --shortstat', | |
| 653 | + 'diff --numstat', | |
| 654 | + 'ls-files', | |
| 655 | + 'diff -- app.ts', | |
| 656 | + ]) | |
| 657 | + }) | |
| 658 | + | |
| 659 | + test('edits landing together open one pane', async ($, on) => { | |
| 660 | + const world = Fixtures.inRepository(on) | |
| 661 | + | |
| 662 | + const edit = () => | |
| 663 | + $.tool.call({ | |
| 664 | + tool: 'Edit', | |
| 665 | + file_path: '/work/app.ts', | |
| 666 | + old_string: '1', | |
| 667 | + new_string: '2', | |
| 668 | + }) | |
| 669 | + | |
| 670 | + on('tool.call', () => ({ result: 'edited' })) | |
| 671 | + | |
| 672 | + await $.session.start(Fixtures.SESSION) | |
| 673 | + await $.ui.render(Fixtures.HINT) | |
| 674 | + await Promise.all([edit(), edit(), edit()]) | |
| 675 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 676 | + | |
| 677 | + expect(world.opened.map(pane => pane.id)).toEqual(['diff']) | |
| 678 | + | |
| 679 | + expect( | |
| 680 | + world.runs.filter(run => run.argv.includes('--numstat')), | |
| 681 | + 'on one fetch', | |
| 682 | + ).toHaveLength(1) | |
| 683 | + }) | |
| 684 | + | |
| 685 | + test('an edit or a command that lands while the diff is read is read too', async ($, on) => { | |
| 686 | + const script: Record<string, string> = { | |
| 687 | + ...Fixtures.REPOSITORY, | |
| 688 | + 'HEAD --shortstat': '', | |
| 689 | + 'HEAD --numstat': '', | |
| 690 | + } | |
| 691 | + | |
| 692 | + const world = Fixtures.inSlowRepository(on, script) | |
| 693 | + const openedIds = () => world.opened.map(pane => pane.id) | |
| 694 | + | |
| 695 | + const edit = (path: string) => | |
| 696 | + $.tool.call({ | |
| 697 | + tool: 'Edit', | |
| 698 | + file_path: path, | |
| 699 | + old_string: '1', | |
| 700 | + new_string: '2', | |
| 701 | + }) | |
| 702 | + | |
| 703 | + on('tool.call', () => ({ result: 'edited' })) | |
| 704 | + | |
| 705 | + await $.session.start(Fixtures.SESSION) | |
| 706 | + await $.ui.render(Fixtures.HINT) | |
| 707 | + await edit('/work/ignored/cache.json') | |
| 708 | + await world.clock.settle() | |
| 709 | + | |
| 710 | + Object.assign(script, Fixtures.REPOSITORY) | |
| 711 | + | |
| 712 | + await edit('/work/app.ts') | |
| 713 | + await world.clock.settle() | |
| 714 | + | |
| 715 | + expect(world.reads, 'the second edit began no read of its own').toEqual([0]) | |
| 716 | + | |
| 717 | + await world.clock.advance(1) | |
| 718 | + | |
| 719 | + expect(openedIds(), 'the first read had found nothing').toEqual([]) | |
| 720 | + expect(world.reads, 'so the edit it missed is read').toEqual([0, 1]) | |
| 721 | + | |
| 722 | + await $.tool.call({ tool: 'Bash', command: 'make' }) | |
| 723 | + await world.clock.advance(1) | |
| 724 | + | |
| 725 | + expect(openedIds(), 'that read lists a file').toEqual(['diff']) | |
| 726 | + | |
| 727 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 728 | + | |
| 729 | + expect( | |
| 730 | + world.reads, | |
| 731 | + 'and the pane, open, reads what the command may have changed since', | |
| 732 | + ).toHaveLength(3) | |
| 733 | + }) | |
| 734 | + | |
| 735 | + test('a read that /clear overtakes opens nothing, the next edit reads again', async ($, on) => { | |
| 736 | + const world = Fixtures.inSlowRepository(on) | |
| 737 | + | |
| 738 | + const edit = () => | |
| 739 | + $.tool.call({ | |
| 740 | + tool: 'Edit', | |
| 741 | + file_path: '/work/app.ts', | |
| 742 | + old_string: '1', | |
| 743 | + new_string: '2', | |
| 744 | + }) | |
| 745 | + | |
| 746 | + on('tool.call', () => ({ result: 'edited' })) | |
| 747 | + on('command.run', { command: 'clear' }, () => ({})) | |
| 748 | + | |
| 749 | + await $.session.start(Fixtures.SESSION) | |
| 750 | + await $.ui.render(Fixtures.HINT) | |
| 751 | + await edit() | |
| 752 | + await world.clock.settle() | |
| 753 | + await $.command.run(Fixtures.CLEAR) | |
| 754 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 755 | + | |
| 756 | + expect(world.reads, 'a read was under way at /clear').toEqual([0]) | |
| 757 | + expect(world.opened, 'it read for the conversation before').toEqual([]) | |
| 758 | + | |
| 759 | + await edit() | |
| 760 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 761 | + | |
| 762 | + expect( | |
| 763 | + world.opened.map(pane => pane.id), | |
| 764 | + "the new conversation's first edit", | |
| 765 | + ).toEqual(['diff']) | |
| 766 | + }) | |
| 767 | + | |
| 768 | + test('a pane the person hid while the diff was read stays hidden', async ($, on) => { | |
| 769 | + const world = Fixtures.inSlowRepository(on) | |
| 770 | + | |
| 771 | + on('tool.call', () => ({ result: 'edited' })) | |
| 772 | + | |
| 773 | + await $.session.start(Fixtures.SESSION) | |
| 774 | + await $.ui.render(Fixtures.HINT) | |
| 775 | + | |
| 776 | + await $.tool.call({ | |
| 777 | + tool: 'Edit', | |
| 778 | + file_path: '/work/app.ts', | |
| 779 | + old_string: '1', | |
| 780 | + new_string: '2', | |
| 781 | + }) | |
| 782 | + | |
| 783 | + await world.clock.settle() | |
| 784 | + | |
| 785 | + expect(world.reads, "the edit's read is under way").toEqual([0]) | |
| 786 | + | |
| 787 | + expect(await $.command.run(Fixtures.DIFF)).toEqual({ | |
| 788 | + text: 'Diff panel shown', | |
| 789 | + }) | |
| 790 | + | |
| 791 | + expect(await $.command.run(Fixtures.DIFF)).toEqual({ | |
| 792 | + text: 'Diff panel hidden', | |
| 793 | + }) | |
| 794 | + | |
| 795 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 796 | + | |
| 797 | + expect( | |
| 798 | + world.opened.map(pane => pane.id), | |
| 799 | + "/diff's open alone: the read gave way to the person's close", | |
| 800 | + ).toEqual(['diff']) | |
| 801 | + }) | |
| 802 | + | |
| 803 | + test('nor does a command landing in that read open it again', async ($, on) => { | |
| 804 | + const world = Fixtures.inSlowRepository(on) | |
| 805 | + | |
| 806 | + on('tool.call', () => ({ result: 'done' })) | |
| 807 | + | |
| 808 | + await $.session.start(Fixtures.SESSION) | |
| 809 | + await $.ui.render(Fixtures.HINT) | |
| 810 | + | |
| 811 | + await $.tool.call({ | |
| 812 | + tool: 'Edit', | |
| 813 | + file_path: '/work/app.ts', | |
| 814 | + old_string: '1', | |
| 815 | + new_string: '2', | |
| 816 | + }) | |
| 817 | + | |
| 818 | + await world.clock.settle() | |
| 819 | + await $.command.run(Fixtures.DIFF) | |
| 820 | + await $.command.run(Fixtures.DIFF) | |
| 821 | + await $.tool.call({ tool: 'Bash', command: 'make' }) | |
| 822 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 823 | + | |
| 824 | + expect( | |
| 825 | + world.opened.map(pane => pane.id), | |
| 826 | + 'the attempt stood down; it did not read again for what landed', | |
| 827 | + ).toEqual(['diff']) | |
| 828 | + }) | |
| 829 | + | |
| 830 | + test('/diff typed while the diff is read opens the one pane', async ($, on) => { | |
| 831 | + const world = Fixtures.inSlowRepository(on) | |
| 832 | + | |
| 833 | + on('tool.call', () => ({ result: 'edited' })) | |
| 834 | + | |
| 835 | + await $.session.start(Fixtures.SESSION) | |
| 836 | + await $.ui.render(Fixtures.HINT) | |
| 837 | + | |
| 838 | + await $.tool.call({ | |
| 839 | + tool: 'Edit', | |
| 840 | + file_path: '/work/app.ts', | |
| 841 | + old_string: '1', | |
| 842 | + new_string: '2', | |
| 843 | + }) | |
| 844 | + | |
| 845 | + await world.clock.settle() | |
| 846 | + await $.command.run(Fixtures.DIFF) | |
| 847 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 848 | + | |
| 849 | + expect( | |
| 850 | + world.opened.map(pane => pane.id), | |
| 851 | + "/diff's, not a second", | |
| 852 | + ).toEqual(['diff']) | |
| 853 | + }) | |
| 854 | + | |
| 855 | + test('a terminal narrowed while the diff is read opens nothing', async ($, on) => { | |
| 856 | + const world = Fixtures.inSlowRepository(on) | |
| 857 | + | |
| 858 | + on('tool.call', () => ({ result: 'edited' })) | |
| 859 | + | |
| 860 | + await $.session.start(Fixtures.SESSION) | |
| 861 | + await $.ui.render(Fixtures.HINT) | |
| 862 | + | |
| 863 | + await $.tool.call({ | |
| 864 | + tool: 'Edit', | |
| 865 | + file_path: '/work/app.ts', | |
| 866 | + old_string: '1', | |
| 867 | + new_string: '2', | |
| 868 | + }) | |
| 869 | + | |
| 870 | + await world.clock.settle() | |
| 871 | + await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) | |
| 872 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 873 | + | |
| 874 | + expect(world.reads, 'a read was under way as it narrowed').toEqual([0]) | |
| 875 | + expect(world.opened, 'the room it read for is gone').toEqual([]) | |
| 876 | + }) | |
| 877 | + | |
| 451 | 878 | test('/clear closes the pane it finds open', async ($, on) => { |
| 452 | 879 | const world = Fixtures.inRepository(on) |
| 453 | 880 |
| @@ -1,0 +1,64 @@ | ||
| 1 | +import { describe, expect, test, tier } from 'claude-code/testing' | |
| 2 | + | |
| 3 | +import Tools from '../../hooks/tools' | |
| 4 | + | |
| 5 | +tier('builtin') | |
| 6 | + | |
| 7 | +describe('edited-path-of', () => { | |
| 8 | + test("each editing tool's own path argument", () => { | |
| 9 | + expect( | |
| 10 | + Tools.editedPathOf({ | |
| 11 | + tool: 'Edit', | |
| 12 | + tool_use_id: 'toolu_1', | |
| 13 | + file_path: '/work/app.ts', | |
| 14 | + old_string: '1', | |
| 15 | + new_string: '2', | |
| 16 | + }), | |
| 17 | + ).toBe('/work/app.ts') | |
| 18 | + | |
| 19 | + expect( | |
| 20 | + Tools.editedPathOf({ | |
| 21 | + tool: 'Write', | |
| 22 | + tool_use_id: 'toolu_2', | |
| 23 | + file_path: 'notes.md', | |
| 24 | + content: '', | |
| 25 | + }), | |
| 26 | + ).toBe('notes.md') | |
| 27 | + | |
| 28 | + expect( | |
| 29 | + Tools.editedPathOf({ | |
| 30 | + tool: 'NotebookEdit', | |
| 31 | + tool_use_id: 'toolu_3', | |
| 32 | + notebook_path: '/work/plot.ipynb', | |
| 33 | + new_source: '1', | |
| 34 | + }), | |
| 35 | + ).toBe('/work/plot.ipynb') | |
| 36 | + }) | |
| 37 | + | |
| 38 | + test('an empty path, or a tool that edits no file: null', () => { | |
| 39 | + expect( | |
| 40 | + Tools.editedPathOf({ | |
| 41 | + tool: 'Write', | |
| 42 | + tool_use_id: 'toolu_4', | |
| 43 | + file_path: '', | |
| 44 | + content: '', | |
| 45 | + }), | |
| 46 | + ).toBeNull() | |
| 47 | + | |
| 48 | + expect( | |
| 49 | + Tools.editedPathOf({ | |
| 50 | + tool: 'Bash', | |
| 51 | + tool_use_id: 'toolu_5', | |
| 52 | + command: 'make', | |
| 53 | + }), | |
| 54 | + ).toBeNull() | |
| 55 | + | |
| 56 | + expect( | |
| 57 | + Tools.editedPathOf({ | |
| 58 | + tool: 'mcp__files__write', | |
| 59 | + tool_use_id: 'toolu_6', | |
| 60 | + file_path: '/work/app.ts', | |
| 61 | + }), | |
| 62 | + ).toBeNull() | |
| 63 | + }) | |
| 64 | +}) |