backstep 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -8,8 +8,8 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
8
8
  - **Supabase:** `execute_sql` and `apply_migration` snapshot the tables they change, over a connection to the project's database. Backstep never connects it on its own: when Claude Code opens a project that uses Supabase and isn't connected, it asks (Connect, Not now, or Don't ask for this project). Connect uses a connection string in the project's `.env` files, the local `supabase start` database, or a Supabase sign-in (`supabase login`, `SUPABASE_ACCESS_TOKEN`, or an access token you give Backstep once), with which the project gets a temporary database login that Backstep renews as it runs out. You can also ask Claude to "connect Backstep to Supabase" any time. Deleted rows, dropped tables and migrations, including the migration's history row, all come back. Works for the claude.ai Supabase connector and a local Supabase MCP server alike.
9
9
  - **Firebase:** before a change, Backstep reads what's there through the Firebase MCP server itself, and undoes through it too, so there's nothing to set up. Firestore documents updated, deleted or added; Realtime Database writes; Remote Config publishes (rolled back to the version before); and users enabled or disabled.
10
10
  - **claude.ai connectors and servers Claude Code signs in to:** Backstep can't sit in front of these, so the Claude Code mod watches their calls instead. Calls with an undo rule are undone through Claude Code's own connection.
11
- - **Database commands Claude runs** (`supabase db push`, `supabase migration up`, `supabase db reset`, `psql`, `prisma migrate`, `drizzle-kit push`, seed scripts…): in a project whose Supabase database is connected, the whole database is noted before the command runs, and ctrl+z puts it back in one transaction: rows, new tables dropped, dropped or altered tables rebuilt with their constraints, indexes, foreign keys, row level security policies, triggers and grants, sequence counters, and the migration history. Copies sit in a `backstep` schema in the same database; the newest 10 per database are kept. On a very big database it keeps every table's structure and the migration history, and rows smallest-table-first up to a budget (`BACKSTEP_DB_SNAPSHOT_MB`, default 250); undo names any table whose rows it couldn't bring back. Functions, views, types and extensions aren't restored.
12
- - **Code Claude pushed:** ctrl+z rewinds the commits locally, then asks whether to undo them on GitHub (or any remote) too. Yes pushes one new commit that undoes Claude's, made in a temporary worktree: no force push, no rewritten history, so it works on protected branches and for teammates who already pulled. Newer commits that clash, or a rewritten branch, stop it. A branch Claude created is deleted instead, if nobody has added to it. It uses the project's own git sign-in, so there's nothing to connect.
11
+ - **Database commands Claude runs** (`supabase db push`, `supabase migration up`, `supabase db reset`, `psql`, `prisma migrate`, `drizzle-kit push`, seed scripts…): in a project whose Supabase database is connected, the whole database is noted before the command runs, and ctrl+u puts it back in one transaction: rows, new tables dropped, dropped or altered tables rebuilt with their constraints, indexes, foreign keys, row level security policies, triggers and grants, sequence counters, and the migration history. Copies sit in a `backstep` schema in the same database; the newest 10 per database are kept. On a very big database it keeps every table's structure and the migration history, and rows smallest-table-first up to a budget (`BACKSTEP_DB_SNAPSHOT_MB`, default 250); undo names any table whose rows it couldn't bring back. Functions, views, types and extensions aren't restored.
12
+ - **Code Claude pushed:** ctrl+u rewinds the commits locally, then asks whether to undo them on GitHub (or any remote) too. Yes pushes one new commit that undoes Claude's, made in a temporary worktree: no force push, no rewritten history, so it works on protected branches and for teammates who already pulled. Newer commits that clash, or a rewritten branch, stop it. A branch Claude created is deleted instead, if nobody has added to it. It uses the project's own git sign-in, so there's nothing to connect.
13
13
  - **Things that can't be undone** (sending email, payments, deploys, `ALTER TABLE`…) run as normal and are marked in the journal, so you know what an undo didn't cover.
14
14
  - **Anything else, by rule:** a rules file maps a tool to its opposite (`create_issue` → close the issue, `send_message` → retract it), filled in from the call and its result. See [Undo rules](#undo-rules).
15
15
  - **Conflicts:** undoing an action first undoes any later action that touched the same file or table, so nothing gets silently thrown away.
@@ -20,7 +20,7 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
20
20
  npx backstep setup
21
21
  ```
22
22
 
23
- This installs Backstep, adds its Claude Code mod, binds **ctrl+z** to undo everything Claude did in its last turn, and puts the MCP servers you've already added to Claude Code behind Backstep, so their changes can be undone too. If you use Supabase, it asks for your database's connection string (Enter skips; you can ask Claude to connect it later). Restart Claude Code afterwards. Needs Node 22.13+ and Claude Code.
23
+ This installs Backstep, adds its Claude Code mod, binds **ctrl+u** to undo everything Claude did in its last turn, and puts the MCP servers you've already added to Claude Code behind Backstep, so their changes can be undone too. If you use Supabase, it asks for your database's connection string (Enter skips; you can ask Claude to connect it later). Restart Claude Code afterwards. Needs Node 22.13+ and Claude Code.
24
24
 
25
25
  Servers in a repo's shared `.mcp.json` that you've approved get a wrapped copy for that project only; the repo's file is never edited. Remote servers that Claude Code signs in to stay as they are, and the mod watches their calls instead. Servers you add later are wrapped the next time Claude Code starts. `backstep unwrap` puts every server back the way it was and stops new ones being wrapped.
26
26
 
@@ -53,7 +53,7 @@ With Claude Code: `claude mcp add files -- backstep wrap --name files -- <the se
53
53
  | `--rules <file>` | undo rules (or set `BACKSTEP_RULES`) |
54
54
  | `--pg <url>` | the Postgres database the wrapped server writes to. Enables database undo. Without it, SQL writes are recorded but can't be undone. |
55
55
 
56
- The undo key is ctrl+z. The first press asks "Undo Claude's last turn?" above the prompt and a second press within 5 seconds undoes, so a stray press does nothing. Change the key whenever you like: ask Claude ("change my Backstep undo key to ctrl+u"), or run `backstep key ctrl+u`. The line above the prompt is always there, even with nothing to undo, because the key only reaches Backstep while it's on screen; otherwise ctrl+z would fall through to the terminal and suspend Claude Code.
56
+ The undo key is ctrl+u (not ctrl+z: Claude Code suspends itself on ctrl+z in a terminal before anything else sees the key, so it can't be used). The first press asks "Undo Claude's last turn?" above the prompt and a second press within 5 seconds undoes, so a stray press does nothing. Change the key whenever you like: ask Claude ("change my Backstep undo key to ctrl+q"), or run `backstep key ctrl+q`. The line above the prompt is always there, even with nothing to undo, because the key only reaches Backstep while it's on screen; otherwise a press would do nothing.
57
57
 
58
58
  Backstep checks npm for a newer version as each Claude Code session starts and asks before updating (Update, Not now, or Skip this version; `backstep update` does it by hand, `BACKSTEP_UPDATE_CHECK=0` turns the check off).
59
59
 
@@ -76,7 +76,7 @@ For tools Backstep can't snapshot, a rule names the tool that undoes them. `{{ar
76
76
  ] }
77
77
  ```
78
78
 
79
- `server` is optional and matches `--name`. For claude.ai connectors and servers Claude Code signs in to, put rules in `~/.backstep/rules.json`; `server` is the name as it appears in tool names (`claude_ai_Notion`), and pressing ctrl+z runs the opposite call through Claude Code. A rule overrides the built-in guess, so a `send_message` with a retract rule is undoable. The undo runs through a live `backstep wrap` of that server; if none is running, the dashboard tells you to start it, and any running copy with the same `--name` picks up the request. `examples/rules.json` has GitHub rules as a starting point. Check the tool and argument names against your server's version.
79
+ `server` is optional and matches `--name`. For claude.ai connectors and servers Claude Code signs in to, put rules in `~/.backstep/rules.json`; `server` is the name as it appears in tool names (`claude_ai_Notion`), and pressing ctrl+u runs the opposite call through Claude Code. A rule overrides the built-in guess, so a `send_message` with a retract rule is undoable. The undo runs through a live `backstep wrap` of that server; if none is running, the dashboard tells you to start it, and any running copy with the same `--name` picks up the request. `examples/rules.json` has GitHub rules as a starting point. Check the tool and argument names against your server's version.
80
80
 
81
81
  ## How it decides
82
82
 
@@ -101,7 +101,7 @@ Everything is journaled to `~/.backstep/journal.db` (SQLite, mode 0600; override
101
101
  - Undoing a table restore runs `DELETE` and then re-inserts rows, so foreign keys from other tables pointing at deleted rows can block it. The undo then fails cleanly inside a transaction.
102
102
  - `--pg` must point at the same database the wrapped server uses.
103
103
  - The dashboard only accepts requests from this machine. The journal stores the `--pg` URL so undo works later.
104
- - Remote servers that need an OAuth login can't be wrapped; they're watched from Claude Code instead, so their undo works with ctrl+z but not from the dashboard.
104
+ - Remote servers that need an OAuth login can't be wrapped; they're watched from Claude Code instead, so their undo works with ctrl+u but not from the dashboard.
105
105
  - Shell snapshots cover the project folder (the git repository, or the folder Claude Code runs in). Files outside it, and ignored folders like `node_modules`, aren't saved: undo restores `package.json` and the lockfile, and you re-run the install.
106
106
  - Shell commands are judged before they run by what they look like, so a script that pushes or deploys from inside is snapshotted, not held.
107
107
 
package/dist/cli.js CHANGED
@@ -53,7 +53,7 @@ function envHeaders() {
53
53
  }
54
54
  const HELP = `backstep: undo for everything your agents touch
55
55
 
56
- backstep setup install the Claude Code mod, bind ctrl+z to undo Claude's last turn,
56
+ backstep setup install the Claude Code mod, bind ctrl+u to undo Claude's last turn,
57
57
  and put your MCP servers behind Backstep
58
58
  backstep unwrap take your MCP servers back out from behind Backstep
59
59
  backstep connect [supabase|firebase] [<access-token>|<connection-string>] [--project <ref>] [--dir <folder>] [--json]
package/dist/keys.js CHANGED
@@ -1,13 +1,16 @@
1
1
  // The key that undoes Claude's last turn, in Claude Code's ~/.claude/keybindings.json.
2
2
  // The mod's undo button borrows a keybinding action only the diff panel uses
3
3
  // (mod buttons can't have actions of their own yet), and whatever key is bound
4
- // to it, in Global and Chat, presses the button. ctrl+z by default; the person
4
+ // to it, in Global and Chat, presses the button. ctrl+u by default (ctrl+z can't
5
+ // be: Claude Code suspends itself on it before any binding sees it); the person
5
6
  // can ask Claude for another, which runs `backstep key <key>`.
6
7
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
8
  import { homedir } from "node:os";
8
9
  import { join } from "node:path";
9
10
  export const UNDO_ACTION = "app:cycleDiffBase";
10
- export const DEFAULT_KEY = "ctrl+z";
11
+ export const DEFAULT_KEY = "ctrl+u";
12
+ // Bound by Backstep before it knew Claude Code suspends on it; moved to the default on update.
13
+ export const OLD_KEY = "ctrl+z";
11
14
  export const defaultKeys = () => [DEFAULT_KEY];
12
15
  const CONTEXTS = ["Global", "Chat"];
13
16
  // Claude Code never hands these to anything else.
@@ -41,6 +44,8 @@ export function normalizeKey(raw) {
41
44
  throw new Error(`"${raw}" on its own would type a letter. Use a modifier, like ctrl+${last}`);
42
45
  if (RESERVED.has(key))
43
46
  throw new Error(`Claude Code keeps ${key} for itself. Pick another, like ctrl+u`);
47
+ if (key === OLD_KEY || key === "ctrl+shift+z")
48
+ throw new Error(`Claude Code suspends itself on ${key} in a terminal, before Backstep sees it. Pick another, like ctrl+u`);
44
49
  return key;
45
50
  }
46
51
  // Binds undo to `keys` (one, or several) and unbinds the old ones. Refuses a key that does something else, unless it was undo's.
@@ -83,3 +88,19 @@ export function setUndoKey(raw) {
83
88
  export function keyLabel(k) {
84
89
  return k.startsWith("cmd+") ? `⌘${k.slice(4).toUpperCase()}` : k;
85
90
  }
91
+ // As setup runs: keeps a key the person picked, binds ctrl+u on a first
92
+ // install, and moves an install that had ctrl+z onto it. Returns a problem to
93
+ // show, or "".
94
+ export function ensureUndoKey() {
95
+ const keys = undoKeys();
96
+ if (keys.length && !keys.includes(OLD_KEY))
97
+ return "";
98
+ const rest = keys.filter((k) => k !== OLD_KEY);
99
+ try {
100
+ setUndoKey(rest.length ? rest : defaultKeys());
101
+ return "";
102
+ }
103
+ catch (err) {
104
+ return err instanceof Error ? err.message : String(err);
105
+ }
106
+ }
package/dist/setup.js CHANGED
@@ -3,7 +3,7 @@ import { cpSync, readFileSync, rmSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { homeDir } from "./store.js";
6
- import { defaultKeys, keyLabel, setUndoKey, undoKey, undoKeys } from "./keys.js";
6
+ import { defaultKeys, ensureUndoKey, keyLabel, undoKeys } from "./keys.js";
7
7
  import { createInterface } from "node:readline/promises";
8
8
  import { autoWrap, wrapServers } from "./servers.js";
9
9
  import { check, printCheck } from "./check.js";
@@ -29,18 +29,6 @@ function keepCopy() {
29
29
  cpSync(dirname(ROOT), modules, { recursive: true });
30
30
  return { root: join(modules, "backstep"), onPath: false };
31
31
  }
32
- // Keeps a key the person picked; binds ctrl+z on a first install.
33
- function bindUndoKey() {
34
- if (undoKey())
35
- return "";
36
- try {
37
- setUndoKey(defaultKeys());
38
- return "";
39
- }
40
- catch (err) {
41
- return err instanceof Error ? err.message : String(err);
42
- }
43
- }
44
32
  const WHERE = `In Supabase, open your project, press Connect at the top, and copy the URI under
45
33
  "Direct connection" (or "Session pooler" if your network has no IPv6). Put your
46
34
  database password in place of [YOUR-PASSWORD].`;
@@ -80,7 +68,7 @@ export async function setup() {
80
68
  if (!r.ok)
81
69
  throw new Error(`claude plugin install failed:\n${r.out}`);
82
70
  // Only a problem is worth a line here; the summary below says the rest.
83
- const bound = bindUndoKey();
71
+ const bound = ensureUndoKey();
84
72
  if (bound)
85
73
  console.log(` ${white("!")} ${bound}`);
86
74
  // Wrap last, so a failed install never leaves servers pointing at a Backstep that isn't there.
@@ -89,7 +77,7 @@ export async function setup() {
89
77
  // What's covered and what isn't, said once here and kept up to date by `backstep status`.
90
78
  const key = (undoKeys().length ? undoKeys() : defaultKeys()).map(keyLabel).join(" or ");
91
79
  console.log(`\n ${pink("✓")} ${white("Backstep is ready.")} Restart Claude Code, then press ${white(key)} after any turn to undo it.`);
92
- console.log(dim(` Want a different key? Just ask Claude: "change my Backstep undo key to ctrl+u"`));
80
+ console.log(dim(` Want a different key? Just ask Claude: "change my Backstep undo key to ctrl+q"`));
93
81
  printCheck(check(process.cwd()), bs);
94
82
  if (telemetryOn()) {
95
83
  console.log(dim(" Sends an anonymous install count. Turn it off with BACKSTEP_TELEMETRY=0.\n"));
package/dist/undo.js CHANGED
@@ -111,7 +111,7 @@ async function undoInverse(store, a, u) {
111
111
  // Claude Code reports back how the call went with `backstep settle`.
112
112
  function undoEngine(store, a, u, opts) {
113
113
  if (!opts.viaClaudeCode) {
114
- return { id: a.id, tool: a.tool, ok: false, detail: `undo this with ctrl+z in Claude Code: only it is connected to ${u.server}` };
114
+ return { id: a.id, tool: a.tool, ok: false, detail: `undo this from Claude Code (ctrl+u): only it is connected to ${u.server}` };
115
115
  }
116
116
  if (!store.transition(a.id, a.status, "undo_requested")) {
117
117
  return { id: a.id, tool: a.tool, ok: false, detail: "this action changed while undoing; try again" };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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": {
@@ -119,7 +119,7 @@ test('a change that cannot be undone stops the rewind and says so', { options: {
119
119
  expect(prompts).toHaveLength(1)
120
120
  })
121
121
 
122
- test('with nothing to undo the band still holds the key, so ctrl+z never suspends Claude Code', async ($, on) => {
122
+ test('with nothing to undo the band still holds the key, and a press says there is nothing to undo', async ($, on) => {
123
123
  engine(on, { exitCode: 0, stdout: '[]' })
124
124
  const toasts: string[] = []
125
125
  on('ui.toast', (_, e) => {
@@ -502,7 +502,7 @@ test('asking Claude to change the undo key rebinds it and relabels the band', {
502
502
  const runs: string[][] = []
503
503
  on('process.run', (_, e) => {
504
504
  runs.push([...e.argv])
505
- const stdout = e.argv.includes('key') ? JSON.stringify({ ok: true, key: 'ctrl+u', previous: 'ctrl+z' }) : '[]'
505
+ const stdout = e.argv.includes('key') ? JSON.stringify({ ok: true, key: 'ctrl+q', previous: 'ctrl+u' }) : '[]'
506
506
  return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
507
507
  })
508
508
  on('ui.toast', () => ({ value: undefined }))
@@ -510,11 +510,11 @@ test('asking Claude to change the undo key rebinds it and relabels the band', {
510
510
  const { Text } = $.ui.resolve(e)
511
511
  return <Text>engine</Text>
512
512
  })
513
- const r = await $.tool.call({ tool: 'mcp__backstep__undo_key', key: 'Ctrl + U' } as never)
514
- expect(runs[0].slice(3)).toEqual(['key', 'Ctrl + U', '--json'])
515
- expect(r.result).toBe('Undo is now ctrl+u (it was ctrl+z). It works right away.')
513
+ const r = await $.tool.call({ tool: 'mcp__backstep__undo_key', key: 'Ctrl + Q' } as never)
514
+ expect(runs[0].slice(3)).toEqual(['key', 'Ctrl + Q', '--json'])
515
+ expect(r.result).toBe('Undo is now ctrl+q (it was ctrl+u). It works right away.')
516
516
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
517
- expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u to undo')
517
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+q to undo')
518
518
  await ui.unmount()
519
519
  })
520
520
 
@@ -528,7 +528,7 @@ test('one press only asks; it lets go after a few seconds, or on cancel, and und
528
528
 
529
529
  await ui.press({ key: 'undo' })
530
530
  expect((await ui.find({ type: 'Text', text: /Undo Claude's last turn\?/ }))).toBeTruthy()
531
- expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+z again to undo')
531
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u again to undo')
532
532
  await clock.advance(6000)
533
533
  expect((await ui.find({ type: 'Text', text: /Last turn/ }))).toBeTruthy()
534
534
 
@@ -541,9 +541,9 @@ test('one press only asks; it lets go after a few seconds, or on cancel, and und
541
541
  await ui.unmount()
542
542
  })
543
543
 
544
- test('on a Mac the band names both keys', { options: { cli: CLI } }, async ($, on) => {
544
+ test('with two keys bound the band names both', { options: { cli: CLI } }, async ($, on) => {
545
545
  on('process.run', (_, e) => {
546
- const stdout = e.argv.includes('key') ? JSON.stringify({ key: 'cmd+z', keys: ['cmd+z', 'ctrl+z'] }) : '[]'
546
+ const stdout = e.argv.includes('key') ? JSON.stringify({ key: 'ctrl+u', keys: ['ctrl+u', 'ctrl+q'] }) : '[]'
547
547
  return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
548
548
  })
549
549
  const logs: string[] = []
@@ -559,8 +559,8 @@ test('on a Mac the band names both keys', { options: { cli: CLI } }, async ($, o
559
559
  return <Text>engine</Text>
560
560
  })
561
561
  await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
562
- expect(logs[0]).toContain('press ⌘Z / ctrl+z to undo')
562
+ expect(logs[0]).toContain('press ctrl+u / ctrl+q to undo')
563
563
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
564
- expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('⌘Z / ctrl+z to undo')
564
+ expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u / ctrl+q to undo')
565
565
  await ui.unmount()
566
566
  })
@@ -1,6 +1,6 @@
1
1
  // Backstep for Claude Code: snapshot every file Claude is about to change,
2
2
  // and the whole project before each shell command, and rewind its whole last
3
- // turn with one key (ctrl+z) and a short animation above the prompt. Database
3
+ // turn with one key (ctrl+u) and a short animation above the prompt. Database
4
4
  // and tool changes made through servers wrapped by `backstep wrap`, and
5
5
  // Supabase changes over a connected database, rewind with it, by the turn's
6
6
  // time window.
@@ -13,12 +13,17 @@ const turns = atom({ plugin: 'backstep', key: 'turns' } as const, [] as Turn[])
13
13
  const rewind = atom({ plugin: 'backstep', key: 'rewind' } as const, null as Rewind | null)
14
14
  const note = atom({ plugin: 'backstep', key: 'note' } as const, null as string | null)
15
15
  const armed = atom({ plugin: 'backstep', key: 'armed' } as const, null as number | null)
16
+ // The keys bound to undo, read as a session starts and when the person changes
17
+ // them. Kept in state, not a variable, so the band redraws with the new key.
18
+ const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as string[])
19
+ // Backstep's mark, two rewind triangles, in the terminal's own black or white.
20
+ const MARK = '◀◀'
16
21
  // How long the "press again to undo" question waits before it lets go.
17
22
  const CONFIRM_MS = 5000
18
23
 
19
24
  // The engine has no keybinding actions of its own for plugins yet, so the undo
20
25
  // button borrows one that only the diff panel handles; ~/.claude/keybindings.json
21
- // binds ctrl+z to it everywhere else.
26
+ // binds ctrl+u to it everywhere else.
22
27
  const UNDO_ACTION = 'app:cycleDiffBase'
23
28
  const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
24
29
  const KEEP_TURNS = 30
@@ -186,8 +191,9 @@ async function undoLastTurn($: EngineInterface) {
186
191
 
187
192
  if (done) {
188
193
  const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
194
+ const key = await keysLabel($)
189
195
  await update($, note, () =>
190
- `[Backstep] The user pressed undo (${keysLabel()}) on your previous turn. These changes were reverted:\n${list}\n` +
196
+ `[Backstep] The user pressed undo (${key}) on your previous turn. These changes were reverted:\n${list}\n` +
191
197
  (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` : '') +
192
198
  `Treat them as not having happened, re-read files before editing them, and don't redo them unless asked.`,
193
199
  )
@@ -275,10 +281,8 @@ type ShellPlan = { kind: 'read' | 'write' | 'outbound'; reason?: string; id?: st
275
281
 
276
282
  // Set from the plugin's options each time the module registers.
277
283
  let cli = 'backstep'
278
- // The keys bound to undo, read as a session starts and when the person changes them.
279
- let undoKeys = ['ctrl+z']
280
284
  const keyLabel = (k: string) => (k.startsWith('cmd+') ? `⌘${k.slice(4).toUpperCase()}` : k)
281
- const keysLabel = () => undoKeys.map(keyLabel).join(' / ')
285
+ const keysLabel = async ($: EngineInterface) => (await read($, keys)).map(keyLabel).join(' / ')
282
286
 
283
287
  function backstep(args: string[]): string[] {
284
288
  return cli === 'backstep' ? ['backstep', ...args] : ['node', '--disable-warning=ExperimentalWarning', cli, ...args]
@@ -320,7 +324,7 @@ async function wrapNewServers($: EngineInterface, cwd: string) {
320
324
 
321
325
  // As a session starts: connects what the project's own settings allow, then
322
326
  // says what Claude reaches that Backstep could undo once connected, so nobody
323
- // presses ctrl+z expecting it to come back.
327
+ // presses undo expecting it to come back.
324
328
  async function checkCoverage($: EngineInterface, cwd: string) {
325
329
  // Supabase is only ever noticed here, never connected: that's the person's
326
330
  // call. Firebase is added once someone is signed in to it.
@@ -419,13 +423,13 @@ const KEY = 'mcp__backstep__undo_key'
419
423
  const KEY_TOOL = {
420
424
  name: 'undo_key',
421
425
  description:
422
- "Change the key that undoes Claude's last turn with Backstep (ctrl+z unless the user changed it). " +
426
+ "Change the key that undoes Claude's last turn with Backstep (ctrl+u unless the user changed it; ctrl+z can't be used, Claude Code suspends on it). " +
423
427
  'Use it when the user asks to change, rebind or pick a different Backstep or undo shortcut. ' +
424
428
  'Keys are written like ctrl+u, cmd+z (⌘ on a Mac; most Mac terminals keep ⌘ keys for themselves, so pair it with a ctrl key), meta+z (Option) or ctrl+shift+z. ' +
425
429
  'Several keys can share undo: separate them with commas. It takes effect right away, no restart.',
426
430
  inputSchema: {
427
431
  type: 'object',
428
- properties: { key: { type: 'string', description: 'the new key, like ctrl+u, or several separated by commas, like "cmd+z, ctrl+z"' } },
432
+ properties: { key: { type: 'string', description: 'the new key, like ctrl+u, or several separated by commas, like "ctrl+u, ctrl+q"' } },
429
433
  required: ['key'],
430
434
  },
431
435
  }
@@ -441,10 +445,12 @@ async function changeKey($: EngineInterface, key: string): Promise<string> {
441
445
  r = { ok: false, detail: ran.stderr.trim() || 'it failed' }
442
446
  }
443
447
  if (!r.ok || !r.key) return `Couldn't change the undo key: ${r.detail ?? 'it failed'}`
444
- undoKeys = r.keys?.length ? r.keys : [r.key]
445
- $.ui.toast(`Backstep: undo is now ${keysLabel()}.`)
446
- const was = r.previous && !undoKeys.includes(r.previous) ? ` (it was ${keyLabel(r.previous)})` : ''
447
- return `Undo is now ${keysLabel()}${was}. It works right away.`
448
+ const now = r.keys?.length ? r.keys : [r.key]
449
+ await update($, keys, () => now)
450
+ const label = await keysLabel($)
451
+ $.ui.toast(`Backstep: undo is now ${label}.`)
452
+ const was = r.previous && !now.includes(r.previous) ? ` (it was ${keyLabel(r.previous)})` : ''
453
+ return `Undo is now ${label}${was}. It works right away.`
448
454
  }
449
455
 
450
456
  const CONNECT = 'mcp__backstep__connect'
@@ -452,7 +458,7 @@ const CONNECT = 'mcp__backstep__connect'
452
458
  const CONNECT_TOOL = {
453
459
  name: 'connect',
454
460
  description:
455
- "Connect Backstep (the undo for this session's changes) to the services the project uses, so changes Claude makes to them can be undone with ctrl+z. " +
461
+ "Connect Backstep (the undo for this session's changes) to the services the project uses, so changes Claude makes to them can be undone with ctrl+u. " +
456
462
  'Use it when the user asks to connect Backstep, or to make their database or Firebase undoable. ' +
457
463
  "Supabase: finds the database's connection string in the project's .env files or its local `supabase start` database. " +
458
464
  'When it reports it needs a sign-in, tell the user how to make an access token (it says where), ask them to paste it, then call this again with connection_string. ' +
@@ -499,15 +505,15 @@ export const register: Register = (on, options) => {
499
505
  await $.tool.register(CONNECT_TOOL)
500
506
  await $.tool.register(KEY_TOOL)
501
507
  const r = await next(e)
502
- const bound = await $.process.run(backstep(['key', '--json'])).catch(() => null)
508
+ const boundOut = await $.process.run(backstep(['key', '--json'])).catch(() => null)
503
509
  try {
504
- const keys = (JSON.parse(bound?.stdout || '{}') as { keys?: string[] }).keys
505
- if (keys?.length) undoKeys = keys
510
+ const bound = (JSON.parse(boundOut?.stdout || '{}') as { keys?: string[] }).keys
511
+ if (bound?.length) await update($, keys, () => bound)
506
512
  } catch {
507
513
  // keep the default
508
514
  }
509
515
  // Said first thing, so nobody has to wonder whether it's running.
510
- $.ui.log(`✓ Backstep connected · press ${keysLabel()} to undo Claude's last turn`)
516
+ $.ui.log(`✓ Backstep connected · press ${await keysLabel($)} to undo Claude's last turn`)
511
517
  // In the background: a session never waits on either.
512
518
  void wrapNewServers($, e.cwd)
513
519
  // One question at a time: Supabase first, then any update.
@@ -592,15 +598,15 @@ export const register: Register = (on, options) => {
592
598
  if (r?.phase === 'running') {
593
599
  return (
594
600
  <Box>
595
- <Text color="success" bold>↺ Rewinding </Text>
596
- <Text color="success">{bar(r.frame)}</Text>
601
+ <Text bold>{MARK} Rewinding </Text>
602
+ <Text>{bar(r.frame)}</Text>
597
603
  <Text dimColor> undoing the last turn…</Text>
598
604
  </Box>
599
605
  )
600
606
  }
601
607
 
602
608
  if (r) {
603
- const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : 'success'
609
+ const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : undefined
604
610
  const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
605
611
  const visible = r.items.slice(0, Math.min(r.shown, 6))
606
612
  const more = r.items.length - 6
@@ -612,7 +618,7 @@ export const register: Register = (on, options) => {
612
618
  {visible.map((i, n) => (
613
619
  <Text key={`i${n}`} color={i.ok ? undefined : 'error'} dimColor={i.ok}>
614
620
  {' '}
615
- {i.ok ? '↺ ' : '✗ '}
621
+ {i.ok ? '◀ ' : '✗ '}
616
622
  {i.label}
617
623
  </Text>
618
624
  ))}
@@ -622,13 +628,14 @@ export const register: Register = (on, options) => {
622
628
  }
623
629
 
624
630
  const target = (await read($, turns)).findLast(hasWork)
631
+ const label = (await read($, keys)).map(keyLabel).join(' / ')
625
632
  if (target && (await read($, armed)) !== null) {
626
633
  return (
627
634
  <Box>
628
- <Text color="warning" bold>↺ Undo Claude's last turn? </Text>
635
+ <Text color="warning" bold>{MARK} Undo Claude's last turn? </Text>
629
636
  <Text dimColor>{summary(target)} </Text>
630
637
  <Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
631
- {`${keysLabel()} again to undo`}
638
+ {`${label} again to undo`}
632
639
  </Button>
633
640
  <Text dimColor> · </Text>
634
641
  <Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
@@ -640,13 +647,13 @@ export const register: Register = (on, options) => {
640
647
 
641
648
  // Idle: a quiet line naming what one press would take back. It's always
642
649
  // there, because the button has to be on screen for the key to reach it;
643
- // without it, ctrl+z falls through to the terminal and suspends Claude Code.
650
+ // with nothing to undo, a press says so rather than doing nothing.
644
651
  return (
645
652
  <Box>
646
- <Text color="success">↺ </Text>
653
+ <Text bold>{MARK} </Text>
647
654
  <Text dimColor>{target ? `Last turn: ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
648
655
  <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
649
- {`${keysLabel()} to undo`}
656
+ {`${label} to undo`}
650
657
  </Button>
651
658
  </Box>
652
659
  )
@@ -26,6 +26,7 @@ export type Rewind = {
26
26
  declare module 'claude-code' {
27
27
  interface PluginState {
28
28
  // armed: when the undo key was first pressed; a second press within a few seconds undoes.
29
- backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null }
29
+ // keys: the keys bound to undo, kept here so the band redraws when they change.
30
+ backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[] }
30
31
  }
31
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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",