Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/chatwidget/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/chatwidget/input_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/chatwidget/slash_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 "
15 changes: 9 additions & 6 deletions codex-rs/tui/src/chatwidget/status_controls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand All @@ -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,
Expand All @@ -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.
Expand Down
115 changes: 85 additions & 30 deletions codex-rs/tui/src/chatwidget/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
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) {
Expand All @@ -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();
}
Expand All @@ -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>) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
}
4 changes: 3 additions & 1 deletion codex-rs/tui/src/chatwidget/tests/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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>,
Expand All @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions codex-rs/tui/src/chatwidget/tests/plan_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion codex-rs/tui/src/chatwidget/tests/slash_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading