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.
This commit is contained in:
@@ -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,
|
||||
),
|
||||
|
||||
+214
-20
@@ -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<SidebarToolRow> {
|
||||
};
|
||||
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<SidebarToolRow> {
|
||||
}
|
||||
}
|
||||
|
||||
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<SidebarToolRow>, limit: usize) -> Vec<SidebarTo
|
||||
|
||||
let mut candidates: Vec<Candidate> = 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<String> = 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<SidebarToolRow>, limit: usize) -> Vec<SidebarTo
|
||||
});
|
||||
}
|
||||
|
||||
for (order, mut row, count) in ci_poll_groups {
|
||||
if count > 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::<Vec<_>>().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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn actionable_shell_segment(command: &str) -> Option<String> {
|
||||
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<String> {
|
||||
let tokens: Vec<String> = 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user