From 9eb588c3836bf4686da6260be19048aed2fa21a4 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Thu, 14 May 2026 03:21:57 -0500 Subject: [PATCH] fix(tui): summarize live tool status noise (#1618) Summary: - concise live shell/tool labels - collapsed pending CI polling rows - hardened stale task-panel timing test Validation: CI green before merge. --- crates/tui/src/tui/footer_ui.rs | 4 +- crates/tui/src/tui/sidebar.rs | 234 +++++++++++++++++++++++++++++--- crates/tui/src/tui/ui/tests.rs | 30 +++- crates/tui/src/tui/ui_text.rs | 94 +++++++++++++ 4 files changed, 339 insertions(+), 23 deletions(-) diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 8086076c..c3f86f7a 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -15,7 +15,7 @@ use crate::tui::ui::{ active_foreground_shell_running, context_usage_snapshot, selected_detail_footer_label, status_color, }; -use crate::tui::ui_text::truncate_line_to_width; +use crate::tui::ui_text::{concise_shell_command_label, truncate_line_to_width}; use crate::tui::widgets::{FooterProps, FooterToast, FooterWidget, Renderable}; use crate::tui::workspace_context; @@ -265,7 +265,7 @@ fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatu }; match tool { ToolCell::Exec(exec) => snapshot.record( - format!("run {}", one_line_summary(&exec.command, 80)), + concise_shell_command_label(&exec.command, 80), exec.status, exec.started_at, ), diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 7c833205..e7a44bac 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -25,7 +25,7 @@ use crate::tools::todo::TodoStatus; use super::app::{App, SidebarFocus, TaskPanelEntry}; use super::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus, summarize_tool_output}; use super::subagent_routing::active_fanout_counts; -use super::ui_text::truncate_line_to_width; +use super::ui_text::{concise_shell_command_label, truncate_line_to_width}; /// Tolerance for floating-point cost comparison in the sidebar breakdown. /// Must be large enough that accumulated f64 error across hundreds of turns @@ -821,17 +821,19 @@ fn sidebar_tool_row_from_cell(cell: &HistoryCell) -> Option { }; match tool { ToolCell::Exec(exec) => Some(SidebarToolRow { - name: "run".to_string(), - status: exec.status, - summary: compact_join([ - exec.command.clone(), - exec.output_summary.clone().unwrap_or_default(), - exec.output - .as_deref() - .map(first_nonempty_line) - .unwrap_or_default() - .to_string(), - ]), + name: concise_shell_command_label(&exec.command, 48), + status: shell_status_for_sidebar( + &exec.command, + exec.status, + exec.output_summary.as_deref(), + exec.output.as_deref(), + ), + summary: shell_summary_for_sidebar( + &exec.command, + exec.status, + exec.output_summary.as_deref(), + exec.output.as_deref(), + ), duration_ms: exec.duration_ms.or_else(|| { (exec.status == ToolStatus::Running).then(|| { u64::try_from( @@ -934,6 +936,87 @@ fn sidebar_tool_row_from_cell(cell: &HistoryCell) -> Option { } } +fn shell_status_for_sidebar( + command: &str, + status: ToolStatus, + output_summary: Option<&str>, + output: Option<&str>, +) -> ToolStatus { + if status == ToolStatus::Failed && looks_like_pending_ci(command, output_summary, output) { + ToolStatus::Running + } else { + status + } +} + +fn shell_summary_for_sidebar( + command: &str, + status: ToolStatus, + output_summary: Option<&str>, + output: Option<&str>, +) -> String { + if status == ToolStatus::Failed && looks_like_pending_ci(command, output_summary, output) { + return format!( + "Waiting for CI \u{00B7} {} details", + crate::tui::key_shortcuts::tool_details_shortcut_label() + ); + } + + let summary = compact_join([ + output_summary.unwrap_or_default().to_string(), + output + .map(first_nonempty_line) + .unwrap_or_default() + .to_string(), + ]); + if status == ToolStatus::Failed { + failure_summary_with_hint(&summary) + } else { + summary + } +} + +fn looks_like_pending_ci( + command: &str, + output_summary: Option<&str>, + output: Option<&str>, +) -> bool { + let command_label = concise_shell_command_label(command, 80).to_ascii_lowercase(); + if !command_label.starts_with("gh pr checks") && !command_label.starts_with("gh run watch") { + return false; + } + + let text = compact_join([ + output_summary.unwrap_or_default().to_string(), + output.unwrap_or_default().to_string(), + ]) + .to_ascii_lowercase(); + if text.is_empty() { + return false; + } + let pending = ["pending", "queued", "in_progress", "in progress", "waiting"] + .iter() + .any(|needle| text.contains(needle)); + let hard_failure = ["failed", "failure", "error", "cancelled", "canceled"] + .iter() + .any(|needle| text.contains(needle)); + pending && !hard_failure +} + +fn failure_summary_with_hint(summary: &str) -> String { + let hint = format!( + "inspect details with {}", + crate::tui::key_shortcuts::tool_details_shortcut_label() + ); + if summary.trim().is_empty() { + hint + } else if summary.contains(&hint) { + summary.to_string() + } else { + format!("{summary} \u{00B7} {hint}") + } +} + fn friendly_generic_tool_name(name: &str) -> &str { match name { "task_shell_start" => "start shell job", @@ -997,8 +1080,8 @@ fn background_task_duplicates_live_tool( !command.is_empty() && active_rows.iter().any(|row| { row.status == ToolStatus::Running - && row.name == "run" - && normalize_activity_text(&row.summary).contains(&command) + && normalize_activity_text(&format!("{} {}", row.name, row.summary)) + .contains(&command) }) } @@ -1012,9 +1095,29 @@ fn editorial_tool_rows(rows: Vec, limit: usize) -> Vec = Vec::new(); let mut low_value_groups: Vec<(usize, SidebarToolRow, usize)> = Vec::new(); + let mut ci_poll_groups: Vec<(usize, SidebarToolRow, usize)> = Vec::new(); let mut seen_success: Vec = Vec::new(); - for (order, row) in rows.into_iter().enumerate() { + for (order, mut row) in rows.into_iter().enumerate() { + if row.status == ToolStatus::Failed { + row.summary = failure_summary_with_hint(&row.summary); + } + + if is_ci_poll_row(&row) { + if let Some((_, grouped, count)) = ci_poll_groups + .iter_mut() + .find(|(_, grouped, _)| grouped.name == row.name) + { + *count += 1; + if grouped.duration_ms.is_none() { + grouped.duration_ms = row.duration_ms; + } + } else { + ci_poll_groups.push((order, row, 1)); + } + continue; + } + if is_low_value_tool(&row.name) && row.status == ToolStatus::Success { if let Some((_, grouped, count)) = low_value_groups .iter_mut() @@ -1045,6 +1148,23 @@ fn editorial_tool_rows(rows: Vec, limit: usize) -> Vec 1 { + let command = row.name.clone(); + row.name = "Waiting for CI".to_string(); + row.summary = format!( + "{command} \u{00B7} {count} polls collapsed \u{00B7} {} details", + crate::tui::key_shortcuts::tool_details_shortcut_label() + ); + row.status = ToolStatus::Running; + } + candidates.push(Candidate { + rank: tool_row_rank(&row), + order, + row, + }); + } + for (order, mut row, count) in low_value_groups { if count > 1 { row.name = format!("{} x{count}", row.name); @@ -1075,6 +1195,10 @@ fn sidebar_row_identity(row: &SidebarToolRow) -> String { ) } +fn is_ci_poll_row(row: &SidebarToolRow) -> bool { + row.name.starts_with("gh pr checks") || row.name.starts_with("gh run watch") +} + fn normalize_activity_text(text: &str) -> String { text.split_whitespace().collect::>().join(" ") } @@ -1965,10 +2089,8 @@ mod tests { command: command.to_string(), status: ToolStatus::Running, output: None, - started_at: Some( - Instant::now() - ACTIVE_TOOL_STALE_RUNNING_ROW_TTL - Duration::from_secs(1), - ), - duration_ms: None, + started_at: None, + duration_ms: Some(ACTIVE_TOOL_STALE_RUNNING_ROW_TTL.as_millis() as u64 + 1), source: ExecSource::Assistant, interaction: None, output_summary: None, @@ -2100,6 +2222,77 @@ mod tests { ); } + #[test] + fn tasks_panel_collapses_repeated_pending_ci_polls() { + let mut app = create_test_app(); + for _ in 0..3 { + app.history.push(HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "cd /tmp/repo && sleep 15 && gh pr checks 1616 --repo Hmbown/DeepSeek-TUI" + .to_string(), + status: ToolStatus::Failed, + output: Some("Lint pending\nTest pending".to_string()), + started_at: None, + duration_ms: Some(15_000), + source: ExecSource::Assistant, + interaction: None, + output_summary: Some("2 checks pending".to_string()), + }))); + } + + let text = lines_to_text(&task_panel_lines(&app, 80, 12)); + + assert!( + text.iter().any(|line| line.contains("[~] Waiting for CI")), + "pending CI should not render as a hard failure: {text:?}" + ); + assert!( + text.iter().any(|line| line.contains("gh pr checks 1616")), + "concise command label should remain visible: {text:?}" + ); + assert!( + text.iter().any(|line| line.contains("3 polls collapsed")), + "repeated polling should collapse into one row: {text:?}" + ); + assert!( + text.iter() + .any(|line| line.contains(crate::tui::key_shortcuts::tool_details_shortcut_label())), + "collapsed CI row should point to details: {text:?}" + ); + assert!( + !text.iter().any(|line| line.contains("[!] gh pr checks")), + "pending CI should not look like a real failure: {text:?}" + ); + } + + #[test] + fn tasks_panel_failed_shell_rows_point_to_activity_details() { + let mut app = create_test_app(); + app.history.push(HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "cargo test -p deepseek-tui".to_string(), + status: ToolStatus::Failed, + output: Some("test failed".to_string()), + started_at: None, + duration_ms: Some(1_250), + source: ExecSource::Assistant, + interaction: None, + output_summary: Some("test failed".to_string()), + }))); + + let text = lines_to_text(&task_panel_lines(&app, 80, 8)); + + assert!( + text.iter().any(|line| line.contains("[!] cargo test")), + "failed shell command should keep its concise label: {text:?}" + ); + assert!( + text.iter().any(|line| line.contains(&format!( + "inspect details with {}", + crate::tui::key_shortcuts::tool_details_shortcut_label() + ))), + "failed row should include the next action: {text:?}" + ); + } + #[test] fn tasks_panel_keeps_duration_and_status_on_recent_shell_rows() { let mut app = create_test_app(); @@ -2117,7 +2310,8 @@ mod tests { let text = lines_to_text(&task_panel_lines(&app, 80, 8)); assert!( - text.iter().any(|line| line.contains("[x] run 1.2s")), + text.iter() + .any(|line| line.contains("[x] cargo check 1.2s")), "status marker and duration should stay in the row label: {text:?}" ); assert!( diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index deccfe34..b8a17937 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1456,12 +1456,40 @@ fn active_tool_status_label_summarizes_live_tool_group() { let label = active_tool_status_label(&app).expect("status label"); - assert!(label.contains("run cargo test")); + assert!(label.contains("cargo test")); assert!(label.contains("1 active")); assert!(label.contains("1 done")); assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label())); } +#[test] +fn active_tool_status_label_strips_shell_wrappers_from_ci_polling() { + let mut app = create_test_app(); + app.turn_started_at = Some(Instant::now() - Duration::from_secs(5)); + let mut active = ActiveCell::new(); + active.push_tool( + "exec-1", + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI" + .to_string(), + status: ToolStatus::Running, + output: None, + started_at: app.turn_started_at, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + })), + ); + app.active_cell = Some(active); + + let label = active_tool_status_label(&app).expect("status label"); + + assert!(label.contains("gh pr checks 1611"), "label: {label}"); + assert!(!label.contains("cd /tmp"), "label: {label}"); + assert!(!label.contains("sleep 15"), "label: {label}"); +} + #[test] fn active_tool_status_label_counts_foreground_rlm_work() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/ui_text.rs b/crates/tui/src/tui/ui_text.rs index c437319e..eac6d0cf 100644 --- a/crates/tui/src/tui/ui_text.rs +++ b/crates/tui/src/tui/ui_text.rs @@ -43,6 +43,85 @@ pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String { out } +pub(crate) fn concise_shell_command_label(command: &str, max_width: usize) -> String { + let normalized = normalize_shell_text(command); + if let Some(label) = gh_command_label(&normalized) { + return truncate_line_to_width(&label, max_width); + } + + let segment = actionable_shell_segment(&normalized).unwrap_or_else(|| normalized.clone()); + truncate_line_to_width(&segment, max_width) +} + +fn normalize_shell_text(text: &str) -> String { + text.split_whitespace().collect::>().join(" ") +} + +fn actionable_shell_segment(command: &str) -> Option { + command + .replace("&&", "\n") + .replace("||", "\n") + .replace('|', "\n") + .split(['\n', ';']) + .map(str::trim) + .find(|segment| { + !segment.is_empty() + && !segment.starts_with("cd ") + && !segment.starts_with("sleep ") + && !segment.starts_with("export ") + && *segment != "true" + && *segment != ":" + }) + .map(str::to_string) +} + +fn gh_command_label(command: &str) -> Option { + let tokens: Vec = command + .split_whitespace() + .map(|token| { + token + .trim_matches(|ch: char| matches!(ch, '\'' | '"' | '(' | ')' | ';' | ',')) + .to_string() + }) + .filter(|token| !token.is_empty()) + .collect(); + + for index in 0..tokens.len() { + let token = tokens[index].as_str(); + if token != "gh" && !token.ends_with("/gh") { + continue; + } + let Some(area) = tokens.get(index + 1).map(String::as_str) else { + continue; + }; + let Some(action) = tokens.get(index + 2).map(String::as_str) else { + continue; + }; + if !matches!(area, "pr" | "run") { + continue; + } + if !matches!( + action, + "checks" | "view" | "status" | "list" | "watch" | "rerun" + ) { + continue; + } + + let mut label = format!("gh {area} {action}"); + if let Some(target) = tokens + .iter() + .skip(index + 3) + .map(String::as_str) + .find(|token| !token.starts_with('-') && *token != "&&" && *token != ";") + { + label.push(' '); + label.push_str(target); + } + return Some(label); + } + None +} + pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String { cell.transcript_lines(width) .into_iter() @@ -172,4 +251,19 @@ mod tests { let text = "ab"; assert_eq!(slice_text(text, 1, 5), "b"); } + + #[test] + fn concise_shell_command_label_prefers_gh_pr_checks_over_wrappers() { + let label = concise_shell_command_label( + "cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/DeepSeek-TUI", + 80, + ); + assert_eq!(label, "gh pr checks 1611"); + } + + #[test] + fn concise_shell_command_label_falls_back_to_actionable_segment() { + let label = concise_shell_command_label("cd /tmp/repo && cargo test --workspace", 80); + assert_eq!(label, "cargo test --workspace"); + } }