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
| @@ -46,7 +46,7 @@ | ||
| 46 | 46 | or the first edit a pane has room to open on first needs the repository |
| 47 | 47 | (an answer of no repository is kept too, until `/clear` or `/resume` |
| 48 | 48 | 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 | |
| 50 | 50 | read the built-in has no counterpart for is a `git status` at a pane's |
| 51 | 51 | first fetch, which stands in for the change time the built-in dates a |
| 52 | 52 | moved file by. |
| @@ -65,8 +65,8 @@ | ||
| 65 | 65 | | `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. | |
| 66 | 66 | | `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. | |
| 67 | 67 | | `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. | | |
| 70 | 70 | | `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. | |
| 71 | 71 | |
| 72 | 72 | ## 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' | |
| 2 | 8 | |
| 3 | 9 | import Ask from './ask' |
| 4 | 10 | import Backend from './backend' |
| @@ -50,7 +56,7 @@ | ||
| 50 | 56 | const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>() |
| 51 | 57 | |
| 52 | 58 | const polled = { toplevel: '', headKey: '' } |
| 53 | - const pin = { cwd: '', isEmpty: false } | |
| 59 | + const pin = { cwd: '', isEmpty: false, epoch: 0 } | |
| 54 | 60 | |
| 55 | 61 | let model: PaneState.PaneModel = PaneState.INITIAL_MODEL |
| 56 | 62 | |
| @@ -108,6 +114,7 @@ | ||
| 108 | 114 | async function probeBackend(engine: Host): Promise<boolean> { |
| 109 | 115 | const asked = { isAnswered: true } |
| 110 | 116 | const probeHost = backendHostOf(engine) |
| 117 | + const { epoch } = pin | |
| 111 | 118 | |
| 112 | 119 | const probed = await Backend.backendOf( |
| 113 | 120 | { |
| @@ -123,6 +130,10 @@ | ||
| 123 | 130 | Backend.INSTALLED_BACKEND_PROBES, |
| 124 | 131 | ) |
| 125 | 132 | |
| 133 | + if (epoch !== pin.epoch) { | |
| 134 | + return false | |
| 135 | + } | |
| 136 | + | |
| 126 | 137 | backend ??= probed |
| 127 | 138 | pin.isEmpty = backend === null && asked.isAnswered |
| 128 | 139 | |
| @@ -151,6 +162,7 @@ | ||
| 151 | 162 | function unpin() { |
| 152 | 163 | backend = null |
| 153 | 164 | pin.isEmpty = false |
| 165 | + pin.epoch += 1 | |
| 154 | 166 | polled.toplevel = '' |
| 155 | 167 | polled.headKey = '' |
| 156 | 168 | timers.get('poll')?.cancel() |
| @@ -796,25 +808,47 @@ | ||
| 796 | 808 | return result |
| 797 | 809 | }) |
| 798 | 810 | |
| 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 | + | |
| 799 | 837 | on( |
| 800 | 838 | 'tool.call', |
| 801 | 839 | { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] }, |
| 802 | 840 | async ($, e, next) => { |
| 803 | - const result = await next(e) | |
| 841 | + let result: ResultOf['tool.call'] | undefined | |
| 804 | 842 | |
| 805 | - if (host && !('deny' in result)) { | |
| 806 | - if (isPaneOpen) { | |
| 807 | - scheduleRefresh(host) | |
| 808 | - } | |
| 843 | + try { | |
| 844 | + result = await next(e) | |
| 809 | 845 | |
| 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) | |
| 814 | 850 | } |
| 815 | 851 | } |
| 816 | - | |
| 817 | - return result | |
| 818 | 852 | }, |
| 819 | 853 | ) |
| 820 | 854 |
| @@ -1,4 +1,4 @@ | ||
| 1 | -import type { Args } from 'claude-code' | |
| 1 | +import type { Args, ResultOf } from 'claude-code' | |
| 2 | 2 | import { describe, expect, mock, test, tier } from 'claude-code/testing' |
| 3 | 3 | |
| 4 | 4 | import Limits from '../hooks/limits' |
| @@ -348,6 +348,42 @@ | ||
| 348 | 348 | expect(world.opened.map(pane => pane.id)).toEqual(['diff']) |
| 349 | 349 | }) |
| 350 | 350 | |
| 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 | + | |
| 351 | 387 | test('/clear closes the pane it finds open', async ($, on) => { |
| 352 | 388 | const world = Fixtures.inRepository(on) |
| 353 | 389 |