backstep 0.1.14 → 0.1.17

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
@@ -25,10 +25,16 @@ Undo for everything your agents touch. Backstep sits between your agent (Claude
25
25
  npx backstep setup
26
26
  ```
27
27
 
28
- 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.
28
+ 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. Databases (Supabase, Postgres, Firebase) aren't touched at setup: the first time Claude goes to change one, Backstep asks whether to connect it. Restart Claude Code afterwards. Needs Node 22.13+ and Claude Code.
29
29
 
30
30
  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.
31
31
 
32
+ ## Uninstall
33
+
34
+ In Claude Code, type **/uninstall-backstep**, or just ask Claude to uninstall Backstep. From a terminal: `backstep uninstall` (or `npx backstep uninstall`).
35
+
36
+ It asks first, then takes away everything Backstep set up: your MCP servers go back to their own commands, the database copies Backstep saved are dropped (and its `backstep` schema, only if that leaves it empty), the undo key comes out of Claude Code's keybindings, the plugin is removed, and `~/.backstep` (history, snapshots, saved connections) is deleted. Nothing you undid or kept changes. Restart Claude Code to finish.
37
+
32
38
  ## Try it
33
39
 
34
40
  ```sh
@@ -112,7 +118,7 @@ Everything is journaled to `~/.backstep/journal.db` (SQLite, mode 0600; override
112
118
 
113
119
  ## Telemetry
114
120
 
115
- `backstep setup` sends one anonymous `install` event, and Claude Code with the Backstep mod sends one `active` event a day. Each carries a random id made on your machine (`~/.backstep/install-id`), the Backstep version, your OS and CPU type, and the Node major version. Nothing else: no paths, commands, server names, IP-based location, or anything from the journal. The code is `src/telemetry.ts`.
121
+ `backstep setup` sends one anonymous `install` event, Claude Code with the Backstep mod sends one `active` event a day, and `backstep uninstall` sends one `uninstall` event. Each carries a random id made on your machine (`~/.backstep/install-id`), the Backstep version, your OS and CPU type, and the Node major version. Nothing else: no paths, commands, server names, IP-based location, or anything from the journal. The code is `src/telemetry.ts`.
116
122
 
117
123
  Turn it off with `BACKSTEP_TELEMETRY=0`, or the standard `DO_NOT_TRACK=1`.
118
124
 
@@ -263,3 +263,11 @@ async function dropCopies(c, u) {
263
263
  export async function discardDb(u) {
264
264
  await withClient(u.url, (c) => dropCopies(c, u)).catch(() => { });
265
265
  }
266
+ // On uninstall, after every copy is dropped: removes Backstep's schema, but only
267
+ // if it's empty (no CASCADE), so a schema of the project's own by that name is never touched.
268
+ export async function dropSnapSchema(url) {
269
+ return withClient(url, async (c) => {
270
+ await c.query(`DROP SCHEMA IF EXISTS ${q(SNAP_SCHEMA)}`);
271
+ return true;
272
+ }).catch(() => false);
273
+ }
@@ -24,6 +24,13 @@ function sizeOf(p, budget) {
24
24
  return total;
25
25
  }
26
26
  // Returns null when a path is too large to copy; the caller then can't promise undo.
27
+ // Whether a path sits in the folder Claude Code has open. Edits elsewhere belong to
28
+ // other projects: Backstep never records them, so undo here can never touch them.
29
+ export function insideDir(path, dir) {
30
+ const p = resolvePath(path, dir);
31
+ const d = resolve(dir).replace(/\/$/, "");
32
+ return p === d || p.startsWith(d + "/");
33
+ }
27
34
  export function snapshotPaths(actionId, paths, cwd) {
28
35
  const dir = snapshotDir(actionId);
29
36
  const seen = new Set();
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { runProxy } from "./proxy.js";
5
5
  import { startUi } from "./ui/server.js";
6
6
  import { Store, snapshotDir } from "./store.js";
7
7
  import { pruneDatabaseCopies, undoAction, undoRun, undoSince } from "./undo.js";
8
- import { snapshotPaths } from "./adapters/fs.js";
8
+ import { insideDir, snapshotPaths } from "./adapters/fs.js";
9
9
  import { notePushed, snapshotSqlite, snapshotTree } from "./adapters/tree.js";
10
10
  import { revertPush } from "./push.js";
11
11
  import { ghMade, grantScope, undoGh } from "./github.js";
@@ -24,8 +24,16 @@ import { autoWrap, printChanges, unwrapServers, wrapServers } from "./servers.js
24
24
  import { disconnectSupabase, dismissSupabase, firebaseConn, postgresUrl, restKey } from "./supabase.js";
25
25
  // The Supabase projects a shell command could change: those in the folder it
26
26
  // works in (or a level or two below), when the command is one that reaches a database.
27
+ // A command that `cd`s into another project is out of scope: only the open folder is tracked.
28
+ function scopedDir(command, cwd) {
29
+ const d = commandDir(command, cwd);
30
+ return insideDir(d, cwd) ? d : null;
31
+ }
27
32
  function reachedProjects(command, cwd) {
28
- const dirs = supabaseDirs(commandDir(command, cwd));
33
+ const at = scopedDir(command, cwd);
34
+ if (!at)
35
+ return [];
36
+ const dirs = supabaseDirs(at);
29
37
  return touchesDatabase(command) || touchesSupabase(command, dirs.length > 0) ? dirs : [];
30
38
  }
31
39
  // Every database a shell command could change, by folder: Supabase, other Postgres, Firebase.
@@ -33,7 +41,8 @@ function reachedDatabases(command, cwd) {
33
41
  const out = [];
34
42
  for (const d of reachedProjects(command, cwd))
35
43
  out.push({ dir: d, service: "supabase" });
36
- for (const d of dbDirs(commandDir(command, cwd))) {
44
+ const at = scopedDir(command, cwd);
45
+ for (const d of at ? dbDirs(at) : []) {
37
46
  const u = projectUses(d);
38
47
  if (u.postgres && (touchesDatabase(command) || touchesSupabase(command, true)))
39
48
  out.push({ dir: d, service: "postgres" });
@@ -79,6 +88,8 @@ const HELP = `backstep: undo for everything your agents touch
79
88
  backstep setup install the Claude Code mod, bind ctrl+u to undo Claude's last turn,
80
89
  and put your MCP servers behind Backstep
81
90
  backstep unwrap take your MCP servers back out from behind Backstep
91
+ backstep uninstall [--yes] remove Backstep and everything it set up (in Claude Code: /uninstall-backstep,
92
+ or ask Claude to uninstall Backstep)
82
93
  backstep connect [supabase|firebase] [<access-token>|<connection-string>] [--project <ref>] [--dir <folder>] [--json]
83
94
  connect what the project uses, so Claude's changes to it can be undone:
84
95
  Supabase from the project's .env files or local database (or the
@@ -157,6 +168,36 @@ async function main() {
157
168
  await sendActive();
158
169
  return;
159
170
  }
171
+ if (cmd === "uninstall") {
172
+ const { values } = parseArgs({ args: rest, options: { yes: { type: "boolean", default: false }, json: { type: "boolean", default: false } } });
173
+ if (!values.yes) {
174
+ if (!process.stdin.isTTY)
175
+ throw new Error("run it with --yes to confirm: backstep uninstall --yes");
176
+ const { createInterface } = await import("node:readline/promises");
177
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
178
+ const answer = (await rl.question("This removes Backstep from this computer: your MCP servers go back to how they were, the undo key\n" +
179
+ "and the Claude Code plugin are removed, and Backstep's history and saved copies are deleted\n" +
180
+ "(nothing you undid or kept is changed). Remove Backstep? [y/N] ")).trim();
181
+ rl.close();
182
+ if (!/^y(es)?$/i.test(answer)) {
183
+ console.log("Kept Backstep.");
184
+ return;
185
+ }
186
+ }
187
+ const { uninstall } = await import("./uninstall.js");
188
+ const removed = await uninstall();
189
+ if (values.json) {
190
+ console.log(JSON.stringify(removed));
191
+ }
192
+ else {
193
+ for (const r of removed)
194
+ console.log(` ${r.ok ? "✓" : "!"} ${r.step}: ${r.detail}`);
195
+ console.log(`\nBackstep is removed.${removed.every((r) => r.ok) ? "" : " The lines marked ! need a look."} Restart Claude Code to finish.`);
196
+ }
197
+ if (removed.some((r) => !r.ok))
198
+ process.exitCode = 1;
199
+ return;
200
+ }
160
201
  if (cmd === "unwrap") {
161
202
  autoWrap(false);
162
203
  const changes = unwrapServers();
@@ -307,10 +348,17 @@ async function main() {
307
348
  args: sep === -1 ? rest : rest.slice(0, sep),
308
349
  options: { session: { type: "string" }, tool: { type: "string" }, cwd: { type: "string" } },
309
350
  });
310
- const paths = sep === -1 ? [] : rest.slice(sep + 1);
311
- if (!values.session || !values.tool || paths.length === 0) {
351
+ const asked = sep === -1 ? [] : rest.slice(sep + 1);
352
+ if (!values.session || !values.tool || asked.length === 0) {
312
353
  throw new Error("usage: backstep snap --session <id> --tool <name> [--cwd <dir>] -- <paths...>");
313
354
  }
355
+ // Claude Code names the folder it has open: a file in another project is left out entirely.
356
+ const scope = values.cwd;
357
+ const paths = scope ? asked.filter((p) => insideDir(p, scope)) : asked;
358
+ if (paths.length === 0) {
359
+ console.log(JSON.stringify({ outside: true, undoable: false }));
360
+ return;
361
+ }
314
362
  const store = new Store();
315
363
  const run = store.sessionRun("claude-code", `session:${values.session}`);
316
364
  const a = store.addAction({
@@ -541,11 +589,12 @@ async function main() {
541
589
  until: { type: "string" },
542
590
  json: { type: "boolean", default: false },
543
591
  "via-claude-code": { type: "boolean", default: false },
592
+ session: { type: "string" },
544
593
  },
545
594
  });
546
595
  const store = new Store();
547
596
  const outcomes = values.since
548
- ? await undoSince(store, Number(values.since), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"] })
597
+ ? await undoSince(store, Number(values.since), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"], session: values.session })
549
598
  : values.run
550
599
  ? await undoRun(store, values.run)
551
600
  : await undoAction(store, positionals[0] ?? "");
package/dist/connect.js CHANGED
@@ -354,26 +354,16 @@ async function supabase(dir, given, ref, interactive = false, auto = false) {
354
354
  `Or the database's connection string (${WHERE})${errors.length ? `. Tried the one in the project's settings: ${errors[0]}` : ""}`,
355
355
  };
356
356
  }
357
- // firebase-tools keeps its sign-in here once someone runs `firebase login`.
358
- function firebaseSignedIn() {
359
- try {
360
- const c = JSON.parse(read(join(homedir(), ".config", "configstore", "firebase-tools.json")));
361
- return Boolean(c.tokens?.refresh_token);
362
- }
363
- catch {
364
- return false;
365
- }
366
- }
367
- // `auto`: on its own as a session starts, so a server is only added when it would work.
357
+ // `auto`: as a session starts. Nothing is added then: like Supabase, Firebase is
358
+ // only set up once Claude goes to change it and the person says Connect.
368
359
  function firebase(dir, cli, auto = false) {
369
360
  if (servers(dir).some((s) => /firebase|firestore/i.test(s))) {
370
361
  return { service: "firebase", state: "covered", detail: "Claude reaches Firebase through its MCP server, so its changes can be undone" };
371
362
  }
372
363
  if (!projectUses(dir).firebase)
373
364
  return { service: "firebase", state: "unused", detail: "this project doesn't use Firebase" };
374
- if (auto && !firebaseSignedIn()) {
375
- return { service: "firebase", state: "needs", detail: "sign in to Firebase once (run: npx firebase-tools login), and Backstep connects it by itself" };
376
- }
365
+ if (auto)
366
+ return { service: "firebase", state: "unused", detail: "set up the first time Claude goes to change Firebase" };
377
367
  // For this project only, like `claude mcp add` without -s.
378
368
  const r = spawnSync("claude", ["mcp", "add", "firebase", "--", ...FIREBASE_SERVER], { cwd: dir, encoding: "utf8" });
379
369
  if (r.status !== 0) {
package/dist/keys.js CHANGED
@@ -84,6 +84,24 @@ export function setUndoKey(raw) {
84
84
  writeFileSync(file(), JSON.stringify(config, null, 2) + "\n");
85
85
  return { key, keys, previous };
86
86
  }
87
+ // On uninstall: takes every key bound to Backstep's undo back out. Returns the keys removed.
88
+ export function removeUndoKeys() {
89
+ if (!existsSync(file()))
90
+ return [];
91
+ const config = load();
92
+ const removed = new Set();
93
+ for (const block of config.bindings ?? []) {
94
+ for (const [k, a] of Object.entries(block.bindings)) {
95
+ if (a === UNDO_ACTION) {
96
+ delete block.bindings[k];
97
+ removed.add(k);
98
+ }
99
+ }
100
+ }
101
+ if (removed.size)
102
+ writeFileSync(file(), JSON.stringify(config, null, 2) + "\n");
103
+ return [...removed];
104
+ }
87
105
  // How a key reads on screen: cmd+z → ⌘Z.
88
106
  export function keyLabel(k) {
89
107
  return k.startsWith("cmd+") ? `⌘${k.slice(4).toUpperCase()}` : k;
package/dist/setup.js CHANGED
@@ -78,6 +78,7 @@ export async function setup() {
78
78
  const key = (undoKeys().length ? undoKeys() : defaultKeys()).map(keyLabel).join(" or ");
79
79
  console.log(`\n ${pink("✓")} ${white("Backstep is ready.")} Restart Claude Code, then press ${white(key)} after any turn to undo it.`);
80
80
  console.log(dim(` Want a different key? Just ask Claude: "change my Backstep undo key to ctrl+q"`));
81
+ console.log(dim(` To remove Backstep, type /uninstall-backstep in Claude Code, or ask Claude to uninstall it.`));
81
82
  printCheck(check(process.cwd()), bs);
82
83
  if (telemetryOn()) {
83
84
  console.log(dim(" Sends an anonymous install count. Turn it off with BACKSTEP_TELEMETRY=0.\n"));
package/dist/telemetry.js CHANGED
@@ -63,6 +63,7 @@ async function send(event) {
63
63
  }
64
64
  }
65
65
  export const sendInstall = () => send("install");
66
+ export const sendUninstall = () => send("uninstall");
66
67
  // At most once a day, as Claude Code starts with Backstep's mod.
67
68
  export async function sendActive() {
68
69
  if (!telemetryOn())
package/dist/undo.js CHANGED
@@ -201,9 +201,21 @@ export async function undoRun(store, runId) {
201
201
  // Everything any agent did in a window of time: what one turn of a chat did,
202
202
  // across Claude's own edits and every server wrapped by Backstep.
203
203
  export async function undoSince(store, since, until = Date.now(), opts = {}) {
204
- const inWindow = store.db
204
+ let inWindow = store.db
205
205
  .prepare("SELECT * FROM actions WHERE created_at >= ? AND created_at <= ? ORDER BY created_at, seq")
206
206
  .all(since, until);
207
+ if (opts.session) {
208
+ const mine = `session:${opts.session}`;
209
+ const commands = new Map();
210
+ inWindow = inWindow.filter((a) => {
211
+ if (!commands.has(a.run_id))
212
+ commands.set(a.run_id, store.getRun(a.run_id)?.command);
213
+ const command = commands.get(a.run_id);
214
+ // Another session's run: never ours to undo. Servers wrapped by `backstep wrap`
215
+ // record under their own runs and stay included, as before.
216
+ return !(command?.startsWith("session:") && command !== mine);
217
+ });
218
+ }
207
219
  return undoAll(store, inWindow.filter(undoable), opts);
208
220
  }
209
221
  async function undoAll(store, actions, opts = {}) {
@@ -0,0 +1,105 @@
1
+ // Takes Backstep off this computer: everything setup and use put in place, in
2
+ // the order that never leaves something pointing at a Backstep that's gone.
3
+ // 1. MCP servers wrapped behind Backstep get their own commands back
4
+ // 2. the database copies Backstep made are dropped, and its schema if empty
5
+ // 3. the undo key comes out of Claude Code's keybindings
6
+ // 4. the Claude Code plugin and its source are removed
7
+ // 5. ~/.backstep (history, snapshots, saved connections) is deleted
8
+ // 6. the global npm package, when setup installed one
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync, rmSync } from "node:fs";
11
+ import { homeDir, Store } from "./store.js";
12
+ import { unwrapServers } from "./servers.js";
13
+ import { removeUndoKeys } from "./keys.js";
14
+ import { discardDb, dropSnapSchema } from "./adapters/dbsnap.js";
15
+ import { dbKey, freshUrl } from "./supabase.js";
16
+ import { sendUninstall } from "./telemetry.js";
17
+ const run = (cmd, args) => {
18
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
19
+ return { ok: r.status === 0, missing: Boolean(r.error), out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
20
+ };
21
+ const plural = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
22
+ async function dropDatabaseCopies() {
23
+ if (!existsSync(homeDir()))
24
+ return null;
25
+ const store = new Store();
26
+ const urls = new Map();
27
+ let dropped = 0;
28
+ for (const a of store.databaseCopies()) {
29
+ const u = JSON.parse(a.undo);
30
+ for (const d of u.dbs ?? []) {
31
+ const url = await freshUrl(d.url).catch(() => d.url);
32
+ await discardDb({ ...d, url });
33
+ urls.set(dbKey(d.url), url);
34
+ dropped++;
35
+ }
36
+ }
37
+ if (urls.size === 0)
38
+ return null;
39
+ let schemas = 0;
40
+ for (const url of urls.values())
41
+ if (await dropSnapSchema(url))
42
+ schemas++;
43
+ return {
44
+ step: "database copies",
45
+ ok: schemas === urls.size,
46
+ detail: `dropped ${plural(dropped, "saved copy")} from ${plural(urls.size, "database")}` +
47
+ (schemas < urls.size ? `; couldn't reach ${urls.size - schemas} of them, so a "backstep" schema may be left there to drop by hand` : ""),
48
+ };
49
+ }
50
+ export async function uninstall() {
51
+ const out = [];
52
+ // Counted before anything is deleted (the install id lives in ~/.backstep).
53
+ await sendUninstall();
54
+ const servers = unwrapServers();
55
+ const restored = servers.filter((c) => c.done).length;
56
+ const kept = servers.filter((c) => !c.done && !/nothing to put back/.test(c.note));
57
+ if (servers.length) {
58
+ out.push({
59
+ step: "MCP servers",
60
+ ok: kept.length === 0,
61
+ detail: `gave ${plural(restored, "server")} back ${restored === 1 ? "its" : "their"} own command` +
62
+ (kept.length ? `; left ${kept.map((c) => `${c.name} (${c.note})`).join(", ")}` : ""),
63
+ });
64
+ }
65
+ try {
66
+ const db = await dropDatabaseCopies();
67
+ if (db)
68
+ out.push(db);
69
+ }
70
+ catch (err) {
71
+ out.push({ step: "database copies", ok: false, detail: err instanceof Error ? err.message : String(err) });
72
+ }
73
+ try {
74
+ const keys = removeUndoKeys();
75
+ if (keys.length)
76
+ out.push({ step: "undo key", ok: true, detail: `unbound ${keys.join(", ")}` });
77
+ }
78
+ catch (err) {
79
+ out.push({ step: "undo key", ok: false, detail: `couldn't edit Claude Code's keybindings: ${err instanceof Error ? err.message : String(err)}` });
80
+ }
81
+ const plugin = run("claude", ["plugin", "uninstall", "backstep@backstep"]);
82
+ const market = run("claude", ["plugin", "marketplace", "remove", "backstep"]);
83
+ if (plugin.missing) {
84
+ out.push({ step: "Claude Code plugin", ok: false, detail: "Claude Code isn't on the PATH: remove the plugin with /plugin inside Claude Code" });
85
+ }
86
+ else {
87
+ const gone = plugin.ok || /not (installed|found)/i.test(plugin.out);
88
+ out.push({ step: "Claude Code plugin", ok: gone, detail: gone ? `removed${market.ok ? ", with its source" : ""}` : plugin.out.split("\n").at(-1) ?? "failed" });
89
+ }
90
+ if (existsSync(homeDir())) {
91
+ try {
92
+ rmSync(homeDir(), { recursive: true, force: true });
93
+ out.push({ step: "Backstep's folder", ok: true, detail: `deleted ${homeDir()} (history, snapshots, saved connections)` });
94
+ }
95
+ catch (err) {
96
+ out.push({ step: "Backstep's folder", ok: false, detail: `couldn't delete ${homeDir()}: ${err instanceof Error ? err.message : String(err)}` });
97
+ }
98
+ }
99
+ // Last: this may be the copy that's running.
100
+ if (run("npm", ["ls", "-g", "backstep", "--depth=0"]).ok) {
101
+ const npm = run("npm", ["uninstall", "-g", "backstep"]);
102
+ out.push({ step: "backstep command", ok: npm.ok, detail: npm.ok ? "removed the global npm package" : "run: npm uninstall -g backstep" });
103
+ }
104
+ return out;
105
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.14",
3
+ "version": "0.1.17",
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": {
@@ -60,6 +60,39 @@ test('an edit is snapshotted and the band offers to undo the turn', { options: {
60
60
  }
61
61
  })
62
62
 
63
+ test('edits outside the open folder are never tracked', { options: { cli: CLI } }, async ($, on) => {
64
+ mock.clock(on, { now: 5000 })
65
+ const { runs } = engine(on, { exitCode: 0, stdout: '[]' })
66
+
67
+ await $.prompt.submit({ text: 'fix the other project', wait: false, origin: { kind: 'composer' } })
68
+ await $.tool.call({ tool: 'Write', file_path: '/other-project/src/new.ts', content: 'x' })
69
+ await $.tool.call({ tool: 'Edit', file_path: '/repo-sibling/a.ts', old_string: 'a', new_string: 'b' })
70
+ await $.tool.call({ tool: 'Edit', file_path: '/repo/../other-project/a.ts', old_string: 'a', new_string: 'b' })
71
+
72
+ expect(runs.filter((r) => r.includes('snap'))).toEqual([])
73
+ const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
74
+ expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))?.text).toBeTruthy()
75
+ await ui.unmount()
76
+ })
77
+
78
+ test('a long last turn stays on one line with a fixed gap after the logo', { options: { cli: CLI } }, async ($, on) => {
79
+ mock.clock(on, { now: 5000 })
80
+ engine(on, { exitCode: 0, stdout: '[]' }, { kind: 'write', undoable: true })
81
+
82
+ await $.prompt.submit({ text: 'scrap that, lets try making the whole thing much longer than fits', wait: false, origin: { kind: 'composer' } })
83
+ for (const f of ['a', 'b', 'c']) await $.tool.call({ tool: 'Edit', file_path: `/repo/src/${f}.ts`, old_string: 'a', new_string: 'b' })
84
+ for (let i = 0; i < 8; i++) await $.tool.call({ tool: 'Bash', command: `touch x${i}` })
85
+
86
+ const ui = await $.ui.mount({ ...BAND, props: { ...BAND.props, bodyColumns: 60 }, surface: 'terminal' })
87
+ const line = await ui.find({ type: 'Text', text: /Last turn/ })
88
+ // No leading space to be trimmed on a wrap: the gap is the logo box's own margin.
89
+ expect(line?.text).toMatch(/^Last turn:/)
90
+ expect(line?.props.wrap).toBe('truncate-end')
91
+ expect(await ui.find({ type: 'Box', props: { flexShrink: 0, marginRight: 1 } })).toBeTruthy()
92
+ expect(await ui.find({ type: 'Button', key: 'undo' })).toBeTruthy()
93
+ await ui.unmount()
94
+ })
95
+
63
96
  test('pressing undo rewinds the turn and tells Claude on the next prompt', { options: { cli: CLI } }, async ($, on) => {
64
97
  const clock = mock.clock(on, { now: 5000 })
65
98
  const { runs, prompts } = engine(on, {
@@ -75,7 +108,7 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
75
108
  const pressed = ui.press({ key: 'undo' })
76
109
  await clock.settle()
77
110
  expect((await ui.find({ type: 'Text', text: /Rewinding/ }))?.text).toBeTruthy()
78
- expect(runs.at(-1)).toEqual(['node', '--disable-warning=ExperimentalWarning', CLI, 'undo', '--since', '5000', '--json', '--via-claude-code'])
111
+ expect(runs.at(-1)).toEqual(['node', '--disable-warning=ExperimentalWarning', CLI, 'undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code'])
79
112
 
80
113
  await clock.advance(1000)
81
114
  await pressed
@@ -141,7 +174,8 @@ test('with nothing to undo the band still holds the key, and a press says there
141
174
 
142
175
  const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
143
176
  expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))).toBeTruthy()
144
- // The mark: the front triangle in the terminal's own color, the one behind in the logo's pink.
177
+ // The gap after the mark is its own fixed margin, so a long line can't squeeze it away.
178
+ expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))?.text).toMatch(/^Backstep is on/) // The mark: the front triangle in the terminal's own color, the one behind in the logo's pink.
145
179
  expect((await ui.find({ type: 'Text', text: '◀', props: { color: '#ee7bb6' } }))).toBeTruthy()
146
180
  await ui.press({ key: 'undo' })
147
181
  expect(toasts[0]).toContain('Nothing to undo yet')
@@ -201,14 +235,13 @@ test('servers added since setup are wrapped as a session starts', { options: { c
201
235
 
202
236
  await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
203
237
  await new Promise((r) => setTimeout(r, 10))
204
- expect(tools).toEqual(['connect', 'undo_key'])
238
+ expect(tools).toEqual(['connect', 'undo_key', 'uninstall'])
205
239
  expect(logs[0]).toContain('Backstep connected')
206
240
  expect(runs).toEqual([
207
241
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'key', '--json'],
208
242
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'wrap-servers', '--project', '/repo', '--json'],
209
- ['node', '--disable-warning=ExperimentalWarning', CLI, 'connect', '--dir', '/repo', '--json', '--auto'],
210
- ['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
211
243
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'status', '--quick', '--json', '--dir', '/repo'],
244
+ ['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
212
245
  ['node', '--disable-warning=ExperimentalWarning', CLI, 'update-check'],
213
246
  ])
214
247
  expect(toasts.find((t) => t.includes('linear is now behind Backstep'))).toBeTruthy()
@@ -352,6 +385,8 @@ test('asking Claude to connect runs backstep connect, with the connection string
352
385
  expect(toasts).toHaveLength(1)
353
386
  })
354
387
 
388
+ const settle = () => new Promise((r) => setTimeout(r, 20))
389
+
355
390
  // A project that uses Supabase, not connected: Backstep asks, and only the answer connects it.
356
391
  function supabaseProject(on: On, answer: string, connectResult: object) {
357
392
  const runs: string[][] = []
@@ -359,7 +394,11 @@ function supabaseProject(on: On, answer: string, connectResult: object) {
359
394
  const asked: string[] = []
360
395
  on('process.run', (_, e) => {
361
396
  runs.push([...e.argv])
362
- const stdout = e.argv.includes('--auto')
397
+ const stdout = e.argv.includes('db-check')
398
+ ? JSON.stringify({ ask: '/repo', service: 'supabase' })
399
+ : e.argv.includes('shell')
400
+ ? JSON.stringify({ kind: 'write', undoable: true })
401
+ : e.argv.includes('--auto')
363
402
  ? JSON.stringify([{ service: 'supabase', state: 'ask', detail: "this project uses Supabase, but Backstep isn't connected to it" }])
364
403
  : e.argv.includes('status')
365
404
  ? JSON.stringify({ items: [], suggest: [] })
@@ -379,15 +418,32 @@ function supabaseProject(on: On, answer: string, connectResult: object) {
379
418
  return { result: { questions, answers: { [questions[0].question]: answer } } }
380
419
  })
381
420
  on('session.start', (_, e) => ({ cwd: e.cwd }))
421
+ on('session.cwd', () => ({ value: '/repo' }))
422
+ on('session.id', () => ({ value: 's1' }))
423
+ on('tool.call', () => ({ result: {} }))
382
424
  return { runs, toasts, asked }
383
425
  }
384
426
 
385
- const settle = () => new Promise((r) => setTimeout(r, 20))
427
+ // Claude is about to change the database: the moment Backstep asks.
428
+ async function changeDatabase($: Parameters<Parameters<typeof test>[2]>[0], on: On) {
429
+ on('ui.log', () => ({ value: undefined }))
430
+ mock.clock(on, { now: 5000 })
431
+ await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
432
+ await settle()
433
+ await $.tool.call({ tool: 'Bash', command: 'supabase db push' })
434
+ await settle()
435
+ }
386
436
 
387
- test('a Supabase project that is not connected is asked about, and Connect connects it', { options: { cli: CLI } }, async ($, on) => {
388
- const { runs, toasts, asked } = supabaseProject(on, 'Connect', { service: 'supabase', state: 'connected', detail: 'connected project abc' })
437
+ test('opening a session never asks about a database, even with Supabase projects below the folder', { options: { cli: CLI } }, async ($, on) => {
438
+ const { asked } = supabaseProject(on, 'Connect', {})
389
439
  await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
390
440
  await settle()
441
+ expect(asked).toHaveLength(0)
442
+ })
443
+
444
+ test('a Supabase project that is not connected is asked about when Claude goes to change it, and Connect connects it', { options: { cli: CLI } }, async ($, on) => {
445
+ const { runs, toasts, asked } = supabaseProject(on, 'Connect', { service: 'supabase', state: 'connected', detail: 'connected project abc' })
446
+ await changeDatabase($, on)
391
447
  expect(asked).toHaveLength(1)
392
448
  expect(runs.some((r) => r.slice(3).join(' ') === 'connect supabase --dir /repo --json')).toBe(true)
393
449
  expect(toasts.find((t) => t.includes('connected project abc'))).toBeTruthy()
@@ -395,15 +451,13 @@ test('a Supabase project that is not connected is asked about, and Connect conne
395
451
 
396
452
  test('Not now connects nothing', { options: { cli: CLI } }, async ($, on) => {
397
453
  const { runs } = supabaseProject(on, 'Not now', {})
398
- await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
399
- await settle()
454
+ await changeDatabase($, on)
400
455
  expect(runs.filter((r) => r.includes('connect') && !r.includes('--auto'))).toHaveLength(0)
401
456
  })
402
457
 
403
458
  test("Don't ask for this project remembers it", { options: { cli: CLI } }, async ($, on) => {
404
459
  const { runs } = supabaseProject(on, "Don't ask for this project", {})
405
- await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
406
- await settle()
460
+ await changeDatabase($, on)
407
461
  expect(runs.some((r) => r.slice(3).join(' ') === 'connect supabase --dir /repo --dismiss')).toBe(true)
408
462
  })
409
463
 
@@ -490,9 +544,22 @@ function updates(on: On, answer: string) {
490
544
  return { result: { questions, answers: { [questions[0].question]: answer } } }
491
545
  })
492
546
  on('session.start', (_, e) => ({ cwd: e.cwd }))
547
+ on('session.cwd', () => ({ value: '/repo' }))
548
+ on('session.id', () => ({ value: 's1' }))
549
+ on('tool.call', () => ({ result: {} }))
493
550
  return { runs, toasts, asked }
494
551
  }
495
552
 
553
+ // Claude is about to change the database: the moment Backstep asks.
554
+ async function changeDatabase($: Parameters<Parameters<typeof test>[2]>[0], on: On) {
555
+ on('ui.log', () => ({ value: undefined }))
556
+ mock.clock(on, { now: 5000 })
557
+ await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
558
+ await settle()
559
+ await $.tool.call({ tool: 'Bash', command: 'supabase db push' })
560
+ await settle()
561
+ }
562
+
496
563
  test('a newer version is offered as a session starts, and Update installs it', { options: { cli: CLI } }, async ($, on) => {
497
564
  const { runs, toasts, asked } = updates(on, 'Update')
498
565
  await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
@@ -666,7 +733,7 @@ test('when the last turn only committed and pushed, undo offers to take back the
666
733
  await clock.advance(1000)
667
734
  await pressed
668
735
  // Both turns: everything since the first one started.
669
- expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--json', '--via-claude-code'])
736
+ expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code'])
670
737
  await ui.unmount()
671
738
  })
672
739
 
@@ -792,3 +859,39 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
792
859
  expect(logs.find((l) => l.includes('Backstep undid'))).not.toContain('changes GitHub')
793
860
  await ui.unmount()
794
861
  })
862
+
863
+ // Uninstalling: asked first, and only the answer removes anything.
864
+ function uninstallSetup(on: On, answer: string) {
865
+ const runs: string[][] = []
866
+ const asked: string[] = []
867
+ on('session.cwd', () => ({ value: '/repo' }))
868
+ on('session.id', () => ({ value: 's1' }))
869
+ on('ui.toast', () => ({ value: undefined }))
870
+ on('process.run', (_, e) => {
871
+ runs.push([...e.argv])
872
+ const stdout = e.argv.includes('uninstall') ? JSON.stringify([{ step: 'Claude Code plugin', ok: true, detail: 'removed' }]) : ''
873
+ return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
874
+ })
875
+ on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
876
+ const questions = (e as unknown as { questions: { question: string }[] }).questions
877
+ asked.push(questions[0].question)
878
+ return { result: { questions, answers: { [questions[0].question]: answer } } }
879
+ })
880
+ return { runs, asked }
881
+ }
882
+
883
+ test('/uninstall-backstep asks, then removes Backstep', { options: { cli: CLI } }, async ($, on) => {
884
+ const { runs, asked } = uninstallSetup(on, 'Remove Backstep')
885
+ const r = await $.command.run({ command: 'uninstall-backstep' } as never)
886
+ expect(asked).toHaveLength(1)
887
+ expect(runs.at(-1)?.slice(3)).toEqual(['uninstall', '--yes', '--json'])
888
+ expect(JSON.stringify(r)).toContain('Backstep is removed')
889
+ })
890
+
891
+ test('asking Claude to uninstall asks too, and Keep it removes nothing', { options: { cli: CLI } }, async ($, on) => {
892
+ const { runs, asked } = uninstallSetup(on, 'Keep it')
893
+ const r = await $.tool.call({ tool: 'mcp__backstep__uninstall' } as never)
894
+ expect(asked).toHaveLength(1)
895
+ expect(runs.filter((a) => a.includes('uninstall'))).toHaveLength(0)
896
+ expect(r.result).toBe('Kept Backstep: nothing was removed.')
897
+ })
@@ -183,6 +183,19 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone =
183
183
  // The first press asks, right there above the prompt; a second press within a
184
184
  // few seconds undoes. A dialog would take the band off screen, and with it the
185
185
  // key, which would then suspend Claude Code in a terminal.
186
+ // Whether a file path is inside the folder this session has open.
187
+ function within(path: string, cwd: string): boolean {
188
+ const abs = path.startsWith('/') ? path : `${cwd}/${path}`
189
+ const parts: string[] = []
190
+ for (const seg of abs.split('/')) {
191
+ if (seg === '..') parts.pop()
192
+ else if (seg && seg !== '.') parts.push(seg)
193
+ }
194
+ const p = '/' + parts.join('/')
195
+ const d = cwd.replace(/\/+$/, '')
196
+ return p === d || p.startsWith(d + '/')
197
+ }
198
+
186
199
  async function pressUndo($: EngineInterface) {
187
200
  if (await read($, rewind)) return
188
201
  const target = (await read($, turns)).findLast(hasWork)
@@ -218,7 +231,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
218
231
  const cwd = await $.session.cwd()
219
232
  const short = (p: string) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p)
220
233
  const [ran] = await Promise.all([
221
- $.process.run(backstep(['undo', '--since', String(target.startedAt), '--json', '--via-claude-code'])).catch((err: unknown) => ({
234
+ $.process.run(backstep(['undo', '--since', String(target.startedAt), '--session', await $.session.id(), '--json', '--via-claude-code'])).catch((err: unknown) => ({
222
235
  exitCode: 1,
223
236
  stdout: '',
224
237
  stderr: err instanceof Error ? err.message : String(err),
@@ -476,17 +489,9 @@ async function wrapNewServers($: EngineInterface, cwd: string) {
476
489
  // says what Claude reaches that Backstep could undo once connected, so nobody
477
490
  // presses undo expecting it to come back.
478
491
  async function checkCoverage($: EngineInterface, cwd: string) {
479
- // Supabase is only ever noticed here, never connected: that's the person's
480
- // call. Firebase is added once someone is signed in to it.
481
- const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
482
- try {
483
- for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
484
- if (c.state === 'ask') await askDatabase($, c.dir ?? cwd, c.service)
485
- if (c.state === 'added') $.ui.toast('Backstep added Firebase for this project, so Claude can reach it and its changes can be undone. Restart Claude Code to use it.')
486
- }
487
- } catch {
488
- // Backstep couldn't be reached: nothing to say.
489
- }
492
+ // Nothing is asked or set up here: a database (Supabase, Postgres, Firebase)
493
+ // is only asked about once Claude is about to change it (see the Bash hook),
494
+ // not just because a project in or below this folder happens to use one.
490
495
  const ran = await $.process.run(backstep(['status', '--quick', '--json', '--dir', cwd])).catch(() => null)
491
496
  let items: { name: string; level: string; detail: string }[] = []
492
497
  try {
@@ -512,9 +517,9 @@ const asked = new Set<string>()
512
517
  // How each database is named in the question, and what the answer connects.
513
518
  const DB_NAME: Record<string, string> = { supabase: 'Supabase', postgres: 'a Postgres database', firebase: 'Firebase' }
514
519
 
515
- // Asked when Claude Code opens a project that uses a database (Supabase, other
516
- // Postgres, Firebase), or Claude is about to change one, and Backstep isn't
517
- // connected to it; the answer is the only thing that connects it.
520
+ // Asked when Claude is about to change a database (Supabase, other Postgres,
521
+ // Firebase) that Backstep isn't connected to; never just because a project uses
522
+ // one. The answer is the only thing that connects it.
518
523
  async function askDatabase($: EngineInterface, cwd: string, service = 'supabase') {
519
524
  const key = `${service}:${cwd}`
520
525
  if (asked.has(key)) return
@@ -663,12 +668,56 @@ async function connectServices($: EngineInterface, input: { service?: string; co
663
668
  return out.map((s) => `${s.service === 'supabase' ? 'Supabase' : 'Firebase'} (${s.state}): ${s.detail}`).join('\n')
664
669
  }
665
670
 
671
+ const UNINSTALL = 'mcp__backstep__uninstall'
672
+
673
+ const UNINSTALL_TOOL = {
674
+ name: 'uninstall',
675
+ description:
676
+ 'Remove Backstep (the undo for this session) from this computer: puts MCP servers back as they were, drops its saved database copies, ' +
677
+ "removes the undo key and the Claude Code plugin, and deletes Backstep's history. Use it only when the user asks to uninstall, remove or delete Backstep. " +
678
+ 'The user is asked to confirm first; nothing else is needed.',
679
+ inputSchema: { type: 'object', properties: {} },
680
+ }
681
+
682
+ const UNINSTALL_ASK = { yes: 'Remove Backstep', no: 'Keep it' }
683
+
684
+ // Asked first, every time: the answer is the only thing that removes it.
685
+ async function uninstallBackstep($: EngineInterface): Promise<string> {
686
+ const answer = await $.ui
687
+ .ask(
688
+ "Remove Backstep from this computer? Your MCP servers go back to how they were, and the undo key, the plugin and Backstep's history are removed. " +
689
+ "Nothing you've undone or kept changes, but Claude's turns can't be undone after this.",
690
+ {
691
+ header: 'Backstep',
692
+ options: [UNINSTALL_ASK.yes, UNINSTALL_ASK.no],
693
+ },
694
+ )
695
+ .catch(() => null)
696
+ if (answer !== UNINSTALL_ASK.yes) return 'Kept Backstep: nothing was removed.'
697
+ const ran = await $.process
698
+ .run(backstep(['uninstall', '--yes', '--json']), { timeoutMs: 180_000 })
699
+ .catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
700
+ let removed: { step: string; ok: boolean; detail: string }[] = []
701
+ try {
702
+ removed = JSON.parse(ran.stdout || '[]') as typeof removed
703
+ } catch {
704
+ removed = []
705
+ }
706
+ if (removed.length === 0) return `Backstep couldn't uninstall itself: ${(ran.stderr || ran.stdout).trim().split('\n').at(-1) || 'it failed'}. Run: backstep uninstall`
707
+ const lines = removed.map((r) => `${r.ok ? '✓' : '!'} ${r.step}: ${r.detail}`).join('\n')
708
+ $.ui.toast('Backstep is removed. Restart Claude Code to finish.')
709
+ return `Backstep is removed. Restart Claude Code to finish.\n${lines}`
710
+ }
711
+
666
712
  export const register: Register = (on, options) => {
667
713
  cli = String(options.cli ?? 'backstep')
668
714
 
669
715
  on('session.start', async ($, e, next) => {
670
716
  await $.tool.register(CONNECT_TOOL)
671
717
  await $.tool.register(KEY_TOOL)
718
+ await $.tool.register(UNINSTALL_TOOL)
719
+ // A command that can't be added never holds up the session: asking Claude still uninstalls.
720
+ await $.command.register({ name: 'uninstall-backstep', description: 'Remove Backstep and everything it set up from this computer' }).catch(() => null)
672
721
  const r = await next(e)
673
722
  const boundOut = await $.process.run(backstep(['key', '--json'])).catch(() => null)
674
723
  try {
@@ -706,15 +755,21 @@ export const register: Register = (on, options) => {
706
755
 
707
756
  on('tool.call', { tool: CONNECT }, async ($, e) => ({ result: await connectServices($, e as { service?: string; connection_string?: string; project_ref?: string }) }))
708
757
 
758
+ on('tool.call', { tool: UNINSTALL }, async ($) => ({ result: await uninstallBackstep($) }))
759
+
760
+ on('command.run', { command: 'uninstall-backstep' }, async ($) => ({ text: await uninstallBackstep($) }))
761
+
709
762
  on('tool.call', async ($, e, next) => {
710
763
  const tool = String(e.tool)
711
- if (tool === CONNECT || tool === KEY) return next(e)
764
+ if (tool === CONNECT || tool === KEY || tool === UNINSTALL) return next(e)
712
765
  if (EDIT_TOOLS.has(tool)) {
713
766
  const input = e as { file_path?: string; notebook_path?: string }
714
767
  const path = input.file_path ?? input.notebook_path
715
- if (path) {
768
+ const cwd = await $.session.cwd()
769
+ // Only the open folder is tracked: an edit in another project is never
770
+ // recorded, so undoing here can never delete or roll back files there.
771
+ if (path && within(path, cwd)) {
716
772
  const t = await current($)
717
- const cwd = await $.session.cwd()
718
773
  const sid = await $.session.id()
719
774
  // A failed snapshot never blocks the edit; it just won't be undoable.
720
775
  const snap = await $.process
@@ -778,14 +833,21 @@ export const register: Register = (on, options) => {
778
833
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
779
834
  if (e.props.hasSurvey) return next(e)
780
835
  const { Box, Button, Text } = $.ui.resolve(e)
836
+ // The logo keeps its own box with a fixed gap after it: a space inside the
837
+ // text next to it gets trimmed when a long line wraps, crushing the two together.
838
+ const mark = () => (
839
+ <Box flexShrink={0} marginRight={1}>
840
+ <Text bold>◀</Text>
841
+ <Text color={PINK} bold>◀</Text>
842
+ </Box>
843
+ )
781
844
  const r = await read($, rewind)
782
845
 
783
846
  if (r?.phase === 'running') {
784
847
  return (
785
848
  <Box>
786
- <Text bold>◀</Text>
787
- <Text color={PINK} bold>◀</Text>
788
- <Text bold> Rewinding </Text>
849
+ {mark()}
850
+ <Text bold>Rewinding </Text>
789
851
  <Text>{bar(r.frame)}</Text>
790
852
  <Text dimColor> undoing the last turn…</Text>
791
853
  </Box>
@@ -797,9 +859,8 @@ export const register: Register = (on, options) => {
797
859
  const step = waiting ? (
798
860
  <Box flexDirection="column" marginTop={r ? 1 : 0}>
799
861
  <Box>
800
- <Text bold>◀</Text>
801
- <Text color={PINK} bold>◀</Text>
802
- <Text color="warning" bold>{` One more step to ${waiting.what}:`}</Text>
862
+ {mark()}
863
+ <Text color="warning" bold>{`One more step to ${waiting.what}:`}</Text>
803
864
  </Box>
804
865
  <Text>{' Paste this code on the GitHub page that just opened, then press Authorize:'}</Text>
805
866
  <Box>
@@ -858,9 +919,8 @@ export const register: Register = (on, options) => {
858
919
  return (
859
920
  <Box flexDirection="column">
860
921
  <Box>
861
- <Text bold>◀</Text>
862
- <Text color={PINK} bold>◀</Text>
863
- <Text color="warning" bold>{' That turn only saved and pushed to git.'}</Text>
922
+ {mark()}
923
+ <Text color="warning" bold>{'That turn only saved and pushed to git.'}</Text>
864
924
  </Box>
865
925
  <Text dimColor>{` Undo it and the turn before it${quoted(before, 50)}?`}</Text>
866
926
  <Box>
@@ -881,9 +941,8 @@ export const register: Register = (on, options) => {
881
941
  return (
882
942
  <Box flexDirection="column">
883
943
  <Box>
884
- <Text bold>◀</Text>
885
- <Text color={PINK} bold>◀</Text>
886
- <Text color="warning" bold>{" Undo Claude's last turn?"}</Text>
944
+ {mark()}
945
+ <Text color="warning" bold>{"Undo Claude's last turn?"}</Text>
887
946
  </Box>
888
947
  <Text dimColor>{` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
889
948
  <Box>
@@ -903,13 +962,16 @@ export const register: Register = (on, options) => {
903
962
  // with nothing to undo, a press says so rather than doing nothing.
904
963
  return (
905
964
  <Box>
906
- <Text bold>◀</Text>
907
- <Text color={PINK} bold>◀</Text>
908
- <Text> </Text>
909
- <Text dimColor>{target ? `Last turn:${quoted(target, 28)} · ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
910
- <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
911
- {`${label} to undo`}
912
- </Button>
965
+ {mark()}
966
+ {/* One line: a long summary is cut short so the key to press always shows. */}
967
+ <Box flexShrink={1} minWidth={0}>
968
+ <Text dimColor wrap="truncate-end">{target ? `Last turn:${quoted(target, 28)} · ${summary(target)}` : 'Backstep is on · nothing to undo yet'}</Text>
969
+ </Box>
970
+ <Box flexShrink={0} marginLeft={2}>
971
+ <Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
972
+ {`${label} to undo`}
973
+ </Button>
974
+ </Box>
913
975
  </Box>
914
976
  )
915
977
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backstep",
3
- "version": "0.1.14",
3
+ "version": "0.1.17",
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",