backstep 0.1.3 → 0.1.4
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 +2 -0
- package/dist/check.js +2 -1
- package/dist/cli.js +22 -0
- package/dist/keys.js +88 -0
- package/dist/setup.js +13 -31
- package/mod/hooks/register.test.tsx +84 -4
- package/mod/hooks/register.tsx +97 -9
- package/mod/types/index.d.ts +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,6 +53,8 @@ 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 ⌘Z and ctrl+z on a Mac (⌘Z where the terminal passes it on, as Ghostty, Kitty and WezTerm do; Terminal.app and iTerm2 keep ⌘Z for their own menu) and ctrl+z elsewhere. 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.
|
|
57
|
+
|
|
56
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).
|
|
57
59
|
|
|
58
60
|
Other commands: `backstep ui`, `backstep log`, `backstep undo <action-id>`, `backstep undo --run <run-id>`, `backstep connect [supabase|firebase]` (connects what the project uses, the same as asking Claude), `backstep disconnect supabase <ref>`.
|
package/dist/check.js
CHANGED
|
@@ -7,6 +7,7 @@ import { configServers, projectUses } from "./connect.js";
|
|
|
7
7
|
import { supabaseProjects } from "./supabase.js";
|
|
8
8
|
import { watchRules } from "./watch.js";
|
|
9
9
|
import { dim, pink, white, width, wrap } from "./look.js";
|
|
10
|
+
import { DEFAULT_KEY, keyLabel, undoKeys } from "./keys.js";
|
|
10
11
|
const WRAPPED = /(^|[/\s])(backstep|cli\.[jt]s) wrap\b/;
|
|
11
12
|
const SUPABASE = /supabase/i;
|
|
12
13
|
const FIREBASE = /firebase|firestore/i;
|
|
@@ -83,7 +84,7 @@ export function printCheck(c, bs = "backstep") {
|
|
|
83
84
|
const supabase = c.items.find((i) => SUPABASE.test(i.name));
|
|
84
85
|
const supabaseLeft = supabase?.level === "connect" || c.suggest.some((s) => s.title === "Connect Supabase");
|
|
85
86
|
const other = [...new Set(c.items.filter((i) => i.level === "recorded").map((i) => i.name))];
|
|
86
|
-
console.log(`\n ${white(
|
|
87
|
+
console.log(`\n ${white(`What ${(undoKeys().length ? undoKeys() : [DEFAULT_KEY]).map(keyLabel).join(" or ")} can undo`)}`);
|
|
87
88
|
console.log(` ${pink("✓")} Files Claude edits and commands it runs`);
|
|
88
89
|
console.log(` ${pink("✓")} Code Claude pushes to GitHub ${dim("(automatic, nothing to connect)")}`);
|
|
89
90
|
for (const n of undoable)
|
package/dist/cli.js
CHANGED
|
@@ -22,6 +22,7 @@ import { disconnectSupabase, dismissSupabase } from "./supabase.js";
|
|
|
22
22
|
import { connect } from "./connect.js";
|
|
23
23
|
import { check, printCheck } from "./check.js";
|
|
24
24
|
import { logo } from "./look.js";
|
|
25
|
+
import { keyLabel, setUndoKey, undoKeys } from "./keys.js";
|
|
25
26
|
import { checkUpdate, runUpdate, skipVersion } from "./update.js";
|
|
26
27
|
import { askConnectionString } from "./setup.js";
|
|
27
28
|
// Header values can name env vars, so tokens don't have to sit in agent config files.
|
|
@@ -60,6 +61,7 @@ const HELP = `backstep: undo for everything your agents touch
|
|
|
60
61
|
Supabase from the project's .env files or local database (or the
|
|
61
62
|
connection string you give), Firebase by adding its MCP server.
|
|
62
63
|
In Claude Code you can just ask: "connect Backstep to my Supabase"
|
|
64
|
+
backstep key [<key>] show the key that undoes Claude's last turn, or change it (e.g. backstep key ctrl+u)
|
|
63
65
|
backstep update install the newest version (Claude Code offers this as a session starts)
|
|
64
66
|
backstep status [--json] what Backstep can and can't undo for each service Claude Code reaches,
|
|
65
67
|
and what to connect next
|
|
@@ -212,6 +214,26 @@ async function main() {
|
|
|
212
214
|
}
|
|
213
215
|
return;
|
|
214
216
|
}
|
|
217
|
+
// The undo key: `backstep key` shows it, `backstep key ctrl+u` changes it (what Claude runs when asked).
|
|
218
|
+
if (cmd === "key") {
|
|
219
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { json: { type: "boolean", default: false } } });
|
|
220
|
+
if (!positionals[0]) {
|
|
221
|
+
const keys = undoKeys();
|
|
222
|
+
console.log(values.json ? JSON.stringify({ key: keys[0] ?? null, keys }) : keys.length ? `Undo is ${keys.map(keyLabel).join(" or ")}.` : "No undo key is bound. Run: backstep key ctrl+z");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const r = setUndoKey(positionals.join(" "));
|
|
227
|
+
console.log(values.json ? JSON.stringify({ ok: true, ...r }) : `Undo is now ${r.keys.map(keyLabel).join(" or ")}${r.previous && !r.keys.includes(r.previous) ? ` (was ${keyLabel(r.previous)})` : ""}.`);
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
if (!values.json)
|
|
231
|
+
throw err;
|
|
232
|
+
console.log(JSON.stringify({ ok: false, detail: err instanceof Error ? err.message : String(err) }));
|
|
233
|
+
process.exitCode = 1;
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
215
237
|
// Run by the Claude Code mod as a session starts: is there a newer Backstep?
|
|
216
238
|
if (cmd === "update-check") {
|
|
217
239
|
console.log(JSON.stringify(await checkUpdate()));
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The key that undoes Claude's last turn, in Claude Code's ~/.claude/keybindings.json.
|
|
2
|
+
// The mod's undo button borrows a keybinding action only the diff panel uses
|
|
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
|
|
5
|
+
// can ask Claude for another, which runs `backstep key <key>`.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
export const UNDO_ACTION = "app:cycleDiffBase";
|
|
10
|
+
export const DEFAULT_KEY = "ctrl+z";
|
|
11
|
+
// A Mac gets ⌘Z too, where the terminal passes it on (Ghostty, Kitty, WezTerm,
|
|
12
|
+
// Claude Code's desktop app); Terminal.app and iTerm2 keep ⌘Z for their own
|
|
13
|
+
// Edit menu, so ctrl+z stays bound alongside it.
|
|
14
|
+
export const defaultKeys = () => (process.platform === "darwin" ? ["cmd+z", DEFAULT_KEY] : [DEFAULT_KEY]);
|
|
15
|
+
const CONTEXTS = ["Global", "Chat"];
|
|
16
|
+
// Claude Code never hands these to anything else.
|
|
17
|
+
const RESERVED = new Set(["ctrl+c", "ctrl+d", "ctrl+m", "ctrl+i", "ctrl+h", "ctrl+[", "ctrl+\\"]);
|
|
18
|
+
const MODIFIERS = new Set(["ctrl", "control", "shift", "alt", "opt", "option", "meta", "cmd", "command", "super", "win"]);
|
|
19
|
+
const file = () => join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "keybindings.json");
|
|
20
|
+
function load() {
|
|
21
|
+
if (!existsSync(file()))
|
|
22
|
+
return { $schema: "https://www.schemastore.org/claude-code-keybindings.json", $docs: "https://code.claude.com/docs/en/keybindings" };
|
|
23
|
+
return JSON.parse(readFileSync(file(), "utf8"));
|
|
24
|
+
}
|
|
25
|
+
// Every key bound to undo now; empty when none is.
|
|
26
|
+
export function undoKeys() {
|
|
27
|
+
try {
|
|
28
|
+
const block = load().bindings?.find((b) => b.context === "Global");
|
|
29
|
+
return Object.entries(block?.bindings ?? {}).filter(([, a]) => a === UNDO_ACTION).map(([k]) => k);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export const undoKey = () => undoKeys()[0] ?? null;
|
|
36
|
+
// "Ctrl + U" → "ctrl+u". Throws with a reason a person can act on.
|
|
37
|
+
export function normalizeKey(raw) {
|
|
38
|
+
const key = raw.trim().toLowerCase().replace(/^⌘\s*\+?\s*/, "cmd+").replace(/\s*\+\s*/g, "+").replace(/^control\+/, "ctrl+").replace(/^(option|opt|alt)\+/, "meta+").replace(/^command\+/, "cmd+");
|
|
39
|
+
const parts = key.split("+");
|
|
40
|
+
const last = parts.at(-1) ?? "";
|
|
41
|
+
if (!last || parts.slice(0, -1).some((m) => !MODIFIERS.has(m)))
|
|
42
|
+
throw new Error(`"${raw}" isn't a key Claude Code understands. Try something like ctrl+u or meta+z`);
|
|
43
|
+
if (parts.length === 1)
|
|
44
|
+
throw new Error(`"${raw}" on its own would type a letter. Use a modifier, like ctrl+${last}`);
|
|
45
|
+
if (RESERVED.has(key))
|
|
46
|
+
throw new Error(`Claude Code keeps ${key} for itself. Pick another, like ctrl+u`);
|
|
47
|
+
return key;
|
|
48
|
+
}
|
|
49
|
+
// Binds undo to `keys` (one, or several) and unbinds the old ones. Refuses a key that does something else, unless it was undo's.
|
|
50
|
+
export function setUndoKey(raw) {
|
|
51
|
+
const keys = (Array.isArray(raw) ? raw : raw.split(/\s*(?:,|\s(?:or|and)\s)\s*/)).filter(Boolean).map(normalizeKey);
|
|
52
|
+
const key = keys[0];
|
|
53
|
+
if (!key)
|
|
54
|
+
throw new Error("give a key, like ctrl+u");
|
|
55
|
+
let config;
|
|
56
|
+
try {
|
|
57
|
+
config = load();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
throw new Error(`couldn't read ${file()}. Add "${key}": "${UNDO_ACTION}" under Global and Chat by hand`);
|
|
61
|
+
}
|
|
62
|
+
config.bindings ??= [];
|
|
63
|
+
const previous = undoKey();
|
|
64
|
+
for (const context of CONTEXTS) {
|
|
65
|
+
for (const k of keys) {
|
|
66
|
+
const taken = config.bindings.find((b) => b.context === context)?.bindings[k];
|
|
67
|
+
if (taken && taken !== UNDO_ACTION)
|
|
68
|
+
throw new Error(`${k} already does ${taken} in Claude Code. Pick another key`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const context of CONTEXTS) {
|
|
72
|
+
let block = config.bindings.find((b) => b.context === context);
|
|
73
|
+
if (!block)
|
|
74
|
+
config.bindings.push((block = { context, bindings: {} }));
|
|
75
|
+
for (const [k, a] of Object.entries(block.bindings))
|
|
76
|
+
if (a === UNDO_ACTION)
|
|
77
|
+
delete block.bindings[k];
|
|
78
|
+
for (const k of keys)
|
|
79
|
+
block.bindings[k] = UNDO_ACTION;
|
|
80
|
+
}
|
|
81
|
+
mkdirSync(join(file(), ".."), { recursive: true });
|
|
82
|
+
writeFileSync(file(), JSON.stringify(config, null, 2) + "\n");
|
|
83
|
+
return { key, keys, previous };
|
|
84
|
+
}
|
|
85
|
+
// How a key reads on screen: cmd+z → ⌘Z.
|
|
86
|
+
export function keyLabel(k) {
|
|
87
|
+
return k.startsWith("cmd+") ? `⌘${k.slice(4).toUpperCase()}` : k;
|
|
88
|
+
}
|
package/dist/setup.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { cpSync,
|
|
3
|
-
import { homedir } from "node:os";
|
|
2
|
+
import { cpSync, readFileSync, rmSync } from "node:fs";
|
|
4
3
|
import { dirname, join } from "node:path";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
6
5
|
import { homeDir } from "./store.js";
|
|
6
|
+
import { defaultKeys, keyLabel, setUndoKey, undoKey, 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";
|
|
@@ -11,9 +11,6 @@ import { dim, logo, pink, white, width, wrap } from "./look.js";
|
|
|
11
11
|
import { sendInstall, telemetryOn } from "./telemetry.js";
|
|
12
12
|
// Both src/ and dist/ sit one level below the package root, next to mod/.
|
|
13
13
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
-
const UNDO_KEY = "ctrl+z";
|
|
15
|
-
// The mod's undo button borrows this action; see mod/hooks/register.tsx.
|
|
16
|
-
const UNDO_ACTION = "app:cycleDiffBase";
|
|
17
14
|
function run(cmd, args) {
|
|
18
15
|
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
19
16
|
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim(), missing: r.error !== undefined };
|
|
@@ -32,34 +29,17 @@ function keepCopy() {
|
|
|
32
29
|
cpSync(dirname(ROOT), modules, { recursive: true });
|
|
33
30
|
return { root: join(modules, "backstep"), onPath: false };
|
|
34
31
|
}
|
|
32
|
+
// Keeps a key the person picked; on a first install binds ⌘Z and ctrl+z on a Mac, ctrl+z elsewhere.
|
|
35
33
|
function bindUndoKey() {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
};
|
|
42
|
-
if (existsSync(file)) {
|
|
43
|
-
try {
|
|
44
|
-
config = JSON.parse(readFileSync(file, "utf8"));
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return `couldn't read ${file}, so ${UNDO_KEY} isn't bound. Add "${UNDO_KEY}": "${UNDO_ACTION}" under Global and Chat.`;
|
|
48
|
-
}
|
|
34
|
+
if (undoKey())
|
|
35
|
+
return "";
|
|
36
|
+
try {
|
|
37
|
+
setUndoKey(defaultKeys());
|
|
38
|
+
return "";
|
|
49
39
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
let block = config.bindings.find((b) => b.context === context);
|
|
53
|
-
if (!block)
|
|
54
|
-
config.bindings.push((block = { context, bindings: {} }));
|
|
55
|
-
const taken = block.bindings[UNDO_KEY];
|
|
56
|
-
if (taken && taken !== UNDO_ACTION)
|
|
57
|
-
return `${UNDO_KEY} is already bound to ${taken} in ${file}; left it alone.`;
|
|
58
|
-
block.bindings[UNDO_KEY] = UNDO_ACTION;
|
|
40
|
+
catch (err) {
|
|
41
|
+
return err instanceof Error ? err.message : String(err);
|
|
59
42
|
}
|
|
60
|
-
mkdirSync(dir, { recursive: true });
|
|
61
|
-
writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
|
|
62
|
-
return "";
|
|
63
43
|
}
|
|
64
44
|
const WHERE = `In Supabase, open your project, press Connect at the top, and copy the URI under
|
|
65
45
|
"Direct connection" (or "Session pooler" if your network has no IPv6). Put your
|
|
@@ -107,7 +87,9 @@ export async function setup() {
|
|
|
107
87
|
autoWrap(true);
|
|
108
88
|
wrapServers(cli);
|
|
109
89
|
// What's covered and what isn't, said once here and kept up to date by `backstep status`.
|
|
110
|
-
|
|
90
|
+
const key = (undoKeys().length ? undoKeys() : defaultKeys()).map(keyLabel).join(" or ");
|
|
91
|
+
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"`));
|
|
111
93
|
printCheck(check(process.cwd()), bs);
|
|
112
94
|
if (telemetryOn()) {
|
|
113
95
|
console.log(dim(" Sends an anonymous install count. Turn it off with BACKSTEP_TELEMETRY=0.\n"));
|
|
@@ -65,6 +65,7 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
|
|
|
65
65
|
await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
|
|
66
66
|
|
|
67
67
|
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
68
|
+
await ui.press({ key: 'undo' })
|
|
68
69
|
const pressed = ui.press({ key: 'undo' })
|
|
69
70
|
await clock.settle()
|
|
70
71
|
expect((await ui.find({ type: 'Text', text: /Rewinding/ }))?.text).toBeTruthy()
|
|
@@ -77,7 +78,7 @@ test('pressing undo rewinds the turn and tells Claude on the next prompt', { opt
|
|
|
77
78
|
|
|
78
79
|
// The band folds away, and with the turn undone nothing is left to offer.
|
|
79
80
|
await clock.advance(5000)
|
|
80
|
-
expect(await ui.find({ type: '
|
|
81
|
+
expect(await ui.find({ type: 'Text', text: /nothing to undo yet/ })).toBeTruthy()
|
|
81
82
|
await ui.unmount()
|
|
82
83
|
|
|
83
84
|
await $.prompt.submit({ text: 'try again', wait: false, origin: { kind: 'composer' } })
|
|
@@ -104,6 +105,7 @@ test('a change that cannot be undone stops the rewind and says so', { options: {
|
|
|
104
105
|
await $.tool.call({ tool: 'mcp__db__query', sql: 'update t set x = 1' } as never)
|
|
105
106
|
|
|
106
107
|
const ui = await $.ui.mount({ ...BAND, surface: 'desktop' })
|
|
108
|
+
await ui.press({ key: 'undo' })
|
|
107
109
|
const pressed = ui.press({ key: 'undo' })
|
|
108
110
|
await clock.advance(1000)
|
|
109
111
|
await pressed
|
|
@@ -117,14 +119,21 @@ test('a change that cannot be undone stops the rewind and says so', { options: {
|
|
|
117
119
|
expect(prompts).toHaveLength(1)
|
|
118
120
|
})
|
|
119
121
|
|
|
120
|
-
test('with nothing to undo the band
|
|
122
|
+
test('with nothing to undo the band still holds the key, so ctrl+z never suspends Claude Code', async ($, on) => {
|
|
121
123
|
engine(on, { exitCode: 0, stdout: '[]' })
|
|
124
|
+
const toasts: string[] = []
|
|
125
|
+
on('ui.toast', (_, e) => {
|
|
126
|
+
toasts.push(e.text)
|
|
127
|
+
return { value: undefined }
|
|
128
|
+
})
|
|
122
129
|
|
|
123
130
|
await $.prompt.submit({ text: 'hi', wait: false, origin: { kind: 'composer' } })
|
|
124
131
|
await $.tool.call({ tool: 'Bash', command: 'ls' })
|
|
125
132
|
|
|
126
133
|
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
127
|
-
expect((await ui.find({ type: 'Text' }))
|
|
134
|
+
expect((await ui.find({ type: 'Text', text: /nothing to undo yet/ }))).toBeTruthy()
|
|
135
|
+
await ui.press({ key: 'undo' })
|
|
136
|
+
expect(toasts[0]).toContain('Nothing to undo yet')
|
|
128
137
|
await ui.unmount()
|
|
129
138
|
})
|
|
130
139
|
|
|
@@ -181,9 +190,10 @@ test('servers added since setup are wrapped as a session starts', { options: { c
|
|
|
181
190
|
|
|
182
191
|
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
183
192
|
await new Promise((r) => setTimeout(r, 10))
|
|
184
|
-
expect(tools).toEqual(['connect'])
|
|
193
|
+
expect(tools).toEqual(['connect', 'undo_key'])
|
|
185
194
|
expect(logs[0]).toContain('Backstep connected')
|
|
186
195
|
expect(runs).toEqual([
|
|
196
|
+
['node', '--disable-warning=ExperimentalWarning', CLI, 'key', '--json'],
|
|
187
197
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'wrap-servers', '--project', '/repo', '--json'],
|
|
188
198
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'connect', '--dir', '/repo', '--json', '--auto'],
|
|
189
199
|
['node', '--disable-warning=ExperimentalWarning', CLI, 'ping'],
|
|
@@ -248,6 +258,7 @@ test('a connector call with an undo rule is undone through Claude Code', { optio
|
|
|
248
258
|
|
|
249
259
|
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
250
260
|
expect((await ui.find({ type: 'Text', text: /Last turn/ }))?.text).toContain('1 tool action')
|
|
261
|
+
await ui.press({ key: 'undo' })
|
|
251
262
|
const pressed = ui.press({ key: 'undo' })
|
|
252
263
|
await clock.advance(1000)
|
|
253
264
|
await pressed
|
|
@@ -265,6 +276,7 @@ test('when the opposite call fails, the rewind says so', { options: { cli: CLI }
|
|
|
265
276
|
await $.prompt.submit({ text: 'make a page', wait: false, origin: { kind: 'composer' } })
|
|
266
277
|
await $.tool.call({ tool: 'mcp__claude_ai_Notion__create-page', title: 'Plan' } as never)
|
|
267
278
|
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
279
|
+
await ui.press({ key: 'undo' })
|
|
268
280
|
const pressed = ui.press({ key: 'undo' })
|
|
269
281
|
await clock.advance(1000)
|
|
270
282
|
await pressed
|
|
@@ -424,6 +436,7 @@ test('after undoing a turn that pushed, Backstep asks and pushes the undo', { op
|
|
|
424
436
|
expect(runs.at(-1)?.argv.slice(3)).toEqual(['shell-end', 'p1'])
|
|
425
437
|
|
|
426
438
|
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
439
|
+
await ui.press({ key: 'undo' })
|
|
427
440
|
const pressed = ui.press({ key: 'undo' })
|
|
428
441
|
await clock.advance(1000)
|
|
429
442
|
await pressed
|
|
@@ -484,3 +497,70 @@ test('Skip this version stops asking about it, and Not now changes nothing', { o
|
|
|
484
497
|
expect(runs.some((r) => r.slice(3).join(' ') === 'update --skip 0.2.0')).toBe(true)
|
|
485
498
|
expect(runs.some((r) => r.includes('--version'))).toBe(false)
|
|
486
499
|
})
|
|
500
|
+
|
|
501
|
+
test('asking Claude to change the undo key rebinds it and relabels the band', { options: { cli: CLI } }, async ($, on) => {
|
|
502
|
+
const runs: string[][] = []
|
|
503
|
+
on('process.run', (_, e) => {
|
|
504
|
+
runs.push([...e.argv])
|
|
505
|
+
const stdout = e.argv.includes('key') ? JSON.stringify({ ok: true, key: 'ctrl+u', previous: 'ctrl+z' }) : '[]'
|
|
506
|
+
return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
507
|
+
})
|
|
508
|
+
on('ui.toast', () => ({ value: undefined }))
|
|
509
|
+
on('ui.render', ($, e) => {
|
|
510
|
+
const { Text } = $.ui.resolve(e)
|
|
511
|
+
return <Text>engine</Text>
|
|
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.')
|
|
516
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
517
|
+
expect((await ui.find({ type: 'Button', key: 'undo' }))?.text).toBe('ctrl+u to undo')
|
|
518
|
+
await ui.unmount()
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
test('one press only asks; it lets go after a few seconds, or on cancel, and undoes nothing', { options: { cli: CLI } }, async ($, on) => {
|
|
522
|
+
const clock = mock.clock(on, { now: 5000 })
|
|
523
|
+
const { runs } = engine(on, { exitCode: 0, stdout: '[]' })
|
|
524
|
+
|
|
525
|
+
await $.prompt.submit({ text: 'fix it', wait: false, origin: { kind: 'composer' } })
|
|
526
|
+
await $.tool.call({ tool: 'Edit', file_path: '/repo/src/a.ts', old_string: 'a', new_string: 'b' })
|
|
527
|
+
const ui = await $.ui.mount({ ...BAND, surface: 'terminal' })
|
|
528
|
+
|
|
529
|
+
await ui.press({ key: 'undo' })
|
|
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')
|
|
532
|
+
await clock.advance(6000)
|
|
533
|
+
expect((await ui.find({ type: 'Text', text: /Last turn/ }))).toBeTruthy()
|
|
534
|
+
|
|
535
|
+
await ui.press({ key: 'undo' })
|
|
536
|
+
await ui.press({ key: 'cancel' })
|
|
537
|
+
expect((await ui.find({ type: 'Text', text: /Last turn/ }))).toBeTruthy()
|
|
538
|
+
// A press after the question let go asks again rather than undoing.
|
|
539
|
+
await ui.press({ key: 'undo' })
|
|
540
|
+
expect(runs.some((r) => r.includes('undo'))).toBe(false)
|
|
541
|
+
await ui.unmount()
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
test('on a Mac the band names both keys', { options: { cli: CLI } }, async ($, on) => {
|
|
545
|
+
on('process.run', (_, e) => {
|
|
546
|
+
const stdout = e.argv.includes('key') ? JSON.stringify({ key: 'cmd+z', keys: ['cmd+z', 'ctrl+z'] }) : '[]'
|
|
547
|
+
return { value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
548
|
+
})
|
|
549
|
+
const logs: string[] = []
|
|
550
|
+
on('ui.log', (_, e) => {
|
|
551
|
+
logs.push(e.text)
|
|
552
|
+
return { value: undefined }
|
|
553
|
+
})
|
|
554
|
+
on('ui.toast', () => ({ value: undefined }))
|
|
555
|
+
on('tool.register', (_, e) => ({ value: { tool: `mcp__backstep__${e.name}` } }))
|
|
556
|
+
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
557
|
+
on('ui.render', ($, e) => {
|
|
558
|
+
const { Text } = $.ui.resolve(e)
|
|
559
|
+
return <Text>engine</Text>
|
|
560
|
+
})
|
|
561
|
+
await $.session.start({ cwd: '/repo', surface: 'terminal', hasPerson: true } as never)
|
|
562
|
+
expect(logs[0]).toContain('press ⌘Z / ctrl+z to undo')
|
|
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')
|
|
565
|
+
await ui.unmount()
|
|
566
|
+
})
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -12,6 +12,9 @@ import type { Rewind, RewindItem, Turn } from '../types'
|
|
|
12
12
|
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
|
+
const armed = atom({ plugin: 'backstep', key: 'armed' } as const, null as number | null)
|
|
16
|
+
// How long the "press again to undo" question waits before it lets go.
|
|
17
|
+
const CONFIRM_MS = 5000
|
|
15
18
|
|
|
16
19
|
// The engine has no keybinding actions of its own for plugins yet, so the undo
|
|
17
20
|
// button borrows one that only the diff panel handles; ~/.claude/keybindings.json
|
|
@@ -80,6 +83,28 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
// The first press asks, right there above the prompt; a second press within a
|
|
87
|
+
// few seconds undoes. A dialog would take the band off screen, and with it the
|
|
88
|
+
// key, which would then suspend Claude Code in a terminal.
|
|
89
|
+
async function pressUndo($: EngineInterface) {
|
|
90
|
+
if (await read($, rewind)) return
|
|
91
|
+
const target = (await read($, turns)).findLast(hasWork)
|
|
92
|
+
if (!target) {
|
|
93
|
+
$.ui.toast('Nothing to undo yet: Claude hasn\'t changed anything Backstep can take back.')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const at = await read($, armed)
|
|
97
|
+
const now = await $.clock.now()
|
|
98
|
+
if (at !== null && now - at < CONFIRM_MS) {
|
|
99
|
+
await update($, armed, () => null)
|
|
100
|
+
return undoLastTurn($)
|
|
101
|
+
}
|
|
102
|
+
await update($, armed, () => now)
|
|
103
|
+
$.clock.after(CONFIRM_MS, () => {
|
|
104
|
+
void update($, armed, (a) => (a === now ? null : a))
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
83
108
|
async function undoLastTurn($: EngineInterface) {
|
|
84
109
|
if (await read($, rewind)) return
|
|
85
110
|
const target = (await read($, turns)).findLast(hasWork)
|
|
@@ -162,7 +187,7 @@ async function undoLastTurn($: EngineInterface) {
|
|
|
162
187
|
if (done) {
|
|
163
188
|
const list = items.filter((i) => i.ok).map((i) => `- ${i.label}`).join('\n')
|
|
164
189
|
await update($, note, () =>
|
|
165
|
-
`[Backstep] The user pressed undo (
|
|
190
|
+
`[Backstep] The user pressed undo (${keysLabel()}) on your previous turn. These changes were reverted:\n${list}\n` +
|
|
166
191
|
(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` : '') +
|
|
167
192
|
`Treat them as not having happened, re-read files before editing them, and don't redo them unless asked.`,
|
|
168
193
|
)
|
|
@@ -250,6 +275,10 @@ type ShellPlan = { kind: 'read' | 'write' | 'outbound'; reason?: string; id?: st
|
|
|
250
275
|
|
|
251
276
|
// Set from the plugin's options each time the module registers.
|
|
252
277
|
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
|
+
const keyLabel = (k: string) => (k.startsWith('cmd+') ? `⌘${k.slice(4).toUpperCase()}` : k)
|
|
281
|
+
const keysLabel = () => undoKeys.map(keyLabel).join(' / ')
|
|
253
282
|
|
|
254
283
|
function backstep(args: string[]): string[] {
|
|
255
284
|
return cli === 'backstep' ? ['backstep', ...args] : ['node', '--disable-warning=ExperimentalWarning', cli, ...args]
|
|
@@ -385,6 +414,39 @@ async function offerUpdate($: EngineInterface) {
|
|
|
385
414
|
$.ui.toast(r.ok ? `Backstep ${r.detail}. Restart Claude Code to use it.` : `Backstep couldn't update: ${r.detail}. Try: npx backstep@latest setup`)
|
|
386
415
|
}
|
|
387
416
|
|
|
417
|
+
const KEY = 'mcp__backstep__undo_key'
|
|
418
|
+
|
|
419
|
+
const KEY_TOOL = {
|
|
420
|
+
name: 'undo_key',
|
|
421
|
+
description:
|
|
422
|
+
"Change the key that undoes Claude's last turn with Backstep (⌘Z and ctrl+z on a Mac, ctrl+z elsewhere, unless the user changed it). " +
|
|
423
|
+
'Use it when the user asks to change, rebind or pick a different Backstep or undo shortcut. ' +
|
|
424
|
+
'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
|
+
'Several keys can share undo: separate them with commas. It takes effect right away, no restart.',
|
|
426
|
+
inputSchema: {
|
|
427
|
+
type: 'object',
|
|
428
|
+
properties: { key: { type: 'string', description: 'the new key, like ctrl+u, or several separated by commas, like "cmd+z, ctrl+z"' } },
|
|
429
|
+
required: ['key'],
|
|
430
|
+
},
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function changeKey($: EngineInterface, key: string): Promise<string> {
|
|
434
|
+
const ran = await $.process
|
|
435
|
+
.run(backstep(['key', key, '--json']))
|
|
436
|
+
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
437
|
+
let r: { ok?: boolean; key?: string; keys?: string[]; previous?: string | null; detail?: string } = {}
|
|
438
|
+
try {
|
|
439
|
+
r = JSON.parse(ran.stdout || '{}') as typeof r
|
|
440
|
+
} catch {
|
|
441
|
+
r = { ok: false, detail: ran.stderr.trim() || 'it failed' }
|
|
442
|
+
}
|
|
443
|
+
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
|
+
}
|
|
449
|
+
|
|
388
450
|
const CONNECT = 'mcp__backstep__connect'
|
|
389
451
|
|
|
390
452
|
const CONNECT_TOOL = {
|
|
@@ -435,9 +497,17 @@ export const register: Register = (on, options) => {
|
|
|
435
497
|
|
|
436
498
|
on('session.start', async ($, e, next) => {
|
|
437
499
|
await $.tool.register(CONNECT_TOOL)
|
|
500
|
+
await $.tool.register(KEY_TOOL)
|
|
438
501
|
const r = await next(e)
|
|
502
|
+
const bound = await $.process.run(backstep(['key', '--json'])).catch(() => null)
|
|
503
|
+
try {
|
|
504
|
+
const keys = (JSON.parse(bound?.stdout || '{}') as { keys?: string[] }).keys
|
|
505
|
+
if (keys?.length) undoKeys = keys
|
|
506
|
+
} catch {
|
|
507
|
+
// keep the default
|
|
508
|
+
}
|
|
439
509
|
// Said first thing, so nobody has to wonder whether it's running.
|
|
440
|
-
$.ui.log(
|
|
510
|
+
$.ui.log(`✓ Backstep connected · press ${keysLabel()} to undo Claude's last turn`)
|
|
441
511
|
// In the background: a session never waits on either.
|
|
442
512
|
void wrapNewServers($, e.cwd)
|
|
443
513
|
// One question at a time: Supabase first, then any update.
|
|
@@ -458,11 +528,13 @@ export const register: Register = (on, options) => {
|
|
|
458
528
|
return next({ ...e, context: [...(e.context ?? []), pending] })
|
|
459
529
|
})
|
|
460
530
|
|
|
531
|
+
on('tool.call', { tool: KEY }, async ($, e) => ({ result: await changeKey($, String((e as { key?: unknown }).key ?? '')) }))
|
|
532
|
+
|
|
461
533
|
on('tool.call', { tool: CONNECT }, async ($, e) => ({ result: await connectServices($, e as { service?: string; connection_string?: string; project_ref?: string }) }))
|
|
462
534
|
|
|
463
535
|
on('tool.call', async ($, e, next) => {
|
|
464
536
|
const tool = String(e.tool)
|
|
465
|
-
if (tool === CONNECT) return next(e)
|
|
537
|
+
if (tool === CONNECT || tool === KEY) return next(e)
|
|
466
538
|
if (EDIT_TOOLS.has(tool)) {
|
|
467
539
|
const input = e as { file_path?: string; notebook_path?: string }
|
|
468
540
|
const path = input.file_path ?? input.notebook_path
|
|
@@ -549,16 +621,32 @@ export const register: Register = (on, options) => {
|
|
|
549
621
|
)
|
|
550
622
|
}
|
|
551
623
|
|
|
552
|
-
// Idle: a quiet line naming what one press would take back. The button
|
|
553
|
-
// has to be on screen for the chord to reach it.
|
|
554
624
|
const target = (await read($, turns)).findLast(hasWork)
|
|
555
|
-
if (
|
|
625
|
+
if (target && (await read($, armed)) !== null) {
|
|
626
|
+
return (
|
|
627
|
+
<Box>
|
|
628
|
+
<Text color="warning" bold>↺ Undo Claude's last turn? </Text>
|
|
629
|
+
<Text dimColor>{summary(target)} </Text>
|
|
630
|
+
<Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
|
|
631
|
+
{`${keysLabel()} again to undo`}
|
|
632
|
+
</Button>
|
|
633
|
+
<Text dimColor> · </Text>
|
|
634
|
+
<Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
|
|
635
|
+
cancel
|
|
636
|
+
</Button>
|
|
637
|
+
</Box>
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Idle: a quiet line naming what one press would take back. It's always
|
|
642
|
+
// 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.
|
|
556
644
|
return (
|
|
557
645
|
<Box>
|
|
558
646
|
<Text color="success">↺ </Text>
|
|
559
|
-
<Text dimColor>Last turn: {summary(target)} </Text>
|
|
560
|
-
<Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void
|
|
561
|
-
|
|
647
|
+
<Text dimColor>{target ? `Last turn: ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
|
|
648
|
+
<Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
|
|
649
|
+
{`${keysLabel()} to undo`}
|
|
562
650
|
</Button>
|
|
563
651
|
</Box>
|
|
564
652
|
)
|
package/mod/types/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export type Rewind = {
|
|
|
25
25
|
|
|
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
30
|
}
|
|
30
31
|
}
|