diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index ed405dd8c..7505ae1e4 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -949,7 +949,7 @@ "landlock_0.4.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"enumflags2\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2.175\"},{\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"strum_macros\",\"req\":\"^0.26\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"}],\"features\":{}}", "language-tags_0.3.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", "lazy_static_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"once\"],\"name\":\"spin\",\"optional\":true,\"req\":\"^0.9.8\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{\"spin_no_std\":[\"spin\"]}}", - "libc_0.2.180": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", + "libc_0.2.182": "{\"dependencies\":[{\"name\":\"rustc-std-workspace-core\",\"optional\":true,\"req\":\"^1.0.1\"}],\"features\":{\"align\":[],\"const-extern-fn\":[],\"default\":[\"std\"],\"extra_traits\":[],\"rustc-dep-of-std\":[\"align\",\"rustc-std-workspace-core\"],\"std\":[],\"use_std\":[\"std\"]}}", "libdbus-sys_0.2.7": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.0.78\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"default\":[\"pkg-config\"],\"vendored\":[\"cc\"]}}", "libm_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"no-panic\",\"req\":\"^0.1.35\"}],\"features\":{\"arch\":[],\"default\":[\"arch\"],\"force-soft-floats\":[],\"unstable\":[\"unstable-intrinsics\",\"unstable-float\"],\"unstable-float\":[],\"unstable-intrinsics\":[],\"unstable-public-internals\":[]}}", "libredox_0.1.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"call\":[],\"default\":[\"call\",\"std\",\"redox_syscall\"],\"mkns\":[\"ioslice\"],\"std\":[]}}", @@ -1277,7 +1277,7 @@ "supports-color_2.1.0": "{\"dependencies\":[{\"name\":\"is-terminal\",\"req\":\"^0.4.0\"},{\"name\":\"is_ci\",\"req\":\"^1.1.1\"}],\"features\":{}}", "supports-color_3.0.2": "{\"dependencies\":[{\"name\":\"is_ci\",\"req\":\"^1.2.0\"}],\"features\":{}}", "syn_1.0.109": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.46\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1.0\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.1\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", - "syn_2.0.114": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.13\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1\"},{\"name\":\"unicode-ident\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\",\"target\":\"cfg(not(miri))\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"dep:quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote?/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", + "syn_2.0.117": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.91\"},{\"default_features\":false,\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"ref-cast\",\"req\":\"^1\"},{\"features\":[\"blocking\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.13\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"syn-test-suite\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"tar\",\"req\":\"^0.4.16\",\"target\":\"cfg(not(miri))\"},{\"kind\":\"dev\",\"name\":\"termcolor\",\"req\":\"^1\"},{\"name\":\"unicode-ident\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\",\"target\":\"cfg(not(miri))\"}],\"features\":{\"clone-impls\":[],\"default\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"proc-macro\"],\"derive\":[],\"extra-traits\":[],\"fold\":[],\"full\":[],\"parsing\":[],\"printing\":[\"dep:quote\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"quote?/proc-macro\"],\"test\":[\"syn-test-suite/all-features\"],\"visit\":[],\"visit-mut\":[]}}", "sync_wrapper_1.0.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"pin-project-lite\",\"req\":\"^0.2.7\"}],\"features\":{\"futures\":[\"futures-core\"]}}", "synstructure_0.13.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"printing\",\"clone-impls\",\"visit\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"synstructure_test_traits\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"proc-macro\"],\"proc-macro\":[\"proc-macro2/proc-macro\",\"syn/proc-macro\",\"quote/proc-macro\"]}}", "syntect_5.3.0": "{\"dependencies\":[{\"name\":\"bincode\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"fancy-regex\",\"optional\":true,\"req\":\"^0.16.2\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"fnv\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"getopts\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"default_features\":false,\"name\":\"onig\",\"optional\":true,\"req\":\"^6.5.1\"},{\"name\":\"plist\",\"optional\":true,\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"public-api\",\"req\":\"^0.50.1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"regex-syntax\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rustdoc-json\",\"req\":\"^0.9.7\"},{\"kind\":\"dev\",\"name\":\"rustup-toolchain\",\"req\":\"^0.1.5\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.12\"},{\"name\":\"walkdir\",\"req\":\"^2.0\"},{\"name\":\"yaml-rust\",\"optional\":true,\"req\":\"^0.4.5\"}],\"features\":{\"default\":[\"default-onig\"],\"default-fancy\":[\"parsing\",\"default-syntaxes\",\"default-themes\",\"html\",\"plist-load\",\"yaml-load\",\"dump-load\",\"dump-create\",\"regex-fancy\"],\"default-onig\":[\"parsing\",\"default-syntaxes\",\"default-themes\",\"html\",\"plist-load\",\"yaml-load\",\"dump-load\",\"dump-create\",\"regex-onig\"],\"default-syntaxes\":[\"parsing\",\"dump-load\"],\"default-themes\":[\"dump-load\"],\"dump-create\":[\"flate2\",\"bincode\"],\"dump-load\":[\"flate2\",\"bincode\"],\"html\":[\"parsing\"],\"metadata\":[\"parsing\",\"plist-load\",\"dep:serde_json\"],\"parsing\":[\"regex-syntax\",\"fnv\",\"dump-create\",\"dump-load\"],\"plist-load\":[\"plist\",\"dep:serde_json\"],\"regex-fancy\":[\"fancy-regex\"],\"regex-onig\":[\"onig\"],\"yaml-load\":[\"yaml-rust\",\"parsing\"]}}", diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 9c7b940f0..32e49763e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -131,6 +131,7 @@ pub(crate) use chat_composer::ChatComposerConfig; pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; +use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::status_indicator_widget::StatusIndicatorWidget; pub(crate) use experimental_features_view::ExperimentalFeatureItem; pub(crate) use experimental_features_view::ExperimentalFeaturesView; @@ -549,10 +550,16 @@ 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. - pub(crate) fn update_status(&mut self, header: String, details: Option) { + pub(crate) fn update_status( + &mut self, + header: String, + details: Option, + details_capitalization: StatusDetailsCapitalization, + details_max_lines: usize, + ) { if let Some(status) = self.status.as_mut() { status.update_header(header); - status.update_details(details); + status.update_details(details, details_capitalization, details_max_lines.max(1)); self.request_redraw(); } } @@ -982,6 +989,8 @@ impl Renderable for BottomPane { mod tests { use super::*; use crate::app_event::AppEvent; + use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; + use crate::status_indicator_widget::StatusDetailsCapitalization; use codex_protocol::protocol::Op; use codex_protocol::protocol::SkillScope; use crossterm::event::KeyModifiers; @@ -1275,6 +1284,8 @@ mod tests { pane.update_status( "Working".to_string(), Some("First detail line\nSecond detail line".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, ); pane.set_queued_user_messages(vec!["Queued follow-up question".to_string()]); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2cde0cab1..e7b1ee371 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -243,6 +243,8 @@ use crate::render::renderable::RenderableExt; use crate::render::renderable::RenderableItem; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; +use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES; +use crate::status_indicator_widget::StatusDetailsCapitalization; use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod interrupts; @@ -911,15 +913,27 @@ impl ChatWidget { /// Update the status indicator header and details. /// /// Passing `None` clears any existing details. - fn set_status(&mut self, header: String, details: Option) { + fn set_status( + &mut self, + header: String, + details: Option, + details_capitalization: StatusDetailsCapitalization, + details_max_lines: usize, + ) { self.current_status_header = header.clone(); - self.bottom_pane.update_status(header, details); + self.bottom_pane + .update_status(header, details, details_capitalization, details_max_lines); } /// Convenience wrapper around [`Self::set_status`]; /// updates the status indicator header and clears any existing details. fn set_status_header(&mut self, header: String) { - self.set_status(header, None); + self.set_status( + header, + None, + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); } /// Sets the currently rendered footer status-line value. @@ -1955,15 +1969,16 @@ impl ChatWidget { .map(|process| process.command_display.clone()); if ev.stdin.is_empty() { // Empty stdin means we are polling for background output. - // Surface this in the status header (single "waiting" surface) instead of the transcript. + // Surface this in the status indicator (single "waiting" surface) instead of + // the transcript. Keep the header short so the interrupt hint remains visible. self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(true); - let header = if let Some(command) = &command_display { - format!("Waiting for background terminal · {command}") - } else { - "Waiting for background terminal".to_string() - }; - self.set_status_header(header); + self.set_status( + "Waiting for background terminal".to_string(), + command_display.clone(), + StatusDetailsCapitalization::Preserve, + 1, + ); match &mut self.unified_exec_wait_streak { Some(wait) if wait.process_id == ev.process_id => { wait.update_command_display(command_display); @@ -2236,7 +2251,12 @@ impl ChatWidget { self.retry_status_header = Some(self.current_status_header.clone()); } self.bottom_pane.ensure_status_indicator(); - self.set_status(message, additional_details); + self.set_status( + message, + additional_details, + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); } /// Handle completion of an `AgentMessage` turn item. @@ -6293,6 +6313,8 @@ impl ChatWidget { self.set_status( "Setting up sandbox...".to_string(), Some("Hang tight, this may take a few minutes".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, ); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap new file mode 100644 index 000000000..933bc7072 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_wait_status_renders_command_in_single_details_row.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: rendered +--- +• Waiting for background terminal (0s • esc to … + └ cargo test -p codex-core -- --exact… + + +› Ask Codex to do anything + + ? for shortcuts 100% context left diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 4599377e9..cc10484c3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -4002,8 +4002,14 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { assert!(chat.active_cell.is_none()); assert_eq!( chat.current_status_header, - "Waiting for background terminal · sleep 5" + "Waiting for background terminal" ); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Waiting for background terminal"); + assert_eq!(status.details(), Some("sleep 5")); } #[tokio::test] @@ -4016,8 +4022,14 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { terminal_interaction(&mut chat, "call-wait-1b", "proc-1", ""); assert_eq!( chat.current_status_header, - "Waiting for background terminal · just fix" + "Waiting for background terminal" ); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Waiting for background terminal"); + assert_eq!(status.details(), Some("just fix")); chat.handle_codex_event(Event { id: "turn-wait-1".into(), @@ -4035,6 +4047,26 @@ async fn unified_exec_waiting_multiple_empty_snapshots() { assert_snapshot!("unified_exec_waiting_multiple_empty_after", combined); } +#[tokio::test] +async fn unified_exec_wait_status_renders_command_in_single_details_row_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.on_task_started(); + begin_unified_exec_startup( + &mut chat, + "call-wait-ui", + "proc-ui", + "cargo test -p codex-core -- --exact some::very::long::test::name", + ); + + terminal_interaction(&mut chat, "call-wait-ui-stdin", "proc-ui", ""); + + let rendered = render_bottom_popup(&chat, 48); + assert_snapshot!( + "unified_exec_wait_status_renders_command_in_single_details_row", + rendered + ); +} + #[tokio::test] async fn unified_exec_empty_then_non_empty_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -4062,8 +4094,14 @@ async fn unified_exec_non_empty_then_empty_snapshots() { terminal_interaction(&mut chat, "call-wait-3b", "proc-3", ""); assert_eq!( chat.current_status_header, - "Waiting for background terminal · just fix" + "Waiting for background terminal" ); + let status = chat + .bottom_pane + .status_widget() + .expect("status indicator should be visible"); + assert_eq!(status.header(), "Waiting for background terminal"); + assert_eq!(status.details(), Some("just fix")); let pre_cells = drain_insert_history(&mut rx); let active_combined = pre_cells .iter() diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 01027c3c9..cb4f9c5ab 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -31,14 +31,21 @@ use crate::tui::FrameRequester; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_lines; -const DETAILS_MAX_LINES: usize = 3; +pub(crate) const STATUS_DETAILS_DEFAULT_MAX_LINES: usize = 3; const DETAILS_PREFIX: &str = " └ "; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StatusDetailsCapitalization { + CapitalizeFirst, + Preserve, +} + /// 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, + details_max_lines: usize, /// Optional suffix rendered after the elapsed/interrupt segment. inline_message: Option, show_interrupt_hint: bool, @@ -77,6 +84,7 @@ impl StatusIndicatorWidget { Self { header: String::from("Working"), details: None, + details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES, inline_message: None, show_interrupt_hint: true, elapsed_running: Duration::ZERO, @@ -99,10 +107,22 @@ impl StatusIndicatorWidget { } /// Update the details text shown below the header. - pub(crate) fn update_details(&mut self, details: Option) { + pub(crate) fn update_details( + &mut self, + details: Option, + capitalization: StatusDetailsCapitalization, + max_lines: usize, + ) { + self.details_max_lines = max_lines.max(1); self.details = details .filter(|details| !details.is_empty()) - .map(|details| capitalize_first(details.trim_start())); + .map(|details| { + let trimmed = details.trim_start(); + match capitalization { + StatusDetailsCapitalization::CapitalizeFirst => capitalize_first(trimmed), + StatusDetailsCapitalization::Preserve => trimmed.to_string(), + } + }); } /// Update the inline suffix text shown after `({elapsed} • esc to interrupt)`. @@ -193,8 +213,8 @@ impl StatusIndicatorWidget { let mut out = word_wrap_lines(details.lines().map(|line| vec![line.dim()]), opts); - if out.len() > DETAILS_MAX_LINES { - out.truncate(DETAILS_MAX_LINES); + if out.len() > self.details_max_lines { + out.truncate(self.details_max_lines); let content_width = usize::from(width).saturating_sub(prefix_width).max(1); let max_base_len = content_width.saturating_sub(1); if let Some(last) = out.last_mut() @@ -329,7 +349,11 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false); - w.update_details(Some("A man a plan a canal panama".to_string())); + w.update_details( + Some("A man a plan a canal panama".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); w.set_interrupt_hint_visible(false); // Freeze time-dependent rendering (elapsed + spinner) to keep the snapshot stable. @@ -372,14 +396,45 @@ mod tests { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); - w.update_details(Some("abcd abcd abcd abcd".to_string())); + w.update_details( + Some("abcd abcd abcd abcd".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); let lines = w.wrapped_details_lines(6); - assert_eq!(lines.len(), DETAILS_MAX_LINES); + assert_eq!(lines.len(), STATUS_DETAILS_DEFAULT_MAX_LINES); let last = lines.last().expect("expected last details line"); assert!( last.spans[1].content.as_ref().ends_with("…"), "expected ellipsis in last line: {last:?}" ); } + + #[test] + fn details_args_can_disable_capitalization_and_limit_lines() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true); + w.update_details( + Some("cargo test -p codex-core and then cargo test -p codex-tui".to_string()), + StatusDetailsCapitalization::Preserve, + 1, + ); + + assert_eq!( + w.details(), + Some("cargo test -p codex-core and then cargo test -p codex-tui") + ); + + let lines = w.wrapped_details_lines(24); + assert_eq!(lines.len(), 1); + let last = lines.last().expect("expected one details line"); + assert!( + last.spans + .last() + .is_some_and(|span| span.content.as_ref().contains('…')), + "expected one-line details to be ellipsized, got {last:?}" + ); + } }