Repository navigation
Conversation
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.
|
Reviewed at 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), 1. The reads that decide whether to open write no
|
| 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 drawing1 file changed +1,1 test/generated (show), "Only tests and generated files changed". No file row (isNoiseShownstartsfalse). 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-toplevelanswers with the physical path (checked on Linux, git 2.53:git -C <link>andgit -C <link>/subboth 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.tsgiveopened: []and one git process for the whole session; the same edits spelled physically open the pane. Git.isAbsolutePathwas written for lines git prints (is-absolute-path.ts:2-9) and is now applied to tool input. With aC:/work/repotop:/work/repo/a.tsreads as outside although Win32 resolves it into the tree;\work\other\a.tsreads 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 runsrev-parseandstatus. The README's "The one read the built-in has no counterpart for is agit statusat a pane's first fetch" now happens at the first deciding read, pane or no pane. hasAutoOpenedis set beforeopenPaneresolves (as on the base). New with this PR: the refresh starts before the open is awaited, so a deniedui.openwrites anokmark 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):
- a diff git could not read opens nothing and still marks the read (
sad/git_diff_failed) - a diff that lists no session file opens nothing and still marks the read (
ok) - a read that opens the pane marks once, not twice
- an attempt gives way once the terminal loses room: tools landing after it spawn no git
- room lost during a read, then regained: the next edit with a row opens the pane
- room lost as the pane opens: a command that landed in the read is still caught up on
- a refresh armed before
/cleardoes not run for the closed pane, so the next open draws its own read - nor does a refresh queued behind one in flight at
/cleararm it again - (optional patch) a first edit that changes only a test file opens nothing, the next edit with a row to list does
- (optional patch) a change past the per-file cap draws no row: opens nothing
- (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 = nullOptional, 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.
|
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 Items 1, 3 and 4 are design calls (what Generated by Claude Code |
…y-auto-open # Conflicts: # mods/diff/README.md # mods/diff/hooks/register.ts # mods/diff/tests/register.test.ts
konsta95
left a comment
There was a problem hiding this comment.
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
… to the next edit, under test
|
Retested at 23a906a on Claude Code 2.1.274 and 2.1.278: 179/179 in |
Fix for the four
|
|
@bcherny, follow-up to my earlier Fix branch: konsta95:fix/diff-pending-pane-races-01a0e7e9, based on this PR’s If
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.mp4The before/after video uses actual Claude Code 2.1.284 clients with the same 20-second delay before I’m continuing with the remaining races around pending opens (#95476), pre-open reads (#95488), and resume/clear/session timing (#95587): stalled |
|
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. resume-before-after-2.1.284-slow.mp4Recorded 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. |
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
F1–F3, merged with current main and @poteat's #98374, #98357 and #98445Three more lifecycle fixes, now merged with current main, which includes poteat's three 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 The fixesF1:
F2: a lost reply to a pane open
F3: pane size updates
Merging poteat's PRsI merged the three PRs from their heads before they landed. Main's
poteat's tests all pass, and poteat's test changes are kept as written, except the setup of VideosAll 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 The captions call the AFTER side Six videos are below. Three more replace the recordings in my earlier comment. #98374: a finished rebase (1:00). Before: rebase-2.1.286-side-by-side.mp4F1, video 1: a missing and a cancelled f1-resume-2.1.286-side-by-side.mp4F1, video 2: an old read after a committed f1-committed-resume-2.1.286-side-by-side.mp4F2, video 1: a lost reply to a pane open (2:16). Before: f2-lost-reply-2.1.286-side-by-side.mp4F2, 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, f2-unknown-lookup-2.1.286-side-by-side.mp4In both F2 videos, the built-in F3: a held size update and a hide (1:16). Before: f3-2.1.286-side-by-side.mp4Stopping here. |
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;
/diffis unchanged.tool.callevent. 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,/workdoes 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.One attempt runs at a time. An edit or shell command that lands while it reads is read again; an attempt gives way if
/clearor/resumeovertakes 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/diffafter mergingmain(155 onmainalone);tsc -p mods/tsconfig.jsonis 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,/clearovertaking a read,/difftyped 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 onmain, apart from two that pin behaviour that already held (edits landing together open one pane;/difftyped 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