Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 28 additions & 15 deletions codex-rs/core/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -701,7 +701,6 @@ impl Session {
let _ = self.tx_event.send(event).await;
}

#[allow(clippy::too_many_arguments)]
async fn on_exec_command_end(
&self,
turn_diff_tracker: &mut TurnDiffTracker,
Expand All @@ -721,6 +720,7 @@ impl Session {
const MAX_STREAM_OUTPUT: usize = 5 * 1024; // 5KiB
let stdout = stdout.text.chars().take(MAX_STREAM_OUTPUT).collect();
let stderr = stderr.text.chars().take(MAX_STREAM_OUTPUT).collect();
let formatted_output = format_exec_output_str(output);

let msg = if is_apply_patch {
EventMsg::PatchApplyEnd(PatchApplyEndEvent {
Expand All @@ -734,6 +734,7 @@ impl Session {
call_id: call_id.to_string(),
stdout,
stderr,
formatted_output,
duration: *duration,
exit_code: *exit_code,
})
Expand Down Expand Up @@ -2357,7 +2358,7 @@ async fn handle_container_exec_with_params(
let ExecToolCallOutput { exit_code, .. } = &output;

let is_success = *exit_code == 0;
let content = format_exec_output(output);
let content = format_exec_output(&output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
Expand Down Expand Up @@ -2490,7 +2491,7 @@ async fn handle_sandbox_error(
let ExecToolCallOutput { exit_code, .. } = &retry_output;

let is_success = *exit_code == 0;
let content = format_exec_output(retry_output);
let content = format_exec_output(&retry_output);

ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
Expand Down Expand Up @@ -2522,13 +2523,33 @@ async fn handle_sandbox_error(
}
}

/// Exec output is a pre-serialized JSON payload
fn format_exec_output(exec_output: ExecToolCallOutput) -> String {
fn format_exec_output_str(exec_output: &ExecToolCallOutput) -> String {
let ExecToolCallOutput {
exit_code,
stdout,
stderr,
..
} = exec_output;

let is_success = *exit_code == 0;
let output = if is_success { stdout } else { stderr };

let mut formatted_output = output.text.clone();
if let Some(truncated_after_lines) = output.truncated_after_lines {
formatted_output.push_str(&format!(
"\n\n[Output truncated after {truncated_after_lines} lines: too many lines or bytes.]",
));
}

formatted_output
}

/// Exec output is a pre-serialized JSON payload
fn format_exec_output(exec_output: &ExecToolCallOutput) -> String {
let ExecToolCallOutput {
exit_code,
duration,
..
} = exec_output;

#[derive(Serialize)]
Expand All @@ -2546,20 +2567,12 @@ fn format_exec_output(exec_output: ExecToolCallOutput) -> String {
// round to 1 decimal place
let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0;

let is_success = exit_code == 0;
let output = if is_success { stdout } else { stderr };

let mut formatted_output = output.text;
if let Some(truncated_after_lines) = output.truncated_after_lines {
formatted_output.push_str(&format!(
"\n\n[Output truncated after {truncated_after_lines} lines: too many lines or bytes.]",
));
}
let formatted_output = format_exec_output_str(exec_output);

let payload = ExecOutput {
output: &formatted_output,
metadata: ExecMetadata {
exit_code,
exit_code: *exit_code,
duration_seconds,
},
};
Expand Down
1 change: 1 addition & 0 deletions codex-rs/exec/src/event_processor_with_human_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
stderr,
duration,
exit_code,
..
}) => {
let exec_command = self.call_id_to_command.remove(&call_id);
let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/protocol/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,8 @@ pub struct ExecCommandEndEvent {
pub exit_code: i32,
/// The duration of the command execution.
pub duration: Duration,
/// Formatted output from the command, as seen by the model.
pub formatted_output: String,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
Expand Down
87 changes: 45 additions & 42 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use codex_core::protocol::TokenUsage;
use codex_file_search::FileMatch;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;

Expand Down Expand Up @@ -95,19 +97,43 @@ impl BottomPane {
} else {
self.composer.desired_height(width)
};
let top_pad = if self.active_view.is_none() || self.status_view_active {
1
} else {
0
};
view_height
.saturating_add(Self::BOTTOM_PAD_LINES)
.saturating_add(top_pad)
}

fn layout(&self, area: Rect) -> Rect {
let top = if self.active_view.is_none() || self.status_view_active {
1
} else {
0
};

view_height.saturating_add(Self::BOTTOM_PAD_LINES)
let [_, content, _] = Layout::vertical([
Constraint::Max(top),
Constraint::Min(1),
Constraint::Max(BottomPane::BOTTOM_PAD_LINES),
])
.areas(area);

content
}

pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
// Hide the cursor whenever an overlay view is active (e.g. the
// status indicator shown while a task is running, or approval modal).
// In these states the textarea is not interactable, so we should not
// show its caret.
if self.active_view.is_some() {
if self.active_view.is_some() || self.status_view_active {
None
} else {
self.composer.cursor_pos(area)
let content = self.layout(area);
self.composer.cursor_pos(content)
}
}

Expand Down Expand Up @@ -365,31 +391,12 @@ impl BottomPane {

impl WidgetRef for &BottomPane {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let content = self.layout(area);

if let Some(view) = &self.active_view {
// Reserve bottom padding lines; keep at least 1 line for the view.
let avail = area.height;
if avail > 0 {
let pad = BottomPane::BOTTOM_PAD_LINES.min(avail.saturating_sub(1));
let view_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
height: avail - pad,
};
view.render(view_rect, buf);
}
view.render(content, buf);
} else {
let avail = area.height;
if avail > 0 {
let composer_rect = Rect {
x: area.x,
y: area.y,
width: area.width,
// Reserve bottom padding
height: avail - BottomPane::BOTTOM_PAD_LINES.min(avail.saturating_sub(1)),
};
(&self.composer).render_ref(composer_rect, buf);
}
(&self.composer).render_ref(content, buf);
}
}
}
Expand Down Expand Up @@ -495,13 +502,13 @@ mod tests {
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);
let mut row0 = String::new();
let mut row1 = String::new();
for x in 0..area.width {
row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
row1.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert!(
row0.contains("Working"),
"expected Working header after denial: {row0:?}"
row1.contains("Working"),
"expected Working header after denial on row 1: {row1:?}"
);

// Drain the channel to avoid unused warnings.
Expand All @@ -523,14 +530,13 @@ mod tests {
// Begin a task: show initial status.
pane.set_task_running(true);

// Render and confirm the line contains the "Working" header.
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);

let mut row0 = String::new();
for x in 0..area.width {
row0.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
row0.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert!(
row0.contains("Working"),
Expand Down Expand Up @@ -563,12 +569,12 @@ mod tests {
let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf);

// Top row contains the status header
// Row 1 contains the status header (row 0 is the spacer)
let mut top = String::new();
for x in 0..area.width {
top.push(buf[(x, 0)].symbol().chars().next().unwrap_or(' '));
top.push(buf[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert_eq!(buf[(0, 0)].symbol().chars().next().unwrap_or(' '), '▌');
assert_eq!(buf[(0, 1)].symbol().chars().next().unwrap_or(' '), '▌');
assert!(
top.contains("Working"),
"expected Working header on top row: {top:?}"
Expand Down Expand Up @@ -605,7 +611,7 @@ mod tests {

pane.set_task_running(true);

// Height=2 → pad shrinks to 1; bottom row is blank, top row has spinner.
// Height=2 → with spacer, spinner on row 1; no bottom padding.
let area2 = Rect::new(0, 0, 20, 2);
let mut buf2 = Buffer::empty(area2);
(&pane).render_ref(area2, &mut buf2);
Expand All @@ -615,13 +621,10 @@ mod tests {
row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' '));
row1.push(buf2[(x, 1)].symbol().chars().next().unwrap_or(' '));
}
assert!(row0.trim().is_empty(), "expected spacer on row 0: {row0:?}");
assert!(
row0.contains("Working"),
"expected Working header on row 0: {row0:?}"
);
assert!(
row1.trim().is_empty(),
"expected bottom padding on row 1: {row1:?}"
row1.contains("Working"),
"expected Working on row 1: {row1:?}"
);

// Height=1 → no padding; single row is the spinner.
Expand Down
27 changes: 24 additions & 3 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ pub(crate) struct ChatWidget {
full_reasoning_buffer: String,
session_id: Option<Uuid>,
frame_requester: FrameRequester,
last_history_was_exec: bool,
}

struct UserMessage {
Expand Down Expand Up @@ -376,6 +377,9 @@ impl ChatWidget {
self.bottom_pane.set_task_running(false);
self.task_complete_pending = false;
}
// A completed stream indicates non-exec content was just inserted.
// Reset the exec header grouping so the next exec shows its header.
self.last_history_was_exec = false;
self.flush_interrupt_queue();
}
}
Expand All @@ -401,16 +405,24 @@ impl ChatWidget {
exit_code: ev.exit_code,
stdout: ev.stdout.clone(),
stderr: ev.stderr.clone(),
formatted_output: ev.formatted_output.clone(),
},
));

if self.running_commands.is_empty() {
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(
command, parsed, output,
));
let include_header = !self.last_history_was_exec;
let cell = history_cell::new_completed_exec_command(
command,
parsed,
output,
include_header,
ev.duration,
);
self.add_to_history(cell);
self.last_history_was_exec = true;
}
}
}
Expand Down Expand Up @@ -473,9 +485,11 @@ impl ChatWidget {
exec.parsed.extend(ev.parsed_cmd);
}
_ => {
let include_header = !self.last_history_was_exec;
self.active_exec_cell = Some(history_cell::new_active_exec_command(
ev.command,
ev.parsed_cmd,
include_header,
));
}
}
Expand Down Expand Up @@ -565,6 +579,7 @@ impl ChatWidget {
reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(),
session_id: None,
last_history_was_exec: false,
}
}

Expand Down Expand Up @@ -713,13 +728,19 @@ impl ChatWidget {

fn flush_active_exec_cell(&mut self) {
if let Some(active) = self.active_exec_cell.take() {
self.last_history_was_exec = true;
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(active)));
}
}

fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
// Only break exec grouping if the cell renders visible lines.
let has_display_lines = !cell.display_lines().is_empty();
self.flush_active_exec_cell();
if has_display_lines {
self.last_history_was_exec = false;
}
self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
}
Expand Down
Loading
Loading