返回 DeepSeek-Reasonix
rewind.go
根目录 / internal / cli / rewind.go
1 package cli
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7
8 tea "charm.land/bubbletea/v2"
9 "github.com/charmbracelet/x/ansi"
10
11 "reasonix/internal/checkpoint"
12 "reasonix/internal/control"
13 "reasonix/internal/i18n"
14 )
15
16 // rewindPicker is the in-chat overlay for Esc-Esc / "/rewind". Stage 0 lists the
17 // session's turns (one checkpoint each); stage 1 picks what to restore for the
18 // chosen turn; stage 2 explicitly confirms file restore when checkpoint coverage
19 // is partial. It mirrors the chooser overlay: keys route through handleRewindKey
20 // and it renders via renderRewind while m.rewind is set.
21 type rewindPicker struct {
22 metas []checkpoint.Meta
23 sel int // selected turn (index into metas)
24 stage int // 0 = pick turn, 1 = pick scope, 2 = confirm partial coverage
25 scope int // index into rewindActions (stage 1)
26 pendingPlan checkpoint.RewindPlan
27 }
28
29 var rewindActions = []struct {
30 kind string // "scope" | "fork" | "summ-from" | "summ-upto"
31 scope control.RewindScope
32 }{
33 {"scope", control.RewindBoth},
34 {"scope", control.RewindConversation},
35 {"scope", control.RewindCode},
36 {"fork", 0},
37 {"summ-from", 0},
38 {"summ-upto", 0},
39 }
40
41 // openRewind populates the picker from the session's checkpoints, selecting the
42 // most recent turn. A no-op (with a notice) when there is nothing to rewind.
43 func (m *chatTUI) openRewind() {
44 metas := m.ctrl.Checkpoints()
45 if len(metas) == 0 {
46 m.notice(i18n.M.RewindNone)
47 return
48 }
49 m.rewind = &rewindPicker{metas: metas, sel: len(metas) - 1}
50 }
51
52 func (m chatTUI) handleRewindKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
53 r := m.rewind
54 switch msg.String() {
55 case "esc":
56 switch r.stage {
57 case 2:
58 r.stage = 1
59 r.pendingPlan = checkpoint.RewindPlan{}
60 case 1:
61 r.stage = 0
62 default:
63 m.rewind = nil
64 }
65 case "up", "k":
66 if r.stage == 0 {
67 if r.sel > 0 {
68 r.sel--
69 }
70 } else if r.scope > 0 {
71 r.scope--
72 }
73 case "down", "j":
74 if r.stage == 0 {
75 if r.sel < len(r.metas)-1 {
76 r.sel++
77 }
78 } else if r.scope < len(rewindActions)-1 {
79 r.scope++
80 }
81 case "enter":
82 switch r.stage {
83 case 0:
84 r.stage = 1
85 case 1:
86 return m.applyRewind()
87 default:
88 return m.commitPreparedRewind()
89 }
90 case "y":
91 if r.stage == 2 {
92 return m.commitPreparedRewind()
93 }
94 case "b":
95 if r.stage == 1 {
96 r.scope = 0
97 return m.applyRewind()
98 }
99 case "c":
100 if r.stage == 1 {
101 r.scope = 1
102 return m.applyRewind()
103 }
104 case "d":
105 if r.stage == 1 {
106 r.scope = 2
107 return m.applyRewind()
108 }
109 case "f":
110 if r.stage == 1 {
111 r.scope = 3
112 return m.applyRewind()
113 }
114 case "s":
115 if r.stage == 1 {
116 r.scope = 4
117 return m.applyRewind()
118 }
119 case "u":
120 if r.stage == 1 {
121 r.scope = 5
122 return m.applyRewind()
123 }
124 }
125 return m, nil
126 }
127
128 func (m chatTUI) applyRewind() (tea.Model, tea.Cmd) {
129 r := m.rewind
130 meta := r.metas[r.sel]
131 act := rewindActions[r.scope]
132 // The controller emits notices for operation errors and committed rewinds.
133 // A prepared-but-disabled plan has no controller error, so this picker reports
134 // that precheck result itself below.
135 switch act.kind {
136 case "fork":
137 m.rewind = nil
138 if _, err := m.ctrl.Fork(meta.Turn); err == nil {
139 m.followSessionLease()
140 m.replayActiveBranch(fmt.Sprintf("branched from turn %d", meta.Turn+1))
141 }
142 return m, nil // the branch is a new session
143 case "summ-from":
144 m.rewind = nil
145 _ = m.ctrl.SummarizeFrom(context.Background(), meta.Turn)
146 return m, nil
147 case "summ-upto":
148 m.rewind = nil
149 _ = m.ctrl.SummarizeUpTo(context.Background(), meta.Turn)
150 return m, nil
151 }
152 plan, err := m.ctrl.PrepareRewind(meta.Turn, act.scope)
153 if err != nil {
154 m.rewind = nil
155 return m, nil
156 }
157 if !rewindPlanCanApply(plan) {
158 reason := strings.TrimSpace(plan.DisabledReason)
159 if reason == "" {
160 reason = "precheck failed"
161 }
162 m.notice(fmt.Sprintf(i18n.M.RewindUnavailableFmt, reason))
163 m.rewind = nil
164 return m, nil
165 }
166 if control.RewindPlanRequiresConfirmation(plan) {
167 r.pendingPlan = plan
168 r.stage = 2
169 return m, nil
170 }
171 r.pendingPlan = plan
172 return m.commitPreparedRewind()
173 }
174
175 func rewindPlanCanApply(plan checkpoint.RewindPlan) bool {
176 switch plan.Scope {
177 case checkpoint.RewindCode:
178 return plan.CanFiles
179 case checkpoint.RewindConversation:
180 return plan.CanConversation
181 case checkpoint.RewindBoth:
182 return plan.CanFiles && plan.CanConversation
183 default:
184 return false
185 }
186 }
187
188 func (m chatTUI) commitPreparedRewind() (tea.Model, tea.Cmd) {
189 r := m.rewind
190 if r == nil || r.pendingPlan.PlanID == "" {
191 return m, nil
192 }
193 meta := r.metas[r.sel]
194 scope := control.RewindScope(r.pendingPlan.Scope)
195 planID := r.pendingPlan.PlanID
196 m.rewind = nil
197 result, err := m.ctrl.CommitRewind(planID)
198 if err != nil || !result.OK {
199 return m, nil
200 }
201 // The controller emits a notice marking the rewind point; the committed
202 // transcript stays in terminal scrollback (v2 has no managed viewport), so for a
203 // conversation/both rewind we prefill the composer with that turn's prompt to
204 // re-send or edit — Claude Code's behavior — while the model's context is
205 // truncated underneath.
206 if scope != control.RewindCode && strings.TrimSpace(meta.Prompt) != "" {
207 m.input.SetValue(meta.Prompt)
208 m.growInputToFit()
209 }
210 return m, nil
211 }
212
213 func (m chatTUI) renderRewind() string {
214 r := m.rewind
215 if r == nil {
216 return ""
217 }
218 w := max(m.width, 10)
219 var b strings.Builder
220 if r.stage == 0 {
221 b.WriteString(accent(i18n.M.RewindPickTitle) + "\n")
222 // Long sessions list one row per turn; window it like quickPicker so
223 // the overlay never outgrows the terminal (no scrolling viewport).
224 start, end := quickPickerWindow(len(r.metas), r.sel)
225 if start > 0 {
226 b.WriteString(dim(" ↑ more") + "\n")
227 }
228 for i := start; i < end; i++ {
229 meta := r.metas[i]
230 b.WriteString(rowLine(i == r.sel, meta.Turn+1, "", turnLabel(meta, w), false) + "\n")
231 }
232 if end < len(r.metas) {
233 b.WriteString(dim(" ↓ more") + "\n")
234 }
235 b.WriteString(dim(i18n.M.RewindPickHint))
236 return choicePanelStyle.Width(w).Render(b.String())
237 }
238 meta := r.metas[r.sel]
239 if r.stage == 2 {
240 b.WriteString(accent(i18n.M.RewindCoverageTitle) + "\n")
241 b.WriteString(fmt.Sprintf(i18n.M.RewindCoverageWarningFmt, len(r.pendingPlan.CoverageGaps)) + "\n")
242 b.WriteString(dim(fmt.Sprintf(i18n.M.RewindRestoreTitleFmt, meta.Turn+1)+oneLine(meta.Prompt, 48)) + "\n")
243 b.WriteString(dim(i18n.M.RewindConfirmHint))
244 return choicePanelStyle.Width(w).Render(b.String())
245 }
246 b.WriteString(accent(fmt.Sprintf(i18n.M.RewindRestoreTitleFmt, meta.Turn+1)) + dim(oneLine(meta.Prompt, 48)) + "\n")
247 for i := range rewindActions {
248 b.WriteString(rowLine(i == r.scope, i+1, "", rewindActionLabel(i), false) + "\n")
249 }
250 b.WriteString(dim(i18n.M.RewindApplyHint))
251 return choicePanelStyle.Width(w).Render(b.String())
252 }
253
254 func rewindActionLabel(i int) string {
255 switch i {
256 case 0:
257 return i18n.M.RewindCodeConversation
258 case 1:
259 return i18n.M.RewindConversationOnly
260 case 2:
261 return i18n.M.RewindCodeOnly
262 case 3:
263 return i18n.M.RewindFork
264 case 4:
265 return i18n.M.RewindSummarizeFrom
266 case 5:
267 return i18n.M.RewindSummarizeUpto
268 default:
269 return ""
270 }
271 }
272
273 func turnLabel(meta checkpoint.Meta, w int) string {
274 label := oneLine(meta.Prompt, max(20, w-30))
275 if n := len(meta.Paths); n > 0 {
276 s := ""
277 if n != 1 {
278 s = "s"
279 }
280 label += dim(fmt.Sprintf(" (%d file%s)", n, s))
281 }
282 return label
283 }
284
285 // oneLine flattens s to a single line and truncates it to display width n.
286 func oneLine(s string, n int) string {
287 s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
288 if s == "" {
289 return i18n.M.RewindEmpty
290 }
291 return ansi.Truncate(s, n, "…")
292 }
293
293 lines GO