backstep 0.1.10 → 0.1.14
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 +4 -0
- package/dist/adapters/firestore.js +160 -0
- package/dist/adapters/tree.js +100 -3
- package/dist/cli.js +80 -17
- package/dist/connect.js +166 -8
- package/dist/github.js +113 -0
- package/dist/shell.js +9 -0
- package/dist/supabase.js +17 -3
- package/dist/undo.js +13 -5
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.test.tsx +108 -10
- package/mod/hooks/register.tsx +194 -61
- package/mod/types/index.d.ts +5 -1
- package/package.json +1 -1
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'
|
|
@@ -57,16 +59,99 @@ type Outcome = {
|
|
|
57
59
|
detail: string
|
|
58
60
|
run?: { server: string; tool: string; args: Record<string, unknown> }
|
|
59
61
|
pushed?: { root: string; pushes: Pushed[] }
|
|
62
|
+
github?: GhMade[]
|
|
60
63
|
}
|
|
61
64
|
const PUSH = /\bgit\b[^;&|]*\bpush\b/
|
|
65
|
+
// A gh command that makes something on GitHub: noted once it runs, so undo can offer to take it away.
|
|
66
|
+
const GH_CREATE = /\bgh\s+(repo|issue|pr|release)\s+create\b/
|
|
67
|
+
type GhMade = { kind: 'repo' | 'issue' | 'pr' | 'release'; repo: string; number?: number; tag?: string; url: string }
|
|
68
|
+
const describeGh = (g: GhMade) =>
|
|
69
|
+
g.kind === 'repo' ? `the GitHub repository ${g.repo}` : g.kind === 'release' ? `the release ${g.tag} in ${g.repo}` : `${g.kind === 'pr' ? 'pull request' : 'issue'} #${g.number} in ${g.repo}`
|
|
70
|
+
|
|
71
|
+
// Runs a Backstep command that answers with { ok, detail }, however it fails.
|
|
72
|
+
async function jsonRun($: EngineInterface, args: string[], timeoutMs: number, stdin?: string): Promise<{ ok: boolean; detail: string; needs?: string }> {
|
|
73
|
+
const ran = await $.process
|
|
74
|
+
.run(backstep(args), { timeoutMs, ...(stdin !== undefined ? { stdin } : {}) })
|
|
75
|
+
.catch((err: unknown) => ({ exitCode: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(ran.stdout) as { ok: boolean; detail: string; needs?: string }
|
|
78
|
+
} catch {
|
|
79
|
+
return { ok: false, detail: (ran.stderr || 'it failed').trim().split('\n').at(-1) ?? 'it failed' }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ghUndo = ($: EngineInterface, g: GhMade) => jsonRun($, ['gh-undo'], 120_000, JSON.stringify(g))
|
|
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
|
+
|
|
118
|
+
// Asks, for each thing Claude made on GitHub in the undone turns, whether to take it away there too.
|
|
119
|
+
// Returns the repositories deleted, whose pushes then need no undo of their own.
|
|
120
|
+
async function offerGithubUndo($: EngineInterface, outcomes: Outcome[]): Promise<Set<string>> {
|
|
121
|
+
const gone = new Set<string>()
|
|
122
|
+
for (const g of outcomes.flatMap((o) => (o.ok && o.github ? o.github : []))) {
|
|
123
|
+
const yes = g.kind === 'repo' ? 'Delete it' : g.kind === 'issue' || g.kind === 'pr' ? 'Close it' : 'Delete it'
|
|
124
|
+
const answer = await $.ui
|
|
125
|
+
.ask(`Claude created ${describeGh(g)}. ${yes === 'Close it' ? 'Close' : 'Delete'} it on GitHub too?`, { header: 'Backstep', options: [yes, 'Leave it'] })
|
|
126
|
+
.catch(() => null)
|
|
127
|
+
if (answer !== yes) {
|
|
128
|
+
$.ui.log(`${MARK_TEXT} Not undone on GitHub: ${describeGh(g)} stays (you chose to leave it).`)
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
let r = await ghUndo($, g)
|
|
132
|
+
// gh's sign-in needs one more permission: get it on GitHub's own page, then try again.
|
|
133
|
+
if (!r.ok && r.needs) {
|
|
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)}`)
|
|
136
|
+
r = granted.ok ? await ghUndo($, g) : { ok: false, detail: `GitHub didn't give the OK (${granted.detail}), so ${describeGh(g)} is still there` }
|
|
137
|
+
}
|
|
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 })
|
|
140
|
+
$.ui.log(r.ok ? `${MARK_TEXT} On GitHub: ${r.detail}.` : `${MARK_TEXT} Not undone on GitHub: ${r.detail}`)
|
|
141
|
+
$.ui.toast(r.ok ? `Backstep ${r.detail}.` : `Backstep ${r.detail}`)
|
|
142
|
+
}
|
|
143
|
+
return gone
|
|
144
|
+
}
|
|
62
145
|
|
|
63
146
|
const PUSH_ASK = { yes: 'Push the undo', no: 'Leave it as it is' }
|
|
64
147
|
|
|
65
148
|
// Undo rewound the code here; this asks before taking it back on GitHub too,
|
|
66
149
|
// as a new commit that undoes Claude's (never a force push).
|
|
67
|
-
async function offerPushUndo($: EngineInterface, outcomes: Outcome[]) {
|
|
150
|
+
async function offerPushUndo($: EngineInterface, outcomes: Outcome[], repoGone = false) {
|
|
68
151
|
for (const o of outcomes) {
|
|
69
152
|
if (!o.ok || !o.pushed) continue
|
|
153
|
+
// The repository it went to is deleted: nothing left there to undo.
|
|
154
|
+
if (repoGone) continue
|
|
70
155
|
for (const p of o.pushed.pushes) {
|
|
71
156
|
const what =
|
|
72
157
|
p.before === null
|
|
@@ -184,11 +269,14 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
184
269
|
.map((line) => ({ label: line.replace(/^(restored|removed) (.*)$/, (_, verb: string, p: string) => `${short(p)} ${verb}`), ok: true })),
|
|
185
270
|
)
|
|
186
271
|
const stopped = outcomes.filter((o) => !o.ok).map((o) => ({ label: `${o.tool}: ${o.detail.split('\n')[0] ?? 'failed'}`, ok: false }))
|
|
187
|
-
// A push
|
|
272
|
+
// A push, or something made on GitHub, is offered back below, so it isn't listed as lost.
|
|
188
273
|
const offered = outcomes.some((o) => o.ok && o.pushed)
|
|
274
|
+
const offeredGh = outcomes.some((o) => o.ok && o.github?.length)
|
|
275
|
+
// Deleting the repository takes whatever was pushed to it along.
|
|
276
|
+
const offeredRepo = outcomes.some((o) => o.ok && o.github?.some((g) => g.kind === 'repo'))
|
|
189
277
|
const kept = taken
|
|
190
278
|
.flatMap((t) => t.lostItems ?? [])
|
|
191
|
-
.filter((l) => !(offered && /pushes to a remote/.test(l)))
|
|
279
|
+
.filter((l) => !((offered || offeredRepo) && /pushes to a remote/.test(l)) && !(offeredGh && /changes GitHub/.test(l)))
|
|
192
280
|
.map((label) => ({ label, ok: false }))
|
|
193
281
|
const items: RewindItem[] = [...undone, ...stopped, ...kept]
|
|
194
282
|
const done = undone.length
|
|
@@ -228,17 +316,15 @@ async function undoLastTurn($: EngineInterface, count = 1) {
|
|
|
228
316
|
)
|
|
229
317
|
}
|
|
230
318
|
|
|
231
|
-
// Asked once the band shows what came back.
|
|
232
|
-
void offerPushUndo($, outcomes)
|
|
319
|
+
// Asked once the band shows what came back: GitHub things first, then pushes.
|
|
320
|
+
void offerGithubUndo($, outcomes).then((gone) => offerPushUndo($, outcomes, gone.size > 0))
|
|
233
321
|
|
|
234
|
-
// Reveal the
|
|
322
|
+
// Reveal the items one by one. The list stays until the person's next
|
|
323
|
+
// message (it's in the conversation too), so nothing is missed.
|
|
235
324
|
const reveal = $.clock.every(90, () => {
|
|
236
325
|
void update($, rewind, (r) => (r && r.shown < r.items.length ? { ...r, shown: r.shown + 1 } : r))
|
|
237
326
|
})
|
|
238
|
-
$.clock.after(
|
|
239
|
-
reveal.cancel()
|
|
240
|
-
void update($, rewind, () => null)
|
|
241
|
-
})
|
|
327
|
+
$.clock.after(90 * (items.length + 2), () => reveal.cancel())
|
|
242
328
|
}
|
|
243
329
|
|
|
244
330
|
type Next = (e: Record<string, unknown>) => Promise<{ deny?: string; isError?: true; result?: unknown; text?: string }>
|
|
@@ -332,11 +418,12 @@ const brief = (command: string) => {
|
|
|
332
418
|
// Something in a turn that can't be undone, named, so undo can list it.
|
|
333
419
|
const lostOne = (x: Turn, what: string): Turn => ({ ...x, lost: (x.lost ?? 0) + 1, lostItems: [...(x.lostItems ?? []), what].slice(-20) })
|
|
334
420
|
|
|
335
|
-
// A turn named by what the person asked:
|
|
336
|
-
const
|
|
421
|
+
// A turn named by what the person asked: "commit and push it", cut to `max`.
|
|
422
|
+
const quoted = (t: Turn, max = 40) => {
|
|
337
423
|
const p = (t.prompt ?? '').replace(/\s+/g, ' ').trim()
|
|
338
|
-
return p ? `
|
|
424
|
+
return p ? ` "${p.length > max ? `${p.slice(0, max - 1)}…` : p}"` : ''
|
|
339
425
|
}
|
|
426
|
+
const named = (t: Turn) => (t.prompt ? ` (${quoted(t).trim()})` : '')
|
|
340
427
|
|
|
341
428
|
// Each part only saves to git (add, commit, push and friends), or only moves around.
|
|
342
429
|
const GIT_SAVE = /^(git\s+(add|commit|push|status|log|diff|show|branch|remote|fetch|rev-parse|config)\b|cd\s|echo\s|true$)/
|
|
@@ -394,7 +481,7 @@ async function checkCoverage($: EngineInterface, cwd: string) {
|
|
|
394
481
|
const found = await $.process.run(backstep(['connect', '--dir', cwd, '--json', '--auto']), { timeoutMs: 120_000 }).catch(() => null)
|
|
395
482
|
try {
|
|
396
483
|
for (const c of JSON.parse(found?.stdout || '[]') as Connected[]) {
|
|
397
|
-
if (c.
|
|
484
|
+
if (c.state === 'ask') await askDatabase($, c.dir ?? cwd, c.service)
|
|
398
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.')
|
|
399
486
|
}
|
|
400
487
|
} catch {
|
|
@@ -422,36 +509,45 @@ const ASK = {
|
|
|
422
509
|
// Projects asked about this session: "Not now" means not again until the next one.
|
|
423
510
|
const asked = new Set<string>()
|
|
424
511
|
|
|
425
|
-
//
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
512
|
+
// How each database is named in the question, and what the answer connects.
|
|
513
|
+
const DB_NAME: Record<string, string> = { supabase: 'Supabase', postgres: 'a Postgres database', firebase: 'Firebase' }
|
|
514
|
+
|
|
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.
|
|
518
|
+
async function askDatabase($: EngineInterface, cwd: string, service = 'supabase') {
|
|
519
|
+
const key = `${service}:${cwd}`
|
|
520
|
+
if (asked.has(key)) return
|
|
521
|
+
asked.add(key)
|
|
522
|
+
const name = DB_NAME[service] ?? service
|
|
431
523
|
const answer = await $.ui
|
|
432
|
-
.ask(
|
|
524
|
+
.ask(`This project uses ${name}, but Backstep isn't connected to it, so Claude's changes to it can't be undone. Connect it?`, {
|
|
433
525
|
header: 'Backstep',
|
|
434
526
|
options: [ASK.connect, ASK.later, ASK.never],
|
|
435
527
|
})
|
|
436
528
|
.catch(() => null)
|
|
437
529
|
if (answer === ASK.never) {
|
|
438
|
-
await $.process.run(backstep(['connect',
|
|
530
|
+
await $.process.run(backstep(['connect', service, '--dir', cwd, '--dismiss'])).catch(() => null)
|
|
439
531
|
return
|
|
440
532
|
}
|
|
441
533
|
if (answer !== ASK.connect) return
|
|
442
|
-
const ran = await $.process.run(backstep(['connect',
|
|
443
|
-
let
|
|
534
|
+
const ran = await $.process.run(backstep(['connect', service, '--dir', cwd, '--json']), { timeoutMs: 120_000 }).catch(() => null)
|
|
535
|
+
let all: Connected[] = []
|
|
444
536
|
try {
|
|
445
|
-
|
|
537
|
+
all = JSON.parse(ran?.stdout || '[]') as Connected[]
|
|
446
538
|
} catch {
|
|
447
|
-
|
|
539
|
+
all = []
|
|
448
540
|
}
|
|
449
|
-
|
|
450
|
-
|
|
541
|
+
const s = all.find((c) => c.state === 'connected' || c.state === 'covered') ?? all.find((c) => c.state === 'needs')
|
|
542
|
+
if (s && (s.state === 'connected' || s.state === 'covered')) {
|
|
543
|
+
$.ui.toast(`Backstep ${s.detail}.`)
|
|
544
|
+
$.ui.log(`${MARK_TEXT} Backstep ${s.detail}.`)
|
|
451
545
|
return
|
|
452
546
|
}
|
|
453
|
-
//
|
|
454
|
-
|
|
547
|
+
// What's still needed, said where it stays.
|
|
548
|
+
const need = s?.detail ?? `ask Claude to "connect Backstep to ${service}"`
|
|
549
|
+
$.ui.log(`${MARK_TEXT} Not connected to ${name} yet: ${need}`)
|
|
550
|
+
$.ui.toast(service === 'supabase' ? 'Backstep needs a Supabase sign-in for this. Ask Claude to "connect Backstep to Supabase" and it will walk you through it.' : `Backstep isn't connected to ${name} yet: ${need}`)
|
|
455
551
|
}
|
|
456
552
|
|
|
457
553
|
const UPDATE = { yes: 'Update', later: 'Not now', skip: 'Skip this version' }
|
|
@@ -596,6 +692,9 @@ export const register: Register = (on, options) => {
|
|
|
596
692
|
on('prompt.submit', async ($, e, next) => {
|
|
597
693
|
if (e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
|
|
598
694
|
const startedAt = await $.clock.now()
|
|
695
|
+
// The last undo's list has been seen: the band goes back to the undo line.
|
|
696
|
+
const shown = await read($, rewind)
|
|
697
|
+
if (shown && shown.phase !== 'running') await update($, rewind, () => null)
|
|
599
698
|
await update($, turns, (l) => [...(l ?? []), newTurn(startedAt, e.text)].slice(-KEEP_TURNS))
|
|
600
699
|
const pending = await read($, note)
|
|
601
700
|
if (!pending) return next(e)
|
|
@@ -636,8 +735,8 @@ export const register: Register = (on, options) => {
|
|
|
636
735
|
// so it can be saved before the command runs.
|
|
637
736
|
const checked = await $.process.run(backstep(['db-check', '--cwd', cwd, '--', command])).catch(() => null)
|
|
638
737
|
try {
|
|
639
|
-
const ask =
|
|
640
|
-
if (ask) await
|
|
738
|
+
const { ask, service } = JSON.parse(checked?.stdout || '{}') as { ask?: string | null; service?: string | null }
|
|
739
|
+
if (ask) await askDatabase($, ask, service ?? 'supabase')
|
|
641
740
|
} catch {
|
|
642
741
|
// can't tell: run it as before
|
|
643
742
|
}
|
|
@@ -665,9 +764,9 @@ export const register: Register = (on, options) => {
|
|
|
665
764
|
)
|
|
666
765
|
}
|
|
667
766
|
// A push: once it's run, note what it sent, so undo can offer to take it back there.
|
|
668
|
-
if (plan?.id && PUSH.test(command)) {
|
|
767
|
+
if (plan?.id && (PUSH.test(command) || GH_CREATE.test(command))) {
|
|
669
768
|
const r = await next(e)
|
|
670
|
-
await $.process.run(backstep(['shell-end', plan.id])).catch(() => null)
|
|
769
|
+
await $.process.run(backstep(['shell-end', plan.id]), { stdin: r.text ?? '' }).catch(() => null)
|
|
671
770
|
return r
|
|
672
771
|
}
|
|
673
772
|
} else if (tool.startsWith('mcp__')) {
|
|
@@ -693,6 +792,24 @@ export const register: Register = (on, options) => {
|
|
|
693
792
|
)
|
|
694
793
|
}
|
|
695
794
|
|
|
795
|
+
const waiting = await read($, githubCode)
|
|
796
|
+
// GitHub is waiting on the person: the code, big and plain, says this is a step to take, not a failure.
|
|
797
|
+
const step = waiting ? (
|
|
798
|
+
<Box flexDirection="column" marginTop={r ? 1 : 0}>
|
|
799
|
+
<Box>
|
|
800
|
+
<Text bold>◀</Text>
|
|
801
|
+
<Text color={PINK} bold>◀</Text>
|
|
802
|
+
<Text color="warning" bold>{` One more step to ${waiting.what}:`}</Text>
|
|
803
|
+
</Box>
|
|
804
|
+
<Text>{' Paste this code on the GitHub page that just opened, then press Authorize:'}</Text>
|
|
805
|
+
<Box>
|
|
806
|
+
<Text>{' '}</Text>
|
|
807
|
+
<Text color={PINK} bold>{waiting.code}</Text>
|
|
808
|
+
<Text dimColor>{' (already copied: ⌘V / Ctrl+V)'}</Text>
|
|
809
|
+
</Box>
|
|
810
|
+
</Box>
|
|
811
|
+
) : null
|
|
812
|
+
|
|
696
813
|
if (r) {
|
|
697
814
|
const tone = r.phase === 'failed' ? 'error' : r.phase === 'empty' ? 'warning' : undefined
|
|
698
815
|
const glyph = r.phase === 'failed' ? '✗' : r.phase === 'empty' ? '!' : '✓'
|
|
@@ -720,47 +837,63 @@ export const register: Register = (on, options) => {
|
|
|
720
837
|
{i.label}
|
|
721
838
|
</Text>
|
|
722
839
|
))}
|
|
840
|
+
{step}
|
|
723
841
|
</Box>
|
|
724
842
|
)
|
|
725
843
|
}
|
|
726
844
|
|
|
845
|
+
if (step) return step
|
|
846
|
+
|
|
727
847
|
const { target, before } = await undoTargets($)
|
|
728
848
|
const label = (await read($, keys)).map(keyLabel).join(' / ')
|
|
729
849
|
if (target && (await read($, armed)) !== null) {
|
|
850
|
+
// Laid out a line at a time, so nothing gets squeezed into columns on a narrow terminal.
|
|
851
|
+
const cancel = (
|
|
852
|
+
<Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
|
|
853
|
+
cancel
|
|
854
|
+
</Button>
|
|
855
|
+
)
|
|
730
856
|
// The last turn only committed and pushed: the work it saved is what the person most likely wants back.
|
|
731
857
|
if (before) {
|
|
732
858
|
return (
|
|
859
|
+
<Box flexDirection="column">
|
|
860
|
+
<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>
|
|
864
|
+
</Box>
|
|
865
|
+
<Text dimColor>{` Undo it and the turn before it${quoted(before, 50)}?`}</Text>
|
|
866
|
+
<Box>
|
|
867
|
+
<Text>{' '}</Text>
|
|
868
|
+
<Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
|
|
869
|
+
{`${label} again to undo both`}
|
|
870
|
+
</Button>
|
|
871
|
+
<Text dimColor>{' · '}</Text>
|
|
872
|
+
<Button key="just" plain dimColor onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
|
|
873
|
+
just the push
|
|
874
|
+
</Button>
|
|
875
|
+
<Text dimColor>{' · '}</Text>
|
|
876
|
+
{cancel}
|
|
877
|
+
</Box>
|
|
878
|
+
</Box>
|
|
879
|
+
)
|
|
880
|
+
}
|
|
881
|
+
return (
|
|
882
|
+
<Box flexDirection="column">
|
|
733
883
|
<Box>
|
|
734
884
|
<Text bold>◀</Text>
|
|
735
885
|
<Text color={PINK} bold>◀</Text>
|
|
736
|
-
<Text color="warning" bold>
|
|
886
|
+
<Text color="warning" bold>{" Undo Claude's last turn?"}</Text>
|
|
887
|
+
</Box>
|
|
888
|
+
<Text dimColor>{` ${quoted(target, 50).trim() || 'That turn'} · ${summary(target)}`}</Text>
|
|
889
|
+
<Box>
|
|
890
|
+
<Text>{' '}</Text>
|
|
737
891
|
<Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
|
|
738
|
-
{`${label} again to undo
|
|
739
|
-
</Button>
|
|
740
|
-
<Text dimColor> · </Text>
|
|
741
|
-
<Button key="just" plain dimColor onPress={() => void update($, armed, () => null).then(() => undoLastTurn($, 1))}>
|
|
742
|
-
just the push
|
|
743
|
-
</Button>
|
|
744
|
-
<Text dimColor> · </Text>
|
|
745
|
-
<Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
|
|
746
|
-
cancel
|
|
892
|
+
{`${label} again to undo`}
|
|
747
893
|
</Button>
|
|
894
|
+
<Text dimColor>{' · '}</Text>
|
|
895
|
+
{cancel}
|
|
748
896
|
</Box>
|
|
749
|
-
)
|
|
750
|
-
}
|
|
751
|
-
return (
|
|
752
|
-
<Box>
|
|
753
|
-
<Text bold>◀</Text>
|
|
754
|
-
<Text color={PINK} bold>◀</Text>
|
|
755
|
-
<Text color="warning" bold>{` Undo Claude's last turn${named(target)}? `}</Text>
|
|
756
|
-
<Text dimColor>{summary(target)} </Text>
|
|
757
|
-
<Button key="undo" action={UNDO_ACTION} plain onPress={() => void pressUndo($)}>
|
|
758
|
-
{`${label} again to undo`}
|
|
759
|
-
</Button>
|
|
760
|
-
<Text dimColor> · </Text>
|
|
761
|
-
<Button key="cancel" plain dimColor onPress={() => void update($, armed, () => null)}>
|
|
762
|
-
cancel
|
|
763
|
-
</Button>
|
|
764
897
|
</Box>
|
|
765
898
|
)
|
|
766
899
|
}
|
|
@@ -773,7 +906,7 @@ export const register: Register = (on, options) => {
|
|
|
773
906
|
<Text bold>◀</Text>
|
|
774
907
|
<Text color={PINK} bold>◀</Text>
|
|
775
908
|
<Text> </Text>
|
|
776
|
-
<Text dimColor>{target ? `Last turn
|
|
909
|
+
<Text dimColor>{target ? `Last turn:${quoted(target, 28)} · ${summary(target)} ` : 'Backstep is on · nothing to undo yet '}</Text>
|
|
777
910
|
<Button key="undo" action={UNDO_ACTION} plain dimColor onPress={() => void pressUndo($)}>
|
|
778
911
|
{`${label} to undo`}
|
|
779
912
|
</Button>
|
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
|
}
|