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 content_width: Option<usize>,
43}
44
45#[derive(Debug)]
50enum Overlay {
51 Completion(CompletionOverlay),
53 PromptSearch { picker: PromptSearchPicker, draft: String },
56}
57
58#[derive(Debug, Default)]
63struct PromptHistory {
64 entries: Vec<String>,
65 index: Option<usize>,
66 draft: Option<String>,
67}
68
69const 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 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 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 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#[derive(Debug)]
127pub enum ComposerOutcome {
128 Handled,
130 AcceptedCommand(CommandEntry),
132 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 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 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 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 pub fn move_up(&mut self) -> bool {
286 self.move_visual_row(|row| row.checked_sub(1))
287 }
288
289 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 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 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 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 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 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 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 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 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 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 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 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 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}