backstep 0.1.9 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +91 -3
- package/mod/hooks/register.tsx +138 -38
- package/mod/types/index.d.ts +6 -0
- package/package.json +1 -1
|
@@ -13,9 +13,14 @@ const CLI = '/opt/backstep/cli.js'
|
|
|
13
13
|
// `undo` answers with `undo`. Every argv the mod runs is recorded.
|
|
14
14
|
function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: string }, shell: object = { kind: 'read', undoable: false }) {
|
|
15
15
|
const runs: string[][] = []
|
|
16
|
+
const logs: string[] = []
|
|
16
17
|
const prompts: { text: string; context?: readonly string[] }[] = []
|
|
17
18
|
on('session.cwd', () => ({ value: '/repo' }))
|
|
18
19
|
on('session.id', () => ({ value: 's1' }))
|
|
20
|
+
on('ui.log', (_, e) => {
|
|
21
|
+
logs.push(e.text)
|
|
22
|
+
return { value: undefined }
|
|
23
|
+
})
|
|
19
24
|
on('process.run', (_, e) => {
|
|
20
25
|
runs.push([...e.argv])
|
|
21
26
|
const r = e.argv.includes('undo') ? undo : e.argv.includes('shell') ? { exitCode: 0, stdout: JSON.stringify(shell) } : { exitCode: 0, stdout: '' }
|
|
@@ -30,7 +35,7 @@ function engine(on: On, undo: { exitCode: number; stdout: string; stderr?: strin
|
|
|
30
35
|
const { Text } = $.ui.resolve(e)
|
|
31
36
|
return <Text>engine</Text>
|
|
32
37
|
})
|
|
33
|
-
return { runs, prompts }
|
|
38
|
+
return { runs, prompts, logs }
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
test('an edit is snapshotted and the band offers to undo the turn', { options: { cli: CLI } }, async ($, on) => {
|
|
@@ -110,11 +115,12 @@ test('a change that cannot be undone stops the rewind and says so', { options: {
|
|
|
110
115
|
const pressed = ui.press({ key: 'undo' })
|
|
111
116
|
await clock.advance(1000)
|
|
112
117
|
await pressed
|
|
118
|
+
await clock.advance(500)
|
|
113
119
|
expect((await ui.find({ type: 'Text', text: /Stopped/ }))?.text).toContain('Stopped after 1 change')
|
|
114
120
|
expect((await ui.find({ type: 'Text', text: /row changed since/ }))).toBeTruthy()
|
|
115
121
|
|
|
116
122
|
// Still undoable once the band folds, since the turn was not fully rewound.
|
|
117
|
-
await clock.advance(
|
|
123
|
+
await clock.advance(13000)
|
|
118
124
|
expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
|
|
119
125
|
await ui.unmount()
|
|
120
126
|
expect(prompts).toHaveLength(1)
|
|
@@ -219,6 +225,7 @@ function connector(on: On, opts: { start: object; undoable: boolean; mcpFails?:
|
|
|
219
225
|
toasts.push(e.text)
|
|
220
226
|
return { value: undefined }
|
|
221
227
|
})
|
|
228
|
+
on('ui.log', () => ({ value: undefined }))
|
|
222
229
|
on('session.cwd', () => ({ value: '/repo' }))
|
|
223
230
|
on('session.id', () => ({ value: 's1' }))
|
|
224
231
|
on('process.run', (_, e) => {
|
|
@@ -530,7 +537,7 @@ test('one press only asks; it lets go after a few seconds, or on cancel, and und
|
|
|
530
537
|
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
531
538
|
|
|
532
539
|
await ui.press({ key: 'undo' })
|
|
533
|
-
expect((await ui.find({ type: 'Text', text: /Undo Claude's last turn\?/ }))).toBeTruthy()
|
|
540
|
+
expect((await ui.find({ type: 'Text', text: /Undo Claude's last turn \("fix it"\)\?/ }))).toBeTruthy()
|
|
534
541
|
expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo')
|
|
535
542
|
await clock.advance(6000)
|
|
536
543
|
expect((await ui.find({ type: 'Text', text: /Last turn/ }))).toBeTruthy()
|
|
@@ -606,3 +613,84 @@ test('about to change a Supabase project that is not connected, Backstep asks fi
|
|
|
606
613
|
await $.tool.call({ tool: 'Bash', command })
|
|
607
614
|
expect(asked).toHaveLength(1)
|
|
608
615
|
})
|
|
616
|
+
|
|
617
|
+
// An engine where each shell command's plan is given by a test, and undo answers as told.
|
|
618
|
+
function shellEngine(on: On, plans: (command: string) => object, undo: object[]) {
|
|
619
|
+
const runs: string[][] = []
|
|
620
|
+
const logs: string[] = []
|
|
621
|
+
on('session.cwd', () => ({ value: '/repo' }))
|
|
622
|
+
on('session.id', () => ({ value: 's1' }))
|
|
623
|
+
on('ui.toast', () => ({ value: undefined }))
|
|
624
|
+
on('ui.log', (_, e) => {
|
|
625
|
+
logs.push(e.text)
|
|
626
|
+
return { value: undefined }
|
|
627
|
+
})
|
|
628
|
+
on('process.run', (_, e) => {
|
|
629
|
+
runs.push([...e.argv])
|
|
630
|
+
const command = e.argv.at(-1) ?? ''
|
|
631
|
+
const out = e.argv.includes('undo') ? undo : e.argv.includes('shell') ? plans(command) : e.argv.includes('db-check') ? { ask: null } : null
|
|
632
|
+
return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
633
|
+
})
|
|
634
|
+
on('tool.call', { tool: 'AskUserQuestion' }, () => ({ result: { questions: [], answers: {} } }))
|
|
635
|
+
on('tool.call', () => ({ result: {} }))
|
|
636
|
+
on('prompt.submit', (_, e) => ({ text: e.text, context: e.context }))
|
|
637
|
+
on('ui.render', ($, e) => {
|
|
638
|
+
const { Text } = $.ui.resolve(e)
|
|
639
|
+
return <Text>engine</Text>
|
|
640
|
+
})
|
|
641
|
+
return { runs, logs }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
test('when the last turn only committed and pushed, undo offers to take back the work it saved too', { options: { cli: CLI } }, async ($, on) => {
|
|
645
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
646
|
+
const { runs } = shellEngine(on, () => ({ kind: 'outbound', reason: 'pushes to a remote', id: 'p1', undoable: true }), [])
|
|
647
|
+
|
|
648
|
+
await $.prompt.submit({ text: 'add the kanban board', wait: false, origin: { kind: 'composer' } })
|
|
649
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/src/board.tsx', old_string: 'a', new_string: 'b' })
|
|
650
|
+
await clock.advance(1000)
|
|
651
|
+
await $.prompt.submit({ text: 'commit and push it', wait: false, origin: { kind: 'composer' } })
|
|
652
|
+
await $.tool.call({ tool: 'Bash', command: 'git add -A && git commit -m "board" && git push' })
|
|
653
|
+
|
|
654
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
655
|
+
expect((await ui.find({ type: 'Text', text: /Last turn \("commit and push it"\)/ }))).toBeTruthy()
|
|
656
|
+
await ui.press({ key: 'undo' })
|
|
657
|
+
expect((await ui.find({ type: 'Text', text: /only saved and pushed to git/ }))).toBeTruthy()
|
|
658
|
+
expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo it and the turn before ("add the kanban board")')
|
|
659
|
+
expect(await ui.find({ type: 'Button', key: 'just' })).toBeTruthy()
|
|
660
|
+
|
|
661
|
+
const pressed = ui.press({ key: 'undo' })
|
|
662
|
+
await clock.advance(1000)
|
|
663
|
+
await pressed
|
|
664
|
+
// Both turns: everything since the first one started.
|
|
665
|
+
expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--json', '--via-claude-code'])
|
|
666
|
+
await ui.unmount()
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
test('after an undo, everything undone and everything that was not is listed, in the band and the conversation', { options: { cli: CLI } }, async ($, on) => {
|
|
670
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
671
|
+
const { logs } = shellEngine(
|
|
672
|
+
on,
|
|
673
|
+
(command) => (command.startsWith('curl') ? { kind: 'outbound', reason: 'sends a request', id: 'r1', undoable: true } : { kind: 'write', id: 'w1', undoable: true }),
|
|
674
|
+
[{ id: 'w1', tool: 'Bash', ok: true, detail: 'restored /repo/src/a.ts\nremoved /repo/src/b.ts' }],
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
await $.prompt.submit({ text: 'tidy and notify', wait: false, origin: { kind: 'composer' } })
|
|
678
|
+
await $.tool.call({ tool: 'Bash', command: 'rm src/a.ts' })
|
|
679
|
+
await $.tool.call({ tool: 'Bash', command: 'curl -X POST https://hooks.example.com/notify -d done' })
|
|
680
|
+
|
|
681
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
682
|
+
await ui.press({ key: 'undo' })
|
|
683
|
+
const pressed = ui.press({ key: 'undo' })
|
|
684
|
+
await clock.advance(1000)
|
|
685
|
+
await pressed
|
|
686
|
+
await clock.advance(500)
|
|
687
|
+
expect((await ui.find({ type: 'Text', text: /src\/a\.ts restored/ }))).toBeTruthy()
|
|
688
|
+
expect((await ui.find({ type: 'Text', text: /Not undone/ }))).toBeTruthy()
|
|
689
|
+
expect((await ui.find({ type: 'Text', text: /curl -X POST https:\/\/hooks\.example\.com\/notify -d done: sends a request/ }))).toBeTruthy()
|
|
690
|
+
|
|
691
|
+
const report = logs.find((l) => l.includes('Backstep undid'))!
|
|
692
|
+
expect(report).toContain('undid 2 changes from the last turn ("tidy and notify")')
|
|
693
|
+
expect(report).toContain('◀ src/b.ts removed')
|
|
694
|
+
expect(report).toContain('Not undone:\n ✗ curl -X POST')
|
|
695
|
+
await ui.unmount()
|
|
696
|
+
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -19,6 +19,8 @@ const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as st
|
|
|
19
19
|
// Backstep's mark, as on the logo: the front rewind triangle in the terminal's
|
|
20
20
|
// own black or white, the one behind it in the logo's pink.
|
|
21
21
|
const PINK = '#ee7bb6'
|
|
22
|
+
// The mark where only plain text goes (a transcript line).
|
|
23
|
+
const MARK_TEXT = '◀◀'
|
|
22
24
|
// How long the "press again to undo" question waits before it lets go.
|
|
23
25
|
const CONFIRM_MS = 5000
|
|
24
26
|
|
|
@@ -71,7 +73,10 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
|
|
|
71
73
|
? `Claude pushed a new branch, ${p.branch}, to ${p.host}. Delete it there too?`
|
|
72
74
|
: `Claude pushed ${p.commits === 1 ? '1 commit' : `${p.commits} commits`} to ${p.branch} on ${p.host}. Undo ${p.commits === 1 ? 'it' : 'them'} there too?`
|
|
73
75
|
const answer = await $.ui.ask(what, { header: 'Backstep', options: [p.before === null ? 'Delete the branch' : PUSH_ASK.yes, PUSH_ASK.no] }).catch(() => null)
|
|
74
|
-
if (!answer || answer === PUSH_ASK.no)
|
|
76
|
+
if (!answer || answer === PUSH_ASK.no) {
|
|
77
|
+
$.ui.log(`${MARK_TEXT} Not undone on ${p.host}: Claude's ${p.before === null ? `branch ${p.branch}` : p.commits === 1 ? 'commit' : `${p.commits} commits`} stay there (you chose to leave it).`)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
75
80
|
const ran = await $.process
|
|
76
81
|
.run(backstep(['revert-push', '--root', o.pushed.root]), { stdin: JSON.stringify(p), timeoutMs: 180_000 })
|
|
77
82
|
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
@@ -82,6 +87,7 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
|
|
|
82
87
|
// keep the error above
|
|
83
88
|
}
|
|
84
89
|
$.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}. Nothing was changed on ${p.host}.`)
|
|
90
|
+
$.ui.log(r.ok ? `${MARK_TEXT} On ${p.host}: ${r.detail}.` : `${MARK_TEXT} Not undone on ${p.host}: ${r.detail}. Nothing was changed there.`)
|
|
85
91
|
await update($, note, (n) =>
|
|
86
92
|
n ? `${n}\n${r.ok ? `On ${p.host}: ${r.detail}.` : `${p.host} was left as it was: ${r.detail}.`}` : n,
|
|
87
93
|
)
|
|
@@ -103,7 +109,8 @@ async function pressUndo($: EngineInterface) {
|
|
|
103
109
|
const now = await $.clock.now()
|
|
104
110
|
if (at !== null && now - at < CONFIRM_MS) {
|
|
105
111
|
await update($, armed, () => null)
|
|
106
|
-
|
|
112
|
+
const { before } = await undoTargets($)
|
|
113
|
+
return undoLastTurn($, before ? 2 : 1)
|
|
107
114
|
}
|
|
108
115
|
await update($, armed, () => now)
|
|
109
116
|
$.clock.after(CONFIRM_MS, () => {
|
|
@@ -111,9 +118,11 @@ async function pressUndo($: EngineInterface) {
|
|
|
111
118
|
})
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
|
|
121
|
+
// Undoes the last `count` turns that changed something, newest first.
|
|
122
|
+
async function undoLastTurn($: EngineInterface, count = 1) {
|
|
115
123
|
if (await read($, rewind)) return
|
|
116
|
-
const
|
|
124
|
+
const taken = (await read($, turns)).filter(hasWork).slice(-count)
|
|
125
|
+
const target = taken[0]
|
|
117
126
|
if (!target) return
|
|
118
127
|
|
|
119
128
|
await update($, rewind, (): Rewind => ({ phase: 'running', frame: 0, shown: 0, items: [], message: '' }))
|
|
@@ -165,17 +174,28 @@ async function undoLastTurn($: EngineInterface) {
|
|
|
165
174
|
const cut = outcomes.findIndex((o) => !o.ok)
|
|
166
175
|
if (cut !== -1) outcomes = outcomes.slice(0, cut + 1)
|
|
167
176
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
177
|
+
// Every change undone, one per line, and every one that wasn't, with why.
|
|
178
|
+
const undone: RewindItem[] = outcomes
|
|
179
|
+
.filter((o) => o.ok)
|
|
180
|
+
.flatMap((o) =>
|
|
181
|
+
(o.detail || o.tool)
|
|
182
|
+
.split('\n')
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.map((line) => ({ label: line.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`), ok: true })),
|
|
185
|
+
)
|
|
186
|
+
const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
|
|
187
|
+
// A push is offered back on GitHub below, so it isn't listed as lost.
|
|
188
|
+
const offered = outcomes.some((o) => o.ok && o.pushed)
|
|
189
|
+
const kept = taken
|
|
190
|
+
.flatMap((t) => t.lostItems ?? [])
|
|
191
|
+
.filter((l) => !(offered && /pushes to a remote/.test(l)))
|
|
192
|
+
.map((label) => ({ label, ok: false }))
|
|
193
|
+
const items: RewindItem[] = [...undone, ...stopped, ...kept]
|
|
194
|
+
const done = undone.length
|
|
195
|
+
const failed = stopped.length > 0
|
|
196
|
+
const lost = kept.length
|
|
197
|
+
const which = taken.length > 1 ? `the last ${taken.length} turns` : 'the last turn'
|
|
198
|
+
const shell = lost ? ` ${plural(lost, 'thing')} couldn't be undone: listed below.` : ''
|
|
179
199
|
|
|
180
200
|
await update($, rewind, (): Rewind => ({
|
|
181
201
|
phase: failed ? 'failed' : items.length ? 'done' : 'empty',
|
|
@@ -185,16 +205,24 @@ async function undoLastTurn($: EngineInterface) {
|
|
|
185
205
|
message: failed
|
|
186
206
|
? `Stopped after ${plural(done, 'change')}: the next one couldn't be undone.`
|
|
187
207
|
: items.length
|
|
188
|
-
? `Rewound ${plural(done, 'change')} from
|
|
189
|
-
: `Nothing in
|
|
208
|
+
? `Rewound ${plural(done, 'change')} from ${which}.${shell}`
|
|
209
|
+
: `Nothing in ${which} could be undone.${shell}`,
|
|
190
210
|
}))
|
|
191
|
-
await bump($,
|
|
211
|
+
for (const t of taken) await bump($, t.id, (x) => ({ ...x, undone: !failed }))
|
|
212
|
+
|
|
213
|
+
// The whole account, kept in the conversation, where the band's few rows fold away.
|
|
214
|
+
const report = [
|
|
215
|
+
`${MARK_TEXT} Backstep undid ${plural(done, 'change')} from ${which}${taken.length === 1 ? named(target) : ''}${done ? ':' : '.'}`,
|
|
216
|
+
...undone.map((i) => ` ◀ ${i.label}`),
|
|
217
|
+
...(stopped.length || kept.length ? ["Not undone:", ...[...stopped, ...kept].map((i) => ` ✗ ${i.label}`)] : []),
|
|
218
|
+
]
|
|
219
|
+
$.ui.log(report.join('\n'))
|
|
192
220
|
|
|
193
221
|
if (done) {
|
|
194
222
|
const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
|
|
195
223
|
const key = await keysLabel($)
|
|
196
224
|
await update($, note, () =>
|
|
197
|
-
`[Backstep] The user pressed undo (${key}) on your previous turn. These changes were reverted:\n${list}\n` +
|
|
225
|
+
`[Backstep] The user pressed undo (${key}) on your ${taken.length > 1 ? `last ${taken.length} turns` : 'previous turn'}. These changes were reverted:\n${list}\n` +
|
|
198
226
|
(lost ? `${plural(lost, 'action')} from that turn couldn't be undone (they reached outside this machine with no way back, or the project was too big to snapshot).\n` : '') +
|
|
199
227
|
`Treat them as not having happened, re-read files before editing them, and don't redo them unless asked.`,
|
|
200
228
|
)
|
|
@@ -207,7 +235,7 @@ async function undoLastTurn($: EngineInterface) {
|
|
|
207
235
|
const reveal = $.clock.every(90, () => {
|
|
208
236
|
void update($, rewind, (r) => (r && r.shown < r.items.length ? { ...r, shown: r.shown + 1 } : r))
|
|
209
237
|
})
|
|
210
|
-
$.clock.after(failed ?
|
|
238
|
+
$.clock.after(failed || lost ? 12_000 : 4200, () => {
|
|
211
239
|
reveal.cancel()
|
|
212
240
|
void update($, rewind, () => null)
|
|
213
241
|
})
|
|
@@ -257,7 +285,7 @@ async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Ne
|
|
|
257
285
|
hint($, plan)
|
|
258
286
|
// Behind Backstep, or Backstep couldn't be reached: count it as before.
|
|
259
287
|
if (!plan || plan.kind === 'wrapped' || !plan.id) {
|
|
260
|
-
if (plan?.kind !== 'read') await bump($, t.id, (x) => ({ ...x, mcp: x.mcp + 1 }))
|
|
288
|
+
if (plan?.kind !== 'read') await bump($, t.id, (x) => ({ ...x, gitOnly: false, mcp: x.mcp + 1 }))
|
|
261
289
|
return next(e)
|
|
262
290
|
}
|
|
263
291
|
const r = await next(plan.args ? { ...e, ...plan.args } : e)
|
|
@@ -274,7 +302,9 @@ async function watchMcp($: EngineInterface, e: Record<string, unknown>, next: Ne
|
|
|
274
302
|
} catch {
|
|
275
303
|
undoable = false
|
|
276
304
|
}
|
|
277
|
-
if (!r.isError)
|
|
305
|
+
if (!r.isError) {
|
|
306
|
+
await bump($, t.id, (x) => (undoable ? { ...x, gitOnly: false, mcp: x.mcp + 1 } : lostOne({ ...x, gitOnly: false }, `${tool.replace(/^mcp__/, '').replace('__', ' ')}: ${plan.reason ?? 'there is no way to undo it'}`)))
|
|
307
|
+
}
|
|
278
308
|
return r
|
|
279
309
|
}
|
|
280
310
|
|
|
@@ -289,8 +319,40 @@ function backstep(args: string[]): string[] {
|
|
|
289
319
|
return cli === 'backstep' ? ['backstep', ...args] : ['node', '--disable-warning=ExperimentalWarning', cli, ...args]
|
|
290
320
|
}
|
|
291
321
|
|
|
292
|
-
function newTurn(startedAt: number): Turn {
|
|
293
|
-
return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false }
|
|
322
|
+
function newTurn(startedAt: number, prompt?: string): Turn {
|
|
323
|
+
return { id: `t${startedAt}`, startedAt, edits: 0, files: [], mcp: 0, shell: 0, lost: 0, undone: false, ...(prompt ? { prompt } : {}) }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// A command as it reads in a list: without its leading `cd`, cut short.
|
|
327
|
+
const brief = (command: string) => {
|
|
328
|
+
const c = command.replace(/^\s*cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*&&\s*/, '').replace(/\s+/g, ' ').trim()
|
|
329
|
+
return c.length > 60 ? `${c.slice(0, 59)}…` : c
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Something in a turn that can't be undone, named, so undo can list it.
|
|
333
|
+
const lostOne = (x: Turn, what: string): Turn => ({ ...x, lost: (x.lost ?? 0) + 1, lostItems: [...(x.lostItems ?? []), what].slice(-20) })
|
|
334
|
+
|
|
335
|
+
// A turn named by what the person asked: ("commit and push it").
|
|
336
|
+
const named = (t: Turn) => {
|
|
337
|
+
const p = (t.prompt ?? '').replace(/\s+/g, ' ').trim()
|
|
338
|
+
return p ? ` ("${p.length > 40 ? `${p.slice(0, 39)}…` : p}")` : ''
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Each part only saves to git (add, commit, push and friends), or only moves around.
|
|
342
|
+
const GIT_SAVE = /^(git\s+(add|commit|push|status|log|diff|show|branch|remote|fetch|rev-parse|config)\b|cd\s|echo\s|true$)/
|
|
343
|
+
const onlySavesToGit = (command: string) =>
|
|
344
|
+
command
|
|
345
|
+
.split(/&&|\|\||;|\n/)
|
|
346
|
+
.map((p) => p.trim())
|
|
347
|
+
.filter(Boolean)
|
|
348
|
+
.every((p) => GIT_SAVE.test(p))
|
|
349
|
+
|
|
350
|
+
// What one press would take back: the last turn, and, when that turn only
|
|
351
|
+
// committed and pushed, the turn whose work it saved.
|
|
352
|
+
async function undoTargets($: EngineInterface): Promise<{ target: Turn | undefined; before: Turn | undefined }> {
|
|
353
|
+
const list = (await read($, turns)).filter(hasWork)
|
|
354
|
+
const target = list.at(-1)
|
|
355
|
+
return { target, before: target?.gitOnly ? list.at(-2) : undefined }
|
|
294
356
|
}
|
|
295
357
|
|
|
296
358
|
// The turn tool calls belong to; one opens if none is (a call before any prompt).
|
|
@@ -534,7 +596,7 @@ export const register: Register = (on, options) => {
|
|
|
534
596
|
on('prompt.submit', async ($, e, next) => {
|
|
535
597
|
if (e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
|
|
536
598
|
const startedAt = await $.clock.now()
|
|
537
|
-
await update($, turns, (l) => [...(l ?? []), newTurn(startedAt)].slice(-KEEP_TURNS))
|
|
599
|
+
await update($, turns, (l) => [...(l ?? []), newTurn(startedAt, e.text)].slice(-KEEP_TURNS))
|
|
538
600
|
const pending = await read($, note)
|
|
539
601
|
if (!pending) return next(e)
|
|
540
602
|
await update($, note, () => null)
|
|
@@ -560,7 +622,9 @@ export const register: Register = (on, options) => {
|
|
|
560
622
|
.run(backstep(['snap', '--session', sid, '--tool', tool, '--cwd', cwd, '--', path]))
|
|
561
623
|
.catch(() => null)
|
|
562
624
|
if (snap?.exitCode === 0) {
|
|
563
|
-
await bump($, t.id, (x) => ({ ...x, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
|
|
625
|
+
await bump($, t.id, (x) => ({ ...x, gitOnly: false, edits: x.edits + 1, files: x.files.includes(path) ? x.files : [...x.files, path] }))
|
|
626
|
+
} else {
|
|
627
|
+
await bump($, t.id, (x) => lostOne({ ...x, gitOnly: false }, `edit to ${path.split('/').pop()}: Backstep couldn't save a copy first`))
|
|
564
628
|
}
|
|
565
629
|
}
|
|
566
630
|
} else if (tool === 'Bash') {
|
|
@@ -590,9 +654,15 @@ export const register: Register = (on, options) => {
|
|
|
590
654
|
}
|
|
591
655
|
if (plan?.kind === 'outbound') {
|
|
592
656
|
// What it changed here comes back with the project snapshot; what it sent out doesn't (a push is offered back after undo).
|
|
593
|
-
|
|
657
|
+
const git = onlySavesToGit(command)
|
|
658
|
+
await bump($, t.id, (x) =>
|
|
659
|
+
lostOne({ ...x, gitOnly: x.gitOnly !== false && git, shell: (x.shell ?? 0) + (plan.undoable ? 1 : 0) }, `${brief(command)}: ${plan.reason ?? 'it reached outside this computer'}`),
|
|
660
|
+
)
|
|
594
661
|
} else if (plan?.kind === 'write') {
|
|
595
|
-
|
|
662
|
+
const git = onlySavesToGit(command)
|
|
663
|
+
await bump($, t.id, (x) =>
|
|
664
|
+
plan.undoable ? { ...x, gitOnly: x.gitOnly !== false && git, shell: (x.shell ?? 0) + 1 } : lostOne({ ...x, gitOnly: false }, `${brief(command)}: ${plan.reason ?? "Backstep couldn't save a copy first"}`),
|
|
665
|
+
)
|
|
596
666
|
}
|
|
597
667
|
// A push: once it's run, note what it sent, so undo can offer to take it back there.
|
|
598
668
|
if (plan?.id && PUSH.test(command)) {
|
|
@@ -626,33 +696,63 @@ export const register: Register = (on, options) => {
|
|
|
626
696
|
if (r) {
|
|
627
697
|
const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : undefined
|
|
628
698
|
const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
|
|
629
|
-
|
|
630
|
-
const
|
|
699
|
+
// What came back (the first few; the conversation has them all), then everything that didn't.
|
|
700
|
+
const shown = r.items.slice(0, r.shown)
|
|
701
|
+
const back = shown.filter((i) => i.ok)
|
|
702
|
+
const kept = shown.filter((i) => !i.ok)
|
|
703
|
+
const more = r.items.filter((i) => i.ok).length - 5
|
|
631
704
|
return (
|
|
632
705
|
<Box flexDirection="column">
|
|
633
706
|
<Text color={tone} bold>
|
|
634
707
|
{glyph} {r.message}
|
|
635
708
|
</Text>
|
|
636
|
-
{
|
|
637
|
-
<Text key={`
|
|
638
|
-
{' '}
|
|
639
|
-
{i.
|
|
709
|
+
{back.slice(0, 5).map((i, n) => (
|
|
710
|
+
<Text key={`b${n}`} dimColor>
|
|
711
|
+
{' ◀ '}
|
|
712
|
+
{i.label}
|
|
713
|
+
</Text>
|
|
714
|
+
))}
|
|
715
|
+
{back.length >= 5 && more > 0 ? <Text dimColor>{` and ${more} more (all listed in the conversation)`}</Text> : null}
|
|
716
|
+
{kept.length ? <Text bold>{' Not undone:'}</Text> : null}
|
|
717
|
+
{kept.slice(0, 6).map((i, n) => (
|
|
718
|
+
<Text key={`k${n}`} color="error">
|
|
719
|
+
{' ✗ '}
|
|
640
720
|
{i.label}
|
|
641
721
|
</Text>
|
|
642
722
|
))}
|
|
643
|
-
{r.shown >= 6 && more > 0 ? <Text dimColor>{` and ${more} more`}</Text> : null}
|
|
644
723
|
</Box>
|
|
645
724
|
)
|
|
646
725
|
}
|
|
647
726
|
|
|
648
|
-
const target =
|
|
727
|
+
const { target, before } = await undoTargets($)
|
|
649
728
|
const label = (await read($, keys)).map(keyLabel).join(' / ')
|
|
650
729
|
if (target && (await read($, armed)) !== null) {
|
|
730
|
+
// The last turn only committed and pushed: the work it saved is what the person most likely wants back.
|
|
731
|
+
if (before) {
|
|
732
|
+
return (
|
|
733
|
+
<Box>
|
|
734
|
+
<Text bold>◀</Text>
|
|
735
|
+
<Text color={PINK} bold>◀</Text>
|
|
736
|
+
<Text color="warning" bold> That turn only saved and pushed to git. </Text>
|
|
737
|
+
<Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
|
|
738
|
+
{`${label} again to undo it and the turn before${named(before)}`}
|
|
739
|
+
</Button>
|
|
740
|
+
<Text dimColor> · </Text>
|
|
741
|
+
<Button key="just" plain dimColor onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
|
|
742
|
+
just the push
|
|
743
|
+
</Button>
|
|
744
|
+
<Text dimColor> · </Text>
|
|
745
|
+
<Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
|
|
746
|
+
cancel
|
|
747
|
+
</Button>
|
|
748
|
+
</Box>
|
|
749
|
+
)
|
|
750
|
+
}
|
|
651
751
|
return (
|
|
652
752
|
<Box>
|
|
653
753
|
<Text bold>◀</Text>
|
|
654
754
|
<Text color={PINK} bold>◀</Text>
|
|
655
|
-
<Text color="warning" bold> Undo Claude's last turn? </Text>
|
|
755
|
+
<Text color="warning" bold>{` Undo Claude's last turn${named(target)}? `}</Text>
|
|
656
756
|
<Text dimColor>{summary(target)} </Text>
|
|
657
757
|
<Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
|
|
658
758
|
{`${label} again to undo`}
|
|
@@ -673,7 +773,7 @@ export const register: Register = (on, options) => {
|
|
|
673
773
|
<Text bold>◀</Text>
|
|
674
774
|
<Text color={PINK} bold>◀</Text>
|
|
675
775
|
<Text> </Text>
|
|
676
|
-
<Text dimColor>{target ? `Last turn: ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
|
|
776
|
+
<Text dimColor>{target ? `Last turn${named(target)}: ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
|
|
677
777
|
<Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
|
|
678
778
|
{`${label} to undo`}
|
|
679
779
|
</Button>
|
package/mod/types/index.d.ts
CHANGED
|
@@ -10,6 +10,12 @@ export type Turn = {
|
|
|
10
10
|
// Shell commands and tool calls whose effects can't be taken back: they reached outside the machine with no undo rule, or the project was too big to snapshot.
|
|
11
11
|
lost: number
|
|
12
12
|
undone: boolean
|
|
13
|
+
// What the person asked for, so undo can name the turn it would take back.
|
|
14
|
+
prompt?: string
|
|
15
|
+
// Every change was a git commit or push (saving the turn before's work), so undo offers to take both.
|
|
16
|
+
gitOnly?: boolean
|
|
17
|
+
// What in this turn can't be undone, and why, so undo can say so by name.
|
|
18
|
+
lostItems?: string[]
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export type RewindItem = { label: string; ok: boolean }
|