返回 DeepSeek-TUI-2026
file_mention.rs
根目录 / crates / tui / src / tui / file_mention.rs
1 //! `@`-mention parsing, completion, and expansion for the composer.
2 //!
3 //! Two responsibilities live here:
4 //!
5 //! 1. **Tab-completion** at the cursor — `try_autocomplete_file_mention` is
6 //! called by the composer's Tab handler. Walks the workspace, ranks
7 //! candidates by prefix-then-substring match, and either splices the
8 //! completion in directly (single match), extends to a shared prefix, or
9 //! surfaces options in the status line.
10 //! 2. **Expansion before send** — when the user hits Enter on a message that
11 //! contains `@<path>` references, `user_request_with_file_mentions`
12 //! appends a "Local context from @mentions" block with the file contents
13 //! (or directory listings, or media-attachment hints) so the model can see
14 //! what the user pointed at. Capped per-message and per-file.
15 //!
16 //! The module is deliberately self-contained: nothing inside reaches into UI
17 //! widgets or rendering, so it stays unit-testable from `ui/tests.rs` and
18 //! from its own module-level tests.
19 //!
20 //! Pulled out of `ui.rs` to shrink the 5,500-line monolith and to give the
21 //! mention logic a single home that future maintainers can find without
22 //! grepping for `@` across half the codebase.
23
24 use std::fmt::Write;
25 use std::io::Read;
26 use std::path::{Path, PathBuf};
27
28 use serde::{Deserialize, Serialize};
29
30 use crate::tui::app::{App, MentionCompletionCache};
31 use crate::working_set::Workspace;
32
33 /// Maximum number of `@`-mentions whose contents are inlined into one user
34 /// message. Beyond this we stop appending blocks but the raw `@token` text
35 /// remains in the message.
36 pub const MAX_FILE_MENTIONS_PER_MESSAGE: usize = 8;
37 /// Per-file byte ceiling when inlining mention contents.
38 pub const MAX_MENTION_FILE_BYTES: u64 = 128 * 1024;
39 /// Per-directory entry ceiling when inlining a directory listing.
40 pub const MAX_DIRECTORY_MENTION_ENTRIES: usize = 80;
41
42 /// Maximum file-mention completion candidates to consider per keypress. Caps
43 /// the cost of walking large workspaces; subsequent keystrokes narrow further.
44 const FILE_MENTION_COMPLETION_LIMIT: usize = 64;
45
46 /// Compact composer preview row for local context that will be included or
47 /// skipped when the user submits the current input.
48 #[derive(Debug, Clone, PartialEq, Eq)]
49 pub struct FileMentionPreview {
50 pub kind: String,
51 pub label: String,
52 pub detail: Option<String>,
53 pub included: bool,
54 pub removable: bool,
55 }
56
57 /// Durable, compact metadata for a user-visible context reference.
58 ///
59 /// The transcript keeps the user's compact text (`@path` or `[Attached ...]`)
60 /// readable. This record preserves the exact target and inclusion state for
61 /// the context inspector and for session resume without leaking raw metadata
62 /// into the visible history cell.
63 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64 pub struct ContextReference {
65 pub kind: ContextReferenceKind,
66 pub source: ContextReferenceSource,
67 /// Short badge for terminal display, e.g. `file`, `dir`, `image`.
68 pub badge: String,
69 /// Compact display label from the transcript, without the leading `@`.
70 pub label: String,
71 /// Resolved target path or URI-equivalent string.
72 pub target: String,
73 pub included: bool,
74 pub expanded: bool,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub detail: Option<String>,
77 }
78
79 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80 #[serde(rename_all = "snake_case")]
81 pub enum ContextReferenceKind {
82 File,
83 Directory,
84 Missing,
85 Unsupported,
86 MediaMention,
87 MediaAttachment,
88 }
89
90 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91 #[serde(rename_all = "snake_case")]
92 pub enum ContextReferenceSource {
93 AtMention,
94 Attachment,
95 }
96
97 // ---------------------------------------------------------------------------
98 // Tab-completion
99 // ---------------------------------------------------------------------------
100
101 /// If the cursor sits inside a `@<partial>` token in the input, return the
102 /// byte offset where the `@` starts (so we can splice in a completion) and
103 /// the partial path the user has typed so far. The token stops at whitespace
104 /// or the end of input. Returns `None` when the cursor is outside any mention
105 /// or the token is empty (`@` with nothing after it).
106 pub fn partial_file_mention_at_cursor(input: &str, cursor_chars: usize) -> Option<(usize, String)> {
107 let chars: Vec<char> = input.chars().collect();
108 if cursor_chars > chars.len() {
109 return None;
110 }
111 // Walk left from the cursor until we find an `@` or a whitespace; if
112 // whitespace comes first the cursor isn't inside a mention.
113 let mut start_chars = cursor_chars;
114 while start_chars > 0 {
115 let prev = chars[start_chars - 1];
116 if prev == '@' {
117 start_chars -= 1;
118 break;
119 }
120 if prev.is_whitespace() {
121 return None;
122 }
123 start_chars -= 1;
124 }
125 if start_chars == cursor_chars || chars.get(start_chars) != Some(&'@') {
126 return None;
127 }
128 // Confirm the `@` itself is at a valid mention boundary.
129 if !is_file_mention_start(&chars, start_chars) {
130 return None;
131 }
132 // Consume from the `@` to the next whitespace (the end of the token).
133 let mut end_chars = start_chars + 1;
134 while end_chars < chars.len() && !chars[end_chars].is_whitespace() {
135 end_chars += 1;
136 }
137 let partial: String = chars[start_chars + 1..end_chars].iter().collect();
138 let byte_start: usize = chars[..start_chars].iter().map(|c| c.len_utf8()).sum();
139 Some((byte_start, partial))
140 }
141
142 /// Cwd-aware completion entry point. Shares its walker with the future
143 /// Ctrl+P fuzzy picker (#97); see [`Workspace::completions`] for the
144 /// ranking + display rules.
145 pub fn find_file_mention_completions(
146 workspace: &Workspace,
147 partial: &str,
148 limit: usize,
149 ) -> Vec<String> {
150 let entries = workspace.completions(partial, limit);
151 // #441: re-rank by frecency so files the user mentions a lot float up.
152 // Never-mentioned candidates fall back to the workspace ranker's order.
153 let entries = super::file_frecency::rerank_by_frecency(entries);
154 tracing::debug!(
155 target: "deepseek_tui::file_mention",
156 partial = %partial,
157 workspace = %workspace.root.display(),
158 cwd = ?std::env::current_dir().ok(),
159 match_count = entries.len(),
160 "file mention completion walk",
161 );
162 entries
163 }
164
165 /// Build a `Workspace` for the running app: anchors at `app.workspace` and
166 /// captures the process CWD so the resolver and completion walker honor the
167 /// user's launch directory when it differs from `--workspace`.
168 fn workspace_for_app(app: &App) -> Workspace {
169 Workspace::with_cwd(app.workspace.clone(), std::env::current_dir().ok())
170 }
171
172 /// Resolve the `@`-mention completion popup contents for the current
173 /// composer state. Returns an empty `Vec` when:
174 ///
175 /// - The popup is suppressed (`app.mention_menu_hidden`).
176 /// - The cursor is not inside an `@<partial>` token.
177 /// - The workspace walk produced no candidates.
178 ///
179 /// Mirrors `visible_slash_menu_entries` so the composer widget can treat
180 /// both menus identically (one `Vec<String>` of entries, one selected index).
181 ///
182 /// Once the composer widget is extended to render this as a popup, it will
183 /// pair with `apply_mention_menu_selection` for the Up/Down/Enter flow.
184 #[must_use]
185 pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec<String> {
186 if app.mention_menu_hidden {
187 return Vec::new();
188 }
189 let Some((_byte_start, partial)) =
190 partial_file_mention_at_cursor(&app.input, app.cursor_position)
191 else {
192 return Vec::new();
193 };
194 if limit == 0 {
195 return Vec::new();
196 }
197
198 let workspace = app.workspace.clone();
199 let cwd = std::env::current_dir().ok();
200 if let Some(ref cache) = app.composer.mention_completion_cache
201 && cache.workspace == workspace
202 && cache.cwd == cwd
203 && cache.partial == partial
204 && cache.limit == limit
205 {
206 return cache.entries.clone();
207 }
208
209 let ws = Workspace::with_cwd(workspace.clone(), cwd.clone());
210 let entries = find_file_mention_completions(&ws, &partial, limit);
211
212 app.composer.mention_completion_cache = Some(MentionCompletionCache {
213 workspace,
214 cwd,
215 partial,
216 limit,
217 entries: entries.clone(),
218 });
219
220 entries
221 }
222
223 /// Apply the currently selected `@`-mention popup entry to the composer
224 /// input, splicing it in place of the `@<partial>` token at the cursor.
225 /// Returns `true` if a substitution occurred.
226 ///
227 /// Designed to be invoked by the same keybinding that drives
228 /// `apply_slash_menu_selection` (Enter / Tab); the caller is responsible
229 /// for choosing which menu is "active" based on cursor context.
230 pub fn apply_mention_menu_selection(app: &mut App, entries: &[String]) -> bool {
231 if entries.is_empty() {
232 return false;
233 }
234 let Some((byte_start, partial)) =
235 partial_file_mention_at_cursor(&app.input, app.cursor_position)
236 else {
237 return false;
238 };
239 let selected_idx = app
240 .mention_menu_selected
241 .min(entries.len().saturating_sub(1));
242 let replacement = &entries[selected_idx];
243 // #441: bump this path's frecency before we splice it in. The store
244 // persists asynchronously, so this never blocks input handling.
245 super::file_frecency::record_mention(replacement);
246 replace_file_mention(app, byte_start, &partial, replacement);
247 app.mention_menu_hidden = false;
248 app.status_message = Some(format!("Attached @{replacement}"));
249 true
250 }
251
252 /// Tab-completion handler for `@file` mentions. Mirrors the slash-command
253 /// flow: a single match is applied directly; multiple matches with a longer
254 /// shared prefix extend the partial; otherwise the first few candidates are
255 /// surfaced via the status line. Returns true when the input was modified or
256 /// a suggestion was offered, so the caller can short-circuit other handlers.
257 pub fn try_autocomplete_file_mention(app: &mut App) -> bool {
258 let Some((byte_start, partial)) =
259 partial_file_mention_at_cursor(&app.input, app.cursor_position)
260 else {
261 return false;
262 };
263 let ws = workspace_for_app(app);
264 let candidates = find_file_mention_completions(&ws, &partial, FILE_MENTION_COMPLETION_LIMIT);
265 if candidates.is_empty() {
266 app.status_message = Some(format!("No files match @{partial}"));
267 return true;
268 }
269 if candidates.len() == 1 {
270 // #441: a unique-match completion is also a "mention" for ranking.
271 super::file_frecency::record_mention(&candidates[0]);
272 replace_file_mention(app, byte_start, &partial, &candidates[0]);
273 app.status_message = Some(format!("Attached @{}", candidates[0]));
274 return true;
275 }
276 let candidate_refs: Vec<&str> = candidates.iter().map(String::as_str).collect();
277 let shared = longest_common_prefix(&candidate_refs);
278 if shared.len() > partial.len() {
279 replace_file_mention(app, byte_start, &partial, shared);
280 app.status_message = Some(format!("@{shared}…"));
281 return true;
282 }
283 let preview = candidates
284 .iter()
285 .take(5)
286 .map(|c| format!("@{c}"))
287 .collect::<Vec<_>>()
288 .join(", ");
289 app.status_message = Some(format!("Matches: {preview}"));
290 true
291 }
292
293 /// Splice a completion into the input, replacing the `@<partial>` token at
294 /// `byte_start` with `@<replacement>`. Cursor moves to the end of the new
295 /// token so further keystrokes extend (or escape via space) naturally.
296 fn replace_file_mention(app: &mut App, byte_start: usize, partial: &str, replacement: &str) {
297 let original_token_len = '@'.len_utf8() + partial.len();
298 let original_token_end = byte_start + original_token_len;
299 let mut new_input =
300 String::with_capacity(app.input.len() - original_token_len + 1 + replacement.len());
301 new_input.push_str(&app.input[..byte_start]);
302 new_input.push('@');
303 new_input.push_str(replacement);
304 if original_token_end < app.input.len() {
305 new_input.push_str(&app.input[original_token_end..]);
306 }
307 let new_cursor_chars =
308 app.input[..byte_start].chars().count() + 1 + replacement.chars().count();
309 app.input = new_input;
310 app.cursor_position = new_cursor_chars;
311 }
312
313 pub fn longest_common_prefix<'a>(values: &[&'a str]) -> &'a str {
314 let Some(first) = values.first().copied() else {
315 return "";
316 };
317 let mut end = first.len();
318
319 for value in values.iter().skip(1) {
320 while end > 0 && !value.starts_with(&first[..end]) {
321 end -= 1;
322 // Ensure we land on a valid UTF-8 char boundary.
323 while end > 0 && !first.is_char_boundary(end) {
324 end -= 1;
325 }
326 }
327 if end == 0 {
328 return "";
329 }
330 }
331
332 &first[..end]
333 }
334
335 // ---------------------------------------------------------------------------
336 // Expansion at send-time
337 // ---------------------------------------------------------------------------
338
339 /// Append a "Local context from @mentions" block to the user's message when
340 /// any `@path` references are present. Returns the input unchanged when
341 /// there are none.
342 ///
343 /// `cwd` carries the user's launch directory and drives the second
344 /// resolution pass (issue #101): relative `@<path>` mentions resolve under
345 /// `cwd` when `workspace.join(path)` doesn't exist, so the user's mental
346 /// anchor (their shell's pwd) wins when it diverges from `--workspace`.
347 /// Pass `None` to disable the cwd pass entirely (workspace-only).
348 pub fn user_request_with_file_mentions(
349 input: &str,
350 workspace: &Path,
351 cwd: Option<PathBuf>,
352 ) -> String {
353 let Some(context) = local_context_from_file_mentions(input, workspace, cwd) else {
354 return input.to_string();
355 };
356 format!("{input}\n\n---\n\nLocal context from @mentions:\n{context}")
357 }
358
359 #[must_use]
360 pub fn pending_context_previews(
361 input: &str,
362 workspace: &Path,
363 cwd: Option<PathBuf>,
364 ) -> Vec<FileMentionPreview> {
365 context_references_from_input(input, workspace, cwd)
366 .into_iter()
367 .map(|reference| FileMentionPreview {
368 kind: reference.badge,
369 label: reference.label,
370 detail: reference.detail,
371 included: reference.included,
372 removable: reference.source == ContextReferenceSource::Attachment,
373 })
374 .collect()
375 }
376
377 #[must_use]
378 pub fn context_references_from_input(
379 input: &str,
380 workspace: &Path,
381 cwd: Option<PathBuf>,
382 ) -> Vec<ContextReference> {
383 let mut references = Vec::new();
384 let mut seen = std::collections::HashSet::new();
385 let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd);
386
387 for mention in extract_file_mentions(input)
388 .into_iter()
389 .take(MAX_FILE_MENTIONS_PER_MESSAGE)
390 {
391 let (path, display_path, exists) = match ws.resolve(&mention) {
392 Ok(path) => {
393 let display = path.display().to_string();
394 (path, display, true)
395 }
396 Err(path) => {
397 let display = path.display().to_string();
398 (path, display, false)
399 }
400 };
401 let reference = context_reference_for_mention(&mention, &path, &display_path, exists);
402 if !seen.insert(format!(
403 "{:?}:{:?}:{}:{}",
404 reference.source, reference.kind, reference.target, reference.label
405 )) {
406 continue;
407 }
408 references.push(reference);
409 }
410
411 for reference in extract_media_attachment_references(input) {
412 let context_reference = ContextReference {
413 kind: ContextReferenceKind::MediaAttachment,
414 source: ContextReferenceSource::Attachment,
415 badge: reference.kind,
416 label: reference.path.clone(),
417 target: reference.path,
418 included: true,
419 expanded: false,
420 detail: Some("attached media".to_string()),
421 };
422 if !seen.insert(format!(
423 "{:?}:{:?}:{}:{}",
424 context_reference.source,
425 context_reference.kind,
426 context_reference.target,
427 context_reference.label
428 )) {
429 continue;
430 }
431 references.push(context_reference);
432 }
433
434 references
435 }
436
437 fn context_reference_for_mention(
438 raw: &str,
439 path: &Path,
440 display_path: &str,
441 exists: bool,
442 ) -> ContextReference {
443 if !exists {
444 return ContextReference {
445 kind: ContextReferenceKind::Missing,
446 source: ContextReferenceSource::AtMention,
447 badge: "missing".to_string(),
448 label: raw.to_string(),
449 target: display_path.to_string(),
450 included: false,
451 expanded: false,
452 detail: Some("not found".to_string()),
453 };
454 }
455 if path.is_dir() {
456 return ContextReference {
457 kind: ContextReferenceKind::Directory,
458 source: ContextReferenceSource::AtMention,
459 badge: "dir".to_string(),
460 label: raw.to_string(),
461 target: display_path.to_string(),
462 included: true,
463 expanded: true,
464 detail: Some("directory listing".to_string()),
465 };
466 }
467 if !path.is_file() {
468 return ContextReference {
469 kind: ContextReferenceKind::Unsupported,
470 source: ContextReferenceSource::AtMention,
471 badge: "skipped".to_string(),
472 label: raw.to_string(),
473 target: display_path.to_string(),
474 included: false,
475 expanded: false,
476 detail: Some("unsupported path".to_string()),
477 };
478 }
479 if is_media_path(path) {
480 return ContextReference {
481 kind: ContextReferenceKind::MediaMention,
482 source: ContextReferenceSource::AtMention,
483 badge: "media".to_string(),
484 label: raw.to_string(),
485 target: display_path.to_string(),
486 included: false,
487 expanded: false,
488 detail: Some("use /attach for media bytes".to_string()),
489 };
490 }
491
492 let detail = match std::fs::metadata(path) {
493 Ok(metadata) if metadata.len() > MAX_MENTION_FILE_BYTES => {
494 Some("included truncated".to_string())
495 }
496 Ok(_) => Some("included".to_string()),
497 Err(err) => Some(format!("metadata: {err}")),
498 };
499
500 ContextReference {
501 kind: ContextReferenceKind::File,
502 source: ContextReferenceSource::AtMention,
503 badge: "file".to_string(),
504 label: raw.to_string(),
505 target: display_path.to_string(),
506 included: true,
507 expanded: true,
508 detail: detail.or_else(|| Some(display_path.to_string())),
509 }
510 }
511
512 #[derive(Debug, Clone, PartialEq, Eq)]
513 pub struct MediaAttachmentReference {
514 pub kind: String,
515 pub path: String,
516 pub start_byte: usize,
517 pub end_byte: usize,
518 }
519
520 pub fn media_attachment_references(input: &str) -> Vec<MediaAttachmentReference> {
521 let mut out = Vec::new();
522 let mut offset = 0usize;
523 for line in input.split_inclusive('\n') {
524 let start_byte = offset;
525 let end_byte = offset + line.len();
526 offset = end_byte;
527 let trimmed = line.trim();
528 let Some(body) = trimmed
529 .strip_prefix("[Attached ")
530 .and_then(|value| value.strip_suffix(']'))
531 else {
532 continue;
533 };
534 let Some((kind, rest)) = body.split_once(": ") else {
535 continue;
536 };
537 let path = rest
538 .rsplit_once(" at ")
539 .map_or(rest, |(_, path)| path)
540 .trim();
541 if !path.is_empty() {
542 out.push(MediaAttachmentReference {
543 kind: kind.trim().to_string(),
544 path: path.to_string(),
545 start_byte,
546 end_byte,
547 });
548 }
549 }
550 out
551 }
552
553 fn extract_media_attachment_references(input: &str) -> Vec<MediaAttachmentReference> {
554 media_attachment_references(input)
555 }
556
557 fn local_context_from_file_mentions(
558 input: &str,
559 workspace: &Path,
560 cwd: Option<PathBuf>,
561 ) -> Option<String> {
562 let mentions = extract_file_mentions(input);
563 if mentions.is_empty() {
564 return None;
565 }
566
567 let mut blocks = Vec::new();
568 let mut seen = std::collections::HashSet::new();
569 let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd);
570
571 for mention in mentions.into_iter().take(MAX_FILE_MENTIONS_PER_MESSAGE) {
572 // `Workspace::resolve` already returns absolute paths when the root
573 // is absolute (TUI always runs from an absolute workspace), so we
574 // skip `canonicalize()` here — it's per-mention I/O on the
575 // message-send hot path. Accept the rare symlink-aliasing dedup
576 // miss as the cost of avoiding a syscall (Gemini code-review).
577 let (path, display_path, exists) = match ws.resolve(&mention) {
578 Ok(p) => {
579 let d = p.display().to_string();
580 (p, d, true)
581 }
582 Err(p) => {
583 let d = p.display().to_string();
584 (p, d, false)
585 }
586 };
587 tracing::debug!(
588 target: "deepseek_tui::file_mention",
589 raw_typed = %mention,
590 workspace = %workspace.display(),
591 cwd = ?std::env::current_dir().ok(),
592 resolved = %display_path,
593 exists,
594 "file mention resolution",
595 );
596
597 // Gate every block — including <missing-file> — through the dedup
598 // set so a user typing the same non-existent file twice doesn't
599 // waste tokens on duplicate missing-file blocks (Devin code-review).
600 if !seen.insert(display_path.clone()) {
601 continue;
602 }
603
604 if exists {
605 blocks.push(render_file_mention_context(&mention, &path, &display_path));
606 } else {
607 blocks.push(format!(
608 "<missing-file mention=\"@{mention}\" path=\"{display_path}\" />"
609 ));
610 }
611 }
612
613 if blocks.is_empty() {
614 None
615 } else {
616 Some(blocks.join("\n\n"))
617 }
618 }
619
620 fn extract_file_mentions(input: &str) -> Vec<String> {
621 let chars: Vec<char> = input.chars().collect();
622 let mut mentions = Vec::new();
623 let mut idx = 0;
624
625 while idx < chars.len() {
626 if chars[idx] != '@' || !is_file_mention_start(&chars, idx) {
627 idx += 1;
628 continue;
629 }
630
631 let Some(next) = chars.get(idx + 1).copied() else {
632 break;
633 };
634 if next.is_whitespace() {
635 idx += 1;
636 continue;
637 }
638
639 if matches!(next, '"' | '\'') {
640 let quote = next;
641 let mut end = idx + 2;
642 let mut raw = String::new();
643 while end < chars.len() && chars[end] != quote {
644 raw.push(chars[end]);
645 end += 1;
646 }
647 if !raw.trim().is_empty() {
648 mentions.push(raw.trim().to_string());
649 }
650 idx = end.saturating_add(1);
651 continue;
652 }
653
654 let mut end = idx + 1;
655 let mut raw = String::new();
656 while end < chars.len() && !chars[end].is_whitespace() {
657 raw.push(chars[end]);
658 end += 1;
659 }
660 let trimmed = trim_unquoted_mention(&raw);
661 if !trimmed.is_empty() {
662 mentions.push(trimmed.to_string());
663 }
664 idx = end;
665 }
666
667 mentions
668 }
669
670 fn is_file_mention_start(chars: &[char], idx: usize) -> bool {
671 if idx == 0 {
672 return true;
673 }
674 chars
675 .get(idx.saturating_sub(1))
676 .is_some_and(|ch| ch.is_whitespace() || matches!(ch, '(' | '[' | '{' | '<' | '"' | '\''))
677 }
678
679 fn trim_unquoted_mention(raw: &str) -> &str {
680 let mut trimmed = raw.trim();
681 while trimmed.chars().count() > 1
682 && trimmed
683 .chars()
684 .last()
685 .is_some_and(|ch| matches!(ch, ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'))
686 {
687 trimmed = &trimmed[..trimmed.len() - trimmed.chars().last().unwrap().len_utf8()];
688 }
689 trimmed
690 }
691
692 fn render_file_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
693 if !path.exists() {
694 return format!("<missing-file mention=\"@{raw}\" path=\"{display_path}\" />");
695 }
696 if path.is_dir() {
697 return render_directory_mention_context(raw, path, display_path);
698 }
699 if !path.is_file() {
700 return format!("<unsupported-path mention=\"@{raw}\" path=\"{display_path}\" />");
701 }
702 if is_media_path(path) {
703 return format!(
704 "<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>"
705 );
706 }
707
708 match read_text_prefix(path) {
709 Ok((text, truncated)) => {
710 let truncated_attr = if truncated { " truncated=\"true\"" } else { "" };
711 format!(
712 "<file mention=\"@{raw}\" path=\"{display_path}\"{truncated_attr}>\n{text}\n</file>"
713 )
714 }
715 Err(err) => {
716 format!(
717 "<unreadable-file mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-file>"
718 )
719 }
720 }
721 }
722
723 fn render_directory_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
724 let entries = match std::fs::read_dir(path) {
725 Ok(entries) => entries,
726 Err(err) => {
727 return format!(
728 "<unreadable-directory mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-directory>"
729 );
730 }
731 };
732
733 let mut names = entries
734 .filter_map(|entry| entry.ok())
735 .map(|entry| {
736 let marker = entry
737 .file_type()
738 .ok()
739 .filter(|ty| ty.is_dir())
740 .map_or("", |_| "/");
741 format!("{}{}", entry.file_name().to_string_lossy(), marker)
742 })
743 .collect::<Vec<_>>();
744 names.sort();
745 let total = names.len();
746 names.truncate(MAX_DIRECTORY_MENTION_ENTRIES);
747 let mut body = names.join("\n");
748 if total > MAX_DIRECTORY_MENTION_ENTRIES {
749 let omitted = total - MAX_DIRECTORY_MENTION_ENTRIES;
750 let _ = write!(body, "\n... {omitted} more entries");
751 }
752 format!("<directory mention=\"@{raw}\" path=\"{display_path}\">\n{body}\n</directory>")
753 }
754
755 fn read_text_prefix(path: &Path) -> std::io::Result<(String, bool)> {
756 let mut file = std::fs::File::open(path)?;
757 let mut buffer = Vec::new();
758 file.by_ref()
759 .take(MAX_MENTION_FILE_BYTES + 1)
760 .read_to_end(&mut buffer)?;
761 let truncated = buffer.len() as u64 > MAX_MENTION_FILE_BYTES;
762 if truncated {
763 buffer.truncate(MAX_MENTION_FILE_BYTES as usize);
764 }
765 if buffer.contains(&0) {
766 return Err(std::io::Error::new(
767 std::io::ErrorKind::InvalidData,
768 "file appears to be binary",
769 ));
770 }
771 let text = if truncated {
772 String::from_utf8_lossy(&buffer).to_string()
773 } else {
774 std::str::from_utf8(&buffer)
775 .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "file is not UTF-8"))?
776 .to_string()
777 };
778 Ok((text, truncated))
779 }
780
781 fn is_media_path(path: &Path) -> bool {
782 let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
783 return false;
784 };
785 matches!(
786 ext.to_ascii_lowercase().as_str(),
787 "png"
788 | "jpg"
789 | "jpeg"
790 | "gif"
791 | "webp"
792 | "bmp"
793 | "tif"
794 | "tiff"
795 | "ppm"
796 | "mp4"
797 | "mov"
798 | "m4v"
799 | "webm"
800 | "avi"
801 | "mkv"
802 )
803 }
804
805 // ---------------------------------------------------------------------------
806 // #101 regression repros
807 // ---------------------------------------------------------------------------
808 //
809 // The bug being guarded: typing `@<some/file>` resolved under `--workspace`,
810 // not the user's launch CWD. When the two diverged (the canonical case is
811 // `--workspace=/repo` with `pwd=/repo/sub`), every relative `@` token routed
812 // to the wrong root and the prompt got `<missing-file>` blocks.
813 #[cfg(test)]
814 mod tests {
815 use super::*;
816 use tempfile::TempDir;
817
818 /// #101 regression — workspace-vs-cwd divergence: `@bar.txt` typed from
819 /// the cwd `<root>/sub` MUST resolve to `<root>/sub/bar.txt`, never to
820 /// `<root>/bar.txt` (which doesn't exist).
821 #[test]
822 fn cwd_pass_resolves_when_workspace_pass_misses() {
823 let tmp = TempDir::new().expect("tempdir");
824 let sub = tmp.path().join("sub");
825 std::fs::create_dir_all(&sub).expect("mkdir");
826 let bar = sub.join("bar.txt");
827 std::fs::write(&bar, "hello bar").expect("write bar");
828
829 let content =
830 user_request_with_file_mentions("look at @bar.txt", tmp.path(), Some(sub.clone()));
831
832 // The block must reference the cwd-rooted path with the file's body —
833 // and crucially it must NOT collapse to <missing-file>.
834 assert!(
835 content.contains("hello bar"),
836 "expected file body to be inlined; got: {content}",
837 );
838 assert!(
839 !content.contains("<missing-file"),
840 "must not surface <missing-file> for a path that exists under cwd; got: {content}",
841 );
842 let bar_disp = bar.display().to_string();
843 assert!(
844 content.contains(&bar_disp),
845 "expected resolved path {bar_disp} in content; got: {content}",
846 );
847 // Belt-and-suspenders: the workspace-rooted path doesn't exist and
848 // must not appear in the rendered <file path="..."> attribute.
849 let wrong = tmp.path().join("bar.txt").display().to_string();
850 assert!(
851 !content.contains(&format!("path=\"{wrong}\"")),
852 "should NOT have routed to {wrong}; got: {content}",
853 );
854 }
855
856 /// #101 regression — nested workspace path: `@nested/deep/file.md` with
857 /// the file at workspace root resolves through the workspace pass.
858 #[test]
859 fn workspace_pass_resolves_nested_path() {
860 let tmp = TempDir::new().expect("tempdir");
861 let nested = tmp.path().join("nested/deep");
862 std::fs::create_dir_all(&nested).expect("mkdir");
863 let file_md = nested.join("file.md");
864 std::fs::write(&file_md, "# nested deep").expect("write file_md");
865
866 // Cwd is irrelevant; an unrelated tempdir would do. Pass `None` so we
867 // are unambiguously testing the workspace-pass path.
868 let content = user_request_with_file_mentions("see @nested/deep/file.md", tmp.path(), None);
869
870 assert!(content.contains("# nested deep"), "got: {content}");
871 assert!(!content.contains("<missing-file"), "got: {content}");
872 // Path-separator-portable check: the resolved path's filename is the
873 // most reliable cross-platform anchor (Windows mixes `/` and `\` when
874 // join() preserves user-typed separators).
875 let basename = file_md
876 .file_name()
877 .and_then(|n| n.to_str())
878 .expect("file_name utf-8");
879 assert!(
880 content.contains(basename),
881 "basename {basename} not in path; got: {content}",
882 );
883 }
884
885 /// Snapshot-style check: the rendered `<file>` block for a resolvable
886 /// mention must include the expected attributes and contents, and must
887 /// NOT contain `<missing-file>`.
888 #[test]
889 fn resolvable_mention_renders_file_block_not_missing_file() {
890 let tmp = TempDir::new().expect("tempdir");
891 std::fs::write(tmp.path().join("guide.md"), "# Guide\nUse the fast path.\n")
892 .expect("write");
893
894 let content = user_request_with_file_mentions("read @guide.md", tmp.path(), None);
895
896 // Header + tag presence.
897 assert!(content.contains("Local context from @mentions:"));
898 assert!(content.contains("<file mention=\"@guide.md\""));
899 assert!(content.contains("# Guide\nUse the fast path."));
900 assert!(content.ends_with("</file>"), "got: {content}");
901 // The bug fingerprint MUST be absent.
902 assert!(!content.contains("<missing-file"), "got: {content}");
903 }
904
905 /// Negative test: a truly missing path still produces `<missing-file>`
906 /// so the user gets an explicit signal instead of silent failure.
907 #[test]
908 fn truly_missing_mention_still_renders_missing_file() {
909 let tmp = TempDir::new().expect("tempdir");
910
911 let content = user_request_with_file_mentions(
912 "huh @does/not/exist.txt",
913 tmp.path(),
914 Some(tmp.path().to_path_buf()),
915 );
916
917 assert!(
918 content.contains("<missing-file mention=\"@does/not/exist.txt\""),
919 "got: {content}",
920 );
921 }
922
923 #[test]
924 fn pending_context_preview_marks_included_and_missing_mentions() {
925 let tmp = TempDir::new().expect("tempdir");
926 std::fs::write(tmp.path().join("guide.md"), "hello").expect("write");
927
928 let previews = pending_context_previews(
929 "read @guide.md and @missing.md",
930 tmp.path(),
931 Some(tmp.path().to_path_buf()),
932 );
933
934 assert_eq!(previews.len(), 2);
935 assert_eq!(previews[0].kind, "file");
936 assert_eq!(previews[0].label, "guide.md");
937 assert!(previews[0].included);
938 assert_eq!(previews[1].kind, "missing");
939 assert_eq!(previews[1].label, "missing.md");
940 assert!(!previews[1].included);
941 }
942
943 #[test]
944 fn pending_context_preview_distinguishes_attach_media_from_at_media() {
945 let tmp = TempDir::new().expect("tempdir");
946 std::fs::write(tmp.path().join("photo.png"), b"png").expect("write");
947 let attached = tmp.path().join("photo.png").display().to_string();
948 let input = format!("inspect @photo.png\n[Attached image: {attached}]");
949
950 let previews = pending_context_previews(&input, tmp.path(), Some(tmp.path().to_path_buf()));
951
952 assert!(
953 previews
954 .iter()
955 .any(|item| item.kind == "media" && !item.included),
956 "at-mention media should be hint-only: {previews:?}"
957 );
958 assert!(
959 previews
960 .iter()
961 .any(|item| item.kind == "image" && item.included),
962 "/attach media should be included: {previews:?}"
963 );
964 }
965
966 #[test]
967 fn media_attachment_references_include_removable_line_ranges() {
968 let input = "before\n[Attached image: 8x4 PNG at /tmp/pasted.png]\nafter";
969
970 let references = media_attachment_references(input);
971
972 assert_eq!(references.len(), 1);
973 let reference = &references[0];
974 assert_eq!(reference.kind, "image");
975 assert_eq!(reference.path, "/tmp/pasted.png");
976 assert_eq!(
977 &input[reference.start_byte..reference.end_byte],
978 "[Attached image: 8x4 PNG at /tmp/pasted.png]\n"
979 );
980 }
981
982 #[test]
983 fn context_references_preserve_exact_targets_and_roundtrip() {
984 let tmp = TempDir::new().expect("tempdir");
985 std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
986 std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write");
987 let input = "read @src/main.rs";
988
989 let references =
990 context_references_from_input(input, tmp.path(), Some(tmp.path().to_path_buf()));
991
992 assert_eq!(references.len(), 1);
993 let reference = &references[0];
994 assert_eq!(reference.kind, ContextReferenceKind::File);
995 assert_eq!(reference.source, ContextReferenceSource::AtMention);
996 assert_eq!(reference.label, "src/main.rs");
997 assert!(reference.target.ends_with("src/main.rs"));
998 assert!(reference.included);
999 assert!(reference.expanded);
1000
1001 let encoded = serde_json::to_string(reference).expect("serialize");
1002 let decoded: ContextReference = serde_json::from_str(&encoded).expect("deserialize");
1003 assert_eq!(&decoded, reference);
1004 }
1005 }
1006
1006 lines RUST