diff --git a/crates/tui/src/tui/backtrack.rs b/crates/tui/src/tui/backtrack.rs new file mode 100644 index 00000000..0bf9e60b --- /dev/null +++ b/crates/tui/src/tui/backtrack.rs @@ -0,0 +1,386 @@ +//! Esc-Esc backtrack state machine (issue #133). +//! +//! Lets the user rewind the active conversation to a previous user message. +//! The chord is intentionally two-step so a single stray `Esc` after a popup +//! close cannot accidentally rewind a turn: +//! +//! 1. **First Esc** (no popup, no streaming, nothing to clear) — moves +//! `Inactive` → `Primed`. The composer surfaces a transient hint +//! ("Press Esc again to backtrack"). A second Esc within the prime +//! window opens the overlay. Any other key path can later cancel the +//! prime. +//! 2. **Second Esc** — moves `Primed` → `Selecting { selected_idx: 0 }`. +//! The live-transcript overlay opens with the most recent user message +//! highlighted. Left/Right step through prior user messages. +//! 3. **Enter** — commits the selection: yields the chosen `selected_idx` +//! (a depth-from-tail offset, where `0` = newest user turn). Resets the +//! machine to `Inactive`. The caller then forks the thread, populates +//! the composer with the rolled-back text, and trims the transcript. +//! +//! The state machine knows nothing about the rest of the app — it stores +//! only the small bookkeeping required to pick the right user turn. UI +//! routing (popup detection, streaming guard, fork side effects) lives in +//! `tui::ui`. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BacktrackPhase { + /// No prime in flight; Esc behaves normally. + #[default] + Inactive, + /// First Esc captured. The next Esc transitions into `Selecting`; any + /// other Esc-equivalent dismissal cancels back to `Inactive`. + Primed, + /// Overlay open. `selected_idx` is the depth-from-tail of the user + /// message currently highlighted (`0` = most recent). `total` is the + /// number of user messages available to step through, captured at + /// entry so bounds checks stay stable even if the transcript mutates + /// underneath the overlay (which it will, because the engine never + /// pauses). + Selecting { selected_idx: usize, total: usize }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + /// Step toward older user messages (increases `selected_idx`). + Left, + /// Step toward newer user messages (decreases `selected_idx`). + Right, +} + +/// What the caller should do in response to a single `Esc` press. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EscEffect { + /// No backtrack action — the caller should run its normal Esc path. + None, + /// Move from `Inactive` to `Primed`. The caller should surface the + /// transient prime hint. + Prime, + /// Cancel a Primed state without entering Selecting. The caller should + /// clear the prime hint. + Cancel, + /// Open the backtrack overlay (we transitioned `Primed` → `Selecting`). + /// The caller should push the live-transcript overlay in + /// `BacktrackPreview` mode. + OpenOverlay, +} + +/// Small bookkeeping struct hung off `App`. Owns only the state machine — +/// no transcript snapshots, no UI handles. The caller is responsible for +/// telling the state machine how many user messages exist when entering +/// `Selecting`, which avoids tying this module to any particular +/// transcript representation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct BacktrackState { + pub phase: BacktrackPhase, +} + +impl BacktrackState { + #[must_use] + pub fn new() -> Self { + Self { + phase: BacktrackPhase::Inactive, + } + } + + /// `true` whenever the user has armed or opened backtrack. The UI uses + /// this to skip the prime hint once the overlay is up and to know + /// whether arrow keys should drive selection. + #[allow(dead_code)] // helper exposed for future UI consumers + tests. + #[must_use] + pub fn is_active(&self) -> bool { + !matches!(self.phase, BacktrackPhase::Inactive) + } + + /// `true` only when the overlay is open and Left/Right should step + /// through prior user messages. `Primed` is intentionally excluded — + /// during the prime window arrows still scroll the transcript. + #[allow(dead_code)] // helper exposed for future UI consumers + tests. + #[must_use] + pub fn is_selecting(&self) -> bool { + matches!(self.phase, BacktrackPhase::Selecting { .. }) + } + + /// Current depth-from-tail offset, if any. Convenient for renderers + /// that need the highlight index without matching the enum. + #[must_use] + pub fn selected_idx(&self) -> Option { + match self.phase { + BacktrackPhase::Selecting { selected_idx, .. } => Some(selected_idx), + _ => None, + } + } + + /// Process an Esc press. + /// + /// `total_user_messages` is the count of user turns in the live + /// transcript right now. It's only consulted on the `Primed` → `Selecting` + /// transition; a value of `0` short-circuits and cancels the prime + /// (nothing to backtrack to). + pub fn handle_esc(&mut self, total_user_messages: usize) -> EscEffect { + match self.phase { + BacktrackPhase::Inactive => { + if total_user_messages == 0 { + // Nothing to backtrack to — do not even prime. + return EscEffect::None; + } + self.phase = BacktrackPhase::Primed; + EscEffect::Prime + } + BacktrackPhase::Primed => { + if total_user_messages == 0 { + self.phase = BacktrackPhase::Inactive; + return EscEffect::Cancel; + } + self.phase = BacktrackPhase::Selecting { + selected_idx: 0, + total: total_user_messages, + }; + EscEffect::OpenOverlay + } + BacktrackPhase::Selecting { .. } => { + // Esc while Selecting closes the overlay via the modal's own + // handler; it should not be routed back through here. Defend + // against accidental routing by canceling. + self.phase = BacktrackPhase::Inactive; + EscEffect::Cancel + } + } + } + + /// Step the selection while in `Selecting`. No-op in any other phase. + /// `Left` walks backward in time (older), `Right` walks forward (newer). + /// Bounds-checked: `selected_idx` is clamped to `[0, total - 1]`. + pub fn step(&mut self, dir: Direction) { + if let BacktrackPhase::Selecting { + selected_idx, + total, + } = self.phase + { + if total == 0 { + return; + } + let last = total.saturating_sub(1); + let new_idx = match dir { + Direction::Left => selected_idx.saturating_add(1).min(last), + Direction::Right => selected_idx.saturating_sub(1), + }; + self.phase = BacktrackPhase::Selecting { + selected_idx: new_idx, + total, + }; + } + } + + /// Commit the current selection. Returns the depth-from-tail offset + /// (0 = newest user turn) on success and resets to `Inactive`. + /// Returns `None` if not currently selecting — the caller should treat + /// it as a no-op. + pub fn confirm(&mut self) -> Option { + match self.phase { + BacktrackPhase::Selecting { selected_idx, .. } => { + self.phase = BacktrackPhase::Inactive; + Some(selected_idx) + } + _ => None, + } + } + + /// Force the state machine back to `Inactive`. Used by the UI when a + /// popup steals focus, when streaming starts, when the overlay closes + /// without a confirm, and when any non-arrow / non-Enter key arrives + /// during `Primed`. + pub fn reset(&mut self) { + self.phase = BacktrackPhase::Inactive; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_state_is_inactive() { + let s = BacktrackState::new(); + assert!(!s.is_active()); + assert!(!s.is_selecting()); + assert_eq!(s.selected_idx(), None); + } + + #[test] + fn first_esc_primes() { + let mut s = BacktrackState::new(); + let effect = s.handle_esc(3); + assert_eq!(effect, EscEffect::Prime); + assert!(matches!(s.phase, BacktrackPhase::Primed)); + assert!(s.is_active()); + assert!(!s.is_selecting()); + } + + #[test] + fn first_esc_with_no_user_messages_is_noop() { + let mut s = BacktrackState::new(); + let effect = s.handle_esc(0); + assert_eq!(effect, EscEffect::None); + assert!(matches!(s.phase, BacktrackPhase::Inactive)); + } + + #[test] + fn double_esc_enters_selecting() { + let mut s = BacktrackState::new(); + assert_eq!(s.handle_esc(5), EscEffect::Prime); + let effect = s.handle_esc(5); + assert_eq!(effect, EscEffect::OpenOverlay); + assert_eq!( + s.phase, + BacktrackPhase::Selecting { + selected_idx: 0, + total: 5, + } + ); + assert!(s.is_selecting()); + } + + #[test] + fn primed_with_zero_messages_cancels() { + // If the transcript empties between the first and second Esc (e.g. + // /clear ran in another path), the second Esc must cancel rather + // than open an empty overlay. + let mut s = BacktrackState::new(); + s.phase = BacktrackPhase::Primed; + let effect = s.handle_esc(0); + assert_eq!(effect, EscEffect::Cancel); + assert!(matches!(s.phase, BacktrackPhase::Inactive)); + } + + #[test] + fn step_left_walks_back_in_time() { + let mut s = BacktrackState::new(); + s.phase = BacktrackPhase::Selecting { + selected_idx: 0, + total: 3, + }; + s.step(Direction::Left); + assert_eq!(s.selected_idx(), Some(1)); + s.step(Direction::Left); + assert_eq!(s.selected_idx(), Some(2)); + // Bounds: cannot go past `total - 1`. + s.step(Direction::Left); + assert_eq!(s.selected_idx(), Some(2)); + } + + #[test] + fn step_right_walks_forward_in_time() { + let mut s = BacktrackState::new(); + s.phase = BacktrackPhase::Selecting { + selected_idx: 2, + total: 3, + }; + s.step(Direction::Right); + assert_eq!(s.selected_idx(), Some(1)); + s.step(Direction::Right); + assert_eq!(s.selected_idx(), Some(0)); + // Bounds: saturating_sub keeps the floor at 0. + s.step(Direction::Right); + assert_eq!(s.selected_idx(), Some(0)); + } + + #[test] + fn step_in_inactive_or_primed_is_noop() { + let mut s = BacktrackState::new(); + s.step(Direction::Left); + assert!(matches!(s.phase, BacktrackPhase::Inactive)); + s.phase = BacktrackPhase::Primed; + s.step(Direction::Right); + assert!(matches!(s.phase, BacktrackPhase::Primed)); + } + + #[test] + fn step_with_total_one_clamps_at_zero() { + let mut s = BacktrackState::new(); + s.phase = BacktrackPhase::Selecting { + selected_idx: 0, + total: 1, + }; + s.step(Direction::Left); + assert_eq!(s.selected_idx(), Some(0)); + s.step(Direction::Right); + assert_eq!(s.selected_idx(), Some(0)); + } + + #[test] + fn confirm_yields_index_and_resets() { + let mut s = BacktrackState::new(); + s.phase = BacktrackPhase::Selecting { + selected_idx: 2, + total: 5, + }; + let idx = s.confirm(); + assert_eq!(idx, Some(2)); + assert!(matches!(s.phase, BacktrackPhase::Inactive)); + } + + #[test] + fn confirm_outside_selecting_returns_none() { + let mut s = BacktrackState::new(); + assert_eq!(s.confirm(), None); + s.phase = BacktrackPhase::Primed; + assert_eq!(s.confirm(), None); + assert!(matches!(s.phase, BacktrackPhase::Primed)); + } + + #[test] + fn reset_returns_to_inactive_from_any_phase() { + let mut s = BacktrackState::new(); + s.phase = BacktrackPhase::Primed; + s.reset(); + assert!(matches!(s.phase, BacktrackPhase::Inactive)); + + s.phase = BacktrackPhase::Selecting { + selected_idx: 1, + total: 3, + }; + s.reset(); + assert!(matches!(s.phase, BacktrackPhase::Inactive)); + } + + #[test] + fn esc_during_selecting_resets_defensively() { + // Routing Esc through the state machine while already selecting + // should not enter a fourth state — it cancels. The overlay's own + // Esc handler is the canonical close path, but we defend against + // a callsite that misroutes. + let mut s = BacktrackState::new(); + s.phase = BacktrackPhase::Selecting { + selected_idx: 1, + total: 3, + }; + let effect = s.handle_esc(3); + assert_eq!(effect, EscEffect::Cancel); + assert!(matches!(s.phase, BacktrackPhase::Inactive)); + } + + #[test] + fn primed_then_step_then_second_esc_reaches_selecting() { + // Steps that arrive while Primed should be no-ops on phase, so a + // subsequent Esc still completes the chord. (Practically this + // matters for the case where the user, for instance, pressed an + // arrow key while the prime hint was visible.) + let mut s = BacktrackState::new(); + assert_eq!(s.handle_esc(2), EscEffect::Prime); + s.step(Direction::Left); // no-op + assert!(matches!(s.phase, BacktrackPhase::Primed)); + assert_eq!(s.handle_esc(2), EscEffect::OpenOverlay); + assert_eq!(s.selected_idx(), Some(0)); + } + + #[test] + fn full_walk_then_confirm_returns_chosen_index() { + let mut s = BacktrackState::new(); + assert_eq!(s.handle_esc(4), EscEffect::Prime); + assert_eq!(s.handle_esc(4), EscEffect::OpenOverlay); + s.step(Direction::Left); // 0 -> 1 + s.step(Direction::Left); // 1 -> 2 + assert_eq!(s.confirm(), Some(2)); + assert!(matches!(s.phase, BacktrackPhase::Inactive)); + } +} diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index 5eeca1a1..cdaca726 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -5,6 +5,7 @@ pub mod active_cell; pub mod app; pub mod approval; +pub mod backtrack; pub mod clipboard; pub mod command_palette; pub mod diff_render;