Compare commits

...

9 Commits

Author SHA1 Message Date
jif-oai
ae1c6b6106 Fix tests 2026-01-06 20:14:27 +00:00
jif-oai
cc6179ec83 Bunch of cleaning 2026-01-06 20:05:04 +00:00
jif-oai
dc76e8fbe4 Merge remote-tracking branch 'origin/main' into jif/agent-control3 2026-01-06 19:56:20 +00:00
jif-oai
3ea40dc826 feat: add small tui on top of agent control to be able to spawn and switch between agents 2026-01-06 19:55:53 +00:00
jif-oai
4a239bf8cc fix merge 2026-01-06 19:17:36 +00:00
jif-oai
1e01a90eb1 Merge remote-tracking branch 'origin/main' into jif/agent-control2
# Conflicts:
#	codex-rs/core/src/agent/control.rs
#	codex-rs/core/src/agent/mod.rs
#	codex-rs/core/src/codex.rs
#	codex-rs/core/src/conversation_manager.rs
2026-01-06 19:12:14 +00:00
jif-oai
7a2a86d968 feat: drop agent bus and store the agent status in codex directly 2026-01-06 18:45:47 +00:00
jif-oai
3e071c460d NITs 2026-01-06 13:46:04 +00:00
jif-oai
afa4f6ff73 feat: agent controller 2026-01-06 13:30:29 +00:00
7 changed files with 626 additions and 230 deletions

View File

@@ -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<String>,
}
struct ConversationTab {
name: String,
chat_widget: ChatWidget,
transcript_cells: Vec<Arc<dyn HistoryCell>>,
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<ConversationManager>,
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<ConversationId, ConversationTab>,
conversation_order: Vec<ConversationId>,
pub(crate) auth_manager: Arc<AuthManager>,
/// 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<dyn HistoryCell>) {
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<SelectionItem> = 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<Line<'static>> = 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<Line<'static>> =
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<Line<'static>> =
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<dyn HistoryCell> = 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<dyn HistoryCell> = 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,

View File

@@ -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<PathBuf>,
},
/// 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<dyn HistoryCell>),
InsertHistoryCellForConversation {
conversation_id: ConversationId,
cell: Box<dyn HistoryCell>,
},
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),

View File

@@ -414,6 +414,13 @@ impl ChatComposer {
images.into_iter().map(|img| img.path).collect()
}
pub(crate) fn attached_image_paths(&self) -> Vec<PathBuf> {
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())
}

View File

@@ -549,6 +549,10 @@ impl BottomPane {
self.composer.take_recent_submission_images()
}
pub(crate) fn composer_attached_image_paths(&self) -> Vec<PathBuf> {
self.composer.attached_image_paths()
}
fn as_renderable(&'_ self) -> RenderableItem<'_> {
if let Some(view) = self.active_view() {
RenderableItem::Borrowed(view)

View File

@@ -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<PathBuf>) -> Optio
}
impl ChatWidget {
fn emit_history_cell(&self, cell: Box<dyn HistoryCell>) {
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,96 +1411,13 @@ impl ChatWidget {
}
}
pub(crate) fn new(
common: ChatWidgetInit,
conversation_manager: Arc<ConversationManager>,
) -> Self {
let ChatWidgetInit {
config,
frame_requester,
app_event_tx,
initial_prompt,
initial_images,
enhanced_keys_supported,
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 mut widget = Self {
app_event_tx: app_event_tx.clone(),
frame_requester: frame_requester.clone(),
codex_op_tx,
bottom_pane: BottomPane::new(BottomPaneParams {
frame_requester,
app_event_tx,
has_input_focus: true,
enhanced_keys_supported,
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
skills: None,
}),
active_cell: None,
config,
model: model.clone(),
auth_manager,
models_manager,
session_header: SessionHeader::new(model),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
),
token_info: None,
rate_limit_snapshot: None,
plan_type: None,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
rate_limit_poller: None,
stream_controller: None,
running_commands: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
task_complete_pending: false,
unified_exec_sessions: Vec::new(),
mcp_startup_status: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
current_status_header: String::from("Working"),
retry_status_header: None,
conversation_id: None,
queued_user_messages: VecDeque::new(),
show_welcome_banner: is_first_run,
suppress_session_configured_redraw: false,
pending_notification: None,
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
feedback,
current_rollout_path: None,
external_editor_state: ExternalEditorState::Closed,
};
widget.prefetch_rate_limits();
widget
}
/// Create a ChatWidget attached to an existing conversation (e.g., a fork).
pub(crate) fn new_from_existing(
common: ChatWidgetInit,
conversation: std::sync::Arc<codex_core::CodexConversation>,
session_configured: codex_core::protocol::SessionConfiguredEvent,
) -> Self {
let conversation_id = session_configured.session_id;
let ChatWidgetInit {
config,
frame_requester,
@@ -1504,6 +1431,8 @@ impl ChatWidget {
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,7 +1481,7 @@ 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,
@@ -1571,6 +1500,101 @@ impl ChatWidget {
widget
}
/// 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<codex_core::CodexConversation>,
session_configured: codex_core::protocol::SessionConfiguredEvent,
) -> Self {
let conversation_id = session_configured.session_id;
let ChatWidgetInit {
config,
frame_requester,
app_event_tx,
initial_prompt,
initial_images,
enhanced_keys_supported,
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_from_existing(conversation, session_configured, app_event_tx.clone());
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
frame_requester: frame_requester.clone(),
codex_op_tx,
bottom_pane: BottomPane::new(BottomPaneParams {
frame_requester,
app_event_tx,
has_input_focus: true,
enhanced_keys_supported,
placeholder_text: placeholder,
disable_paste_burst: config.disable_paste_burst,
animations_enabled: config.animations,
skills: None,
}),
active_cell: None,
config,
model: model.clone(),
auth_manager,
models_manager,
session_header: SessionHeader::new(model),
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
initial_images,
),
token_info: None,
rate_limit_snapshot: None,
plan_type: None,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
rate_limit_poller: None,
stream_controller: None,
running_commands: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
task_complete_pending: false,
unified_exec_sessions: Vec::new(),
mcp_startup_status: None,
interrupts: InterruptManager::new(),
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
current_status_header: String::from("Working"),
retry_status_header: None,
conversation_id: Some(conversation_id),
queued_user_messages: VecDeque::new(),
show_welcome_banner: is_first_run,
suppress_session_configured_redraw: false,
pending_notification: None,
is_review_mode: false,
pre_review_token_info: None,
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
feedback,
current_rollout_path: None,
external_editor_state: ExternalEditorState::Closed,
};
widget.prefetch_rate_limits();
widget
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
@@ -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<String>,
on_submit: Box<dyn Fn(String) + Send + Sync>,
) {
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<SelectionItem> = Vec::new();
@@ -3510,6 +3559,13 @@ impl ChatWidget {
.unwrap_or_default()
}
pub(crate) fn composer_draft(&self) -> (String, Vec<PathBuf>) {
(
self.bottom_pane.composer_text(),
self.bottom_pane.composer_attached_image_paths(),
)
}
pub(crate) fn conversation_id(&self) -> Option<ConversationId> {
self.conversation_id
}

View File

@@ -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<Op>` used by the UI to submit operations.
pub(crate) fn spawn_agent(
config: Config,
app_event_tx: AppEventSender,
server: Arc<ConversationManager>,
) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
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<CodexConversation>,
conversation: Arc<CodexConversation>,
session_configured: codex_core::protocol::SessionConfiguredEvent,
app_event_tx: AppEventSender,
) -> UnboundedSender<Op> {
let conversation_id = session_configured.session_id;
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
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,
});
}
});

View File

@@ -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::<AppEvent>();
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;