Skip to content

diff: the first edit opens the pane only when it has a file to list - #94847

Open
bcherny wants to merge 7 commits into
mainfrom
claude/diff-pane-empty-auto-open
Open

bcherny wants to merge 7 commits into
mainfrom
claude/diff-pane-empty-auto-open

Conversation

@bcherny

@bcherny bcherny commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The diff pane auto-opened on the session's first successful Edit, Write or NotebookEdit to any path, and it opened before fetching. A write outside the repository, to an ignored file, or into a different worktree than the one the session started in put up an empty pane ("No tracked changes", "Diff unavailable", "No changes this session") before there was anything to show.
Since #95488 a docked pane fetches before it opens, which removes the "Loading diff…" flash, but the fetch does not decide whether to open: an edit outside the repository, an empty or unreadable diff still open the pane. This change makes the auto-open's deciding read that pre-open fetch. Since #95587 a resumed session whose transcript already holds an edit opens the pane through the same path once the width is known, so it too now opens only when the diff has a file to list.

Change

Two gates on the auto-open path only; /diff is unchanged.

  1. The edited path is read from the tool.call event. Once the repository is pinned, a path that resolves outside its working tree opens nothing and leaves the opening to a later edit. The check is lexical (normalised absolute paths, separator-aware, /work does not contain /workshop). Where the event names no path, or the session directory itself is not spelled under the top level (a symlink on the way), the second gate decides alone.
  2. The diff is fetched first, and the pane opens only when that fetch settles with at least one session file to list. It opens already populated from that same fetch, so no "Loading diff…" and no second git spawn sequence. No repository, an unreadable diff, or no session file opens nothing, does not latch, and the next edit tries again.

One attempt runs at a time. An edit or shell command that lands while it reads is read again; an attempt gives way if /clear or /resume overtakes it, if the pane is opened or hidden in the meantime, or if the terminal loses room (no longer fullscreen, or narrower than the column floor the edit was let in on).

Cost

Edits that are gated out (below the column floor, not fullscreen, pane closed by preference, failed or denied edits) still spawn no git processes. An edit inside the repository with room now runs one fetch per edit until one opens the pane, so a session that only edits ignored files pays a fetch per edit.

Known limits

Containment is lexical: an in-repo file addressed through a symlink alias, or with a case mismatch on a case-insensitive file system, reads as outside and the open waits for a later edit.

Tests

179 tests pass in mods/diff after merging main (155 on main alone); tsc -p mods/tsconfig.json is clean. New tests cover: an edit outside the repository, a sibling-prefix directory, a fetch with no rows, an unreadable diff / withheld untracked listing, the populated happy path with exactly one fetch, concurrent edits, /clear overtaking a read, /diff typed during a read, hiding the pane during a read (with and without a command landing in it), losing room during a read, and a resumed session whose diff lists nothing. The new behaviour tests fail on main, apart from two that pin behaviour that already held (edits landing together open one pane; /diff typed during a read opens one pane). Also run end to end in an interactive session with --plugin-dir mods/diff: a write outside the repository and a write to an ignored file open nothing, an edit of a tracked file opens the pane already listing it, and a narrow terminal spawns no git.


Generated by Claude Code

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.
@konsta95

Copy link
Copy Markdown

Reviewed at 2bdd1fd. The empty pane on a first edit was real, and the core of the change holds up. I ran the head and the base side by side with claude plugin test mods/diff (2.1.274, CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1): with a repository that lists a file, the head opens the pane populated from the fetch it read, on one fetch, where the base opened the pane first and read the diff after. The description's numbers check out too: 164 pass on the head, 142 on the base alone, and of the 12 new register tests exactly the 10 it says fail against the base's hooks. A 21-scenario differential over /diff, close, /clear, polling and refresh prints identical traces on the head and on the head with the base's register.ts.

Five places where the description and the code part ways, each reproduced with a test in a copy of the head. Patches and tests for four of them are at the end; with them the suite is 172/0 (175/0 with the optional gate change), tsc -p mods/tsconfig.json (5.9.3) stays clean, and the same 21 scenarios print identical traces on the patched trees.

1. The reads that decide whether to open write no repl_diff_read mark

record.mark(Record.FEATURES.read, …) lives only in refresh (register.ts:357, :375-382). openOnFetchedFiles reads through readOf (:483) and reaches refresh only when the read lists a file. On the base every such read was marked, because the pane opened and refreshed.

Measured with a telemetry provider seated the way mods/README.md describes, same test bytes on both trees, one landed Edit per row:

scenario base head git fetches base / head
git diff unavailable 1 × sad/git_diff_failed none 1 / 1
same, three edits 3 × sad/git_diff_failed none 3 / 3
data, no session file 1 × ok none 1 / 1
data with a session file 1 × ok 1 × ok 1 / 1

Same git work, no signal: a repository whose diff always fails now produces no pane and no git_diff_failed, and the surviving series only covers reads for an already-open pane, so a success rate over it answers a different question than before. Nothing under mods/diff/tests looks at marks, so the suite cannot notice either way. Fix: mark the read that does not open (patch below), or say in the description that the series changes meaning. The patch marks every deciding read that opens nothing, including one that /diff, /clear or a lost terminal width overtook: one mark per fetch round, which is how record/features words read, but more rows than the base wrote, since the base had no discarded reads.

2. An attempt does not give way when the terminal loses room

"An attempt gives way … if the terminal loses room" and "edits that are gated out (below the column floor …) still spawn no git processes". In openOnFetchedFiles the room check only feeds isCurrent; the loop's exits are seen === landed and isOvertaken (register.ts:496). Narrow the terminal during a read, land three shell commands, one during each read that follows: three more full fetches (nine git child processes) with no room to open anything. Widen again and land one more command, and that same attempt opens the pane.

tests/register.test.ts "a terminal narrowed while the diff is read opens nothing" does not see this because it lands nothing during the read, so the loop ends on seen === landed. Fix: one condition (patch below). It exits only when the read opened nothing, so an attempt that did open the pane still makes its catch-up read for a command that landed meanwhile, and the next edit with room starts a fresh attempt; tests 5 and 6 below pin both.

3. "Only when it has a file to list" is decided on the header's count, not on the list

hasSessionFiles mirrors headerTotalsOf. The list has two more no-row states than emptyStateOf (list-body-of.ts:24-41), and the gate opens on both:

  • first edit is test/a.test.ts, the only change: the pane opens drawing 1 file changed +1, 1 test/generated (show), "Only tests and generated files changed". No file row (isNoiseShown starts false). A session that writes the test first gets this.
  • 501 changed files: the pane opens on "Too many changed files to show diff".

Both are designed states of the pane and both are pinned by tests/pane-state/has-session-files.test.ts (the TEST_ROW line and the past-the-cap test), so this reads as deliberate. Then the title, the README sentence ("one after which the diff lists nothing … opens nothing") and the gate's own docstring say more than the code does. Either reword those, or gate on a drawn row (optional patch below; it can only withhold an open the current gate makes, never add one). Its cost, which you may not want: an edit that sorts past the row cap behind older dirty files counts in the header but draws no row, so it would no longer open the pane. I read that cost from the source and did not run it.

4. The path gate is a veto, and it is lexical

When isOutsideWorkingTree says outside, the fetch gate never runs (register.ts:536-545), so a false "outside" is final. Known limits says such an edit "waits for a later edit"; when every path in the session carries the alias, nothing ever opens, which the base did not do.

  • Symlink alias under a physical session directory (macOS /tmp → /private/tmp, any symlinked checkout): git rev-parse --show-toplevel answers with the physical path (checked on Linux, git 2.53: git -C <link> and git -C <link>/sub both print the real top), so the escape hatch for an aliased cwd does not help an aliased path. Through the mock host, with the top and the cwd at /private/tmp/work: three edits to /tmp/work/app.ts give opened: [] and one git process for the whole session; the same edits spelled physically open the pane.
  • Git.isAbsolutePath was written for lines git prints (is-absolute-path.ts:2-9) and is now applied to tool input. With a C:/work/repo top: /work/repo/a.ts reads as outside although Win32 resolves it into the tree; \work\other\a.ts reads as inside; \\?\C:\… and \\server\share\… are taken as relative and always land inside. An NFC and an NFD spelling of the same directory disagree. These are unit-level results on Linux, argued from Win32 and macOS semantics; I had no Windows or macOS host.

Does tool.call hand the hook file_path as the model typed it, or normalised? The relative-path test in this PR suggests as typed; if it is normalised upstream, most of this item shrinks. If not: the gate only saves the three spawns the description already accepts for ignored files, so it could fail open on anything it cannot prove, or ask git once per directory that reads as outside (git -C <dir> rev-parse --show-toplevel compared with the pinned top, cached), which lets git do the canonicalising on every platform. No patch for this one: the right shape depends on that answer.

5. "It opens already populated from that same fetch" has an exception

refresh returns early when one is already running and drops the read it was handed (register.ts:334-338); openPane has already set hasAutoOpened. Two ways there through /clear, which cancels only the 'poll' timer: a refresh debounce armed by a shell command survives it, and a refresh queued behind one in flight re-arms the debounce from its finally. Either way: /diff by hand, the tree moves on to other.ts, a shell command lands, /clear, one edit. The head opens drawing app.ts and runs three diffs; without the shell command it draws other.ts on one. The base was stale in both arms, so this is not a regression, only the new sentence not holding. The three lines below close both of those paths. A refresh still fetching at the moment the auto-open hands over its read drops it all the same; that one wants the handed read carried into the queued refresh rather than discarded.

Smaller

  • "Pays a fetch per edit" is three git processes per edit (diff --shortstat, diff --numstat, ls-files), after a first edit that also runs rev-parse and status. The README's "The one read the built-in has no counterpart for is a git status at a pane's first fetch" now happens at the first deciding read, pane or no pane.
  • hasAutoOpened is set before openPane resolves (as on the base). New with this PR: the refresh starts before the open is awaited, so a denied ui.open writes an ok mark where the base wrote none.

Patches and tests

Against the head 2bdd1fd; both patches apply with git apply from the repository root, the optional one on top of the first. The test files are written against tests/fixtures and typed without any (the telemetry seat is the README's engine.create recipe); the file names are mine, fold them in wherever they belong.

Removing any one hunk of the first patch fails exactly its own tests. On the unpatched head 1, 2, 4, 7, 8, 9 and 10 fail; 3, 5, 6 and 11 pass there and are guards (6 guards the condition in item 2 against dropping the catch-up read; 11 pins the isNoiseShown argument, without which the rest of the optional patch stays green):

  1. a diff git could not read opens nothing and still marks the read (sad/git_diff_failed)
  2. a diff that lists no session file opens nothing and still marks the read (ok)
  3. a read that opens the pane marks once, not twice
  4. an attempt gives way once the terminal loses room: tools landing after it spawn no git
  5. room lost during a read, then regained: the next edit with a row opens the pane
  6. room lost as the pane opens: a command that landed in the read is still caught up on
  7. a refresh armed before /clear does not run for the closed pane, so the next open draws its own read
  8. nor does a refresh queued behind one in flight at /clear arm it again
  9. (optional patch) a first edit that changes only a test file opens nothing, the next edit with a row to list does
  10. (optional patch) a change past the per-file cap draws no row: opens nothing
  11. (optional patch) tests revealed, then /clear: the next test-file edit has a row to list and opens
hooks/register.ts, items 1, 2 and 5 (+30/−1)
--- a/mods/diff/hooks/register.ts
+++ b/mods/diff/hooks/register.ts
@@ -465,6 +465,26 @@
   const hasRoomFor = (floor: number) =>
     model.isFullscreen === true && columns !== null && columns >= floor
 
+  function markUnopenedRead(engine: Host, outcome: Git.FetchOutcome): void {
+    const record = Record.recorderOf(engine)
+
+    switch (outcome.kind) {
+      case 'no-repository':
+        break
+      case 'unavailable':
+        record.mark(Record.FEATURES.read, {
+          kind: 'sad',
+          reason: 'git_diff_failed',
+        })
+
+        break
+      case 'data':
+        record.mark(Record.FEATURES.read, { kind: 'ok' })
+
+        break
+    }
+  }
+
   async function openOnFetchedFiles(
     engine: Host,
     floor: number,
@@ -491,9 +511,15 @@
         if (isListing) {
           hasAutoOpened = true
           await openPane(engine, 'auto_open', read)
+        } else {
+          markUnopenedRead(engine, read.outcome)
         }
 
-        if (seen === landed || isOvertaken) {
+        if (
+          seen === landed ||
+          isOvertaken ||
+          (!isListing && !hasRoomFor(floor))
+        ) {
           return
         }
 
@@ -880,6 +906,9 @@
       }
 
       unpin()
+      timers.get('refresh')?.cancel()
+      timers.delete('refresh')
+      isRefreshQueued = false
       hasAutoOpened = false
       bodyStamp = null
       bodyBase = null
Optional, item 3: gate on a drawn row (+26/−14, re-pins two tests in has-session-files.test.ts)
--- a/mods/diff/hooks/pane-state/has-session-files/has-session-files.ts
+++ b/mods/diff/hooks/pane-state/has-session-files/has-session-files.ts
@@ -1,22 +1,25 @@
 import type Git from '../../git'
+import { partitionOf } from '../partition-of.js'
 
 /**
- * Whether a fetch settled with a file the header would count, so a pane
- * opened on it lists something where emptyStateOf would find a headline.
+ * Whether a fetch settled with a file the list would draw, so a pane opened
+ * on it lists a row: not an empty state, not the too-many state, not tests
+ * and generated files alone while those are hidden.
  *
  * No repository, a diff git could not read, and rows that all predate the
- * session count nothing.
+ * session list nothing.
  *
  * @param outcome how the fetch ended
- * @returns whether the header's session file count is above zero
+ * @param noise whether the list keeps tests and generated files
+ * @returns whether the list has at least one session row to draw
  */
-export function hasSessionFiles(outcome: Git.FetchOutcome): boolean {
+export function hasSessionFiles(
+  outcome: Git.FetchOutcome,
+  noise: 'shown' | 'hidden' = 'hidden',
+): boolean {
   if (outcome.kind !== 'data') {
     return false
   }
 
-  const { stats, files } = outcome.data
-  const before = files.filter(file => file.isPreSession)
-
-  return stats.filesCount - before.length > 0
+  return partitionOf(outcome.data.files, noise).shown.length > 0
 }
--- a/mods/diff/hooks/register.ts
+++ b/mods/diff/hooks/register.ts
@@ -506,7 +506,12 @@
         const isCurrent =
           epoch === pin.epoch && !isOvertaken && hasRoomFor(floor) && !isTaken()
 
-        const isListing = isCurrent && PaneState.hasSessionFiles(read.outcome)
+        const isListing =
+          isCurrent &&
+          PaneState.hasSessionFiles(
+            read.outcome,
+            model.isNoiseShown ? 'shown' : 'hidden',
+          )
 
         if (isListing) {
           hasAutoOpened = true
--- a/mods/diff/tests/pane-state/has-session-files.test.ts
+++ b/mods/diff/tests/pane-state/has-session-files.test.ts
@@ -26,9 +26,13 @@
     },
   })
 
-  test("a file the header counts, a test among them: there's one", () => {
+  test("a row the list draws: there's one; a test alone only while tests are shown", () => {
     expect(PaneState.hasSessionFiles(dataOf([Fixtures.SESSION_ROW]))).toBe(true)
-    expect(PaneState.hasSessionFiles(dataOf([Fixtures.TEST_ROW]))).toBe(true)
+    expect(PaneState.hasSessionFiles(dataOf([Fixtures.TEST_ROW]))).toBe(false)
+
+    expect(
+      PaneState.hasSessionFiles(dataOf([Fixtures.TEST_ROW]), 'shown'),
+    ).toBe(true)
 
     expect(
       PaneState.hasSessionFiles(
@@ -37,14 +41,14 @@
     ).toBe(true)
   })
 
-  test('files counted past the row cap, none stored, still count', () => {
+  test('files counted past the row cap, none stored, draw no row', () => {
     expect(
       PaneState.hasSessionFiles(
         dataOf([], {
           stats: { ...Fixtures.NO_STATS, filesCount: Fixtures.PAST_CAP_COUNT },
         }),
       ),
-    ).toBe(true)
+    ).toBe(false)
   })
 
   test('wherever the pane would headline an empty state: none', () => {
tests/owner-fix.test.ts, tests 1 to 8
import type { On } from 'claude-code'
import type { TestBody } from 'claude-code/testing'
import { describe, expect, mock, test, tier } from 'claude-code/testing'

import Limits from '../hooks/limits'
import Fixtures from './fixtures'

tier('builtin')

/**
 * An inline plugin that seats `$.telemetry` in the engine.create fold, so a
 * test may hook `telemetry.mark` (the README's provider recipe).
 */
const TELEMETRY = {
  name: 'telemetry-seat',
  register(on: On) {
    on('engine.create', async ($, e, next) => {
      const beneath = await next(e)

      return {
        ...beneath,
        telemetry: {
          log: async () => undefined,
          mark: async () => undefined,
        },
      }
    })
  },
}

const EDIT = {
  tool: 'Edit',
  file_path: '/work/app.ts',
  old_string: '1',
  new_string: '2',
} as const

const NOTHING_LISTED: Record<string, string> = {
  ...Fixtures.REPOSITORY,
  'HEAD --shortstat': '',
  'HEAD --numstat': '',
}

const MOVED_ON: Record<string, string> = {
  'HEAD --shortstat': ' 1 file changed, 1 insertion(+)',
  'HEAD --numstat': '1\t0\tother.ts\0',
  'ls-files': '',
  '-- other.ts': '@@ -1 +1 @@\n-const b = 1\n+const b = 2\n',
}

function marksOf(on: On) {
  const marks: { feature: string; kind: string; reason?: string }[] = []

  on('telemetry.mark', ($, e) => {
    marks.push({
      feature: e.feature,
      kind: e.kind,
      ...(e.kind === 'sad' && { reason: e.reason }),
    })

    return { value: undefined }
  })

  on('telemetry.log', () => ({ value: undefined }))
  on('session.id', () => ({ value: 'session-owner-fix' }))

  return marks
}

describe('owner-fix', () => {
  test(
    'a diff git could not read opens nothing and still marks the read',
    { plugins: [TELEMETRY] },
    async ($, on) => {
      const script: Record<string, string> = { ...Fixtures.REPOSITORY }

      delete script['HEAD --numstat']
      delete script['ls-files']

      const world = Fixtures.inRepository(on, script)
      const marks = marksOf(on)

      on('tool.call', () => ({ result: 'edited' }))

      await $.session.start(Fixtures.SESSION)
      await $.ui.render(Fixtures.HINT)
      await $.tool.call(EDIT)
      await world.clock.advance(Fixtures.SETTLE_MS)

      expect(world.opened.map(pane => pane.id)).toEqual([])

      expect(marks).toEqual([
        { feature: 'repl_diff_read', kind: 'sad', reason: 'git_diff_failed' },
      ])
    },
  )

  test(
    'a diff that lists no session file opens nothing and still marks the read',
    { plugins: [TELEMETRY] },
    async ($, on) => {
      const world = Fixtures.inRepository(on, { ...NOTHING_LISTED })
      const marks = marksOf(on)

      on('tool.call', () => ({ result: 'edited' }))

      await $.session.start(Fixtures.SESSION)
      await $.ui.render(Fixtures.HINT)
      await $.tool.call(EDIT)
      await world.clock.advance(Fixtures.SETTLE_MS)

      expect(world.opened.map(pane => pane.id)).toEqual([])
      expect(marks).toEqual([{ feature: 'repl_diff_read', kind: 'ok' }])
    },
  )

  test(
    'a read that opens the pane marks once, not twice',
    { plugins: [TELEMETRY] },
    async ($, on) => {
      const world = Fixtures.inRepository(on, { ...Fixtures.REPOSITORY })
      const marks = marksOf(on)

      on('tool.call', () => ({ result: 'edited' }))

      await $.session.start(Fixtures.SESSION)
      await $.ui.render(Fixtures.HINT)
      await $.tool.call(EDIT)
      await world.clock.advance(Fixtures.SETTLE_MS)

      expect(world.opened.map(pane => pane.id)).toEqual(['diff'])

      expect(marks.filter(mark => mark.feature === 'repl_diff_read')).toEqual([
        { feature: 'repl_diff_read', kind: 'ok' },
      ])
    },
  )

  test('an attempt gives way once the terminal loses room: tools landing after it spawn no git', async ($, on) => {
    const script: Record<string, string> = { ...NOTHING_LISTED }
    const words: string[] = []
    const opened: string[] = []
    const clock = Fixtures.startsSession(on)

    on('process.run', async (_engine, e) => {
      words.push(Fixtures.gitWordOf(e.argv))

      const value = Fixtures.gitIn(e.argv, script)

      if (e.argv.includes('--numstat')) {
        await clock.sleep(1)
      }

      return { value }
    })

    on('ui.open', (_engine, e) => {
      opened.push(e.id)

      return { value: undefined }
    })

    on('ui.close', () => ({ value: undefined }))
    on('ui.status', () => ({ value: undefined }))
    on('ui.invalidate', () => ({ value: undefined }))
    on('ui.render', { component: 'PromptHint' }, () => Fixtures.HINT_DRAWN)
    on('session.messages', () => ({ value: [] }))
    on('tool.call', () => ({ result: 'done' }))
    mock.store(on, {})

    await $.session.start(Fixtures.SESSION)
    await $.ui.render(Fixtures.HINT)
    await $.tool.call({ ...EDIT, file_path: '/work/ignored/cache.json' })
    await clock.settle()
    await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))

    const at = words.length

    for (const _ of [0, 1, 2]) {
      await $.tool.call({ tool: 'Bash', command: 'make' })
      await clock.advance(1)
    }

    await clock.advance(Fixtures.SETTLE_MS)

    expect(
      words.slice(at).filter(word => word === 'diff --numstat'),
      'no new fetch once the room is gone',
    ).toEqual([])

    expect(opened).toEqual([])
  })

  test('room lost during a read, then regained: the next edit with a row opens the pane', async ($, on) => {
    const script: Record<string, string> = { ...NOTHING_LISTED }
    const w = Fixtures.inSlowRepository(on, script)

    on('tool.call', () => ({ result: 'done' }))

    await $.session.start(Fixtures.SESSION)
    await $.ui.render(Fixtures.HINT)
    await $.tool.call({ ...EDIT, file_path: '/work/ignored/cache.json' })
    await w.clock.settle()
    await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
    await $.tool.call({ tool: 'Bash', command: 'make' })
    await w.clock.advance(Fixtures.SETTLE_MS)

    expect(w.opened.map(pane => pane.id), 'nothing while narrow').toEqual([])

    Object.assign(script, Fixtures.REPOSITORY)
    await $.ui.render(Fixtures.HINT)
    await $.tool.call(EDIT)
    await w.clock.advance(Fixtures.SETTLE_MS)

    expect(w.opened.map(pane => pane.id), 'the next edit with room').toEqual([
      'diff',
    ])
  })

  test('room lost as the pane opens: a command that landed in the read is still caught up on', async ($, on) => {
    const script: Record<string, string> = { ...Fixtures.REPOSITORY }
    const words: string[] = []
    const clock = Fixtures.startsSession(on)

    on('process.run', async (_engine, e) => {
      words.push(Fixtures.gitWordOf(e.argv))

      const value = Fixtures.gitIn(e.argv, script)

      if (e.argv.includes('--numstat')) {
        await clock.sleep(1)
      }

      return { value }
    })

    on('ui.open', async () => {
      await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))

      return { value: undefined }
    })

    on('ui.close', () => ({ value: undefined }))
    on('ui.status', () => ({ value: undefined }))
    on('ui.invalidate', () => ({ value: undefined }))
    on('ui.render', { component: 'PromptHint' }, () => Fixtures.HINT_DRAWN)
    on('session.messages', () => ({ value: [] }))
    on('tool.call', () => ({ result: 'done' }))
    mock.store(on, {})

    await $.session.start(Fixtures.SESSION)
    await $.ui.render(Fixtures.HINT)
    await $.tool.call(EDIT)
    await clock.settle()
    await $.tool.call({ tool: 'Bash', command: 'make' })

    Object.assign(script, {
      'HEAD --shortstat': ' 2 files changed, 2 insertions(+)',
      'HEAD --numstat': '1\t0\tapp.ts\0' + '1\t0\tother.ts\0',
      '-- other.ts': '@@ -1 +1 @@\n-const b = 1\n+const b = 2\n',
    })

    const at = words.length

    await clock.advance(1)
    await clock.advance(Fixtures.SETTLE_MS)

    expect(
      words.slice(at).filter(word => word === 'diff --numstat'),
      'the open pane reads again for the command',
    ).toEqual(['diff --numstat'])

    expect(Fixtures.textOf(await $.ui.render(Fixtures.PANE))).toContain(
      'other.ts',
    )
  })

  const clearedWorld = async (
    $: Parameters<TestBody>[0],
    on: On,
    queuesSecondRefresh: boolean,
  ) => {
    const script: Record<string, string> = { ...Fixtures.REPOSITORY }
    const drawnAtOpen: string[] = []
    const clock = Fixtures.startsSession(on)
    const nap = { ms: 500 }
    let diffs = 0

    on('process.run', async (_engine, e) => {
      const value = Fixtures.gitIn(e.argv, script)

      if (e.argv.includes('--numstat')) {
        diffs += 1
        await clock.sleep(nap.ms)
      }

      return { value }
    })

    on('ui.open', async () => {
      drawnAtOpen.push(Fixtures.textOf(await $.ui.render(Fixtures.PANE)))

      return { value: undefined }
    })

    on('ui.close', () => ({ value: undefined }))
    on('ui.status', () => ({ value: undefined }))
    on('ui.invalidate', () => ({ value: undefined }))
    on('ui.render', { component: 'PromptHint' }, () => Fixtures.HINT_DRAWN)
    on('session.messages', () => ({ value: [] }))
    on('tool.call', () => ({ result: 'done' }))
    on('command.run', { command: 'clear' }, () => ({}))
    mock.store(on, {})

    await $.session.start(Fixtures.SESSION)
    await $.ui.render(Fixtures.HINT)
    await $.command.run(Fixtures.DIFF)
    await clock.advance(2000)

    const openedByHand = drawnAtOpen.length

    if (queuesSecondRefresh) {
      nap.ms = 1000
      await $.tool.call({ tool: 'Bash', command: 'make' })
      await clock.advance(200)
      await $.tool.call({ tool: 'Bash', command: 'make again' })
      await clock.advance(200)
    } else {
      await $.tool.call({ tool: 'Bash', command: 'make' })
    }

    Object.assign(script, MOVED_ON)
    await $.command.run(Fixtures.CLEAR)

    if (queuesSecondRefresh) {
      await clock.advance(760)
    }

    const before = diffs

    nap.ms = 500
    await $.tool.call({ ...EDIT, file_path: '/work/other.ts' })
    await clock.settle()
    await clock.advance(200)
    await clock.advance(4000)

    return { drawn: drawnAtOpen.slice(openedByHand), diffs: diffs - before }
  }

  test('a refresh armed before /clear does not run for the closed pane, so the next open draws its own read', async ($, on) => {
    const world = await clearedWorld($, on, false)

    expect(world.drawn.length, 'one auto-open').toBe(1)
    expect(world.drawn[0], 'the rows it read').toContain('other.ts')
    expect(world.diffs, 'one fetch for the open').toBe(1)
  })

  test('nor does a refresh queued behind one in flight at /clear arm it again', async ($, on) => {
    const world = await clearedWorld($, on, true)

    expect(world.drawn.length, 'one auto-open').toBe(1)
    expect(world.drawn[0], 'the rows it read').toContain('other.ts')
    expect(world.diffs, 'one fetch for the open').toBe(1)
  })
})
tests/owner-fix2.test.ts, tests 9 to 11 (with the optional patch)
import { describe, expect, test, tier } from 'claude-code/testing'

import Fixtures from './fixtures'

tier('builtin')

const TEST_ONLY: Record<string, string> = {
  ...Fixtures.REPOSITORY,
  'HEAD --shortstat': ' 1 file changed, 1 insertion(+)',
  'HEAD --numstat': '1\t0\ttest/a.test.ts\0',
  '-- test/a.test.ts': '@@ -1 +1 @@\n-const a = 1\n+const a = 2\n',
}

const TEST_EDIT = {
  tool: 'Edit',
  file_path: '/work/test/a.test.ts',
  old_string: '1',
  new_string: '2',
} as const

describe('owner-fix2', () => {
  test('a first edit that changes only a test file opens nothing, the next edit with a row to list does', async ($, on) => {
    const script: Record<string, string> = { ...TEST_ONLY }
    const world = Fixtures.inRepository(on, script)

    on('tool.call', () => ({ result: 'edited' }))

    await $.session.start(Fixtures.SESSION)
    await $.ui.render(Fixtures.HINT)
    await $.tool.call(TEST_EDIT)
    await world.clock.advance(Fixtures.SETTLE_MS)

    expect(
      world.opened.map(pane => pane.id),
      'only a hidden row: nothing to list',
    ).toEqual([])

    Object.assign(script, {
      'HEAD --shortstat': ' 2 files changed, 2 insertions(+)',
      'HEAD --numstat': '1\t0\tapp.ts\0' + '1\t0\ttest/a.test.ts\0',
    })

    await $.tool.call({ ...TEST_EDIT, file_path: '/work/app.ts' })
    await world.clock.advance(Fixtures.SETTLE_MS)

    expect(world.opened.map(pane => pane.id), 'a row to list').toEqual(['diff'])
  })

  test('a change past the per-file cap draws no row: opens nothing', async ($, on) => {
    const world = Fixtures.inRepository(on, {
      ...Fixtures.REPOSITORY,
      'HEAD --shortstat': ' 501 files changed, 501 insertions(+)',
      'HEAD --numstat': '',
    })

    on('tool.call', () => ({ result: 'edited' }))

    await $.session.start(Fixtures.SESSION)
    await $.ui.render(Fixtures.HINT)
    await $.tool.call({ ...TEST_EDIT, file_path: '/work/app.ts' })
    await world.clock.advance(Fixtures.SETTLE_MS)

    expect(world.opened.map(pane => pane.id)).toEqual([])
  })

  test('tests revealed, then /clear: the next test-file edit has a row to list and opens', async ($, on) => {
    const world = Fixtures.inRepository(on, TEST_ONLY)

    on('tool.call', () => ({ result: 'edited' }))
    on('command.run', { command: 'clear' }, () => ({}))

    await $.session.start(Fixtures.SESSION)
    await $.ui.render(Fixtures.HINT)
    await $.command.run(Fixtures.DIFF)
    await world.clock.advance(Fixtures.SETTLE_MS)
    await $.ui.render(Fixtures.PANE)
    await $.ui.press({ plugin: 'diff', key: 'noise' })
    await world.clock.settle()

    const revealed = Fixtures.textOf(await $.ui.render(Fixtures.PANE))

    await $.command.run(Fixtures.CLEAR)
    await world.clock.advance(Fixtures.SETTLE_MS)

    const shut = world.opened.length

    await $.tool.call(TEST_EDIT)
    await world.clock.advance(Fixtures.SETTLE_MS)

    expect(revealed, 'the revealed list draws the test row').toContain(
      'test/a.test.ts',
    )

    expect(
      world.opened.slice(shut).map(pane => pane.id),
      'the auto-open after the reveal',
    ).toEqual(['diff'])
  })
})

How this was checked: two independent review passes by Claude Code agents; every item above then re-run in copies of the head and of the base's hooks under the PR's tests; a further pass tried to break the patches, and its corrections (the !isListing in item 2's condition, the isRefreshQueued line, test 11, typed tests) are folded in. The differential can see a change when there is one: a one-token slip in hasRoomFor moves its digest. Telemetry was measured at the telemetry.mark call, not at ingest. Everything ran through claude plugin test's mock clock and scripted git: no real terminal resize. CI's merge ref predates one commit on main that touches only CHANGELOG.md and feed.xml.

…y-auto-open

Conflicts resolved:

mods/diff/hooks/register.ts: main's gates (the main loop's edit only,
checkpointing on, an open left waiting withdrawn) now sit before this
branch's fetch that decides whether to open. openPane seeds the model from
the read it is handed, so the pane lists as it opens, and loads hunks only
once the engine has placed it; a withdrawn open ends the attempt and the
next edit asks again.

mods/diff/tests/register.test.ts: both sides' tests kept. main's "an open
left waiting is withdrawn" now expects the one deciding read before the
open, and still no hunks fetched for a pane no one sees.

mods/diff/README.md: both descriptions combined.
…er the engine's 512 files

This branch's hooks module linked exactly 512 files, the most the engine
reads for one module; with the two is-checkpointing files from main it
linked 514 and did not load ("is past the 512 files a hooks module may
link and was not read"). editedPathOf, hasSessionFiles and normalPathOf
each lose their own folder and index and sit as one file beside the
barrel that exports them, as editing-tools.ts and partition-of.ts do: 511
files linked.
The room only fed the decision to open: an attempt whose read found the
terminal narrowed went on reading for each edit or command that landed
during a read, with no room to open anything. It now ends there, and the
next edit with room starts an attempt of its own.

The README's `git status` sentence says where that read now happens: at
the first fetch, the one an edit decides on included.

bcherny commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Item 2 was a real bug: an attempt now returns once the terminal has lost room instead of re-reading for each tool that lands, with a test that fails without the fix (d7f15cd). The README sentence on when the git status read happens is reworded in the same commit.

Items 1, 3 and 4 are design calls (what repl_diff_read should count, whether the gate should be drawn rows rather than the header count, and lexical vs resolved containment), so I've left them for the PR author to decide rather than widening this change. Item 5 is present on the base too, so it isn't addressed here.


Generated by Claude Code

…y-auto-open

# Conflicts:
#	mods/diff/README.md
#	mods/diff/hooks/register.ts
#	mods/diff/tests/register.test.ts

@konsta95 konsta95 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retested at d017bd6: the width-loss regression check passes here and fails on the pre-fix revision, confirming the fix for item 2 in my earlier comment. As you noted, the telemetry row-count and path-containment questions remain separate design decisions.

…y-auto-open

# Conflicts:
#	mods/diff/README.md
#	mods/diff/hooks/register.ts
@konsta95

Copy link
Copy Markdown

Retested at 23a906a on Claude Code 2.1.274 and 2.1.278: 179/179 in mods/diff on both. The merge's two hand-resolved hunks (README.md; register.ts, where openOnFirstEdit now defaults path to null for the restore path) read clean under --remerge-diff. Control: this head's tests over main's hooks fail 18, the new restored-empty-diff test among them at the pane-opened assertion, so the new test discriminates. Separately, four /clear//resume timing races reproduce identically on this head and on main 7974a70 — inherited, not introduced here; I'll take those up on #95587. Nothing in this range changes my d017bd6 conclusion.

@konsta95

konsta95 commented Sep 27, 2026 •

Copy link
Copy Markdown

Fix for the four /clear and /resume timing bugs

In my last comment I mentioned four timing bugs that happen both on this PR and on main. Here is a fix for each, with recordings.

What goes wrong

The pane reads git in the background. If /clear or /resume happens while a read is still running, the read finishes afterwards as if nothing had changed:

  1. An update that was waiting to run when you /clear still runs, so git is read twice.
  2. A read that started before /clear finishes after it, and shows the old session's edits as edits of the new session.
  3. When you continue an earlier session, the plugin checks its history to decide whether to open the pane. If you /clear before that check finishes, the pane opens by itself a few seconds later, for the session you just cleared.
  4. The same as 3, but after /resume.

The fix

Every /clear and /resume now starts a new round. Anything that started in an earlier round (a git read, a history check, a pane about to open) is thrown away when it finishes. An update still waiting at /clear is cancelled.

The version for this PR also includes main's existing rule that read-only shell commands like ls don't trigger an update (#95423), copied unchanged, so both versions can share the same tests. Once this PR is merged with main, the result is the same with or without it.

Recordings

Updated: re-recorded side by side on stock Claude Code 2.1.286. The earlier 2.1.283 recordings are in this comment's edit history.

Both sides of each video run the same client, with git slowed down so the timing is easy to hit; git's answers are real. BEFORE is main at 7779afb. AFTER is my current branch at 2cf6751, which has this fix merged with F1–F3 and poteat's three mods/diff PRs. The captions call it 96eee2a: the same mods/ code, before I reworded its commit messages. The mod runs as /diff2 so it doesn't clash with the built-in /diff. The videos play at half speed, and the input is typed one character at a time, so the typing looks slow.

Bug 2: a change made with the Bash tool, then /clear while the update after it is still reading. On main, the old app.ts edit shows up in the new session's pane after /clear and stays until a later update removes it. With the fix, it stays out.

stale-read-2.1.286-side-by-side.mp4

Bug 3: a saved session reopened with claude --resume, then /clear while the history check is still running. On main, the pane opens by itself shortly after /clear, for the session that was just cleared. With the fix, no pane opens.

restored-session-2.1.286-side-by-side.mp4

Bugs 1 and 4 are covered by tests only.

2.1.283 already includes this plugin as a built-in, behind a feature flag that is off by default. With the flag switched on in a separate test setup, bugs 2 and 3 happen there too. Bug 3 needs the pane to be allowed to open by itself; once you've closed it with /diff, it doesn't.

Tests

On Claude Code 2.1.283:

One change you'll notice

If /clear or /resume happens while /diff is still doing its first read, /diff now answers "The session changed. Run /diff again to show the diff panel." and opens nothing. Before, it opened a pane for the session that had just ended. You can't normally hit this by typing, because a typed /clear waits until /diff is done (video below, the same on main and with the fix). It takes a hook or plugin that runs /clear or /resume.

queue-control-2.1.286-side-by-side.mp4

Not covered yet

A /clear or /resume that lands while the pane is being placed on screen still leaves the pane up with the old session's changes. Main behaves the same, and the window is only a few milliseconds.

@konsta95

konsta95 commented Sep 28, 2026 •

Copy link
Copy Markdown

@bcherny, follow-up to my earlier /clear and /resume fixes: I’ve fixed the pending-placement race listed under “Not covered yet” in that comment.

Fix branch: konsta95:fix/diff-pending-pane-races-01a0e7e9, based on this PR’s 23a906a plus my earlier session-epoch patch. Compare against this PR.

If /clear or /resume overtakes a pending pane placement, cleanup finishes before another open proceeds. An old request therefore cannot close a newer pane. The follow-up also:

  • Keeps the command reply and saved preference consistent when /clear retains the pane.
  • Logs denied stale-placement cleanup and refreshes the retained pane for the current session.
  • Waits for the new session’s start time before reading the diff, and prevents an older /clear completion from overwriting newer session timing.

GitHub CI is green: 222 diff tests pass on stock Claude Code 2.1.284, and the all-mod typecheck passes with TypeScript 5.9.3. This check covers diff tests and typechecking; the broader suite’s agents-md/telemetry compatibility failures also reproduce on the unchanged base and remain separate.

before-after-2.1.284-slow.mp4

The before/after video uses actual Claude Code 2.1.284 clients with the same 20-second delay before ui.open to expose the ordering. The release-source version leaves the old pane visible after /clear; the fixed version removes it. A brief empty-pane flash remains during cleanup. The fixed arm uses this PR-based patch, rather than a fresh port onto the release tag.

I’m continuing with the remaining races around pending opens (#95476), pre-open reads (#95488), and resume/clear/session timing (#95587): stalled ui.open/session.usage recovery, denied closes during /resume, and safe cancellation before placement to avoid the flash. I’m also checking whether a rejected auto-open can leave its retry latch set; that case is not yet confirmed in a real client.

@konsta95

konsta95 commented Sep 29, 2026 •

Copy link
Copy Markdown

Follow-up to the pending-pane race patch: I fixed the /resume path where closing the diff pane is denied.

The fix is on a separate fork branch, with an incremental diff from the previous patch.

Before: /resume completes, but a denied close leaves the pane showing stale app.ts contents without a diagnostic from the diff mod.
After: the mod reports the failed close and refreshes the retained pane using the resumed session's timing. It now shows other.ts, matching the working tree.
Before/after video — 1:48, with AFTER starting at 0:54:

resume-before-after-2.1.284-slow.mp4

Recorded with real stock Claude Code 2.1.284 in isolated containers, with half-speed playback and labeled explanation pauses. Both runs use the same disclosed hook that denies one pane close. Before resuming, the fixture restores app.ts and changes other.ts externally; the plugin command/pane is aliased to diff2 to coexist with built-in /diff. This compares the previous fork patch (7fb9aef) against the resume fix (aa37075), not two stock releases.

The patch also prevents an older delayed resume-close completion from resetting a newer /clear or discarding its refresh; that interleaving is covered by regression tests, not this video. The same final suite gives 223 pass / 4 fail on the previous source and 227 pass / 0 fail with the fix. GitHub CI passes the complete diff suite and all-mod TypeScript check on the published branch.

I'm continuing through the remaining related races. Ordered placement stays; stalled-hook recovery and safe cancellation remain separate work.

konsta95 added a commit to konsta95/claude-code that referenced this pull request Sep 30, 2026
poteat's HEAD watch from the pin, quiet polling on unusual branch names,
and the /clear shown mark, fitted into the bundle's ownership model:

- register(): poteat's doc wording, with anthropics#94847's in-tree fetch clause kept.
- probeBackend: startPoll runs right after the pin, as poteat's does after
  `backend ??= probed`. Here the pin follows the owner check, so a probe
  overtaken while it reads the store starts no watch.
- refresh: a listed read no longer starts the poll (poteat).
- placePane: poteat's keepBaseline() on open. The shown mark stays with the
  bundle's recordShown, which is fenced by epoch and close.
- openOnRestore: the bundle's epoch guard is kept. poteat's change there
  only restyles the same read.
- /clear under an open pane (confirmSessionChange): the pane counts as shown
  for the new conversation with trigger 'manual', and its HEAD baseline is
  kept after the pin (poteat's two additions).
- register.test.ts: anthropics#94847's resumed-session test is kept, and poteat's
  retitle is taken.

Co-authored with a harness running Claude and Codex teams.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: 16cb7844-98c6-4af7-b67a-3e9d16705e7f
@konsta95

konsta95 commented Sep 30, 2026 •

Copy link
Copy Markdown

F1–F3, merged with current main and @poteat's #98374, #98357 and #98445

Three more lifecycle fixes, now merged with current main, which includes poteat's three mods/diff PRs, so they work together. They are on my fix branch at 2cf6751: this PR's 23a906a, my earlier fixes, F1–F3, and main at 525d3b3.

CI is green on that commit, on stock Claude Code 2.1.284 and 2.1.286: 406 diff tests pass, 0 fail on each, and the all-mod typecheck (TypeScript 5.9.3) and plugin validation pass. Locally the same 406 also pass on 2.1.285, and the diff hooks typecheck against the API that each of 2.1.284, 2.1.285 and 2.1.286 generates with /plugin-types.

The fixes

F1: /resume and stale reads

  • Cancelling /resume, or giving it a session that doesn't exist, now keeps the current pane and session.
  • A committed switch drops the old session's work before it waits for the pane to close. In the order the tests and the video reproduce, an old git read that finishes during a slow close can no longer add files to the pane.

F2: a lost reply to a pane open

  • If the reply to a pane open is lost after the pane was placed, the mod now checks the pane's actual state, so the next /diff hides the pane. Before, it opened the pane a second time and reported it shown.
  • If the pane's state can't be read either, /diff says it can't tell and leaves the pane alone. A later /diff that can read the state acts on it.

F3: pane size updates

  • A held size update could outlive a successful close and bring the pane back. One started during a close could also reopen the pane, or leave the list after it at the wrong height. Size updates are now ordered with opens and closes, only the latest request per pane and session is kept, and the cached size is dropped before a reply becomes uncertain.

Merging poteat's PRs

I merged the three PRs from their heads before they landed. Main's mods/ is identical to that merge, so merging main afterwards brought in only CHANGELOG.md and feed.xml.

poteat's tests all pass, and poteat's test changes are kept as written, except the setup of /clear under an open pane counts it shown again: it now switches sessions the way the fixes' other /clear tests do, with the same assertions. Apart from poteat's own changes, seven of the fixes' tests changed, all in owner-lifecycle.test.ts. Four HEAD-read counts now include #98357's baseline read. Three body tests count one batched read where there were per-file reads, and one of them, the skip test, now reaches its skipped read by queueing it behind two held batches.

Videos

All videos are new side-by-side recordings on stock Claude Code 2.1.286. Both sides of a video run the same client and the same fixtures; only the mod's revision differs. BEFORE is the fork commit named under each video, and AFTER is this branch. The mod runs as /diff2 so it doesn't clash with the built-in /diff. Injected delays and faults are named in the captions, and held frames are labelled. The videos play at half speed, and the input is typed one character at a time, so the typing looks slow.

The captions call the AFTER side 96eee2a. That is this branch before I reworded its commit messages, merged main and adjusted the CI workflow, so it isn't on GitHub. Its mods/ is identical to 2cf6751's.

Six videos are below. Three more replace the recordings in my earlier comment.

#98374: a finished rebase (1:00). Before: e67570d, F1–F3 without poteat's PRs. A real rebase has finished but left REBASE_HEAD, and app.ts has an uncommitted edit. Before, /diff2 shows "No changes yet"; after, it lists app.ts.

rebase-2.1.286-side-by-side.mp4

F1, video 1: a missing and a cancelled /resume (3:04). Before: f6cb646, my previous fix branch, without F1. Two parts: /resume with a session that doesn't exist, and the /resume picker closed with Escape. The session doesn't change in either. Before, the pane closes anyway; after, it stays open. No injected faults; both sides run the same session and pane tracing.

f1-resume-2.1.286-side-by-side.mp4

F1, video 2: an old read after a committed /resume (1:24). Same before commit. A fixture holds a real git answer until the old pane starts closing, then delays the close by 1.5 seconds. Before, the old read's other.ts appears in the pane during the close; after, it doesn't. That moment is frozen for 8 seconds on both sides, and the freeze is labelled.

f1-committed-resume-2.1.286-side-by-side.mp4

F2, video 1: a lost reply to a pane open (2:16). Before: edb677c, with F1 but without F2. A real Edit triggers the pane's auto-open, and a fault plugin denies the reply after the pane is placed. Before, the next /diff2 opens the pane a second time and shows it; after, it finds the placed pane and hides it.

f2-lost-reply-2.1.286-side-by-side.mp4

F2, video 2: a lost reply and failed pane lookups (3:04). Same before commit, and the fault plugin also refuses the first two pane lookups. Before, /diff2 opens the pane a second time. After, it says it can't tell the pane's state and leaves the pane alone; the next /diff2, whose lookup succeeds, hides the pane without another open.

f2-unknown-lookup-2.1.286-side-by-side.mp4

In both F2 videos, the built-in /diff is run once before /diff2 to hide its own pane, which covers the mod's pane.

F3: a held size update and a hide (1:16). Before: 1b148c8, with F1 and F2 but without F3. Opening the detail view requests a size update, which a fixture holds for 20 seconds, and /diff2 hides the pane meanwhile. The client's own 10-second hook budget runs out first. Before, the pane comes back when the budget runs out and is still there when the hold ends; after, the hide waits for the size update and the pane stays hidden. This shows the ordering, not recovery from a hook that never returns.

f3-2.1.286-side-by-side.mp4

Stopping here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants