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 content_width: Option<usize>,
37}
38
39#[derive(Debug)]
44enum Overlay {
45 Completion(CompletionOverlay),
47 PromptSearch { picker: PromptSearchPicker, draft: String },
50}
51
52#[derive(Debug, Default)]
57struct PromptHistory {
58 entries: Vec<String>,
59 index: Option<usize>,
60 draft: Option<String>,
61}
62
63const 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 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 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 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#[derive(Debug)]
121pub enum ComposerOutcome {
122 Handled,
124 AcceptedCommand(CommandEntry),
126 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 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 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 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 pub fn move_up(&mut self) -> bool {
269 self.move_visual_row(|row| row.checked_sub(1))
270 }
271
272 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 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 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 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 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 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 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 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 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 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 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 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 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}