| 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 |