From a64eb06a8596a8143e5f937191ab1e5ff163a7d8 Mon Sep 17 00:00:00 2001 From: "contextbridge-pr-automation[bot]" <259134118+contextbridge-pr-automation[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:57:02 +0000 Subject: [PATCH 1/4] fix(wisp): render finished bash tool previews as completed actions A finished bash call kept the agent's "Run command" title and sat on a contrasting code background, so the row still read as work in flight. Render the title as "Ran" once the call is no longer running, and drop the background from the highlighted command so it sits directly on the terminal's own background with token colors only. Fixes #432 --- crates/wisp/src/conversation/tool_calls.rs | 8 +++++ crates/wisp/src/conversation/tool_view.rs | 26 +++++++++++---- crates/wisp/tests/tui/conversation.rs | 37 +++++++++++++++++++--- crates/wisp/tests/tui/subagents.rs | 4 +-- 4 files changed, 61 insertions(+), 14 deletions(-) diff --git a/crates/wisp/src/conversation/tool_calls.rs b/crates/wisp/src/conversation/tool_calls.rs index 475cef4d9..af563b731 100644 --- a/crates/wisp/src/conversation/tool_calls.rs +++ b/crates/wisp/src/conversation/tool_calls.rs @@ -18,6 +18,10 @@ pub struct SubAgentToolCall { } impl SubAgentToolCall { + pub(crate) fn is_bash(&self) -> bool { + self.kind == ToolKind::Bash + } + pub fn bash_command(&self) -> Option { bash_command(self.kind, &self.raw_input) } @@ -186,6 +190,10 @@ impl ToolCall { } } + pub(crate) fn is_bash(&self) -> bool { + self.kind == ToolKind::Bash + } + pub fn bash_command(&self) -> Option { bash_command(self.kind, &self.raw_input) } diff --git a/crates/wisp/src/conversation/tool_view.rs b/crates/wisp/src/conversation/tool_view.rs index 286525be3..5bcf0bf99 100644 --- a/crates/wisp/src/conversation/tool_view.rs +++ b/crates/wisp/src/conversation/tool_view.rs @@ -31,7 +31,10 @@ pub(crate) fn tool_lines( Span::raw(" ".repeat(padding)), status_glyph(&tool.status, spinner_tick, theme), Span::raw(" "), - Span::styled(tool.title.clone(), Style::new().fg(theme.text_primary)), + Span::styled( + display_title(&tool.title, tool.is_bash(), &tool.status).to_owned(), + Style::new().fg(theme.text_primary), + ), ]); let suffix = tool_suffix(detail, &tool.status, theme); let mut lines = tool_line(prefix, suffix, bash_command, content_width, padding + 2, theme, highlighter); @@ -91,7 +94,7 @@ fn sub_agent_tree_lines( let prefix = Line::from(vec![ Span::raw(format!("{pad}{branch}")), status_glyph(&tool.status, spinner_tick, theme), - Span::raw(format!(" {}", tool.name)), + Span::raw(format!(" {}", display_title(&tool.name, tool.is_bash(), &tool.status))), ]); let suffix = tool_suffix(detail, &tool.status, theme); lines.extend(tool_line(prefix, suffix, bash_command, content_width, padding + 6, theme, highlighter)); @@ -140,6 +143,16 @@ fn bash_tool_detail(command: &str, display_value: Option<&str>, status: &ToolSta value.rfind(" (exit ").map_or_else(|| format!(" ({value})"), |index| format!(" {}", &value[index + 1..])) } +/// A finished bash call reads as a completed action: the agent's "Run command" +/// title renders as "Ran" once the command is no longer running. +fn display_title<'a>(title: &'a str, is_bash: bool, status: &ToolStatus) -> &'a str { + if is_bash && !matches!(status, ToolStatus::Running) && title == "Run command" { + "Ran" + } else { + title + } +} + /// The muted detail and, on failure, the error cause that trail a tool line. fn tool_suffix(detail: String, status: &ToolStatus, theme: &Theme) -> Vec> { let mut suffix = vec![Span::styled(detail, Style::new().fg(theme.muted))]; @@ -164,7 +177,7 @@ fn tool_line( if let Some(command_lines) = &command_lines && let Some(first) = command_lines.first() { - line.push_span(Span::styled(" ", Style::new().bg(theme.background))); + line.push_span(Span::raw(" ")); line.spans.extend(styled_code_line(first.clone(), theme).spans); } line.spans.extend(suffix); @@ -185,10 +198,9 @@ fn tool_line( lines } +/// Token colors only: the command sits directly on the terminal's own +/// background rather than a code block behind it. fn styled_code_line(mut line: Line<'static>, theme: &Theme) -> Line<'static> { - line.style = line.style.patch(Style::new().fg(theme.code_fg).bg(theme.code_bg)); - for span in &mut line.spans { - span.style = span.style.patch(Style::new().bg(theme.code_bg)); - } + line.style = line.style.patch(Style::new().fg(theme.code_fg)); line } diff --git a/crates/wisp/tests/tui/conversation.rs b/crates/wisp/tests/tui/conversation.rs index 22b47eadb..c4e22ef97 100644 --- a/crates/wisp/tests/tui/conversation.rs +++ b/crates/wisp/tests/tui/conversation.rs @@ -471,12 +471,12 @@ fn completed_bash_tool_renders_the_command_with_shell_syntax_highlighting() { assert!(text.contains(&format!("Bash {command}")), "command should share the tool row: {text:?}"); let command_start = u16::try_from(text[..text.find(command).expect("command position")].width()).unwrap(); let gap = conversation.cell((conversation.area.left() + command_start - 1, row)).expect("gap before command"); - assert_eq!(gap.bg, ui.app().theme().background, "the gap should use the normal background"); + assert_eq!(gap.bg, Color::Reset, "the gap should not set a background"); let cells = (command_start..command_start + u16::try_from(command.width()).unwrap()) .filter_map(|offset| conversation.cell((conversation.area.left() + offset, row))) .filter(|cell| cell.symbol() != " ") .collect::>(); - assert!(cells.iter().all(|cell| cell.bg == ui.app().theme().code_bg), "command should use the code background"); + assert!(cells.iter().all(|cell| cell.bg == Color::Reset), "command should sit on the terminal background"); let keyword = cells.iter().find(|cell| cell.symbol() == "i").expect("if keyword"); let variable = cells.iter().find(|cell| cell.symbol() == "$").expect("shell variable"); assert_ne!(keyword.fg, variable.fg, "shell keywords and variables should use distinct token colors"); @@ -509,11 +509,38 @@ fn bash_tool_keeps_highlighting_after_title_and_display_metadata_updates() { let viewport = ui.viewport_text(); assert!( - viewport.contains("Run command cargo test (exit 0)"), - "result and command should share the tool row: {viewport}" + viewport.contains("Ran cargo test (exit 0)") && !viewport.contains("Run command"), + "a finished bash call should read as completed: {viewport}" ); assert_eq!(viewport.matches(command).count(), 1, "the command should render exactly once: {viewport}"); - assert!(has_cell(&ui.conversation(), "c", |cell| cell.bg == ui.app().theme().code_bg)); + assert!(!has_cell(&ui.conversation(), "c", |cell| cell.bg == ui.app().theme().code_bg)); +} + +#[test] +fn running_bash_tool_keeps_the_run_command_title() { + let mut ui = TestUi::with_dimensions(100, 15); + ui.submit("run shell command"); + let mut tool_meta = serde_json::Map::new(); + tool_meta.insert(acp_utils::AETHER_TOOL_NAME_META_KEY.to_string(), "coding__bash".into()); + let command = "cargo test"; + let tool = acp::ToolCall::new("bash-1".to_string(), "Bash") + .raw_input(serde_json::json!({"command": command})) + .meta(tool_meta); + ui.acp_event(session_update(acp::SessionUpdate::ToolCall(tool))); + let mut update_meta = serde_json::Map::new(); + update_meta.insert("display_value".to_string(), format!("{command} (running)").into()); + ui.acp_event(session_update(acp::SessionUpdate::ToolCallUpdate( + acp::ToolCallUpdate::new( + "bash-1".to_string(), + acp::ToolCallUpdateFields::new().title("Run command").status(acp::ToolCallStatus::InProgress), + ) + .meta(update_meta), + ))); + + ui.draw(); + + let viewport = ui.viewport_text(); + assert!(viewport.contains("Run command cargo test"), "a running bash call should keep its title: {viewport}"); } #[test] diff --git a/crates/wisp/tests/tui/subagents.rs b/crates/wisp/tests/tui/subagents.rs index fbce67ddc..d86fecb8b 100644 --- a/crates/wisp/tests/tui/subagents.rs +++ b/crates/wisp/tests/tui/subagents.rs @@ -179,12 +179,12 @@ fn completed_sub_agent_bash_tool_renders_a_highlighted_command() { assert!(text.contains(&format!("bash {command}")), "command should share the tool row: {text:?}"); let command_start = u16::try_from(text[..text.find(command).expect("command position")].width()).unwrap(); let gap = conversation.cell((conversation.area.left() + command_start - 1, row)).expect("gap before command"); - assert_eq!(gap.bg, ui.app().theme().background, "the gap should use the normal background"); + assert_eq!(gap.bg, Color::Reset, "the gap should not set a background"); let cells = (command_start..command_start + u16::try_from(command.width()).unwrap()) .filter_map(|offset| conversation.cell((conversation.area.left() + offset, row))) .filter(|cell| cell.symbol() != " ") .collect::>(); - assert!(cells.iter().all(|cell| cell.bg == ui.app().theme().code_bg), "command should use the code background"); + assert!(cells.iter().all(|cell| cell.bg == Color::Reset), "command should sit on the terminal background"); let keyword = cells.iter().find(|cell| cell.symbol() == "i").expect("if keyword"); let variable = cells.iter().find(|cell| cell.symbol() == "$").expect("shell variable"); assert_ne!(keyword.fg, variable.fg, "shell keywords and variables should use distinct token colors"); From 9633ae2879cbcdaa8af1952bd85929c1877d2a2b Mon Sep 17 00:00:00 2001 From: "contextbridge-pr-automation[bot]" <259134118+contextbridge-pr-automation[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:03:20 +0000 Subject: [PATCH 2/4] fix(mcp-servers): title finished bash commands "Ran" The bash tool attached a "Run command" title to its result display metadata, so finished calls kept reading as work in flight. Send "Ran" for finished commands instead, including timed-out ones; the in-flight preview still reports "Run command". --- .../mcp-servers/src/coding/tools/bash/mod.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/mcp-servers/src/coding/tools/bash/mod.rs b/crates/mcp-servers/src/coding/tools/bash/mod.rs index 23b54c0a0..adc6f31ab 100644 --- a/crates/mcp-servers/src/coding/tools/bash/mod.rs +++ b/crates/mcp-servers/src/coding/tools/bash/mod.rs @@ -118,7 +118,7 @@ pub async fn execute_command( process_group.kill(); let _ = tokio::time::timeout(Duration::from_secs(5), &mut child_task).await; let display_meta = ToolDisplayMeta::new( - "Run command", + "Ran", format!("{} (exit -1, timed out)", truncate(&args.command, 40)), ); return Ok(BashOutput { @@ -145,8 +145,7 @@ pub async fn execute_command( let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let exit_code = output.status.code().unwrap_or(-1); - let display_meta = - ToolDisplayMeta::new("Run command", format!("{} (exit {exit_code})", truncate(&args.command, 40))); + let display_meta = ToolDisplayMeta::new("Ran", format!("{} (exit {exit_code})", truncate(&args.command, 40))); Ok(BashOutput { output: format!("{stdout}{stderr}"), exit_code, killed: false, meta: Some(display_meta.into()) }) } @@ -188,6 +187,20 @@ impl Drop for ProcessGroupGuard { mod tests { use super::*; + #[tokio::test] + async fn finished_commands_are_titled_ran() { + let output = execute_command( + BashInput { command: "printf '%s' done".into(), ..Default::default() }, + None, + &BashEnvironment::new(), + ) + .await + .unwrap(); + let meta = output.meta.expect("finished bash results should carry display metadata"); + assert_eq!(meta.display.title, "Ran"); + assert!(meta.display.value.contains("(exit 0)"), "value should report the exit: {}", meta.display.value); + } + #[tokio::test] async fn environment_overrides_are_passed_to_bash() { let environment = BashEnvironment::new().with_var("AETHER_TEST_VALUE", "present"); From 7ce96760bf01a50679549e8fd1ae7ccde718fdfc Mon Sep 17 00:00:00 2001 From: "contextbridge-pr-automation[bot]" <259134118+contextbridge-pr-automation[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:03:25 +0000 Subject: [PATCH 3/4] refactor(wisp): render tool titles as sent by the agent Wisp rewrote a finished bash call's "Run command" title to "Ran" at render time, mucking with display metadata that belongs to the tool. The bash tool now sends that title itself, so drop the title rewriting and the is_bash helpers and render titles verbatim. --- crates/wisp/src/conversation/tool_calls.rs | 8 ------ crates/wisp/src/conversation/tool_view.rs | 17 ++----------- crates/wisp/tests/tui/conversation.rs | 29 +--------------------- 3 files changed, 3 insertions(+), 51 deletions(-) diff --git a/crates/wisp/src/conversation/tool_calls.rs b/crates/wisp/src/conversation/tool_calls.rs index af563b731..475cef4d9 100644 --- a/crates/wisp/src/conversation/tool_calls.rs +++ b/crates/wisp/src/conversation/tool_calls.rs @@ -18,10 +18,6 @@ pub struct SubAgentToolCall { } impl SubAgentToolCall { - pub(crate) fn is_bash(&self) -> bool { - self.kind == ToolKind::Bash - } - pub fn bash_command(&self) -> Option { bash_command(self.kind, &self.raw_input) } @@ -190,10 +186,6 @@ impl ToolCall { } } - pub(crate) fn is_bash(&self) -> bool { - self.kind == ToolKind::Bash - } - pub fn bash_command(&self) -> Option { bash_command(self.kind, &self.raw_input) } diff --git a/crates/wisp/src/conversation/tool_view.rs b/crates/wisp/src/conversation/tool_view.rs index 5bcf0bf99..db763255a 100644 --- a/crates/wisp/src/conversation/tool_view.rs +++ b/crates/wisp/src/conversation/tool_view.rs @@ -31,10 +31,7 @@ pub(crate) fn tool_lines( Span::raw(" ".repeat(padding)), status_glyph(&tool.status, spinner_tick, theme), Span::raw(" "), - Span::styled( - display_title(&tool.title, tool.is_bash(), &tool.status).to_owned(), - Style::new().fg(theme.text_primary), - ), + Span::styled(tool.title.clone(), Style::new().fg(theme.text_primary)), ]); let suffix = tool_suffix(detail, &tool.status, theme); let mut lines = tool_line(prefix, suffix, bash_command, content_width, padding + 2, theme, highlighter); @@ -94,7 +91,7 @@ fn sub_agent_tree_lines( let prefix = Line::from(vec![ Span::raw(format!("{pad}{branch}")), status_glyph(&tool.status, spinner_tick, theme), - Span::raw(format!(" {}", display_title(&tool.name, tool.is_bash(), &tool.status))), + Span::raw(format!(" {}", tool.name)), ]); let suffix = tool_suffix(detail, &tool.status, theme); lines.extend(tool_line(prefix, suffix, bash_command, content_width, padding + 6, theme, highlighter)); @@ -143,16 +140,6 @@ fn bash_tool_detail(command: &str, display_value: Option<&str>, status: &ToolSta value.rfind(" (exit ").map_or_else(|| format!(" ({value})"), |index| format!(" {}", &value[index + 1..])) } -/// A finished bash call reads as a completed action: the agent's "Run command" -/// title renders as "Ran" once the command is no longer running. -fn display_title<'a>(title: &'a str, is_bash: bool, status: &ToolStatus) -> &'a str { - if is_bash && !matches!(status, ToolStatus::Running) && title == "Run command" { - "Ran" - } else { - title - } -} - /// The muted detail and, on failure, the error cause that trail a tool line. fn tool_suffix(detail: String, status: &ToolStatus, theme: &Theme) -> Vec> { let mut suffix = vec![Span::styled(detail, Style::new().fg(theme.muted))]; diff --git a/crates/wisp/tests/tui/conversation.rs b/crates/wisp/tests/tui/conversation.rs index c4e22ef97..61999086b 100644 --- a/crates/wisp/tests/tui/conversation.rs +++ b/crates/wisp/tests/tui/conversation.rs @@ -500,7 +500,7 @@ fn bash_tool_keeps_highlighting_after_title_and_display_metadata_updates() { ui.acp_event(session_update(acp::SessionUpdate::ToolCallUpdate( acp::ToolCallUpdate::new( "bash-1".to_string(), - acp::ToolCallUpdateFields::new().title("Run command").status(acp::ToolCallStatus::Completed), + acp::ToolCallUpdateFields::new().title("Ran").status(acp::ToolCallStatus::Completed), ) .meta(update_meta), ))); @@ -516,33 +516,6 @@ fn bash_tool_keeps_highlighting_after_title_and_display_metadata_updates() { assert!(!has_cell(&ui.conversation(), "c", |cell| cell.bg == ui.app().theme().code_bg)); } -#[test] -fn running_bash_tool_keeps_the_run_command_title() { - let mut ui = TestUi::with_dimensions(100, 15); - ui.submit("run shell command"); - let mut tool_meta = serde_json::Map::new(); - tool_meta.insert(acp_utils::AETHER_TOOL_NAME_META_KEY.to_string(), "coding__bash".into()); - let command = "cargo test"; - let tool = acp::ToolCall::new("bash-1".to_string(), "Bash") - .raw_input(serde_json::json!({"command": command})) - .meta(tool_meta); - ui.acp_event(session_update(acp::SessionUpdate::ToolCall(tool))); - let mut update_meta = serde_json::Map::new(); - update_meta.insert("display_value".to_string(), format!("{command} (running)").into()); - ui.acp_event(session_update(acp::SessionUpdate::ToolCallUpdate( - acp::ToolCallUpdate::new( - "bash-1".to_string(), - acp::ToolCallUpdateFields::new().title("Run command").status(acp::ToolCallStatus::InProgress), - ) - .meta(update_meta), - ))); - - ui.draw(); - - let viewport = ui.viewport_text(); - assert!(viewport.contains("Run command cargo test"), "a running bash call should keep its title: {viewport}"); -} - #[test] fn non_bash_tool_with_a_command_argument_keeps_generic_rendering() { let mut ui = TestUi::with_dimensions(100, 15); From ef3ae98d5fec5c650cea60224f81c6773cb23dcb Mon Sep 17 00:00:00 2001 From: "contextbridge-pr-automation[bot]" <259134118+contextbridge-pr-automation[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:23:36 +0000 Subject: [PATCH 4/4] chore: remove redundant bash title test and stale doc comment Per PR #433 review: the finished-command title is covered by the existing tool-result behavior and the unit test only asserted a string literal, and the styled_code_line doc comment restated what the code already makes obvious. --- crates/mcp-servers/src/coding/tools/bash/mod.rs | 14 -------------- crates/wisp/src/conversation/tool_view.rs | 2 -- 2 files changed, 16 deletions(-) diff --git a/crates/mcp-servers/src/coding/tools/bash/mod.rs b/crates/mcp-servers/src/coding/tools/bash/mod.rs index adc6f31ab..8c917bf5e 100644 --- a/crates/mcp-servers/src/coding/tools/bash/mod.rs +++ b/crates/mcp-servers/src/coding/tools/bash/mod.rs @@ -187,20 +187,6 @@ impl Drop for ProcessGroupGuard { mod tests { use super::*; - #[tokio::test] - async fn finished_commands_are_titled_ran() { - let output = execute_command( - BashInput { command: "printf '%s' done".into(), ..Default::default() }, - None, - &BashEnvironment::new(), - ) - .await - .unwrap(); - let meta = output.meta.expect("finished bash results should carry display metadata"); - assert_eq!(meta.display.title, "Ran"); - assert!(meta.display.value.contains("(exit 0)"), "value should report the exit: {}", meta.display.value); - } - #[tokio::test] async fn environment_overrides_are_passed_to_bash() { let environment = BashEnvironment::new().with_var("AETHER_TEST_VALUE", "present"); diff --git a/crates/wisp/src/conversation/tool_view.rs b/crates/wisp/src/conversation/tool_view.rs index db763255a..0b6dbf581 100644 --- a/crates/wisp/src/conversation/tool_view.rs +++ b/crates/wisp/src/conversation/tool_view.rs @@ -185,8 +185,6 @@ fn tool_line( lines } -/// Token colors only: the command sits directly on the terminal's own -/// background rather than a code block behind it. fn styled_code_line(mut line: Line<'static>, theme: &Theme) -> Line<'static> { line.style = line.style.patch(Style::new().fg(theme.code_fg)); line