feat(tui/undo): add timeline overlay previews

This commit is contained in:
James Peter
2025-10-02 13:27:21 +10:00
parent b5ccaf139a
commit e1916f7d7c
7 changed files with 957 additions and 427 deletions
+2 -7
View File
@@ -1651,18 +1651,13 @@ impl App<'_> {
);
}
}
AppEvent::ShowUndoOptions { index } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_undo_restore_options(index);
}
}
AppEvent::PerformUndoRestore {
index,
commit,
restore_files,
restore_conversation,
} => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.perform_undo_restore(index, restore_files, restore_conversation);
widget.perform_undo_restore(commit.as_deref(), restore_files, restore_conversation);
}
}
AppEvent::DispatchCommand(command, command_text) => {
+1 -3
View File
@@ -174,11 +174,9 @@ pub(crate) enum AppEvent {
/// layer so it can be handled centrally. Includes the full command text.
DispatchCommand(SlashCommand, String),
/// Open undo options for a previously captured snapshot.
ShowUndoOptions { index: usize },
/// Restore workspace state according to the chosen undo scope.
PerformUndoRestore {
index: usize,
commit: Option<String>,
restore_files: bool,
restore_conversation: bool,
},
+3 -3
View File
@@ -48,7 +48,7 @@ mod theme_selection_view;
mod verbosity_selection_view;
pub(crate) mod validation_settings_view;
mod update_settings_view;
mod undo_restore_view;
mod undo_timeline_view;
mod notifications_settings_view;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -84,7 +84,7 @@ use codex_core::config_types::ThemeName;
use model_selection_view::ModelSelectionView;
use theme_selection_view::ThemeSelectionView;
use verbosity_selection_view::VerbositySelectionView;
pub(crate) use undo_restore_view::UndoRestoreView;
pub(crate) use undo_timeline_view::{UndoTimelineEntry, UndoTimelineEntryKind, UndoTimelineView};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ActiveViewKind {
@@ -667,7 +667,7 @@ impl BottomPane<'_> {
self.request_redraw();
}
pub fn show_undo_restore_view(&mut self, view: UndoRestoreView) {
pub fn show_undo_timeline_view(&mut self, view: UndoTimelineView) {
self.active_view = Some(Box::new(view));
self.active_view_kind = ActiveViewKind::Other;
self.status_view_active = false;
@@ -1,296 +0,0 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::bottom_pane_view::BottomPaneView;
use super::BottomPane;
pub(crate) struct UndoRestoreView {
snapshot_index: usize,
short_id: String,
title_line: String,
summary: Option<String>,
timestamp_line: String,
user_delta: usize,
assistant_delta: usize,
restore_files: bool,
restore_conversation: bool,
conversation_available: bool,
selection: usize,
is_complete: bool,
app_event_tx: AppEventSender,
}
impl UndoRestoreView {
#[allow(clippy::too_many_arguments)]
pub fn new(
snapshot_index: usize,
short_id: String,
title_line: String,
summary: Option<String>,
timestamp_line: String,
user_delta: usize,
assistant_delta: usize,
restore_conversation_default: bool,
conversation_available: bool,
app_event_tx: AppEventSender,
) -> Self {
Self {
snapshot_index,
short_id,
title_line,
summary,
timestamp_line,
user_delta,
assistant_delta,
restore_files: true,
restore_conversation: restore_conversation_default && conversation_available,
conversation_available,
selection: 0,
is_complete: false,
app_event_tx,
}
}
fn toggle_files(&mut self) {
self.restore_files = !self.restore_files;
}
fn toggle_conversation(&mut self) {
if self.conversation_available {
self.restore_conversation = !self.restore_conversation;
}
}
fn send_restore_request(&mut self) {
self.app_event_tx.send(AppEvent::PerformUndoRestore {
index: self.snapshot_index,
restore_files: self.restore_files,
restore_conversation: self.restore_conversation,
});
self.is_complete = true;
}
fn checkbox(label: &str, enabled: bool, disabled: bool) -> Line<'static> {
let box_text = if enabled { "[x]" } else { "[ ]" };
let mut spans = Vec::new();
let mut style = Style::default().fg(crate::colors::text());
if disabled {
style = Style::default().fg(crate::colors::text_dim());
}
spans.push(Span::styled(box_text, style.add_modifier(Modifier::BOLD)));
spans.push(Span::raw(" "));
spans.push(Span::styled(label.to_string(), style));
Line::from(spans)
}
fn footer_line(&self) -> Line<'static> {
Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::light_blue())),
Span::raw(" Navigate "),
Span::styled("Space", Style::default().fg(crate::colors::success())),
Span::raw(" Toggle "),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::raw(" Confirm "),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::raw(" Cancel"),
])
}
fn highlight_if_selected(line: Line<'static>, selected: bool) -> Line<'static> {
if !selected {
return line;
}
let mut line = line;
let patch = Style::default().bg(crate::colors::selection());
for span in &mut line.spans {
span.style = span.style.patch(patch);
}
line
}
fn conversation_detail_line(&self) -> Option<Line<'static>> {
if !self.conversation_available {
return None;
}
let mut parts = Vec::new();
if self.user_delta > 0 {
parts.push(if self.user_delta == 1 {
"1 user turn".to_string()
} else {
format!("{} user turns", self.user_delta)
});
}
if self.assistant_delta > 0 {
parts.push(if self.assistant_delta == 1 {
"1 assistant reply".to_string()
} else {
format!("{} assistant replies", self.assistant_delta)
});
}
if parts.is_empty() {
return None;
}
Some(Line::from(vec![Span::styled(
format!(" → {}", parts.join(", ")),
Style::default().fg(crate::colors::text_dim()),
)]))
}
}
impl<'a> BottomPaneView<'a> for UndoRestoreView {
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
match key_event {
KeyEvent { code: KeyCode::Up, .. } => {
if self.selection > 0 {
self.selection -= 1;
}
}
KeyEvent { code: KeyCode::Down, .. } => {
if self.selection < 3 {
self.selection += 1;
}
}
KeyEvent { code: KeyCode::Esc, .. } => {
self.is_complete = true;
}
KeyEvent { code: KeyCode::Char(' '), .. } => match self.selection {
0 => self.toggle_files(),
1 => self.toggle_conversation(),
_ => {}
},
KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => match self.selection {
0 => self.toggle_files(),
1 => self.toggle_conversation(),
2 => self.send_restore_request(),
3 => self.is_complete = true,
_ => {}
},
KeyEvent { code: KeyCode::Left | KeyCode::Right, .. } => match self.selection {
0 => self.toggle_files(),
1 => self.toggle_conversation(),
_ => {}
},
_ => {}
}
}
fn is_complete(&self) -> bool {
self.is_complete
}
fn desired_height(&self, _width: u16) -> u16 {
15
}
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
.title(" Restore snapshot ")
.title_alignment(Alignment::Center);
let inner = block.inner(area);
block.render(area, buf);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(vec![
Span::styled(
self.title_line.clone(),
Style::default().fg(crate::colors::text_dim()),
),
]));
lines.push(Line::from(vec![
Span::styled(
format!("Commit {}", self.short_id),
Style::default().fg(crate::colors::text()).add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(vec![
Span::styled(
self.timestamp_line.clone(),
Style::default().fg(crate::colors::text_dim()),
),
]));
if let Some(summary) = &self.summary {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
summary.clone(),
Style::default().fg(crate::colors::text()),
)]));
}
lines.push(Line::from(""));
let files_line = Self::highlight_if_selected(
Self::checkbox("Restore workspace files", self.restore_files, false),
self.selection == 0,
);
lines.push(files_line);
let conversation_disabled = !self.conversation_available;
let conversation_line = Self::checkbox(
if conversation_disabled {
"Restore conversation (no newer turns)"
} else {
"Restore conversation"
},
self.restore_conversation,
conversation_disabled,
);
let conversation_line = Self::highlight_if_selected(conversation_line, self.selection == 1);
lines.push(conversation_line);
if let Some(detail) = self.conversation_detail_line() {
lines.push(detail);
}
lines.push(Line::from(""));
let confirm_style = if self.selection == 2 {
Style::default()
.bg(crate::colors::selection())
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(vec![Span::styled(
"Confirm restore",
confirm_style,
)]));
let cancel_style = if self.selection == 3 {
Style::default()
.bg(crate::colors::selection())
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
lines.push(Line::from(vec![Span::styled(
"Cancel",
cancel_style,
)]));
lines.push(Line::from(""));
lines.push(self.footer_line());
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
paragraph.render(
Rect {
x: inner.x.saturating_add(1),
y: inner.y,
width: inner.width.saturating_sub(2),
height: inner.height,
},
buf,
);
}
}
@@ -0,0 +1,574 @@
use std::cmp::max;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::bottom_pane_view::{BottomPaneView, ConditionalUpdate};
use super::{BottomPane, CancellationEvent};
const MAX_VISIBLE_LIST_ROWS: usize = 12;
#[derive(Clone, Debug)]
pub(crate) enum UndoTimelineEntryKind {
Snapshot { commit: String },
Current,
}
#[derive(Clone, Debug)]
pub(crate) struct UndoTimelineEntry {
pub label: String,
pub summary: Option<String>,
pub timestamp_line: Option<String>,
pub relative_time: Option<String>,
pub stats_line: Option<String>,
pub commit_line: Option<String>,
pub conversation_lines: Vec<Line<'static>>,
pub file_lines: Vec<Line<'static>>,
pub conversation_available: bool,
pub files_available: bool,
pub kind: UndoTimelineEntryKind,
}
impl UndoTimelineEntry {
fn list_line_count(&self) -> usize {
let mut rows = 1;
if self.summary.is_some() {
rows += 1;
}
if self.timestamp_line.is_some() || self.relative_time.is_some() {
rows += 1;
}
if self.stats_line.is_some() {
rows += 1;
}
if self.commit_line.is_some() {
rows += 1;
}
rows + 1
}
}
pub(crate) struct UndoTimelineView {
entries: Vec<UndoTimelineEntry>,
selected: usize,
top_row: usize,
restore_files: bool,
restore_conversation: bool,
restore_files_forced_off: bool,
restore_conversation_forced_off: bool,
app_event_tx: AppEventSender,
is_complete: bool,
}
impl UndoTimelineView {
pub fn new(entries: Vec<UndoTimelineEntry>, initial_selected: usize, app_event_tx: AppEventSender) -> Self {
let selected = initial_selected.min(entries.len().saturating_sub(1));
let mut view = Self {
entries,
selected,
top_row: 0,
restore_files: true,
restore_conversation: true,
restore_files_forced_off: false,
restore_conversation_forced_off: false,
app_event_tx,
is_complete: false,
};
view.align_toggles_to_selection();
view.ensure_visible();
view
}
fn selected_entry(&self) -> Option<&UndoTimelineEntry> {
self.entries.get(self.selected)
}
fn move_up(&mut self) {
if self.entries.is_empty() {
return;
}
if self.selected == 0 {
self.selected = self.entries.len().saturating_sub(1);
} else {
self.selected -= 1;
}
self.align_toggles_to_selection();
self.ensure_visible();
}
fn move_down(&mut self) {
if self.entries.is_empty() {
return;
}
self.selected = (self.selected + 1) % self.entries.len();
self.align_toggles_to_selection();
self.ensure_visible();
}
fn page_up(&mut self) {
if self.entries.is_empty() {
return;
}
let mut remaining = MAX_VISIBLE_LIST_ROWS;
while remaining > 0 && self.selected > 0 {
self.selected -= 1;
remaining = remaining.saturating_sub(self.entries[self.selected].list_line_count());
}
self.align_toggles_to_selection();
self.ensure_visible();
}
fn page_down(&mut self) {
if self.entries.is_empty() {
return;
}
let mut remaining = MAX_VISIBLE_LIST_ROWS;
while remaining > 0 && self.selected + 1 < self.entries.len() {
self.selected += 1;
remaining = remaining.saturating_sub(self.entries[self.selected].list_line_count());
}
self.align_toggles_to_selection();
self.ensure_visible();
}
fn go_home(&mut self) {
if self.entries.is_empty() {
return;
}
self.selected = 0;
self.align_toggles_to_selection();
self.ensure_visible();
}
fn go_end(&mut self) {
if self.entries.is_empty() {
return;
}
self.selected = self.entries.len().saturating_sub(1);
self.align_toggles_to_selection();
self.ensure_visible();
}
fn align_toggles_to_selection(&mut self) {
let Some(entry) = self.entries.get(self.selected).cloned() else {
return;
};
if entry.files_available {
if self.restore_files_forced_off {
self.restore_files = true;
}
self.restore_files_forced_off = false;
} else {
self.restore_files = false;
self.restore_files_forced_off = true;
}
if entry.conversation_available {
if self.restore_conversation_forced_off {
self.restore_conversation = true;
}
self.restore_conversation_forced_off = false;
} else {
self.restore_conversation = false;
self.restore_conversation_forced_off = true;
}
}
fn ensure_visible(&mut self) {
if self.entries.is_empty() {
self.top_row = 0;
return;
}
let mut cumulative = 0usize;
for (idx, entry) in self.entries.iter().enumerate() {
if idx < self.selected {
cumulative = cumulative.saturating_add(entry.list_line_count());
}
}
let selected_height = self
.entries
.get(self.selected)
.map(|entry| entry.list_line_count())
.unwrap_or(1);
if cumulative < self.top_row {
self.top_row = cumulative;
} else {
let bottom = cumulative + selected_height;
let window_bottom = self.top_row + MAX_VISIBLE_LIST_ROWS;
if bottom > window_bottom {
self.top_row = bottom.saturating_sub(MAX_VISIBLE_LIST_ROWS);
}
}
}
fn toggle_files(&mut self) {
if let Some(entry) = self.selected_entry() {
if entry.files_available {
self.restore_files = !self.restore_files;
self.restore_files_forced_off = false;
}
}
}
fn toggle_conversation(&mut self) {
if let Some(entry) = self.selected_entry() {
if entry.conversation_available {
self.restore_conversation = !self.restore_conversation;
self.restore_conversation_forced_off = false;
}
}
}
fn confirm(&mut self) {
if let Some(entry) = self.selected_entry() {
match entry.kind {
UndoTimelineEntryKind::Snapshot { ref commit } => {
self.app_event_tx.send(AppEvent::PerformUndoRestore {
commit: Some(commit.clone()),
restore_files: self.restore_files && entry.files_available,
restore_conversation: self.restore_conversation && entry.conversation_available,
});
self.is_complete = true;
}
UndoTimelineEntryKind::Current => {
self.is_complete = true;
}
}
}
}
fn total_list_height(&self) -> usize {
self.entries.iter().map(|entry| entry.list_line_count()).sum()
}
fn visible_range(&self) -> (usize, usize) {
let total = self.total_list_height();
if total <= MAX_VISIBLE_LIST_ROWS {
return (0, self.entries.len());
}
let mut start_entry = 0usize;
let mut spent = 0usize;
while start_entry < self.entries.len() && spent + self.entries[start_entry].list_line_count() <= self.top_row {
spent = spent.saturating_add(self.entries[start_entry].list_line_count());
start_entry += 1;
}
if start_entry > self.selected {
start_entry = self.selected;
}
while start_entry < self.selected {
let span: usize = self.entries[start_entry..=self.selected]
.iter()
.map(|entry| entry.list_line_count())
.sum();
if span <= MAX_VISIBLE_LIST_ROWS {
break;
}
start_entry += 1;
}
let mut end_entry = start_entry;
let mut used = 0usize;
while end_entry < self.entries.len() {
let lines = self.entries[end_entry].list_line_count();
if used + lines > MAX_VISIBLE_LIST_ROWS && end_entry > self.selected {
break;
}
used = used.saturating_add(lines);
end_entry += 1;
if used >= MAX_VISIBLE_LIST_ROWS && end_entry > self.selected {
break;
}
}
if end_entry <= self.selected && end_entry < self.entries.len() {
while end_entry <= self.selected && end_entry < self.entries.len() {
used = used.saturating_add(self.entries[end_entry].list_line_count());
end_entry += 1;
}
}
(start_entry, end_entry)
}
fn render_list(&self, area: Rect, buf: &mut Buffer) {
let (start, end) = self.visible_range();
let mut lines: Vec<Line<'static>> = Vec::new();
for (idx, entry) in self.entries[start..end].iter().enumerate() {
let absolute_idx = start + idx;
let selected = absolute_idx == self.selected;
let marker = if selected { "›" } else { " " };
let mut title_spans = vec![
Span::styled(format!("{marker} "), Style::default().fg(crate::colors::primary())),
Span::styled(
entry.label.clone(),
if selected {
Style::default()
.fg(crate::colors::text())
.add_modifier(Modifier::BOLD)
.bg(crate::colors::selection())
} else {
Style::default().fg(crate::colors::text())
},
),
];
if let Some(commit) = &entry.commit_line {
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
commit.clone(),
if selected {
Style::default()
.fg(crate::colors::text_dim())
.bg(crate::colors::selection())
} else {
Style::default().fg(crate::colors::text_dim())
},
));
}
lines.push(Line::from(title_spans));
if let Some(summary) = &entry.summary {
let style = if selected {
Style::default()
.fg(crate::colors::text_dim())
.bg(crate::colors::selection())
} else {
Style::default().fg(crate::colors::text_dim())
};
lines.push(Line::from(Span::styled(format!(" {summary}"), style)));
}
if entry.timestamp_line.is_some() || entry.relative_time.is_some() {
let mut parts: Vec<String> = Vec::new();
if let Some(ts) = &entry.timestamp_line {
parts.push(ts.clone());
}
if let Some(rel) = &entry.relative_time {
parts.push(rel.clone());
}
let style = if selected {
Style::default()
.fg(crate::colors::text_dim())
.bg(crate::colors::selection())
} else {
Style::default().fg(crate::colors::text_dim())
};
lines.push(Line::from(Span::styled(format!(" {}", parts.join(" • ")), style)));
}
if let Some(stats) = &entry.stats_line {
let style = if selected {
Style::default()
.fg(crate::colors::text_dim())
.bg(crate::colors::selection())
} else {
Style::default().fg(crate::colors::text_dim())
};
lines.push(Line::from(Span::styled(format!(" {stats}"), style)));
}
if selected {
lines.push(Line::from(Span::styled(
String::new(),
Style::default().bg(crate::colors::selection()),
)));
} else {
lines.push(Line::from(String::new()));
}
}
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
.wrap(ratatui::widgets::Wrap { trim: true });
paragraph.render(area, buf);
}
fn render_preview(&self, area: Rect, buf: &mut Buffer) {
let Some(entry) = self.selected_entry() else {
return;
};
let [conversation_area, files_area, footer_area] = Layout::vertical([
Constraint::Percentage(55),
Constraint::Percentage(35),
Constraint::Length(3),
])
.areas(area);
let conversation_block = Block::default()
.borders(Borders::ALL)
.title(" Conversation preview ")
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
let conversation_inner = conversation_block.inner(conversation_area);
conversation_block.render(conversation_area, buf);
let conversation = Paragraph::new(entry.conversation_lines.clone())
.wrap(ratatui::widgets::Wrap { trim: true })
.style(Style::default().bg(crate::colors::background()))
.alignment(Alignment::Left);
conversation.render(conversation_inner, buf);
let files_block = Block::default()
.borders(Borders::ALL)
.title(" File changes ")
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
let files_inner = files_block.inner(files_area);
files_block.render(files_area, buf);
let file_lines = if entry.file_lines.is_empty() {
vec![Line::from(Span::styled(
"No file changes captured for this snapshot.",
Style::default().fg(crate::colors::text_dim()),
))]
} else {
entry.file_lines.clone()
};
let file_summary = Paragraph::new(file_lines)
.wrap(ratatui::widgets::Wrap { trim: true })
.style(Style::default().bg(crate::colors::background()))
.alignment(Alignment::Left);
file_summary.render(files_inner, buf);
let footer_lines = self.footer_lines(entry);
Paragraph::new(footer_lines)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
.wrap(ratatui::widgets::Wrap { trim: true })
.render(footer_area, buf);
}
fn footer_lines(&self, entry: &UndoTimelineEntry) -> Vec<Line<'static>> {
let files_status = if entry.files_available {
if self.restore_files {
Span::styled("[x] Files", Style::default().fg(crate::colors::success()))
} else {
Span::styled("[ ] Files", Style::default().fg(crate::colors::text_dim()))
}
} else {
Span::styled("[ ] Files", Style::default().fg(crate::colors::text_dim()))
};
let convo_status = if entry.conversation_available {
if self.restore_conversation {
Span::styled("[x] Conversation", Style::default().fg(crate::colors::success()))
} else {
Span::styled("[ ] Conversation", Style::default().fg(crate::colors::text_dim()))
}
} else {
Span::styled("[ ] Conversation", Style::default().fg(crate::colors::text_dim()))
};
vec![
Line::from(vec![files_status, Span::raw(" "), convo_status]),
Line::from(vec![
Span::styled("↑↓ PgUp PgDn", Style::default().fg(crate::colors::light_blue())),
Span::raw(" Navigate "),
Span::styled("Space", Style::default().fg(crate::colors::success())),
Span::raw(" Toggle files "),
Span::styled("C", Style::default().fg(crate::colors::success())),
Span::raw(" Toggle conversation "),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::raw(" Restore "),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::raw(" Close"),
]),
]
}
}
impl<'a> BottomPaneView<'a> for UndoTimelineView {
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up => self.move_up(),
KeyCode::Down => self.move_down(),
KeyCode::PageUp => self.page_up(),
KeyCode::PageDown => self.page_down(),
KeyCode::Home => self.go_home(),
KeyCode::End => self.go_end(),
KeyCode::Enter => self.confirm(),
KeyCode::Esc => self.is_complete = true,
KeyCode::Char(' ') => self.toggle_files(),
KeyCode::Char('c') | KeyCode::Char('C') => self.toggle_conversation(),
KeyCode::Char('f') | KeyCode::Char('F') => self.toggle_files(),
KeyCode::Tab => {
if let Some(entry) = self.selected_entry() {
if entry.conversation_available && !entry.files_available {
self.toggle_conversation();
} else if entry.files_available && !entry.conversation_available {
self.toggle_files();
} else {
if self.restore_files {
self.toggle_conversation();
} else {
self.toggle_files();
}
}
}
}
KeyCode::Right if key_event.modifiers.contains(KeyModifiers::CONTROL) => self.toggle_conversation(),
KeyCode::Left if key_event.modifiers.contains(KeyModifiers::CONTROL) => self.toggle_files(),
_ => {}
}
}
fn is_complete(&self) -> bool {
self.is_complete
}
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent {
self.is_complete = true;
CancellationEvent::Handled
}
fn update_status_text(&mut self, _text: String) -> ConditionalUpdate {
ConditionalUpdate::NeedsRedraw
}
fn desired_height(&self, _width: u16) -> u16 {
max(MAX_VISIBLE_LIST_ROWS as u16 + 6, 24)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
Clear.render(area, buf);
let block = Block::default()
.borders(Borders::ALL)
.title(" Restore workspace snapshot ")
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
.title_alignment(Alignment::Center);
let inner = block.inner(area);
block.render(area, buf);
let [list_area, preview_area] = Layout::horizontal([
Constraint::Percentage(38),
Constraint::Fill(1),
])
.areas(inner);
let list_block = Block::default()
.borders(Borders::ALL)
.title(" Snapshots ")
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
let list_inner = list_block.inner(list_area);
list_block.render(list_area, buf);
self.render_list(list_inner, buf);
self.render_preview(preview_area, buf);
}
}
+370 -110
View File
@@ -13,6 +13,7 @@ use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::sync::OnceLock;
use std::time::{Duration, Instant, SystemTime};
use std::process::Command;
use ratatui::style::Modifier;
use ratatui::style::Style;
@@ -219,13 +220,13 @@ use crate::app_event::{
};
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::CustomPromptView;
use crate::bottom_pane::list_selection_view::{ListSelectionView, SelectionAction, SelectionItem};
use crate::bottom_pane::list_selection_view::{ListSelectionView, SelectionItem};
use crate::bottom_pane::CloudTasksView;
use crate::bottom_pane::validation_settings_view;
use crate::bottom_pane::validation_settings_view::{GroupStatus, ToolRow};
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::UndoRestoreView;
use crate::bottom_pane::{UndoTimelineEntry, UndoTimelineEntryKind, UndoTimelineView};
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::LoginAccountsState;
@@ -313,6 +314,7 @@ use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use ratatui::style::Stylize;
use ratatui::symbols::scrollbar as scrollbar_symbols;
use ratatui::text::Span;
use ratatui::text::Text as RtText;
use textwrap::wrap;
use unicode_segmentation::UnicodeSegmentation;
@@ -521,16 +523,6 @@ impl AutoCoordinatorUiState {
}
}
struct UndoSnapshotPreview {
index: usize,
short_id: String,
summary: Option<String>,
captured_at: DateTime<Local>,
age: Option<std::time::Duration>,
user_delta: usize,
assistant_delta: usize,
}
pub(crate) struct ChatWidget<'a> {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>,
@@ -752,6 +744,12 @@ struct GhostSnapshot {
history: HistorySnapshot,
}
#[derive(Clone, Copy)]
enum UndoPreviewRole {
User,
Assistant,
}
impl GhostSnapshot {
fn new(
commit: GhostCommit,
@@ -1252,6 +1250,10 @@ enum SystemPlacement {
}
impl ChatWidget<'_> {
const MAX_UNDO_CONVERSATION_MESSAGES: usize = 8;
const MAX_UNDO_PREVIEW_CHARS: usize = 160;
const MAX_UNDO_FILE_LINES: usize = 24;
fn fmt_short_duration(&self, d: Duration) -> String {
let s = d.as_secs();
let h = s / 3600;
@@ -7027,21 +7029,6 @@ impl ChatWidget<'_> {
self.spawn_next_ghost_snapshot();
}
fn snapshot_preview(&self, index: usize) -> Option<UndoSnapshotPreview> {
self.ghost_snapshots.get(index).map(|snapshot| {
let (user_delta, assistant_delta) = self.conversation_delta_since(&snapshot.conversation);
UndoSnapshotPreview {
index,
short_id: snapshot.short_id(),
summary: snapshot.summary.clone(),
captured_at: snapshot.captured_at,
age: snapshot.age_from(Local::now()),
user_delta,
assistant_delta,
}
})
}
pub(crate) fn handle_undo_command(&mut self) {
if self.ghost_snapshots_disabled {
let reason = self
@@ -7158,45 +7145,8 @@ impl ChatWidget<'_> {
}
fn show_undo_snapshot_picker(&mut self) {
let now = Local::now();
let mut entries: Vec<(usize, &GhostSnapshot)> = self
.ghost_snapshots
.iter()
.enumerate()
.collect();
entries.reverse();
let mut items: Vec<SelectionItem> = Vec::new();
for (display_idx, (actual_idx, snapshot)) in entries.into_iter().enumerate() {
let idx = actual_idx;
let short_id = snapshot.short_id();
let name = snapshot
.summary_snippet(80)
.unwrap_or_else(|| format!("Snapshot {short_id}"));
let mut details: Vec<String> = Vec::new();
if let Some(age) = snapshot.age_from(now) {
details.push(format!("captured {} ago", format_duration(age)));
} else {
details.push("captured moments ago".to_string());
}
details.push(snapshot.captured_at.format("%Y-%m-%d %H:%M:%S").to_string());
details.push(format!("commit {short_id}"));
let description = Some(details.join(" • "));
let actions: Vec<SelectionAction> = vec![Box::new(move |tx: &AppEventSender| {
tx.send(AppEvent::ShowUndoOptions { index: idx });
})];
items.push(SelectionItem {
name,
description,
is_current: display_idx == 0,
actions,
});
}
if items.is_empty() {
let entries = self.build_undo_timeline_entries();
if entries.len() <= 1 {
self.push_background_tail(
"/undo unavailable: no snapshots captured yet. Run a file-modifying command to create one.".to_string(),
);
@@ -7204,55 +7154,353 @@ impl ChatWidget<'_> {
return;
}
let mut subtitle_lines: Vec<String> = Vec::new();
subtitle_lines.push("Restores workspace files only; chat history stays unchanged.".to_string());
subtitle_lines.push("Select a snapshot to jump back in time.".to_string());
let view = ListSelectionView::new(
" Restore a workspace snapshot ".to_string(),
Some(subtitle_lines.join("\n")),
Some("Enter restore • Esc cancel".to_string()),
items,
self.app_event_tx.clone(),
8,
);
self.bottom_pane.show_list_selection(
"Restore snapshot".to_string(),
Some(
"Restores workspace files only; chat history stays unchanged.".to_string(),
),
Some("Enter restore • Esc cancel".to_string()),
view,
);
let current_index = entries.len().saturating_sub(1);
let view = UndoTimelineView::new(entries, current_index, self.app_event_tx.clone());
self.bottom_pane.show_undo_timeline_view(view);
}
#[cfg(test)]
pub(crate) fn show_undo_restore_options(&mut self, index: usize) {
let Some(preview) = self.snapshot_preview(index) else {
if index >= self.ghost_snapshots.len() {
self.push_background_tail("Selected snapshot is no longer available.".to_string());
return;
}
let entries = self.build_undo_timeline_entries();
let view = UndoTimelineView::new(entries, index, self.app_event_tx.clone());
self.bottom_pane.show_undo_timeline_view(view);
}
fn build_undo_timeline_entries(&self) -> Vec<UndoTimelineEntry> {
let mut entries: Vec<UndoTimelineEntry> = Vec::with_capacity(self.ghost_snapshots.len().saturating_add(1));
for snapshot in self.ghost_snapshots.iter() {
entries.push(self.timeline_entry_for_snapshot(snapshot));
}
entries.push(self.timeline_entry_for_current());
entries
}
fn timeline_entry_for_snapshot(&self, snapshot: &GhostSnapshot) -> UndoTimelineEntry {
let short_id = snapshot.short_id();
let label = format!("Snapshot {short_id}");
let summary = snapshot.summary.clone();
let timestamp_line = Some(snapshot.captured_at.format("%Y-%m-%d %H:%M:%S").to_string());
let relative_time = snapshot
.age_from(Local::now())
.map(|age| format!("captured {} ago", format_duration(age)));
let (user_delta, assistant_delta) = self.conversation_delta_since(&snapshot.conversation);
let stats_line = if user_delta == 0 && assistant_delta == 0 {
Some("conversation already matches current state".to_string())
} else if assistant_delta == 0 {
Some(format!(
"rewind {} user turn{}",
user_delta,
if user_delta == 1 { "" } else { "s" }
))
} else {
Some(format!(
"rewind {} user turn{} and {} assistant repl{}",
user_delta,
if user_delta == 1 { "" } else { "s" },
assistant_delta,
if assistant_delta == 1 { "y" } else { "ies" }
))
};
let timestamp = preview.captured_at.format("%Y-%m-%d %H:%M:%S").to_string();
let timestamp_line = preview
.age
.map(|age| format!("Captured {} ({})", timestamp, format_duration(age)))
.unwrap_or_else(|| format!("Captured {}", timestamp));
let title_line = "Select what to restore".to_string();
let conversation_available = preview.user_delta > 0;
let conversation_lines = Self::conversation_preview_lines_from_snapshot(&snapshot.history);
let file_lines = self.timeline_file_lines_for_commit(snapshot.commit().id());
let view = UndoRestoreView::new(
preview.index,
preview.short_id.clone(),
title_line,
preview.summary.clone(),
UndoTimelineEntry {
label,
summary,
timestamp_line,
preview.user_delta,
preview.assistant_delta,
false,
conversation_available,
self.app_event_tx.clone(),
relative_time,
stats_line,
commit_line: Some(format!("commit {short_id}")),
conversation_lines,
file_lines,
conversation_available: user_delta > 0,
files_available: true,
kind: UndoTimelineEntryKind::Snapshot {
commit: snapshot.commit().id().to_string(),
},
}
}
fn timeline_entry_for_current(&self) -> UndoTimelineEntry {
let history_snapshot = self.history_snapshot_for_persistence();
let conversation_lines = Self::conversation_preview_lines_from_snapshot(&history_snapshot);
let file_lines = self.timeline_file_lines_for_current();
UndoTimelineEntry {
label: "Current workspace".to_string(),
summary: None,
timestamp_line: Some(Local::now().format("%Y-%m-%d %H:%M:%S").to_string()),
relative_time: Some("current point".to_string()),
stats_line: Some("Already at this point in time".to_string()),
commit_line: None,
conversation_lines,
file_lines,
conversation_available: false,
files_available: false,
kind: UndoTimelineEntryKind::Current,
}
}
fn conversation_preview_lines_from_snapshot(snapshot: &HistorySnapshot) -> Vec<Line<'static>> {
let mut state = HistoryState::new();
state.restore(snapshot);
let mut messages: Vec<(UndoPreviewRole, String)> = Vec::new();
for record in &state.records {
match record {
HistoryRecord::PlainMessage(msg) => match msg.kind {
PlainMessageKind::User => {
let text = Self::message_lines_to_plain_preview(&msg.lines);
if !text.is_empty() {
messages.push((UndoPreviewRole::User, text));
}
}
PlainMessageKind::Assistant => {
let text = Self::message_lines_to_plain_preview(&msg.lines);
if !text.is_empty() {
messages.push((UndoPreviewRole::Assistant, text));
}
}
_ => {}
},
HistoryRecord::AssistantMessage(msg) => {
let text = Self::markdown_to_plain_preview(&msg.markdown);
if !text.is_empty() {
messages.push((UndoPreviewRole::Assistant, text));
}
}
_ => {}
}
}
if messages.is_empty() {
return vec![Line::from(Span::styled(
"No conversation captured in this snapshot.",
Style::default().fg(crate::colors::text_dim()),
))];
}
let len = messages.len();
let start = len.saturating_sub(Self::MAX_UNDO_CONVERSATION_MESSAGES);
messages[start..]
.iter()
.map(|(role, text)| Self::conversation_line(*role, text.as_str()))
.collect()
}
fn conversation_line(role: UndoPreviewRole, text: &str) -> Line<'static> {
let (label, color) = match role {
UndoPreviewRole::User => ("You", crate::colors::text_bright()),
UndoPreviewRole::Assistant => ("Code", crate::colors::primary()),
};
let label_span = Span::styled(
format!("{label}: "),
Style::default().fg(color).add_modifier(Modifier::BOLD),
);
self.bottom_pane.show_undo_restore_view(view);
let content_span = Span::styled(text.to_string(), Style::default().fg(crate::colors::text()));
Line::from(vec![label_span, content_span])
}
fn message_lines_to_plain_preview(lines: &[MessageLine]) -> String {
let mut segments: Vec<String> = Vec::new();
for line in lines {
match line.kind {
MessageLineKind::Blank => continue,
MessageLineKind::Metadata => continue,
_ => {
let mut text = String::new();
for span in &line.spans {
text.push_str(&span.text);
}
let trimmed = text.trim();
if !trimmed.is_empty() {
segments.push(trimmed.to_string());
}
}
}
if segments.len() >= Self::MAX_UNDO_CONVERSATION_MESSAGES {
break;
}
}
let joined = segments.join(" ");
Self::truncate_preview_text(joined, Self::MAX_UNDO_PREVIEW_CHARS)
}
fn markdown_to_plain_preview(markdown: &str) -> String {
let mut segments: Vec<String> = Vec::new();
for line in markdown.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('#') {
segments.push(trimmed.trim_start_matches('#').trim().to_string());
} else {
segments.push(trimmed.to_string());
}
if segments.len() >= Self::MAX_UNDO_CONVERSATION_MESSAGES {
break;
}
}
if segments.is_empty() {
return String::new();
}
let joined = segments.join(" ");
Self::truncate_preview_text(joined, Self::MAX_UNDO_PREVIEW_CHARS)
}
fn truncate_preview_text(text: String, limit: usize) -> String {
if text.chars().count() <= limit {
return text;
}
let truncated: String = text.chars().take(limit.saturating_sub(1)).collect();
format!("{truncated}…")
}
fn timeline_file_lines_for_commit(&self, commit_id: &str) -> Vec<Line<'static>> {
match self.git_numstat(["show", "--numstat", "--format=", commit_id]) {
Ok(entries) => Self::file_change_lines(entries),
Err(err) => vec![Line::from(Span::styled(
err,
Style::default().fg(crate::colors::error()),
))],
}
}
fn timeline_file_lines_for_current(&self) -> Vec<Line<'static>> {
match self.git_numstat(["diff", "--numstat", "HEAD"]) {
Ok(entries) => {
if entries.is_empty() {
vec![Line::from(Span::styled(
"Working tree clean",
Style::default().fg(crate::colors::text_dim()),
))]
} else {
Self::file_change_lines(entries)
}
}
Err(err) => vec![Line::from(Span::styled(
err,
Style::default().fg(crate::colors::error()),
))],
}
}
fn git_numstat<I, S>(
&self,
args: I,
) -> Result<Vec<(Option<u32>, Option<u32>, String)>, String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.run_git_command(args, |stdout| {
let mut out = Vec::new();
for line in stdout.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let mut parts = trimmed.splitn(3, '\t');
let added = parts.next();
let removed = parts.next();
let path = parts.next();
if let (Some(added), Some(removed), Some(path)) = (added, removed, path) {
out.push((
Self::parse_numstat_count(added),
Self::parse_numstat_count(removed),
path.to_string(),
));
}
}
Ok(out)
})
}
fn run_git_command<I, S, F, T>(&self, args: I, parser: F) -> Result<T, String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
F: FnOnce(String) -> Result<T, String>,
{
let args_vec: Vec<String> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
let output = Command::new("git")
.current_dir(&self.config.cwd)
.args(&args_vec)
.output()
.map_err(|err| format!("git {} failed: {err}", args_vec.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = stderr.trim();
if msg.is_empty() {
Err(format!(
"git {} exited with status {}",
args_vec.join(" "),
output.status
))
} else {
Err(msg.to_string())
}
} else {
parser(String::from_utf8_lossy(&output.stdout).to_string())
}
}
fn parse_numstat_count(raw: &str) -> Option<u32> {
if raw == "-" {
None
} else {
raw.parse::<u32>().ok()
}
}
fn file_change_lines(entries: Vec<(Option<u32>, Option<u32>, String)>) -> Vec<Line<'static>> {
if entries.is_empty() {
return vec![Line::from(Span::styled(
"No file changes recorded for this snapshot.",
Style::default().fg(crate::colors::text_dim()),
))];
}
let max_entries = (Self::MAX_UNDO_FILE_LINES / 2).max(1);
let mut lines: Vec<Line<'static>> = Vec::new();
for (idx, (added, removed, path)) in entries.iter().enumerate() {
if idx >= max_entries {
break;
}
lines.push(Line::from(Span::styled(
path.clone(),
Style::default().fg(crate::colors::text()),
)));
let added_text = added.map_or("-".to_string(), |v| v.to_string());
let removed_text = removed.map_or("-".to_string(), |v| v.to_string());
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("+{added_text}"),
Style::default().fg(crate::colors::success()),
),
Span::raw(" "),
Span::styled(
format!("-{removed_text}"),
Style::default().fg(crate::colors::error()),
),
]));
}
if entries.len() > max_entries {
let remaining = entries.len() - max_entries;
lines.push(Line::from(Span::styled(
format!("… and {remaining} more file{}", if remaining == 1 { "" } else { "s" }),
Style::default().fg(crate::colors::text_dim()),
)));
}
lines
}
pub(crate) fn restore_history_snapshot(&mut self, snapshot: &HistorySnapshot) {
@@ -7364,21 +7612,33 @@ impl ChatWidget<'_> {
pub(crate) fn perform_undo_restore(
&mut self,
index: usize,
commit: Option<&str>,
restore_files: bool,
restore_conversation: bool,
) {
if index >= self.ghost_snapshots.len() {
self.push_background_tail("Selected snapshot is no longer available.".to_string());
let Some(commit_id) = commit else {
self.push_background_tail("No snapshot selected.".to_string());
return;
}
};
let Some((index, snapshot)) = self
.ghost_snapshots
.iter()
.enumerate()
.find(|(_, snap)| snap.commit().id() == commit_id)
.map(|(idx, snap)| (idx, snap.clone()))
else {
self.push_background_tail(
"Selected snapshot is no longer available.".to_string(),
);
return;
};
if !restore_files && !restore_conversation {
self.push_background_tail("No restore options selected.".to_string());
return;
}
let snapshot = self.ghost_snapshots[index].clone();
let mut files_restored = false;
let mut conversation_rewind_requested = false;
let mut errors: Vec<String> = Vec::new();
+7 -8
View File
@@ -1161,11 +1161,8 @@ fn pump_app_events(
AppEvent::DispatchCommand(SlashCommand::Update, command_text) => {
chat.handle_update_command(&command_text);
}
AppEvent::ShowUndoOptions { index } => {
chat.show_undo_restore_options(index);
}
AppEvent::PerformUndoRestore { index, restore_files, restore_conversation } => {
chat.perform_undo_restore(index, restore_files, restore_conversation);
AppEvent::PerformUndoRestore { commit, restore_files, restore_conversation } => {
chat.perform_undo_restore(commit.as_deref(), restore_files, restore_conversation);
}
AppEvent::ShowAgentsOverview => chat.show_agents_overview_ui(),
AppEvent::RequestRedraw | AppEvent::Redraw | AppEvent::ScheduleFrameIn(_) => {}
@@ -1411,12 +1408,14 @@ fn undo_options_view_shows_toggles() {
let plain = buffer_to_string(terminal.backend().buffer());
let lower = plain.to_ascii_lowercase();
assert!(lower.contains("restore workspace snapshot"), "expected overlay title\n{plain}");
assert!(lower.contains("conversation preview"), "expected conversation preview block\n{plain}");
assert!(
lower.contains("restore workspace files"),
"expected workspace toggle\n{plain}"
lower.contains("[x] files") || lower.contains("[ ] files"),
"expected files toggle\n{plain}"
);
assert!(
lower.contains("restore conversation"),
lower.contains("[x] conversation") || lower.contains("[ ] conversation"),
"expected conversation toggle\n{plain}"
);
}