Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cd030e0
Add ephemeral side conversations
etraut-openai Apr 16, 2026
513998a
Merge remote-tracking branch 'origin/main' into etraut/side-conversation
etraut-openai Apr 16, 2026
a83aa7c
Hide parent transcript in side conversations
etraut-openai Apr 16, 2026
24be0fd
codex: address PR review feedback (#18190)
etraut-openai Apr 17, 2026
fca718e
codex: suppress side conversation preamble (#18190)
etraut-openai Apr 17, 2026
732ed5d
codex: address side conversation review feedback (#18190)
etraut-openai Apr 17, 2026
41a5ed5
Merge remote-tracking branch 'origin/main' into etraut/side-conversation
etraut-openai Apr 17, 2026
3ce0841
codex: tighten side conversation boundary
etraut-openai Apr 17, 2026
48fa6f9
codex: address side teardown review feedback
etraut-openai Apr 17, 2026
08ff218
codex: handle closed side failover
etraut-openai Apr 17, 2026
4a12713
codex: hide parent state from side conversations
etraut-openai Apr 17, 2026
3566528
codex: keep failed side cleanup visible
etraut-openai Apr 17, 2026
1b77a1c
codex: clear full terminal on thread switch
etraut-openai Apr 17, 2026
823d34e
Merge branch 'main' into etraut/side-conversation
etraut-openai Apr 17, 2026
ccb693f
codex: simplify side conversation tool handling
etraut-openai Apr 17, 2026
06a7171
Refine side conversation behavior
etraut-openai Apr 17, 2026
0e15258
codex: address PR review feedback (#18190)
etraut-openai Apr 17, 2026
b29707f
codex: fix CI failure on PR #18190
etraut-openai Apr 17, 2026
f761411
codex: stabilize side snapshot on windows
etraut-openai Apr 17, 2026
45e434b
Merge remote-tracking branch 'origin/main' into etraut/side-conversation
etraut-openai Apr 17, 2026
745bdc6
Merge branch 'main' into etraut/side-conversation
etraut-openai Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
489 changes: 473 additions & 16 deletions codex-rs/tui/src/app.rs

Large diffs are not rendered by default.

390 changes: 390 additions & 0 deletions codex-rs/tui/src/app/side.rs

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use codex_utils_approval_presets::ApprovalPreset;
use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::chatwidget::UserMessage;
use crate::history_cell::HistoryCell;
use crate::legacy_core::plugins::PluginCapabilitySummary;

Expand Down Expand Up @@ -103,6 +104,12 @@ pub(crate) enum AppEvent {
/// Switch the active thread to the selected agent.
SelectAgentThread(ThreadId),

/// Fork the current thread into a transient side conversation.
StartSide {
parent_thread_id: ThreadId,
user_message: Option<UserMessage>,
},

/// Submit an op to the specified thread, regardless of current focus.
SubmitThreadOp {
thread_id: ThreadId,
Expand Down
50 changes: 50 additions & 0 deletions codex-rs/tui/src/app_server_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadInjectItemsResponse;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
Expand Down Expand Up @@ -76,6 +78,7 @@ use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
Expand Down Expand Up @@ -415,6 +418,29 @@ impl AppServerSession {
Ok(response.thread)
}

pub(crate) async fn thread_inject_items(
&mut self,
thread_id: ThreadId,
items: Vec<ResponseItem>,
) -> Result<ThreadInjectItemsResponse> {
let items = items
.into_iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()
.wrap_err("failed to encode thread/inject_items payload")?;
let request_id = self.next_request_id();
self.client
.request_typed(ClientRequest::ThreadInjectItems {
request_id,
params: ThreadInjectItemsParams {
thread_id: thread_id.to_string(),
items,
},
})
.await
.wrap_err("thread/inject_items failed during TUI side conversation setup")
}

#[allow(clippy::too_many_arguments)]
pub(crate) async fn turn_start(
&mut self,
Expand Down Expand Up @@ -967,6 +993,8 @@ fn thread_fork_params_from_config(
approvals_reviewer: approvals_reviewer_override_from_config(&config),
sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get().clone()),
config: config_request_overrides_from_config(&config),
base_instructions: config.base_instructions.clone(),
developer_instructions: config.developer_instructions.clone(),
ephemeral: config.ephemeral,
persist_extended_history: true,
..ThreadForkParams::default()
Expand Down Expand Up @@ -1329,6 +1357,28 @@ mod tests {
assert_eq!(fork.model_provider, None);
}

#[tokio::test]
async fn thread_fork_params_forward_instruction_overrides() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let mut config = build_config(&temp_dir).await;
config.base_instructions = Some("Base override.".to_string());
config.developer_instructions = Some("Developer override.".to_string());
let thread_id = ThreadId::new();

let params = thread_fork_params_from_config(
config,
thread_id,
ThreadParamsMode::Embedded,
/*remote_cwd_override*/ None,
);

assert_eq!(params.base_instructions.as_deref(), Some("Base override."));
assert_eq!(
params.developer_instructions.as_deref(),
Some("Developer override.")
);
}

#[tokio::test]
async fn resume_response_restores_turns_from_thread_items() {
let temp_dir = tempfile::tempdir().expect("tempdir");
Expand Down
33 changes: 28 additions & 5 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ use super::footer::render_footer_from_props;
use super::footer::render_footer_hint_items;
use super::footer::render_footer_line;
use super::footer::reset_mode_after_activity;
use super::footer::side_conversation_context_line;
use super::footer::single_line_footer_layout;
use super::footer::toggle_shortcut_mode;
use super::footer::uses_passive_footer_status_layout;
Expand Down Expand Up @@ -359,9 +360,11 @@ pub(crate) struct ChatComposer {
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
windows_degraded_sandbox_active: bool,
side_conversation_active: bool,
is_zellij: bool,
status_line_value: Option<Line<'static>>,
status_line_enabled: bool,
side_conversation_context_label: Option<String>,
// Agent label injected into the footer's contextual row when multi-agent mode is active.
active_agent_label: Option<String>,
history_search: Option<HistorySearchSession>,
Expand Down Expand Up @@ -411,6 +414,7 @@ impl ChatComposer {
realtime_conversation_enabled: self.realtime_conversation_enabled,
audio_device_selection_enabled: self.audio_device_selection_enabled,
allow_elevate_sandbox: self.windows_degraded_sandbox_active,
side_conversation_active: self.side_conversation_active,
}
}

Expand Down Expand Up @@ -494,12 +498,14 @@ impl ChatComposer {
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
is_zellij: matches!(
codex_terminal_detection::terminal_info().multiplexer,
Some(codex_terminal_detection::Multiplexer::Zellij {})
),
status_line_value: None,
status_line_enabled: false,
side_conversation_context_label: None,
active_agent_label: None,
history_search: None,
};
Expand Down Expand Up @@ -594,6 +600,10 @@ impl ChatComposer {
self.audio_device_selection_enabled = enabled;
}

pub fn set_side_conversation_active(&mut self, active: bool) {
self.side_conversation_active = active;
}

/// Compatibility shim for tests that still toggle the removed steer mode flag.
#[cfg(test)]
pub fn set_steer_enabled(&mut self, _enabled: bool) {}
Expand Down Expand Up @@ -3258,6 +3268,7 @@ impl ChatComposer {
realtime_conversation_enabled,
audio_device_selection_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
side_conversation_active: self.side_conversation_active,
});
command_popup.on_composer_text_change(first_line.to_string());
self.active_popup = ActivePopup::Command(command_popup);
Expand Down Expand Up @@ -3502,6 +3513,14 @@ impl ChatComposer {
true
}

pub(crate) fn set_side_conversation_context_label(&mut self, label: Option<String>) -> bool {
if self.side_conversation_context_label == label {
return false;
}
self.side_conversation_context_label = label;
true
}

/// Replaces the contextual footer label for the currently viewed agent.
///
/// Returning `false` means the value was unchanged, so callers can skip redraw work. This
Expand Down Expand Up @@ -3711,12 +3730,13 @@ impl ChatComposer {
} else {
self.collaboration_mode_indicator
};
let active_footer_hint_override = self.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
Expand All @@ -3732,7 +3752,11 @@ impl ChatComposer {
show_queue_hint,
)
};
let right_line = if status_line_active {
let right_line = if let Some(label) =
self.side_conversation_context_label.as_ref()
{
Some(side_conversation_context_line(label))
} else if status_line_active {
let full =
mode_indicator_line(self.collaboration_mode_indicator, show_cycle_hint);
let compact = mode_indicator_line(
Expand Down Expand Up @@ -3765,7 +3789,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 {
Expand Down Expand Up @@ -3843,7 +3867,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 {
Expand All @@ -3860,7 +3884,6 @@ impl ChatComposer {
show_queue_hint,
);
}

if show_right && let Some(line) = &right_line {
render_context_right(hint_rect, buf, line);
}
Expand Down
7 changes: 7 additions & 0 deletions codex-rs/tui/src/bottom_pane/command_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub(crate) struct CommandPopupFlags {
pub(crate) realtime_conversation_enabled: bool,
pub(crate) audio_device_selection_enabled: bool,
pub(crate) windows_degraded_sandbox_active: bool,
pub(crate) side_conversation_active: bool,
}

impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
Expand All @@ -51,6 +52,7 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
realtime_conversation_enabled: value.realtime_conversation_enabled,
audio_device_selection_enabled: value.audio_device_selection_enabled,
allow_elevate_sandbox: value.windows_degraded_sandbox_active,
side_conversation_active: value.side_conversation_active,
}
}
}
Expand Down Expand Up @@ -359,6 +361,7 @@ mod tests {
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
});
popup.on_composer_text_change("/collab".to_string());

Expand All @@ -379,6 +382,7 @@ mod tests {
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
});
popup.on_composer_text_change("/plan".to_string());

Expand All @@ -399,6 +403,7 @@ mod tests {
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
});
popup.on_composer_text_change("/pers".to_string());

Expand Down Expand Up @@ -426,6 +431,7 @@ mod tests {
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
});
popup.on_composer_text_change("/personality".to_string());

Expand All @@ -446,6 +452,7 @@ mod tests {
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
side_conversation_active: false,
});
popup.on_composer_text_change("/aud".to_string());

Expand Down
10 changes: 9 additions & 1 deletion codex-rs/tui/src/bottom_pane/footer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
//! confirmation, shortcut help, or queue hints.
//! - "contextual footer" means the footer is free to show ambient context instead of an
//! instruction. In that state, the footer may render the configured status line, the active
//! agent label, or both combined.
//! agent label, side-conversation state, or some combination of those.
//!
//! Single-line collapse overview:
//! 1. The composer decides the current `FooterMode` and hint flags, then calls
Expand Down Expand Up @@ -483,6 +483,14 @@ pub(crate) fn mode_indicator_line(
indicator.map(|indicator| Line::from(vec![indicator.styled_span(show_cycle_hint)]))
}

pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> {
if let Some(rest) = label.strip_prefix("Side ") {
Line::from(vec!["Side".magenta().bold(), format!(" {rest}").magenta()])
} else {
Line::from(label.to_string()).magenta()
}
}

fn right_aligned_x(area: Rect, content_width: u16) -> Option<u16> {
if area.is_empty() {
return None;
Expand Down
16 changes: 16 additions & 0 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,16 @@ impl BottomPane {
self.request_redraw();
}

pub(crate) fn set_side_conversation_active(&mut self, active: bool) {
self.composer.set_side_conversation_active(active);
self.request_redraw();
}

pub(crate) fn set_placeholder_text(&mut self, placeholder: String) {
self.composer.set_placeholder_text(placeholder);
self.request_redraw();
}

/// Update the key hint shown next to queued messages so it matches the
/// binding that `ChatWidget` actually listens for.
pub(crate) fn set_queued_message_edit_binding(&mut self, binding: KeyBinding) {
Expand Down Expand Up @@ -1272,6 +1282,12 @@ impl BottomPane {
self.request_redraw();
}
}

pub(crate) fn set_side_conversation_context_label(&mut self, label: Option<String>) {
if self.composer.set_side_conversation_context_label(label) {
self.request_redraw();
}
}
}

#[cfg(not(target_os = "linux"))]
Expand Down
Loading
Loading