feat: select rewind points directly from Console history

This commit is contained in:
2026-10-11 14:50:49 +09:00
parent 04bdf1da23
commit 5ac3b1e482
27 changed files with 694 additions and 545 deletions
+44 -59
View File
@@ -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
}
+11 -3
View File
@@ -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]
+31 -69
View File
@@ -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
View File
@@ -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),
)));
}
}
}