mirror of
https://github.com/openai/codex.git
synced 2026-02-06 08:53:41 +00:00
Compare commits
9 Commits
queue/stee
...
jif/agent-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae1c6b6106 | ||
|
|
cc6179ec83 | ||
|
|
dc76e8fbe4 | ||
|
|
3ea40dc826 | ||
|
|
4a239bf8cc | ||
|
|
1e01a90eb1 | ||
|
|
7a2a86d968 | ||
|
|
3e071c460d | ||
|
|
afa4f6ff73 |
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user