mirror of
https://github.com/openai/codex.git
synced 2026-10-21 12:10:06 +00:00
Preserve TUI status timing when the status row is hidden (#41913)
## Why Streaming can temporarily hide the status row, but its elapsed time is still needed when the row returns and when the final `Worked for` duration falls back to the local clock. Approval prompts must also keep that clock paused. ## What changed - Own the pause-aware status timer in the bottom pane independently of the optional status row. - Preserve elapsed time and modal pause state while hiding or restoring the row, and reset the clock for new turns and Windows sandbox setup. - Use the independent timer as the fallback for the final turn duration. ## Testing Add timer unit coverage and TUI tests for hidden rows, outstanding approvals, fallback turn durations, and fresh Windows sandbox setup clocks. GitOrigin-RevId: a77931e465d5aedd4a8cd606a3a3117d1bb37ce7
This commit is contained in:
@@ -249,6 +249,8 @@ pub(crate) struct BottomPane {
|
||||
/// Inline status indicator shown above the composer while a task is running.
|
||||
status: Option<StatusIndicatorWidget>,
|
||||
inline_banner: Option<actionable_banner::InlineBanner>,
|
||||
/// Streaming may drop the row without losing its elapsed time or modal pause.
|
||||
status_timer: crate::status_indicator_widget::StatusTimer,
|
||||
/// Unified exec session summary source.
|
||||
///
|
||||
/// When a status row exists, this summary is mirrored inline in that row;
|
||||
@@ -320,6 +322,7 @@ impl BottomPane {
|
||||
is_task_running: false,
|
||||
status: None,
|
||||
inline_banner: None,
|
||||
status_timer: crate::status_indicator_widget::StatusTimer::default(),
|
||||
unified_exec_footer: UnifiedExecFooter::new(),
|
||||
pending_input_preview: PendingInputPreview::new(),
|
||||
pending_thread_approvals: PendingThreadApprovals::new(),
|
||||
@@ -546,6 +549,15 @@ impl BottomPane {
|
||||
self.status.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn status_elapsed(&self) -> Option<Duration> {
|
||||
self.is_task_running
|
||||
.then(|| self.status_timer.elapsed_at(Instant::now()))
|
||||
}
|
||||
|
||||
pub(crate) fn reset_status_timer(&mut self, elapsed: Duration) {
|
||||
self.status_timer.reset(elapsed);
|
||||
}
|
||||
|
||||
pub fn skills(&self) -> Option<&Vec<SkillMetadata>> {
|
||||
self.composer.skills()
|
||||
}
|
||||
@@ -1054,7 +1066,7 @@ impl BottomPane {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn status_indicator_visible(&self) -> bool {
|
||||
self.status.is_some()
|
||||
self.status_widget().is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1085,6 +1097,7 @@ impl BottomPane {
|
||||
|
||||
if running {
|
||||
if !was_running {
|
||||
self.status_timer.reset(Duration::ZERO);
|
||||
if self.status.is_none() {
|
||||
self.status = Some(StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
@@ -1121,11 +1134,13 @@ impl BottomPane {
|
||||
|
||||
pub(crate) fn ensure_status_indicator(&mut self) {
|
||||
if self.status.is_none() {
|
||||
self.status = Some(StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
self.animations_enabled,
|
||||
));
|
||||
self.status.get_or_insert_with(|| {
|
||||
StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
self.animations_enabled,
|
||||
)
|
||||
});
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_interrupt_binding(
|
||||
self.keymap
|
||||
@@ -1453,7 +1468,7 @@ impl BottomPane {
|
||||
&& !(is_agent_command && key_event.code == KeyCode::Esc)
|
||||
&& self.no_modal_or_popup_active()
|
||||
&& !self.composer_should_handle_vim_insert_escape(key_event)
|
||||
&& self.status.is_some()
|
||||
&& self.status_widget().is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn terminal_title_requires_action(&self) -> bool {
|
||||
@@ -1714,15 +1729,12 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
fn pause_status_timer_for_modal(&mut self) {
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.pause_timer();
|
||||
}
|
||||
self.status_timer.pause_at(Instant::now());
|
||||
}
|
||||
|
||||
fn resume_status_timer_after_modal(&mut self) {
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.resume_timer();
|
||||
}
|
||||
self.status_timer.resume_at(Instant::now());
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Height (terminal rows) required by the current bottom pane.
|
||||
@@ -1854,12 +1866,15 @@ impl BottomPane {
|
||||
{
|
||||
flex.push(/*flex*/ 0, RenderableItem::Borrowed(banner));
|
||||
}
|
||||
if let Some(status) = &self.status {
|
||||
flex.push(/*flex*/ 0, RenderableItem::Borrowed(status));
|
||||
if let Some(status) = self.status_widget() {
|
||||
flex.push(
|
||||
/*flex*/ 0,
|
||||
RenderableItem::Owned(Box::new(status.with_timer(&self.status_timer))),
|
||||
);
|
||||
}
|
||||
// Avoid double-surfacing the same summary and avoid adding an extra
|
||||
// row while the status line is already visible.
|
||||
if self.status.is_none() && !self.unified_exec_footer.is_empty() {
|
||||
if self.status_widget().is_none() && !self.unified_exec_footer.is_empty() {
|
||||
flex.push(
|
||||
/*flex*/ 0,
|
||||
RenderableItem::Borrowed(&self.unified_exec_footer),
|
||||
@@ -1870,7 +1885,7 @@ impl BottomPane {
|
||||
|| !self.pending_input_preview.pending_steers.is_empty()
|
||||
|| !self.pending_input_preview.rejected_steers.is_empty();
|
||||
let has_status_or_footer =
|
||||
self.status.is_some() || !self.unified_exec_footer.is_empty();
|
||||
self.status_widget().is_some() || !self.unified_exec_footer.is_empty();
|
||||
let has_inline_previews = has_pending_thread_approvals || has_pending_input;
|
||||
if has_inline_previews && has_status_or_footer {
|
||||
flex.push(/*flex*/ 0, RenderableItem::Owned("".into()));
|
||||
@@ -2663,6 +2678,53 @@ mod tests {
|
||||
|
||||
let bufs = snapshot_buffer(&buf);
|
||||
assert!(bufs.contains("• Working"), "expected Working header");
|
||||
|
||||
pane.reset_status_timer(Duration::from_secs(/*secs*/ 42));
|
||||
pane.hide_status_indicator();
|
||||
pane.pause_status_timer_for_modal();
|
||||
let paused = pane.status_timer.elapsed_at(Instant::now());
|
||||
pane.ensure_status_indicator();
|
||||
assert_snapshot!(
|
||||
"status_timer_survives_hidden_row",
|
||||
render_snapshot(&pane, area)
|
||||
);
|
||||
assert_eq!(
|
||||
pane.status_timer
|
||||
.elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10)),
|
||||
paused
|
||||
);
|
||||
pane.resume_status_timer_after_modal();
|
||||
assert!(
|
||||
pane.status_timer
|
||||
.elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10))
|
||||
>= paused + Duration::from_secs(/*secs*/ 10)
|
||||
);
|
||||
pane.set_task_running(/*running*/ false);
|
||||
pane.set_task_running(/*running*/ true);
|
||||
assert!(pane.status_timer.elapsed_at(Instant::now()) < paused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_start_keeps_an_outstanding_approval_paused() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let mut pane = test_pane(AppEventSender::new(tx));
|
||||
// MCP startup can already own the running row when an agent turn starts.
|
||||
pane.set_task_running(/*running*/ true);
|
||||
pane.push_approval_request(exec_request(), &Features::default());
|
||||
pane.hide_status_indicator();
|
||||
pane.set_task_running(/*running*/ true);
|
||||
pane.reset_status_timer(Duration::ZERO);
|
||||
pane.ensure_status_indicator();
|
||||
let later = Instant::now() + Duration::from_secs(/*secs*/ 120);
|
||||
assert_eq!(pane.status_timer.elapsed_at(later), Duration::ZERO);
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert!(pane.no_modal_or_popup_active());
|
||||
assert!(
|
||||
pane.status_timer
|
||||
.elapsed_at(Instant::now() + Duration::from_secs(/*secs*/ 10))
|
||||
>= Duration::from_secs(/*secs*/ 10)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/mod.rs
|
||||
assertion_line: 2570
|
||||
expression: "render_snapshot(&pane, area)"
|
||||
---
|
||||
• Working (42s • esc to interrupt)
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
? for shortcuts 100% context left
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/permissions.rs
|
||||
assertion_line: 610
|
||||
expression: "setup.replace(&chat.config.cwd.display().to_string(), \"[CWD]\")"
|
||||
---
|
||||
• Setting up sandbox... (0s)
|
||||
└ Hang tight, this may take a few minutes
|
||||
|
||||
|
||||
› Input disabled until setup completes.
|
||||
|
||||
gpt-5.6-sol default · [CWD]
|
||||
@@ -875,35 +875,43 @@ async fn unified_exec_wait_before_streamed_agent_message_snapshot() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_worked_for_uses_cumulative_turn_duration_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
for duration_ms in [Some(125_000), None] {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
handle_turn_started(&mut chat, "turn-1");
|
||||
|
||||
let exec = begin_exec_with_source(
|
||||
&mut chat,
|
||||
"call-1",
|
||||
"echo preparing",
|
||||
ExecCommandSource::Agent,
|
||||
);
|
||||
end_exec(&mut chat, exec, "preparing\n", "", /*exit_code*/ 0);
|
||||
let exec = begin_exec_with_source(
|
||||
&mut chat,
|
||||
"call-1",
|
||||
"echo preparing",
|
||||
ExecCommandSource::Agent,
|
||||
);
|
||||
end_exec(&mut chat, exec, "preparing\n", "", /*exit_code*/ 0);
|
||||
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"msg-final",
|
||||
"Final response.",
|
||||
Some(MessagePhase::FinalAnswer),
|
||||
);
|
||||
handle_turn_completed(&mut chat, "turn-1", Some(125_000));
|
||||
chat.bottom_pane
|
||||
.reset_status_timer(Duration::from_secs(/*secs*/ 125));
|
||||
handle_agent_message_delta(&mut chat, "Final response.\n");
|
||||
chat.on_commit_tick();
|
||||
assert!(!chat.bottom_pane.status_indicator_visible());
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
assert!(
|
||||
combined.contains("Worked for 2m 05s"),
|
||||
"expected final separator to use cumulative turn duration, got:\n{combined}"
|
||||
);
|
||||
assert_chatwidget_snapshot!("final_worked_for_uses_cumulative_turn_duration", combined);
|
||||
complete_assistant_message(
|
||||
&mut chat,
|
||||
"msg-final",
|
||||
"Final response.",
|
||||
Some(MessagePhase::FinalAnswer),
|
||||
);
|
||||
handle_turn_completed(&mut chat, "turn-1", duration_ms);
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
assert!(
|
||||
combined.contains("Worked for 2m 05s"),
|
||||
"expected final separator to use cumulative turn duration, got:\n{combined}"
|
||||
);
|
||||
assert_chatwidget_snapshot!("final_worked_for_uses_cumulative_turn_duration", combined);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -598,6 +598,28 @@ async fn fragmented_terminal_response_cannot_acknowledge_world_writable_warning(
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn windows_sandbox_setup_starts_a_fresh_status_clock() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.bottom_pane
|
||||
.reset_status_timer(Duration::from_secs(/*secs*/ 125));
|
||||
|
||||
chat.show_windows_sandbox_setup_status();
|
||||
let setup = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(setup.contains("Setting up sandbox"));
|
||||
assert!(setup.contains("(0s"));
|
||||
assert_chatwidget_snapshot!(
|
||||
"windows_sandbox_setup_fresh_clock",
|
||||
setup.replace(&chat.config.cwd.display().to_string(), "[CWD]")
|
||||
);
|
||||
|
||||
chat.clear_windows_sandbox_setup_status();
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
let working = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(working.contains("Working"));
|
||||
assert!(!working.contains("Setting up sandbox"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_windows_sandbox_setup_defers_configured_initial_prompt() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -82,6 +82,7 @@ impl ChatWidget {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.update_task_running_state();
|
||||
self.bottom_pane.reset_status_timer(Duration::ZERO);
|
||||
self.status_state.retry_status_header = None;
|
||||
self.clear_active_hook_cell();
|
||||
self.status_state.pending_status_indicator_restore = false;
|
||||
@@ -156,8 +157,8 @@ impl ChatWidget {
|
||||
.map(|duration_ms| duration_ms / 1_000)
|
||||
.or_else(|| {
|
||||
self.bottom_pane
|
||||
.status_widget()
|
||||
.map(crate::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds)
|
||||
.status_elapsed()
|
||||
.map(|elapsed| elapsed.as_secs())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -479,7 +479,7 @@ impl ChatWidget {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, _show_now: bool) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {
|
||||
// While elevated sandbox setup runs, prevent typing so the user doesn't
|
||||
// accidentally queue messages that will run under an unexpected mode.
|
||||
@@ -487,6 +487,7 @@ impl ChatWidget {
|
||||
/*enabled*/ false,
|
||||
Some("Input disabled until setup completes.".to_string()),
|
||||
);
|
||||
self.bottom_pane.reset_status_timer(Duration::ZERO);
|
||||
self.bottom_pane.ensure_status_indicator();
|
||||
self.bottom_pane
|
||||
.set_interrupt_hint_visible(/*visible*/ false);
|
||||
@@ -499,11 +500,11 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(not(any(target_os = "windows", test)))]
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn show_windows_sandbox_setup_status(&mut self) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {
|
||||
self.bottom_pane
|
||||
.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None);
|
||||
@@ -511,6 +512,6 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[cfg(not(any(target_os = "windows", test)))]
|
||||
pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! A live task status row rendered above the composer while the agent is busy.
|
||||
//!
|
||||
//! The row owns spinner timing, the optional interrupt hint, and short inline
|
||||
//! The row renders a separately owned clock, the optional interrupt hint, and short inline
|
||||
//! context (for example, the unified-exec background-process summary). Keeping
|
||||
//! these pieces on one line avoids vertical layout churn in the bottom pane.
|
||||
|
||||
@@ -32,6 +32,9 @@ use crate::tui::FrameRequester;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
|
||||
mod timer;
|
||||
pub(crate) use timer::StatusTimer;
|
||||
|
||||
pub(crate) const STATUS_DETAILS_DEFAULT_MAX_LINES: usize = 3;
|
||||
const DETAILS_PREFIX: &str = " └ ";
|
||||
|
||||
@@ -52,9 +55,6 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
show_interrupt_hint: bool,
|
||||
interrupt_binding: Option<ShortcutHint>,
|
||||
|
||||
elapsed_running: Duration,
|
||||
last_resume_at: Instant,
|
||||
is_paused: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
@@ -90,10 +90,6 @@ impl StatusIndicatorWidget {
|
||||
inline_message: None,
|
||||
show_interrupt_hint: true,
|
||||
interrupt_binding: Some(key_hint::plain(KeyCode::Esc).into()),
|
||||
elapsed_running: Duration::ZERO,
|
||||
last_resume_at: Instant::now(),
|
||||
is_paused: false,
|
||||
|
||||
app_event_tx,
|
||||
frame_requester,
|
||||
animations_enabled,
|
||||
@@ -156,45 +152,8 @@ impl StatusIndicatorWidget {
|
||||
self.interrupt_binding = binding;
|
||||
}
|
||||
|
||||
pub(crate) fn pause_timer(&mut self) {
|
||||
self.pause_timer_at(Instant::now());
|
||||
}
|
||||
|
||||
pub(crate) fn resume_timer(&mut self) {
|
||||
self.resume_timer_at(Instant::now());
|
||||
}
|
||||
|
||||
pub(crate) fn pause_timer_at(&mut self, now: Instant) {
|
||||
if self.is_paused {
|
||||
return;
|
||||
}
|
||||
self.elapsed_running += now.saturating_duration_since(self.last_resume_at);
|
||||
self.is_paused = true;
|
||||
}
|
||||
|
||||
pub(crate) fn resume_timer_at(&mut self, now: Instant) {
|
||||
if !self.is_paused {
|
||||
return;
|
||||
}
|
||||
self.last_resume_at = now;
|
||||
self.is_paused = false;
|
||||
self.frame_requester.schedule_frame();
|
||||
}
|
||||
|
||||
fn elapsed_duration_at(&self, now: Instant) -> Duration {
|
||||
let mut elapsed = self.elapsed_running;
|
||||
if !self.is_paused {
|
||||
elapsed += now.saturating_duration_since(self.last_resume_at);
|
||||
}
|
||||
elapsed
|
||||
}
|
||||
|
||||
fn elapsed_seconds_at(&self, now: Instant) -> u64 {
|
||||
self.elapsed_duration_at(now).as_secs()
|
||||
}
|
||||
|
||||
pub fn elapsed_seconds(&self) -> u64 {
|
||||
self.elapsed_seconds_at(Instant::now())
|
||||
pub(crate) fn with_timer<'a>(&'a self, timer: &'a StatusTimer) -> impl Renderable + 'a {
|
||||
StatusIndicator { row: self, timer }
|
||||
}
|
||||
|
||||
/// Wrap the details text into a fixed width and return the lines, truncating if necessary.
|
||||
@@ -230,9 +189,14 @@ impl StatusIndicatorWidget {
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for StatusIndicatorWidget {
|
||||
struct StatusIndicator<'a> {
|
||||
row: &'a StatusIndicatorWidget,
|
||||
timer: &'a StatusTimer,
|
||||
}
|
||||
|
||||
impl Renderable for StatusIndicator<'_> {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
1 + u16::try_from(self.wrapped_details_lines(width).len()).unwrap_or(0)
|
||||
1 + u16::try_from(self.row.wrapped_details_lines(width).len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -240,31 +204,32 @@ impl Renderable for StatusIndicatorWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.animations_enabled {
|
||||
let row = self.row;
|
||||
if row.animations_enabled {
|
||||
// Schedule next animation frame.
|
||||
self.frame_requester
|
||||
row.frame_requester
|
||||
.schedule_frame_in(Duration::from_millis(32));
|
||||
}
|
||||
let now = Instant::now();
|
||||
let elapsed_duration = self.elapsed_duration_at(now);
|
||||
let elapsed_duration = self.timer.elapsed_at(now);
|
||||
let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs());
|
||||
let motion_mode = MotionMode::from_animations_enabled(self.animations_enabled);
|
||||
let motion_mode = MotionMode::from_animations_enabled(row.animations_enabled);
|
||||
|
||||
let mut spans = Vec::with_capacity(5);
|
||||
if let Some(indicator) = activity_indicator(
|
||||
Some(self.last_resume_at),
|
||||
Some(self.timer.last_resume_at),
|
||||
motion_mode,
|
||||
ReducedMotionIndicator::Hidden,
|
||||
) {
|
||||
spans.push(indicator);
|
||||
spans.push(" ".into());
|
||||
}
|
||||
spans.extend(shimmer_text(&self.header, motion_mode));
|
||||
spans.extend(shimmer_text(&row.header, motion_mode));
|
||||
if !spans.is_empty() {
|
||||
spans.push(" ".into());
|
||||
}
|
||||
if self.show_interrupt_hint
|
||||
&& let Some(interrupt_binding) = self.interrupt_binding
|
||||
if row.show_interrupt_hint
|
||||
&& let Some(interrupt_binding) = row.interrupt_binding
|
||||
{
|
||||
spans.extend(vec![
|
||||
format!("({pretty_elapsed} • ").dim(),
|
||||
@@ -274,7 +239,7 @@ impl Renderable for StatusIndicatorWidget {
|
||||
} else {
|
||||
spans.push(format!("({pretty_elapsed})").dim());
|
||||
}
|
||||
if let Some(message) = &self.inline_message {
|
||||
if let Some(message) = &row.inline_message {
|
||||
// Keep optional context after elapsed/interrupt text so that core
|
||||
// interrupt affordances stay in a fixed visual location.
|
||||
spans.push(" · ".dim());
|
||||
@@ -288,7 +253,7 @@ impl Renderable for StatusIndicatorWidget {
|
||||
));
|
||||
if area.height > 1 {
|
||||
// If there is enough space, add the details lines below the header.
|
||||
let details = self.wrapped_details_lines(area.width);
|
||||
let details = row.wrapped_details_lines(area.width);
|
||||
let max_details = usize::from(area.height.saturating_sub(1));
|
||||
lines.extend(details.into_iter().take(max_details));
|
||||
}
|
||||
@@ -304,8 +269,6 @@ mod tests {
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -328,6 +291,7 @@ mod tests {
|
||||
fn renders_with_working_header() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let timer = StatusTimer::default();
|
||||
let w = StatusIndicatorWidget::new(
|
||||
tx,
|
||||
crate::tui::FrameRequester::test_dummy(),
|
||||
@@ -337,7 +301,7 @@ mod tests {
|
||||
// Render into a fixed-size test terminal and snapshot the backend.
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| w.render(f.area(), f.buffer_mut()))
|
||||
.draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
@@ -346,6 +310,7 @@ mod tests {
|
||||
fn renders_truncated() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let timer = StatusTimer::default();
|
||||
let w = StatusIndicatorWidget::new(
|
||||
tx,
|
||||
crate::tui::FrameRequester::test_dummy(),
|
||||
@@ -355,7 +320,7 @@ mod tests {
|
||||
// Render into a fixed-size test terminal and snapshot the backend.
|
||||
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| w.render(f.area(), f.buffer_mut()))
|
||||
.draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
@@ -377,14 +342,14 @@ mod tests {
|
||||
w.set_interrupt_hint_visible(/*visible*/ false);
|
||||
|
||||
// Freeze time-dependent rendering (elapsed + spinner) to keep the snapshot stable.
|
||||
w.is_paused = true;
|
||||
w.elapsed_running = Duration::ZERO;
|
||||
let mut timer = StatusTimer::default();
|
||||
timer.pause_at(timer.last_resume_at);
|
||||
|
||||
// Prefix is 4 columns, so a width of 30 yields a content width of 26: one column
|
||||
// short of fitting the whole phrase (27 cols), forcing exactly one wrap without ellipsis.
|
||||
let mut terminal = Terminal::new(TestBackend::new(30, 3)).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| w.render(f.area(), f.buffer_mut()))
|
||||
.draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
@@ -393,17 +358,17 @@ mod tests {
|
||||
fn renders_without_spinner_when_animations_disabled() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(
|
||||
let w = StatusIndicatorWidget::new(
|
||||
tx,
|
||||
crate::tui::FrameRequester::test_dummy(),
|
||||
/*animations_enabled*/ false,
|
||||
);
|
||||
w.is_paused = true;
|
||||
w.elapsed_running = Duration::ZERO;
|
||||
let mut timer = StatusTimer::default();
|
||||
timer.pause_at(timer.last_resume_at);
|
||||
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 1)).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| w.render(f.area(), f.buffer_mut()))
|
||||
.draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
let line = terminal.backend().buffer().content()[..80]
|
||||
.iter()
|
||||
@@ -423,41 +388,16 @@ mod tests {
|
||||
/*animations_enabled*/ false,
|
||||
);
|
||||
w.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12)).into()));
|
||||
w.is_paused = true;
|
||||
w.elapsed_running = Duration::ZERO;
|
||||
let mut timer = StatusTimer::default();
|
||||
timer.pause_at(timer.last_resume_at);
|
||||
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 1)).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| w.render(f.area(), f.buffer_mut()))
|
||||
.draw(|f| w.with_timer(&timer).render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_pauses_when_requested() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut widget = StatusIndicatorWidget::new(
|
||||
tx,
|
||||
crate::tui::FrameRequester::test_dummy(),
|
||||
/*animations_enabled*/ true,
|
||||
);
|
||||
|
||||
let baseline = Instant::now();
|
||||
widget.last_resume_at = baseline;
|
||||
|
||||
let before_pause = widget.elapsed_seconds_at(baseline + Duration::from_secs(5));
|
||||
assert_eq!(before_pause, 5);
|
||||
|
||||
widget.pause_timer_at(baseline + Duration::from_secs(5));
|
||||
let paused_elapsed = widget.elapsed_seconds_at(baseline + Duration::from_secs(10));
|
||||
assert_eq!(paused_elapsed, before_pause);
|
||||
|
||||
widget.resume_timer_at(baseline + Duration::from_secs(10));
|
||||
let after_resume = widget.elapsed_seconds_at(baseline + Duration::from_secs(13));
|
||||
assert_eq!(after_resume, before_pause + 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_overflow_adds_ellipsis() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Pause-aware status time owned independently of the optional status row.
|
||||
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct StatusTimer {
|
||||
pub(super) elapsed_running: Duration,
|
||||
pub(super) last_resume_at: Instant,
|
||||
pub(super) is_paused: bool,
|
||||
}
|
||||
|
||||
impl Default for StatusTimer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
elapsed_running: Duration::ZERO,
|
||||
last_resume_at: Instant::now(),
|
||||
is_paused: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatusTimer {
|
||||
pub(crate) fn reset(&mut self, elapsed: Duration) {
|
||||
self.elapsed_running = elapsed;
|
||||
self.last_resume_at = Instant::now();
|
||||
// A turn can start while an MCP elicitation or approval is still open.
|
||||
// Reset elapsed time without changing that modal's pause state.
|
||||
}
|
||||
|
||||
pub(crate) fn pause_at(&mut self, now: Instant) {
|
||||
if !self.is_paused {
|
||||
self.elapsed_running += now.saturating_duration_since(self.last_resume_at);
|
||||
self.is_paused = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resume_at(&mut self, now: Instant) {
|
||||
if self.is_paused {
|
||||
self.last_resume_at = now;
|
||||
self.is_paused = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn elapsed_at(&self, now: Instant) -> Duration {
|
||||
self.elapsed_running
|
||||
+ if self.is_paused {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
now.saturating_duration_since(self.last_resume_at)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "timer_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,34 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn pauses_and_resumes_without_counting_wait_time() {
|
||||
let baseline = Instant::now();
|
||||
let mut timer = StatusTimer {
|
||||
last_resume_at: baseline,
|
||||
..StatusTimer::default()
|
||||
};
|
||||
timer.pause_at(baseline + Duration::from_secs(5));
|
||||
timer.pause_at(baseline + Duration::from_secs(8));
|
||||
assert_eq!(
|
||||
timer.elapsed_at(baseline + Duration::from_secs(10)),
|
||||
Duration::from_secs(5)
|
||||
);
|
||||
timer.resume_at(baseline + Duration::from_secs(10));
|
||||
timer.resume_at(baseline + Duration::from_secs(12));
|
||||
assert_eq!(
|
||||
timer.elapsed_at(baseline + Duration::from_secs(13)),
|
||||
Duration::from_secs(8)
|
||||
);
|
||||
timer.reset(Duration::from_secs(24));
|
||||
assert_eq!(
|
||||
timer.elapsed_at(timer.last_resume_at),
|
||||
Duration::from_secs(24)
|
||||
);
|
||||
timer.pause_at(timer.last_resume_at);
|
||||
timer.reset(Duration::ZERO);
|
||||
assert_eq!(
|
||||
timer.elapsed_at(timer.last_resume_at + Duration::from_secs(/*secs*/ 120)),
|
||||
Duration::ZERO
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user