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: a shell command that failed or was cut short still refreshes an open pane, an edit that did opens nothing, and a probe /clear overtook pins nothingBrowse files
poteat authored on Sep 15, 2026 parent 21993fa · commit 8a4ad5455967
3 changed files+87 -17compared with 21993fa
Mmods/diff/README.md
Mmods/diff/hooks/register.ts
Mmods/diff/tests/register.test.ts
@@ -46,7 +46,7 @@
4646 or the first edit a pane has room to open on first needs the repository
4747 (an answer of no repository is kept too, until `/clear` or `/resume`
4848 forgets it); and the working tree is read only by a fetch for a pane that
49-is open, after an edit or a shell command that was not refused. The one
49+is open, after an edit that landed or a shell command that ran. The one
5050 read the built-in has no counterpart for is a `git status` at a pane's
5151 first fetch, which stands in for the change time the built-in dates a
5252 moved file by.
@@ -65,8 +65,8 @@
6565 | `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. |
6666 | `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. |
6767 | `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state, the pinned repository with it. |
68-| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that was not refused, refreshes an open pane; the session's first successful edit opens it, pinning the repository then if the terminal has the room. |
69-| `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, refreshes an open pane. |
68+| `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. |
69+| `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, failed and interrupted ones too, refreshes an open pane. |
7070 | `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. |
7171
7272 ## What it calls on `$`
@@ -1,4 +1,10 @@
1-import type { On, PaneOpenArgs, SessionMessage, Timer } from 'claude-code'
1+import type {
2+ On,
3+ PaneOpenArgs,
4+ ResultOf,
5+ SessionMessage,
6+ Timer,
7+} from 'claude-code'
28
39 import Ask from './ask'
410 import Backend from './backend'
@@ -50,7 +56,7 @@
5056 const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>()
5157
5258 const polled = { toplevel: '', headKey: '' }
53- const pin = { cwd: '', isEmpty: false }
59+ const pin = { cwd: '', isEmpty: false, epoch: 0 }
5460
5561 let model: PaneState.PaneModel = PaneState.INITIAL_MODEL
5662
@@ -108,6 +114,7 @@
108114 async function probeBackend(engine: Host): Promise<boolean> {
109115 const asked = { isAnswered: true }
110116 const probeHost = backendHostOf(engine)
117+ const { epoch } = pin
111118
112119 const probed = await Backend.backendOf(
113120 {
@@ -123,6 +130,10 @@
123130 Backend.INSTALLED_BACKEND_PROBES,
124131 )
125132
133+ if (epoch !== pin.epoch) {
134+ return false
135+ }
136+
126137 backend ??= probed
127138 pin.isEmpty = backend === null && asked.isAnswered
128139
@@ -151,6 +162,7 @@
151162 function unpin() {
152163 backend = null
153164 pin.isEmpty = false
165+ pin.epoch += 1
154166 polled.toplevel = ''
155167 polled.headKey = ''
156168 timers.get('poll')?.cancel()
@@ -796,25 +808,47 @@
796808 return result
797809 })
798810
811+ function afterTool(
812+ engine: Host,
813+ tool: string,
814+ result: ResultOf['tool.call'] | undefined,
815+ ) {
816+ const isEdit = Tools.EDITING_TOOLS.some(name => name === tool)
817+
818+ const hasEdited =
819+ isEdit &&
820+ result !== undefined &&
821+ result.deny === undefined &&
822+ result.isError !== true
823+
824+ const isStale =
825+ isPaneOpen &&
826+ (isEdit ? hasEdited : result === undefined || result.deny === undefined)
827+
828+ if (isStale) {
829+ scheduleRefresh(engine)
830+ }
831+
832+ if (hasEdited) {
833+ void openOnFirstEdit(engine).catch(() => undefined)
834+ }
835+ }
836+
799837 on(
800838 'tool.call',
801839 { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] },
802840 async ($, e, next) => {
803- const result = await next(e)
841+ let result: ResultOf['tool.call'] | undefined
804842
805- if (host && !('deny' in result)) {
806- if (isPaneOpen) {
807- scheduleRefresh(host)
808- }
843+ try {
844+ result = await next(e)
809845
810- const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool)
811-
812- if (isEdit) {
813- void openOnFirstEdit(host).catch(() => undefined)
846+ return result
847+ } finally {
848+ if (host) {
849+ afterTool(host, e.tool, result)
814850 }
815851 }
816-
817- return result
818852 },
819853 )
820854
@@ -1,4 +1,4 @@
1-import type { Args } from 'claude-code'
1+import type { Args, ResultOf } from 'claude-code'
22 import { describe, expect, mock, test, tier } from 'claude-code/testing'
33
44 import Limits from '../hooks/limits'
@@ -348,6 +348,42 @@
348348 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
349349 })
350350
351+ test('an edit that failed or was refused opens nothing', async ($, on) => {
352+ const world = Fixtures.inRepository(on)
353+
354+ const answers: ResultOf['tool.call'][] = [
355+ { isError: true, result: 'no such text' },
356+ { deny: 'not allowed' },
357+ ]
358+
359+ const edit = () =>
360+ $.tool.call({
361+ tool: 'Edit',
362+ file_path: '/work/app.ts',
363+ old_string: '1',
364+ new_string: '2',
365+ })
366+
367+ on('tool.call', () => answers.shift() ?? { result: 'edited' })
368+
369+ await $.session.start(Fixtures.SESSION)
370+ await $.ui.render(Fixtures.HINT)
371+ await edit()
372+ await edit()
373+ await world.clock.advance(Fixtures.SETTLE_MS)
374+
375+ expect(world.opened, 'neither edit landed').toEqual([])
376+ expect(world.runs, 'so nothing asked after the repository').toEqual([])
377+
378+ await edit()
379+ await world.clock.advance(Fixtures.SETTLE_MS)
380+
381+ expect(
382+ world.opened.map(pane => pane.id),
383+ 'the third did',
384+ ).toEqual(['diff'])
385+ })
386+
351387 test('/clear closes the pane it finds open', async ($, on) => {
352388 const world = Fixtures.inRepository(on)
353389