mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Avoid redundant TUI redraws while streaming (#34049)
## What changed - Redraw assistant and plan stream tails only when a completed line changes the visible tail. - Cache the first reasoning header and update the status indicator only when its visible state changes, while restoring the cached header if the indicator is recreated. - Keep regular commit ticks able to clear stale stream tails. ## Testing - Add coverage for unchanged partial deltas, changed newline-delimited tails, reasoning-header redraws, recreated status indicators, stale plan tails, and history-line draw scheduling. GitOrigin-RevId: 9016175f97ef2db2edc2fd5ba13a4d731dc2d98c
This commit is contained in:
@@ -917,19 +917,22 @@ impl BottomPane {
|
||||
|
||||
/// Update the status indicator header (defaults to "Working") and details below it.
|
||||
///
|
||||
/// Passing `None` clears any existing details. No-ops if the status indicator is not active.
|
||||
/// Passing `None` clears any existing details. Returns whether the active status indicator
|
||||
/// was updated and requested a redraw.
|
||||
pub(crate) fn update_status(
|
||||
&mut self,
|
||||
header: String,
|
||||
details: Option<String>,
|
||||
details_capitalization: StatusDetailsCapitalization,
|
||||
details_max_lines: usize,
|
||||
) {
|
||||
) -> bool {
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.update_header(header);
|
||||
status.update_details(details, details_capitalization, details_max_lines.max(1));
|
||||
self.request_redraw();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Show the transient "press again to quit" hint for `key`.
|
||||
|
||||
@@ -630,6 +630,8 @@ pub(crate) struct ChatWidget {
|
||||
interrupts: InterruptManager,
|
||||
// Accumulates the current reasoning block text to extract a header
|
||||
reasoning_buffer: String,
|
||||
// Caches the first completed bold header so later deltas do not rescan the whole block.
|
||||
reasoning_header: Option<String>,
|
||||
// Preserves reasoning-summary part boundaries for transcript-only recording.
|
||||
reasoning_summary_parts: Vec<String>,
|
||||
status_state: StatusState,
|
||||
|
||||
@@ -179,6 +179,7 @@ impl ChatWidget {
|
||||
newly_installed_marketplace_tab_id: None,
|
||||
interrupts: InterruptManager::new(),
|
||||
reasoning_buffer: String::new(),
|
||||
reasoning_header: None,
|
||||
reasoning_summary_parts: Vec::new(),
|
||||
status_state: StatusState::default(),
|
||||
review: ReviewState::default(),
|
||||
|
||||
@@ -42,6 +42,7 @@ impl ChatWidget {
|
||||
// Submitted is emitted when user submits.
|
||||
// Reset any reasoning header only when we are actually submitting a turn.
|
||||
self.reasoning_buffer.clear();
|
||||
self.reasoning_header = None;
|
||||
self.reasoning_summary_parts.clear();
|
||||
self.set_status_header(String::from("Working"));
|
||||
self.submit_user_message(user_message);
|
||||
|
||||
@@ -739,6 +739,7 @@ impl ChatWidget {
|
||||
);
|
||||
if self.is_session_configured() {
|
||||
self.reasoning_buffer.clear();
|
||||
self.reasoning_header = None;
|
||||
self.reasoning_summary_parts.clear();
|
||||
self.set_status_header(String::from("Working"));
|
||||
self.submit_user_message(user_message);
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: normalized_backend_snapshot(terminal.backend())
|
||||
---
|
||||
" "
|
||||
"• Checking files (0s • esc to interrupt) · 1 background terminal running · /ps …"
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
" "
|
||||
" gpt-5.6-sol default · /tmp/project "
|
||||
@@ -9,14 +9,15 @@ use super::*;
|
||||
impl ChatWidget {
|
||||
/// Update the status indicator header and details.
|
||||
///
|
||||
/// Passing `None` clears any existing details.
|
||||
/// Passing `None` clears any existing details. Returns whether the visible status indicator
|
||||
/// requested a redraw.
|
||||
pub(super) fn set_status(
|
||||
&mut self,
|
||||
header: String,
|
||||
details: Option<String>,
|
||||
details_capitalization: StatusDetailsCapitalization,
|
||||
details_max_lines: usize,
|
||||
) {
|
||||
) -> bool {
|
||||
let details = details
|
||||
.filter(|details| !details.is_empty())
|
||||
.map(|details| {
|
||||
@@ -33,7 +34,7 @@ impl ChatWidget {
|
||||
details: details.clone(),
|
||||
details_max_lines,
|
||||
});
|
||||
self.bottom_pane.update_status(
|
||||
let status_indicator_updated = self.bottom_pane.update_status(
|
||||
header,
|
||||
details,
|
||||
StatusDetailsCapitalization::Preserve,
|
||||
@@ -51,17 +52,19 @@ impl ChatWidget {
|
||||
if title_uses_status {
|
||||
self.refresh_status_surfaces();
|
||||
}
|
||||
status_indicator_updated
|
||||
}
|
||||
|
||||
/// Convenience wrapper around [`Self::set_status`];
|
||||
/// updates the status indicator header and clears any existing details.
|
||||
pub(super) fn set_status_header(&mut self, header: String) {
|
||||
/// updates the status indicator header and clears any existing details, returning whether the
|
||||
/// visible status indicator requested a redraw.
|
||||
pub(super) fn set_status_header(&mut self, header: String) -> bool {
|
||||
self.set_status(
|
||||
header,
|
||||
/*details*/ None,
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/// Sets the currently rendered footer status-line value.
|
||||
|
||||
@@ -7,7 +7,10 @@ use super::*;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn restore_reasoning_status_header(&mut self) {
|
||||
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
|
||||
if self.reasoning_header.is_none() {
|
||||
self.reasoning_header = extract_first_bold(&self.reasoning_buffer);
|
||||
}
|
||||
if let Some(header) = self.reasoning_header.clone() {
|
||||
self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
|
||||
self.set_status_header(header);
|
||||
} else if self.bottom_pane.is_task_running() {
|
||||
@@ -149,8 +152,10 @@ impl ChatWidget {
|
||||
self.app_event_tx.send(AppEvent::StartCommitAnimation);
|
||||
self.run_catch_up_commit_tick();
|
||||
}
|
||||
self.sync_active_stream_tail();
|
||||
self.request_redraw();
|
||||
// Unterminated source is buffered by the controller and cannot change the visible tail.
|
||||
if delta.contains('\n') && self.sync_active_stream_tail() {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn on_plan_item_completed(&mut self, text: String) {
|
||||
@@ -213,24 +218,41 @@ impl ChatWidget {
|
||||
self.reasoning_buffer.push_str(&delta);
|
||||
|
||||
if self.safety_buffering_is_waiting() {
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
if self.unified_exec_wait_streak.is_some() {
|
||||
// Unified exec waiting should take precedence over reasoning-derived status headers.
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
|
||||
// Update the shimmer header to the extracted reasoning chunk header.
|
||||
self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
|
||||
self.set_status_header(header);
|
||||
} else {
|
||||
// Fallback while we don't yet have a bold header: leave existing header as-is.
|
||||
if self.reasoning_header.is_none() {
|
||||
self.reasoning_header = extract_first_bold(&self.reasoning_buffer);
|
||||
}
|
||||
let Some(header) = self.reasoning_header.as_deref() else {
|
||||
// Fallback while we don't yet have a bold header: leave existing header as-is.
|
||||
return;
|
||||
};
|
||||
|
||||
let status = &self.status_state.current_status;
|
||||
if self.status_state.terminal_title_status_kind == TerminalTitleStatusKind::Thinking
|
||||
&& status.header == header
|
||||
&& status.details.is_none()
|
||||
&& status.details_max_lines == STATUS_DETAILS_DEFAULT_MAX_LINES
|
||||
&& self
|
||||
.bottom_pane
|
||||
.status_widget()
|
||||
.is_none_or(|status| status.header() == header)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the shimmer header to the extracted reasoning chunk header.
|
||||
let header = header.to_string();
|
||||
self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Thinking;
|
||||
if !self.set_status_header(header) {
|
||||
self.request_redraw();
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn on_agent_reasoning_final(&mut self) {
|
||||
@@ -245,6 +267,7 @@ impl ChatWidget {
|
||||
self.add_boxed_history(cell);
|
||||
}
|
||||
self.reasoning_buffer.clear();
|
||||
self.reasoning_header = None;
|
||||
self.reasoning_summary_parts.clear();
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -255,6 +278,7 @@ impl ChatWidget {
|
||||
self.reasoning_summary_parts
|
||||
.push(std::mem::take(&mut self.reasoning_buffer));
|
||||
}
|
||||
self.reasoning_header = None;
|
||||
}
|
||||
|
||||
pub(super) fn on_stream_error(&mut self, message: String, additional_details: Option<String>) {
|
||||
@@ -352,7 +376,9 @@ impl ChatWidget {
|
||||
self.bottom_pane.hide_status_indicator();
|
||||
self.add_boxed_history(cell);
|
||||
}
|
||||
self.sync_active_stream_tail();
|
||||
if scope == CommitTickScope::AnyMode || outcome.has_controller {
|
||||
self.sync_active_stream_tail();
|
||||
}
|
||||
|
||||
if outcome.has_controller && outcome.all_idle {
|
||||
self.maybe_restore_status_indicator_after_stream_idle();
|
||||
@@ -434,8 +460,10 @@ impl ChatWidget {
|
||||
self.app_event_tx.send(AppEvent::StartCommitAnimation);
|
||||
self.run_catch_up_commit_tick();
|
||||
}
|
||||
self.sync_active_stream_tail();
|
||||
self.request_redraw();
|
||||
// Unterminated source is buffered by the controller and cannot change the visible tail.
|
||||
if delta.contains('\n') && self.sync_active_stream_tail() {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn active_cell_is_stream_tail(&self) -> bool {
|
||||
@@ -450,47 +478,74 @@ impl ChatWidget {
|
||||
&& self.active_cell_is_stream_tail()
|
||||
}
|
||||
|
||||
pub(super) fn sync_active_stream_tail(&mut self) {
|
||||
pub(super) fn sync_active_stream_tail(&mut self) -> bool {
|
||||
if let Some(controller) = self.stream_controller.as_ref() {
|
||||
let tail_lines = controller.current_tail_lines();
|
||||
if tail_lines.is_empty() {
|
||||
self.clear_active_stream_tail();
|
||||
return;
|
||||
return self.clear_active_stream_tail();
|
||||
}
|
||||
|
||||
self.bottom_pane.hide_status_indicator();
|
||||
self.transcript.active_cell =
|
||||
Some(Box::new(history_cell::StreamingAgentTailCell::new(
|
||||
tail_lines,
|
||||
controller.tail_starts_stream(),
|
||||
)));
|
||||
let cell = history_cell::StreamingAgentTailCell::new(
|
||||
tail_lines,
|
||||
controller.tail_starts_stream(),
|
||||
);
|
||||
if self
|
||||
.transcript
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.and_then(|active| {
|
||||
active
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::StreamingAgentTailCell>()
|
||||
})
|
||||
.is_some_and(|active| active == &cell)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.transcript.active_cell = Some(Box::new(cell));
|
||||
self.bump_active_cell_revision();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Some(controller) = self.plan_stream_controller.as_ref() {
|
||||
let tail_lines = controller.current_tail_display_lines();
|
||||
if tail_lines.is_empty() {
|
||||
self.clear_active_stream_tail();
|
||||
return;
|
||||
return self.clear_active_stream_tail();
|
||||
}
|
||||
|
||||
self.bottom_pane.hide_status_indicator();
|
||||
self.transcript.active_cell = Some(Box::new(history_cell::StreamingPlanTailCell::new(
|
||||
let cell = history_cell::StreamingPlanTailCell::new(
|
||||
tail_lines,
|
||||
!controller.tail_starts_stream(),
|
||||
)));
|
||||
);
|
||||
if self
|
||||
.transcript
|
||||
.active_cell
|
||||
.as_ref()
|
||||
.and_then(|active| {
|
||||
active
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::StreamingPlanTailCell>()
|
||||
})
|
||||
.is_some_and(|active| active == &cell)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.transcript.active_cell = Some(Box::new(cell));
|
||||
self.bump_active_cell_revision();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
self.clear_active_stream_tail();
|
||||
self.clear_active_stream_tail()
|
||||
}
|
||||
|
||||
pub(super) fn clear_active_stream_tail(&mut self) {
|
||||
pub(super) fn clear_active_stream_tail(&mut self) -> bool {
|
||||
if self.active_cell_is_stream_tail() {
|
||||
self.transcript.active_cell = None;
|
||||
self.bump_active_cell_revision();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ pub(super) async fn make_chatwidget_manual(
|
||||
model_override,
|
||||
/*has_chatgpt_account*/ false,
|
||||
/*has_codex_backend_auth*/ false,
|
||||
FrameRequester::test_dummy(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -162,6 +163,7 @@ pub(super) async fn make_chatwidget_manual_with_auth(
|
||||
model_override: Option<&str>,
|
||||
has_chatgpt_account: bool,
|
||||
has_codex_backend_auth: bool,
|
||||
frame_requester: FrameRequester,
|
||||
) -> (
|
||||
ChatWidget,
|
||||
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
@@ -181,7 +183,7 @@ pub(super) async fn make_chatwidget_manual_with_auth(
|
||||
let model_catalog = test_model_catalog(&cfg);
|
||||
let common = ChatWidgetInit {
|
||||
config: cfg,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
frame_requester,
|
||||
app_event_tx,
|
||||
workspace_command_runner: None,
|
||||
initial_user_message: None,
|
||||
|
||||
@@ -1174,6 +1174,28 @@ async fn plan_completion_restores_status_indicator_after_streaming_plan_output()
|
||||
assert_eq!(chat.bottom_pane.is_task_running(), true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unterminated_plan_delta_does_not_redraw_unchanged_stream_tail() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
|
||||
let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
chat.on_plan_delta("| Step | Owner |\n".to_string());
|
||||
assert!(chat.active_cell_is_stream_tail());
|
||||
let revision = chat.transcript.active_cell_revision;
|
||||
|
||||
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
|
||||
chat.frame_requester = frame_requester;
|
||||
chat.on_plan_delta("| partial".to_string());
|
||||
|
||||
assert_eq!(chat.transcript.active_cell_revision, revision);
|
||||
assert!(matches!(
|
||||
draw_rx.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_user_message_queues_while_compaction_turn_is_running() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -1308,8 +1308,10 @@ async fn usage_command_runs_with_backend_auth_without_chatgpt_account_flag() {
|
||||
#[tokio::test]
|
||||
async fn usage_command_runs_with_backend_auth_from_widget_init() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual_with_auth(
|
||||
/*model_override*/ None, /*has_chatgpt_account*/ false,
|
||||
/*model_override*/ None,
|
||||
/*has_chatgpt_account*/ false,
|
||||
/*has_codex_backend_auth*/ true,
|
||||
FrameRequester::test_dummy(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -3767,6 +3767,190 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unterminated_agent_delta_does_not_redraw_unchanged_stream_tail() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.handle_streaming_delta("| Step | Owner |\n".to_string());
|
||||
assert!(chat.active_cell_is_stream_tail());
|
||||
let revision = chat.transcript.active_cell_revision;
|
||||
|
||||
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
|
||||
chat.frame_requester = frame_requester;
|
||||
chat.handle_streaming_delta("| partial".to_string());
|
||||
|
||||
assert_eq!(chat.transcript.active_cell_revision, revision);
|
||||
assert!(matches!(
|
||||
draw_rx.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn newline_agent_delta_redraws_stream_tail_after_noop_catch_up() {
|
||||
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth(
|
||||
/*model_override*/ None,
|
||||
/*has_chatgpt_account*/ false,
|
||||
/*has_codex_backend_auth*/ false,
|
||||
frame_requester,
|
||||
)
|
||||
.await;
|
||||
chat.on_task_started();
|
||||
chat.handle_streaming_delta("Earlier line\n".to_string());
|
||||
chat.on_commit_tick();
|
||||
assert!(!chat.bottom_pane.status_indicator_visible());
|
||||
while draw_rx.try_recv().is_ok() {}
|
||||
|
||||
chat.handle_streaming_delta("Intro line\n| Step | Owner |\n".to_string());
|
||||
|
||||
assert!(chat.active_cell_is_stream_tail());
|
||||
assert!(
|
||||
draw_rx.try_recv().is_ok(),
|
||||
"expected the changed assistant stream tail to schedule a redraw",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn newline_plan_delta_redraws_stream_tail_after_noop_catch_up() {
|
||||
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth(
|
||||
/*model_override*/ Some("gpt-5"),
|
||||
/*has_chatgpt_account*/ false,
|
||||
/*has_codex_backend_auth*/ false,
|
||||
frame_requester,
|
||||
)
|
||||
.await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
|
||||
let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
chat.on_task_started();
|
||||
chat.on_plan_delta("Earlier line\n".to_string());
|
||||
chat.on_commit_tick();
|
||||
assert!(!chat.bottom_pane.status_indicator_visible());
|
||||
while draw_rx.try_recv().is_ok() {}
|
||||
|
||||
chat.on_plan_delta("Intro line\n| Step | Owner |\n".to_string());
|
||||
|
||||
assert!(chat.active_cell_is_stream_tail());
|
||||
assert!(
|
||||
draw_rx.try_recv().is_ok(),
|
||||
"expected the changed plan stream tail to schedule a redraw",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regular_commit_tick_clears_orphaned_plan_stream_tail() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
|
||||
let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
|
||||
.expect("expected plan collaboration mask");
|
||||
chat.set_collaboration_mask(plan_mask);
|
||||
chat.on_task_started();
|
||||
chat.on_plan_delta("| Step | Owner |\n".to_string());
|
||||
assert!(chat.active_cell_is_stream_tail());
|
||||
|
||||
chat.on_task_started();
|
||||
assert!(chat.active_cell_is_stream_tail());
|
||||
chat.on_commit_tick();
|
||||
|
||||
assert!(!chat.active_cell_is_stream_tail());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reasoning_delta_redraws_only_when_header_becomes_visible() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
|
||||
chat.frame_requester = frame_requester;
|
||||
|
||||
chat.on_agent_reasoning_delta("still looking".to_string());
|
||||
assert!(matches!(
|
||||
draw_rx.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
assert_eq!(chat.reasoning_header, None);
|
||||
|
||||
chat.on_agent_reasoning_delta(" **Checking".to_string());
|
||||
assert!(matches!(
|
||||
draw_rx.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
|
||||
chat.on_agent_reasoning_delta(" files**".to_string());
|
||||
assert!(draw_rx.try_recv().is_ok());
|
||||
assert_eq!(chat.reasoning_header.as_deref(), Some("Checking files"));
|
||||
|
||||
chat.on_agent_reasoning_delta(" and preparing a response".to_string());
|
||||
assert!(matches!(
|
||||
draw_rx.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reasoning_delta_does_not_double_schedule_visible_status_redraw() {
|
||||
let (frame_requester, mut draw_rx) = FrameRequester::test_channel();
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual_with_auth(
|
||||
/*model_override*/ None,
|
||||
/*has_chatgpt_account*/ false,
|
||||
/*has_codex_backend_auth*/ false,
|
||||
frame_requester,
|
||||
)
|
||||
.await;
|
||||
chat.on_task_started();
|
||||
assert!(chat.bottom_pane.status_indicator_visible());
|
||||
while draw_rx.try_recv().is_ok() {}
|
||||
|
||||
chat.on_agent_reasoning_delta("**Checking files**".to_string());
|
||||
|
||||
assert_eq!(chat.reasoning_header.as_deref(), Some("Checking files"));
|
||||
assert!(draw_rx.try_recv().is_ok());
|
||||
assert!(matches!(
|
||||
draw_rx.try_recv(),
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reasoning_delta_restores_recreated_status_indicator_header() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.on_task_started();
|
||||
chat.on_agent_reasoning_delta("**Checking files**".to_string());
|
||||
|
||||
chat.on_agent_message_delta("Preamble line\n".to_string());
|
||||
chat.on_commit_tick();
|
||||
drain_insert_history(&mut rx);
|
||||
assert!(!chat.bottom_pane.status_indicator_visible());
|
||||
|
||||
begin_unified_exec_startup(&mut chat, "call-1", "proc-1", "sleep 2");
|
||||
let status = chat
|
||||
.bottom_pane
|
||||
.status_widget()
|
||||
.expect("status indicator should be recreated");
|
||||
assert_eq!(status.header(), "Working");
|
||||
|
||||
chat.on_agent_reasoning_delta(" and preparing a response".to_string());
|
||||
|
||||
let status = chat
|
||||
.bottom_pane
|
||||
.status_widget()
|
||||
.expect("status indicator should remain visible");
|
||||
assert_eq!(status.header(), "Checking files");
|
||||
|
||||
let width: u16 = 80;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
|
||||
.expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(/*x*/ 0, /*y*/ 0, width, height));
|
||||
terminal
|
||||
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
|
||||
.expect("draw restored reasoning status");
|
||||
assert_chatwidget_snapshot!(
|
||||
"reasoning_delta_restores_recreated_status_indicator",
|
||||
normalized_backend_snapshot(terminal.backend())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -82,6 +82,7 @@ impl ChatWidget {
|
||||
}
|
||||
self.reasoning_summary_parts.clear();
|
||||
self.reasoning_buffer.clear();
|
||||
self.reasoning_header = None;
|
||||
self.set_ambient_pet_notification(
|
||||
crate::pets::PetNotificationKind::Running,
|
||||
/*body*/ None,
|
||||
|
||||
@@ -438,8 +438,8 @@ impl HistoryCell for AgentMarkdownCell {
|
||||
///
|
||||
/// During streaming, lines that have not yet been committed to scrollback because they belong to
|
||||
/// an in-progress table are displayed via this cell in the `active_cell` slot. It is replaced on
|
||||
/// every delta and cleared when the stream finalizes.
|
||||
#[derive(Debug)]
|
||||
/// deltas that change the visible tail and cleared when the stream finalizes.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) struct StreamingAgentTailCell {
|
||||
lines: Vec<HyperlinkLine>,
|
||||
is_first_line: bool,
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::*;
|
||||
/// The controller prepares the full styled plan lines because plan tails need the same header,
|
||||
/// padding, and background treatment as committed `ProposedPlanStreamCell`s while remaining
|
||||
/// preview-only during streaming.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(crate) struct StreamingPlanTailCell {
|
||||
lines: Vec<HyperlinkLine>,
|
||||
is_stream_continuation: bool,
|
||||
|
||||
@@ -139,7 +139,6 @@ impl StatusIndicatorWidget {
|
||||
.filter(|message| !message.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn header(&self) -> &str {
|
||||
&self.header
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ mod tests {
|
||||
use codex_config::types::NotificationCondition;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
|
||||
#[test]
|
||||
fn unfocused_notification_condition_is_suppressed_when_focused() {
|
||||
@@ -170,6 +171,20 @@ mod tests {
|
||||
"expected stale cells inside the new viewport to be cleared, rows: {rows:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread", start_paused = true)]
|
||||
async fn inserting_history_lines_schedules_a_draw() {
|
||||
let mut tui = crate::tui::test_support::make_test_tui().expect("test tui");
|
||||
let mut draw_rx = tui.draw_tx.subscribe();
|
||||
|
||||
tui.insert_history_lines(vec![Line::from("committed stream line")]);
|
||||
tokio::time::advance(std::time::Duration::from_millis(20)).await;
|
||||
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(50), draw_rx.recv()).await,
|
||||
Ok(Ok(()))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_modes() -> Result<()> {
|
||||
|
||||
@@ -133,6 +133,19 @@ mod tests {
|
||||
use tokio::time;
|
||||
use tokio_util::time::FutureExt;
|
||||
|
||||
impl FrameRequester {
|
||||
/// Create a frame requester and expose its request channel for deterministic tests.
|
||||
pub(crate) fn test_channel() -> (Self, mpsc::UnboundedReceiver<Instant>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
(
|
||||
FrameRequester {
|
||||
frame_schedule_tx: tx,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread", start_paused = true)]
|
||||
async fn test_schedule_frame_immediate_triggers_once() {
|
||||
let (draw_tx, mut draw_rx) = broadcast::channel(16);
|
||||
|
||||
Reference in New Issue
Block a user