backstep 0.1.13 → 0.1.16
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/dist/adapters/fs.js +7 -0
- package/dist/cli.js +25 -7
- package/dist/github.js +4 -2
- package/dist/undo.js +13 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +62 -6
- package/mod/hooks/register.tsx +104 -24
- package/mod/types/index.d.ts +5 -1
- package/package.json +1 -1
package/dist/adapters/fs.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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" });
|
|
@@ -307,10 +316,17 @@ async function main() {
|
|
|
307
316
|
args: sep === -1 ? rest : rest.slice(0, sep),
|
|
308
317
|
options: { session: { type: "string" }, tool: { type: "string" }, cwd: { type: "string" } },
|
|
309
318
|
});
|
|
310
|
-
const
|
|
311
|
-
if (!values.session || !values.tool ||
|
|
319
|
+
const asked = sep === -1 ? [] : rest.slice(sep + 1);
|
|
320
|
+
if (!values.session || !values.tool || asked.length === 0) {
|
|
312
321
|
throw new Error("usage: backstep snap --session <id> --tool <name> [--cwd <dir>] -- <paths...>");
|
|
313
322
|
}
|
|
323
|
+
// Claude Code names the folder it has open: a file in another project is left out entirely.
|
|
324
|
+
const scope = values.cwd;
|
|
325
|
+
const paths = scope ? asked.filter((p) => insideDir(p, scope)) : asked;
|
|
326
|
+
if (paths.length === 0) {
|
|
327
|
+
console.log(JSON.stringify({ outside: true, undoable: false }));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
314
330
|
const store = new Store();
|
|
315
331
|
const run = store.sessionRun("claude-code", `session:${values.session}`);
|
|
316
332
|
const a = store.addAction({
|
|
@@ -464,8 +480,9 @@ async function main() {
|
|
|
464
480
|
}
|
|
465
481
|
// Takes away something Claude made on GitHub (the thing, as undo reported it, on stdin). The mod runs this after asking.
|
|
466
482
|
// Gets the person's OK on GitHub for another gh permission: code on the clipboard, approval page opened.
|
|
483
|
+
// The code is printed on a line of its own straight away, so the mod can show it; the answer is the last line.
|
|
467
484
|
if (cmd === "gh-auth") {
|
|
468
|
-
const out = await grantScope(rest[0] ?? "");
|
|
485
|
+
const out = await grantScope(rest[0] ?? "", (code) => console.log(JSON.stringify({ code, url: "https://github.com/login/device" })));
|
|
469
486
|
console.log(JSON.stringify(out));
|
|
470
487
|
if (!out.ok)
|
|
471
488
|
process.exitCode = 1;
|
|
@@ -540,11 +557,12 @@ async function main() {
|
|
|
540
557
|
until: { type: "string" },
|
|
541
558
|
json: { type: "boolean", default: false },
|
|
542
559
|
"via-claude-code": { type: "boolean", default: false },
|
|
560
|
+
session: { type: "string" },
|
|
543
561
|
},
|
|
544
562
|
});
|
|
545
563
|
const store = new Store();
|
|
546
564
|
const outcomes = values.since
|
|
547
|
-
? await undoSince(store, Number(values.since), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"] })
|
|
565
|
+
? await undoSince(store, Number(values.since), values.until ? Number(values.until) : undefined, { viaClaudeCode: values["via-claude-code"], session: values.session })
|
|
548
566
|
: values.run
|
|
549
567
|
? await undoRun(store, values.run)
|
|
550
568
|
: await undoAction(store, positionals[0] ?? "");
|
package/dist/github.js
CHANGED
|
@@ -80,8 +80,9 @@ function openPage(url) {
|
|
|
80
80
|
// Gives gh's sign-in another permission (like delete_repo) with GitHub's own
|
|
81
81
|
// approval page: the one-time code is put on the clipboard and the page opened,
|
|
82
82
|
// so the person only pastes it and approves. Resolves once GitHub says yes,
|
|
83
|
-
// or no, or nine minutes pass (Claude Code gives a command ten).
|
|
84
|
-
|
|
83
|
+
// or no, or nine minutes pass (Claude Code gives a command ten). `onCode` hears
|
|
84
|
+
// the code as soon as gh prints it, so it can be shown while GitHub waits.
|
|
85
|
+
export function grantScope(scope, onCode) {
|
|
85
86
|
return new Promise((resolve) => {
|
|
86
87
|
// gh would open the page too; Backstep already does, so gh's browser is told to do nothing.
|
|
87
88
|
const env = { ...process.env, NO_COLOR: "1", GH_BROWSER: process.platform === "win32" ? "cmd /c exit" : "true" };
|
|
@@ -96,6 +97,7 @@ export function grantScope(scope) {
|
|
|
96
97
|
shown = true;
|
|
97
98
|
copy(code);
|
|
98
99
|
openPage(DEVICE);
|
|
100
|
+
onCode?.(code);
|
|
99
101
|
// gh waits for Enter before it would open the page itself; it's already open.
|
|
100
102
|
child.stdin.write("\n");
|
|
101
103
|
}
|
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
|
-
|
|
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 = {}) {
|
|
@@ -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
|
|
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')
|
|
@@ -666,7 +700,7 @@ test('when the last turn only committed and pushed, undo offers to take back the
|
|
|
666
700
|
await clock.advance(1000)
|
|
667
701
|
await pressed
|
|
668
702
|
// 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'])
|
|
703
|
+
expect(runs.find((r) => r.includes('undo'))?.slice(3)).toEqual(['undo', '--since', '5000', '--session', 's1', '--json', '--via-claude-code'])
|
|
670
704
|
await ui.unmount()
|
|
671
705
|
})
|
|
672
706
|
|
|
@@ -723,13 +757,25 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
|
|
|
723
757
|
runs.filter((r) => r.argv.includes('gh-undo')).length === 1
|
|
724
758
|
? { ok: false, needs: 'delete_repo', detail: 'GitHub needs your OK before bzells11/taskflow can be deleted' }
|
|
725
759
|
: { ok: true, detail: 'deleted the repository bzells11/taskflow' }
|
|
726
|
-
: e.argv.includes('gh-auth')
|
|
727
|
-
? { ok: true, detail: 'GitHub approved it' }
|
|
728
760
|
: e.argv.includes('db-check')
|
|
729
761
|
? { ask: null }
|
|
730
762
|
: null
|
|
731
763
|
return { value: { exitCode: 0, stdout: out ? JSON.stringify(out) : '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
|
732
764
|
})
|
|
765
|
+
// GitHub's approval: the code comes first, then the answer once the person approves (when `approve` is called).
|
|
766
|
+
let approve = () => {}
|
|
767
|
+
const approved = new Promise<void>((resolve) => (approve = resolve))
|
|
768
|
+
let codeShown: Promise<void> = Promise.resolve()
|
|
769
|
+
let gotCode = () => {}
|
|
770
|
+
codeShown = new Promise<void>((resolve) => (gotCode = resolve))
|
|
771
|
+
on('process.spawn', async function* (_, e) {
|
|
772
|
+
runs.push({ argv: [...e.argv] })
|
|
773
|
+
yield { stream: 'stdout' as const, text: '{"code":"ABCD-1234","url":"https://github.com/login/device"}\n' }
|
|
774
|
+
gotCode()
|
|
775
|
+
await approved
|
|
776
|
+
yield { stream: 'stdout' as const, text: '{"ok":true,"detail":"GitHub approved it"}\n' }
|
|
777
|
+
return { value: { code: 0, signal: null } }
|
|
778
|
+
})
|
|
733
779
|
on('tool.call', { tool: 'AskUserQuestion' }, (_, e) => {
|
|
734
780
|
const questions = (e as unknown as { questions: { question: string }[] }).questions
|
|
735
781
|
asked.push(questions[0].question)
|
|
@@ -753,7 +799,17 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
|
|
|
753
799
|
const pressed = ui.press({ key: 'undo' })
|
|
754
800
|
await clock.advance(1000)
|
|
755
801
|
await pressed
|
|
802
|
+
await codeShown
|
|
803
|
+
await clock.settle()
|
|
804
|
+
// While GitHub waits, the band says it's a step to take and shows the code, not a failure.
|
|
805
|
+
expect(await ui.find({ type: 'Text', text: /One more step to delete the GitHub repository bzells11\/taskflow/ })).toBeTruthy()
|
|
806
|
+
expect(await ui.find({ type: 'Text', text: 'ABCD-1234' })).toBeTruthy()
|
|
807
|
+
// The push went to the repository being deleted, so it isn't listed as not undone.
|
|
808
|
+
expect(await ui.find({ type: 'Text', text: /Not undone/ })).toBeFalsy()
|
|
809
|
+
approve()
|
|
756
810
|
await clock.settle()
|
|
811
|
+
expect(await ui.find({ type: 'Text', text: 'ABCD-1234' })).toBeFalsy()
|
|
812
|
+
expect(await ui.find({ type: 'Text', text: /on GitHub: deleted the repository bzells11\/taskflow/ })).toBeTruthy()
|
|
757
813
|
expect(asked).toEqual(['Claude created the GitHub repository bzells11/taskflow. Delete it on GitHub too?'])
|
|
758
814
|
expect(JSON.parse(runs.find((r) => r.argv.includes('gh-undo'))?.stdin ?? '{}')).toEqual(repo)
|
|
759
815
|
// Missing permission: GitHub's OK is fetched by Backstep itself, then the delete is tried again.
|
|
@@ -762,7 +818,7 @@ test('a GitHub repository Claude created is offered for deletion after undo, and
|
|
|
762
818
|
'gh-auth delete_repo',
|
|
763
819
|
'gh-undo',
|
|
764
820
|
])
|
|
765
|
-
expect(logs.find((l) => l.includes('
|
|
821
|
+
expect(logs.find((l) => l.includes('paste the code ABCD-1234 on the GitHub page'))).toBeTruthy()
|
|
766
822
|
expect(logs.find((l) => l.includes('On GitHub: deleted the repository'))).toBeTruthy()
|
|
767
823
|
// The repository is gone, so its push isn't asked about.
|
|
768
824
|
expect(runs.some((r) => r.argv.includes('revert-push'))).toBe(false)
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { atom, read, update } from 'claude-code'
|
|
8
8
|
import type { EngineInterface, Register } from 'claude-code'
|
|
9
9
|
|
|
10
|
-
import type { Rewind, RewindItem, Turn } from '../types'
|
|
10
|
+
import type { GithubCode, Rewind, RewindItem, Turn } from '../types'
|
|
11
11
|
|
|
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)
|
|
@@ -16,6 +16,8 @@ const armed = atom({ plugin: 'backstep', key: 'armed' } as const, null as number
|
|
|
16
16
|
// The keys bound to undo, read as a session starts and when the person changes
|
|
17
17
|
// them. Kept in state, not a variable, so the band redraws with the new key.
|
|
18
18
|
const keys = atom({ plugin: 'backstep', key: 'keys' } as const, ['ctrl+u'] as string[])
|
|
19
|
+
// The one-time code GitHub's approval page wants, on the band while GitHub waits.
|
|
20
|
+
const githubCode = atom({ plugin: 'backstep', key: 'githubCode' } as const, null as GithubCode | null)
|
|
19
21
|
// Backstep's mark, as on the logo: the front rewind triangle in the terminal's
|
|
20
22
|
// own black or white, the one behind it in the logo's pink.
|
|
21
23
|
const PINK = '#ee7bb6'
|
|
@@ -80,6 +82,39 @@ async function jsonRun($: EngineInterface, args: string[], timeoutMs: number, st
|
|
|
80
82
|
|
|
81
83
|
const ghUndo = ($: EngineInterface, g: GhMade) => jsonRun($, ['gh-undo'], 120_000, JSON.stringify(g))
|
|
82
84
|
|
|
85
|
+
// Gets GitHub's OK for one more gh permission. The CLI prints the one-time code
|
|
86
|
+
// on a line of its own as soon as GitHub gives it, so the band shows it while
|
|
87
|
+
// GitHub waits; the answer is its last line.
|
|
88
|
+
async function ghAuth($: EngineInterface, scope: string, what: string): Promise<{ ok: boolean; detail: string }> {
|
|
89
|
+
let out = ''
|
|
90
|
+
try {
|
|
91
|
+
const child = $.process.spawn({ argv: backstep(['gh-auth', scope]) })
|
|
92
|
+
for await (const { stream, text } of child) {
|
|
93
|
+
if (stream !== 'stdout') continue
|
|
94
|
+
out += text
|
|
95
|
+
const code = out.match(/"code":"([A-Z0-9]{4}-[A-Z0-9]{4})"/)?.[1]
|
|
96
|
+
if (code && !(await read($, githubCode))) {
|
|
97
|
+
await update($, githubCode, () => ({ code, what }))
|
|
98
|
+
$.ui.log(`${MARK_TEXT} One more step to ${what}: paste the code ${code} on the GitHub page that just opened, then press Authorize. (It's already copied, so ⌘V / Ctrl+V works.)`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
out += `\n${JSON.stringify({ ok: false, detail: err instanceof Error ? err.message : String(err) })}`
|
|
103
|
+
} finally {
|
|
104
|
+
await update($, githubCode, () => null)
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(out.trim().split('\n').at(-1) ?? '') as { ok: boolean; detail: string }
|
|
108
|
+
} catch {
|
|
109
|
+
return { ok: false, detail: "GitHub didn't answer" }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Adds a line to the band's list once something offered after the rewind is settled.
|
|
114
|
+
async function addItem($: EngineInterface, item: RewindItem) {
|
|
115
|
+
await update($, rewind, (r) => (r ? { ...r, items: [...r.items, item], shown: r.shown + 1 } : r))
|
|
116
|
+
}
|
|
117
|
+
|
|
83
118
|
// Asks, for each thing Claude made on GitHub in the undone turns, whether to take it away there too.
|
|
84
119
|
// Returns the repositories deleted, whose pushes then need no undo of their own.
|
|
85
120
|
async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise<Set<string>> {
|
|
@@ -96,12 +131,12 @@ async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise
|
|
|
96
131
|
let r = await ghUndo($, g)
|
|
97
132
|
// gh's sign-in needs one more permission: get it on GitHub's own page, then try again.
|
|
98
133
|
if (!r.ok && r.needs) {
|
|
99
|
-
$.ui.toast(
|
|
100
|
-
|
|
101
|
-
const granted = await jsonRun($, ['gh-auth', r.needs], 600_000)
|
|
134
|
+
$.ui.toast('One more step: paste the code shown above the prompt on the GitHub page that just opened, then press Authorize.', { timeoutMs: 15_000 })
|
|
135
|
+
const granted = await ghAuth($, r.needs, `${yes === 'Close it' ? 'close' : 'delete'} ${describeGh(g)}`)
|
|
102
136
|
r = granted.ok ? await ghUndo($, g) : { ok: false, detail: `GitHub didn't give the OK (${granted.detail}), so ${describeGh(g)} is still there` }
|
|
103
137
|
}
|
|
104
138
|
if (r.ok && g.kind === 'repo') gone.add(g.repo)
|
|
139
|
+
await addItem($, r.ok ? { label: `on GitHub: ${r.detail}`, ok: true } : { label: `on GitHub: ${r.detail}`, ok: false })
|
|
105
140
|
$.ui.log(r.ok ? `${MARK_TEXT} On GitHub: ${r.detail}.` : `${MARK_TEXT} Not undone on GitHub: ${r.detail}`)
|
|
106
141
|
$.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}`)
|
|
107
142
|
}
|
|
@@ -148,6 +183,19 @@ async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone =
|
|
|
148
183
|
// The first press asks, right there above the prompt; a second press within a
|
|
149
184
|
// few seconds undoes. A dialog would take the band off screen, and with it the
|
|
150
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
|
+
|
|
151
199
|
async function pressUndo($: EngineInterface) {
|
|
152
200
|
if (await read($, rewind)) return
|
|
153
201
|
const target = (await read($, turns)).findLast(hasWork)
|
|
@@ -183,7 +231,7 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
183
231
|
const cwd = await $.session.cwd()
|
|
184
232
|
const short = (p: string) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p)
|
|
185
233
|
const [ran] = await Promise.all([
|
|
186
|
-
$.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) => ({
|
|
187
235
|
exitCode: 1,
|
|
188
236
|
stdout: '',
|
|
189
237
|
stderr: err instanceof Error ? err.message : String(err),
|
|
@@ -237,9 +285,11 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
237
285
|
// A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
|
|
238
286
|
const offered = outcomes.some((o) => o.ok && o.pushed)
|
|
239
287
|
const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
|
|
288
|
+
// Deleting the repository takes whatever was pushed to it along.
|
|
289
|
+
const offeredRepo = outcomes.some((o) => o.ok && o.github?.some((g) => g.kind === 'repo'))
|
|
240
290
|
const kept = taken
|
|
241
291
|
.flatMap((t) => t.lostItems ?? [])
|
|
242
|
-
.filter((l) => !(offered && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
|
|
292
|
+
.filter((l) => !((offered || offeredRepo) && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
|
|
243
293
|
.map((label) => ({ label, ok: false }))
|
|
244
294
|
const items: RewindItem[] = [...undone, ...stopped, ...kept]
|
|
245
295
|
const done = undone.length
|
|
@@ -675,9 +725,11 @@ export const register: Register = (on, options) => {
|
|
|
675
725
|
if (EDIT_TOOLS.has(tool)) {
|
|
676
726
|
const input = e as { file_path?: string; notebook_path?: string }
|
|
677
727
|
const path = input.file_path ?? input.notebook_path
|
|
678
|
-
|
|
728
|
+
const cwd = await $.session.cwd()
|
|
729
|
+
// Only the open folder is tracked: an edit in another project is never
|
|
730
|
+
// recorded, so undoing here can never delete or roll back files there.
|
|
731
|
+
if (path && within(path, cwd)) {
|
|
679
732
|
const t = await current($)
|
|
680
|
-
const cwd = await $.session.cwd()
|
|
681
733
|
const sid = await $.session.id()
|
|
682
734
|
// A failed snapshot never blocks the edit; it just won't be undoable.
|
|
683
735
|
const snap = await $.process
|
|
@@ -741,20 +793,44 @@ export const register: Register = (on, options) => {
|
|
|
741
793
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
742
794
|
if (e.props.hasSurvey) return next(e)
|
|
743
795
|
const { Box, Button, Text } = $.ui.resolve(e)
|
|
796
|
+
// The logo keeps its own box with a fixed gap after it: a space inside the
|
|
797
|
+
// text next to it gets trimmed when a long line wraps, crushing the two together.
|
|
798
|
+
const mark = () => (
|
|
799
|
+
<Box flexShrink={0} marginRight={1}>
|
|
800
|
+
<Text bold>◀</Text>
|
|
801
|
+
<Text color={PINK} bold>◀</Text>
|
|
802
|
+
</Box>
|
|
803
|
+
)
|
|
744
804
|
const r = await read($, rewind)
|
|
745
805
|
|
|
746
806
|
if (r?.phase === 'running') {
|
|
747
807
|
return (
|
|
748
808
|
<Box>
|
|
749
|
-
|
|
750
|
-
<Text
|
|
751
|
-
<Text bold> Rewinding </Text>
|
|
809
|
+
{mark()}
|
|
810
|
+
<Text bold>Rewinding </Text>
|
|
752
811
|
<Text>{bar(r.frame)}</Text>
|
|
753
812
|
<Text dimColor> undoing the last turn…</Text>
|
|
754
813
|
</Box>
|
|
755
814
|
)
|
|
756
815
|
}
|
|
757
816
|
|
|
817
|
+
const waiting = await read($, githubCode)
|
|
818
|
+
// GitHub is waiting on the person: the code, big and plain, says this is a step to take, not a failure.
|
|
819
|
+
const step = waiting ? (
|
|
820
|
+
<Box flexDirection="column" marginTop={r ? 1 : 0}>
|
|
821
|
+
<Box>
|
|
822
|
+
{mark()}
|
|
823
|
+
<Text color="warning" bold>{`One more step to ${waiting.what}:`}</Text>
|
|
824
|
+
</Box>
|
|
825
|
+
<Text>{' Paste this code on the GitHub page that just opened, then press Authorize:'}</Text>
|
|
826
|
+
<Box>
|
|
827
|
+
<Text>{' '}</Text>
|
|
828
|
+
<Text color={PINK} bold>{waiting.code}</Text>
|
|
829
|
+
<Text dimColor>{' (already copied: ⌘V / Ctrl+V)'}</Text>
|
|
830
|
+
</Box>
|
|
831
|
+
</Box>
|
|
832
|
+
) : null
|
|
833
|
+
|
|
758
834
|
if (r) {
|
|
759
835
|
const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : undefined
|
|
760
836
|
const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
|
|
@@ -782,10 +858,13 @@ export const register: Register = (on, options) => {
|
|
|
782
858
|
{i.label}
|
|
783
859
|
</Text>
|
|
784
860
|
))}
|
|
861
|
+
{step}
|
|
785
862
|
</Box>
|
|
786
863
|
)
|
|
787
864
|
}
|
|
788
865
|
|
|
866
|
+
if (step) return step
|
|
867
|
+
|
|
789
868
|
const { target, before } = await undoTargets($)
|
|
790
869
|
const label = (await read($, keys)).map(keyLabel).join(' / ')
|
|
791
870
|
if (target && (await read($, armed)) !== null) {
|
|
@@ -800,9 +879,8 @@ export const register: Register = (on, options) => {
|
|
|
800
879
|
return (
|
|
801
880
|
<Box flexDirection="column">
|
|
802
881
|
<Box>
|
|
803
|
-
|
|
804
|
-
<Text color={
|
|
805
|
-
<Text color="warning" bold>{' That turn only saved and pushed to git.'}</Text>
|
|
882
|
+
{mark()}
|
|
883
|
+
<Text color="warning" bold>{'That turn only saved and pushed to git.'}</Text>
|
|
806
884
|
</Box>
|
|
807
885
|
<Text dimColor>{` Undo it and the turn before it${quoted(before, 50)}?`}</Text>
|
|
808
886
|
<Box>
|
|
@@ -823,9 +901,8 @@ export const register: Register = (on, options) => {
|
|
|
823
901
|
return (
|
|
824
902
|
<Box flexDirection="column">
|
|
825
903
|
<Box>
|
|
826
|
-
|
|
827
|
-
<Text color={
|
|
828
|
-
<Text color="warning" bold>{" Undo Claude's last turn?"}</Text>
|
|
904
|
+
{mark()}
|
|
905
|
+
<Text color="warning" bold>{"Undo Claude's last turn?"}</Text>
|
|
829
906
|
</Box>
|
|
830
907
|
<Text dimColor>{` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
|
|
831
908
|
<Box>
|
|
@@ -845,13 +922,16 @@ export const register: Register = (on, options) => {
|
|
|
845
922
|
// with nothing to undo, a press says so rather than doing nothing.
|
|
846
923
|
return (
|
|
847
924
|
<Box>
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
<
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
925
|
+
{mark()}
|
|
926
|
+
{/* One line: a long summary is cut short so the key to press always shows. */}
|
|
927
|
+
<Box flexShrink={1} minWidth={0}>
|
|
928
|
+
<Text dimColor wrap="truncate-end">{target ? `Last turn:${quoted(target, 28)} · ${summary(target)}` : 'Backstep is on · nothing to undo yet'}</Text>
|
|
929
|
+
</Box>
|
|
930
|
+
<Box flexShrink={0} marginLeft={2}>
|
|
931
|
+
<Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
|
|
932
|
+
{`${label} to undo`}
|
|
933
|
+
</Button>
|
|
934
|
+
</Box>
|
|
855
935
|
</Box>
|
|
856
936
|
)
|
|
857
937
|
})
|
package/mod/types/index.d.ts
CHANGED
|
@@ -29,10 +29,14 @@ export type Rewind = {
|
|
|
29
29
|
message: string
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// GitHub waiting on the person's OK for one more permission: the one-time code to paste, and what it's for.
|
|
33
|
+
export type GithubCode = { code: string; what: string }
|
|
34
|
+
|
|
32
35
|
declare module 'claude-code' {
|
|
33
36
|
interface PluginState {
|
|
34
37
|
// armed: when the undo key was first pressed; a second press within a few seconds undoes.
|
|
35
38
|
// keys: the keys bound to undo, kept here so the band redraws when they change.
|
|
36
|
-
|
|
39
|
+
// githubCode: the code GitHub's approval page wants, shown on the band until GitHub answers.
|
|
40
|
+
backstep: { turns: Turn[]; rewind: Rewind | null; note: string | null; armed: number | null; keys: string[]; githubCode: GithubCode | null }
|
|
37
41
|
}
|
|
38
42
|
}
|