Skip to content

Commit 7974a70

Browse files
authored
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 start (#95587)
1 parent 4564326 commit 7974a70

11 files changed

Lines changed: 302 additions & 24 deletions

File tree

‎mods/diff/README.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ opens nothing, and where the surface does not say, nothing opens by
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 @@ dismissed`; toasts are held while it is up. A file's ask button arms that
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 @@ moved file by.
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. |

‎mods/diff/hooks/host/host.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,14 @@ export type Host = {
110110
*/
111111
sessionId: () => Promise<string>
112112

113+
/**
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+
113121
/**
114122
* `$.telemetry.mark`; rejects where the telemetry built-in is absent.
115123
*/

‎mods/diff/hooks/register.ts‎

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,19 @@ import PaneState from './pane-state'
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
/**
3233
* Registers the diff pane: `/diff` once the built-in stands down, the
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 @@ export function register(on: On) {
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 @@ export function register(on: On) {
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 @@ export function register(on: On) {
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 @@ export function register(on: On) {
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 @@ export function register(on: On) {
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,18 +878,37 @@ export function register(on: On) {
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)
890+
}
891+
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)
856912
}
857913

858914
return result

‎mods/diff/tests/fixtures/beneath/beneath.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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
/**
@@ -20,6 +21,12 @@ export type Beneath = {
2021
*/
2122
env?: Readonly<Record<string, string>>
2223

24+
/**
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+
2330
/**
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.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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+
]

‎mods/diff/tests/fixtures/in-repository.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import { startsSession } from './starts-session.js'
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 @@ export function inRepository(
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 ?? {})

‎mods/diff/tests/fixtures/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export * from './dialog-diff.js'
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 @@ export * from './poll-word.js'
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 @@ export * from './turn-diffs-of'
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'

‎mods/diff/tests/fixtures/resume.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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+
})

‎mods/diff/tests/git.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,42 @@ describe('git', () => {
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
})

0 commit comments

Comments
 (0)