mirror of
https://github.com/openai/codex.git
synced 2026-11-04 00:24:43 +00:00
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:
1 parent
72b41c55fb
commit
469ce0db51
17 files changed
+928
-549
No files matched your search
@@ -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,
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user