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