use std::fmt::Write;
use std::io::Read;
use std::path::{Path, PathBuf};
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;
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: &Workspace,
partial: &str,
limit: usize,
) -> Vec<String> {
let entries = workspace.completions(partial, limit);
tracing::debug!(
target: "deepseek_tui::file_mention",
partial = %partial,
workspace = %workspace.root.display(),
cwd = ?std::env::current_dir().ok(),
match_count = entries.len(),
"file mention completion walk",
);
entries
}
fn workspace_for_app(app: &App) -> Workspace {
Workspace::with_cwd(app.workspace.clone(), std::env::current_dir().ok())
}
#[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();
}
let ws = workspace_for_app(app);
find_file_mention_completions(&ws, &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 ws = workspace_for_app(app);
let candidates = find_file_mention_completions(&ws, &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,
cwd: Option<PathBuf>,
) -> String {
let Some(context) = local_context_from_file_mentions(input, workspace, cwd) 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,
cwd: Option<PathBuf>,
) -> 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::with_cwd(workspace.to_path_buf(), cwd);
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)
}
};
tracing::debug!(
target: "deepseek_tui::file_mention",
raw_typed = %mention,
workspace = %workspace.display(),
cwd = ?std::env::current_dir().ok(),
resolved = %display_path,
exists,
"file mention resolution",
);
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"
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn cwd_pass_resolves_when_workspace_pass_misses() {
let tmp = TempDir::new().expect("tempdir");
let sub = tmp.path().join("sub");
std::fs::create_dir_all(&sub).expect("mkdir");
let bar = sub.join("bar.txt");
std::fs::write(&bar, "hello bar").expect("write bar");
let content =
user_request_with_file_mentions("look at @bar.txt", tmp.path(), Some(sub.clone()));
assert!(
content.contains("hello bar"),
"expected file body to be inlined; got: {content}",
);
assert!(
!content.contains("<missing-file"),
"must not surface <missing-file> for a path that exists under cwd; got: {content}",
);
let bar_disp = bar.display().to_string();
assert!(
content.contains(&bar_disp),
"expected resolved path {bar_disp} in content; got: {content}",
);
let wrong = tmp.path().join("bar.txt").display().to_string();
assert!(
!content.contains(&format!("path=\"{wrong}\"")),
"should NOT have routed to {wrong}; got: {content}",
);
}
#[test]
fn workspace_pass_resolves_nested_path() {
let tmp = TempDir::new().expect("tempdir");
let nested = tmp.path().join("nested/deep");
std::fs::create_dir_all(&nested).expect("mkdir");
let file_md = nested.join("file.md");
std::fs::write(&file_md, "# nested deep").expect("write file_md");
let content = user_request_with_file_mentions("see @nested/deep/file.md", tmp.path(), None);
assert!(content.contains("# nested deep"), "got: {content}");
assert!(!content.contains("<missing-file"), "got: {content}");
let basename = file_md
.file_name()
.and_then(|n| n.to_str())
.expect("file_name utf-8");
assert!(
content.contains(basename),
"basename {basename} not in path; got: {content}",
);
}
#[test]
fn resolvable_mention_renders_file_block_not_missing_file() {
let tmp = TempDir::new().expect("tempdir");
std::fs::write(tmp.path().join("guide.md"), "# Guide\nUse the fast path.\n")
.expect("write");
let content = user_request_with_file_mentions("read @guide.md", tmp.path(), None);
assert!(content.contains("Local context from @mentions:"));
assert!(content.contains("<file mention=\"@guide.md\""));
assert!(content.contains("# Guide\nUse the fast path."));
assert!(content.ends_with("</file>"), "got: {content}");
assert!(!content.contains("<missing-file"), "got: {content}");
}
#[test]
fn truly_missing_mention_still_renders_missing_file() {
let tmp = TempDir::new().expect("tempdir");
let content = user_request_with_file_mentions(
"huh @does/not/exist.txt",
tmp.path(),
Some(tmp.path().to_path_buf()),
);
assert!(
content.contains("<missing-file mention=\"@does/not/exist.txt\""),
"got: {content}",
);
}
}