go back in history

This commit is contained in:
Ahmed Ibrahim
2025-08-22 10:38:20 -07:00
parent 682ec7f0ef
commit d41ca91054
7 changed files with 529 additions and 15 deletions

View File

@@ -41,6 +41,8 @@ pub(crate) struct App {
// Transcript overlay state
transcript_overlay: Option<TranscriptApp>,
// If true, overlay is opened as an Esc-backtrack preview.
transcript_overlay_is_backtrack: bool,
deferred_history_lines: Vec<Line<'static>>,
transcript_saved_viewport: Option<Rect>,
@@ -48,6 +50,11 @@ pub(crate) struct App {
/// Controls the animation thread that sends CommitTick events.
commit_anim_running: Arc<AtomicBool>,
// Esc-backtracking state
esc_backtrack_primed: bool,
esc_backtrack_base: Option<uuid::Uuid>,
esc_backtrack_count: usize,
}
impl App {
@@ -86,9 +93,13 @@ impl App {
enhanced_keys_supported,
transcript_lines: Vec::new(),
transcript_overlay: None,
transcript_overlay_is_backtrack: false,
deferred_history_lines: Vec::new(),
transcript_saved_viewport: None,
commit_anim_running: Arc::new(AtomicBool::new(false)),
esc_backtrack_primed: false,
esc_backtrack_base: None,
esc_backtrack_count: 0,
};
let tui_events = tui.event_stream();
@@ -113,19 +124,58 @@ impl App {
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<bool> {
if let Some(overlay) = &mut self.transcript_overlay {
overlay.handle_event(tui, event)?;
if overlay.is_done {
// Exit alternate screen and restore viewport.
let _ = execute!(tui.terminal.backend_mut(), LeaveAlternateScreen);
if let Some(saved) = self.transcript_saved_viewport.take() {
tui.terminal.set_viewport_area(saved);
if self.transcript_overlay.is_some() {
// Intercept Esc/Enter when overlay is a backtrack preview.
let mut handled = false;
if self.transcript_overlay_is_backtrack {
match event {
TuiEvent::Key(KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. }) => {
if self.esc_backtrack_base.is_some() {
self.esc_backtrack_count = self.esc_backtrack_count.saturating_add(1);
let offset = self.compute_backtrack_overlay_offset(tui, self.esc_backtrack_count);
let hl = self.backtrack_highlight_range(self.esc_backtrack_count);
if let Some(overlay) = &mut self.transcript_overlay {
if let Some(off) = offset { overlay.scroll_offset = off; }
overlay.set_highlight_range(hl);
}
tui.frame_requester().schedule_frame();
handled = true;
}
}
TuiEvent::Key(KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, .. }) => {
// Confirm the backtrack: close overlay, fork, and prefill.
let base = self.esc_backtrack_base;
let count = self.esc_backtrack_count;
self.close_transcript_overlay(tui);
if let Some(base_id) = base {
if count > 0 {
if let Err(e) = self.fork_and_render_backtrack(tui, base_id, count).await {
tracing::error!("Backtrack confirm failed: {e:#}");
}
}
}
// Reset backtrack state after confirming.
self.esc_backtrack_primed = false;
self.esc_backtrack_base = None;
self.esc_backtrack_count = 0;
handled = true;
}
_ => {}
}
if !self.deferred_history_lines.is_empty() {
let lines = std::mem::take(&mut self.deferred_history_lines);
tui.insert_history_lines(lines);
}
// Forward to overlay if not handled
if !handled {
if let Some(overlay) = &mut self.transcript_overlay {
overlay.handle_event(tui, event)?;
if overlay.is_done {
self.close_transcript_overlay(tui);
if self.transcript_overlay_is_backtrack {
self.esc_backtrack_primed = false;
self.esc_backtrack_base = None;
self.esc_backtrack_count = 0;
}
}
}
self.transcript_overlay = None;
}
tui.frame_requester().schedule_frame();
} else {
@@ -249,16 +299,21 @@ impl App {
self.chat_widget.apply_file_search_result(query, matches);
}
AppEvent::UpdateReasoningEffort(effort) => {
// Keep App-level config in sync with TUI so forks/new sessions inherit overrides.
self.chat_widget.set_reasoning_effort(effort);
self.config.model_reasoning_effort = effort;
}
AppEvent::UpdateModel(model) => {
self.chat_widget.set_model(model);
self.chat_widget.set_model(model.clone());
self.config.model = model;
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
self.chat_widget.set_approval_policy(policy);
self.config.approval_policy = policy;
}
AppEvent::UpdateSandboxPolicy(policy) => {
self.chat_widget.set_sandbox_policy(policy);
self.chat_widget.set_sandbox_policy(policy.clone());
self.config.sandbox_policy = policy;
}
}
Ok(true)
@@ -304,6 +359,55 @@ impl App {
self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone()));
tui.frame_requester().schedule_frame();
}
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
// Only handle backtracking when composer is empty to avoid clobbering edits.
if self.chat_widget.composer_is_empty() {
if !self.esc_backtrack_primed {
// Arm backtracking and record base conversation.
self.esc_backtrack_primed = true;
self.esc_backtrack_count = 0;
self.esc_backtrack_base = self.chat_widget.session_id();
} else if self.transcript_overlay.is_none() {
// Open transcript overlay in backtrack preview mode and jump to the target message.
self.open_transcript_overlay(tui);
self.transcript_overlay_is_backtrack = true;
self.esc_backtrack_count = self.esc_backtrack_count.saturating_add(1);
let offset = self.compute_backtrack_overlay_offset(tui, self.esc_backtrack_count);
let hl = self.backtrack_highlight_range(self.esc_backtrack_count);
if let Some(overlay) = &mut self.transcript_overlay {
if let Some(off) = offset { overlay.scroll_offset = off; }
overlay.set_highlight_range(hl);
}
} else if self.transcript_overlay_is_backtrack {
// Already previewing: step to the next older message.
self.esc_backtrack_count = self.esc_backtrack_count.saturating_add(1);
let offset = self.compute_backtrack_overlay_offset(tui, self.esc_backtrack_count);
let hl = self.backtrack_highlight_range(self.esc_backtrack_count);
if let Some(overlay) = &mut self.transcript_overlay {
if let Some(off) = offset { overlay.scroll_offset = off; }
overlay.set_highlight_range(hl);
}
}
}
}
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
KeyEvent { code: KeyCode::Enter, kind: KeyEventKind::Press, .. }
if self.esc_backtrack_primed && self.esc_backtrack_count > 0 && self.chat_widget.composer_is_empty() =>
{
if let Some(base_id) = self.esc_backtrack_base {
if let Err(e) = self.fork_and_render_backtrack(tui, base_id, self.esc_backtrack_count).await {
tracing::error!("Backtrack confirm failed: {e:#}");
}
}
// Reset backtrack state after confirming.
self.esc_backtrack_primed = false;
self.esc_backtrack_base = None;
self.esc_backtrack_count = 0;
}
KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
@@ -315,4 +419,178 @@ impl App {
}
};
}
/// Re-render the full transcript into the terminal scrollback in one call.
/// Useful when switching sessions to ensure prior history remains visible.
pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) {
if !self.transcript_lines.is_empty() {
tui.insert_history_lines(self.transcript_lines.clone());
}
}
fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
// Enter alternate screen and set viewport to full size.
let _ = execute!(tui.terminal.backend_mut(), EnterAlternateScreen);
if let Ok(size) = tui.terminal.size() {
self.transcript_saved_viewport = Some(tui.terminal.viewport_area);
tui.terminal
.set_viewport_area(Rect::new(0, 0, size.width, size.height));
let _ = tui.terminal.clear();
}
self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone()));
tui.frame_requester().schedule_frame();
}
fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) {
// Exit alternate screen and restore viewport.
let _ = execute!(tui.terminal.backend_mut(), LeaveAlternateScreen);
if let Some(saved) = self.transcript_saved_viewport.take() {
tui.terminal.set_viewport_area(saved);
}
if !self.deferred_history_lines.is_empty() {
let lines = std::mem::take(&mut self.deferred_history_lines);
tui.insert_history_lines(lines);
}
self.transcript_overlay = None;
self.transcript_overlay_is_backtrack = false;
}
async fn fork_and_render_backtrack(
&mut self,
tui: &mut tui::Tui,
base_id: uuid::Uuid,
drop_last_messages: usize,
) -> color_eyre::eyre::Result<()> {
// Compute the text to prefill by extracting the N-th last user message
// from the UI transcript lines already rendered.
let prefill = self.nth_last_user_text_from_transcript(drop_last_messages);
// Fork conversation with the requested drop.
let fork = self
.server
.fork_conversation(base_id, drop_last_messages, self.config.clone())
.await?;
// Replace chat widget with one attached to the new conversation.
self.chat_widget = ChatWidget::new_from_existing(
self.config.clone(),
fork.conversation,
fork.session_configured,
tui.frame_requester(),
self.app_event_tx.clone(),
self.enhanced_keys_supported,
);
// Trim transcript to preserve only content up to the selected user message.
if let Some(cut_idx) = self.nth_last_user_header_index(drop_last_messages) {
self.transcript_lines.truncate(cut_idx);
} else {
self.transcript_lines.clear();
}
let _ = tui.terminal.clear();
self.render_transcript_once(tui);
// Prefill the composer with the dropped user message text, if any.
if let Some(text) = prefill {
if !text.is_empty() {
self.chat_widget.insert_str(&text);
}
}
tui.frame_requester().schedule_frame();
Ok(())
}
/// Compute the overlay scroll offset for the Nth last user message.
fn compute_backtrack_overlay_offset(&self, tui: &mut tui::Tui, n: usize) -> Option<usize> {
if n == 0 {
return None;
}
let header_idx = self.nth_last_user_header_index(n)?;
// Compute wrapped offset up to header_idx with current overlay width.
let width = tui.terminal.viewport_area.width;
let wrapped_before =
crate::insert_history::word_wrap_lines(&self.transcript_lines[0..header_idx], width);
Some(wrapped_before.len())
}
fn nth_last_user_text_from_transcript(&self, n: usize) -> Option<String> {
if n == 0 {
return None;
}
let mut found = 0usize;
let mut header_idx: Option<usize> = None;
for (idx, line) in self.transcript_lines.iter().enumerate().rev() {
let content: String = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("");
if content.trim() == "user" {
found += 1;
if found == n {
header_idx = Some(idx);
break;
}
}
}
let start = header_idx? + 1;
let mut out: Vec<String> = Vec::new();
for line in self.transcript_lines.iter().skip(start) {
let is_blank = line.spans.iter().all(|s| s.content.trim().is_empty());
if is_blank {
break;
}
let text = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("");
out.push(text);
}
if out.is_empty() {
None
} else {
Some(out.join("\n"))
}
}
fn nth_last_user_header_index(&self, n: usize) -> Option<usize> {
if n == 0 {
return None;
}
let mut found = 0usize;
for (idx, line) in self.transcript_lines.iter().enumerate().rev() {
let content: String = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("");
if content.trim() == "user" {
found += 1;
if found == n {
return Some(idx);
}
}
}
None
}
fn backtrack_highlight_range(&self, n: usize) -> Option<(usize, usize)> {
let header = self.nth_last_user_header_index(n)?;
// Include header and the following message lines up to the first blank line.
let mut end = header + 1;
while end < self.transcript_lines.len() {
let is_blank = self.transcript_lines[end]
.spans
.iter()
.all(|s| s.content.trim().is_empty());
if is_blank {
break;
}
end += 1;
}
Some((header, end))
}
}

View File

@@ -250,6 +250,13 @@ impl ChatComposer {
};
match key_event {
KeyEvent {
code: KeyCode::Esc, ..
} => {
// Close the popup.
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Up, ..
} => {
@@ -503,6 +510,10 @@ impl ChatComposer {
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
match key_event {
// Esc is handled at the app layer (conversation backtracking); ignore here.
KeyEvent {
code: KeyCode::Esc, ..
} => (InputResult::None, false),
// -------------------------------------------------------------
// History navigation (Up / Down) only when the composer is not
// empty or when the cursor is at the correct position, to avoid
@@ -1312,4 +1323,6 @@ mod tests {
]
);
}
// Esc backtracking is handled at the app layer (conversation fork); no composer test here.
}

View File

@@ -42,6 +42,13 @@ impl ChatComposerHistory {
}
}
/// Reset transient browsing state so the next navigation starts from the
/// most recent entry again.
pub fn reset_browsing(&mut self) {
self.history_cursor = None;
self.last_history_text = None;
}
/// Update metadata when a new session is configured.
pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) {
self.history_log_id = Some(log_id);

View File

@@ -62,6 +62,7 @@ mod interrupts;
use self::interrupts::InterruptManager;
mod agent;
use self::agent::spawn_agent;
use self::agent::spawn_agent_from_existing;
use crate::streaming::controller::AppEventHistorySink;
use crate::streaming::controller::StreamController;
use codex_common::approval_presets::ApprovalPreset;
@@ -105,6 +106,8 @@ pub(crate) struct ChatWidget {
full_reasoning_buffer: String,
session_id: Option<Uuid>,
frame_requester: FrameRequester,
// Whether to include the initial welcome banner on session configured
show_welcome_banner: bool,
}
struct UserMessage {
@@ -143,7 +146,11 @@ impl ChatWidget {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
self.session_id = Some(event.session_id);
self.add_to_history(history_cell::new_session_info(&self.config, event, true));
self.add_to_history(history_cell::new_session_info(
&self.config,
event,
self.show_welcome_banner,
));
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
}
@@ -565,6 +572,7 @@ impl ChatWidget {
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
session_id: None,
show_welcome_banner: true,
}
}
@@ -576,6 +584,50 @@ impl ChatWidget {
.map_or(0, |c| c.desired_height(width))
}
/// Create a ChatWidget attached to an existing conversation (e.g., a fork).
pub(crate) fn new_from_existing(
config: Config,
conversation: std::sync::Arc<codex_core::CodexConversation>,
session_configured: codex_core::protocol::SessionConfiguredEvent,
frame_requester: FrameRequester,
app_event_tx: AppEventSender,
enhanced_keys_supported: bool,
) -> Self {
let mut rng = rand::rng();
let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string();
let codex_op_tx =
spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone());
Self {
app_event_tx: app_event_tx.clone(),
frame_requester: frame_requester.clone(),
codex_op_tx,
bottom_pane: BottomPane::new(BottomPaneParams {
frame_requester,
app_event_tx,
has_input_focus: true,
enhanced_keys_supported,
placeholder_text: placeholder,
}),
active_exec_cell: None,
config: config.clone(),
initial_user_message: None,
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
stream: StreamController::new(config),
running_commands: HashMap::new(),
pending_exec_completions: Vec::new(),
task_complete_pending: false,
interrupts: InterruptManager::new(),
needs_redraw: false,
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
session_id: None,
show_welcome_banner: false,
}
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Press {
self.bottom_pane.clear_ctrl_c_quit_hint();
@@ -807,6 +859,80 @@ impl ChatWidget {
}
}
/// Render a conversation history snapshot (e.g., restored items from a forked
/// conversation) into the UI history. Only user/assistant messages and
/// reasoning blocks are displayed; tool calls and other items are skipped.
pub(crate) fn render_conversation_history(&mut self, items: Vec<serde_json::Value>) {
let sink = AppEventHistorySink(self.app_event_tx.clone());
for item in items.into_iter() {
let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
match item_type {
"message" => {
let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("");
let mut text_parts: Vec<String> = Vec::new();
if let Some(contents) = item.get("content").and_then(|v| v.as_array()) {
for c in contents {
if let Some(ct) = c.get("type").and_then(|v| v.as_str())
&& (ct == "input_text" || ct == "output_text")
&& c.get("text").and_then(|t| t.as_str()).is_some()
{
if let Some(t) = c.get("text").and_then(|t| t.as_str()) {
text_parts.push(t.to_string());
}
}
}
}
let text = text_parts.join("\n");
if role == "user" {
if !text.is_empty() {
self.add_to_history(history_cell::new_user_prompt(text));
}
} else if role == "assistant" {
let _ = self.stream.apply_final_answer(&text, &sink);
}
}
"reasoning" => {
let mut buf = String::new();
if let Some(parts) = item.get("content").and_then(|v| v.as_array()) {
for p in parts {
if let Some(pt) = p.get("type").and_then(|v| v.as_str())
&& (pt == "reasoning_text" || pt == "text")
&& p.get("text").and_then(|t| t.as_str()).is_some()
{
if !buf.is_empty() {
buf.push('\n');
}
if let Some(t) = p.get("text").and_then(|t| t.as_str()) {
buf.push_str(t);
}
}
}
}
if buf.is_empty() {
if let Some(sum) = item.get("summary").and_then(|v| v.as_array()) {
for s in sum {
if s.get("type").and_then(|v| v.as_str()) == Some("summary_text")
&& s.get("text").and_then(|t| t.as_str()).is_some()
{
if !buf.is_empty() {
buf.push('\n');
}
if let Some(t) = s.get("text").and_then(|t| t.as_str()) {
buf.push_str(t);
}
}
}
}
}
if !buf.is_empty() {
self.add_to_history(history_cell::new_reasoning_block(buf, &self.config));
}
}
_ => {}
}
}
}
fn request_redraw(&mut self) {
self.frame_requester.schedule_frame();
}
@@ -1001,6 +1127,10 @@ impl ChatWidget {
&self.total_token_usage
}
pub(crate) fn session_id(&self) -> Option<Uuid> {
self.session_id
}
pub(crate) fn clear_token_usage(&mut self) {
self.total_token_usage = TokenUsage::default();
self.bottom_pane.set_token_usage(

View File

@@ -1,5 +1,6 @@
use std::sync::Arc;
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
@@ -59,3 +60,40 @@ pub(crate) fn spawn_agent(
codex_op_tx
}
/// Spawn agent loops for an existing conversation (e.g., a forked conversation).
/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent
/// events and accepts Ops for submission.
pub(crate) fn spawn_agent_from_existing(
conversation: std::sync::Arc<CodexConversation>,
session_configured: codex_core::protocol::SessionConfiguredEvent,
app_event_tx: AppEventSender,
) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
let app_event_tx_clone = app_event_tx.clone();
tokio::spawn(async move {
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
let ev = codex_core::protocol::Event {
id: "".to_string(),
msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured),
};
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
let conversation_clone = conversation.clone();
tokio::spawn(async move {
while let Some(op) = codex_op_rx.recv().await {
let id = conversation_clone.submit(op).await;
if let Err(e) = id {
tracing::error!("failed to submit op: {e}");
}
}
});
while let Ok(event) = conversation.next_event().await {
app_event_tx_clone.send(AppEvent::CodexEvent(event));
}
});
codex_op_tx
}

View File

@@ -154,6 +154,7 @@ fn make_chatwidget_manual() -> (
full_reasoning_buffer: String::new(),
session_id: None,
frame_requester: crate::tui::FrameRequester::test_dummy(),
show_welcome_banner: true,
};
(widget, rx, op_rx)
}

View File

@@ -18,17 +18,24 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
pub(crate) struct TranscriptApp {
// Base (unmodified) transcript lines
base_transcript_lines: Vec<Line<'static>>,
// Renderable transcript lines (may include highlight styling)
pub(crate) transcript_lines: Vec<Line<'static>>,
pub(crate) scroll_offset: usize,
pub(crate) is_done: bool,
// Optional highlight range [start, end) in terms of base_transcript_lines indices
highlight_range: Option<(usize, usize)>,
}
impl TranscriptApp {
pub(crate) fn new(transcript_lines: Vec<Line<'static>>) -> Self {
Self {
base_transcript_lines: transcript_lines.clone(),
transcript_lines,
scroll_offset: 0,
is_done: false,
highlight_range: None,
}
}
@@ -46,7 +53,47 @@ impl TranscriptApp {
}
pub(crate) fn insert_lines(&mut self, lines: Vec<Line<'static>>) {
self.transcript_lines.extend(lines);
self.base_transcript_lines.extend(lines.clone());
// If a highlight is active, rebuild with highlight; else append directly.
if self.highlight_range.is_some() {
self.rebuild_highlighted_lines();
} else {
self.transcript_lines.extend(lines);
}
}
/// Highlight the specified range [start, end) of base transcript lines.
pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) {
self.highlight_range = range;
self.rebuild_highlighted_lines();
}
fn rebuild_highlighted_lines(&mut self) {
// Start from base and optionally apply highlight styles to the target range.
let mut out = self.base_transcript_lines.clone();
if let Some((start, end)) = self.highlight_range {
use ratatui::style::Modifier;
let len = out.len();
let start = start.min(len);
let end = end.min(len);
for (idx, line) in out.iter_mut().enumerate().take(end).skip(start) {
// Apply REVERSED to all spans; add BOLD on the first line (header)
let mut spans = Vec::with_capacity(line.spans.len());
for (i, s) in line.spans.iter().enumerate() {
let mut style = s.style;
style.add_modifier = style.add_modifier | Modifier::REVERSED;
if idx == start && i == 0 {
style.add_modifier = style.add_modifier | Modifier::BOLD;
}
spans.push(ratatui::text::Span {
style,
content: s.content.clone(),
});
}
line.spans = spans;
}
}
self.transcript_lines = out;
}
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {