mod view;
pub(crate) use view::ComposerBodyView;
use crate::attachment::{AttachmentKind, PromptAttachment, classify_attachment};
use crate::command::FilesystemCommand;
use crate::file_index::FileEntry;
use crate::surfaces::input::MouseAction;
use crate::surfaces::picker::{CommandEntry, CompletionOverlay};
use crate::surfaces::prompt_search::{self, PromptSearchPicker};
use crate::view::edit_buffer::{EditBuffer, apply_edit_key};
use crate::view::filterable_list::FilterableList;
use crate::request::RequestId;
use crate::view::selection::Direction;
use acp_utils::notifications::PromptSearchResponse;
use crossterm::event::KeyCode;
use ratatui::layout::Position;
use ratatui::text::Line;
use std::collections::HashSet;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectedFileMention {
pub path: std::path::PathBuf,
pub display_name: String,
}
#[derive(Debug, Default)]
pub struct Composer {
buffer: EditBuffer,
overlay: Option<Overlay>,
mentions: Vec<SelectedFileMention>,
pending_media: Vec<PromptAttachment>,
history: PromptHistory,
content_width: Option<usize>,
}
#[derive(Debug)]
enum Overlay {
Completion(CompletionOverlay),
PromptSearch { picker: PromptSearchPicker, draft: String },
}
#[derive(Debug, Default)]
struct PromptHistory {
entries: Vec<String>,
index: Option<usize>,
draft: Option<String>,
}
const MAX_HISTORY_ENTRIES: usize = 500;
impl PromptHistory {
fn push(&mut self, prompt: &str) {
if prompt.trim().is_empty() {
return;
}
self.entries.push(prompt.to_string());
if self.entries.len() > MAX_HISTORY_ENTRIES {
self.entries.remove(0);
}
}
fn previous(&mut self, draft: &str) -> Option<&str> {
let index = match self.index {
Some(0) => return None,
Some(index) => index - 1,
None => {
self.draft = Some(draft.to_string());
self.entries.len().checked_sub(1)?
}
};
self.index = Some(index);
self.entries.get(index).map(String::as_str)
}
fn next(&mut self) -> Option<String> {
let index = self.index?;
if index + 1 < self.entries.len() {
self.index = Some(index + 1);
return self.entries.get(index + 1).cloned();
}
self.index = None;
Some(self.draft.take().unwrap_or_default())
}
fn reset(&mut self) {
self.index = None;
self.draft = None;
}
}
pub struct ComposerLayout {
pub lines: Vec<Line<'static>>,
pub cursor: Position,
}
#[derive(Debug)]
pub enum ComposerOutcome {
Handled,
AcceptedCommand(CommandEntry),
Search(String),
}
impl Composer {
pub fn new() -> Self {
Self::default()
}
pub fn text(&self) -> &str {
self.buffer.text()
}
pub fn is_empty(&self) -> bool {
self.buffer.is_empty() && self.pending_media.is_empty()
}
pub fn selected_mentions(&self) -> Vec<SelectedFileMention> {
let tokens: HashSet<&str> = self.buffer.text().split_whitespace().collect();
self.mentions
.iter()
.filter(|mention| {
let needle = format!("@{}", mention.display_name);
tokens.contains(needle.as_str())
})
.cloned()
.collect()
}
pub fn take_submission(&mut self) -> (String, Vec<PromptAttachment>) {
let text = self.buffer.take();
let pending_media = std::mem::take(&mut self.pending_media);
self.history.push(&text);
self.overlay = None;
self.mentions.clear();
self.history.reset();
(text, pending_media)
}
pub fn clear(&mut self) {
self.buffer.set_text("");
self.pending_media.clear();
self.overlay = None;
self.mentions.clear();
self.history.reset();
}
pub fn add_dropped_media(&mut self, paths: Vec<std::path::PathBuf>) -> bool {
if !paths.iter().all(|path| path.is_file()) {
return false;
}
let mut existing: HashSet<std::path::PathBuf> = self.pending_media.iter().map(|a| a.path.clone()).collect();
let before = self.pending_media.len();
for path in paths {
if !matches!(classify_attachment(&path), AttachmentKind::Image | AttachmentKind::Audio) {
continue;
}
if !existing.insert(path.clone()) {
continue;
}
let display_name = path
.file_name()
.map_or_else(|| path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned());
self.pending_media.push(PromptAttachment { path, display_name });
}
self.pending_media.len() > before
}
pub fn pending_media(&self) -> &[PromptAttachment] {
&self.pending_media
}
pub fn apply_edit_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
if key.code == KeyCode::Backspace {
self.backspace();
return true;
}
let before = self.buffer.text().len();
let handled = apply_edit_key(&mut self.buffer, key);
if self.buffer.text().len() != before {
self.history.reset();
}
handled
}
pub fn insert_char(&mut self, character: char) {
self.history.reset();
self.buffer.insert_char(character);
}
pub fn insert_str(&mut self, text: &str) {
self.history.reset();
self.buffer.insert_str(text);
}
pub fn insert_paste(&mut self, text: &str) {
self.history.reset();
self.buffer.insert_paste(text);
}
pub fn insert_newline(&mut self) {
self.insert_char('\n');
self.overlay = None;
}
pub fn backspace(&mut self) {
self.history.reset();
if self.buffer.is_empty() && !self.pending_media.is_empty() {
self.pending_media.pop();
return;
}
self.buffer.backspace();
}
pub fn move_left(&mut self) {
self.buffer.move_left();
}
pub fn move_line_start(&mut self) {
self.buffer.move_line_start();
}
pub fn move_line_end(&mut self) {
self.buffer.move_line_end();
}
pub fn move_up(&mut self) -> bool {
self.move_visual_row(|row| row.checked_sub(1))
}
pub fn move_down(&mut self) -> bool {
self.move_visual_row(|row| Some(row + 1))
}
pub fn recall_previous(&mut self) -> bool {
let Some(prompt) = self.history.previous(self.buffer.text()).map(str::to_string) else {
return false;
};
self.set_text(prompt);
self.buffer.set_cursor(0);
true
}
pub fn recall_next(&mut self) -> bool {
let Some(prompt) = self.history.next() else {
return false;
};
self.set_text(prompt);
self.buffer.set_cursor(self.buffer.text().len());
true
}
pub fn open_command_picker(&mut self, commands: Vec<CommandEntry>) {
self.overlay = Some(Overlay::Completion(CompletionOverlay::command(commands)));
}
pub fn open_file_picker(&mut self, root: &std::path::Path) -> FilesystemCommand {
let request_id = RequestId::next();
self.overlay = Some(Overlay::Completion(CompletionOverlay::file(request_id)));
FilesystemCommand::IndexFiles { request_id, root: root.to_path_buf() }
}
pub fn on_files_indexed(&mut self, request_id: RequestId, files: Vec<FileEntry>) {
if let Some(overlay) = self.completion_mut() {
overlay.set_files(request_id, files);
}
}
pub fn has_completion(&self) -> bool {
matches!(self.overlay, Some(Overlay::Completion(_)))
}
pub fn completion(&self) -> Option<&CompletionOverlay> {
match self.overlay.as_ref()? {
Overlay::Completion(overlay) => Some(overlay),
Overlay::PromptSearch { .. } => None,
}
}
pub fn completion_mut(&mut self) -> Option<&mut CompletionOverlay> {
match self.overlay.as_mut()? {
Overlay::Completion(overlay) => Some(overlay),
Overlay::PromptSearch { .. } => None,
}
}
pub fn prompt_search(&self) -> Option<&PromptSearchPicker> {
match self.overlay.as_ref()? {
Overlay::PromptSearch { picker, .. } => Some(picker),
Overlay::Completion(_) => None,
}
}
pub fn prompt_search_mut(&mut self) -> Option<&mut PromptSearchPicker> {
match self.overlay.as_mut()? {
Overlay::PromptSearch { picker, .. } => Some(picker),
Overlay::Completion(_) => None,
}
}
pub fn has_open_overlay(&self) -> bool {
self.overlay.is_some()
}
pub fn on_overlay_mouse(&mut self, action: MouseAction, row: u16) {
let direction = action.direction();
match self.overlay.as_mut() {
Some(Overlay::Completion(overlay)) => navigate_list(overlay.entries_mut(), direction, row),
Some(Overlay::PromptSearch { picker, .. }) => {
navigate_list(picker.results_mut(), direction, row);
self.apply_selected_search_result();
}
None => {}
}
}
pub fn has_prompt_search(&self) -> bool {
matches!(self.overlay, Some(Overlay::PromptSearch { .. }))
}
pub fn open_prompt_search(&mut self) {
let draft = self.buffer.text().to_string();
self.overlay = Some(Overlay::PromptSearch { picker: PromptSearchPicker::new(), draft });
}
fn close_prompt_search(&mut self, confirmed: bool) {
let Some(Overlay::PromptSearch { draft, .. }) = self.overlay.take() else {
return;
};
if !confirmed {
self.buffer.set_text(draft);
}
}
pub fn on_prompt_search_key(&mut self, key: crossterm::event::KeyEvent) -> Option<ComposerOutcome> {
if !self.has_prompt_search() {
return None;
}
let query = self.prompt_search_query_on_key(key);
Some(self.search_outcome(query))
}
pub fn on_prompt_search_paste(&mut self, text: &str) -> Option<ComposerOutcome> {
let query = self.prompt_search_mut()?.push_str(text);
Some(self.search_outcome(Some(query)))
}
pub fn on_completion_key(&mut self, key: crossterm::event::KeyEvent) -> Option<ComposerOutcome> {
if !self.has_completion() {
return None;
}
Some(match self.completion_on_key(key) {
Some(command) => ComposerOutcome::AcceptedCommand(command),
None => ComposerOutcome::Handled,
})
}
fn search_outcome(&mut self, query: Option<String>) -> ComposerOutcome {
match query {
Some(query) if !query.trim().is_empty() => ComposerOutcome::Search(query),
Some(_) => {
if let Some(Overlay::PromptSearch { draft, .. }) = &self.overlay {
self.buffer.set_text(draft.clone());
}
ComposerOutcome::Handled
}
None => ComposerOutcome::Handled,
}
}
fn prompt_search_query_on_key(&mut self, key: crossterm::event::KeyEvent) -> Option<String> {
let picker = self.prompt_search_mut()?;
match key.code {
KeyCode::Esc => {
self.close_prompt_search(false);
None
}
KeyCode::Enter => {
let confirmed = picker.selected_result().is_some();
self.close_prompt_search(confirmed);
None
}
KeyCode::Down => {
picker.results_mut().step(Direction::Forward);
self.apply_selected_search_result();
None
}
KeyCode::Up => {
picker.results_mut().step(Direction::Backward);
self.apply_selected_search_result();
None
}
KeyCode::Backspace => Some(picker.backspace()),
KeyCode::Char(c)
if !key
.modifiers
.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) =>
{
Some(picker.push_char(c))
}
_ => None,
}
}
pub fn prompt_search_on_results(&mut self, response: PromptSearchResponse) {
let Some(picker) = self.prompt_search_mut() else {
return;
};
if picker.on_results(response) {
self.apply_selected_search_result();
}
}
fn apply_selected_search_result(&mut self) {
let Some(result) = self.prompt_search().and_then(|picker| picker.selected_result()) else {
return;
};
let prompt = result.prompt.clone();
let cursor = prompt_search::cursor_at_match_end(&prompt, result.match_end);
self.buffer.set_text(prompt);
self.buffer.set_cursor(cursor);
}
fn completion_on_key(&mut self, key: crossterm::event::KeyEvent) -> Option<CommandEntry> {
match key.code {
KeyCode::Esc => self.close_overlay(),
KeyCode::Up => self.step_completion(Direction::Backward),
KeyCode::Down => self.step_completion(Direction::Forward),
KeyCode::Enter | KeyCode::Tab => {
let command = self.accept_command();
if command.is_none() {
self.accept_file();
}
return command;
}
KeyCode::Backspace if self.completion().is_some_and(|overlay| overlay.query().is_empty()) => {
self.backspace();
self.close_overlay();
}
KeyCode::Backspace => {
self.backspace();
self.refresh_overlay_query();
}
KeyCode::Char(character)
if !key
.modifiers
.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) =>
{
self.insert_char(character);
if character.is_whitespace() {
self.close_overlay();
} else {
self.refresh_overlay_query();
}
}
_ => {}
}
None
}
fn step_completion(&mut self, direction: Direction) {
if let Some(overlay) = self.completion_mut() {
overlay.entries_mut().step(direction);
}
}
fn close_overlay(&mut self) {
self.overlay = None;
}
pub fn accept_command(&mut self) -> Option<CommandEntry> {
let command = self.completion()?.selected_command()?;
self.replace_token('/', &format!("/{}", command.name));
self.overlay = None;
Some(command)
}
pub fn accept_file(&mut self) -> Option<FileEntry> {
let file = self.completion()?.selected_file()?;
self.replace_token('@', &format!("@{} ", file.display_name));
self.mentions.push(SelectedFileMention { path: file.path.clone(), display_name: file.display_name.clone() });
self.overlay = None;
Some(file)
}
pub fn refresh_overlay_query(&mut self) {
let Some(trigger) = self.completion().map(CompletionOverlay::trigger) else {
return;
};
let query = self
.active_token(trigger)
.map_or_else(String::new, |range| self.buffer.text()[range.start + 1..range.end].to_string());
if let Some(overlay) = self.completion_mut() {
overlay.set_query(query);
}
}
fn move_visual_row(&mut self, target: impl FnOnce(usize) -> Option<usize>) -> bool {
let content_width = self.content_width.unwrap_or(usize::MAX);
let layout = view::input_layout(self.buffer.text(), self.buffer.cursor(), content_width);
let Some(cursor) =
target(layout.cursor_row).and_then(|row| layout.byte_at(self.buffer.text(), row, layout.cursor_column))
else {
return false;
};
self.buffer.set_cursor(cursor);
true
}
pub fn cursor_position(&self) -> (usize, usize) {
let before = &self.buffer.text()[..self.buffer.cursor()];
let row = before.matches('\n').count();
let column = before[self.buffer.line_start()..].width();
(row, column)
}
fn active_token(&self, trigger: char) -> Option<std::ops::Range<usize>> {
let before_cursor = &self.buffer.text()[..self.buffer.cursor()];
let start = before_cursor.rfind(trigger)?;
let before_trigger = &before_cursor[..start];
(trigger == '/' && start == 0
|| trigger == '@' && (before_trigger.is_empty() || before_trigger.ends_with(char::is_whitespace)))
.then_some(start..self.buffer.cursor())
}
fn replace_token(&mut self, trigger: char, replacement: &str) {
let Some(range) = self.active_token(trigger) else {
return;
};
self.buffer.replace_range(range, replacement);
}
fn set_text(&mut self, text: String) {
self.buffer.set_text(text);
self.mentions.clear();
self.overlay = None;
}
}
fn navigate_list<T>(list: &mut FilterableList<T>, direction: Option<Direction>, row: u16) {
match direction {
Some(direction) => list.step(direction),
None => {
list.select_at(row);
}
}
}