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 resumed session with edits opens the pane before any new edit, /clear leaves it up, and the session line follows the engine's startBrowse files
poteat authored on Sep 19, 2026 parent bf7d404 · commit de3ae18044c1
11 changed files+302 -24compared with bf7d404
Mmods/diff/README.md
Mmods/diff/hooks/host/host.ts
Mmods/diff/hooks/register.ts
Mmods/diff/tests/fixtures/beneath/beneath.ts
Amods/diff/tests/fixtures/edited-transcript.ts
Mmods/diff/tests/fixtures/in-repository.ts
Mmods/diff/tests/fixtures/index.ts
Amods/diff/tests/fixtures/resume.ts
Amods/diff/tests/fixtures/usage-at.ts
Mmods/diff/tests/git.test.ts
Mmods/diff/tests/register.test.ts
@@ -23,7 +23,9 @@
2323 itself. A docked pane fetches before it opens, as the built-in panel
2424 primes its data, so it never lands on `Loading diff…`; an open the engine
2525 leaves waiting undrawn is withdrawn, so no later resize seats it, and the
26-next edit asks again.
26+next edit asks again. A session resumed or continued whose transcript
27+already holds such an edit opens the pane on the same terms as soon as the
28+width is known, as the built-in opens on the history it restores.
2729
2830 Under the fullscreen layout a terminal under 110 columns gets the
2931 built-in's line asking for a wider one and nothing opens. Without that
@@ -39,7 +41,9 @@
3941 file: its hunks ride the next prompt as context, once.
4042
4143 The pane compares the working tree against HEAD, split at the session's
42-start (the default), against HEAD plainly, or against the merge-base with
44+start (the default; the start the engine gives in `$.session.usage()`, so a
45+resumed session keeps its first and `/clear` begins anew), against HEAD
46+plainly, or against the merge-base with
4347 the default branch; the base line under the header names a base other than
4448 the session's, and the choice is kept per repository in the plugin's store.
4549 A picker shows one earlier turn's edits instead of the working tree, read
@@ -65,7 +69,7 @@
6569
6670 | event | what the hook does |
6771 | --- | --- |
68-| `session.start` | Binds the engine once and registers `/diff` (a session where another `/diff` is listed leaves the plugin idle); asks nothing of the repository, which `/diff` or the first edit pins when it comes. |
72+| `session.start` | Binds the engine once and registers `/diff` (a session where another `/diff` is listed leaves the plugin idle); asks nothing of the repository, which `/diff` or the first edit pins when it comes; off its dispatch, reads the transcript, and for a resumed session whose turns edited opens the pane as the first edit would. |
6973 | `ui.render` of `PromptHint` | Reads the terminal's width and whether its layout docks a pane, which decide whether the first edit opens the pane. |
7074 | `ui.render` of `Pane` | Draws the pane: docked, the header, base line, source picker, file list and toggles over the window of hunks; inline, the dialog. |
7175 | `command.run` of `diff` | Pins the repository when none is, opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. |
@@ -111,6 +111,14 @@
111111 sessionId: () => Promise<string>
112112
113113 /**
114+ * When the session began, `$.session.usage`'s `startedAt`: where the line
115+ * between this session's edits and earlier ones falls, a resumed session's
116+ * first start, moved by `/clear`. Not a number under an engine that
117+ * predates it, where the plugin's own start stands in.
118+ */
119+ startedAt: () => Promise<unknown>
120+
121+ /**
114122 * `$.telemetry.mark`; rejects where the telemetry built-in is absent.
115123 */
116124 mark: EngineInterface['telemetry']['mark']
@@ -26,6 +26,7 @@
2626 import PaneToggle from './pane-toggle'
2727 import Record from './record'
2828 import Tools from './tools'
29+import Turns from './turns'
2930 import Views from './views'
3031
3132 /**
@@ -33,8 +34,11 @@
3334 * pane's drawing and refresh, its opening on Claude's first edit, the ask.
3435 *
3536 * Git runs when the built-in's would: `session.start` binds the host and
36- * registers `/diff`; `/diff` or the main loop's first checkpointed edit with
37- * room pins the backend, until `/clear`; a docked pane fetches, then opens.
37+ * registers `/diff`, and off its dispatch reads the transcript, so a resumed
38+ * session whose turns already edited opens as its first edit would; `/diff`
39+ * or the main loop's first checkpointed edit with room pins the backend,
40+ * until `/clear`, which reads afresh under a pane it leaves open; a docked
41+ * pane fetches, then opens.
3842 *
3943 * @param on the engine's registrar
4044 */
@@ -46,6 +50,7 @@
4650 let isPaneOpen = false
4751 let dialogRows: number | null = null
4852 let hasAutoOpened = false
53+ let hasRestoredEdits = false
4954 let columns: number | null = null
5055 let shownSessionId: string | null = null
5156 let armed: Ask.ArmedAsk | null = null
@@ -500,6 +505,16 @@
500505 hasAutoOpened = await openPane(engine, 'auto_open')
501506 }
502507
508+ async function openOnRestore(engine: Host): Promise<void> {
509+ const messages = await engine.messages().catch((): SessionMessage[] => [])
510+
511+ hasRestoredEdits = Turns.turnDiffsOf(messages).length > 0
512+
513+ if (hasRestoredEdits) {
514+ await openOnFirstEdit(engine)
515+ }
516+ }
517+
503518 function disarm(engine: Host) {
504519 armed = null
505520 model = { ...model, armedPath: null }
@@ -604,8 +619,14 @@
604619 redraw(engine)
605620 }
606621
622+ async function startedAtOf(engine: Host): Promise<number | null> {
623+ const startedAt: unknown = await engine.startedAt().catch(() => undefined)
624+
625+ return typeof startedAt === 'number' ? startedAt : null
626+ }
627+
607628 async function bind(engine: Host, cwd: string): Promise<void> {
608- sessionStartMs = await engine.now()
629+ sessionStartMs = (await startedAtOf(engine)) ?? (await engine.now())
609630 pin.cwd = cwd
610631
611632 try {
@@ -645,12 +666,22 @@
645666 closePane: pane => $.ui.close(pane),
646667 registerCommand: spec => $.command.register(spec),
647668 sessionId: () => $.session.id(),
669+ startedAt: () =>
670+ $.session
671+ .usage()
672+ .then((usage: unknown) =>
673+ isRecord(usage) ? usage.startedAt : undefined,
674+ ),
648675 mark: entry => $.telemetry.mark(entry),
649676 log: entry => $.telemetry.log(entry),
650677 },
651678 e.cwd,
652679 )
653680
681+ if (host) {
682+ void openOnRestore(host).catch(() => undefined)
683+ }
684+
654685 return next(e)
655686 })
656687
@@ -659,12 +690,18 @@
659690 const viewport: { columns?: number; isFullscreen?: boolean } | undefined =
660691 e.viewport
661692
693+ const isFirstMeasure = columns === null && viewport?.columns !== undefined
694+
662695 columns = viewport?.columns ?? columns
663696
664697 model = {
665698 ...model,
666699 isFullscreen: viewport?.isFullscreen ?? model.isFullscreen,
667700 }
701+
702+ if (isFirstMeasure && hasRestoredEdits && host) {
703+ void openOnFirstEdit(host).catch(() => undefined)
704+ }
668705 }
669706
670707 return next(e)
@@ -841,20 +878,39 @@
841878 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
842879 const result = await next(e)
843880
844- if (host) {
845- if (isPaneOpen) {
846- await closePane(host).catch(() => undefined)
847- }
881+ if (!host) {
882+ return result
883+ }
848884
849- unpin()
850- hasAutoOpened = false
851- bodyStamp = null
852- bodyBase = null
853- bodyLoads.clear()
854- disarm(host)
855- model = PaneState.afterNewSession(model)
885+ const isResume = e.command === 'resume'
886+ const isKeptOpen = isPaneOpen && !isResume
887+
888+ if (isPaneOpen && isResume) {
889+ await closePane(host).catch(() => undefined)
856890 }
857891
892+ unpin()
893+ hasAutoOpened = false
894+ hasRestoredEdits = false
895+ bodyStamp = null
896+ bodyBase = null
897+ bodyLoads.clear()
898+ disarm(host)
899+ model = PaneState.afterNewSession(model)
900+
901+ sessionStartMs =
902+ (await startedAtOf(host)) ??
903+ (isResume ? sessionStartMs : await host.now())
904+
905+ if (isKeptOpen) {
906+ await pinBackend(host)
907+ void refresh(host)
908+ }
909+
910+ if (isResume) {
911+ void openOnRestore(host).catch(() => undefined)
912+ }
913+
858914 return result
859915 })
860916
@@ -1,8 +1,9 @@
1-import type { Settings } from 'claude-code'
1+import type { SessionMessage, Settings } from 'claude-code'
22
33 /**
44 * What the world beneath a repository session holds and answers besides
5- * git: the store, the settings, the environment, whether a pane is seated.
5+ * git: the store, the settings, the environment, the transcript, whether a
6+ * pane is seated.
67 */
78 export type Beneath = {
89 /**
@@ -21,6 +22,12 @@
2122 env?: Readonly<Record<string, string>>
2223
2324 /**
25+ * What `$.session.messages` answers each time it is read, as a resumed
26+ * session's transcript already holds turns; empty when not given.
27+ */
28+ messages?: () => readonly SessionMessage[]
29+
30+ /**
2431 * Whether an open made now is left waiting undrawn (LEFT_WAITING), as an
2532 * engine leaves an unasked open on a narrow terminal; placed when not given.
2633 */
@@ -1,0 +1,14 @@
1+import type { SessionMessage } from 'claude-code'
2+
3+import { editPatchOf } from './edit-patch-of.js'
4+import TurnDiffsOf from './turn-diffs-of'
5+
6+/**
7+ * A transcript a resumed session brings back whose one turn edited a file:
8+ * the prompt, the Edit that landed, its result.
9+ */
10+export const EDITED_TRANSCRIPT: readonly SessionMessage[] = [
11+ TurnDiffsOf.promptOf('change a to b'),
12+ TurnDiffsOf.editsOf(editPatchOf(['-a', '+b'])),
13+ TurnDiffsOf.toolResults(),
14+]
@@ -18,7 +18,8 @@
1818 *
1919 * @param on the test's `on`
2020 * @param script git's output for each invocation whose line holds the key
21- * @param beneath the store, settings, environment, whether an open is seated
21+ * @param beneath the store, settings, environment, transcript, whether an
22+ * open is seated
2223 * @returns the runs, the ring's moves, the panes opened, left waiting and
2324 * closed, the statuses, the clock
2425 */
@@ -68,7 +69,7 @@
6869 on('ui.close', closed.hook)
6970 on('ui.invalidate', () => ({ value: undefined }))
7071 on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN)
71- on('session.messages', () => ({ value: [] }))
72+ on('session.messages', () => ({ value: [...(beneath.messages?.() ?? [])] }))
7273 on('settings.read', () => ({ value: beneath.settings ?? {} }))
7374 mock.store(on, beneath.stored ?? {})
7475 mock.env(on, beneath.env ?? {})
@@ -13,6 +13,7 @@
1313 export * from './diff.js'
1414 export * from './diff-at.js'
1515 export * from './edit-patch-of.js'
16+export * from './edited-transcript.js'
1617 export * from './fetch-diff'
1718 export * from './git-hung.js'
1819 export * from './git-in.js'
@@ -42,6 +43,7 @@
4243 export * from './renamed.js'
4344 export * from './repository.js'
4445 export * from './repository-of'
46+export * from './resume.js'
4547 export * from './ring-onto.js'
4648 export * from './scripted'
4749 export * from './session.js'
@@ -54,6 +56,7 @@
5456 export * from './two-files.js'
5557 export * from './typed-prompt-of.js'
5658 export * from './unsaid-hint.js'
59+export * from './usage-at.js'
5760 export * from './vs-main.js'
5861 export * from './wheel-over-list.js'
5962 export * from './wheel-tick.js'
@@ -1,0 +1,12 @@
1+import type { CommandRunInput } from 'claude-code'
2+
3+/**
4+ * The command that brings an earlier conversation back, as the person types
5+ * it under the fullscreen layout on a 160-column terminal.
6+ */
7+export const RESUME: CommandRunInput = {
8+ command: 'resume',
9+ args: '',
10+ origin: { kind: 'composer' },
11+ presentation: { isFullscreen: true, columns: 160 },
12+}
@@ -1,0 +1,14 @@
1+/**
2+ * What `$.session.usage()` answers for a session that began at the given
3+ * moment: an empty window, no rate limits, nothing spent.
4+ *
5+ * Left untyped, so it compiles against declarations that predate `startedAt`.
6+ *
7+ * @param startedAt when the session began, by the test's clock
8+ * @returns the usage
9+ */
10+export const usageAt = (startedAt: number) => ({
11+ startedAt,
12+ context: { window: 200_000 },
13+ rateLimits: [],
14+})
@@ -94,4 +94,42 @@
9494 expect(drawn).toContain('moved.ts')
9595 expect(drawn).toContain('+1 file edited before this session (show)')
9696 })
97+
98+ test("the session began when the engine says: a resumed one's first start, again at /clear", async ($, on) => {
99+ let startedAt = 0
100+
101+ const clock = Fixtures.startsSession(on, Fixtures.SETTLE_MS)
102+
103+ on('process.run', ($, e) => ({
104+ value: Fixtures.gitIn(e.argv, Fixtures.MOVED_IN),
105+ }))
106+
107+ on('ui.open', () => ({ value: undefined }))
108+ on('ui.invalidate', () => ({ value: undefined }))
109+ on('session.messages', () => ({ value: [] }))
110+ on('session.usage', () => ({ value: Fixtures.usageAt(startedAt) }))
111+ on('command.run', { command: 'clear' }, () => ({}))
112+ Fixtures.oldFiles(on)
113+ mock.store(on)
114+
115+ await $.session.start(Fixtures.SESSION)
116+ await $.command.run(Fixtures.DIFF)
117+ await clock.advance(Fixtures.SETTLE_MS)
118+
119+ expect(
120+ Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
121+ 'resumed from before the old files were written: both are the ' +
122+ "session's, as the built-in restores its start",
123+ ).toContain('2 files changed +2 -2')
124+
125+ startedAt = clock.now()
126+
127+ await $.command.run(Fixtures.CLEAR)
128+ await clock.advance(Fixtures.SETTLE_MS)
129+
130+ expect(
131+ Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
132+ '/clear started the session over, past the dirty file',
133+ ).toContain('+1 file edited before this session (show)')
134+ })
97135 })
@@ -1,4 +1,4 @@
1-import type { Args, ResultOf } from 'claude-code'
1+import type { Args, ResultOf, SessionMessage } from 'claude-code'
22 import { describe, expect, mock, test, tier } from 'claude-code/testing'
33
44 import Limits from '../hooks/limits'
@@ -681,18 +681,139 @@
681681 expect(drawn).not.toContain('Loading diff')
682682 })
683683
684- test('/clear closes the pane it finds open', async ($, on) => {
684+ test('/clear leaves the pane it finds open up and reads the repository afresh', async ($, on) => {
685685 const world = Fixtures.inRepository(on)
686686
687687 on('command.run', { command: 'clear' }, () => ({}))
688688
689689 await $.session.start(Fixtures.SESSION)
690690 await $.command.run(Fixtures.DIFF)
691+ await world.clock.advance(Fixtures.SETTLE_MS)
692+
693+ const read = world.runs.length
694+
691695 await $.command.run(Fixtures.CLEAR)
696+ await world.clock.advance(Fixtures.SETTLE_MS)
692697
693- expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
698+ expect(world.closed, 'as the built-in panel stays across /clear').toEqual(
699+ [],
700+ )
701+
702+ expect(
703+ world.runs.slice(read).map(run => Fixtures.gitWordOf(run.argv))[0],
704+ 'the repository found again for the conversation that starts over',
705+ ).toBe('rev-parse --show-toplevel')
706+
707+ expect(
708+ Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
709+ 'drawn from the fresh read',
710+ ).toContain('1 file changed')
694711 })
695712
713+ test('a resumed session whose turns edited opens the pane before any new edit', async ($, on) => {
714+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
715+ messages: () => Fixtures.EDITED_TRANSCRIPT,
716+ })
717+
718+ await $.session.start(Fixtures.SESSION)
719+
720+ expect(world.runs, 'the start itself still runs no git').toEqual([])
721+
722+ await $.ui.render(Fixtures.HINT)
723+ await world.clock.advance(Fixtures.SETTLE_MS)
724+
725+ expect(
726+ world.opened.map(pane => pane.id),
727+ 'open once the width is known, as the built-in opens on the restore',
728+ ).toEqual(['diff'])
729+ })
730+
731+ test('a resumed session opens nothing where its first edit would not', async ($, on) => {
732+ const narrow = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
733+ messages: () => Fixtures.EDITED_TRANSCRIPT,
734+ })
735+
736+ await $.session.start(Fixtures.SESSION)
737+ await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
738+ await narrow.clock.advance(Fixtures.SETTLE_MS)
739+
740+ expect(narrow.opened, 'under the unasked floor, never kept open').toEqual(
741+ [],
742+ )
743+
744+ expect(narrow.runs, 'and no git for it').toEqual([])
745+ })
746+
747+ test('a resumed session the person kept the pane open in opens it from the lower floor', async ($, on) => {
748+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
749+ messages: () => Fixtures.EDITED_TRANSCRIPT,
750+ stored: { [Names.STORE_OPEN_KEY]: true },
751+ })
752+
753+ await $.session.start(Fixtures.SESSION)
754+ await $.ui.render(Fixtures.hintAt(Limits.OPEN_MIN_COLUMNS))
755+ await world.clock.advance(Fixtures.SETTLE_MS)
756+
757+ expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
758+ })
759+
760+ test('a resumed session with no edits opens nothing', async ($, on) => {
761+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
762+ messages: () => [Fixtures.promptOf('just talk')],
763+ })
764+
765+ await $.session.start(Fixtures.SESSION)
766+ await $.ui.render(Fixtures.HINT)
767+ await world.clock.advance(Fixtures.SETTLE_MS)
768+
769+ expect(world.opened).toEqual([])
770+ expect(world.runs).toEqual([])
771+ })
772+
773+ test('a resumed session whose pane the person closed opens nothing', async ($, on) => {
774+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
775+ messages: () => Fixtures.EDITED_TRANSCRIPT,
776+ stored: { [Names.STORE_OPEN_KEY]: false },
777+ })
778+
779+ await $.session.start(Fixtures.SESSION)
780+ await $.ui.render(Fixtures.HINT)
781+ await world.clock.advance(Fixtures.SETTLE_MS)
782+
783+ expect(world.opened).toEqual([])
784+ expect(world.runs).toEqual([])
785+ })
786+
787+ test('/resume closes the pane, then opens it for the turns it brought back', async ($, on) => {
788+ let transcript: readonly SessionMessage[] = []
789+
790+ const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
791+ messages: () => transcript,
792+ })
793+
794+ on('command.run', { command: 'resume' }, () => {
795+ transcript = Fixtures.EDITED_TRANSCRIPT
796+
797+ return {}
798+ })
799+
800+ await $.session.start(Fixtures.SESSION)
801+ await $.ui.render(Fixtures.HINT)
802+ await $.command.run(Fixtures.DIFF)
803+ await world.clock.advance(Fixtures.SETTLE_MS)
804+ await $.command.run(Fixtures.RESUME)
805+ await world.clock.advance(Fixtures.SETTLE_MS)
806+
807+ expect(world.closed.map(pane => pane.id), 'closed at /resume').toEqual([
808+ 'diff',
809+ ])
810+
811+ expect(
812+ world.opened.map(pane => pane.id),
813+ '/diff opened it, /resume opened it again for the restored turns',
814+ ).toEqual(['diff', 'diff'])
815+ })
816+
696817 test('ask attaches the hunks on screen and calls no tool', async ($, on) => {
697818 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
698819 const called: Args<'tool.call'>[] = []