返回 DeepSeek-TUI-2026
backtrack.rs
根目录 / crates / tui / src / tui / backtrack.rs
1 //! Esc-Esc backtrack state machine (issue #133).
2 //!
3 //! Lets the user rewind the active conversation to a previous user message.
4 //! The chord is intentionally two-step so a single stray `Esc` after a popup
5 //! close cannot accidentally rewind a turn:
6 //!
7 //! 1. **First Esc** (no popup, no streaming, nothing to clear) — moves
8 //! `Inactive` → `Primed`. The composer surfaces a transient hint
9 //! ("Press Esc again to backtrack"). A second Esc within the prime
10 //! window opens the overlay. Any other key path can later cancel the
11 //! prime.
12 //! 2. **Second Esc** — moves `Primed` → `Selecting { selected_idx: 0 }`.
13 //! The live-transcript overlay opens with the most recent user message
14 //! highlighted. Left/Right step through prior user messages.
15 //! 3. **Enter** — commits the selection: yields the chosen `selected_idx`
16 //! (a depth-from-tail offset, where `0` = newest user turn). Resets the
17 //! machine to `Inactive`. The caller then forks the thread, populates
18 //! the composer with the rolled-back text, and trims the transcript.
19 //!
20 //! The state machine knows nothing about the rest of the app — it stores
21 //! only the small bookkeeping required to pick the right user turn. UI
22 //! routing (popup detection, streaming guard, fork side effects) lives in
23 //! `tui::ui`.
24
25 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26 pub enum BacktrackPhase {
27 /// No prime in flight; Esc behaves normally.
28 #[default]
29 Inactive,
30 /// First Esc captured. The next Esc transitions into `Selecting`; any
31 /// other Esc-equivalent dismissal cancels back to `Inactive`.
32 Primed,
33 /// Overlay open. `selected_idx` is the depth-from-tail of the user
34 /// message currently highlighted (`0` = most recent). `total` is the
35 /// number of user messages available to step through, captured at
36 /// entry so bounds checks stay stable even if the transcript mutates
37 /// underneath the overlay (which it will, because the engine never
38 /// pauses).
39 Selecting { selected_idx: usize, total: usize },
40 }
41
42 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
43 pub enum Direction {
44 /// Step toward older user messages (increases `selected_idx`).
45 Left,
46 /// Step toward newer user messages (decreases `selected_idx`).
47 Right,
48 }
49
50 /// What the caller should do in response to a single `Esc` press.
51 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
52 pub enum EscEffect {
53 /// No backtrack action — the caller should run its normal Esc path.
54 None,
55 /// Move from `Inactive` to `Primed`. The caller should surface the
56 /// transient prime hint.
57 Prime,
58 /// Cancel a Primed state without entering Selecting. The caller should
59 /// clear the prime hint.
60 Cancel,
61 /// Open the backtrack overlay (we transitioned `Primed` → `Selecting`).
62 /// The caller should push the live-transcript overlay in
63 /// `BacktrackPreview` mode.
64 OpenOverlay,
65 }
66
67 /// Small bookkeeping struct hung off `App`. Owns only the state machine —
68 /// no transcript snapshots, no UI handles. The caller is responsible for
69 /// telling the state machine how many user messages exist when entering
70 /// `Selecting`, which avoids tying this module to any particular
71 /// transcript representation.
72 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73 pub struct BacktrackState {
74 pub phase: BacktrackPhase,
75 }
76
77 impl BacktrackState {
78 #[must_use]
79 pub fn new() -> Self {
80 Self {
81 phase: BacktrackPhase::Inactive,
82 }
83 }
84
85 /// `true` whenever the user has armed or opened backtrack. The UI uses
86 /// this to skip the prime hint once the overlay is up and to know
87 /// whether arrow keys should drive selection.
88 #[allow(dead_code)] // helper exposed for future UI consumers + tests.
89 #[must_use]
90 pub fn is_active(&self) -> bool {
91 !matches!(self.phase, BacktrackPhase::Inactive)
92 }
93
94 /// `true` only when the overlay is open and Left/Right should step
95 /// through prior user messages. `Primed` is intentionally excluded —
96 /// during the prime window arrows still scroll the transcript.
97 #[allow(dead_code)] // helper exposed for future UI consumers + tests.
98 #[must_use]
99 pub fn is_selecting(&self) -> bool {
100 matches!(self.phase, BacktrackPhase::Selecting { .. })
101 }
102
103 /// Current depth-from-tail offset, if any. Convenient for renderers
104 /// that need the highlight index without matching the enum.
105 #[must_use]
106 pub fn selected_idx(&self) -> Option<usize> {
107 match self.phase {
108 BacktrackPhase::Selecting { selected_idx, .. } => Some(selected_idx),
109 _ => None,
110 }
111 }
112
113 /// Process an Esc press.
114 ///
115 /// `total_user_messages` is the count of user turns in the live
116 /// transcript right now. It's only consulted on the `Primed` → `Selecting`
117 /// transition; a value of `0` short-circuits and cancels the prime
118 /// (nothing to backtrack to).
119 pub fn handle_esc(&mut self, total_user_messages: usize) -> EscEffect {
120 match self.phase {
121 BacktrackPhase::Inactive => {
122 if total_user_messages == 0 {
123 // Nothing to backtrack to — do not even prime.
124 return EscEffect::None;
125 }
126 self.phase = BacktrackPhase::Primed;
127 EscEffect::Prime
128 }
129 BacktrackPhase::Primed => {
130 if total_user_messages == 0 {
131 self.phase = BacktrackPhase::Inactive;
132 return EscEffect::Cancel;
133 }
134 self.phase = BacktrackPhase::Selecting {
135 selected_idx: 0,
136 total: total_user_messages,
137 };
138 EscEffect::OpenOverlay
139 }
140 BacktrackPhase::Selecting { .. } => {
141 // Esc while Selecting closes the overlay via the modal's own
142 // handler; it should not be routed back through here. Defend
143 // against accidental routing by canceling.
144 self.phase = BacktrackPhase::Inactive;
145 EscEffect::Cancel
146 }
147 }
148 }
149
150 /// Step the selection while in `Selecting`. No-op in any other phase.
151 /// `Left` walks backward in time (older), `Right` walks forward (newer).
152 /// Bounds-checked: `selected_idx` is clamped to `[0, total - 1]`.
153 pub fn step(&mut self, dir: Direction) {
154 if let BacktrackPhase::Selecting {
155 selected_idx,
156 total,
157 } = self.phase
158 {
159 if total == 0 {
160 return;
161 }
162 let last = total.saturating_sub(1);
163 let new_idx = match dir {
164 Direction::Left => selected_idx.saturating_add(1).min(last),
165 Direction::Right => selected_idx.saturating_sub(1),
166 };
167 self.phase = BacktrackPhase::Selecting {
168 selected_idx: new_idx,
169 total,
170 };
171 }
172 }
173
174 /// Commit the current selection. Returns the depth-from-tail offset
175 /// (0 = newest user turn) on success and resets to `Inactive`.
176 /// Returns `None` if not currently selecting — the caller should treat
177 /// it as a no-op.
178 pub fn confirm(&mut self) -> Option<usize> {
179 match self.phase {
180 BacktrackPhase::Selecting { selected_idx, .. } => {
181 self.phase = BacktrackPhase::Inactive;
182 Some(selected_idx)
183 }
184 _ => None,
185 }
186 }
187
188 /// Force the state machine back to `Inactive`. Used by the UI when a
189 /// popup steals focus, when streaming starts, when the overlay closes
190 /// without a confirm, and when any non-arrow / non-Enter key arrives
191 /// during `Primed`.
192 pub fn reset(&mut self) {
193 self.phase = BacktrackPhase::Inactive;
194 }
195 }
196
197 #[cfg(test)]
198 mod tests {
199 use super::*;
200
201 #[test]
202 fn new_state_is_inactive() {
203 let s = BacktrackState::new();
204 assert!(!s.is_active());
205 assert!(!s.is_selecting());
206 assert_eq!(s.selected_idx(), None);
207 }
208
209 #[test]
210 fn first_esc_primes() {
211 let mut s = BacktrackState::new();
212 let effect = s.handle_esc(3);
213 assert_eq!(effect, EscEffect::Prime);
214 assert!(matches!(s.phase, BacktrackPhase::Primed));
215 assert!(s.is_active());
216 assert!(!s.is_selecting());
217 }
218
219 #[test]
220 fn first_esc_with_no_user_messages_is_noop() {
221 let mut s = BacktrackState::new();
222 let effect = s.handle_esc(0);
223 assert_eq!(effect, EscEffect::None);
224 assert!(matches!(s.phase, BacktrackPhase::Inactive));
225 }
226
227 #[test]
228 fn double_esc_enters_selecting() {
229 let mut s = BacktrackState::new();
230 assert_eq!(s.handle_esc(5), EscEffect::Prime);
231 let effect = s.handle_esc(5);
232 assert_eq!(effect, EscEffect::OpenOverlay);
233 assert_eq!(
234 s.phase,
235 BacktrackPhase::Selecting {
236 selected_idx: 0,
237 total: 5,
238 }
239 );
240 assert!(s.is_selecting());
241 }
242
243 #[test]
244 fn primed_with_zero_messages_cancels() {
245 // If the transcript empties between the first and second Esc (e.g.
246 // /clear ran in another path), the second Esc must cancel rather
247 // than open an empty overlay.
248 let mut s = BacktrackState::new();
249 s.phase = BacktrackPhase::Primed;
250 let effect = s.handle_esc(0);
251 assert_eq!(effect, EscEffect::Cancel);
252 assert!(matches!(s.phase, BacktrackPhase::Inactive));
253 }
254
255 #[test]
256 fn step_left_walks_back_in_time() {
257 let mut s = BacktrackState::new();
258 s.phase = BacktrackPhase::Selecting {
259 selected_idx: 0,
260 total: 3,
261 };
262 s.step(Direction::Left);
263 assert_eq!(s.selected_idx(), Some(1));
264 s.step(Direction::Left);
265 assert_eq!(s.selected_idx(), Some(2));
266 // Bounds: cannot go past `total - 1`.
267 s.step(Direction::Left);
268 assert_eq!(s.selected_idx(), Some(2));
269 }
270
271 #[test]
272 fn step_right_walks_forward_in_time() {
273 let mut s = BacktrackState::new();
274 s.phase = BacktrackPhase::Selecting {
275 selected_idx: 2,
276 total: 3,
277 };
278 s.step(Direction::Right);
279 assert_eq!(s.selected_idx(), Some(1));
280 s.step(Direction::Right);
281 assert_eq!(s.selected_idx(), Some(0));
282 // Bounds: saturating_sub keeps the floor at 0.
283 s.step(Direction::Right);
284 assert_eq!(s.selected_idx(), Some(0));
285 }
286
287 #[test]
288 fn step_in_inactive_or_primed_is_noop() {
289 let mut s = BacktrackState::new();
290 s.step(Direction::Left);
291 assert!(matches!(s.phase, BacktrackPhase::Inactive));
292 s.phase = BacktrackPhase::Primed;
293 s.step(Direction::Right);
294 assert!(matches!(s.phase, BacktrackPhase::Primed));
295 }
296
297 #[test]
298 fn step_with_total_one_clamps_at_zero() {
299 let mut s = BacktrackState::new();
300 s.phase = BacktrackPhase::Selecting {
301 selected_idx: 0,
302 total: 1,
303 };
304 s.step(Direction::Left);
305 assert_eq!(s.selected_idx(), Some(0));
306 s.step(Direction::Right);
307 assert_eq!(s.selected_idx(), Some(0));
308 }
309
310 #[test]
311 fn confirm_yields_index_and_resets() {
312 let mut s = BacktrackState::new();
313 s.phase = BacktrackPhase::Selecting {
314 selected_idx: 2,
315 total: 5,
316 };
317 let idx = s.confirm();
318 assert_eq!(idx, Some(2));
319 assert!(matches!(s.phase, BacktrackPhase::Inactive));
320 }
321
322 #[test]
323 fn confirm_outside_selecting_returns_none() {
324 let mut s = BacktrackState::new();
325 assert_eq!(s.confirm(), None);
326 s.phase = BacktrackPhase::Primed;
327 assert_eq!(s.confirm(), None);
328 assert!(matches!(s.phase, BacktrackPhase::Primed));
329 }
330
331 #[test]
332 fn reset_returns_to_inactive_from_any_phase() {
333 let mut s = BacktrackState::new();
334 s.phase = BacktrackPhase::Primed;
335 s.reset();
336 assert!(matches!(s.phase, BacktrackPhase::Inactive));
337
338 s.phase = BacktrackPhase::Selecting {
339 selected_idx: 1,
340 total: 3,
341 };
342 s.reset();
343 assert!(matches!(s.phase, BacktrackPhase::Inactive));
344 }
345
346 #[test]
347 fn esc_during_selecting_resets_defensively() {
348 // Routing Esc through the state machine while already selecting
349 // should not enter a fourth state — it cancels. The overlay's own
350 // Esc handler is the canonical close path, but we defend against
351 // a callsite that misroutes.
352 let mut s = BacktrackState::new();
353 s.phase = BacktrackPhase::Selecting {
354 selected_idx: 1,
355 total: 3,
356 };
357 let effect = s.handle_esc(3);
358 assert_eq!(effect, EscEffect::Cancel);
359 assert!(matches!(s.phase, BacktrackPhase::Inactive));
360 }
361
362 #[test]
363 fn primed_then_step_then_second_esc_reaches_selecting() {
364 // Steps that arrive while Primed should be no-ops on phase, so a
365 // subsequent Esc still completes the chord. (Practically this
366 // matters for the case where the user, for instance, pressed an
367 // arrow key while the prime hint was visible.)
368 let mut s = BacktrackState::new();
369 assert_eq!(s.handle_esc(2), EscEffect::Prime);
370 s.step(Direction::Left); // no-op
371 assert!(matches!(s.phase, BacktrackPhase::Primed));
372 assert_eq!(s.handle_esc(2), EscEffect::OpenOverlay);
373 assert_eq!(s.selected_idx(), Some(0));
374 }
375
376 #[test]
377 fn full_walk_then_confirm_returns_chosen_index() {
378 let mut s = BacktrackState::new();
379 assert_eq!(s.handle_esc(4), EscEffect::Prime);
380 assert_eq!(s.handle_esc(4), EscEffect::OpenOverlay);
381 s.step(Direction::Left); // 0 -> 1
382 s.step(Direction::Left); // 1 -> 2
383 assert_eq!(s.confirm(), Some(2));
384 assert!(matches!(s.phase, BacktrackPhase::Inactive));
385 }
386 }
387
387 lines RUST