Skip to content

Commit cb312df

Browse files
Update header from Working once batched commands are done (#2249)
Update commands from Working to Complete or Failed after they're done before: <img width="725" height="332" alt="image" src="https://github.com/user-attachments/assets/fb93d21f-5c4a-42bc-a154-14f4fe99d5f9" /> after: <img width="464" height="65" alt="image" src="https://github.com/user-attachments/assets/15ec7c3b-355f-473e-9a8e-eab359ec5f0d" />
1 parent 0159bc7 commit cb312df

File tree

3 files changed

+129
-7
lines changed

3 files changed

+129
-7
lines changed

codex-rs/tui/src/chatwidget.rs

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ pub(crate) struct ChatWidget<'a> {
7676
// Track the most recently active stream kind in the current turn
7777
last_stream_kind: Option<StreamKind>,
7878
running_commands: HashMap<String, RunningCommand>,
79+
pending_exec_completions: Vec<(Vec<String>, Vec<ParsedCommand>, CommandOutput)>,
7980
task_complete_pending: bool,
8081
// Queue of interruptive UI events deferred during an active write cycle
8182
interrupts: InterruptManager,
@@ -112,6 +113,10 @@ impl ChatWidget<'_> {
112113
fn mark_needs_redraw(&mut self) {
113114
self.needs_redraw = true;
114115
}
116+
fn flush_answer_stream_with_separator(&mut self) {
117+
let sink = AppEventHistorySink(self.app_event_tx.clone());
118+
let _ = self.stream.finalize(StreamKind::Answer, true, &sink);
119+
}
115120
// --- Small event handlers ---
116121
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
117122
self.bottom_pane
@@ -215,6 +220,7 @@ impl ChatWidget<'_> {
215220
}
216221

217222
fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) {
223+
self.flush_answer_stream_with_separator();
218224
let ev2 = ev.clone();
219225
self.defer_or_handle(|q| q.push_exec_begin(ev), |s| s.handle_exec_begin_now(ev2));
220226
}
@@ -344,12 +350,11 @@ impl ChatWidget<'_> {
344350

345351
pub(crate) fn handle_exec_end_now(&mut self, ev: ExecCommandEndEvent) {
346352
let running = self.running_commands.remove(&ev.call_id);
347-
self.active_exec_cell = None;
348353
let (command, parsed) = match running {
349354
Some(rc) => (rc.command, rc.parsed_cmd),
350355
None => (vec![ev.call_id.clone()], Vec::new()),
351356
};
352-
self.add_to_history(HistoryCell::new_completed_exec_command(
357+
self.pending_exec_completions.push((
353358
command,
354359
parsed,
355360
CommandOutput {
@@ -358,6 +363,16 @@ impl ChatWidget<'_> {
358363
stderr: ev.stderr.clone(),
359364
},
360365
));
366+
367+
if self.running_commands.is_empty() {
368+
self.active_exec_cell = None;
369+
let pending = std::mem::take(&mut self.pending_exec_completions);
370+
for (command, parsed, output) in pending {
371+
self.add_to_history(HistoryCell::new_completed_exec_command(
372+
command, parsed, output,
373+
));
374+
}
375+
}
361376
}
362377

363378
pub(crate) fn handle_patch_apply_end_now(
@@ -372,6 +387,7 @@ impl ChatWidget<'_> {
372387
}
373388

374389
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
390+
self.flush_answer_stream_with_separator();
375391
// Log a background summary immediately so the history is chronological.
376392
let cmdline = strip_bash_lc_and_escape(&ev.command);
377393
let text = format!(
@@ -398,6 +414,7 @@ impl ChatWidget<'_> {
398414
id: String,
399415
ev: ApplyPatchApprovalRequestEvent,
400416
) {
417+
self.flush_answer_stream_with_separator();
401418
self.add_to_history(HistoryCell::new_patch_event(
402419
PatchEventType::ApprovalRequest,
403420
ev.changes.clone(),
@@ -423,16 +440,29 @@ impl ChatWidget<'_> {
423440
parsed_cmd: ev.parsed_cmd.clone(),
424441
},
425442
);
426-
self.active_exec_cell = Some(HistoryCell::new_active_exec_command(
427-
ev.command,
428-
ev.parsed_cmd,
429-
));
443+
// Accumulate parsed commands into a single active Exec cell so they stack
444+
match self.active_exec_cell.as_mut() {
445+
Some(HistoryCell::Exec(exec)) => {
446+
exec.parsed.extend(ev.parsed_cmd);
447+
}
448+
_ => {
449+
self.active_exec_cell = Some(HistoryCell::new_active_exec_command(
450+
ev.command,
451+
ev.parsed_cmd,
452+
));
453+
}
454+
}
455+
456+
// Request a redraw so the working header and command list are visible immediately.
457+
self.mark_needs_redraw();
430458
}
431459

432460
pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
461+
self.flush_answer_stream_with_separator();
433462
self.add_to_history(HistoryCell::new_active_mcp_tool_call(ev.invocation));
434463
}
435464
pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
465+
self.flush_answer_stream_with_separator();
436466
self.add_to_history(HistoryCell::new_completed_mcp_tool_call(
437467
80,
438468
ev.invocation,
@@ -494,6 +524,7 @@ impl ChatWidget<'_> {
494524
stream: StreamController::new(config),
495525
last_stream_kind: None,
496526
running_commands: HashMap::new(),
527+
pending_exec_completions: Vec::new(),
497528
task_complete_pending: false,
498529
interrupts: InterruptManager::new(),
499530
needs_redraw: false,

codex-rs/tui/src/chatwidget/tests.rs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ use codex_core::protocol::AgentReasoningDeltaEvent;
1515
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
1616
use codex_core::protocol::Event;
1717
use codex_core::protocol::EventMsg;
18+
use codex_core::protocol::ExecCommandBeginEvent;
19+
use codex_core::protocol::ExecCommandEndEvent;
1820
use codex_core::protocol::FileChange;
1921
use codex_core::protocol::PatchApplyBeginEvent;
2022
use codex_core::protocol::PatchApplyEndEvent;
@@ -134,6 +136,7 @@ fn make_chatwidget_manual() -> (
134136
stream: StreamController::new(cfg),
135137
last_stream_kind: None,
136138
running_commands: HashMap::new(),
139+
pending_exec_completions: Vec::new(),
137140
task_complete_pending: false,
138141
interrupts: InterruptManager::new(),
139142
needs_redraw: false,
@@ -188,6 +191,90 @@ fn open_fixture(name: &str) -> std::fs::File {
188191
File::open(name).expect("open fixture file")
189192
}
190193

194+
#[test]
195+
fn exec_history_cell_shows_working_then_completed() {
196+
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
197+
198+
// Begin command
199+
chat.handle_codex_event(Event {
200+
id: "call-1".into(),
201+
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
202+
call_id: "call-1".into(),
203+
command: vec!["bash".into(), "-lc".into(), "echo done".into()],
204+
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
205+
parsed_cmd: vec![codex_core::parse_command::ParsedCommand::Unknown {
206+
cmd: vec!["echo".into(), "done".into()],
207+
}],
208+
}),
209+
});
210+
211+
// End command successfully
212+
chat.handle_codex_event(Event {
213+
id: "call-1".into(),
214+
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
215+
call_id: "call-1".into(),
216+
stdout: "done".into(),
217+
stderr: String::new(),
218+
exit_code: 0,
219+
duration: std::time::Duration::from_millis(5),
220+
}),
221+
});
222+
223+
let cells = drain_insert_history(&rx);
224+
assert_eq!(
225+
cells.len(),
226+
1,
227+
"expected only the completed exec cell to be inserted into history"
228+
);
229+
let blob = lines_to_single_string(&cells[0]);
230+
assert!(
231+
blob.contains("Completed"),
232+
"expected completed exec cell to show Completed header: {blob:?}"
233+
);
234+
}
235+
236+
#[test]
237+
fn exec_history_cell_shows_working_then_failed() {
238+
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
239+
240+
// Begin command
241+
chat.handle_codex_event(Event {
242+
id: "call-2".into(),
243+
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
244+
call_id: "call-2".into(),
245+
command: vec!["bash".into(), "-lc".into(), "false".into()],
246+
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
247+
parsed_cmd: vec![codex_core::parse_command::ParsedCommand::Unknown {
248+
cmd: vec!["false".into()],
249+
}],
250+
}),
251+
});
252+
253+
// End command with failure
254+
chat.handle_codex_event(Event {
255+
id: "call-2".into(),
256+
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
257+
call_id: "call-2".into(),
258+
stdout: String::new(),
259+
stderr: "error".into(),
260+
exit_code: 2,
261+
duration: std::time::Duration::from_millis(7),
262+
}),
263+
});
264+
265+
let cells = drain_insert_history(&rx);
266+
assert_eq!(
267+
cells.len(),
268+
1,
269+
"expected only the completed exec cell to be inserted into history"
270+
);
271+
let blob = lines_to_single_string(&cells[0]);
272+
assert!(
273+
blob.contains("Failed (exit 2)"),
274+
"expected completed exec cell to show Failed header with exit code: {blob:?}"
275+
);
276+
}
277+
191278
#[tokio::test(flavor = "current_thread")]
192279
async fn binary_size_transcript_matches_ideal_fixture() {
193280
let (mut chat, rx, _op_rx) = make_chatwidget_manual();

codex-rs/tui/src/history_cell.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,11 @@ impl HistoryCell {
342342
parsed_commands: &[ParsedCommand],
343343
output: Option<&CommandOutput>,
344344
) -> Vec<Line<'static>> {
345-
let mut lines: Vec<Line> = vec![Line::from("⚙︎ Working")];
345+
let mut lines: Vec<Line> = vec![match output {
346+
None => Line::from("⚙︎ Working".magenta().bold()),
347+
Some(o) if o.exit_code == 0 => Line::from("✓ Completed".green().bold()),
348+
Some(o) => Line::from(format!("✗ Failed (exit {})", o.exit_code).red().bold()),
349+
}];
346350

347351
for (i, parsed) in parsed_commands.iter().enumerate() {
348352
let text = match parsed {

0 commit comments

Comments
 (0)