spacehub

surshar / claude-code

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

Built by Anthropic · github.com/anthropics

diff: the first edit opens the pane only from the main loop with checkpointing on, and an open the engine leaves waiting is withdrawn (#95476)Browse files
Alice T'Poteat authored on Sep 18, 2026 · GitHub committed on Sep 18, 2026 parent 6ce37e9 · commit 2287e5d473d1
12 changed files+337 -30compared with 6ce37e9
Mmods/diff/README.md
Mmods/diff/hooks/host/host.ts
Mmods/diff/hooks/index.ts
Amods/diff/hooks/is-checkpointing/index.ts
Amods/diff/hooks/is-checkpointing/is-checkpointing.ts
Mmods/diff/hooks/register.ts
Amods/diff/tests/fixtures/beneath/beneath.ts
Amods/diff/tests/fixtures/beneath/index.ts
Amods/diff/tests/fixtures/beneath/left-waiting.ts
Mmods/diff/tests/fixtures/in-repository.ts
Mmods/diff/tests/fixtures/index.ts
Mmods/diff/tests/register.test.ts
@@ -13,12 +13,15 @@
1313 through Buttons that declare the engine's own actions. The pane refreshes
1414 as Claude edits and runs shell commands, and while it is open it polls
1515 the repository's HEAD so a commit or checkout made elsewhere shows too.
16-The first successful edit of a session opens the pane by itself where the
16+The main loop's first successful edit of a session opens the pane by
17+itself, as the built-in panel opens on its first checkpoint: where the
1718 layout docks it beside the transcript (the fullscreen layout, which each
18-drawing's `viewport` says) and the terminal is wide enough (144 columns
19-when the person never chose, 110 when they kept it open before; a person
20-who closed it is left alone); where the surface does not say, nothing
21-opens by itself.
19+drawing's `viewport` says), the terminal is wide enough (144 columns when
20+the person never chose, 110 when they kept it open before; a person who
21+closed it is left alone) and file checkpointing is on; a subagent's edit
22+opens nothing, and where the surface does not say, nothing opens by
23+itself. An open the engine leaves waiting undrawn is withdrawn, so no
24+later resize seats it, and the next edit asks again.
2225
2326 Under the fullscreen layout a terminal under 110 columns gets the
2427 built-in's line asking for a wider one and nothing opens. Without that
@@ -68,16 +71,17 @@
6871 | `ui.scroll` of the pane | Docked, moves the hunks under the pinned header and list (three rows a wheel tick, a page a page key), or the list when the wheel is over it, and keeps the engine's window still. |
6972 | `ui.focus` in the pane | In the dialog's list, selects the file the ring lands on, re-centres the five rows on it, and lands the ring where that row now sits. |
7073 | `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state, the pinned repository with it. |
71-| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the session's first such edit opens it, pinning the repository then if the terminal has the room. |
74+| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the main loop's first such edit opens it, pinning the repository then if the terminal has the room and checkpointing is on. |
7275 | `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, failed and interrupted ones too, refreshes an open pane. |
7376 | `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. |
7477
7578 ## What it calls on `$`
7679
77-`clock.after`, `clock.every`, `clock.now`, `command.register`, `fs.list`,
78-`fs.read`, `fs.stat`, `process.run` (git, read-only), `session.messages`,
79-`store.get`, `store.set`, `telemetry.log`, `telemetry.mark`, `ui.close`,
80-`ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`.
80+`clock.after`, `clock.every`, `clock.now`, `command.register`, `env.get`
81+(`CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING`), `fs.list`, `fs.read`, `fs.stat`,
82+`process.run` (git, read-only), `session.id`, `session.messages`,
83+`settings.read`, `store.get`, `store.set`, `telemetry.log`, `telemetry.mark`,
84+`ui.close`, `ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`.
8185
8286 `$.telemetry` is the telemetry plugin's noun; where it is absent the rows
8387 are dropped and nothing else changes.
@@ -65,6 +65,12 @@
6565 storeSet: (key: string, value: unknown) => Promise<void>
6666
6767 /**
68+ * Whether the session checkpoints edits (`$.settings.read`, `$.env.get`):
69+ * the built-in panel opens on an edit only while it does.
70+ */
71+ isCheckpointing: () => Promise<boolean>
72+
73+ /**
6874 * `$.session.messages`.
6975 */
7076 messages: () => Promise<SessionMessage[]>
@@ -8,6 +8,7 @@
88 export * from './entry-kinds-of'
99 export * from './git'
1010 export * from './host'
11+export * from './is-checkpointing'
1112 export * from './is-on-pane-surface'
1213 export * from './is-record'
1314 export * from './kept-of'
@@ -1,0 +1,3 @@
1+export * from './is-checkpointing.js'
2+
3+export * as default from '.'
@@ -1,0 +1,20 @@
1+import type { Settings } from 'claude-code'
2+
3+/**
4+ * Whether the session checkpoints Claude's edits, read as the built-in
5+ * reads it: the setting on unless set false, the variable unset or falsy.
6+ *
7+ * The built-in panel opens on an edit only through a checkpoint, so with
8+ * checkpointing off its first-edit open never happens; `/diff` still opens.
9+ *
10+ * @param settings the merged settings (`$.settings.read()`)
11+ * @param disabling `CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING` as `$.env.get`
12+ * answers it
13+ * @returns false when either turns checkpointing off
14+ */
15+export const isCheckpointing = (
16+ settings: Settings,
17+ disabling: string | undefined,
18+): boolean =>
19+ settings.fileCheckpointingEnabled !== false &&
20+ !['1', 'true', 'yes', 'on'].includes((disabling ?? '').trim().toLowerCase())
@@ -1,4 +1,5 @@
11 import type {
2+ Args,
23 On,
34 PaneOpenArgs,
45 ResultOf,
@@ -13,7 +14,9 @@
1314 import { entryKindsOf } from './entry-kinds-of'
1415 import type Git from './git'
1516 import type { Host } from './host'
17+import { isCheckpointing } from './is-checkpointing'
1618 import { isOnPaneSurface } from './is-on-pane-surface'
19+import { isRecord } from './is-record'
1720 import Limits from './limits'
1821 import { mapLimited } from './map-limited'
1922 import { messageOf } from './message-of'
@@ -30,8 +33,8 @@
3033 * pane's drawing and refresh, its opening on Claude's first edit, the ask.
3134 *
3235 * Git runs when the built-in's would: `session.start` binds the host and
33- * registers `/diff`; `/diff` or the first edit with room pins the backend
34- * where the session started, until `/clear`; an open pane alone fetches.
36+ * registers `/diff`; `/diff` or the main loop's first checkpointed edit with
37+ * room pins the backend, until `/clear`; only a placed, open pane fetches.
3538 *
3639 * @param on the engine's registrar
3740 */
@@ -394,7 +397,7 @@
394397 async function openPane(
395398 engine: Host,
396399 trigger: (typeof Record.SHOWN_TRIGGERS)[number],
397- ): Promise<void> {
400+ ): Promise<boolean> {
398401 const isDialog = model.isFullscreen === false
399402
400403 model = {
@@ -406,12 +409,20 @@
406409
407410 dialogRows = isDialog ? Views.dialogRowsOf(model) : null
408411
409- await engine.openPane(
412+ const opened = await engine.openPane(
410413 isDialog
411414 ? { ...dialogPane(), focus: true }
412415 : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true },
413416 )
414417
418+ const isWaiting = isRecord(opened) && opened.isPlaced === false
419+
420+ if (isWaiting) {
421+ await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined)
422+
423+ return false
424+ }
425+
415426 isPaneOpen = true
416427
417428 const sessionId = await engine.sessionId().catch(() => null)
@@ -422,6 +433,8 @@
422433 }
423434
424435 void refresh(engine)
436+
437+ return true
425438 }
426439
427440 async function closePane(engine: Host): Promise<void> {
@@ -460,6 +473,12 @@
460473 return
461474 }
462475
476+ const isCheckpointed = await engine.isCheckpointing().catch(() => true)
477+
478+ if (!isCheckpointed || isTaken()) {
479+ return
480+ }
481+
463482 await pinBackend(engine)
464483
465484 if (!backend || isTaken()) {
@@ -467,7 +486,7 @@
467486 }
468487
469488 hasAutoOpened = true
470- await openPane(engine, 'auto_open')
489+ hasAutoOpened = await openPane(engine, 'auto_open')
471490 }
472491
473492 function disarm(engine: Host) {
@@ -602,6 +621,11 @@
602621 readFile: path => $.fs.read(path),
603622 storeGet: key => $.store.get(key),
604623 storeSet: (key, value) => $.store.set(key, value),
624+ isCheckpointing: async () =>
625+ isCheckpointing(
626+ await $.settings.read(),
627+ await $.env.get('CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING'),
628+ ),
605629 messages: () => $.session.messages(),
606630 invalidate: () => $.ui.invalidate('ui.render'),
607631 status: text => $.ui.status(text),
@@ -699,8 +723,15 @@
699723 }
700724
701725 const isOpening = toggle === 'open'
702- await (isOpening ? openPane(host, 'manual') : closePane(host))
703726
727+ const isDone = isOpening
728+ ? await openPane(host, 'manual')
729+ : await closePane(host).then(() => true)
730+
731+ if (!isDone) {
732+ return { text: Names.RESIZE_TERMINAL_TEXT }
733+ }
734+
704735 if (!isFullscreen) {
705736 return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
706737 }
@@ -818,10 +849,10 @@
818849
819850 function afterTool(
820851 engine: Host,
821- tool: string,
852+ e: Args<'tool.call'>,
822853 result: ResultOf['tool.call'] | undefined,
823854 ) {
824- const isEdit = Tools.EDITING_TOOLS.some(name => name === tool)
855+ const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool)
825856
826857 const hasEdited =
827858 isEdit &&
@@ -837,7 +868,9 @@
837868 scheduleRefresh(engine)
838869 }
839870
840- if (hasEdited) {
871+ const isMainLoopEdit = hasEdited && e.agentId === undefined
872+
873+ if (isMainLoopEdit) {
841874 void openOnFirstEdit(engine).catch(() => undefined)
842875 }
843876 }
@@ -854,7 +887,7 @@
854887 return result
855888 } finally {
856889 if (host) {
857- afterTool(host, e.tool, result)
890+ afterTool(host, e, result)
858891 }
859892 }
860893 },
@@ -1,0 +1,28 @@
1+import type { Settings } from 'claude-code'
2+
3+/**
4+ * What the world beneath a repository session holds and answers besides
5+ * git: the store, the settings, the environment, whether a pane is seated.
6+ */
7+export type Beneath = {
8+ /**
9+ * What the plugin's store holds at the start; nothing when not given.
10+ */
11+ stored?: Readonly<Record<string, unknown>>
12+
13+ /**
14+ * What `$.settings.read` answers; empty when not given.
15+ */
16+ settings?: Settings
17+
18+ /**
19+ * The variables `$.env.get` answers from; none set when not given.
20+ */
21+ env?: Readonly<Record<string, string>>
22+
23+ /**
24+ * Whether an open made now is left waiting undrawn (LEFT_WAITING), as an
25+ * engine leaves an unasked open on a narrow terminal; placed when not given.
26+ */
27+ isLeftWaiting?: () => boolean
28+}
@@ -1,0 +1,4 @@
1+export type * from './beneath.js'
2+export * from './left-waiting.js'
3+
4+export * as default from '.'
@@ -1,0 +1,17 @@
1+import type { ResultOf } from 'claude-code'
2+
3+/**
4+ * What an engine answers an open it leaves waiting undrawn: the plugin
5+ * opened unasked on a terminal narrower than an unrequested pane is given.
6+ *
7+ * Typed through `never` so it compiles against declarations that predate the
8+ * answer, where `ui.open` resolves nothing.
9+ */
10+export const LEFT_WAITING: ResultOf['ui.open'] = {
11+ value: {
12+ isPlaced: false,
13+ reason:
14+ 'unasked below 144 columns (120 now): placed when the person opens ' +
15+ 'it, or when the terminal is widened to 144 columns',
16+ } as never,
17+}
@@ -1,6 +1,7 @@
11 import type { Args, On } from 'claude-code'
22 import { mock } from 'claude-code/testing'
33
4+import Beneath from './beneath'
45 import { gitIn } from './git-in.js'
56 import { HINT_DRAWN } from './hint-drawn.js'
67 import { keeping } from './keeping.js'
@@ -11,24 +12,25 @@
1112 * A session in a repository git answers for from a script (REPOSITORY, in
1213 * /work, when none is given), keeping what the plugin does there.
1314 *
14- * Kept: each git run, ring move, pane opened or closed, status line. The clock
15- * starts at 0 and the engine draws the hint. A test that rewrites the
16- * script between calls changes what git answers next.
15+ * Kept: each git run, ring move, pane opened, left waiting or closed, status
16+ * line. The clock starts at 0 and the engine draws the hint; the rest of the
17+ * world is the test's (Beneath). Rewriting the script changes git's answers.
1718 *
1819 * @param on the test's `on`
1920 * @param script git's output for each invocation whose line holds the key
20- * @param stored what the plugin's store holds at the start
21- * @returns the runs, the ring's moves, the panes opened and closed, the
22- * statuses, the clock
21+ * @param beneath the store, settings, environment, whether an open is seated
22+ * @returns the runs, the ring's moves, the panes opened, left waiting and
23+ * closed, the statuses, the clock
2324 */
2425 export function inRepository(
2526 on: On,
2627 script: Readonly<Record<string, string>> = REPOSITORY,
27- stored: Readonly<Record<string, unknown>> = {},
28+ beneath: Beneath.Beneath = {},
2829 ) {
2930 const runs: Args<'process.run'>[] = []
3031 const focused: Args<'ui.focus'>[] = []
3132 const statuses: (string | undefined)[] = []
33+ const waiting: Args<'ui.open'>[] = []
3234 const opened = keeping<Args<'ui.open'>>()
3335 const closed = keeping<Args<'ui.close'>>()
3436 const clock = startsSession(on)
@@ -51,17 +53,31 @@
5153 return { value: undefined }
5254 })
5355
54- on('ui.open', opened.hook)
56+ on('ui.open', (engine, e) => {
57+ const isWaiting = beneath.isLeftWaiting?.() === true
58+
59+ if (!isWaiting) {
60+ return opened.hook(engine, e)
61+ }
62+
63+ waiting.push(e)
64+
65+ return Beneath.LEFT_WAITING
66+ })
67+
5568 on('ui.close', closed.hook)
5669 on('ui.invalidate', () => ({ value: undefined }))
5770 on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN)
5871 on('session.messages', () => ({ value: [] }))
59- mock.store(on, stored)
72+ on('settings.read', () => ({ value: beneath.settings ?? {} }))
73+ mock.store(on, beneath.stored ?? {})
74+ mock.env(on, beneath.env ?? {})
6075
6176 return {
6277 runs,
6378 focused,
6479 opened: opened.kept,
80+ waiting,
6581 closed: closed.kept,
6682 statuses,
6783 clock,
@@ -1,6 +1,7 @@
11 export * from './answers-of.js'
22 export * from './ask'
33 export * from './backend-of'
4+export * from './beneath'
45 export * from './big-lines.js'
56 export * from './branch-base-of'
67 export * from './builtin-holds.js'
@@ -448,6 +448,180 @@
448448 ).toEqual(['diff'])
449449 })
450450
451+ test("only the main loop's edit opens the pane", async ($, on) => {
452+ const world = Fixtures.inRepository(on)
453+
454+ const edit = (agentId?: string) =>
455+ $.tool.call({
456+ tool: 'Write',
457+ file_path: '/work/app.ts',
458+ content: '2',
459+ ...(agentId !== undefined && { agentId }),
460+ })
461+
462+ on('tool.call', () => ({ result: 'written' }))
463+
464+ await $.session.start(Fixtures.SESSION)
465+ await $.ui.render(Fixtures.HINT)
466+ await edit('a-explorer')
467+ await world.clock.advance(Fixtures.SETTLE_MS)
468+
469+ expect(world.opened, 'the built-in checkpoints no subagent edit').toEqual(
470+ [],
471+ )
472+
473+ expect(world.runs, 'so nothing was asked of the repository').toEqual([])
474+
475+ await edit()
476+ await world.clock.advance(Fixtures.SETTLE_MS)
477+
478+ expect(
479+ world.opened.map(pane => pane.id),
480+ "the main loop's",
481+ ).toEqual(['diff'])
482+ })
483+
484+ test('checkpointing off in settings: edits open nothing', async ($, on) => {
485+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
486+ settings: { fileCheckpointingEnabled: false },
487+ })
488+
489+ on('tool.call', () => ({ result: 'edited' }))
490+
491+ await $.session.start(Fixtures.SESSION)
492+ await $.ui.render(Fixtures.HINT)
493+
494+ await $.tool.call({
495+ tool: 'Edit',
496+ file_path: '/work/app.ts',
497+ old_string: '1',
498+ new_string: '2',
499+ })
500+
501+ await world.clock.advance(Fixtures.SETTLE_MS)
502+
503+ expect(world.opened, 'the built-in opens on a checkpoint alone').toEqual([])
504+
505+ expect(world.runs, 'so nothing was asked of the repository').toEqual([])
506+
507+ expect(await $.command.run(Fixtures.DIFF)).toEqual({
508+ text: 'Diff panel shown',
509+ })
510+ })
511+
512+ test('checkpointing off by variable: edits open nothing', async ($, on) => {
513+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
514+ env: { CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING: 'true' },
515+ })
516+
517+ on('tool.call', () => ({ result: 'edited' }))
518+
519+ await $.session.start(Fixtures.SESSION)
520+ await $.ui.render(Fixtures.HINT)
521+
522+ await $.tool.call({
523+ tool: 'Edit',
524+ file_path: '/work/app.ts',
525+ old_string: '1',
526+ new_string: '2',
527+ })
528+
529+ await world.clock.advance(Fixtures.SETTLE_MS)
530+
531+ expect(world.opened).toEqual([])
532+ })
533+
534+ test('an open left waiting is withdrawn; /diff opens it', async ($, on) => {
535+ let isNarrow = true
536+
537+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
538+ stored: { [Names.STORE_OPEN_KEY]: true },
539+ isLeftWaiting: () => isNarrow,
540+ })
541+
542+ const edit = () =>
543+ $.tool.call({
544+ tool: 'Edit',
545+ file_path: '/work/app.ts',
546+ old_string: '1',
547+ new_string: '2',
548+ })
549+
550+ on('tool.call', () => ({ result: 'edited' }))
551+
552+ await $.session.start(Fixtures.SESSION)
553+ await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
554+ await edit()
555+ await world.clock.advance(Fixtures.SETTLE_MS)
556+
557+ expect(
558+ world.waiting.map(pane => pane.id),
559+ 'the edit opened',
560+ ).toEqual(['diff'])
561+
562+ expect(
563+ world.closed.map(pane => pane.id),
564+ 'and withdrew the pane the engine left waiting, so no resize seats it',
565+ ).toEqual(['diff'])
566+
567+ expect(
568+ world.runs.map(run => Fixtures.gitWordOf(run.argv)),
569+ 'the repository was found, nothing fetched for a pane no one sees',
570+ ).toEqual(['rev-parse --show-toplevel'])
571+
572+ isNarrow = false
573+
574+ expect(
575+ await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)),
576+ '/diff opens it, where a pane believed open would have been hidden',
577+ ).toEqual({ text: 'Diff panel shown' })
578+
579+ await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
580+ await edit()
581+ await world.clock.advance(Fixtures.SETTLE_MS)
582+
583+ expect(
584+ world.opened.map(pane => pane.id),
585+ 'closed by hand, no edit opens it again',
586+ ).toEqual(['diff'])
587+ })
588+
589+ test('an unplaced open is tried again at the next edit', async ($, on) => {
590+ let isNarrow = true
591+
592+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
593+ stored: { [Names.STORE_OPEN_KEY]: true },
594+ isLeftWaiting: () => isNarrow,
595+ })
596+
597+ const edit = () =>
598+ $.tool.call({
599+ tool: 'Edit',
600+ file_path: '/work/app.ts',
601+ old_string: '1',
602+ new_string: '2',
603+ })
604+
605+ on('tool.call', () => ({ result: 'edited' }))
606+
607+ await $.session.start(Fixtures.SESSION)
608+ await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
609+ await edit()
610+ await world.clock.advance(Fixtures.SETTLE_MS)
611+
612+ expect(world.opened, 'the first try waited and was withdrawn').toEqual([])
613+
614+ isNarrow = false
615+
616+ await edit()
617+ await world.clock.advance(Fixtures.SETTLE_MS)
618+
619+ expect(
620+ world.opened.map(pane => pane.id),
621+ 'the next edit opened it, as the built-in reads the width again then',
622+ ).toEqual(['diff'])
623+ })
624+
451625 test('/clear closes the pane it finds open', async ($, on) => {
452626 const world = Fixtures.inRepository(on)
453627
@@ -528,7 +702,7 @@
528702
529703 test('a worktree opens on the base it kept', async ($, on) => {
530704 const world = Fixtures.inRepository(on, Fixtures.oneSecret(), {
531- 'base:/main/wt': 'uncommitted',
705+ stored: { 'base:/main/wt': 'uncommitted' },
532706 })
533707
534708 await $.session.start(Fixtures.WORKTREE_SESSION)