Remove the legacy TUI split (#15922)

This is the part 1 of 2 PRs that will delete the `tui` /
`tui_app_server` split. This part simply deletes the existing `tui`
directory and marks the `tui_app_server` feature flag as removed. I left
the `tui_app_server` feature flag in place for now so its presence
doesn't result in an error. It is simply ignored.

Part 2 will rename the `tui_app_server` directory `tui`. I did this as
two parts to reduce visible code churn.
This commit is contained in:
Eric Traut authored and GitHub committed 2026-03-27 22:56:44 +00:00
1 parent 307e427a9b
commit d65deec617
856 files changed
+257 -130872

No files matched your search

-149
View File
@@ -1,149 +0,0 @@
use std::sync::Arc;
use codex_core::CodexThread;
use codex_core::NewThread;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::mpsc::unbounded_channel;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
const TUI_NOTIFY_CLIENT: &str = "codex-tui";
async fn initialize_app_server_client_name(thread: &CodexThread) {
if let Err(err) = thread
.set_app_server_client_name(Some(TUI_NOTIFY_CLIENT.to_string()))
.await
{
tracing::error!("failed to set app server client name: {err}");
}
}
/// Spawn the agent bootstrapper and op forwarding loop, returning the
/// `UnboundedSender<Op>` used by the UI to submit operations.
pub(crate) fn spawn_agent(
config: Config,
app_event_tx: AppEventSender,
server: Arc<ThreadManager>,
) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
let app_event_tx_clone = app_event_tx;
tokio::spawn(async move {
let NewThread {
thread,
session_configured,
..
} = match server.start_thread(config).await {
Ok(v) => v,
Err(err) => {
let message = format!("Failed to initialize codex: {err}");
tracing::error!("{message}");
app_event_tx_clone.send(AppEvent::CodexEvent(Event {
id: "".to_string(),
msg: EventMsg::Error(err.to_error_event(/*message_prefix*/ None)),
}));
app_event_tx_clone.send(AppEvent::FatalExitRequest(message));
tracing::error!("failed to initialize codex: {err}");
return;
}
};
initialize_app_server_client_name(thread.as_ref()).await;
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
let ev = codex_protocol::protocol::Event {
// The `id` does not matter for rendering, so we can use a fake value.
id: "".to_string(),
msg: codex_protocol::protocol::EventMsg::SessionConfigured(session_configured),
};
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
let thread_clone = thread.clone();
tokio::spawn(async move {
while let Some(op) = codex_op_rx.recv().await {
let id = thread_clone.submit(op).await;
if let Err(e) = id {
tracing::error!("failed to submit op: {e}");
}
}
});
while let Ok(event) = thread.next_event().await {
let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete);
app_event_tx_clone.send(AppEvent::CodexEvent(event));
if is_shutdown_complete {
// ShutdownComplete is terminal for a thread; drop this receiver task so
// the Arc<CodexThread> can be released and thread resources can clean up.
break;
}
}
});
codex_op_tx
}
/// Spawn agent loops for an existing thread (e.g., a forked thread).
/// Sends the provided `SessionConfiguredEvent` immediately, then forwards subsequent
/// events and accepts Ops for submission.
pub(crate) fn spawn_agent_from_existing(
thread: std::sync::Arc<CodexThread>,
session_configured: codex_protocol::protocol::SessionConfiguredEvent,
app_event_tx: AppEventSender,
) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
let app_event_tx_clone = app_event_tx;
tokio::spawn(async move {
initialize_app_server_client_name(thread.as_ref()).await;
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
let ev = codex_protocol::protocol::Event {
id: "".to_string(),
msg: codex_protocol::protocol::EventMsg::SessionConfigured(session_configured),
};
app_event_tx_clone.send(AppEvent::CodexEvent(ev));
let thread_clone = thread.clone();
tokio::spawn(async move {
while let Some(op) = codex_op_rx.recv().await {
let id = thread_clone.submit(op).await;
if let Err(e) = id {
tracing::error!("failed to submit op: {e}");
}
}
});
while let Ok(event) = thread.next_event().await {
let is_shutdown_complete = matches!(event.msg, EventMsg::ShutdownComplete);
app_event_tx_clone.send(AppEvent::CodexEvent(event));
if is_shutdown_complete {
// ShutdownComplete is terminal for a thread; drop this receiver task so
// the Arc<CodexThread> can be released and thread resources can clean up.
break;
}
}
});
codex_op_tx
}
/// Spawn an op-forwarding loop for an existing thread without subscribing to events.
pub(crate) fn spawn_op_forwarder(thread: std::sync::Arc<CodexThread>) -> UnboundedSender<Op> {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
tokio::spawn(async move {
initialize_app_server_client_name(thread.as_ref()).await;
while let Some(op) = codex_op_rx.recv().await {
if let Err(e) = thread.submit(op).await {
tracing::error!("failed to submit op: {e}");
}
}
});
codex_op_tx
}
-105
View File
@@ -1,105 +0,0 @@
use std::collections::VecDeque;
use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::ExecCommandBeginEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::request_user_input::RequestUserInputEvent;
use super::ChatWidget;
#[derive(Debug)]
pub(crate) enum QueuedInterrupt {
ExecApproval(ExecApprovalRequestEvent),
ApplyPatchApproval(ApplyPatchApprovalRequestEvent),
Elicitation(ElicitationRequestEvent),
RequestPermissions(RequestPermissionsEvent),
RequestUserInput(RequestUserInputEvent),
ExecBegin(ExecCommandBeginEvent),
ExecEnd(ExecCommandEndEvent),
McpBegin(McpToolCallBeginEvent),
McpEnd(McpToolCallEndEvent),
PatchEnd(PatchApplyEndEvent),
}
#[derive(Default)]
pub(crate) struct InterruptManager {
queue: VecDeque<QueuedInterrupt>,
}
impl InterruptManager {
pub(crate) fn new() -> Self {
Self {
queue: VecDeque::new(),
}
}
#[inline]
pub(crate) fn is_empty(&self) -> bool {
self.queue.is_empty()
}
pub(crate) fn push_exec_approval(&mut self, ev: ExecApprovalRequestEvent) {
self.queue.push_back(QueuedInterrupt::ExecApproval(ev));
}
pub(crate) fn push_apply_patch_approval(&mut self, ev: ApplyPatchApprovalRequestEvent) {
self.queue
.push_back(QueuedInterrupt::ApplyPatchApproval(ev));
}
pub(crate) fn push_elicitation(&mut self, ev: ElicitationRequestEvent) {
self.queue.push_back(QueuedInterrupt::Elicitation(ev));
}
pub(crate) fn push_request_permissions(&mut self, ev: RequestPermissionsEvent) {
self.queue
.push_back(QueuedInterrupt::RequestPermissions(ev));
}
pub(crate) fn push_user_input(&mut self, ev: RequestUserInputEvent) {
self.queue.push_back(QueuedInterrupt::RequestUserInput(ev));
}
pub(crate) fn push_exec_begin(&mut self, ev: ExecCommandBeginEvent) {
self.queue.push_back(QueuedInterrupt::ExecBegin(ev));
}
pub(crate) fn push_exec_end(&mut self, ev: ExecCommandEndEvent) {
self.queue.push_back(QueuedInterrupt::ExecEnd(ev));
}
pub(crate) fn push_mcp_begin(&mut self, ev: McpToolCallBeginEvent) {
self.queue.push_back(QueuedInterrupt::McpBegin(ev));
}
pub(crate) fn push_mcp_end(&mut self, ev: McpToolCallEndEvent) {
self.queue.push_back(QueuedInterrupt::McpEnd(ev));
}
pub(crate) fn push_patch_end(&mut self, ev: PatchApplyEndEvent) {
self.queue.push_back(QueuedInterrupt::PatchEnd(ev));
}
pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) {
while let Some(q) = self.queue.pop_front() {
match q {
QueuedInterrupt::ExecApproval(ev) => chat.handle_exec_approval_now(ev),
QueuedInterrupt::ApplyPatchApproval(ev) => chat.handle_apply_patch_approval_now(ev),
QueuedInterrupt::Elicitation(ev) => chat.handle_elicitation_request_now(ev),
QueuedInterrupt::RequestPermissions(ev) => chat.handle_request_permissions_now(ev),
QueuedInterrupt::RequestUserInput(ev) => chat.handle_request_user_input_now(ev),
QueuedInterrupt::ExecBegin(ev) => chat.handle_exec_begin_now(ev),
QueuedInterrupt::ExecEnd(ev) => chat.handle_exec_end_now(ev),
QueuedInterrupt::McpBegin(ev) => chat.handle_mcp_begin_now(ev),
QueuedInterrupt::McpEnd(ev) => chat.handle_mcp_end_now(ev),
QueuedInterrupt::PatchEnd(ev) => chat.handle_patch_apply_end_now(ev),
}
}
}
}
File diff suppressed because it is too large. Load diff
-547
View File
@@ -1,547 +0,0 @@
use super::*;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
#[cfg(not(target_os = "linux"))]
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::RealtimeEvent;
#[cfg(not(target_os = "linux"))]
use std::sync::atomic::AtomicUsize;
#[cfg(not(target_os = "linux"))]
use std::time::Duration;
const REALTIME_CONVERSATION_PROMPT: &str = "You are in a realtime voice conversation in the Codex TUI. Respond conversationally and concisely.";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum RealtimeConversationPhase {
#[default]
Inactive,
Starting,
Active,
Stopping,
}
#[derive(Default)]
pub(super) struct RealtimeConversationUiState {
pub(super) phase: RealtimeConversationPhase,
#[cfg(not(target_os = "linux"))]
audio_behavior: RealtimeAudioBehavior,
requested_close: bool,
session_id: Option<String>,
warned_audio_only_submission: bool,
#[cfg(not(target_os = "linux"))]
pub(super) meter_placeholder_id: Option<String>,
#[cfg(not(target_os = "linux"))]
capture_stop_flag: Option<Arc<AtomicBool>>,
#[cfg(not(target_os = "linux"))]
capture: Option<crate::voice::VoiceCapture>,
#[cfg(not(target_os = "linux"))]
audio_player: Option<crate::voice::RealtimeAudioPlayer>,
#[cfg(not(target_os = "linux"))]
// Shared queue depth lets capture suppress echoed speaker audio without
// taking the playback queue lock from the input callback.
playback_queued_samples: Arc<AtomicUsize>,
}
#[cfg(not(target_os = "linux"))]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum RealtimeAudioBehavior {
#[default]
Legacy,
PlaybackAware,
}
#[cfg(not(target_os = "linux"))]
impl RealtimeAudioBehavior {
fn from_version(version: RealtimeConversationVersion) -> Self {
match version {
RealtimeConversationVersion::V1 => Self::Legacy,
RealtimeConversationVersion::V2 => Self::PlaybackAware,
}
}
fn input_behavior(
self,
playback_queued_samples: Arc<AtomicUsize>,
) -> crate::voice::RealtimeInputBehavior {
match self {
Self::Legacy => crate::voice::RealtimeInputBehavior::Ungated,
Self::PlaybackAware => crate::voice::RealtimeInputBehavior::PlaybackAware {
playback_queued_samples,
},
}
}
}
impl RealtimeConversationUiState {
pub(super) fn is_live(&self) -> bool {
matches!(
self.phase,
RealtimeConversationPhase::Starting
| RealtimeConversationPhase::Active
| RealtimeConversationPhase::Stopping
)
}
#[cfg(not(target_os = "linux"))]
pub(super) fn is_active(&self) -> bool {
matches!(self.phase, RealtimeConversationPhase::Active)
}
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct RenderedUserMessageEvent {
pub(super) message: String,
pub(super) remote_image_urls: Vec<String>,
pub(super) local_images: Vec<PathBuf>,
pub(super) text_elements: Vec<TextElement>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct PendingSteerCompareKey {
pub(super) message: String,
pub(super) image_count: usize,
}
impl ChatWidget {
pub(super) fn stop_realtime_conversation_from_ui(&mut self) {
self.request_realtime_conversation_close(/*info_message*/ None);
}
#[cfg(not(target_os = "linux"))]
pub(crate) fn stop_realtime_conversation_for_deleted_meter(&mut self, id: &str) -> bool {
if self.realtime_conversation.is_live()
&& self.realtime_conversation.meter_placeholder_id.as_deref() == Some(id)
{
self.realtime_conversation.meter_placeholder_id = None;
self.stop_realtime_conversation_from_ui();
return true;
}
false
}
pub(super) fn rendered_user_message_event_from_parts(
message: String,
text_elements: Vec<TextElement>,
local_images: Vec<PathBuf>,
remote_image_urls: Vec<String>,
) -> RenderedUserMessageEvent {
RenderedUserMessageEvent {
message,
remote_image_urls,
local_images,
text_elements,
}
}
pub(super) fn rendered_user_message_event_from_event(
event: &UserMessageEvent,
) -> RenderedUserMessageEvent {
Self::rendered_user_message_event_from_parts(
event.message.clone(),
event.text_elements.clone(),
event.local_images.clone(),
event.images.clone().unwrap_or_default(),
)
}
/// Build the compare key for a submitted pending steer without invoking the
/// expensive request-serialization path. Pending steers only need to match the
/// committed `ItemCompleted(UserMessage)` emitted after core drains input, which
/// preserves flattened text and total image count but not UI-only text ranges or
/// local image paths.
pub(super) fn pending_steer_compare_key_from_items(
items: &[UserInput],
) -> PendingSteerCompareKey {
let mut message = String::new();
let mut image_count = 0;
for item in items {
match item {
UserInput::Text { text, .. } => message.push_str(text),
UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1,
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
_ => {}
}
}
PendingSteerCompareKey {
message,
image_count,
}
}
pub(super) fn pending_steer_compare_key_from_item(
item: &codex_protocol::items::UserMessageItem,
) -> PendingSteerCompareKey {
Self::pending_steer_compare_key_from_items(&item.content)
}
#[cfg(test)]
pub(super) fn rendered_user_message_event_from_inputs(
items: &[UserInput],
) -> RenderedUserMessageEvent {
let mut message = String::new();
let mut remote_image_urls = Vec::new();
let mut local_images = Vec::new();
let mut text_elements = Vec::new();
for item in items {
match item {
UserInput::Text {
text,
text_elements: current_text_elements,
} => append_text_with_rebased_elements(
&mut message,
&mut text_elements,
text,
current_text_elements.iter().map(|element| {
TextElement::new(
element.byte_range,
element.placeholder(text).map(str::to_string),
)
}),
),
UserInput::Image { image_url } => remote_image_urls.push(image_url.clone()),
UserInput::LocalImage { path } => local_images.push(path.clone()),
UserInput::Skill { .. } | UserInput::Mention { .. } => {}
_ => {}
}
}
Self::rendered_user_message_event_from_parts(
message,
text_elements,
local_images,
remote_image_urls,
)
}
pub(super) fn should_render_realtime_user_message_event(
&self,
event: &UserMessageEvent,
) -> bool {
if !self.realtime_conversation.is_live() {
return false;
}
let key = Self::rendered_user_message_event_from_event(event);
self.last_rendered_user_message_event.as_ref() != Some(&key)
}
pub(super) fn maybe_defer_user_message_for_realtime(
&mut self,
user_message: UserMessage,
) -> Option<UserMessage> {
if !self.realtime_conversation.is_live() {
return Some(user_message);
}
self.restore_user_message_to_composer(user_message);
if !self.realtime_conversation.warned_audio_only_submission {
self.realtime_conversation.warned_audio_only_submission = true;
self.add_info_message(
"Realtime voice mode is audio-only. Use /realtime to stop.".to_string(),
/*hint*/ None,
);
} else {
self.request_redraw();
}
None
}
pub(super) fn start_realtime_conversation(&mut self) {
self.realtime_conversation.phase = RealtimeConversationPhase::Starting;
self.realtime_conversation.requested_close = false;
self.realtime_conversation.session_id = None;
#[cfg(not(target_os = "linux"))]
{
self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy;
}
self.realtime_conversation.warned_audio_only_submission = false;
self.set_footer_hint_override(Some(vec![(
"/realtime".to_string(),
"stop live voice".to_string(),
)]));
self.submit_op(Op::RealtimeConversationStart(ConversationStartParams {
prompt: REALTIME_CONVERSATION_PROMPT.to_string(),
session_id: None,
}));
self.request_redraw();
}
pub(super) fn request_realtime_conversation_close(&mut self, info_message: Option<String>) {
if !self.realtime_conversation.is_live() {
if let Some(message) = info_message {
self.add_info_message(message, /*hint*/ None);
}
return;
}
self.realtime_conversation.requested_close = true;
self.realtime_conversation.phase = RealtimeConversationPhase::Stopping;
self.submit_op(Op::RealtimeConversationClose);
self.stop_realtime_local_audio();
self.set_footer_hint_override(/*items*/ None);
if let Some(message) = info_message {
self.add_info_message(message, /*hint*/ None);
} else {
self.request_redraw();
}
}
pub(super) fn reset_realtime_conversation_state(&mut self) {
self.stop_realtime_local_audio();
self.set_footer_hint_override(/*items*/ None);
self.realtime_conversation.phase = RealtimeConversationPhase::Inactive;
self.realtime_conversation.requested_close = false;
self.realtime_conversation.session_id = None;
#[cfg(not(target_os = "linux"))]
{
self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy;
}
self.realtime_conversation.warned_audio_only_submission = false;
}
fn fail_realtime_conversation(&mut self, message: String) {
self.add_error_message(message);
if self.realtime_conversation.is_live() {
self.request_realtime_conversation_close(/*info_message*/ None);
} else {
self.reset_realtime_conversation_state();
self.request_redraw();
}
}
pub(super) fn on_realtime_conversation_started(
&mut self,
ev: RealtimeConversationStartedEvent,
) {
if !self.realtime_conversation_enabled() {
self.request_realtime_conversation_close(/*info_message*/ None);
return;
}
self.realtime_conversation.phase = RealtimeConversationPhase::Active;
self.realtime_conversation.session_id = ev.session_id;
#[cfg(not(target_os = "linux"))]
{
self.realtime_conversation.audio_behavior =
RealtimeAudioBehavior::from_version(ev.version);
}
self.realtime_conversation.warned_audio_only_submission = false;
self.set_footer_hint_override(Some(vec![(
"/realtime".to_string(),
"stop live voice".to_string(),
)]));
self.start_realtime_local_audio();
self.request_redraw();
}
pub(super) fn on_realtime_conversation_realtime(
&mut self,
ev: RealtimeConversationRealtimeEvent,
) {
match ev.payload {
RealtimeEvent::SessionUpdated { session_id, .. } => {
self.realtime_conversation.session_id = Some(session_id);
}
RealtimeEvent::InputAudioSpeechStarted(_) | RealtimeEvent::ResponseCancelled(_) => {
#[cfg(not(target_os = "linux"))]
{
if matches!(
self.realtime_conversation.audio_behavior,
RealtimeAudioBehavior::PlaybackAware
) && let Some(player) = &self.realtime_conversation.audio_player
{
// Once the server detects user speech or the current response is cancelled,
// any buffered assistant audio is stale and should stop gating mic input.
player.clear();
}
}
}
RealtimeEvent::InputTranscriptDelta(_) => {}
RealtimeEvent::OutputTranscriptDelta(_) => {}
RealtimeEvent::AudioOut(frame) => self.enqueue_realtime_audio_out(&frame),
RealtimeEvent::ConversationItemAdded(_item) => {}
RealtimeEvent::ConversationItemDone { .. } => {}
RealtimeEvent::HandoffRequested(_) => {}
RealtimeEvent::Error(message) => {
self.fail_realtime_conversation(format!("Realtime voice error: {message}"));
}
}
}
pub(super) fn on_realtime_conversation_closed(&mut self, ev: RealtimeConversationClosedEvent) {
let requested = self.realtime_conversation.requested_close;
let reason = ev.reason;
self.reset_realtime_conversation_state();
if !requested
&& let Some(reason) = reason
&& reason != "error"
{
self.add_info_message(
format!("Realtime voice mode closed: {reason}"),
/*hint*/ None,
);
}
self.request_redraw();
}
fn enqueue_realtime_audio_out(&mut self, frame: &RealtimeAudioFrame) {
#[cfg(not(target_os = "linux"))]
{
if self.realtime_conversation.audio_player.is_none() {
self.realtime_conversation.audio_player = crate::voice::RealtimeAudioPlayer::start(
&self.config,
Arc::clone(&self.realtime_conversation.playback_queued_samples),
)
.ok();
}
if let Some(player) = &self.realtime_conversation.audio_player
&& let Err(err) = player.enqueue_frame(frame)
{
warn!("failed to play realtime audio: {err}");
}
}
#[cfg(target_os = "linux")]
{
let _ = frame;
}
}
#[cfg(not(target_os = "linux"))]
fn start_realtime_local_audio(&mut self) {
if self.realtime_conversation.capture_stop_flag.is_some() {
return;
}
let placeholder_id = self.bottom_pane.insert_transcription_placeholder("⠤⠤⠤⠤");
self.realtime_conversation.meter_placeholder_id = Some(placeholder_id.clone());
self.request_redraw();
let capture = match crate::voice::VoiceCapture::start_realtime(
&self.config,
self.app_event_tx.clone(),
self.realtime_conversation
.audio_behavior
.input_behavior(Arc::clone(
&self.realtime_conversation.playback_queued_samples,
)),
) {
Ok(capture) => capture,
Err(err) => {
self.realtime_conversation.meter_placeholder_id = None;
self.remove_transcription_placeholder(&placeholder_id);
self.fail_realtime_conversation(format!(
"Failed to start microphone capture: {err}"
));
return;
}
};
let stop_flag = capture.stopped_flag();
let peak = capture.last_peak_arc();
let meter_placeholder_id = placeholder_id;
let app_event_tx = self.app_event_tx.clone();
self.realtime_conversation.capture_stop_flag = Some(stop_flag.clone());
self.realtime_conversation.capture = Some(capture);
if self.realtime_conversation.audio_player.is_none() {
self.realtime_conversation.audio_player = crate::voice::RealtimeAudioPlayer::start(
&self.config,
Arc::clone(&self.realtime_conversation.playback_queued_samples),
)
.ok();
}
std::thread::spawn(move || {
let mut meter = crate::voice::RecordingMeterState::new();
loop {
if stop_flag.load(Ordering::Relaxed) {
break;
}
let meter_text = meter.next_text(peak.load(Ordering::Relaxed));
app_event_tx.send(AppEvent::UpdateRecordingMeter {
id: meter_placeholder_id.clone(),
text: meter_text,
});
std::thread::sleep(Duration::from_millis(60));
}
});
}
#[cfg(target_os = "linux")]
fn start_realtime_local_audio(&mut self) {}
#[cfg(not(target_os = "linux"))]
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
if !self.realtime_conversation.is_active() {
return;
}
match kind {
RealtimeAudioDeviceKind::Microphone => {
self.stop_realtime_microphone();
self.start_realtime_local_audio();
}
RealtimeAudioDeviceKind::Speaker => {
self.stop_realtime_speaker();
match crate::voice::RealtimeAudioPlayer::start(
&self.config,
Arc::clone(&self.realtime_conversation.playback_queued_samples),
) {
Ok(player) => {
self.realtime_conversation.audio_player = Some(player);
}
Err(err) => {
self.fail_realtime_conversation(format!(
"Failed to start speaker output: {err}"
));
}
}
}
}
self.request_redraw();
}
#[cfg(target_os = "linux")]
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
let _ = kind;
}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_local_audio(&mut self) {
self.stop_realtime_microphone();
self.stop_realtime_speaker();
}
#[cfg(target_os = "linux")]
fn stop_realtime_local_audio(&mut self) {}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_microphone(&mut self) {
if let Some(flag) = self.realtime_conversation.capture_stop_flag.take() {
flag.store(true, Ordering::Relaxed);
}
if let Some(capture) = self.realtime_conversation.capture.take() {
let _ = capture.stop();
}
if let Some(id) = self.realtime_conversation.meter_placeholder_id.take() {
self.remove_transcription_placeholder(&id);
}
}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_speaker(&mut self) {
if let Some(player) = self.realtime_conversation.audio_player.take() {
player.clear();
}
}
}
@@ -1,16 +0,0 @@
pub(crate) struct SessionHeader {
model: String,
}
impl SessionHeader {
pub(crate) fn new(model: String) -> Self {
Self { model }
}
/// Updates the header's model text.
pub(crate) fn set_model(&mut self, model: &str) {
if self.model != model {
self.model = model.to_string();
}
}
}
-452
View File
@@ -1,452 +0,0 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::SkillsToggleItem;
use crate::bottom_pane::SkillsToggleView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use codex_chatgpt::connectors::AppInfo;
use codex_core::connectors::connector_mention_slug;
use codex_core::mention_syntax::TOOL_MENTION_SIGIL;
use codex_core::skills::model::SkillDependencies;
use codex_core::skills::model::SkillInterface;
use codex_core::skills::model::SkillMetadata;
use codex_core::skills::model::SkillToolDependency;
use codex_protocol::protocol::ListSkillsResponseEvent;
use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_protocol::protocol::SkillsListEntry;
impl ChatWidget {
pub(crate) fn open_skills_list(&mut self) {
self.insert_str("$");
}
pub(crate) fn open_skills_menu(&mut self) {
let items = vec![
SelectionItem {
name: "List skills".to_string(),
description: Some("Tip: press $ to open this list directly.".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenSkillsList);
})],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Enable/Disable Skills".to_string(),
description: Some("Enable or disable skills.".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenManageSkillsPopup);
})],
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Skills".to_string()),
subtitle: Some("Choose an action".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn open_manage_skills_popup(&mut self) {
if self.skills_all.is_empty() {
self.add_info_message("No skills available.".to_string(), /*hint*/ None);
return;
}
let mut initial_state = HashMap::new();
for skill in &self.skills_all {
initial_state.insert(normalize_skill_config_path(&skill.path), skill.enabled);
}
self.skills_initial_state = Some(initial_state);
let items: Vec<SkillsToggleItem> = self
.skills_all
.iter()
.map(|skill| {
let core_skill = protocol_skill_to_core(skill);
let display_name = skill_display_name(&core_skill).to_string();
let description = skill_description(&core_skill).to_string();
let name = core_skill.name.clone();
let path = core_skill.path_to_skills_md;
SkillsToggleItem {
name: display_name,
skill_name: name,
description,
enabled: skill.enabled,
path,
}
})
.collect();
let view = SkillsToggleView::new(items, self.app_event_tx.clone());
self.bottom_pane.show_view(Box::new(view));
}
pub(crate) fn update_skill_enabled(&mut self, path: PathBuf, enabled: bool) {
let target = normalize_skill_config_path(&path);
for skill in &mut self.skills_all {
if normalize_skill_config_path(&skill.path) == target {
skill.enabled = enabled;
}
}
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
}
pub(crate) fn handle_manage_skills_closed(&mut self) {
let Some(initial_state) = self.skills_initial_state.take() else {
return;
};
let mut current_state = HashMap::new();
for skill in &self.skills_all {
current_state.insert(normalize_skill_config_path(&skill.path), skill.enabled);
}
let mut enabled_count = 0;
let mut disabled_count = 0;
for (path, was_enabled) in initial_state {
let Some(is_enabled) = current_state.get(&path) else {
continue;
};
if was_enabled != *is_enabled {
if *is_enabled {
enabled_count += 1;
} else {
disabled_count += 1;
}
}
}
if enabled_count == 0 && disabled_count == 0 {
return;
}
self.add_info_message(
format!("{enabled_count} skills enabled, {disabled_count} skills disabled"),
/*hint*/ None,
);
}
pub(crate) fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) {
let skills = skills_for_cwd(&self.config.cwd, &response.skills);
self.skills_all = skills;
self.set_skills(Some(enabled_skills_for_mentions(&self.skills_all)));
}
}
fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec<ProtocolSkillMetadata> {
skills_entries
.iter()
.find(|entry| entry.cwd.as_path() == cwd)
.map(|entry| entry.skills.clone())
.unwrap_or_default()
}
fn enabled_skills_for_mentions(skills: &[ProtocolSkillMetadata]) -> Vec<SkillMetadata> {
skills
.iter()
.filter(|skill| skill.enabled)
.map(protocol_skill_to_core)
.collect()
}
fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata {
SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}),
dependencies: skill
.dependencies
.clone()
.map(|dependencies| SkillDependencies {
tools: dependencies
.tools
.into_iter()
.map(|tool| SkillToolDependency {
r#type: tool.r#type,
value: tool.value,
description: tool.description,
transport: tool.transport,
command: tool.command,
url: tool.url,
})
.collect(),
}),
policy: None,
path_to_skills_md: skill.path.clone(),
scope: skill.scope,
}
}
fn normalize_skill_config_path(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
pub(crate) fn collect_tool_mentions(
text: &str,
mention_paths: &HashMap<String, String>,
) -> ToolMentions {
let mut mentions = extract_tool_mentions_from_text(text);
for (name, path) in mention_paths {
if mentions.names.contains(name) {
mentions.linked_paths.insert(name.clone(), path.clone());
}
}
mentions
}
pub(crate) fn find_skill_mentions_with_tool_mentions(
mentions: &ToolMentions,
skills: &[SkillMetadata],
) -> Vec<SkillMetadata> {
let mention_skill_paths: HashSet<&str> = mentions
.linked_paths
.values()
.filter(|path| is_skill_path(path))
.map(|path| normalize_skill_path(path))
.collect();
let mut seen_names = HashSet::new();
let mut seen_paths = HashSet::new();
let mut matches: Vec<SkillMetadata> = Vec::new();
for skill in skills {
if seen_paths.contains(&skill.path_to_skills_md) {
continue;
}
let path_str = skill.path_to_skills_md.to_string_lossy();
if mention_skill_paths.contains(path_str.as_ref()) {
seen_paths.insert(skill.path_to_skills_md.clone());
seen_names.insert(skill.name.clone());
matches.push(skill.clone());
}
}
for skill in skills {
if seen_paths.contains(&skill.path_to_skills_md) {
continue;
}
if mentions.names.contains(&skill.name) && seen_names.insert(skill.name.clone()) {
seen_paths.insert(skill.path_to_skills_md.clone());
matches.push(skill.clone());
}
}
matches
}
pub(crate) fn find_app_mentions(
mentions: &ToolMentions,
apps: &[AppInfo],
skill_names_lower: &HashSet<String>,
) -> Vec<AppInfo> {
let mut explicit_names = HashSet::new();
let mut selected_ids = HashSet::new();
for (name, path) in &mentions.linked_paths {
if let Some(connector_id) = app_id_from_path(path) {
explicit_names.insert(name.clone());
selected_ids.insert(connector_id.to_string());
}
}
let mut slug_counts: HashMap<String, usize> = HashMap::new();
for app in apps.iter().filter(|app| app.is_enabled) {
let slug = connector_mention_slug(app);
*slug_counts.entry(slug).or_insert(0) += 1;
}
for app in apps.iter().filter(|app| app.is_enabled) {
let slug = connector_mention_slug(app);
let slug_count = slug_counts.get(&slug).copied().unwrap_or(0);
if mentions.names.contains(&slug)
&& !explicit_names.contains(&slug)
&& slug_count == 1
&& !skill_names_lower.contains(&slug)
{
selected_ids.insert(app.id.clone());
}
}
apps.iter()
.filter(|app| app.is_enabled && selected_ids.contains(&app.id))
.cloned()
.collect()
}
pub(crate) struct ToolMentions {
names: HashSet<String>,
linked_paths: HashMap<String, String>,
}
fn extract_tool_mentions_from_text(text: &str) -> ToolMentions {
extract_tool_mentions_from_text_with_sigil(text, TOOL_MENTION_SIGIL)
}
fn extract_tool_mentions_from_text_with_sigil(text: &str, sigil: char) -> ToolMentions {
let text_bytes = text.as_bytes();
let mut names: HashSet<String> = HashSet::new();
let mut linked_paths: HashMap<String, String> = HashMap::new();
let mut index = 0;
while index < text_bytes.len() {
let byte = text_bytes[index];
if byte == b'['
&& let Some((name, path, end_index)) =
parse_linked_tool_mention(text, text_bytes, index, sigil)
{
if !is_common_env_var(name) {
if is_skill_path(path) {
names.insert(name.to_string());
}
linked_paths
.entry(name.to_string())
.or_insert(path.to_string());
}
index = end_index;
continue;
}
if byte != sigil as u8 {
index += 1;
continue;
}
let name_start = index + 1;
let Some(first_name_byte) = text_bytes.get(name_start) else {
index += 1;
continue;
};
if !is_mention_name_char(*first_name_byte) {
index += 1;
continue;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_mention_name_char(*next_byte)
{
name_end += 1;
}
let name = &text[name_start..name_end];
if !is_common_env_var(name) {
names.insert(name.to_string());
}
index = name_end;
}
ToolMentions {
names,
linked_paths,
}
}
fn parse_linked_tool_mention<'a>(
text: &'a str,
text_bytes: &[u8],
start: usize,
sigil: char,
) -> Option<(&'a str, &'a str, usize)> {
let sigil_index = start + 1;
if text_bytes.get(sigil_index) != Some(&(sigil as u8)) {
return None;
}
let name_start = sigil_index + 1;
let first_name_byte = text_bytes.get(name_start)?;
if !is_mention_name_char(*first_name_byte) {
return None;
}
let mut name_end = name_start + 1;
while let Some(next_byte) = text_bytes.get(name_end)
&& is_mention_name_char(*next_byte)
{
name_end += 1;
}
if text_bytes.get(name_end) != Some(&b']') {
return None;
}
let mut path_start = name_end + 1;
while let Some(next_byte) = text_bytes.get(path_start)
&& next_byte.is_ascii_whitespace()
{
path_start += 1;
}
if text_bytes.get(path_start) != Some(&b'(') {
return None;
}
let mut path_end = path_start + 1;
while let Some(next_byte) = text_bytes.get(path_end)
&& *next_byte != b')'
{
path_end += 1;
}
if text_bytes.get(path_end) != Some(&b')') {
return None;
}
let path = text[path_start + 1..path_end].trim();
if path.is_empty() {
return None;
}
let name = &text[name_start..name_end];
Some((name, path, path_end + 1))
}
fn is_common_env_var(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
matches!(
upper.as_str(),
"PATH"
| "HOME"
| "USER"
| "SHELL"
| "PWD"
| "TMPDIR"
| "TEMP"
| "TMP"
| "LANG"
| "TERM"
| "XDG_CONFIG_HOME"
)
}
fn is_mention_name_char(byte: u8) -> bool {
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-')
}
fn is_skill_path(path: &str) -> bool {
!path.starts_with("app://") && !path.starts_with("mcp://") && !path.starts_with("plugin://")
}
fn normalize_skill_path(path: &str) -> &str {
path.strip_prefix("skill://").unwrap_or(path)
}
fn app_id_from_path(path: &str) -> Option<&str> {
path.strip_prefix("app://")
.filter(|value| !value.is_empty())
}
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&approved_lines)
---
• Added foo.txt (+1 -0)
1 +hello
@@ -1,17 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend().vt100().screen().contents()
---
Would you like to run the following command?
Reason: this is a test reason such as one that would be produced by the model
$ echo hello world
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `echo hello world` (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -1,16 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: contents
---
Would you like to run the following command?
$ python - <<'PY'
print('hello')
PY
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -1,13 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend().vt100().screen().contents()
---
Would you like to run the following command?
$ echo hello world
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `echo hello world` (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -1,20 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend().vt100().screen().contents()
---
Would you like to make the following edits?
Reason: The model wants to apply changes
README.md (+2 -0)
1 +hello
2 +world
› 1. Yes, proceed (y)
2. Yes, and don't ask again for these files (a)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -1,15 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7368
expression: popup
---
Update Model Permissions
› 1. Default Codex can read and edit files in the current workspace, and
run commands. Approval is required to access the internet or
edit other files.
2. Full Access Codex can edit files outside this workspace and access the
internet without asking for approval. Exercise caution when
using.
Press enter to confirm or esc to go back
@@ -1,18 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7365
expression: popup
---
Update Model Permissions
› 1. Read Only (current) Codex can read files in the current workspace.
Approval is required to edit files or access the
internet.
2. Default Codex can read and edit files in the current
workspace, and run commands. Approval is required to
access the internet or edit other files.
3. Full Access Codex can edit files outside this workspace and
access the internet without asking for approval.
Exercise caution when using.
Press enter to confirm or esc to go back
@@ -1,23 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 3945
expression: popup
---
Update Model Permissions
› 1. Read Only (current) Codex can read files in the current
workspace. Approval is required to edit
files or access the internet.
2. Default (non-admin sandbox) Codex can read and edit files in the
current workspace, and run commands.
Approval is required to access the
internet or edit other files.
3. Full Access Codex can edit files outside this
workspace and access the internet without
asking for approval. Exercise caution
when using.
The non-admin sandbox protects your files and prevents network access under
most circumstances. However, it carries greater risk if prompt injected. To
upgrade to the default sandbox, run /setup-default-sandbox.
Press enter to confirm or esc to go back
@@ -1,8 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: before
---
Apps
Loading installed and available apps...
› Loading apps... This updates when the full list is ready.
@@ -1,153 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: "lines[start_idx..].join(\"\\n\")"
---
• I need to check the codex-rs repository to explain why the project's binaries
are large. The user is likely seeking specifics about the setup: are Rust
builds static, what features are enabled, and is debug information included?
It could be due to static linking, included OpenSSL, or how panic handling
is set up. I should look into the Cargo.toml file to confirm features and
profiles without needing to edit any code. Let's get started on this!
─ Worked for 0s ────────────────────────────────────────────────────────────────
• I’m going to scan the workspace and Cargo manifests to see build profiles and
dependencies that impact binary size. Then I’ll summarize the main causes.
• Explored
└ List ls -la
Read Cargo.toml
• I'm reviewing the workspace's release profile, which has settings like
lto=fat, strip=symbols, and codegen-units=1 to reduce binary size. However, it
seems that "strip = symbols" only removes symbols, not debug information. In
Rust 1.73+, both "symbols" and "debuginfo" stripping options are available. I
should check whether using these results in a smaller binary. I’ll also skim
through crates for heavy dependencies in Cargo.toml, including cli, core,
exec, linux-sandbox, tui, login, ollama, and mcp.
• Ran for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
│ file-search linux-sandbox login mcp-client mcp-server ollama
│ tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo;
│ … +1 lines
└ --- ansi-escape/Cargo.toml
[package]
… +243 lines
] }
tracing = { version
• Explored
└ Read Cargo.toml
• I’m evaluating how binary size is influenced by various factors. There are
many binaries, such as codex and codex-exec, which include dependencies by
default, pulling in features like Tokio and hyper. For instance, reqwest
includes default features like TLS through rustls. If default features remain
enabled, it inflates the size with many additional components. I notice
that for musl targets, using vendored builds of OpenSSL could significantly
increase binary size. This is quite the intricate web of dependencies!
Analyzing musl and binary size
I’m considering the implications of building for musl with static linking.
In this case, OpenSSL might come into play through some crates, but reqwest
defaults to using rustls, although openssl-sys vendored could increase
binary size. It’s interesting to think about whether they’re targeting musl,
especially since it seems they might often be building for Mac and Linux with
dynamic linking instead.
I also realize there’s a difference between debug and release builds — release
should be smaller due to optimizations, but if they’re defaulting to debug
builds, that could lead to much larger binaries.
Assessing crate impacts on size
I need to be precise when analyzing binary sizes from various crates. Some
crates, like ratatui-image, include heavy features such as libjpeg decoders,
which can really bulk things up. Then there are the tree-sitter crates that
contain large static grammar tables, adding significant size. The starlark
crate and tracing-subscriber also contribute to size increases.
With static linking per binary being standard in Rust, it leads to multiple
copies of the same dependencies unless cdylibs are used. That makes the
compile times longer, but using LTO helps reduce bloat somewhat through
deduplication.
Evaluating binary size factors
Having multiple binaries indeed increases the overall size significantly.
I'm considering options like upgrading to "opt-level = 'z'" or using
"strip=debuginfo" for reducing size. It's interesting that while the profile
only sets symbols for stripping, not debuginfo, this could inflate sizes if
retained in release builds.
The distinction between "symbols" and "debuginfo" is critical; "symbols"
keeps debug information which may not align with the goal of minimizing
size. I’d like to verify with the Rust documentation, but my network access
is restricted.
Explaining large binary sizes
I’m analyzing why binaries in this project are so large. The primary reasons
include monolithic static linking, where each binary includes the full
dependency graph without shared libraries. Heavy dependencies such as reqwest,
hyper, and TLS components also contribute significantly to size, along with
image decoders and other libraries.
Using MUSL targets means vendored OpenSSL can drastically increase the
size due to static linking. Furthermore, if the release profile keeps debug
information due to using "strip = symbols," that can lead to larger binaries
as well. It’s a mix of several factors, including panic strategies and
platform-specific linking behavior.
Identifying binary size contributors
I’m considering why binaries are still large even without static linking. The
tracing-subscriber crate potentially adds weight because it includes regex,
though it can sometimes use a lighter version. References from files like
"core/Cargo.toml" show dependencies like reqwest and tokio, while the tui uses
the image crate.
To present this clearly, I’ll format the final answer with two main sections:
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
reducing size, but I want to stay focused on answering the user's question.
─ Worked for 0s ────────────────────────────────────────────────────────────────
• Here’s what’s driving size in this workspace’s binaries.
Main Causes
- Static linking style: Each bin (codex, codex-tui, codex-exec, codex-mcp-
server, etc.) statically links its full dependency graph, so common code isn’t
shared at runtime across executables.
- Heavy deps (HTTP/TLS): reqwest brings in Hyper, HTTP/2, compressors,
and a TLS stack (rustls by default; OpenSSL on musl). In core, login, tui,
and ollama you enable reqwest with json/stream, which still pulls a large
transitive set.
- Image/terminal stack: codex-tui includes image (with jpeg), ratatui,
crossterm, and ratatui-image, which together contribute significant code size
(decoders, pixel ops, terminal backends).
- Parsers/VMs: tree-sitter + tree-sitter-bash (in core and apply-patch) and
starlark (in execpolicy) include sizeable parser/VM tables and runtimes.
- Tokio runtime: Broad tokio features (rt-multi-thread, macros, process,
signal) across many crates inflate code size even if only subsets are used
per bin.
- Panic + backtraces: Default panic = unwind and backtrace support keep
unwinding tables and symbols that add weight.
- Per-target OpenSSL (musl): For *-unknown-linux-musl, core enables openssl-
sys with vendored, compiling OpenSSL into the binary—this adds multiple
megabytes per executable.
Build-Mode Notes
- Release settings: You use lto = "fat" and codegen-units = 1 (good for size),
but strip = "symbols" keeps debuginfo. Debuginfo is often the largest single
contributor; if you build in release with that setting, binaries can still
be large.
- Debug builds: cargo build (dev profile) includes full debuginfo, no LTO, and
assertions—outputs are much larger than cargo build --release.
If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt-
level = "z", panic abort, tighter tokio/reqwest features) and estimate impact
per binary.
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
" "
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
" "
@@ -1,44 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• I’m going to search the repo for where “Change Approved” is rendered to update
that view.
• Explored
└ Search Change Approved
Read diff_render.rs
• Investigating rendering code (0s • esc to interrupt)
› Summarize recent commits
tab to queue message 100% context left
@@ -1,18 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: visual
---
• -- Indented code block (4 spaces)
SELECT *
FROM "users"
WHERE "email" LIKE '%@example.com';
```sh
printf 'fenced within fenced\n'
```
{
// comment allowed in jsonc
"path": "C:\\Program Files\\App",
"regex": "^foo.*(bar)?$"
}
@@ -1,28 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
• Queued follow-up messages
↳ Hello, world! 0
↳ Hello, world! 1
↳ Hello, world! 2
↳ Hello, world! 3
↳ Hello, world! 4
↳ Hello, world! 5
↳ Hello, world! 6
↳ Hello, world! 7
↳ Hello, world! 8
↳ Hello, world! 9
↳ Hello, world! 10
↳ Hello, world! 11
↳ Hello, world! 12
↳ Hello, world! 13
↳ Hello, world! 14
↳ Hello, world! 15
› Ask Codex to do anything
? for shortcuts 100% context left
@@ -1,21 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
• Messages to be submitted at end of turn
↳ Steer submitted while /compact was running.
› Ask Codex to do anything
? for shortcuts 100% context left
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9494
expression: combined
---
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob
---
■ '/model' is disabled while a task is in progress.
@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 495
expression: lines_to_single_string(&aborted_long)
---
✗ You canceled the request to run echo
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&aborted_multi)
---
✗ You canceled the request to run echo line1 ...
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&decision)
---
✔ You approved codex to run echo hello world this time
@@ -1,39 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 13 },
content: [
" ",
" ",
" Would you like to run the following command? ",
" ",
" Reason: this is a test reason such as one that would be produced by the ",
" model ",
" ",
" $ echo hello world ",
" ",
"› 1. Yes, proceed (y) ",
" 2. No, and tell Codex what to do differently (esc) ",
" ",
" Press enter to confirm or esc to cancel ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 46, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 10, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 7, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 7, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE,
x: 20, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
]
}
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Experimental features
Toggle experimental features. Changes are saved to config.toml.
› [ ] Ghost snapshots Capture undo snapshots each turn.
[x] Shell tool Allow the model to run shell commands.
Press space to select or enter to save for next conversation
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob1
---
• Exploring
└ List ls -la
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob2
---
• Explored
└ List ls -la
@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob3
---
• Exploring
└ List ls -la
Read foo.txt
@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob4
---
• Explored
└ List ls -la
Read foo.txt
@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob5
---
• Explored
└ List ls -la
Read foo.txt
@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob6
---
• Explored
└ List ls -la
Read foo.txt, bar.txt
@@ -1,15 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Upload logs?
The following files will be sent:
• codex-logs.log
• codex-connectivity-diagnostics.txt
› 1. Yes Share the current Codex session logs with the team for
troubleshooting.
2. No
Press enter to confirm or esc to go back
@@ -1,13 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
How was this?
› 1. bug Crash, error message, hang, or broken UI/behavior.
2. bad result Output was off-target, incorrect, incomplete, or unhelpful.
3. good result Helpful, correct, high‑quality, or delightful result worth
celebrating.
4. safety check Benign usage blocked due to safety checks or refusals.
5. other Slowness, feature suggestion, UX feedback, or anything
else.
@@ -1,19 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Upload logs?
The following files will be sent:
• codex-logs.log
• codex-connectivity-diagnostics.txt
Connectivity diagnostics
- OPENAI_BASE_URL is set and may affect connectivity.
- OPENAI_BASE_URL = hello
› 1. Yes Share the current Codex session logs with the team for
troubleshooting.
2. No
Press enter to confirm or esc to go back
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Here is the result.
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Thread forked from named-thread (e9f18a88-8081-4e51-9d4e-8af5cde2d8dd)
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Thread forked from 019c2d47-4935-7423-a190-05691f566092
@@ -1,15 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Enable full access?
When Codex runs with full access, it can edit any file on your computer and
run commands with network, without your approval. Exercise caution when
enabling full access. This significantly increases the risk of data loss,
leaks, or unexpected behavior.
› 1. Yes, continue anyway Apply full access for this session
2. Yes, and don't ask again Enable full access and remember this choice
3. Cancel Go back without enabling full access
Press enter to confirm or esc to go back
@@ -1,17 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9237
expression: term.backend().vt100().screen().contents()
---
✔ Auto-reviewer approved codex to run rm -f /tmp/guardian-approved.sqlite this
time
› Ask Codex to do anything
? for shortcuts 100% context left
@@ -1,25 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9085
expression: term.backend().vt100().screen().contents()
---
⚠ Automatic approval review denied (risk: high): The planned action would
transmit the full contents of a workspace source file (`core/src/codex.rs`) to
`https://example.com`, which is an external and untrusted endpoint.
✗ Request denied for codex to run curl -sS -i -X POST --data-binary @core/src/c
odex.rs https://example.com
• Working (0s • esc to interrupt)
› Ask Codex to do anything
? for shortcuts 100% context left
@@ -1,13 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9336
expression: rendered
---
• Reviewing 2 approval requests (0s • esc to interrupt)
└ • rm -rf '/tmp/guardian target 1'
• rm -rf '/tmp/guardian target 2'
› Ask Codex to do anything
? for shortcuts 100% context left
@@ -1,8 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 5889
expression: combined
---
• Generated Image:
└ A tiny blue square
└ Saved to: file:///tmp/ig-1.png
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: exec_blob
---
• Ran sleep 1
└ (no output)
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: snapshot
---
cells=1
■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: last
---
■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: info
---
• Model interrupted to submit steer instructions.
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Viewed Image
└ example.png
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"• Booting MCP server: alpha (0s • esc to interrupt) "
" "
" "
"› Ask Codex to do anything "
" "
" ? for shortcuts 100% context left "
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1989
expression: popup
---
Select Model and Effort
Access legacy models by running codex -m <model_name> or in your config.toml
› 1. test-visible-model (current) test-visible-model description
Press enter to select reasoning effort, or esc to dismiss.
@@ -1,12 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Reasoning Level for gpt-5.1-codex-max
1. Low Fast responses with lighter reasoning
2. Medium (default) Balances speed and reasoning depth for everyday tasks
› 3. High (current) Greater reasoning depth for complex problems
4. Extra high Extra high reasoning depth for complex problems
Press enter to confirm or esc to go back
@@ -1,15 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Reasoning Level for gpt-5.1-codex-max
1. Low Fast responses with lighter reasoning
2. Medium (default) Balances speed and reasoning depth for everyday
tasks
3. High Greater reasoning depth for complex problems
› 4. Extra high (current) Extra high reasoning depth for complex problems
⚠ Extra high reasoning effort can quickly consume
Plus plan rate limits.
Press enter to confirm or esc to go back
@@ -1,18 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Model and Effort
Access legacy models by running codex -m <model_name> or in your config.toml
› 1. gpt-5.3-codex (default) Latest frontier agentic coding model.
2. gpt-5.4 Latest frontier agentic coding model.
3. gpt-5.2-codex Frontier agentic coding model.
4. gpt-5.1-codex-max Codex-optimized flagship for deep and fast
reasoning.
5. gpt-5.2 Latest frontier model with improvements across
knowledge, reasoning and coding
6. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less
capable.
Press enter to select reasoning effort, or esc to dismiss.
@@ -1,12 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 6001
expression: popup
---
Enable subagents?
Subagents are currently disabled in your config.
› 1. Yes, enable Save the setting now. You will need a new session to use it.
2. Not now Keep subagents disabled.
Press enter to confirm or esc to go back
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7963
expression: "lines_to_single_string(&cells[0])"
---
• Permissions updated to Full Access
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: "lines_to_single_string(&cells[0])"
---
• Permissions updated to Default
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: "lines_to_single_string(&cells[0])"
---
• Permissions updated to Default (non-admin sandbox)
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Personality
Choose a communication style for Codex.
1. Friendly Warm, collaborative, and helpful.
› 2. Pragmatic (current) Concise, task-focused, and direct.
Press enter to confirm or esc to go back
@@ -1,10 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Implement this plan?
› 1. Yes, implement this plan Switch to Default and start coding.
2. No, stay in Plan mode Continue planning with the model.
Press enter to confirm or esc to go back
@@ -1,10 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Implement this plan?
1. Yes, implement this plan Switch to Default and start coding.
› 2. No, stay in Plan mode Continue planning with the model.
Press enter to confirm or esc to go back
@@ -1,17 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Plugins
Figma · Can be installed · ChatGPT Marketplace
Data shared with this app is subject to the app's terms of service and privacy policy. Learn
more.
Turn Figma files into implementation context.
› 1. Back to plugins Return to the plugin list.
2. Install plugin Install this plugin now.
Skills design-review, extract-copy
Apps Figma, Slack
MCP Servers figma-mcp, docs-mcp
Press esc to close.
@@ -1,15 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Plugins
Figma · Installed · ChatGPT Marketplace
Turn Figma files into implementation context.
› 1. Back to plugins Return to the plugin list.
2. Uninstall plugin Remove this plugin now.
Skills design-review, extract-copy
Apps Figma, Slack
MCP Servers figma-mcp, docs-mcp
Press esc to close.
@@ -1,17 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7289
expression: popup
---
Plugins
Browse plugins from available marketplaces.
Installed 1 of 4 available plugins.
Using cached marketplace data: remote sync timed out
Type to search plugins
› Alpha Sync Installed · Disabled Press Enter to view plugin details.
Bravo Search Available · ChatGPT Marketplace · Search docs and tickets.
Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins.
Starter Available by default · ChatGPT Marketplace · Included by default.
Press esc to close.
@@ -1,9 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Plugins
Loading available plugins...
This first pass shows the ChatGPT marketplace only.
› Loading plugins... This updates when the marketplace list is ready.
@@ -1,13 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7527
expression: popup
---
Plugins
Browse plugins from available marketplaces.
Installed 0 of 3 available plugins.
sla
› Slack Available Press Enter to view plugin details.
Press esc to close.
@@ -1,9 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Running PostToolUse hook: warming the shell
PostToolUse hook (completed)
warning: Heads up from the hook
hook context: Remember the startup checklist.
@@ -1,9 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Running PreToolUse hook: warming the shell
PreToolUse hook (completed)
warning: Heads up from the hook
hook context: Remember the startup checklist.
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"• Working (0s • esc to interrupt) "
" "
" "
"› Ask Codex to do anything "
" "
" ? for shortcuts 100% context left "
@@ -1,14 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Approaching rate limits
Switch to gpt-5.1-codex-mini for lower credit usage?
› 1. Switch to gpt-5.1-codex-mini Optimized for codex. Cheaper,
faster, but less capable.
2. Keep current model
3. Keep current model (never show again) Hide future rate limit reminders
about switching models.
Press enter to confirm or esc to go back
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Settings
Configure settings for Codex.
› 1. Microphone Current: System default
2. Speaker Current: System default
Press enter to confirm or esc to go back
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Settings
Configure settings for Codex.
› 1. Microphone Current: System default
2. Speaker Current: System default
Press enter to confirm or esc to go back
@@ -1,18 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Microphone
Saved devices apply to realtime voice only.
1. System default Use your operating system
default device.
› Unavailable: Studio Mic (current) (disabled) Configured device is not
currently available.
(disabled: Reconnect the
device or choose another
one.)
3. Built-in Mic
4. USB Mic
Press enter to confirm or esc to go back
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 7060
expression: header
---
› Ask Codex to do anything
@@ -1,22 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
• Messages to be submitted at end of turn
↳ Steer submitted while /review was running.
› Ask Codex to do anything
? for shortcuts 100% context left
@@ -1,9 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Running SessionStart hook: warming the shell
SessionStart hook (completed)
warning: Heads up from the hook
hook context: Remember the startup checklist.
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 4430
expression: rendered.clone()
---
• `/copy` is unavailable before the first Codex output or right after a rollback.
@@ -1,9 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
"› Ask Codex to do anything "
" "
" Fast on "
@@ -1,9 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
"› Ask Codex to do anything "
" "
" gpt-5.4 xhigh fast · 100% left · /tmp/project "
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"• Analyzing (0s • esc to interrupt) "
" "
" "
"› Ask Codex to do anything "
" "
" ? for shortcuts 100% context left "
@@ -1,17 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
" "
" Would you like to run the following command? "
" "
" Reason: this is a test reason such as one that would be produced by the model "
" "
" $ echo 'hello world' "
" "
"› 1. Yes, proceed (y) "
" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) "
" 3. No, and tell Codex what to do differently (esc) "
" "
" Press enter to confirm or esc to cancel "
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view…"
" "
" "
"› Ask Codex to do anything "
" "
" ? for shortcuts 100% context left "
@@ -1,8 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Waited for background terminal · just fix
↳ Interacted with background terminal · just fix
└ ls
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: active_combined
---
↳ Interacted with background terminal · just fix
└ pwd
@@ -1,8 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
↳ Interacted with background terminal · just fix
└ pwd
• Waited for background terminal · just fix
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: snapshot
---
History:
• Ran echo repro-marker
└ repro-marker
Active:
• Exploring
└ Read null
@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Waited for background terminal · cargo test -p codex-core
• Final response.
@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Waited for background terminal · cargo test -p codex-core
• Streaming response.
@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: rendered
---
• Waiting for background terminal (0s • esc to …
└ cargo test -p codex-core -- --exact…
› Ask Codex to do anything
? for shortcuts 100% context left
@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: combined
---
• Waited for background terminal · just fix
@@ -1,14 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend().vt100().screen().contents()
---
✨ New version available! Would you like to update?
Full release notes: https://github.com/openai/codex/releases/latest
› 1. Yes, update now
2. No, not now
3. Don't remind me
Press enter to confirm or esc to go back
@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob
---
• You ran ls
└ file1
file2
@@ -1,662 +0,0 @@
//! Status-line and terminal-title rendering helpers for `ChatWidget`.
//!
//! Keeping this logic in a focused submodule makes the additive title/status
//! behavior easier to review without paging through the rest of `chatwidget.rs`.
use super::*;
pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["spinner", "project"];
pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] =
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
/// Compact runtime states that can be rendered into the terminal title.
///
/// This is intentionally smaller than the full status-header vocabulary. The
/// title needs short, stable labels, so callers map richer lifecycle events
/// onto one of these buckets before rendering.
pub(super) enum TerminalTitleStatusKind {
Working,
WaitingForBackgroundTerminal,
Undoing,
#[default]
Thinking,
}
#[derive(Debug)]
/// Parsed status-surface configuration for one refresh pass.
///
/// The status line and terminal title share some expensive or stateful inputs
/// (notably git branch lookup and invalid-item warnings). This snapshot lets one
/// refresh pass compute those shared concerns once, then render both surfaces
/// from the same selection set.
struct StatusSurfaceSelections {
status_line_items: Vec<StatusLineItem>,
invalid_status_line_items: Vec<String>,
terminal_title_items: Vec<TerminalTitleItem>,
invalid_terminal_title_items: Vec<String>,
}
impl StatusSurfaceSelections {
fn uses_git_branch(&self) -> bool {
self.status_line_items.contains(&StatusLineItem::GitBranch)
|| self
.terminal_title_items
.contains(&TerminalTitleItem::GitBranch)
}
}
#[derive(Clone, Debug)]
/// Cached project-root display name keyed by the cwd used for the last lookup.
///
/// Terminal-title refreshes can happen very frequently, so the title path avoids
/// repeatedly walking up the filesystem to rediscover the same project root name
/// while the working directory is unchanged.
pub(super) struct CachedProjectRootName {
pub(super) cwd: PathBuf,
pub(super) root_name: Option<String>,
}
impl ChatWidget {
fn status_surface_selections(&self) -> StatusSurfaceSelections {
let (status_line_items, invalid_status_line_items) = self.status_line_items_with_invalids();
let (terminal_title_items, invalid_terminal_title_items) =
self.terminal_title_items_with_invalids();
StatusSurfaceSelections {
status_line_items,
invalid_status_line_items,
terminal_title_items,
invalid_terminal_title_items,
}
}
fn warn_invalid_status_line_items_once(&mut self, invalid_items: &[String]) {
if self.thread_id.is_some()
&& !invalid_items.is_empty()
&& self
.status_line_invalid_items_warned
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
let label = if invalid_items.len() == 1 {
"item"
} else {
"items"
};
let message = format!(
"Ignored invalid status line {label}: {}.",
proper_join(invalid_items)
);
self.on_warning(message);
}
}
fn warn_invalid_terminal_title_items_once(&mut self, invalid_items: &[String]) {
if self.thread_id.is_some()
&& !invalid_items.is_empty()
&& self
.terminal_title_invalid_items_warned
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
let label = if invalid_items.len() == 1 {
"item"
} else {
"items"
};
let message = format!(
"Ignored invalid terminal title {label}: {}.",
proper_join(invalid_items)
);
self.on_warning(message);
}
}
fn sync_status_surface_shared_state(&mut self, selections: &StatusSurfaceSelections) {
if !selections.uses_git_branch() {
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
return;
}
let cwd = self.status_line_cwd().to_path_buf();
self.sync_status_line_branch_state(&cwd);
if !self.status_line_branch_lookup_complete {
self.request_status_line_branch(cwd);
}
}
fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) {
let enabled = !selections.status_line_items.is_empty();
self.bottom_pane.set_status_line_enabled(enabled);
if !enabled {
self.set_status_line(/*status_line*/ None);
return;
}
let mut parts = Vec::new();
for item in &selections.status_line_items {
if let Some(value) = self.status_line_value_for_item(item) {
parts.push(value);
}
}
let line = if parts.is_empty() {
None
} else {
Some(Line::from(parts.join(" · ")))
};
self.set_status_line(line);
}
/// Clears the terminal title Codex most recently wrote, if any.
///
/// This does not attempt to restore the shell or terminal's previous title;
/// it only clears the managed title and updates the cache after a successful
/// OSC write.
pub(crate) fn clear_managed_terminal_title(&mut self) -> std::io::Result<()> {
if self.last_terminal_title.is_some() {
clear_terminal_title()?;
self.last_terminal_title = None;
}
Ok(())
}
/// Renders and applies the terminal title for one parsed selection snapshot.
///
/// Empty selections clear the managed title. Non-empty selections render the
/// current values in configured order, skip unavailable segments, and cache
/// the last successfully written title so redundant OSC writes are avoided.
/// When the `spinner` item is present in an animated running state, this also
/// schedules the next frame so the spinner keeps advancing.
fn refresh_terminal_title_from_selections(&mut self, selections: &StatusSurfaceSelections) {
if selections.terminal_title_items.is_empty() {
if let Err(err) = self.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title");
}
return;
}
let now = Instant::now();
let mut previous = None;
let title = selections
.terminal_title_items
.iter()
.copied()
.filter_map(|item| {
self.terminal_title_value_for_item(item, now)
.map(|value| (item, value))
})
.fold(String::new(), |mut title, (item, value)| {
title.push_str(item.separator_from_previous(previous));
title.push_str(&value);
previous = Some(item);
title
});
let title = (!title.is_empty()).then_some(title);
let should_animate_spinner =
self.should_animate_terminal_title_spinner_with_selections(selections);
if self.last_terminal_title == title {
if should_animate_spinner {
self.frame_requester
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
}
return;
}
match title {
Some(title) => match set_terminal_title(&title) {
Ok(SetTerminalTitleResult::Applied) => {
self.last_terminal_title = Some(title);
}
Ok(SetTerminalTitleResult::NoVisibleContent) => {
if let Err(err) = self.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title");
}
}
Err(err) => {
tracing::debug!(error = %err, "failed to set terminal title");
}
},
None => {
if let Err(err) = self.clear_managed_terminal_title() {
tracing::debug!(error = %err, "failed to clear terminal title");
}
}
}
if should_animate_spinner {
self.frame_requester
.schedule_frame_in(TERMINAL_TITLE_SPINNER_INTERVAL);
}
}
/// Recomputes both status surfaces from one shared config snapshot.
///
/// This is the common refresh entrypoint for the footer status line and the
/// terminal title. It parses both configurations once, emits invalid-item
/// warnings once, synchronizes shared cached state (such as git-branch
/// lookup), then renders each surface from that shared snapshot.
pub(crate) fn refresh_status_surfaces(&mut self) {
let selections = self.status_surface_selections();
self.warn_invalid_status_line_items_once(&selections.invalid_status_line_items);
self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items);
self.sync_status_surface_shared_state(&selections);
self.refresh_status_line_from_selections(&selections);
self.refresh_terminal_title_from_selections(&selections);
}
/// Recomputes and emits the terminal title from config and runtime state.
pub(crate) fn refresh_terminal_title(&mut self) {
let selections = self.status_surface_selections();
self.warn_invalid_terminal_title_items_once(&selections.invalid_terminal_title_items);
self.sync_status_surface_shared_state(&selections);
self.refresh_terminal_title_from_selections(&selections);
}
pub(super) fn request_status_line_branch_refresh(&mut self) {
let selections = self.status_surface_selections();
if !selections.uses_git_branch() {
return;
}
let cwd = self.status_line_cwd().to_path_buf();
self.sync_status_line_branch_state(&cwd);
self.request_status_line_branch(cwd);
}
/// Parses configured status-line ids into known items and collects unknown ids.
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn status_line_items_with_invalids(&self) -> (Vec<StatusLineItem>, Vec<String>) {
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in self.configured_status_line_items() {
match id.parse::<StatusLineItem>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
}
pub(super) fn configured_status_line_items(&self) -> Vec<String> {
self.config.tui_status_line.clone().unwrap_or_else(|| {
DEFAULT_STATUS_LINE_ITEMS
.iter()
.map(ToString::to_string)
.collect()
})
}
/// Parses configured terminal-title ids into known items and collects unknown ids.
///
/// Unknown ids are deduplicated in insertion order for warning messages.
fn terminal_title_items_with_invalids(&self) -> (Vec<TerminalTitleItem>, Vec<String>) {
let mut invalid = Vec::new();
let mut invalid_seen = HashSet::new();
let mut items = Vec::new();
for id in self.configured_terminal_title_items() {
match id.parse::<TerminalTitleItem>() {
Ok(item) => items.push(item),
Err(_) => {
if invalid_seen.insert(id.clone()) {
invalid.push(format!(r#""{id}""#));
}
}
}
}
(items, invalid)
}
/// Returns the configured terminal-title ids, or the default ordering when unset.
pub(super) fn configured_terminal_title_items(&self) -> Vec<String> {
self.config.tui_terminal_title.clone().unwrap_or_else(|| {
DEFAULT_TERMINAL_TITLE_ITEMS
.iter()
.map(ToString::to_string)
.collect()
})
}
fn status_line_cwd(&self) -> &Path {
self.current_cwd
.as_deref()
.unwrap_or(self.config.cwd.as_path())
}
/// Resolves the project root associated with `cwd`.
///
/// Git repository root wins when available. Otherwise we fall back to the
/// nearest project config layer so non-git projects can still surface a
/// stable project label.
fn status_line_project_root_for_cwd(&self, cwd: &Path) -> Option<PathBuf> {
if let Some(repo_root) = get_git_repo_root(cwd) {
return Some(repo_root);
}
self.config
.config_layer_stack
.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
/*include_disabled*/ true,
)
.iter()
.find_map(|layer| match &layer.name {
ConfigLayerSource::Project { dot_codex_folder } => {
dot_codex_folder.as_path().parent().map(Path::to_path_buf)
}
_ => None,
})
}
fn status_line_project_root_name_for_cwd(&self, cwd: &Path) -> Option<String> {
self.status_line_project_root_for_cwd(cwd).map(|root| {
root.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| format_directory_display(&root, /*max_width*/ None))
})
}
/// Returns a cached project-root display name for the active cwd.
fn status_line_project_root_name(&mut self) -> Option<String> {
let cwd = self.status_line_cwd().to_path_buf();
if let Some(cache) = &self.status_line_project_root_name_cache
&& cache.cwd == cwd
{
return cache.root_name.clone();
}
let root_name = self.status_line_project_root_name_for_cwd(&cwd);
self.status_line_project_root_name_cache = Some(CachedProjectRootName {
cwd,
root_name: root_name.clone(),
});
root_name
}
/// Produces the terminal-title `project` value.
///
/// This prefers the cached project-root name and falls back to the current
/// directory name when no project root can be inferred.
fn terminal_title_project_name(&mut self) -> Option<String> {
let project = self.status_line_project_root_name().or_else(|| {
let cwd = self.status_line_cwd();
Some(
cwd.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| format_directory_display(cwd, /*max_width*/ None)),
)
})?;
Some(Self::truncate_terminal_title_part(
project, /*max_chars*/ 24,
))
}
/// Resets git-branch cache state when the status-line cwd changes.
///
/// The branch cache is keyed by cwd because branch lookup is performed relative to that path.
/// Keeping stale branch values across cwd changes would surface incorrect repository context.
fn sync_status_line_branch_state(&mut self, cwd: &Path) {
if self
.status_line_branch_cwd
.as_ref()
.is_some_and(|path| path == cwd)
{
return;
}
self.status_line_branch_cwd = Some(cwd.to_path_buf());
self.status_line_branch = None;
self.status_line_branch_pending = false;
self.status_line_branch_lookup_complete = false;
}
/// Starts an async git-branch lookup unless one is already running.
///
/// The resulting `StatusLineBranchUpdated` event carries the lookup cwd so callers can reject
/// stale completions after directory changes.
fn request_status_line_branch(&mut self, cwd: PathBuf) {
if self.status_line_branch_pending {
return;
}
self.status_line_branch_pending = true;
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
let branch = current_branch_name(&cwd).await;
tx.send(AppEvent::StatusLineBranchUpdated { cwd, branch });
});
}
/// Resolves a display string for one configured status-line item.
///
/// Returning `None` means "omit this item for now", not "configuration error". Callers rely on
/// this to keep partially available status lines readable while waiting for session, token, or
/// git metadata.
pub(super) fn status_line_value_for_item(&mut self, item: &StatusLineItem) -> Option<String> {
match item {
StatusLineItem::ModelName => Some(self.model_display_name().to_string()),
StatusLineItem::ModelWithReasoning => {
let label =
Self::status_line_reasoning_effort_label(self.effective_reasoning_effort());
let fast_label = if self
.should_show_fast_status(self.current_model(), self.config.service_tier)
{
" fast"
} else {
""
};
Some(format!("{} {label}{fast_label}", self.model_display_name()))
}
StatusLineItem::CurrentDir => {
Some(format_directory_display(
self.status_line_cwd(),
/*max_width*/ None,
))
}
StatusLineItem::ProjectRoot => self.status_line_project_root_name(),
StatusLineItem::GitBranch => self.status_line_branch.clone(),
StatusLineItem::UsedTokens => {
let usage = self.status_line_total_usage();
let total = usage.tokens_in_context_window();
if total <= 0 {
None
} else {
Some(format!("{} used", format_tokens_compact(total)))
}
}
StatusLineItem::ContextRemaining => self
.status_line_context_remaining_percent()
.map(|remaining| format!("{remaining}% left")),
StatusLineItem::ContextUsed => self
.status_line_context_used_percent()
.map(|used| format!("{used}% used")),
StatusLineItem::FiveHourLimit => {
let window = self
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|s| s.primary.as_ref());
let label = window
.and_then(|window| window.window_minutes)
.map(get_limits_duration)
.unwrap_or_else(|| "5h".to_string());
self.status_line_limit_display(window, &label)
}
StatusLineItem::WeeklyLimit => {
let window = self
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|s| s.secondary.as_ref());
let label = window
.and_then(|window| window.window_minutes)
.map(get_limits_duration)
.unwrap_or_else(|| "weekly".to_string());
self.status_line_limit_display(window, &label)
}
StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()),
StatusLineItem::ContextWindowSize => self
.status_line_context_window_size()
.map(|cws| format!("{} window", format_tokens_compact(cws))),
StatusLineItem::TotalInputTokens => Some(format!(
"{} in",
format_tokens_compact(self.status_line_total_usage().input_tokens)
)),
StatusLineItem::TotalOutputTokens => Some(format!(
"{} out",
format_tokens_compact(self.status_line_total_usage().output_tokens)
)),
StatusLineItem::SessionId => self.thread_id.map(|id| id.to_string()),
StatusLineItem::FastMode => Some(
if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
"Fast on".to_string()
} else {
"Fast off".to_string()
},
),
}
}
/// Resolves one configured terminal-title item into a displayable segment.
///
/// Returning `None` means "omit this segment for now" so callers can keep
/// the configured order while hiding values that are not yet available.
fn terminal_title_value_for_item(
&mut self,
item: TerminalTitleItem,
now: Instant,
) -> Option<String> {
match item {
TerminalTitleItem::AppName => Some("codex".to_string()),
TerminalTitleItem::Project => self.terminal_title_project_name(),
TerminalTitleItem::Spinner => self.terminal_title_spinner_text_at(now),
TerminalTitleItem::Status => Some(self.terminal_title_status_text()),
TerminalTitleItem::Thread => self.thread_name.as_ref().and_then(|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(Self::truncate_terminal_title_part(
trimmed.to_string(),
/*max_chars*/ 48,
))
}
}),
TerminalTitleItem::GitBranch => self.status_line_branch.as_ref().map(|branch| {
Self::truncate_terminal_title_part(branch.clone(), /*max_chars*/ 32)
}),
TerminalTitleItem::Model => Some(Self::truncate_terminal_title_part(
self.model_display_name().to_string(),
/*max_chars*/ 32,
)),
TerminalTitleItem::TaskProgress => self.terminal_title_task_progress(),
}
}
/// Computes the compact runtime status label used by the terminal title.
///
/// Startup takes precedence over normal task states, and idle state renders
/// as `Ready` regardless of the last active status bucket.
pub(super) fn terminal_title_status_text(&self) -> String {
if self.mcp_startup_status.is_some() {
return "Starting".to_string();
}
match self.terminal_title_status_kind {
TerminalTitleStatusKind::Working if !self.bottom_pane.is_task_running() => {
"Ready".to_string()
}
TerminalTitleStatusKind::WaitingForBackgroundTerminal
if !self.bottom_pane.is_task_running() =>
{
"Ready".to_string()
}
TerminalTitleStatusKind::Thinking if !self.bottom_pane.is_task_running() => {
"Ready".to_string()
}
TerminalTitleStatusKind::Working => "Working".to_string(),
TerminalTitleStatusKind::WaitingForBackgroundTerminal => "Waiting".to_string(),
TerminalTitleStatusKind::Undoing => "Undoing".to_string(),
TerminalTitleStatusKind::Thinking => "Thinking".to_string(),
}
}
pub(super) fn terminal_title_spinner_text_at(&self, now: Instant) -> Option<String> {
if !self.config.animations {
return None;
}
if !self.terminal_title_has_active_progress() {
return None;
}
Some(self.terminal_title_spinner_frame_at(now).to_string())
}
fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str {
let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin);
let frame_index =
(elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize;
TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()]
}
fn terminal_title_uses_spinner(&self) -> bool {
self.config
.tui_terminal_title
.as_ref()
.is_none_or(|items| items.iter().any(|item| item == "spinner"))
}
fn terminal_title_has_active_progress(&self) -> bool {
self.mcp_startup_status.is_some()
|| self.bottom_pane.is_task_running()
|| self.terminal_title_status_kind == TerminalTitleStatusKind::Undoing
}
pub(super) fn should_animate_terminal_title_spinner(&self) -> bool {
self.config.animations
&& self.terminal_title_uses_spinner()
&& self.terminal_title_has_active_progress()
}
fn should_animate_terminal_title_spinner_with_selections(
&self,
selections: &StatusSurfaceSelections,
) -> bool {
self.config.animations
&& selections
.terminal_title_items
.contains(&TerminalTitleItem::Spinner)
&& self.terminal_title_has_active_progress()
}
/// Formats the last `update_plan` progress snapshot for terminal-title display.
pub(super) fn terminal_title_task_progress(&self) -> Option<String> {
let (completed, total) = self.last_plan_progress?;
if total == 0 {
return None;
}
Some(format!("Tasks {completed}/{total}"))
}
/// Truncates a title segment by grapheme cluster and appends `...` when needed.
pub(super) fn truncate_terminal_title_part(value: String, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let mut graphemes = value.graphemes(true);
let head: String = graphemes.by_ref().take(max_chars).collect();
if graphemes.next().is_none() || max_chars <= 3 {
return head;
}
let mut truncated = head.graphemes(true).take(max_chars - 3).collect::<String>();
truncated.push_str("...");
truncated
}
}
File diff suppressed because it is too large. Load diff