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: git runs when the built-in panel's would: one rev-parse at first need, the tree walked only for an open pane, nothing after a turn or a refused toolBrowse files
poteat authored on Sep 15, 2026 parent 733a0de · commit 21993fab7a04
12 changed files+318 -118compared with 733a0de
Mmods/diff/README.md
Mmods/diff/hooks/git/git-backend-of.ts
Mmods/diff/hooks/git/probes/dirty-paths-of/dirty-paths-of.ts
Mmods/diff/hooks/git/probes/was-dirty-at-start/was-dirty-at-start.ts
Mmods/diff/hooks/git/types/git-deps/git-deps.ts
Mmods/diff/hooks/register.ts
Mmods/diff/tests/backend/backend-of.test.ts
Amods/diff/tests/fixtures/git-word-of.ts
Mmods/diff/tests/fixtures/index.ts
Amods/diff/tests/fixtures/poll-word.ts
Mmods/diff/tests/git.test.ts
Mmods/diff/tests/register.test.ts
@@ -11,11 +11,11 @@
1111 built-in's list keys (`ctrl+up`/`ctrl+down`, `opt+up`/`opt+down`), and
1212 `ctrl+x b` moves the comparison base on, as the built-in's chord does: both
1313 through Buttons that declare the engine's own actions. The pane refreshes
14-as Claude edits, runs shell commands and finishes turns, and while it is
15-open it polls the repository's HEAD so a commit or checkout made elsewhere
16-shows too. The first successful edit of a session opens the pane by itself
17-where the terminal is wide enough (144 columns when the person never chose,
18-110 when they kept it open before; a person who closed it is left alone).
14+as Claude edits and runs shell commands, and while it is open it polls
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+terminal is wide enough (144 columns when the person never chose, 110 when
18+they kept it open before; a person who closed it is left alone).
1919
2020 Under the fullscreen layout a terminal under 110 columns gets the
2121 built-in's line asking for a wider one and nothing opens. Without that
@@ -36,11 +36,21 @@
3636 the session's, and the choice is kept per repository in the plugin's store.
3737 A picker shows one earlier turn's edits instead of the working tree, read
3838 from the session's messages. Files that changed before the session started
39-(by their timestamp, among the paths already dirty at the start), and noise
40-(lockfiles, generated and test files), are listed apart and folded until
41-asked for; a rename lists as git prints it. Outside a git repository
42-`/diff` says so and does nothing else.
39+(by their timestamp, among the paths already dirty when the pane first
40+read the repository), and noise (lockfiles, generated and test files), are
41+listed apart and folded until asked for; a rename lists as git prints it.
42+Outside a git repository `/diff` says so and does nothing else.
4343
44+Git runs when the built-in panel's would: nothing at the session's start;
45+one `git rev-parse`, in the directory the session started in, when `/diff`
46+or the first edit a pane has room to open on first needs the repository
47+(an answer of no repository is kept too, until `/clear` or `/resume`
48+forgets it); and the working tree is read only by a fetch for a pane that
49+is open, after an edit or a shell command that was not refused. The one
50+read the built-in has no counterpart for is a `git status` at a pane's
51+first fetch, which stands in for the change time the built-in dates a
52+moved file by.
53+
4454 `hooks/register.ts` is the module; everything under `hooks/` is its parts.
4555
4656 ## What it hooks
@@ -50,14 +60,13 @@
5060 | `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. |
5161 | `ui.render` of `PromptHint` | Reads the terminal's width, which decides whether the first edit opens the pane. |
5262 | `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. |
53-| `command.run` of `diff` | Opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. |
63+| `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. |
5464 | `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. |
5565 | `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. |
5666 | `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. |
57-| `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state. |
58-| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After the edit, refreshes an open pane; the session's first successful edit opens it. |
59-| `tool.call` of `Bash`, `PowerShell` | After the command, refreshes an open pane. |
60-| `turn.complete` | Refreshes an open pane. |
67+| `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state, the pinned repository with it. |
68+| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that was not refused, refreshes an open pane; the session's first successful edit opens it, pinning the repository then if the terminal has the room. |
69+| `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, refreshes an open pane. |
6170 | `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. |
6271
6372 ## What it calls on `$`
@@ -15,7 +15,7 @@
1515 *
1616 * Each child runs with `--git-dir`, `--work-tree` and cwd the top, under
1717 * the C locale and the fetch's timeout (the built-in's execPinnedGit); the
18- * paths dirty at the start are read once, first (Probes.dirtyPathsOf).
18+ * first fetch reads the paths dirty then, once (Probes.dirtyPathsOf).
1919 *
2020 * @param host the bound host's runner and probes
2121 * @returns the backend, or null outside a git working tree
@@ -47,15 +47,16 @@
4747 }
4848
4949 const run = runOf(repository)
50- const baseline = await Probes.dirtyPathsOf(run)
5150
52- const depsOf = (): Types.GitDeps => ({
51+ let baseline: Promise<ReadonlySet<string> | null> | null = null
52+
53+ const depsOf = (dirty: ReadonlySet<string> | null): Types.GitDeps => ({
5354 run,
5455 repository,
5556 mtimeOf: host.mtimeOf,
5657 entryKindsOf: host.entryKindsOf,
5758 sessionStartMs: host.sessionStartMsOf(),
58- baseline,
59+ baseline: dirty,
5960 onBranchBase: host.onBranchBase,
6061 })
6162
@@ -63,9 +64,16 @@
6364 repository,
6465 baseModes: GIT_BASE_MODES,
6566 words: GIT_WORDS,
66- fetchDiff: mode => fetchDiff(depsOf(), mode),
67+ fetchDiff: async mode => {
68+ baseline ??= Probes.dirtyPathsOf(run)
69+
70+ return fetchDiff(depsOf(await baseline), mode)
71+ },
6772 fetchFileHunks: (data, file) => fetchFileHunks(run, data, file),
6873 headKeyOf: () =>
69- Probes.headKeyOf({ ...depsOf(), readFile: host.readFile }, repository),
74+ Probes.headKeyOf(
75+ { ...depsOf(null), readFile: host.readFile },
76+ repository,
77+ ),
7078 }
7179 }
@@ -7,9 +7,9 @@
77 * Every path dirty in the working tree right now, tracked changes and
88 * untracked files alike, as `git status -z` lists them; null unread.
99 *
10- * Read once when the backend is pinned, so a fetch can tell a path that
11- * was already dirty when the session began from one that turned up since
12- * (GitDeps `baseline`). Renames are not paired: each side is its own path.
10+ * Read once, by the backend's first fetch (it walks the whole tree), so a
11+ * later fetch can tell a path that was dirty then from one that turned up
12+ * since (GitDeps `baseline`). Renames unpaired: each side is its own path.
1313 *
1414 * @param run runs git against the pinned repository
1515 * @returns the paths, root-relative, or null on a failed or cut listing
@@ -2,7 +2,7 @@
22
33 /**
44 * Whether a path may predate the session at all: it was dirty when the
5- * session began, or nothing says otherwise (no baseline was read).
5+ * baseline was read (the first fetch), or nothing says otherwise (none was).
66 *
77 * A path missing from the baseline turned up since, so its old timestamp
88 * (a rename keeps the file's, a move keeps the source's) does not date it,
@@ -12,7 +12,5 @@
1212 * @param path the root-relative path
1313 * @returns false only when a baseline exists and lacks the path
1414 */
15-export const wasDirtyAtStart = (
16- context: Types.DatingContext,
17- path: string,
18-): boolean => context.deps.baseline?.has(path) ?? true
15+export const wasDirtyAtStart = (context: Types.DatingContext, path: string) =>
16+ context.deps.baseline?.has(path) ?? true
@@ -33,11 +33,11 @@
3333 sessionStartMs: number
3434
3535 /**
36- * The paths dirty when the backend was pinned, at the session's start
37- * (Probes.dirtyPathsOf); null when unlisted, absent where none is kept.
36+ * The paths dirty when the backend first fetched (Probes.dirtyPathsOf),
37+ * read then and kept; null when unlisted, absent where none is kept.
3838 *
39- * A path absent from it turned up during the session (a rename's new
40- * name, a file moved in), whatever timestamp it carries.
39+ * A path absent from it turned up since (a rename's new name, a file
40+ * moved in), whatever timestamp it carries.
4141 */
4242 baseline?: ReadonlySet<string> | null
4343
@@ -1,10 +1,4 @@
1-import type {
2- On,
3- PaneOpenArgs,
4- ResultOf,
5- SessionMessage,
6- Timer,
7-} from 'claude-code'
1+import type { On, PaneOpenArgs, SessionMessage, Timer } from 'claude-code'
82
93 import Ask from './ask'
104 import Backend from './backend'
@@ -29,9 +23,9 @@
2923 * Registers the diff pane: `/diff` once the built-in stands down, the
3024 * pane's drawing and refresh, its opening on Claude's first edit, the ask.
3125 *
32- * `session.start` registers `/diff` and binds the host (currentOf: the
33- * latest start's) and asks nothing of the repository; `/diff` or the
34- * first edit pins the backend (backendOf) when it first needs it.
26+ * Git runs when the built-in's would: `session.start` binds the host and
27+ * registers `/diff`; `/diff` or the first edit with room pins the backend
28+ * where the session started, until `/clear`; an open pane alone fetches.
3529 *
3630 * @param on the engine's registrar
3731 */
@@ -56,6 +50,7 @@
5650 const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>()
5751
5852 const polled = { toplevel: '', headKey: '' }
53+ const pin = { cwd: '', isEmpty: false }
5954
6055 let model: PaneState.PaneModel = PaneState.INITIAL_MODEL
6156
@@ -65,7 +60,11 @@
6560 const currentOf = (engine: Host): Host => host ?? engine
6661
6762 const backendHostOf = (engine: Host): Backend.BackendHost => ({
68- run: (argv, init) => currentOf(engine).run(argv, init),
63+ run: (argv, init) =>
64+ currentOf(engine).run(
65+ argv,
66+ pin.cwd === '' ? init : { cwd: pin.cwd, ...init },
67+ ),
6968 readFile: path => currentOf(engine).readFile(path),
7069 mtimeOf: path => mtimeOf(currentOf(engine))(path),
7170 entryKindsOf: dir => entryKindsOf(currentOf(engine))(dir),
@@ -95,7 +94,7 @@
9594 })
9695
9796 function pinBackend(engine: Host): Promise<boolean> {
98- if (backend) {
97+ if (backend || pin.isEmpty) {
9998 return Promise.resolve(true)
10099 }
101100
@@ -125,6 +124,7 @@
125124 )
126125
127126 backend ??= probed
127+ pin.isEmpty = backend === null && asked.isAnswered
128128
129129 if (!probed || backend !== probed) {
130130 return asked.isAnswered || backend !== null
@@ -148,6 +148,15 @@
148148 return true
149149 }
150150
151+ function unpin() {
152+ backend = null
153+ pin.isEmpty = false
154+ polled.toplevel = ''
155+ polled.headKey = ''
156+ timers.get('poll')?.cancel()
157+ timers.delete('poll')
158+ }
159+
151160 function dialogPane(): PaneOpenArgs {
152161 return {
153162 id: Names.PANE_ID,
@@ -423,26 +432,28 @@
423432 }
424433
425434 const preference = await engine.storeGet(Names.STORE_OPEN_KEY)
426-
427- await pinBackend(engine)
428-
429435 const isKeptOpen = preference === true
430436
431437 const floor = isKeptOpen
432438 ? Limits.OPEN_MIN_COLUMNS
433439 : Limits.AUTO_OPEN_MIN_COLUMNS
434440
435- const isEligible =
441+ const hasRoom =
436442 preference !== false &&
437443 model.isFullscreen !== false &&
438444 columns !== null &&
439- columns >= floor &&
440- backend !== null
445+ columns >= floor
441446
442- if (!isEligible || isTaken()) {
447+ if (!hasRoom || isTaken()) {
443448 return
444449 }
445450
451+ await pinBackend(engine)
452+
453+ if (!backend || isTaken()) {
454+ return
455+ }
456+
446457 hasAutoOpened = true
447458 await openPane(engine, 'auto_open')
448459 }
@@ -551,8 +562,9 @@
551562 redraw(engine)
552563 }
553564
554- async function bind(engine: Host): Promise<void> {
565+ async function bind(engine: Host, cwd: string): Promise<void> {
555566 sessionStartMs = await engine.now()
567+ pin.cwd = cwd
556568
557569 try {
558570 await engine.registerCommand(COMMAND_SPEC)
@@ -567,27 +579,30 @@
567579 }
568580
569581 on('session.start', async ($, e, next) => {
570- await bind({
571- now: () => $.clock.now(),
572- after: (ms, fn) => $.clock.after(ms, fn),
573- every: (ms, fn) => $.clock.every(ms, fn),
574- run: (argv, init) => $.process.run(argv, init),
575- stat: path => $.fs.stat(path),
576- listDir: path => $.fs.list(path),
577- readFile: path => $.fs.read(path),
578- storeGet: key => $.store.get(key),
579- storeSet: (key, value) => $.store.set(key, value),
580- messages: () => $.session.messages(),
581- invalidate: () => $.ui.invalidate('ui.render'),
582- status: text => $.ui.status(text),
583- uiLog: text => $.ui.log(text),
584- openPane: pane => $.ui.open(pane),
585- closePane: pane => $.ui.close(pane),
586- registerCommand: spec => $.command.register(spec),
587- sessionId: () => $.session.id(),
588- mark: entry => $.telemetry.mark(entry),
589- log: entry => $.telemetry.log(entry),
590- })
582+ await bind(
583+ {
584+ now: () => $.clock.now(),
585+ after: (ms, fn) => $.clock.after(ms, fn),
586+ every: (ms, fn) => $.clock.every(ms, fn),
587+ run: (argv, init) => $.process.run(argv, init),
588+ stat: path => $.fs.stat(path),
589+ listDir: path => $.fs.list(path),
590+ readFile: path => $.fs.read(path),
591+ storeGet: key => $.store.get(key),
592+ storeSet: (key, value) => $.store.set(key, value),
593+ messages: () => $.session.messages(),
594+ invalidate: () => $.ui.invalidate('ui.render'),
595+ status: text => $.ui.status(text),
596+ uiLog: text => $.ui.log(text),
597+ openPane: pane => $.ui.open(pane),
598+ closePane: pane => $.ui.close(pane),
599+ registerCommand: spec => $.command.register(spec),
600+ sessionId: () => $.session.id(),
601+ mark: entry => $.telemetry.mark(entry),
602+ log: entry => $.telemetry.log(entry),
603+ },
604+ e.cwd,
605+ )
591606
592607 return next(e)
593608 })
@@ -769,6 +784,7 @@
769784 await closePane(host).catch(() => undefined)
770785 }
771786
787+ unpin()
772788 hasAutoOpened = false
773789 bodyStamp = null
774790 bodyBase = null
@@ -780,46 +796,28 @@
780796 return result
781797 })
782798
783- on('tool.call', { tool: [...Tools.EDITING_TOOLS] }, async ($, e, next) => {
784- let result: ResultOf['tool.call'] | undefined
799+ on(
800+ 'tool.call',
801+ { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] },
802+ async ($, e, next) => {
803+ const result = await next(e)
785804
786- try {
787- result = await next(e)
788-
789- return result
790- } finally {
791- if (host) {
805+ if (host && !('deny' in result)) {
792806 if (isPaneOpen) {
793807 scheduleRefresh(host)
794808 }
795809
796- const isEditDone = result !== undefined && !('deny' in result)
810+ const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool)
797811
798- if (isEditDone) {
812+ if (isEdit) {
799813 void openOnFirstEdit(host).catch(() => undefined)
800814 }
801815 }
802- }
803- })
804816
805- on('tool.call', { tool: [...Tools.SHELL_TOOLS] }, async ($, e, next) => {
806- try {
807- return await next(e)
808- } finally {
809- if (host && isPaneOpen) {
810- scheduleRefresh(host)
811- }
812- }
813- })
817+ return result
818+ },
819+ )
814820
815- on('turn.complete', ($, e, next) => {
816- if (host && isPaneOpen) {
817- scheduleRefresh(host)
818- }
819-
820- return next(e)
821- })
822-
823821 on('prompt.submit', async ($, e, next) => {
824822 const asked = armed
825823
@@ -13,10 +13,10 @@
1313 expect((await Backend.backendOf(inGit.host, []))?.words.lister).toBe('git')
1414 expect(await Backend.backendOf(outside.host, [])).toBeNull()
1515
16- expect([inGit.programs, outside.programs]).toEqual([
17- ['git', 'git'],
18- ['git'],
19- ])
16+ expect(
17+ [inGit.programs, outside.programs],
18+ 'pinning spawns the one rev-parse and walks nothing',
19+ ).toEqual([['git'], ['git']])
2020 })
2121
2222 test('a declining probe leaves a git working tree to git', async () => {
@@ -27,7 +27,7 @@
2727 expect(backend?.words.lister).toBe('git')
2828 expect(backend?.baseModes).toEqual(['session', 'uncommitted', 'branch'])
2929 expect(declining.asked).toEqual([inGit.host])
30- expect(inGit.programs).toEqual(['git', 'git'])
30+ expect(inGit.programs).toEqual(['git'])
3131 })
3232
3333 test('a probe that answers pins before git is asked', async () => {
@@ -1,0 +1,36 @@
1+/**
2+ * One git invocation named by what it asks: its subcommand, and its kind.
3+ *
4+ * For a `diff` what kind (`--shortstat`, `--numstat`, or the path its body
5+ * is asked for), for a `rev-parse` whether it finds the repository
6+ * (`--show-toplevel`) or reads `HEAD` (the poll's fallback where no file
7+ * answers), so a test reads a session's spawns as the built-in's list.
8+ *
9+ * @param argv the invocation, program first, any pinned lead after it
10+ * @returns `rev-parse --show-toplevel`, `status`, `diff --numstat`, ...
11+ */
12+export function gitWordOf(argv: readonly string[]) {
13+ const [subcommand = '?'] = argv.filter(
14+ (word, at) => at > 0 && !word.startsWith('-') && argv[at - 1] !== '-c',
15+ )
16+
17+ const dashes = argv.indexOf('--')
18+
19+ if (subcommand === 'rev-parse') {
20+ return argv.includes('--show-toplevel')
21+ ? 'rev-parse --show-toplevel'
22+ : 'rev-parse HEAD'
23+ }
24+
25+ if (subcommand !== 'diff') {
26+ return subcommand
27+ }
28+
29+ if (dashes !== -1) {
30+ return `diff -- ${argv.slice(dashes + 1).join(' ')}`
31+ }
32+
33+ const stat = argv.find(word => word === '--shortstat' || word === '--numstat')
34+
35+ return stat ? `diff ${stat}` : 'diff'
36+}
@@ -15,6 +15,7 @@
1515 export * from './fetch-diff'
1616 export * from './git-hung.js'
1717 export * from './git-in.js'
18+export * from './git-word-of.js'
1819 export * from './head-key-of'
1920 export * from './header-totals-of'
2021 export * from './hint.js'
@@ -35,6 +36,7 @@
3536 export * from './pane.js'
3637 export * from './pane-view'
3738 export * from './parse-file-diff'
39+export * from './poll-word.js'
3840 export * from './renamed.js'
3941 export * from './repository.js'
4042 export * from './repository-of'
@@ -1,0 +1,7 @@
1+/**
2+ * The open pane's HEAD poll as gitWordOf names it where no file answers.
3+ *
4+ * On disk the poll stats `.git/HEAD` and spawns nothing, so a test that
5+ * lists the session's spawns as the built-in's lets this word by.
6+ */
7+export const POLL_WORD = 'rev-parse HEAD'
@@ -20,7 +20,12 @@
2020 const diffs = pinned.filter(run => run.argv.includes('diff'))
2121
2222 expect(discovery?.argv).toContain('--show-toplevel')
23- expect(discovery?.init?.cwd).toBeUndefined()
23+
24+ expect(
25+ discovery?.init?.cwd,
26+ "found where the session started, wherever Claude's shell has gone",
27+ ).toBe('/main/wt')
28+
2429 expect(diffs.length).toBeGreaterThanOrEqual(3)
2530
2631 for (const run of world.runs) {
@@ -38,6 +43,34 @@
3843 expect(world.runs.some(run => run.argv.includes('config'))).toBe(false)
3944 })
4045
46+ test('the working tree is walked once, by the first fetch', async ($, on) => {
47+ const world = Fixtures.inRepository(on)
48+
49+ const walks = () =>
50+ world.runs.filter(run => Fixtures.gitWordOf(run.argv) === 'status')
51+
52+ const fetches = () =>
53+ world.runs.filter(
54+ run => Fixtures.gitWordOf(run.argv) === 'diff --numstat',
55+ )
56+
57+ on('tool.call', () => ({ result: 'ran' }))
58+
59+ await $.session.start(Fixtures.SESSION)
60+ await $.command.run(Fixtures.DIFF)
61+ await world.clock.advance(Fixtures.SETTLE_MS)
62+
63+ expect(walks(), 'the first fetch walked').toHaveLength(1)
64+ expect(fetches()).toHaveLength(1)
65+
66+ await $.tool.call({ tool: 'Bash', command: 'make' })
67+ await world.clock.advance(Fixtures.SETTLE_MS)
68+
69+ expect(fetches(), 'the command fetched again').toHaveLength(2)
70+ expect(walks(), 'and read the walk it had').toHaveLength(1)
71+ expect(walks()[0]?.init?.cwd, 'pinned like every child').toBe('/work')
72+ })
73+
4174 test('a file moved in since the start is session work', async ($, on) => {
4275 const clock = Fixtures.startsSession(on, Fixtures.SETTLE_MS)
4376
@@ -20,6 +20,107 @@
2020 })
2121 })
2222
23+ test("git runs at the built-in's moments and no others", async ($, on) => {
24+ const world = Fixtures.inRepository(on)
25+
26+ let read = 0
27+
28+ function spawnedSince(): string[] {
29+ const words = world.runs
30+ .slice(read)
31+ .map(run => Fixtures.gitWordOf(run.argv))
32+ .filter(word => word !== Fixtures.POLL_WORD)
33+
34+ read = world.runs.length
35+
36+ return words
37+ }
38+
39+ const edit = () =>
40+ $.tool.call({
41+ tool: 'Edit',
42+ file_path: '/work/app.ts',
43+ old_string: '1',
44+ new_string: '2',
45+ })
46+
47+ on('tool.call', () => ({ result: 'done' }))
48+ on('turn.complete', ($, e) => ({ text: e.answer }))
49+ on('command.run', { command: 'clear' }, () => ({}))
50+
51+ await $.session.start(Fixtures.SESSION)
52+ await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
53+ await world.clock.advance(Fixtures.SETTLE_MS)
54+
55+ expect(spawnedSince(), 'the start and the footer: nothing').toEqual([])
56+
57+ await edit()
58+ await world.clock.advance(Fixtures.SETTLE_MS)
59+
60+ expect(
61+ spawnedSince(),
62+ 'an edit with no room for a pane: nothing, the width is read first',
63+ ).toEqual([])
64+
65+ await $.command.run(Fixtures.DIFF)
66+ await world.clock.advance(Fixtures.SETTLE_MS)
67+
68+ expect(spawnedSince(), '/diff: found, then one fetch for the pane').toEqual(
69+ [
70+ 'rev-parse --show-toplevel',
71+ 'status',
72+ 'diff --shortstat',
73+ 'diff --numstat',
74+ 'ls-files',
75+ 'diff -- app.ts',
76+ ],
77+ )
78+
79+ await edit()
80+ await world.clock.advance(Fixtures.SETTLE_MS)
81+
82+ expect(spawnedSince(), 'an edit, the pane open: one fetch').toEqual([
83+ 'diff --shortstat',
84+ 'diff --numstat',
85+ 'ls-files',
86+ 'diff -- app.ts',
87+ ])
88+
89+ await $.tool.call({ tool: 'Bash', command: 'make' })
90+ await world.clock.advance(Fixtures.SETTLE_MS)
91+
92+ expect(spawnedSince(), 'a shell command, the pane open: one fetch').toEqual(
93+ ['diff --shortstat', 'diff --numstat', 'ls-files', 'diff -- app.ts'],
94+ )
95+
96+ await $.turn.complete({
97+ answer: 'done',
98+ durationMs: 1,
99+ isAborted: false,
100+ turnId: 't1',
101+ reason: 'answer',
102+ })
103+
104+ await world.clock.advance(Fixtures.SETTLE_MS)
105+
106+ expect(spawnedSince(), "a turn's end: nothing").toEqual([])
107+
108+ await $.command.run(Fixtures.DIFF)
109+ await edit()
110+ await $.tool.call({ tool: 'Bash', command: 'make' })
111+ await world.clock.advance(Fixtures.SETTLE_MS)
112+
113+ expect(spawnedSince(), 'the pane closed by hand: nothing').toEqual([])
114+
115+ await $.command.run(Fixtures.CLEAR)
116+ await $.command.run(Fixtures.DIFF)
117+ await world.clock.advance(Fixtures.SETTLE_MS)
118+
119+ expect(spawnedSince()[0], '/clear forgot the repository').toBe(
120+ 'rev-parse --show-toplevel',
121+ )
122+ })
123+
23124 test('/diff whose probe never answers probes once more', async ($, on) => {
24125 const probes: (readonly string[])[] = []
25126 const clock = Fixtures.startsSession(on)
@@ -340,19 +441,29 @@
340441 ).toContain('uncommitted (vs HEAD)')
341442 })
342443
343- test('a repository made later is pinned once, on /diff', async ($, on) => {
444+ test('no repository is an answer kept until /clear', async ($, on) => {
344445 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
345446 Fixtures.oneSecret()
346447
347448 const script: Record<string, string> = notYet
348449 const world = Fixtures.inRepository(on, script)
349450
451+ const probesOf = () =>
452+ world.runs.filter(run => run.argv.includes('--show-toplevel')).length
453+
454+ on('command.run', { command: 'clear' }, () => ({}))
455+
350456 await $.session.start(Fixtures.WORKTREE_SESSION)
351457
352458 const before = await $.command.run(Fixtures.DIFF)
353459
354460 script['rev-parse --path-format=absolute'] = worktree
355461
462+ const again = await $.command.run(Fixtures.DIFF)
463+ const probesKept = probesOf()
464+
465+ await $.command.run(Fixtures.CLEAR)
466+
356467 const after = await $.command.run(Fixtures.DIFF)
357468
358469 await world.clock.advance(Fixtures.SETTLE_MS)
@@ -365,20 +476,18 @@
365476 await world.clock.advance(Fixtures.SETTLE_MS)
366477 await third
367478
368- const probes = world.runs.filter(run =>
369- run.argv.includes('--show-toplevel'),
479+ expect(before.text).toContain("isn't in a git repository")
480+
481+ expect(again.text, "kept, as the built-in's answer is").toContain(
482+ "isn't in a git repository",
370483 )
371484
372- expect(before.text).toContain("isn't in a git repository")
373- expect(after.text).toBe('Diff panel shown')
485+ expect(probesKept, 'one probe answered both').toBe(1)
486+ expect(after.text, '/clear forgot it').toBe('Diff panel shown')
374487 expect(world.opened[0]?.id).toBe('diff')
488+ expect(probesOf(), 'then one more, pinned from there on').toBe(2)
375489
376490 expect(
377- probes,
378- 'the first two /diff probed; the third was pinned',
379- ).toHaveLength(2)
380-
381- expect(
382491 world.runs.some(run => run.argv.includes('--git-dir=/else/.git')),
383492 ).toBe(false)
384493