Skip to content

Commit 4fb0b54

Browse files
authored
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" />
1 parent 87abf06 commit 4fb0b54

11 files changed

+234
-81
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
source: tui/src/bottom_pane/unified_exec_footer.rs
3+
expression: "format!(\"{buf:?}\")"
4+
---
5+
Buffer {
6+
area: Rect { x: 0, y: 0, width: 50, height: 1 },
7+
content: [
8+
" 123 background terminals running · /ps to view ",
9+
],
10+
styles: [
11+
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
12+
x: 48, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
13+
]
14+
}
Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,14 @@
11
---
22
source: tui/src/bottom_pane/unified_exec_footer.rs
3-
assertion_line: 123
43
expression: "format!(\"{buf:?}\")"
54
---
65
Buffer {
7-
area: Rect { x: 0, y: 0, width: 50, height: 3 },
6+
area: Rect { x: 0, y: 0, width: 50, height: 1 },
87
content: [
9-
" Background terminal running: echo hello · rg ",
10-
" "foo" src · 1 more ",
11-
" running ",
8+
" 1 background terminal running · /ps to view ",
129
],
1310
styles: [
1411
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
15-
x: 30, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
16-
x: 31, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE,
17-
x: 41, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
18-
x: 44, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE,
19-
x: 46, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
20-
x: 31, y: 1, fg: Cyan, bg: Reset, underline: Reset, modifier: NONE,
21-
x: 40, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
22-
x: 49, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
23-
x: 31, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
24-
x: 38, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
12+
x: 45, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
2513
]
2614
}

codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_two_sessions.snap

Lines changed: 0 additions & 22 deletions
This file was deleted.

codex-rs/tui/src/bottom_pane/unified_exec_footer.rs

Lines changed: 12 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,8 @@ use ratatui::style::Stylize;
44
use ratatui::text::Line;
55
use ratatui::widgets::Paragraph;
66

7+
use crate::live_wrap::take_prefix_by_width;
78
use crate::render::renderable::Renderable;
8-
use crate::text_formatting::truncate_text;
9-
use crate::wrapping::RtOptions;
10-
use crate::wrapping::word_wrap_lines;
11-
12-
const MAX_SESSION_LABEL_GRAPHEMES: usize = 48;
13-
const MAX_VISIBLE_SESSIONS: usize = 2;
149

1510
pub(crate) struct UnifiedExecFooter {
1611
sessions: Vec<String>,
@@ -40,34 +35,11 @@ impl UnifiedExecFooter {
4035
return Vec::new();
4136
}
4237

43-
let label = " Background terminal running:";
44-
let mut spans = Vec::new();
45-
spans.push(label.dim());
46-
spans.push(" ".into());
47-
48-
let visible = self.sessions.iter().take(MAX_VISIBLE_SESSIONS);
49-
let mut visible_count = 0usize;
50-
for (idx, command) in visible.enumerate() {
51-
if idx > 0 {
52-
spans.push(" · ".dim());
53-
}
54-
let truncated = truncate_text(command, MAX_SESSION_LABEL_GRAPHEMES);
55-
spans.push(truncated.cyan());
56-
visible_count += 1;
57-
}
58-
59-
let remaining = self.sessions.len().saturating_sub(visible_count);
60-
if remaining > 0 {
61-
spans.push(" · ".dim());
62-
spans.push(format!("{remaining} more running").dim());
63-
}
64-
65-
let indent = " ".repeat(label.len() + 1);
66-
let line = Line::from(spans);
67-
word_wrap_lines(
68-
std::iter::once(line),
69-
RtOptions::new(width as usize).subsequent_indent(Line::from(indent).dim()),
70-
)
38+
let count = self.sessions.len();
39+
let plural = if count == 1 { "" } else { "s" };
40+
let message = format!(" {count} background terminal{plural} running · /ps to view");
41+
let (truncated, _, _) = take_prefix_by_width(&message, width as usize);
42+
vec![Line::from(truncated.dim())]
7143
}
7244
}
7345

@@ -98,28 +70,24 @@ mod tests {
9870
}
9971

10072
#[test]
101-
fn render_two_sessions() {
73+
fn render_more_sessions() {
10274
let mut footer = UnifiedExecFooter::new();
103-
footer.set_sessions(vec!["echo hello".to_string(), "rg \"foo\" src".to_string()]);
75+
footer.set_sessions(vec!["rg \"foo\" src".to_string()]);
10476
let width = 50;
10577
let height = footer.desired_height(width);
10678
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
10779
footer.render(Rect::new(0, 0, width, height), &mut buf);
108-
assert_snapshot!("render_two_sessions", format!("{buf:?}"));
80+
assert_snapshot!("render_more_sessions", format!("{buf:?}"));
10981
}
11082

11183
#[test]
112-
fn render_more_sessions() {
84+
fn render_many_sessions() {
11385
let mut footer = UnifiedExecFooter::new();
114-
footer.set_sessions(vec![
115-
"echo hello".to_string(),
116-
"rg \"foo\" src".to_string(),
117-
"cat README.md".to_string(),
118-
]);
86+
footer.set_sessions((0..123).map(|idx| format!("cmd {idx}")).collect());
11987
let width = 50;
12088
let height = footer.desired_height(width);
12189
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
12290
footer.render(Rect::new(0, 0, width, height), &mut buf);
123-
assert_snapshot!("render_more_sessions", format!("{buf:?}"));
91+
assert_snapshot!("render_many_sessions", format!("{buf:?}"));
12492
}
12593
}

codex-rs/tui/src/chatwidget.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1700,6 +1700,9 @@ impl ChatWidget {
17001700
SlashCommand::Status => {
17011701
self.add_status_output();
17021702
}
1703+
SlashCommand::Ps => {
1704+
self.add_ps_output();
1705+
}
17031706
SlashCommand::Mcp => {
17041707
self.add_mcp_output();
17051708
}
@@ -2154,6 +2157,16 @@ impl ChatWidget {
21542157
self.model_family.get_model_slug(),
21552158
));
21562159
}
2160+
2161+
pub(crate) fn add_ps_output(&mut self) {
2162+
let sessions = self
2163+
.unified_exec_sessions
2164+
.iter()
2165+
.map(|session| session.command_display.clone())
2166+
.collect();
2167+
self.add_to_history(history_cell::new_unified_exec_sessions_output(sessions));
2168+
}
2169+
21572170
fn stop_rate_limit_poller(&mut self) {
21582171
if let Some(handle) = self.rate_limit_poller.take() {
21592172
handle.abort();

codex-rs/tui/src/history_cell.rs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::exec_cell::output_lines;
77
use crate::exec_cell::spinner;
88
use crate::exec_command::relativize_to_home;
99
use crate::exec_command::strip_bash_lc_and_escape;
10+
use crate::live_wrap::take_prefix_by_width;
1011
use crate::markdown::append_markdown;
1112
use crate::render::line_utils::line_to_static;
1213
use crate::render::line_utils::prefix_lines;
@@ -56,6 +57,7 @@ use std::path::PathBuf;
5657
use std::time::Duration;
5758
use std::time::Instant;
5859
use tracing::error;
60+
use unicode_segmentation::UnicodeSegmentation;
5961
use unicode_width::UnicodeWidthStr;
6062

6163
/// Represents an event to display in the conversation history. Returns its
@@ -441,6 +443,106 @@ pub(crate) fn new_unified_exec_interaction(
441443
UnifiedExecInteractionCell::new(command_display, stdin)
442444
}
443445

446+
#[derive(Debug)]
447+
struct UnifiedExecSessionsCell {
448+
sessions: Vec<String>,
449+
}
450+
451+
impl UnifiedExecSessionsCell {
452+
fn new(sessions: Vec<String>) -> Self {
453+
Self { sessions }
454+
}
455+
}
456+
457+
impl HistoryCell for UnifiedExecSessionsCell {
458+
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
459+
if width == 0 {
460+
return Vec::new();
461+
}
462+
463+
let wrap_width = width as usize;
464+
let max_sessions = 16usize;
465+
let mut out: Vec<Line<'static>> = Vec::new();
466+
out.push(vec!["Background terminals".bold()].into());
467+
out.push("".into());
468+
469+
if self.sessions.is_empty() {
470+
out.push(" • No background terminals running.".italic().into());
471+
return out;
472+
}
473+
474+
let prefix = " • ";
475+
let prefix_width = UnicodeWidthStr::width(prefix);
476+
let truncation_suffix = " [...]";
477+
let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix);
478+
let mut shown = 0usize;
479+
for command in &self.sessions {
480+
if shown >= max_sessions {
481+
break;
482+
}
483+
let (snippet, snippet_truncated) = {
484+
let (first_line, has_more_lines) = match command.split_once('\n') {
485+
Some((first, _)) => (first, true),
486+
None => (command.as_str(), false),
487+
};
488+
let max_graphemes = 80;
489+
let mut graphemes = first_line.grapheme_indices(true);
490+
if let Some((byte_index, _)) = graphemes.nth(max_graphemes) {
491+
(first_line[..byte_index].to_string(), true)
492+
} else {
493+
(first_line.to_string(), has_more_lines)
494+
}
495+
};
496+
if wrap_width <= prefix_width {
497+
out.push(Line::from(prefix.dim()));
498+
shown += 1;
499+
continue;
500+
}
501+
let budget = wrap_width.saturating_sub(prefix_width);
502+
let mut needs_suffix = snippet_truncated;
503+
if !needs_suffix {
504+
let (_, remainder, _) = take_prefix_by_width(&snippet, budget);
505+
if !remainder.is_empty() {
506+
needs_suffix = true;
507+
}
508+
}
509+
if needs_suffix && budget > truncation_suffix_width {
510+
let available = budget.saturating_sub(truncation_suffix_width);
511+
let (truncated, _, _) = take_prefix_by_width(&snippet, available);
512+
out.push(vec![prefix.dim(), truncated.cyan(), truncation_suffix.dim()].into());
513+
} else {
514+
let (truncated, _, _) = take_prefix_by_width(&snippet, budget);
515+
out.push(vec![prefix.dim(), truncated.cyan()].into());
516+
}
517+
shown += 1;
518+
}
519+
520+
let remaining = self.sessions.len().saturating_sub(shown);
521+
if remaining > 0 {
522+
let more_text = format!("... and {remaining} more running");
523+
if wrap_width <= prefix_width {
524+
out.push(Line::from(prefix.dim()));
525+
} else {
526+
let budget = wrap_width.saturating_sub(prefix_width);
527+
let (truncated, _, _) = take_prefix_by_width(&more_text, budget);
528+
out.push(vec![prefix.dim(), truncated.dim()].into());
529+
}
530+
}
531+
532+
out
533+
}
534+
535+
fn desired_height(&self, width: u16) -> u16 {
536+
self.display_lines(width).len() as u16
537+
}
538+
}
539+
540+
pub(crate) fn new_unified_exec_sessions_output(sessions: Vec<String>) -> CompositeHistoryCell {
541+
let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]);
542+
let summary = UnifiedExecSessionsCell::new(sessions);
543+
CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)])
544+
}
545+
444546
fn truncate_exec_snippet(full_cmd: &str) -> String {
445547
let mut snippet = match full_cmd.split_once('\n') {
446548
Some((first, _)) => format!("{first} ..."),
@@ -1649,6 +1751,40 @@ mod tests {
16491751
);
16501752
}
16511753

1754+
#[test]
1755+
fn ps_output_empty_snapshot() {
1756+
let cell = new_unified_exec_sessions_output(Vec::new());
1757+
let rendered = render_lines(&cell.display_lines(60)).join("\n");
1758+
insta::assert_snapshot!(rendered);
1759+
}
1760+
1761+
#[test]
1762+
fn ps_output_multiline_snapshot() {
1763+
let cell = new_unified_exec_sessions_output(vec![
1764+
"echo hello\nand then some extra text".to_string(),
1765+
"rg \"foo\" src".to_string(),
1766+
]);
1767+
let rendered = render_lines(&cell.display_lines(40)).join("\n");
1768+
insta::assert_snapshot!(rendered);
1769+
}
1770+
1771+
#[test]
1772+
fn ps_output_long_command_snapshot() {
1773+
let cell = new_unified_exec_sessions_output(vec![String::from(
1774+
"rg \"foo\" src --glob '**/*.rs' --max-count 1000 --no-ignore --hidden --follow --glob '!target/**'",
1775+
)]);
1776+
let rendered = render_lines(&cell.display_lines(36)).join("\n");
1777+
insta::assert_snapshot!(rendered);
1778+
}
1779+
1780+
#[test]
1781+
fn ps_output_many_sessions_snapshot() {
1782+
let cell =
1783+
new_unified_exec_sessions_output((0..20).map(|idx| format!("command {idx}")).collect());
1784+
let rendered = render_lines(&cell.display_lines(32)).join("\n");
1785+
insta::assert_snapshot!(rendered);
1786+
}
1787+
16521788
#[test]
16531789
fn mcp_tools_output_masks_sensitive_values() {
16541790
let mut config = test_config();

codex-rs/tui/src/slash_command.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub enum SlashCommand {
3131
Exit,
3232
Feedback,
3333
Rollout,
34+
Ps,
3435
TestApproval,
3536
}
3637

@@ -50,6 +51,7 @@ impl SlashCommand {
5051
SlashCommand::Mention => "mention a file",
5152
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
5253
SlashCommand::Status => "show current session configuration and token usage",
54+
SlashCommand::Ps => "list background terminals",
5355
SlashCommand::Model => "choose what model and reasoning effort to use",
5456
SlashCommand::Approvals => "choose what Codex can do without approval",
5557
SlashCommand::Experimental => "toggle beta features",
@@ -83,6 +85,7 @@ impl SlashCommand {
8385
| SlashCommand::Mention
8486
| SlashCommand::Skills
8587
| SlashCommand::Status
88+
| SlashCommand::Ps
8689
| SlashCommand::Mcp
8790
| SlashCommand::Feedback
8891
| SlashCommand::Quit
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
source: tui/src/history_cell.rs
3+
expression: rendered
4+
---
5+
/ps
6+
7+
Background terminals
8+
9+
No background terminals running.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
source: tui/src/history_cell.rs
3+
expression: rendered
4+
---
5+
/ps
6+
7+
Background terminals
8+
9+
rg "foo" src --glob '**/*. [...]

0 commit comments

Comments
 (0)