SLOPSHOPPER

better-rewind

Rewind or summarize from any prompt or AskUserQuestion answer, with a reply preview and saved branches

newpaneguardcommandtoaststatus
v1.0.0MITupdated 2026-10-10hamTotk/better-rewind
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · better-rewind
│ ┃ Better Rewind ✕ › fix the failing auth test and add an audit log call │ ┃ Could not load: Could not read the result │ ● better-rewind: better-rewind: could not back up /work/app/src/auth. │ ● better-rewind: better-rewind: could not back up /work/app/src/audit │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /better-rewind │ ⎿ better-rewind: Opened 0 rewind points │ ● better-rewind: better-rewind: could not back up /work/app/src/cache │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Better Rewind
Could not load: Could not read the result
README

better-rewind

A Claude Code mod that lets you rewind or summarize from any of your prompts, or from any question Claude asked you with AskUserQuestion.

The built-in /rewind goes back to one of your prompts. better-rewind adds a few things to that:

  • Re-answer a question: go back to just after one of Claude's AskUserQuestion questions and pick a different answer. Claude then carries on from the new answer by itself.
  • Preview the reply: see the reply that followed each point before you choose it.
  • Keep the old branch: the conversation you rewound away is saved, and /better-rewind branches lets you read it.

It does not replace /rewind; it adds its own command, /better-rewind.

日本語の説明は下にあります

Install

At the prompt of a terminal Claude Code session:

/plugin install better-rewind --marketplace hamTotk/better-rewind

Answer y to add the marketplace, then pick a scope (the user scope is first). The mod is active at once, no restart needed. Node.js must be on your PATH.

To remove it: claude plugin uninstall better-rewind.

Use

  • /better-rewind opens the list of points: each of your prompts, and each AskUserQuestion question (indented under its prompt). Other tool calls are not listed; they show as ▸ Tool lines in the reply preview.
  • ↑ / ↓ select a point, and the reply that followed it is shown below. Enter chooses it; Esc closes the pane.
  • Then pick what to do. The wording and order follow the built-in Rewind:
  • Restore code and conversation, Restore conversation, Restore code: go back to just before the point. A prompt you rewind to is put back in the input field.
  • On a question point, the conversation choices read …, then re-answer. You choose an answer for each question (one of the options, or your own), and Claude continues from it.
  • Summarize from here: replace the point and everything after it with a summary; earlier messages stay as they are.
  • Summarize up to here: replace everything before the point with a summary; the point and what follows stay as they are.
  • Never mind.
  • Before summarizing you can add context ("what the summary should keep"), or summarize without it.
  • /better-rewind branches lists the conversations you rewound away, newest first, read-only.
  • Nothing is rewound or summarized while Claude is responding; the pane says so.

How it works

  • Conversation: the mod cuts the session's transcript (~/.claude/projects/<project>/<session>.jsonl) at the point and reloads the same session with /resume. No new session is created. The full transcript from before the cut is saved first.
  • Code: just before each Edit, Write or NotebookEdit, the mod saves the file as it was (or notes that it did not exist). Restoring puts every file changed after the point back to that state, and removes files that were created after it. Changes made through Bash are not tracked, and edits made before the mod was installed cannot be restored.
  • Summary: the mod runs the built-in /compact and splits the conversation at the point, so only one side is summarized. The transcript is not edited directly, and a /compact line appears in the conversation.
  • If something fails, nothing changes. Files are restored first and the transcript last; if a step fails, the restored files are put back and the reason is shown.

Saved data lives in ~/.claude/better-rewind/<session>/ (under CLAUDE_CONFIG_DIR if set): snaps/ holds the file backups, branches/ the transcripts before each rewind. Nothing is deleted automatically; delete the folder when you no longer need it.

Notes

  • The mod API is early access. better-rewind is built on Claude Code's function-hook mods, which may change between releases. Tested on Claude Code 2.1.296.
  • It edits your session transcript. Rewinding rewrites the transcript file of the current session (a copy of the original is kept in branches/).
  • Right after a re-answer, Claude Code inserts a short "No response requested." reply before Claude continues. This comes from Claude Code itself.
  • For a multi-select question, choose one option or type several separated by commas.
  • Install from a terminal. The /plugin install line works in the terminal.

Develop

node --test tests/transcript.test.mjs
claude plugin test .
claude plugin validate .
claude --plugin-dir .

License

MIT


日本語

自分の発言と、Claude が AskUserQuestion で聞いてきた質問のどこからでも、会話とコードを戻したり要約したりできる Claude Code の mod です。

組み込みの /rewind は自分の発言の地点に戻れます。better-rewind はそれに次のことを足します。

  • 質問への回答のやり直し: Claude が AskUserQuestion で聞いてきた質問の直後に戻り、別の回答を選べます。選ぶと Claude が新しい回答で自動的に続きを始めます。
  • 返答のプレビュー: 地点を選ぶ前に、その地点の後に続いた返答を見られます。
  • 戻す前の会話を残す: 巻き戻して捨てた会話は保存され、/better-rewind branches で読めます。

組み込みの /rewind は置き換えません。専用のコマンド /better-rewind で使います。

入れ方

ターミナル版の Claude Code で次の 1 行を打ちます。

/plugin install better-rewind --marketplace hamTotk/better-rewind

マーケットプレイスを追加するか聞かれたら y、続けて入れる範囲を選びます (先頭がユーザー全体)。入れた時点で使えるようになり、再起動は要りません。Node.js が PATH にある必要があります。

外すときは claude plugin uninstall better-rewind です。

使い方

  • /better-rewind で地点の一覧が開きます。地点は自分の発言と、AskUserQuestion の質問 (発言の下に字下げして表示) です。ほかのツール呼び出しは一覧に出ず、返答のプレビューに ▸ ツール名 の 1 行で出ます。
  • ↑ / ↓ で地点を選ぶと、その後に続いた返答が下に出ます。Enter で決定、Esc で閉じます。
  • 続けて何をするかを選びます。言葉と並びは組み込みの Rewind に合わせて英語です。
  • Restore code and conversation / Restore conversation / Restore code: 地点の直前に戻します。自分の発言の地点に戻すと、その発言が入力欄に戻ります。
  • 質問の地点では、会話を戻す選択肢が「…, then re-answer」になります。質問ごとに回答 (選択肢か自分で書いた文) を選ぶと、Claude がその回答で続けます。
  • Summarize from here: 地点から後ろを要約に置き換えます。地点より前はそのまま残ります。
  • Summarize up to here: 地点より前を要約に置き換えます。地点から後ろはそのまま残ります。
  • Never mind: やめます。
  • 要約の前に、要約で残してほしいことを書き足せます。書かずに要約することもできます。
  • /better-rewind branches で、巻き戻して捨てた会話を新しい順に読めます (読むだけです)。
  • Claude が応答している間は戻さず、要約もしません (画面に理由を出します)。

仕組み

  • 会話: セッションの記録ファイル (~/.claude/projects/<project>/<session>.jsonl) を地点で切り、同じセッションを /resume で読み直します。新しいセッションは増えません。切る前の記録は先に丸ごと保存します。
  • コード: Edit・Write・NotebookEdit の直前に、対象のファイルの中身 (無ければ「無かった」こと) を控えます。戻すときは、地点より後に変わったファイルを控えの状態に戻し、地点より後に作られたファイルは消します。Bash で変わったファイルと、mod を入れる前の編集は戻せません。
  • 要約: 組み込みの /compact を動かし、会話を地点で分けて片側だけを要約させます。記録ファイルは直接書き換えません。会話には /compact の行が残ります。
  • 失敗したときは何も変えません。 ファイルを先に戻し、記録の書き換えを最後にします。途中で失敗したら、戻したファイルを元に戻してから理由を出します。

保存先は ~/.claude/better-rewind/<session>/ (CLAUDE_CONFIG_DIR があればその下) です。snaps/ にファイルの控え、branches/ に巻き戻す前の記録が入ります。自動では消さないので、要らなくなったらフォルダごと消してください。

注意

  • Claude Code の mod の仕組みは先行公開の段階で、更新で動かなくなる可能性があります。確認済みの版は 2.1.296 です。
  • 巻き戻すと、今のセッションの記録ファイルを書き換えます (元の記録は branches/ に残ります)。
  • 回答をやり直した直後、Claude が続きを始める前に、Claude Code 本体が "No response requested." という短い返答を 1 つ挟みます。
  • 複数選択の質問は、選択肢から 1 つ選ぶか、自分で「,」区切りで書きます。
  • 入れる操作はターミナル版でだけできます。
Source 3 files
hooks/register.tsx 657 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Branch, Mode, Point, RewindResult, View } from '../types'
5import type { OptionKey } from './lib'
6import {
7  BUSY,
8  branchLines,
9  changesParts,
10  clipToRows,
11  doneMessage,
12  editedFile,
13  isAsk,
14  isMissing,
15  modeOptions,
16  plural,
17  pointKey,
18  snapName,
19  splitForSummary,
20  summarizeRequest,
21  summarizedMessage,
22  windowAround,
23} from './lib'
24
25// /better-rewind で巻き戻し地点 (人の発言と AskUserQuestion の質問) の一覧ペインを開く。見た目は組み込みの Rewind 画面に寄せる:
26// 1 地点 2 行 (内容と、そこから次の人の発言までに変わったファイルの要約)、選択中は ❯ と suggestion 色、最後に (現在)。
27// キー操作はペインのフォーカスリング (矢印で移動・Enter で決定・Esc で閉じる) に任せ、
28// フォーカスの移動を ui.focus で受けてカーソルとし、その地点の AI の返答を下にプレビューする。
29// 地点を Enter で選ぶと戻し方 (AskUserQuestion なら続けて回答) を選ばせ、bin/transcript.mjs (node) で記録を切って
30// /resume で同じセッションを読み直す。要約を選ぶと添える指示を聞いて $.command.run で /compact を動かし、
31// session.compact の hook が地点の前後で要約する部分と残す部分を分ける (記録は書き換えない)。
32// $.session.compact は呼ばない: 呼んだ mod 自身の session.compact hook が飛ばされ、会話全体が要約される
33// (2026-10-10 実機で確認)。/compact を経由すると hook が trigger 'manual' で呼ばれ、要約が終わってから戻る。
34// /better-rewind branches は巻き戻す前の会話を読むだけの一覧。
35// Edit / Write / NotebookEdit の直前には tool.call で対象ファイルの控えを取る
36
37const PANE = 'better-rewind'
38// 一覧の最後の「(現在)」の key。選ぶと巻き戻さずに閉じる
39const CURRENT = 'current'
40// 枝の全文から一覧へ戻るボタンの key
41const BACK = 'back'
42// 回答を自分で書く入力欄の key
43const OWN = 'answer:own'
44// 回答の段階で 1 つ前へ戻るボタンの key
45const ANSWER_BACK = 'answer:back'
46// 要約に添える指示の入力欄・指示なしで要約するボタン・戻し方の選択へ戻るボタンの key
47const CONTEXT = 'summarize:context'
48const SUMMARIZE_NOW = 'summarize:now'
49const SUMMARIZE_BACK = 'summarize:back'
50// 見出し・説明・一覧前の空行・区切り前の空行・区切り・案内前の空行・案内
51const FIXED_ROWS = 7
52// 応答の中の地点の字下げ幅
53const NEST = 4
54// 回答を選び直して読み直したあと、AI に続きを始めさせる一言
55// (AI には新しい回答しか見えないので「変えた」とは言わない)
56const CONTINUE = 'Continue based on my answer to your question.'
57// 要約を地点で分けられなかったときに /compact を断る理由
58const SPLIT_FAILED = 'better-rewind could not split the conversation at this point. Nothing was changed'
59// $.fs で控えを取る上限。base64 で 4/3 倍になり $.fs.write の 4 MiB に収める。超えたら node で写す
60const INLINE_MAX = 2.5 * 1024 * 1024
61
62const points = atom({ plugin: 'better-rewind', key: 'points' } as const, [])
63const cursor = atom({ plugin: 'better-rewind', key: 'cursor' } as const, null)
64const status = atom({ plugin: 'better-rewind', key: 'status' } as const, '')
65const view = atom({ plugin: 'better-rewind', key: 'view' } as const, 'points')
66const picked = atom({ plugin: 'better-rewind', key: 'picked' } as const, null)
67const mode = atom({ plugin: 'better-rewind', key: 'mode' } as const, null)
68const answers = atom({ plugin: 'better-rewind', key: 'answers' } as const, [])
69const direction = atom({ plugin: 'better-rewind', key: 'direction' } as const, null)
70const pending = atom({ plugin: 'better-rewind', key: 'pending' } as const, null)
71const outcome = atom({ plugin: 'better-rewind', key: 'outcome' } as const, null)
72const sub = atom({ plugin: 'better-rewind', key: 'sub' } as const, null)
73const branches = atom({ plugin: 'better-rewind', key: 'branches' } as const, [])
74const opened = atom({ plugin: 'better-rewind', key: 'opened' } as const, null)
75const isOpen = atom({ plugin: 'better-rewind', key: 'isOpen' } as const, false)
76const isRunning = atom({ plugin: 'better-rewind', key: 'isRunning' } as const, false)
77
78type Ui = Elements[RenderSurface]
79
80// bin/transcript.mjs を呼んで、最後の行の JSON を返す。読めなければ error に理由を入れる
81async function node<T>($: EngineInterface, args: string[], stdin?: string): Promise<Partial<T> & { error?: string }> {
82  const r = await $.process.run(['node', `${$.plugin.root}/bin/transcript.mjs`, ...args], { timeoutMs: 60_000, ...(stdin === undefined ? {} : { stdin }) })
83  try {
84    return JSON.parse(r.stdout.trim().split('\n').at(-1) || '{}') as Partial<T> & { error?: string }
85  } catch {
86    return { error: r.stderr.slice(0, 120) || 'Could not read the result' } as Partial<T> & { error?: string }
87  }
88}
89
90async function loadPoints($: EngineInterface): Promise<Point[]> {
91  await update($, status, () => 'Loading…')
92  const out = await node<{ points: Point[] }>($, ['points', await $.session.id()])
93  if (!out.points) {
94    await update($, status, () => `Could not load: ${out.error ?? ''}`)
95    return []
96  }
97  await update($, points, () => out.points ?? [])
98  await update($, status, () => '')
99  return out.points
100}
101
102// 段階を切り替え、キーボードをその段階の要素に置く
103async function showView($: EngineInterface, v: View, key: string) {
104  await update($, view, () => v)
105  await update($, sub, () => (v === 'points' ? null : key))
106  if (v === 'points') await update($, cursor, () => (key === CURRENT ? null : key))
107  await $.ui.focus({ requestId: PANE, key }).catch(() => undefined)
108}
109
110async function closePane($: EngineInterface) {
111  await update($, isOpen, () => false)
112  await update($, view, () => 'points')
113  await $.ui.close({ id: PANE })
114}
115
116async function refuseWhileRunning($: EngineInterface): Promise<boolean> {
117  if (!(await read($, isRunning))) return false
118  await update($, status, () => BUSY)
119  $.ui.toast(BUSY)
120  return true
121}
122
123// Enter で地点を選んだとき。(現在) なら閉じる。それ以外は戻し方の段階へ (選べるのが「やめる」だけの地点は断る)
124async function choose($: EngineInterface, p: Point | undefined) {
125  if (!p) return closePane($)
126  if (await refuseWhileRunning($)) return
127  const options = modeOptions(p, await read($, points))
128  if (options.length <= 1) {
129    $.ui.toast('Cannot rewind to this point (the conversation cannot be cut or summarized here and no file backups exist)')
130    return
131  }
132  await update($, picked, () => p)
133  await update($, mode, () => null)
134  await update($, answers, () => [])
135  await update($, direction, () => null)
136  await update($, status, () => '')
137  await showView($, 'modes', `mode:${options[0]?.key ?? 'cancel'}`)
138}
139
140async function pickMode($: EngineInterface, p: Point, m: OptionKey) {
141  if (m === 'cancel') return showView($, 'points', pointKey(p))
142  if (m === 'summarize' || m === 'summarize_up_to') {
143    await update($, direction, () => (m === 'summarize' ? 'from' : 'up_to'))
144    return showView($, 'summarize', CONTEXT)
145  }
146  if (isAsk(p) && m !== 'files') {
147    await update($, mode, () => m)
148    await update($, answers, () => [])
149    return showView($, 'answer', 'answer:0')
150  }
151  return execute($, p, m)
152}
153
154// 要約に添える指示を受けて (空でもよい)、ペインを閉じて /compact を動かす。どこで分けるかは session.compact の hook が
155// pending を読んで決め、どうしたかを outcome に残す。終わったらそれを読んで知らせる。ファイルには触れず、枝も残さない
156async function summarize($: EngineInterface, p: Point, context: string) {
157  if (await refuseWhileRunning($)) return
158  const d = await read($, direction)
159  const request = d ? summarizeRequest(p, await read($, points), d) : undefined
160  if (!request) {
161    const msg = 'Cannot summarize from this point'
162    await update($, status, () => msg)
163    $.ui.toast(msg)
164    return
165  }
166  await update($, status, () => '')
167  await closePane($)
168  await update($, outcome, () => null)
169  await update($, pending, () => request)
170  $.ui.status('Summarizing…')
171  const instructions = context.trim()
172  try {
173    await $.command.run({ command: 'compact', ...(instructions ? { args: instructions } : {}) })
174    $.ui.toast(summarizedMessage(request.direction, await read($, outcome)))
175  } catch (err) {
176    // hook が断った後で /compact が失敗を返しても、断った理由の方を出す
177    const o = await read($, outcome)
178    $.ui.toast(o?.kind === 'skip' ? summarizedMessage(request.direction, o) : `Could not summarize: ${String(err)}`)
179  } finally {
180    $.ui.status(undefined)
181    await update($, pending, () => null)
182  }
183}
184
185// 質問 1 つ分の回答。全部そろったら巻き戻す
186async function giveAnswer($: EngineInterface, p: Point, text: string) {
187  const answer = text.trim()
188  const m = await read($, mode)
189  if (!answer || !m) return
190  const given = [...(await read($, answers)), answer]
191  if (given.length < (p.questions?.length ?? 0)) {
192    await update($, answers, () => given)
193    return showView($, 'answer', 'answer:0')
194  }
195  return execute($, p, m, given)
196}
197
198// 回答の段階で 1 つ前へ。1 問目なら戻し方の選択へ戻る
199async function stepBack($: EngineInterface, p: Point) {
200  const given = await read($, answers)
201  if (given.length === 0) return showView($, 'modes', `mode:${(await read($, mode)) ?? modeOptions(p, await read($, points))[0]?.key ?? 'cancel'}`)
202  await update($, answers, () => given.slice(0, -1))
203  return showView($, 'answer', 'answer:0')
204}
205
206// 記録を切り (とファイルを戻し)、同じセッションを読み直す。回答を選び直したときは AI に続きを始めさせ、
207// 人の発言の地点ではその発言を入力欄に戻す
208async function execute($: EngineInterface, p: Point, m: Mode, given?: string[]) {
209  if (await refuseWhileRunning($)) return
210  await update($, status, () => 'Rewinding…')
211  const sessionId = await $.session.id()
212  const spec = { uuid: p.uuid, toolUseId: p.toolUseId, label: p.label, mode: m, answers: given }
213  const out = await node<RewindResult>($, ['rewind', sessionId], JSON.stringify(spec))
214  const failure = out.error !== undefined || !out.restored || !out.removed ? (out.error ?? 'Could not read the result') : undefined
215  // 記録を書き換えたなら、途中で失敗していても読み直す (記録と動いている会話を食い違わせない)
216  const changed = out.transcriptChanged === true
217  if (failure !== undefined && !changed) {
218    const msg = `Rewind failed: ${failure}`
219    await update($, status, () => msg)
220    $.ui.toast(msg)
221    return
222  }
223  await update($, status, () => '')
224  await closePane($)
225  if (m !== 'files' || changed) {
226    try {
227      await $.command.run({ command: 'resume', args: sessionId })
228    } catch (err) {
229      $.ui.toast(`The transcript was rewound but could not be reloaded. Run /resume ${sessionId} (${String(err)})`)
230      return
231    }
232    if (failure !== undefined) {
233      $.ui.toast(`The conversation was rewound and reloaded, but part of it failed: ${failure}`)
234      return
235    }
236    if (given) void $.prompt.submit({ text: CONTINUE }).catch(() => undefined)
237    else if (p.kind === 'prompt' && p.text) await $.prompt.fill({ text: p.text }).catch(() => undefined)
238  }
239  $.ui.toast(doneMessage(m, { restored: out.restored ?? [], removed: out.removed ?? [] }))
240}
241
242// 控えの保存先 (<設定フォルダ>/better-rewind/<session>)。セッションごとに 1 度だけ node に聞く
243const dataDirs = new Map<string, Promise<string>>()
244function dataDir($: EngineInterface, sessionId: string): Promise<string> {
245  const known = dataDirs.get(sessionId)
246  if (known) return known
247  const asked = node<{ dir: string }>($, ['dir', sessionId]).then(out => {
248    if (!out.dir) throw new Error(out.error ?? 'Backup folder not found')
249    return out.dir
250  })
251  dataDirs.set(sessionId, asked)
252  asked.catch(() => dataDirs.delete(sessionId))
253  return asked
254}
255
256// Edit / Write / NotebookEdit の実行直前の中身を snaps/<tool_use id>.json (と .b64) に控える。
257// 無いファイル (ENOENT) は「無かった」と記録する。あるのに読めないなど他の理由で stat に失敗したら控えを取らない
258// (「無かった」と控えると、戻すときに消してしまう)。エラーに ENOENT が読めないときは exists で確かめる。
259// 大きいファイルは node に写させる
260async function snapshot($: EngineInterface, toolUseId: string, tool: string, file: string) {
261  const sessionId = await $.session.id()
262  const dir = `${await dataDir($, sessionId)}/snaps`
263  const id = snapName(toolUseId)
264  const meta = { toolUseId, tool, path: file, at: Date.now(), existed: false }
265  const stat = await $.fs.stat(file).catch(async (err: unknown) => {
266    if (isMissing(err) || !(await $.fs.exists(file))) return undefined
267    throw err
268  })
269  if (stat && stat.kind !== 'file') return
270  if (stat && stat.size > INLINE_MAX) {
271    const out = await node($, ['snap', sessionId, toolUseId, tool, file, String(meta.at)])
272    if (out.error !== undefined) throw new Error(out.error)
273    return
274  }
275  if (stat) {
276    const { base64 } = await $.fs.read(file, { as: 'bytes' })
277    // 中身を書いてから情報を書く (情報があれば中身もある)
278    await $.fs.write(`${dir}/${id}.b64`, base64)
279    await $.fs.write(`${dir}/${id}.json`, JSON.stringify({ ...meta, existed: true, data: `${id}.b64` }))
280    return
281  }
282  await $.fs.write(`${dir}/${id}.json`, JSON.stringify(meta))
283}
284
285async function openBranches($: EngineInterface) {
286  const out = await node<{ branches: Branch[] }>($, ['branches', await $.session.id()])
287  const list = out.branches ?? []
288  await update($, branches, () => list)
289  await update($, opened, () => null)
290  await update($, status, () => (out.branches ? '' : `Could not load: ${out.error ?? ''}`))
291  await update($, view, () => 'branches')
292  await update($, sub, () => list[0]?.id ?? null)
293  const placed = await $.ui.open({ id: PANE, title: 'Better Rewind', focus: true, closeOnEscape: true })
294  await update($, isOpen, () => true)
295  if (list[0]) await $.ui.focus({ requestId: PANE, key: list[0].id }).catch(() => undefined)
296  return { text: `Opened ${plural(list.length, 'saved conversation')}${placed.isPlaced ? '' : ' (waiting: terminal too narrow)'}` }
297}
298
299// 枝の全文を開く (id) か一覧へ戻る (null)。focus は次に置くキー
300async function openBranch($: EngineInterface, id: string | null, focus: string) {
301  await update($, opened, () => id)
302  await showView($, id === null ? 'branches' : 'branch', focus)
303}
304
305// 見出しと説明の 2 行。どの段階でも同じ
306function header(ui: Ui, line: string) {
307  const { Text } = ui
308  return [
309    <Text key="title" bold color="suggestion">
310      Better Rewind
311    </Text>,
312    <Text key="lead">{line}</Text>,
313  ]
314}
315
316// 段階の選択肢 1 行。一覧と同じく ❯ の 1 文字ボタンと suggestion 色
317function choiceRow(ui: Ui, key: string, label: string, isFocused: boolean, onPress: () => void, detail?: string) {
318  const { Box, Text, Button } = ui
319  return (
320    <Box key={`row:${key}`} flexDirection="column">
321      <Box flexDirection="row">
322        <Button key={key} plain label={isFocused ? '❯' : ' '} onPress={onPress} />
323        <Text> </Text>
324        <Text color={isFocused ? 'suggestion' : undefined} wrap="truncate-end">
325          {label}
326        </Text>
327      </Box>
328      {detail && (
329        <Text dimColor wrap="truncate-end">
330          {'  '}
331          {detail}
332        </Text>
333      )}
334    </Box>
335  )
336}
337
338function footer(ui: Ui, note: string, hint: string) {
339  const { Box, Text } = ui
340  return (
341    <Box key="footer" flexDirection="column" marginTop={1}>
342      {note && <Text color="warning">{note}</Text>}
343      <Text dimColor>{hint}</Text>
344    </Box>
345  )
346}
347
348async function renderModes($: EngineInterface, ui: Ui, p: Point) {
349  const { Box, Text } = ui
350  const focus = await read($, sub)
351  const options = modeOptions(p, await read($, points))
352  const at = options.some(o => `mode:${o.key}` === focus) ? focus : `mode:${options[0]?.key}`
353  const c = changesParts(p.changes)
354  return (
355    <Box flexDirection="column">
356      {header(ui, isAsk(p) ? `Change your answer to "${p.label}"` : `Rewind to just before "${p.label}"`)}
357      <Box flexDirection="column" marginTop={1}>
358        {options.map(o => choiceRow(ui, `mode:${o.key}`, o.label, `mode:${o.key}` === at, () => void pickMode($, p, o.key)))}
359      </Box>
360      {p.restorable && (
361        <Box marginTop={1}>
362          <Text dimColor wrap="truncate-end">
363            Files are restored to how they were before Edit / Write changed them after this point ({c.text})
364          </Text>
365        </Box>
366      )}
367      {footer(ui, await read($, status), '↑↓ to select · Enter to confirm · Esc to close')}
368    </Box>
369  )
370}
371
372// 要約に添える指示を聞く段階。組み込みと同じく指示は任意で、空のまま Enter でも要約する
373async function renderSummarize($: EngineInterface, ui: Ui, p: Point) {
374  const { Box, Text } = ui
375  const d = await read($, direction)
376  const focus = await read($, sub)
377  const back = `mode:${d === 'up_to' ? 'summarize_up_to' : 'summarize'}`
378  return (
379    <Box flexDirection="column">
380      {header(ui, d === 'up_to' ? `Summarize up to "${p.label}"` : `Summarize from "${p.label}"`)}
381      <Box marginTop={1}>
382        <Text dimColor>
383          {d === 'up_to'
384            ? 'Messages before this point are replaced with a summary. This point and later stay as they are. Files are not changed'
385            : 'This point and everything after it are replaced with a summary. Earlier messages stay as they are. Files are not changed'}
386        </Text>
387      </Box>
388      {/* 入力欄の無い画面 (mobile) では指示なしで要約するボタンだけ。ラベルの後ろの「: 」は入力欄が自分で描く */}
389      {'Input' in ui && (
390        <Box marginTop={1}>
391          <ui.Input key={CONTEXT} label="Add context (optional)" placeholder="What the summary should keep" submitLabel="Summarize" onSubmit={(value: string) => void summarize($, p, value)} />
392        </Box>
393      )}
394      <Box flexDirection="column" marginTop={1}>
395        {choiceRow(ui, SUMMARIZE_NOW, 'Summarize without context', focus === SUMMARIZE_NOW, () => void summarize($, p, ''))}
396        {choiceRow(ui, SUMMARIZE_BACK, 'Back to rewind options', focus === SUMMARIZE_BACK, () => void showView($, 'modes', back))}
397      </Box>
398      {footer(ui, await read($, status), 'Enter to summarize · Esc to close')}
399    </Box>
400  )
401}
402
403async function renderAnswer($: EngineInterface, ui: Ui, p: Point) {
404  const { Box, Text } = ui
405  const questions = p.questions ?? []
406  const done = (await read($, answers)).length
407  const q = questions[done]
408  if (!q) return <Text dimColor>No questions</Text>
409  const focus = await read($, sub)
410  const options = q.options ?? []
411  const count = questions.length > 1 ? ` (${done + 1}/${questions.length})` : ''
412  return (
413    <Box flexDirection="column">
414      {header(ui, `${q.header ? `${q.header}: ` : ''}${q.question}${count}`)}
415      <Box flexDirection="column" marginTop={1}>
416        {options.map((o, i) => choiceRow(ui, `answer:${i}`, o.label, `answer:${i}` === focus, () => void giveAnswer($, p, o.label), o.description))}
417        {choiceRow(ui, ANSWER_BACK, done > 0 ? 'Back to previous question' : 'Back to rewind options', focus === ANSWER_BACK, () => void stepBack($, p))}
418      </Box>
419      {/* 入力欄の無い画面 (mobile) では選択肢だけ */}
420      {'Input' in ui && (
421        <Box marginTop={1}>
422          <ui.Input
423            key={OWN}
424            label="Your answer"
425            placeholder={q.multiSelect ? 'Separate multiple answers with commas' : 'Type an answer and press Enter'}
426            submitLabel="Answer"
427            onSubmit={(value: string) => void giveAnswer($, p, value)}
428          />
429        </Box>
430      )}
431      {footer(ui, await read($, status), '↑↓ to select · Enter to confirm · Esc to close')}
432    </Box>
433  )
434}
435
436async function renderBranches($: EngineInterface, ui: Ui, rows: number, columns: number) {
437  const { Box, Text, Markdown } = ui
438  const list = await read($, branches)
439  const note = await read($, status)
440  const openId = await read($, opened)
441  const shown = list.find(b => b.id === openId)
442
443  // 全文: 読むだけ。一覧へ戻るボタンを最後に置く
444  if (shown) {
445    const lines = branchLines(shown)
446    return (
447      <Box flexDirection="column">
448        {header(ui, `${lines.title} (${lines.detail})`)}
449        <Box flexDirection="column" marginTop={1}>
450          <Markdown text={shown.text || '(no conversation)'} />
451        </Box>
452        <Box marginTop={1}>{choiceRow(ui, BACK, 'Back to list', true, () => void openBranch($, null, shown.id))}</Box>
453        {footer(ui, '', 'Enter to go back · Esc to close')}
454      </Box>
455    )
456  }
457
458  if (list.length === 0) return <Text dimColor>{note || 'No saved conversations yet'}</Text>
459  const focus = await read($, sub)
460  const found = list.findIndex(b => b.id === focus)
461  const index = found >= 0 ? found : 0
462  const previewRows = Math.min(10, Math.max(3, Math.floor(rows * 0.3)))
463  const visible = Math.max(1, Math.floor((rows - FIXED_ROWS - previewRows + 1) / 3))
464  const [start, end] = windowAround(index, list.length, visible)
465  const items = list.slice(start, end).map((b, i) => {
466    const lines = branchLines(b)
467    return (
468      <Box key={`gap:${b.id}`} marginTop={i > 0 ? 1 : 0}>
469        {choiceRow(ui, b.id, lines.title, start + i === index, () => void openBranch($, b.id, BACK), lines.detail)}
470      </Box>
471    )
472  })
473  return (
474    <Box flexDirection="column">
475      {header(ui, 'Saved conversations (read-only)')}
476      <Box flexDirection="column" marginTop={1}>
477        {items}
478      </Box>
479      <Box marginTop={1}>
480        <Text dimColor>── Conversation after the rewind point</Text>
481      </Box>
482      <Markdown text={clipToRows(list[index]?.text || '(no conversation)', previewRows, columns)} />
483      {footer(ui, note, '↑↓ to select · Enter to read all · Esc to close')}
484    </Box>
485  )
486}
487
488export const register: Register = on => {
489  on('session.start', async ($, e, next) => {
490    const started = await next(e)
491    await update($, isRunning, () => false)
492    await $.command.register({ name: 'better-rewind', description: 'Rewind or summarize from any prompt or question', argumentHint: '[branches]' })
493    return started
494  })
495
496  on('command.run', { command: 'better-rewind' }, async ($, e) => {
497    if (e.args.trim() === 'branches') return openBranches($)
498    await update($, cursor, () => null)
499    await update($, view, () => 'points')
500    await update($, sub, () => null)
501    const list = await loadPoints($)
502    const opened = await $.ui.open({ id: PANE, title: 'Better Rewind', focus: true, closeOnEscape: true })
503    await update($, isOpen, () => true)
504    // キーボードを一覧の (現在) に置く。渡せなかったときは理由を出力に残す (Tab かクリックで渡せる)
505    const focused = await $.ui.focus({ requestId: PANE, key: CURRENT }).catch((err: unknown) => ({ deny: String(err) }))
506    const note = 'deny' in focused ? ` (could not move keyboard focus: ${focused.deny})` : ''
507    return { text: `Opened ${plural(list.length, 'rewind point')}${opened.isPlaced ? '' : ' (waiting: terminal too narrow)'}${note}` }
508  })
509
510  on('ui.close', async ($, e, next) => {
511    if (e.id === PANE) {
512      await update($, isOpen, () => false)
513      await update($, view, () => 'points')
514    }
515    return next(e)
516  }).catch(($, e, next) => next(e))
517
518  // AI が応答している間は巻き戻しを断る (記録へ書き足している最中に切ると壊れる)
519  on('turn.start', async ($, e, next) => {
520    await update($, isRunning, () => true)
521    return next(e)
522  }).catch(($, e, next) => next(e))
523
524  // 応答が終わったら断るのをやめ、開いているペインは地点を読み直す (戻し方・回答・要約の段階でも、
525  // 要約の頼みで同じ本文の発言を後ろから数えるのに新しい一覧を使う)
526  on('turn.complete', async ($, e, next) => {
527    const done = await next(e)
528    if (e.agentId === undefined) {
529      await update($, isRunning, () => false)
530      const v = await read($, view)
531      if ((await read($, isOpen)) && v !== 'branches' && v !== 'branch') await loadPoints($)
532    }
533    return done
534  })
535
536  // ペインから頼んだ要約 (pending) だけを、地点の前後で分けて行う。ペインは /compact を動かすので trigger は manual
537  // (plugin も同じに扱う)。from は地点から後ろを、up_to は地点より前を core に要約させ、残す部分とつなぐ。
538  // 頼みの無い /compact・自動の要約・subagent の要約には手を付けずに通す
539  on('session.compact', async ($, e, next) => {
540    if ((e.trigger !== 'manual' && e.trigger !== 'plugin') || e.agentId !== undefined) return next(e)
541    const request = await read($, pending)
542    if (!request) return next(e)
543    // 頼み 1 つで分けるのは 1 回だけ。先に下ろして、後から来た要約を分けない
544    await update($, pending, () => null)
545    let result: Awaited<ReturnType<typeof next>>
546    try {
547      const split = splitForSummary(e.messages, request)
548      if ('skip' in split) result = { skip: split.skip }
549      else {
550        const done = await next({ ...e, messages: split.summarize })
551        result = done.skip !== undefined ? done : { ...done, messages: split.join(done.messages) }
552      }
553    } catch {
554      // 分ける途中や core の要約で失敗したら断る。next(e) に任せると、会話全体の要約か、要約した部分だけ (残す部分が消える) になる
555      result = { skip: SPLIT_FAILED }
556    }
557    await update($, outcome, () => (result.skip !== undefined ? { kind: 'skip' as const, reason: result.skip } : { kind: 'done' as const })).catch(() => undefined)
558    return result
559  })
560    // ここに来るのは頼みを下ろす前の失敗だけ。分けずにそのまま通す
561    .catch(($, e, next) => next(e))
562
563  // 書き換える直前のファイルを控える。控えに失敗してもツールは止めない
564  on('tool.call', async ($, e, next) => {
565    const file = editedFile(e)
566    if (file !== undefined) await snapshot($, e.tool_use_id, e.tool, file).catch((err: unknown) => $.ui.log(`better-rewind: could not back up ${file}: ${String(err)}`))
567    return next(e)
568  }).catch(($, e, next) => next(e))
569
570  // 矢印・Tab・クリックでフォーカスが動いたら、それをカーソルにする (一覧以外の段階では sub に)
571  on('ui.focus', async ($, e, next) => {
572    if (e.component === 'Pane' && e.requestId === PANE && e.element) {
573      const key = e.element
574      if ((await read($, view)) === 'points') await update($, cursor, () => (key === CURRENT ? null : key))
575      else await update($, sub, () => key)
576    }
577    return next(e)
578  }).catch(($, e, next) => next(e))
579
580  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
581    const ui = $.ui.resolve(e)
582    const { Box, Text, Button, Markdown } = ui
583    const rows = e.viewport?.rows ?? 24
584    const columns = e.viewport?.columns ?? 80
585    const v = await read($, view)
586    const p = await read($, picked)
587    if (v === 'modes' && p) return renderModes($, ui, p)
588    if (v === 'answer' && p) return renderAnswer($, ui, p)
589    if (v === 'summarize' && p) return renderSummarize($, ui, p)
590    if (v === 'branches' || v === 'branch') return renderBranches($, ui, rows, columns)
591
592    const list = await read($, points)
593    const note = await read($, status)
594    if (list.length === 0) return <Text dimColor>{note || 'No rewind points'}</Text>
595
596    const at = await read($, cursor)
597    const found = at === null ? -1 : list.findIndex(p => pointKey(p) === at)
598    // 一覧の並び: 地点のあとに (現在)。カーソルが無ければ (現在)
599    const index = found >= 0 ? found : list.length
600    const selected = found >= 0 ? list[found] : undefined
601
602    const previewRows = Math.min(10, Math.max(3, Math.floor(rows * 0.3)))
603    // 1 地点 = 2 行 + 地点のあいだの空行 1 行 (組み込みと同じ間隔)
604    const visible = Math.max(1, Math.floor((rows - FIXED_ROWS - previewRows + 1) / 3))
605    const [start, end] = windowAround(index, list.length + 1, visible)
606
607    const items = []
608    for (let i = start; i < end; i++) {
609      const isFocused = i === index
610      const p = list[i]
611      const key = p ? pointKey(p) : CURRENT
612      const c = p ? changesParts(p.changes) : undefined
613      // 人の発言 (組み込みの rewind に出る項目) を上の階層に置き、その応答の中の地点は字下げする
614      const isNested = p !== undefined && p.kind !== 'prompt'
615      items.push(
616        <Box key={`row:${key}`} flexDirection="column" marginTop={i > start ? 1 : 0} paddingLeft={isNested ? NEST : 0}>
617          <Box flexDirection="row">
618            <Button key={key} plain label={isFocused ? '❯' : ' '} onPress={() => void choose($, p)} />
619            <Text> </Text>
620            <Text color={isFocused ? 'suggestion' : undefined} italic={!p || undefined} wrap="truncate-end">
621              {p ? `${p.kind === 'prompt' ? '' : '● '}${p.label}` : '(current)'}
622            </Text>
623          </Box>
624          {c && (
625            <Text color="inactive" dimColor={!isFocused} wrap="truncate-end">
626              {'  '}
627              {c.text}
628              {c.added && <Text color="success"> {c.added}</Text>}
629              {c.removed && <Text color="error"> {c.removed}</Text>}
630            </Text>
631          )}
632        </Box>,
633      )
634    }
635
636    return (
637      <Box flexDirection="column">
638        <Text bold color="suggestion">
639          Better Rewind
640        </Text>
641        <Text>Restore the code and/or conversation to the point before…</Text>
642        <Box flexDirection="column" marginTop={1}>
643          {items}
644        </Box>
645        <Box marginTop={1}>
646          <Text dimColor>── AI reply from this point</Text>
647        </Box>
648        {/* AI の返答と同じ描き方 (見出し・強調・コード)。行数は元の文で見積もって切る */}
649        <Markdown text={clipToRows(selected ? selected.reply || '(no reply)' : 'This is the current state', previewRows, columns)} />
650        <Box marginTop={1}>
651          <Text dimColor>↑↓ to select · Enter to confirm · Esc to close</Text>
652        </Box>
653      </Box>
654    )
655  })
656}
657
hooks/lib.ts 185 lines
1import type { SessionMessage } from 'claude-code'
2
3import type { Branch, Direction, Mode, Point, RewindResult, SummarizeOutcome, SummarizeRequest } from '../types'
4
5// 1 行に複数のツール呼び出しが乗ることがあるので、uuid に種類 (tool_use id) を足して一意にする
6export const pointKey = (p: Point) => `${p.uuid}:${p.toolUseId ?? p.kind}`
7
8// カーソルを中央に置いた表示範囲 [start, end)。端では詰める
9export function windowAround(index: number, total: number, size: number): [number, number] {
10  if (total <= size) return [0, total]
11  const start = Math.min(Math.max(0, index - Math.floor(size / 2)), total - size)
12  return [start, start + size]
13}
14
15// 端末での表示幅。全角 (CJK・全角記号) は 2 とみなす
16const charWidth = (ch: string) => ((ch.codePointAt(0) ?? 0) >= 0x1100 ? 2 : 1)
17export const cellWidth = (s: string) => [...s].reduce((n, ch) => n + charWidth(ch), 0)
18
19function sliceCells(s: string, cells: number): string {
20  let used = 0
21  let out = ''
22  for (const ch of s) {
23    if (used + charWidth(ch) > cells) break
24    out += ch
25    used += charWidth(ch)
26  }
27  return out
28}
29
30// 本文を幅で折り返したと見なして、rows 行に収まるところで切る
31export function clipToRows(text: string, rows: number, columns: number): string {
32  const width = Math.max(2, columns)
33  const out: string[] = []
34  let used = 0
35  for (const line of text.split('\n')) {
36    const need = Math.max(1, Math.ceil(cellWidth(line) / width))
37    if (used + need > rows) {
38      const room = rows - used
39      if (room > 0) out.push(`${sliceCells(line, room * width - 1)}…`)
40      else if (out.length > 0) out[out.length - 1] += '…'
41      break
42    }
43    out.push(line)
44    used += need
45  }
46  return out.join('\n')
47}
48
49// 2 行目の要約を組み込みの書き方で作る: 変更なし / 1 ファイルならその名前 / 複数なら件数。増減は 0 なら出さない
50export function changesParts(c: Point['changes']): { text: string; added: string; removed: string } {
51  const added = c.added > 0 ? `+${c.added}` : ''
52  const removed = c.removed > 0 ? `-${c.removed}` : ''
53  if (c.files.length === 0) return { text: 'No code changes', added: '', removed: '' }
54  const only = c.files[0]
55  const text = c.files.length === 1 && only ? (only.split(/[\\/]/).pop() ?? only) : `${c.files.length} files changed`
56  return { text, added, removed }
57}
58
59// 件数と名詞 (1 file / 2 files)
60export const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
61
62export const BUSY = 'Cannot rewind while the AI is responding. Try again when it finishes'
63
64// 回答を選び直せる地点か (AskUserQuestion で質問が読める)
65export const isAsk = (p: Point) => p.tool === 'AskUserQuestion' && (p.questions?.length ?? 0) > 0
66
67// 戻し方の選択肢の key。要約の 2 つは組み込みの値に合わせる
68export type OptionKey = Mode | 'summarize' | 'summarize_up_to' | 'cancel'
69
70// 戻し方の選択肢。並びは組み込みと同じく 戻す → 要約 → やめる。会話を戻せない地点・控えの無い地点では、その選択肢を出さない。
71// AskUserQuestion の地点で会話を戻すときは、質問の直後に戻して回答を選び直す。
72// 要約は記録の中で位置を探せる地点だけ。「ここまでを要約」は前に要約するものがある地点 (一覧の先頭でない) だけ
73export function modeOptions(p: Point, list: readonly Point[]): { key: OptionKey; label: string }[] {
74  const ask = isAsk(p)
75  const out: { key: OptionKey; label: string }[] = []
76  if (p.canCut && p.restorable) out.push({ key: 'both', label: ask ? 'Restore code and conversation, then re-answer' : 'Restore code and conversation' })
77  if (p.canCut) out.push({ key: 'conversation', label: ask ? 'Restore conversation, then re-answer' : 'Restore conversation' })
78  if (p.restorable) out.push({ key: 'files', label: 'Restore code' })
79  if (canSummarize(p)) {
80    out.push({ key: 'summarize', label: 'Summarize from here' })
81    if (list.findIndex(q => pointKey(q) === pointKey(p)) > 0) out.push({ key: 'summarize_up_to', label: 'Summarize up to here' })
82  }
83  out.push({ key: 'cancel', label: 'Never mind' })
84  return out
85}
86
87// ---- 要約 ----
88
89// 人の発言を見比べる形。貼り付けの囲みを外して空白をすべて除く (記録の本文と SessionMessage の text で区切りの書き方が違ってもそろう)
90const promptKey = (s: string) => s.replace(/<pasted_content[^>]*>([\s\S]*?)<\/pasted_content[^>]*>/g, '$1').replace(/\s+/g, '')
91
92// 要約できる地点か。質問の地点は tool_use id で、人の発言は本文で e.messages の中の位置を探す
93// (コマンドと長すぎる発言は本文を持たないので探せない)
94export const canSummarize = (p: Point) => (p.kind === 'tool' ? p.toolUseId !== undefined : promptKey(p.text ?? '') !== '')
95
96// 地点 p への要約の頼み。同じ本文の発言は、一覧で p より後ろにいくつあるか (fromEnd) で区別する。
97// 前から数えないのは、要約済みの会話では e.messages が前の方を欠くため (後ろはどちらも今の会話の先端でそろう)
98export function summarizeRequest(p: Point, list: readonly Point[], direction: Direction): SummarizeRequest | undefined {
99  if (!canSummarize(p)) return undefined
100  if (p.kind === 'tool') return { direction, toolUseId: p.toolUseId, fromEnd: 0 }
101  const at = list.findIndex(q => pointKey(q) === pointKey(p))
102  if (at < 0) return undefined
103  const key = promptKey(p.text ?? '')
104  const fromEnd = list.slice(at + 1).filter(q => q.kind === 'prompt' && promptKey(q.text ?? '') === key).length
105  return { direction, text: p.text, fromEnd }
106}
107
108type Message = Pick<SessionMessage, 'role' | 'text' | 'toolUses' | 'toolResults'>
109
110const NOT_FOUND = 'Could not find this point in the conversation (it may already be summarized). Nothing was changed'
111
112// 地点が e.messages の何番目かを探す。見つからない・1 つに決まらないときは推測せず、要約を断る理由を返す。
113// 人の発言は本文が同じものの後ろから fromEnd 番目。同じ本文が無いときに限り、本文で始まるメッセージが 1 つだけならそれを使う
114// (エンジンが発言に何かを付けて 1 つのメッセージにしている場合)
115export function locatePoint(messages: readonly Message[], r: SummarizeRequest): { index: number } | { skip: string } {
116  const hits = (match: (m: Message) => boolean) => messages.flatMap((m, i) => (match(m) ? [i] : []))
117  if (r.toolUseId !== undefined) {
118    const found = hits(m => m.role === 'assistant' && m.toolUses.some(u => u.tool_use_id === r.toolUseId))
119    if (found.length > 1) return { skip: 'This point matches more than one message in the conversation. Nothing was changed' }
120    return found[0] === undefined ? { skip: NOT_FOUND } : { index: found[0] }
121  }
122  const key = promptKey(r.text ?? '')
123  if (key === '') return { skip: NOT_FOUND }
124  const isPrompt = (m: Message) => m.role === 'user' && (m.toolResults?.length ?? 0) === 0
125  const same = hits(m => isPrompt(m) && promptKey(m.text) === key)
126  if (same.length > 0) {
127    const index = same[same.length - 1 - r.fromEnd]
128    return index === undefined ? { skip: NOT_FOUND } : { index }
129  }
130  const starts = hits(m => isPrompt(m) && promptKey(m.text).startsWith(key))
131  return starts.length === 1 && r.fromEnd === 0 && starts[0] !== undefined ? { index: starts[0] } : { skip: NOT_FOUND }
132}
133
134// 要約に回す部分と残す部分に分ける。from は地点から後ろを、up_to は地点より前を要約する。
135// join は要約の結果 (core が返すメッセージ) を残す部分とつないで、要約した後の会話にする
136export function splitForSummary<M extends Message>(messages: readonly M[], r: SummarizeRequest): { summarize: M[]; join: (summary: readonly M[]) => M[] } | { skip: string } {
137  const at = locatePoint(messages, r)
138  if ('skip' in at) return at
139  const before = messages.slice(0, at.index)
140  const after = messages.slice(at.index)
141  if (r.direction === 'from') return { summarize: after, join: summary => [...before, ...summary] }
142  if (before.length === 0) return { skip: 'There is nothing before this point to summarize' }
143  return { summarize: before, join: summary => [...summary, ...after] }
144}
145
146// /compact を通して要約を頼み終えたときの一言。hook が分けた・断った・(結末が無く) hook まで届かなかったで分ける
147export function summarizedMessage(direction: Direction, outcome: SummarizeOutcome | null): string {
148  if (outcome?.kind === 'done') return direction === 'from' ? 'Summarized from here' : 'Summarized up to here'
149  if (outcome?.kind === 'skip') return `Not summarized: ${outcome.reason}`
150  return 'Could not summarize from this point: /compact did not reach better-rewind (it may have summarized the whole conversation)'
151}
152
153// 巻き戻しを終えたときの一言
154export function doneMessage(mode: Mode, r: Pick<RewindResult, 'restored' | 'removed'>): string {
155  const parts = [mode === 'files' ? 'Code restored' : 'Rewound']
156  if (mode !== 'conversation') parts.push(plural(r.restored.length + r.removed.length, 'file'))
157  if (mode !== 'files') parts.push('see the previous conversation with /better-rewind branches')
158  return parts.join(' · ')
159}
160
161// 枝の一覧の 1 行目 (いつ・どの地点で) と 2 行目 (どう戻したか)
162export function branchLines(b: Branch): { title: string; detail: string } {
163  const d = new Date(b.at)
164  const pad = (n: number) => String(n).padStart(2, '0')
165  const when = Number.isNaN(d.getTime()) ? b.at : `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
166  const how = b.answered ? 'Answer changed' : b.mode === 'both' ? 'Code and conversation restored' : 'Conversation restored'
167  return { title: `${when}  ${b.label}`, detail: how }
168}
169
170// Edit / Write / NotebookEdit が書き換えるファイル。それ以外のツールは undefined
171export function editedFile(e: { tool: string }): string | undefined {
172  const args = e as { tool: string; file_path?: unknown; notebook_path?: unknown }
173  const file = e.tool === 'NotebookEdit' ? args.notebook_path : e.tool === 'Edit' || e.tool === 'Write' ? args.file_path : undefined
174  return typeof file === 'string' && file !== '' ? file : undefined
175}
176
177// $.fs が「無い」(ENOENT) と断ったか。読み取り権限が無いなどの失敗とは分ける
178export function isMissing(err: unknown): boolean {
179  const e = err as { code?: unknown; message?: unknown } | null | undefined
180  return e?.code === 'ENOENT' || /\bENOENT\b/.test(String(e?.message ?? err))
181}
182
183// 控えのファイル名。bin/transcript.mjs の snapName と同じ規則
184export const snapName = (id: string) => id.replace(/[^\w-]/g, '_')
185
types/index.d.ts 109 lines
1// AskUserQuestion の質問 1 つ (ツールの入力のまま)
2export type Question = {
3  question: string
4  header?: string
5  multiSelect?: boolean
6  options?: { label: string; description?: string }[]
7}
8
9// 巻き戻し地点。bin/transcript.mjs の points() が返す形
10export type Point = {
11  uuid: string
12  // 人の発言 (prompt) か AskUserQuestion の質問 (tool)。ほかのツール呼び出しは地点にしない
13  kind: 'prompt' | 'tool'
14  label: string
15  reply: string
16  tool?: string
17  toolUseId?: string
18  resultUuid?: string
19  // 人の発言の本文。巻き戻した後に入力欄へ戻す (コマンドと長すぎる発言では無い)
20  text?: string
21  // AskUserQuestion の地点の質問
22  questions?: Question[]
23  // その地点から次の人の発言までに Edit/Write で変わったファイル (絶対パス) と増減の行数
24  changes: { files: string[]; added: number; removed: number }
25  // 会話を戻せるか (最初の発言の前や、切ると記録が壊れる地点は false)
26  canCut: boolean
27  // 戻せるファイルの控えがあるか
28  restorable: boolean
29}
30
31// 戻し方: 会話とファイル / 会話だけ / ファイルだけ
32export type Mode = 'both' | 'conversation' | 'files'
33
34// 要約の向き (組み込みの summarizeMetadata.direction と同じ値): 地点から後ろを要約する / 地点より前を要約する
35export type Direction = 'from' | 'up_to'
36
37// 要約の頼み。session.compact の hook が e.messages の中で地点を探す手がかり (SessionMessage には uuid が無い)。
38// 質問の地点は toolUseId、人の発言は本文 (text) と、同じ本文の発言のうち後ろから何番目か (fromEnd)
39export type SummarizeRequest = {
40  direction: Direction
41  toolUseId?: string
42  text?: string
43  fromEnd: number
44}
45
46// ペインから頼んだ要約の結末。session.compact の hook が記録し、要約を頼んだ側が読んで知らせる。
47// done は地点の前後で分けて要約した、skip は分けずに断った (reason はその理由)
48export type SummarizeOutcome = { kind: 'done' } | { kind: 'skip'; reason: string }
49
50// ペインの段階: 地点の一覧 → 戻し方 → (AskUserQuestion なら) 回答 / (要約なら) 添える指示。枝の一覧 → 枝の全文
51export type View = 'points' | 'modes' | 'answer' | 'summarize' | 'branches' | 'branch'
52
53// 巻き戻す前の会話 (枝)。bin/transcript.mjs の branches() が返す形
54export type Branch = {
55  id: string
56  at: string
57  label: string
58  mode: Mode
59  answered: boolean
60  // 切った地点より後ろの会話 (読むだけ)
61  text: string
62}
63
64// bin/transcript.mjs の rewind() が返す形
65export type RewindResult = {
66  id: string
67  mode: Mode
68  leaf?: string
69  dropped: number
70  restored: string[]
71  removed: string[]
72  // 記録を書き換えたか。失敗の結果でも true なら mod は読み直す
73  transcriptChanged?: boolean
74}
75
76declare module 'claude-code' {
77  interface PluginState {
78    'better-rewind': {
79      points: Point[]
80      // カーソルのある地点の key (pointKey)。null は一覧の最後の (現在)
81      cursor: string | null
82      // 読み込み中・失敗の一言。空なら表示しない
83      status: string
84      view: View
85      // 戻し方・回答の段階で選んでいる地点
86      picked: Point | null
87      // 回答の段階に進んだときの戻し方
88      mode: Mode | null
89      // AskUserQuestion の質問ごとに選んだ回答 (先頭から)
90      answers: string[]
91      // 要約の段階に進んだときの向き
92      direction: Direction | null
93      // ペインから頼んだ要約。/compact を動かす間だけ立て、hook が最初の 1 回で下ろす (立っていない要約は hook が素通しする)
94      pending: SummarizeRequest | null
95      // pending を下ろした hook がどうしたか。/compact の前に null に戻す (null のままなら hook は動かなかった)
96      outcome: SummarizeOutcome | null
97      // 一覧以外の段階でフォーカスのある要素の key
98      sub: string | null
99      branches: Branch[]
100      // 全文を開いている枝の id
101      opened: string | null
102      // ペインが開いているか
103      isOpen: boolean
104      // AI が応答している最中か (turn.start から turn.complete まで)
105      isRunning: boolean
106    }
107  }
108}
109