mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
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:
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
expression: rendered
|
||||
---
|
||||
/ps
|
||||
|
||||
Background terminals
|
||||
|
||||
• No background terminals running.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
expression: rendered
|
||||
---
|
||||
/ps
|
||||
|
||||
Background terminals
|
||||
|
||||
• rg "foo" src --glob '**/*. [...]
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
expression: rendered
|
||||
---
|
||||
/ps
|
||||
|
||||
Background terminals
|
||||
|
||||
• echo hello [...]
|
||||
• rg "foo" src
|
||||
Reference in New Issue
Block a user