spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

diff: the first edit opens the pane only when it has a file to listBrowse files
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.
Claude authored on Sep 16, 2026 parent 744fb6a · commit 2bdd1fdf20f8
21 changed files+907 -44compared with 744fb6a
Mmods/diff/.claude-plugin/plugin.json
Mmods/diff/README.md
Mmods/diff/hooks/hooks.json
Mmods/diff/hooks/index.ts
Amods/diff/hooks/is-outside-working-tree/index.ts
Amods/diff/hooks/is-outside-working-tree/is-outside-working-tree.ts
Amods/diff/hooks/is-outside-working-tree/normal-path-of/index.ts
Amods/diff/hooks/is-outside-working-tree/normal-path-of/normal-path-of.ts
Amods/diff/hooks/pane-state/has-session-files/has-session-files.ts
Amods/diff/hooks/pane-state/has-session-files/index.ts
Mmods/diff/hooks/pane-state/index.ts
Mmods/diff/hooks/register.ts
Amods/diff/hooks/tools/edited-path-of/edited-path-of.ts
Amods/diff/hooks/tools/edited-path-of/index.ts
Mmods/diff/hooks/tools/index.ts
Amods/diff/tests/fixtures/in-slow-repository.ts
Mmods/diff/tests/fixtures/index.ts
Amods/diff/tests/is-outside-working-tree.test.ts
Amods/diff/tests/pane-state/has-session-files.test.ts
Mmods/diff/tests/register.test.ts
Amods/diff/tests/tools/edited-path-of.test.ts
@@ -1,7 +1,7 @@
11 {
22 "name": "diff",
33 "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.",
55 "author": {
66 "name": "Anthropic"
77 }
@@ -13,12 +13,14 @@
1313 through Buttons that declare the engine's own actions. The pane refreshes
1414 as Claude edits and runs shell commands, and while it is open it polls
1515 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.
2224
2325 Under the fullscreen layout a terminal under 110 columns gets the
2426 built-in's line asking for a wider one and nothing opens. Without that
@@ -49,10 +51,11 @@
4951 or the first edit a pane has room to open on first needs the repository
5052 (an answer of no repository is kept too, until `/clear` or `/resume`
5153 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.
5659
5760 `hooks/register.ts` is the module; everything under `hooks/` is its parts.
5861
@@ -61,14 +64,14 @@
6164 | event | what the hook does |
6265 | --- | --- |
6366 | `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. |
6568 | `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. |
6669 | `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. |
6770 | `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. |
6871 | `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. |
6972 | `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. |
7073 | `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. |
7275 | `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, failed and interrupted ones too, refreshes an open pane. |
7376 | `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. |
7477
@@ -1,4 +1,4 @@
11 {
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",
33 "modules": ["./register.ts"]
44 }
@@ -9,6 +9,7 @@
99 export * from './git'
1010 export * from './host'
1111 export * from './is-on-pane-surface'
12+export * from './is-outside-working-tree'
1213 export * from './is-record'
1314 export * from './kept-of'
1415 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 @@
77 export * from './dialog-title-of.js'
88 export * from './empty-state'
99 export * from './empty-state-of.js'
10+export * from './has-session-files'
1011 export * from './header-totals'
1112 export * from './header-totals-of.js'
1213 export * from './initial-model'
@@ -4,6 +4,7 @@
44 ResultOf,
55 SessionMessage,
66 Timer,
7+ ToolCallInput,
78 } from 'claude-code'
89
910 import Ask from './ask'
@@ -14,6 +15,7 @@
1415 import type Git from './git'
1516 import type { Host } from './host'
1617 import { isOnPaneSurface } from './is-on-pane-surface'
18+import { isOutsideWorkingTree } from './is-outside-working-tree'
1719 import Limits from './limits'
1820 import { mapLimited } from './map-limited'
1921 import { messageOf } from './message-of'
@@ -31,7 +33,8 @@
3133 *
3234 * Git runs when the built-in's would: `session.start` binds the host and
3335 * 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.
3538 *
3639 * @param on the engine's registrar
3740 */
@@ -43,6 +46,9 @@
4346 let isPaneOpen = false
4447 let dialogRows: number | null = null
4548 let hasAutoOpened = false
49+ let isAutoOpening = false
50+ let landed = 0
51+ let opens = 0
4652 let columns: number | null = null
4753 let shownSessionId: string | null = null
4854 let armed: Ask.ArmedAsk | null = null
@@ -304,7 +310,27 @@
304310 )
305311 }
306312
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> {
308334 if (isRefreshing) {
309335 isRefreshQueued = true
310336
@@ -316,20 +342,13 @@
316342 const record = Record.recorderOf(engine)
317343 const pinned = backend
318344
319- const fetched = (): Promise<Git.FetchOutcome> =>
320- pinned
321- ? pinned.fetchDiff(model.requestedMode)
322- : Promise.resolve({ kind: 'no-repository' })
323-
324345 try {
325346 model = { ...model, isLoading: model.data === null }
326347
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
331350
332- model = PaneState.afterFetch(model, { outcome, messages })
351+ model = PaneState.afterFetch(model, fetched)
333352
334353 switch (outcome.kind) {
335354 case 'no-repository':
@@ -394,9 +413,12 @@
394413 async function openPane(
395414 engine: Host,
396415 trigger: (typeof Record.SHOWN_TRIGGERS)[number],
416+ read: PaneState.Fetched | null = null,
397417 ): Promise<void> {
398418 const isDialog = model.isFullscreen === false
399419
420+ opens += 1
421+
400422 model = {
401423 ...model,
402424 selectedPath: null,
@@ -406,6 +428,8 @@
406428
407429 dialogRows = isDialog ? Views.dialogRowsOf(model) : null
408430
431+ const settling = read ? refresh(engine, read) : null
432+
409433 await engine.openPane(
410434 isDialog
411435 ? { ...dialogPane(), focus: true }
@@ -421,7 +445,7 @@
421445 Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns))
422446 }
423447
424- void refresh(engine)
448+ void (settling ?? refresh(engine))
425449 }
426450
427451 async function closePane(engine: Host): Promise<void> {
@@ -436,9 +460,56 @@
436460 })
437461 }
438462
439- async function openOnFirstEdit(engine: Host): Promise<void> {
440- const isTaken = () => isPaneOpen || hasAutoOpened
463+ const isTaken = () => isPaneOpen || hasAutoOpened
441464
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> {
442513 if (isTaken()) {
443514 return
444515 }
@@ -450,11 +521,7 @@
450521 ? Limits.OPEN_MIN_COLUMNS
451522 : Limits.AUTO_OPEN_MIN_COLUMNS
452523
453- const hasRoom =
454- preference !== false &&
455- model.isFullscreen === true &&
456- columns !== null &&
457- columns >= floor
524+ const hasRoom = preference !== false && hasRoomFor(floor)
458525
459526 if (!hasRoom || isTaken()) {
460527 return
@@ -466,8 +533,16 @@
466533 return
467534 }
468535
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+ }
471546 }
472547
473548 function disarm(engine: Host) {
@@ -818,10 +893,10 @@
818893
819894 function afterTool(
820895 engine: Host,
821- tool: string,
896+ e: ToolCallInput,
822897 result: ResultOf['tool.call'] | undefined,
823898 ) {
824- const isEdit = Tools.EDITING_TOOLS.some(name => name === tool)
899+ const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool)
825900
826901 const hasEdited =
827902 isEdit &&
@@ -829,16 +904,20 @@
829904 result.deny === undefined &&
830905 result.isError !== true
831906
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
835910
836- if (isStale) {
911+ if (hasLanded) {
912+ landed += 1
913+ }
914+
915+ if (isPaneOpen && hasLanded) {
837916 scheduleRefresh(engine)
838917 }
839918
840919 if (hasEdited) {
841- void openOnFirstEdit(engine).catch(() => undefined)
920+ void openOnFirstEdit(engine, Tools.editedPathOf(e)).catch(() => undefined)
842921 }
843922 }
844923
@@ -854,7 +933,7 @@
854933 return result
855934 } finally {
856935 if (host) {
857- afterTool(host, e.tool, result)
936+ afterTool(host, e, result)
858937 }
859938 }
860939 },
@@ -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'
12 export * from './editing-tools.js'
23 export * from './shell-tools.js'
34 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 @@
2222 export * from './hint-at.js'
2323 export * from './hint-drawn.js'
2424 export * from './in-repository.js'
25+export * from './in-slow-repository.js'
2526 export * from './inline-pane.js'
2627 export * from './keeping.js'
2728 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 @@
448448 ).toEqual(['diff'])
449449 })
450450
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+
451878 test('/clear closes the pane it finds open', async ($, on) => {
452879 const world = Fixtures.inRepository(on)
453880
@@ -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+})