hide CoT by default; show headers in status indicator (#2316)

Plan is for full CoT summaries to be visible in a "transcript view" when
we implement that, but for now they're hidden.


https://github.com/user-attachments/assets/e8a1b0ef-8f2a-48ff-9625-9c3c67d92cdb
This commit is contained in:
Jeremy Rose
2025-08-20 16:58:56 -07:00
committed by GitHub
parent 2ec5a28528
commit e95cad1946
13 changed files with 231 additions and 361 deletions

View File

@@ -28,6 +28,11 @@ pub(crate) trait BottomPaneView {
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);
/// Update the status indicator animated header. Default no-op.
fn update_status_header(&mut self, _header: String) {
// no-op
}
/// Called when task completes to check if the view should be hidden.
fn should_hide_when_task_is_done(&mut self) -> bool {
false

View File

@@ -182,6 +182,17 @@ impl BottomPane {
self.request_redraw();
}
/// Update the animated header shown to the left of the brackets in the
/// status indicator (defaults to "Working"). This will update the active
/// StatusIndicatorView if present; otherwise, if a live overlay is active,
/// it will update that. If neither is present, this call is a no-op.
pub(crate) fn update_status_header(&mut self, header: String) {
if let Some(view) = self.active_view.as_mut() {
view.update_status_header(header.clone());
self.request_redraw();
}
}
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
self.ctrl_c_quit_hint = true;
self.composer

View File

@@ -24,9 +24,17 @@ impl StatusIndicatorView {
pub fn update_text(&mut self, text: String) {
self.view.update_text(text);
}
pub fn update_header(&mut self, header: String) {
self.view.update_header(header);
}
}
impl BottomPaneView for StatusIndicatorView {
fn update_status_header(&mut self, header: String) {
self.update_header(header);
}
fn should_hide_when_task_is_done(&mut self) -> bool {
true
}

View File

@@ -89,8 +89,6 @@ pub(crate) struct ChatWidget {
last_token_usage: TokenUsage,
// Stream lifecycle controller
stream: StreamController,
// Track the most recently active stream kind in the current turn
last_stream_kind: Option<StreamKind>,
running_commands: HashMap<String, RunningCommand>,
pending_exec_completions: Vec<(Vec<String>, Vec<ParsedCommand>, CommandOutput)>,
task_complete_pending: bool,
@@ -98,6 +96,8 @@ pub(crate) struct ChatWidget {
interrupts: InterruptManager,
// Whether a redraw is needed after handling the current event
needs_redraw: bool,
// Accumulates the current reasoning block text to extract a header
reasoning_buffer: String,
session_id: Option<Uuid>,
frame_requester: FrameRequester,
}
@@ -107,8 +107,6 @@ struct UserMessage {
image_paths: Vec<PathBuf>,
}
use crate::streaming::StreamKind;
impl From<String> for UserMessage {
fn from(text: String) -> Self {
Self {
@@ -133,7 +131,7 @@ impl ChatWidget {
}
fn flush_answer_stream_with_separator(&mut self) {
let sink = AppEventHistorySink(self.app_event_tx.clone());
let _ = self.stream.finalize(StreamKind::Answer, true, &sink);
let _ = self.stream.finalize(true, &sink);
}
// --- Small event handlers ---
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
@@ -150,30 +148,38 @@ impl ChatWidget {
fn on_agent_message(&mut self, message: String) {
let sink = AppEventHistorySink(self.app_event_tx.clone());
let finished = self.stream.apply_final_answer(&message, &sink);
self.last_stream_kind = Some(StreamKind::Answer);
self.handle_if_stream_finished(finished);
self.mark_needs_redraw();
}
fn on_agent_message_delta(&mut self, delta: String) {
self.handle_streaming_delta(StreamKind::Answer, delta);
self.handle_streaming_delta(delta);
}
fn on_agent_reasoning_delta(&mut self, delta: String) {
self.handle_streaming_delta(StreamKind::Reasoning, delta);
// For reasoning deltas, do not stream to history. Accumulate the
// current reasoning block and extract the first bold element
// (between **/**) as the chunk header. Show this header as status.
self.reasoning_buffer.push_str(&delta);
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
// Update the shimmer header to the extracted reasoning chunk header.
self.bottom_pane.update_status_header(header);
} else {
// Fallback while we don't yet have a bold header: leave existing header as-is.
}
self.mark_needs_redraw();
}
fn on_agent_reasoning_final(&mut self, text: String) {
let sink = AppEventHistorySink(self.app_event_tx.clone());
let finished = self.stream.apply_final_reasoning(&text, &sink);
self.last_stream_kind = Some(StreamKind::Reasoning);
self.handle_if_stream_finished(finished);
fn on_agent_reasoning_final(&mut self) {
// Clear the reasoning buffer at the end of a reasoning block.
self.reasoning_buffer.clear();
self.mark_needs_redraw();
}
fn on_reasoning_section_break(&mut self) {
let sink = AppEventHistorySink(self.app_event_tx.clone());
self.stream.insert_reasoning_section_break(&sink);
// Start a new reasoning block for header extraction.
self.reasoning_buffer.clear();
}
// Raw reasoning uses the same flow as summarized reasoning
@@ -182,7 +188,7 @@ impl ChatWidget {
self.bottom_pane.clear_ctrl_c_quit_hint();
self.bottom_pane.set_task_running(true);
self.stream.reset_headers_for_new_turn();
self.last_stream_kind = None;
self.reasoning_buffer.clear();
self.mark_needs_redraw();
}
@@ -191,9 +197,7 @@ impl ChatWidget {
// without emitting stray headers for other streams.
if self.stream.is_write_cycle_active() {
let sink = AppEventHistorySink(self.app_event_tx.clone());
if let Some(kind) = self.last_stream_kind {
let _ = self.stream.finalize(kind, true, &sink);
}
let _ = self.stream.finalize(true, &sink);
}
// Mark task stopped and request redraw now that all content is in history.
self.bottom_pane.set_task_running(false);
@@ -355,10 +359,9 @@ impl ChatWidget {
}
#[inline]
fn handle_streaming_delta(&mut self, kind: StreamKind, delta: String) {
fn handle_streaming_delta(&mut self, delta: String) {
let sink = AppEventHistorySink(self.app_event_tx.clone());
self.stream.begin(kind, &sink);
self.last_stream_kind = Some(kind);
self.stream.begin(&sink);
self.stream.push_and_maybe_commit(&delta, &sink);
self.mark_needs_redraw();
}
@@ -532,12 +535,12 @@ impl ChatWidget {
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
stream: StreamController::new(config),
last_stream_kind: None,
running_commands: HashMap::new(),
pending_exec_completions: Vec::new(),
task_complete_pending: false,
interrupts: InterruptManager::new(),
needs_redraw: false,
reasoning_buffer: String::new(),
session_id: None,
}
}
@@ -641,9 +644,9 @@ impl ChatWidget {
| EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
delta,
}) => self.on_agent_reasoning_delta(delta),
EventMsg::AgentReasoning(AgentReasoningEvent { text })
| EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
self.on_agent_reasoning_final(text)
EventMsg::AgentReasoning(AgentReasoningEvent { .. })
| EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { .. }) => {
self.on_agent_reasoning_final()
}
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
EventMsg::TaskStarted => self.on_task_started(),
@@ -932,5 +935,35 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
}
}
// Extract the first bold (Markdown) element in the form **...** from `s`.
// Returns the inner text if found; otherwise `None`.
fn extract_first_bold(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut i = 0usize;
while i + 1 < bytes.len() {
if bytes[i] == b'*' && bytes[i + 1] == b'*' {
let start = i + 2;
let mut j = start;
while j + 1 < bytes.len() {
if bytes[j] == b'*' && bytes[j + 1] == b'*' {
// Found closing **
let inner = &s[start..j];
let trimmed = inner.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
} else {
return None;
}
}
j += 1;
}
// No closing; stop searching (wait for more deltas)
return None;
}
i += 1;
}
None
}
#[cfg(test)]
mod tests;

View File

@@ -1,10 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 886
expression: combined
---
thinking
I will first analyze the request.
codex
Here is the result.

View File

@@ -2,8 +2,5 @@
source: tui/src/chatwidget/tests.rs
expression: combined
---
thinking
I will first analyze the request.
codex
Here is the result.

View File

@@ -144,12 +144,12 @@ fn make_chatwidget_manual() -> (
total_token_usage: TokenUsage::default(),
last_token_usage: TokenUsage::default(),
stream: StreamController::new(cfg),
last_stream_kind: None,
running_commands: HashMap::new(),
pending_exec_completions: Vec::new(),
task_complete_pending: false,
interrupts: InterruptManager::new(),
needs_redraw: false,
reasoning_buffer: String::new(),
session_id: None,
frame_requester: crate::tui::FrameRequester::test_dummy(),
};
@@ -375,6 +375,11 @@ async fn binary_size_transcript_matches_ideal_fixture() {
.expect("read ideal-binary-response.txt");
// Normalize line endings for Windows vs. Unix checkouts
let ideal = ideal.replace("\r\n", "\n");
let ideal_first_line = ideal
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.to_string();
// Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line
// and drop trailing empty lines so the shape matches the ideal fixture exactly.
@@ -400,22 +405,68 @@ async fn binary_size_transcript_matches_ideal_fixture() {
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
// Compare only after the last session banner marker, and start at the next 'thinking' line.
// Compare only after the last session banner marker. Skip the transient
// 'thinking' header if present, and start from the first non-empty line
// of content that follows.
const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in ";
let last_marker_line_idx = lines
.iter()
.rposition(|l| l.starts_with(MARKER_PREFIX))
.expect("marker not found in visible output");
let thinking_line_idx = (last_marker_line_idx + 1..lines.len())
.find(|&idx| lines[idx].trim_start() == "thinking")
.expect("no 'thinking' line found after marker");
// Anchor to the first ideal line if present; otherwise use heuristics.
let start_idx = (last_marker_line_idx + 1..lines.len())
.find(|&idx| lines[idx].trim_start() == ideal_first_line)
.or_else(|| {
// Prefer the first assistant content line (blockquote '>' prefix) after the marker.
(last_marker_line_idx + 1..lines.len())
.find(|&idx| lines[idx].trim_start().starts_with('>'))
})
.unwrap_or_else(|| {
// Fallback: first non-empty, non-'thinking' line
(last_marker_line_idx + 1..lines.len())
.find(|&idx| {
let t = lines[idx].trim_start();
!t.is_empty() && t != "thinking"
})
.expect("no content line found after marker")
});
let mut compare_lines: Vec<String> = Vec::new();
// Ensure the first line is exactly 'thinking' without leading spaces to match the fixture
compare_lines.push(lines[thinking_line_idx].trim_start().to_string());
compare_lines.extend(lines[(thinking_line_idx + 1)..].iter().cloned());
// Ensure the first line is trimmed-left to match the fixture shape.
compare_lines.push(lines[start_idx].trim_start().to_string());
compare_lines.extend(lines[(start_idx + 1)..].iter().cloned());
let visible_after = compare_lines.join("\n");
// Normalize: drop a leading 'thinking' line if present in either side to
// avoid coupling to whether the reasoning header is rendered in history.
fn drop_leading_thinking(s: &str) -> String {
let mut it = s.lines();
let first = it.next();
let rest = it.collect::<Vec<_>>().join("\n");
if first.is_some_and(|l| l.trim() == "thinking") {
rest
} else {
s.to_string()
}
}
let visible_after = drop_leading_thinking(&visible_after);
let ideal = drop_leading_thinking(&ideal);
// Normalize: strip leading Markdown blockquote markers ('>' or '> ') which
// may be present in rendered transcript lines but not in the ideal text.
fn strip_blockquotes(s: &str) -> String {
s.lines()
.map(|l| {
l.strip_prefix("> ")
.or_else(|| l.strip_prefix('>'))
.unwrap_or(l)
})
.collect::<Vec<_>>()
.join("\n")
}
let visible_after = strip_blockquotes(&visible_after);
let ideal = strip_blockquotes(&ideal);
// Optionally update the fixture when env var is set
if std::env::var("UPDATE_IDEAL").as_deref() == Ok("1") {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
@@ -746,7 +797,7 @@ fn plan_update_renders_history_cell() {
}
#[test]
fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// Answer: no header until a newline commit
@@ -804,7 +855,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
"expected 'codex' header to be emitted after first newline commit"
);
// Reasoning: header immediately
// Reasoning: do NOT emit a history header; status text is updated instead
let (mut chat2, mut rx2, _op_rx2) = make_chatwidget_manual();
chat2.handle_codex_event(Event {
id: "sub-b".into(),
@@ -828,8 +879,8 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
}
}
assert!(
saw_thinking,
"expected 'thinking' header to be emitted at stream start"
!saw_thinking,
"reasoning deltas should not emit history headers"
);
}

View File

@@ -45,22 +45,6 @@ impl MarkdownStreamCollector {
self.buffer.push_str(delta);
}
/// Insert a paragraph/section separator if one is not already present at the
/// end of the buffer. Ensures the next content starts after a blank line.
pub fn insert_section_break(&mut self) {
if self.buffer.is_empty() {
return;
}
if self.buffer.ends_with("\n\n") {
return;
}
if self.buffer.ends_with('\n') {
self.buffer.push('\n');
} else {
self.buffer.push_str("\n\n");
}
}
/// Render the full buffer and return only the newly completed logical lines
/// since the last commit. When the buffer does not end with a newline, the
/// final rendered line is considered incomplete and is not emitted.

View File

@@ -22,7 +22,7 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec<Span<'static>> {
// Use time-based sweep synchronized to process start.
let padding = 10usize;
let period = chars.len() + padding * 2;
let sweep_seconds = 2.5f32;
let sweep_seconds = 2.0f32;
let pos_f =
(elapsed_since_start().as_secs_f32() % sweep_seconds) / sweep_seconds * (period as f32);
let pos = pos_f as usize;

View File

@@ -30,6 +30,8 @@ pub(crate) struct StatusIndicatorWidget {
/// Latest text to display (truncated to the available width at render
/// time).
text: String,
/// Animated header text (defaults to "Working").
header: String,
/// Animation state: reveal target `text` progressively like a typewriter.
/// We compute the currently visible prefix length based on the current
@@ -47,6 +49,7 @@ impl StatusIndicatorWidget {
pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self {
Self {
text: String::from("waiting for model"),
header: String::from("Working"),
last_target_len: 0,
base_frame: 0,
reveal_len_at_base: 0,
@@ -95,6 +98,13 @@ impl StatusIndicatorWidget {
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
}
/// Update the animated header label (left of the brackets).
pub(crate) fn update_header(&mut self, header: String) {
if self.header != header {
self.header = header;
}
}
/// Reset the animation and start revealing `text` from the beginning.
#[cfg(test)]
pub(crate) fn restart_with_text(&mut self, text: String) {
@@ -147,12 +157,12 @@ impl WidgetRef for StatusIndicatorWidget {
// Schedule next animation frame.
self.frame_requester
.schedule_frame_in(Duration::from_millis(100));
.schedule_frame_in(Duration::from_millis(32));
let idx = self.current_frame();
let elapsed = self.start_time.elapsed().as_secs();
let shown_now = self.current_shown_len(idx);
let status_prefix: String = self.text.chars().take(shown_now).collect();
let animated_spans = shimmer_spans("Working");
let animated_spans = shimmer_spans(&self.header);
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
let inner_width = area.width as usize;

View File

@@ -2,7 +2,6 @@ use codex_core::config::Config;
use ratatui::text::Line;
use super::HeaderEmitter;
use super::StreamKind;
use super::StreamState;
/// Sink for history insertions and animation control.
@@ -36,8 +35,8 @@ type Lines = Vec<Line<'static>>;
pub(crate) struct StreamController {
config: Config,
header: HeaderEmitter,
states: [StreamState; 2],
current_stream: Option<StreamKind>,
state: StreamState,
active: bool,
finishing_after_drain: bool,
}
@@ -46,8 +45,8 @@ impl StreamController {
Self {
config,
header: HeaderEmitter::new(),
states: [StreamState::new(), StreamState::new()],
current_stream: None,
state: StreamState::new(),
active: false,
finishing_after_drain: false,
}
}
@@ -57,29 +56,18 @@ impl StreamController {
}
pub(crate) fn is_write_cycle_active(&self) -> bool {
self.current_stream.is_some()
self.active
}
pub(crate) fn clear_all(&mut self) {
self.states.iter_mut().for_each(|s| s.clear());
self.current_stream = None;
self.state.clear();
self.active = false;
self.finishing_after_drain = false;
// leave header state unchanged; caller decides when to reset
}
#[inline]
fn idx(kind: StreamKind) -> usize {
kind as usize
}
fn state(&self, kind: StreamKind) -> &StreamState {
&self.states[Self::idx(kind)]
}
fn state_mut(&mut self, kind: StreamKind) -> &mut StreamState {
&mut self.states[Self::idx(kind)]
}
fn emit_header_if_needed(&mut self, kind: StreamKind, out_lines: &mut Lines) -> bool {
self.header.maybe_emit(kind, out_lines)
fn emit_header_if_needed(&mut self, out_lines: &mut Lines) -> bool {
self.header.maybe_emit(out_lines)
}
#[inline]
@@ -93,56 +81,23 @@ impl StreamController {
}
}
/// Begin a stream, flushing previously completed lines from any other
/// active stream to maintain ordering.
pub(crate) fn begin(&mut self, kind: StreamKind, sink: &impl HistorySink) {
if let Some(current) = self.current_stream
&& current != kind
{
// Synchronously flush completed lines from previous stream.
let cfg = self.config.clone();
let prev_state = self.state_mut(current);
let newly_completed = prev_state.collector.commit_complete_lines(&cfg);
if !newly_completed.is_empty() {
prev_state.enqueue(newly_completed);
}
let step = prev_state.drain_all();
if !step.history.is_empty() {
let mut lines: Lines = Vec::new();
self.emit_header_if_needed(current, &mut lines);
lines.extend(step.history);
// Ensure at most one trailing blank after the flushed block.
Self::ensure_single_trailing_blank(&mut lines);
sink.insert_history(lines);
}
self.current_stream = None;
}
if self.current_stream != Some(kind) {
let prev = self.current_stream;
self.current_stream = Some(kind);
// Starting a new stream cancels any pending finish-from-previous-stream animation.
self.finishing_after_drain = false;
if prev.is_some() {
self.header.reset_for_stream(kind);
}
// Emit header immediately for reasoning; for answers, defer to first commit.
if matches!(kind, StreamKind::Reasoning) {
let mut header_lines = Vec::new();
if self.emit_header_if_needed(kind, &mut header_lines) {
sink.insert_history(header_lines);
}
}
/// Begin an answer stream. Does not emit header yet; it is emitted on first commit.
pub(crate) fn begin(&mut self, _sink: &impl HistorySink) {
// Starting a new stream cancels any pending finish-from-previous-stream animation.
if !self.active {
self.header.reset_for_stream();
}
self.finishing_after_drain = false;
self.active = true;
}
/// Push a delta; if it contains a newline, commit completed lines and start animation.
pub(crate) fn push_and_maybe_commit(&mut self, delta: &str, sink: &impl HistorySink) {
let Some(kind) = self.current_stream else {
if !self.active {
return;
};
}
let cfg = self.config.clone();
let state = self.state_mut(kind);
let state = &mut self.state;
// Record that at least one delta was received for this stream
if !delta.is_empty() {
state.has_seen_delta = true;
@@ -157,42 +112,22 @@ impl StreamController {
}
}
/// Insert a reasoning section break and commit any newly completed lines.
pub(crate) fn insert_reasoning_section_break(&mut self, sink: &impl HistorySink) {
if self.current_stream != Some(StreamKind::Reasoning) {
self.begin(StreamKind::Reasoning, sink);
}
let cfg = self.config.clone();
let state = self.state_mut(StreamKind::Reasoning);
state.collector.insert_section_break();
let newly_completed = state.collector.commit_complete_lines(&cfg);
if !newly_completed.is_empty() {
state.enqueue(newly_completed);
sink.start_commit_animation();
}
}
/// Finalize the active stream. If `flush_immediately` is true, drain and emit now.
pub(crate) fn finalize(
&mut self,
kind: StreamKind,
flush_immediately: bool,
sink: &impl HistorySink,
) -> bool {
if self.current_stream != Some(kind) {
pub(crate) fn finalize(&mut self, flush_immediately: bool, sink: &impl HistorySink) -> bool {
if !self.active {
return false;
}
let cfg = self.config.clone();
// Finalize collector first.
let remaining = {
let state = self.state_mut(kind);
let state = &mut self.state;
state.collector.finalize_and_drain(&cfg)
};
if flush_immediately {
// Collect all output first to avoid emitting headers when there is no content.
let mut out_lines: Lines = Vec::new();
{
let state = self.state_mut(kind);
let state = &mut self.state;
if !remaining.is_empty() {
state.enqueue(remaining);
}
@@ -201,28 +136,28 @@ impl StreamController {
}
if !out_lines.is_empty() {
let mut lines_with_header: Lines = Vec::new();
self.emit_header_if_needed(kind, &mut lines_with_header);
self.emit_header_if_needed(&mut lines_with_header);
lines_with_header.extend(out_lines);
Self::ensure_single_trailing_blank(&mut lines_with_header);
sink.insert_history(lines_with_header);
}
// Cleanup
self.state_mut(kind).clear();
// Allow a subsequent block of the same kind in this turn to emit its header.
self.header.allow_reemit_for_same_kind_in_turn(kind);
self.state.clear();
// Allow a subsequent block in this turn to emit its header.
self.header.allow_reemit_in_turn();
// Also clear the per-stream emitted flag so the header can render again.
self.header.reset_for_stream(kind);
self.current_stream = None;
self.header.reset_for_stream();
self.active = false;
self.finishing_after_drain = false;
true
} else {
if !remaining.is_empty() {
let state = self.state_mut(kind);
let state = &mut self.state;
state.enqueue(remaining);
}
// Spacer animated out
self.state_mut(kind).enqueue(vec![Line::from("")]);
self.state.enqueue(vec![Line::from("")]);
self.finishing_after_drain = true;
sink.start_commit_animation();
false
@@ -231,32 +166,29 @@ impl StreamController {
/// Step animation: commit at most one queued line and handle end-of-drain cleanup.
pub(crate) fn on_commit_tick(&mut self, sink: &impl HistorySink) -> bool {
let Some(kind) = self.current_stream else {
if !self.active {
return false;
};
let step = {
let state = self.state_mut(kind);
state.step()
};
}
let step = { self.state.step() };
if !step.history.is_empty() {
let mut lines: Lines = Vec::new();
self.emit_header_if_needed(kind, &mut lines);
self.emit_header_if_needed(&mut lines);
let mut out = lines;
out.extend(step.history);
sink.insert_history(out);
}
let is_idle = self.state(kind).is_idle();
let is_idle = self.state.is_idle();
if is_idle {
sink.stop_commit_animation();
if self.finishing_after_drain {
// Reset and notify
self.state_mut(kind).clear();
// Allow a subsequent block of the same kind in this turn to emit its header.
self.header.allow_reemit_for_same_kind_in_turn(kind);
self.state.clear();
// Allow a subsequent block in this turn to emit its header.
self.header.allow_reemit_in_turn();
// Also clear the per-stream emitted flag so the header can render again.
self.header.reset_for_stream(kind);
self.current_stream = None;
self.header.reset_for_stream();
self.active = false;
self.finishing_after_drain = false;
return true;
}
@@ -267,24 +199,14 @@ impl StreamController {
/// Apply a full final answer: replace queued content with only the remaining tail,
/// then finalize immediately and notify completion.
pub(crate) fn apply_final_answer(&mut self, message: &str, sink: &impl HistorySink) -> bool {
self.apply_full_final(StreamKind::Answer, message, true, sink)
self.apply_full_final(message, sink)
}
pub(crate) fn apply_final_reasoning(&mut self, message: &str, sink: &impl HistorySink) -> bool {
self.apply_full_final(StreamKind::Reasoning, message, false, sink)
}
fn apply_full_final(
&mut self,
kind: StreamKind,
message: &str,
immediate: bool,
sink: &impl HistorySink,
) -> bool {
self.begin(kind, sink);
fn apply_full_final(&mut self, message: &str, sink: &impl HistorySink) -> bool {
self.begin(sink);
{
let state = self.state_mut(kind);
let state = &mut self.state;
// Only inject the final full message if we have not seen any deltas for this stream.
// If deltas were received, rely on the collector's existing buffer to avoid duplication.
if !state.has_seen_delta && !message.is_empty() {
@@ -301,7 +223,6 @@ impl StreamController {
.replace_with_and_mark_committed(&msg, committed);
}
}
self.finalize(kind, immediate, sink)
self.finalize(true, sink)
}
}

View File

@@ -2,12 +2,6 @@ use crate::markdown_stream::AnimatedLineStreamer;
use crate::markdown_stream::MarkdownStreamCollector;
pub(crate) mod controller;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StreamKind {
Answer,
Reasoning,
}
pub(crate) struct StreamState {
pub(crate) collector: MarkdownStreamCollector,
pub(crate) streamer: AnimatedLineStreamer,
@@ -42,92 +36,44 @@ impl StreamState {
}
pub(crate) struct HeaderEmitter {
reasoning_emitted_this_turn: bool,
answer_emitted_this_turn: bool,
reasoning_emitted_in_stream: bool,
answer_emitted_in_stream: bool,
emitted_this_turn: bool,
emitted_in_stream: bool,
}
impl HeaderEmitter {
pub(crate) fn new() -> Self {
Self {
reasoning_emitted_this_turn: false,
answer_emitted_this_turn: false,
reasoning_emitted_in_stream: false,
answer_emitted_in_stream: false,
emitted_this_turn: false,
emitted_in_stream: false,
}
}
pub(crate) fn reset_for_new_turn(&mut self) {
self.reasoning_emitted_this_turn = false;
self.answer_emitted_this_turn = false;
self.reasoning_emitted_in_stream = false;
self.answer_emitted_in_stream = false;
self.emitted_this_turn = false;
self.emitted_in_stream = false;
}
pub(crate) fn reset_for_stream(&mut self, kind: StreamKind) {
match kind {
StreamKind::Reasoning => self.reasoning_emitted_in_stream = false,
StreamKind::Answer => self.answer_emitted_in_stream = false,
}
pub(crate) fn reset_for_stream(&mut self) {
self.emitted_in_stream = false;
}
pub(crate) fn has_emitted_for_stream(&self, kind: StreamKind) -> bool {
match kind {
StreamKind::Reasoning => self.reasoning_emitted_in_stream,
StreamKind::Answer => self.answer_emitted_in_stream,
}
/// Allow emitting the header again within the current turn after a finalize.
pub(crate) fn allow_reemit_in_turn(&mut self) {
self.emitted_this_turn = false;
}
/// Allow emitting the header again for the same kind within the current turn.
///
/// This is used when a stream (e.g., Answer) is finalized and a subsequent
/// block of the same kind is started within the same turn. Without this,
/// only the first block would render a header.
pub(crate) fn allow_reemit_for_same_kind_in_turn(&mut self, kind: StreamKind) {
match kind {
StreamKind::Reasoning => self.reasoning_emitted_this_turn = false,
StreamKind::Answer => self.answer_emitted_this_turn = false,
}
}
pub(crate) fn maybe_emit(
&mut self,
kind: StreamKind,
out_lines: &mut Vec<ratatui::text::Line<'static>>,
) -> bool {
let already_emitted_this_turn = match kind {
StreamKind::Reasoning => self.reasoning_emitted_this_turn,
StreamKind::Answer => self.answer_emitted_this_turn,
};
let already_emitted_in_stream = self.has_emitted_for_stream(kind);
if !already_emitted_in_stream && !already_emitted_this_turn {
out_lines.push(render_header_line(kind));
match kind {
StreamKind::Reasoning => {
self.reasoning_emitted_in_stream = true;
self.reasoning_emitted_this_turn = true;
// Reset opposite header so it may be emitted again this turn
self.answer_emitted_this_turn = false;
}
StreamKind::Answer => {
self.answer_emitted_in_stream = true;
self.answer_emitted_this_turn = true;
// Reset opposite header so it may be emitted again this turn
self.reasoning_emitted_this_turn = false;
}
}
true
} else {
false
pub(crate) fn maybe_emit(&mut self, out_lines: &mut Vec<ratatui::text::Line<'static>>) -> bool {
if !self.emitted_in_stream && !self.emitted_this_turn {
out_lines.push(render_header_line());
self.emitted_in_stream = true;
self.emitted_this_turn = true;
return true;
}
false
}
}
fn render_header_line(kind: StreamKind) -> ratatui::text::Line<'static> {
fn render_header_line() -> ratatui::text::Line<'static> {
use ratatui::style::Stylize;
match kind {
StreamKind::Reasoning => ratatui::text::Line::from("thinking".magenta().italic()),
StreamKind::Answer => ratatui::text::Line::from("codex".magenta().bold()),
}
ratatui::text::Line::from("codex".magenta().bold())
}

View File

@@ -1,12 +1,9 @@
thinking
Investigating binary sizes
To get started, describe a task or try one of these commands:
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!
/init - create an AGENTS.md file with instructions for Codex
/status - show current session configuration and token usage
/approvals - choose what Codex can do without approval
/model - choose what model and reasoning effort to use
codex
Im going to scan the workspace and Cargo manifests to see build profiles and
@@ -37,16 +34,6 @@ dependencies that impact binary size. Then Ill summarize the main causes.
# ratatui = { path = "../../ratatui" }
ratatui = { git = "https://github.com/nornagon/ratatui", branch =
"nornagon-v0.29.0-patch" }
Optimizing Rust Release Profile
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. Ill also skim through crates
for heavy dependencies in Cargo.toml, including cli, core, exec, linux-sandbox,
tui, login, ollama, and mcp.
⚡ Running for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama tui; do
echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
@@ -181,85 +168,6 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
[dev-dependencies]
tempfile = "3"
thinking
Considering binary size impacts
Im 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
Im 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. Its interesting to think about whether theyre targeting musl, especially
since it seems they might often be building for Mac and Linux with dynamic
linking instead.
I also realize theres a difference between debug and release builds — release
should be smaller due to optimizations, but if theyre 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. Id like
to verify with the Rust documentation, but my network access is restricted.
Explaining large binary sizes
Im 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. Its a
mix of several factors, including panic strategies and platform-specific linking
behavior.
Identifying binary size contributors
Im 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, Ill 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.
codex
Heres whats driving size in this workspaces binaries.
@@ -295,4 +203,4 @@ 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.
binary.