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

+73 -8
View File
@@ -4,6 +4,7 @@
//! actions are delegated to focused app submodules so the central match remains the routing layer. //! actions are delegated to focused app submodules so the central match remains the routing layer.
use super::resize_reflow::trailing_run_start; use super::resize_reflow::trailing_run_start;
use super::session_lifecycle::ThreadAttachPresentation;
use super::*; use super::*;
use crate::config_update::format_config_error; use crate::config_update::format_config_error;
use crate::external_agent_config_migration_flow::ExternalAgentConfigMigrationFlowOutcome; use crate::external_agent_config_migration_flow::ExternalAgentConfigMigrationFlowOutcome;
@@ -182,7 +183,11 @@ impl App {
self.shutdown_current_thread(app_server).await; self.shutdown_current_thread(app_server).await;
match self match self
.replace_chat_widget_with_app_server_thread( .replace_chat_widget_with_app_server_thread(
tui, app_server, forked, /*initial_user_message*/ None, tui,
app_server,
forked,
ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
) )
.await .await
{ {
@@ -224,6 +229,72 @@ impl App {
tui.frame_requester().schedule_frame(); tui.frame_requester().schedule_frame();
} }
AppEvent::ForkSessionForPromptEdit {
thread_id,
nth_user_message,
mut prompt,
} => {
if self.chat_widget.thread_id() != Some(thread_id) {
return Ok(AppRunControl::Continue);
}
self.session_telemetry.counter(
"codex.thread.fork",
/*inc*/ 1,
&[("source", "transcript")],
);
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
.await;
let config = self.fresh_session_config();
let started = match app_server
.thread_read(thread_id, /*include_turns*/ true)
.await
{
Ok(thread) => match crate::app_backtrack::backtrack_fork_last_turn_id(
&thread.turns,
nth_user_message,
&mut prompt,
) {
Ok(Some(last_turn_id)) => {
app_server
.fork_thread_after(config.clone(), thread_id, last_turn_id)
.await
}
Ok(None) => {
app_server
.start_thread_with_session_start_source(
&config, /*session_start_source*/ None,
)
.await
}
Err(err) => Err(err),
},
Err(err) => Err(err),
};
match started {
Ok(forked) => {
self.shutdown_current_thread(app_server).await;
match self
.replace_chat_widget_with_app_server_thread(
tui,
app_server,
forked,
ThreadAttachPresentation::PromptEdit,
/*initial_user_message*/ None,
)
.await
{
Ok(()) => self.chat_widget.restore_user_message_to_composer(prompt),
Err(err) => {
self.restore_backtrack_prompt_after_branch_error(prompt, err);
}
}
}
Err(err) => {
self.restore_backtrack_prompt_after_branch_error(prompt, err);
}
}
tui.frame_requester().schedule_frame();
}
AppEvent::BeginInitialHistoryReplayBuffer => { AppEvent::BeginInitialHistoryReplayBuffer => {
self.begin_initial_history_replay_buffer(); self.begin_initial_history_replay_buffer();
} }
@@ -288,11 +359,6 @@ impl App {
self.chat_widget.note_stream_consolidation_completed(); self.chat_widget.note_stream_consolidation_completed();
self.insert_pending_usage_output_after_stream_shutdown(tui); self.insert_pending_usage_output_after_stream_shutdown(tui);
} }
AppEvent::ApplyThreadRollback { num_turns } => {
if self.apply_non_pending_thread_rollback(num_turns) {
tui.frame_requester().schedule_frame();
}
}
AppEvent::StartCommitAnimation => { AppEvent::StartCommitAnimation => {
if self if self
.commit_anim_running .commit_anim_running
@@ -400,11 +466,10 @@ impl App {
}; };
self.chat_widget.prepare_safety_buffering_retry(); self.chat_widget.prepare_safety_buffering_retry();
self.handle_thread_rollback_response_with_origin( self.handle_thread_rollback_response(
thread_id, thread_id,
/*num_turns*/ 1, /*num_turns*/ 1,
&rollback_response, &rollback_response,
super::thread_routing::ThreadRollbackOrigin::SafetyBufferingRetry,
) )
.await; .await;
+2 -1
View File
@@ -233,7 +233,8 @@ impl App {
&& self.chat_widget.composer_is_empty() => && self.chat_widget.composer_is_empty() =>
{ {
if let Some(selection) = self.confirm_backtrack_from_main() { if let Some(selection) = self.confirm_backtrack_from_main() {
self.apply_backtrack_selection(tui, selection); self.apply_backtrack_selection(selection);
tui.frame_requester().schedule_frame();
} }
} }
KeyEvent { KeyEvent {
+19 -3
View File
@@ -6,6 +6,12 @@
use super::*; use super::*;
#[derive(Clone, Copy)]
pub(super) enum ThreadAttachPresentation {
SessionLineage,
PromptEdit,
}
impl App { impl App {
pub(super) async fn open_agent_picker(&mut self, app_server: &mut AppServerSession) { pub(super) async fn open_agent_picker(&mut self, app_server: &mut AppServerSession) {
self.backfill_loaded_subagent_threads(app_server).await; self.backfill_loaded_subagent_threads(app_server).await;
@@ -571,6 +577,7 @@ impl App {
tui, tui,
app_server, app_server,
started, started,
ThreadAttachPresentation::SessionLineage,
initial_user_message, initial_user_message,
) )
.await .await
@@ -605,6 +612,7 @@ impl App {
tui: &mut tui::Tui, tui: &mut tui::Tui,
app_server: &mut AppServerSession, app_server: &mut AppServerSession,
started: AppServerStartedThread, started: AppServerStartedThread,
presentation: ThreadAttachPresentation,
initial_user_message: Option<crate::chatwidget::UserMessage>, initial_user_message: Option<crate::chatwidget::UserMessage>,
) -> Result<()> { ) -> Result<()> {
// Initial messages are for freshly attached primary threads only. Thread switches and // Initial messages are for freshly attached primary threads only. Thread switches and
@@ -617,8 +625,12 @@ impl App {
initial_user_message, initial_user_message,
); );
self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.replace_chat_widget(ChatWidget::new_with_app_event(init));
self.enqueue_primary_thread_session(started.session, started.turns) self.enqueue_primary_thread_session_with_presentation(
.await?; started.session,
started.turns,
presentation,
)
.await?;
self.backfill_loaded_subagent_threads(app_server).await; self.backfill_loaded_subagent_threads(app_server).await;
Ok(()) Ok(())
} }
@@ -808,7 +820,11 @@ impl App {
.update_search_dir(self.config.cwd.to_path_buf()); .update_search_dir(self.config.cwd.to_path_buf());
match self match self
.replace_chat_widget_with_app_server_thread( .replace_chat_widget_with_app_server_thread(
tui, app_server, resumed, /*initial_user_message*/ None, tui,
app_server,
resumed,
ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
) )
.await .await
{ {
+286 -119
View File
@@ -98,7 +98,12 @@ use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions; use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS; use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_protocol::user_input::TextElement; use codex_protocol::user_input::TextElement;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
@@ -5160,8 +5165,8 @@ async fn fresh_session_config_uses_current_service_tier() {
} }
#[tokio::test] #[tokio::test]
async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let user_cell = |text: &str, let user_cell = |text: &str,
text_elements: Vec<TextElement>, text_elements: Vec<TextElement>,
@@ -5246,6 +5251,11 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
]; ];
assert_eq!(user_count(&app.transcript_cells), 2); assert_eq!(user_count(&app.transcript_cells), 2);
let transcript_before: Vec<String> = app
.transcript_cells
.iter()
.map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80)))
.collect();
let base_id = ThreadId::new(); let base_id = ThreadId::new();
app.chat_widget app.chat_widget
@@ -5279,131 +5289,290 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() {
let selection = app let selection = app
.confirm_backtrack_from_main() .confirm_backtrack_from_main()
.expect("backtrack selection"); .expect("backtrack selection");
assert_eq!(selection.nth_user_message, 1); let expected = BacktrackSelection {
assert_eq!(selection.prefill, edited_text); thread_id: base_id,
assert_eq!(selection.text_elements, edited_text_elements); nth_user_message: 1,
assert_eq!(selection.local_image_paths, edited_local_image_paths); prompt: crate::chatwidget::UserMessage {
assert_eq!( text: edited_text,
selection.remote_image_urls, local_images: vec![crate::bottom_pane::LocalImageAttachment {
vec!["https://example.com/backtrack.png".to_string()] placeholder: placeholder.to_string(),
path: edited_local_image_paths[0].clone(),
}],
remote_image_urls: vec!["https://example.com/backtrack.png".to_string()],
text_elements: edited_text_elements,
mention_bindings: Vec::new(),
},
};
assert_eq!(selection, expected);
app.apply_backtrack_selection(selection);
let event = std::iter::from_fn(|| app_event_rx.try_recv().ok())
.find(|event| matches!(event, AppEvent::ForkSessionForPromptEdit { .. }))
.expect("prompt edit fork should be requested");
assert_matches!(
event,
AppEvent::ForkSessionForPromptEdit {
thread_id,
nth_user_message,
prompt,
} if thread_id == expected.thread_id
&& nth_user_message == expected.nth_user_message
&& prompt == expected.prompt
); );
app.apply_backtrack_rollback(selection); let transcript_after: Vec<String> = app
.transcript_cells
.iter()
.map(|cell| lines_to_single_string(&cell.display_lines(/*width*/ 80)))
.collect();
assert_eq!(transcript_after, transcript_before);
}
#[tokio::test]
async fn backtrack_branch_failure_restores_selected_prompt_snapshot() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
app.restore_backtrack_prompt_after_branch_error(
crate::chatwidget::UserMessage::from("edit this prompt"),
"branch unavailable",
);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"edit this prompt"
);
let cell = match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
assert_app_snapshot!(
"backtrack_branch_failure_restores_selected_prompt",
rendered
);
}
#[tokio::test]
async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let config = app.chat_widget.config_ref().clone();
let filename_ts = "2025-01-05T12-00-00";
let source_thread_id = app_test_support::create_fake_rollout(
config.codex_home.as_path(),
filename_ts,
"2025-01-05T12:00:00Z",
"unused preview",
Some("test-provider"),
/*git_info*/ None,
)
.expect("materialized rollout should be created");
let source_path =
app_test_support::rollout_path(config.codex_home.as_path(), filename_ts, &source_thread_id);
let session_meta = std::fs::read_to_string(&source_path)?
.lines()
.next()
.expect("fake rollout should have session metadata")
.to_string();
std::fs::write(&source_path, format!("{session_meta}\n"))?;
for (turn_id, message, images, local_images) in [
("turn-1", "retained prompt", None, Vec::new()),
(
"turn-2",
"selected prompt [Image #1]",
Some(vec!["https://example.com/backtrack.png".to_string()]),
vec![PathBuf::from("/tmp/fake-image.png")],
),
] {
for item in [
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::default(),
})),
RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent {
message: message.to_string(),
images,
local_images,
..Default::default()
})),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
last_agent_message: None,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
})),
] {
codex_rollout::append_rollout_item_to_path(&source_path, &item).await?;
}
}
let source_thread_id = ThreadId::from_string(&source_thread_id)?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
let started = app_server
.resume_thread(
config.clone(),
source_thread_id,
crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig,
)
.await?;
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
while app_event_rx.try_recv().is_ok() {}
let source_before = std::fs::read_to_string(&source_path)?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let prompt = crate::chatwidget::UserMessage {
text: "selected prompt [Image #1]".to_string(),
local_images: vec![crate::bottom_pane::LocalImageAttachment {
placeholder: "[Image #1]".to_string(),
path: PathBuf::from("/tmp/fake-image.png"),
}],
remote_image_urls: vec!["https://example.com/backtrack.png".to_string()],
text_elements: Vec::new(),
mention_bindings: Vec::new(),
};
let control = Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::ForkSessionForPromptEdit {
thread_id: source_thread_id,
nth_user_message: 1,
prompt: prompt.clone(),
},
))
.await?;
assert!(matches!(control, AppRunControl::Continue));
let forked_thread_id = app
.chat_widget
.thread_id()
.expect("prompt edit should switch to a forked thread");
assert_ne!(forked_thread_id, source_thread_id);
assert_eq!(app.chat_widget.composer_text_with_pending(), prompt.text);
assert_eq!( assert_eq!(
app.chat_widget.remote_image_urls(), app.chat_widget.remote_image_urls(),
vec!["https://example.com/backtrack.png".to_string()] prompt.remote_image_urls
);
assert_eq!(std::fs::read_to_string(&source_path)?, source_before);
assert_eq!(
app_server
.thread_read(source_thread_id, /*include_turns*/ true)
.await?
.turns
.iter()
.map(|turn| turn.id.as_str())
.collect::<Vec<_>>(),
vec!["turn-1", "turn-2"]
);
assert_eq!(
app_server
.thread_read(forked_thread_id, /*include_turns*/ true)
.await?
.turns
.iter()
.map(|turn| turn.id.as_str())
.collect::<Vec<_>>(),
vec!["turn-1"]
); );
let mut rollback_turns = None; let history = std::iter::from_fn(|| app_event_rx.try_recv().ok())
while let Ok(op) = op_rx.try_recv() { .filter_map(|event| match event {
if let Op::ThreadRollback { num_turns } = op { AppEvent::InsertHistoryCell(cell) => {
rollback_turns = Some(num_turns); Some(lines_to_single_string(&cell.display_lines(/*width*/ 120)))
} }
} _ => None,
})
.collect::<Vec<_>>();
let retained_index = history
.iter()
.position(|line| line.contains("retained prompt"))
.expect("forked history should replay the retained prompt");
let notice_index = history
.iter()
.position(|line| line == "• You’re continuing from this point in a new conversation")
.expect("prompt edit should emit the branch notice");
assert!(retained_index < notice_index);
assert!(
!history
.iter()
.any(|line| line.contains("Thread forked from"))
);
app_server.shutdown().await?;
assert_eq!(rollback_turns, Some(1)); Ok(())
} }
#[tokio::test] #[tokio::test]
async fn backtrack_remote_image_only_selection_clears_existing_composer_draft() { async fn prompt_edit_before_first_prompt_starts_fresh_thread() -> Result<()> {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let config = app.chat_widget.config_ref().clone();
app.transcript_cells = vec![Arc::new(UserHistoryCell { let source_thread_id = app_test_support::create_fake_rollout(
message: "original".to_string(), config.codex_home.as_path(),
text_elements: Vec::new(), "2025-01-05T12-00-00",
local_image_paths: Vec::new(), "2025-01-05T12:00:00Z",
remote_image_urls: Vec::new(), "first prompt",
}) as Arc<dyn HistoryCell>]; Some("test-provider"),
app.chat_widget /*git_info*/ None,
.set_composer_text("stale draft".to_string(), Vec::new(), Vec::new()); )
.expect("materialized rollout should be created");
let remote_image_url = "https://example.com/remote-only.png".to_string(); let source_thread_id = ThreadId::from_string(&source_thread_id)?;
app.apply_backtrack_rollback(BacktrackSelection { let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
nth_user_message: 0, let started = app_server
prefill: String::new(), .resume_thread(
text_elements: Vec::new(), config.clone(),
local_image_paths: Vec::new(), source_thread_id,
remote_image_urls: vec![remote_image_url.clone()], crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig,
});
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
assert_eq!(app.chat_widget.remote_image_urls(), vec![remote_image_url]);
let mut rollback_turns = None;
while let Ok(op) = op_rx.try_recv() {
if let Op::ThreadRollback { num_turns } = op {
rollback_turns = Some(num_turns);
}
}
assert_eq!(rollback_turns, Some(1));
}
#[tokio::test]
async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() {
let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
app.chat_widget
.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id,
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-test".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: None,
collaboration_mode: None,
personality: None,
message_history: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
});
let data_image_url = "data:image/png;base64,abc123".to_string();
app.transcript_cells = vec![Arc::new(UserHistoryCell {
message: "please inspect this".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec![data_image_url.clone()],
}) as Arc<dyn HistoryCell>];
app.apply_backtrack_rollback(BacktrackSelection {
nth_user_message: 0,
prefill: "please inspect this".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec![data_image_url.clone()],
});
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let mut saw_rollback = false;
let mut submitted_items: Option<Vec<UserInput>> = None;
while let Ok(op) = op_rx.try_recv() {
match op {
Op::ThreadRollback { .. } => saw_rollback = true,
Op::UserTurn { items, .. } => submitted_items = Some(items),
_ => {}
}
}
assert!(saw_rollback);
let items = submitted_items.expect("expected user turn after backtrack resubmit");
assert!(items.iter().any(|item| {
matches!(
item,
UserInput::Image { url, .. } if url == &data_image_url
) )
})); .await?;
app.enqueue_primary_thread_session(started.session, started.turns)
.await?;
while app_event_rx.try_recv().is_ok() {}
let mut tui = crate::tui::test_support::make_test_tui()?;
let control = Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::ForkSessionForPromptEdit {
thread_id: source_thread_id,
nth_user_message: 0,
prompt: crate::chatwidget::UserMessage::from("first prompt"),
},
))
.await?;
assert!(matches!(control, AppRunControl::Continue));
let fresh_thread_id = app
.chat_widget
.thread_id()
.expect("first prompt edit should start a fresh thread");
assert_ne!(fresh_thread_id, source_thread_id);
assert_eq!(app.chat_widget.composer_text_with_pending(), "first prompt");
let history = std::iter::from_fn(|| app_event_rx.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 120)))
}
_ => None,
})
.collect::<Vec<_>>();
assert!(
history.iter().any(|line| {
line == "• You’re continuing from this point in a new conversation"
})
);
assert!(
!history
.iter()
.any(|line| line.contains("Thread forked from"))
);
app_server.shutdown().await?;
Ok(())
} }
#[tokio::test] #[tokio::test]
@@ -5670,8 +5839,7 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
/*status_account_display*/ None, /*plan_type*/ None, /*status_account_display*/ None, /*plan_type*/ None,
/*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true,
); );
app.chat_widget app.chat_widget.insert_str("/usage daily");
.set_composer_text("/usage daily".to_string(), Vec::new(), Vec::new());
app.chat_widget app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.chat_widget app.chat_widget
@@ -6271,7 +6439,6 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
assert!(!app.has_emitted_history_lines); assert!(!app.has_emitted_history_lines);
assert!(!app.backtrack.primed); assert!(!app.backtrack.primed);
assert!(!app.backtrack.overlay_preview_active); assert!(!app.backtrack.overlay_preview_active);
assert!(app.backtrack.pending_rollback.is_none());
assert!(!app.backtrack_render_pending); assert!(!app.backtrack_render_pending);
assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt"); assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt");
+1 -2
View File
@@ -196,8 +196,7 @@ async fn stale_rate_limit_reads_preserve_newer_workspace_hard_stop_for_every_ori
credits: None, credits: None,
}), }),
); );
app.chat_widget app.chat_widget.insert_str("/usage");
.set_composer_text("/usage".to_string(), Vec::new(), Vec::new());
app.chat_widget app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.chat_widget app.chat_widget
+27 -43
View File
@@ -4,20 +4,13 @@
//! channels, submits thread-scoped operations through the app server, and replays buffered events //! channels, submits thread-scoped operations through the app server, and replays buffered events
//! when the visible thread changes. //! when the visible thread changes.
use super::session_lifecycle::ThreadAttachPresentation;
use super::*; use super::*;
use crate::session_resume::read_session_model; use crate::session_resume::read_session_model;
#[derive(Clone, Copy)]
pub(super) enum ThreadRollbackOrigin {
Backtrack,
SafetyBufferingRetry,
}
impl App { impl App {
pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) { pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) {
if let Some(thread_id) = self.chat_widget.thread_id() { if let Some(thread_id) = self.chat_widget.thread_id() {
// Clear any in-flight rollback guard when switching threads.
self.backtrack.pending_rollback = None;
if let Err(err) = app_server.thread_unsubscribe(thread_id).await { if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
tracing::warn!("failed to unsubscribe thread {thread_id}: {err}"); tracing::warn!("failed to unsubscribe thread {thread_id}: {err}");
} }
@@ -698,18 +691,6 @@ impl App {
.await?; .await?;
Ok(true) Ok(true)
} }
AppCommand::ThreadRollback { num_turns } => {
let response = match app_server.thread_rollback(thread_id, *num_turns).await {
Ok(response) => response,
Err(err) => {
self.handle_backtrack_rollback_failed();
return Err(err);
}
};
self.handle_thread_rollback_response(thread_id, *num_turns, &response)
.await;
Ok(true)
}
AppCommand::Review { target } => { AppCommand::Review { target } => {
let response = app_server.review_start(thread_id, target.clone()).await?; let response = app_server.review_start(thread_id, target.clone()).await?;
let review_thread_id = ThreadId::from_string(&response.review_thread_id) let review_thread_id = ThreadId::from_string(&response.review_thread_id)
@@ -1110,6 +1091,20 @@ impl App {
&mut self, &mut self,
session: ThreadSessionState, session: ThreadSessionState,
turns: Vec<Turn>, turns: Vec<Turn>,
) -> Result<()> {
self.enqueue_primary_thread_session_with_presentation(
session,
turns,
ThreadAttachPresentation::SessionLineage,
)
.await
}
pub(super) async fn enqueue_primary_thread_session_with_presentation(
&mut self,
session: ThreadSessionState,
turns: Vec<Turn>,
presentation: ThreadAttachPresentation,
) -> Result<()> { ) -> Result<()> {
let thread_id = session.thread_id; let thread_id = session.thread_id;
self.primary_thread_id = Some(thread_id); self.primary_thread_id = Some(thread_id);
@@ -1126,7 +1121,14 @@ impl App {
self.activate_thread_channel(thread_id).await; self.activate_thread_channel(thread_id).await;
self.chat_widget self.chat_widget
.set_initial_user_message_submit_suppressed(/*suppressed*/ true); .set_initial_user_message_submit_suppressed(/*suppressed*/ true);
self.chat_widget.handle_thread_session(session); match presentation {
ThreadAttachPresentation::SessionLineage => {
self.chat_widget.handle_thread_session(session);
}
ThreadAttachPresentation::PromptEdit => {
self.chat_widget.handle_prompt_edit_thread_session(session);
}
}
let should_buffer_initial_replay = !turns.is_empty(); let should_buffer_initial_replay = !turns.is_empty();
if should_buffer_initial_replay { if should_buffer_initial_replay {
self.app_event_tx self.app_event_tx
@@ -1138,6 +1140,9 @@ impl App {
self.app_event_tx self.app_event_tx
.send(AppEvent::EndInitialHistoryReplayBuffer); .send(AppEvent::EndInitialHistoryReplayBuffer);
} }
if matches!(presentation, ThreadAttachPresentation::PromptEdit) {
self.chat_widget.emit_prompt_edit_thread_event();
}
let pending = std::mem::take(&mut self.pending_primary_events); let pending = std::mem::take(&mut self.pending_primary_events);
for pending_event in pending { for pending_event in pending {
match pending_event { match pending_event {
@@ -1411,22 +1416,6 @@ impl App {
thread_id: ThreadId, thread_id: ThreadId,
num_turns: u32, num_turns: u32,
response: &ThreadRollbackResponse, response: &ThreadRollbackResponse,
) {
self.handle_thread_rollback_response_with_origin(
thread_id,
num_turns,
response,
ThreadRollbackOrigin::Backtrack,
)
.await;
}
pub(super) async fn handle_thread_rollback_response_with_origin(
&mut self,
thread_id: ThreadId,
num_turns: u32,
response: &ThreadRollbackResponse,
origin: ThreadRollbackOrigin,
) { ) {
if let Some(channel) = self.thread_event_channels.get(&thread_id) { if let Some(channel) = self.thread_event_channels.get(&thread_id) {
let mut store = channel.store.lock().await; let mut store = channel.store.lock().await;
@@ -1453,12 +1442,7 @@ impl App {
self.clear_active_thread().await; self.clear_active_thread().await;
} }
} }
match origin { self.apply_non_pending_thread_rollback(num_turns);
ThreadRollbackOrigin::Backtrack => self.handle_backtrack_rollback_succeeded(num_turns),
ThreadRollbackOrigin::SafetyBufferingRetry => {
self.apply_non_pending_thread_rollback(num_turns);
}
}
} }
pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) { pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) {
+343 -264
View File
@@ -3,18 +3,15 @@
//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also //! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also
//! mediates a key rendering boundary for the transcript overlay. //! mediates a key rendering boundary for the transcript overlay.
//! //!
//! Overall goal: keep the main chat view and the transcript overlay in sync while allowing //! Overall goal: keep the main chat view and the transcript overlay in sync while allowing users
//! users to "rewind" to an earlier user message. We stage a rollback request, wait for core to //! to edit an earlier prompt on a source-preserving branch. Confirming a selection forks through
//! confirm it, then trim the local transcript to the matching history boundary. This avoids UI //! the immediately preceding turn and restores the selected prompt in the new composer.
//! state diverging from the agent if a rollback fails or targets a different thread.
//! //!
//! Backtrack operates as a small state machine: //! Backtrack operates as a small state machine:
//! - The first `Esc` in the main view "primes" the feature and captures a base thread id. //! - The first `Esc` in the main view "primes" the feature and captures a base thread id.
//! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message when //! - A subsequent `Esc` opens the transcript overlay (`Ctrl+T`) and highlights a user message when
//! there is a rewind target. //! there is a prompt to reuse.
//! - `Enter` requests a rollback from core and records a `pending_rollback` guard. //! - `Enter` requests a fork before the selected prompt and reopens it for editing.
//! - On rollback completion, we either finish an in-flight backtrack request or queue a
//! rollback trim so it runs in event order with transcript inserts.
//! //!
//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live //! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live
//! tail derived from the current in-flight `ChatWidget.active_cell`. //! tail derived from the current in-flight `ChatWidget.active_cell`.
@@ -25,12 +22,14 @@
//! both committed history and in-flight activity without changing flush or coalescing behavior. //! both committed history and in-flight activity without changing flush or coalescing behavior.
use std::any::TypeId; use std::any::TypeId;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use crate::app::App; use crate::app::App;
use crate::app_command::AppCommand;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::bottom_pane::LocalImageAttachment;
use crate::chatwidget::ChatWidget;
use crate::chatwidget::UserMessage;
use crate::chatwidget::mention_bindings_from_user_inputs;
#[cfg(test)] #[cfg(test)]
use crate::history_cell::AgentMessageCell; use crate::history_cell::AgentMessageCell;
use crate::history_cell::SessionInfoCell; use crate::history_cell::SessionInfoCell;
@@ -38,9 +37,13 @@ use crate::history_cell::UserHistoryCell;
use crate::pager_overlay::Overlay; use crate::pager_overlay::Overlay;
use crate::tui; use crate::tui;
use crate::tui::TuiEvent; use crate::tui::TuiEvent;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
use codex_protocol::user_input::TextElement; use codex_protocol::models::local_image_label_text;
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use color_eyre::eyre::bail;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind; use crossterm::event::KeyEventKind;
@@ -54,7 +57,7 @@ pub(crate) const SIDE_EDIT_PREVIOUS_UNAVAILABLE_MESSAGE: &str =
pub(crate) struct BacktrackState { pub(crate) struct BacktrackState {
/// True when Esc has primed backtrack mode in the main view. /// True when Esc has primed backtrack mode in the main view.
pub(crate) primed: bool, pub(crate) primed: bool,
/// Session id of the base thread to rollback. /// Session id of the base thread whose transcript is being inspected.
/// ///
/// If the current thread changes, backtrack selections become invalid and must be ignored. /// If the current thread changes, backtrack selections become invalid and must be ignored.
pub(crate) base_id: Option<ThreadId>, pub(crate) base_id: Option<ThreadId>,
@@ -65,42 +68,15 @@ pub(crate) struct BacktrackState {
pub(crate) nth_user_message: usize, pub(crate) nth_user_message: usize,
/// True when the transcript overlay is showing a backtrack preview. /// True when the transcript overlay is showing a backtrack preview.
pub(crate) overlay_preview_active: bool, pub(crate) overlay_preview_active: bool,
/// Pending rollback request awaiting confirmation from core.
///
/// This acts as a guardrail: once we request a rollback, we block additional backtrack
/// submissions until core responds with either a success or failure event.
pub(crate) pending_rollback: Option<PendingBacktrackRollback>,
} }
/// A user-visible backtrack choice that can be confirmed into a rollback request. /// A user-visible backtrack choice that can be reopened on a source-preserving branch.
#[derive(Debug, Clone)] #[derive(Debug, Clone, PartialEq)]
pub(crate) struct BacktrackSelection { pub(crate) struct BacktrackSelection {
pub(crate) thread_id: ThreadId,
/// The selected user message, counted from the most recent session start. /// The selected user message, counted from the most recent session start.
///
/// This value is used both to compute the rollback depth and to trim the local transcript
/// after core confirms the rollback.
pub(crate) nth_user_message: usize, pub(crate) nth_user_message: usize,
/// Composer prefill derived from the selected user message. pub(crate) prompt: UserMessage,
///
/// This is applied immediately on selection confirmation; if the rollback fails, the prefill
/// remains as a convenience so the user can retry or edit.
pub(crate) prefill: String,
/// Text elements associated with the selected user message.
pub(crate) text_elements: Vec<TextElement>,
/// Local image paths associated with the selected user message.
pub(crate) local_image_paths: Vec<PathBuf>,
/// Remote image URLs associated with the selected user message.
pub(crate) remote_image_urls: Vec<String>,
}
/// An in-flight rollback requested from core.
///
/// We keep enough information to apply the corresponding local trim only if the response targets
/// the same active thread we issued the request for.
#[derive(Debug, Clone)]
pub(crate) struct PendingBacktrackRollback {
pub(crate) selection: BacktrackSelection,
pub(crate) thread_id: Option<ThreadId>,
} }
impl App { impl App {
@@ -184,14 +160,8 @@ impl App {
} }
} }
/// Stage a backtrack and request thread history from the agent. /// Request a source-preserving branch before the selected prompt.
/// pub(crate) fn apply_backtrack_selection(&mut self, selection: BacktrackSelection) {
/// We send the rollback request immediately, but we only mutate the transcript after core
/// confirms success so the UI cannot get ahead of the actual thread state.
///
/// The composer prefill is applied immediately as a UX convenience; it does not imply that
/// core has accepted the rollback.
pub(crate) fn apply_backtrack_rollback(&mut self, selection: BacktrackSelection) {
if self.chat_widget.side_conversation_active() { if self.chat_widget.side_conversation_active() {
self.reset_backtrack_state(); self.reset_backtrack_state();
self.chat_widget self.chat_widget
@@ -199,43 +169,26 @@ impl App {
return; return;
} }
let user_total = user_count(&self.transcript_cells); if self.chat_widget.thread_id() != Some(selection.thread_id) {
if user_total == 0 {
return; return;
} }
if self.backtrack.pending_rollback.is_some() { self.app_event_tx.send(AppEvent::ForkSessionForPromptEdit {
self.chat_widget thread_id: selection.thread_id,
.add_error_message("Backtrack rollback already in progress.".to_string()); nth_user_message: selection.nth_user_message,
return; prompt: selection.prompt,
}
let num_turns = user_total.saturating_sub(selection.nth_user_message);
let num_turns = u32::try_from(num_turns).unwrap_or(u32::MAX);
if num_turns == 0 {
return;
}
let prefill = selection.prefill.clone();
let text_elements = selection.text_elements.clone();
let local_image_paths = selection.local_image_paths.clone();
let remote_image_urls = selection.remote_image_urls.clone();
let has_remote_image_urls = !remote_image_urls.is_empty();
self.backtrack.pending_rollback = Some(PendingBacktrackRollback {
selection,
thread_id: self.chat_widget.thread_id(),
}); });
self.chat_widget }
.submit_op(AppCommand::thread_rollback(num_turns));
self.chat_widget.set_remote_image_urls(remote_image_urls); pub(crate) fn restore_backtrack_prompt_after_branch_error(
if !prefill.is_empty() &mut self,
|| !text_elements.is_empty() prompt: UserMessage,
|| !local_image_paths.is_empty() err: impl std::fmt::Display,
|| has_remote_image_urls ) {
{ self.chat_widget.restore_user_message_to_composer(prompt);
self.chat_widget self.chat_widget.add_error_message(format!(
.set_composer_text(prefill, text_elements, local_image_paths); "Failed to branch before the selected prompt: {err}"
} ));
} }
/// Open transcript overlay (enters alternate screen and shows full transcript). /// Open transcript overlay (enters alternate screen and shows full transcript).
@@ -431,7 +384,7 @@ impl App {
let selection = self.backtrack_selection(nth_user_message); let selection = self.backtrack_selection(nth_user_message);
self.close_transcript_overlay(tui); self.close_transcript_overlay(tui);
if let Some(selection) = selection { if let Some(selection) = selection {
self.apply_backtrack_rollback(selection); self.apply_backtrack_selection(selection);
tui.frame_requester().schedule_frame(); tui.frame_requester().schedule_frame();
} }
} }
@@ -461,7 +414,7 @@ impl App {
} }
/// Confirm a primed backtrack from the main view (no overlay visible). /// Confirm a primed backtrack from the main view (no overlay visible).
/// Computes the prefill from the selected user message for rollback. /// Computes the prompt state from the selected user message.
pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option<BacktrackSelection> { pub(crate) fn confirm_backtrack_from_main(&mut self) -> Option<BacktrackSelection> {
let selection = self.backtrack_selection(self.backtrack.nth_user_message); let selection = self.backtrack_selection(self.backtrack.nth_user_message);
self.reset_backtrack_state(); self.reset_backtrack_state();
@@ -477,30 +430,7 @@ impl App {
self.chat_widget.clear_esc_backtrack_hint(); self.chat_widget.clear_esc_backtrack_hint();
} }
pub(crate) fn apply_backtrack_selection( /// Apply rollback semantics for a confirmed safety-buffering retry rollback.
&mut self,
tui: &mut tui::Tui,
selection: BacktrackSelection,
) {
self.apply_backtrack_rollback(selection);
tui.frame_requester().schedule_frame();
}
pub(crate) fn handle_backtrack_rollback_succeeded(&mut self, num_turns: u32) {
if self.backtrack.pending_rollback.is_some() {
self.finish_pending_backtrack();
} else {
self.app_event_tx
.send(AppEvent::ApplyThreadRollback { num_turns });
}
}
pub(crate) fn handle_backtrack_rollback_failed(&mut self) {
self.backtrack.pending_rollback = None;
}
/// Apply rollback semantics for a confirmed rollback where this TUI does
/// not have an in-flight backtrack request (`pending_rollback` is `None`).
/// ///
/// Returns `true` when local transcript state changed. /// Returns `true` when local transcript state changed.
pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool { pub(crate) fn apply_non_pending_thread_rollback(&mut self, num_turns: u32) -> bool {
@@ -516,57 +446,35 @@ impl App {
true true
} }
/// Finish a pending rollback by applying the local trim and scheduling a scrollback refresh.
///
/// We ignore events that do not correspond to the currently active thread to avoid applying
/// stale updates after a session switch.
fn finish_pending_backtrack(&mut self) {
let Some(pending) = self.backtrack.pending_rollback.take() else {
return;
};
if pending.thread_id != self.chat_widget.thread_id() {
// Ignore rollbacks targeting a prior thread.
return;
}
if trim_transcript_cells_to_nth_user(
&mut self.transcript_cells,
pending.selection.nth_user_message,
) {
self.chat_widget.clear_pending_token_activity_refreshes();
self.chat_widget.clear_pending_rate_limit_reset_hint();
self.chat_widget
.truncate_agent_copy_history_to_user_turn_count(user_count(&self.transcript_cells));
self.sync_overlay_after_transcript_trim();
self.backtrack_render_pending = true;
}
}
fn backtrack_selection(&self, nth_user_message: usize) -> Option<BacktrackSelection> { fn backtrack_selection(&self, nth_user_message: usize) -> Option<BacktrackSelection> {
let base_id = self.backtrack.base_id?; let base_id = self.backtrack.base_id?;
if self.chat_widget.thread_id() != Some(base_id) { if self.chat_widget.thread_id() != Some(base_id) {
return None; return None;
} }
let (prefill, text_elements, local_image_paths, remote_image_urls) = let selected = nth_user_position(&self.transcript_cells, nth_user_message)
nth_user_position(&self.transcript_cells, nth_user_message) .and_then(|idx| self.transcript_cells.get(idx))
.and_then(|idx| self.transcript_cells.get(idx)) .and_then(|cell| cell.as_any().downcast_ref::<UserHistoryCell>())?;
.and_then(|cell| cell.as_any().downcast_ref::<UserHistoryCell>()) let local_images = selected
.map(|cell| { .local_image_paths
( .iter()
cell.message.clone(), .enumerate()
cell.text_elements.clone(), .map(|(index, path)| LocalImageAttachment {
cell.local_image_paths.clone(), placeholder: local_image_label_text(index + 1),
cell.remote_image_urls.clone(), path: path.clone(),
) })
}) .collect();
.unwrap_or_else(|| (String::new(), Vec::new(), Vec::new(), Vec::new()));
Some(BacktrackSelection { Some(BacktrackSelection {
thread_id: base_id,
nth_user_message, nth_user_message,
prefill, prompt: UserMessage {
text_elements, text: selected.message.clone(),
local_image_paths, local_images,
remote_image_urls, remote_image_urls: selected.remote_image_urls.clone(),
text_elements: selected.text_elements.clone(),
mention_bindings: Vec::new(),
},
}) })
} }
@@ -598,20 +506,80 @@ impl App {
} }
} }
fn trim_transcript_cells_to_nth_user( /// Find the persisted turn immediately before a selected transcript prompt.
transcript_cells: &mut Vec<Arc<dyn crate::history_cell::HistoryCell>>, ///
/// Replay hides review prompts and other display-empty inputs, so the selected ordinal must be
/// resolved against the same visible projection before restoring its canonical mention bindings.
///
/// A turn can contain multiple user messages when it was steered. Only its initial prompt can be
/// reopened independently because app-server cannot fork in the middle of a turn.
pub(crate) fn backtrack_fork_last_turn_id(
turns: &[Turn],
nth_user_message: usize, nth_user_message: usize,
) -> bool { prompt: &mut UserMessage,
if nth_user_message == usize::MAX { ) -> Result<Option<String>> {
return false; let mut visible_user_messages_seen = 0_usize;
let mut review_mode = false;
for (turn_index, turn) in turns.iter().enumerate() {
let mut user_messages_in_turn = 0_usize;
for item in &turn.items {
let content = match item {
ThreadItem::EnteredReviewMode { .. } => {
review_mode = true;
continue;
}
ThreadItem::ExitedReviewMode { .. } => {
review_mode = false;
continue;
}
ThreadItem::UserMessage { content, .. } => content,
_ => continue,
};
let is_steer = user_messages_in_turn > 0;
user_messages_in_turn = user_messages_in_turn.saturating_add(/*rhs*/ 1);
if review_mode {
continue;
}
let display = ChatWidget::user_message_display_from_inputs(content);
if display.message.trim().is_empty()
&& display.text_elements.is_empty()
&& display.local_images.is_empty()
&& display.remote_image_urls.is_empty()
{
continue;
}
if visible_user_messages_seen != nth_user_message {
visible_user_messages_seen =
visible_user_messages_seen.saturating_add(/*rhs*/ 1);
continue;
}
if is_steer {
bail!("the selected prompt is a steer and cannot be branched independently");
}
if matches!(turn.status, TurnStatus::InProgress) {
bail!("the selected prompt belongs to a turn that is still in progress");
}
let selected_local_images = prompt.local_images.iter().map(|image| &image.path);
if prompt.text != display.message
|| prompt.text_elements != display.text_elements
|| prompt.remote_image_urls != display.remote_image_urls
|| !selected_local_images.eq(display.local_images.iter())
{
bail!("the selected transcript prompt no longer matches the persisted thread");
}
prompt.mention_bindings = mention_bindings_from_user_inputs(content, &display.message);
return Ok(turn_index
.checked_sub(1)
.and_then(|index| turns.get(index))
.map(|turn| turn.id.clone()));
}
} }
if let Some(cut_idx) = nth_user_position(transcript_cells, nth_user_message) { bail!("the selected prompt was not found in the persisted thread")
let original_len = transcript_cells.len();
transcript_cells.truncate(cut_idx);
return transcript_cells.len() != original_len;
}
false
} }
pub(crate) fn trim_transcript_cells_drop_last_n_user_turns( pub(crate) fn trim_transcript_cells_drop_last_n_user_turns(
@@ -705,10 +673,13 @@ fn agent_group_positions_iter(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::bottom_pane::MentionBinding;
use crate::history_cell::AgentMessageCell; use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell; use crate::history_cell::HistoryCell;
use codex_app_server_protocol::UserInput;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use ratatui::prelude::Line; use ratatui::prelude::Line;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
fn render_lines(lines: &[Line<'static>]) -> Vec<String> { fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
@@ -723,120 +694,228 @@ mod tests {
.collect() .collect()
} }
#[test] fn turn(turn_id: &str, status: TurnStatus, user_messages: usize) -> Turn {
fn trim_transcript_for_first_user_drops_user_and_newer_cells() { Turn {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![ id: turn_id.to_string(),
Arc::new(UserHistoryCell { items: (0..user_messages)
message: "first user".to_string(), .map(|index| ThreadItem::UserMessage {
text_elements: Vec::new(), id: format!("user-{index}"),
local_image_paths: Vec::new(), client_id: None,
remote_image_urls: Vec::new(), content: vec![UserInput::Text {
}) as Arc<dyn HistoryCell>, text: format!("{turn_id}-prompt-{index}"),
Arc::new(AgentMessageCell::new( text_elements: Vec::new(),
vec![Line::from("assistant")], }],
/*is_first_line*/ true, })
)) as Arc<dyn HistoryCell>, .collect(),
]; items_view: codex_app_server_protocol::TurnItemsView::Full,
trim_transcript_cells_to_nth_user(&mut cells, /*nth_user_message*/ 0); status,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
}
}
assert!(cells.is_empty()); fn prompt(text: &str) -> UserMessage {
UserMessage {
text: text.to_string(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
mention_bindings: Vec::new(),
}
} }
#[test] #[test]
fn trim_transcript_preserves_cells_before_selected_user() { fn backtrack_fork_last_turn_id_resolves_first_and_later_prompts() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![ let turns = vec![
Arc::new(AgentMessageCell::new( turn("turn-1", TurnStatus::Completed, /*user_messages*/ 1),
vec![Line::from("intro")], turn(
/*is_first_line*/ true, "turn-compaction",
)) as Arc<dyn HistoryCell>, TurnStatus::Completed,
Arc::new(UserHistoryCell { /*user_messages*/ 0,
message: "first".to_string(), ),
text_elements: Vec::new(), turn("turn-2", TurnStatus::Completed, /*user_messages*/ 1),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>,
]; ];
trim_transcript_cells_to_nth_user(&mut cells, /*nth_user_message*/ 0);
assert_eq!(cells.len(), 1); assert_eq!(
let agent = cells[0] backtrack_fork_last_turn_id(
.as_any() &turns,
.downcast_ref::<AgentMessageCell>() /*nth_user_message*/ 0,
.expect("agent cell"); &mut prompt("turn-1-prompt-0"),
let agent_lines = agent.display_lines(u16::MAX); )
assert_eq!(agent_lines.len(), 1); .expect("first prompt should resolve"),
let intro_text: String = agent_lines[0] None
.spans );
.iter() assert_eq!(
.map(|span| span.content.as_ref()) backtrack_fork_last_turn_id(
.collect(); &turns,
assert_eq!(intro_text, "• intro"); /*nth_user_message*/ 1,
&mut prompt("turn-2-prompt-0"),
)
.expect("later prompt should resolve"),
Some("turn-compaction".to_string())
);
} }
#[test] #[test]
fn trim_transcript_for_later_user_keeps_prior_history() { fn backtrack_fork_last_turn_id_rejects_mid_turn_steers() {
let mut cells: Vec<Arc<dyn HistoryCell>> = vec![ let turns = vec![turn(
Arc::new(AgentMessageCell::new( "turn-1",
vec![Line::from("intro")], TurnStatus::Completed,
/*is_first_line*/ true, /*user_messages*/ 2,
)) as Arc<dyn HistoryCell>, )];
Arc::new(UserHistoryCell {
message: "first".to_string(), let error = backtrack_fork_last_turn_id(
text_elements: Vec::new(), &turns,
local_image_paths: Vec::new(), /*nth_user_message*/ 1,
remote_image_urls: Vec::new(), &mut prompt("turn-1-prompt-1"),
}) as Arc<dyn HistoryCell>, )
Arc::new(AgentMessageCell::new( .expect_err("a steer cannot be branched independently");
vec![Line::from("between")],
/*is_first_line*/ false, assert_eq!(
)) as Arc<dyn HistoryCell>, error.to_string(),
Arc::new(UserHistoryCell { "the selected prompt is a steer and cannot be branched independently"
message: "second".to_string(), );
text_elements: Vec::new(), }
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(), #[test]
}) as Arc<dyn HistoryCell>, fn backtrack_fork_last_turn_id_rejects_in_progress_and_missing_prompts() {
Arc::new(AgentMessageCell::new( let turns = vec![turn(
vec![Line::from("tail")], "turn-1",
/*is_first_line*/ false, TurnStatus::InProgress,
)) as Arc<dyn HistoryCell>, /*user_messages*/ 1,
)];
assert_eq!(
backtrack_fork_last_turn_id(
&turns,
/*nth_user_message*/ 0,
&mut prompt("turn-1-prompt-0"),
)
.expect_err("in-progress prompt cannot be branched")
.to_string(),
"the selected prompt belongs to a turn that is still in progress"
);
assert_eq!(
backtrack_fork_last_turn_id(
&turns,
/*nth_user_message*/ 1,
&mut prompt("missing prompt"),
)
.expect_err("missing prompt cannot be branched")
.to_string(),
"the selected prompt was not found in the persisted thread"
);
let completed_turns = vec![turn(
"turn-1",
TurnStatus::Completed,
/*user_messages*/ 1,
)];
assert_eq!(
backtrack_fork_last_turn_id(
&completed_turns,
/*nth_user_message*/ 0,
&mut prompt("different prompt"),
)
.expect_err("a stale transcript prompt cannot be branched")
.to_string(),
"the selected transcript prompt no longer matches the persisted thread"
);
}
#[test]
fn backtrack_fork_last_turn_id_skips_hidden_review_prompts() {
let mut review_turn = turn(
"turn-review",
TurnStatus::Completed,
/*user_messages*/ 1,
);
review_turn.items.insert(
/*index*/ 0,
ThreadItem::EnteredReviewMode {
id: "review-start".to_string(),
review: "changes against main".to_string(),
},
);
review_turn.items.push(ThreadItem::ExitedReviewMode {
id: "review-end".to_string(),
review: "review complete".to_string(),
});
let turns = vec![
turn("turn-1", TurnStatus::Completed, /*user_messages*/ 1),
review_turn,
turn("turn-2", TurnStatus::Completed, /*user_messages*/ 1),
]; ];
trim_transcript_cells_to_nth_user(&mut cells, /*nth_user_message*/ 1);
assert_eq!(cells.len(), 3); assert_eq!(
let agent_intro = cells[0] backtrack_fork_last_turn_id(
.as_any() &turns,
.downcast_ref::<AgentMessageCell>() /*nth_user_message*/ 1,
.expect("intro agent"); &mut prompt("turn-2-prompt-0"),
let intro_lines = agent_intro.display_lines(u16::MAX); )
let intro_text: String = intro_lines[0] .expect("the visible prompt after review should resolve"),
.spans Some("turn-review".to_string())
.iter() );
.map(|span| span.content.as_ref()) }
.collect();
assert_eq!(intro_text, "• intro");
let user_first = cells[1] #[test]
.as_any() fn backtrack_fork_last_turn_id_restores_canonical_mention_bindings() {
.downcast_ref::<UserHistoryCell>() let mut selected_turn = turn("turn-2", TurnStatus::Completed, /*user_messages*/ 1);
.expect("first user"); selected_turn.items = vec![ThreadItem::UserMessage {
assert_eq!(user_first.message, "first"); id: "selected-prompt".to_string(),
client_id: None,
content: vec![
UserInput::Text {
text: "use $skill @sample $google-calendar".to_string(),
text_elements: Vec::new(),
},
UserInput::Skill {
name: "skill".to_string(),
path: PathBuf::from("/tmp/skills/skill/SKILL.md"),
},
UserInput::Mention {
name: "Sample Plugin".to_string(),
path: "plugin://sample@test".to_string(),
},
UserInput::Mention {
name: "Google Calendar".to_string(),
path: "app://google_calendar".to_string(),
},
],
}];
let turns = vec![
turn("turn-1", TurnStatus::Completed, /*user_messages*/ 1),
selected_turn,
];
let mut selected_prompt = prompt("use $skill @sample $google-calendar");
let agent_between = cells[2] assert_eq!(
.as_any() backtrack_fork_last_turn_id(&turns, /*nth_user_message*/ 1, &mut selected_prompt,)
.downcast_ref::<AgentMessageCell>() .expect("the selected prompt should resolve"),
.expect("between agent"); Some("turn-1".to_string())
let between_lines = agent_between.display_lines(u16::MAX); );
let between_text: String = between_lines[0] assert_eq!(
.spans selected_prompt.mention_bindings,
.iter() vec![
.map(|span| span.content.as_ref()) MentionBinding {
.collect(); sigil: '$',
assert_eq!(between_text, " between"); mention: "skill".to_string(),
path: "/tmp/skills/skill/SKILL.md".to_string(),
},
MentionBinding {
sigil: '@',
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
},
MentionBinding {
sigil: '$',
mention: "google-calendar".to_string(),
path: "app://google_calendar".to_string(),
},
]
);
} }
#[test] #[test]
-7
View File
@@ -91,9 +91,6 @@ pub(crate) enum AppCommand {
name: String, name: String,
}, },
Shutdown, Shutdown,
ThreadRollback {
num_turns: u32,
},
Review { Review {
target: ReviewTarget, target: ReviewTarget,
}, },
@@ -240,10 +237,6 @@ impl AppCommand {
Self::Shutdown Self::Shutdown
} }
pub(crate) fn thread_rollback(num_turns: u32) -> Self {
Self::ThreadRollback { num_turns }
}
pub(crate) fn review(target: ReviewTarget) -> Self { pub(crate) fn review(target: ReviewTarget) -> Self {
Self::Review { target } Self::Review { target }
} }
+7 -9
View File
@@ -240,6 +240,13 @@ pub(crate) enum AppEvent {
/// Fork the current session into a new thread. /// Fork the current session into a new thread.
ForkCurrentSession, ForkCurrentSession,
/// Branch before a selected prompt and reopen it in the new thread's composer.
ForkSessionForPromptEdit {
thread_id: ThreadId,
nth_user_message: usize,
prompt: UserMessage,
},
/// Request to exit the application. /// Request to exit the application.
/// ///
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the /// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the
@@ -691,15 +698,6 @@ pub(crate) enum AppEvent {
/// finalization. /// finalization.
ConsolidateProposedPlan(String), ConsolidateProposedPlan(String),
/// Apply rollback semantics to local transcript cells.
///
/// This is emitted when rollback was not initiated by the current
/// backtrack flow so trimming occurs in AppEvent queue order relative to
/// inserted history cells.
ApplyThreadRollback {
num_turns: u32,
},
StartCommitAnimation, StartCommitAnimation,
StopCommitAnimation, StopCommitAnimation,
CommitTick, CommitTick,
+29 -6
View File
@@ -531,6 +531,26 @@ impl AppServerSession {
&mut self, &mut self,
config: Config, config: Config,
thread_id: ThreadId, thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
self.fork_thread_at(config, thread_id, /*last_turn_id*/ None)
.await
}
pub(crate) async fn fork_thread_after(
&mut self,
config: Config,
thread_id: ThreadId,
last_turn_id: String,
) -> Result<AppServerStartedThread> {
self.fork_thread_at(config, thread_id, /*last_turn_id*/ Some(last_turn_id))
.await
}
async fn fork_thread_at(
&mut self,
config: Config,
thread_id: ThreadId,
last_turn_id: Option<String>,
) -> Result<AppServerStartedThread> { ) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id(); let request_id = self.next_request_id();
let session_config = self.session_config_with_effective_service_tier(&config); let session_config = self.session_config_with_effective_service_tier(&config);
@@ -538,12 +558,15 @@ impl AppServerSession {
.client .client
.request_typed(ClientRequest::ThreadFork { .request_typed(ClientRequest::ThreadFork {
request_id, request_id,
params: thread_fork_params_from_config( params: ThreadForkParams {
session_config, last_turn_id,
thread_id, ..thread_fork_params_from_config(
self.thread_params_mode(), session_config,
self.remote_cwd_override.as_deref(), thread_id,
), self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
)
},
}) })
.await .await
.map_err(|err| { .map_err(|err| {
+3 -71
View File
@@ -163,8 +163,6 @@ use codex_terminal_detection::terminal_info;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::resume_hint; use codex_utils_cli::resume_hint;
use codex_utils_path_uri::PathUri; 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::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind; use crossterm::event::KeyEventKind;
@@ -436,6 +434,7 @@ use self::user_messages::UserMessageHistoryOverride;
use self::user_messages::UserMessageHistoryRecord; use self::user_messages::UserMessageHistoryRecord;
use self::user_messages::app_server_text_elements; use self::user_messages::app_server_text_elements;
pub(crate) use self::user_messages::create_initial_user_message; 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;
use self::user_messages::merge_user_messages_with_history_record; use self::user_messages::merge_user_messages_with_history_record;
#[cfg(test)] #[cfg(test)]
@@ -796,6 +795,7 @@ pub(crate) enum ReplayKind {
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SessionConfiguredDisplay { enum SessionConfiguredDisplay {
Normal, Normal,
PromptEdit,
/// Apply session state without emitting the session info cell. /// Apply session state without emitting the session info cell.
Quiet, Quiet,
SideConversation, SideConversation,
@@ -1262,69 +1262,13 @@ impl ChatWidget {
if self.review.is_review_mode { if self.review.is_review_mode {
return; 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 self.bottom_pane
.record_replayed_user_message_history(HistoryEntry { .record_replayed_user_message_history(HistoryEntry {
text: display.message.clone(), text: display.message.clone(),
text_elements: display.text_elements.clone(), text_elements: display.text_elements.clone(),
local_image_paths: display.local_images.clone(), local_image_paths: display.local_images.clone(),
remote_image_urls: display.remote_image_urls.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(), pending_pastes: Vec::new(),
}); });
self.on_user_message_display(display); self.on_user_message_display(display);
@@ -1745,18 +1689,6 @@ impl ChatWidget {
self.bottom_pane.insert_str(text); 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>) { pub(crate) fn set_remote_image_urls(&mut self, remote_image_urls: Vec<String>) {
self.bottom_pane.set_remote_image_urls(remote_image_urls); self.bottom_pane.set_remote_image_urls(remote_image_urls);
} }
+25 -1
View File
@@ -111,7 +111,10 @@ impl ChatWidget {
self.sync_goal_command_enabled(); self.sync_goal_command_enabled();
self.refresh_plugin_mentions(); self.refresh_plugin_mentions();
let model_for_header = self.current_model().to_string(); 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 startup_tooltip_override = self.startup_tooltip_override.take();
let show_fast_status = self let show_fast_status = self
.should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref()); .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) { pub(crate) fn handle_side_thread_session(&mut self, session: ThreadSessionState) {
self.instruction_source_paths = session.instruction_source_paths.clone(); self.instruction_source_paths = session.instruction_source_paths.clone();
let fork_parent_title = session.fork_parent_title.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( pub(super) fn on_thread_name_updated(
&mut self, &mut self,
thread_id: ThreadId, 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); 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] #[tokio::test]
async fn app_server_forked_thread_history_line_uses_app_server_title_snapshot() { 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; let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+15 -9
View File
@@ -1,6 +1,12 @@
use super::*; use super::*;
use pretty_assertions::assert_eq; 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] #[test]
fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() { fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() {
assert!(contains_plan_keyword("plan")); assert!(contains_plan_keyword("plan"));
@@ -14,19 +20,19 @@ fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() {
#[tokio::test] #[tokio::test]
async fn plan_mode_nudge_shows_only_for_eligible_default_mode_drafts() { 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; 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(); chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible()); 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(); chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible()); 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(); chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible()); 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()) let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
.expect("expected plan collaboration mode"); .expect("expected plan collaboration mode");
chat.set_collaboration_mask(plan_mask); chat.set_collaboration_mask(plan_mask);
@@ -37,7 +43,7 @@ async fn plan_mode_nudge_shows_only_for_eligible_default_mode_drafts() {
#[tokio::test] #[tokio::test]
async fn plan_mode_nudge_hides_while_task_or_modal_is_active() { 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; 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(); chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible()); 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 first_thread = ThreadId::new();
let second_thread = ThreadId::new(); let second_thread = ThreadId::new();
chat.thread_id = Some(first_thread); 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(); chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible()); 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] #[tokio::test]
async fn plan_mode_nudge_shift_tab_uses_existing_mode_cycle_path() { 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; 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(); chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible()); 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( chat.set_token_info(Some(make_token_info(
/*total_tokens*/ 50_000, /*context_window*/ 100_000, /*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(); chat.pre_draw_tick();
assert_chatwidget_snapshot!("plan_mode_nudge", render_bottom_popup(&chat, /*width*/ 80)); assert_chatwidget_snapshot!("plan_mode_nudge", render_bottom_popup(&chat, /*width*/ 80));
@@ -114,7 +120,7 @@ async fn plan_mode_nudge_snapshot() {
#[tokio::test] #[tokio::test]
async fn plan_mode_nudge_narrow_snapshot() { async fn plan_mode_nudge_narrow_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; 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(); chat.pre_draw_tick();
assert_chatwidget_snapshot!( 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::models::local_image_label_text;
use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement; 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; use super::ChatWidget;
@@ -525,11 +527,69 @@ pub(super) fn merge_user_messages_with_history_record(
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub(super) struct UserMessageDisplay { pub(crate) struct UserMessageDisplay {
pub(super) message: String, pub(crate) message: String,
pub(super) remote_image_urls: Vec<String>, pub(crate) remote_image_urls: Vec<String>,
pub(super) local_images: Vec<PathBuf>, pub(crate) local_images: Vec<PathBuf>,
pub(super) text_elements: Vec<TextElement>, 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)] #[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 message = String::new();
let mut remote_image_urls = Vec::new(); let mut remote_image_urls = Vec::new();
let mut local_images = Vec::new(); let mut local_images = Vec::new();
@@ -0,0 +1,5 @@
---
source: tui/src/app/tests.rs
expression: rendered
---
■ Failed to branch before the selected prompt: branch unavailable