feat: add output to /ps (#10154)

<img width="599" height="238" alt="Screenshot 2026-01-29 at 13 24 57"
src="https://github.com/user-attachments/assets/1e9a5af2-f649-476c-b310-ae4938814538"
/>
This commit is contained in:
jif-oai
2026-01-30 09:00:44 +01:00
committed by GitHub
parent 34f89b12d0
commit a270a28a06
6 changed files with 127 additions and 11 deletions

View File

@@ -225,7 +225,9 @@ struct RunningCommand {
struct UnifiedExecProcessSummary {
key: String,
call_id: String,
command_display: String,
recent_chunks: Vec<String>,
}
struct UnifiedExecWaitState {
@@ -1426,6 +1428,8 @@ impl ChatWidget {
}
fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) {
self.track_unified_exec_output_chunk(&ev.call_id, &ev.chunk);
let Some(cell) = self
.active_cell
.as_mut()
@@ -1545,11 +1549,15 @@ impl ChatWidget {
.iter_mut()
.find(|process| process.key == key)
{
existing.call_id = ev.call_id.clone();
existing.command_display = command_display;
existing.recent_chunks.clear();
} else {
self.unified_exec_processes.push(UnifiedExecProcessSummary {
key,
call_id: ev.call_id.clone(),
command_display,
recent_chunks: Vec::new(),
});
}
self.sync_unified_exec_footer();
@@ -1574,6 +1582,32 @@ impl ChatWidget {
self.bottom_pane.set_unified_exec_processes(processes);
}
/// Record recent stdout/stderr lines for the unified exec footer.
fn track_unified_exec_output_chunk(&mut self, call_id: &str, chunk: &[u8]) {
let Some(process) = self
.unified_exec_processes
.iter_mut()
.find(|process| process.call_id == call_id)
else {
return;
};
let text = String::from_utf8_lossy(chunk);
for line in text
.lines()
.map(str::trim_end)
.filter(|line| !line.is_empty())
{
process.recent_chunks.push(line.to_string());
}
const MAX_RECENT_CHUNKS: usize = 3;
if process.recent_chunks.len() > MAX_RECENT_CHUNKS {
let drop_count = process.recent_chunks.len() - MAX_RECENT_CHUNKS;
process.recent_chunks.drain(0..drop_count);
}
}
fn clear_unified_exec_processes(&mut self) {
if self.unified_exec_processes.is_empty() {
return;
@@ -3448,7 +3482,10 @@ impl ChatWidget {
let processes = self
.unified_exec_processes
.iter()
.map(|process| process.command_display.clone())
.map(|process| history_cell::UnifiedExecProcessDetails {
command_display: process.command_display.clone(),
recent_chunks: process.recent_chunks.clone(),
})
.collect();
self.add_to_history(history_cell::new_unified_exec_processes_output(processes));
}

View File

@@ -2025,7 +2025,9 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() {
chat.on_task_started();
chat.unified_exec_processes.push(UnifiedExecProcessSummary {
key: "proc-1".to_string(),
call_id: "call-1".to_string(),
command_display: "sleep 5".to_string(),
recent_chunks: Vec::new(),
});
chat.on_terminal_interaction(TerminalInteractionEvent {

View File

@@ -558,15 +558,21 @@ pub(crate) fn new_unified_exec_interaction(
#[derive(Debug)]
struct UnifiedExecProcessesCell {
processes: Vec<String>,
processes: Vec<UnifiedExecProcessDetails>,
}
impl UnifiedExecProcessesCell {
fn new(processes: Vec<String>) -> Self {
fn new(processes: Vec<UnifiedExecProcessDetails>) -> Self {
Self { processes }
}
}
#[derive(Debug, Clone)]
pub(crate) struct UnifiedExecProcessDetails {
pub(crate) command_display: String,
pub(crate) recent_chunks: Vec<String>,
}
impl HistoryCell for UnifiedExecProcessesCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if width == 0 {
@@ -589,10 +595,11 @@ impl HistoryCell for UnifiedExecProcessesCell {
let truncation_suffix = " [...]";
let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix);
let mut shown = 0usize;
for command in &self.processes {
for process in &self.processes {
if shown >= max_processes {
break;
}
let command = &process.command_display;
let (snippet, snippet_truncated) = {
let (first_line, has_more_lines) = match command.split_once('\n') {
Some((first, _)) => (first, true),
@@ -627,6 +634,32 @@ impl HistoryCell for UnifiedExecProcessesCell {
let (truncated, _, _) = take_prefix_by_width(&snippet, budget);
out.push(vec![prefix.dim(), truncated.cyan()].into());
}
let chunk_prefix_first = "";
let chunk_prefix_next = " ";
for (idx, chunk) in process.recent_chunks.iter().enumerate() {
let chunk_prefix = if idx == 0 {
chunk_prefix_first
} else {
chunk_prefix_next
};
let chunk_prefix_width = UnicodeWidthStr::width(chunk_prefix);
if wrap_width <= chunk_prefix_width {
out.push(Line::from(chunk_prefix.dim()));
continue;
}
let budget = wrap_width.saturating_sub(chunk_prefix_width);
let (truncated, remainder, _) = take_prefix_by_width(chunk, budget);
if !remainder.is_empty() && budget > truncation_suffix_width {
let available = budget.saturating_sub(truncation_suffix_width);
let (shorter, _, _) = take_prefix_by_width(chunk, available);
out.push(
vec![chunk_prefix.dim(), shorter.dim(), truncation_suffix.dim()].into(),
);
} else {
out.push(vec![chunk_prefix.dim(), truncated.dim()].into());
}
}
shown += 1;
}
@@ -650,7 +683,9 @@ impl HistoryCell for UnifiedExecProcessesCell {
}
}
pub(crate) fn new_unified_exec_processes_output(processes: Vec<String>) -> CompositeHistoryCell {
pub(crate) fn new_unified_exec_processes_output(
processes: Vec<UnifiedExecProcessDetails>,
) -> CompositeHistoryCell {
let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]);
let summary = UnifiedExecProcessesCell::new(processes);
CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)])
@@ -2022,8 +2057,14 @@ mod tests {
#[test]
fn ps_output_multiline_snapshot() {
let cell = new_unified_exec_processes_output(vec![
"echo hello\nand then some extra text".to_string(),
"rg \"foo\" src".to_string(),
UnifiedExecProcessDetails {
command_display: "echo hello\nand then some extra text".to_string(),
recent_chunks: vec!["hello".to_string(), "done".to_string()],
},
UnifiedExecProcessDetails {
command_display: "rg \"foo\" src".to_string(),
recent_chunks: vec!["src/main.rs:12:foo".to_string()],
},
]);
let rendered = render_lines(&cell.display_lines(40)).join("\n");
insta::assert_snapshot!(rendered);
@@ -2031,9 +2072,12 @@ mod tests {
#[test]
fn ps_output_long_command_snapshot() {
let cell = new_unified_exec_processes_output(vec![String::from(
"rg \"foo\" src --glob '**/*.rs' --max-count 1000 --no-ignore --hidden --follow --glob '!target/**'",
)]);
let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails {
command_display: String::from(
"rg \"foo\" src --glob '**/*.rs' --max-count 1000 --no-ignore --hidden --follow --glob '!target/**'",
),
recent_chunks: vec!["searching...".to_string()],
}]);
let rendered = render_lines(&cell.display_lines(36)).join("\n");
insta::assert_snapshot!(rendered);
}
@@ -2041,12 +2085,30 @@ mod tests {
#[test]
fn ps_output_many_sessions_snapshot() {
let cell = new_unified_exec_processes_output(
(0..20).map(|idx| format!("command {idx}")).collect(),
(0..20)
.map(|idx| UnifiedExecProcessDetails {
command_display: format!("command {idx}"),
recent_chunks: Vec::new(),
})
.collect(),
);
let rendered = render_lines(&cell.display_lines(32)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn ps_output_chunk_leading_whitespace_snapshot() {
let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails {
command_display: "just fix".to_string(),
recent_chunks: vec![
" indented first".to_string(),
" more indented".to_string(),
],
}]);
let rendered = render_lines(&cell.display_lines(60)).join("\n");
insta::assert_snapshot!(rendered);
}
#[tokio::test]
async fn mcp_tools_output_masks_sensitive_values() {
let mut config = test_config().await;

View File

@@ -0,0 +1,11 @@
---
source: tui/src/history_cell.rs
expression: rendered
---
/ps
Background terminals
• just fix
↳ indented first
more indented

View File

@@ -7,3 +7,4 @@ expression: rendered
Background terminals
• rg "foo" src --glob '**/*. [...]
↳ searching...

View File

@@ -7,4 +7,7 @@ expression: rendered
Background terminals
• echo hello [...]
↳ hello
done
• rg "foo" src
↳ src/main.rs:12:foo