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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Press ctrl+z to rewind everything Claude did in its last turn: file edits, shell commands, and database and tool changes.",
5
5
  "types": "./types/index.d.ts",
6
6
  "userConfig": {
@@ -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(10000)
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
+ })
@@ -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) continue
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
- return undoLastTurn($)
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
- async function undoLastTurn($: EngineInterface) {
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 target = (await read($, turns)).findLast(hasWork)
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
- const items: RewindItem[] = outcomes.map((o) => {
169
- const first = o.detail.split('\n')[0] ?? ''
170
- const what = first.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`)
171
- return { label: o.ok ? what || o.tool : `${o.tool}: ${first}`, ok: o.ok }
172
- })
173
- const done = items.filter((i) => i.ok).length
174
- const failed = items.some((i) => !i.ok)
175
- // A push counted as lost when it ran, but undo can now offer to take it back there.
176
- const offered = outcomes.filter((o) => o.ok && o.pushed).length
177
- const lost = Math.max(0, (target.lost ?? 0) - offered)
178
- const shell = lost ? ` ${plural(lost, 'action')} that reached outside couldn't be undone.` : ''
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 the last turn.${shell}`
189
- : `Nothing in the last turn could be undone.${shell}`,
208
+ ? `Rewound ${plural(done, 'change')} from ${which}.${shell}`
209
+ : `Nothing in ${which} could be undone.${shell}`,
190
210
  }))
191
- await bump($, target.id, (t) => ({ ...t, undone: !failed }))
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 ? 9000 : 4200, () => {
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) await bump($, t.id, (x) => (undoable ? { ...x, mcp: x.mcp + 1 } : { ...x, lost: (x.lost ?? 0) + 1 }))
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
- await bump($, t.id, (x) => ({ ...x, shell: (x.shell ?? 0) + (plan.undoable ? 1 : 0), lost: (x.lost ?? 0) + 1 }))
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
- await bump($, t.id, (x) => (plan.undoable ? { ...x, shell: (x.shell ?? 0) + 1 } : { ...x, lost: (x.lost ?? 0) + 1 }))
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
- const visible = r.items.slice(0, Math.min(r.shown, 6))
630
- const more = r.items.length - 6
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
- {visible.map((i, n) => (
637
- <Text key={`i${n}`} color={i.ok ? undefined : 'error'} dimColor={i.ok}>
638
- {' '}
639
- {i.ok ? '◀ ' : '✗ '}
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 = (await read($, turns)).findLast(hasWork)
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>
@@ -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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Undo for everything your agents touch: an MCP proxy that makes agent actions reversible.",
5
5
  "scripts": {
6
6
  "dev": "node src/cli.ts",