Skip to content

Commit 841f59b

Browse files
committed
Fix background task UI and pending Esc handling
1 parent d50e5ea commit 841f59b

9 files changed

Lines changed: 149 additions & 26 deletions

File tree

codex-rs/tui/src/app.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1120,7 +1120,11 @@ See the Codex keymap documentation for supported actions and examples."
11201120
TuiEvent::Draw | TuiEvent::Resize => {
11211121
if self.backtrack_render_pending {
11221122
self.backtrack_render_pending = false;
1123-
self.render_transcript_once(tui);
1123+
if let Err(err) = self.reflow_transcript_now(tui) {
1124+
tracing::warn!(error = %err, "failed to reflow transcript after backtrack");
1125+
self.chat_widget
1126+
.add_error_message(format!("Failed to redraw transcript: {err}"));
1127+
}
11241128
}
11251129
self.chat_widget.maybe_post_pending_notification(tui);
11261130
if self

codex-rs/tui/src/app_backtrack.rs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -255,20 +255,6 @@ impl App {
255255
}
256256
}
257257

258-
/// Re-render the full transcript into the terminal scrollback in one call.
259-
/// Useful when switching sessions to ensure prior history remains visible.
260-
pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) {
261-
if !self.transcript_cells.is_empty() {
262-
let width = tui.terminal.last_known_screen_size.width;
263-
for cell in &self.transcript_cells {
264-
tui.insert_history_lines_with_wrap_policy(
265-
cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()),
266-
self.history_line_wrap_policy(),
267-
);
268-
}
269-
}
270-
}
271-
272258
/// Initialize backtrack state and show composer hint.
273259
fn prime_backtrack(&mut self) {
274260
self.backtrack.primed = true;

codex-rs/tui/src/bottom_pane/background_tasks_view.rs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ pub(crate) enum BackgroundTaskKind {
2929
pub(crate) struct BackgroundTaskItem {
3030
pub(crate) kind: BackgroundTaskKind,
3131
pub(crate) title: String,
32+
pub(crate) role: Option<String>,
33+
pub(crate) task: Option<String>,
34+
pub(crate) elapsed: Option<String>,
3235
pub(crate) detail: Vec<String>,
3336
pub(crate) status: Option<String>,
3437
pub(crate) output_lines: Vec<String>,
@@ -210,11 +213,17 @@ impl BackgroundTasksView {
210213
let mut lines = vec![
211214
"Shell details".bold().into(),
212215
status_line(item.status.as_deref().unwrap_or("running")),
213-
"".into(),
214-
label_value_line("Command", &item.title),
215-
"".into(),
216-
"Output".bold().into(),
217216
];
217+
if let Some(elapsed) = item.elapsed.as_deref() {
218+
lines.push(label_value_line("Running", elapsed));
219+
}
220+
lines.push("".into());
221+
lines.push(label_value_line("Command", &item.title));
222+
if let Some(task) = item.task.as_deref() {
223+
lines.push(label_value_line("Task", task));
224+
}
225+
lines.push("".into());
226+
lines.push("Output".bold().into());
218227

219228
if item.output_lines.is_empty() {
220229
lines.push(" No output yet.".italic().into());
@@ -246,9 +255,19 @@ impl BackgroundTasksView {
246255
"Agent details".bold().into(),
247256
label_value_line("Agent", &item.title),
248257
];
258+
if let Some(role) = item.role.as_deref() {
259+
lines.push(label_value_line("Role", role));
260+
}
249261
if let Some(status) = item.status.as_deref() {
250262
lines.push(status_line(status));
251263
}
264+
if let Some(elapsed) = item.elapsed.as_deref() {
265+
lines.push(label_value_line("Running", elapsed));
266+
}
267+
if let Some(task) = item.task.as_deref() {
268+
lines.push("".into());
269+
lines.push(label_value_line("Task", task));
270+
}
252271

253272
lines.push("".into());
254273
lines.push("Progress".bold().into());
@@ -364,7 +383,17 @@ fn append_section(
364383
title.push(": ".dim());
365384
title.push(status.to_string().dim());
366385
}
386+
if let Some(elapsed) = item.elapsed.as_deref() {
387+
title.push(" · ".dim());
388+
title.push(elapsed.to_string().dim());
389+
}
367390
lines.push(Line::from(title));
391+
if let Some(role) = item.role.as_deref() {
392+
lines.push(label_value_line(" Role", role));
393+
}
394+
if let Some(task) = item.task.as_deref() {
395+
lines.push(label_value_line(" Task", task));
396+
}
368397
for detail in item.detail.iter().take(2) {
369398
lines.push(Line::from(vec![" ↳ ".dim(), detail.clone().dim()]));
370399
}

codex-rs/tui/src/chatwidget.rs

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ use crate::slash_command::SlashCommand;
317317
use crate::status::RateLimitSnapshotDisplay;
318318
use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
319319
use crate::status_indicator_widget::StatusDetailsCapitalization;
320+
use crate::status_indicator_widget::fmt_elapsed_compact;
320321
use crate::text_formatting::truncate_text;
321322
use crate::tui::FrameRequester;
322323
mod background_agents;
@@ -398,6 +399,7 @@ struct UnifiedExecProcessSummary {
398399
key: String,
399400
call_id: String,
400401
command_display: String,
402+
started_at: Instant,
401403
recent_chunks: Vec<String>,
402404
output_lines: Vec<String>,
403405
}
@@ -1021,6 +1023,8 @@ pub(crate) struct ChatWidget {
10211023
#[derive(Debug)]
10221024
struct BackgroundActivity {
10231025
cell: Box<dyn HistoryCell>,
1026+
started_at: Instant,
1027+
task: Option<String>,
10241028
}
10251029

10261030
impl BackgroundActivity {
@@ -1047,6 +1051,10 @@ fn split_background_task_status(title: String) -> (String, Option<String>) {
10471051
(title, None)
10481052
}
10491053

1054+
fn background_elapsed_text(started_at: Instant) -> String {
1055+
fmt_elapsed_compact(started_at.elapsed().as_secs())
1056+
}
1057+
10501058
#[cfg_attr(not(test), allow(dead_code))]
10511059
enum CodexOpTarget {
10521060
Direct(UnboundedSender<AppCommand>),
@@ -3950,13 +3958,15 @@ impl ChatWidget {
39503958
{
39513959
existing.call_id = call_id.to_string();
39523960
existing.command_display = command_display;
3961+
existing.started_at = Instant::now();
39533962
existing.recent_chunks.clear();
39543963
existing.output_lines.clear();
39553964
} else {
39563965
self.unified_exec_processes.push(UnifiedExecProcessSummary {
39573966
key,
39583967
call_id: call_id.to_string(),
39593968
command_display,
3969+
started_at: Instant::now(),
39603970
recent_chunks: Vec::new(),
39613971
output_lines: Vec::new(),
39623972
});
@@ -6983,8 +6993,11 @@ impl ChatWidget {
69836993
fn finalize_active_cell_as_failed(&mut self) {
69846994
if let Some(cell) = self.active_cell.take() {
69856995
if cell.as_any().is::<multi_agents::CollabAgentActivityCell>() {
6986-
self.background_activities
6987-
.push_back(BackgroundActivity { cell });
6996+
self.background_activities.push_back(BackgroundActivity {
6997+
cell,
6998+
started_at: Instant::now(),
6999+
task: None,
7000+
});
69887001
} else {
69897002
self.finalize_boxed_cell_as_failed(cell);
69907003
}
@@ -7092,6 +7105,10 @@ impl ChatWidget {
70927105
user_message_preview_text(message, self.rejected_steer_history_records.get(idx))
70937106
})
70947107
.collect();
7108+
if !queued_messages.is_empty() || !pending_steers.is_empty() || !rejected_steers.is_empty()
7109+
{
7110+
self.clear_esc_backtrack_hint();
7111+
}
70957112
self.bottom_pane.set_pending_input_preview(
70967113
queued_messages,
70977114
pending_steers,
@@ -10534,8 +10551,11 @@ impl ChatWidget {
1053410551
}
1053510552

1053610553
if let Some(cell) = self.active_cell.take() {
10537-
self.background_activities
10538-
.push_back(BackgroundActivity { cell });
10554+
self.background_activities.push_back(BackgroundActivity {
10555+
cell,
10556+
started_at: Instant::now(),
10557+
task: None,
10558+
});
1053910559
self.bump_active_cell_revision();
1054010560
}
1054110561
self.task_backgrounded = true;
@@ -10580,11 +10600,18 @@ impl ChatWidget {
1058010600
.downcast_ref::<multi_agents::CollabAgentActivityCell>()
1058110601
{
1058210602
let (title, status) = split_background_task_status(title);
10603+
let task = activity
10604+
.task
10605+
.as_deref()
10606+
.map(|task| truncate_text(task, 240));
1058310607
params.subagents.push(BackgroundTaskItem {
1058410608
kind: BackgroundTaskKind::Subagent {
1058510609
thread_id: cell.thread_id(),
1058610610
},
1058710611
title,
10612+
role: cell.agent_role().map(str::to_string),
10613+
task,
10614+
elapsed: Some(background_elapsed_text(activity.started_at)),
1058810615
detail,
1058910616
status,
1059010617
output_lines: Vec::new(),
@@ -10593,6 +10620,9 @@ impl ChatWidget {
1059310620
params.terminals.push(BackgroundTaskItem {
1059410621
kind: BackgroundTaskKind::Terminal,
1059510622
title,
10623+
role: None,
10624+
task: None,
10625+
elapsed: Some(background_elapsed_text(activity.started_at)),
1059610626
detail,
1059710627
status: Some("running".to_string()),
1059810628
output_lines: Vec::new(),
@@ -10606,6 +10636,9 @@ impl ChatWidget {
1060610636
params.terminals.push(BackgroundTaskItem {
1060710637
kind: BackgroundTaskKind::Terminal,
1060810638
title: process.command_display.clone(),
10639+
role: None,
10640+
task: None,
10641+
elapsed: Some(background_elapsed_text(process.started_at)),
1060910642
detail,
1061010643
status: Some("running".to_string()),
1061110644
output_lines: process.output_lines.clone(),
@@ -10655,7 +10688,10 @@ impl ChatWidget {
1065510688
}
1065610689

1065710690
fn should_interrupt_before_submitting_pending_input(&self) -> bool {
10658-
self.is_cancellable_work_active() || self.user_turn_pending_start
10691+
self.agent_turn_running
10692+
|| self.mcp_startup_status.is_some()
10693+
|| self.is_review_mode
10694+
|| self.user_turn_pending_start
1065910695
}
1066010696

1066110697
/// Return the markdown body width available to an active stream.

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use codex_app_server_protocol::CollabAgentToolCallStatus;
88
use codex_app_server_protocol::ThreadItem;
99
use codex_protocol::ThreadId;
1010
use std::collections::HashSet;
11+
use std::time::Instant;
1112

1213
pub(super) fn sync_collab_agent_background_activity(chat: &mut ChatWidget, item: &ThreadItem) {
1314
let ThreadItem::CollabAgentToolCall {
@@ -37,15 +38,16 @@ pub(super) fn sync_collab_agent_background_activity(chat: &mut ChatWidget, item:
3738
if let Ok(parsed_thread_id) = ThreadId::from_string(thread_id) {
3839
seen.insert(parsed_thread_id);
3940
if let Some(state) = agents_states.get(thread_id) {
40-
changed |= sync_collab_agent_activity_state(chat, parsed_thread_id, state);
41+
changed |=
42+
sync_collab_agent_activity_state(chat, item, parsed_thread_id, state);
4143
}
4244
}
4345
}
4446
for (thread_id, state) in agents_states {
4547
if let Ok(parsed_thread_id) = ThreadId::from_string(thread_id)
4648
&& seen.insert(parsed_thread_id)
4749
{
48-
changed |= sync_collab_agent_activity_state(chat, parsed_thread_id, state);
50+
changed |= sync_collab_agent_activity_state(chat, item, parsed_thread_id, state);
4951
}
5052
}
5153
}
@@ -58,6 +60,7 @@ pub(super) fn sync_collab_agent_background_activity(chat: &mut ChatWidget, item:
5860

5961
fn sync_collab_agent_activity_state(
6062
chat: &mut ChatWidget,
63+
item: &ThreadItem,
6164
thread_id: ThreadId,
6265
state: &CollabAgentState,
6366
) -> bool {
@@ -99,6 +102,9 @@ fn sync_collab_agent_activity_state(
99102
&& cell.thread_id() == thread_id
100103
{
101104
cell.update(state, &metadata);
105+
if activity.task.is_none() {
106+
activity.task = prompt_for_thread(item, thread_id);
107+
}
102108
return true;
103109
}
104110
}
@@ -107,13 +113,35 @@ fn sync_collab_agent_activity_state(
107113
cell: Box::new(multi_agents::CollabAgentActivityCell::new(
108114
thread_id, state, &metadata,
109115
)),
116+
started_at: Instant::now(),
117+
task: prompt_for_thread(item, thread_id),
110118
});
111119
true
112120
} else {
113121
remove_collab_agent_background_activity(chat, thread_id)
114122
}
115123
}
116124

125+
fn prompt_for_thread(item: &ThreadItem, thread_id: ThreadId) -> Option<String> {
126+
let ThreadItem::CollabAgentToolCall {
127+
tool,
128+
prompt,
129+
receiver_thread_ids,
130+
..
131+
} = item
132+
else {
133+
return None;
134+
};
135+
if !matches!(tool, CollabAgentTool::SpawnAgent) {
136+
return None;
137+
}
138+
let targets_thread = receiver_thread_ids
139+
.iter()
140+
.filter_map(|id| ThreadId::from_string(id).ok())
141+
.any(|id| id == thread_id);
142+
if targets_thread { prompt.clone() } else { None }
143+
}
144+
117145
fn remove_collab_agent_background_activity(chat: &mut ChatWidget, thread_id: ThreadId) -> bool {
118146
if let Some(index) = chat.background_activities.iter().position(|activity| {
119147
activity

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ async fn down_uses_collab_agent_metadata_from_agent_state() {
148148

149149
let rendered = render_bottom_popup(&chat, /*width*/ 96);
150150
assert!(rendered.contains("Robie [explorer]: Running"));
151+
assert!(rendered.contains("Explore the repo"));
151152
assert!(
152153
!rendered.contains(&spawned_thread_id.to_string()),
153154
"background task title should prefer nickname over raw thread id: {rendered:?}"
@@ -223,6 +224,7 @@ async fn down_lists_spawned_agent_activity_without_submitting_core_op() {
223224
key: "proc-1".to_string(),
224225
call_id: "call-terminal".to_string(),
225226
command_display: "sleep 300".to_string(),
227+
started_at: std::time::Instant::now(),
226228
recent_chunks: vec!["still running".to_string()],
227229
output_lines: vec!["still running".to_string()],
228230
});
@@ -247,6 +249,7 @@ async fn down_lists_spawned_agent_activity_without_submitting_core_op() {
247249
assert!(rendered.contains("Background tasks"));
248250
assert!(rendered.contains("Robie [explorer]"));
249251
assert!(rendered.contains("Running"));
252+
assert!(rendered.contains("Explore the repo"));
250253
assert!(rendered.contains("Terminals"));
251254
assert!(rendered.contains("sleep 300"));
252255
assert!(rendered.contains("still running"));
@@ -308,7 +311,9 @@ async fn down_enter_subagent_opens_agent_detail_without_selecting_thread() {
308311
"Enter should open subagent detail: {rendered:?}"
309312
);
310313
assert!(rendered.contains("Robie [explorer]"));
314+
assert!(rendered.contains("Role: explorer"));
311315
assert!(rendered.contains("Running"));
316+
assert!(rendered.contains("Task: Explore the repo"));
312317
assert!(rendered.contains("Progress"));
313318
assert!(rendered.contains("Reading files"));
314319
}

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,31 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() {
915915
assert!(chat.submit_pending_steers_after_interrupt);
916916
}
917917

918+
#[tokio::test]
919+
async fn pending_steer_esc_interrupts_backgrounded_agent_turn() {
920+
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
921+
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
922+
923+
chat.on_task_started();
924+
chat.task_backgrounded = true;
925+
chat.update_task_running_state();
926+
chat.pending_steers.push_back(pending_steer("queued steer"));
927+
chat.refresh_pending_input_preview();
928+
929+
assert!(chat.agent_turn_running);
930+
assert!(chat.task_backgrounded);
931+
assert!(!chat.bottom_pane.is_task_running());
932+
933+
chat.handle_key_event(esc);
934+
935+
match op_rx.try_recv() {
936+
Ok(Op::Interrupt) => {}
937+
other => panic!("expected Op::Interrupt, got {other:?}"),
938+
}
939+
assert!(chat.submit_pending_steers_after_interrupt);
940+
assert_eq!(chat.pending_steers.len(), 1);
941+
}
942+
918943
#[tokio::test]
919944
async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
920945
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

0 commit comments

Comments
 (0)