mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
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:
co-authored by
Codex
parent
b32d921cd9
commit
2b71d1ea20
+258
-742
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+492
-582
File diff suppressed because it is too large
Load Diff
+9
@@ -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 "
|
||||
+9
@@ -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 "
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
+1059
-2426
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
+9
@@ -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 "
|
||||
+9
@@ -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 "
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user