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

+25 -1
View File
@@ -111,7 +111,10 @@ impl ChatWidget {
self.sync_goal_command_enabled();
self.refresh_plugin_mentions();
let model_for_header = self.current_model().to_string();
if display == SessionConfiguredDisplay::Normal {
if matches!(
display,
SessionConfiguredDisplay::Normal | SessionConfiguredDisplay::PromptEdit
) {
let startup_tooltip_override = self.startup_tooltip_override.take();
let show_fast_status = self
.should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref());
@@ -169,6 +172,16 @@ impl ChatWidget {
);
}
pub(crate) fn handle_prompt_edit_thread_session(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone();
let fork_parent_title = session.fork_parent_title.clone();
self.on_session_configured_with_display_and_fork_parent_title(
session,
SessionConfiguredDisplay::PromptEdit,
fork_parent_title,
);
}
pub(crate) fn handle_side_thread_session(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone();
let fork_parent_title = session.fork_parent_title.clone();
@@ -210,6 +223,17 @@ impl ChatWidget {
)));
}
pub(crate) fn emit_prompt_edit_thread_event(&mut self) {
let line: Line<'static> = vec![
"• ".dim(),
"You’re continuing from this point in a new conversation".into(),
]
.into();
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
PlainHistoryCell::new(vec![line]),
)));
}
pub(super) fn on_thread_name_updated(
&mut self,
thread_id: ThreadId,
@@ -0,0 +1,5 @@
---
source: tui/src/chatwidget/tests/history_replay.rs
expression: combined
---
• You’re continuing from this point in a new conversation
@@ -742,6 +742,28 @@ async fn forked_thread_history_line_without_name_shows_id_once_snapshot() {
assert_chatwidget_snapshot!("forked_thread_history_line_without_name", combined);
}
#[tokio::test]
async fn prompt_edit_thread_history_line_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.emit_prompt_edit_thread_event();
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 2), async {
loop {
match rx.recv().await {
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
Some(_) => continue,
None => panic!("app event channel closed before prompt edit history was emitted"),
}
}
})
.await
.expect("timed out waiting for prompt edit history");
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
assert_chatwidget_snapshot!("prompt_edit_thread_history_line", combined);
}
#[tokio::test]
async fn app_server_forked_thread_history_line_uses_app_server_title_snapshot() {
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+15 -9
View File
@@ -1,6 +1,12 @@
use super::*;
use pretty_assertions::assert_eq;
fn set_composer_text(chat: &mut ChatWidget, text: &str) {
chat.bottom_pane
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
chat.refresh_plan_mode_nudge();
}
#[test]
fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() {
assert!(contains_plan_keyword("plan"));
@@ -14,19 +20,19 @@ fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() {
#[tokio::test]
async fn plan_mode_nudge_shows_only_for_eligible_default_mode_drafts() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
chat.set_composer_text("/plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "/plan");
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
chat.set_composer_text("!plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "!plan");
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
.expect("expected plan collaboration mode");
chat.set_collaboration_mask(plan_mask);
@@ -37,7 +43,7 @@ async fn plan_mode_nudge_shows_only_for_eligible_default_mode_drafts() {
#[tokio::test]
async fn plan_mode_nudge_hides_while_task_or_modal_is_active() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
@@ -65,7 +71,7 @@ async fn plan_mode_nudge_dismissal_is_scoped_to_current_thread() {
let first_thread = ThreadId::new();
let second_thread = ThreadId::new();
chat.thread_id = Some(first_thread);
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
@@ -89,7 +95,7 @@ async fn plan_mode_nudge_dismissal_is_scoped_to_current_thread() {
#[tokio::test]
async fn plan_mode_nudge_shift_tab_uses_existing_mode_cycle_path() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
@@ -105,7 +111,7 @@ async fn plan_mode_nudge_snapshot() {
chat.set_token_info(Some(make_token_info(
/*total_tokens*/ 50_000, /*context_window*/ 100_000,
)));
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert_chatwidget_snapshot!("plan_mode_nudge", render_bottom_popup(&chat, /*width*/ 80));
@@ -114,7 +120,7 @@ async fn plan_mode_nudge_snapshot() {
#[tokio::test]
async fn plan_mode_nudge_narrow_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert_chatwidget_snapshot!(
+66 -6
View File
@@ -21,6 +21,8 @@ use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::models::local_image_label_text;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
use super::ChatWidget;
@@ -525,11 +527,69 @@ pub(super) fn merge_user_messages_with_history_record(
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct UserMessageDisplay {
pub(super) message: String,
pub(super) remote_image_urls: Vec<String>,
pub(super) local_images: Vec<PathBuf>,
pub(super) text_elements: Vec<TextElement>,
pub(crate) struct UserMessageDisplay {
pub(crate) message: String,
pub(crate) remote_image_urls: Vec<String>,
pub(crate) local_images: Vec<PathBuf>,
pub(crate) text_elements: Vec<TextElement>,
}
pub(crate) fn mention_bindings_from_user_inputs(
items: &[UserInput],
message: &str,
) -> Vec<MentionBinding> {
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)
});
mention_bindings
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -599,7 +659,7 @@ impl ChatWidget {
}
}
pub(super) fn user_message_display_from_inputs(items: &[UserInput]) -> UserMessageDisplay {
pub(crate) fn user_message_display_from_inputs(items: &[UserInput]) -> UserMessageDisplay {
let mut message = String::new();
let mut remote_image_urls = Vec::new();
let mut local_images = Vec::new();