Branch conversations when editing earlier TUI prompts (#33201)

## What changed

- Fork the conversation immediately before the selected prompt instead of rolling back the current thread in place, preserving the original conversation.
- Reopen the selected prompt in the new conversation with its images, text elements, and mention bindings intact.
- Start a fresh conversation when editing the first prompt, show a dedicated continuation notice, and restore the prompt with an error if branching fails.

## Testing

- Cover branching before first and later prompts, source preservation, prompt restoration, hidden review prompts, mid-turn steering, in-progress turns, stale transcript entries, and mention bindings.

GitOrigin-RevId: 15473535b3f9733b0300711a498969e6d389db79
This commit is contained in:
Felipe Coury authored and copyberry committed 2026-07-15 01:36:44 +00:00
1 parent 72b41c55fb
commit 469ce0db51
17 files changed
+928 -549

No files matched your search

+3 -71
View File
@@ -163,8 +163,6 @@ use codex_terminal_detection::terminal_info;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::resume_hint;
use codex_utils_path_uri::PathUri;
use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -436,6 +434,7 @@ use self::user_messages::UserMessageHistoryOverride;
use self::user_messages::UserMessageHistoryRecord;
use self::user_messages::app_server_text_elements;
pub(crate) use self::user_messages::create_initial_user_message;
pub(crate) use self::user_messages::mention_bindings_from_user_inputs;
use self::user_messages::merge_user_messages;
use self::user_messages::merge_user_messages_with_history_record;
#[cfg(test)]
@@ -796,6 +795,7 @@ pub(crate) enum ReplayKind {
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SessionConfiguredDisplay {
Normal,
PromptEdit,
/// Apply session state without emitting the session info cell.
Quiet,
SideConversation,
@@ -1262,69 +1262,13 @@ impl ChatWidget {
if self.review.is_review_mode {
return;
}
let message = display.message.as_str();
let mention_start = |sigil: char, mention: &str| {
let token = format!("{sigil}{mention}");
message.match_indices(&token).find_map(|(start, _)| {
let end = start + token.len();
message
.as_bytes()
.get(end)
.is_none_or(|byte| {
!byte.is_ascii_alphanumeric() && !matches!(byte, b'_' | b'-')
})
.then_some(start)
})
};
let mut mention_bindings: Vec<MentionBinding> = items
.iter()
.filter_map(|item| match item {
UserInput::Skill { name, path } => Some(MentionBinding {
sigil: TOOL_MENTION_SIGIL,
mention: name.clone(),
path: path.to_string_lossy().into_owned(),
}),
UserInput::Mention { name, path } => {
let plugin_id = path.strip_prefix("plugin://");
let mention = if let Some(plugin_id) = plugin_id {
plugin_id
.split_once('@')
.map(|(plugin_name, _)| plugin_name)
.unwrap_or(plugin_id)
.to_string()
} else if path.starts_with("app://") {
codex_connectors::metadata::connector_mention_slug_from_name(name)
} else {
name.clone()
};
let sigil = if plugin_id.is_some()
&& mention_start(PLUGIN_TEXT_MENTION_SIGIL, &mention).is_some()
{
PLUGIN_TEXT_MENTION_SIGIL
} else {
TOOL_MENTION_SIGIL
};
Some(MentionBinding {
sigil,
mention,
path: path.clone(),
})
}
UserInput::Text { .. }
| UserInput::Image { .. }
| UserInput::LocalImage { .. } => None,
})
.collect();
mention_bindings.sort_by_key(|binding| {
mention_start(binding.sigil, &binding.mention).unwrap_or(usize::MAX)
});
self.bottom_pane
.record_replayed_user_message_history(HistoryEntry {
text: display.message.clone(),
text_elements: display.text_elements.clone(),
local_image_paths: display.local_images.clone(),
remote_image_urls: display.remote_image_urls.clone(),
mention_bindings,
mention_bindings: mention_bindings_from_user_inputs(items, &display.message),
pending_pastes: Vec::new(),
});
self.on_user_message_display(display);
@@ -1745,18 +1689,6 @@ impl ChatWidget {
self.bottom_pane.insert_str(text);
}
/// Replace the composer content with the provided text and reset cursor.
pub(crate) fn set_composer_text(
&mut self,
text: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
) {
self.bottom_pane
.set_composer_text(text, text_elements, local_image_paths);
self.refresh_plan_mode_nudge();
}
pub(crate) fn set_remote_image_urls(&mut self, remote_image_urls: Vec<String>) {
self.bottom_pane.set_remote_image_urls(remote_image_urls);
}