Show short turn durations in TUI completion footers (#48807)

## Why

Completion footers hid known durations unless they exceeded 60 seconds, leaving short turns without elapsed-time information.

## What changed

Show all known durations for live and restored turns, rendering sub-second durations as `Worked for <1s`. Separate completion metadata with `•` instead of `·`.

## Testing

Update live completion, history replay, and separator tests to cover short durations, missing timestamps, and the revised footer format. Refresh affected TUI snapshots.

GitOrigin-RevId: 666e765db5426c5f9cc00d73f71cab649ad34947
This commit is contained in:
Felipe Coury
2026-09-27 23:02:36 +00:00
committed by copyberry
parent d9487a2930
commit 99f7758a57
21 changed files with 103 additions and 82 deletions
@@ -1,6 +1,5 @@
---
source: tui/src/app/turn_tips_tests.rs
assertion_line: 337
expression: "crate::chatwidget::tests::helpers::normalize_snapshot_paths(screens.join(\"\\n\\n\"))"
---
working=true, response rows=1, 80x12
@@ -31,7 +30,7 @@ working=false, response rows=1, 80x12
• Done.
└ Tip: Try /help.
[completion time]
Worked for [duration] • [completion time]
› Ask Codex to do anything
@@ -45,7 +44,7 @@ working=false, response rows=30, 80x12
• Done.
└ Tip: Try /help.
[completion time]
Worked for [duration] • [completion time]
› Ask Codex to do anything
@@ -56,7 +55,7 @@ working=false, response rows=30, 80x12
working=false, response rows=1, 80x9
• Done.
[completion time]
Worked for [duration] • [completion time]
› Ask Codex to do anything
@@ -68,7 +67,7 @@ working=false, response rows=1, 80x10
› Show a response.
• Done.
[completion time]
Worked for [duration] • [completion time]
› Ask Codex to do anything
@@ -81,7 +80,7 @@ working=false, response rows=1, 80x11
• Done.
└ Tip: Try /help.
[completion time]
Worked for [duration] • [completion time]
› Ask Codex to do anything
@@ -90,11 +89,11 @@ working=false, response rows=1, 80x11
? for shortcuts
working=false, response rows=1, 12x12
Response row
0
• Done.
Worked
for <1s •
[completion time]
@@ -1,11 +1,11 @@
---
source: tui/src/app/tests/pagination_completion_tests.rs
expression: "snapshots.join(\"\\n\\n--- next overlapping page ---\\n\\n\")"
expression: "afternoon_clock.replace_all(&snapshots.join(\"\\n\\n--- next overlapping page ---\\n\\n\"),\n\"at 2:$1 PM\",)"
---
Earlier messages are available — press ctrl+t to view the full transcript
• Middle answer
Worked for 2m 5s · Sep 6, 2000 at 2:33 PM
Worked for 2m 5s • Sep 6, 2000 at 2:33 PM
--- next overlapping page ---
@@ -15,7 +15,7 @@ Earlier messages are available — press ctrl+t to view the full transcript
• Oldest answer
Worked for 2m 5s · Sep 6, 2000 at 2:32 PM
Worked for 2m 5s • Sep 6, 2000 at 2:32 PM
› Middle prompt
@@ -23,4 +23,4 @@ Earlier messages are available — press ctrl+t to view the full transcript
• Middle answer
Worked for 2m 5s · Sep 6, 2000 at 2:33 PM
Worked for 2m 5s • Sep 6, 2000 at 2:33 PM
@@ -1,7 +1,8 @@
---
source: tui/src/app/tests/safety_buffering.rs
assertion_line: 960
expression: rendered_retry
---
› Handle the safety-buffered request
Keep the accepted steer
⚠ Full-history hydration is deprecated for paginated threads; omit `includeTurns` or set it to `false`, then page with `thread/turns/list` and `thread/items/list`.• done [completion time]
⚠ Full-history hydration is deprecated for paginated threads; omit `includeTurns` or set it to `false`, then page with `thread/turns/list` and `thread/items/list`.• done Worked for [duration] • [completion time]
+1 -1
View File
@@ -17,7 +17,7 @@ fn notification(method: &str, thread: ThreadId, turn: usize, status: &str) -> Se
"method": method,
"params": { "threadId": thread.to_string(), "turn": {
"id": turn.to_string(), "items": [], "itemsView": "full", "status": status,
"error": null, "startedAt": null, "completedAt": null, "durationMs": null,
"error": null, "startedAt": null, "completedAt": null, "durationMs": 500,
}},
}))
.unwrap()
+1 -2
View File
@@ -28,8 +28,7 @@ impl ChatWidget {
} else {
None
}
})
.filter(|seconds| *seconds > 60);
});
let completed_at = turn
.completed_at
.and_then(|timestamp| chrono::DateTime::from_timestamp(timestamp, /*nsecs*/ 0))
@@ -1,6 +1,7 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: "normalize_completion_timestamps(&\n(normalize_snapshot_paths(term.backend().vt100().screen().contents())))"
assertion_line: 5824
expression: normalize_snapshot_paths(term.backend().vt100().screen().contents())
---
@@ -50,4 +51,4 @@ expression: "normalize_completion_timestamps(&\n(normalize_snapshot_paths(term.b
"path": "C:\\Program Files\\App",
"regex": "^foo.*(bar)?$"
}
[completion time]
Worked for [duration] • [completion time]
@@ -1,7 +1,7 @@
---
source: tui/src/chatwidget/tests/completion_styling_tests.rs
expression: normalize_completion_timestamps(& (text))
expression: text
---
• The change is ready.
Worked for [duration] · [completion time]
Worked for [duration] • [completion time]
@@ -1,10 +1,10 @@
---
source: tui/src/chatwidget/tests/completion_styling_tests.rs
expression: normalize_completion_timestamps(& (text))
expression: text
---
• Ran echo ready
└ ready
• The change is ready.
Worked for [duration] · [completion time]
Worked for [duration] • [completion time]
@@ -1,10 +1,10 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
expression: normalize_completion_timestamps(& (combined))
expression: combined
---
• Ran echo preparing
└ preparing
• Final response.
Worked for [duration] · [completion time]
Worked for [duration] • [completion time]
@@ -1,8 +1,9 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
expression: "normalize_completion_timestamps(&\n(cells.iter().map(|cell|\nlines_to_single_string(cell).trim().to_string()).collect::<Vec<_>>().join(\"\\n\")))"
assertion_line: 2193
expression: "cells.iter().map(|cell|\nlines_to_single_string(cell).trim().to_string()).collect::<Vec<_>>().join(\"\\n\")"
---
[completion time]
Worked for [duration] • [completion time]
• Saved conversation to conversation.md
• Copied conversation to clipboard
■ Copy failed: clipboard unavailable
@@ -1,10 +1,11 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
expression: normalize_completion_timestamps(& (combined))
assertion_line: 1309
expression: combined
---
↳ Interacted with background terminal · just fix
└ pwd
• Waited for background terminal · just fix
[completion time]
Worked for [duration] • [completion time]
@@ -1,9 +1,10 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
expression: normalize_completion_timestamps(& (combined))
assertion_line: 1068
expression: combined
---
• Waited for background terminal · cargo test -p codex-core
• Final response.
[completion time]
Worked for [duration] • [completion time]
@@ -1,9 +1,10 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
expression: normalize_completion_timestamps(& (combined))
assertion_line: 1092
expression: combined
---
• Waited for background terminal · cargo test -p codex-core
• Streaming response.
[completion time]
Worked for [duration] • [completion time]
@@ -1,10 +1,11 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
assertion_line: 1229
expression: combined
---
Chat:
[completion time]
Worked for [duration] • [completion time]
Transcript:
• Waited for background terminal · just fix
[completion time]
Worked for [duration] • [completion time]
@@ -936,7 +936,10 @@ async fn live_app_server_turn_completed_clears_working_status_after_answer_item(
.iter()
.map(|lines| lines_to_single_string(lines).trim().to_string())
.collect::<Vec<_>>();
assert_eq!(completion_cells, vec!["[completion time]"]);
assert_eq!(
completion_cells,
vec!["Worked for [duration] • [completion time]"]
);
assert!(!chat.bottom_pane.is_task_running());
assert!(chat.bottom_pane.status_widget().is_none());
assert_eq!(
@@ -93,15 +93,15 @@ async fn completion_follows_plain_and_streamed_tool_answers() {
}
#[tokio::test]
async fn completion_live_applies_duration_threshold_and_preserves_timestamp_fallback() {
async fn completion_live_shows_known_durations_and_preserves_timestamp_fallback() {
for (duration_ms, completed_at, prefix) in [
(598, Some(COMPLETED_AT), ""),
(1_000, Some(COMPLETED_AT), ""),
(60_000, Some(COMPLETED_AT), ""),
(60_999, Some(COMPLETED_AT), ""),
(61_000, Some(COMPLETED_AT), "Worked for 1m 1s · "),
(125_000, Some(COMPLETED_AT), "Worked for 2m 5s · "),
(1_000, None, ""),
(598, Some(COMPLETED_AT), "Worked for <1s • "),
(1_000, Some(COMPLETED_AT), "Worked for 1s • "),
(60_000, Some(COMPLETED_AT), "Worked for 1m 0s • "),
(60_999, Some(COMPLETED_AT), "Worked for 1m 0s • "),
(61_000, Some(COMPLETED_AT), "Worked for 1m 1s • "),
(125_000, Some(COMPLETED_AT), "Worked for 2m 5s • "),
(1_000, None, "Worked for 1s • "),
] {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
handle_turn_started(&mut chat, "turn-1");
@@ -131,17 +131,26 @@ async fn completion_replay_preserves_metadata_and_input_without_live_side_effect
(
Some(125_000),
Some(COMPLETED_AT),
format!("Worked for 2m 5s · {done}"),
format!("Worked for 2m 5s • {done}"),
),
(None, None, String::new()),
(Some(598), Some(COMPLETED_AT), done.clone()),
(Some(60_000), Some(COMPLETED_AT), done.clone()),
(None, Some(COMPLETED_AT), done.clone()),
(
Some(598),
Some(COMPLETED_AT),
format!("Worked for <1s • {done}"),
),
(
Some(60_000),
Some(COMPLETED_AT),
format!("Worked for 1m 0s • {done}"),
),
(
Some(61_000),
Some(COMPLETED_AT),
format!("Worked for 1m 1s · {done}"),
format!("Worked for 1m 1s • {done}"),
),
(Some(60_000), None, String::new()),
(Some(60_000), None, "Worked for 1m 0s".to_string()),
(Some(125_000), None, "Worked for 2m 5s".to_string()),
] {
for replay_kind in [
@@ -190,7 +199,10 @@ async fn completion_replay_waits_for_older_turn_items_to_load() {
let newer = completed_turn(Some(1_000), Some(COMPLETED_AT));
chat.replay_thread_turns(vec![unloaded, newer], ReplayKind::ResumeInitialMessages);
assert_eq!(completion_labels(&mut rx), saved_completion_label());
assert_eq!(
completion_labels(&mut rx),
format!("Worked for 1s • {}", saved_completion_label())
);
chat.replay_thread_turns(vec![older], ReplayKind::ThreadSnapshot);
let older_time = Local
@@ -203,7 +215,7 @@ async fn completion_replay_waits_for_older_turn_items_to_load() {
.to_string();
assert_eq!(
completion_labels(&mut rx),
format!("Worked for 2m 5s · {older_time}"),
format!("Worked for 2m 5s • {older_time}"),
);
}
@@ -265,7 +277,7 @@ fn completion_snapshot_normalization_preserves_clock_only_message_lines() {
&footer,
lines_to_single_string(&footer.display_lines(/*width*/ 80))
),
" Worked for [duration] · [completion time]\n"
" Worked for [duration] • [completion time]\n"
);
}
}
+2 -2
View File
@@ -1829,7 +1829,7 @@ pub(crate) fn normalize_completion_timestamps(
}
static COMPLETION_FOOTER: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(
|| {
regex_lite::Regex::new(r"(?m)^(?P<indent>[ \t]*)(?P<duration>Worked for (?:[0-9]+h )?(?:[0-9]+m )?[0-9]+s · )?(?:[A-Z][a-z]{2} [0-9]{1,2}(?:, [0-9]{4})? at )?[0-9]{1,2}:[0-9]{2}(?: (?:AM|PM))?(?P<padding>[ \t]*)$")
regex_lite::Regex::new(r"(?m)^(?P<indent>[ \t]*)(?P<duration>Worked for (?:<1s|(?:[0-9]+h )?(?:[0-9]+m )?[0-9]+s) • )?(?:[A-Z][a-z]{2} [0-9]{1,2}(?:, [0-9]{4})? at )?[0-9]{1,2}:[0-9]{2}(?: (?:AM|PM))?(?P<padding>[ \t]*)$")
.expect("valid completion footer pattern")
},
);
@@ -1838,7 +1838,7 @@ pub(crate) fn normalize_completion_timestamps(
let indent = &captures["indent"];
let padding = &captures["padding"];
let duration = if captures.name("duration").is_some() {
"Worked for [duration] · "
"Worked for [duration] • "
} else {
""
};
+5 -3
View File
@@ -11,7 +11,7 @@ use chrono::NaiveDate;
///
/// The timestamp records when the turn actually finished, including when restored from history.
/// Times use the host's clock preference; other local days include the date and other years the year.
/// Durations are shown only above sixty seconds; shorter turns still show their timestamp.
/// Known durations are always shown; sub-second durations display as less than one second.
/// Absent metadata occupies no transcript rows.
/// The display date is fixed at construction so crossing midnight cannot invalidate cached heights;
/// restoring the conversation constructs new cells and refreshes whether the timestamp needs a date.
@@ -58,7 +58,7 @@ impl FinalMessageSeparator {
fn label(&self, today: NaiveDate) -> Option<String> {
let mut label_parts = Vec::new();
if let Some(elapsed_seconds) = self.elapsed_seconds.filter(|seconds| *seconds > 60) {
if let Some(elapsed_seconds) = self.elapsed_seconds {
let hours = elapsed_seconds / 3_600;
let minutes = (elapsed_seconds % 3_600) / 60;
let seconds = elapsed_seconds % 60;
@@ -66,6 +66,8 @@ impl FinalMessageSeparator {
format!("{hours}h {minutes}m {seconds}s")
} else if minutes > 0 {
format!("{minutes}m {seconds}s")
} else if seconds == 0 {
"<1s".to_string()
} else {
format!("{seconds}s")
};
@@ -88,7 +90,7 @@ impl FinalMessageSeparator {
if let Some(metrics_label) = self.runtime_metrics.and_then(runtime_metrics_label) {
label_parts.push(metrics_label);
}
(!label_parts.is_empty()).then(|| label_parts.join(" · "))
(!label_parts.is_empty()).then(|| label_parts.join(" • "))
}
}
impl HistoryCell for FinalMessageSeparator {
@@ -19,7 +19,7 @@ fn completed_at() -> DateTime<Local> {
}
#[test]
fn completion_label_shows_duration_only_above_sixty_seconds() {
fn completion_label_shows_known_durations_including_short_turns() {
let completed_at = completed_at();
let labels = [
None,
@@ -41,12 +41,12 @@ fn completion_label_shows_duration_only_above_sixty_seconds() {
insta::assert_snapshot!(labels.join("\n"), @"
2:32 PM
2:32 PM
2:32 PM
2:32 PM
Worked for 1m 1s · 2:32 PM
Worked for 2m 5s · 2:32 PM
Worked for 1h 0m 5s · 2:32 PM
Worked for <1s • 2:32 PM
Worked for 12s • 2:32 PM
Worked for 1m 0s • 2:32 PM
Worked for 1m 1s • 2:32 PM
Worked for 2m 5s • 2:32 PM
Worked for 1h 0m 5s • 2:32 PM
");
}
@@ -60,9 +60,9 @@ fn completion_label_includes_date_when_viewed_on_another_day() {
.with_completed_at(completed_at, ClockFormat::TwelveHour);
let tomorrow = completed_at.date_naive().succ_opt().expect("next day");
insta::assert_snapshot!(cell.label(tomorrow).expect("completion label"), @"Worked for 2m 5s · Sep 6 at 2:32 PM");
insta::assert_snapshot!(cell.label(tomorrow).expect("completion label"), @"Worked for 2m 5s • Sep 6 at 2:32 PM");
let next_year = tomorrow.with_year(/*year*/ 2001).expect("valid next year");
insta::assert_snapshot!(cell.label(next_year).expect("completion label"), @"Worked for 2m 5s · Sep 6, 2000 at 2:32 PM");
insta::assert_snapshot!(cell.label(next_year).expect("completion label"), @"Worked for 2m 5s • Sep 6, 2000 at 2:32 PM");
}
#[test]
@@ -149,12 +149,12 @@ fn completion_wraps_metadata_and_preserves_unwrapped_raw_text() {
let rendered = lines.iter().map(ToString::to_string).collect::<Vec<_>>();
insta::assert_snapshot!(rendered.join("\n"), @r"
Worked for 2m 5s · Sep
6, 2000 at 2:32 PM ·
Worked for 2m 5s • Sep
6, 2000 at 2:32 PM •
Local tools: 3 calls
(2.5s)
");
insta::assert_snapshot!(cell.raw_lines()[0].to_string(), @"Worked for 2m 5s · Sep 6, 2000 at 2:32 PM · Local tools: 3 calls (2.5s)");
insta::assert_snapshot!(cell.raw_lines()[0].to_string(), @"Worked for 2m 5s • Sep 6, 2000 at 2:32 PM • Local tools: 3 calls (2.5s)");
for width in [0, 1, 5, 24] {
assert!(
cell.display_lines(width)
@@ -1,20 +1,19 @@
---
source: tui/src/history_cell/separators_tests.rs
assertion_line: 101
expression: "labels.join(\"\\n\")"
---
Worked for 2m 5s · 00:25
Worked for 2m 5s · Sep 6 at 00:25
Worked for 2m 5s · Sep 6, 2000 at 00:25
Worked for 2m 5s · Sep
Worked for 2m 5s • 00:25
Worked for 2m 5s • Sep 6 at 00:25
Worked for 2m 5s • Sep 6, 2000 at 00:25
Worked for 2m 5s • Sep
6, 2000 at 00:25
Worked for 2m 5s · 12:25
Worked for 2m 5s · Sep 6 at 12:25
Worked for 2m 5s · Sep 6, 2000 at 12:25
Worked for 2m 5s · Sep
Worked for 2m 5s • 12:25
Worked for 2m 5s • Sep 6 at 12:25
Worked for 2m 5s • Sep 6, 2000 at 12:25
Worked for 2m 5s • Sep
6, 2000 at 12:25
Worked for 2m 5s · 23:25
Worked for 2m 5s · Sep 6 at 23:25
Worked for 2m 5s · Sep 6, 2000 at 23:25
Worked for 2m 5s · Sep
Worked for 2m 5s • 23:25
Worked for 2m 5s • Sep 6 at 23:25
Worked for 2m 5s • Sep 6, 2000 at 23:25
Worked for 2m 5s • Sep
6, 2000 at 23:25
+1 -1
View File
@@ -696,7 +696,7 @@ fn final_message_separator_preserves_runtime_metrics_for_short_turns() {
let rendered = render_lines(&cell.display_lines(/*width*/ 600));
assert_eq!(rendered.len(), 1);
assert!(rendered[0].starts_with(" Local tools:"));
assert!(rendered[0].starts_with(" Worked for 12s • Local tools:"));
assert!(rendered[0].contains("Local tools: 3 calls (2.5s)"));
assert!(rendered[0].contains("Inference: 2 calls (1.2s)"));
assert!(rendered[0].contains("WebSocket: 1 events send (700ms)"));