Skip to main content

wisp/surfaces/composer/
mod.rs

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