Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:06:39.961Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

thread_search_bar.rs

958 lines · 33.3 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.