Compare commits

...

7 Commits

Author SHA1 Message Date
viyatb-oai
542d94dc88 test: fix BTW TUI snapshots and blob size
Co-authored-by: Codex <noreply@openai.com>
2026-03-30 22:26:06 -07:00
viyatb-oai
dd654587b2 Merge branch 'origin/main' into ccunningham/btw-clean
Co-authored-by: Codex <noreply@openai.com>
2026-03-30 21:54:06 -07:00
Charles Cunningham
32d589880e Show immediate BTW footer feedback
Co-authored-by: Codex <noreply@openai.com>
2026-03-25 00:15:45 -07:00
Charles Cunningham
d85f889894 Fix TUI /fork shutdown routing
Co-authored-by: Codex <noreply@openai.com>
2026-03-24 18:48:17 -07:00
Charles Cunningham
3f638d4ac9 Suppress interrupted BTW warning in TUI
Co-authored-by: Codex <noreply@openai.com>
2026-03-24 18:00:51 -07:00
Charles Cunningham
f96d0f66d5 Fix BTW argument comment lint
Co-authored-by: Codex <noreply@openai.com>
2026-03-24 17:29:00 -07:00
Charles Cunningham
2ea2b940df Add ephemeral /btw side-question threads
Co-authored-by: Codex <noreply@openai.com>
2026-03-24 17:05:35 -07:00
17 changed files with 1046 additions and 140 deletions

View File

@@ -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
View 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)
}
}

View File

@@ -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,

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
expression: rendered
---
" "
" "
" Ask Codex to do anything "
" "
" BTW from main thread · Esc to return "

View File

@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
expression: rendered
---
" "
" "
" Ask Codex to do anything "
" "
" Save and close external editor to continue. "

View File

@@ -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)

View File

@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests.rs
expression: rendered
---
" "
"• Working (0s • esc to interrupt) "
" "
" "
" Ask Codex to do anything "
" "
" BTW starting... "

View File

@@ -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;

View 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()
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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).

View 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)
}
}