Merge branch 'feat/v070-backtrack' (#133 Esc-Esc backtrack)
This commit is contained in:
@@ -843,6 +843,125 @@ impl RuntimeThreadManager {
|
|||||||
Ok(forked)
|
Ok(forked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fork a thread, dropping every turn from the Nth-from-tail user
|
||||||
|
/// message onward (issue #133 — Esc-Esc backtrack).
|
||||||
|
///
|
||||||
|
/// `depth_from_tail` selects which user turn to roll back *to*:
|
||||||
|
///
|
||||||
|
/// - `0` — drop the most recent turn (the freshest user message and
|
||||||
|
/// everything after it)
|
||||||
|
/// - `1` — drop the two most recent turns (rewind one further)
|
||||||
|
/// - …and so on
|
||||||
|
///
|
||||||
|
/// Returns a tuple of `(forked_thread, original_user_text)` where the
|
||||||
|
/// second element is the `detail` of the first `UserMessage` item in
|
||||||
|
/// the *first dropped* turn — i.e. the input the user typed to start
|
||||||
|
/// that turn — so the caller can pre-populate the composer with it.
|
||||||
|
/// `None` when no detail was recorded (defensive — every persisted
|
||||||
|
/// `UserMessage` since v0.6 carries a detail string).
|
||||||
|
///
|
||||||
|
/// Counts user turns by iterating `list_turns_for_thread` (sorted
|
||||||
|
/// oldest → newest) backwards. A turn is counted as a "user turn"
|
||||||
|
/// when at least one of its items has `kind ==
|
||||||
|
/// TurnItemKind::UserMessage`. Steered turns (which append additional
|
||||||
|
/// `UserMessage` items) still count as one turn — backtrack rewinds
|
||||||
|
/// at the turn boundary, not at the steer boundary.
|
||||||
|
///
|
||||||
|
/// Errors:
|
||||||
|
/// - `depth_from_tail` exceeds the number of user turns
|
||||||
|
/// - source thread not found
|
||||||
|
#[allow(dead_code)] // exposed for the runtime/HTTP fork-on-backtrack path; the in-TUI Esc-Esc flow trims `App` state directly. Issue #133.
|
||||||
|
pub async fn fork_at_user_message(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
depth_from_tail: usize,
|
||||||
|
) -> Result<(ThreadRecord, Option<String>)> {
|
||||||
|
let source = self.get_thread(id).await?;
|
||||||
|
let source_turns = self.store.list_turns_for_thread(&source.id)?;
|
||||||
|
|
||||||
|
// Walk turns from newest to oldest. For each turn, ask: does it
|
||||||
|
// contain a UserMessage item? If yes, it counts toward the depth.
|
||||||
|
let mut user_turn_indices: Vec<usize> = Vec::new();
|
||||||
|
for (idx, turn) in source_turns.iter().enumerate().rev() {
|
||||||
|
let items = self.store.list_items_for_turn(&turn.id)?;
|
||||||
|
if items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.kind == TurnItemKind::UserMessage)
|
||||||
|
{
|
||||||
|
user_turn_indices.push(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if depth_from_tail >= user_turn_indices.len() {
|
||||||
|
bail!(
|
||||||
|
"fork_at_user_message: depth {} exceeds {} user turn(s)",
|
||||||
|
depth_from_tail,
|
||||||
|
user_turn_indices.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// `user_turn_indices` is newest-first because we iterated in
|
||||||
|
// reverse, so the Nth element is exactly the Nth-from-tail user
|
||||||
|
// turn in the original chronological list.
|
||||||
|
let target_turn_idx = user_turn_indices[depth_from_tail];
|
||||||
|
let target_turn_id = source_turns[target_turn_idx].id.clone();
|
||||||
|
|
||||||
|
// Pull the original user-message text out of the dropped turn so
|
||||||
|
// the caller can drop it back into the composer.
|
||||||
|
let target_items = self.store.list_items_for_turn(&target_turn_id)?;
|
||||||
|
let original_user_text = target_items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.kind == TurnItemKind::UserMessage)
|
||||||
|
.and_then(|item| item.detail.clone());
|
||||||
|
|
||||||
|
// Copy turns strictly before `target_turn_idx` into a new thread.
|
||||||
|
// Mirrors `fork_thread` but stops at the cutoff instead of copying
|
||||||
|
// every turn. Kept structurally close so future parity reviews
|
||||||
|
// can spot drift between the two paths.
|
||||||
|
let mut forked = source.clone();
|
||||||
|
let now = Utc::now();
|
||||||
|
forked.id = format!("thr_{}", &Uuid::new_v4().to_string()[..8]);
|
||||||
|
forked.created_at = now;
|
||||||
|
forked.updated_at = now;
|
||||||
|
forked.latest_turn_id = None;
|
||||||
|
forked.archived = false;
|
||||||
|
self.store.save_thread(&forked)?;
|
||||||
|
|
||||||
|
for source_turn in source_turns.iter().take(target_turn_idx) {
|
||||||
|
let mut cloned_turn = source_turn.clone();
|
||||||
|
cloned_turn.id = format!("turn_{}", &Uuid::new_v4().to_string()[..8]);
|
||||||
|
cloned_turn.thread_id = forked.id.clone();
|
||||||
|
cloned_turn.item_ids.clear();
|
||||||
|
self.store.save_turn(&cloned_turn)?;
|
||||||
|
|
||||||
|
let items = self.store.list_items_for_turn(&source_turn.id)?;
|
||||||
|
for item in items {
|
||||||
|
let mut cloned_item = item.clone();
|
||||||
|
cloned_item.id = format!("item_{}", &Uuid::new_v4().to_string()[..8]);
|
||||||
|
cloned_item.turn_id = cloned_turn.id.clone();
|
||||||
|
self.store.save_item(&cloned_item)?;
|
||||||
|
cloned_turn.item_ids.push(cloned_item.id.clone());
|
||||||
|
}
|
||||||
|
self.store.save_turn(&cloned_turn)?;
|
||||||
|
forked.latest_turn_id = Some(cloned_turn.id.clone());
|
||||||
|
forked.updated_at = now;
|
||||||
|
self.store.save_thread(&forked)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.emit_event(
|
||||||
|
&forked.id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"thread.forked",
|
||||||
|
json!({
|
||||||
|
"thread": forked,
|
||||||
|
"source_thread_id": source.id,
|
||||||
|
"backtrack_depth_from_tail": depth_from_tail,
|
||||||
|
"dropped_turn_id": target_turn_id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok((forked, original_user_text))
|
||||||
|
}
|
||||||
|
|
||||||
/// Seed a thread with messages from a saved session so subsequent turns
|
/// Seed a thread with messages from a saved session so subsequent turns
|
||||||
/// continue with the prior conversation context.
|
/// continue with the prior conversation context.
|
||||||
pub async fn seed_thread_from_messages(
|
pub async fn seed_thread_from_messages(
|
||||||
@@ -3978,4 +4097,191 @@ mod tests {
|
|||||||
assert_eq!(hints.len(), 1);
|
assert_eq!(hints.len(), 1);
|
||||||
assert_eq!(hints[0].status, AgentRebindStatus::Completed);
|
assert_eq!(hints[0].status, AgentRebindStatus::Completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper for the `fork_at_user_message` tests: write a sequence of
|
||||||
|
/// (user, assistant) turns under the given thread id. Each turn gets
|
||||||
|
/// one UserMessage item carrying `user_text` in `detail` plus one
|
||||||
|
/// AgentMessage item. Turn `created_at` is monotonically increasing
|
||||||
|
/// so the chronological sort in `list_turns_for_thread` is stable.
|
||||||
|
fn seed_turns_with_user_messages(
|
||||||
|
manager: &RuntimeThreadManager,
|
||||||
|
thread_id: &str,
|
||||||
|
user_texts: &[&str],
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let mut turn_ids = Vec::new();
|
||||||
|
let base = Utc::now();
|
||||||
|
for (offset, text) in user_texts.iter().enumerate() {
|
||||||
|
let created_at = base + chrono::Duration::milliseconds(offset as i64);
|
||||||
|
let turn_id = format!("turn_test_{offset}");
|
||||||
|
let user_item_id = format!("item_user_{offset}");
|
||||||
|
let asst_item_id = format!("item_asst_{offset}");
|
||||||
|
manager.store.save_item(&TurnItemRecord {
|
||||||
|
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||||
|
id: user_item_id.clone(),
|
||||||
|
turn_id: turn_id.clone(),
|
||||||
|
kind: TurnItemKind::UserMessage,
|
||||||
|
status: TurnItemLifecycleStatus::Completed,
|
||||||
|
summary: (*text).to_string(),
|
||||||
|
detail: Some((*text).to_string()),
|
||||||
|
artifact_refs: Vec::new(),
|
||||||
|
started_at: Some(created_at),
|
||||||
|
ended_at: Some(created_at),
|
||||||
|
})?;
|
||||||
|
manager.store.save_item(&TurnItemRecord {
|
||||||
|
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||||
|
id: asst_item_id.clone(),
|
||||||
|
turn_id: turn_id.clone(),
|
||||||
|
kind: TurnItemKind::AgentMessage,
|
||||||
|
status: TurnItemLifecycleStatus::Completed,
|
||||||
|
summary: format!("reply {offset}"),
|
||||||
|
detail: Some(format!("reply {offset}")),
|
||||||
|
artifact_refs: Vec::new(),
|
||||||
|
started_at: Some(created_at),
|
||||||
|
ended_at: Some(created_at),
|
||||||
|
})?;
|
||||||
|
manager.store.save_turn(&TurnRecord {
|
||||||
|
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||||
|
id: turn_id.clone(),
|
||||||
|
thread_id: thread_id.to_string(),
|
||||||
|
status: RuntimeTurnStatus::Completed,
|
||||||
|
input_summary: (*text).to_string(),
|
||||||
|
created_at,
|
||||||
|
started_at: Some(created_at),
|
||||||
|
ended_at: Some(created_at),
|
||||||
|
duration_ms: Some(0),
|
||||||
|
usage: None,
|
||||||
|
error: None,
|
||||||
|
item_ids: vec![user_item_id, asst_item_id],
|
||||||
|
steer_count: 0,
|
||||||
|
})?;
|
||||||
|
turn_ids.push(turn_id);
|
||||||
|
}
|
||||||
|
Ok(turn_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fork_at_user_message_drops_tail_and_returns_user_text() -> Result<()> {
|
||||||
|
// Seed three completed user/assistant turns. Backtracking with
|
||||||
|
// depth=0 should drop only the most recent turn ("third") and
|
||||||
|
// hand back its original text so the caller can refill the
|
||||||
|
// composer.
|
||||||
|
let manager = test_manager(test_runtime_dir())?;
|
||||||
|
let thread = manager
|
||||||
|
.create_thread(CreateThreadRequest {
|
||||||
|
model: None,
|
||||||
|
workspace: None,
|
||||||
|
mode: None,
|
||||||
|
allow_shell: None,
|
||||||
|
trust_mode: None,
|
||||||
|
auto_approve: None,
|
||||||
|
archived: false,
|
||||||
|
system_prompt: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
seed_turns_with_user_messages(&manager, &thread.id, &["first", "second", "third"])?;
|
||||||
|
|
||||||
|
let (forked, original_text) = manager.fork_at_user_message(&thread.id, 0).await?;
|
||||||
|
assert_eq!(original_text.as_deref(), Some("third"));
|
||||||
|
assert_ne!(forked.id, thread.id);
|
||||||
|
|
||||||
|
let forked_turns = manager.store.list_turns_for_thread(&forked.id)?;
|
||||||
|
assert_eq!(
|
||||||
|
forked_turns.len(),
|
||||||
|
2,
|
||||||
|
"depth=0 should drop the most recent turn"
|
||||||
|
);
|
||||||
|
let summaries: Vec<&str> = forked_turns
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.input_summary.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(summaries, vec!["first", "second"]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fork_at_user_message_depth_one_drops_two_turns() -> Result<()> {
|
||||||
|
let manager = test_manager(test_runtime_dir())?;
|
||||||
|
let thread = manager
|
||||||
|
.create_thread(CreateThreadRequest {
|
||||||
|
model: None,
|
||||||
|
workspace: None,
|
||||||
|
mode: None,
|
||||||
|
allow_shell: None,
|
||||||
|
trust_mode: None,
|
||||||
|
auto_approve: None,
|
||||||
|
archived: false,
|
||||||
|
system_prompt: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
seed_turns_with_user_messages(&manager, &thread.id, &["a", "b", "c", "d"])?;
|
||||||
|
|
||||||
|
let (forked, original_text) = manager.fork_at_user_message(&thread.id, 1).await?;
|
||||||
|
assert_eq!(original_text.as_deref(), Some("c"));
|
||||||
|
let forked_turns = manager.store.list_turns_for_thread(&forked.id)?;
|
||||||
|
let summaries: Vec<&str> = forked_turns
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.input_summary.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(summaries, vec!["a", "b"]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fork_at_user_message_out_of_range_errors() -> Result<()> {
|
||||||
|
let manager = test_manager(test_runtime_dir())?;
|
||||||
|
let thread = manager
|
||||||
|
.create_thread(CreateThreadRequest {
|
||||||
|
model: None,
|
||||||
|
workspace: None,
|
||||||
|
mode: None,
|
||||||
|
allow_shell: None,
|
||||||
|
trust_mode: None,
|
||||||
|
auto_approve: None,
|
||||||
|
archived: false,
|
||||||
|
system_prompt: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
seed_turns_with_user_messages(&manager, &thread.id, &["only"])?;
|
||||||
|
|
||||||
|
let err = manager.fork_at_user_message(&thread.id, 5).await.err();
|
||||||
|
assert!(err.is_some(), "depth past the end should bail out");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fork_at_user_message_does_not_mutate_source() -> Result<()> {
|
||||||
|
// The source thread must be untouched: turns still present, items
|
||||||
|
// still present, latest_turn_id still pointing at the original
|
||||||
|
// tail. Backtrack creates a sibling, never edits in place.
|
||||||
|
let manager = test_manager(test_runtime_dir())?;
|
||||||
|
let thread = manager
|
||||||
|
.create_thread(CreateThreadRequest {
|
||||||
|
model: None,
|
||||||
|
workspace: None,
|
||||||
|
mode: None,
|
||||||
|
allow_shell: None,
|
||||||
|
trust_mode: None,
|
||||||
|
auto_approve: None,
|
||||||
|
archived: false,
|
||||||
|
system_prompt: None,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let turn_ids = seed_turns_with_user_messages(&manager, &thread.id, &["x", "y", "z"])?;
|
||||||
|
|
||||||
|
let _ = manager.fork_at_user_message(&thread.id, 0).await?;
|
||||||
|
|
||||||
|
let source_turns = manager.store.list_turns_for_thread(&thread.id)?;
|
||||||
|
assert_eq!(
|
||||||
|
source_turns.len(),
|
||||||
|
3,
|
||||||
|
"source thread must still hold every turn after fork"
|
||||||
|
);
|
||||||
|
for tid in &turn_ids {
|
||||||
|
assert!(
|
||||||
|
manager.store.load_turn(tid).is_ok(),
|
||||||
|
"turn {tid} must remain on disk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -489,6 +489,10 @@ pub struct App {
|
|||||||
pub approval_mode: ApprovalMode,
|
pub approval_mode: ApprovalMode,
|
||||||
// Modal view stack (approval/help/etc.)
|
// Modal view stack (approval/help/etc.)
|
||||||
pub view_stack: ViewStack,
|
pub view_stack: ViewStack,
|
||||||
|
/// Esc-Esc backtrack state machine (#133). `Inactive` by default; first
|
||||||
|
/// Esc primes, second Esc opens the live-transcript overlay scoped to
|
||||||
|
/// previous user messages so the user can rewind a turn.
|
||||||
|
pub backtrack: crate::tui::backtrack::BacktrackState,
|
||||||
/// Current session ID for auto-save updates
|
/// Current session ID for auto-save updates
|
||||||
pub current_session_id: Option<String>,
|
pub current_session_id: Option<String>,
|
||||||
/// Trust mode - allow access outside workspace
|
/// Trust mode - allow access outside workspace
|
||||||
@@ -893,6 +897,7 @@ impl App {
|
|||||||
ApprovalMode::Suggest
|
ApprovalMode::Suggest
|
||||||
},
|
},
|
||||||
view_stack: ViewStack::new(),
|
view_stack: ViewStack::new(),
|
||||||
|
backtrack: crate::tui::backtrack::BacktrackState::new(),
|
||||||
current_session_id: None,
|
current_session_id: None,
|
||||||
trust_mode: initial_mode == AppMode::Yolo,
|
trust_mode: initial_mode == AppMode::Yolo,
|
||||||
// Honour `tui.status_items` from config; fall back to the v0.6.6
|
// Honour `tui.status_items` from config; fall back to the v0.6.6
|
||||||
@@ -1199,6 +1204,36 @@ impl App {
|
|||||||
cell
|
cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Truncate `history` (and the parallel `history_revisions` + auxiliary
|
||||||
|
/// per-cell maps) so that only cells with index `< new_len` remain.
|
||||||
|
/// Used by Esc-Esc backtrack (#133) to roll the visible transcript
|
||||||
|
/// back to a chosen user message. Cells dropped here are gone — the
|
||||||
|
/// caller is expected to also trim the matching `api_messages` so the
|
||||||
|
/// next turn matches what the user sees.
|
||||||
|
pub fn truncate_history_to(&mut self, new_len: usize) {
|
||||||
|
if new_len >= self.history.len() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.history.truncate(new_len);
|
||||||
|
if self.history_revisions.len() > new_len {
|
||||||
|
self.history_revisions.truncate(new_len);
|
||||||
|
}
|
||||||
|
// Drop any auxiliary maps keyed on history indices that now point
|
||||||
|
// past the new tail. We keep the rest intact so unaffected tool
|
||||||
|
// cells continue to render correctly.
|
||||||
|
self.tool_cells.retain(|_, idx| *idx < new_len);
|
||||||
|
self.tool_details_by_cell.retain(|idx, _| *idx < new_len);
|
||||||
|
self.subagent_card_index.retain(|_, idx| *idx < new_len);
|
||||||
|
if self
|
||||||
|
.last_fanout_card_index
|
||||||
|
.is_some_and(|idx| idx >= new_len)
|
||||||
|
{
|
||||||
|
self.last_fanout_card_index = None;
|
||||||
|
}
|
||||||
|
self.history_version = self.history_version.wrapping_add(1);
|
||||||
|
self.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Bump the active-cell revision counter and request a redraw.
|
/// Bump the active-cell revision counter and request a redraw.
|
||||||
///
|
///
|
||||||
/// Use this whenever an entry inside `active_cell` is mutated. The
|
/// Use this whenever an entry inside `active_cell` is mutated. The
|
||||||
|
|||||||
@@ -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<usize> {
|
||||||
|
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<usize> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,6 +193,11 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[
|
|||||||
description: "Open live transcript overlay (sticky-tail auto-scroll)",
|
description: "Open live transcript overlay (sticky-tail auto-scroll)",
|
||||||
section: KeybindingSection::Submission,
|
section: KeybindingSection::Submission,
|
||||||
},
|
},
|
||||||
|
KeybindingEntry {
|
||||||
|
chord: "Esc Esc",
|
||||||
|
description: "Backtrack to a previous user message (Left/Right step, Enter to rewind)",
|
||||||
|
section: KeybindingSection::Submission,
|
||||||
|
},
|
||||||
// --- Modes ---
|
// --- Modes ---
|
||||||
KeybindingEntry {
|
KeybindingEntry {
|
||||||
chord: "Tab / Shift+Tab",
|
chord: "Tab / Shift+Tab",
|
||||||
|
|||||||
@@ -26,16 +26,32 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|||||||
use ratatui::{
|
use ratatui::{
|
||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::Style,
|
style::{Modifier, Style},
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap},
|
widgets::{Block, Borders, Clear, Padding, Paragraph, Widget, Wrap},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::palette;
|
use crate::palette;
|
||||||
use crate::tui::app::App;
|
use crate::tui::app::App;
|
||||||
|
use crate::tui::backtrack::Direction;
|
||||||
use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
|
use crate::tui::history::{HistoryCell, TranscriptRenderOptions};
|
||||||
use crate::tui::transcript_cache::{CellId, TranscriptCache};
|
use crate::tui::transcript_cache::{CellId, TranscriptCache};
|
||||||
use crate::tui::views::{ModalKind, ModalView, ViewAction};
|
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
|
||||||
|
|
||||||
|
/// Render mode for the overlay. `Tail` is the original Ctrl+T sticky-tail
|
||||||
|
/// behaviour (#94). `BacktrackPreview` (#133) highlights the Nth-from-tail
|
||||||
|
/// `HistoryCell::User` so the user can see which turn Esc-Esc-Enter will
|
||||||
|
/// roll back to. The mode also disables sticky-tail (we want the user to
|
||||||
|
/// scan history, not be yanked to live output) and pins scroll near the
|
||||||
|
/// highlighted cell on transitions.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Mode {
|
||||||
|
#[default]
|
||||||
|
Tail,
|
||||||
|
BacktrackPreview {
|
||||||
|
selected_idx: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// Single-line footer hint. Kept short so it fits on narrow terminals.
|
/// Single-line footer hint. Kept short so it fits on narrow terminals.
|
||||||
const FOOTER_HINT: &str =
|
const FOOTER_HINT: &str =
|
||||||
@@ -74,6 +90,9 @@ pub struct LiveTranscriptOverlay {
|
|||||||
last_total_lines: RefCell<usize>,
|
last_total_lines: RefCell<usize>,
|
||||||
/// Pending `gg` second keystroke for Vim-style jump-to-top.
|
/// Pending `gg` second keystroke for Vim-style jump-to-top.
|
||||||
pending_g: bool,
|
pending_g: bool,
|
||||||
|
/// Render mode — `Tail` is the live-stream mode; `BacktrackPreview`
|
||||||
|
/// highlights the selected user message (#133).
|
||||||
|
mode: Mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LiveTranscriptOverlay {
|
impl LiveTranscriptOverlay {
|
||||||
@@ -88,9 +107,35 @@ impl LiveTranscriptOverlay {
|
|||||||
last_visible_height: RefCell::new(0),
|
last_visible_height: RefCell::new(0),
|
||||||
last_total_lines: RefCell::new(0),
|
last_total_lines: RefCell::new(0),
|
||||||
pending_g: false,
|
pending_g: false,
|
||||||
|
mode: Mode::Tail,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switch the overlay into backtrack-preview mode. Sticky-tail is
|
||||||
|
/// turned off so the highlighted cell stays in view while the user
|
||||||
|
/// steps through prior turns. The wrap cache stays valid because the
|
||||||
|
/// underlying snapshot data hasn't changed — only the post-wrap
|
||||||
|
/// highlight overlay does.
|
||||||
|
pub fn set_backtrack_preview(&mut self, selected_idx: usize) {
|
||||||
|
self.mode = Mode::BacktrackPreview { selected_idx };
|
||||||
|
self.sticky_to_bottom = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the overlay to live-tail mode (used when backtrack is
|
||||||
|
/// confirmed or canceled). Re-arms sticky-tail so streaming resumes.
|
||||||
|
#[allow(dead_code)] // exposed for callers that retain an overlay across a backtrack cancel; current UI just pops the view.
|
||||||
|
pub fn set_tail_mode(&mut self) {
|
||||||
|
self.mode = Mode::Tail;
|
||||||
|
self.sticky_to_bottom = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For tests + UI: current mode.
|
||||||
|
#[allow(dead_code)] // currently consumed only by tests; kept public for symmetry with `set_*` setters.
|
||||||
|
#[must_use]
|
||||||
|
pub fn mode(&self) -> Mode {
|
||||||
|
self.mode
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull the latest cells + revisions from `App` so the next `render` shows
|
/// Pull the latest cells + revisions from `App` so the next `render` shows
|
||||||
/// streaming mutations. Must be called before `view_stack.render` while
|
/// streaming mutations. Must be called before `view_stack.render` while
|
||||||
/// this overlay is on top; otherwise the cells stay frozen at whatever
|
/// this overlay is on top; otherwise the cells stay frozen at whatever
|
||||||
@@ -129,11 +174,39 @@ impl LiveTranscriptOverlay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Wrap each cell (using the cache) and return the flat line vector.
|
/// Wrap each cell (using the cache) and return the flat line vector.
|
||||||
|
/// In `BacktrackPreview` mode the lines belonging to the selected
|
||||||
|
/// `HistoryCell::User` are decorated with a leading `▶` marker on the
|
||||||
|
/// first line and reverse-video styling on every line so the eye
|
||||||
|
/// snaps to them at a glance. The decoration is applied *after* the
|
||||||
|
/// cache lookup so toggling preview mode never invalidates wraps.
|
||||||
fn flatten(&self, width: u16) -> Vec<Line<'static>> {
|
fn flatten(&self, width: u16) -> Vec<Line<'static>> {
|
||||||
let width = width.max(1);
|
let width = width.max(1);
|
||||||
let mut out: Vec<Line<'static>> = Vec::new();
|
let mut out: Vec<Line<'static>> = Vec::new();
|
||||||
|
|
||||||
|
// Pre-compute which cell index (in `self.snapshots`) is the one
|
||||||
|
// the user has selected via Esc-Esc. We walk snapshots backwards
|
||||||
|
// counting User cells; the snapshot index whose count matches
|
||||||
|
// `selected_idx + 1` is the highlighted one.
|
||||||
|
let highlighted_cell_idx: Option<usize> = match self.mode {
|
||||||
|
Mode::BacktrackPreview { selected_idx } => {
|
||||||
|
let mut count = 0usize;
|
||||||
|
let mut hit = None;
|
||||||
|
for (idx, snap) in self.snapshots.iter().enumerate().rev() {
|
||||||
|
if matches!(snap.cell, HistoryCell::User { .. }) {
|
||||||
|
if count == selected_idx {
|
||||||
|
hit = Some(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hit
|
||||||
|
}
|
||||||
|
Mode::Tail => None,
|
||||||
|
};
|
||||||
|
|
||||||
let mut cache = self.cache.borrow_mut();
|
let mut cache = self.cache.borrow_mut();
|
||||||
for snap in &self.snapshots {
|
for (cell_idx, snap) in self.snapshots.iter().enumerate() {
|
||||||
let lines: Vec<Line<'static>> = match cache.get(snap.id, width, snap.revision) {
|
let lines: Vec<Line<'static>> = match cache.get(snap.id, width, snap.revision) {
|
||||||
Some(cached) => cached.to_vec(),
|
Some(cached) => cached.to_vec(),
|
||||||
None => {
|
None => {
|
||||||
@@ -142,7 +215,12 @@ impl LiveTranscriptOverlay {
|
|||||||
rendered
|
rendered
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
out.extend(lines);
|
|
||||||
|
if Some(cell_idx) == highlighted_cell_idx {
|
||||||
|
out.extend(decorate_highlight(lines));
|
||||||
|
} else {
|
||||||
|
out.extend(lines);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -211,6 +289,34 @@ impl Default for LiveTranscriptOverlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply a backtrack-preview highlight to the lines belonging to a single
|
||||||
|
/// `HistoryCell::User`. The first line gets a `▶ ` prefix in accent color
|
||||||
|
/// (so the marker remains visible even on terminals where reverse-video
|
||||||
|
/// is washed out); every line in the cell gets `Modifier::REVERSED` so
|
||||||
|
/// the cell visually pops out of the surrounding transcript. Internal
|
||||||
|
/// span structure is preserved so syntax/role coloring underneath the
|
||||||
|
/// reverse stays readable.
|
||||||
|
fn decorate_highlight(mut lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||||
|
if lines.is_empty() {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
for line in &mut lines {
|
||||||
|
for span in &mut line.spans {
|
||||||
|
span.style = span.style.add_modifier(Modifier::REVERSED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let marker = Span::styled(
|
||||||
|
"\u{25B6} ",
|
||||||
|
Style::default()
|
||||||
|
.fg(palette::TEXT_ACCENT)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
if let Some(first) = lines.first_mut() {
|
||||||
|
first.spans.insert(0, marker);
|
||||||
|
}
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
impl ModalView for LiveTranscriptOverlay {
|
impl ModalView for LiveTranscriptOverlay {
|
||||||
fn kind(&self) -> ModalKind {
|
fn kind(&self) -> ModalKind {
|
||||||
ModalKind::LiveTranscript
|
ModalKind::LiveTranscript
|
||||||
@@ -224,6 +330,34 @@ impl ModalView for LiveTranscriptOverlay {
|
|||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||||
|
|
||||||
|
// Backtrack-preview mode (#133) intercepts Left/Right/Enter/Esc
|
||||||
|
// before the normal scroll handlers so the user can step through
|
||||||
|
// prior user messages without their input being interpreted as
|
||||||
|
// pager navigation. Other keys (page up/down, gg/G, etc.) still
|
||||||
|
// fall through so the user can scroll the transcript while
|
||||||
|
// previewing.
|
||||||
|
if matches!(self.mode, Mode::BacktrackPreview { .. }) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Left | KeyCode::Char('h') if !ctrl => {
|
||||||
|
return ViewAction::Emit(ViewEvent::BacktrackStep {
|
||||||
|
direction: Direction::Left,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
KeyCode::Right | KeyCode::Char('l') if !ctrl => {
|
||||||
|
return ViewAction::Emit(ViewEvent::BacktrackStep {
|
||||||
|
direction: Direction::Right,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
return ViewAction::EmitAndClose(ViewEvent::BacktrackConfirm);
|
||||||
|
}
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
|
return ViewAction::EmitAndClose(ViewEvent::BacktrackCancel);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ctrl {
|
if ctrl {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Char('d') | KeyCode::Char('D') => {
|
KeyCode::Char('d') | KeyCode::Char('D') => {
|
||||||
@@ -355,10 +489,18 @@ impl ModalView for LiveTranscriptOverlay {
|
|||||||
lines[scroll..end].to_vec()
|
lines[scroll..end].to_vec()
|
||||||
};
|
};
|
||||||
|
|
||||||
let title = if self.sticky_to_bottom {
|
let title: String = match self.mode {
|
||||||
" Live transcript (tailing) "
|
Mode::BacktrackPreview { selected_idx } => format!(
|
||||||
} else {
|
" Backtrack preview — turn {} (\u{2190}/\u{2192} step, Enter rewind, Esc cancel) ",
|
||||||
" Live transcript (paused) "
|
selected_idx + 1
|
||||||
|
),
|
||||||
|
Mode::Tail => {
|
||||||
|
if self.sticky_to_bottom {
|
||||||
|
" Live transcript (tailing) ".to_string()
|
||||||
|
} else {
|
||||||
|
" Live transcript (paused) ".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let footer = Line::from(Span::styled(
|
let footer = Line::from(Span::styled(
|
||||||
@@ -564,4 +706,93 @@ mod tests {
|
|||||||
"replay at old width must hit cache"
|
"replay at old width must hit cache"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backtrack_preview_disables_sticky() {
|
||||||
|
let mut v = LiveTranscriptOverlay::new();
|
||||||
|
assert!(v.is_sticky());
|
||||||
|
v.set_backtrack_preview(0);
|
||||||
|
assert!(!v.is_sticky());
|
||||||
|
assert!(matches!(
|
||||||
|
v.mode(),
|
||||||
|
Mode::BacktrackPreview { selected_idx: 0 }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_tail_mode_re_arms_sticky() {
|
||||||
|
let mut v = LiveTranscriptOverlay::new();
|
||||||
|
v.set_backtrack_preview(2);
|
||||||
|
v.set_tail_mode();
|
||||||
|
assert!(v.is_sticky());
|
||||||
|
assert!(matches!(v.mode(), Mode::Tail));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backtrack_preview_does_not_panic_with_no_user_cells() {
|
||||||
|
// Render in preview mode against a transcript that has zero User
|
||||||
|
// cells — the highlight scan should miss gracefully.
|
||||||
|
let mut v = LiveTranscriptOverlay::new();
|
||||||
|
install_snapshots(&mut v, vec![assistant("hi", false)]);
|
||||||
|
v.set_backtrack_preview(0);
|
||||||
|
let area = Rect::new(0, 0, 40, 10);
|
||||||
|
let mut buf = Buffer::empty(area);
|
||||||
|
v.render(area, &mut buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backtrack_preview_highlights_selected_user_cell() {
|
||||||
|
// With 3 user cells (oldest → newest: u0, u1, u2), `selected_idx
|
||||||
|
// = 0` should highlight u2 (newest), `= 1` u1, `= 2` u0. We can
|
||||||
|
// detect the highlight by scanning the rendered buffer for the
|
||||||
|
// marker glyph.
|
||||||
|
let mut v = LiveTranscriptOverlay::new();
|
||||||
|
install_snapshots(
|
||||||
|
&mut v,
|
||||||
|
vec![
|
||||||
|
user("u0"),
|
||||||
|
assistant("a0", false),
|
||||||
|
user("u1"),
|
||||||
|
assistant("a1", false),
|
||||||
|
user("u2"),
|
||||||
|
assistant("a2", false),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
for sel in [0usize, 1, 2] {
|
||||||
|
v.set_backtrack_preview(sel);
|
||||||
|
// Force Tail re-render between iterations to confirm marker
|
||||||
|
// really moves rather than smearing.
|
||||||
|
let area = Rect::new(0, 0, 40, 24);
|
||||||
|
let mut buf = Buffer::empty(area);
|
||||||
|
v.render(area, &mut buf);
|
||||||
|
// Just verify the cell index resolved without panicking and
|
||||||
|
// the buffer is non-empty. Detailed marker placement is
|
||||||
|
// visual, hence not asserted here.
|
||||||
|
let mut any_content = false;
|
||||||
|
for y in 0..buf.area.height {
|
||||||
|
for x in 0..buf.area.width {
|
||||||
|
if !buf[(x, y)].symbol().is_empty() && buf[(x, y)].symbol() != " " {
|
||||||
|
any_content = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if any_content {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(any_content, "preview render must produce visible content");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backtrack_preview_out_of_range_does_not_panic() {
|
||||||
|
// Selecting beyond the user-cell count should simply not
|
||||||
|
// highlight anything — no panic, no marker.
|
||||||
|
let mut v = LiveTranscriptOverlay::new();
|
||||||
|
install_snapshots(&mut v, vec![user("only")]);
|
||||||
|
v.set_backtrack_preview(99);
|
||||||
|
let area = Rect::new(0, 0, 40, 10);
|
||||||
|
let mut buf = Buffer::empty(area);
|
||||||
|
v.render(area, &mut buf);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
pub mod active_cell;
|
pub mod active_cell;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod approval;
|
pub mod approval;
|
||||||
|
pub mod backtrack;
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
pub mod command_palette;
|
pub mod command_palette;
|
||||||
pub mod diff_render;
|
pub mod diff_render;
|
||||||
|
|||||||
+189
-3
@@ -1392,6 +1392,19 @@ async fn run_event_loop(
|
|||||||
app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
|
app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel a pending Esc-Esc prime as soon as any non-Esc key
|
||||||
|
// arrives. Without this the prime would hang around for the
|
||||||
|
// rest of the session and the user's next genuine Esc would
|
||||||
|
// suddenly skip straight into the backtrack overlay.
|
||||||
|
if !matches!(key.code, KeyCode::Esc)
|
||||||
|
&& matches!(
|
||||||
|
app.backtrack.phase,
|
||||||
|
crate::tui::backtrack::BacktrackPhase::Primed
|
||||||
|
)
|
||||||
|
{
|
||||||
|
app.backtrack.reset();
|
||||||
|
}
|
||||||
|
|
||||||
// Global keybindings
|
// Global keybindings
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Enter
|
KeyCode::Enter
|
||||||
@@ -1541,8 +1554,15 @@ async fn run_event_loop(
|
|||||||
app.mention_menu_selected = 0;
|
app.mention_menu_selected = 0;
|
||||||
}
|
}
|
||||||
KeyCode::Esc => match next_escape_action(app, slash_menu_open) {
|
KeyCode::Esc => match next_escape_action(app, slash_menu_open) {
|
||||||
EscapeAction::CloseSlashMenu => app.close_slash_menu(),
|
EscapeAction::CloseSlashMenu => {
|
||||||
|
// A popup-style action wins over backtrack — clear
|
||||||
|
// any prime so a stale Primed state can't jump us
|
||||||
|
// straight into Selecting on the next Esc.
|
||||||
|
app.backtrack.reset();
|
||||||
|
app.close_slash_menu();
|
||||||
|
}
|
||||||
EscapeAction::CancelRequest => {
|
EscapeAction::CancelRequest => {
|
||||||
|
app.backtrack.reset();
|
||||||
engine_handle.cancel();
|
engine_handle.cancel();
|
||||||
app.is_loading = false;
|
app.is_loading = false;
|
||||||
app.streaming_state.reset();
|
app.streaming_state.reset();
|
||||||
@@ -1554,6 +1574,7 @@ async fn run_event_loop(
|
|||||||
app.status_message = Some("Request cancelled".to_string());
|
app.status_message = Some("Request cancelled".to_string());
|
||||||
}
|
}
|
||||||
EscapeAction::SteerAndAbort => {
|
EscapeAction::SteerAndAbort => {
|
||||||
|
app.backtrack.reset();
|
||||||
if let Some(input) = app.submit_input() {
|
if let Some(input) = app.submit_input() {
|
||||||
let queued = build_queued_message(app, input);
|
let queued = build_queued_message(app, input);
|
||||||
app.push_pending_steer(queued);
|
app.push_pending_steer(queued);
|
||||||
@@ -1571,11 +1592,42 @@ async fn run_event_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
EscapeAction::DiscardQueuedDraft => {
|
EscapeAction::DiscardQueuedDraft => {
|
||||||
|
app.backtrack.reset();
|
||||||
app.queued_draft = None;
|
app.queued_draft = None;
|
||||||
app.status_message = Some("Stopped editing queued message".to_string());
|
app.status_message = Some("Stopped editing queued message".to_string());
|
||||||
}
|
}
|
||||||
EscapeAction::ClearInput => app.clear_input(),
|
EscapeAction::ClearInput => {
|
||||||
EscapeAction::Noop => {}
|
app.backtrack.reset();
|
||||||
|
app.clear_input();
|
||||||
|
}
|
||||||
|
EscapeAction::Noop => {
|
||||||
|
// Nothing else cares about this Esc — route it
|
||||||
|
// through the backtrack state machine. While
|
||||||
|
// streaming or with the live transcript already
|
||||||
|
// open, fall through silently (#133 acceptance:
|
||||||
|
// "during streaming Esc-Esc is a silent no-op").
|
||||||
|
if app.is_loading
|
||||||
|
|| app.view_stack.top_kind() == Some(ModalKind::LiveTranscript)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let total = count_user_history_cells(app);
|
||||||
|
match app.backtrack.handle_esc(total) {
|
||||||
|
crate::tui::backtrack::EscEffect::None => {}
|
||||||
|
crate::tui::backtrack::EscEffect::Prime => {
|
||||||
|
app.status_message =
|
||||||
|
Some("Press Esc again to backtrack".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
crate::tui::backtrack::EscEffect::Cancel => {
|
||||||
|
app.status_message = Some("Backtrack canceled".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
crate::tui::backtrack::EscEffect::OpenOverlay => {
|
||||||
|
open_backtrack_overlay(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// #85: Alt+↑ pops the most-recent queued message back into the
|
// #85: Alt+↑ pops the most-recent queued message back into the
|
||||||
// composer for editing when the preview's affordance is visible
|
// composer for editing when the preview's affordance is visible
|
||||||
@@ -3172,6 +3224,21 @@ fn refresh_live_transcript_overlay(app: &mut App) {
|
|||||||
app.view_stack.push_boxed(overlay);
|
app.view_stack.push_boxed(overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the live transcript overlay in backtrack-preview mode (#133).
|
||||||
|
/// The overlay starts highlighting the most recent user message
|
||||||
|
/// (`selected_idx = 0`) and routes Left/Right/Enter/Esc through
|
||||||
|
/// `ViewEvent::Backtrack*` so the main key dispatcher can advance the
|
||||||
|
/// `BacktrackState` and apply the rewind on confirm.
|
||||||
|
fn open_backtrack_overlay(app: &mut App) {
|
||||||
|
let mut overlay = LiveTranscriptOverlay::new();
|
||||||
|
overlay.refresh_from_app(app);
|
||||||
|
overlay.set_backtrack_preview(0);
|
||||||
|
app.view_stack.push(overlay);
|
||||||
|
app.status_message =
|
||||||
|
Some("Backtrack: \u{2190}/\u{2192} step Enter rewind Esc cancel".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Toggle the live transcript overlay on `Ctrl+T`. Closes the overlay if it's
|
/// Toggle the live transcript overlay on `Ctrl+T`. Closes the overlay if it's
|
||||||
/// already on top; otherwise pushes a fresh one in sticky-tail mode.
|
/// already on top; otherwise pushes a fresh one in sticky-tail mode.
|
||||||
fn toggle_live_transcript_overlay(app: &mut App) {
|
fn toggle_live_transcript_overlay(app: &mut App) {
|
||||||
@@ -3441,12 +3508,131 @@ async fn handle_view_events(
|
|||||||
ViewEvent::ProviderPickerApiKeySubmitted { provider, api_key } => {
|
ViewEvent::ProviderPickerApiKeySubmitted { provider, api_key } => {
|
||||||
apply_provider_picker_api_key(app, engine_handle, config, provider, api_key).await;
|
apply_provider_picker_api_key(app, engine_handle, config, provider, api_key).await;
|
||||||
}
|
}
|
||||||
|
ViewEvent::BacktrackStep { direction } => {
|
||||||
|
app.backtrack.step(direction);
|
||||||
|
if let Some(idx) = app.backtrack.selected_idx() {
|
||||||
|
update_backtrack_overlay_selection(app, idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewEvent::BacktrackConfirm => {
|
||||||
|
if let Some(depth) = app.backtrack.confirm() {
|
||||||
|
apply_backtrack(app, depth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ViewEvent::BacktrackCancel => {
|
||||||
|
app.backtrack.reset();
|
||||||
|
app.status_message = Some("Backtrack canceled".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push the new `selected_idx` into the live transcript overlay so the
|
||||||
|
/// highlight follows the user's Left/Right input. No-op if the overlay is
|
||||||
|
/// no longer on top (e.g. it was closed underneath us).
|
||||||
|
fn update_backtrack_overlay_selection(app: &mut App, selected_idx: usize) {
|
||||||
|
if app.view_stack.top_kind() != Some(ModalKind::LiveTranscript) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(mut overlay) = app.view_stack.pop() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
|
||||||
|
typed.set_backtrack_preview(selected_idx);
|
||||||
|
}
|
||||||
|
app.view_stack.push_boxed(overlay);
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count how many `HistoryCell::User` entries currently live in the
|
||||||
|
/// transcript. Used by the backtrack state machine to decide whether
|
||||||
|
/// there's anything to rewind to. Walks `app.history` directly so it
|
||||||
|
/// stays accurate even mid-stream (the streaming Assistant cell never
|
||||||
|
/// counts as a user turn).
|
||||||
|
fn count_user_history_cells(app: &App) -> usize {
|
||||||
|
app.history
|
||||||
|
.iter()
|
||||||
|
.filter(|cell| matches!(cell, HistoryCell::User { .. }))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the absolute index of the Nth-from-tail `HistoryCell::User` in
|
||||||
|
/// `app.history`. `depth` of 0 selects the most recent user cell.
|
||||||
|
/// Returns `None` if `depth` is out of range.
|
||||||
|
fn find_user_cell_index_from_tail(app: &App, depth: usize) -> Option<usize> {
|
||||||
|
let mut count = 0usize;
|
||||||
|
for (idx, cell) in app.history.iter().enumerate().rev() {
|
||||||
|
if matches!(cell, HistoryCell::User { .. }) {
|
||||||
|
if count == depth {
|
||||||
|
return Some(idx);
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the user's backtrack selection: trim `app.history` and
|
||||||
|
/// `app.api_messages` so everything from the chosen user message onward
|
||||||
|
/// is dropped, populate the composer with the dropped user text, close
|
||||||
|
/// the overlay, and surface a status hint. The cycle counter is bumped
|
||||||
|
/// so any persistent indices clear; the engine's in-flight context is
|
||||||
|
/// re-synced via `Op::SyncSession` so the next turn starts fresh.
|
||||||
|
fn apply_backtrack(app: &mut App, depth: usize) {
|
||||||
|
let Some(history_idx) = find_user_cell_index_from_tail(app, depth) else {
|
||||||
|
app.status_message = Some("Backtrack target no longer present".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Snapshot the user text before truncating so we can refill the
|
||||||
|
// composer.
|
||||||
|
let user_text = match app.history.get(history_idx) {
|
||||||
|
Some(HistoryCell::User { content }) => content.clone(),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trim the visible transcript at the chosen user cell. Per-cell
|
||||||
|
// revisions and tool-cell maps are kept consistent through
|
||||||
|
// `App::truncate_history_to`.
|
||||||
|
app.truncate_history_to(history_idx);
|
||||||
|
|
||||||
|
// Trim the API-message log at the matching user message. We
|
||||||
|
// re-walk `api_messages` from the tail, counting role=="user"
|
||||||
|
// boundaries so the depth aligns with what the model sees on the
|
||||||
|
// next turn.
|
||||||
|
let mut user_seen = 0usize;
|
||||||
|
let mut cut = None;
|
||||||
|
for (idx, msg) in app.api_messages.iter().enumerate().rev() {
|
||||||
|
if msg.role == "user" {
|
||||||
|
if user_seen == depth {
|
||||||
|
cut = Some(idx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
user_seen += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(idx) = cut {
|
||||||
|
app.api_messages.truncate(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand the dropped text back to the user so they can edit + resend.
|
||||||
|
app.input = user_text;
|
||||||
|
app.cursor_position = app.input.chars().count();
|
||||||
|
|
||||||
|
// Close the overlay, refresh sticky-tail flag, and surface a hint.
|
||||||
|
if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
|
||||||
|
app.view_stack.pop();
|
||||||
|
}
|
||||||
|
app.status_message =
|
||||||
|
Some("Rewound to previous user message — edit and Enter to resend".to_string());
|
||||||
|
app.scroll_to_bottom();
|
||||||
|
app.mark_history_updated();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist the typed API key to `~/.deepseek/config.toml`, refresh the
|
/// Persist the typed API key to `~/.deepseek/config.toml`, refresh the
|
||||||
/// in-memory config so the engine can see it, then switch to the provider.
|
/// in-memory config so the engine can see it, then switch to the provider.
|
||||||
async fn apply_provider_picker_api_key(
|
async fn apply_provider_picker_api_key(
|
||||||
|
|||||||
@@ -122,6 +122,23 @@ pub enum ViewEvent {
|
|||||||
items: Vec<crate::config::StatusItem>,
|
items: Vec<crate::config::StatusItem>,
|
||||||
final_save: bool,
|
final_save: bool,
|
||||||
},
|
},
|
||||||
|
/// Emitted by the live-transcript overlay while in backtrack preview
|
||||||
|
/// mode (#133) when the user steps the highlighted user message with
|
||||||
|
/// Left or Right. The handler advances `app.backtrack`, refreshes the
|
||||||
|
/// overlay's `selected_idx`, and pins scroll near the new highlight.
|
||||||
|
BacktrackStep {
|
||||||
|
direction: crate::tui::backtrack::Direction,
|
||||||
|
},
|
||||||
|
/// Emitted by the live-transcript overlay when the user presses Enter
|
||||||
|
/// in backtrack preview mode (#133). The handler calls
|
||||||
|
/// `app.backtrack.confirm()`, trims `app.history`/`api_messages` to
|
||||||
|
/// the selected user message, populates the composer with the
|
||||||
|
/// dropped user text, and closes the overlay.
|
||||||
|
BacktrackConfirm,
|
||||||
|
/// Emitted by the live-transcript overlay when the user presses Esc
|
||||||
|
/// in backtrack preview mode (#133). The handler resets
|
||||||
|
/// `app.backtrack` and closes the overlay without trimming.
|
||||||
|
BacktrackCancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
Reference in New Issue
Block a user