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

This commit is contained in:
poteat
2026-09-19 12:14:47 -07:00
parent bf7d404e26
commit de3ae18044
11 changed files with 303 additions and 25 deletions
+7 -3
View File
@@ -23,7 +23,9 @@ opens nothing, and where the surface does not say, nothing opens by
itself. A docked pane fetches before it opens, as the built-in panel
primes its data, so it never lands on `Loading diff…`; an open the engine
leaves waiting undrawn is withdrawn, so no later resize seats it, and the
next edit asks again.
next edit asks again. A session resumed or continued whose transcript
already holds such an edit opens the pane on the same terms as soon as the
width is known, as the built-in opens on the history it restores.
Under the fullscreen layout a terminal under 110 columns gets the
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
file: its hunks ride the next prompt as context, once.
The pane compares the working tree against HEAD, split at the session's
start (the default), against HEAD plainly, or against the merge-base with
start (the default; the start the engine gives in `$.session.usage()`, so a
resumed session keeps its first and `/clear` begins anew), against HEAD
plainly, or against the merge-base with
the default branch; the base line under the header names a base other than
the session's, and the choice is kept per repository in the plugin's store.
A picker shows one earlier turn's edits instead of the working tree, read
@@ -65,7 +69,7 @@ moved file by.
| event | what the hook does |
| --- | --- |
| `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. |
| `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. |
| `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. |
| `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. |
| `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. |
+8
View File
@@ -110,6 +110,14 @@ export type Host = {
*/
sessionId: () => Promise<string>
/**
* When the session began, `$.session.usage`'s `startedAt`: where the line
* between this session's edits and earlier ones falls, a resumed session's
* first start, moved by `/clear`. Not a number under an engine that
* predates it, where the plugin's own start stands in.
*/
startedAt: () => Promise<unknown>
/**
* `$.telemetry.mark`; rejects where the telemetry built-in is absent.
*/
+70 -14
View File
@@ -26,6 +26,7 @@ import PaneState from './pane-state'
import PaneToggle from './pane-toggle'
import Record from './record'
import Tools from './tools'
import Turns from './turns'
import Views from './views'
/**
@@ -33,8 +34,11 @@ import Views from './views'
* pane's drawing and refresh, its opening on Claude's first edit, the ask.
*
* Git runs when the built-in's would: `session.start` binds the host and
* registers `/diff`; `/diff` or the main loop's first checkpointed edit with
* room pins the backend, until `/clear`; a docked pane fetches, then opens.
* registers `/diff`, and off its dispatch reads the transcript, so a resumed
* session whose turns already edited opens as its first edit would; `/diff`
* or the main loop's first checkpointed edit with room pins the backend,
* until `/clear`, which reads afresh under a pane it leaves open; a docked
* pane fetches, then opens.
*
* @param on the engine's registrar
*/
@@ -46,6 +50,7 @@ export function register(on: On) {
let isPaneOpen = false
let dialogRows: number | null = null
let hasAutoOpened = false
let hasRestoredEdits = false
let columns: number | null = null
let shownSessionId: string | null = null
let armed: Ask.ArmedAsk | null = null
@@ -500,6 +505,16 @@ export function register(on: On) {
hasAutoOpened = await openPane(engine, 'auto_open')
}
async function openOnRestore(engine: Host): Promise<void> {
const messages = await engine.messages().catch((): SessionMessage[] => [])
hasRestoredEdits = Turns.turnDiffsOf(messages).length > 0
if (hasRestoredEdits) {
await openOnFirstEdit(engine)
}
}
function disarm(engine: Host) {
armed = null
model = { ...model, armedPath: null }
@@ -604,8 +619,14 @@ export function register(on: On) {
redraw(engine)
}
async function startedAtOf(engine: Host): Promise<number | null> {
const startedAt: unknown = await engine.startedAt().catch(() => undefined)
return typeof startedAt === 'number' ? startedAt : null
}
async function bind(engine: Host, cwd: string): Promise<void> {
sessionStartMs = await engine.now()
sessionStartMs = (await startedAtOf(engine)) ?? (await engine.now())
pin.cwd = cwd
try {
@@ -645,12 +666,22 @@ export function register(on: On) {
closePane: pane => $.ui.close(pane),
registerCommand: spec => $.command.register(spec),
sessionId: () => $.session.id(),
startedAt: () =>
$.session
.usage()
.then((usage: unknown) =>
isRecord(usage) ? usage.startedAt : undefined,
),
mark: entry => $.telemetry.mark(entry),
log: entry => $.telemetry.log(entry),
},
e.cwd,
)
if (host) {
void openOnRestore(host).catch(() => undefined)
}
return next(e)
})
@@ -659,12 +690,18 @@ export function register(on: On) {
const viewport: { columns?: number; isFullscreen?: boolean } | undefined =
e.viewport
const isFirstMeasure = columns === null && viewport?.columns !== undefined
columns = viewport?.columns ?? columns
model = {
...model,
isFullscreen: viewport?.isFullscreen ?? model.isFullscreen,
}
if (isFirstMeasure && hasRestoredEdits && host) {
void openOnFirstEdit(host).catch(() => undefined)
}
}
return next(e)
@@ -841,18 +878,37 @@ export function register(on: On) {
on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
const result = await next(e)
if (host) {
if (isPaneOpen) {
await closePane(host).catch(() => undefined)
}
if (!host) {
return result
}
unpin()
hasAutoOpened = false
bodyStamp = null
bodyBase = null
bodyLoads.clear()
disarm(host)
model = PaneState.afterNewSession(model)
const isResume = e.command === 'resume'
const isKeptOpen = isPaneOpen && !isResume
if (isPaneOpen && isResume) {
await closePane(host).catch(() => undefined)
}
unpin()
hasAutoOpened = false
hasRestoredEdits = false
bodyStamp = null
bodyBase = null
bodyLoads.clear()
disarm(host)
model = PaneState.afterNewSession(model)
sessionStartMs =
(await startedAtOf(host)) ??
(isResume ? sessionStartMs : await host.now())
if (isKeptOpen) {
await pinBackend(host)
void refresh(host)
}
if (isResume) {
void openOnRestore(host).catch(() => undefined)
}
return result
+9 -2
View File
@@ -1,8 +1,9 @@
import type { Settings } from 'claude-code'
import type { SessionMessage, Settings } from 'claude-code'
/**
* What the world beneath a repository session holds and answers besides
* git: the store, the settings, the environment, whether a pane is seated.
* git: the store, the settings, the environment, the transcript, whether a
* pane is seated.
*/
export type Beneath = {
/**
@@ -20,6 +21,12 @@ export type Beneath = {
*/
env?: Readonly<Record<string, string>>
/**
* What `$.session.messages` answers each time it is read, as a resumed
* session's transcript already holds turns; empty when not given.
*/
messages?: () => readonly SessionMessage[]
/**
* Whether an open made now is left waiting undrawn (LEFT_WAITING), as an
* engine leaves an unasked open on a narrow terminal; placed when not given.
+14
View File
@@ -0,0 +1,14 @@
import type { SessionMessage } from 'claude-code'
import { editPatchOf } from './edit-patch-of.js'
import TurnDiffsOf from './turn-diffs-of'
/**
* A transcript a resumed session brings back whose one turn edited a file:
* the prompt, the Edit that landed, its result.
*/
export const EDITED_TRANSCRIPT: readonly SessionMessage[] = [
TurnDiffsOf.promptOf('change a to b'),
TurnDiffsOf.editsOf(editPatchOf(['-a', '+b'])),
TurnDiffsOf.toolResults(),
]
+3 -2
View File
@@ -18,7 +18,8 @@ import { startsSession } from './starts-session.js'
*
* @param on the test's `on`
* @param script git's output for each invocation whose line holds the key
* @param beneath the store, settings, environment, whether an open is seated
* @param beneath the store, settings, environment, transcript, whether an
* open is seated
* @returns the runs, the ring's moves, the panes opened, left waiting and
* closed, the statuses, the clock
*/
@@ -68,7 +69,7 @@ export function inRepository(
on('ui.close', closed.hook)
on('ui.invalidate', () => ({ value: undefined }))
on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN)
on('session.messages', () => ({ value: [] }))
on('session.messages', () => ({ value: [...(beneath.messages?.() ?? [])] }))
on('settings.read', () => ({ value: beneath.settings ?? {} }))
mock.store(on, beneath.stored ?? {})
mock.env(on, beneath.env ?? {})
+3
View File
@@ -13,6 +13,7 @@ export * from './dialog-diff.js'
export * from './diff.js'
export * from './diff-at.js'
export * from './edit-patch-of.js'
export * from './edited-transcript.js'
export * from './fetch-diff'
export * from './git-hung.js'
export * from './git-in.js'
@@ -42,6 +43,7 @@ export * from './poll-word.js'
export * from './renamed.js'
export * from './repository.js'
export * from './repository-of'
export * from './resume.js'
export * from './ring-onto.js'
export * from './scripted'
export * from './session.js'
@@ -54,6 +56,7 @@ export * from './turn-diffs-of'
export * from './two-files.js'
export * from './typed-prompt-of.js'
export * from './unsaid-hint.js'
export * from './usage-at.js'
export * from './vs-main.js'
export * from './wheel-over-list.js'
export * from './wheel-tick.js'
+12
View File
@@ -0,0 +1,12 @@
import type { CommandRunInput } from 'claude-code'
/**
* The command that brings an earlier conversation back, as the person types
* it under the fullscreen layout on a 160-column terminal.
*/
export const RESUME: CommandRunInput = {
command: 'resume',
args: '',
origin: { kind: 'composer' },
presentation: { isFullscreen: true, columns: 160 },
}
+14
View File
@@ -0,0 +1,14 @@
/**
* What `$.session.usage()` answers for a session that began at the given
* moment: an empty window, no rate limits, nothing spent.
*
* Left untyped, so it compiles against declarations that predate `startedAt`.
*
* @param startedAt when the session began, by the test's clock
* @returns the usage
*/
export const usageAt = (startedAt: number) => ({
startedAt,
context: { window: 200_000 },
rateLimits: [],
})
+38
View File
@@ -94,4 +94,42 @@ describe('git', () => {
expect(drawn).toContain('moved.ts')
expect(drawn).toContain('+1 file edited before this session (show)')
})
test("the session began when the engine says: a resumed one's first start, again at /clear", async ($, on) => {
let startedAt = 0
const clock = Fixtures.startsSession(on, Fixtures.SETTLE_MS)
on('process.run', ($, e) => ({
value: Fixtures.gitIn(e.argv, Fixtures.MOVED_IN),
}))
on('ui.open', () => ({ value: undefined }))
on('ui.invalidate', () => ({ value: undefined }))
on('session.messages', () => ({ value: [] }))
on('session.usage', () => ({ value: Fixtures.usageAt(startedAt) }))
on('command.run', { command: 'clear' }, () => ({}))
Fixtures.oldFiles(on)
mock.store(on)
await $.session.start(Fixtures.SESSION)
await $.command.run(Fixtures.DIFF)
await clock.advance(Fixtures.SETTLE_MS)
expect(
Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
'resumed from before the old files were written: both are the ' +
"session's, as the built-in restores its start",
).toContain('2 files changed +2 -2')
startedAt = clock.now()
await $.command.run(Fixtures.CLEAR)
await clock.advance(Fixtures.SETTLE_MS)
expect(
Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
'/clear started the session over, past the dirty file',
).toContain('+1 file edited before this session (show)')
})
})
+125 -4
View File
@@ -1,4 +1,4 @@
import type { Args, ResultOf } from 'claude-code'
import type { Args, ResultOf, SessionMessage } from 'claude-code'
import { describe, expect, mock, test, tier } from 'claude-code/testing'
import Limits from '../hooks/limits'
@@ -681,16 +681,137 @@ describe('register', () => {
expect(drawn).not.toContain('Loading diff')
})
test('/clear closes the pane it finds open', async ($, on) => {
test('/clear leaves the pane it finds open up and reads the repository afresh', async ($, on) => {
const world = Fixtures.inRepository(on)
on('command.run', { command: 'clear' }, () => ({}))
await $.session.start(Fixtures.SESSION)
await $.command.run(Fixtures.DIFF)
await $.command.run(Fixtures.CLEAR)
await world.clock.advance(Fixtures.SETTLE_MS)
expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
const read = world.runs.length
await $.command.run(Fixtures.CLEAR)
await world.clock.advance(Fixtures.SETTLE_MS)
expect(world.closed, 'as the built-in panel stays across /clear').toEqual(
[],
)
expect(
world.runs.slice(read).map(run => Fixtures.gitWordOf(run.argv))[0],
'the repository found again for the conversation that starts over',
).toBe('rev-parse --show-toplevel')
expect(
Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
'drawn from the fresh read',
).toContain('1 file changed')
})
test('a resumed session whose turns edited opens the pane before any new edit', async ($, on) => {
const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
messages: () => Fixtures.EDITED_TRANSCRIPT,
})
await $.session.start(Fixtures.SESSION)
expect(world.runs, 'the start itself still runs no git').toEqual([])
await $.ui.render(Fixtures.HINT)
await world.clock.advance(Fixtures.SETTLE_MS)
expect(
world.opened.map(pane => pane.id),
'open once the width is known, as the built-in opens on the restore',
).toEqual(['diff'])
})
test('a resumed session opens nothing where its first edit would not', async ($, on) => {
const narrow = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
messages: () => Fixtures.EDITED_TRANSCRIPT,
})
await $.session.start(Fixtures.SESSION)
await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
await narrow.clock.advance(Fixtures.SETTLE_MS)
expect(narrow.opened, 'under the unasked floor, never kept open').toEqual(
[],
)
expect(narrow.runs, 'and no git for it').toEqual([])
})
test('a resumed session the person kept the pane open in opens it from the lower floor', async ($, on) => {
const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
messages: () => Fixtures.EDITED_TRANSCRIPT,
stored: { [Names.STORE_OPEN_KEY]: true },
})
await $.session.start(Fixtures.SESSION)
await $.ui.render(Fixtures.hintAt(Limits.OPEN_MIN_COLUMNS))
await world.clock.advance(Fixtures.SETTLE_MS)
expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
})
test('a resumed session with no edits opens nothing', async ($, on) => {
const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
messages: () => [Fixtures.promptOf('just talk')],
})
await $.session.start(Fixtures.SESSION)
await $.ui.render(Fixtures.HINT)
await world.clock.advance(Fixtures.SETTLE_MS)
expect(world.opened).toEqual([])
expect(world.runs).toEqual([])
})
test('a resumed session whose pane the person closed opens nothing', async ($, on) => {
const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
messages: () => Fixtures.EDITED_TRANSCRIPT,
stored: { [Names.STORE_OPEN_KEY]: false },
})
await $.session.start(Fixtures.SESSION)
await $.ui.render(Fixtures.HINT)
await world.clock.advance(Fixtures.SETTLE_MS)
expect(world.opened).toEqual([])
expect(world.runs).toEqual([])
})
test('/resume closes the pane, then opens it for the turns it brought back', async ($, on) => {
let transcript: readonly SessionMessage[] = []
const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
messages: () => transcript,
})
on('command.run', { command: 'resume' }, () => {
transcript = Fixtures.EDITED_TRANSCRIPT
return {}
})
await $.session.start(Fixtures.SESSION)
await $.ui.render(Fixtures.HINT)
await $.command.run(Fixtures.DIFF)
await world.clock.advance(Fixtures.SETTLE_MS)
await $.command.run(Fixtures.RESUME)
await world.clock.advance(Fixtures.SETTLE_MS)
expect(world.closed.map(pane => pane.id), 'closed at /resume').toEqual([
'diff',
])
expect(
world.opened.map(pane => pane.id),
'/diff opened it, /resume opened it again for the restored turns',
).toEqual(['diff', 'diff'])
})
test('ask attaches the hunks on screen and calls no tool', async ($, on) => {