diff --git a/app/src/terminal/model/blocks.rs b/app/src/terminal/model/blocks.rs index 9f842408d53..d93e5ee385b 100644 --- a/app/src/terminal/model/blocks.rs +++ b/app/src/terminal/model/blocks.rs @@ -54,6 +54,7 @@ use super::image_map::StoredImageMetadata; use super::kitty::{KittyAction, KittyResponse}; use super::rich_content::RichContentType; use super::secrets::RespectObfuscatedSecrets; +use super::session::command_executor; use super::{ansi::InputBufferValue, block::SerializedAIMetadata}; use super::selection::ScrollDelta; @@ -1561,6 +1562,43 @@ impl BlockList { } } + /// Removes the shell's echo of an in-band command if it was classified as + /// background output. + /// + /// Live in-band commands are written only after + /// [`Self::start_active_block_for_in_band_command`] has started the active + /// block, so the shell's echo lands in that hidden block. Replayed PTY output + /// (e.g. a daemon session re-attach) carries no such client-side mark: the + /// echo arrives while the active block is not started, becomes background + /// output, and would otherwise stay visible in the block list. + /// + /// Only a live, unfinished background block whose entire trimmed content is + /// exactly the echoed command line is removed. Any other background output is + /// left untouched. + fn remove_echoed_in_band_command(&mut self, command: &str) { + let command = command.trim(); + if command.is_empty() { + return; + } + let Some(background_block) = self.background_block_mut() else { + return; + }; + let contents = background_block + .output_grid() + .contents_to_string_with_secrets_unobfuscated( + false, /* include_escape_sequences */ + None, /* max_rows */ + ); + let is_only_echoed_command = contents + .trim_end() + .strip_suffix(command) + .is_some_and(|preceding| preceding.trim().is_empty()); + if is_only_echoed_command { + log::debug!("Removing echoed in-band command from background output"); + self.remove_background_block(); + } + } + /// The setter for Block::block_banner needs to update the block_heights SumTree in order to /// keep that data structure in sync. pub(in crate::terminal) fn set_active_block_banner( @@ -3857,6 +3895,13 @@ impl ansi::Handler for BlockList { EarlyOutput::preexec(self); } + // In-band commands must never be visible. If their echo ended up as + // background output (replayed PTY output without the client-side + // in-band mark), drop it; the active block is hidden by its own preexec. + if command_executor::is_in_band_command(&data.command) { + self.remove_echoed_in_band_command(&data.command); + } + delegate_to_block!(self.preexec(data)); } diff --git a/app/src/terminal/model/blocks_test.rs b/app/src/terminal/model/blocks_test.rs index b687a45753c..344f4f15919 100644 --- a/app/src/terminal/model/blocks_test.rs +++ b/app/src/terminal/model/blocks_test.rs @@ -1939,6 +1939,80 @@ fn test_background_blocks_finished() { } } +/// A bootstrapped block list wide enough that the test commands fit on one row. +fn new_wide_bootstrapped_block_list() -> BlockList { + let block_sizes = BlockSize { + size: SizeInfo::new_without_font_metrics(10, 120), + ..test_utils::block_size() + }; + new_bootstrapped_block_list( + Some(block_sizes), + None, + ChannelEventListener::new_for_test(), + ) +} + +#[test] +fn test_replayed_in_band_command_echo_is_not_visible() { + let mut block_list = new_wide_bootstrapped_block_list(); + let command = "warp_run_generator_command 1791414087539893 'compgen -c'"; + + // Replayed PTY output: the shell echoes the in-band command, but the client + // never started the active block for it (no + // `start_active_block_for_in_band_command`), so the echo is classified as + // background output. + input_string(&mut block_list, command); + block_list.carriage_return(); + block_list.linefeed(); + block_list.on_finish_byte_processing(&ansi::ProcessorInput::new(&[])); + assert!(block_list.background_block_mut().is_some()); + + block_list.preexec(PreexecValue { + command: command.to_owned(), + }); + assert!(block_list.background_block_mut().is_none()); + assert!(block_list.active_block().is_in_band_command_block()); + + command_finished_and_precmd(&mut block_list); + + let agent_view_state = block_list.agent_view_state(); + for block in block_list.blocks() { + let contains_generator = block.command_to_string().contains(command) + || block + .output_grid() + .contents_to_string(false, None) + .contains(command); + assert!( + !contains_generator || block.should_hide_block(agent_view_state), + "in-band command must not be visible in block {:?}", + block.index() + ); + } +} + +#[test] +fn test_in_band_preexec_keeps_unrelated_background_output() { + let mut block_list = new_wide_bootstrapped_block_list(); + let command = "warp_run_generator_command 1791414087539893 'compgen -c'"; + + // Output of a background job, not the echo of the in-band command. + input_string(&mut block_list, "[1]+ Done sleep 1"); + block_list.carriage_return(); + block_list.linefeed(); + block_list.on_finish_byte_processing(&ansi::ProcessorInput::new(&[])); + + block_list.preexec(PreexecValue { + command: command.to_owned(), + }); + + let background_output = block_list + .background_block_mut() + .expect("unrelated background output must be kept") + .output_grid() + .contents_to_string(false, None); + assert!(background_output.contains("[1]+ Done sleep 1")); +} + #[test] fn test_interleaves_background_with_gaps() { let mut block_list =