fix: refresh tui after rewind
This commit is contained in:
parent
20daae0c59
commit
949ceb5a21
|
|
@ -3,7 +3,7 @@ use std::path::Path;
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus,
|
||||
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Method, PodStatus,
|
||||
RewindTarget, RunResult, Segment,
|
||||
};
|
||||
|
||||
|
|
@ -69,6 +69,11 @@ pub struct RewindPickerState {
|
|||
pub targets: Vec<RewindTarget>,
|
||||
pub selected: usize,
|
||||
pub scroll: RewindPickerScroll,
|
||||
/// True after Enter submitted an authoritative `RewindTo` and before the
|
||||
/// Pod replies with either `RewindApplied` or `Error`. While set, the
|
||||
/// picker remains visible but further submits/navigation are ignored so a
|
||||
/// destructive rewind cannot be queued multiple times by key repeat.
|
||||
pub applying: bool,
|
||||
}
|
||||
|
||||
impl RewindPickerState {
|
||||
|
|
@ -79,6 +84,7 @@ impl RewindPickerState {
|
|||
targets,
|
||||
selected,
|
||||
scroll: RewindPickerScroll::default(),
|
||||
applying: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,6 +282,11 @@ pub struct App {
|
|||
/// Dedicated main-view rewind picker state.
|
||||
pub rewind_picker: Option<RewindPickerState>,
|
||||
rewind_request_pending: bool,
|
||||
/// After a successful rewind restore, ignore any queued live-update events
|
||||
/// until the authoritative Pod status/snapshot catches up. This prevents
|
||||
/// old stream tail events that were already in transit from re-polluting the
|
||||
/// just-restored display.
|
||||
rewind_refresh_fence: bool,
|
||||
greeting: Option<protocol::Greeting>,
|
||||
/// In-TUI mirror of the Pod's session task store, reconstructed
|
||||
/// directly from observed `TaskCreate` / `TaskUpdate` tool calls and
|
||||
|
|
@ -337,6 +348,7 @@ impl App {
|
|||
completion: None,
|
||||
rewind_picker: None,
|
||||
rewind_request_pending: false,
|
||||
rewind_refresh_fence: false,
|
||||
greeting: None,
|
||||
task_store: TaskStore::new(),
|
||||
task_pane_open: false,
|
||||
|
|
@ -799,6 +811,33 @@ impl App {
|
|||
});
|
||||
}
|
||||
|
||||
fn handle_error(&mut self, code: ErrorCode, message: String) {
|
||||
let text = format!("[{code:?}] {message}");
|
||||
let was_applying = if let Some(picker) = self.rewind_picker.as_mut() {
|
||||
let applying = picker.applying;
|
||||
picker.applying = false;
|
||||
applying
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if was_applying {
|
||||
self.flash_actionbar_notice(
|
||||
format!("Rewind failed: {text}"),
|
||||
ActionbarNoticeLevel::Error,
|
||||
ActionbarNoticeSource::Tui,
|
||||
Duration::from_secs(6),
|
||||
);
|
||||
}
|
||||
self.push_error(text);
|
||||
}
|
||||
|
||||
fn rewind_submit_pending(&self) -> bool {
|
||||
self.rewind_picker
|
||||
.as_ref()
|
||||
.map(|picker| picker.applying)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn push_history_item(&mut self, item: &serde_json::Value) {
|
||||
let item_type = item["type"].as_str().unwrap_or("");
|
||||
match item_type {
|
||||
|
|
@ -927,6 +966,10 @@ impl App {
|
|||
}
|
||||
|
||||
pub fn handle_pod_event(&mut self, event: Event) -> Option<Method> {
|
||||
if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match event {
|
||||
Event::UserMessage { segments } => {
|
||||
self.turn_index += 1;
|
||||
|
|
@ -1148,7 +1191,7 @@ impl App {
|
|||
self.run_output_tokens += output_tokens.unwrap_or(0);
|
||||
}
|
||||
Event::Error { code, message } => {
|
||||
self.push_error(format!("[{code:?}] {message}"));
|
||||
self.handle_error(code, message);
|
||||
}
|
||||
Event::RunEnd { result } => {
|
||||
self.latest_llm_wait_event = None;
|
||||
|
|
@ -1231,10 +1274,12 @@ impl App {
|
|||
greeting,
|
||||
status,
|
||||
} => {
|
||||
self.rewind_refresh_fence = false;
|
||||
self.restore_snapshot(&entries, greeting);
|
||||
self.set_pod_status(status);
|
||||
}
|
||||
Event::Status { status } => {
|
||||
self.rewind_refresh_fence = false;
|
||||
self.set_pod_status(status);
|
||||
}
|
||||
Event::Completions { kind, entries } => {
|
||||
|
|
@ -1262,9 +1307,8 @@ impl App {
|
|||
input,
|
||||
summary,
|
||||
} => {
|
||||
if let Some(greeting) = self.greeting.clone() {
|
||||
self.restore_snapshot(&entries, greeting);
|
||||
}
|
||||
self.restore_rewind_snapshot(&entries);
|
||||
self.rewind_refresh_fence = true;
|
||||
let restored_composer = if self.input.is_empty() {
|
||||
self.input.replace_with_segments(&input);
|
||||
true
|
||||
|
|
@ -1610,6 +1654,10 @@ impl App {
|
|||
}
|
||||
|
||||
pub fn request_rewind_picker(&mut self) -> Option<Method> {
|
||||
if self.rewind_submit_pending() {
|
||||
self.push_command_diagnostic("rewind is already applying; wait for the Pod response");
|
||||
return None;
|
||||
}
|
||||
if !self.connected {
|
||||
self.push_command_diagnostic("cannot rewind before the Pod is connected");
|
||||
return None;
|
||||
|
|
@ -1629,9 +1677,22 @@ impl App {
|
|||
self.rewind_request_pending = false;
|
||||
}
|
||||
|
||||
pub fn cancel_rewind_picker(&mut self) {
|
||||
if self.rewind_submit_pending() {
|
||||
self.flash_actionbar_notice(
|
||||
"Rewind is applying; wait for the Pod response.",
|
||||
ActionbarNoticeLevel::Warn,
|
||||
ActionbarNoticeSource::Tui,
|
||||
Duration::from_secs(3),
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.close_rewind_picker();
|
||||
}
|
||||
|
||||
pub fn rewind_picker_up(&mut self) {
|
||||
if let Some(picker) = self.rewind_picker.as_mut() {
|
||||
if picker.targets.is_empty() {
|
||||
if picker.applying || picker.targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
picker.selected = if picker.selected == 0 {
|
||||
|
|
@ -1644,13 +1705,17 @@ impl App {
|
|||
|
||||
pub fn rewind_picker_down(&mut self) {
|
||||
if let Some(picker) = self.rewind_picker.as_mut() {
|
||||
if !picker.targets.is_empty() {
|
||||
if !picker.applying && !picker.targets.is_empty() {
|
||||
picker.selected = (picker.selected + 1) % picker.targets.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit_rewind_picker(&mut self) -> Option<Method> {
|
||||
if self.rewind_submit_pending() {
|
||||
self.push_command_diagnostic("rewind is already applying; wait for the Pod response");
|
||||
return None;
|
||||
}
|
||||
if self.paused {
|
||||
self.push_command_diagnostic(
|
||||
"cannot apply rewind while the Pod is paused; resume or wait for idle first",
|
||||
|
|
@ -1666,11 +1731,13 @@ impl App {
|
|||
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");
|
||||
if picker.applying {
|
||||
self.push_command_diagnostic("rewind is already applying; wait for the Pod response");
|
||||
return None;
|
||||
};
|
||||
if !target.eligible {
|
||||
}
|
||||
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
|
||||
|
|
@ -1679,9 +1746,17 @@ impl App {
|
|||
);
|
||||
return None;
|
||||
}
|
||||
None => {
|
||||
self.push_command_diagnostic("no rewind target is available");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if let Some(picker) = self.rewind_picker.as_mut() {
|
||||
picker.applying = true;
|
||||
}
|
||||
Some(Method::RewindTo {
|
||||
target: target.id.clone(),
|
||||
expected_head_entries: target.expected_head_entries,
|
||||
target: target_id,
|
||||
expected_head_entries,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1836,12 +1911,50 @@ impl App {
|
|||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
self.session_context_tokens = greeting.context_tokens;
|
||||
self.restore_entries(entries, Some(greeting));
|
||||
}
|
||||
|
||||
/// Restore after a successful destructive rewind. The Pod's
|
||||
/// `RewindApplied` event already contains the authoritative post-rewind
|
||||
/// session tail; always clear/replay from it even if this TUI instance has
|
||||
/// somehow lost connect-time greeting metadata. Skipping the restore in
|
||||
/// that case would leave old post-target output visible after success.
|
||||
fn restore_rewind_snapshot(&mut self, entries: &[serde_json::Value]) {
|
||||
let greeting = self.greeting.clone().or_else(|| {
|
||||
self.blocks.iter().find_map(|b| match b {
|
||||
Block::Greeting(g) => Some(g.clone()),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
if let Some(greeting) = greeting.clone() {
|
||||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
self.session_context_tokens = greeting.context_tokens;
|
||||
}
|
||||
let missing_greeting = greeting.is_none();
|
||||
self.restore_entries(entries, greeting);
|
||||
if missing_greeting {
|
||||
self.blocks.push(Block::Alert {
|
||||
level: AlertLevel::Warn,
|
||||
source: AlertSource::Pod,
|
||||
message: "Rewind applied, but greeting metadata was unavailable; restored the session tail without the header.".to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_entries(
|
||||
&mut self,
|
||||
entries: &[serde_json::Value],
|
||||
greeting: Option<protocol::Greeting>,
|
||||
) {
|
||||
self.turn_index = 0;
|
||||
self.blocks.clear();
|
||||
self.cache = FileCache::new();
|
||||
self.task_store = TaskStore::new();
|
||||
self.task_pane_scroll = 0;
|
||||
if let Some(greeting) = greeting {
|
||||
self.blocks.push(Block::Greeting(greeting));
|
||||
}
|
||||
self.assistant_streaming = false;
|
||||
|
||||
for entry in entries {
|
||||
|
|
@ -1865,6 +1978,7 @@ impl App {
|
|||
self.task_store = TaskStore::new();
|
||||
self.task_pane_scroll = 0;
|
||||
if let Some(g) = greeting {
|
||||
self.greeting = Some(g.clone());
|
||||
self.blocks.push(Block::Greeting(g));
|
||||
}
|
||||
}
|
||||
|
|
@ -1962,6 +2076,38 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn event_is_stale_after_rewind(event: &Event) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
Event::Alert(_)
|
||||
| Event::MemoryWorker(_)
|
||||
| Event::CompactStart
|
||||
| Event::CompactDone { .. }
|
||||
| Event::CompactFailed { .. }
|
||||
| Event::SegmentRotated { .. }
|
||||
| Event::UserMessage { .. }
|
||||
| Event::SystemItem { .. }
|
||||
| Event::TurnStart { .. }
|
||||
| Event::InvokeStart { .. }
|
||||
| Event::LlmCallStart { .. }
|
||||
| Event::LlmCallEnd { .. }
|
||||
| Event::LlmRetry { .. }
|
||||
| Event::LlmContinuation { .. }
|
||||
| Event::TextDelta { .. }
|
||||
| Event::TextDone { .. }
|
||||
| Event::ThinkingStart
|
||||
| Event::ThinkingDelta { .. }
|
||||
| Event::ThinkingDone { .. }
|
||||
| Event::ToolCallStart { .. }
|
||||
| Event::ToolCallArgsDelta { .. }
|
||||
| Event::ToolCallDone { .. }
|
||||
| Event::ToolResult { .. }
|
||||
| Event::Usage { .. }
|
||||
| Event::TurnEnd { .. }
|
||||
| Event::RunEnd { .. }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fmt_tokens(n: u64) -> String {
|
||||
if n >= 1_000_000 {
|
||||
format!("{:.1}M", n as f64 / 1_000_000.0)
|
||||
|
|
@ -2109,6 +2255,159 @@ mod actionbar_notice_tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rewind_refresh_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rewind_applied_replaces_old_live_tail_and_restores_input() {
|
||||
let mut app = app_with_rewind_picker();
|
||||
app.greeting = Some(greeting());
|
||||
app.blocks.push(Block::Greeting(greeting()));
|
||||
app.blocks.push(Block::AssistantText {
|
||||
text: "old post-target output".into(),
|
||||
});
|
||||
|
||||
app.handle_pod_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
input: vec![Segment::text("selected rewind input")],
|
||||
summary: summary(3),
|
||||
});
|
||||
|
||||
assert!(app.rewind_picker.is_none());
|
||||
assert!(!blocks_contain(&app, "old post-target output"));
|
||||
assert_eq!(composer_text(&app), "selected rewind input");
|
||||
assert!(blocks_contain(&app, "Rewound session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewind_applied_clears_old_tail_even_when_greeting_is_missing() {
|
||||
let mut app = app_with_rewind_picker();
|
||||
app.blocks.push(Block::AssistantText {
|
||||
text: "old live tail without greeting".into(),
|
||||
});
|
||||
|
||||
app.handle_pod_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
input: vec![Segment::text("rewound input")],
|
||||
summary: summary(1),
|
||||
});
|
||||
|
||||
assert!(!blocks_contain(&app, "old live tail without greeting"));
|
||||
assert!(blocks_contain(&app, "greeting metadata was unavailable"));
|
||||
assert_eq!(composer_text(&app), "rewound input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_rewind_submit_suppresses_duplicate_enter_and_failure_preserves_display() {
|
||||
let mut app = app_with_rewind_picker();
|
||||
app.blocks.push(Block::AssistantText {
|
||||
text: "still-visible old display on failure".into(),
|
||||
});
|
||||
|
||||
let first = app.submit_rewind_picker();
|
||||
assert!(matches!(first, Some(Method::RewindTo { .. })));
|
||||
assert!(app.rewind_picker.as_ref().unwrap().applying);
|
||||
assert!(app.submit_rewind_picker().is_none());
|
||||
|
||||
app.handle_pod_event(Event::Error {
|
||||
code: ErrorCode::InvalidRequest,
|
||||
message: "stale rewind target".into(),
|
||||
});
|
||||
|
||||
assert!(!app.rewind_picker.as_ref().unwrap().applying);
|
||||
assert!(blocks_contain(&app, "still-visible old display on failure"));
|
||||
let notice = app.current_actionbar_notice(Instant::now()).unwrap();
|
||||
assert!(notice.text.contains("Rewind failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_live_update_after_success_does_not_repollute_restored_display() {
|
||||
let mut app = app_with_rewind_picker();
|
||||
app.greeting = Some(greeting());
|
||||
app.blocks.push(Block::Greeting(greeting()));
|
||||
app.blocks.push(Block::AssistantText {
|
||||
text: "old tail before rewind".into(),
|
||||
});
|
||||
|
||||
app.handle_pod_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
input: vec![Segment::text("rewound input")],
|
||||
summary: summary(2),
|
||||
});
|
||||
app.handle_pod_event(Event::TextDelta {
|
||||
text: "stale tail after rewind".into(),
|
||||
});
|
||||
assert!(!blocks_contain(&app, "stale tail after rewind"));
|
||||
|
||||
app.handle_pod_event(Event::Status {
|
||||
status: PodStatus::Idle,
|
||||
});
|
||||
app.handle_pod_event(Event::TextDelta {
|
||||
text: "new live tail after status".into(),
|
||||
});
|
||||
assert!(blocks_contain(&app, "new live tail after status"));
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn greeting() -> protocol::Greeting {
|
||||
protocol::Greeting {
|
||||
pod_name: "test".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "mock".into(),
|
||||
model: "mock".into(),
|
||||
scope_summary: "scope".into(),
|
||||
tools: vec![],
|
||||
context_window: 100,
|
||||
context_tokens: 10,
|
||||
}
|
||||
}
|
||||
|
||||
fn summary(discarded_entries: usize) -> protocol::RewindSummary {
|
||||
protocol::RewindSummary {
|
||||
truncated_to_entries: 1,
|
||||
discarded_entries,
|
||||
tool_side_effect_warning: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn blocks_contain(app: &App, needle: &str) -> bool {
|
||||
app.blocks.iter().any(|block| match block {
|
||||
Block::AssistantText { text }
|
||||
| Block::SystemMessage { text }
|
||||
| Block::Alert { message: text, .. } => text.contains(needle),
|
||||
Block::UserMessage { segments } => Segment::flatten_to_text(segments).contains(needle),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn composer_text(app: &App) -> String {
|
||||
Segment::flatten_to_text(&app.input.submit_segments())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod composer_history_persistence_tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -726,7 +726,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||
if app.rewind_picker.is_some() {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.close_rewind_picker();
|
||||
app.cancel_rewind_picker();
|
||||
return None;
|
||||
}
|
||||
KeyCode::Enter => return app.submit_rewind_picker(),
|
||||
|
|
|
|||
|
|
@ -433,7 +433,25 @@ fn draw_rewind_picker(
|
|||
picker: &mut crate::app::RewindPickerState,
|
||||
) {
|
||||
let mut logical: Vec<Line<'static>> = Vec::new();
|
||||
logical.push(Line::from(vec![
|
||||
let action_spans = if picker.applying {
|
||||
vec![
|
||||
Span::styled(
|
||||
"Applying rewind…",
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" waiting for Pod response"),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Span::styled("Enter", Style::default().fg(Color::Green)),
|
||||
Span::raw(" apply "),
|
||||
Span::styled("Esc", Style::default().fg(Color::Green)),
|
||||
Span::raw(" cancel"),
|
||||
]
|
||||
};
|
||||
let mut header = vec![
|
||||
Span::styled(
|
||||
"Rewind targets",
|
||||
Style::default()
|
||||
|
|
@ -441,11 +459,9 @@ fn draw_rewind_picker(
|
|||
.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"),
|
||||
]));
|
||||
];
|
||||
header.extend(action_spans);
|
||||
logical.push(Line::from(header));
|
||||
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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user