返回 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 controller is on the fork now
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.CommitRewindInPlace(planID)
198 if err != nil || !result.OK {
199 return m, nil
200 }
201 if result.ConversationForked {
202 // The controller is already on the rewound conversation: a head of the
203 // same log, or the fork file of a schema-1 session. Only the lease and
204 // the transcript view still follow it.
205 m.followSessionLease()
206 m.replayActiveBranch(fmt.Sprintf("rewound to turn %d", meta.Turn+1))
207 }
208 // Conversation rewind activates the fork and prefills the selected prompt
209 // for editing. Code-only rewind keeps the current transcript on screen.
210 if scope != control.RewindCode && strings.TrimSpace(meta.Prompt) != "" {
211 m.input.SetValue(meta.Prompt)
212 m.growInputToFit()
213 }
214 return m, nil
215 }
216
217 func (m chatTUI) renderRewind() string {
218 r := m.rewind
219 if r == nil {
220 return ""
221 }
222 w := max(m.width, 10)
223 var b strings.Builder
224 if r.stage == 0 {
225 b.WriteString(accent(i18n.M.RewindPickTitle) + "\n")
226 // Long sessions list one row per turn; window it like quickPicker so
227 // the overlay never outgrows the terminal (no scrolling viewport).
228 start, end := quickPickerWindow(len(r.metas), r.sel)
229 if start > 0 {
230 b.WriteString(dim(" ↑ more") + "\n")
231 }
232 for i := start; i < end; i++ {
233 meta := r.metas[i]
234 b.WriteString(rowLine(i == r.sel, meta.Turn+1, "", turnLabel(meta, w), false) + "\n")
235 }
236 if end < len(r.metas) {
237 b.WriteString(dim(" ↓ more") + "\n")
238 }
239 b.WriteString(dim(i18n.M.RewindPickHint))
240 return choicePanelStyle.Width(w).Render(b.String())
241 }
242 meta := r.metas[r.sel]
243 if r.stage == 2 {
244 b.WriteString(accent(i18n.M.RewindCoverageTitle) + "\n")
245 b.WriteString(fmt.Sprintf(i18n.M.RewindCoverageWarningFmt, len(r.pendingPlan.CoverageGaps)) + "\n")
246 b.WriteString(dim(fmt.Sprintf(i18n.M.RewindRestoreTitleFmt, meta.Turn+1)+oneLine(meta.Prompt, 48)) + "\n")
247 b.WriteString(dim(i18n.M.RewindConfirmHint))
248 return choicePanelStyle.Width(w).Render(b.String())
249 }
250 b.WriteString(accent(fmt.Sprintf(i18n.M.RewindRestoreTitleFmt, meta.Turn+1)) + dim(oneLine(meta.Prompt, 48)) + "\n")
251 for i := range rewindActions {
252 b.WriteString(rowLine(i == r.scope, i+1, "", rewindActionLabel(i), false) + "\n")
253 }
254 b.WriteString(dim(i18n.M.RewindApplyHint))
255 return choicePanelStyle.Width(w).Render(b.String())
256 }
257
258 func rewindActionLabel(i int) string {
259 switch i {
260 case 0:
261 return i18n.M.RewindCodeConversation
262 case 1:
263 return i18n.M.RewindConversationOnly
264 case 2:
265 return i18n.M.RewindCodeOnly
266 case 3:
267 return i18n.M.RewindFork
268 case 4:
269 return i18n.M.RewindSummarizeFrom
270 case 5:
271 return i18n.M.RewindSummarizeUpto
272 default:
273 return ""
274 }
275 }
276
277 func turnLabel(meta checkpoint.Meta, w int) string {
278 label := oneLine(meta.Prompt, max(20, w-30))
279 if n := len(meta.Paths); n > 0 {
280 s := ""
281 if n != 1 {
282 s = "s"
283 }
284 label += dim(fmt.Sprintf(" (%d file%s)", n, s))
285 }
286 return label
287 }
288
289 // oneLine flattens s to a single line and truncates it to display width n.
290 func oneLine(s string, n int) string {
291 s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
292 if s == "" {
293 return i18n.M.RewindEmpty
294 }
295 return ansi.Truncate(s, n, "…")
296 }
297
297 lines GO