tui: coalesce command output; show unabridged commands in transcript (#2590)

https://github.com/user-attachments/assets/effec7c7-732a-4b61-a2ae-3cb297b6b19b
This commit is contained in:
Jeremy Rose
2025-08-22 16:32:31 -07:00
committed by GitHub
parent 6de9541f0a
commit d994019f3f
13 changed files with 394 additions and 209 deletions

View File

@@ -701,7 +701,6 @@ impl Session {
let _ = self.tx_event.send(event).await; let _ = self.tx_event.send(event).await;
} }
#[allow(clippy::too_many_arguments)]
async fn on_exec_command_end( async fn on_exec_command_end(
&self, &self,
turn_diff_tracker: &mut TurnDiffTracker, turn_diff_tracker: &mut TurnDiffTracker,
@@ -721,6 +720,7 @@ impl Session {
const MAX_STREAM_OUTPUT: usize = 5 * 1024; // 5KiB const MAX_STREAM_OUTPUT: usize = 5 * 1024; // 5KiB
let stdout = stdout.text.chars().take(MAX_STREAM_OUTPUT).collect(); let stdout = stdout.text.chars().take(MAX_STREAM_OUTPUT).collect();
let stderr = stderr.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 { let msg = if is_apply_patch {
EventMsg::PatchApplyEnd(PatchApplyEndEvent { EventMsg::PatchApplyEnd(PatchApplyEndEvent {
@@ -734,6 +734,7 @@ impl Session {
call_id: call_id.to_string(), call_id: call_id.to_string(),
stdout, stdout,
stderr, stderr,
formatted_output,
duration: *duration, duration: *duration,
exit_code: *exit_code, exit_code: *exit_code,
}) })
@@ -2357,7 +2358,7 @@ async fn handle_container_exec_with_params(
let ExecToolCallOutput { exit_code, .. } = &output; let ExecToolCallOutput { exit_code, .. } = &output;
let is_success = *exit_code == 0; let is_success = *exit_code == 0;
let content = format_exec_output(output); let content = format_exec_output(&output);
ResponseInputItem::FunctionCallOutput { ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(), call_id: call_id.clone(),
output: FunctionCallOutputPayload { output: FunctionCallOutputPayload {
@@ -2490,7 +2491,7 @@ async fn handle_sandbox_error(
let ExecToolCallOutput { exit_code, .. } = &retry_output; let ExecToolCallOutput { exit_code, .. } = &retry_output;
let is_success = *exit_code == 0; let is_success = *exit_code == 0;
let content = format_exec_output(retry_output); let content = format_exec_output(&retry_output);
ResponseInputItem::FunctionCallOutput { ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(), call_id: call_id.clone(),
@@ -2522,13 +2523,33 @@ async fn handle_sandbox_error(
} }
} }
/// Exec output is a pre-serialized JSON payload fn format_exec_output_str(exec_output: &ExecToolCallOutput) -> String {
fn format_exec_output(exec_output: ExecToolCallOutput) -> String {
let ExecToolCallOutput { let ExecToolCallOutput {
exit_code, exit_code,
stdout, stdout,
stderr, 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, duration,
..
} = exec_output; } = exec_output;
#[derive(Serialize)] #[derive(Serialize)]
@@ -2546,20 +2567,12 @@ fn format_exec_output(exec_output: ExecToolCallOutput) -> String {
// round to 1 decimal place // round to 1 decimal place
let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0; let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0;
let is_success = exit_code == 0; let formatted_output = format_exec_output_str(exec_output);
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 payload = ExecOutput { let payload = ExecOutput {
output: &formatted_output, output: &formatted_output,
metadata: ExecMetadata { metadata: ExecMetadata {
exit_code, exit_code: *exit_code,
duration_seconds, duration_seconds,
}, },
}; };

View File

@@ -291,6 +291,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
stderr, stderr,
duration, duration,
exit_code, exit_code,
..
}) => { }) => {
let exec_command = self.call_id_to_command.remove(&call_id); let exec_command = self.call_id_to_command.remove(&call_id);
let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command

View File

@@ -674,6 +674,8 @@ pub struct ExecCommandEndEvent {
pub exit_code: i32, pub exit_code: i32,
/// The duration of the command execution. /// The duration of the command execution.
pub duration: Duration, pub duration: Duration,
/// Formatted output from the command, as seen by the model.
pub formatted_output: String,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]

View File

@@ -9,6 +9,8 @@ use codex_core::protocol::TokenUsage;
use codex_file_search::FileMatch; use codex_file_search::FileMatch;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
@@ -95,8 +97,31 @@ impl BottomPane {
} else { } else {
self.composer.desired_height(width) 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)
}
view_height.saturating_add(Self::BOTTOM_PAD_LINES) fn layout(&self, area: Rect) -> Rect {
let top = if self.active_view.is_none() || self.status_view_active {
1
} else {
0
};
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)> { pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
@@ -104,10 +129,11 @@ impl BottomPane {
// status indicator shown while a task is running, or approval modal). // status indicator shown while a task is running, or approval modal).
// In these states the textarea is not interactable, so we should not // In these states the textarea is not interactable, so we should not
// show its caret. // show its caret.
if self.active_view.is_some() { if self.active_view.is_some() || self.status_view_active {
None None
} else { } else {
self.composer.cursor_pos(area) let content = self.layout(area);
self.composer.cursor_pos(content)
} }
} }
@@ -365,31 +391,12 @@ impl BottomPane {
impl WidgetRef for &BottomPane { impl WidgetRef for &BottomPane {
fn render_ref(&self, area: Rect, buf: &mut Buffer) { fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let content = self.layout(area);
if let Some(view) = &self.active_view { if let Some(view) = &self.active_view {
// Reserve bottom padding lines; keep at least 1 line for the view. view.render(content, buf);
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);
}
} else { } else {
let avail = area.height; (&self.composer).render_ref(content, buf);
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);
}
} }
} }
} }
@@ -495,13 +502,13 @@ mod tests {
let area = Rect::new(0, 0, 40, 3); let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf); (&pane).render_ref(area, &mut buf);
let mut row0 = String::new(); let mut row1 = String::new();
for x in 0..area.width { 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!( assert!(
row0.contains("Working"), row1.contains("Working"),
"expected Working header after denial: {row0:?}" "expected Working header after denial on row 1: {row1:?}"
); );
// Drain the channel to avoid unused warnings. // Drain the channel to avoid unused warnings.
@@ -523,14 +530,13 @@ mod tests {
// Begin a task: show initial status. // Begin a task: show initial status.
pane.set_task_running(true); pane.set_task_running(true);
// Render and confirm the line contains the "Working" header.
let area = Rect::new(0, 0, 40, 3); let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf); (&pane).render_ref(area, &mut buf);
let mut row0 = String::new(); let mut row0 = String::new();
for x in 0..area.width { 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!( assert!(
row0.contains("Working"), row0.contains("Working"),
@@ -563,12 +569,12 @@ mod tests {
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);
(&pane).render_ref(area, &mut buf); (&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(); let mut top = String::new();
for x in 0..area.width { 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!( assert!(
top.contains("Working"), top.contains("Working"),
"expected Working header on top row: {top:?}" "expected Working header on top row: {top:?}"
@@ -605,7 +611,7 @@ mod tests {
pane.set_task_running(true); 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 area2 = Rect::new(0, 0, 20, 2);
let mut buf2 = Buffer::empty(area2); let mut buf2 = Buffer::empty(area2);
(&pane).render_ref(area2, &mut buf2); (&pane).render_ref(area2, &mut buf2);
@@ -615,13 +621,10 @@ mod tests {
row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' ')); row0.push(buf2[(x, 0)].symbol().chars().next().unwrap_or(' '));
row1.push(buf2[(x, 1)].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!( assert!(
row0.contains("Working"), row1.contains("Working"),
"expected Working header on row 0: {row0:?}" "expected Working on row 1: {row1:?}"
);
assert!(
row1.trim().is_empty(),
"expected bottom padding on row 1: {row1:?}"
); );
// Height=1 → no padding; single row is the spinner. // Height=1 → no padding; single row is the spinner.

View File

@@ -105,6 +105,7 @@ pub(crate) struct ChatWidget {
full_reasoning_buffer: String, full_reasoning_buffer: String,
session_id: Option<Uuid>, session_id: Option<Uuid>,
frame_requester: FrameRequester, frame_requester: FrameRequester,
last_history_was_exec: bool,
} }
struct UserMessage { struct UserMessage {
@@ -376,6 +377,9 @@ impl ChatWidget {
self.bottom_pane.set_task_running(false); self.bottom_pane.set_task_running(false);
self.task_complete_pending = 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(); self.flush_interrupt_queue();
} }
} }
@@ -401,6 +405,7 @@ impl ChatWidget {
exit_code: ev.exit_code, exit_code: ev.exit_code,
stdout: ev.stdout.clone(), stdout: ev.stdout.clone(),
stderr: ev.stderr.clone(), stderr: ev.stderr.clone(),
formatted_output: ev.formatted_output.clone(),
}, },
)); ));
@@ -408,9 +413,16 @@ impl ChatWidget {
self.active_exec_cell = None; self.active_exec_cell = None;
let pending = std::mem::take(&mut self.pending_exec_completions); let pending = std::mem::take(&mut self.pending_exec_completions);
for (command, parsed, output) in pending { for (command, parsed, output) in pending {
self.add_to_history(history_cell::new_completed_exec_command( let include_header = !self.last_history_was_exec;
command, parsed, output, 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;
} }
} }
} }
@@ -473,9 +485,11 @@ impl ChatWidget {
exec.parsed.extend(ev.parsed_cmd); 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( self.active_exec_cell = Some(history_cell::new_active_exec_command(
ev.command, ev.command,
ev.parsed_cmd, ev.parsed_cmd,
include_header,
)); ));
} }
} }
@@ -565,6 +579,7 @@ impl ChatWidget {
reasoning_buffer: String::new(), reasoning_buffer: String::new(),
full_reasoning_buffer: String::new(), full_reasoning_buffer: String::new(),
session_id: None, session_id: None,
last_history_was_exec: false,
} }
} }
@@ -713,13 +728,19 @@ impl ChatWidget {
fn flush_active_exec_cell(&mut self) { fn flush_active_exec_cell(&mut self) {
if let Some(active) = self.active_exec_cell.take() { if let Some(active) = self.active_exec_cell.take() {
self.last_history_was_exec = true;
self.app_event_tx self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(active))); .send(AppEvent::InsertHistoryCell(Box::new(active)));
} }
} }
fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { 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(); self.flush_active_exec_cell();
if has_display_lines {
self.last_history_was_exec = false;
}
self.app_event_tx self.app_event_tx
.send(AppEvent::InsertHistoryCell(Box::new(cell))); .send(AppEvent::InsertHistoryCell(Box::new(cell)));
} }

View File

@@ -44,6 +44,31 @@ fn test_config() -> Config {
.expect("config") .expect("config")
} }
// Backward-compat shim for older session logs that predate the
// `formatted_output` field on ExecCommandEnd events.
fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json::Value {
if let Some(obj) = payload.as_object_mut()
&& let Some(msg) = obj.get_mut("msg")
&& let Some(m) = msg.as_object_mut()
{
let ty = m.get("type").and_then(|v| v.as_str()).unwrap_or("");
if ty == "exec_command_end" && !m.contains_key("formatted_output") {
let stdout = m.get("stdout").and_then(|v| v.as_str()).unwrap_or("");
let stderr = m.get("stderr").and_then(|v| v.as_str()).unwrap_or("");
let formatted = if stderr.is_empty() {
stdout.to_string()
} else {
format!("{stdout}{stderr}")
};
m.insert(
"formatted_output".to_string(),
serde_json::Value::String(formatted),
);
}
}
payload
}
#[test] #[test]
fn final_answer_without_newline_is_flushed_immediately() { fn final_answer_without_newline_is_flushed_immediately() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
@@ -157,6 +182,7 @@ fn make_chatwidget_manual() -> (
full_reasoning_buffer: String::new(), full_reasoning_buffer: String::new(),
session_id: None, session_id: None,
frame_requester: crate::tui::FrameRequester::test_dummy(), frame_requester: crate::tui::FrameRequester::test_dummy(),
last_history_was_exec: false,
}; };
(widget, rx, op_rx) (widget, rx, op_rx)
} }
@@ -239,6 +265,7 @@ fn exec_history_cell_shows_working_then_completed() {
stderr: String::new(), stderr: String::new(),
exit_code: 0, exit_code: 0,
duration: std::time::Duration::from_millis(5), duration: std::time::Duration::from_millis(5),
formatted_output: "done".into(),
}), }),
}); });
@@ -250,8 +277,12 @@ fn exec_history_cell_shows_working_then_completed() {
); );
let blob = lines_to_single_string(&cells[0]); let blob = lines_to_single_string(&cells[0]);
assert!( assert!(
blob.contains("Completed"), blob.contains('✓'),
"expected completed exec cell to show Completed header: {blob:?}" "expected completed exec cell to show success marker: {blob:?}"
);
assert!(
blob.contains("echo done"),
"expected command text to be present: {blob:?}"
); );
} }
@@ -284,6 +315,7 @@ fn exec_history_cell_shows_working_then_failed() {
stderr: "error".into(), stderr: "error".into(),
exit_code: 2, exit_code: 2,
duration: std::time::Duration::from_millis(7), duration: std::time::Duration::from_millis(7),
formatted_output: "".into(),
}), }),
}); });
@@ -295,9 +327,80 @@ fn exec_history_cell_shows_working_then_failed() {
); );
let blob = lines_to_single_string(&cells[0]); let blob = lines_to_single_string(&cells[0]);
assert!( assert!(
blob.contains("Failed (exit 2)"), blob.contains('✗'),
"expected completed exec cell to show Failed header with exit code: {blob:?}" "expected failure marker present: {blob:?}"
); );
assert!(
blob.contains("false"),
"expected command text present: {blob:?}"
);
}
#[test]
fn exec_history_extends_previous_when_consecutive() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// First command
chat.handle_codex_event(Event {
id: "call-a".into(),
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: "call-a".into(),
command: vec!["bash".into(), "-lc".into(), "echo one".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
parsed_cmd: vec![
codex_core::parse_command::ParsedCommand::Unknown {
cmd: "echo one".into(),
}
.into(),
],
}),
});
chat.handle_codex_event(Event {
id: "call-a".into(),
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "call-a".into(),
stdout: "one".into(),
stderr: String::new(),
exit_code: 0,
duration: std::time::Duration::from_millis(5),
formatted_output: "one".into(),
}),
});
let first_cells = drain_insert_history(&mut rx);
assert_eq!(first_cells.len(), 1, "first exec should insert history");
// Second command
chat.handle_codex_event(Event {
id: "call-b".into(),
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: "call-b".into(),
command: vec!["bash".into(), "-lc".into(), "echo two".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
parsed_cmd: vec![
codex_core::parse_command::ParsedCommand::Unknown {
cmd: "echo two".into(),
}
.into(),
],
}),
});
chat.handle_codex_event(Event {
id: "call-b".into(),
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "call-b".into(),
stdout: "two".into(),
stderr: String::new(),
exit_code: 0,
duration: std::time::Duration::from_millis(5),
formatted_output: "two".into(),
}),
});
let second_cells = drain_insert_history(&mut rx);
assert_eq!(second_cells.len(), 1, "second exec should extend history");
let first_blob = lines_to_single_string(&first_cells[0]);
let second_blob = lines_to_single_string(&second_cells[0]);
assert!(first_blob.contains('✓'));
assert!(second_blob.contains("echo two"));
} }
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
@@ -340,7 +443,9 @@ async fn binary_size_transcript_matches_ideal_fixture() {
match kind { match kind {
"codex_event" => { "codex_event" => {
if let Some(payload) = v.get("payload") { if let Some(payload) = v.get("payload") {
let ev: Event = serde_json::from_value(payload.clone()).expect("parse"); let ev: Event =
serde_json::from_value(upgrade_event_payload_for_tests(payload.clone()))
.expect("parse");
chat.handle_codex_event(ev); chat.handle_codex_event(ev);
while let Ok(app_ev) = rx.try_recv() { while let Ok(app_ev) = rx.try_recv() {
match app_ev { match app_ev {

View File

@@ -9,13 +9,7 @@ pub(crate) fn escape_command(command: &[String]) -> String {
pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String {
match command { match command {
// exactly three items [first, second, third] if first == "bash" && second == "-lc" => third.clone(),
[first, second, third]
// first two must be "bash", "-lc"
if first == "bash" && second == "-lc" =>
{
third.clone() // borrow `third`
}
_ => escape_command(command), _ => escape_command(command),
} }
} }

View File

@@ -29,10 +29,10 @@ use ratatui::prelude::*;
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::style::Modifier; use ratatui::style::Modifier;
use ratatui::style::Style; use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap; use ratatui::widgets::Wrap;
use shlex::try_join as shlex_try_join;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Cursor; use std::io::Cursor;
use std::path::PathBuf; use std::path::PathBuf;
@@ -46,6 +46,7 @@ pub(crate) struct CommandOutput {
pub(crate) exit_code: i32, pub(crate) exit_code: i32,
pub(crate) stdout: String, pub(crate) stdout: String,
pub(crate) stderr: String, pub(crate) stderr: String,
pub(crate) formatted_output: String,
} }
pub(crate) enum PatchEventType { pub(crate) enum PatchEventType {
@@ -104,6 +105,8 @@ pub(crate) struct ExecCell {
pub(crate) parsed: Vec<ParsedCommand>, pub(crate) parsed: Vec<ParsedCommand>,
pub(crate) output: Option<CommandOutput>, pub(crate) output: Option<CommandOutput>,
start_time: Option<Instant>, start_time: Option<Instant>,
duration: Option<Duration>,
include_header: bool,
} }
impl HistoryCell for ExecCell { impl HistoryCell for ExecCell {
fn display_lines(&self) -> Vec<Line<'static>> { fn display_lines(&self) -> Vec<Line<'static>> {
@@ -112,15 +115,63 @@ impl HistoryCell for ExecCell {
&self.parsed, &self.parsed,
self.output.as_ref(), self.output.as_ref(),
self.start_time, self.start_time,
self.include_header,
) )
} }
fn transcript_lines(&self) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec!["".into()];
let cmd_display = strip_bash_lc_and_escape(&self.command);
for (i, part) in cmd_display.lines().enumerate() {
if i == 0 {
lines.push(Line::from(vec!["$ ".magenta(), part.to_string().into()]));
} else {
lines.push(Line::from(vec![" ".into(), part.to_string().into()]));
}
}
// Command output: include full stdout and stderr (no truncation)
if let Some(output) = self.output.as_ref() {
lines.extend(output.formatted_output.lines().map(ansi_escape_line));
}
if let Some(output) = self.output.as_ref() {
let duration = self
.duration
.map(format_duration)
.unwrap_or_else(|| "unknown".to_string());
let mut result = if output.exit_code == 0 {
Line::from("".green().bold())
} else {
Line::from(vec![
"".red().bold(),
format!(" ({})", output.exit_code).into(),
])
};
result.push_span(format!("{duration}").dim());
lines.push(result);
}
lines
}
} }
impl WidgetRef for &ExecCell { impl WidgetRef for &ExecCell {
fn render_ref(&self, area: Rect, buf: &mut Buffer) { fn render_ref(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let content_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height,
};
Paragraph::new(Text::from(self.display_lines())) Paragraph::new(Text::from(self.display_lines()))
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.render(area, buf); .render(content_area, buf);
} }
} }
@@ -131,8 +182,8 @@ struct CompletedMcpToolCallWithImageOutput {
impl HistoryCell for CompletedMcpToolCallWithImageOutput { impl HistoryCell for CompletedMcpToolCallWithImageOutput {
fn display_lines(&self) -> Vec<Line<'static>> { fn display_lines(&self) -> Vec<Line<'static>> {
vec![ vec![
Line::from("tool result (image output omitted)"),
Line::from(""), Line::from(""),
Line::from("tool result (image output omitted)"),
] ]
} }
} }
@@ -179,6 +230,7 @@ pub(crate) fn new_session_info(
}; };
let lines: Vec<Line<'static>> = vec![ let lines: Vec<Line<'static>> = vec![
Line::from(Span::from("")),
Line::from(vec![ Line::from(vec![
Span::raw(">_ ").dim(), Span::raw(">_ ").dim(),
Span::styled( Span::styled(
@@ -194,17 +246,16 @@ pub(crate) fn new_session_info(
Line::from(format!(" /status - {}", SlashCommand::Status.description()).dim()), Line::from(format!(" /status - {}", SlashCommand::Status.description()).dim()),
Line::from(format!(" /approvals - {}", SlashCommand::Approvals.description()).dim()), Line::from(format!(" /approvals - {}", SlashCommand::Approvals.description()).dim()),
Line::from(format!(" /model - {}", SlashCommand::Model.description()).dim()), Line::from(format!(" /model - {}", SlashCommand::Model.description()).dim()),
Line::from("".dim()),
]; ];
PlainHistoryCell { lines } PlainHistoryCell { lines }
} else if config.model == model { } else if config.model == model {
PlainHistoryCell { lines: Vec::new() } PlainHistoryCell { lines: Vec::new() }
} else { } else {
let lines = vec![ let lines = vec![
Line::from(""),
Line::from("model changed:".magenta().bold()), Line::from("model changed:".magenta().bold()),
Line::from(format!("requested: {}", config.model)), Line::from(format!("requested: {}", config.model)),
Line::from(format!("used: {model}")), Line::from(format!("used: {model}")),
Line::from(""),
]; ];
PlainHistoryCell { lines } PlainHistoryCell { lines }
} }
@@ -212,9 +263,9 @@ pub(crate) fn new_session_info(
pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell { pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from("user".cyan().bold())); lines.push(Line::from("user".cyan().bold()));
lines.extend(message.lines().map(|l| Line::from(l.to_string()))); lines.extend(message.lines().map(|l| Line::from(l.to_string())));
lines.push(Line::from(""));
PlainHistoryCell { lines } PlainHistoryCell { lines }
} }
@@ -222,12 +273,15 @@ pub(crate) fn new_user_prompt(message: String) -> PlainHistoryCell {
pub(crate) fn new_active_exec_command( pub(crate) fn new_active_exec_command(
command: Vec<String>, command: Vec<String>,
parsed: Vec<ParsedCommand>, parsed: Vec<ParsedCommand>,
include_header: bool,
) -> ExecCell { ) -> ExecCell {
ExecCell { ExecCell {
command, command,
parsed, parsed,
output: None, output: None,
start_time: Some(Instant::now()), start_time: Some(Instant::now()),
duration: None,
include_header,
} }
} }
@@ -235,76 +289,61 @@ pub(crate) fn new_completed_exec_command(
command: Vec<String>, command: Vec<String>,
parsed: Vec<ParsedCommand>, parsed: Vec<ParsedCommand>,
output: CommandOutput, output: CommandOutput,
include_header: bool,
duration: Duration,
) -> ExecCell { ) -> ExecCell {
ExecCell { ExecCell {
command, command,
parsed, parsed,
output: Some(output), output: Some(output),
start_time: None, start_time: None,
duration: Some(duration),
include_header,
} }
} }
fn exec_duration(start: Instant) -> String {
format!("{}s", start.elapsed().as_secs())
}
fn exec_command_lines( fn exec_command_lines(
command: &[String], command: &[String],
parsed: &[ParsedCommand], parsed: &[ParsedCommand],
output: Option<&CommandOutput>, output: Option<&CommandOutput>,
start_time: Option<Instant>, start_time: Option<Instant>,
include_header: bool,
) -> Vec<Line<'static>> { ) -> Vec<Line<'static>> {
match parsed.is_empty() { match parsed.is_empty() {
true => new_exec_command_generic(command, output, start_time), true => new_exec_command_generic(command, output, start_time, include_header),
false => new_parsed_command(command, parsed, output, start_time), false => new_parsed_command(command, parsed, output, start_time, include_header),
} }
} }
fn new_parsed_command( fn new_parsed_command(
command: &[String], _command: &[String],
parsed_commands: &[ParsedCommand], parsed_commands: &[ParsedCommand],
output: Option<&CommandOutput>, output: Option<&CommandOutput>,
start_time: Option<Instant>, start_time: Option<Instant>,
include_header: bool,
) -> Vec<Line<'static>> { ) -> Vec<Line<'static>> {
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
match output { // Leading spacer and header line above command list
None => { if include_header {
let mut spans = vec!["⚙︎ Working".magenta().bold()]; lines.push(Line::from(""));
if let Some(st) = start_time { lines.push(Line::from(">_".magenta()));
let dur = exec_duration(st);
spans.push(format!("{dur}").dim());
}
lines.push(Line::from(spans));
}
Some(o) if o.exit_code == 0 => {
lines.push(Line::from(vec!["".green(), " Completed".into()]));
}
Some(o) => {
lines.push(Line::from(vec![
"".red(),
format!(" Failed (exit {})", o.exit_code).into(),
]));
}
};
// Optionally include the complete, unaltered command from the model.
if std::env::var("SHOW_FULL_COMMANDS")
.map(|v| !v.is_empty())
.unwrap_or(false)
{
let full_cmd = shlex_try_join(command.iter().map(|s| s.as_str()))
.unwrap_or_else(|_| command.join(" "));
lines.push(Line::from(vec![
Span::styled("", Style::default().add_modifier(Modifier::DIM)),
Span::styled(
full_cmd,
Style::default()
.add_modifier(Modifier::DIM)
.add_modifier(Modifier::ITALIC),
),
]));
} }
for (i, parsed) in parsed_commands.iter().enumerate() { // Determine the leading status marker: spinner while running, ✓ on success, ✗ on failure.
let status_marker: Span<'static> = match output {
None => {
// Animated braille spinner choose frame based on elapsed time.
const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let idx = start_time
.map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len())
.unwrap_or(0);
let ch = FRAMES[idx];
Span::raw(format!("{ch}"))
}
Some(o) if o.exit_code == 0 => Span::styled("", Style::default().fg(Color::Green)),
Some(_) => Span::styled("", Style::default().fg(Color::Red)),
};
for parsed in parsed_commands.iter() {
let text = match parsed { let text = match parsed {
ParsedCommand::Read { name, .. } => format!("📖 {name}"), ParsedCommand::Read { name, .. } => format!("📖 {name}"),
ParsedCommand::ListFiles { cmd, path } => match path { ParsedCommand::ListFiles { cmd, path } => match path {
@@ -323,19 +362,25 @@ fn new_parsed_command(
ParsedCommand::Unknown { cmd } => format!("⌨️ {cmd}"), ParsedCommand::Unknown { cmd } => format!("⌨️ {cmd}"),
ParsedCommand::Noop { cmd } => format!("🔄 {cmd}"), ParsedCommand::Noop { cmd } => format!("🔄 {cmd}"),
}; };
// Prefix: two spaces, marker, space. Continuations align under the text block.
let first_prefix = if i == 0 { "" } else { " " };
for (j, line_text) in text.lines().enumerate() { for (j, line_text) in text.lines().enumerate() {
let prefix = if j == 0 { first_prefix } else { " " }; if j == 0 {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled(prefix, Style::default().add_modifier(Modifier::DIM)), " ".into(),
line_text.to_string().dim(), status_marker.clone(),
])); " ".into(),
line_text.to_string().light_blue(),
]));
} else {
lines.push(Line::from(vec![
" ".into(),
line_text.to_string().light_blue(),
]));
}
} }
} }
lines.extend(output_lines(output, true, false)); lines.extend(output_lines(output, true, false));
lines.push(Line::from(""));
lines lines
} }
@@ -344,29 +389,44 @@ fn new_exec_command_generic(
command: &[String], command: &[String],
output: Option<&CommandOutput>, output: Option<&CommandOutput>,
start_time: Option<Instant>, start_time: Option<Instant>,
include_header: bool,
) -> Vec<Line<'static>> { ) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
let command_escaped = strip_bash_lc_and_escape(command); // Leading spacer and header line above command list
let mut cmd_lines = command_escaped.lines(); if include_header {
if let Some(first) = cmd_lines.next() { lines.push(Line::from(""));
let mut spans: Vec<Span> = vec!["⚡ Running".magenta()]; lines.push(Line::from(">_".magenta()));
if let Some(st) = start_time {
let dur = exec_duration(st);
spans.push(format!("{dur}").dim());
}
spans.push(" ".into());
spans.push(first.to_string().into());
lines.push(Line::from(spans));
} else {
let mut spans: Vec<Span> = vec!["⚡ Running".magenta()];
if let Some(st) = start_time {
let dur = exec_duration(st);
spans.push(format!("{dur}").dim());
}
lines.push(Line::from(spans));
} }
for cont in cmd_lines { let command_escaped = strip_bash_lc_and_escape(command);
lines.push(Line::from(cont.to_string()));
// Determine marker: spinner while running, ✓/✗ when completed
let status_marker: Span<'static> = match output {
None => {
const FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let idx = start_time
.map(|st| ((st.elapsed().as_millis() / 100) as usize) % FRAMES.len())
.unwrap_or(0);
let ch = FRAMES[idx];
Span::raw(format!("{ch}"))
}
Some(o) if o.exit_code == 0 => Span::styled("", Style::default().fg(Color::Green)),
Some(_) => Span::styled("", Style::default().fg(Color::Red)),
};
for (i, line) in command_escaped.lines().enumerate() {
if i == 0 {
lines.push(Line::from(vec![
Span::raw(" "),
status_marker.clone(),
Span::raw(" "),
Span::raw(line.to_string()),
]));
} else {
lines.push(Line::from(vec![
Span::styled(" ", Style::default().add_modifier(Modifier::DIM)),
Span::raw(line.to_string()),
]));
}
} }
lines.extend(output_lines(output, false, true)); lines.extend(output_lines(output, false, true));
@@ -377,9 +437,9 @@ fn new_exec_command_generic(
pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistoryCell { pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistoryCell {
let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]); let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]);
let lines: Vec<Line> = vec![ let lines: Vec<Line> = vec![
Line::from(""),
title_line, title_line,
format_mcp_invocation(invocation.clone()), format_mcp_invocation(invocation.clone()),
Line::from(""),
]; ];
PlainHistoryCell { lines } PlainHistoryCell { lines }
@@ -489,8 +549,6 @@ pub(crate) fn new_completed_mcp_tool_call(
)); ));
} }
} }
lines.push(Line::from(""));
} }
Err(e) => { Err(e) => {
lines.push(Line::from(vec![ lines.push(Line::from(vec![
@@ -503,6 +561,8 @@ pub(crate) fn new_completed_mcp_tool_call(
} }
}; };
// Leading blank separator at the start of this cell
lines.insert(0, Line::from(""));
Box::new(PlainHistoryCell { lines }) Box::new(PlainHistoryCell { lines })
} }
@@ -512,6 +572,7 @@ pub(crate) fn new_status_output(
session_id: &Option<Uuid>, session_id: &Option<Uuid>,
) -> PlainHistoryCell { ) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from("/status".magenta())); lines.push(Line::from("/status".magenta()));
let config_entries = create_config_summary_entries(config); let config_entries = create_config_summary_entries(config);
@@ -596,8 +657,6 @@ pub(crate) fn new_status_output(
])); ]));
} }
lines.push(Line::from(""));
// 👤 Account (only if ChatGPT tokens exist), shown under the first block // 👤 Account (only if ChatGPT tokens exist), shown under the first block
let auth_file = get_auth_file(&config.codex_home); let auth_file = get_auth_file(&config.codex_home);
if let Ok(auth) = try_read_auth_json(&auth_file) if let Ok(auth) = try_read_auth_json(&auth_file)
@@ -688,13 +747,13 @@ pub(crate) fn new_status_output(
usage.blended_total().to_string().into(), usage.blended_total().to_string().into(),
])); ]));
lines.push(Line::from(""));
PlainHistoryCell { lines } PlainHistoryCell { lines }
} }
/// Render a summary of configured MCP servers from the current `Config`. /// Render a summary of configured MCP servers from the current `Config`.
pub(crate) fn empty_mcp_output() -> PlainHistoryCell { pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![ let lines: Vec<Line<'static>> = vec![
Line::from(""),
Line::from("/mcp".magenta()), Line::from("/mcp".magenta()),
Line::from(""), Line::from(""),
Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]), Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]),
@@ -709,7 +768,6 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
" to configure them.".into(), " to configure them.".into(),
]) ])
.style(Style::default().add_modifier(Modifier::DIM)), .style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
]; ];
PlainHistoryCell { lines } PlainHistoryCell { lines }
@@ -782,7 +840,7 @@ pub(crate) fn new_mcp_tools_output(
} }
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![vec!["🖐 ".red().bold(), message.into()].into(), "".into()]; let lines: Vec<Line<'static>> = vec!["".into(), vec!["🖐 ".red().bold(), message.into()].into()];
PlainHistoryCell { lines } PlainHistoryCell { lines }
} }
@@ -797,6 +855,8 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell {
let UpdatePlanArgs { explanation, plan } = update; let UpdatePlanArgs { explanation, plan } = update;
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
// Leading blank for separation
lines.push(Line::from(""));
// Header with progress summary // Header with progress summary
let total = plan.len(); let total = plan.len();
let completed = plan let completed = plan
@@ -887,8 +947,6 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell {
} }
} }
lines.push(Line::from(""));
PlainHistoryCell { lines } PlainHistoryCell { lines }
} }
@@ -908,16 +966,16 @@ pub(crate) fn new_patch_event(
auto_approved: false, auto_approved: false,
} => { } => {
let lines: Vec<Line<'static>> = vec![ let lines: Vec<Line<'static>> = vec![
Line::from("✏️ Applying patch".magenta().bold()),
Line::from(""), Line::from(""),
Line::from("✏️ Applying patch".magenta().bold()),
]; ];
return PlainHistoryCell { lines }; return PlainHistoryCell { lines };
} }
}; };
let mut lines: Vec<Line<'static>> = create_diff_summary(title, &changes, event_type); let mut lines: Vec<Line<'static>> = create_diff_summary(title, &changes, event_type);
// Add leading blank separator for the cell
lines.push(Line::from("")); lines.insert(0, Line::from(""));
PlainHistoryCell { lines } PlainHistoryCell { lines }
} }
@@ -934,14 +992,15 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
exit_code: 1, exit_code: 1,
stdout: String::new(), stdout: String::new(),
stderr, stderr,
formatted_output: String::new(),
}), }),
true, true,
true, true,
)); ));
} }
lines.push(Line::from("")); // Leading blank separator
lines.insert(0, Line::from(""));
PlainHistoryCell { lines } PlainHistoryCell { lines }
} }
@@ -988,9 +1047,8 @@ pub(crate) fn new_patch_apply_success(stdout: String) -> PlainHistoryCell {
lines.push(Line::from(format!("... +{remaining} lines")).dim()); lines.push(Line::from(format!("... +{remaining} lines")).dim());
} }
} }
// Leading blank separator
lines.push(Line::from("")); lines.insert(0, Line::from(""));
PlainHistoryCell { lines } PlainHistoryCell { lines }
} }
@@ -999,9 +1057,9 @@ pub(crate) fn new_reasoning_block(
config: &Config, config: &Config,
) -> TranscriptOnlyHistoryCell { ) -> TranscriptOnlyHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(""));
lines.push(Line::from("thinking".magenta().italic())); lines.push(Line::from("thinking".magenta().italic()));
append_markdown(&full_reasoning_buffer, &mut lines, config); append_markdown(&full_reasoning_buffer, &mut lines, config);
lines.push(Line::from(""));
TranscriptOnlyHistoryCell { lines } TranscriptOnlyHistoryCell { lines }
} }
@@ -1014,6 +1072,7 @@ fn output_lines(
exit_code, exit_code,
stdout, stdout,
stderr, stderr,
..
} = match output { } = match output {
Some(output) if only_err && output.exit_code == 0 => return vec![], Some(output) if only_err && output.exit_code == 0 => return vec![],
Some(output) => output, Some(output) => output,
@@ -1096,9 +1155,14 @@ mod tests {
let parsed = vec![ParsedCommand::Unknown { let parsed = vec![ParsedCommand::Unknown {
cmd: "printf 'foo\nbar'".to_string(), cmd: "printf 'foo\nbar'".to_string(),
}]; }];
let lines = exec_command_lines(&[], &parsed, None, None); let lines = exec_command_lines(&[], &parsed, None, None, true);
assert!(lines.len() >= 3); assert!(lines.len() >= 4);
assert_eq!(lines[1].spans[0].content, ""); // Leading spacer then header line
assert_eq!(lines[2].spans[0].content, " "); assert!(lines[0].spans.is_empty() || lines[0].spans[0].content.is_empty());
assert_eq!(lines[1].spans[0].content, ">_");
// First rendered command line starts with two-space + marker.
assert_eq!(lines[2].spans[0].content, " ");
// Continuation lines align under the text block.
assert_eq!(lines[3].spans[0].content, " ");
} }
} }

View File

@@ -34,12 +34,3 @@ pub fn is_blank_line_spaces_only(line: &Line<'_>) -> bool {
.iter() .iter()
.all(|s| s.content.is_empty() || s.content.chars().all(|c| c == ' ')) .all(|s| s.content.is_empty() || s.content.chars().all(|c| c == ' '))
} }
/// Consider a line blank if its spans are empty or all span contents are
/// whitespace when trimmed.
pub fn is_blank_line_trim(line: &Line<'_>) -> bool {
if line.spans.is_empty() {
return true;
}
line.spans.iter().all(|s| s.content.trim().is_empty())
}

View File

@@ -70,17 +70,6 @@ impl StreamController {
self.header.maybe_emit(out_lines) self.header.maybe_emit(out_lines)
} }
#[inline]
fn ensure_single_trailing_blank(lines: &mut Lines) {
if lines
.last()
.map(|l| !crate::render::line_utils::is_blank_line_trim(l))
.unwrap_or(true)
{
lines.push(Line::from(""));
}
}
/// Begin an answer stream. Does not emit header yet; it is emitted on first commit. /// Begin an answer stream. Does not emit header yet; it is emitted on first commit.
pub(crate) fn begin(&mut self, _sink: &impl HistorySink) { pub(crate) fn begin(&mut self, _sink: &impl HistorySink) {
// Starting a new stream cancels any pending finish-from-previous-stream animation. // Starting a new stream cancels any pending finish-from-previous-stream animation.
@@ -138,7 +127,6 @@ impl StreamController {
let mut lines_with_header: Lines = Vec::new(); let mut lines_with_header: Lines = Vec::new();
self.emit_header_if_needed(&mut lines_with_header); self.emit_header_if_needed(&mut lines_with_header);
lines_with_header.extend(out_lines); lines_with_header.extend(out_lines);
Self::ensure_single_trailing_blank(&mut lines_with_header);
sink.insert_history(lines_with_header); sink.insert_history(lines_with_header);
} }

View File

@@ -64,6 +64,8 @@ impl HeaderEmitter {
pub(crate) fn maybe_emit(&mut self, out_lines: &mut Vec<ratatui::text::Line<'static>>) -> bool { 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 { if !self.emitted_in_stream && !self.emitted_this_turn {
// Add a leading blank line before the header for visual spacing
out_lines.push(ratatui::text::Line::from(""));
out_lines.push(render_header_line()); out_lines.push(render_header_line());
self.emitted_in_stream = true; self.emitted_in_stream = true;
self.emitted_this_turn = true; self.emitted_this_turn = true;

View File

@@ -258,7 +258,7 @@ impl UserApprovalWidget {
} }
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) { fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = vec![Line::from("")];
match &self.approval_request { match &self.approval_request {
ApprovalRequest::Exec { command, .. } => { ApprovalRequest::Exec { command, .. } => {
let cmd = strip_bash_lc_and_escape(command); let cmd = strip_bash_lc_and_escape(command);
@@ -327,7 +327,6 @@ impl UserApprovalWidget {
lines.push(Line::from(l.to_string())); lines.push(Line::from(l.to_string()));
} }
} }
lines.push(Line::from(""));
self.app_event_tx.send(AppEvent::InsertHistoryLines(lines)); self.app_event_tx.send(AppEvent::InsertHistoryLines(lines));
let op = match &self.approval_request { let op = match &self.approval_request {

View File

@@ -9,7 +9,8 @@ codex
Im going to scan the workspace and Cargo manifests to see build profiles and Im going to scan the workspace and Cargo manifests to see build profiles and
dependencies that impact binary size. Then Ill summarize the main causes. dependencies that impact binary size. Then Ill summarize the main causes.
⚡ Running ls -la >_
✓ ls -la
└ total 6696 └ total 6696
drwxr-xr-x@ 39 easong staff 1248 Aug 9 08:49 . drwxr-xr-x@ 39 easong staff 1248 Aug 9 08:49 .
drwxr-xr-x@ 29 easong staff 928 Aug 9 08:50 .. drwxr-xr-x@ 29 easong staff 928 Aug 9 08:50 ..
@@ -21,7 +22,7 @@ dependencies that impact binary size. Then Ill summarize the main causes.
drwxr-xr-x@ 6 easong staff 192 Aug 7 19:20 tui drwxr-xr-x@ 6 easong staff 192 Aug 7 19:20 tui
-rw-r--r--@ 1 easong staff 8639 Aug 6 23:30 typewriter.md -rw-r--r--@ 1 easong staff 8639 Aug 6 23:30 typewriter.md
-rw-r--r--@ 1 easong staff 7673 Aug 6 21:58 wrap-plan.md -rw-r--r--@ 1 easong staff 7673 Aug 6 21:58 wrap-plan.md
Running sed -n '1,200p' Cargo.toml sed -n '1,200p' Cargo.toml
└ [workspace] └ [workspace]
members = [ members = [
"ansi-escape", "ansi-escape",
@@ -34,7 +35,7 @@ dependencies that impact binary size. Then Ill summarize the main causes.
# ratatui = { path = "../../ratatui" } # ratatui = { path = "../../ratatui" }
ratatui = { git = "https://github.com/nornagon/ratatui", branch = ratatui = { git = "https://github.com/nornagon/ratatui", branch =
"nornagon-v0.29.0-patch" } "nornagon-v0.29.0-patch" }
Running for d in ansi-escape apply-patch arg0 cli common core exec execpolicy 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 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 echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
└ --- ansi-escape/Cargo.toml └ --- ansi-escape/Cargo.toml
@@ -48,7 +49,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
"signal", "signal",
] } ] }
tracing = { version tracing = { version
Running sed -n '1,200p' execpolicy/Cargo.toml sed -n '1,200p' execpolicy/Cargo.toml
└ [package] └ [package]
name = "codex-execpolicy" name = "codex-execpolicy"
version = { workspace = true } version = { workspace = true }
@@ -60,7 +61,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
[dev-dependencies] [dev-dependencies]
tempfile = "3.13.0" tempfile = "3.13.0"
Running sed -n '1,200p' file-search/Cargo.toml sed -n '1,200p' file-search/Cargo.toml
└ [package] └ [package]
edition = "2024" edition = "2024"
name = "codex-file-search" name = "codex-file-search"
@@ -72,7 +73,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0.142" serde_json = "1.0.142"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
Running sed -n '1,200p' linux-sandbox/Cargo.toml sed -n '1,200p' linux-sandbox/Cargo.toml
└ [package] └ [package]
edition = "2024" edition = "2024"
name = "codex-linux-sandbox" name = "codex-linux-sandbox"
@@ -84,7 +85,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
"rt-multi-thread", "rt-multi-thread",
"signal", "signal",
] } ] }
Running sed -n '1,200p' login/Cargo.toml sed -n '1,200p' login/Cargo.toml
└ [package] └ [package]
edition = "2024" edition = "2024"
name = "codex-login" name = "codex-login"
@@ -96,7 +97,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
tempfile = "3" tempfile = "3"
Running sed -n '1,200p' mcp-client/Cargo.toml sed -n '1,200p' mcp-client/Cargo.toml
└ [package] └ [package]
name = "codex-mcp-client" name = "codex-mcp-client"
version = { workspace = true } version = { workspace = true }
@@ -108,7 +109,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
"sync", "sync",
"time", "time",
] } ] }
Running sed -n '1,200p' mcp-server/Cargo.toml sed -n '1,200p' mcp-server/Cargo.toml
└ [package] └ [package]
edition = "2024" edition = "2024"
name = "codex-mcp-server" name = "codex-mcp-server"
@@ -120,7 +121,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
tempfile = "3" tempfile = "3"
tokio-test = "0.4" tokio-test = "0.4"
wiremock = "0.6" wiremock = "0.6"
Running sed -n '1,200p' mcp-types/Cargo.toml sed -n '1,200p' mcp-types/Cargo.toml
└ [package] └ [package]
edition = "2024" edition = "2024"
name = "mcp-types" name = "mcp-types"
@@ -132,7 +133,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
[dependencies] [dependencies]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
Running sed -n '1,200p' ollama/Cargo.toml sed -n '1,200p' ollama/Cargo.toml
└ [package] └ [package]
edition = "2024" edition = "2024"
name = "codex-ollama" name = "codex-ollama"
@@ -144,7 +145,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
Running sed -n '1,200p' tui/Cargo.toml sed -n '1,200p' tui/Cargo.toml
└ [package] └ [package]
edition = "2024" edition = "2024"
name = "codex-tui" name = "codex-tui"
@@ -156,7 +157,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
pretty_assertions = "1" pretty_assertions = "1"
rand = "0.8" rand = "0.8"
vt100 = "0.16.2" vt100 = "0.16.2"
Running sed -n '1,200p' chatgpt/Cargo.toml sed -n '1,200p' chatgpt/Cargo.toml
└ [package] └ [package]
edition = "2024" edition = "2024"
name = "codex-chatgpt" name = "codex-chatgpt"
@@ -168,6 +169,7 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
codex codex
Heres whats driving size in this workspaces binaries. Heres whats driving size in this workspaces binaries.