Skip to main content

wisp/surfaces/composer/
mod.rs

1mod view;
2pub(crate) use view::ComposerBodyView;
3
4use crate::attachment::{AttachmentKind, PromptAttachment, classify_attachment};
5use crate::command::FilesystemCommand;
6use crate::file_index::FileEntry;
7use crate::surfaces::input::MouseAction;
8use crate::surfaces::picker::{CommandEntry, CompletionOverlay};
9use crate::surfaces::prompt_search::{self, PromptSearchPicker};
10use crate::view::edit_buffer::{EditBuffer, apply_edit_key};
11use crate::view::filterable_list::FilterableList;
12use crate::request::RequestId;
13use crate::view::selection::Direction;
14use acp_utils::notifications::PromptSearchResponse;
15use crossterm::event::KeyCode;
16use ratatui::layout::Position;
17use ratatui::text::Line;
18use std::collections::HashSet;
19use unicode_width::UnicodeWidthStr;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct SelectedFileMention {
23    pub path: std::path::PathBuf,
24    pub display_name: String,
25}
26
27#[derive(Debug, Default)]
28pub struct Composer {
29    buffer: EditBuffer,
30    overlay: Option<Overlay>,
31    mentions: Vec<SelectedFileMention>,
32    pending_media: Vec<PromptAttachment>,
33    history: PromptHistory,
34    /// Columns the last layout wrapped the input at. Unset until the composer
35    /// has been drawn once, which reads as "wide enough that nothing wraps".
36    content_width: Option<usize>,
37}
38
39/// The inline picker open around the composer's text.
40///
41/// At most one, because they compete for the same keys: making that an enum
42/// rather than two `Option` fields means the pair can never both be open.
43#[derive(Debug)]
44enum Overlay {
45    /// The `/` command or `@` file list, drawn below the text.
46    Completion(CompletionOverlay),
47    /// Prompt-history search, drawn above the text. It carries the draft it
48    /// replaced, so backing out restores what the user was writing.
49    PromptSearch { picker: PromptSearchPicker, draft: String },
50}
51
52/// The prompts submitted this session, and where recall has walked back to.
53///
54/// Navigation stashes the draft it replaced, so stepping past the newest entry
55/// puts the user back where they started.
56#[derive(Debug, Default)]
57struct PromptHistory {
58    entries: Vec<String>,
59    index: Option<usize>,
60    draft: Option<String>,
61}
62
63/// Prompts kept for recall. Older ones are dropped rather than growing forever.
64const MAX_HISTORY_ENTRIES: usize = 500;
65
66impl PromptHistory {
67    fn push(&mut self, prompt: &str) {
68        if prompt.trim().is_empty() {
69            return;
70        }
71        self.entries.push(prompt.to_string());
72        if self.entries.len() > MAX_HISTORY_ENTRIES {
73            self.entries.remove(0);
74        }
75    }
76
77    /// The previous prompt, stashing `draft` the first time recall starts.
78    fn previous(&mut self, draft: &str) -> Option<&str> {
79        let index = match self.index {
80            Some(0) => return None,
81            Some(index) => index - 1,
82            None => {
83                self.draft = Some(draft.to_string());
84                self.entries.len().checked_sub(1)?
85            }
86        };
87        self.index = Some(index);
88        self.entries.get(index).map(String::as_str)
89    }
90
91    /// The next prompt, or the stashed draft once recall walks past the newest.
92    fn next(&mut self) -> Option<String> {
93        let index = self.index?;
94        if index + 1 < self.entries.len() {
95            self.index = Some(index + 1);
96            return self.entries.get(index + 1).cloned();
97        }
98        self.index = None;
99        Some(self.draft.take().unwrap_or_default())
100    }
101
102    /// Ends navigation, so the recalled prompt becomes the user's own draft.
103    fn reset(&mut self) {
104        self.index = None;
105        self.draft = None;
106    }
107}
108
109pub struct ComposerLayout {
110    pub lines: Vec<Line<'static>>,
111    pub cursor: Position,
112}
113
114/// What a keystroke or paste the composer's own overlay consumed asks the app
115/// to do next.
116///
117/// The composer owns its overlays and every edit they imply; the app is told
118/// only the two things it has to act on outside the composer, so the overlay
119/// state itself never has to leave.
120#[derive(Debug)]
121pub enum ComposerOutcome {
122    /// Fully handled inside the composer.
123    Handled,
124    /// A command was accepted from the `/` list and needs running.
125    AcceptedCommand(CommandEntry),
126    /// The history-search query changed and needs re-running against the agent.
127    Search(String),
128}
129
130impl Composer {
131    pub fn new() -> Self {
132        Self::default()
133    }
134
135    pub fn text(&self) -> &str {
136        self.buffer.text()
137    }
138
139    pub fn is_empty(&self) -> bool {
140        self.buffer.is_empty() && self.pending_media.is_empty()
141    }
142
143    pub fn selected_mentions(&self) -> Vec<SelectedFileMention> {
144        // Match whole whitespace-delimited tokens so that `@foo` does not also match the
145        // longer mention `@foobar`. The insertion path always writes `@<display> `, so a
146        // complete token comparison is the correct granularity.
147        let tokens: HashSet<&str> = self.buffer.text().split_whitespace().collect();
148        self.mentions
149            .iter()
150            .filter(|mention| {
151                let needle = format!("@{}", mention.display_name);
152                tokens.contains(needle.as_str())
153            })
154            .cloned()
155            .collect()
156    }
157
158    pub fn take_submission(&mut self) -> (String, Vec<PromptAttachment>) {
159        let text = self.buffer.take();
160        let pending_media = std::mem::take(&mut self.pending_media);
161        self.history.push(&text);
162        self.overlay = None;
163        self.mentions.clear();
164        self.history.reset();
165        (text, pending_media)
166    }
167
168    pub fn clear(&mut self) {
169        self.buffer.set_text("");
170        self.pending_media.clear();
171        self.overlay = None;
172        self.mentions.clear();
173        self.history.reset();
174    }
175
176    pub fn add_dropped_media(&mut self, paths: Vec<std::path::PathBuf>) -> bool {
177        // Atomic across the whole parsed list: if any path is missing or not a
178        // regular file, the paste is ambiguous, so attach nothing and let the
179        // caller keep the original payload as composer text.
180        if !paths.iter().all(|path| path.is_file()) {
181            return false;
182        }
183
184        let mut existing: HashSet<std::path::PathBuf> = self.pending_media.iter().map(|a| a.path.clone()).collect();
185
186        let before = self.pending_media.len();
187
188        for path in paths {
189            if !matches!(classify_attachment(&path), AttachmentKind::Image | AttachmentKind::Audio) {
190                continue;
191            }
192            if !existing.insert(path.clone()) {
193                continue;
194            }
195            let display_name = path
196                .file_name()
197                .map_or_else(|| path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned());
198            self.pending_media.push(PromptAttachment { path, display_name });
199        }
200
201        self.pending_media.len() > before
202    }
203
204    pub fn pending_media(&self) -> &[PromptAttachment] {
205        &self.pending_media
206    }
207
208    /// Applies one shared editing keystroke. Anything that changes the text also
209    /// ends history navigation, so the recalled prompt becomes the user's draft.
210    pub fn apply_edit_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
211        if key.code == KeyCode::Backspace {
212            self.backspace();
213            return true;
214        }
215        let before = self.buffer.text().len();
216        let handled = apply_edit_key(&mut self.buffer, key);
217        if self.buffer.text().len() != before {
218            self.history.reset();
219        }
220        handled
221    }
222
223    pub fn insert_char(&mut self, character: char) {
224        self.history.reset();
225        self.buffer.insert_char(character);
226    }
227
228    pub fn insert_str(&mut self, text: &str) {
229        self.history.reset();
230        self.buffer.insert_str(text);
231    }
232
233    pub fn insert_paste(&mut self, text: &str) {
234        self.history.reset();
235        self.buffer.insert_paste(text);
236    }
237
238    pub fn insert_newline(&mut self) {
239        self.insert_char('\n');
240        self.overlay = None;
241    }
242
243    pub fn backspace(&mut self) {
244        self.history.reset();
245        if self.buffer.is_empty() && !self.pending_media.is_empty() {
246            self.pending_media.pop();
247            return;
248        }
249        self.buffer.backspace();
250    }
251
252    pub fn move_left(&mut self) {
253        self.buffer.move_left();
254    }
255
256    pub fn move_line_start(&mut self) {
257        self.buffer.move_line_start();
258    }
259
260    pub fn move_line_end(&mut self) {
261        self.buffer.move_line_end();
262    }
263
264    /// Moves the cursor to the visual row above, reporting whether there was
265    /// one. A long line the composer soft-wrapped has rows above without any
266    /// newline before the cursor, so this follows what is on screen rather than
267    /// the newlines in the text.
268    pub fn move_up(&mut self) -> bool {
269        self.move_visual_row(|row| row.checked_sub(1))
270    }
271
272    /// Moves the cursor to the visual row below, reporting whether there was one.
273    pub fn move_down(&mut self) -> bool {
274        self.move_visual_row(|row| Some(row + 1))
275    }
276
277    pub fn recall_previous(&mut self) -> bool {
278        let Some(prompt) = self.history.previous(self.buffer.text()).map(str::to_string) else {
279            return false;
280        };
281        self.set_text(prompt);
282        self.buffer.set_cursor(0);
283        true
284    }
285
286    pub fn recall_next(&mut self) -> bool {
287        let Some(prompt) = self.history.next() else {
288            return false;
289        };
290        self.set_text(prompt);
291        self.buffer.set_cursor(self.buffer.text().len());
292        true
293    }
294
295    pub fn open_command_picker(&mut self, commands: Vec<CommandEntry>) {
296        self.overlay = Some(Overlay::Completion(CompletionOverlay::command(commands)));
297    }
298
299    /// Opens the `@` picker and asks for the file index it will show. The walk
300    /// runs off the event loop, so opening the picker never stalls the keystroke
301    /// that triggered it.
302    pub fn open_file_picker(&mut self, root: &std::path::Path) -> FilesystemCommand {
303        let request_id = RequestId::next();
304        self.overlay = Some(Overlay::Completion(CompletionOverlay::file(request_id)));
305        FilesystemCommand::IndexFiles { request_id, root: root.to_path_buf() }
306    }
307
308    pub fn on_files_indexed(&mut self, request_id: RequestId, files: Vec<FileEntry>) {
309        if let Some(overlay) = self.completion_mut() {
310            overlay.set_files(request_id, files);
311        }
312    }
313
314    pub fn has_completion(&self) -> bool {
315        matches!(self.overlay, Some(Overlay::Completion(_)))
316    }
317
318    pub fn completion(&self) -> Option<&CompletionOverlay> {
319        match self.overlay.as_ref()? {
320            Overlay::Completion(overlay) => Some(overlay),
321            Overlay::PromptSearch { .. } => None,
322        }
323    }
324
325    /// The open completion list, for navigation and stateful rendering.
326    pub fn completion_mut(&mut self) -> Option<&mut CompletionOverlay> {
327        match self.overlay.as_mut()? {
328            Overlay::Completion(overlay) => Some(overlay),
329            Overlay::PromptSearch { .. } => None,
330        }
331    }
332
333    pub fn prompt_search(&self) -> Option<&PromptSearchPicker> {
334        match self.overlay.as_ref()? {
335            Overlay::PromptSearch { picker, .. } => Some(picker),
336            Overlay::Completion(_) => None,
337        }
338    }
339
340    /// The open prompt-history picker, for queries and stateful rendering.
341    pub fn prompt_search_mut(&mut self) -> Option<&mut PromptSearchPicker> {
342        match self.overlay.as_mut()? {
343            Overlay::PromptSearch { picker, .. } => Some(picker),
344            Overlay::Completion(_) => None,
345        }
346    }
347
348    pub fn has_open_overlay(&self) -> bool {
349        self.overlay.is_some()
350    }
351
352    /// Routes a mouse event to whichever overlay is open. Browsing history
353    /// results previews each candidate in the composer, the way the arrow keys
354    /// do.
355    pub fn on_overlay_mouse(&mut self, action: MouseAction, row: u16) {
356        let direction = action.direction();
357        match self.overlay.as_mut() {
358            Some(Overlay::Completion(overlay)) => navigate_list(overlay.entries_mut(), direction, row),
359            Some(Overlay::PromptSearch { picker, .. }) => {
360                navigate_list(picker.results_mut(), direction, row);
361                self.apply_selected_search_result();
362            }
363            None => {}
364        }
365    }
366
367    pub fn has_prompt_search(&self) -> bool {
368        matches!(self.overlay, Some(Overlay::PromptSearch { .. }))
369    }
370
371    pub fn open_prompt_search(&mut self, workspace_access: crate::session::WorkspaceAccess) {
372        let draft = self.buffer.text().to_string();
373        self.overlay = Some(Overlay::PromptSearch { picker: PromptSearchPicker::new(workspace_access), draft });
374    }
375
376    /// Closes the search, restoring the draft it replaced unless the user
377    /// confirmed one of the results.
378    fn close_prompt_search(&mut self, confirmed: bool) {
379        let Some(Overlay::PromptSearch { draft, .. }) = self.overlay.take() else {
380            return;
381        };
382        if !confirmed {
383            self.buffer.set_text(draft);
384        }
385    }
386
387    /// Applies a keystroke to the open history search, or reports that there is
388    /// none for it to go to.
389    pub fn on_prompt_search_key(&mut self, key: crossterm::event::KeyEvent) -> Option<ComposerOutcome> {
390        if !self.has_prompt_search() {
391            return None;
392        }
393        let query = self.prompt_search_query_on_key(key);
394        Some(self.search_outcome(query))
395    }
396
397    /// Applies a paste to the open history search, or reports that there is none
398    /// for it to go to.
399    pub fn on_prompt_search_paste(&mut self, text: &str) -> Option<ComposerOutcome> {
400        let query = self.prompt_search_mut()?.push_str(text);
401        Some(self.search_outcome(Some(query)))
402    }
403
404    /// Applies a keystroke to the open completion list, or reports that there is
405    /// none for it to go to.
406    pub fn on_completion_key(&mut self, key: crossterm::event::KeyEvent) -> Option<ComposerOutcome> {
407        if !self.has_completion() {
408            return None;
409        }
410        Some(match self.completion_on_key(key) {
411            Some(command) => ComposerOutcome::AcceptedCommand(command),
412            None => ComposerOutcome::Handled,
413        })
414    }
415
416    /// A new query goes to the agent; an emptied one puts back the draft the
417    /// search replaced, which is the composer's own business.
418    fn search_outcome(&mut self, query: Option<String>) -> ComposerOutcome {
419        match query {
420            Some(query) if !query.trim().is_empty() => ComposerOutcome::Search(query),
421            Some(_) => {
422                if let Some(Overlay::PromptSearch { draft, .. }) = &self.overlay {
423                    self.buffer.set_text(draft.clone());
424                }
425                ComposerOutcome::Handled
426            }
427            None => ComposerOutcome::Handled,
428        }
429    }
430
431    /// Applies a keystroke to the open history search, returning the query to
432    /// re-run when the keystroke changed it.
433    fn prompt_search_query_on_key(&mut self, key: crossterm::event::KeyEvent) -> Option<String> {
434        let picker = self.prompt_search_mut()?;
435        match key.code {
436            KeyCode::Esc => {
437                self.close_prompt_search(false);
438                None
439            }
440            KeyCode::Enter => {
441                let confirmed = picker.selected_result().is_some();
442                self.close_prompt_search(confirmed);
443                None
444            }
445            KeyCode::Down => {
446                picker.results_mut().step(Direction::Forward);
447                self.apply_selected_search_result();
448                None
449            }
450            KeyCode::Up => {
451                picker.results_mut().step(Direction::Backward);
452                self.apply_selected_search_result();
453                None
454            }
455            KeyCode::Backspace => Some(picker.backspace()),
456            KeyCode::Char(c)
457                if !key
458                    .modifiers
459                    .intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) =>
460            {
461                Some(picker.push_char(c))
462            }
463            _ => None,
464        }
465    }
466
467    pub fn prompt_search_on_results(&mut self, response: PromptSearchResponse) {
468        let Some(picker) = self.prompt_search_mut() else {
469            return;
470        };
471        if picker.on_results(response) {
472            self.apply_selected_search_result();
473        }
474    }
475
476    fn apply_selected_search_result(&mut self) {
477        let Some(result) = self.prompt_search().and_then(|picker| picker.selected_result()) else {
478            return;
479        };
480        let prompt = result.prompt.clone();
481        let cursor = prompt_search::cursor_at_match_end(&prompt, result.match_end);
482        self.buffer.set_text(prompt);
483        self.buffer.set_cursor(cursor);
484    }
485
486    /// Applies a keystroke to the open completion list, returning the command it
487    /// accepted.
488    ///
489    /// The mirror of [`Composer::on_prompt_search_key`]: the composer owns the
490    /// list's own keys and the edits they imply, and the app decides what an
491    /// accepted command means.
492    fn completion_on_key(&mut self, key: crossterm::event::KeyEvent) -> Option<CommandEntry> {
493        match key.code {
494            KeyCode::Esc => self.close_overlay(),
495            KeyCode::Up => self.step_completion(Direction::Backward),
496            KeyCode::Down => self.step_completion(Direction::Forward),
497            KeyCode::Enter | KeyCode::Tab => {
498                let command = self.accept_command();
499                if command.is_none() {
500                    self.accept_file();
501                }
502                return command;
503            }
504            KeyCode::Backspace if self.completion().is_some_and(|overlay| overlay.query().is_empty()) => {
505                self.backspace();
506                self.close_overlay();
507            }
508            KeyCode::Backspace => {
509                self.backspace();
510                self.refresh_overlay_query();
511            }
512            KeyCode::Char(character)
513                if !key
514                    .modifiers
515                    .intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) =>
516            {
517                self.insert_char(character);
518                if character.is_whitespace() {
519                    self.close_overlay();
520                } else {
521                    self.refresh_overlay_query();
522                }
523            }
524            _ => {}
525        }
526        None
527    }
528
529    fn step_completion(&mut self, direction: Direction) {
530        if let Some(overlay) = self.completion_mut() {
531            overlay.entries_mut().step(direction);
532        }
533    }
534
535    fn close_overlay(&mut self) {
536        self.overlay = None;
537    }
538
539    pub fn accept_command(&mut self) -> Option<CommandEntry> {
540        let command = self.completion()?.selected_command()?;
541        self.replace_token('/', &format!("/{}", command.name));
542        self.overlay = None;
543        Some(command)
544    }
545
546    pub fn accept_file(&mut self) -> Option<FileEntry> {
547        let file = self.completion()?.selected_file()?;
548        self.replace_token('@', &format!("@{} ", file.display_name));
549        self.mentions.push(SelectedFileMention { path: file.path.clone(), display_name: file.display_name.clone() });
550        self.overlay = None;
551        Some(file)
552    }
553
554    pub fn refresh_overlay_query(&mut self) {
555        let Some(trigger) = self.completion().map(CompletionOverlay::trigger) else {
556            return;
557        };
558        let query = self
559            .active_token(trigger)
560            .map_or_else(String::new, |range| self.buffer.text()[range.start + 1..range.end].to_string());
561        if let Some(overlay) = self.completion_mut() {
562            overlay.set_query(query);
563        }
564    }
565
566    /// Moves the cursor to the row `target` picks, keeping its column where that
567    /// row is long enough to hold it.
568    fn move_visual_row(&mut self, target: impl FnOnce(usize) -> Option<usize>) -> bool {
569        let content_width = self.content_width.unwrap_or(usize::MAX);
570        let layout = view::input_layout(self.buffer.text(), self.buffer.cursor(), content_width);
571        let Some(cursor) =
572            target(layout.cursor_row).and_then(|row| layout.byte_at(self.buffer.text(), row, layout.cursor_column))
573        else {
574            return false;
575        };
576        self.buffer.set_cursor(cursor);
577        true
578    }
579
580    pub fn cursor_position(&self) -> (usize, usize) {
581        let before = &self.buffer.text()[..self.buffer.cursor()];
582        let row = before.matches('\n').count();
583        let column = before[self.buffer.line_start()..].width();
584        (row, column)
585    }
586
587    fn active_token(&self, trigger: char) -> Option<std::ops::Range<usize>> {
588        let before_cursor = &self.buffer.text()[..self.buffer.cursor()];
589        let start = before_cursor.rfind(trigger)?;
590        let before_trigger = &before_cursor[..start];
591        (trigger == '/' && start == 0
592            || trigger == '@' && (before_trigger.is_empty() || before_trigger.ends_with(char::is_whitespace)))
593        .then_some(start..self.buffer.cursor())
594    }
595
596    fn replace_token(&mut self, trigger: char, replacement: &str) {
597        let Some(range) = self.active_token(trigger) else {
598            return;
599        };
600        self.buffer.replace_range(range, replacement);
601    }
602
603    fn set_text(&mut self, text: String) {
604        self.buffer.set_text(text);
605        self.mentions.clear();
606        self.overlay = None;
607    }
608}
609
610fn navigate_list<T>(list: &mut FilterableList<T>, direction: Option<Direction>, row: u16) {
611    match direction {
612        Some(direction) => list.step(direction),
613        None => {
614            list.select_at(row);
615        }
616    }
617}