Add /btw ephemeral side-question threads

Implement /btw as an ephemeral forked side-thread flow in the TUI and the
mirrored tui_app_server frontend. The branch adds BTW thread lifecycle and
navigation handling, uses interrupted-snapshot forks when needed, keeps BTW
threads hidden from normal agent navigation, routes BTW prompts to the model,
and mirrors the footer/fork-banner UI plus replay-only fallback behavior in the
app-server variant.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-24 15:34:49 -07:00
co-authored by Codex
parent b32d921cd9
commit 2b71d1ea20
30 changed files with 4004 additions and 5996 deletions
+258 -742
View File
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
use super::*;
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 {
/// Keeps BTW-specific UI state in sync with the currently displayed thread.
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(None);
chat_widget.clear_thread_rename_block();
};
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);
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) -> 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(tui, 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();
// Selecting the immediate return thread should keep that ancestor reachable. Only discard
// the BTW suffix that becomes unreachable after the replacement thread is fully attached
// and replayed.
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())
}
/// Shuts down and forgets one ephemeral BTW thread.
///
/// This removes the thread from the core thread manager, aborts its listener task, clears any
/// TUI bookkeeping for replay/navigation, and recomputes the footer state. Callers that are
/// leaving a nested BTW stack are responsible for discarding the whole hidden chain in the
/// correct order.
pub(super) async fn discard_btw_thread(&mut self, thread_id: ThreadId) {
self.shutdown_and_remove_thread(thread_id).await;
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;
match store.session_configured.as_ref().map(|event| &event.msg) {
Some(EventMsg::SessionConfigured(session)) => session
.thread_name
.clone()
.filter(|name| !name.trim().is_empty()),
_ => None,
}
}
pub(super) async fn handle_start_btw(
&mut self,
tui: &mut tui::Tui,
parent_thread_id: ThreadId,
user_message: crate::chatwidget::UserMessage,
) -> Result<AppRunControl> {
self.session_telemetry
.counter("codex.thread.btw", 1, &[("source", "slash_command")]);
self.refresh_in_memory_config_from_disk_best_effort("starting a BTW subagent")
.await;
let parent_rollout_path = match self.server.get_thread(parent_thread_id).await {
Ok(thread) => thread.rollout_path(),
Err(err) => {
if self.current_displayed_thread_id() == Some(parent_thread_id) {
self.chat_widget.rollout_path()
} else {
self.chat_widget.add_error_message(format!(
"Failed to fork BTW thread from {parent_thread_id}: {err}"
));
return Ok(AppRunControl::Continue);
}
}
}
.filter(|path| path.exists());
let Some(parent_rollout_path) = parent_rollout_path else {
self.chat_widget.add_error_message(
"A thread must contain at least one turn before /btw can fork it.".to_string(),
);
return Ok(AppRunControl::Continue);
};
let fork_result = if self.chat_widget.agent_turn_running() {
self.server
.fork_thread(
ForkSnapshot::Interrupted,
self.config.clone(),
parent_rollout_path.clone(),
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
.await
} else {
self.server
.fork_thread(
/*nth_user_message*/ usize::MAX,
self.config.clone(),
parent_rollout_path.clone(),
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
.await
};
match fork_result {
Ok(forked) => {
let child_thread_id = forked.thread_id;
let next_fork_banner_parent_label =
self.fork_banner_parent_label(parent_thread_id).await;
self.attach_live_thread(
child_thread_id,
Arc::clone(&forked.thread),
forked.session_configured,
AgentPickerVisibility::Hidden,
)
.await?;
self.btw_threads.insert(
child_thread_id,
BtwThreadState {
parent_thread_id,
next_fork_banner_parent_label,
},
);
if let Err(err) = self.select_agent_thread(tui, child_thread_id).await {
self.discard_btw_thread(child_thread_id).await;
return Err(err);
}
if self.active_thread_id == Some(child_thread_id) {
if let Some(op) = self
.chat_widget
.submit_user_message_as_plain_user_turn(user_message)
{
self.note_active_thread_outbound_op(&op).await;
}
} else {
self.discard_btw_thread(child_thread_id).await;
self.chat_widget.add_error_message(format!(
"Failed to switch into BTW thread {child_thread_id}."
));
}
}
Err(err) => {
let path_display = parent_rollout_path.display();
self.chat_widget.add_error_message(format!(
"Failed to start BTW thread from {path_display}: {err}"
));
}
}
Ok(AppRunControl::Continue)
}
}
+471
View File
@@ -0,0 +1,471 @@
use super::*;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[tokio::test]
async fn start_btw_forks_switches_and_esc_returns_to_parent() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = make_test_tui();
let parent_thread_id =
setup_btw_parent_thread(&mut app, Some("what have you explored so far?")).await?;
let child_thread_id = start_btw_thread(&mut app, &mut tui, parent_thread_id).await?;
assert_ne!(child_thread_id, parent_thread_id);
assert_eq!(app.active_btw_parent_thread_id(), Some(parent_thread_id));
assert_eq!(app.agent_navigation.get(&child_thread_id), None);
let child_channel = app
.thread_event_channels
.get(&child_thread_id)
.expect("child thread channel");
let child_store = child_channel.store.lock().await;
let session_configured = child_store
.session_configured
.as_ref()
.expect("child session configured");
let EventMsg::SessionConfigured(session_configured) = &session_configured.msg else {
panic!(
"expected SessionConfigured, got {:?}",
session_configured.msg
);
};
assert!(
session_configured
.initial_messages
.as_ref()
.is_some_and(|messages| {
messages.iter().any(|message| {
matches!(
message,
EventMsg::UserMessage(ev) if ev.message == "what have you explored so far?"
)
})
}),
"expected child session to replay forked history into initial_messages"
);
drop(child_store);
let mut rendered_cells = Vec::new();
let found_user_prompt = loop {
match app_event_rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
let rendered = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
let contains_user_prompt = rendered.contains("explore the codebase");
rendered_cells.push(rendered);
if contains_user_prompt {
break true;
}
}
Ok(_) => continue,
Err(_) => break false,
}
};
assert!(
found_user_prompt,
"expected BTW user prompt cell, got {rendered_cells:?}"
);
let forked_idx = rendered_cells
.iter()
.position(|rendered| rendered.contains("Thread forked from"));
let user_prompt_idx = rendered_cells
.iter()
.position(|rendered| rendered.contains("explore the codebase"));
assert!(
matches!((forked_idx, user_prompt_idx), (Some(forked), Some(prompt)) if forked < prompt),
"expected fork banner before BTW prompt, got {rendered_cells:?}"
);
app.handle_key_event(&mut tui, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.await;
assert_eq!(app.active_thread_id, Some(parent_thread_id));
assert_eq!(app.active_btw_parent_thread_id(), None);
assert!(!app.thread_event_channels.contains_key(&child_thread_id));
Ok(())
}
#[tokio::test]
async fn start_btw_ctrl_c_returns_to_parent() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = make_test_tui();
let parent_thread_id = setup_btw_parent_thread(&mut app, None).await?;
let child_thread_id = start_btw_thread(&mut app, &mut tui, parent_thread_id).await?;
assert_ne!(child_thread_id, parent_thread_id);
app.handle_key_event(
&mut tui,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
assert_eq!(app.active_thread_id, Some(parent_thread_id));
assert_eq!(app.active_btw_parent_thread_id(), None);
assert!(!app.thread_event_channels.contains_key(&child_thread_id));
Ok(())
}
#[tokio::test]
async fn start_btw_uses_displayed_rollout_path_for_replay_only_parent() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = make_test_tui();
let parent_thread_id =
setup_btw_parent_thread(&mut app, Some("what have you explored so far?")).await?;
let sibling = app.server.start_thread(app.config.clone()).await?;
let sibling_thread_id = sibling.thread_id;
app.handle_thread_created(sibling_thread_id).await?;
app.select_agent_thread(&mut tui, sibling_thread_id).await?;
app.server.remove_thread(&parent_thread_id).await;
app.select_agent_thread(&mut tui, parent_thread_id).await?;
let parent_rollout_path = app
.chat_widget
.rollout_path()
.expect("displayed parent rollout path");
assert!(app.server.get_thread(parent_thread_id).await.is_err());
assert!(parent_rollout_path.exists());
let child_thread_id = start_btw_thread(&mut app, &mut tui, parent_thread_id).await?;
assert_ne!(child_thread_id, parent_thread_id);
assert_eq!(app.active_btw_parent_thread_id(), Some(parent_thread_id));
Ok(())
}
#[tokio::test]
async fn idle_main_thread_ctrl_c_requests_shutdown_exit() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = make_test_tui();
let parent_thread_id = setup_btw_parent_thread(&mut app, None).await?;
while app_event_rx.try_recv().is_ok() {}
app.handle_key_event(
&mut tui,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
let mut exit_mode = None;
while let Ok(app_event) = app_event_rx.try_recv() {
if let AppEvent::Exit(mode) = app_event {
exit_mode = Some(mode);
break;
}
}
assert_eq!(app.active_thread_id, Some(parent_thread_id));
assert_eq!(app.active_btw_parent_thread_id(), None);
assert_eq!(exit_mode, Some(ExitMode::ShutdownFirst));
Ok(())
}
#[tokio::test]
async fn ctrl_c_after_returning_from_btw_requests_shutdown_exit() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = make_test_tui();
let parent_thread_id = setup_btw_parent_thread(&mut app, None).await?;
let child_thread_id = start_btw_thread(&mut app, &mut tui, parent_thread_id).await?;
while app_event_rx.try_recv().is_ok() {}
app.handle_key_event(
&mut tui,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
assert_eq!(app.active_thread_id, Some(parent_thread_id));
assert_eq!(app.active_btw_parent_thread_id(), None);
assert!(!app.thread_event_channels.contains_key(&child_thread_id));
while app_event_rx.try_recv().is_ok() {}
app.handle_key_event(
&mut tui,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
let mut exit_mode = None;
while let Ok(app_event) = app_event_rx.try_recv() {
if let AppEvent::Exit(mode) = app_event {
exit_mode = Some(mode);
break;
}
}
assert_eq!(exit_mode, Some(ExitMode::ShutdownFirst));
Ok(())
}
#[tokio::test]
async fn nested_btw_preserves_parent_chain_and_esc_returns_one_level() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = make_test_tui();
let parent_thread_id = setup_btw_parent_thread(&mut app, None).await?;
let child_thread_id = start_btw_thread(&mut app, &mut tui, parent_thread_id).await?;
let grandchild_thread_id = start_btw_thread(&mut app, &mut tui, child_thread_id).await?;
assert_eq!(app.active_thread_id, Some(grandchild_thread_id));
assert_eq!(
app.btw_threads
.get(&grandchild_thread_id)
.map(|state| state.parent_thread_id),
Some(child_thread_id)
);
assert!(app.thread_event_channels.contains_key(&child_thread_id));
assert!(
app.thread_event_channels
.contains_key(&grandchild_thread_id)
);
app.handle_key_event(&mut tui, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.await;
assert_eq!(app.active_thread_id, Some(child_thread_id));
assert_eq!(app.active_btw_parent_thread_id(), Some(parent_thread_id));
assert!(app.thread_event_channels.contains_key(&child_thread_id));
assert!(
!app.thread_event_channels
.contains_key(&grandchild_thread_id)
);
Ok(())
}
#[tokio::test]
async fn switching_away_from_nested_btw_discards_full_hidden_chain() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = make_test_tui();
let parent_thread_id = setup_btw_parent_thread(&mut app, None).await?;
let child_thread_id = start_btw_thread(&mut app, &mut tui, parent_thread_id).await?;
let grandchild_thread_id = start_btw_thread(&mut app, &mut tui, child_thread_id).await?;
app.select_agent_thread(&mut tui, parent_thread_id).await?;
assert_eq!(app.active_thread_id, Some(parent_thread_id));
assert_eq!(app.active_btw_parent_thread_id(), None);
assert!(!app.thread_event_channels.contains_key(&child_thread_id));
assert!(
!app.thread_event_channels
.contains_key(&grandchild_thread_id)
);
assert!(app.server.get_thread(child_thread_id).await.is_err());
assert!(app.server.get_thread(grandchild_thread_id).await.is_err());
Ok(())
}
#[tokio::test]
async fn switching_away_from_nested_btw_clears_hidden_pending_approvals() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = make_test_tui();
let parent_thread_id = setup_btw_parent_thread(&mut app, None).await?;
let child_thread_id = start_btw_thread(&mut app, &mut tui, parent_thread_id).await?;
let _grandchild_thread_id = start_btw_thread(&mut app, &mut tui, child_thread_id).await?;
{
let child_channel = app
.thread_event_channels
.get(&child_thread_id)
.expect("child thread channel");
let mut store = child_channel.store.lock().await;
store.push_event(Event {
id: "ev-approval".to_string(),
msg: EventMsg::ExecApprovalRequest(
codex_protocol::protocol::ExecApprovalRequestEvent {
call_id: "call-approval".to_string(),
approval_id: None,
turn_id: "turn-approval".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
cwd: PathBuf::from("/tmp"),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
additional_permissions: None,
skill_metadata: None,
available_decisions: None,
parsed_cmd: Vec::new(),
},
),
});
}
app.refresh_pending_thread_approvals().await;
assert_eq!(
app.chat_widget.pending_thread_approvals(),
&[app.thread_label(child_thread_id)]
);
app.select_agent_thread(&mut tui, parent_thread_id).await?;
assert!(app.chat_widget.pending_thread_approvals().is_empty());
Ok(())
}
#[tokio::test]
async fn open_agent_picker_keeps_active_btw_thread_until_selection() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = make_test_tui();
let parent_thread_id = setup_btw_parent_thread(&mut app, None).await?;
let child_thread_id = start_btw_thread(&mut app, &mut tui, parent_thread_id).await?;
let control = app
.handle_event(&mut tui, AppEvent::OpenAgentPicker)
.await?;
assert!(matches!(control, AppRunControl::Continue));
assert_eq!(app.active_thread_id, Some(child_thread_id));
assert_eq!(app.active_btw_parent_thread_id(), Some(parent_thread_id));
assert!(app.thread_event_channels.contains_key(&child_thread_id));
assert_eq!(app.agent_navigation.get(&child_thread_id), None);
let control = app
.handle_event(&mut tui, AppEvent::SelectAgentThread(parent_thread_id))
.await?;
assert!(matches!(control, AppRunControl::Continue));
assert_eq!(app.active_thread_id, Some(parent_thread_id));
assert_eq!(app.active_btw_parent_thread_id(), None);
assert!(!app.thread_event_channels.contains_key(&child_thread_id));
Ok(())
}
#[tokio::test]
async fn failed_switch_from_btw_keeps_current_thread_and_parent_chain() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = make_test_tui();
let parent_thread_id = setup_btw_parent_thread(&mut app, None).await?;
let child_thread_id = start_btw_thread(&mut app, &mut tui, parent_thread_id).await?;
let missing_thread_id = ThreadId::new();
app.select_agent_thread(&mut tui, missing_thread_id).await?;
assert_eq!(app.active_thread_id, Some(child_thread_id));
assert_eq!(app.active_btw_parent_thread_id(), Some(parent_thread_id));
assert!(app.thread_event_channels.contains_key(&child_thread_id));
Ok(())
}
#[tokio::test]
async fn fork_current_session_discards_active_btw_chain() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let mut tui = make_test_tui();
let parent_thread_id = setup_btw_parent_thread(&mut app, None).await?;
let child_thread_id = start_btw_thread(&mut app, &mut tui, parent_thread_id).await?;
let child_rollout_path = app
.server
.get_thread(child_thread_id)
.await?
.rollout_path()
.expect("BTW child rollout path");
assert!(
child_rollout_path.exists(),
"expected BTW child rollout path"
);
let control = app
.handle_event(&mut tui, AppEvent::ForkCurrentSession)
.await?;
assert!(matches!(control, AppRunControl::Continue));
assert_eq!(app.active_btw_parent_thread_id(), None);
assert!(app.btw_threads.is_empty());
assert!(app.server.get_thread(parent_thread_id).await.is_err());
assert!(app.server.get_thread(child_thread_id).await.is_err());
Ok(())
}
async fn setup_btw_parent_thread(app: &mut App, parent_message: Option<&str>) -> Result<ThreadId> {
let parent = app.server.start_thread(app.config.clone()).await?;
let parent_thread_id = parent.thread_id;
let parent_rollout_path = parent
.session_configured
.rollout_path
.clone()
.expect("thread rollout path");
if let Some(parent_dir) = parent_rollout_path.parent() {
std::fs::create_dir_all(parent_dir)?;
}
let mut rollout_lines = vec![serde_json::to_string(
&codex_protocol::protocol::RolloutLine {
timestamp: "2026-03-12T00:00:00.000Z".to_string(),
item: codex_protocol::protocol::RolloutItem::SessionMeta(
codex_protocol::protocol::SessionMetaLine {
meta: codex_protocol::protocol::SessionMeta {
id: parent_thread_id,
forked_from_id: None,
timestamp: "2026-03-12T00:00:00.000Z".to_string(),
cwd: app.config.cwd.clone(),
originator: "test".to_string(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
source: SessionSource::Cli,
agent_nickname: None,
agent_role: None,
model_provider: Some(app.config.model_provider_id.clone()),
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
},
git: None,
},
),
},
)?];
if let Some(parent_message) = parent_message {
rollout_lines.push(serde_json::to_string(
&codex_protocol::protocol::RolloutLine {
timestamp: "2026-03-12T00:00:01.000Z".to_string(),
item: codex_protocol::protocol::RolloutItem::EventMsg(EventMsg::UserMessage(
codex_protocol::protocol::UserMessageEvent {
message: parent_message.to_string(),
images: None,
local_images: Vec::new(),
text_elements: Vec::new(),
},
)),
},
)?);
}
std::fs::write(
&parent_rollout_path,
format!("{}\n", rollout_lines.join("\n")),
)?;
app.handle_thread_created(parent_thread_id).await?;
app.primary_thread_id = Some(parent_thread_id);
app.activate_thread_channel(parent_thread_id).await;
Ok(parent_thread_id)
}
async fn start_btw_thread(
app: &mut App,
tui: &mut crate::tui::Tui,
parent_thread_id: ThreadId,
) -> Result<ThreadId> {
let control = app
.handle_event(
tui,
AppEvent::StartBtw {
parent_thread_id,
user_message: "explore the codebase".into(),
},
)
.await?;
assert!(matches!(control, AppRunControl::Continue));
Ok(app
.active_thread_id
.expect("BTW child should be active after start"))
}
+8 -95
View File
@@ -10,27 +10,22 @@
use std::path::PathBuf;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_utils_absolute_path::AbsolutePathBuf;
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;
use codex_features::Feature;
use codex_core::features::Feature;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ServiceTier;
@@ -82,6 +77,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,
@@ -168,84 +169,6 @@ pub(crate) enum AppEvent {
force_refetch: bool,
},
/// Fetch plugin marketplace state for the provided working directory.
FetchPluginsList {
cwd: PathBuf,
},
/// Result of fetching plugin marketplace state.
PluginsLoaded {
cwd: PathBuf,
result: Result<PluginListResponse, String>,
},
/// Replace the plugins popup with a plugin-detail loading state.
OpenPluginDetailLoading {
plugin_display_name: String,
},
/// Fetch detail for a specific plugin from a marketplace.
FetchPluginDetail {
cwd: PathBuf,
params: PluginReadParams,
},
/// Result of fetching plugin detail.
PluginDetailLoaded {
cwd: PathBuf,
result: Result<PluginReadResponse, String>,
},
/// Replace the plugins popup with an install loading state.
OpenPluginInstallLoading {
plugin_display_name: String,
},
/// Replace the plugins popup with an uninstall loading state.
OpenPluginUninstallLoading {
plugin_display_name: String,
},
/// Install a specific plugin from a marketplace.
FetchPluginInstall {
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
plugin_display_name: String,
},
/// Result of installing a plugin.
PluginInstallLoaded {
cwd: PathBuf,
marketplace_path: AbsolutePathBuf,
plugin_name: String,
plugin_display_name: String,
result: Result<PluginInstallResponse, String>,
},
/// Uninstall a specific plugin by canonical plugin id.
FetchPluginUninstall {
cwd: PathBuf,
plugin_id: String,
plugin_display_name: String,
},
/// Result of uninstalling a plugin.
PluginUninstallLoaded {
cwd: PathBuf,
plugin_id: String,
plugin_display_name: String,
result: Result<PluginUninstallResponse, String>,
},
/// Advance the post-install plugin app-auth flow.
PluginInstallAuthAdvance {
refresh_connectors: bool,
},
/// Abandon the post-install plugin app-auth flow.
PluginInstallAuthAbandon,
InsertHistoryCell(Box<dyn HistoryCell>),
/// Apply rollback semantics to local transcript cells.
@@ -536,16 +459,6 @@ pub(crate) enum AppEvent {
},
/// Dismiss the status-line setup UI without changing config.
StatusLineSetupCancelled,
/// Apply a user-confirmed terminal-title item ordering/selection.
TerminalTitleSetup {
items: Vec<TerminalTitleItem>,
},
/// Apply a temporary terminal-title preview while the setup UI is open.
TerminalTitleSetupPreview {
items: Vec<TerminalTitleItem>,
},
/// Dismiss the terminal-title setup UI without changing config.
TerminalTitleSetupCancelled,
/// Apply a user-confirmed syntax theme selection.
SyntaxThemeSelected {
+17 -3
View File
@@ -383,6 +383,7 @@ pub(crate) struct ChatComposer {
custom_prompts: Vec<CustomPrompt>,
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.
@@ -510,6 +511,7 @@ impl ChatComposer {
custom_prompts: Vec::new(),
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,
@@ -957,6 +959,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;
@@ -4290,12 +4300,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
@@ -4344,7 +4358,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 {
@@ -4420,7 +4434,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 {
+11 -2
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());
}
+5
View File
@@ -618,6 +618,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();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
"› Ask Codex to do anything "
" "
" BTW from main thread · Esc to return 100% context left "
@@ -0,0 +1,9 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
"› Ask Codex to do anything "
" "
" Save and close external editor to continue. 100% context left "
@@ -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)
+152 -15
View File
@@ -577,20 +577,12 @@ 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(None).await;
let mut chat = chat;
let temp = tempdir().expect("tempdir");
chat.config.codex_home = temp.path().to_path_buf();
async fn forked_thread_history_line_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(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");
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 {
@@ -615,10 +607,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(None).await;
let mut chat = chat;
let temp = tempdir().expect("tempdir");
chat.config.codex_home = temp.path().to_path_buf();
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let forked_from_id =
ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id");
@@ -2052,7 +2041,9 @@ 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,
frame_requester: FrameRequester::test_dummy(),
show_welcome_banner: true,
startup_tooltip_override: None,
@@ -2679,6 +2670,25 @@ async fn submit_user_message_with_mode_sets_coding_collaboration_mode() {
}
}
#[tokio::test]
async fn submit_user_message_as_plain_user_turn_does_not_run_shell_commands() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).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, .. } => 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:?}"),
}
}
#[tokio::test]
async fn reasoning_selection_in_plan_mode_opens_scope_prompt_event() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex-max")).await;
@@ -6545,6 +6555,83 @@ async fn slash_fork_requests_current_fork() {
assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession));
}
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(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");
}
#[tokio::test]
async fn slash_rename_is_rejected_for_btw_threads() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(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);
}
#[tokio::test]
async fn slash_rename_with_args_is_rejected_for_btw_threads() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(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);
}
#[tokio::test]
async fn slash_btw_requests_forked_side_question_while_task_running() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
let parent_thread_id = ThreadId::new();
chat.thread_id = Some(parent_thread_id);
chat.on_task_started();
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!(
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"
);
assert_eq!(chat.bottom_pane.composer_text(), "");
}
#[tokio::test]
async fn slash_rollout_displays_current_path() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -11782,6 +11869,56 @@ async fn status_line_fast_mode_footer_snapshot() {
assert_snapshot!("status_line_fast_mode_footer", terminal.backend());
}
#[tokio::test]
async fn btw_footer_override_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(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");
assert_snapshot!("btw_footer_override", terminal.backend());
}
#[tokio::test]
async fn clearing_thread_footer_override_preserves_general_footer_hint_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(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(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");
assert_snapshot!(
"clearing_thread_footer_override_preserves_general_footer_hint",
terminal.backend()
);
}
#[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;
+14
View File
@@ -201,6 +201,20 @@ where
})
}
#[cfg(test)]
pub(crate) fn with_test_options(backend: B, screen_size: Size, cursor_pos: Position) -> Self {
Self {
backend,
buffers: [Buffer::empty(Rect::ZERO), Buffer::empty(Rect::ZERO)],
current: 0,
hidden_cursor: false,
viewport_area: Rect::ZERO,
last_known_screen_size: screen_size,
last_known_cursor_pos: cursor_pos,
visible_history_rows: 0,
}
}
/// Get a Frame object which provides a consistent view into the terminal state for rendering.
pub fn get_frame(&mut self) -> Frame<'_> {
Frame {
+5 -1
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,
+34 -9
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,39 @@ 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())),
)
}
#[cfg(test)]
pub(crate) fn new_test() -> Self {
let terminal = CustomTerminal::with_test_options(
CrosstermBackend::new(stdout()),
ratatui::layout::Size {
width: 80,
height: 24,
},
ratatui::layout::Position { x: 0, y: 0 },
);
Self::from_terminal(terminal, false, None)
}
/// 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;
+1
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).
File diff suppressed because it is too large Load Diff
+266
View File
@@ -0,0 +1,266 @@
use super::*;
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(None);
chat_widget.clear_thread_rename_block();
};
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);
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;
match store.session_configured.as_ref().map(|event| &event.msg) {
Some(EventMsg::SessionConfigured(session)) => session
.thread_name
.clone()
.filter(|name| !name.trim().is_empty()),
_ => None,
}
}
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, 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", 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;
let fork_result = match app_server.fork_thread(fork_config, parent_thread_id).await {
Ok(forked) => Ok(forked),
Err(err) => {
if self.current_displayed_thread_id() != Some(parent_thread_id) {
self.chat_widget.add_error_message(format!(
"Failed to fork BTW thread from {parent_thread_id}: {err}"
));
return Ok(AppRunControl::Continue);
}
let Some(parent_rollout_path) =
self.chat_widget.rollout_path().filter(|path| path.exists())
else {
self.chat_widget.add_error_message(
"A thread must contain at least one turn before /btw can fork it."
.to_string(),
);
return Ok(AppRunControl::Continue);
};
let mut fork_config = self.config.clone();
fork_config.ephemeral = true;
app_server
.fork_thread_from_path(fork_config, parent_thread_id, parent_rollout_path)
.await
}
};
match fork_result {
Ok(forked) => {
let child_thread_id = forked.session_configured.session_id;
let next_fork_banner_parent_label =
self.fork_banner_parent_label(parent_thread_id).await;
self.enqueue_thread_event(
child_thread_id,
Event {
id: String::new(),
msg: EventMsg::SessionConfigured(forked.session_configured),
},
)
.await?;
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.add_error_message(format!(
"Failed to start BTW thread from {parent_thread_id}: {err}"
));
}
}
Ok(AppRunControl::Continue)
}
}
+7
View File
@@ -28,6 +28,7 @@ use codex_utils_approval_presets::ApprovalPreset;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::chatwidget::UserMessage;
use crate::history_cell::HistoryCell;
use codex_core::config::types::ApprovalsReviewer;
@@ -82,6 +83,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,
+282 -215
View File
@@ -1,7 +1,6 @@
use codex_app_server_client::AppServerClient;
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ClientRequest;
@@ -43,13 +42,10 @@ use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnStartParams;
@@ -57,9 +53,17 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_core::config::Config;
use codex_core::message_history;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::ImageGenerationItem;
use codex_protocol::items::PlanItem;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
@@ -69,18 +73,19 @@ use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget as CoreReviewTarget;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionNetworkProxyRuntime;
use codex_protocol::protocol::SessionConfiguredEvent;
use color_eyre::eyre::ContextCompat;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::debug;
use crate::bottom_pane::FeedbackAudience;
use crate::status::StatusAccountDisplay;
@@ -103,25 +108,6 @@ pub(crate) struct AppServerSession {
next_request_id: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ThreadSessionState {
pub(crate) thread_id: ThreadId,
pub(crate) forked_from_id: Option<ThreadId>,
pub(crate) thread_name: Option<String>,
pub(crate) model: String,
pub(crate) model_provider_id: String,
pub(crate) service_tier: Option<codex_protocol::config_types::ServiceTier>,
pub(crate) approval_policy: AskForApproval,
pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer,
pub(crate) sandbox_policy: SandboxPolicy,
pub(crate) cwd: PathBuf,
pub(crate) reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
pub(crate) history_log_id: u64,
pub(crate) history_entry_count: u64,
pub(crate) network_proxy: Option<SessionNetworkProxyRuntime>,
pub(crate) rollout_path: Option<PathBuf>,
}
#[derive(Clone, Copy)]
enum ThreadParamsMode {
Embedded,
@@ -138,8 +124,7 @@ impl ThreadParamsMode {
}
pub(crate) struct AppServerStartedThread {
pub(crate) session: ThreadSessionState,
pub(crate) turns: Vec<Turn>,
pub(crate) session_configured: SessionConfiguredEvent,
}
impl AppServerSession {
@@ -179,6 +164,16 @@ impl AppServerSession {
})
.await
.wrap_err("model/list failed during TUI bootstrap")?;
let rate_limit_request_id = self.next_request_id();
let rate_limits: GetAccountRateLimitsResponse = self
.client
.request_typed(ClientRequest::GetAccountRateLimits {
request_id: rate_limit_request_id,
params: None,
})
.await
.wrap_err("account/rateLimits/read failed during TUI bootstrap")?;
let available_models = models
.data
.into_iter()
@@ -243,25 +238,6 @@ impl AppServerSession {
false,
),
};
let rate_limit_snapshots = if account.requires_openai_auth && has_chatgpt_account {
let rate_limit_request_id = self.next_request_id();
match self
.client
.request_typed(ClientRequest::GetAccountRateLimits {
request_id: rate_limit_request_id,
params: None,
})
.await
{
Ok(rate_limits) => app_server_rate_limit_snapshots_to_core(rate_limits),
Err(error) => {
debug!(error = ?error, "failed to fetch rate limits during TUI bootstrap");
Vec::new()
}
}
} else {
Vec::new()
};
Ok(AppServerBootstrap {
account_auth_mode,
@@ -273,7 +249,7 @@ impl AppServerSession {
feedback_audience,
has_chatgpt_account,
available_models,
rate_limit_snapshots,
rate_limit_snapshots: app_server_rate_limit_snapshots_to_core(rate_limits),
})
}
@@ -291,7 +267,7 @@ impl AppServerSession {
})
.await
.wrap_err("thread/start failed during TUI bootstrap")?;
started_thread_from_start_response(response, config).await
started_thread_from_start_response(&response)
}
pub(crate) async fn resume_thread(
@@ -299,20 +275,21 @@ impl AppServerSession {
config: Config,
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
let show_raw_agent_reasoning = config.show_raw_agent_reasoning;
let request_id = self.next_request_id();
let response: ThreadResumeResponse = self
.client
.request_typed(ClientRequest::ThreadResume {
request_id,
params: thread_resume_params_from_config(
config.clone(),
config,
thread_id,
self.thread_params_mode(),
),
})
.await
.wrap_err("thread/resume failed during TUI bootstrap")?;
started_thread_from_resume_response(response, &config).await
started_thread_from_resume_response(&response, show_raw_agent_reasoning)
}
pub(crate) async fn fork_thread(
@@ -320,20 +297,37 @@ impl AppServerSession {
config: Config,
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
self.fork_thread_with_path(config, thread_id, /*path*/ None)
.await
}
pub(crate) async fn fork_thread_from_path(
&mut self,
config: Config,
thread_id: ThreadId,
path: PathBuf,
) -> Result<AppServerStartedThread> {
self.fork_thread_with_path(config, thread_id, Some(path))
.await
}
async fn fork_thread_with_path(
&mut self,
config: Config,
thread_id: ThreadId,
path: Option<PathBuf>,
) -> Result<AppServerStartedThread> {
let show_raw_agent_reasoning = config.show_raw_agent_reasoning;
let request_id = self.next_request_id();
let mut params =
thread_fork_params_from_config(config, thread_id, self.thread_params_mode());
params.path = path;
let response: ThreadForkResponse = self
.client
.request_typed(ClientRequest::ThreadFork {
request_id,
params: thread_fork_params_from_config(
config.clone(),
thread_id,
self.thread_params_mode(),
),
})
.request_typed(ClientRequest::ThreadFork { request_id, params })
.await
.wrap_err("thread/fork failed during TUI bootstrap")?;
started_thread_from_fork_response(response, &config).await
started_thread_from_fork_response(&response, show_raw_agent_reasoning)
}
fn thread_params_mode(&self) -> ThreadParamsMode {
@@ -440,7 +434,7 @@ impl AppServerSession {
thread_id: ThreadId,
turn_id: String,
items: Vec<codex_protocol::user_input::UserInput>,
) -> std::result::Result<TurnSteerResponse, TypedRequestError> {
) -> Result<TurnSteerResponse> {
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::TurnSteer {
@@ -452,6 +446,7 @@ impl AppServerSession {
},
})
.await
.wrap_err("turn/steer failed in app-server TUI")
}
pub(crate) async fn thread_set_name(
@@ -504,26 +499,6 @@ impl AppServerSession {
Ok(())
}
pub(crate) async fn thread_shell_command(
&mut self,
thread_id: ThreadId,
command: String,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadShellCommandResponse = self
.client
.request_typed(ClientRequest::ThreadShellCommand {
request_id,
params: ThreadShellCommandParams {
thread_id: thread_id.to_string(),
command,
},
})
.await
.wrap_err("thread/shellCommand failed in app-server TUI")?;
Ok(())
}
pub(crate) async fn thread_background_terminals_clean(
&mut self,
thread_id: ThreadId,
@@ -876,92 +851,90 @@ fn thread_cwd_from_config(config: &Config, thread_params_mode: ThreadParamsMode)
}
}
async fn started_thread_from_start_response(
response: ThreadStartResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_start_response(&response, config)
.await
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session,
turns: response.thread.turns,
})
}
async fn started_thread_from_resume_response(
response: ThreadResumeResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_resume_response(&response, config)
.await
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session,
turns: response.thread.turns,
})
}
async fn started_thread_from_fork_response(
response: ThreadForkResponse,
config: &Config,
) -> Result<AppServerStartedThread> {
let session = thread_session_state_from_thread_fork_response(&response, config)
.await
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session,
turns: response.thread.turns,
})
}
async fn thread_session_state_from_thread_start_response(
fn started_thread_from_start_response(
response: &ThreadStartResponse,
config: &Config,
) -> Result<ThreadSessionState, String> {
thread_session_state_from_thread_response(
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
config,
)
.await
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_start_response(response)
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread { session_configured })
}
async fn thread_session_state_from_thread_resume_response(
fn started_thread_from_resume_response(
response: &ThreadResumeResponse,
config: &Config,
) -> Result<ThreadSessionState, String> {
thread_session_state_from_thread_response(
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
config,
)
.await
show_raw_agent_reasoning: bool,
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_resume_response(response)
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session_configured: SessionConfiguredEvent {
initial_messages: thread_initial_messages(
&session_configured.session_id,
&response.thread.turns,
show_raw_agent_reasoning,
),
..session_configured
},
})
}
async fn thread_session_state_from_thread_fork_response(
fn started_thread_from_fork_response(
response: &ThreadForkResponse,
config: &Config,
) -> Result<ThreadSessionState, String> {
thread_session_state_from_thread_response(
show_raw_agent_reasoning: bool,
) -> Result<AppServerStartedThread> {
let session_configured = session_configured_from_thread_fork_response(response)
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session_configured: SessionConfiguredEvent {
initial_messages: thread_initial_messages(
&session_configured.session_id,
&response.thread.turns,
show_raw_agent_reasoning,
),
..session_configured
},
})
}
fn session_configured_from_thread_start_response(
response: &ThreadStartResponse,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
)
}
fn session_configured_from_thread_resume_response(
response: &ThreadResumeResponse,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
response.model.clone(),
response.model_provider.clone(),
response.service_tier,
response.approval_policy.to_core(),
response.approvals_reviewer.to_core(),
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
)
}
fn session_configured_from_thread_fork_response(
response: &ThreadForkResponse,
) -> Result<SessionConfiguredEvent, String> {
session_configured_from_thread_response(
&response.thread.id,
response.thread.name.clone(),
response.thread.path.clone(),
@@ -973,9 +946,7 @@ async fn thread_session_state_from_thread_fork_response(
response.sandbox.to_core(),
response.cwd.clone(),
response.reasoning_effort,
config,
)
.await
}
fn review_target_to_app_server(
@@ -1001,7 +972,7 @@ fn review_target_to_app_server(
clippy::too_many_arguments,
reason = "session mapping keeps explicit fields"
)]
async fn thread_session_state_from_thread_response(
fn session_configured_from_thread_response(
thread_id: &str,
thread_name: Option<String>,
rollout_path: Option<PathBuf>,
@@ -1013,15 +984,12 @@ async fn thread_session_state_from_thread_response(
sandbox_policy: SandboxPolicy,
cwd: PathBuf,
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
config: &Config,
) -> Result<ThreadSessionState, String> {
let thread_id = ThreadId::from_string(thread_id)
) -> Result<SessionConfiguredEvent, String> {
let session_id = ThreadId::from_string(thread_id)
.map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?;
let (history_log_id, history_entry_count) = message_history::history_metadata(config).await;
let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX);
Ok(ThreadSessionState {
thread_id,
Ok(SessionConfiguredEvent {
session_id,
forked_from_id: None,
thread_name,
model,
@@ -1032,13 +1000,129 @@ async fn thread_session_state_from_thread_response(
sandbox_policy,
cwd,
reasoning_effort,
history_log_id,
history_entry_count,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path,
})
}
fn thread_initial_messages(
thread_id: &ThreadId,
turns: &[codex_app_server_protocol::Turn],
show_raw_agent_reasoning: bool,
) -> Option<Vec<EventMsg>> {
let events: Vec<EventMsg> = turns
.iter()
.flat_map(|turn| turn_initial_messages(thread_id, turn, show_raw_agent_reasoning))
.collect();
(!events.is_empty()).then_some(events)
}
fn turn_initial_messages(
thread_id: &ThreadId,
turn: &codex_app_server_protocol::Turn,
show_raw_agent_reasoning: bool,
) -> Vec<EventMsg> {
turn.items
.iter()
.cloned()
.filter_map(app_server_thread_item_to_core)
.flat_map(|item| match item {
TurnItem::UserMessage(item) => vec![item.as_legacy_event()],
TurnItem::Plan(item) => vec![EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: *thread_id,
turn_id: turn.id.clone(),
item: TurnItem::Plan(item),
})],
item => item.as_legacy_events(show_raw_agent_reasoning),
})
.collect()
}
fn app_server_thread_item_to_core(item: codex_app_server_protocol::ThreadItem) -> Option<TurnItem> {
match item {
codex_app_server_protocol::ThreadItem::UserMessage { id, content } => {
Some(TurnItem::UserMessage(UserMessageItem {
id,
content: content
.into_iter()
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
}))
}
codex_app_server_protocol::ThreadItem::AgentMessage { id, text, phase } => {
Some(TurnItem::AgentMessage(AgentMessageItem {
id,
content: vec![AgentMessageContent::Text { text }],
phase,
}))
}
codex_app_server_protocol::ThreadItem::Plan { id, text } => {
Some(TurnItem::Plan(PlanItem { id, text }))
}
codex_app_server_protocol::ThreadItem::Reasoning {
id,
summary,
content,
} => Some(TurnItem::Reasoning(ReasoningItem {
id,
summary_text: summary,
raw_content: content,
})),
codex_app_server_protocol::ThreadItem::WebSearch { id, query, action } => {
Some(TurnItem::WebSearch(WebSearchItem {
id,
query,
action: app_server_web_search_action_to_core(action?)?,
}))
}
codex_app_server_protocol::ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
result,
} => Some(TurnItem::ImageGeneration(ImageGenerationItem {
id,
status,
revised_prompt,
result,
saved_path: None,
})),
codex_app_server_protocol::ThreadItem::ContextCompaction { id } => {
Some(TurnItem::ContextCompaction(ContextCompactionItem { id }))
}
codex_app_server_protocol::ThreadItem::CommandExecution { .. }
| codex_app_server_protocol::ThreadItem::FileChange { .. }
| codex_app_server_protocol::ThreadItem::McpToolCall { .. }
| codex_app_server_protocol::ThreadItem::DynamicToolCall { .. }
| codex_app_server_protocol::ThreadItem::CollabAgentToolCall { .. }
| codex_app_server_protocol::ThreadItem::ImageView { .. }
| codex_app_server_protocol::ThreadItem::EnteredReviewMode { .. }
| codex_app_server_protocol::ThreadItem::ExitedReviewMode { .. } => None,
}
}
fn app_server_web_search_action_to_core(
action: codex_app_server_protocol::WebSearchAction,
) -> Option<codex_protocol::models::WebSearchAction> {
match action {
codex_app_server_protocol::WebSearchAction::Search { query, queries } => {
Some(codex_protocol::models::WebSearchAction::Search { query, queries })
}
codex_app_server_protocol::WebSearchAction::OpenPage { url } => {
Some(codex_protocol::models::WebSearchAction::OpenPage { url })
}
codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => {
Some(codex_protocol::models::WebSearchAction::FindInPage { url, pattern })
}
codex_app_server_protocol::WebSearchAction::Other => {
Some(codex_protocol::models::WebSearchAction::Other)
}
}
}
fn app_server_rate_limit_snapshots_to_core(
response: GetAccountRateLimitsResponse,
) -> Vec<RateLimitSnapshot> {
@@ -1135,10 +1219,8 @@ mod tests {
assert_eq!(fork.model_provider, None);
}
#[tokio::test]
async fn resume_response_restores_turns_from_thread_items() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
#[test]
fn resume_response_restores_initial_messages_from_turn_items() {
let thread_id = ThreadId::new();
let response = ThreadResumeResponse {
thread: codex_app_server_protocol::Thread {
@@ -1171,7 +1253,6 @@ mod tests {
id: "assistant-1".to_string(),
text: "assistant reply".to_string(),
phase: None,
memory_citation: None,
},
],
status: TurnStatus::Completed,
@@ -1188,44 +1269,30 @@ mod tests {
reasoning_effort: None,
};
let started = started_thread_from_resume_response(response.clone(), &config)
.await
.expect("resume response should map");
assert_eq!(started.turns.len(), 1);
assert_eq!(started.turns[0], response.thread.turns[0]);
}
let started =
started_thread_from_resume_response(&response, /*show_raw_agent_reasoning*/ false)
.expect("resume response should map");
let initial_messages = started
.session_configured
.initial_messages
.expect("resume response should restore replay history");
#[tokio::test]
async fn session_configured_populates_history_metadata() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let config = build_config(&temp_dir).await;
let thread_id = ThreadId::new();
message_history::append_entry("older", &thread_id, &config)
.await
.expect("history append should succeed");
message_history::append_entry("newer", &thread_id, &config)
.await
.expect("history append should succeed");
let session = thread_session_state_from_thread_response(
&thread_id.to_string(),
Some("restore".to_string()),
None,
"gpt-5.4".to_string(),
"openai".to_string(),
None,
AskForApproval::Never,
codex_protocol::config_types::ApprovalsReviewer::User,
SandboxPolicy::new_read_only_policy(),
PathBuf::from("/tmp/project"),
None,
&config,
)
.await
.expect("session should map");
assert_ne!(session.history_log_id, 0);
assert_eq!(session.history_entry_count, 2);
assert_eq!(initial_messages.len(), 2);
match &initial_messages[0] {
EventMsg::UserMessage(event) => {
assert_eq!(event.message, "hello from history");
assert_eq!(event.images.as_ref(), Some(&Vec::new()));
assert!(event.local_images.is_empty());
assert!(event.text_elements.is_empty());
}
other => panic!("expected replayed user message, got {other:?}"),
}
match &initial_messages[1] {
EventMsg::AgentMessage(event) => {
assert_eq!(event.message, "assistant reply");
assert_eq!(event.phase, None);
}
other => panic!("expected replayed agent message, got {other:?}"),
}
}
}
@@ -383,6 +383,7 @@ pub(crate) struct ChatComposer {
custom_prompts: Vec<CustomPrompt>,
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.
@@ -510,6 +511,7 @@ impl ChatComposer {
custom_prompts: Vec::new(),
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,
@@ -957,6 +959,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;
@@ -4305,12 +4315,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
@@ -4359,7 +4373,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 {
@@ -4435,7 +4449,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());
}
@@ -614,6 +614,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();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
---
source: tui_app_server/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
"› Ask Codex to do anything "
" "
" BTW from main thread · Esc to return 100% context left "
@@ -0,0 +1,9 @@
---
source: tui_app_server/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
"› Ask Codex to do anything "
" "
" Save and close external editor to continue. 100% context left "
@@ -2,4 +2,4 @@
source: tui_app_server/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)
+150 -14
View File
@@ -602,19 +602,11 @@ 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(None).await;
let mut chat = chat;
let temp = tempdir().expect("tempdir");
chat.config.codex_home = temp.path().to_path_buf();
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(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");
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 {
@@ -639,10 +631,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(None).await;
let mut chat = chat;
let temp = tempdir().expect("tempdir");
chat.config.codex_home = temp.path().to_path_buf();
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
let forked_from_id =
ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id");
@@ -2071,7 +2060,9 @@ 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,
frame_requester: FrameRequester::test_dummy(),
show_welcome_banner: true,
startup_tooltip_override: None,
@@ -7186,6 +7177,101 @@ async fn slash_fork_requests_current_fork() {
assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession));
}
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(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");
}
#[tokio::test]
async fn slash_rename_is_rejected_for_btw_threads() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(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);
}
#[tokio::test]
async fn slash_rename_with_args_is_rejected_for_btw_threads() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(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);
}
#[tokio::test]
async fn submit_user_message_as_plain_user_turn_does_not_run_shell_commands() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(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, .. } => 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:?}"),
}
}
#[tokio::test]
async fn slash_btw_requests_forked_side_question_while_task_running() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
let parent_thread_id = ThreadId::new();
chat.thread_id = Some(parent_thread_id);
chat.on_task_started();
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!(
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"
);
}
#[tokio::test]
async fn slash_rollout_displays_current_path() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -12195,6 +12281,56 @@ async fn status_line_fast_mode_footer_snapshot() {
assert_snapshot!("status_line_fast_mode_footer", terminal.backend());
}
#[tokio::test]
async fn btw_footer_override_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(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");
assert_snapshot!("btw_footer_override", terminal.backend());
}
#[tokio::test]
async fn clearing_thread_footer_override_preserves_general_footer_hint_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(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(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");
assert_snapshot!(
"clearing_thread_footer_override_preserves_general_footer_hint",
terminal.backend()
);
}
#[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;
+5 -1
View File
@@ -32,6 +32,7 @@ pub enum SlashCommand {
Plan,
Collab,
Agent,
Btw,
// Undo,
Diff,
Copy,
@@ -100,6 +101,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",
@@ -130,6 +132,7 @@ impl SlashCommand {
| SlashCommand::Rename
| SlashCommand::Plan
| SlashCommand::Fast
| SlashCommand::Btw
| SlashCommand::SandboxReadRoot
)
}
@@ -171,7 +174,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,
+1
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).