diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index c98ed43e..6a6d2aa8 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b6d71198..48ad3f02 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs index dcd1fc0b..7aeade1b 100644 --- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs +++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs @@ -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 } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d10f6e3c..ca166d7b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, running_commands: HashMap, pending_exec_completions: Vec<(Vec, Vec, 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, frame_requester: FrameRequester, } @@ -107,8 +107,6 @@ struct UserMessage { image_paths: Vec, } -use crate::streaming::StreamKind; - impl From 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 { + 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; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap index 8258ea0b..2bc5c75e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -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. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap index c90ec273..2bc5c75e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap @@ -2,8 +2,5 @@ source: tui/src/chatwidget/tests.rs expression: combined --- -thinking -I will first analyze the request. - codex Here is the result. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fab64c88..a7959b1f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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 = 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::>().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::>() + .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" ); } diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index 29fcbc8c..af928cf8 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -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. diff --git a/codex-rs/tui/src/shimmer.rs b/codex-rs/tui/src/shimmer.rs index 2523f6ec..24373cee 100644 --- a/codex-rs/tui/src/shimmer.rs +++ b/codex-rs/tui/src/shimmer.rs @@ -22,7 +22,7 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec> { // 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; diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 2b261941..df4910fe 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -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; diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 5eae2a91..050e6154 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -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>; pub(crate) struct StreamController { config: Config, header: HeaderEmitter, - states: [StreamState; 2], - current_stream: Option, + 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) } } diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs index bb4fdcb6..1e670738 100644 --- a/codex-rs/tui/src/streaming/mod.rs +++ b/codex-rs/tui/src/streaming/mod.rs @@ -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>, - ) -> 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>) -> 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()) } diff --git a/codex-rs/tui/tests/fixtures/ideal-binary-response.txt b/codex-rs/tui/tests/fixtures/ideal-binary-response.txt index bc1bb5e5..80aae5d0 100644 --- a/codex-rs/tui/tests/fixtures/ideal-binary-response.txt +++ b/codex-rs/tui/tests/fixtures/ideal-binary-response.txt @@ -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 I’m going to scan the workspace and Cargo manifests to see build profiles and @@ -37,16 +34,6 @@ dependencies that impact binary size. Then I’ll 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. I’ll 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 - -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. - - - - - - codex Here’s what’s driving size in this workspace’s 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. \ No newline at end of file +binary.