mirror of
https://github.com/openai/codex.git
synced 2026-04-18 19:54:47 +00:00
Compare commits
7 Commits
pr17696
...
ccunningha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
542d94dc88 | ||
|
|
dd654587b2 | ||
|
|
32d589880e | ||
|
|
d85f889894 | ||
|
|
3f638d4ac9 | ||
|
|
f96d0f66d5 | ||
|
|
2ea2b940df |
@@ -146,12 +146,14 @@ use uuid::Uuid;
|
||||
mod agent_navigation;
|
||||
mod app_server_adapter;
|
||||
mod app_server_requests;
|
||||
mod btw;
|
||||
mod loaded_threads;
|
||||
mod pending_interactive_replay;
|
||||
|
||||
use self::agent_navigation::AgentNavigationDirection;
|
||||
use self::agent_navigation::AgentNavigationState;
|
||||
use self::app_server_requests::PendingAppServerRequests;
|
||||
use self::btw::BtwThreadState;
|
||||
use self::loaded_threads::find_loaded_subagent_threads_for_primary;
|
||||
use self::pending_interactive_replay::PendingInteractiveReplayState;
|
||||
|
||||
@@ -985,6 +987,7 @@ pub(crate) struct App {
|
||||
thread_event_channels: HashMap<ThreadId, ThreadEventChannel>,
|
||||
thread_event_listener_tasks: HashMap<ThreadId, JoinHandle<()>>,
|
||||
agent_navigation: AgentNavigationState,
|
||||
btw_threads: HashMap<ThreadId, BtwThreadState>,
|
||||
active_thread_id: Option<ThreadId>,
|
||||
active_thread_rx: Option<mpsc::Receiver<ThreadBufferedEvent>>,
|
||||
primary_thread_id: Option<ThreadId>,
|
||||
@@ -1667,6 +1670,7 @@ impl App {
|
||||
.agent_navigation
|
||||
.active_agent_label(self.current_displayed_thread_id(), self.primary_thread_id);
|
||||
self.chat_widget.set_active_agent_label(label);
|
||||
self.sync_btw_thread_ui();
|
||||
}
|
||||
|
||||
async fn thread_cwd(&self, thread_id: ThreadId) -> Option<PathBuf> {
|
||||
@@ -2633,6 +2637,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
for thread_id in thread_ids {
|
||||
if self.btw_threads.contains_key(&thread_id) {
|
||||
continue;
|
||||
}
|
||||
if !self
|
||||
.refresh_agent_picker_thread_liveness(app_server, thread_id)
|
||||
.await
|
||||
@@ -3009,7 +3016,10 @@ impl App {
|
||||
self.active_thread_rx = Some(receiver);
|
||||
|
||||
let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone());
|
||||
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
|
||||
let mut chat_widget = ChatWidget::new_with_app_event(init);
|
||||
let next_fork_banner_parent_label = self.take_next_btw_fork_banner_parent_label(thread_id);
|
||||
chat_widget.set_next_fork_banner_parent_label(next_fork_banner_parent_label);
|
||||
self.replace_chat_widget(chat_widget);
|
||||
|
||||
self.reset_for_thread_switch(tui)?;
|
||||
self.replay_thread_snapshot(snapshot, !is_replay_only);
|
||||
@@ -3053,6 +3063,7 @@ impl App {
|
||||
self.abort_all_thread_event_listeners();
|
||||
self.thread_event_channels.clear();
|
||||
self.agent_navigation.clear();
|
||||
self.btw_threads.clear();
|
||||
self.active_thread_id = None;
|
||||
self.active_thread_rx = None;
|
||||
self.primary_thread_id = None;
|
||||
@@ -3588,6 +3599,7 @@ impl App {
|
||||
thread_event_channels: HashMap::new(),
|
||||
thread_event_listener_tasks: HashMap::new(),
|
||||
agent_navigation: AgentNavigationState::default(),
|
||||
btw_threads: HashMap::new(),
|
||||
active_thread_id: None,
|
||||
active_thread_rx: None,
|
||||
primary_thread_id: None,
|
||||
@@ -5036,7 +5048,16 @@ impl App {
|
||||
self.open_agent_picker(app_server).await;
|
||||
}
|
||||
AppEvent::SelectAgentThread(thread_id) => {
|
||||
self.select_agent_thread(tui, app_server, thread_id).await?;
|
||||
self.select_agent_thread_and_discard_btw_chain(tui, app_server, thread_id)
|
||||
.await?;
|
||||
}
|
||||
AppEvent::StartBtw {
|
||||
parent_thread_id,
|
||||
user_message,
|
||||
} => {
|
||||
return self
|
||||
.handle_start_btw(tui, app_server, parent_thread_id, user_message)
|
||||
.await;
|
||||
}
|
||||
AppEvent::OpenSkillsList => {
|
||||
self.chat_widget.open_skills_list();
|
||||
@@ -5436,7 +5457,7 @@ impl App {
|
||||
self.active_non_primary_shutdown_target(notification)
|
||||
{
|
||||
self.mark_agent_picker_thread_closed(closed_thread_id);
|
||||
self.select_agent_thread(tui, app_server, primary_thread_id)
|
||||
self.select_agent_thread_and_discard_btw_chain(tui, app_server, primary_thread_id)
|
||||
.await?;
|
||||
if self.active_thread_id == Some(primary_thread_id) {
|
||||
self.chat_widget.add_info_message(
|
||||
@@ -5623,7 +5644,9 @@ impl App {
|
||||
.adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Previous)
|
||||
.await
|
||||
{
|
||||
let _ = self.select_agent_thread(tui, app_server, thread_id).await;
|
||||
let _ = self
|
||||
.select_agent_thread_and_discard_btw_chain(tui, app_server, thread_id)
|
||||
.await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -5638,7 +5661,9 @@ impl App {
|
||||
.adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Next)
|
||||
.await
|
||||
{
|
||||
let _ = self.select_agent_thread(tui, app_server, thread_id).await;
|
||||
let _ = self
|
||||
.select_agent_thread_and_discard_btw_chain(tui, app_server, thread_id)
|
||||
.await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -5698,7 +5723,23 @@ impl App {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
if self.chat_widget.is_normal_backtrack_mode()
|
||||
if self.maybe_return_from_btw(tui, app_server).await {
|
||||
} else if self.chat_widget.is_normal_backtrack_mode()
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
{
|
||||
self.handle_backtrack_esc_key(tui);
|
||||
} else {
|
||||
self.chat_widget.handle_key_event(key_event);
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
if self.maybe_return_from_btw(tui, app_server).await {
|
||||
} else if self.chat_widget.is_normal_backtrack_mode()
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
{
|
||||
self.handle_backtrack_esc_key(tui);
|
||||
@@ -8751,6 +8792,7 @@ guardian_approval = true
|
||||
thread_event_channels: HashMap::new(),
|
||||
thread_event_listener_tasks: HashMap::new(),
|
||||
agent_navigation: AgentNavigationState::default(),
|
||||
btw_threads: HashMap::new(),
|
||||
active_thread_id: None,
|
||||
active_thread_rx: None,
|
||||
primary_thread_id: None,
|
||||
@@ -8805,6 +8847,7 @@ guardian_approval = true
|
||||
thread_event_channels: HashMap::new(),
|
||||
thread_event_listener_tasks: HashMap::new(),
|
||||
agent_navigation: AgentNavigationState::default(),
|
||||
btw_threads: HashMap::new(),
|
||||
active_thread_id: None,
|
||||
active_thread_rx: None,
|
||||
primary_thread_id: None,
|
||||
|
||||
244
codex-rs/tui/src/app/btw.rs
Normal file
244
codex-rs/tui/src/app/btw.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
use super::*;
|
||||
use crate::chatwidget::InterruptedTurnNoticeMode;
|
||||
|
||||
const BTW_RENAME_BLOCK_MESSAGE: &str = "BTW threads are ephemeral and cannot be renamed.";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct BtwThreadState {
|
||||
/// Thread to return to when the current BTW thread is dismissed.
|
||||
pub(super) parent_thread_id: ThreadId,
|
||||
/// Pretty parent label for the next synthetic fork banner, consumed on first attach.
|
||||
pub(super) next_fork_banner_parent_label: Option<String>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn sync_btw_thread_ui(&mut self) {
|
||||
let clear_btw_ui = |chat_widget: &mut crate::chatwidget::ChatWidget| {
|
||||
chat_widget.set_thread_footer_hint_override(/*items*/ None);
|
||||
chat_widget.clear_thread_rename_block();
|
||||
chat_widget.set_interrupted_turn_notice_mode(InterruptedTurnNoticeMode::Default);
|
||||
};
|
||||
let Some(active_thread_id) = self.current_displayed_thread_id() else {
|
||||
clear_btw_ui(&mut self.chat_widget);
|
||||
return;
|
||||
};
|
||||
let Some(mut parent_thread_id) = self
|
||||
.btw_threads
|
||||
.get(&active_thread_id)
|
||||
.map(|state| state.parent_thread_id)
|
||||
else {
|
||||
clear_btw_ui(&mut self.chat_widget);
|
||||
return;
|
||||
};
|
||||
|
||||
self.chat_widget
|
||||
.set_thread_rename_block_message(BTW_RENAME_BLOCK_MESSAGE);
|
||||
self.chat_widget
|
||||
.set_interrupted_turn_notice_mode(InterruptedTurnNoticeMode::Suppress);
|
||||
let mut depth = 1usize;
|
||||
while let Some(next_parent_thread_id) = self
|
||||
.btw_threads
|
||||
.get(&parent_thread_id)
|
||||
.map(|state| state.parent_thread_id)
|
||||
{
|
||||
depth += 1;
|
||||
parent_thread_id = next_parent_thread_id;
|
||||
}
|
||||
let repeated_prefix = "BTW from ".repeat(depth.saturating_sub(1));
|
||||
let label = if self.primary_thread_id == Some(parent_thread_id) {
|
||||
format!("from {repeated_prefix}main thread · Esc to return")
|
||||
} else {
|
||||
let parent_label = self.thread_label(parent_thread_id);
|
||||
format!("from {repeated_prefix}parent thread ({parent_label}) · Esc to return")
|
||||
};
|
||||
self.chat_widget
|
||||
.set_thread_footer_hint_override(Some(vec![("BTW".to_string(), label)]));
|
||||
}
|
||||
|
||||
pub(super) fn active_btw_parent_thread_id(&self) -> Option<ThreadId> {
|
||||
self.current_displayed_thread_id()
|
||||
.and_then(|thread_id| self.btw_threads.get(&thread_id))
|
||||
.map(|state| state.parent_thread_id)
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_return_from_btw(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
) -> bool {
|
||||
if self.overlay.is_none()
|
||||
&& self.chat_widget.no_modal_or_popup_active()
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
&& let Some(parent_thread_id) = self.active_btw_parent_thread_id()
|
||||
{
|
||||
let _ = self
|
||||
.select_agent_thread_and_discard_btw_chain(tui, app_server, parent_thread_id)
|
||||
.await;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn btw_threads_to_discard_after_switch(
|
||||
&self,
|
||||
target_thread_id: ThreadId,
|
||||
) -> Vec<ThreadId> {
|
||||
let Some(mut btw_thread_id) = self.current_displayed_thread_id() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !self.btw_threads.contains_key(&btw_thread_id)
|
||||
|| self
|
||||
.btw_threads
|
||||
.get(&target_thread_id)
|
||||
.map(|state| state.parent_thread_id)
|
||||
== Some(btw_thread_id)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut btw_threads_to_discard = Vec::new();
|
||||
loop {
|
||||
btw_threads_to_discard.push(btw_thread_id);
|
||||
let Some(parent_thread_id) = self
|
||||
.btw_threads
|
||||
.get(&btw_thread_id)
|
||||
.map(|state| state.parent_thread_id)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if parent_thread_id == target_thread_id
|
||||
|| !self.btw_threads.contains_key(&parent_thread_id)
|
||||
{
|
||||
break;
|
||||
}
|
||||
btw_thread_id = parent_thread_id;
|
||||
}
|
||||
btw_threads_to_discard
|
||||
}
|
||||
|
||||
pub(super) fn take_next_btw_fork_banner_parent_label(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
) -> Option<String> {
|
||||
self.btw_threads
|
||||
.get_mut(&thread_id)
|
||||
.and_then(|state| state.next_fork_banner_parent_label.take())
|
||||
}
|
||||
|
||||
pub(super) async fn discard_btw_thread(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) {
|
||||
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
|
||||
tracing::warn!("failed to unsubscribe BTW thread {thread_id}: {err}");
|
||||
}
|
||||
self.abort_thread_event_listener(thread_id);
|
||||
self.thread_event_channels.remove(&thread_id);
|
||||
self.btw_threads.remove(&thread_id);
|
||||
if self.active_thread_id == Some(thread_id) {
|
||||
self.clear_active_thread().await;
|
||||
} else {
|
||||
self.refresh_pending_thread_approvals().await;
|
||||
}
|
||||
self.sync_active_agent_label();
|
||||
}
|
||||
|
||||
async fn fork_banner_parent_label(&self, parent_thread_id: ThreadId) -> Option<String> {
|
||||
if self.chat_widget.thread_id() == Some(parent_thread_id) {
|
||||
return self
|
||||
.chat_widget
|
||||
.thread_name()
|
||||
.filter(|name| !name.trim().is_empty());
|
||||
}
|
||||
|
||||
let channel = self.thread_event_channels.get(&parent_thread_id)?;
|
||||
let store = channel.store.lock().await;
|
||||
store
|
||||
.session
|
||||
.as_ref()
|
||||
.and_then(|session| session.thread_name.clone())
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
}
|
||||
|
||||
pub(super) async fn select_agent_thread_and_discard_btw_chain(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<()> {
|
||||
let btw_threads_to_discard = self.btw_threads_to_discard_after_switch(thread_id);
|
||||
self.select_agent_thread(tui, app_server, thread_id).await?;
|
||||
if self.active_thread_id == Some(thread_id) {
|
||||
for btw_thread_id in btw_threads_to_discard {
|
||||
self.discard_btw_thread(app_server, btw_thread_id).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn handle_start_btw(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
parent_thread_id: ThreadId,
|
||||
user_message: crate::chatwidget::UserMessage,
|
||||
) -> Result<AppRunControl> {
|
||||
self.session_telemetry.counter(
|
||||
"codex.thread.btw",
|
||||
/*inc*/ 1,
|
||||
&[("source", "slash_command")],
|
||||
);
|
||||
self.refresh_in_memory_config_from_disk_best_effort("starting a BTW subagent")
|
||||
.await;
|
||||
|
||||
let mut fork_config = self.config.clone();
|
||||
fork_config.ephemeral = true;
|
||||
match app_server.fork_thread(fork_config, parent_thread_id).await {
|
||||
Ok(forked) => {
|
||||
let child_thread_id = forked.session.thread_id;
|
||||
let next_fork_banner_parent_label =
|
||||
self.fork_banner_parent_label(parent_thread_id).await;
|
||||
let channel = self.ensure_thread_channel(child_thread_id);
|
||||
{
|
||||
let mut store = channel.store.lock().await;
|
||||
store.set_session(forked.session, forked.turns);
|
||||
}
|
||||
self.btw_threads.insert(
|
||||
child_thread_id,
|
||||
BtwThreadState {
|
||||
parent_thread_id,
|
||||
next_fork_banner_parent_label,
|
||||
},
|
||||
);
|
||||
if let Err(err) = self
|
||||
.select_agent_thread_and_discard_btw_chain(tui, app_server, child_thread_id)
|
||||
.await
|
||||
{
|
||||
self.discard_btw_thread(app_server, child_thread_id).await;
|
||||
return Err(err);
|
||||
}
|
||||
if self.active_thread_id == Some(child_thread_id) {
|
||||
let _ = self
|
||||
.chat_widget
|
||||
.submit_user_message_as_plain_user_turn(user_message);
|
||||
} else {
|
||||
self.discard_btw_thread(app_server, child_thread_id).await;
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to switch into BTW thread {child_thread_id}."
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.set_thread_footer_hint_override(/*items*/ None);
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to fork BTW thread from {parent_thread_id}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AppRunControl::Continue)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ use codex_utils_approval_presets::ApprovalPreset;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::StatusLineItem;
|
||||
use crate::bottom_pane::TerminalTitleItem;
|
||||
use crate::chatwidget::UserMessage;
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
use codex_core::config::types::ApprovalsReviewer;
|
||||
@@ -83,6 +84,12 @@ pub(crate) enum AppEvent {
|
||||
/// Switch the active thread to the selected agent.
|
||||
SelectAgentThread(ThreadId),
|
||||
|
||||
/// Fork the current thread into a transient BTW child and submit a side-question there.
|
||||
StartBtw {
|
||||
parent_thread_id: ThreadId,
|
||||
user_message: UserMessage,
|
||||
},
|
||||
|
||||
/// Submit an op to the specified thread, regardless of current focus.
|
||||
SubmitThreadOp {
|
||||
thread_id: ThreadId,
|
||||
|
||||
@@ -307,6 +307,7 @@ pub(crate) struct ChatComposer {
|
||||
disable_paste_burst: bool,
|
||||
footer_mode: FooterMode,
|
||||
footer_hint_override: Option<Vec<(String, String)>>,
|
||||
thread_footer_hint_override: Option<Vec<(String, String)>>,
|
||||
remote_image_urls: Vec<String>,
|
||||
/// Tracks keyboard selection for the remote-image rows so Up/Down + Delete/Backspace
|
||||
/// can highlight and remove remote attachments from the composer UI.
|
||||
@@ -431,6 +432,7 @@ impl ChatComposer {
|
||||
disable_paste_burst: false,
|
||||
footer_mode: FooterMode::ComposerEmpty,
|
||||
footer_hint_override: None,
|
||||
thread_footer_hint_override: None,
|
||||
remote_image_urls: Vec::new(),
|
||||
selected_remote_image_index: None,
|
||||
footer_flash: None,
|
||||
@@ -853,6 +855,14 @@ impl ChatComposer {
|
||||
self.footer_hint_override = items;
|
||||
}
|
||||
|
||||
/// Override the footer hint with thread-scoped UI, such as ephemeral BTW navigation state.
|
||||
///
|
||||
/// This lives below general footer overrides so temporary flows like external editor launch
|
||||
/// or realtime status can still take precedence.
|
||||
pub(crate) fn set_thread_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
|
||||
self.thread_footer_hint_override = items;
|
||||
}
|
||||
|
||||
pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) {
|
||||
self.remote_image_urls = urls;
|
||||
self.selected_remote_image_index = None;
|
||||
@@ -3519,12 +3529,16 @@ impl ChatComposer {
|
||||
} else {
|
||||
self.collaboration_mode_indicator
|
||||
};
|
||||
let active_footer_hint_override = self
|
||||
.footer_hint_override
|
||||
.as_ref()
|
||||
.or(self.thread_footer_hint_override.as_ref());
|
||||
let mut left_width = if self.footer_flash_visible() {
|
||||
self.footer_flash
|
||||
.as_ref()
|
||||
.map(|flash| flash.line.width() as u16)
|
||||
.unwrap_or(0)
|
||||
} else if let Some(items) = self.footer_hint_override.as_ref() {
|
||||
} else if let Some(items) = active_footer_hint_override {
|
||||
footer_hint_items_width(items)
|
||||
} else if status_line_active {
|
||||
truncated_status_line
|
||||
@@ -3573,7 +3587,7 @@ impl ChatComposer {
|
||||
let can_show_left_and_context =
|
||||
can_show_left_with_context(hint_rect, left_width, right_width);
|
||||
let has_override =
|
||||
self.footer_flash_visible() || self.footer_hint_override.is_some();
|
||||
self.footer_flash_visible() || active_footer_hint_override.is_some();
|
||||
let single_line_layout = if has_override || status_line_active {
|
||||
None
|
||||
} else {
|
||||
@@ -3649,7 +3663,7 @@ impl ChatComposer {
|
||||
if let Some(flash) = self.footer_flash.as_ref() {
|
||||
flash.line.render(inset_footer_hint_area(hint_rect), buf);
|
||||
}
|
||||
} else if let Some(items) = self.footer_hint_override.as_ref() {
|
||||
} else if let Some(items) = active_footer_hint_override {
|
||||
render_footer_hint_items(hint_rect, buf, items);
|
||||
} else if status_line_active {
|
||||
if let Some(line) = truncated_status_line {
|
||||
|
||||
@@ -710,9 +710,18 @@ pub(crate) fn footer_hint_items_width(items: &[(String, String)]) -> u16 {
|
||||
fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static> {
|
||||
let mut spans = Vec::with_capacity(items.len() * 4);
|
||||
for (idx, (key, label)) in items.iter().enumerate() {
|
||||
let is_btw = key == "BTW";
|
||||
spans.push(" ".into());
|
||||
spans.push(key.clone().bold());
|
||||
spans.push(format!(" {label}").into());
|
||||
spans.push(if is_btw {
|
||||
key.clone().magenta().bold()
|
||||
} else {
|
||||
key.clone().bold()
|
||||
});
|
||||
spans.push(if is_btw {
|
||||
format!(" {label}").magenta().bold()
|
||||
} else {
|
||||
format!(" {label}").into()
|
||||
});
|
||||
if idx + 1 != items.len() {
|
||||
spans.push(" ".into());
|
||||
}
|
||||
|
||||
@@ -597,6 +597,11 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn set_thread_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
|
||||
self.composer.set_thread_footer_hint_override(items);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) {
|
||||
self.composer.set_remote_image_urls(urls);
|
||||
self.request_redraw();
|
||||
|
||||
@@ -96,7 +96,6 @@ use codex_core::config::types::ApprovalsReviewer;
|
||||
use codex_core::config::types::Notifications;
|
||||
use codex_core::config::types::WindowsSandboxModeToml;
|
||||
use codex_core::config_loader::ConfigLayerStackOrdering;
|
||||
use codex_core::find_thread_name_by_id;
|
||||
use codex_core::plugins::PluginsManager;
|
||||
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
@@ -838,7 +837,11 @@ pub(crate) struct ChatWidget {
|
||||
suppress_queue_autosend: bool,
|
||||
thread_id: Option<ThreadId>,
|
||||
thread_name: Option<String>,
|
||||
thread_rename_block_message: Option<String>,
|
||||
forked_from: Option<ThreadId>,
|
||||
/// Pretty parent label used only for the next fork banner inserted on session configure.
|
||||
next_fork_banner_parent_label: Option<String>,
|
||||
interrupted_turn_notice_mode: InterruptedTurnNoticeMode,
|
||||
frame_requester: FrameRequester,
|
||||
// Whether to include the initial welcome banner on session configured
|
||||
show_welcome_banner: bool,
|
||||
@@ -1010,6 +1013,12 @@ pub(crate) struct UserMessage {
|
||||
mention_bindings: Vec<MentionBinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ShellEscapePolicy {
|
||||
Allow,
|
||||
Disallow,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
struct ThreadComposerState {
|
||||
text: String,
|
||||
@@ -1074,6 +1083,13 @@ struct PendingSteer {
|
||||
compare_key: PendingSteerCompareKey,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum InterruptedTurnNoticeMode {
|
||||
#[default]
|
||||
Default,
|
||||
Suppress,
|
||||
}
|
||||
|
||||
pub(crate) fn create_initial_user_message(
|
||||
text: Option<String>,
|
||||
local_image_paths: Vec<PathBuf>,
|
||||
@@ -2054,48 +2070,28 @@ impl ChatWidget {
|
||||
self.on_session_configured(thread_session_state_to_legacy_event(session));
|
||||
}
|
||||
|
||||
fn emit_forked_thread_event(&self, forked_from_id: ThreadId) {
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
let codex_home = self.config.codex_home.clone();
|
||||
tokio::spawn(async move {
|
||||
let forked_from_id_text = forked_from_id.to_string();
|
||||
let send_name_and_id = |name: String| {
|
||||
let line: Line<'static> = vec![
|
||||
"• ".dim(),
|
||||
"Thread forked from ".into(),
|
||||
name.cyan(),
|
||||
" (".into(),
|
||||
forked_from_id_text.clone().cyan(),
|
||||
")".into(),
|
||||
]
|
||||
.into();
|
||||
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
PlainHistoryCell::new(vec![line]),
|
||||
)));
|
||||
};
|
||||
let send_id_only = || {
|
||||
let line: Line<'static> = vec![
|
||||
"• ".dim(),
|
||||
"Thread forked from ".into(),
|
||||
forked_from_id_text.clone().cyan(),
|
||||
]
|
||||
.into();
|
||||
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
PlainHistoryCell::new(vec![line]),
|
||||
)));
|
||||
};
|
||||
|
||||
match find_thread_name_by_id(&codex_home, &forked_from_id).await {
|
||||
Ok(Some(name)) if !name.trim().is_empty() => {
|
||||
send_name_and_id(name);
|
||||
}
|
||||
Ok(_) => send_id_only(),
|
||||
Err(err) => {
|
||||
tracing::warn!("Failed to read forked thread name: {err}");
|
||||
send_id_only();
|
||||
}
|
||||
}
|
||||
});
|
||||
fn emit_forked_thread_event(&mut self, forked_from_id: ThreadId) {
|
||||
let line: Line<'static> = if let Some(label) = self.next_fork_banner_parent_label.take() {
|
||||
vec![
|
||||
"• ".dim(),
|
||||
"Thread forked from ".into(),
|
||||
label.cyan(),
|
||||
" (".into(),
|
||||
forked_from_id.to_string().cyan(),
|
||||
")".into(),
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
vec![
|
||||
"• ".dim(),
|
||||
"Thread forked from ".into(),
|
||||
forked_from_id.to_string().cyan(),
|
||||
]
|
||||
.into()
|
||||
};
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
PlainHistoryCell::new(vec![line]),
|
||||
)));
|
||||
}
|
||||
|
||||
fn on_thread_name_updated(&mut self, event: codex_protocol::protocol::ThreadNameUpdatedEvent) {
|
||||
@@ -3044,7 +3040,9 @@ impl ChatWidget {
|
||||
self.finalize_turn();
|
||||
let send_pending_steers_immediately = self.submit_pending_steers_after_interrupt;
|
||||
self.submit_pending_steers_after_interrupt = false;
|
||||
if reason != TurnAbortReason::ReviewEnded {
|
||||
if reason != TurnAbortReason::ReviewEnded
|
||||
&& self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress
|
||||
{
|
||||
if send_pending_steers_immediately {
|
||||
self.add_to_history(history_cell::new_info_event(
|
||||
"Model interrupted to submit steer instructions.".to_owned(),
|
||||
@@ -4762,7 +4760,10 @@ impl ChatWidget {
|
||||
suppress_queue_autosend: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
thread_rename_block_message: None,
|
||||
forked_from: None,
|
||||
next_fork_banner_parent_label: None,
|
||||
interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default,
|
||||
queued_user_messages: VecDeque::new(),
|
||||
rejected_steers_queue: VecDeque::new(),
|
||||
pending_steers: VecDeque::new(),
|
||||
@@ -5209,6 +5210,9 @@ impl ChatWidget {
|
||||
}
|
||||
self.open_collaboration_modes_popup();
|
||||
}
|
||||
SlashCommand::Btw => {
|
||||
self.add_error_message("Usage: /btw <question>".to_string());
|
||||
}
|
||||
SlashCommand::Agent | SlashCommand::MultiAgents => {
|
||||
self.app_event_tx.send(AppEvent::OpenAgentPicker);
|
||||
}
|
||||
@@ -5473,6 +5477,9 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
SlashCommand::Rename if !trimmed.is_empty() => {
|
||||
if !self.ensure_thread_rename_allowed() {
|
||||
return;
|
||||
}
|
||||
self.session_telemetry
|
||||
.counter("codex.thread.rename", /*inc*/ 1, &[]);
|
||||
let Some((prepared_args, _prepared_elements)) = self
|
||||
@@ -5496,23 +5503,9 @@ impl ChatWidget {
|
||||
if self.active_mode_kind() != ModeKind::Plan {
|
||||
return;
|
||||
}
|
||||
let Some((prepared_args, prepared_elements)) = self
|
||||
.bottom_pane
|
||||
.prepare_inline_args_submission(/*record_history*/ true)
|
||||
else {
|
||||
let Some(user_message) = self.prepare_inline_submission_user_message() else {
|
||||
return;
|
||||
};
|
||||
let local_images = self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images_with_placeholders();
|
||||
let remote_image_urls = self.take_remote_image_urls();
|
||||
let user_message = UserMessage {
|
||||
text: prepared_args,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
text_elements: prepared_elements,
|
||||
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
|
||||
};
|
||||
if self.is_session_configured() {
|
||||
self.reasoning_buffer.clear();
|
||||
self.full_reasoning_buffer.clear();
|
||||
@@ -5522,6 +5515,26 @@ impl ChatWidget {
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
}
|
||||
SlashCommand::Btw if !trimmed.is_empty() => {
|
||||
let Some(parent_thread_id) = self.thread_id else {
|
||||
self.add_error_message(
|
||||
"BTW is unavailable before the session starts.".to_string(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(user_message) = self.prepare_inline_submission_user_message() else {
|
||||
return;
|
||||
};
|
||||
self.set_thread_footer_hint_override(Some(vec![(
|
||||
"BTW".to_string(),
|
||||
"starting...".to_string(),
|
||||
)]));
|
||||
self.request_redraw();
|
||||
self.app_event_tx.send(AppEvent::StartBtw {
|
||||
parent_thread_id,
|
||||
user_message,
|
||||
});
|
||||
}
|
||||
SlashCommand::Review if !trimmed.is_empty() => {
|
||||
let Some((prepared_args, _prepared_elements)) = self
|
||||
.bottom_pane
|
||||
@@ -5555,6 +5568,9 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn show_rename_prompt(&mut self) {
|
||||
if !self.ensure_thread_rename_allowed() {
|
||||
return;
|
||||
}
|
||||
let tx = self.app_event_tx.clone();
|
||||
let has_name = self
|
||||
.thread_name
|
||||
@@ -5586,6 +5602,34 @@ impl ChatWidget {
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
}
|
||||
|
||||
fn ensure_thread_rename_allowed(&mut self) -> bool {
|
||||
match self.thread_rename_block_message.clone() {
|
||||
Some(message) => {
|
||||
self.add_error_message(message);
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_inline_submission_user_message(&mut self) -> Option<UserMessage> {
|
||||
let (text, text_elements) = self
|
||||
.bottom_pane
|
||||
.prepare_inline_args_submission(/*record_history*/ true)?;
|
||||
let local_images = self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images_with_placeholders();
|
||||
let remote_image_urls = self.take_remote_image_urls();
|
||||
let mention_bindings = self.bottom_pane.take_recent_submission_mention_bindings();
|
||||
Some(UserMessage {
|
||||
text,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
text_elements,
|
||||
mention_bindings,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
@@ -5646,11 +5690,27 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
let _ = self
|
||||
.submit_user_message_with_shell_escape_policy(user_message, ShellEscapePolicy::Allow);
|
||||
}
|
||||
|
||||
pub(crate) fn submit_user_message_as_plain_user_turn(
|
||||
&mut self,
|
||||
user_message: UserMessage,
|
||||
) -> Option<AppCommand> {
|
||||
self.submit_user_message_with_shell_escape_policy(user_message, ShellEscapePolicy::Disallow)
|
||||
}
|
||||
|
||||
fn submit_user_message_with_shell_escape_policy(
|
||||
&mut self,
|
||||
user_message: UserMessage,
|
||||
shell_escape_policy: ShellEscapePolicy,
|
||||
) -> Option<AppCommand> {
|
||||
if !self.is_session_configured() {
|
||||
tracing::warn!("cannot submit user message before session is configured; queueing");
|
||||
self.queued_user_messages.push_front(user_message);
|
||||
self.refresh_pending_input_preview();
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let UserMessage {
|
||||
text,
|
||||
@@ -5660,7 +5720,7 @@ impl ChatWidget {
|
||||
mention_bindings,
|
||||
} = user_message;
|
||||
if text.is_empty() && local_images.is_empty() && remote_image_urls.is_empty() {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
if (!local_images.is_empty() || !remote_image_urls.is_empty())
|
||||
&& !self.current_model_supports_images()
|
||||
@@ -5672,14 +5732,16 @@ impl ChatWidget {
|
||||
mention_bindings,
|
||||
remote_image_urls,
|
||||
);
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
|
||||
let render_in_history = !self.agent_turn_running;
|
||||
let mut items: Vec<UserInput> = Vec::new();
|
||||
|
||||
// Special-case: "!cmd" executes a local shell command instead of sending to the model.
|
||||
if let Some(stripped) = text.strip_prefix('!') {
|
||||
if shell_escape_policy == ShellEscapePolicy::Allow
|
||||
&& let Some(stripped) = text.strip_prefix('!')
|
||||
{
|
||||
let cmd = stripped.trim();
|
||||
if cmd.is_empty() {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
@@ -5688,10 +5750,11 @@ impl ChatWidget {
|
||||
Some(USER_SHELL_COMMAND_HELP_HINT.to_string()),
|
||||
),
|
||||
)));
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
self.submit_op(AppCommand::run_user_shell_command(cmd.to_string()));
|
||||
return;
|
||||
let app_command = AppCommand::run_user_shell_command(cmd.to_string());
|
||||
self.submit_op(app_command.clone());
|
||||
return Some(app_command);
|
||||
}
|
||||
|
||||
for image_url in &remote_image_urls {
|
||||
@@ -5824,7 +5887,7 @@ impl ChatWidget {
|
||||
self.add_error_message(
|
||||
"Thread model is unavailable. Wait for the thread to finish syncing or choose a model before sending input.".to_string(),
|
||||
);
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let collaboration_mode = if self.collaboration_modes_enabled() {
|
||||
self.active_collaboration_mask
|
||||
@@ -5863,8 +5926,8 @@ impl ChatWidget {
|
||||
personality,
|
||||
);
|
||||
|
||||
if !self.submit_op(op) {
|
||||
return;
|
||||
if !self.submit_op(op.clone()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Persist the text to cross-session message history. Mentions are
|
||||
@@ -5924,6 +5987,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
self.needs_final_message_separator = false;
|
||||
Some(op)
|
||||
}
|
||||
|
||||
/// Restore the blocked submission draft without losing mention resolution state.
|
||||
@@ -6594,10 +6658,10 @@ impl ChatWidget {
|
||||
| ServerNotification::McpToolCallProgress(_)
|
||||
| ServerNotification::McpServerOauthLoginCompleted(_)
|
||||
| ServerNotification::AppListUpdated(_)
|
||||
| ServerNotification::FsChanged(_)
|
||||
| ServerNotification::ContextCompacted(_)
|
||||
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
|
||||
| ServerNotification::FuzzyFileSearchSessionCompleted(_)
|
||||
| ServerNotification::FsChanged(_)
|
||||
| ServerNotification::ThreadRealtimeTranscriptUpdated(_)
|
||||
| ServerNotification::WindowsWorldWritableWarning(_)
|
||||
| ServerNotification::WindowsSandboxSetupCompleted(_)
|
||||
@@ -7351,6 +7415,26 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_pending_thread_approvals(threads);
|
||||
}
|
||||
|
||||
pub(crate) fn set_thread_footer_hint_override(&mut self, items: Option<Vec<(String, String)>>) {
|
||||
self.bottom_pane.set_thread_footer_hint_override(items);
|
||||
}
|
||||
|
||||
pub(crate) fn clear_thread_rename_block(&mut self) {
|
||||
self.thread_rename_block_message = None;
|
||||
}
|
||||
|
||||
pub(crate) fn set_thread_rename_block_message(&mut self, message: impl Into<String>) {
|
||||
self.thread_rename_block_message = Some(message.into());
|
||||
}
|
||||
|
||||
pub(crate) fn set_next_fork_banner_parent_label(&mut self, label: Option<String>) {
|
||||
self.next_fork_banner_parent_label = label;
|
||||
}
|
||||
|
||||
pub(crate) fn set_interrupted_turn_notice_mode(&mut self, mode: InterruptedTurnNoticeMode) {
|
||||
self.interrupted_turn_notice_mode = mode;
|
||||
}
|
||||
|
||||
pub(crate) fn add_diff_in_progress(&mut self) {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" BTW from main thread · Esc to return "
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" Save and close external editor to continue. "
|
||||
@@ -2,4 +2,4 @@
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
• Thread forked from named-thread (e9f18a88-8081-4e51-9d4e-8af5cde2d8dd)
|
||||
• Thread forked from named parent (e9f18a88-8081-4e51-9d4e-8af5cde2d8dd)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
" "
|
||||
"• Working (0s • esc to interrupt) "
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" BTW starting... "
|
||||
@@ -199,6 +199,8 @@ use tokio::sync::mpsc::error::TryRecvError;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
mod btw;
|
||||
|
||||
async fn test_config() -> Config {
|
||||
// Use base defaults to avoid depending on host state.
|
||||
let codex_home = std::env::temp_dir();
|
||||
@@ -682,33 +684,7 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce
|
||||
|
||||
#[tokio::test]
|
||||
async fn forked_thread_history_line_includes_name_and_id_snapshot() {
|
||||
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let mut chat = chat;
|
||||
let temp = tempdir().expect("tempdir");
|
||||
chat.config.codex_home = temp.path().to_path_buf();
|
||||
|
||||
let forked_from_id =
|
||||
ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id");
|
||||
let session_index_entry = format!(
|
||||
"{{\"id\":\"{forked_from_id}\",\"thread_name\":\"named-thread\",\"updated_at\":\"2024-01-02T00:00:00Z\"}}\n"
|
||||
);
|
||||
std::fs::write(temp.path().join("session_index.jsonl"), session_index_entry)
|
||||
.expect("write session index");
|
||||
|
||||
chat.emit_forked_thread_event(forked_from_id);
|
||||
|
||||
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
|
||||
Some(_) => continue,
|
||||
None => panic!("app event channel closed before forked thread history was emitted"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("timed out waiting for forked thread history");
|
||||
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
|
||||
let combined = btw::forked_thread_history_line_includes_name_and_id_snapshot().await;
|
||||
|
||||
assert!(
|
||||
combined.contains("Thread forked from"),
|
||||
@@ -719,27 +695,7 @@ async fn forked_thread_history_line_includes_name_and_id_snapshot() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn forked_thread_history_line_without_name_shows_id_once_snapshot() {
|
||||
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let mut chat = chat;
|
||||
let temp = tempdir().expect("tempdir");
|
||||
chat.config.codex_home = temp.path().to_path_buf();
|
||||
|
||||
let forked_from_id =
|
||||
ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id");
|
||||
chat.emit_forked_thread_event(forked_from_id);
|
||||
|
||||
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
|
||||
Some(_) => continue,
|
||||
None => panic!("app event channel closed before forked thread history was emitted"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("timed out waiting for forked thread history");
|
||||
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
|
||||
let combined = btw::forked_thread_history_line_without_name_shows_id_once_snapshot().await;
|
||||
|
||||
assert_snapshot!("forked_thread_history_line_without_name", combined);
|
||||
}
|
||||
@@ -2156,7 +2112,10 @@ async fn make_chatwidget_manual(
|
||||
suppress_queue_autosend: false,
|
||||
thread_id: None,
|
||||
thread_name: None,
|
||||
thread_rename_block_message: None,
|
||||
forked_from: None,
|
||||
next_fork_banner_parent_label: None,
|
||||
interrupted_turn_notice_mode: InterruptedTurnNoticeMode::Default,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
show_welcome_banner: true,
|
||||
startup_tooltip_override: None,
|
||||
@@ -5914,6 +5873,11 @@ queued draft"
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn suppressed_interrupted_turn_notice_skips_history_warning() {
|
||||
btw::suppressed_interrupted_turn_notice_skips_history_warning().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -7494,6 +7458,30 @@ async fn slash_fork_requests_current_fork() {
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_rename_is_rejected_for_btw_threads() {
|
||||
btw::slash_rename_is_rejected_for_btw_threads().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_rename_with_args_is_rejected_for_btw_threads() {
|
||||
btw::slash_rename_with_args_is_rejected_for_btw_threads().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_user_message_as_plain_user_turn_does_not_run_shell_commands() {
|
||||
btw::submit_user_message_as_plain_user_turn_does_not_run_shell_commands().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_btw_requests_forked_side_question_while_task_running() {
|
||||
let rendered = btw::slash_btw_requests_forked_side_question_while_task_running().await;
|
||||
assert_snapshot!(
|
||||
"slash_btw_requests_forked_side_question_while_task_running",
|
||||
rendered
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_rollout_displays_current_path() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
@@ -13233,6 +13221,22 @@ async fn status_line_fast_mode_footer_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn btw_footer_override_snapshot() {
|
||||
let rendered = btw::btw_footer_override_snapshot().await;
|
||||
assert_snapshot!("btw_footer_override", rendered);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_thread_footer_override_preserves_general_footer_hint_snapshot() {
|
||||
let rendered =
|
||||
btw::clearing_thread_footer_override_preserves_general_footer_hint_snapshot().await;
|
||||
assert_snapshot!(
|
||||
"clearing_thread_footer_override_preserves_general_footer_hint",
|
||||
rendered
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||||
|
||||
206
codex-rs/tui/src/chatwidget/tests/btw.rs
Normal file
206
codex-rs/tui/src/chatwidget/tests/btw.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use super::*;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
pub(super) async fn forked_thread_history_line_includes_name_and_id_snapshot() -> String {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_next_fork_banner_parent_label(Some("named parent".to_string()));
|
||||
|
||||
let forked_from_id =
|
||||
ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id");
|
||||
chat.emit_forked_thread_event(forked_from_id);
|
||||
|
||||
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
|
||||
Some(_) => continue,
|
||||
None => panic!("app event channel closed before forked thread history was emitted"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("timed out waiting for forked thread history");
|
||||
lines_to_single_string(&history_cell.display_lines(/*width*/ 80))
|
||||
}
|
||||
|
||||
pub(super) async fn forked_thread_history_line_without_name_shows_id_once_snapshot() -> String {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
let forked_from_id =
|
||||
ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id");
|
||||
chat.emit_forked_thread_event(forked_from_id);
|
||||
|
||||
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
|
||||
Some(_) => continue,
|
||||
None => panic!("app event channel closed before forked thread history was emitted"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("timed out waiting for forked thread history");
|
||||
lines_to_single_string(&history_cell.display_lines(/*width*/ 80))
|
||||
}
|
||||
|
||||
pub(super) async fn suppressed_interrupted_turn_notice_skips_history_warning() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
chat.set_interrupted_turn_notice_mode(InterruptedTurnNoticeMode::Suppress);
|
||||
chat.on_task_started();
|
||||
chat.on_agent_message_delta("partial output".to_string());
|
||||
|
||||
chat.on_interrupted_turn(TurnAbortReason::Interrupted);
|
||||
|
||||
let inserted = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
inserted.iter().all(|cell| {
|
||||
let rendered = lines_to_single_string(cell);
|
||||
!rendered.contains("Conversation interrupted - tell the model what to do differently.")
|
||||
&& !rendered.contains("Model interrupted to submit steer instructions.")
|
||||
}),
|
||||
"unexpected interrupted-turn notice in BTW thread: {inserted:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_btw_rename_rejected(
|
||||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>,
|
||||
) {
|
||||
let event = rx.try_recv().expect("expected BTW rename error");
|
||||
match event {
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80));
|
||||
assert!(
|
||||
rendered.contains("BTW threads are ephemeral and cannot be renamed."),
|
||||
"expected BTW rename error, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected InsertHistoryCell error, got {other:?}"),
|
||||
}
|
||||
assert!(rx.try_recv().is_err(), "expected no follow-up events");
|
||||
assert!(op_rx.try_recv().is_err(), "expected no rename op");
|
||||
}
|
||||
|
||||
pub(super) async fn slash_rename_is_rejected_for_btw_threads() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_thread_rename_block_message(
|
||||
"BTW threads are ephemeral and cannot be renamed.".to_string(),
|
||||
);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Rename);
|
||||
assert_btw_rename_rejected(&mut rx, &mut op_rx);
|
||||
}
|
||||
|
||||
pub(super) async fn slash_rename_with_args_is_rejected_for_btw_threads() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_thread_rename_block_message(
|
||||
"BTW threads are ephemeral and cannot be renamed.".to_string(),
|
||||
);
|
||||
|
||||
chat.dispatch_command_with_args(SlashCommand::Rename, "investigate".to_string(), Vec::new());
|
||||
assert_btw_rename_rejected(&mut rx, &mut op_rx);
|
||||
}
|
||||
|
||||
pub(super) async fn submit_user_message_as_plain_user_turn_does_not_run_shell_commands() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
|
||||
chat.submit_user_message_as_plain_user_turn("!echo hello".into());
|
||||
|
||||
match next_submit_op(&mut op_rx) {
|
||||
Op::UserTurn { items, .. } => pretty_assertions::assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "!echo hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected Op::UserTurn for BTW shell-like input, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn slash_btw_requests_forked_side_question_while_task_running() -> String {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let parent_thread_id = ThreadId::new();
|
||||
chat.thread_id = Some(parent_thread_id);
|
||||
chat.on_task_started();
|
||||
chat.show_welcome_banner = false;
|
||||
chat.bottom_pane.set_composer_text(
|
||||
"/btw explore the codebase".to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches::assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::StartBtw {
|
||||
parent_thread_id: emitted_parent_thread_id,
|
||||
user_message,
|
||||
}) if emitted_parent_thread_id == parent_thread_id
|
||||
&& user_message
|
||||
== UserMessage {
|
||||
text: "explore the codebase".to_string(),
|
||||
local_images: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
mention_bindings: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
op_rx.try_recv().is_err(),
|
||||
"expected no op on the parent thread"
|
||||
);
|
||||
|
||||
let width = 80;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw BTW footer");
|
||||
terminal.backend().to_string()
|
||||
}
|
||||
|
||||
pub(super) async fn btw_footer_override_snapshot() -> String {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.show_welcome_banner = false;
|
||||
chat.set_thread_footer_hint_override(Some(vec![(
|
||||
"BTW".to_string(),
|
||||
"from main thread · Esc to return".to_string(),
|
||||
)]));
|
||||
|
||||
let width = 80;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw BTW footer");
|
||||
terminal.backend().to_string()
|
||||
}
|
||||
|
||||
pub(super) async fn clearing_thread_footer_override_preserves_general_footer_hint_snapshot()
|
||||
-> String {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.show_welcome_banner = false;
|
||||
chat.set_footer_hint_override(Some(vec![(
|
||||
"Save and close external editor to continue.".to_string(),
|
||||
String::new(),
|
||||
)]));
|
||||
chat.set_thread_footer_hint_override(Some(vec![(
|
||||
"BTW".to_string(),
|
||||
"from main thread · Esc to return".to_string(),
|
||||
)]));
|
||||
chat.set_thread_footer_hint_override(/*items*/ None);
|
||||
|
||||
let width = 80;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw preserved footer hint");
|
||||
terminal.backend().to_string()
|
||||
}
|
||||
@@ -32,6 +32,7 @@ pub enum SlashCommand {
|
||||
Plan,
|
||||
Collab,
|
||||
Agent,
|
||||
Btw,
|
||||
// Undo,
|
||||
Diff,
|
||||
Copy,
|
||||
@@ -102,6 +103,7 @@ impl SlashCommand {
|
||||
SlashCommand::Plan => "switch to Plan mode",
|
||||
SlashCommand::Collab => "change collaboration mode (experimental)",
|
||||
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
|
||||
SlashCommand::Btw => "ask a side question in a forked subagent",
|
||||
SlashCommand::Approvals => "choose what Codex is allowed to do",
|
||||
SlashCommand::Permissions => "choose what Codex is allowed to do",
|
||||
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
|
||||
@@ -132,6 +134,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::Btw
|
||||
| SlashCommand::SandboxReadRoot
|
||||
)
|
||||
}
|
||||
@@ -173,7 +176,8 @@ impl SlashCommand {
|
||||
| SlashCommand::Plugins
|
||||
| SlashCommand::Feedback
|
||||
| SlashCommand::Quit
|
||||
| SlashCommand::Exit => true,
|
||||
| SlashCommand::Exit
|
||||
| SlashCommand::Btw => true,
|
||||
SlashCommand::Rollout => true,
|
||||
SlashCommand::TestApproval => true,
|
||||
SlashCommand::Realtime => true,
|
||||
|
||||
@@ -258,17 +258,14 @@ pub struct Tui {
|
||||
}
|
||||
|
||||
impl Tui {
|
||||
pub fn new(terminal: Terminal) -> Self {
|
||||
fn from_terminal(
|
||||
terminal: Terminal,
|
||||
enhanced_keys_supported: bool,
|
||||
notification_backend: Option<DesktopNotificationBackend>,
|
||||
) -> Self {
|
||||
let (draw_tx, _) = broadcast::channel(1);
|
||||
let frame_requester = FrameRequester::new(draw_tx.clone());
|
||||
|
||||
// Detect keyboard enhancement support before any EventStream is created so the
|
||||
// crossterm poller can acquire its lock without contention.
|
||||
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
|
||||
// Cache this to avoid contention with the event reader.
|
||||
supports_color::on_cached(supports_color::Stream::Stdout);
|
||||
let _ = crate::terminal_palette::default_colors();
|
||||
|
||||
Self {
|
||||
frame_requester,
|
||||
draw_tx,
|
||||
@@ -281,11 +278,26 @@ impl Tui {
|
||||
alt_screen_active: Arc::new(AtomicBool::new(false)),
|
||||
terminal_focused: Arc::new(AtomicBool::new(true)),
|
||||
enhanced_keys_supported,
|
||||
notification_backend: Some(detect_backend(NotificationMethod::default())),
|
||||
notification_backend,
|
||||
alt_screen_enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(terminal: Terminal) -> Self {
|
||||
// Detect keyboard enhancement support before any EventStream is created so the
|
||||
// crossterm poller can acquire its lock without contention.
|
||||
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
|
||||
// Cache this to avoid contention with the event reader.
|
||||
supports_color::on_cached(supports_color::Stream::Stdout);
|
||||
let _ = crate::terminal_palette::default_colors();
|
||||
|
||||
Self::from_terminal(
|
||||
terminal,
|
||||
enhanced_keys_supported,
|
||||
Some(detect_backend(NotificationMethod::default())),
|
||||
)
|
||||
}
|
||||
|
||||
/// Set whether alternate screen is enabled. When false, enter_alt_screen() becomes a no-op.
|
||||
pub fn set_alt_screen_enabled(&mut self, enabled: bool) {
|
||||
self.alt_screen_enabled = enabled;
|
||||
|
||||
@@ -8,6 +8,7 @@ Use /skills to list available skills or ask Codex to use one.
|
||||
Use /status to see the current model, approvals, and token usage.
|
||||
Use /statusline to configure which items appear in the status line.
|
||||
Use /fork to branch the current chat into a new thread.
|
||||
Use /btw to ask a side question in a temporary fork without polluting the main thread.
|
||||
Use /init to create an AGENTS.md with project-specific guidance.
|
||||
Use /mcp to list configured MCP tools.
|
||||
Run `codex app` to open Codex Desktop (it installs on macOS if needed).
|
||||
|
||||
244
codex-rs/tui_app_server/src/app/btw.rs
Normal file
244
codex-rs/tui_app_server/src/app/btw.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
use super::*;
|
||||
use crate::chatwidget::InterruptedTurnNoticeMode;
|
||||
|
||||
const BTW_RENAME_BLOCK_MESSAGE: &str = "BTW threads are ephemeral and cannot be renamed.";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct BtwThreadState {
|
||||
/// Thread to return to when the current BTW thread is dismissed.
|
||||
pub(super) parent_thread_id: ThreadId,
|
||||
/// Pretty parent label for the next synthetic fork banner, consumed on first attach.
|
||||
pub(super) next_fork_banner_parent_label: Option<String>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn sync_btw_thread_ui(&mut self) {
|
||||
let clear_btw_ui = |chat_widget: &mut crate::chatwidget::ChatWidget| {
|
||||
chat_widget.set_thread_footer_hint_override(/*items*/ None);
|
||||
chat_widget.clear_thread_rename_block();
|
||||
chat_widget.set_interrupted_turn_notice_mode(InterruptedTurnNoticeMode::Default);
|
||||
};
|
||||
let Some(active_thread_id) = self.current_displayed_thread_id() else {
|
||||
clear_btw_ui(&mut self.chat_widget);
|
||||
return;
|
||||
};
|
||||
let Some(mut parent_thread_id) = self
|
||||
.btw_threads
|
||||
.get(&active_thread_id)
|
||||
.map(|state| state.parent_thread_id)
|
||||
else {
|
||||
clear_btw_ui(&mut self.chat_widget);
|
||||
return;
|
||||
};
|
||||
|
||||
self.chat_widget
|
||||
.set_thread_rename_block_message(BTW_RENAME_BLOCK_MESSAGE);
|
||||
self.chat_widget
|
||||
.set_interrupted_turn_notice_mode(InterruptedTurnNoticeMode::Suppress);
|
||||
let mut depth = 1usize;
|
||||
while let Some(next_parent_thread_id) = self
|
||||
.btw_threads
|
||||
.get(&parent_thread_id)
|
||||
.map(|state| state.parent_thread_id)
|
||||
{
|
||||
depth += 1;
|
||||
parent_thread_id = next_parent_thread_id;
|
||||
}
|
||||
let repeated_prefix = "BTW from ".repeat(depth.saturating_sub(1));
|
||||
let label = if self.primary_thread_id == Some(parent_thread_id) {
|
||||
format!("from {repeated_prefix}main thread · Esc to return")
|
||||
} else {
|
||||
let parent_label = self.thread_label(parent_thread_id);
|
||||
format!("from {repeated_prefix}parent thread ({parent_label}) · Esc to return")
|
||||
};
|
||||
self.chat_widget
|
||||
.set_thread_footer_hint_override(Some(vec![("BTW".to_string(), label)]));
|
||||
}
|
||||
|
||||
pub(super) fn active_btw_parent_thread_id(&self) -> Option<ThreadId> {
|
||||
self.current_displayed_thread_id()
|
||||
.and_then(|thread_id| self.btw_threads.get(&thread_id))
|
||||
.map(|state| state.parent_thread_id)
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_return_from_btw(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
) -> bool {
|
||||
if self.overlay.is_none()
|
||||
&& self.chat_widget.no_modal_or_popup_active()
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
&& let Some(parent_thread_id) = self.active_btw_parent_thread_id()
|
||||
{
|
||||
let _ = self
|
||||
.select_agent_thread_and_discard_btw_chain(tui, app_server, parent_thread_id)
|
||||
.await;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn btw_threads_to_discard_after_switch(
|
||||
&self,
|
||||
target_thread_id: ThreadId,
|
||||
) -> Vec<ThreadId> {
|
||||
let Some(mut btw_thread_id) = self.current_displayed_thread_id() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !self.btw_threads.contains_key(&btw_thread_id)
|
||||
|| self
|
||||
.btw_threads
|
||||
.get(&target_thread_id)
|
||||
.map(|state| state.parent_thread_id)
|
||||
== Some(btw_thread_id)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut btw_threads_to_discard = Vec::new();
|
||||
loop {
|
||||
btw_threads_to_discard.push(btw_thread_id);
|
||||
let Some(parent_thread_id) = self
|
||||
.btw_threads
|
||||
.get(&btw_thread_id)
|
||||
.map(|state| state.parent_thread_id)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if parent_thread_id == target_thread_id
|
||||
|| !self.btw_threads.contains_key(&parent_thread_id)
|
||||
{
|
||||
break;
|
||||
}
|
||||
btw_thread_id = parent_thread_id;
|
||||
}
|
||||
btw_threads_to_discard
|
||||
}
|
||||
|
||||
pub(super) fn take_next_btw_fork_banner_parent_label(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
) -> Option<String> {
|
||||
self.btw_threads
|
||||
.get_mut(&thread_id)
|
||||
.and_then(|state| state.next_fork_banner_parent_label.take())
|
||||
}
|
||||
|
||||
pub(super) async fn discard_btw_thread(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) {
|
||||
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
|
||||
tracing::warn!("failed to unsubscribe BTW thread {thread_id}: {err}");
|
||||
}
|
||||
self.abort_thread_event_listener(thread_id);
|
||||
self.thread_event_channels.remove(&thread_id);
|
||||
self.btw_threads.remove(&thread_id);
|
||||
if self.active_thread_id == Some(thread_id) {
|
||||
self.clear_active_thread().await;
|
||||
} else {
|
||||
self.refresh_pending_thread_approvals().await;
|
||||
}
|
||||
self.sync_active_agent_label();
|
||||
}
|
||||
|
||||
async fn fork_banner_parent_label(&self, parent_thread_id: ThreadId) -> Option<String> {
|
||||
if self.chat_widget.thread_id() == Some(parent_thread_id) {
|
||||
return self
|
||||
.chat_widget
|
||||
.thread_name()
|
||||
.filter(|name| !name.trim().is_empty());
|
||||
}
|
||||
|
||||
let channel = self.thread_event_channels.get(&parent_thread_id)?;
|
||||
let store = channel.store.lock().await;
|
||||
store
|
||||
.session
|
||||
.as_ref()
|
||||
.and_then(|session| session.thread_name.clone())
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
}
|
||||
|
||||
pub(super) async fn select_agent_thread_and_discard_btw_chain(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<()> {
|
||||
let btw_threads_to_discard = self.btw_threads_to_discard_after_switch(thread_id);
|
||||
self.select_agent_thread(tui, app_server, thread_id).await?;
|
||||
if self.active_thread_id == Some(thread_id) {
|
||||
for btw_thread_id in btw_threads_to_discard {
|
||||
self.discard_btw_thread(app_server, btw_thread_id).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn handle_start_btw(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
parent_thread_id: ThreadId,
|
||||
user_message: crate::chatwidget::UserMessage,
|
||||
) -> Result<AppRunControl> {
|
||||
self.session_telemetry.counter(
|
||||
"codex.thread.btw",
|
||||
/*inc*/ 1,
|
||||
&[("source", "slash_command")],
|
||||
);
|
||||
self.refresh_in_memory_config_from_disk_best_effort("starting a BTW subagent")
|
||||
.await;
|
||||
|
||||
let mut fork_config = self.config.clone();
|
||||
fork_config.ephemeral = true;
|
||||
match app_server.fork_thread(fork_config, parent_thread_id).await {
|
||||
Ok(forked) => {
|
||||
let child_thread_id = forked.session.thread_id;
|
||||
let next_fork_banner_parent_label =
|
||||
self.fork_banner_parent_label(parent_thread_id).await;
|
||||
let channel = self.ensure_thread_channel(child_thread_id);
|
||||
{
|
||||
let mut store = channel.store.lock().await;
|
||||
store.set_session(forked.session, forked.turns);
|
||||
}
|
||||
self.btw_threads.insert(
|
||||
child_thread_id,
|
||||
BtwThreadState {
|
||||
parent_thread_id,
|
||||
next_fork_banner_parent_label,
|
||||
},
|
||||
);
|
||||
if let Err(err) = self
|
||||
.select_agent_thread_and_discard_btw_chain(tui, app_server, child_thread_id)
|
||||
.await
|
||||
{
|
||||
self.discard_btw_thread(app_server, child_thread_id).await;
|
||||
return Err(err);
|
||||
}
|
||||
if self.active_thread_id == Some(child_thread_id) {
|
||||
let _ = self
|
||||
.chat_widget
|
||||
.submit_user_message_as_plain_user_turn(user_message);
|
||||
} else {
|
||||
self.discard_btw_thread(app_server, child_thread_id).await;
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to switch into BTW thread {child_thread_id}."
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.set_thread_footer_hint_override(/*items*/ None);
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to fork BTW thread from {parent_thread_id}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AppRunControl::Continue)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user