mirror of
https://github.com/anthropics/claude-code.git
synced 2026-10-02 05:25:05 +08:00
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:
+7
-3
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ?? {})
|
||||
|
||||
Vendored
+3
@@ -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'
|
||||
|
||||
Vendored
+12
@@ -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 },
|
||||
}
|
||||
Vendored
+14
@@ -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: [],
|
||||
})
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user