diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e0af51e7ebb..803f87eaa3d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2,6 +2,9 @@ use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::chatwidget::ChatWidget; use crate::chatwidget::ExternalEditorState; use crate::diff_render::DiffSummary; @@ -34,7 +37,6 @@ use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONF use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; -use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; @@ -52,6 +54,7 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; +use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -127,6 +130,14 @@ struct SessionSummary { resume_command: Option, } +struct ConversationTab { + name: String, + chat_widget: ChatWidget, + transcript_cells: Vec>, + unread_cells: usize, + backtrack: BacktrackState, +} + fn should_show_model_migration_prompt( current_model: &str, target_model: &str, @@ -289,6 +300,11 @@ pub(crate) struct App { pub(crate) server: Arc, pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, + active_conversation_id: ConversationId, + active_conversation_name: String, + active_unread_cells: usize, + inactive_conversations: HashMap, + conversation_order: Vec, pub(crate) auth_manager: Arc, /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, @@ -324,14 +340,202 @@ pub(crate) struct App { } impl App { + #[cfg(test)] async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { self.suppress_shutdown_complete = true; - self.chat_widget.submit_op(Op::Shutdown); + self.chat_widget + .submit_op(codex_core::protocol::Op::Shutdown); self.server.remove_conversation(&conversation_id).await; } } + fn insert_history_cell_for_active(&mut self, tui: &mut tui::Tui, cell: Arc) { + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_cell(cell.clone()); + tui.frame_requester().schedule_frame(); + } + + self.transcript_cells.push(cell.clone()); + + let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); + if display.is_empty() { + return; + } + + // Only insert a separating blank line for new cells that are not + // part of an ongoing stream. Streaming continuations should not + // accrue extra blank lines between chunks. + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); + } else { + self.has_emitted_history_lines = true; + } + } + + if self.overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } + } + + fn emit_pending_active_history(&mut self, tui: &mut tui::Tui) { + if self.overlay.is_some() { + return; + } + + for cell in self.transcript_cells.iter().cloned() { + let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); + if display.is_empty() { + continue; + } + if !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); + } else { + self.has_emitted_history_lines = true; + } + } + tui.insert_history_lines(display); + } + } + + fn switch_to_existing_conversation(&mut self, tui: &mut tui::Tui, id: ConversationId) { + if id == self.active_conversation_id { + return; + } + + if self.overlay.is_some() { + self.close_transcript_overlay(tui); + } + + let Some(mut tab) = self.inactive_conversations.remove(&id) else { + return; + }; + + let old_id = self.active_conversation_id; + + std::mem::swap(&mut self.chat_widget, &mut tab.chat_widget); + std::mem::swap(&mut self.transcript_cells, &mut tab.transcript_cells); + std::mem::swap(&mut self.active_conversation_name, &mut tab.name); + std::mem::swap(&mut self.active_unread_cells, &mut tab.unread_cells); + std::mem::swap(&mut self.backtrack, &mut tab.backtrack); + + self.active_conversation_id = id; + self.active_unread_cells = 0; + + self.inactive_conversations.insert(old_id, tab); + self.emit_pending_active_history(tui); + tui.frame_requester().schedule_frame(); + } + + fn switch_to_new_conversation( + &mut self, + tui: &mut tui::Tui, + id: ConversationId, + mut tab: ConversationTab, + ) { + if self.overlay.is_some() { + self.close_transcript_overlay(tui); + } + + let old_id = self.active_conversation_id; + + std::mem::swap(&mut self.chat_widget, &mut tab.chat_widget); + std::mem::swap(&mut self.transcript_cells, &mut tab.transcript_cells); + std::mem::swap(&mut self.active_conversation_name, &mut tab.name); + std::mem::swap(&mut self.active_unread_cells, &mut tab.unread_cells); + std::mem::swap(&mut self.backtrack, &mut tab.backtrack); + + self.active_conversation_id = id; + self.active_unread_cells = 0; + + self.inactive_conversations.insert(old_id, tab); + self.emit_pending_active_history(tui); + tui.frame_requester().schedule_frame(); + } + + fn open_conversation_picker(&mut self) { + let mut items: Vec = Vec::new(); + + for conversation_id in self.conversation_order.clone() { + let (name, unread) = if conversation_id == self.active_conversation_id { + ( + self.active_conversation_name.clone(), + self.active_unread_cells, + ) + } else if let Some(tab) = self.inactive_conversations.get(&conversation_id) { + (tab.name.clone(), tab.unread_cells) + } else { + continue; + }; + + let description = if unread > 0 { + Some(format!("{unread} unread update(s)")) + } else { + None + }; + + let display_name = if conversation_id == self.active_conversation_id { + format!("{name} (active)") + } else { + name.clone() + }; + + items.push(SelectionItem { + name: display_name.clone(), + description, + actions: vec![Box::new(move |tx: &AppEventSender| { + tx.send(AppEvent::SwitchConversation(conversation_id)); + })], + dismiss_on_select: true, + search_value: Some(display_name), + ..Default::default() + }); + } + + self.chat_widget.show_selection_view(SelectionViewParams { + title: Some("Conversations".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search conversations".to_string()), + ..Default::default() + }); + } + + fn open_new_conversation_prompt(&mut self) { + let (initial_prompt, initial_images) = self.chat_widget.composer_draft(); + + let tx = self.app_event_tx.clone(); + let initial_prompt = Arc::new(initial_prompt); + let initial_images = Arc::new(initial_images); + let default_name = format!("Conversation {}", self.conversation_order.len() + 1); + let title = String::from("New conversation"); + let hint = String::from("Name the conversation and press Enter"); + + self.chat_widget.show_custom_prompt_view( + title, + hint, + Some(default_name.clone()), + Box::new(move |name| { + let name = name.trim().to_string(); + let name = if name.is_empty() { + default_name.clone() + } else { + name + }; + tx.send(AppEvent::CreateConversation { + name, + initial_prompt: (*initial_prompt).clone(), + initial_images: (*initial_images).clone(), + }); + }), + ); + } + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, @@ -372,8 +576,15 @@ impl App { } let enhanced_keys_supported = tui.enhanced_keys_supported(); - let mut chat_widget = match resume_selection { + let (active_conversation_id, mut chat_widget) = match resume_selection { ResumeSelection::StartFresh | ResumeSelection::Exit => { + let mut conversation_config = config.clone(); + conversation_config.model = Some(model.clone()); + let created = conversation_manager + .new_conversation(conversation_config) + .await + .wrap_err("Failed to start new session")?; + let conversation_id = created.conversation_id; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), frame_requester: tui.frame_requester(), @@ -387,7 +598,14 @@ impl App { is_first_run, model: model.clone(), }; - ChatWidget::new(init, conversation_manager.clone()) + ( + conversation_id, + ChatWidget::new_from_new_conversation( + init, + created.conversation, + created.session_configured, + ), + ) } ResumeSelection::Resume(path) => { let resumed = conversation_manager @@ -400,6 +618,7 @@ impl App { .wrap_err_with(|| { format!("Failed to resume session from {}", path.display()) })?; + let conversation_id = resumed.conversation_id; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), frame_requester: tui.frame_requester(), @@ -413,10 +632,13 @@ impl App { is_first_run, model: model.clone(), }; - ChatWidget::new_from_existing( - init, - resumed.conversation, - resumed.session_configured, + ( + conversation_id, + ChatWidget::new_from_existing( + init, + resumed.conversation, + resumed.session_configured, + ), ) } }; @@ -431,6 +653,11 @@ impl App { server: conversation_manager.clone(), app_event_tx, chat_widget, + active_conversation_id, + active_conversation_name: String::from("Conversation 1"), + active_unread_cells: 0, + inactive_conversations: HashMap::new(), + conversation_order: vec![active_conversation_id], auth_manager: auth_manager.clone(), config, current_model: model.clone(), @@ -562,27 +789,10 @@ impl App { .await; match event { AppEvent::NewSession => { - let summary = session_summary( + if let Some(summary) = session_summary( self.chat_widget.token_usage(), self.chat_widget.conversation_id(), - ); - self.shutdown_current_conversation().await; - let init = crate::chatwidget::ChatWidgetInit { - config: self.config.clone(), - frame_requester: tui.frame_requester(), - app_event_tx: self.app_event_tx.clone(), - initial_prompt: None, - initial_images: Vec::new(), - enhanced_keys_supported: self.enhanced_keys_supported, - auth_manager: self.auth_manager.clone(), - models_manager: self.server.get_models_manager(), - feedback: self.feedback.clone(), - is_first_run: false, - model: self.current_model.clone(), - }; - self.chat_widget = ChatWidget::new(init, self.server.clone()); - self.current_model = model_family.get_model_slug().to_string(); - if let Some(summary) = summary { + ) { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { let spans = vec!["To continue this session, run ".into(), command.cyan()]; @@ -590,7 +800,13 @@ impl App { } self.chat_widget.add_plain_history_lines(lines); } - tui.frame_requester().schedule_frame(); + + let name = format!("Conversation {}", self.conversation_order.len() + 1); + self.app_event_tx.send(AppEvent::CreateConversation { + name, + initial_prompt: String::new(), + initial_images: Vec::new(), + }); } AppEvent::OpenResumePicker => { match crate::resume_picker::run_resume_picker( @@ -616,7 +832,22 @@ impl App { .await { Ok(resumed) => { - self.shutdown_current_conversation().await; + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + + let conversation_id = resumed.conversation_id; + let name = + format!("Conversation {}", self.conversation_order.len() + 1); let init = crate::chatwidget::ChatWidgetInit { config: self.config.clone(), frame_requester: tui.frame_requester(), @@ -630,24 +861,25 @@ impl App { is_first_run: false, model: self.current_model.clone(), }; - self.chat_widget = ChatWidget::new_from_existing( + let chat_widget = ChatWidget::new_from_existing( init, resumed.conversation, resumed.session_configured, ); self.current_model = model_family.get_model_slug().to_string(); - if let Some(summary) = summary { - let mut lines: Vec> = - vec![summary.usage_line.clone().into()]; - if let Some(command) = summary.resume_command { - let spans = vec![ - "To continue this session, run ".into(), - command.cyan(), - ]; - lines.push(spans.into()); - } - self.chat_widget.add_plain_history_lines(lines); + + if !self.conversation_order.contains(&conversation_id) { + self.conversation_order.push(conversation_id); } + + let tab = ConversationTab { + name, + chat_widget, + transcript_cells: Vec::new(), + unread_cells: 0, + backtrack: BacktrackState::default(), + }; + self.switch_to_new_conversation(tui, conversation_id, tab); } Err(err) => { self.chat_widget.add_error_message(format!( @@ -665,28 +897,18 @@ impl App { } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.insert_cell(cell.clone()); - tui.frame_requester().schedule_frame(); - } - self.transcript_cells.push(cell.clone()); - let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); - if !display.is_empty() { - // Only insert a separating blank line for new cells that are not - // part of an ongoing stream. Streaming continuations should not - // accrue extra blank lines between chunks. - if !cell.is_stream_continuation() { - if self.has_emitted_history_lines { - display.insert(0, Line::from("")); - } else { - self.has_emitted_history_lines = true; - } - } - if self.overlay.is_some() { - self.deferred_history_lines.extend(display); - } else { - tui.insert_history_lines(display); - } + self.insert_history_cell_for_active(tui, cell); + } + AppEvent::InsertHistoryCellForConversation { + conversation_id, + cell, + } => { + let cell: Arc = cell.into(); + if conversation_id == self.active_conversation_id { + self.insert_history_cell_for_active(tui, cell); + } else if let Some(tab) = self.inactive_conversations.get_mut(&conversation_id) { + tab.transcript_cells.push(cell); + tab.unread_cells = tab.unread_cells.saturating_add(1); } } AppEvent::StartCommitAnimation => { @@ -711,6 +933,36 @@ impl App { AppEvent::CommitTick => { self.chat_widget.on_commit_tick(); } + AppEvent::CodexEventForConversation { + conversation_id, + event, + } => { + if conversation_id == self.active_conversation_id + && self.suppress_shutdown_complete + && matches!(event.msg, EventMsg::ShutdownComplete) + { + self.suppress_shutdown_complete = false; + return Ok(true); + } + + if let EventMsg::ListSkillsResponse(response) = &event.msg { + let cwd = if conversation_id == self.active_conversation_id { + self.chat_widget.config_ref().cwd.clone() + } else if let Some(tab) = self.inactive_conversations.get(&conversation_id) { + tab.chat_widget.config_ref().cwd.clone() + } else { + self.chat_widget.config_ref().cwd.clone() + }; + let errors = errors_for_cwd(&cwd, response); + emit_skill_load_warnings(&self.app_event_tx, &errors); + } + + if conversation_id == self.active_conversation_id { + self.chat_widget.handle_codex_event(event); + } else if let Some(tab) = self.inactive_conversations.get_mut(&conversation_id) { + tab.chat_widget.handle_codex_event(event); + } + } AppEvent::CodexEvent(event) => { if self.suppress_shutdown_complete && matches!(event.msg, EventMsg::ShutdownComplete) @@ -725,6 +977,67 @@ impl App { } self.chat_widget.handle_codex_event(event); } + AppEvent::CreateConversation { + name, + initial_prompt, + initial_images, + } => { + let name = if name.trim().is_empty() { + format!("Conversation {}", self.conversation_order.len() + 1) + } else { + name + }; + + let mut config = self.config.clone(); + config.model = Some(self.current_model.clone()); + let created = match self.server.new_conversation(config).await { + Ok(v) => v, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to start new session: {err}",)); + return Ok(true); + } + }; + self.chat_widget.set_composer_text(String::new()); + let conversation_id = created.conversation_id; + + if !self.conversation_order.contains(&conversation_id) { + self.conversation_order.push(conversation_id); + } + + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: (!initial_prompt.is_empty()).then_some(initial_prompt), + initial_images, + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), + feedback: self.feedback.clone(), + is_first_run: false, + model: self.current_model.clone(), + }; + + let chat_widget = ChatWidget::new_from_new_conversation( + init, + created.conversation, + created.session_configured, + ); + + let tab = ConversationTab { + name, + chat_widget, + transcript_cells: Vec::new(), + unread_cells: 0, + backtrack: BacktrackState::default(), + }; + + self.switch_to_new_conversation(tui, conversation_id, tab); + } + AppEvent::SwitchConversation(conversation_id) => { + self.switch_to_existing_conversation(tui, conversation_id); + } AppEvent::ConversationHistory(ev) => { self.on_conversation_history_for_backtrack(tui, ev).await?; } @@ -832,7 +1145,7 @@ impl App { ); } else { self.app_event_tx.send(AppEvent::CodexOp( - Op::OverrideTurnContext { + codex_core::protocol::Op::OverrideTurnContext { cwd: None, approval_policy: Some(preset.approval), sandbox_policy: Some(preset.sandbox.clone()), @@ -1218,6 +1531,26 @@ impl App { async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { match key_event { + KeyEvent { + code: KeyCode::Char('o'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + if self.overlay.is_none() { + self.open_conversation_picker(); + } + } + KeyEvent { + code: KeyCode::Char('n'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + if self.overlay.is_none() { + self.open_new_conversation_prompt(); + } + } KeyEvent { code: KeyCode::Char('t'), modifiers: crossterm::event::KeyModifiers::CONTROL, @@ -1337,6 +1670,7 @@ mod tests { use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; + use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ConversationId; @@ -1349,6 +1683,7 @@ mod tests { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); let current_model = "gpt-5.2-codex".to_string(); + let active_conversation_id = ConversationId::new(); let server = Arc::new(ConversationManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1361,6 +1696,11 @@ mod tests { server, app_event_tx, chat_widget, + active_conversation_id, + active_conversation_name: String::from("Conversation 1"), + active_unread_cells: 0, + inactive_conversations: HashMap::new(), + conversation_order: vec![active_conversation_id], auth_manager, config, current_model, @@ -1388,6 +1728,7 @@ mod tests { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); let current_model = "gpt-5.2-codex".to_string(); + let active_conversation_id = ConversationId::new(); let server = Arc::new(ConversationManager::with_models_provider( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), @@ -1401,6 +1742,11 @@ mod tests { server, app_event_tx, chat_widget, + active_conversation_id, + active_conversation_name: String::from("Conversation 1"), + active_unread_cells: 0, + inactive_conversations: HashMap::new(), + conversation_order: vec![active_conversation_id], auth_manager, config, current_model, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1f99e372e97..dd4fc80a362 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -5,6 +5,7 @@ use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; +use codex_protocol::ConversationId; use codex_protocol::openai_models::ModelPreset; use crate::bottom_pane::ApprovalRequest; @@ -19,6 +20,10 @@ use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug)] pub(crate) enum AppEvent { CodexEvent(Event), + CodexEventForConversation { + conversation_id: ConversationId, + event: Event, + }, /// Start a new session. NewSession, @@ -33,6 +38,12 @@ pub(crate) enum AppEvent { /// bubbling channels through layers of widgets. CodexOp(codex_core::protocol::Op), + CreateConversation { + name: String, + initial_prompt: String, + initial_images: Vec, + }, + /// Kick off an asynchronous file search for the given query (text after /// the `@`). Previous searches may be cancelled by the app layer so there /// is at most one in-flight search. @@ -54,6 +65,11 @@ pub(crate) enum AppEvent { InsertHistoryCell(Box), + InsertHistoryCellForConversation { + conversation_id: ConversationId, + cell: Box, + }, + StartCommitAnimation, StopCommitAnimation, CommitTick, @@ -159,6 +175,9 @@ pub(crate) enum AppEvent { /// Forwarded conversation history snapshot from the current conversation. ConversationHistory(ConversationPathResponseEvent), + /// Switch active conversation tab. + SwitchConversation(ConversationId), + /// Open the branch picker option from the review popup. OpenReviewBranchPicker(PathBuf), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 0d5fe44064d..2597021b7fd 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -414,6 +414,13 @@ impl ChatComposer { images.into_iter().map(|img| img.path).collect() } + pub(crate) fn attached_image_paths(&self) -> Vec { + self.attached_images + .iter() + .map(|img| img.path.clone()) + .collect() + } + pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { self.handle_paste_burst_flush(Instant::now()) } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7634f699aa0..ec10a0fce2e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -549,6 +549,10 @@ impl BottomPane { self.composer.take_recent_submission_images() } + pub(crate) fn composer_attached_image_paths(&self) -> Vec { + self.composer.attached_image_paths() + } + fn as_renderable(&'_ self) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c83629976cf..e6d5c5ccb89 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -124,7 +124,6 @@ use crate::tui::FrameRequester; mod interrupts; use self::interrupts::InterruptManager; mod agent; -use self::agent::spawn_agent; use self::agent::spawn_agent_from_existing; mod session_header; use self::session_header::SessionHeader; @@ -136,7 +135,6 @@ use codex_common::approval_presets::ApprovalPreset; use codex_common::approval_presets::builtin_approval_presets; use codex_core::AuthManager; use codex_core::CodexAuth; -use codex_core::ConversationManager; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_file_search::FileMatch; @@ -402,6 +400,18 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio } impl ChatWidget { + fn emit_history_cell(&self, cell: Box) { + if let Some(conversation_id) = self.conversation_id { + self.app_event_tx + .send(AppEvent::InsertHistoryCellForConversation { + conversation_id, + cell, + }); + } else { + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + } + } + fn flush_answer_stream_with_separator(&mut self) { if let Some(mut controller) = self.stream_controller.take() && let Some(cell) = controller.finalize() @@ -1401,10 +1411,13 @@ impl ChatWidget { } } - pub(crate) fn new( + /// Create a ChatWidget attached to an existing conversation (e.g., a fork). + pub(crate) fn new_from_existing( common: ChatWidgetInit, - conversation_manager: Arc, + conversation: std::sync::Arc, + session_configured: codex_core::protocol::SessionConfiguredEvent, ) -> Self { + let conversation_id = session_configured.session_id; let ChatWidgetInit { config, frame_requester, @@ -1415,14 +1428,16 @@ impl ChatWidget { auth_manager, models_manager, feedback, - is_first_run, model, + .. } = common; let mut config = config; config.model = Some(model.clone()); let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); - let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), conversation_manager); + + let codex_op_tx = + spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -1466,10 +1481,10 @@ impl ChatWidget { full_reasoning_buffer: String::new(), current_status_header: String::from("Working"), retry_status_header: None, - conversation_id: None, + conversation_id: Some(conversation_id), queued_user_messages: VecDeque::new(), - show_welcome_banner: is_first_run, - suppress_session_configured_redraw: false, + show_welcome_banner: false, + suppress_session_configured_redraw: true, pending_notification: None, is_review_mode: false, pre_review_token_info: None, @@ -1485,12 +1500,17 @@ impl ChatWidget { widget } - /// Create a ChatWidget attached to an existing conversation (e.g., a fork). - pub(crate) fn new_from_existing( + /// Create a ChatWidget attached to a freshly-created conversation. + /// + /// This is like [`Self::new_from_existing`] but preserves the "new session" + /// experience (welcome banner + immediate redraw) while still allowing the + /// caller to create and track the conversation id. + pub(crate) fn new_from_new_conversation( common: ChatWidgetInit, conversation: std::sync::Arc, session_configured: codex_core::protocol::SessionConfiguredEvent, ) -> Self { + let conversation_id = session_configured.session_id; let ChatWidgetInit { config, frame_requester, @@ -1501,9 +1521,13 @@ impl ChatWidget { auth_manager, models_manager, feedback, + is_first_run, model, - .. } = common; + + let mut config = config; + config.model = Some(model.clone()); + let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1552,10 +1576,10 @@ impl ChatWidget { full_reasoning_buffer: String::new(), current_status_header: String::from("Working"), retry_status_header: None, - conversation_id: None, + conversation_id: Some(conversation_id), queued_user_messages: VecDeque::new(), - show_welcome_banner: false, - suppress_session_configured_redraw: true, + show_welcome_banner: is_first_run, + suppress_session_configured_redraw: false, pending_notification: None, is_review_mode: false, pre_review_token_info: None, @@ -1803,7 +1827,7 @@ impl ChatWidget { use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::FileChange; - self.app_event_tx.send(AppEvent::CodexEvent(Event { + let event = Event { id: "1".to_string(), // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { // call_id: "1".to_string(), @@ -1832,7 +1856,16 @@ impl ChatWidget { reason: None, grant_root: Some(PathBuf::from("/tmp")), }), - })); + }; + + if let Some(conversation_id) = self.conversation_id { + self.app_event_tx.send(AppEvent::CodexEventForConversation { + conversation_id, + event, + }); + } else { + self.app_event_tx.send(AppEvent::CodexEvent(event)); + } } } } @@ -1863,7 +1896,7 @@ impl ChatWidget { self.flush_wait_cell(); if let Some(active) = self.active_cell.take() { self.needs_final_message_separator = true; - self.app_event_tx.send(AppEvent::InsertHistoryCell(active)); + self.emit_history_cell(active); } } @@ -1884,8 +1917,7 @@ impl ChatWidget { self.needs_final_message_separator = true; let cell = history_cell::new_unified_exec_interaction(wait_cell.command_display(), String::new()); - self.app_event_tx - .send(AppEvent::InsertHistoryCell(Box::new(cell))); + self.emit_history_cell(Box::new(cell)); } pub(crate) fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { @@ -1898,7 +1930,7 @@ impl ChatWidget { self.flush_active_cell(); self.needs_final_message_separator = true; } - self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); + self.emit_history_cell(cell); } fn queue_user_message(&mut self, user_message: UserMessage) { @@ -3344,6 +3376,23 @@ impl ChatWidget { self.set_skills_from_response(&ev); } + pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) { + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + + pub(crate) fn show_custom_prompt_view( + &mut self, + title: String, + hint: String, + initial_text: Option, + on_submit: Box, + ) { + let view = CustomPromptView::new(title, hint, initial_text, on_submit); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); @@ -3510,6 +3559,13 @@ impl ChatWidget { .unwrap_or_default() } + pub(crate) fn composer_draft(&self) -> (String, Vec) { + ( + self.bottom_pane.composer_text(), + self.bottom_pane.composer_attached_image_paths(), + ) + } + pub(crate) fn conversation_id(&self) -> Option { self.conversation_id } diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs index 240972347fb..3b2132f7ccf 100644 --- a/codex-rs/tui/src/chatwidget/agent.rs +++ b/codex-rs/tui/src/chatwidget/agent.rs @@ -1,11 +1,6 @@ use std::sync::Arc; use codex_core::CodexConversation; -use codex_core::ConversationManager; -use codex_core::NewConversation; -use codex_core::config::Config; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::unbounded_channel; @@ -13,71 +8,15 @@ use tokio::sync::mpsc::unbounded_channel; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; -/// Spawn the agent bootstrapper and op forwarding loop, returning the -/// `UnboundedSender` used by the UI to submit operations. -pub(crate) fn spawn_agent( - config: Config, - app_event_tx: AppEventSender, - server: Arc, -) -> UnboundedSender { - let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); - - let app_event_tx_clone = app_event_tx; - tokio::spawn(async move { - let NewConversation { - conversation_id: _, - conversation, - session_configured, - } = match server.new_conversation(config).await { - Ok(v) => v, - #[allow(clippy::print_stderr)] - Err(err) => { - let message = err.to_string(); - eprintln!("{message}"); - app_event_tx_clone.send(AppEvent::CodexEvent(Event { - id: "".to_string(), - msg: EventMsg::Error(err.to_error_event(None)), - })); - app_event_tx_clone.send(AppEvent::ExitRequest); - tracing::error!("failed to initialize codex: {err}"); - return; - } - }; - - // Forward the captured `SessionConfigured` event so it can be rendered in the UI. - let ev = codex_core::protocol::Event { - // The `id` does not matter for rendering, so we can use a fake value. - id: "".to_string(), - msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), - }; - app_event_tx_clone.send(AppEvent::CodexEvent(ev)); - - let conversation_clone = conversation.clone(); - tokio::spawn(async move { - while let Some(op) = codex_op_rx.recv().await { - let id = conversation_clone.submit(op).await; - if let Err(e) = id { - tracing::error!("failed to submit op: {e}"); - } - } - }); - - while let Ok(event) = conversation.next_event().await { - app_event_tx_clone.send(AppEvent::CodexEvent(event)); - } - }); - - codex_op_tx -} - /// Spawn agent loops for an existing conversation (e.g., a forked conversation). /// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent /// events and accepts Ops for submission. pub(crate) fn spawn_agent_from_existing( - conversation: std::sync::Arc, + conversation: Arc, session_configured: codex_core::protocol::SessionConfiguredEvent, app_event_tx: AppEventSender, ) -> UnboundedSender { + let conversation_id = session_configured.session_id; let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); let app_event_tx_clone = app_event_tx; @@ -87,7 +26,10 @@ pub(crate) fn spawn_agent_from_existing( id: "".to_string(), msg: codex_core::protocol::EventMsg::SessionConfigured(session_configured), }; - app_event_tx_clone.send(AppEvent::CodexEvent(ev)); + app_event_tx_clone.send(AppEvent::CodexEventForConversation { + conversation_id, + event: ev, + }); let conversation_clone = conversation.clone(); tokio::spawn(async move { @@ -100,7 +42,10 @@ pub(crate) fn spawn_agent_from_existing( }); while let Ok(event) = conversation.next_event().await { - app_event_tx_clone.send(AppEvent::CodexEvent(event)); + app_event_tx_clone.send(AppEvent::CodexEventForConversation { + conversation_id, + event, + }); } }); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index b1356bd6c55..bf4b3181e14 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -7,6 +7,7 @@ use assert_matches::assert_matches; use codex_common::approval_presets::builtin_approval_presets; use codex_core::AuthManager; use codex_core::CodexAuth; +use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::Constrained; @@ -311,12 +312,17 @@ async fn context_indicator_shows_used_tokens_when_window_unknown() { async fn helpers_are_available_and_do_not_panic() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let cfg = test_config().await; + let mut cfg = test_config().await; let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + cfg.model = Some(resolved_model.clone()); let conversation_manager = Arc::new(ConversationManager::with_models_provider( CodexAuth::from_api_key("test"), cfg.model_provider.clone(), )); + let created = conversation_manager + .new_conversation(cfg.clone()) + .await + .unwrap(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let init = ChatWidgetInit { config: cfg, @@ -331,7 +337,11 @@ async fn helpers_are_available_and_do_not_panic() { is_first_run: true, model: resolved_model, }; - let mut w = ChatWidget::new(init, conversation_manager); + let mut w = ChatWidget::new_from_new_conversation( + init, + created.conversation, + created.session_configured, + ); // Basic construction sanity. let _ = &mut w; } @@ -439,6 +449,12 @@ fn drain_insert_history( lines.insert(0, "".into()); } out.push(lines) + } else if let AppEvent::InsertHistoryCellForConversation { cell, .. } = ev { + let mut lines = cell.display_lines(80); + if !cell.is_stream_continuation() && !out.is_empty() && !lines.is_empty() { + lines.insert(0, "".into()); + } + out.push(lines) } } out @@ -3619,12 +3635,15 @@ printf 'fenced within fenced\n' chat.on_commit_tick(); let mut inserted_any = false; while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = app_ev { - let lines = cell.display_lines(width); - crate::insert_history::insert_history_lines(&mut term, lines) - .expect("Failed to insert history lines in test"); - inserted_any = true; - } + let cell = match app_ev { + AppEvent::InsertHistoryCell(cell) => cell, + AppEvent::InsertHistoryCellForConversation { cell, .. } => cell, + _ => continue, + }; + let lines = cell.display_lines(width); + crate::insert_history::insert_history_lines(&mut term, lines) + .expect("Failed to insert history lines in test"); + inserted_any = true; } if !inserted_any { break;