diff: the first edit opens the pane only from the main loop with checkpointing on, and an open the engine leaves waiting is withdrawn

This commit is contained in:
poteat
2026-09-18 15:56:58 -07:00
parent 6ce37e9f46
commit 8290ed1b0a
12 changed files with 337 additions and 30 deletions
+14 -10
View File
@@ -13,12 +13,15 @@ built-in's list keys (`ctrl+up`/`ctrl+down`, `opt+up`/`opt+down`), and
through Buttons that declare the engine's own actions. The pane refreshes
as Claude edits and runs shell commands, and while it is open it polls
the repository's HEAD so a commit or checkout made elsewhere shows too.
The first successful edit of a session opens the pane by itself where the
The main loop's first successful edit of a session opens the pane by
itself, as the built-in panel opens on its first checkpoint: where the
layout docks it beside the transcript (the fullscreen layout, which each
drawing's `viewport` says) and the terminal is wide enough (144 columns
when the person never chose, 110 when they kept it open before; a person
who closed it is left alone); where the surface does not say, nothing
opens by itself.
drawing's `viewport` says), the terminal is wide enough (144 columns when
the person never chose, 110 when they kept it open before; a person who
closed it is left alone) and file checkpointing is on; a subagent's edit
opens nothing, and where the surface does not say, nothing opens by
itself. An open the engine leaves waiting undrawn is withdrawn, so no
later resize seats it, and the next edit asks again.
Under the fullscreen layout a terminal under 110 columns gets the
built-in's line asking for a wider one and nothing opens. Without that
@@ -68,16 +71,17 @@ moved file by.
| `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. |
| `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. |
| `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state, the pinned repository with it. |
| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the session's first such edit opens it, pinning the repository then if the terminal has the room. |
| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the main loop's first such edit opens it, pinning the repository then if the terminal has the room and checkpointing is on. |
| `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, failed and interrupted ones too, refreshes an open pane. |
| `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. |
## What it calls on `$`
`clock.after`, `clock.every`, `clock.now`, `command.register`, `fs.list`,
`fs.read`, `fs.stat`, `process.run` (git, read-only), `session.messages`,
`store.get`, `store.set`, `telemetry.log`, `telemetry.mark`, `ui.close`,
`ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`.
`clock.after`, `clock.every`, `clock.now`, `command.register`, `env.get`
(`CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING`), `fs.list`, `fs.read`, `fs.stat`,
`process.run` (git, read-only), `session.id`, `session.messages`,
`settings.read`, `store.get`, `store.set`, `telemetry.log`, `telemetry.mark`,
`ui.close`, `ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`.
`$.telemetry` is the telemetry plugin's noun; where it is absent the rows
are dropped and nothing else changes.
+6
View File
@@ -64,6 +64,12 @@ export type Host = {
*/
storeSet: (key: string, value: unknown) => Promise<void>
/**
* Whether the session checkpoints edits (`$.settings.read`, `$.env.get`):
* the built-in panel opens on an edit only while it does.
*/
isCheckpointing: () => Promise<boolean>
/**
* `$.session.messages`.
*/
+1
View File
@@ -8,6 +8,7 @@ export * from './drawn-files-of'
export * from './entry-kinds-of'
export * from './git'
export * from './host'
export * from './is-checkpointing'
export * from './is-on-pane-surface'
export * from './is-record'
export * from './kept-of'
@@ -0,0 +1,3 @@
export * from './is-checkpointing.js'
export * as default from '.'
@@ -0,0 +1,20 @@
import type { Settings } from 'claude-code'
/**
* Whether the session checkpoints Claude's edits, read as the built-in
* reads it: the setting on unless set false, the variable unset or falsy.
*
* The built-in panel opens on an edit only through a checkpoint, so with
* checkpointing off its first-edit open never happens; `/diff` still opens.
*
* @param settings the merged settings (`$.settings.read()`)
* @param disabling `CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING` as `$.env.get`
* answers it
* @returns false when either turns checkpointing off
*/
export const isCheckpointing = (
settings: Settings,
disabling: string | undefined,
): boolean =>
settings.fileCheckpointingEnabled !== false &&
!['1', 'true', 'yes', 'on'].includes((disabling ?? '').trim().toLowerCase())
+43 -10
View File
@@ -1,4 +1,5 @@
import type {
Args,
On,
PaneOpenArgs,
ResultOf,
@@ -13,7 +14,9 @@ import { drawnFilesOf } from './drawn-files-of'
import { entryKindsOf } from './entry-kinds-of'
import type Git from './git'
import type { Host } from './host'
import { isCheckpointing } from './is-checkpointing'
import { isOnPaneSurface } from './is-on-pane-surface'
import { isRecord } from './is-record'
import Limits from './limits'
import { mapLimited } from './map-limited'
import { messageOf } from './message-of'
@@ -30,8 +33,8 @@ import Views from './views'
* pane's drawing and refresh, its opening on Claude's first edit, the ask.
*
* Git runs when the built-in's would: `session.start` binds the host and
* registers `/diff`; `/diff` or the first edit with room pins the backend
* where the session started, until `/clear`; an open pane alone fetches.
* registers `/diff`; `/diff` or the main loop's first checkpointed edit with
* room pins the backend, until `/clear`; only a placed, open pane fetches.
*
* @param on the engine's registrar
*/
@@ -394,7 +397,7 @@ export function register(on: On) {
async function openPane(
engine: Host,
trigger: (typeof Record.SHOWN_TRIGGERS)[number],
): Promise<void> {
): Promise<boolean> {
const isDialog = model.isFullscreen === false
model = {
@@ -406,12 +409,20 @@ export function register(on: On) {
dialogRows = isDialog ? Views.dialogRowsOf(model) : null
await engine.openPane(
const opened = await engine.openPane(
isDialog
? { ...dialogPane(), focus: true }
: { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true },
)
const isWaiting = isRecord(opened) && opened.isPlaced === false
if (isWaiting) {
await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined)
return false
}
isPaneOpen = true
const sessionId = await engine.sessionId().catch(() => null)
@@ -422,6 +433,8 @@ export function register(on: On) {
}
void refresh(engine)
return true
}
async function closePane(engine: Host): Promise<void> {
@@ -460,6 +473,12 @@ export function register(on: On) {
return
}
const isCheckpointed = await engine.isCheckpointing().catch(() => true)
if (!isCheckpointed || isTaken()) {
return
}
await pinBackend(engine)
if (!backend || isTaken()) {
@@ -467,7 +486,7 @@ export function register(on: On) {
}
hasAutoOpened = true
await openPane(engine, 'auto_open')
hasAutoOpened = await openPane(engine, 'auto_open')
}
function disarm(engine: Host) {
@@ -602,6 +621,11 @@ export function register(on: On) {
readFile: path => $.fs.read(path),
storeGet: key => $.store.get(key),
storeSet: (key, value) => $.store.set(key, value),
isCheckpointing: async () =>
isCheckpointing(
await $.settings.read(),
await $.env.get('CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING'),
),
messages: () => $.session.messages(),
invalidate: () => $.ui.invalidate('ui.render'),
status: text => $.ui.status(text),
@@ -699,7 +723,14 @@ export function register(on: On) {
}
const isOpening = toggle === 'open'
await (isOpening ? openPane(host, 'manual') : closePane(host))
const isDone = isOpening
? await openPane(host, 'manual')
: await closePane(host).then(() => true)
if (!isDone) {
return { text: Names.RESIZE_TERMINAL_TEXT }
}
if (!isFullscreen) {
return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
@@ -818,10 +849,10 @@ export function register(on: On) {
function afterTool(
engine: Host,
tool: string,
e: Args<'tool.call'>,
result: ResultOf['tool.call'] | undefined,
) {
const isEdit = Tools.EDITING_TOOLS.some(name => name === tool)
const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool)
const hasEdited =
isEdit &&
@@ -837,7 +868,9 @@ export function register(on: On) {
scheduleRefresh(engine)
}
if (hasEdited) {
const isMainLoopEdit = hasEdited && e.agentId === undefined
if (isMainLoopEdit) {
void openOnFirstEdit(engine).catch(() => undefined)
}
}
@@ -854,7 +887,7 @@ export function register(on: On) {
return result
} finally {
if (host) {
afterTool(host, e.tool, result)
afterTool(host, e, result)
}
}
},
+28
View File
@@ -0,0 +1,28 @@
import type { Settings } from 'claude-code'
/**
* What the world beneath a repository session holds and answers besides
* git: the store, the settings, the environment, whether a pane is seated.
*/
export type Beneath = {
/**
* What the plugin's store holds at the start; nothing when not given.
*/
stored?: Readonly<Record<string, unknown>>
/**
* What `$.settings.read` answers; empty when not given.
*/
settings?: Settings
/**
* The variables `$.env.get` answers from; none set when not given.
*/
env?: Readonly<Record<string, string>>
/**
* Whether an open made now is left waiting undrawn (LEFT_WAITING), as an
* engine leaves an unasked open on a narrow terminal; placed when not given.
*/
isLeftWaiting?: () => boolean
}
+4
View File
@@ -0,0 +1,4 @@
export type * from './beneath.js'
export * from './left-waiting.js'
export * as default from '.'
+17
View File
@@ -0,0 +1,17 @@
import type { ResultOf } from 'claude-code'
/**
* What an engine answers an open it leaves waiting undrawn: the plugin
* opened unasked on a terminal narrower than an unrequested pane is given.
*
* Typed through `never` so it compiles against declarations that predate the
* answer, where `ui.open` resolves nothing.
*/
export const LEFT_WAITING: ResultOf['ui.open'] = {
value: {
isPlaced: false,
reason:
'unasked below 144 columns (120 now): placed when the person opens ' +
'it, or when the terminal is widened to 144 columns',
} as never,
}
+25 -9
View File
@@ -1,6 +1,7 @@
import type { Args, On } from 'claude-code'
import { mock } from 'claude-code/testing'
import Beneath from './beneath'
import { gitIn } from './git-in.js'
import { HINT_DRAWN } from './hint-drawn.js'
import { keeping } from './keeping.js'
@@ -11,24 +12,25 @@ import { startsSession } from './starts-session.js'
* A session in a repository git answers for from a script (REPOSITORY, in
* /work, when none is given), keeping what the plugin does there.
*
* Kept: each git run, ring move, pane opened or closed, status line. The clock
* starts at 0 and the engine draws the hint. A test that rewrites the
* script between calls changes what git answers next.
* Kept: each git run, ring move, pane opened, left waiting or closed, status
* line. The clock starts at 0 and the engine draws the hint; the rest of the
* world is the test's (Beneath). Rewriting the script changes git's answers.
*
* @param on the test's `on`
* @param script git's output for each invocation whose line holds the key
* @param stored what the plugin's store holds at the start
* @returns the runs, the ring's moves, the panes opened and closed, the
* statuses, the clock
* @param beneath the store, settings, environment, whether an open is seated
* @returns the runs, the ring's moves, the panes opened, left waiting and
* closed, the statuses, the clock
*/
export function inRepository(
on: On,
script: Readonly<Record<string, string>> = REPOSITORY,
stored: Readonly<Record<string, unknown>> = {},
beneath: Beneath.Beneath = {},
) {
const runs: Args<'process.run'>[] = []
const focused: Args<'ui.focus'>[] = []
const statuses: (string | undefined)[] = []
const waiting: Args<'ui.open'>[] = []
const opened = keeping<Args<'ui.open'>>()
const closed = keeping<Args<'ui.close'>>()
const clock = startsSession(on)
@@ -51,17 +53,31 @@ export function inRepository(
return { value: undefined }
})
on('ui.open', opened.hook)
on('ui.open', (engine, e) => {
const isWaiting = beneath.isLeftWaiting?.() === true
if (!isWaiting) {
return opened.hook(engine, e)
}
waiting.push(e)
return Beneath.LEFT_WAITING
})
on('ui.close', closed.hook)
on('ui.invalidate', () => ({ value: undefined }))
on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN)
on('session.messages', () => ({ value: [] }))
mock.store(on, stored)
on('settings.read', () => ({ value: beneath.settings ?? {} }))
mock.store(on, beneath.stored ?? {})
mock.env(on, beneath.env ?? {})
return {
runs,
focused,
opened: opened.kept,
waiting,
closed: closed.kept,
statuses,
clock,
+1
View File
@@ -1,6 +1,7 @@
export * from './answers-of.js'
export * from './ask'
export * from './backend-of'
export * from './beneath'
export * from './big-lines.js'
export * from './branch-base-of'
export * from './builtin-holds.js'
+175 -1
View File
@@ -448,6 +448,180 @@ describe('register', () => {
).toEqual(['diff'])
})
test("only the main loop's edit opens the pane", async ($, on) => {
const world = Fixtures.inRepository(on)
const edit = (agentId?: string) =>
$.tool.call({
tool: 'Write',
file_path: '/work/app.ts',
content: '2',
...(agentId !== undefined && { agentId }),
})
on('tool.call', () => ({ result: 'written' }))
await $.session.start(Fixtures.SESSION)
await $.ui.render(Fixtures.HINT)
await edit('a-explorer')
await world.clock.advance(Fixtures.SETTLE_MS)
expect(world.opened, 'the built-in checkpoints no subagent edit').toEqual(
[],
)
expect(world.runs, 'so nothing was asked of the repository').toEqual([])
await edit()
await world.clock.advance(Fixtures.SETTLE_MS)
expect(
world.opened.map(pane => pane.id),
"the main loop's",
).toEqual(['diff'])
})
test('checkpointing off in settings: edits open nothing', async ($, on) => {
const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
settings: { fileCheckpointingEnabled: false },
})
on('tool.call', () => ({ result: 'edited' }))
await $.session.start(Fixtures.SESSION)
await $.ui.render(Fixtures.HINT)
await $.tool.call({
tool: 'Edit',
file_path: '/work/app.ts',
old_string: '1',
new_string: '2',
})
await world.clock.advance(Fixtures.SETTLE_MS)
expect(world.opened, 'the built-in opens on a checkpoint alone').toEqual([])
expect(world.runs, 'so nothing was asked of the repository').toEqual([])
expect(await $.command.run(Fixtures.DIFF)).toEqual({
text: 'Diff panel shown',
})
})
test('checkpointing off by variable: edits open nothing', async ($, on) => {
const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
env: { CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING: 'true' },
})
on('tool.call', () => ({ result: 'edited' }))
await $.session.start(Fixtures.SESSION)
await $.ui.render(Fixtures.HINT)
await $.tool.call({
tool: 'Edit',
file_path: '/work/app.ts',
old_string: '1',
new_string: '2',
})
await world.clock.advance(Fixtures.SETTLE_MS)
expect(world.opened).toEqual([])
})
test('an open left waiting is withdrawn; /diff opens it', async ($, on) => {
let isNarrow = true
const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
stored: { [Names.STORE_OPEN_KEY]: true },
isLeftWaiting: () => isNarrow,
})
const edit = () =>
$.tool.call({
tool: 'Edit',
file_path: '/work/app.ts',
old_string: '1',
new_string: '2',
})
on('tool.call', () => ({ result: 'edited' }))
await $.session.start(Fixtures.SESSION)
await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
await edit()
await world.clock.advance(Fixtures.SETTLE_MS)
expect(
world.waiting.map(pane => pane.id),
'the edit opened',
).toEqual(['diff'])
expect(
world.closed.map(pane => pane.id),
'and withdrew the pane the engine left waiting, so no resize seats it',
).toEqual(['diff'])
expect(
world.runs.map(run => Fixtures.gitWordOf(run.argv)),
'the repository was found, nothing fetched for a pane no one sees',
).toEqual(['rev-parse --show-toplevel'])
isNarrow = false
expect(
await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)),
'/diff opens it, where a pane believed open would have been hidden',
).toEqual({ text: 'Diff panel shown' })
await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
await edit()
await world.clock.advance(Fixtures.SETTLE_MS)
expect(
world.opened.map(pane => pane.id),
'closed by hand, no edit opens it again',
).toEqual(['diff'])
})
test('an unplaced open is tried again at the next edit', async ($, on) => {
let isNarrow = true
const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
stored: { [Names.STORE_OPEN_KEY]: true },
isLeftWaiting: () => isNarrow,
})
const edit = () =>
$.tool.call({
tool: 'Edit',
file_path: '/work/app.ts',
old_string: '1',
new_string: '2',
})
on('tool.call', () => ({ result: 'edited' }))
await $.session.start(Fixtures.SESSION)
await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
await edit()
await world.clock.advance(Fixtures.SETTLE_MS)
expect(world.opened, 'the first try waited and was withdrawn').toEqual([])
isNarrow = false
await edit()
await world.clock.advance(Fixtures.SETTLE_MS)
expect(
world.opened.map(pane => pane.id),
'the next edit opened it, as the built-in reads the width again then',
).toEqual(['diff'])
})
test('/clear closes the pane it finds open', async ($, on) => {
const world = Fixtures.inRepository(on)
@@ -528,7 +702,7 @@ describe('register', () => {
test('a worktree opens on the base it kept', async ($, on) => {
const world = Fixtures.inRepository(on, Fixtures.oneSecret(), {
'base:/main/wt': 'uncommitted',
stored: { 'base:/main/wt': 'uncommitted' },
})
await $.session.start(Fixtures.WORKTREE_SESSION)