Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/src/ai/blocklist/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4072,6 +4072,13 @@ impl AIBlock {

/// Handles find match focus changes by auto-expanding collapsed reasoning blocks
/// that contain the focused match.
/// The number of cached find matches for this AI block. Test-only; used to
/// assert that find highlights are cleared when the find bar closes.
#[cfg(test)]
pub(crate) fn find_match_count(&self) -> usize {
self.find_state.match_count()
}

fn handle_find_match_focus_change(&mut self, ctx: &mut ViewContext<Self>) {
// Get the currently focused match ID from the terminal's find model
let focused_match_id = self
Expand Down
6 changes: 6 additions & 0 deletions app/src/ai/blocklist/block/find.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ impl FindState {
pub(super) fn location_for_match(&self, id: RichContentMatchId) -> Option<&FindMatchLocation> {
self.matches.get(&id)
}

/// The number of find matches currently cached for this block.
#[cfg(test)]
pub(crate) fn match_count(&self) -> usize {
self.matches.len()
}
}

impl FindableRichContentView for AIBlock {
Expand Down
17 changes: 17 additions & 0 deletions app/src/terminal/find/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,23 @@ impl TerminalFindModel {
ctx.emit(FindEvent::UpdatedFocusedMatch);
}

/// Notifies every registered rich-content child view (e.g. AI blocks) to
/// drop its cached find state and repaint, **without** touching the active
/// find run's options/config.
///
/// Callers that just need stale highlights to disappear (e.g.
/// `close_find_bar`) must use this rather than [`Self::clear_matches`].
/// On the async path, `clear_matches` routes through
/// `AsyncFindController::clear_results`, which also drops
/// `current_find_options` — losing the query that `open_find_bar` later
/// reads back via [`Self::active_find_options`] to restore the previous
/// search.
pub fn clear_rich_content_matches(&self, ctx: &mut ModelContext<Self>) {
for view in self.rich_content_views.values() {
view.clear_matches(ctx);
}
}

/// Clears matches in the active find run, if any.
pub fn clear_matches(&mut self, ctx: &mut ModelContext<Self>) {
if self.terminal_model.lock().is_alt_screen_active() {
Expand Down
13 changes: 12 additions & 1 deletion app/src/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18886,8 +18886,19 @@ impl TerminalView {
}

fn close_find_bar(&mut self, ctx: &mut ViewContext<Self>) {
self.find_model.update(ctx, |find_model, _ctx| {
self.find_model.update(ctx, |find_model, ctx| {
find_model.set_is_find_bar_open(false);
// Notify rich-content child views (e.g. AI blocks) to repaint and
// drop their stale find highlights. Terminal grid highlights are
// gated at paint time on `is_find_bar_open()`, but AI blocks are
// separate child views that won't repaint on their own when the
// find bar closes.
//
// Uses `clear_rich_content_matches` (rich-content only) rather
// than the broader `clear_matches`: on the async-find path the
// latter drops `current_find_options`, breaking `open_find_bar`'s
// restore-previous-query path (it reads `active_find_options`).
find_model.clear_rich_content_matches(ctx);
});
ctx.notify();
}
Expand Down
131 changes: 131 additions & 0 deletions app/src/terminal/view_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6315,3 +6315,134 @@ fn linear_deeplink_via_default_entrypoint_does_not_auto_submit_in_fullscreen() {
});
})
}

/// Regression test for https://github.com/warpdotdev/warp/issues/11212.
///
/// Closing the find bar must immediately clear find highlights on AI blocks.
/// AI blocks are separate child views, so unless `close_find_bar` clears find
/// state they keep stale highlights until the pane is refocused.
#[test]
fn close_find_bar_clears_ai_block_find_highlights() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);

// Create an AI block whose user query contains a searchable term.
terminal.update(&mut app, |view, ctx| {
append_exchange_and_handle_event(
view,
AIAgentInput::UserQuery {
query: "highlight the needle here".to_owned(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
},
ctx,
);
});

let ai_block = terminal.read(&app, |view, _| {
view.rich_content_views
.iter()
.find_map(|rich_content| {
rich_content
.ai_block_metadata()
.map(|metadata| metadata.ai_block_handle.clone())
})
.expect("an AI block should have been inserted")
});

// Open the find bar and run find for a term that matches the AI block.
// The blocklist find pipeline only visits rich-content views that have
// been laid out, which does not happen in this headless test, so also
// drive the AI block's `run_find` directly to put it in the
// highlighted state.
let find_options = || FindOptions {
query: Some("needle".to_owned().into()),
..Default::default()
};
terminal.update(&mut app, |view, ctx| {
view.show_find_bar(ctx);
view.run_find(find_options(), ctx);
});
ai_block.update(&mut app, |block, ctx| {
crate::terminal::find::FindableRichContentView::run_find(block, &find_options(), ctx);
});

assert_eq!(
ai_block.read(&app, |block, _| block.find_match_count()),
1,
"running find should highlight the match inside the AI block"
);

// Dismissing the find bar must clear the AI block's find highlights.
terminal.update(&mut app, |view, ctx| {
view.close_find_bar(ctx);
});

assert_eq!(
ai_block.read(&app, |block, _| block.find_match_count()),
0,
"closing the find bar should immediately clear AI block find highlights"
);
})
}

/// Regression test for the async-find branch of #11212.
///
/// Closing the find bar must clear stale AI block highlights without dropping
/// the saved query options on the async-find path. `open_find_bar` reads
/// `active_find_options` to restore the previous query; if `close_find_bar`
/// routes through `clear_matches → AsyncFindController::clear_results`, that
/// helper resets `current_find_options` and reopening the find bar starts
/// from a blank query instead of the previous one.
#[test]
fn close_find_bar_preserves_options_on_async_find_path() {
App::test((), |mut app| async move {
initialize_app_for_terminal_view(&mut app);
let _async_find = FeatureFlag::AsyncFind.override_enabled(true);
let terminal = add_window_with_terminal(&mut app, None);

let needle_options = || FindOptions {
query: Some("needle".to_owned().into()),
..Default::default()
};

terminal.update(&mut app, |view, ctx| {
view.show_find_bar(ctx);
view.run_find(needle_options(), ctx);
});

// The async controller should have saved the active query.
assert_eq!(
terminal.read(&app, |view, ctx| view
.find_model
.as_ref(ctx)
.active_find_options()
.map(|o| o.query.clone())),
Some(needle_options().query),
"running find on the async path must save the active query"
);

// Closing the find bar must NOT drop the saved query — otherwise
// the next `open_find_bar` would start blank instead of restoring
// the previous search.
terminal.update(&mut app, |view, ctx| {
view.close_find_bar(ctx);
});

assert_eq!(
terminal.read(&app, |view, ctx| view
.find_model
.as_ref(ctx)
.active_find_options()
.map(|o| o.query.clone())),
Some(needle_options().query),
"closing the find bar must preserve the saved query on the async path"
);
})
}