feat: select rewind points directly from Console history
This commit is contained in:
+44
-59
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
|
||||
InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method,
|
||||
RewindTarget, RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
|
||||
RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
|
||||
};
|
||||
|
||||
use crate::block::{
|
||||
@@ -90,10 +90,15 @@ pub struct RewindPickerScroll {
|
||||
pub tail_top_offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RewindEntry {
|
||||
pub entry_id: String,
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RewindPickerState {
|
||||
pub head_entries: usize,
|
||||
pub targets: Vec<RewindTarget>,
|
||||
pub targets: Vec<RewindEntry>,
|
||||
pub selected: usize,
|
||||
pub scroll: RewindPickerScroll,
|
||||
/// True after Enter submitted an authoritative `RewindTo` and before the
|
||||
@@ -104,10 +109,9 @@ pub struct RewindPickerState {
|
||||
}
|
||||
|
||||
impl RewindPickerState {
|
||||
pub fn new(head_entries: usize, targets: Vec<RewindTarget>) -> Self {
|
||||
let selected = targets.iter().position(|t| t.eligible).unwrap_or(0);
|
||||
pub fn new(targets: Vec<RewindEntry>) -> Self {
|
||||
let selected = 0;
|
||||
Self {
|
||||
head_entries,
|
||||
targets,
|
||||
selected,
|
||||
scroll: RewindPickerScroll::default(),
|
||||
@@ -115,7 +119,7 @@ impl RewindPickerState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_target(&self) -> Option<&RewindTarget> {
|
||||
pub fn selected_target(&self) -> Option<&RewindEntry> {
|
||||
self.targets.get(self.selected)
|
||||
}
|
||||
}
|
||||
@@ -334,7 +338,7 @@ pub struct App {
|
||||
completion_target: String,
|
||||
/// Dedicated main-view rewind picker state.
|
||||
pub rewind_picker: Option<RewindPickerState>,
|
||||
rewind_request_pending: bool,
|
||||
rewind_entries: Vec<RewindEntry>,
|
||||
/// After a successful rewind restore, ignore any queued live-update events
|
||||
/// until the authoritative Worker status/snapshot catches up. This prevents
|
||||
/// old stream tail events that were already in transit from re-polluting the
|
||||
@@ -419,7 +423,7 @@ impl App {
|
||||
pending_feature_edit: None,
|
||||
completion_target: protocol::new_submission_request_id(),
|
||||
rewind_picker: None,
|
||||
rewind_request_pending: false,
|
||||
rewind_entries: Vec::new(),
|
||||
rewind_refresh_fence: false,
|
||||
greeting: None,
|
||||
task_store: TaskStore::new(),
|
||||
@@ -1529,7 +1533,19 @@ impl App {
|
||||
Event::PendingSubmissionsChanged { pending } => {
|
||||
self.pending_submissions = pending;
|
||||
}
|
||||
Event::UserMessage { segments, .. } => {
|
||||
Event::UserMessage { segments, entry_id } => {
|
||||
if let Some(entry_id) = entry_id {
|
||||
if !self
|
||||
.rewind_entries
|
||||
.iter()
|
||||
.any(|entry| entry.entry_id == entry_id)
|
||||
{
|
||||
self.rewind_entries.push(RewindEntry {
|
||||
entry_id,
|
||||
preview: Segment::flatten_to_text(&segments),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.turn_index += 1;
|
||||
self.blocks.push(Block::TurnHeader {
|
||||
turn: self.turn_index,
|
||||
@@ -2038,15 +2054,6 @@ impl App {
|
||||
state.selected = 0;
|
||||
}
|
||||
}
|
||||
Event::RewindTargets {
|
||||
head_entries,
|
||||
targets,
|
||||
} => {
|
||||
if self.rewind_request_pending {
|
||||
self.rewind_request_pending = false;
|
||||
self.rewind_picker = Some(RewindPickerState::new(head_entries, targets));
|
||||
}
|
||||
}
|
||||
Event::RewindApplied {
|
||||
session,
|
||||
input,
|
||||
@@ -2467,14 +2474,14 @@ impl App {
|
||||
return None;
|
||||
}
|
||||
self.completion = None;
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = true;
|
||||
Some(Method::ListRewindTargets)
|
||||
self.rewind_picker = Some(RewindPickerState::new(
|
||||
self.rewind_entries.iter().rev().cloned().collect(),
|
||||
));
|
||||
None
|
||||
}
|
||||
|
||||
pub fn close_rewind_picker(&mut self) {
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = false;
|
||||
}
|
||||
|
||||
pub fn cancel_rewind_picker(&mut self) {
|
||||
@@ -2539,17 +2546,8 @@ impl App {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let (target_id, expected_head_entries) = match picker.selected_target() {
|
||||
Some(target) if target.eligible => (target.id.clone(), target.expected_head_entries),
|
||||
Some(target) => {
|
||||
self.push_command_diagnostic(
|
||||
target
|
||||
.disabled_reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| "rewind target is disabled".into()),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let entry_id = match picker.selected_target() {
|
||||
Some(target) => target.entry_id.clone(),
|
||||
None => {
|
||||
self.push_command_diagnostic("no rewind target is available");
|
||||
return None;
|
||||
@@ -2558,10 +2556,7 @@ impl App {
|
||||
if let Some(picker) = self.rewind_picker.as_mut() {
|
||||
picker.applying = true;
|
||||
}
|
||||
Some(Method::RewindTo {
|
||||
target: target_id,
|
||||
expected_head_entries,
|
||||
})
|
||||
Some(Method::RewindTo { entry_id })
|
||||
}
|
||||
|
||||
fn command_environment(&self) -> CommandEnvironment {
|
||||
@@ -2597,10 +2592,8 @@ impl App {
|
||||
command: self.next_command_envelope(),
|
||||
});
|
||||
}
|
||||
if let Some(Method::ListRewindTargets) = method.as_ref() {
|
||||
self.completion = None;
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = true;
|
||||
if result.open_rewind_picker {
|
||||
return self.request_rewind_picker();
|
||||
}
|
||||
method
|
||||
}
|
||||
@@ -2913,10 +2906,15 @@ impl App {
|
||||
}
|
||||
self.assistant_streaming = false;
|
||||
|
||||
self.rewind_entries.clear();
|
||||
for entry in &session.entries {
|
||||
use protocol::{SessionContentPart, SessionMessageRole, SessionSnapshotEntryData};
|
||||
match &entry.data {
|
||||
SessionSnapshotEntryData::UserInput { segments } => {
|
||||
self.rewind_entries.push(RewindEntry {
|
||||
entry_id: entry.entry_id.clone(),
|
||||
preview: Segment::flatten_to_text(segments),
|
||||
});
|
||||
self.turn_index += 1;
|
||||
self.blocks.push(Block::TurnHeader {
|
||||
turn: self.turn_index,
|
||||
@@ -3364,23 +3362,10 @@ mod rewind_refresh_tests {
|
||||
fn app_with_rewind_picker() -> App {
|
||||
let mut app = App::new("test".into());
|
||||
app.connected = true;
|
||||
app.rewind_picker = Some(RewindPickerState::new(
|
||||
5,
|
||||
vec![RewindTarget {
|
||||
id: protocol::RewindTargetId {
|
||||
segment_id: uuid::Uuid::nil(),
|
||||
user_input_entry_index: 1,
|
||||
},
|
||||
expected_head_entries: 5,
|
||||
truncate_entries: 2,
|
||||
turn_index: 1,
|
||||
timestamp_ms: None,
|
||||
preview: "rewind target".into(),
|
||||
eligible: true,
|
||||
disabled_reason: None,
|
||||
warning: None,
|
||||
}],
|
||||
));
|
||||
app.rewind_picker = Some(RewindPickerState::new(vec![RewindEntry {
|
||||
entry_id: "user-1".into(),
|
||||
preview: "test input".into(),
|
||||
}]));
|
||||
app
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ pub struct CommandEnvironment {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandExecution {
|
||||
pub method: Option<Method>,
|
||||
pub open_rewind_picker: bool,
|
||||
pub diagnostics: Vec<CommandDiagnostic>,
|
||||
pub exit_command_mode: bool,
|
||||
pub clear_input: bool,
|
||||
@@ -60,6 +61,7 @@ pub struct CommandExecution {
|
||||
impl CommandExecution {
|
||||
pub fn diagnostic(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
open_rewind_picker: false,
|
||||
method: None,
|
||||
diagnostics: vec![CommandDiagnostic::new(message)],
|
||||
exit_command_mode: false,
|
||||
@@ -69,6 +71,7 @@ impl CommandExecution {
|
||||
|
||||
pub fn notice(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
open_rewind_picker: false,
|
||||
method: None,
|
||||
diagnostics: vec![CommandDiagnostic::new(message)],
|
||||
exit_command_mode: true,
|
||||
@@ -409,6 +412,7 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
let _ = invocation.environment;
|
||||
let _ = invocation.args.raw();
|
||||
CommandExecution {
|
||||
open_rewind_picker: false,
|
||||
method: Some(Method::Compact {
|
||||
command: protocol::WorkerCommandEnvelope::new(0),
|
||||
}),
|
||||
@@ -423,7 +427,8 @@ fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
let _ = invocation.environment;
|
||||
let _ = invocation.args.raw();
|
||||
CommandExecution {
|
||||
method: Some(Method::ListRewindTargets),
|
||||
open_rewind_picker: true,
|
||||
method: None,
|
||||
diagnostics: vec![CommandDiagnostic::new("rewind picker requested")],
|
||||
exit_command_mode: true,
|
||||
clear_input: true,
|
||||
@@ -435,6 +440,7 @@ fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
let _ = invocation.environment;
|
||||
let name = invocation.args.argv()[0].clone();
|
||||
CommandExecution {
|
||||
open_rewind_picker: false,
|
||||
method: Some(Method::RegisterPeer { name: name.clone() }),
|
||||
diagnostics: vec![CommandDiagnostic::new(format!(
|
||||
"peer metadata registration requested with `{name}`"
|
||||
@@ -521,7 +527,8 @@ mod tests {
|
||||
let registry = CommandRegistry::builtins();
|
||||
for command in ["rewind", "rollback"] {
|
||||
let result = registry.dispatch(command, &env());
|
||||
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
|
||||
assert!(result.method.is_none());
|
||||
assert!(result.open_rewind_picker);
|
||||
assert!(result.exit_command_mode);
|
||||
assert!(result.clear_input);
|
||||
assert!(result.diagnostics[0].message.contains("rewind picker"));
|
||||
@@ -549,7 +556,8 @@ mod tests {
|
||||
let mut paused = env();
|
||||
paused.paused = true;
|
||||
let result = registry.dispatch("rewind", &paused);
|
||||
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
|
||||
assert!(result.method.is_none());
|
||||
assert!(result.open_rewind_picker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,7 +18,7 @@ use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use crossterm::{Command, execute};
|
||||
use protocol::{Event, Method, Segment, UploadedFileRef, WorkerStatus};
|
||||
#[cfg(feature = "e2e-test")]
|
||||
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId};
|
||||
use protocol::{Greeting, RewindSummary};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use standalone::{StandaloneHost, StandaloneLaunchConfig};
|
||||
@@ -758,10 +758,10 @@ async fn run_e2e_rewind_fixture(
|
||||
});
|
||||
|
||||
let (_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||
let target_id = RewindTargetId {
|
||||
segment_id: uuid::Uuid::from_u128(1),
|
||||
user_input_entry_index: 1,
|
||||
};
|
||||
app.handle_worker_event(Event::UserMessage {
|
||||
entry_id: Some("rewind-fixture-input".into()),
|
||||
segments: vec![Segment::text("candidate rewind target")],
|
||||
});
|
||||
let mut rewind_submit_count = 0usize;
|
||||
let mut pending_apply: Option<std::time::Instant> = None;
|
||||
let apply_delay = Duration::from_millis(400);
|
||||
@@ -803,45 +803,25 @@ async fn run_e2e_rewind_fixture(
|
||||
.as_ref()
|
||||
.map(|picker| picker.applying)
|
||||
.unwrap_or(false);
|
||||
if let Some(method) = handle_key(&mut app, key) {
|
||||
let was_open = app.rewind_picker.is_some();
|
||||
let method = handle_key(&mut app, key);
|
||||
if !was_open && app.rewind_picker.is_some() {
|
||||
crate::e2e_observer::emit(
|
||||
"single_worker",
|
||||
"rewind_picker_opened",
|
||||
serde_json::json!({"targets": 1, "selected_preview": "candidate rewind target"}),
|
||||
);
|
||||
}
|
||||
if let Some(method) = method {
|
||||
match method {
|
||||
Method::ListRewindTargets => {
|
||||
app.handle_worker_event(Event::RewindTargets {
|
||||
head_entries: 3,
|
||||
targets: vec![RewindTarget {
|
||||
id: target_id.clone(),
|
||||
expected_head_entries: 3,
|
||||
truncate_entries: 1,
|
||||
turn_index: 1,
|
||||
timestamp_ms: Some(1),
|
||||
preview: "candidate rewind target".to_string(),
|
||||
eligible: true,
|
||||
disabled_reason: None,
|
||||
warning: None,
|
||||
}],
|
||||
});
|
||||
crate::e2e_observer::emit(
|
||||
"single_worker",
|
||||
"rewind_picker_opened",
|
||||
serde_json::json!({
|
||||
"targets": 1,
|
||||
"selected_preview": "candidate rewind target",
|
||||
}),
|
||||
);
|
||||
}
|
||||
Method::RewindTo {
|
||||
target,
|
||||
expected_head_entries,
|
||||
} => {
|
||||
Method::RewindTo { entry_id } => {
|
||||
rewind_submit_count += 1;
|
||||
pending_apply = Some(std::time::Instant::now());
|
||||
crate::e2e_observer::emit(
|
||||
"single_worker",
|
||||
"rewind_submit_sent",
|
||||
serde_json::json!({
|
||||
"segment_id": target.segment_id.to_string(),
|
||||
"user_input_entry_index": target.user_input_entry_index,
|
||||
"expected_head_entries": expected_head_entries,
|
||||
"entry_id": entry_id,
|
||||
"submit_count": rewind_submit_count,
|
||||
}),
|
||||
);
|
||||
@@ -1725,10 +1705,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::text_selection::{HistoryViewport, SelectionRow};
|
||||
use async_trait::async_trait;
|
||||
use protocol::{
|
||||
Event, RewindTarget, RewindTargetId, Segment, UploadedFileAvailability, UploadedFileRef,
|
||||
WorkerStatus,
|
||||
};
|
||||
use protocol::{Event, Segment, UploadedFileAvailability, UploadedFileRef, WorkerStatus};
|
||||
|
||||
#[test]
|
||||
fn standalone_console_starts_with_in_process_connection_ready() {
|
||||
@@ -3119,14 +3096,17 @@ mod tests {
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(idle, Some(Method::ListRewindTargets)));
|
||||
assert!(idle.is_none());
|
||||
assert!(app.rewind_picker.is_some());
|
||||
app.close_rewind_picker();
|
||||
|
||||
app.set_worker_status(WorkerStatus::Paused);
|
||||
let paused = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(paused, Some(Method::ListRewindTargets)));
|
||||
assert!(paused.is_none());
|
||||
assert!(app.rewind_picker.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3148,21 +3128,13 @@ mod tests {
|
||||
fn rewind_picker_close_returns_to_history_view() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.connected = true;
|
||||
app.handle_worker_event(Event::RewindTargets {
|
||||
head_entries: 1,
|
||||
targets: vec![],
|
||||
});
|
||||
assert!(app.rewind_picker.is_none());
|
||||
|
||||
let method = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(method, Some(Method::ListRewindTargets)));
|
||||
app.handle_worker_event(Event::RewindTargets {
|
||||
head_entries: 1,
|
||||
targets: vec![],
|
||||
});
|
||||
assert!(method.is_none());
|
||||
assert!(app.rewind_picker.is_some());
|
||||
|
||||
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
@@ -3244,13 +3216,13 @@ mod tests {
|
||||
#[test]
|
||||
fn rewind_apply_rejects_non_empty_composer_and_paused_status() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
|
||||
type_keys(&mut app, "draft");
|
||||
assert!(app.submit_rewind_picker().is_none());
|
||||
assert!(has_alert(&app, "composer is not empty"));
|
||||
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
|
||||
app.set_worker_status(WorkerStatus::Paused);
|
||||
assert!(app.submit_rewind_picker().is_none());
|
||||
assert!(has_alert(
|
||||
@@ -3265,7 +3237,7 @@ mod tests {
|
||||
app.scroll.top_offset = 3;
|
||||
app.scroll.turn_starts = vec![0, 5, 9];
|
||||
app.scroll.total_lines = 42;
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
|
||||
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
|
||||
let original_top_offset = app.scroll.top_offset;
|
||||
let original_turn_starts = app.scroll.turn_starts.clone();
|
||||
let original_total_lines = app.scroll.total_lines;
|
||||
@@ -3282,20 +3254,10 @@ mod tests {
|
||||
assert_eq!(app.scroll.total_lines, original_total_lines);
|
||||
}
|
||||
|
||||
fn rewind_target() -> RewindTarget {
|
||||
RewindTarget {
|
||||
id: RewindTargetId {
|
||||
segment_id: uuid::Uuid::nil(),
|
||||
user_input_entry_index: 0,
|
||||
},
|
||||
expected_head_entries: 1,
|
||||
truncate_entries: 0,
|
||||
turn_index: 1,
|
||||
timestamp_ms: Some(1),
|
||||
preview: "retry this".into(),
|
||||
eligible: true,
|
||||
disabled_reason: None,
|
||||
warning: None,
|
||||
fn rewind_target() -> crate::app::RewindEntry {
|
||||
crate::app::RewindEntry {
|
||||
entry_id: "user-1".into(),
|
||||
preview: "test input".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-27
@@ -728,7 +728,7 @@ fn draw_rewind_picker(
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" head={} ", picker.head_entries)),
|
||||
Span::raw(" "),
|
||||
];
|
||||
header.extend(action_spans);
|
||||
logical.push(Line::from(header));
|
||||
@@ -751,38 +751,13 @@ fn draw_rewind_picker(
|
||||
Style::default()
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if target.eligible {
|
||||
Style::default()
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
Style::default()
|
||||
};
|
||||
let ts = target
|
||||
.timestamp_ms
|
||||
.map(|ts| format!("{}", ts))
|
||||
.unwrap_or_else(|| "-".into());
|
||||
logical.push(Line::from(vec![
|
||||
Span::styled(marker.to_owned(), base_style),
|
||||
Span::styled(
|
||||
format!(
|
||||
" turn {} idx {} ts {} ",
|
||||
target.turn_index, target.id.user_input_entry_index, ts
|
||||
),
|
||||
base_style,
|
||||
),
|
||||
Span::styled(target.preview.clone(), base_style),
|
||||
]));
|
||||
if let Some(warning) = target.warning.as_ref() {
|
||||
logical.push(Line::from(Span::styled(
|
||||
format!(" warning: {warning}"),
|
||||
Style::default().fg(Color::Yellow),
|
||||
)));
|
||||
}
|
||||
if let Some(reason) = target.disabled_reason.as_ref() {
|
||||
logical.push(Line::from(Span::styled(
|
||||
format!(" disabled: {reason}"),
|
||||
Style::default().fg(Color::Red),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user