Compare commits

...

8 Commits

Author SHA1 Message Date
Jeremy Rose
660c4de145 clippy 2025-08-15 15:47:55 -07:00
Jeremy Rose
48a4e04911 tests 2025-08-15 15:36:34 -07:00
Jeremy Rose
b47251cd9d ctrl-t opens a 'transcript' in less 2025-08-15 15:19:59 -07:00
Jeremy Rose
70c24de906 Merge remote-tracking branch 'origin/main' into nornagon/thinking-header 2025-08-14 19:53:43 -07:00
Jeremy Rose
d8abab96f1 fmt 2025-08-14 17:04:39 -07:00
Jeremy Rose
7b6df09801 tests 2025-08-14 17:00:04 -07:00
Jeremy Rose
563787204e smooth anim 2025-08-14 14:16:34 -07:00
Jeremy Rose
4e3384eed2 CoT summary headers displayed in the status indicator 2025-08-14 14:14:50 -07:00
23 changed files with 523 additions and 513 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -924,6 +924,7 @@ dependencies = [
"strum 0.27.2",
"strum_macros 0.27.2",
"supports-color",
"tempfile",
"textwrap 0.16.2",
"tokio",
"tracing",

View File

@@ -66,6 +66,7 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
tempfile = "3"
tracing = { version = "0.1.41", features = ["log"] }
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

View File

@@ -3,6 +3,7 @@ use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff;
use crate::insert_history::write_lines;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::OnboardingScreen;
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
@@ -22,7 +23,9 @@ use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::layout::Offset;
use ratatui::prelude::Backend;
use ratatui::text::Line;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
@@ -31,6 +34,7 @@ use std::sync::mpsc::channel;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use tempfile::NamedTempFile;
/// Time window for debouncing redraw requests.
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
@@ -61,6 +65,7 @@ pub(crate) struct App<'a> {
file_search: FileSearchManager,
pending_history_lines: Vec<Line<'static>>,
transcript: Vec<Line<'static>>,
enhanced_keys_supported: bool,
@@ -70,6 +75,7 @@ pub(crate) struct App<'a> {
/// Channel to schedule one-shot animation frames; coalesced by a single
/// scheduler thread.
frame_schedule_tx: std::sync::mpsc::Sender<Instant>,
event_reader_enabled: Arc<AtomicBool>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -93,6 +99,7 @@ impl App<'_> {
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
let event_reader_enabled = Arc::new(AtomicBool::new(true));
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
@@ -100,8 +107,13 @@ impl App<'_> {
// re-publishing the events as AppEvents, as appropriate.
{
let app_event_tx = app_event_tx.clone();
let events_enabled = event_reader_enabled.clone();
std::thread::spawn(move || {
loop {
if !events_enabled.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(50));
continue;
}
// This timeout is necessary to avoid holding the event lock
// that crossterm::event::read() acquires. In particular,
// reading the cursor position (crossterm::cursor::position())
@@ -215,6 +227,7 @@ impl App<'_> {
server: conversation_manager,
app_event_tx,
pending_history_lines: Vec::new(),
transcript: Vec::new(),
app_event_rx,
app_state,
config,
@@ -222,6 +235,7 @@ impl App<'_> {
enhanced_keys_supported,
commit_anim_running: Arc::new(AtomicBool::new(false)),
frame_schedule_tx: frame_tx,
event_reader_enabled,
}
}
@@ -235,10 +249,16 @@ impl App<'_> {
while let Ok(event) = self.app_event_rx.recv() {
match event {
AppEvent::InsertHistory(lines) => {
AppEvent::InsertHistoryLines(lines) => {
self.transcript.extend(lines.clone());
self.pending_history_lines.extend(lines);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::InsertHistoryCell(cell) => {
self.transcript.extend(cell.transcript_lines());
self.pending_history_lines.extend(cell.display_lines());
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::RequestRedraw => {
self.schedule_frame_in(REDRAW_DEBOUNCE);
}
@@ -299,6 +319,28 @@ impl App<'_> {
}
// No-op on non-Unix platforms.
}
KeyEvent {
code: KeyCode::Char('t'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.event_reader_enabled.store(false, Ordering::Relaxed);
let mut tmp = NamedTempFile::new()?;
write_lines(tmp.as_file_mut(), self.transcript.clone());
tmp.flush().ok();
let path = tmp.into_temp_path();
tui::restore_modes()?;
let _ = Command::new("less")
.arg("-R")
.arg("+G")
.arg(path.as_os_str())
.status();
let _ = path.close();
tui::set_modes()?;
self.event_reader_enabled.store(true, Ordering::Relaxed);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
@@ -499,7 +541,7 @@ impl App<'_> {
#[cfg(unix)]
fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> {
tui::restore()?;
tui::restore_modes()?;
// SAFETY: Unix-only code path. We intentionally send SIGTSTP to the
// current process group (pid 0) to trigger standard job-control
// suspension semantics. This FFI does not involve any raw pointers,

View File

@@ -5,6 +5,7 @@ use ratatui::text::Line;
use std::time::Duration;
use crate::app::ChatWidgetArgs;
use crate::history_cell::HistoryCell;
use crate::slash_command::SlashCommand;
#[allow(clippy::large_enum_variant)]
@@ -51,7 +52,8 @@ pub(crate) enum AppEvent {
matches: Vec<FileMatch>,
},
InsertHistory(Vec<Line<'static>>),
InsertHistoryLines(Vec<Line<'static>>),
InsertHistoryCell(Box<dyn HistoryCell>),
StartCommitAnimation,
StopCommitAnimation,

View File

@@ -28,6 +28,11 @@ pub(crate) trait BottomPaneView<'a> {
/// 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

@@ -167,6 +167,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

@@ -23,9 +23,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

@@ -77,8 +77,6 @@ pub(crate) struct ChatWidget<'a> {
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,
@@ -86,6 +84,9 @@ pub(crate) struct ChatWidget<'a> {
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,
full_reasoning_buffer: String,
session_id: Option<Uuid>,
}
@@ -94,8 +95,6 @@ struct UserMessage {
image_paths: Vec<PathBuf>,
}
use crate::streaming::StreamKind;
impl From<String> for UserMessage {
fn from(text: String) -> Self {
Self {
@@ -120,14 +119,14 @@ 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) {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
self.session_id = Some(event.session_id);
self.add_to_history(&history_cell::new_session_info(&self.config, event, true));
self.add_to_history(history_cell::new_session_info(&self.config, event, true));
if let Some(user_message) = self.initial_user_message.take() {
self.submit_user_message(user_message);
}
@@ -137,30 +136,48 @@ 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.full_reasoning_buffer.push_str(&self.reasoning_buffer);
if !self.full_reasoning_buffer.is_empty() {
self.add_to_history(history_cell::new_reasoning_block(
self.full_reasoning_buffer.clone(),
&self.config,
));
}
self.reasoning_buffer.clear();
self.full_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.full_reasoning_buffer.push_str(&self.reasoning_buffer);
self.full_reasoning_buffer.push_str("\n\n");
self.reasoning_buffer.clear();
}
// Raw reasoning uses the same flow as summarized reasoning
@@ -169,7 +186,8 @@ 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.full_reasoning_buffer.clear();
self.reasoning_buffer.clear();
self.mark_needs_redraw();
}
@@ -178,9 +196,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);
@@ -199,7 +215,7 @@ impl ChatWidget<'_> {
}
fn on_error(&mut self, message: String) {
self.add_to_history(&history_cell::new_error_event(message));
self.add_to_history(history_cell::new_error_event(message));
self.bottom_pane.set_task_running(false);
self.running_commands.clear();
self.stream.clear_all();
@@ -207,7 +223,7 @@ impl ChatWidget<'_> {
}
fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) {
self.add_to_history(&history_cell::new_plan_update(update));
self.add_to_history(history_cell::new_plan_update(update));
}
fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) {
@@ -242,7 +258,7 @@ impl ChatWidget<'_> {
}
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
self.add_to_history(&history_cell::new_patch_event(
self.add_to_history(history_cell::new_patch_event(
PatchEventType::ApplyBegin {
auto_approved: event.auto_approved,
},
@@ -342,10 +358,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();
}
@@ -370,7 +385,7 @@ impl ChatWidget<'_> {
self.active_exec_cell = None;
let pending = std::mem::take(&mut self.pending_exec_completions);
for (command, parsed, output) in pending {
self.add_to_history(&history_cell::new_completed_exec_command(
self.add_to_history(history_cell::new_completed_exec_command(
command, parsed, output,
));
}
@@ -382,9 +397,9 @@ impl ChatWidget<'_> {
event: codex_core::protocol::PatchApplyEndEvent,
) {
if event.success {
self.add_to_history(&history_cell::new_patch_apply_success(event.stdout));
self.add_to_history(history_cell::new_patch_apply_success(event.stdout));
} else {
self.add_to_history(&history_cell::new_patch_apply_failure(event.stderr));
self.add_to_history(history_cell::new_patch_apply_failure(event.stderr));
}
}
@@ -406,7 +421,7 @@ impl ChatWidget<'_> {
ev: ApplyPatchApprovalRequestEvent,
) {
self.flush_answer_stream_with_separator();
self.add_to_history(&history_cell::new_patch_event(
self.add_to_history(history_cell::new_patch_event(
PatchEventType::ApprovalRequest,
ev.changes.clone(),
));
@@ -448,20 +463,26 @@ impl ChatWidget<'_> {
pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
self.flush_answer_stream_with_separator();
self.add_to_history(&history_cell::new_active_mcp_tool_call(ev.invocation));
self.add_to_history(history_cell::new_active_mcp_tool_call(ev.invocation));
}
pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
self.flush_answer_stream_with_separator();
self.add_to_history(&*history_cell::new_completed_mcp_tool_call(
80,
ev.invocation,
ev.duration,
ev.result
.as_ref()
.map(|r| !r.is_error.unwrap_or(false))
.unwrap_or(false),
ev.result,
));
if let Some(cell) =
history_cell::try_new_completed_mcp_tool_call_with_image_output(&ev.result)
{
self.add_to_history(cell);
} else {
self.add_to_history(history_cell::new_completed_mcp_tool_call(
80,
ev.invocation,
ev.duration,
ev.result
.as_ref()
.map(|r| !r.is_error.unwrap_or(false))
.unwrap_or(false),
ev.result,
));
}
}
fn interrupt_running_task(&mut self) {
if self.bottom_pane.is_task_running() {
@@ -513,12 +534,13 @@ 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(),
full_reasoning_buffer: String::new(),
session_id: None,
}
}
@@ -551,14 +573,14 @@ impl ChatWidget<'_> {
fn flush_active_exec_cell(&mut self) {
if let Some(active) = self.active_exec_cell.take() {
self.app_event_tx
.send(AppEvent::InsertHistory(active.display_lines()));
.send(AppEvent::InsertHistoryCell(Box::new(active)));
}
}
fn add_to_history(&mut self, cell: &dyn HistoryCell) {
fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
self.flush_active_exec_cell();
self.app_event_tx
.send(AppEvent::InsertHistory(cell.display_lines()));
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
}
fn submit_user_message(&mut self, user_message: UserMessage) {
@@ -594,7 +616,7 @@ impl ChatWidget<'_> {
// Only show the text portion in conversation history.
if !text.is_empty() {
self.add_to_history(&history_cell::new_user_prompt(text.clone()));
self.add_to_history(history_cell::new_user_prompt(text.clone()));
}
}
@@ -622,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(),
@@ -660,11 +682,11 @@ impl ChatWidget<'_> {
}
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
self.add_to_history(&history_cell::new_diff_output(diff_output.clone()));
self.add_to_history(history_cell::new_diff_output(diff_output.clone()));
}
pub(crate) fn add_status_output(&mut self) {
self.add_to_history(&history_cell::new_status_output(
self.add_to_history(history_cell::new_status_output(
&self.config,
&self.total_token_usage,
&self.session_id,
@@ -672,7 +694,7 @@ impl ChatWidget<'_> {
}
pub(crate) fn add_prompts_output(&mut self) {
self.add_to_history(&history_cell::new_prompts_output());
self.add_to_history(history_cell::new_prompts_output());
}
/// Forward file-search results to the bottom pane.
@@ -783,5 +805,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

@@ -135,12 +135,13 @@ 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(),
full_reasoning_buffer: String::new(),
session_id: None,
};
(widget, rx, op_rx)
@@ -151,8 +152,10 @@ fn drain_insert_history(
) -> Vec<Vec<ratatui::text::Line<'static>>> {
let mut out = Vec::new();
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistory(lines) = ev {
out.push(lines);
match ev {
AppEvent::InsertHistoryLines(lines) => out.push(lines),
AppEvent::InsertHistoryCell(cell) => out.push(cell.display_lines()),
_ => {}
}
}
out
@@ -320,13 +323,25 @@ async fn binary_size_transcript_matches_ideal_fixture() {
let ev: Event = serde_json::from_value(payload.clone()).expect("parse");
chat.handle_codex_event(ev);
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::InsertHistory(lines) = app_ev {
transcript.push_str(&lines_to_single_string(&lines));
crate::insert_history::insert_history_lines_to_writer(
&mut terminal,
&mut ansi,
lines,
);
match app_ev {
AppEvent::InsertHistoryLines(lines) => {
transcript.push_str(&lines_to_single_string(&lines));
crate::insert_history::insert_history_lines_to_writer(
&mut terminal,
&mut ansi,
lines,
);
}
AppEvent::InsertHistoryCell(cell) => {
let lines = cell.display_lines();
transcript.push_str(&lines_to_single_string(&lines));
crate::insert_history::insert_history_lines_to_writer(
&mut terminal,
&mut ansi,
lines,
);
}
_ => {}
}
}
}
@@ -336,13 +351,25 @@ async fn binary_size_transcript_matches_ideal_fixture() {
if variant == "CommitTick" {
chat.on_commit_tick();
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::InsertHistory(lines) = app_ev {
transcript.push_str(&lines_to_single_string(&lines));
crate::insert_history::insert_history_lines_to_writer(
&mut terminal,
&mut ansi,
lines,
);
match app_ev {
AppEvent::InsertHistoryLines(lines) => {
transcript.push_str(&lines_to_single_string(&lines));
crate::insert_history::insert_history_lines_to_writer(
&mut terminal,
&mut ansi,
lines,
);
}
AppEvent::InsertHistoryCell(cell) => {
let lines = cell.display_lines();
transcript.push_str(&lines_to_single_string(&lines));
crate::insert_history::insert_history_lines_to_writer(
&mut terminal,
&mut ansi,
lines,
);
}
_ => {}
}
}
}
@@ -359,6 +386,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.
@@ -384,22 +416,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"));
@@ -730,7 +808,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, rx, _op_rx) = make_chatwidget_manual();
// Answer: no header until a newline commit
@@ -742,7 +820,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
});
let mut saw_codex_pre = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistory(lines) = ev {
if let AppEvent::InsertHistoryLines(lines) = ev {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
@@ -770,7 +848,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
chat.on_commit_tick();
let mut saw_codex_post = false;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::InsertHistory(lines) = ev {
if let AppEvent::InsertHistoryLines(lines) = ev {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
@@ -788,7 +866,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, rx2, _op_rx2) = make_chatwidget_manual();
chat2.handle_codex_event(Event {
id: "sub-b".into(),
@@ -798,7 +876,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
});
let mut saw_thinking = false;
while let Ok(ev) = rx2.try_recv() {
if let AppEvent::InsertHistory(lines) = ev {
if let AppEvent::InsertHistoryLines(lines) = ev {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
@@ -812,8 +890,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

@@ -2,8 +2,11 @@ use crate::colors::LIGHT_BLUE;
use crate::diff_render::create_diff_summary;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::append_markdown;
use crate::slash_command::SlashCommand;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::user_approval_widget::ApprovalRequest;
use crate::user_approval_widget::to_command_display;
use base64::Engine;
use codex_ansi_escape::ansi_escape_line;
use codex_common::create_config_summary_entries;
@@ -15,6 +18,7 @@ use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::ReviewDecision;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::TokenUsage;
@@ -39,7 +43,7 @@ use std::time::Instant;
use tracing::error;
use uuid::Uuid;
#[derive(Clone)]
#[derive(Clone, Debug)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
pub(crate) stdout: String,
@@ -54,9 +58,13 @@ pub(crate) enum PatchEventType {
/// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list.
pub(crate) trait HistoryCell {
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync {
fn display_lines(&self) -> Vec<Line<'static>>;
fn transcript_lines(&self) -> Vec<Line<'static>> {
self.display_lines()
}
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.display_lines()))
.wrap(Wrap { trim: false })
@@ -66,6 +74,7 @@ pub(crate) trait HistoryCell {
}
}
#[derive(Debug)]
pub(crate) struct PlainHistoryCell {
lines: Vec<Line<'static>>,
}
@@ -76,6 +85,22 @@ impl HistoryCell for PlainHistoryCell {
}
}
#[derive(Debug)]
pub(crate) struct TranscriptOnlyHistoryCell {
lines: Vec<Line<'static>>,
}
impl HistoryCell for TranscriptOnlyHistoryCell {
fn display_lines(&self) -> Vec<Line<'static>> {
vec![]
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
self.lines.clone()
}
}
#[derive(Debug)]
pub(crate) struct ExecCell {
pub(crate) command: Vec<String>,
pub(crate) parsed: Vec<ParsedCommand>,
@@ -101,7 +126,8 @@ impl WidgetRef for &ExecCell {
}
}
struct CompletedMcpToolCallWithImageOutput {
#[derive(Debug)]
pub(crate) struct CompletedMcpToolCallWithImageOutput {
_image: DynamicImage,
}
impl HistoryCell for CompletedMcpToolCallWithImageOutput {
@@ -343,7 +369,7 @@ pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistor
/// If the first content is an image, return a new cell with the image.
/// TODO(rgwood-dd): Handle images properly even if they're not the first result.
fn try_new_completed_mcp_tool_call_with_image_output(
pub fn try_new_completed_mcp_tool_call_with_image_output(
result: &Result<mcp_types::CallToolResult, String>,
) -> Option<CompletedMcpToolCallWithImageOutput> {
match result {
@@ -387,11 +413,7 @@ pub(crate) fn new_completed_mcp_tool_call(
duration: Duration,
success: bool,
result: Result<mcp_types::CallToolResult, String>,
) -> Box<dyn HistoryCell> {
if let Some(cell) = try_new_completed_mcp_tool_call_with_image_output(&result) {
return Box::new(cell);
}
) -> PlainHistoryCell {
let duration = format_duration(duration);
let status_str = if success { "success" } else { "failed" };
let title_line = Line::from(vec![
@@ -459,7 +481,7 @@ pub(crate) fn new_completed_mcp_tool_call(
}
};
Box::new(PlainHistoryCell { lines })
PlainHistoryCell { lines }
}
pub(crate) fn new_diff_output(message: String) -> PlainHistoryCell {
@@ -895,6 +917,94 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
Line::from(invocation_spans)
}
pub(crate) fn new_exec_approval_decision(
approval_request: &ApprovalRequest,
decision: codex_core::protocol::ReviewDecision,
feedback: String,
) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
match approval_request {
ApprovalRequest::Exec { command, .. } => {
let cmd = strip_bash_lc_and_escape(command);
let mut cmd_span: Span = cmd.clone().into();
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
// Result line based on decision.
match decision {
ReviewDecision::Approved => {
lines.extend(to_command_display(
vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
],
cmd,
vec![" this time".bold()],
));
}
ReviewDecision::ApprovedForSession => {
lines.extend(to_command_display(
vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
"codex to run ".into(),
],
cmd,
vec![" every time this session".bold()],
));
}
ReviewDecision::Denied => {
lines.extend(to_command_display(
vec![
"".fg(Color::Red),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
],
cmd,
vec![],
));
}
ReviewDecision::Abort => {
lines.extend(to_command_display(
vec![
"".fg(Color::Red),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
],
cmd,
vec![],
));
}
}
}
ApprovalRequest::ApplyPatch { .. } => {
lines.push(Line::from(format!("patch approval decision: {decision:?}")));
}
}
if !feedback.trim().is_empty() {
lines.push(Line::from("feedback:"));
for l in feedback.lines() {
lines.push(Line::from(l.to_string()));
}
}
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_block(
full_reasoning_buffer: String,
config: &Config,
) -> TranscriptOnlyHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("thinking".magenta().italic()));
append_markdown(&full_reasoning_buffer, &mut lines, config);
lines.push(Line::from(""));
TranscriptOnlyHistoryCell { lines }
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -109,6 +109,13 @@ pub fn insert_history_lines_to_writer<B, W>(
}
}
pub fn write_lines(writer: &mut impl Write, lines: Vec<Line>) {
for line in lines {
queue!(writer, Print("\r\n")).ok();
write_spans(writer, line.iter()).ok();
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetScrollRegion(pub std::ops::Range<u16>);

View File

@@ -288,7 +288,7 @@ fn run_ratatui_app(
reason = "TUI should no longer be displayed, so we can write to stderr."
)]
fn restore() {
if let Err(err) = tui::restore() {
if let Err(err) = tui::restore_modes() {
eprintln!(
"failed to restore terminal. Run `reset` or restart your terminal to recover: {err}"
);

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

@@ -160,12 +160,12 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
LOGGER.write_json_line(value);
}
// Internal UI events; still log for fidelity, but avoid heavy payloads.
AppEvent::InsertHistory(lines) => {
AppEvent::InsertHistoryCell(cell) => {
let value = json!({
"ts": now_ts(),
"dir": "to_tui",
"kind": "insert_history",
"lines": lines.len(),
"lines": cell.transcript_lines().len(),
});
LOGGER.write_json_line(value);
}

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

@@ -29,6 +29,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
@@ -45,6 +47,7 @@ impl StatusIndicatorWidget {
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
Self {
text: String::from("waiting for model"),
header: String::from("Working"),
last_target_len: 0,
base_frame: 0,
reveal_len_at_base: 0,
@@ -92,6 +95,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) {
@@ -144,12 +154,12 @@ impl WidgetRef for StatusIndicatorWidget {
// Schedule next animation frame.
self.app_event_tx
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(100)));
.send(AppEvent::ScheduleFrameIn(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.
@@ -18,7 +17,7 @@ pub(crate) struct AppEventHistorySink(pub(crate) crate::app_event_sender::AppEve
impl HistorySink for AppEventHistorySink {
fn insert_history(&self, lines: Vec<Line<'static>>) {
self.0
.send(crate::app_event::AppEvent::InsertHistory(lines))
.send(crate::app_event::AppEvent::InsertHistoryLines(lines))
}
fn start_commit_animation(&self) {
self.0
@@ -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 {
if 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

@@ -23,23 +23,7 @@ pub type Tui = Terminal<CrosstermBackend<Stdout>>;
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
pub fn init(_config: &Config) -> Result<Tui> {
execute!(stdout(), EnableBracketedPaste)?;
enable_raw_mode()?;
// Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated.
// chat_composer.rs is using a keyboard event listener to enter for any modified keys
// to create a new line that require this.
// Some terminals (notably legacy Windows consoles) do not support
// keyboard enhancement flags. Attempt to enable them, but continue
// gracefully if unsupported.
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
set_modes()?;
set_panic_hook();
// Clear screen and move cursor to top-left before drawing UI
@@ -53,13 +37,27 @@ pub fn init(_config: &Config) -> Result<Tui> {
fn set_panic_hook() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = restore(); // ignore any errors as we are already failing
let _ = restore_modes(); // ignore any errors as we are already failing
hook(panic_info);
}));
}
pub fn set_modes() -> Result<()> {
execute!(stdout(), EnableBracketedPaste)?;
enable_raw_mode()?;
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
)
);
Ok(())
}
/// Restore the terminal to its original state
pub fn restore() -> Result<()> {
pub fn restore_modes() -> Result<()> {
// Pop may fail on platforms that didn't support the push; ignore errors.
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
execute!(stdout(), DisableBracketedPaste)?;

View File

@@ -29,6 +29,7 @@ use ratatui::widgets::Wrap;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
/// Request coming from the agent that needs user approval.
pub(crate) enum ApprovalRequest {
@@ -109,7 +110,7 @@ pub(crate) struct UserApprovalWidget<'a> {
done: bool,
}
fn to_command_display<'a>(
pub fn to_command_display<'a>(
first_line: Vec<Span<'a>>,
cmd: String,
last_line: Vec<Span<'a>>,
@@ -258,77 +259,9 @@ impl UserApprovalWidget<'_> {
}
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
let mut lines: Vec<Line<'static>> = Vec::new();
match &self.approval_request {
ApprovalRequest::Exec { command, .. } => {
let cmd = strip_bash_lc_and_escape(command);
let mut cmd_span: Span = cmd.clone().into();
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
// Result line based on decision.
match decision {
ReviewDecision::Approved => {
lines.extend(to_command_display(
vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
],
cmd,
vec![" this time".bold()],
));
}
ReviewDecision::ApprovedForSession => {
lines.extend(to_command_display(
vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
"codex to run ".into(),
],
cmd,
vec![" every time this session".bold()],
));
}
ReviewDecision::Denied => {
lines.extend(to_command_display(
vec![
"".fg(Color::Red),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
],
cmd,
vec![],
));
}
ReviewDecision::Abort => {
lines.extend(to_command_display(
vec![
"".fg(Color::Red),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
],
cmd,
vec![],
));
}
}
}
ApprovalRequest::ApplyPatch { .. } => {
lines.push(Line::from(format!("patch approval decision: {decision:?}")));
}
}
if !feedback.trim().is_empty() {
lines.push(Line::from("feedback:"));
for l in feedback.lines() {
lines.push(Line::from(l.to_string()));
}
}
lines.push(Line::from(""));
self.app_event_tx.send(AppEvent::InsertHistory(lines));
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_exec_approval_decision(&self.approval_request, decision, feedback),
)));
let op = match &self.approval_request {
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {

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
/diff - show git diff (including untracked files)
/prompts - show example prompts
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.