fix(tui): keep unified exec summary on working line (#10962)
## Problem When unified-exec background sessions appear while the status indicator is visible, the bottom pane can grow by one row to show a dedicated footer line. That row insertion/removal makes the composer jump vertically and produces visible jitter/flicker during streaming turns. ## Mental model The bottom pane should expose one canonical background-exec summary string, but it should surface that string in only one place at a time: - if the status indicator row is visible, show the summary inline on that row; - if the status indicator row is hidden, show the summary as the standalone unified-exec footer row. This keeps status information visible while preserving a stable pane height. ## Non-goals This change does not alter unified-exec lifecycle, process tracking, or `/ps` behavior. It does not redesign status text copy, spinner timing, or interrupt handling semantics. ## Tradeoffs Inlining the summary preserves layout stability and keeps interrupt affordances in a fixed location, but it reduces horizontal space for long status/detail text in narrow terminals. We accept that truncation risk in exchange for removing vertical jitter and keeping the composer anchored. ## Architecture `UnifiedExecFooter` remains the source of truth for background-process summary copy via `summary_text()`. `BottomPane` mirrors that text into `StatusIndicatorWidget::update_inline_message()` whenever process state changes or a status widget is created. Rendering enforces single-surface output: the standalone footer row is skipped while status is present, and the status row appends the summary after the elapsed/interrupt segment. ## Documentation pass Added non-functional docs/comments that make the new invariant explicit: - status row owns inline summary when present; - unified-exec footer row renders only when status row is absent; - summary ordering keeps elapsed/interrupt affordance in a stable position. ## Observability No new telemetry or logs are introduced. The behavior is traceable through: - `BottomPane::set_unified_exec_processes()` for state updates, - `BottomPane::sync_status_inline_message()` for status-row synchronization, - `StatusIndicatorWidget::render()` for final inline ordering. ## Tests - Added `bottom_pane::tests::unified_exec_summary_does_not_increase_height_when_status_visible` to lock the no-height-growth invariant. - Updated the unified-exec status restoration snapshot to match inline rendering order. - Validated with: - `just fmt` - `cargo test -p codex-tui --lib` --------- Co-authored-by: Sayan Sisodiya <sayan@openai.com>
This commit is contained in:
parent
ffd4bd345c
commit
2bdf9617bb
10 changed files with 114 additions and 18 deletions
|
|
@ -7580,4 +7580,4 @@
|
|||
}
|
||||
],
|
||||
"title": "EventMsg"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8197,4 +8197,4 @@
|
|||
}
|
||||
],
|
||||
"title": "ServerNotification"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16341,4 +16341,4 @@
|
|||
},
|
||||
"title": "CodexAppServerProtocol",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5186,4 +5186,4 @@
|
|||
],
|
||||
"title": "ForkConversationResponse",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5186,4 +5186,4 @@
|
|||
],
|
||||
"title": "ResumeConversationResponse",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5208,4 +5208,4 @@
|
|||
],
|
||||
"title": "SessionConfiguredNotification",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,7 +158,10 @@ pub(crate) struct BottomPane {
|
|||
|
||||
/// Inline status indicator shown above the composer while a task is running.
|
||||
status: Option<StatusIndicatorWidget>,
|
||||
/// Unified exec session summary shown above the composer.
|
||||
/// Unified exec session summary source.
|
||||
///
|
||||
/// When a status row exists, this summary is mirrored inline in that row;
|
||||
/// when no status row exists, it renders as its own footer row.
|
||||
unified_exec_footer: UnifiedExecFooter,
|
||||
/// Queued user messages to show above the composer while a turn is running.
|
||||
queued_user_messages: QueuedUserMessages,
|
||||
|
|
@ -606,6 +609,7 @@ impl BottomPane {
|
|||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_interrupt_hint_visible(true);
|
||||
}
|
||||
self.sync_status_inline_message();
|
||||
self.request_redraw();
|
||||
}
|
||||
} else {
|
||||
|
|
@ -628,6 +632,7 @@ impl BottomPane {
|
|||
self.frame_requester.clone(),
|
||||
self.animations_enabled,
|
||||
));
|
||||
self.sync_status_inline_message();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
|
@ -684,12 +689,27 @@ impl BottomPane {
|
|||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Update the unified-exec process set and refresh whichever summary surface is active.
|
||||
///
|
||||
/// The summary may be displayed inline in the status row or as a dedicated
|
||||
/// footer row depending on whether a status indicator is currently visible.
|
||||
pub(crate) fn set_unified_exec_processes(&mut self, processes: Vec<String>) {
|
||||
if self.unified_exec_footer.set_processes(processes) {
|
||||
self.sync_status_inline_message();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy unified-exec summary text into the active status row, if any.
|
||||
///
|
||||
/// This keeps status-line inline text synchronized without forcing the
|
||||
/// standalone unified-exec footer row to be visible.
|
||||
fn sync_status_inline_message(&mut self) {
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.update_inline_message(self.unified_exec_footer.summary_text());
|
||||
}
|
||||
}
|
||||
|
||||
/// Update custom prompts available for the slash popup.
|
||||
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
|
||||
self.composer.set_custom_prompts(prompts);
|
||||
|
|
@ -884,7 +904,9 @@ impl BottomPane {
|
|||
if let Some(status) = &self.status {
|
||||
flex.push(0, RenderableItem::Borrowed(status));
|
||||
}
|
||||
if !self.unified_exec_footer.is_empty() {
|
||||
// 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() {
|
||||
flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer));
|
||||
}
|
||||
let has_queued_messages = !self.queued_user_messages.messages.is_empty();
|
||||
|
|
@ -1177,6 +1199,35 @@ mod tests {
|
|||
assert_snapshot!("status_only_snapshot", render_snapshot(&pane, area));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_exec_summary_does_not_increase_height_when_status_visible() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
skills: Some(Vec::new()),
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
let width = 120;
|
||||
let before = pane.desired_height(width);
|
||||
|
||||
pane.set_unified_exec_processes(vec!["sleep 5".to_string()]);
|
||||
let after = pane.desired_height(width);
|
||||
|
||||
assert_eq!(after, before);
|
||||
|
||||
let area = Rect::new(0, 0, width, after);
|
||||
let rendered = render_snapshot(&pane, area);
|
||||
assert!(rendered.contains("background terminal running · /ps to view"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_with_details_and_queued_messages_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
//! Renders and formats unified-exec background session summary text.
|
||||
//!
|
||||
//! This module provides one canonical summary string so the bottom pane can
|
||||
//! either render a dedicated footer row or reuse the same text inline in the
|
||||
//! status row without duplicating copy/grammar logic.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
|
|
@ -7,6 +13,7 @@ use ratatui::widgets::Paragraph;
|
|||
use crate::live_wrap::take_prefix_by_width;
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
/// Tracks active unified-exec processes and renders a compact summary.
|
||||
pub(crate) struct UnifiedExecFooter {
|
||||
processes: Vec<String>,
|
||||
}
|
||||
|
|
@ -30,16 +37,31 @@ impl UnifiedExecFooter {
|
|||
self.processes.is_empty()
|
||||
}
|
||||
|
||||
fn render_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
if self.processes.is_empty() || width < 4 {
|
||||
return Vec::new();
|
||||
/// Returns the unindented summary text used by both footer and status-row rendering.
|
||||
///
|
||||
/// The returned string intentionally omits leading spaces and separators so
|
||||
/// callers can choose layout-specific framing (inline separator vs. row
|
||||
/// indentation). Returning `None` means there is nothing to surface.
|
||||
pub(crate) fn summary_text(&self) -> Option<String> {
|
||||
if self.processes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let count = self.processes.len();
|
||||
let plural = if count == 1 { "" } else { "s" };
|
||||
let message = format!(
|
||||
" {count} background terminal{plural} running · /ps to view · /clean to close"
|
||||
);
|
||||
Some(format!(
|
||||
"{count} background terminal{plural} running · /ps to view · /clean to close"
|
||||
))
|
||||
}
|
||||
|
||||
fn render_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
if width < 4 {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(summary) = self.summary_text() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let message = format!(" {summary}");
|
||||
let (truncated, _, _) = take_prefix_by_width(&message, width as usize);
|
||||
vec![Line::from(truncated.dim())]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ source: tui/src/chatwidget/tests.rs
|
|||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"• Working (0s • esc to interrupt) "
|
||||
" 1 background terminal running · /ps to view · /clean to close "
|
||||
"• Working (0s • esc to interrupt) · 1 background terminal running · /ps to view "
|
||||
" "
|
||||
" "
|
||||
"› Ask Codex to do anything "
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
//! A live status indicator that shows the *latest* log line emitted by the
|
||||
//! application while the agent is processing a long‑running task.
|
||||
//! 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
|
||||
//! context (for example, the unified-exec background-process summary). Keeping
|
||||
//! these pieces on one line avoids vertical layout churn in the bottom pane.
|
||||
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
|
@ -30,10 +33,13 @@ use crate::wrapping::word_wrap_lines;
|
|||
const DETAILS_MAX_LINES: usize = 3;
|
||||
const DETAILS_PREFIX: &str = " └ ";
|
||||
|
||||
/// Displays a single-line in-progress status with optional wrapped details.
|
||||
pub(crate) struct StatusIndicatorWidget {
|
||||
/// Animated header text (defaults to "Working").
|
||||
header: String,
|
||||
details: Option<String>,
|
||||
/// Optional suffix rendered after the elapsed/interrupt segment.
|
||||
inline_message: Option<String>,
|
||||
show_interrupt_hint: bool,
|
||||
|
||||
elapsed_running: Duration,
|
||||
|
|
@ -70,6 +76,7 @@ impl StatusIndicatorWidget {
|
|||
Self {
|
||||
header: String::from("Working"),
|
||||
details: None,
|
||||
inline_message: None,
|
||||
show_interrupt_hint: true,
|
||||
elapsed_running: Duration::ZERO,
|
||||
last_resume_at: Instant::now(),
|
||||
|
|
@ -97,6 +104,17 @@ impl StatusIndicatorWidget {
|
|||
.map(|details| capitalize_first(details.trim_start()));
|
||||
}
|
||||
|
||||
/// Update the inline suffix text shown after `({elapsed} • esc to interrupt)`.
|
||||
///
|
||||
/// Callers should provide plain, already-contextualized text. Passing
|
||||
/// verbose status prose here can cause frequent width truncation and hide
|
||||
/// the more important elapsed/interrupt hint.
|
||||
pub(crate) fn update_inline_message(&mut self, message: Option<String>) {
|
||||
self.inline_message = message
|
||||
.map(|message| message.trim().to_string())
|
||||
.filter(|message| !message.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn header(&self) -> &str {
|
||||
&self.header
|
||||
|
|
@ -225,6 +243,12 @@ impl Renderable for StatusIndicatorWidget {
|
|||
} else {
|
||||
spans.push(format!("({pretty_elapsed})").dim());
|
||||
}
|
||||
if let Some(message) = &self.inline_message {
|
||||
// Keep optional context after elapsed/interrupt text so that core
|
||||
// interrupt affordances stay in a fixed visual location.
|
||||
spans.push(" · ".dim());
|
||||
spans.push(message.clone().dim());
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push(Line::from(spans));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue