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

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:
/better-rewind branches lets you read it.It does not replace /rewind; it adds its own command, /better-rewind.
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.
/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./better-rewind branches lists the conversations you rewound away, newest first, read-only.~/.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./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.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.
branches/)./plugin install line works in the terminal.node --test tests/transcript.test.mjs
claude plugin test .
claude plugin validate .
claude --plugin-dir .
MIT
自分の発言と、Claude が AskUserQuestion で聞いてきた質問のどこからでも、会話とコードを戻したり要約したりできる Claude Code の mod です。
組み込みの /rewind は自分の発言の地点に戻れます。better-rewind はそれに次のことを足します。
/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 行で出ます。/better-rewind branches で、巻き戻して捨てた会話を新しい順に読めます (読むだけです)。~/.claude/projects/<project>/<session>.jsonl) を地点で切り、同じセッションを /resume で読み直します。新しいセッションは増えません。切る前の記録は先に丸ごと保存します。/compact を動かし、会話を地点で分けて片側だけを要約させます。記録ファイルは直接書き換えません。会話には /compact の行が残ります。保存先は ~/.claude/better-rewind/<session>/ (CLAUDE_CONFIG_DIR があればその下) です。snaps/ にファイルの控え、branches/ に巻き戻す前の記録が入ります。自動では消さないので、要らなくなったらフォルダごと消してください。
branches/ に残ります)。hooks/register.tsx 657 lines1import { 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}
657hooks/lib.ts 185 lines1import 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, '_')
185types/index.d.ts 109 lines1// 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