feat: add /ps (#8279)

See snapshots for view of edge cases
This is still named `UnifiedExecSessions` for consistency across the
code but should be renamed to `BackgroundTerminals` in a follow-up

Example:
<img width="945" height="687" alt="Screenshot 2025-12-18 at 20 12 53"
src="https://github.com/user-attachments/assets/92f39ff2-243c-4006-b402-e3fa9e93c952"
/>
This commit is contained in:
jif-oai
2025-12-18 21:09:06 +00:00
committed by GitHub
parent 87abf06e78
commit 4fb0b547d6
11 changed files with 240 additions and 87 deletions

View File

@@ -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,
]
}

View File

@@ -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,
]
}

View File

@@ -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,
]
}

View File

@@ -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<String>,
@@ -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:?}"));
}
}

View File

@@ -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();

View File

@@ -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<String>,
}
impl UnifiedExecSessionsCell {
fn new(sessions: Vec<String>) -> Self {
Self { sessions }
}
}
impl HistoryCell for UnifiedExecSessionsCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
}
let wrap_width = width as usize;
let max_sessions = 16usize;
let mut out: Vec<Line<'static>> = 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<String>) -> 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();

View File

@@ -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

View File

@@ -0,0 +1,9 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
/ps
Background terminals
• No background terminals running.

View File

@@ -0,0 +1,9 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
/ps
Background terminals
• rg "foo" src --glob '**/*. [...]

View File

@@ -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

View File

@@ -0,0 +1,10 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
/ps
Background terminals
• echo hello [...]
• rg "foo" src