Compare commits

...

11 Commits

Author SHA1 Message Date
Ahmed Ibrahim
90d5445d09 refactor 2025-08-22 11:15:54 -07:00
Ahmed Ibrahim
d9b4e87cf8 refactor 2025-08-22 11:12:28 -07:00
Ahmed Ibrahim
a0de5e883a refactor 2025-08-22 11:12:18 -07:00
Ahmed Ibrahim
98e72d3dbf refactor 2025-08-22 11:02:53 -07:00
Ahmed Ibrahim
50e0db431f refactor 2025-08-22 10:56:58 -07:00
Ahmed Ibrahim
6c260e7cfd refactor 2025-08-22 10:52:39 -07:00
Ahmed Ibrahim
3f4826d7fd refactor 2025-08-22 10:52:30 -07:00
Ahmed Ibrahim
d57014ec50 go back in history 2025-08-22 10:44:07 -07:00
Ahmed Ibrahim
119a2bd9f5 go back in history 2025-08-22 10:43:22 -07:00
Ahmed Ibrahim
d41ca91054 go back in history 2025-08-22 10:38:20 -07:00
Ahmed Ibrahim
682ec7f0ef fork conversation 2025-08-21 21:10:06 -07:00
15 changed files with 850 additions and 54 deletions

View File

@@ -130,6 +130,7 @@ pub struct Codex {
next_id: AtomicU64,
tx_sub: Sender<Submission>,
rx_event: Receiver<Event>,
session: Arc<Session>,
}
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
@@ -144,7 +145,11 @@ pub(crate) const INITIAL_SUBMIT_ID: &str = "";
impl Codex {
/// Spawn a new [`Codex`] and initialize the session.
pub async fn spawn(config: Config, auth: Option<CodexAuth>) -> CodexResult<CodexSpawnOk> {
pub async fn spawn(
config: Config,
auth: Option<CodexAuth>,
initial_history: Option<Vec<ResponseItem>>,
) -> CodexResult<CodexSpawnOk> {
let (tx_sub, rx_sub) = async_channel::bounded(64);
let (tx_event, rx_event) = async_channel::unbounded();
@@ -169,21 +174,32 @@ impl Codex {
};
// Generate a unique ID for the lifetime of this Codex session.
let (session, turn_context) =
Session::new(configure_session, config.clone(), auth, tx_event.clone())
.await
.map_err(|e| {
error!("Failed to create session: {e:#}");
CodexErr::InternalAgentDied
})?;
let (session, turn_context) = Session::new(
configure_session,
config.clone(),
auth,
tx_event.clone(),
initial_history,
)
.await
.map_err(|e| {
error!("Failed to create session: {e:#}");
CodexErr::InternalAgentDied
})?;
let session_id = session.session_id;
// This task will run until Op::Shutdown is received.
tokio::spawn(submission_loop(session, turn_context, config, rx_sub));
tokio::spawn(submission_loop(
Arc::clone(&session),
turn_context,
config,
rx_sub,
));
let codex = Codex {
next_id: AtomicU64::new(0),
tx_sub,
rx_event,
session: Arc::clone(&session),
};
Ok(CodexSpawnOk { codex, session_id })
@@ -218,6 +234,11 @@ impl Codex {
.map_err(|_| CodexErr::InternalAgentDied)?;
Ok(event)
}
/// Snapshot of the conversation history (oldest → newest).
pub(crate) fn history_contents(&self) -> Vec<ResponseItem> {
self.session.state.lock_unchecked().history.contents()
}
}
/// Mutable state of the agent
@@ -325,6 +346,7 @@ impl Session {
config: Arc<Config>,
auth: Option<CodexAuth>,
tx_event: Sender<Event>,
initial_history: Option<Vec<ResponseItem>>,
) -> anyhow::Result<(Arc<Self>, TurnContext)> {
let ConfigureSession {
provider,
@@ -384,14 +406,16 @@ impl Session {
}
let rollout_result = match rollout_res {
Ok((session_id, maybe_saved, recorder)) => {
let restored_items: Option<Vec<ResponseItem>> =
maybe_saved.and_then(|saved_session| {
let restored_items: Option<Vec<ResponseItem>> = match initial_history {
Some(items) => Some(items),
None => maybe_saved.and_then(|saved_session| {
if saved_session.items.is_empty() {
None
} else {
Some(saved_session.items)
}
});
}),
};
RolloutResult {
session_id,
rollout_recorder: Some(recorder),

View File

@@ -1,5 +1,6 @@
use crate::codex::Codex;
use crate::error::Result as CodexResult;
use crate::models::ResponseItem;
use crate::protocol::Event;
use crate::protocol::Op;
use crate::protocol::Submission;
@@ -27,4 +28,9 @@ impl CodexConversation {
pub async fn next_event(&self) -> CodexResult<Event> {
self.codex.next_event().await
}
/// Return a snapshot of the current conversation history (oldest → newest).
pub(crate) fn history_contents(&self) -> Vec<ResponseItem> {
self.codex.history_contents()
}
}

View File

@@ -54,7 +54,7 @@ impl ConversationManager {
let CodexSpawnOk {
codex,
session_id: conversation_id,
} = Codex::spawn(config, auth).await?;
} = Codex::spawn(config, auth, None).await?;
// The first event must be `SessionInitialized`. Validate and forward it
// to the caller so that they can display it in the conversation
@@ -93,4 +93,149 @@ impl ConversationManager {
.cloned()
.ok_or_else(|| CodexErr::ConversationNotFound(conversation_id))
}
/// Fork an existing conversation by dropping the last `drop_last_messages`
/// user/assistant messages from its transcript and starting a new
/// conversation with identical configuration (unless overridden by the
/// caller's `config`). The new conversation will have a fresh id.
pub async fn fork_conversation(
&self,
base_conversation_id: Uuid,
drop_last_messages: usize,
config: Config,
) -> CodexResult<NewConversation> {
// Obtain base conversation currently managed in memory.
let base = self.get_conversation(base_conversation_id).await?;
let items = base.history_contents();
// Compute the prefix up to the cut point.
let fork_items = truncate_after_dropping_last_messages(items, drop_last_messages);
// Spawn a new conversation with the computed initial history.
let auth = CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method)?;
let CodexSpawnOk {
codex,
session_id: conversation_id,
} = Codex::spawn(config, auth, Some(fork_items)).await?;
// The first event must be `SessionInitialized`. Validate and forward it
// to the caller so that they can display it in the conversation
// history.
let event = codex.next_event().await?;
let session_configured = match event {
Event {
id,
msg: EventMsg::SessionConfigured(session_configured),
} if id == INITIAL_SUBMIT_ID => session_configured,
_ => {
return Err(CodexErr::SessionConfiguredNotFirstEvent);
}
};
let conversation = Arc::new(CodexConversation::new(codex));
self.conversations
.write()
.await
.insert(conversation_id, conversation.clone());
Ok(NewConversation {
conversation_id,
conversation,
session_configured,
})
}
}
/// Return a prefix of `items` obtained by dropping the last `n` user messages
/// and all items that follow them.
fn truncate_after_dropping_last_messages(
items: Vec<crate::models::ResponseItem>,
n: usize,
) -> Vec<crate::models::ResponseItem> {
if n == 0 || items.is_empty() {
return items;
}
// Walk backwards counting only `user` Message items, find cut index.
let mut count = 0usize;
let mut cut_index = 0usize;
for (idx, item) in items.iter().enumerate().rev() {
if let crate::models::ResponseItem::Message { role, .. } = item
&& role == "user"
{
count += 1;
if count == n {
// Cut everything from this user message to the end.
cut_index = idx;
break;
}
}
}
if count < n {
// If fewer than n messages exist, drop everything.
return Vec::new();
}
items.into_iter().take(cut_index).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::ContentItem;
use crate::models::ReasoningItemReasoningSummary;
use crate::models::ResponseItem;
fn user_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
}
}
fn assistant_msg(text: &str) -> ResponseItem {
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
}
}
#[test]
fn drops_from_last_user_only() {
let items = vec![
user_msg("u1"),
assistant_msg("a1"),
assistant_msg("a2"),
user_msg("u2"),
assistant_msg("a3"),
ResponseItem::Reasoning {
id: "r1".to_string(),
summary: vec![ReasoningItemReasoningSummary::SummaryText {
text: "s".to_string(),
}],
content: None,
encrypted_content: None,
},
ResponseItem::FunctionCall {
id: None,
name: "tool".to_string(),
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
assistant_msg("a4"),
];
let truncated = truncate_after_dropping_last_messages(items.clone(), 1);
assert_eq!(
truncated,
vec![items[0].clone(), items[1].clone(), items[2].clone()]
);
let truncated2 = truncate_after_dropping_last_messages(items, 2);
assert!(truncated2.is_empty());
}
}

View File

@@ -47,6 +47,7 @@ use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD;
use codex_protocol::mcp_protocol::ExecCommandApprovalParams;
use codex_protocol::mcp_protocol::ExecCommandApprovalResponse;
use codex_protocol::mcp_protocol::ForkConversationParams;
use codex_protocol::mcp_protocol::InputItem as WireInputItem;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::InterruptConversationResponse;
@@ -114,6 +115,10 @@ impl CodexMessageProcessor {
// created before processing any subsequent messages.
self.process_new_conversation(request_id, params).await;
}
ClientRequest::ForkConversation { request_id, params } => {
// Same reasoning as NewConversation: ensure ordering.
self.process_fork_conversation(request_id, params).await;
}
ClientRequest::SendUserMessage { request_id, params } => {
self.send_user_message(request_id, params).await;
}
@@ -377,6 +382,79 @@ impl CodexMessageProcessor {
}
}
async fn process_fork_conversation(
&self,
request_id: RequestId,
params: ForkConversationParams,
) {
let ForkConversationParams {
conversation_id,
drop_last_messages,
overrides,
} = params;
// Verify the base conversation exists.
if self
.conversation_manager
.get_conversation(conversation_id.0)
.await
.is_err()
{
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("conversation not found: {conversation_id}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
// Derive config from overrides (or defaults) for the new conversation.
let new_conv_params = overrides.unwrap_or_default();
let config = match derive_config_from_params(
new_conv_params,
self.codex_linux_sandbox_exe.clone(),
) {
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self
.conversation_manager
.fork_conversation(conversation_id.0, drop_last_messages, config)
.await
{
Ok(new_conv) => {
let NewConversation {
conversation_id,
session_configured,
..
} = new_conv;
let response = NewConversationResponse {
conversation_id: ConversationId(conversation_id),
model: session_configured.model,
};
self.outgoing.send_response(request_id, response).await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("error forking conversation: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
}
}
}
async fn send_user_message(&self, request_id: RequestId, params: SendUserMessageParams) {
let SendUserMessageParams {
conversation_id,

View File

@@ -22,6 +22,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
codex_protocol::mcp_protocol::ServerRequest::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::NewConversationParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::NewConversationResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::ForkConversationParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::AddConversationListenerParams::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::AddConversationSubscriptionResponse::export_all_to(out_dir)?;
codex_protocol::mcp_protocol::RemoveConversationListenerParams::export_all_to(out_dir)?;

View File

@@ -53,6 +53,15 @@ pub enum ClientRequest {
request_id: RequestId,
params: NewConversationParams,
},
/// Start a new conversation by forking an existing one and truncating the
/// last N user/assistant messages from its transcript. The new
/// conversation will have a fresh conversation id; all other
/// configuration can be optionally overridden via `overrides`.
ForkConversation {
#[serde(rename = "id")]
request_id: RequestId,
params: ForkConversationParams,
},
SendUserMessage {
#[serde(rename = "id")]
request_id: RequestId,
@@ -152,6 +161,20 @@ pub struct NewConversationResponse {
pub model: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ForkConversationParams {
/// Existing conversation to fork from.
pub conversation_id: ConversationId,
/// Positive number of trailing user/assistant messages to drop in the
/// fork. `1` means the last message is excluded; `2` excludes the last
/// two messages, and so on.
pub drop_last_messages: usize,
/// Optional overrides for the new conversation's initial configuration.
#[serde(skip_serializing_if = "Option::is_none")]
pub overrides: Option<NewConversationParams>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct AddConversationSubscriptionResponse {

View File

@@ -12,9 +12,6 @@ use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::execute;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::layout::Rect;
use ratatui::text::Line;
@@ -28,26 +25,33 @@ use tokio::select;
use tokio::sync::mpsc::unbounded_channel;
pub(crate) struct App {
server: Arc<ConversationManager>,
app_event_tx: AppEventSender,
chat_widget: ChatWidget,
pub(crate) server: Arc<ConversationManager>,
pub(crate) app_event_tx: AppEventSender,
pub(crate) chat_widget: ChatWidget,
/// Config is stored here so we can recreate ChatWidgets as needed.
config: Config,
pub(crate) config: Config,
file_search: FileSearchManager,
pub(crate) file_search: FileSearchManager,
transcript_lines: Vec<Line<'static>>,
pub(crate) transcript_lines: Vec<Line<'static>>,
// Transcript overlay state
transcript_overlay: Option<TranscriptApp>,
deferred_history_lines: Vec<Line<'static>>,
transcript_saved_viewport: Option<Rect>,
pub(crate) transcript_overlay: Option<TranscriptApp>,
// If true, overlay is opened as an Esc-backtrack preview.
pub(crate) transcript_overlay_is_backtrack: bool,
pub(crate) deferred_history_lines: Vec<Line<'static>>,
pub(crate) transcript_saved_viewport: Option<Rect>,
enhanced_keys_supported: bool,
pub(crate) enhanced_keys_supported: bool,
/// Controls the animation thread that sends CommitTick events.
commit_anim_running: Arc<AtomicBool>,
pub(crate) commit_anim_running: Arc<AtomicBool>,
// Esc-backtracking state
pub(crate) esc_backtrack_primed: bool,
pub(crate) esc_backtrack_base: Option<uuid::Uuid>,
pub(crate) esc_backtrack_count: usize,
}
impl App {
@@ -86,9 +90,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,21 +121,8 @@ 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.deferred_history_lines.is_empty() {
let lines = std::mem::take(&mut self.deferred_history_lines);
tui.insert_history_lines(lines);
}
self.transcript_overlay = None;
}
tui.frame_requester().schedule_frame();
if self.transcript_overlay.is_some() {
let _ = self.handle_backtrack_overlay_event(tui, event).await?;
} else {
match event {
TuiEvent::Key(key_event) => {
@@ -292,17 +287,35 @@ impl App {
kind: KeyEventKind::Press,
..
} => {
// 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.open_transcript_overlay(tui);
}
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.handle_backtrack_esc_key(tui);
}
// 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
&& let Err(e) = self
.fork_and_render_backtrack(tui, base_id, self.esc_backtrack_count)
.await
{
tracing::error!("Backtrack confirm failed: {e:#}");
}
self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone()));
tui.frame_requester().schedule_frame();
// 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 +328,6 @@ impl App {
}
};
}
// Backtrack helpers moved to app_backtrack.rs and backtrack_helpers.rs
}

View File

@@ -0,0 +1,252 @@
use crate::app::App;
use crate::backtrack_helpers;
use crate::transcript_app::TranscriptApp;
use crate::tui;
use crate::tui::TuiEvent;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::execute;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
impl App {
// Public entrypoints first (most important)
/// Route TUI events to the overlay when present, handling backtrack preview
/// interactions (Esc to step target, Enter to confirm) and overlay lifecycle.
pub(crate) async fn handle_backtrack_overlay_event(
&mut self,
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<bool> {
// 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 header_idx = backtrack_helpers::find_nth_last_user_header_index(
&self.transcript_lines,
self.esc_backtrack_count,
);
let offset = header_idx.map(|idx| {
backtrack_helpers::wrapped_offset_before(
&self.transcript_lines,
idx,
tui.terminal.viewport_area.width,
)
});
let hl = backtrack_helpers::highlight_range_for_nth_last_user(
&self.transcript_lines,
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
&& count > 0
&& 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;
}
_ => {}
}
}
// Forward to overlay if not handled
if !handled && 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;
}
}
}
tui.frame_requester().schedule_frame();
Ok(true)
}
/// Handle global Esc presses for backtracking when no overlay is present.
pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) {
// 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 header_idx = backtrack_helpers::find_nth_last_user_header_index(
&self.transcript_lines,
self.esc_backtrack_count,
);
let offset = header_idx.map(|idx| {
backtrack_helpers::wrapped_offset_before(
&self.transcript_lines,
idx,
tui.terminal.viewport_area.width,
)
});
let hl = backtrack_helpers::highlight_range_for_nth_last_user(
&self.transcript_lines,
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 header_idx = backtrack_helpers::find_nth_last_user_header_index(
&self.transcript_lines,
self.esc_backtrack_count,
);
let offset = header_idx.map(|idx| {
backtrack_helpers::wrapped_offset_before(
&self.transcript_lines,
idx,
tui.terminal.viewport_area.width,
)
});
let hl = backtrack_helpers::highlight_range_for_nth_last_user(
&self.transcript_lines,
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);
}
}
}
}
/// Fork the conversation and render the trimmed history; prefill composer.
pub(crate) 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 =
backtrack_helpers::nth_last_user_text(&self.transcript_lines, 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 = crate::chatwidget::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) = backtrack_helpers::find_nth_last_user_header_index(
&self.transcript_lines,
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
&& !text.is_empty()
{
self.chat_widget.insert_str(&text);
}
tui.frame_requester().schedule_frame();
Ok(())
}
// Internal helpers
pub(crate) 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(ratatui::layout::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();
}
pub(crate) 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;
}
/// 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());
}
}
}

View File

@@ -0,0 +1,97 @@
use ratatui::text::Line;
// Public helpers (most important first)
/// Convenience: compute the highlight range for the Nth last user message.
pub(crate) fn highlight_range_for_nth_last_user(
lines: &[Line<'_>],
n: usize,
) -> Option<(usize, usize)> {
let header = find_nth_last_user_header_index(lines, n)?;
Some(highlight_range_from_header(lines, header))
}
/// Compute the wrapped display-line offset before `header_idx`, for a given width.
pub(crate) fn wrapped_offset_before(lines: &[Line<'_>], header_idx: usize, width: u16) -> usize {
let before = &lines[0..header_idx];
crate::insert_history::word_wrap_lines(before, width).len()
}
/// Find the header index for the Nth last user message in the transcript.
/// Returns `None` if `n == 0` or there are fewer than `n` user messages.
pub(crate) fn find_nth_last_user_header_index(lines: &[Line<'_>], n: usize) -> Option<usize> {
if n == 0 {
return None;
}
let mut found = 0usize;
for (idx, line) in 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
}
/// Extract the text content of the Nth last user message.
/// The message body is considered to be the lines following the "user" header
/// until the first blank line.
pub(crate) fn nth_last_user_text(lines: &[Line<'_>], n: usize) -> Option<String> {
let header_idx = find_nth_last_user_header_index(lines, n)?;
extract_message_text_after_header(lines, header_idx)
}
// Private helpers
/// Extract message text starting after `header_idx` until the first blank line.
fn extract_message_text_after_header(lines: &[Line<'_>], header_idx: usize) -> Option<String> {
let start = header_idx + 1;
let mut out: Vec<String> = Vec::new();
for line in lines.iter().skip(start) {
let is_blank = line
.spans
.iter()
.all(|s| s.content.as_ref().trim().is_empty());
if is_blank {
break;
}
let text: String = 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"))
}
}
/// Given a header index, return the inclusive range for the message block
/// [header_idx, end) where end is the first blank line after the header or the
/// end of the transcript.
fn highlight_range_from_header(lines: &[Line<'_>], header_idx: usize) -> (usize, usize) {
let mut end = header_idx + 1;
while end < lines.len() {
let is_blank = lines[end]
.spans
.iter()
.all(|s| s.content.as_ref().trim().is_empty());
if is_blank {
break;
}
end += 1;
}
(header_idx, 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

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();
@@ -1001,6 +1053,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

@@ -24,8 +24,10 @@ use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
mod app;
mod app_backtrack;
mod app_event;
mod app_event_sender;
mod backtrack_helpers;
mod bottom_pane;
mod chatwidget;
mod citation_regex;

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 |= Modifier::REVERSED;
if idx == start && i == 0 {
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) {