diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_many_sessions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_many_sessions.snap new file mode 100644 index 000000000..09567f9c3 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_many_sessions.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/unified_exec_footer.rs +expression: "format!(\"{buf:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 50, height: 1 }, + content: [ + " 123 background terminals running · /ps to view ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 48, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap index 1a58ed921..e707e2e81 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap @@ -1,26 +1,14 @@ --- source: tui/src/bottom_pane/unified_exec_footer.rs -assertion_line: 123 expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 50, height: 3 }, + area: Rect { x: 0, y: 0, width: 50, height: 1 }, content: [ - " Background terminal running: echo hello · rg ", - " "foo" src · 1 more ", - " running ", + " 1 background terminal running · /ps to view ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 30, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 31, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, - x: 41, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 44, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, - x: 46, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 31, y: 1, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, - x: 40, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 49, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 31, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 45, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_two_sessions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_two_sessions.snap deleted file mode 100644 index f3a4855bb..000000000 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_two_sessions.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tui/src/bottom_pane/unified_exec_footer.rs -assertion_line: 108 -expression: "format!(\"{buf:?}\")" ---- -Buffer { - area: Rect { x: 0, y: 0, width: 50, height: 2 }, - content: [ - " Background terminal running: echo hello · rg ", - " "foo" src ", - ], - styles: [ - x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 30, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 31, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, - x: 41, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 44, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, - x: 46, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 31, y: 1, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE, - x: 40, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - ] -} 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 848e17553..a0ea58bed 100644 --- a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs +++ b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs @@ -4,13 +4,8 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; +use crate::live_wrap::take_prefix_by_width; use crate::render::renderable::Renderable; -use crate::text_formatting::truncate_text; -use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_lines; - -const MAX_SESSION_LABEL_GRAPHEMES: usize = 48; -const MAX_VISIBLE_SESSIONS: usize = 2; pub(crate) struct UnifiedExecFooter { sessions: Vec, @@ -40,34 +35,11 @@ impl UnifiedExecFooter { return Vec::new(); } - let label = " Background terminal running:"; - let mut spans = Vec::new(); - spans.push(label.dim()); - spans.push(" ".into()); - - let visible = self.sessions.iter().take(MAX_VISIBLE_SESSIONS); - let mut visible_count = 0usize; - for (idx, command) in visible.enumerate() { - if idx > 0 { - spans.push(" · ".dim()); - } - let truncated = truncate_text(command, MAX_SESSION_LABEL_GRAPHEMES); - spans.push(truncated.cyan()); - visible_count += 1; - } - - let remaining = self.sessions.len().saturating_sub(visible_count); - if remaining > 0 { - spans.push(" · ".dim()); - spans.push(format!("{remaining} more running").dim()); - } - - let indent = " ".repeat(label.len() + 1); - let line = Line::from(spans); - word_wrap_lines( - std::iter::once(line), - RtOptions::new(width as usize).subsequent_indent(Line::from(indent).dim()), - ) + let count = self.sessions.len(); + let plural = if count == 1 { "" } else { "s" }; + let message = format!(" {count} background terminal{plural} running · /ps to view"); + let (truncated, _, _) = take_prefix_by_width(&message, width as usize); + vec![Line::from(truncated.dim())] } } @@ -97,29 +69,25 @@ mod tests { assert_eq!(footer.desired_height(40), 0); } - #[test] - fn render_two_sessions() { - let mut footer = UnifiedExecFooter::new(); - footer.set_sessions(vec!["echo hello".to_string(), "rg \"foo\" src".to_string()]); - let width = 50; - let height = footer.desired_height(width); - let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); - footer.render(Rect::new(0, 0, width, height), &mut buf); - assert_snapshot!("render_two_sessions", format!("{buf:?}")); - } - #[test] fn render_more_sessions() { let mut footer = UnifiedExecFooter::new(); - footer.set_sessions(vec![ - "echo hello".to_string(), - "rg \"foo\" src".to_string(), - "cat README.md".to_string(), - ]); + footer.set_sessions(vec!["rg \"foo\" src".to_string()]); let width = 50; let height = footer.desired_height(width); let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); footer.render(Rect::new(0, 0, width, height), &mut buf); assert_snapshot!("render_more_sessions", format!("{buf:?}")); } + + #[test] + fn render_many_sessions() { + let mut footer = UnifiedExecFooter::new(); + footer.set_sessions((0..123).map(|idx| format!("cmd {idx}")).collect()); + let width = 50; + let height = footer.desired_height(width); + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + footer.render(Rect::new(0, 0, width, height), &mut buf); + assert_snapshot!("render_many_sessions", format!("{buf:?}")); + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index be4efd2c7..4d2ed8983 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1700,6 +1700,9 @@ impl ChatWidget { SlashCommand::Status => { self.add_status_output(); } + SlashCommand::Ps => { + self.add_ps_output(); + } SlashCommand::Mcp => { self.add_mcp_output(); } @@ -2154,6 +2157,16 @@ impl ChatWidget { self.model_family.get_model_slug(), )); } + + pub(crate) fn add_ps_output(&mut self) { + let sessions = self + .unified_exec_sessions + .iter() + .map(|session| session.command_display.clone()) + .collect(); + self.add_to_history(history_cell::new_unified_exec_sessions_output(sessions)); + } + fn stop_rate_limit_poller(&mut self) { if let Some(handle) = self.rate_limit_poller.take() { handle.abort(); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a8c37d0f8..1dce96636 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -7,6 +7,7 @@ use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; +use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; @@ -56,6 +57,7 @@ use std::path::PathBuf; use std::time::Duration; use std::time::Instant; use tracing::error; +use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; /// Represents an event to display in the conversation history. Returns its @@ -441,6 +443,106 @@ pub(crate) fn new_unified_exec_interaction( UnifiedExecInteractionCell::new(command_display, stdin) } +#[derive(Debug)] +struct UnifiedExecSessionsCell { + sessions: Vec, +} + +impl UnifiedExecSessionsCell { + fn new(sessions: Vec) -> Self { + Self { sessions } + } +} + +impl HistoryCell for UnifiedExecSessionsCell { + fn display_lines(&self, width: u16) -> Vec> { + if width == 0 { + return Vec::new(); + } + + let wrap_width = width as usize; + let max_sessions = 16usize; + let mut out: Vec> = Vec::new(); + out.push(vec!["Background terminals".bold()].into()); + out.push("".into()); + + if self.sessions.is_empty() { + out.push(" • No background terminals running.".italic().into()); + return out; + } + + let prefix = " • "; + let prefix_width = UnicodeWidthStr::width(prefix); + let truncation_suffix = " [...]"; + let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix); + let mut shown = 0usize; + for command in &self.sessions { + if shown >= max_sessions { + break; + } + let (snippet, snippet_truncated) = { + let (first_line, has_more_lines) = match command.split_once('\n') { + Some((first, _)) => (first, true), + None => (command.as_str(), false), + }; + let max_graphemes = 80; + let mut graphemes = first_line.grapheme_indices(true); + if let Some((byte_index, _)) = graphemes.nth(max_graphemes) { + (first_line[..byte_index].to_string(), true) + } else { + (first_line.to_string(), has_more_lines) + } + }; + if wrap_width <= prefix_width { + out.push(Line::from(prefix.dim())); + shown += 1; + continue; + } + let budget = wrap_width.saturating_sub(prefix_width); + let mut needs_suffix = snippet_truncated; + if !needs_suffix { + let (_, remainder, _) = take_prefix_by_width(&snippet, budget); + if !remainder.is_empty() { + needs_suffix = true; + } + } + if needs_suffix && budget > truncation_suffix_width { + let available = budget.saturating_sub(truncation_suffix_width); + let (truncated, _, _) = take_prefix_by_width(&snippet, available); + out.push(vec![prefix.dim(), truncated.cyan(), truncation_suffix.dim()].into()); + } else { + let (truncated, _, _) = take_prefix_by_width(&snippet, budget); + out.push(vec![prefix.dim(), truncated.cyan()].into()); + } + shown += 1; + } + + let remaining = self.sessions.len().saturating_sub(shown); + if remaining > 0 { + let more_text = format!("... and {remaining} more running"); + if wrap_width <= prefix_width { + out.push(Line::from(prefix.dim())); + } else { + let budget = wrap_width.saturating_sub(prefix_width); + let (truncated, _, _) = take_prefix_by_width(&more_text, budget); + out.push(vec![prefix.dim(), truncated.dim()].into()); + } + } + + out + } + + fn desired_height(&self, width: u16) -> u16 { + self.display_lines(width).len() as u16 + } +} + +pub(crate) fn new_unified_exec_sessions_output(sessions: Vec) -> CompositeHistoryCell { + let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]); + let summary = UnifiedExecSessionsCell::new(sessions); + CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)]) +} + fn truncate_exec_snippet(full_cmd: &str) -> String { let mut snippet = match full_cmd.split_once('\n') { Some((first, _)) => format!("{first} ..."), @@ -1649,6 +1751,40 @@ mod tests { ); } + #[test] + fn ps_output_empty_snapshot() { + let cell = new_unified_exec_sessions_output(Vec::new()); + let rendered = render_lines(&cell.display_lines(60)).join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn ps_output_multiline_snapshot() { + let cell = new_unified_exec_sessions_output(vec![ + "echo hello\nand then some extra text".to_string(), + "rg \"foo\" src".to_string(), + ]); + let rendered = render_lines(&cell.display_lines(40)).join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn ps_output_long_command_snapshot() { + let cell = new_unified_exec_sessions_output(vec![String::from( + "rg \"foo\" src --glob '**/*.rs' --max-count 1000 --no-ignore --hidden --follow --glob '!target/**'", + )]); + let rendered = render_lines(&cell.display_lines(36)).join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn ps_output_many_sessions_snapshot() { + let cell = + new_unified_exec_sessions_output((0..20).map(|idx| format!("command {idx}")).collect()); + let rendered = render_lines(&cell.display_lines(32)).join("\n"); + insta::assert_snapshot!(rendered); + } + #[test] fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config(); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index bfc5616e2..220dc36b5 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -31,6 +31,7 @@ pub enum SlashCommand { Exit, Feedback, Rollout, + Ps, TestApproval, } @@ -50,6 +51,7 @@ impl SlashCommand { SlashCommand::Mention => "mention a file", SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Experimental => "toggle beta features", @@ -83,6 +85,7 @@ impl SlashCommand { | SlashCommand::Mention | SlashCommand::Skills | SlashCommand::Status + | SlashCommand::Ps | SlashCommand::Mcp | SlashCommand::Feedback | SlashCommand::Quit diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_empty_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_empty_snapshot.snap new file mode 100644 index 000000000..a638aca72 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_empty_snapshot.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/ps + +Background terminals + + • No background terminals running. diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap new file mode 100644 index 000000000..b9302295d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/ps + +Background terminals + + • rg "foo" src --glob '**/*. [...] diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_many_sessions_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_many_sessions_snapshot.snap new file mode 100644 index 000000000..d0138b274 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_many_sessions_snapshot.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/ps + +Background terminals + + • command 0 + • command 1 + • command 2 + • command 3 + • command 4 + • command 5 + • command 6 + • command 7 + • command 8 + • command 9 + • command 10 + • command 11 + • command 12 + • command 13 + • command 14 + • command 15 + • ... and 4 more running diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap new file mode 100644 index 000000000..c073349e8 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_multiline_snapshot.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/ps + +Background terminals + + • echo hello [...] + • rg "foo" src