Vectrum
Sign In

Find in thread (Ctrl+F), like T3 Code

A search field floats over the conversation's top right corner: it counts
the messages that match (1/3), steps through them with Enter, Shift+Enter
or the arrows, scrolls to each (unfolding its turn) and outlines them.
Esc or the close button puts the focus back in the composer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent d508fcacommitf279824

Showing 3 changed files with 228 additions and 5 deletions.

+18−0src/ui/chat.rsView File
@@ -4,6 +4,7 @@
44 //! its footer in `footer`, and the strip hanging below it in `strip`.
55
66 mod dock;
7+ mod find;
78 mod footer;
89 mod strip;
910 mod timeline;
@@ -177,6 +178,8 @@ pub struct ChatView {
177178 /// Where ↑/↓ are in the thread's sent prompts: the entry shown, and the
178179 /// text put in the composer for it.
179180 history: Option<(usize, String)>,
181+ /// The find bar, while open (Ctrl+F).
182+ find: Option<find::Find>,
180183 /// Runs of tool calls unfolded in the timeline, by their first call.
181184 expanded_groups: std::collections::HashSet<usize>,
182185 /// Long user messages unfolded in the timeline.
@@ -312,6 +315,7 @@ impl ChatView {
312315 files: None,
313316 files_task: Task::ready(()),
314317 history: None,
318+ find: None,
315319 expanded_groups: Default::default(),
316320 expanded_messages: Default::default(),
317321 stash: Vec::new(),
@@ -692,6 +696,14 @@ impl ChatView {
692696 cx.stop_propagation();
693697 return;
694698 }
699+ let modifiers = &event.keystroke.modifiers;
700+ if event.keystroke.key == "f" && modifiers.control && !modifiers.shift && !modifiers.alt {
701+ if !self.session.read(cx).items.is_empty() && self.diff.is_none() {
702+ cx.stop_propagation();
703+ self.open_find(window, cx);
704+ }
705+ return;
706+ }
695707 let keystroke = &event.keystroke;
696708 if self.diff.is_some() || keystroke.modifiers.modified() || !self.composer.read(cx).value().is_empty() {
697709 return;
@@ -1089,6 +1101,10 @@ impl ChatView {
10891101 }
10901102
10911103 fn escape(&mut self, cx: &mut Context<Self>) {
1104+ if self.find.take().is_some() {
1105+ cx.notify();
1106+ return;
1107+ }
10921108 if self.stash_open {
10931109 self.stash_open = false;
10941110 cx.notify();
@@ -1364,6 +1380,7 @@ impl ChatView {
13641380 let scroller = self.scroller.clone();
13651381 let chat = cx.entity().downgrade();
13661382 div()
1383+ .relative()
13671384 .flex_1()
13681385 .min_h_0()
13691386 .w_full()
@@ -1387,6 +1404,7 @@ impl ChatView {
13871404 .with_row_style(StyleRefinement::default().pb_0())
13881405 .with_bottom_fade(metal::palette(cx).s),
13891406 )
1407+ .children(self.render_find_bar(cx))
13901408 }
13911409
13921410 /// The line closing the conversation, as T3 Code ends its timeline: the
+197−0src/ui/chat/find.rsaddedView File
@@ -0,0 +1,197 @@
1+ //! Find in thread (Ctrl+F), like T3 Code's: a search field over the
2+ //! conversation's top right corner with the match count, previous and next,
3+ //! which scrolls to each message that matches.
4+
5+ use gpui_kit::component::{
6+ Icon, IconName, h_flex,
7+ input::{Input, InputEvent, InputState},
8+ };
9+ use gpui_kit::*;
10+
11+ use super::ChatView;
12+ use crate::session::ChatItem;
13+ use crate::ui::metal::{self, MetalButton};
14+
15+ pub(super) struct Find {
16+ input: Entity<InputState>,
17+ /// The transcript rows that match, in order.
18+ pub matches: Vec<usize>,
19+ pub current: usize,
20+ _subscription: Subscription,
21+ }
22+
23+ impl ChatView {
24+ /// Opens the find bar, or with it open, selects what's typed in it.
25+ pub(super) fn open_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
26+ if self.find.is_none() {
27+ let input = cx.new(|cx| InputState::new(window, cx).placeholder("Find in thread"));
28+ let subscription = cx.subscribe_in(&input, window, |this, _, event: &InputEvent, _, cx| match event {
29+ InputEvent::Change => {
30+ this.update_find(cx);
31+ this.find_step(true, true, cx);
32+ }
33+ InputEvent::PressEnter { shift, .. } => this.find_step(!shift, false, cx),
34+ _ => {}
35+ });
36+ self.find = Some(Find { input, matches: Vec::new(), current: 0, _subscription: subscription });
37+ }
38+ if let Some(find) = &self.find {
39+ find.input.update(cx, |input, cx| {
40+ input.focus(window, cx);
41+ input.select_all(window, cx);
42+ });
43+ }
44+ cx.notify();
45+ }
46+
47+ pub(super) fn close_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
48+ if self.find.take().is_some() {
49+ self.focus_composer(window, cx);
50+ cx.notify();
51+ }
52+ }
53+
54+ /// The rows whose messages contain what's typed, case aside.
55+ fn update_find(&mut self, cx: &mut Context<Self>) {
56+ let Some(find) = self.find.as_mut() else { return };
57+ let query = find.input.read(cx).value().trim().to_lowercase();
58+ find.matches = if query.is_empty() {
59+ Vec::new()
60+ } else {
61+ self.session
62+ .read(cx)
63+ .items
64+ .iter()
65+ .enumerate()
66+ .filter(|(_, item)| match item {
67+ ChatItem::User { text, .. } => text.to_lowercase().contains(&query),
68+ ChatItem::Assistant(answer) => answer.text.to_lowercase().contains(&query),
69+ ChatItem::Tool(tool) if tool.name == "ExitPlanMode" => {
70+ tool.input.get("plan").and_then(|plan| plan.as_str()).is_some_and(|plan| plan.to_lowercase().contains(&query))
71+ }
72+ _ => false,
73+ })
74+ .map(|(ix, _)| ix)
75+ .collect()
76+ };
77+ find.current = find.current.min(find.matches.len().saturating_sub(1));
78+ }
79+
80+ /// Moves to the next (or previous) match, unfolding the turn it's in
81+ /// and scrolling to it. `stay` keeps the current one, for a new query.
82+ fn find_step(&mut self, forward: bool, stay: bool, cx: &mut Context<Self>) {
83+ let Some(find) = self.find.as_mut() else { return };
84+ let count = find.matches.len();
85+ if count == 0 {
86+ cx.notify();
87+ return;
88+ }
89+ if !stay {
90+ find.current = if forward { (find.current + 1) % count } else { (find.current + count - 1) % count };
91+ }
92+ let ix = find.matches[find.current];
93+ let session = self.session.read(cx);
94+ let fold = crate::settings::get(|settings| settings.fold_turns);
95+ let hidden = session
96+ .finished_turn(ix)
97+ .filter(|turn| turn.answer != Some(ix) && session.turn_expanded(turn.end) != fold)
98+ .map(|turn| turn.end);
99+ if let Some(end) = hidden {
100+ self.session.update(cx, |session, cx| session.toggle_turn(end, cx));
101+ }
102+ self.scroller.update(cx, |scroller, cx| {
103+ if hidden.is_some() {
104+ scroller.remeasure(cx);
105+ }
106+ scroller.scroll_to_item(ix, cx);
107+ });
108+ cx.notify();
109+ }
110+
111+ /// The bar, floating over the conversation's top right corner.
112+ pub(super) fn render_find_bar(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
113+ let find = self.find.as_ref()?;
114+ let p = metal::palette(cx);
115+ let count = find.matches.len();
116+ let searching = !find.input.read(cx).value().trim().is_empty();
117+ let label = if count > 0 { format!("{}/{count}", find.current + 1) } else { "0/0".to_string() };
118+ Some(
119+ h_flex()
120+ .id("find-bar")
121+ .absolute()
122+ .top(px(12.))
123+ .right(px(12.))
124+ .w(px(384.))
125+ .h(px(36.))
126+ .pl(px(12.))
127+ .pr(px(4.))
128+ .gap(px(4.))
129+ .rounded(px(12.))
130+ .bg(p.menu_bg)
131+ .shadow(p.raise.clone())
132+ .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
133+ if event.keystroke.key == "escape" {
134+ cx.stop_propagation();
135+ this.close_find(window, cx);
136+ }
137+ }))
138+ .child(Icon::new(IconName::Search).size(px(14.)).text_color(p.ink3))
139+ .child(div().flex_1().min_w_0().text_size(px(13.)).child(Input::new(&find.input).appearance(false)))
140+ .child(
141+ div()
142+ .min_w(px(40.))
143+ .text_center()
144+ .text_size(px(12.))
145+ .text_color(if searching && count == 0 { p.red } else { p.ink3 })
146+ .child(label),
147+ )
148+ .child(
149+ MetalButton::new("find-previous")
150+ .ghost()
151+ .mini()
152+ .size(px(24.))
153+ .icon(IconName::ChevronUp)
154+ .tooltip("Previous match")
155+ .disabled(count == 0)
156+ .on_click(cx.listener(|this, _, _, cx| this.find_step(false, false, cx))),
157+ )
158+ .child(
159+ MetalButton::new("find-next")
160+ .ghost()
161+ .mini()
162+ .size(px(24.))
163+ .icon(IconName::ChevronDown)
164+ .tooltip("Next match")
165+ .disabled(count == 0)
166+ .on_click(cx.listener(|this, _, _, cx| this.find_step(true, false, cx))),
167+ )
168+ .child(
169+ MetalButton::new("find-close")
170+ .ghost()
171+ .mini()
172+ .size(px(24.))
173+ .icon(IconName::Close)
174+ .tooltip("Close find")
175+ .on_click(cx.listener(|this, _, window, cx| this.close_find(window, cx))),
176+ )
177+ .into_any_element(),
178+ )
179+ }
180+
181+ /// The match the find bar is on, and the others, for the timeline to
182+ /// mark.
183+ pub(super) fn find_marks(&self) -> Option<(Vec<usize>, Option<usize>)> {
184+ let find = self.find.as_ref()?;
185+ Some((find.matches.clone(), find.matches.get(find.current).copied()))
186+ }
187+ }
188+
189+ /// How a matching message is marked: an amber outline, stronger on the
190+ /// current one.
191+ pub(super) fn mark(element: Div, current: bool, p: &metal::Palette) -> Div {
192+ element
193+ .rounded(px(12.))
194+ .border_1()
195+ .border_color(p.amber.opacity(if current { 0.9 } else { 0.35 }))
196+ .bg(p.amber.opacity(if current { 0.08 } else { 0.03 }))
197+ }
+13−5src/ui/chat/timeline.rsView File
@@ -295,12 +295,14 @@ fn copy_button(id: impl Into<ElementId>, text: String) -> MetalButton {
295295 })
296296 }
297297
298- /// The ChatView's fold state for groups and long messages.
299- fn view_state(chat: &WeakEntity<ChatView>, cx: &App) -> (HashSet<usize>, HashSet<usize>) {
298+ /// The ChatView's fold state for groups and long messages, and the find
299+ /// bar's matches.
300+ #[allow(clippy::type_complexity)]
301+ fn view_state(chat: &WeakEntity<ChatView>, cx: &App) -> (HashSet<usize>, HashSet<usize>, Option<(Vec<usize>, Option<usize>)>) {
300302 chat.upgrade()
301303 .map(|chat| {
302304 let chat = chat.read(cx);
303- (chat.expanded_groups.clone(), chat.expanded_messages.clone())
305+ (chat.expanded_groups.clone(), chat.expanded_messages.clone(), chat.find_marks())
304306 })
305307 .unwrap_or_default()
306308 }
@@ -323,7 +325,8 @@ pub(super) fn render_item(
323325 if is_hidden(item) {
324326 return div().into_any_element();
325327 }
326- let (expanded_groups, expanded_messages) = view_state(chat, cx);
328+ let (expanded_groups, expanded_messages, marks) = view_state(chat, cx);
329+ let marked = marks.as_ref().filter(|(matches, _)| matches.contains(&ix)).map(|(_, current)| *current == Some(ix));
327330
328331 // A finished turn shows only its answer (and any errors) under one
329332 // "Worked for" row, unless the user unfolds it. Its end shows what it
@@ -417,7 +420,12 @@ pub(super) fn render_item(
417420 .max_w(px(CONTENT_WIDTH))
418421 .map(|this| match item {
419422 ChatItem::TaskEvent { .. } => this.children(content).children(summary.map(|summary| div().pt(px(8.)).child(summary))),
420- _ => this.children(summary.map(|summary| div().pb(px(6.)).child(summary))).children(content),
423+ _ => this.children(summary.map(|summary| div().pb(px(6.)).child(summary))).children(content.map(|content| {
424+ match marked {
425+ Some(current) => super::find::mark(div().p(px(4.)), current, p).child(content).into_any_element(),
426+ None => content,
427+ }
428+ })),
421429 })
422430 .children(working),
423431 )