Skip to repository content958 lines · 33.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:06:27.724Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
thread_search_bar.rs
1use std::ops::Range;
2use std::sync::Arc;
3use std::time::Duration;
4
5use acp_thread::{
6 AcpThread, AcpThreadEvent, AgentThreadEntry, AssistantMessageChunk, ContentBlock,
7 ToolCallContent,
8};
9use collections::HashMap;
10use editor::{
11 Editor, EditorElement, EditorEvent, EditorStyle, HighlightKey, SelectionEffects,
12 scroll::Autoscroll,
13};
14use gpui::{
15 Action, Entity, EntityId, EventEmitter, FocusHandle, Focusable, KeyContext, Subscription, Task,
16 TextStyle, WeakEntity, actions, prelude::*,
17};
18use markdown::Markdown;
19use multi_buffer::{Anchor, MultiBufferOffset, MultiBufferSnapshot};
20use project::search::SearchQuery;
21use search::{SearchOption, SearchOptions, SearchSource};
22use settings::Settings as _;
23use theme_settings::ThemeSettings;
24use ui::{IconButtonShape, Tooltip, prelude::*};
25use util::paths::PathMatcher;
26
27use crate::entry_view_state::EntryViewState;
28
29actions!(
30 agent,
31 [
32 /// Closes the thread search bar.
33 DismissThreadSearch,
34 /// Selects the next thread search match.
35 SelectNextThreadMatch,
36 /// Selects the previous thread search match.
37 SelectPreviousThreadMatch,
38 ]
39);
40
41/// Debounce for streaming thread updates, which can fire once per streamed
42/// chunk. Query edits are handled immediately instead (see the query editor
43/// subscription in `ThreadSearchBar::new`).
44pub(super) const SEARCH_UPDATE_DEBOUNCE: Duration = Duration::from_millis(150);
45
46/// Search hits can be painted on either markdown or past-message editors.
47#[derive(Clone)]
48enum MatchTarget {
49 Markdown {
50 markdown: WeakEntity<Markdown>,
51 markdown_match_ix: usize,
52 },
53 Editor {
54 editor: WeakEntity<Editor>,
55 anchor_range: Range<Anchor>,
56 editor_match_ix: usize,
57 },
58}
59
60impl MatchTarget {
61 fn entity_id(&self) -> EntityId {
62 match self {
63 MatchTarget::Markdown { markdown, .. } => markdown.entity_id(),
64 MatchTarget::Editor { editor, .. } => editor.entity_id(),
65 }
66 }
67
68 /// Index of this hit within its painted entity (markdown- or editor-local).
69 fn match_ix(&self) -> usize {
70 match self {
71 MatchTarget::Markdown {
72 markdown_match_ix, ..
73 } => *markdown_match_ix,
74 MatchTarget::Editor {
75 editor_match_ix, ..
76 } => *editor_match_ix,
77 }
78 }
79}
80
81struct ThreadMatch {
82 entry_ix: usize,
83 target: MatchTarget,
84 source_range: Range<usize>,
85}
86
87impl ThreadMatch {
88 /// Stable identity used to re-locate the active match across a rescan.
89 fn key(&self) -> MatchKey {
90 MatchKey {
91 entry_ix: self.entry_ix,
92 entity_id: self.target.entity_id(),
93 source_range: self.source_range.clone(),
94 }
95 }
96}
97
98#[derive(PartialEq)]
99struct MatchKey {
100 entry_ix: usize,
101 entity_id: EntityId,
102 source_range: Range<usize>,
103}
104
105enum SearchTarget {
106 Editor {
107 entry_ix: usize,
108 editor: Entity<Editor>,
109 snapshot: MultiBufferSnapshot,
110 },
111 Markdown {
112 entry_ix: usize,
113 markdown: Entity<Markdown>,
114 source: SharedString,
115 },
116}
117
118enum ScannedTarget {
119 Editor {
120 entry_ix: usize,
121 editor: Entity<Editor>,
122 ranges: Vec<Range<usize>>,
123 anchor_ranges: Vec<Range<Anchor>>,
124 },
125 Markdown {
126 entry_ix: usize,
127 markdown: Entity<Markdown>,
128 ranges: Vec<Range<usize>>,
129 },
130}
131
132pub struct ThreadSearchBar {
133 pub(super) query_editor: Entity<Editor>,
134 options: SearchOptions,
135 matches: Vec<ThreadMatch>,
136 active_match: Option<usize>,
137 query_error: bool,
138 query_error_message: Option<SharedString>,
139 highlighted_markdowns: Vec<WeakEntity<Markdown>>,
140 highlighted_editors: Vec<WeakEntity<Editor>>,
141 thread: Entity<AcpThread>,
142 entry_view_state: Entity<EntryViewState>,
143 on_activate_match: Arc<dyn Fn(usize, &mut Window, &mut App)>,
144 is_active: bool,
145 _update_matches_task: Option<Task<()>>,
146 _search_task: Option<Task<()>>,
147 _subscriptions: Vec<Subscription>,
148}
149
150pub enum ThreadSearchBarEvent {
151 Dismissed,
152}
153
154impl EventEmitter<ThreadSearchBarEvent> for ThreadSearchBar {}
155
156impl Focusable for ThreadSearchBar {
157 fn focus_handle(&self, cx: &App) -> FocusHandle {
158 self.query_editor.focus_handle(cx)
159 }
160}
161
162impl ThreadSearchBar {
163 pub fn new(
164 thread: Entity<AcpThread>,
165 entry_view_state: Entity<EntryViewState>,
166 on_activate_match: Arc<dyn Fn(usize, &mut Window, &mut App)>,
167 window: &mut Window,
168 cx: &mut Context<Self>,
169 ) -> Self {
170 let query_editor = cx.new(|cx| {
171 let mut editor = Editor::single_line(window, cx);
172 editor.set_placeholder_text("Search this thread…", window, cx);
173 editor
174 });
175 let editor_subscription = cx.subscribe_in(
176 &query_editor,
177 window,
178 |this, _editor, event: &EditorEvent, window, cx| {
179 if matches!(
180 event,
181 EditorEvent::Edited { .. } | EditorEvent::BufferEdited
182 ) {
183 // Re-scan immediately so typing feels responsive
184 this._update_matches_task = None;
185 this.update_matches(window, cx);
186 }
187 },
188 );
189 let thread_subscription = cx.subscribe_in(
190 &thread,
191 window,
192 |this, _thread, event: &AcpThreadEvent, window, cx| {
193 if this.is_active
194 && matches!(
195 event,
196 AcpThreadEvent::NewEntry
197 | AcpThreadEvent::EntryUpdated(_)
198 | AcpThreadEvent::EntriesRemoved(_)
199 )
200 {
201 this.schedule_update_matches(window, cx);
202 }
203 },
204 );
205 cx.on_release(|this, cx| {
206 this.clear_highlights_impl(cx);
207 })
208 .detach();
209 Self {
210 query_editor,
211 options: SearchOptions::NONE,
212 matches: Vec::new(),
213 active_match: None,
214 query_error: false,
215 query_error_message: None,
216 highlighted_markdowns: Vec::new(),
217 highlighted_editors: Vec::new(),
218 thread,
219 entry_view_state,
220 on_activate_match,
221 is_active: false,
222 _update_matches_task: None,
223 _search_task: None,
224 _subscriptions: vec![editor_subscription, thread_subscription],
225 }
226 }
227
228 pub fn focus_and_refresh(&mut self, window: &mut Window, cx: &mut Context<Self>) {
229 self.is_active = true;
230 self.focus_query_and_select_all(window, cx);
231 self.update_matches(window, cx);
232 }
233
234 fn focus_query_and_select_all(&self, window: &mut Window, cx: &mut Context<Self>) {
235 let focus_handle = self.query_editor.focus_handle(cx);
236 focus_handle.focus(window, cx);
237 self.query_editor.update(cx, |editor, cx| {
238 editor.select_all(&editor::actions::SelectAll, window, cx);
239 });
240 }
241
242 fn schedule_update_matches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
243 self._update_matches_task = Some(cx.spawn_in(window, async move |this, cx| {
244 cx.background_executor().timer(SEARCH_UPDATE_DEBOUNCE).await;
245 this.update_in(cx, |this, window, cx| this.update_matches(window, cx))
246 .ok();
247 }));
248 }
249
250 #[cfg(test)]
251 pub(super) fn match_count(&self) -> usize {
252 self.matches.len()
253 }
254
255 #[cfg(test)]
256 pub(super) fn active_match_index(&self) -> Option<usize> {
257 self.active_match
258 }
259
260 pub fn active_match_text(&self, cx: &App) -> Option<String> {
261 if self.query_editor.read(cx).text(cx).is_empty() {
262 return None;
263 }
264 match self.active_match {
265 Some(ix) => Some(format!("{}/{}", ix + 1, self.matches.len())),
266 None => Some(format!("0/{}", self.matches.len())),
267 }
268 }
269
270 fn current_query(&self, cx: &App) -> String {
271 self.query_editor.read(cx).text(cx)
272 }
273
274 fn build_query(&self, cx: &App) -> (Option<Arc<SearchQuery>>, Option<SharedString>) {
275 let text = self.current_query(cx);
276 if text.is_empty() {
277 return (None, None);
278 }
279 let whole_word = self.options.contains(SearchOptions::WHOLE_WORD);
280 let case_sensitive = self.options.contains(SearchOptions::CASE_SENSITIVE);
281 let result = if self.options.contains(SearchOptions::REGEX) {
282 SearchQuery::regex(
283 text,
284 whole_word,
285 case_sensitive,
286 false,
287 false,
288 PathMatcher::default(),
289 PathMatcher::default(),
290 false,
291 None,
292 )
293 } else {
294 SearchQuery::text(
295 text,
296 whole_word,
297 case_sensitive,
298 false,
299 PathMatcher::default(),
300 PathMatcher::default(),
301 false,
302 None,
303 )
304 };
305 match result {
306 Ok(q) => (Some(Arc::new(q)), None),
307 Err(err) => (None, Some(SharedString::from(err.to_string()))),
308 }
309 }
310
311 pub(super) fn update_matches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
312 let previous_active_match_ix = self.active_match;
313 let previous_active_key = previous_active_match_ix
314 .and_then(|ix| self.matches.get(ix))
315 .map(ThreadMatch::key);
316
317 let (query, err_msg) = self.build_query(cx);
318 self.query_error = !self.current_query(cx).is_empty() && query.is_none();
319 self.query_error_message = err_msg;
320
321 let Some(query) = query else {
322 self.clear_results(cx);
323 cx.notify();
324 return;
325 };
326
327 let mut targets: Vec<SearchTarget> = Vec::new();
328 let thread = self.thread.read(cx);
329 let entry_view_state = self.entry_view_state.read(cx);
330 for (entry_ix, entry) in thread.entries().iter().enumerate() {
331 match entry {
332 // Past user messages render through `MessageEditor`, not markdown.
333 AgentThreadEntry::UserMessage(_) => {
334 let editor = entry_view_state
335 .entry(entry_ix)
336 .and_then(|view_entry| view_entry.message_editor())
337 .map(|message_editor| message_editor.read(cx).editor().clone());
338 let Some(editor) = editor else {
339 continue;
340 };
341 let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
342 targets.push(SearchTarget::Editor {
343 entry_ix,
344 editor,
345 snapshot,
346 });
347 }
348 _ => {
349 for markdown in collect_markdowns(entry_ix, entry, &entry_view_state, cx) {
350 let source = markdown.read(cx).source().clone();
351 targets.push(SearchTarget::Markdown {
352 entry_ix,
353 markdown,
354 source,
355 });
356 }
357 }
358 }
359 }
360
361 if targets.is_empty() {
362 self.clear_results(cx);
363 cx.notify();
364 return;
365 }
366
367 self._search_task = Some(cx.spawn_in(window, async move |this, cx| {
368 let scanned = cx
369 .background_spawn(async move {
370 targets
371 .into_iter()
372 .filter_map(|target| match target {
373 SearchTarget::Editor {
374 entry_ix,
375 editor,
376 snapshot,
377 } => {
378 let ranges = query.search_str(&snapshot.text());
379 if ranges.is_empty() {
380 return None;
381 }
382 let anchor_ranges = ranges
383 .iter()
384 .map(|range| {
385 snapshot.anchor_before(MultiBufferOffset(range.start))
386 ..snapshot.anchor_after(MultiBufferOffset(range.end))
387 })
388 .collect();
389 Some(ScannedTarget::Editor {
390 entry_ix,
391 editor,
392 ranges,
393 anchor_ranges,
394 })
395 }
396 SearchTarget::Markdown {
397 entry_ix,
398 markdown,
399 source,
400 } => {
401 let ranges = query.search_str(&source);
402 if ranges.is_empty() {
403 return None;
404 }
405 Some(ScannedTarget::Markdown {
406 entry_ix,
407 markdown,
408 ranges,
409 })
410 }
411 })
412 .collect::<Vec<_>>()
413 })
414 .await;
415 this.update_in(cx, |this, window, cx| {
416 this.apply_search_results(
417 scanned,
418 previous_active_key,
419 previous_active_match_ix,
420 window,
421 cx,
422 );
423 })
424 .ok();
425 }));
426 }
427
428 fn apply_search_results(
429 &mut self,
430 scanned: Vec<ScannedTarget>,
431 previous_active_key: Option<MatchKey>,
432 previous_active_match_ix: Option<usize>,
433 window: &mut Window,
434 cx: &mut Context<Self>,
435 ) {
436 self.clear_match_highlights(cx);
437 self.matches.clear();
438 self.active_match = None;
439
440 for target in scanned {
441 match target {
442 ScannedTarget::Editor {
443 entry_ix,
444 editor,
445 ranges,
446 anchor_ranges,
447 } => {
448 let weak_editor = editor.downgrade();
449 for (ix, (range, anchor_range)) in ranges.iter().zip(&anchor_ranges).enumerate()
450 {
451 self.matches.push(ThreadMatch {
452 entry_ix,
453 target: MatchTarget::Editor {
454 editor: weak_editor.clone(),
455 anchor_range: anchor_range.clone(),
456 editor_match_ix: ix,
457 },
458 source_range: range.clone(),
459 });
460 }
461 self.highlighted_editors.push(weak_editor);
462 }
463 ScannedTarget::Markdown {
464 entry_ix,
465 markdown,
466 ranges,
467 } => {
468 let weak = markdown.downgrade();
469 for (ix, range) in ranges.iter().enumerate() {
470 self.matches.push(ThreadMatch {
471 entry_ix,
472 target: MatchTarget::Markdown {
473 markdown: weak.clone(),
474 markdown_match_ix: ix,
475 },
476 source_range: range.clone(),
477 });
478 }
479 self.highlighted_markdowns.push(weak);
480 markdown.update(cx, |md, cx| {
481 md.set_search_highlights(ranges, None, cx);
482 });
483 }
484 }
485 }
486
487 if !self.matches.is_empty() {
488 let preserved_ix = previous_active_key
489 .as_ref()
490 .and_then(|key| self.matches.iter().position(|m| &m.key() == key));
491 let active_match_ix = preserved_ix
492 .or_else(|| previous_active_match_ix.filter(|ix| *ix < self.matches.len()))
493 .unwrap_or(0);
494 let scroll_to_match = preserved_ix.is_none();
495 self.activate_match(active_match_ix, scroll_to_match, window, cx);
496 } else {
497 cx.notify();
498 }
499 }
500
501 fn activate_match(
502 &mut self,
503 ix: usize,
504 scroll_to_match: bool,
505 window: &mut Window,
506 cx: &mut Context<Self>,
507 ) {
508 let Some(m) = self.matches.get(ix) else {
509 return;
510 };
511 let entry_ix = m.entry_ix;
512 let source_index = m.source_range.start;
513 let target = m.target.clone();
514 // Markdown and editor entity ids never collide, so the active hit is
515 // simply the painted entity whose id equals the target's.
516 let target_entity_id = target.entity_id();
517 let target_match_ix = target.match_ix();
518
519 for weak in &self.highlighted_markdowns {
520 if let Some(markdown) = weak.upgrade() {
521 let active = (weak.entity_id() == target_entity_id).then_some(target_match_ix);
522 markdown.update(cx, |markdown, cx| {
523 markdown.set_active_search_highlight(active, cx);
524 if active.is_some() && scroll_to_match {
525 markdown.request_autoscroll_to_source_index(source_index, cx);
526 }
527 });
528 }
529 }
530
531 // Editor highlight colors are computed by index, so repaint on navigation.
532 let mut per_editor: HashMap<EntityId, (WeakEntity<Editor>, Vec<Range<Anchor>>)> =
533 HashMap::default();
534 for mat in &self.matches {
535 if let MatchTarget::Editor {
536 editor,
537 anchor_range,
538 ..
539 } = &mat.target
540 {
541 let entry = per_editor
542 .entry(editor.entity_id())
543 .or_insert_with(|| (editor.clone(), Vec::new()));
544 entry.1.push(anchor_range.clone());
545 }
546 }
547 for (editor_id, (weak_editor, ranges)) in per_editor {
548 let Some(editor) = weak_editor.upgrade() else {
549 continue;
550 };
551 let active_ix = (editor_id == target_entity_id).then_some(target_match_ix);
552 editor.update(cx, |editor, cx| {
553 editor.highlight_background(
554 HighlightKey::BufferSearchHighlights,
555 &ranges,
556 move |index, theme| {
557 if active_ix == Some(*index) {
558 theme.colors().search_active_match_background
559 } else {
560 theme.colors().search_match_background
561 }
562 },
563 cx,
564 );
565 });
566 }
567
568 if scroll_to_match
569 && let MatchTarget::Editor {
570 editor,
571 anchor_range,
572 ..
573 } = &target
574 && let Some(editor) = editor.upgrade()
575 {
576 let anchor_range = anchor_range.clone();
577 editor.update(cx, |editor, cx| {
578 editor.change_selections(
579 SelectionEffects::scroll(Autoscroll::fit()).from_search(true),
580 window,
581 cx,
582 |selections| selections.select_anchor_ranges([anchor_range]),
583 );
584 });
585 }
586
587 self.active_match = Some(ix);
588 if scroll_to_match {
589 (self.on_activate_match)(entry_ix, window, cx);
590 }
591 cx.notify();
592 }
593
594 pub(super) fn select_next_match(
595 &mut self,
596 _: &SelectNextThreadMatch,
597 window: &mut Window,
598 cx: &mut Context<Self>,
599 ) {
600 if self.matches.is_empty() {
601 return;
602 }
603 let next = match self.active_match {
604 Some(ix) => (ix + 1) % self.matches.len(),
605 None => 0,
606 };
607 self.activate_match(next, true, window, cx);
608 }
609
610 pub(super) fn select_prev_match(
611 &mut self,
612 _: &SelectPreviousThreadMatch,
613 window: &mut Window,
614 cx: &mut Context<Self>,
615 ) {
616 if self.matches.is_empty() {
617 return;
618 }
619 let prev = match self.active_match {
620 Some(ix) => {
621 if ix == 0 {
622 self.matches.len() - 1
623 } else {
624 ix - 1
625 }
626 }
627 None => self.matches.len() - 1,
628 };
629 self.activate_match(prev, true, window, cx);
630 }
631
632 fn dismiss(&mut self, _: &DismissThreadSearch, _window: &mut Window, cx: &mut Context<Self>) {
633 self.clear_highlights(cx);
634 cx.emit(ThreadSearchBarEvent::Dismissed);
635 }
636
637 pub fn clear_highlights(&mut self, cx: &mut Context<Self>) {
638 self.clear_highlights_impl(cx);
639 cx.notify();
640 }
641
642 fn clear_highlights_impl(&mut self, cx: &mut App) {
643 self.clear_results(cx);
644 self.is_active = false;
645 self._update_matches_task = None;
646 }
647
648 /// Drops all painted highlights, recorded matches, and any in-flight scan.
649 fn clear_results(&mut self, cx: &mut App) {
650 self.clear_match_highlights(cx);
651 self.matches.clear();
652 self.active_match = None;
653 self._search_task = None;
654 }
655
656 fn clear_match_highlights(&mut self, cx: &mut App) {
657 for weak in self.highlighted_markdowns.drain(..) {
658 if let Some(md) = weak.upgrade() {
659 md.update(cx, |md, cx| md.clear_search_highlights(cx));
660 }
661 }
662 for weak in self.highlighted_editors.drain(..) {
663 if let Some(editor) = weak.upgrade() {
664 editor.update(cx, |editor, cx| {
665 editor.clear_background_highlights(HighlightKey::BufferSearchHighlights, cx);
666 });
667 }
668 }
669 }
670
671 pub(super) fn toggle_case_sensitive(
672 &mut self,
673 _: &search::ToggleCaseSensitive,
674 window: &mut Window,
675 cx: &mut Context<Self>,
676 ) {
677 self.options.toggle(SearchOptions::CASE_SENSITIVE);
678 self.update_matches(window, cx);
679 }
680
681 pub(super) fn toggle_whole_word(
682 &mut self,
683 _: &search::ToggleWholeWord,
684 window: &mut Window,
685 cx: &mut Context<Self>,
686 ) {
687 self.options.toggle(SearchOptions::WHOLE_WORD);
688 self.update_matches(window, cx);
689 }
690
691 pub(super) fn toggle_regex(
692 &mut self,
693 _: &search::ToggleRegex,
694 window: &mut Window,
695 cx: &mut Context<Self>,
696 ) {
697 self.options.toggle(SearchOptions::REGEX);
698 self.update_matches(window, cx);
699 }
700
701 pub(super) fn focus_search(
702 &mut self,
703 _: &search::FocusSearch,
704 window: &mut Window,
705 cx: &mut Context<Self>,
706 ) {
707 self.focus_query_and_select_all(window, cx);
708 }
709}
710
711impl Render for ThreadSearchBar {
712 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
713 let focus_handle = self.query_editor.focus_handle(cx);
714 let theme = cx.theme().colors();
715
716 let has_matches = !self.matches.is_empty();
717 let query_empty = self.query_editor.read(cx).text(cx).is_empty();
718 let in_error_state = self.query_error || (!query_empty && !has_matches);
719
720 let mut key_context = KeyContext::new_with_defaults();
721 key_context.add("AcpThreadSearchBar");
722
723 let counter_text = self.active_match_text(cx).unwrap_or_default();
724
725 let bar_row = h_flex()
726 .track_focus(&focus_handle)
727 .key_context(key_context)
728 .on_action(cx.listener(Self::dismiss))
729 .on_action(cx.listener(Self::select_next_match))
730 .on_action(cx.listener(Self::select_prev_match))
731 .on_action(cx.listener(Self::toggle_case_sensitive))
732 .on_action(cx.listener(Self::toggle_whole_word))
733 .on_action(cx.listener(Self::toggle_regex))
734 .on_action(cx.listener(Self::focus_search))
735 .w_full()
736 .gap_2()
737 .child(
738 h_flex()
739 .min_h_8()
740 .min_w_32()
741 .flex_1()
742 .px_1p5()
743 .border_1()
744 .border_color(theme.border)
745 .bg(theme.editor_background)
746 .rounded_md()
747 .child(div().px_1().flex_1().child(render_query_input(
748 &self.query_editor,
749 in_error_state,
750 cx,
751 )))
752 .child(
753 h_flex()
754 .flex_none()
755 .gap_1()
756 .child(SearchOption::CaseSensitive.as_button(
757 self.options,
758 SearchSource::Buffer,
759 focus_handle.clone(),
760 ))
761 .child(SearchOption::WholeWord.as_button(
762 self.options,
763 SearchSource::Buffer,
764 focus_handle.clone(),
765 ))
766 .child(SearchOption::Regex.as_button(
767 self.options,
768 SearchSource::Buffer,
769 focus_handle.clone(),
770 )),
771 ),
772 )
773 .child(
774 h_flex()
775 .flex_none()
776 .gap_1()
777 .child(nav_button(
778 "thread-search-prev",
779 IconName::ChevronLeft,
780 !has_matches,
781 "Previous Match",
782 &SelectPreviousThreadMatch,
783 focus_handle.clone(),
784 ))
785 .child(nav_button(
786 "thread-search-next",
787 IconName::ChevronRight,
788 !has_matches,
789 "Next Match",
790 &SelectNextThreadMatch,
791 focus_handle.clone(),
792 ))
793 .child(
794 div().ml_1().min_w(rems(2.5)).child(
795 Label::new(counter_text)
796 .size(LabelSize::Small)
797 .when(!has_matches, |this| this.color(Color::Muted)),
798 ),
799 )
800 .child(nav_button(
801 "thread-search-dismiss",
802 IconName::Close,
803 false,
804 "Close Search",
805 &DismissThreadSearch,
806 focus_handle,
807 )),
808 );
809
810 let error_row = self
811 .query_error_message
812 .clone()
813 .map(|msg| Label::new(msg).size(LabelSize::Small).color(Color::Error));
814
815 v_flex()
816 .w_full()
817 .p_1p5()
818 .bg(theme.panel_background)
819 .border_b_1()
820 .border_color(theme.border.opacity(0.6))
821 .child(bar_row)
822 .children(error_row)
823 }
824}
825
826fn render_query_input(editor: &Entity<Editor>, has_error: bool, app: &App) -> impl IntoElement {
827 let theme = app.theme().colors();
828 let (color, use_syntax) = if has_error {
829 (Color::Error.color(app), false)
830 } else {
831 (theme.text, true)
832 };
833
834 let settings = ThemeSettings::get_global(app);
835
836 let text_style = TextStyle {
837 color,
838 font_family: settings.ui_font.family.clone(),
839 font_features: settings.ui_font.features.clone(),
840 font_fallbacks: settings.ui_font.fallbacks.clone(),
841 font_size: rems(0.875).into(),
842 font_weight: settings.ui_font.weight,
843 line_height: relative(1.3),
844 ..TextStyle::default()
845 };
846 let mut style = EditorStyle {
847 background: theme.editor_background,
848 local_player: app.theme().players().local(),
849 text: text_style,
850 ..EditorStyle::default()
851 };
852 if use_syntax {
853 style.syntax = app.theme().syntax().clone();
854 }
855 EditorElement::new(editor, style)
856}
857
858fn nav_button(
859 id: &'static str,
860 icon: IconName,
861 disabled: bool,
862 tooltip: &'static str,
863 action: &'static dyn Action,
864 focus_handle: FocusHandle,
865) -> IconButton {
866 let action_for_dispatch = action;
867 IconButton::new(id, icon)
868 .shape(IconButtonShape::Square)
869 .disabled(disabled)
870 .on_click({
871 let focus_handle = focus_handle.clone();
872 move |_, window, cx| {
873 if !focus_handle.is_focused(window) {
874 window.focus(&focus_handle, cx);
875 }
876 window.dispatch_action(action_for_dispatch.boxed_clone(), cx);
877 }
878 })
879 .tooltip(move |_window, cx| Tooltip::for_action_in(tooltip, action, &focus_handle, cx))
880}
881
882fn collect_markdowns(
883 entry_ix: usize,
884 entry: &AgentThreadEntry,
885 entry_view_state: &EntryViewState,
886 cx: &App,
887) -> Vec<Entity<Markdown>> {
888 let mut out = Vec::new();
889 match entry {
890 AgentThreadEntry::UserMessage(_) => {}
891 AgentThreadEntry::AssistantMessage(message) => {
892 for (chunk_ix, chunk) in message.chunks.iter().enumerate() {
893 match chunk {
894 AssistantMessageChunk::Message { block, .. } => {
895 if let Some(md) = block.markdown() {
896 out.push(md.clone());
897 }
898 }
899 AssistantMessageChunk::Thought { block, .. }
900 if entry_view_state
901 .thinking_block_state((entry_ix, chunk_ix), cx)
902 .0 =>
903 {
904 if let Some(md) = block.markdown() {
905 out.push(md.clone());
906 }
907 }
908 AssistantMessageChunk::Thought { .. } => {}
909 }
910 }
911 }
912 AgentThreadEntry::ToolCall(tool_call) => {
913 out.push(tool_call.label.clone());
914 if entry_view_state.is_tool_call_expanded(&tool_call.id) {
915 out.extend(
916 tool_call
917 .content
918 .iter()
919 .filter_map(|content| match content {
920 ToolCallContent::ContentBlock(ContentBlock::Markdown { markdown }) => {
921 Some(markdown.clone())
922 }
923 ToolCallContent::ContentBlock(ContentBlock::EmbeddedResource {
924 markdown: Some(markdown),
925 ..
926 }) => Some(markdown.clone()),
927 ToolCallContent::ContentBlock(
928 ContentBlock::Empty
929 | ContentBlock::EmbeddedResource { markdown: None, .. }
930 | ContentBlock::ResourceLink { .. }
931 | ContentBlock::Image { .. },
932 )
933 | ToolCallContent::Diff(_)
934 | ToolCallContent::Terminal(_) => None,
935 }),
936 );
937 }
938 }
939 AgentThreadEntry::CompletedPlan(entries) => {
940 out.extend(entries.iter().map(|e| e.content.clone()))
941 }
942 AgentThreadEntry::ContextCompaction(compaction)
943 if entry_view_state.is_compaction_expanded(entry_ix) =>
944 {
945 if let Some(summary) = &compaction.summary {
946 out.push(summary.clone());
947 }
948 }
949 AgentThreadEntry::Elicitation(_) => {}
950 AgentThreadEntry::ContextCompaction(_) => {}
951 // OMEGA-DELTA-0045. A system note carries no `Entity<Markdown>`; its
952 // text is a plain `SharedString` the host wrote, so there is nothing
953 // here for the Markdown-backed search index to collect.
954 AgentThreadEntry::SystemNote(_) => {}
955 }
956 out
957}
958