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 content_width: Option<usize>,
56}
57
58#[derive(Debug)]
63enum Overlay {
64 Completion(CompletionOverlay),
66 PromptSearch { picker: PromptSearchPicker, draft: String },
69}
70
71#[derive(Debug, Default)]
76struct PromptHistory {
77 entries: Vec<String>,
78 index: Option<usize>,
79 draft: Option<String>,
80}
81
82const 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 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 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 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#[derive(Debug)]
140pub enum ComposerOutcome {
141 Handled,
143 AcceptedCommand(CommandEntry),
145 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 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 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 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 pub fn move_up(&mut self) -> bool {
299 self.move_visual_row(|row| row.checked_sub(1))
300 }
301
302 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 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 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 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 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 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 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 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 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 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 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 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 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}