use std::fmt::Write;
use std::io::Read;
use std::path::Path;
use ignore::WalkBuilder;
use crate::tui::app::App;
use crate::working_set::Workspace;
pub const MAX_FILE_MENTIONS_PER_MESSAGE: usize = 8;
pub const MAX_MENTION_FILE_BYTES: u64 = 128 * 1024;
pub const MAX_DIRECTORY_MENTION_ENTRIES: usize = 80;
const FILE_MENTION_COMPLETION_LIMIT: usize = 64;
const FILE_MENTION_COMPLETION_DEPTH: usize = 6;
pub fn partial_file_mention_at_cursor(input: &str, cursor_chars: usize) -> Option<(usize, String)> {
let chars: Vec<char> = input.chars().collect();
if cursor_chars > chars.len() {
return None;
}
let mut start_chars = cursor_chars;
while start_chars > 0 {
let prev = chars[start_chars - 1];
if prev == '@' {
start_chars -= 1;
break;
}
if prev.is_whitespace() {
return None;
}
start_chars -= 1;
}
if start_chars == cursor_chars || chars.get(start_chars) != Some(&'@') {
return None;
}
if !is_file_mention_start(&chars, start_chars) {
return None;
}
let mut end_chars = start_chars + 1;
while end_chars < chars.len() && !chars[end_chars].is_whitespace() {
end_chars += 1;
}
let partial: String = chars[start_chars + 1..end_chars].iter().collect();
let byte_start: usize = chars[..start_chars].iter().map(|c| c.len_utf8()).sum();
Some((byte_start, partial))
}
pub fn find_file_mention_completions(workspace: &Path, partial: &str, limit: usize) -> Vec<String> {
if limit == 0 {
return Vec::new();
}
let needle = partial.to_lowercase();
let mut prefix_hits: Vec<String> = Vec::new();
let mut substring_hits: Vec<String> = Vec::new();
let mut builder = WalkBuilder::new(workspace);
builder
.hidden(true)
.follow_links(false)
.max_depth(Some(FILE_MENTION_COMPLETION_DEPTH));
for entry in builder.build().flatten() {
if prefix_hits.len() + substring_hits.len() >= limit {
break;
}
let path = entry.path();
let Ok(rel) = path.strip_prefix(workspace) else {
continue;
};
let rel_str = rel.to_string_lossy().replace('\\', "/");
if rel_str.is_empty() {
continue;
}
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
let candidate = if is_dir {
format!("{rel_str}/")
} else {
rel_str.clone()
};
let lower = candidate.to_lowercase();
if needle.is_empty() || lower.starts_with(&needle) {
prefix_hits.push(candidate);
} else if lower.contains(&needle) {
substring_hits.push(candidate);
}
}
prefix_hits.sort();
substring_hits.sort();
prefix_hits.extend(substring_hits);
prefix_hits.truncate(limit);
prefix_hits
}
#[must_use]
pub fn visible_mention_menu_entries(app: &App, limit: usize) -> Vec<String> {
if app.mention_menu_hidden {
return Vec::new();
}
let Some((_byte_start, partial)) =
partial_file_mention_at_cursor(&app.input, app.cursor_position)
else {
return Vec::new();
};
if limit == 0 {
return Vec::new();
}
find_file_mention_completions(&app.workspace, &partial, limit)
}
pub fn apply_mention_menu_selection(app: &mut App, entries: &[String]) -> bool {
if entries.is_empty() {
return false;
}
let Some((byte_start, partial)) =
partial_file_mention_at_cursor(&app.input, app.cursor_position)
else {
return false;
};
let selected_idx = app
.mention_menu_selected
.min(entries.len().saturating_sub(1));
let replacement = &entries[selected_idx];
replace_file_mention(app, byte_start, &partial, replacement);
app.mention_menu_hidden = false;
app.status_message = Some(format!("Attached @{replacement}"));
true
}
pub fn try_autocomplete_file_mention(app: &mut App) -> bool {
let Some((byte_start, partial)) =
partial_file_mention_at_cursor(&app.input, app.cursor_position)
else {
return false;
};
let workspace = app.workspace.clone();
let candidates =
find_file_mention_completions(&workspace, &partial, FILE_MENTION_COMPLETION_LIMIT);
if candidates.is_empty() {
app.status_message = Some(format!("No files match @{partial}"));
return true;
}
if candidates.len() == 1 {
replace_file_mention(app, byte_start, &partial, &candidates[0]);
app.status_message = Some(format!("Attached @{}", candidates[0]));
return true;
}
let candidate_refs: Vec<&str> = candidates.iter().map(String::as_str).collect();
let shared = longest_common_prefix(&candidate_refs);
if shared.len() > partial.len() {
replace_file_mention(app, byte_start, &partial, shared);
app.status_message = Some(format!("@{shared}…"));
return true;
}
let preview = candidates
.iter()
.take(5)
.map(|c| format!("@{c}"))
.collect::<Vec<_>>()
.join(", ");
app.status_message = Some(format!("Matches: {preview}"));
true
}
fn replace_file_mention(app: &mut App, byte_start: usize, partial: &str, replacement: &str) {
let original_token_len = '@'.len_utf8() + partial.len();
let original_token_end = byte_start + original_token_len;
let mut new_input =
String::with_capacity(app.input.len() - original_token_len + 1 + replacement.len());
new_input.push_str(&app.input[..byte_start]);
new_input.push('@');
new_input.push_str(replacement);
if original_token_end < app.input.len() {
new_input.push_str(&app.input[original_token_end..]);
}
let new_cursor_chars =
app.input[..byte_start].chars().count() + 1 + replacement.chars().count();
app.input = new_input;
app.cursor_position = new_cursor_chars;
}
pub fn longest_common_prefix<'a>(values: &[&'a str]) -> &'a str {
let Some(first) = values.first().copied() else {
return "";
};
let mut end = first.len();
for value in values.iter().skip(1) {
while end > 0 && !value.starts_with(&first[..end]) {
end -= 1;
while end > 0 && !first.is_char_boundary(end) {
end -= 1;
}
}
if end == 0 {
return "";
}
}
&first[..end]
}
pub fn user_request_with_file_mentions(input: &str, workspace: &Path) -> String {
let Some(context) = local_context_from_file_mentions(input, workspace) else {
return input.to_string();
};
format!("{input}\n\n---\n\nLocal context from @mentions:\n{context}")
}
fn local_context_from_file_mentions(input: &str, workspace: &Path) -> Option<String> {
let mentions = extract_file_mentions(input);
if mentions.is_empty() {
return None;
}
let mut blocks = Vec::new();
let mut seen = std::collections::HashSet::new();
let ws = Workspace::new(workspace.to_path_buf());
for mention in mentions.into_iter().take(MAX_FILE_MENTIONS_PER_MESSAGE) {
let (path, display_path, exists) = match ws.resolve(&mention) {
Ok(p) => {
let d = p.display().to_string();
(p, d, true)
}
Err(p) => {
let d = p.display().to_string();
(p, d, false)
}
};
if !seen.insert(display_path.clone()) {
continue;
}
if exists {
blocks.push(render_file_mention_context(&mention, &path, &display_path));
} else {
blocks.push(format!(
"<missing-file mention=\"@{mention}\" path=\"{display_path}\" />"
));
}
}
if blocks.is_empty() {
None
} else {
Some(blocks.join("\n\n"))
}
}
fn extract_file_mentions(input: &str) -> Vec<String> {
let chars: Vec<char> = input.chars().collect();
let mut mentions = Vec::new();
let mut idx = 0;
while idx < chars.len() {
if chars[idx] != '@' || !is_file_mention_start(&chars, idx) {
idx += 1;
continue;
}
let Some(next) = chars.get(idx + 1).copied() else {
break;
};
if next.is_whitespace() {
idx += 1;
continue;
}
if matches!(next, '"' | '\'') {
let quote = next;
let mut end = idx + 2;
let mut raw = String::new();
while end < chars.len() && chars[end] != quote {
raw.push(chars[end]);
end += 1;
}
if !raw.trim().is_empty() {
mentions.push(raw.trim().to_string());
}
idx = end.saturating_add(1);
continue;
}
let mut end = idx + 1;
let mut raw = String::new();
while end < chars.len() && !chars[end].is_whitespace() {
raw.push(chars[end]);
end += 1;
}
let trimmed = trim_unquoted_mention(&raw);
if !trimmed.is_empty() {
mentions.push(trimmed.to_string());
}
idx = end;
}
mentions
}
fn is_file_mention_start(chars: &[char], idx: usize) -> bool {
if idx == 0 {
return true;
}
chars
.get(idx.saturating_sub(1))
.is_some_and(|ch| ch.is_whitespace() || matches!(ch, '(' | '[' | '{' | '<' | '"' | '\''))
}
fn trim_unquoted_mention(raw: &str) -> &str {
let mut trimmed = raw.trim();
while trimmed.chars().count() > 1
&& trimmed
.chars()
.last()
.is_some_and(|ch| matches!(ch, ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'))
{
trimmed = &trimmed[..trimmed.len() - trimmed.chars().last().unwrap().len_utf8()];
}
trimmed
}
fn render_file_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
if !path.exists() {
return format!("<missing-file mention=\"@{raw}\" path=\"{display_path}\" />");
}
if path.is_dir() {
return render_directory_mention_context(raw, path, display_path);
}
if !path.is_file() {
return format!("<unsupported-path mention=\"@{raw}\" path=\"{display_path}\" />");
}
if is_media_path(path) {
return format!(
"<media-file mention=\"@{raw}\" path=\"{display_path}\">\nUse /attach {raw} when the intent is to attach this image or video to the next message.\n</media-file>"
);
}
match read_text_prefix(path) {
Ok((text, truncated)) => {
let truncated_attr = if truncated { " truncated=\"true\"" } else { "" };
format!(
"<file mention=\"@{raw}\" path=\"{display_path}\"{truncated_attr}>\n{text}\n</file>"
)
}
Err(err) => {
format!(
"<unreadable-file mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-file>"
)
}
}
}
fn render_directory_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
let entries = match std::fs::read_dir(path) {
Ok(entries) => entries,
Err(err) => {
return format!(
"<unreadable-directory mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-directory>"
);
}
};
let mut names = entries
.filter_map(|entry| entry.ok())
.map(|entry| {
let marker = entry
.file_type()
.ok()
.filter(|ty| ty.is_dir())
.map_or("", |_| "/");
format!("{}{}", entry.file_name().to_string_lossy(), marker)
})
.collect::<Vec<_>>();
names.sort();
let total = names.len();
names.truncate(MAX_DIRECTORY_MENTION_ENTRIES);
let mut body = names.join("\n");
if total > MAX_DIRECTORY_MENTION_ENTRIES {
let omitted = total - MAX_DIRECTORY_MENTION_ENTRIES;
let _ = write!(body, "\n... {omitted} more entries");
}
format!("<directory mention=\"@{raw}\" path=\"{display_path}\">\n{body}\n</directory>")
}
fn read_text_prefix(path: &Path) -> std::io::Result<(String, bool)> {
let mut file = std::fs::File::open(path)?;
let mut buffer = Vec::new();
file.by_ref()
.take(MAX_MENTION_FILE_BYTES + 1)
.read_to_end(&mut buffer)?;
let truncated = buffer.len() as u64 > MAX_MENTION_FILE_BYTES;
if truncated {
buffer.truncate(MAX_MENTION_FILE_BYTES as usize);
}
if buffer.contains(&0) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"file appears to be binary",
));
}
let text = if truncated {
String::from_utf8_lossy(&buffer).to_string()
} else {
std::str::from_utf8(&buffer)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "file is not UTF-8"))?
.to_string()
};
Ok((text, truncated))
}
fn is_media_path(path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
return false;
};
matches!(
ext.to_ascii_lowercase().as_str(),
"png"
| "jpg"
| "jpeg"
| "gif"
| "webp"
| "bmp"
| "tif"
| "tiff"
| "ppm"
| "mp4"
| "mov"
| "m4v"
| "webm"
| "avi"
| "mkv"
)
}