feat: add manual rewind control
This commit is contained in:
+134
-2
@@ -2,8 +2,8 @@ use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus, RunResult,
|
||||
Segment,
|
||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus,
|
||||
RewindTarget, RunResult, Segment,
|
||||
};
|
||||
|
||||
use crate::block::{
|
||||
@@ -51,6 +51,19 @@ impl CompletionState {
|
||||
pub const MAX_VISIBLE: usize = 6;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RewindPickerState {
|
||||
pub head_entries: usize,
|
||||
pub targets: Vec<RewindTarget>,
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
impl RewindPickerState {
|
||||
pub fn selected_target(&self) -> Option<&RewindTarget> {
|
||||
self.targets.get(self.selected)
|
||||
}
|
||||
}
|
||||
|
||||
struct RollbackSubmitState {
|
||||
text: String,
|
||||
segments: Vec<Segment>,
|
||||
@@ -126,6 +139,10 @@ pub struct App {
|
||||
/// Completion popup state, when an `@` / `#` / `/` token is in
|
||||
/// flight. `None` whenever the trigger conditions don't hold.
|
||||
pub completion: Option<CompletionState>,
|
||||
/// Dedicated main-view rewind picker state.
|
||||
pub rewind_picker: Option<RewindPickerState>,
|
||||
rewind_request_pending: bool,
|
||||
greeting: Option<protocol::Greeting>,
|
||||
/// In-TUI mirror of the Pod's session task store, reconstructed
|
||||
/// directly from observed `TaskCreate` / `TaskUpdate` tool calls and
|
||||
/// `[Session TaskStore snapshot]` system messages — no protocol
|
||||
@@ -177,6 +194,9 @@ impl App {
|
||||
cache: FileCache::new(),
|
||||
assistant_streaming: false,
|
||||
completion: None,
|
||||
rewind_picker: None,
|
||||
rewind_request_pending: false,
|
||||
greeting: None,
|
||||
task_store: TaskStore::new(),
|
||||
task_pane_open: false,
|
||||
task_pane_scroll: 0,
|
||||
@@ -921,6 +941,48 @@ impl App {
|
||||
state.selected = 0;
|
||||
}
|
||||
}
|
||||
Event::RewindTargets {
|
||||
head_entries,
|
||||
targets,
|
||||
} => {
|
||||
if self.rewind_request_pending {
|
||||
self.rewind_request_pending = false;
|
||||
let selected = targets.iter().position(|t| t.eligible).unwrap_or(0);
|
||||
self.rewind_picker = Some(RewindPickerState {
|
||||
head_entries,
|
||||
targets,
|
||||
selected,
|
||||
});
|
||||
self.scroll = Scroll::default();
|
||||
}
|
||||
}
|
||||
Event::RewindApplied {
|
||||
entries,
|
||||
input,
|
||||
summary,
|
||||
} => {
|
||||
if let Some(greeting) = self.greeting.clone() {
|
||||
self.restore_snapshot(&entries, greeting);
|
||||
}
|
||||
self.input.replace_with_segments(&input);
|
||||
self.completion = None;
|
||||
self.close_rewind_picker();
|
||||
self.reset_run_state(self.pod_status);
|
||||
let mut message = format!(
|
||||
"Rewound session: discarded {} log entries; restored selected input to composer.",
|
||||
summary.discarded_entries
|
||||
);
|
||||
if summary.tool_side_effect_warning {
|
||||
message.push_str(
|
||||
" History suffix was discarded; tool side effects were not undone.",
|
||||
);
|
||||
}
|
||||
self.blocks.push(Block::Alert {
|
||||
level: AlertLevel::Warn,
|
||||
source: AlertSource::Pod,
|
||||
message,
|
||||
});
|
||||
}
|
||||
Event::VisiblePods { .. }
|
||||
| Event::PodInspection { .. }
|
||||
| Event::PodAttachRestore { .. } => {}
|
||||
@@ -1220,6 +1282,70 @@ impl App {
|
||||
self.command_input.insert_str(&completed);
|
||||
}
|
||||
|
||||
pub fn request_rewind_picker(&mut self) -> Option<Method> {
|
||||
if !self.connected {
|
||||
self.push_command_diagnostic("cannot rewind before the Pod is connected");
|
||||
return None;
|
||||
}
|
||||
if self.running {
|
||||
self.push_command_diagnostic("cannot rewind while the Pod is running");
|
||||
return None;
|
||||
}
|
||||
self.completion = None;
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = true;
|
||||
Some(Method::ListRewindTargets)
|
||||
}
|
||||
|
||||
pub fn close_rewind_picker(&mut self) {
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = false;
|
||||
}
|
||||
|
||||
pub fn rewind_picker_up(&mut self) {
|
||||
if let Some(picker) = self.rewind_picker.as_mut() {
|
||||
if picker.targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
picker.selected = if picker.selected == 0 {
|
||||
picker.targets.len() - 1
|
||||
} else {
|
||||
picker.selected - 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rewind_picker_down(&mut self) {
|
||||
if let Some(picker) = self.rewind_picker.as_mut() {
|
||||
if !picker.targets.is_empty() {
|
||||
picker.selected = (picker.selected + 1) % picker.targets.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit_rewind_picker(&mut self) -> Option<Method> {
|
||||
let Some(picker) = self.rewind_picker.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let Some(target) = picker.selected_target() else {
|
||||
self.push_command_diagnostic("no rewind target is available");
|
||||
return None;
|
||||
};
|
||||
if !target.eligible {
|
||||
self.push_command_diagnostic(
|
||||
target
|
||||
.disabled_reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| "rewind target is disabled".into()),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(Method::RewindTo {
|
||||
target: target.id.clone(),
|
||||
expected_head_entries: target.expected_head_entries,
|
||||
})
|
||||
}
|
||||
|
||||
fn command_environment(&self) -> CommandEnvironment {
|
||||
CommandEnvironment {
|
||||
connected: self.connected,
|
||||
@@ -1247,6 +1373,11 @@ impl App {
|
||||
self.input_mode = CommandInputMode::Composer;
|
||||
self.command_completion_selected = None;
|
||||
}
|
||||
if let Some(Method::ListRewindTargets) = result.method.as_ref() {
|
||||
self.completion = None;
|
||||
self.rewind_picker = None;
|
||||
self.rewind_request_pending = true;
|
||||
}
|
||||
result.method
|
||||
}
|
||||
|
||||
@@ -1334,6 +1465,7 @@ impl App {
|
||||
/// produced. Followed by `Event::Entry` updates for anything
|
||||
/// committed after the snapshot.
|
||||
fn restore_snapshot(&mut self, entries: &[serde_json::Value], greeting: protocol::Greeting) {
|
||||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
self.session_context_tokens = greeting.context_tokens;
|
||||
self.turn_index = 0;
|
||||
|
||||
@@ -147,6 +147,15 @@ impl CommandRegistry {
|
||||
can_execute: compact_available,
|
||||
executor: compact_command,
|
||||
});
|
||||
registry.register(CommandSpec {
|
||||
name: "rewind",
|
||||
aliases: &["rollback"],
|
||||
usage: "rewind",
|
||||
description: "Open the rewind target picker.",
|
||||
argument_parser: rewind_args,
|
||||
can_execute: rewind_available,
|
||||
executor: rewind_command,
|
||||
});
|
||||
registry
|
||||
}
|
||||
|
||||
@@ -284,6 +293,15 @@ fn compact_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
||||
}
|
||||
}
|
||||
|
||||
fn rewind_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
|
||||
let args = CommandArgs::parse_whitespace(raw);
|
||||
if args.argv().is_empty() {
|
||||
Ok(args)
|
||||
} else {
|
||||
Err(CommandDiagnostic::new("Invalid arguments. Usage: rewind"))
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||
if !environment.connected {
|
||||
return Err(CommandDiagnostic::new(
|
||||
@@ -303,6 +321,20 @@ fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiag
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rewind_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
|
||||
if !environment.connected {
|
||||
return Err(CommandDiagnostic::new(
|
||||
"Cannot rewind before the Pod is connected.",
|
||||
));
|
||||
}
|
||||
if environment.running {
|
||||
return Err(CommandDiagnostic::new(
|
||||
"Cannot rewind while the Pod is running.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn help_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
if let Some(name) = invocation.args.argv().first() {
|
||||
let Some(command) = invocation.registry.find(name) else {
|
||||
@@ -350,6 +382,18 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
}
|
||||
}
|
||||
|
||||
fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
||||
let _ = invocation.command;
|
||||
let _ = invocation.environment;
|
||||
let _ = invocation.args.raw();
|
||||
CommandExecution {
|
||||
method: Some(Method::ListRewindTargets),
|
||||
diagnostics: vec![CommandDiagnostic::new("rewind picker requested")],
|
||||
exit_command_mode: true,
|
||||
clear_input: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -421,4 +465,40 @@ mod tests {
|
||||
assert!(result.method.is_none());
|
||||
assert!(result.diagnostics[0].message.contains("paused"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_command_and_alias_return_list_method() {
|
||||
let registry = CommandRegistry::builtins();
|
||||
for command in ["rewind", "rollback"] {
|
||||
let result = registry.dispatch(command, &env());
|
||||
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
|
||||
assert!(result.exit_command_mode);
|
||||
assert!(result.clear_input);
|
||||
assert!(result.diagnostics[0].message.contains("rewind picker"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_invalid_arguments_are_local_diagnostic() {
|
||||
let registry = CommandRegistry::builtins();
|
||||
let result = registry.dispatch("rewind now", &env());
|
||||
assert!(result.method.is_none());
|
||||
assert!(!result.exit_command_mode);
|
||||
assert!(result.diagnostics[0].message.contains("Invalid arguments"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_rejects_running_but_allows_paused() {
|
||||
let registry = CommandRegistry::builtins();
|
||||
let mut running = env();
|
||||
running.running = true;
|
||||
let result = registry.dispatch("rewind", &running);
|
||||
assert!(result.method.is_none());
|
||||
assert!(result.diagnostics[0].message.contains("running"));
|
||||
|
||||
let mut paused = env();
|
||||
paused.paused = true;
|
||||
let result = registry.dispatch("rewind", &paused);
|
||||
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,6 +801,9 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||
app.toggle_task_pane();
|
||||
Some(None)
|
||||
}
|
||||
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'r') && ctrl => {
|
||||
Some(app.request_rewind_picker())
|
||||
}
|
||||
KeyCode::Char('a') if ctrl => {
|
||||
app.move_cursor_start();
|
||||
Some(app.refresh_completion())
|
||||
@@ -880,6 +883,25 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||
return handle_command_key(app, key);
|
||||
}
|
||||
|
||||
if app.rewind_picker.is_some() {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.close_rewind_picker();
|
||||
return None;
|
||||
}
|
||||
KeyCode::Enter => return app.submit_rewind_picker(),
|
||||
KeyCode::Up => {
|
||||
app.rewind_picker_up();
|
||||
return None;
|
||||
}
|
||||
KeyCode::Down => {
|
||||
app.rewind_picker_down();
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Completion popup overrides — only when there's something to
|
||||
// navigate / commit. An empty popup (request in flight) falls
|
||||
// through to the default behaviour.
|
||||
@@ -1079,6 +1101,7 @@ fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use protocol::{Event, Segment};
|
||||
|
||||
#[test]
|
||||
fn parse_pod_name_mode() {
|
||||
@@ -1542,6 +1565,104 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_r_requests_rewind_picker_when_idle_or_paused() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.connected = true;
|
||||
let idle = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(idle, Some(Method::ListRewindTargets)));
|
||||
|
||||
app.set_pod_status(PodStatus::Paused);
|
||||
let paused = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
assert!(matches!(paused, Some(Method::ListRewindTargets)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_r_is_rejected_while_running() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.connected = true;
|
||||
app.set_pod_status(PodStatus::Running);
|
||||
|
||||
let method = handle_key(
|
||||
&mut app,
|
||||
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
|
||||
);
|
||||
|
||||
assert!(method.is_none());
|
||||
assert!(has_alert(&app, "cannot rewind while the Pod is running"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_picker_close_returns_to_history_view() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.connected = true;
|
||||
app.handle_pod_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_pod_event(Event::RewindTargets {
|
||||
head_entries: 1,
|
||||
targets: vec![],
|
||||
});
|
||||
assert!(app.rewind_picker.is_some());
|
||||
|
||||
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(method.is_none());
|
||||
assert!(app.rewind_picker.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_applied_reseeds_display_and_restores_composer() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.handle_pod_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: vec![],
|
||||
status: PodStatus::Idle,
|
||||
});
|
||||
app.handle_pod_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
input: vec![Segment::Text {
|
||||
content: "retry this".into(),
|
||||
}],
|
||||
summary: protocol::RewindSummary {
|
||||
truncated_to_entries: 0,
|
||||
discarded_entries: 2,
|
||||
tool_side_effect_warning: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert_eq!(input_text(&app), "retry this");
|
||||
assert!(app.rewind_picker.is_none());
|
||||
assert!(has_alert(&app, "tool side effects"));
|
||||
}
|
||||
|
||||
fn test_greeting() -> protocol::Greeting {
|
||||
protocol::Greeting {
|
||||
pod_name: "agent".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test".into(),
|
||||
model: "test".into(),
|
||||
scope_summary: "".into(),
|
||||
tools: vec![],
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_registry_suggestions_are_available() {
|
||||
let mut app = App::new("agent".to_string());
|
||||
|
||||
@@ -419,6 +419,11 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(picker) = app.rewind_picker.clone() {
|
||||
draw_rewind_picker(frame, app, history_area, inner, outer_block, &picker);
|
||||
return;
|
||||
}
|
||||
|
||||
let HistoryLayout { lines, turn_starts } = compute_history(app, inner.width);
|
||||
|
||||
// `lines` is already pre-wrapped: 1 entry == 1 terminal row. Scroll
|
||||
@@ -447,6 +452,101 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
.render(history_area, frame.buffer_mut());
|
||||
}
|
||||
|
||||
fn draw_rewind_picker(
|
||||
frame: &mut Frame,
|
||||
app: &mut App,
|
||||
history_area: Rect,
|
||||
inner: Rect,
|
||||
outer_block: UiBlock<'_>,
|
||||
picker: &crate::app::RewindPickerState,
|
||||
) {
|
||||
let mut logical: Vec<Line<'static>> = Vec::new();
|
||||
logical.push(Line::from(vec![
|
||||
Span::styled(
|
||||
"Rewind targets",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" head={} ", picker.head_entries)),
|
||||
Span::styled("Enter", Style::default().fg(Color::Green)),
|
||||
Span::raw(" apply "),
|
||||
Span::styled("Esc", Style::default().fg(Color::Green)),
|
||||
Span::raw(" cancel"),
|
||||
]));
|
||||
logical.push(Line::from(Span::styled(
|
||||
"Selecting a target discards the later history suffix; tool side effects are not undone.",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
logical.push(Line::from(""));
|
||||
|
||||
if picker.targets.is_empty() {
|
||||
logical.push(Line::from(Span::styled(
|
||||
"No previous user messages are available to rewind.",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
} else {
|
||||
for (idx, target) in picker.targets.iter().enumerate() {
|
||||
let selected = idx == picker.selected;
|
||||
let marker = if selected { "▶" } else { " " };
|
||||
let base_style = if selected {
|
||||
Style::default()
|
||||
.bg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if target.eligible {
|
||||
Style::default()
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
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),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for line in logical {
|
||||
wrap_line_into(line, inner.width, &mut lines);
|
||||
}
|
||||
|
||||
let tail_top = lines.len().saturating_sub(inner.height as usize);
|
||||
app.scroll.area_height = inner.height;
|
||||
app.scroll.total_lines = lines.len();
|
||||
app.scroll.tail_top_offset = tail_top;
|
||||
app.scroll.turn_starts.clear();
|
||||
app.scroll.top_offset = app.scroll.top_offset.min(tail_top);
|
||||
|
||||
let end = (app.scroll.top_offset + inner.height as usize).min(lines.len());
|
||||
let visible = lines[app.scroll.top_offset..end].to_vec();
|
||||
Paragraph::new(visible)
|
||||
.block(outer_block)
|
||||
.render(history_area, frame.buffer_mut());
|
||||
}
|
||||
|
||||
/// Width to reserve for the task side pane within the history rect.
|
||||
/// Returns 0 when the pane is closed or the rect is too narrow to host
|
||||
/// it without crushing the history view.
|
||||
|
||||
Reference in New Issue
Block a user