diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 893c126ac..59976887d 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -7580,4 +7580,4 @@ } ], "title": "EventMsg" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index a2800e197..edaceb5a1 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -8197,4 +8197,4 @@ } ], "title": "ServerNotification" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6f57754dd..11e8fe9e4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -16341,4 +16341,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index 497c2df1a..8204afaae 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -5186,4 +5186,4 @@ ], "title": "ForkConversationResponse", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index 3f644e0ba..e45cb6d35 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -5186,4 +5186,4 @@ ], "title": "ResumeConversationResponse", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index 7ea8c2731..7d782f438 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -5208,4 +5208,4 @@ ], "title": "SessionConfiguredNotification", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d4a337e06..edf103bc4 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -158,7 +158,10 @@ pub(crate) struct BottomPane { /// Inline status indicator shown above the composer while a task is running. status: Option, - /// 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) { 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) { 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::(); + 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::(); diff --git a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs index 8cde20fd7..f0b7afcaf 100644 --- a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs +++ b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs @@ -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, } @@ -30,16 +37,31 @@ impl UnifiedExecFooter { self.processes.is_empty() } - fn render_lines(&self, width: u16) -> Vec> { - 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 { + 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> { + 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())] } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap index 56b3ae4cf..9976dc6ee 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap @@ -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 " diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index bef0d0328..94eb038ce 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -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, + /// Optional suffix rendered after the elapsed/interrupt segment. + inline_message: Option, 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) { + 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));