返回 CodeWhale
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::tui::git_mention::{self, GitMentionCache, GitMentionKind};
32 use crate::tui::mention_completion::{MentionDiscoveryBehavior, MentionDiscoveryKey};
33 use crate::working_set::Workspace;
34
35 /// Maximum number of `@`-mentions whose contents are inlined into one user
36 /// message. Beyond this we stop appending blocks but the raw `@token` text
37 /// remains in the message.
38 pub const MAX_FILE_MENTIONS_PER_MESSAGE: usize = 8;
39 /// Per-file byte ceiling when inlining mention contents.
40 pub const MAX_MENTION_FILE_BYTES: u64 = 128 * 1024;
41 /// Per-directory entry ceiling when inlining a directory listing.
42 pub const MAX_DIRECTORY_MENTION_ENTRIES: usize = 80;
43
44 /// Maximum file-mention completion candidates to consider per keypress. Caps
45 /// the cost of walking large workspaces; subsequent keystrokes narrow further.
46 const FILE_MENTION_COMPLETION_LIMIT: usize = 64;
47
48 /// Compact composer preview row for local context. `included=false` also
49 /// covers lexical `@` mentions whose exact inclusion is resolved on send.
50 #[derive(Debug, Clone, PartialEq, Eq)]
51 pub struct FileMentionPreview {
52 pub kind: String,
53 pub label: String,
54 pub detail: Option<String>,
55 pub included: bool,
56 pub removable: bool,
57 }
58
59 /// Durable, compact metadata for a user-visible context reference.
60 ///
61 /// The transcript keeps the user's compact text (`@path` or `[Attached ...]`)
62 /// readable. This record preserves the exact target and inclusion state for
63 /// the context inspector and for session resume without leaking raw metadata
64 /// into the visible history cell.
65 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66 pub struct ContextReference {
67 pub kind: ContextReferenceKind,
68 pub source: ContextReferenceSource,
69 /// Short badge for terminal display, e.g. `file`, `dir`, `image`.
70 pub badge: String,
71 /// Compact display label from the transcript, without the leading `@`.
72 pub label: String,
73 /// Resolved target path or URI-equivalent string.
74 pub target: String,
75 pub included: bool,
76 pub expanded: bool,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub detail: Option<String>,
79 }
80
81 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82 #[serde(rename_all = "snake_case")]
83 pub enum ContextReferenceKind {
84 File,
85 Directory,
86 Missing,
87 Unsupported,
88 MediaMention,
89 MediaAttachment,
90 /// `@git` / `@diff` — curated git context rather than a path (#4067).
91 GitContext,
92 }
93
94 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95 #[serde(rename_all = "snake_case")]
96 pub enum ContextReferenceSource {
97 AtMention,
98 Attachment,
99 }
100
101 // ---------------------------------------------------------------------------
102 // Tab-completion
103 // ---------------------------------------------------------------------------
104
105 /// If the cursor sits inside a `@<partial>` token in the input, return the
106 /// byte offset where the `@` starts (so we can splice in a completion) and
107 /// the partial path the user has typed so far. The token stops at whitespace
108 /// or the end of input. Returns `None` when the cursor is outside any mention
109 /// or the token is empty (`@` with nothing after it).
110 pub fn partial_file_mention_at_cursor(input: &str, cursor_chars: usize) -> Option<(usize, String)> {
111 let chars: Vec<char> = input.chars().collect();
112 if cursor_chars > chars.len() {
113 return None;
114 }
115 // Walk left from the cursor until we find an `@` or a whitespace; if
116 // whitespace comes first the cursor isn't inside a mention.
117 let mut start_chars = cursor_chars;
118 while start_chars > 0 {
119 let prev = chars[start_chars - 1];
120 if prev == '@' {
121 start_chars -= 1;
122 break;
123 }
124 if prev.is_whitespace() {
125 return None;
126 }
127 start_chars -= 1;
128 }
129 if start_chars == cursor_chars || chars.get(start_chars) != Some(&'@') {
130 return None;
131 }
132 // Confirm the `@` itself is at a valid mention boundary.
133 if !is_file_mention_start(&chars, start_chars) {
134 return None;
135 }
136 // Consume from the `@` to the next whitespace (the end of the token).
137 let mut end_chars = start_chars + 1;
138 while end_chars < chars.len() && !chars[end_chars].is_whitespace() {
139 end_chars += 1;
140 }
141 let partial: String = chars[start_chars + 1..end_chars].iter().collect();
142 let byte_start: usize = chars[..start_chars].iter().map(|c| c.len_utf8()).sum();
143 Some((byte_start, partial))
144 }
145
146 /// Cwd-aware completion entry point. Shares its walker with the future
147 /// Ctrl+P fuzzy picker (#97); see [`Workspace::completions`] for the
148 /// ranking + display rules.
149 #[cfg(test)]
150 pub fn find_file_mention_completions(
151 workspace: &Workspace,
152 partial: &str,
153 limit: usize,
154 ) -> Vec<String> {
155 let entries = workspace.completions(partial, limit);
156 // #441: re-rank by frecency so files the user mentions a lot float up.
157 // Never-mentioned candidates fall back to the workspace ranker's order.
158 let entries = super::file_frecency::rerank_by_frecency(entries);
159 tracing::debug!(
160 target: "codewhale_tui::file_mention",
161 partial = %partial,
162 workspace = %workspace.root.display(),
163 cwd = ?std::env::current_dir().ok(),
164 match_count = entries.len(),
165 "file mention completion walk",
166 );
167 entries
168 }
169
170 /// Resolve the `@`-mention completion popup contents for the current
171 /// composer state. Returns an empty `Vec` when:
172 ///
173 /// - The popup is suppressed (`app.mention_menu_hidden`).
174 /// - The cursor is not inside an `@<partial>` token.
175 /// - The workspace walk produced no candidates.
176 ///
177 /// Mirrors `visible_slash_menu_entries` so the composer widget can treat
178 /// both menus identically (one `Vec<String>` of entries, one selected index).
179 ///
180 /// Once the composer widget is extended to render this as a popup, it will
181 /// pair with `apply_mention_menu_selection` for the Up/Down/Enter flow.
182 #[must_use]
183 pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec<String> {
184 if app.mention_menu_hidden {
185 app.composer.mention_discovery.cancel();
186 return Vec::new();
187 }
188 let Some((_byte_start, partial)) =
189 partial_file_mention_at_cursor(&app.input, app.cursor_position)
190 else {
191 app.composer.mention_discovery.cancel();
192 return Vec::new();
193 };
194 if limit == 0 {
195 app.composer.mention_discovery.cancel();
196 return Vec::new();
197 }
198
199 mention_menu_entries(app, &partial, limit).0
200 }
201
202 /// Drain a completed discovery result without waiting. The event loop calls
203 /// this once per tick so a finished scan repaints the popup even when the user
204 /// has stopped typing.
205 pub(crate) fn poll_background_mention_discovery(app: &mut App) -> bool {
206 if app.composer.mention_discovery.poll() {
207 app.composer.mention_completion_cache = None;
208 return true;
209 }
210 false
211 }
212
213 /// Return `(entries, ready)`. `ready = false` means discovery is still in the
214 /// background; callers must not misreport that temporary empty result as "no
215 /// matches".
216 fn mention_menu_entries(app: &mut App, partial: &str, limit: usize) -> (Vec<String>, bool) {
217 if poll_background_mention_discovery(app) {
218 app.needs_redraw = true;
219 }
220
221 let workspace = app.workspace.clone();
222 let cwd = app.composer.mention_cwd.clone();
223 let walk_depth = app.mention_walk_depth;
224 let behavior = app.mention_menu_behavior.clone();
225 let follow_links = app.workspace_follow_symlinks;
226 let discovery_key = if behavior == "browser" {
227 MentionDiscoveryKey::browser(
228 workspace.clone(),
229 cwd.clone(),
230 walk_depth,
231 follow_links,
232 partial.to_string(),
233 )
234 } else {
235 MentionDiscoveryKey::fuzzy(workspace.clone(), cwd.clone(), walk_depth, follow_links)
236 };
237 app.composer
238 .mention_discovery
239 .ensure_requested(discovery_key.clone());
240
241 if let Some(ref cache) = app.composer.mention_completion_cache
242 && cache.workspace == workspace
243 && cache.cwd == cwd
244 && cache.partial == partial
245 && cache.limit == limit
246 && cache.walk_depth == walk_depth
247 && cache.behavior == behavior
248 && cache.follow_links == follow_links
249 {
250 return (cache.entries.clone(), true);
251 }
252
253 let Some(candidates) = app
254 .composer
255 .mention_discovery
256 .cached_entries(&discovery_key)
257 else {
258 return (Vec::new(), false);
259 };
260 let entries = match &discovery_key.behavior {
261 MentionDiscoveryBehavior::Fuzzy => {
262 let ranked = crate::working_set::rank_completion_candidates(candidates, partial, limit);
263 super::file_frecency::rerank_by_frecency(ranked)
264 }
265 MentionDiscoveryBehavior::Browser { .. } => {
266 candidates.iter().take(limit).cloned().collect()
267 }
268 };
269
270 let entries = with_git_mention_entries(entries, partial, limit);
271
272 app.composer.mention_completion_cache = Some(MentionCompletionCache {
273 workspace,
274 cwd,
275 partial: partial.to_string(),
276 limit,
277 walk_depth,
278 behavior,
279 follow_links,
280 entries: entries.clone(),
281 });
282
283 (entries, true)
284 }
285
286 /// Prepend the `@git` / `@diff` tokens that prefix-match `partial` to the path
287 /// completions, so curated git context is discoverable from the same menu as
288 /// files (#4067). They lead because a two-entry prefix match is what the user
289 /// meant when they typed `gi` or `di`, and paths still fill the rest.
290 ///
291 /// A bare `@` is deliberately left alone: that menu is the file picker, and
292 /// pushing two fixed tokens above every path would cost a slot on every
293 /// mention the user makes. The tokens appear as soon as a matching character
294 /// is typed.
295 fn with_git_mention_entries(entries: Vec<String>, partial: &str, limit: usize) -> Vec<String> {
296 // `mention_menu_limit = 0` is a documented way to disable the popup
297 // entirely. The git tokens are menu entries like any other and must
298 // respect the same cap, or setting 0 would still pop a one-entry menu.
299 if limit == 0 {
300 return Vec::new();
301 }
302 let needle = partial.trim().to_lowercase();
303 if needle.is_empty() {
304 return entries;
305 }
306 let matching: Vec<String> = GitMentionKind::iter_all()
307 .filter(|kind| kind.token().starts_with(&needle))
308 .map(|kind| kind.token().to_string())
309 .collect();
310 if matching.is_empty() {
311 return entries;
312 }
313 let mut combined = matching;
314 combined.truncate(limit);
315 for entry in entries {
316 if combined.len() >= limit {
317 break;
318 }
319 if !combined.contains(&entry) {
320 combined.push(entry);
321 }
322 }
323 combined
324 }
325
326 /// Apply the currently selected `@`-mention popup entry to the composer
327 /// input, splicing it in place of the `@<partial>` token at the cursor.
328 /// Returns `true` if a substitution occurred.
329 ///
330 /// Designed to be invoked by the same keybinding that drives
331 /// `apply_slash_menu_selection` (Enter / Tab); the caller is responsible
332 /// for choosing which menu is "active" based on cursor context.
333 pub fn apply_mention_menu_selection(app: &mut App, entries: &[String]) -> bool {
334 if entries.is_empty() {
335 return false;
336 }
337 let Some((byte_start, partial)) =
338 partial_file_mention_at_cursor(&app.input, app.cursor_position)
339 else {
340 return false;
341 };
342 let selected_idx = app
343 .mention_menu_selected
344 .min(entries.len().saturating_sub(1));
345 let replacement = &entries[selected_idx];
346 // #441: bump this path's frecency before we splice it in. The store
347 // persists asynchronously, so this never blocks input handling.
348 super::file_frecency::record_mention(replacement);
349 replace_file_mention(app, byte_start, &partial, replacement);
350 app.mention_menu_hidden = false;
351 app.status_message = Some(format!("Attached @{replacement}"));
352 true
353 }
354
355 /// Tab-completion handler for `@file` mentions. Mirrors the slash-command
356 /// flow: a single match is applied directly; multiple matches with a longer
357 /// shared prefix extend the partial; otherwise the first few candidates are
358 /// surfaced via the status line. Returns true when the input was modified or
359 /// a suggestion was offered, so the caller can short-circuit other handlers.
360 pub fn try_autocomplete_file_mention(app: &mut App) -> bool {
361 let Some((byte_start, partial)) =
362 partial_file_mention_at_cursor(&app.input, app.cursor_position)
363 else {
364 return false;
365 };
366 let (candidates, ready) = mention_menu_entries(app, &partial, FILE_MENTION_COMPLETION_LIMIT);
367 if !ready {
368 return true;
369 }
370 if candidates.is_empty() {
371 app.status_message = Some(no_file_mention_matches_status(
372 &partial,
373 app.mention_walk_depth,
374 ));
375 return true;
376 }
377 if candidates.len() == 1 {
378 // #441: a unique-match completion is also a "mention" for ranking.
379 super::file_frecency::record_mention(&candidates[0]);
380 replace_file_mention(app, byte_start, &partial, &candidates[0]);
381 app.status_message = Some(format!("Attached @{}", candidates[0]));
382 return true;
383 }
384 let candidate_refs: Vec<&str> = candidates.iter().map(String::as_str).collect();
385 let shared = longest_common_prefix(&candidate_refs);
386 if shared.len() > partial.len() {
387 replace_file_mention(app, byte_start, &partial, shared);
388 app.status_message = Some(format!("@{shared}…"));
389 return true;
390 }
391 let preview = candidates
392 .iter()
393 .take(5)
394 .map(|c| format!("@{c}"))
395 .collect::<Vec<_>>()
396 .join(", ");
397 app.status_message = Some(format!("Matches: {preview}"));
398 true
399 }
400
401 fn no_file_mention_matches_status(partial: &str, walk_depth: usize) -> String {
402 if path_partial_reaches_walk_depth(partial, walk_depth) {
403 format!(
404 "No files match @{partial} (mention_walk_depth={walk_depth}; use /config set mention_walk_depth 0 to search deeper)"
405 )
406 } else {
407 format!("No files match @{partial}")
408 }
409 }
410
411 fn path_partial_reaches_walk_depth(partial: &str, walk_depth: usize) -> bool {
412 if walk_depth == 0 {
413 return false;
414 }
415 let component_count = partial
416 .split(['/', '\\'])
417 .filter(|component| !component.is_empty())
418 .count();
419 component_count >= walk_depth
420 }
421
422 /// Splice a completion into the input, replacing the `@<partial>` token at
423 /// `byte_start` with `@<replacement>`. Cursor moves to the end of the new
424 /// token so further keystrokes extend (or escape via space) naturally.
425 fn replace_file_mention(app: &mut App, byte_start: usize, partial: &str, replacement: &str) {
426 let original_token_len = '@'.len_utf8() + partial.len();
427 let original_token_end = byte_start + original_token_len;
428 let mut new_input =
429 String::with_capacity(app.input.len() - original_token_len + 1 + replacement.len());
430 new_input.push_str(&app.input[..byte_start]);
431 new_input.push('@');
432 new_input.push_str(replacement);
433 if original_token_end < app.input.len() {
434 new_input.push_str(&app.input[original_token_end..]);
435 }
436 let new_cursor_chars =
437 app.input[..byte_start].chars().count() + 1 + replacement.chars().count();
438 app.input = new_input;
439 app.cursor_position = new_cursor_chars;
440 }
441
442 pub fn longest_common_prefix<'a>(values: &[&'a str]) -> &'a str {
443 let Some(first) = values.first().copied() else {
444 return "";
445 };
446 let mut end = first.len();
447
448 for value in values.iter().skip(1) {
449 while end > 0 && !value.starts_with(&first[..end]) {
450 end -= 1;
451 // Ensure we land on a valid UTF-8 char boundary.
452 while end > 0 && !first.is_char_boundary(end) {
453 end -= 1;
454 }
455 }
456 if end == 0 {
457 return "";
458 }
459 }
460
461 &first[..end]
462 }
463
464 // ---------------------------------------------------------------------------
465 // Expansion at send-time
466 // ---------------------------------------------------------------------------
467
468 /// Append a "Local context from @mentions" block to the user's message when
469 /// any `@path` references are present. Returns the input unchanged when
470 /// there are none.
471 ///
472 /// `cwd` carries the user's launch directory and drives the second
473 /// resolution pass (issue #101): relative `@<path>` mentions resolve under
474 /// `cwd` when `workspace.join(path)` doesn't exist, so the user's mental
475 /// anchor (their shell's pwd) wins when it diverges from `--workspace`.
476 /// Pass `None` to disable the cwd pass entirely (workspace-only).
477 ///
478 /// Resolution here never walks the tree on submit (#4365). A miss on the
479 /// exact two-pass lookup falls back to a bounded, unique-match-only search of
480 /// the composer's already-built background completion index
481 /// (`completion_index`, when the caller has one cached); the winning
482 /// candidate must still exist on disk. Ambiguous, stale, or absent matches
483 /// stay an honest `<missing-file>` that names only what the user typed —
484 /// never a fabricated workspace-root path.
485 ///
486 /// Convenience wrapper that allocates a throwaway cache. Test-only: the real
487 /// send paths share one cache across the references and payload passes.
488 #[cfg(test)]
489 pub fn user_request_with_file_mentions(
490 input: &str,
491 workspace: &Path,
492 cwd: Option<PathBuf>,
493 ) -> String {
494 user_request_with_file_mentions_cached(
495 input,
496 workspace,
497 cwd,
498 &mut GitMentionCache::default(),
499 None,
500 )
501 }
502
503 pub fn user_request_with_file_mentions_cached(
504 input: &str,
505 workspace: &Path,
506 cwd: Option<PathBuf>,
507 git_cache: &mut GitMentionCache,
508 completion_index: Option<&[String]>,
509 ) -> String {
510 let Some(context) =
511 local_context_from_file_mentions(input, workspace, cwd, git_cache, completion_index)
512 else {
513 return input.to_string();
514 };
515 format!("{input}\n\n---\n\nLocal context from @mentions:\n{context}")
516 }
517
518 #[must_use]
519 pub fn pending_context_previews(input: &str) -> Vec<FileMentionPreview> {
520 let mut previews = Vec::new();
521 let mut seen = std::collections::HashSet::new();
522 for mention in extract_file_mentions(input)
523 .into_iter()
524 .take(MAX_FILE_MENTIONS_PER_MESSAGE)
525 {
526 if !seen.insert(mention.clone()) {
527 continue;
528 }
529 // Composer previews stay lexical (no git subprocess from the render
530 // loop, same rule as #4365 for path stats); the payload is resolved
531 // once at submit time.
532 if let Some(kind) = git_mention::git_mention_kind(&mention) {
533 previews.push(FileMentionPreview {
534 kind: "git".to_string(),
535 label: kind.label().to_string(),
536 detail: Some("resolved on send".to_string()),
537 included: false,
538 removable: false,
539 });
540 continue;
541 }
542 let media = is_media_path(Path::new(&mention));
543 previews.push(FileMentionPreview {
544 kind: if media { "media" } else { "mention" }.to_string(),
545 label: mention,
546 detail: Some(if media {
547 "use /attach for media bytes".to_string()
548 } else {
549 "resolved on send".to_string()
550 }),
551 // Lexical preview deliberately does not stat the path while the
552 // user types. Exact inclusion/missing metadata is resolved once,
553 // at submit time, rather than from the render loop (#4365).
554 included: false,
555 removable: false,
556 });
557 }
558
559 for attachment in extract_media_attachment_references(input) {
560 previews.push(FileMentionPreview {
561 kind: attachment.kind,
562 label: attachment.path,
563 detail: Some("attached media".to_string()),
564 included: true,
565 removable: true,
566 });
567 }
568 previews
569 }
570
571 /// Convenience wrapper that allocates a throwaway cache. Test-only, as above.
572 #[cfg(test)]
573 #[must_use]
574 pub fn context_references_from_input(
575 input: &str,
576 workspace: &Path,
577 cwd: Option<PathBuf>,
578 ) -> Vec<ContextReference> {
579 context_references_from_input_cached(
580 input,
581 workspace,
582 cwd,
583 &mut GitMentionCache::default(),
584 None,
585 )
586 }
587
588 #[must_use]
589 pub fn context_references_from_input_cached(
590 input: &str,
591 workspace: &Path,
592 cwd: Option<PathBuf>,
593 git_cache: &mut GitMentionCache,
594 completion_index: Option<&[String]>,
595 ) -> Vec<ContextReference> {
596 let mut references = Vec::new();
597 let mut seen = std::collections::HashSet::new();
598 let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd);
599
600 for mention in extract_file_mentions(input)
601 .into_iter()
602 .take(MAX_FILE_MENTIONS_PER_MESSAGE)
603 {
604 // Git mentions resolve against the working tree, not the path index,
605 // so the inspector reports their real size and budget (#4067).
606 if let Some(kind) = git_mention::git_mention_kind(&mention) {
607 let payload = git_cache.resolve(kind, workspace).clone();
608 let detail = match payload.unavailable_reason.as_deref() {
609 Some(reason) => format!("{}, {reason}", kind.label()),
610 None if payload.truncated => format!(
611 "{}, {} bytes truncated at {} budget",
612 kind.label(),
613 payload.bytes,
614 kind.byte_budget()
615 ),
616 None => format!("{}, {} bytes", kind.label(), payload.bytes),
617 };
618 let reference = ContextReference {
619 kind: ContextReferenceKind::GitContext,
620 source: ContextReferenceSource::AtMention,
621 badge: "git".to_string(),
622 label: kind.token().to_string(),
623 target: workspace.display().to_string(),
624 included: payload.unavailable_reason.is_none(),
625 expanded: false,
626 detail: Some(detail),
627 };
628 if seen.insert(format!("git-mention:{}", kind.token())) {
629 references.push(reference);
630 }
631 continue;
632 }
633
634 let (path, display_path, exists) =
635 resolve_mention_for_send(&ws, &mention, completion_index);
636 let reference = context_reference_for_mention(&mention, &path, &display_path, exists);
637 if !seen.insert(format!(
638 "{:?}:{:?}:{}:{}",
639 reference.source, reference.kind, reference.target, reference.label
640 )) {
641 continue;
642 }
643 references.push(reference);
644 }
645
646 for reference in extract_media_attachment_references(input) {
647 let context_reference = ContextReference {
648 kind: ContextReferenceKind::MediaAttachment,
649 source: ContextReferenceSource::Attachment,
650 badge: reference.kind,
651 label: reference.path.clone(),
652 target: reference.path,
653 included: true,
654 expanded: false,
655 detail: Some("attached media".to_string()),
656 };
657 if !seen.insert(format!(
658 "{:?}:{:?}:{}:{}",
659 context_reference.source,
660 context_reference.kind,
661 context_reference.target,
662 context_reference.label
663 )) {
664 continue;
665 }
666 references.push(context_reference);
667 }
668
669 references
670 }
671
672 fn context_reference_for_mention(
673 raw: &str,
674 path: &Path,
675 display_path: &str,
676 exists: bool,
677 ) -> ContextReference {
678 if !exists {
679 return ContextReference {
680 kind: ContextReferenceKind::Missing,
681 source: ContextReferenceSource::AtMention,
682 badge: "missing".to_string(),
683 label: raw.to_string(),
684 // No resolved target exists; naming the workspace-root guess here
685 // would present a path we already know is wrong.
686 target: raw.to_string(),
687 included: false,
688 expanded: false,
689 detail: Some("not found".to_string()),
690 };
691 }
692 if path.is_dir() {
693 return ContextReference {
694 kind: ContextReferenceKind::Directory,
695 source: ContextReferenceSource::AtMention,
696 badge: "dir".to_string(),
697 label: raw.to_string(),
698 target: display_path.to_string(),
699 included: true,
700 expanded: true,
701 detail: Some("directory listing".to_string()),
702 };
703 }
704 if !path.is_file() {
705 return ContextReference {
706 kind: ContextReferenceKind::Unsupported,
707 source: ContextReferenceSource::AtMention,
708 badge: "skipped".to_string(),
709 label: raw.to_string(),
710 target: display_path.to_string(),
711 included: false,
712 expanded: false,
713 detail: Some("unsupported path".to_string()),
714 };
715 }
716 if is_media_path(path) {
717 return ContextReference {
718 kind: ContextReferenceKind::MediaMention,
719 source: ContextReferenceSource::AtMention,
720 badge: "media".to_string(),
721 label: raw.to_string(),
722 target: display_path.to_string(),
723 included: false,
724 expanded: false,
725 detail: Some("use /attach for media bytes".to_string()),
726 };
727 }
728
729 let detail = match std::fs::metadata(path) {
730 Ok(metadata) if metadata.len() > MAX_MENTION_FILE_BYTES => {
731 Some("included truncated".to_string())
732 }
733 Ok(_) => Some("included".to_string()),
734 Err(err) => Some(format!("metadata: {err}")),
735 };
736
737 ContextReference {
738 kind: ContextReferenceKind::File,
739 source: ContextReferenceSource::AtMention,
740 badge: "file".to_string(),
741 label: raw.to_string(),
742 target: display_path.to_string(),
743 included: true,
744 expanded: true,
745 detail: detail.or_else(|| Some(display_path.to_string())),
746 }
747 }
748
749 #[derive(Debug, Clone, PartialEq, Eq)]
750 pub struct MediaAttachmentReference {
751 pub kind: String,
752 pub path: String,
753 pub start_byte: usize,
754 pub end_byte: usize,
755 }
756
757 pub fn media_attachment_references(input: &str) -> Vec<MediaAttachmentReference> {
758 let mut out = Vec::new();
759 let mut offset = 0usize;
760 for line in input.split_inclusive('\n') {
761 let start_byte = offset;
762 let end_byte = offset + line.len();
763 offset = end_byte;
764 let trimmed = line.trim();
765 let Some(body) = trimmed
766 .strip_prefix("[Attached ")
767 .and_then(|value| value.strip_suffix(']'))
768 else {
769 continue;
770 };
771 let Some((kind, rest)) = body.split_once(": ") else {
772 continue;
773 };
774 let path = rest
775 .rsplit_once(" at ")
776 .map_or(rest, |(_, path)| path)
777 .trim();
778 if !path.is_empty() {
779 out.push(MediaAttachmentReference {
780 kind: kind.trim().to_string(),
781 path: path.to_string(),
782 start_byte,
783 end_byte,
784 });
785 }
786 }
787 out
788 }
789
790 fn extract_media_attachment_references(input: &str) -> Vec<MediaAttachmentReference> {
791 media_attachment_references(input)
792 }
793
794 fn local_context_from_file_mentions(
795 input: &str,
796 workspace: &Path,
797 cwd: Option<PathBuf>,
798 git_cache: &mut GitMentionCache,
799 completion_index: Option<&[String]>,
800 ) -> Option<String> {
801 let mentions = extract_file_mentions(input);
802 if mentions.is_empty() {
803 return None;
804 }
805
806 let mut blocks = Vec::new();
807 let mut seen = std::collections::HashSet::new();
808 let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd);
809
810 for mention in mentions.into_iter().take(MAX_FILE_MENTIONS_PER_MESSAGE) {
811 // `@git` / `@diff` resolve to curated git context, not to a path, so
812 // they short-circuit before any workspace path resolution (#4067).
813 if let Some(kind) = git_mention::git_mention_kind(&mention) {
814 if !seen.insert(format!("git-mention:{}", kind.token())) {
815 continue;
816 }
817 blocks.push(git_cache.resolve(kind, workspace).block.clone());
818 continue;
819 }
820
821 // `Workspace::resolve_exact` already returns absolute paths when the root
822 // is absolute (TUI always runs from an absolute workspace), so we
823 // skip `canonicalize()` here — it's per-mention I/O on the
824 // message-send hot path. Accept the rare symlink-aliasing dedup
825 // miss as the cost of avoiding a syscall (Gemini code-review).
826 let (path, display_path, exists) =
827 resolve_mention_for_send(&ws, &mention, completion_index);
828 tracing::debug!(
829 target: "codewhale_tui::file_mention",
830 raw_typed = %mention,
831 workspace = %workspace.display(),
832 cwd = ?std::env::current_dir().ok(),
833 resolved = %display_path,
834 exists,
835 "file mention resolution",
836 );
837
838 // Gate every block — including <missing-file> — through the dedup
839 // set so a user typing the same non-existent file twice doesn't
840 // waste tokens on duplicate missing-file blocks (Devin code-review).
841 // Missing mentions dedup on the typed token: there is no resolved
842 // path to key on, and the workspace-root guess must not become one.
843 let dedup_key = if exists {
844 display_path.clone()
845 } else {
846 format!("missing:{mention}")
847 };
848 if !seen.insert(dedup_key) {
849 continue;
850 }
851
852 if exists {
853 blocks.push(render_file_mention_context(&mention, &path, &display_path));
854 } else {
855 // Honest miss: name only what the user typed. Emitting the
856 // workspace-root join as `path=` presented a non-existent file
857 // as if it were the resolved target.
858 blocks.push(format!("<missing-file mention=\"@{mention}\" />"));
859 }
860 }
861
862 if blocks.is_empty() {
863 None
864 } else {
865 Some(blocks.join("\n\n"))
866 }
867 }
868
869 /// Send-time mention resolution: exact two-pass lookup first (workspace root,
870 /// then launch cwd), then a bounded fallback against the composer's cached
871 /// background completion index. Returns the path, its display form, and
872 /// whether it exists. On a miss the returned path is the workspace-root guess
873 /// — callers must not present it as resolved when `exists` is false.
874 fn resolve_mention_for_send(
875 ws: &Workspace,
876 mention: &str,
877 completion_index: Option<&[String]>,
878 ) -> (PathBuf, String, bool) {
879 let guess = match ws.resolve_exact(mention) {
880 Ok(path) => {
881 let display = path.display().to_string();
882 return (path, display, true);
883 }
884 Err(guess) => guess,
885 };
886 if let Some(resolved) = completion_index
887 .and_then(|candidates| resolve_mention_in_completion_index(mention, candidates, ws))
888 {
889 let display = resolved.display().to_string();
890 return (resolved, display, true);
891 }
892 let display = guess.display().to_string();
893 (guess, display, false)
894 }
895
896 /// Bounded send-time fallback for `@`-mention misses.
897 ///
898 /// #4365 keeps filesystem walks off the submit path, so instead of walking we
899 /// match the typed token against the composer's already-built background
900 /// completion index (workspace- or cwd-relative display strings). A candidate
901 /// wins only when it is the *unique* path-suffix or basename match and the
902 /// winning path still resolves on disk — the index may be a few seconds
903 /// stale. Anything else (no hit, ambiguous hit, stale hit) returns `None` so
904 /// the caller emits an honest `<missing-file>` instead of attaching an
905 /// arbitrary same-name file from a nested directory.
906 fn resolve_mention_in_completion_index(
907 mention: &str,
908 candidates: &[String],
909 ws: &Workspace,
910 ) -> Option<PathBuf> {
911 // Absolute and home-anchored mentions name an exact location; a basename
912 // lookalike elsewhere in the tree would be a different file, not a fix-up.
913 // A leading separator is rooted on every platform, but `Path::is_absolute`
914 // is false for `/foo` on Windows (no drive prefix), which would otherwise
915 // let a rooted miss fall through to the index and attach an unrelated
916 // same-name file. Test the root marker directly so the guard holds there.
917 // `\` is a root marker only on Windows; on Unix it is an ordinary
918 // (if unusual) leading filename character, so leave that case alone.
919 let rooted = mention.starts_with('/') || (cfg!(windows) && mention.starts_with('\\'));
920 if mention.starts_with('~') || rooted || Path::new(mention).is_absolute() {
921 return None;
922 }
923 let needle = mention.replace('\\', "/");
924 let needle = needle.trim_matches('/');
925 if needle.is_empty() {
926 return None;
927 }
928 let needle_lower = needle.to_lowercase();
929 let basename_lower = needle_lower.rsplit('/').next()?;
930
931 let normalized = |candidate: &str| candidate.replace('\\', "/");
932 let suffix_match = |candidate: &str| {
933 let cand = normalized(candidate);
934 let cand = cand.trim_end_matches('/').to_lowercase();
935 !cand.is_empty() && (cand == needle_lower || cand.ends_with(&format!("/{needle_lower}")))
936 };
937 let basename_match = |candidate: &str| {
938 let cand = normalized(candidate);
939 let cand = cand.trim_end_matches('/').to_lowercase();
940 !cand.is_empty() && cand.rsplit('/').next() == Some(basename_lower)
941 };
942
943 // Prefer path-suffix hits: they carry the user's typed directory context.
944 // Fall back to basename hits only when no suffix hit exists. Either way,
945 // more than one distinct winner is ambiguous and resolves nothing.
946 let predicates: [&dyn Fn(&str) -> bool; 2] = [&suffix_match, &basename_match];
947 for predicate in predicates {
948 let mut winner: Option<&str> = None;
949 for candidate in candidates {
950 if !predicate(candidate) {
951 continue;
952 }
953 match winner {
954 None => winner = Some(candidate.as_str()),
955 Some(existing) if existing == candidate.as_str() => {}
956 Some(_) => return None,
957 }
958 }
959 if let Some(winner) = winner {
960 // The index can be stale; only a path that still resolves exists.
961 // Display strings are workspace- or cwd-relative, which
962 // `resolve_exact` re-anchors exactly like a typed path.
963 //
964 // Rejoin the components with this platform's separator first.
965 // Index strings are `/`-separated, and `root.join("ops/f.md")`
966 // keeps that slash verbatim on Windows, yielding a mixed
967 // `C:\ws\ops/f.md` that we then hand to the model and print in
968 // the context inspector. Same path, inconsistent rendering.
969 let native: PathBuf = winner.split('/').filter(|part| !part.is_empty()).collect();
970 return ws.resolve_exact(&native.to_string_lossy()).ok();
971 }
972 }
973 None
974 }
975
976 fn extract_file_mentions(input: &str) -> Vec<String> {
977 let chars: Vec<char> = input.chars().collect();
978 let mut mentions = Vec::new();
979 let mut idx = 0;
980
981 while idx < chars.len() {
982 if chars[idx] != '@' || !is_file_mention_start(&chars, idx) {
983 idx += 1;
984 continue;
985 }
986
987 let Some(next) = chars.get(idx + 1).copied() else {
988 break;
989 };
990 if next.is_whitespace() {
991 idx += 1;
992 continue;
993 }
994
995 if matches!(next, '"' | '\'') {
996 let quote = next;
997 let mut end = idx + 2;
998 let mut raw = String::new();
999 while end < chars.len() && chars[end] != quote {
1000 raw.push(chars[end]);
1001 end += 1;
1002 }
1003 if !raw.trim().is_empty() {
1004 mentions.push(raw.trim().to_string());
1005 }
1006 idx = end.saturating_add(1);
1007 continue;
1008 }
1009
1010 let mut end = idx + 1;
1011 let mut raw = String::new();
1012 while end < chars.len() && !chars[end].is_whitespace() {
1013 raw.push(chars[end]);
1014 end += 1;
1015 }
1016 let trimmed = trim_unquoted_mention(&raw);
1017 if !trimmed.is_empty() {
1018 mentions.push(trimmed.to_string());
1019 }
1020 idx = end;
1021 }
1022
1023 mentions
1024 }
1025
1026 fn is_file_mention_start(chars: &[char], idx: usize) -> bool {
1027 if idx == 0 {
1028 return true;
1029 }
1030 chars
1031 .get(idx.saturating_sub(1))
1032 .is_some_and(|ch| ch.is_whitespace() || matches!(ch, '(' | '[' | '{' | '<' | '"' | '\''))
1033 }
1034
1035 fn trim_unquoted_mention(raw: &str) -> &str {
1036 let mut trimmed = raw.trim();
1037 while trimmed.chars().count() > 1
1038 && trimmed
1039 .chars()
1040 .last()
1041 .is_some_and(|ch| matches!(ch, ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'))
1042 {
1043 trimmed = &trimmed[..trimmed.len() - trimmed.chars().last().unwrap().len_utf8()];
1044 }
1045 trimmed
1046 }
1047
1048 fn render_file_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
1049 if !path.exists() {
1050 return format!("<missing-file mention=\"@{raw}\" path=\"{display_path}\" />");
1051 }
1052 if path.is_dir() {
1053 return render_directory_mention_context(raw, path, display_path);
1054 }
1055 if !path.is_file() {
1056 return format!("<unsupported-path mention=\"@{raw}\" path=\"{display_path}\" />");
1057 }
1058 if is_media_path(path) {
1059 return format!(
1060 "<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>"
1061 );
1062 }
1063
1064 match read_text_prefix(path) {
1065 Ok((text, truncated)) => {
1066 let truncated_attr = if truncated { " truncated=\"true\"" } else { "" };
1067 format!(
1068 "<file mention=\"@{raw}\" path=\"{display_path}\"{truncated_attr}>\n{text}\n</file>"
1069 )
1070 }
1071 Err(err) => {
1072 format!(
1073 "<unreadable-file mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-file>"
1074 )
1075 }
1076 }
1077 }
1078
1079 fn render_directory_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
1080 let entries = match std::fs::read_dir(path) {
1081 Ok(entries) => entries,
1082 Err(err) => {
1083 return format!(
1084 "<unreadable-directory mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-directory>"
1085 );
1086 }
1087 };
1088
1089 let mut names = entries
1090 .filter_map(|entry| entry.ok())
1091 .map(|entry| {
1092 let marker = entry
1093 .file_type()
1094 .ok()
1095 .filter(|ty| ty.is_dir())
1096 .map_or("", |_| "/");
1097 format!("{}{}", entry.file_name().to_string_lossy(), marker)
1098 })
1099 .collect::<Vec<_>>();
1100 names.sort();
1101 let total = names.len();
1102 names.truncate(MAX_DIRECTORY_MENTION_ENTRIES);
1103 let mut body = names.join("\n");
1104 if total > MAX_DIRECTORY_MENTION_ENTRIES {
1105 let omitted = total - MAX_DIRECTORY_MENTION_ENTRIES;
1106 let _ = write!(body, "\n... {omitted} more entries");
1107 }
1108 format!("<directory mention=\"@{raw}\" path=\"{display_path}\">\n{body}\n</directory>")
1109 }
1110
1111 fn read_text_prefix(path: &Path) -> std::io::Result<(String, bool)> {
1112 let mut file = std::fs::File::open(path)?;
1113 let mut buffer = Vec::new();
1114 file.by_ref()
1115 .take(MAX_MENTION_FILE_BYTES + 1)
1116 .read_to_end(&mut buffer)?;
1117 let truncated = buffer.len() as u64 > MAX_MENTION_FILE_BYTES;
1118 if truncated {
1119 buffer.truncate(MAX_MENTION_FILE_BYTES as usize);
1120 // Round down to the nearest valid UTF-8 character boundary so a
1121 // multi-byte sequence (CJK, emoji, etc.) is never split at the cut point.
1122 // Only adjust when error_len() is None — that means truncation landed
1123 // mid-sequence (incomplete tail). A Some(_) error_len means the file
1124 // genuinely contains invalid UTF-8 bytes; leave the buffer intact so
1125 // the from_utf8 call below returns the correct "file is not UTF-8" error.
1126 if let Err(e) = std::str::from_utf8(&buffer)
1127 && e.error_len().is_none()
1128 {
1129 buffer.truncate(e.valid_up_to());
1130 }
1131 }
1132 if buffer.contains(&0) {
1133 return Err(std::io::Error::new(
1134 std::io::ErrorKind::InvalidData,
1135 "file appears to be binary",
1136 ));
1137 }
1138 let text = std::str::from_utf8(&buffer)
1139 .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "file is not UTF-8"))?
1140 .to_string();
1141 Ok((text, truncated))
1142 }
1143
1144 fn is_media_path(path: &Path) -> bool {
1145 let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
1146 return false;
1147 };
1148 matches!(
1149 ext.to_ascii_lowercase().as_str(),
1150 "png"
1151 | "jpg"
1152 | "jpeg"
1153 | "gif"
1154 | "webp"
1155 | "bmp"
1156 | "tif"
1157 | "tiff"
1158 | "ppm"
1159 | "mp4"
1160 | "mov"
1161 | "m4v"
1162 | "webm"
1163 | "avi"
1164 | "mkv"
1165 )
1166 }
1167
1168 // ---------------------------------------------------------------------------
1169 // #101 regression repros
1170 // ---------------------------------------------------------------------------
1171 //
1172 // The bug being guarded: typing `@<some/file>` resolved under `--workspace`,
1173 // not the user's launch CWD. When the two diverged (the canonical case is
1174 // `--workspace=/repo` with `pwd=/repo/sub`), every relative `@` token routed
1175 // to the wrong root and the prompt got `<missing-file>` blocks.
1176 #[cfg(test)]
1177 mod tests {
1178 use super::*;
1179 use tempfile::TempDir;
1180
1181 /// #101 regression — workspace-vs-cwd divergence: `@bar.txt` typed from
1182 /// the cwd `<root>/sub` MUST resolve to `<root>/sub/bar.txt`, never to
1183 /// `<root>/bar.txt` (which doesn't exist).
1184 #[test]
1185 fn cwd_pass_resolves_when_workspace_pass_misses() {
1186 let tmp = TempDir::new().expect("tempdir");
1187 let sub = tmp.path().join("sub");
1188 std::fs::create_dir_all(&sub).expect("mkdir");
1189 let bar = sub.join("bar.txt");
1190 std::fs::write(&bar, "hello bar").expect("write bar");
1191
1192 let content =
1193 user_request_with_file_mentions("look at @bar.txt", tmp.path(), Some(sub.clone()));
1194
1195 // The block must reference the cwd-rooted path with the file's body —
1196 // and crucially it must NOT collapse to <missing-file>.
1197 assert!(
1198 content.contains("hello bar"),
1199 "expected file body to be inlined; got: {content}",
1200 );
1201 assert!(
1202 !content.contains("<missing-file"),
1203 "must not surface <missing-file> for a path that exists under cwd; got: {content}",
1204 );
1205 let bar_disp = bar.display().to_string();
1206 assert!(
1207 content.contains(&bar_disp),
1208 "expected resolved path {bar_disp} in content; got: {content}",
1209 );
1210 // Belt-and-suspenders: the workspace-rooted path doesn't exist and
1211 // must not appear in the rendered <file path="..."> attribute.
1212 let wrong = tmp.path().join("bar.txt").display().to_string();
1213 assert!(
1214 !content.contains(&format!("path=\"{wrong}\"")),
1215 "should NOT have routed to {wrong}; got: {content}",
1216 );
1217 }
1218
1219 /// #101 regression — nested workspace path: `@nested/deep/file.md` with
1220 /// the file at workspace root resolves through the workspace pass.
1221 #[test]
1222 fn workspace_pass_resolves_nested_path() {
1223 let tmp = TempDir::new().expect("tempdir");
1224 let nested = tmp.path().join("nested/deep");
1225 std::fs::create_dir_all(&nested).expect("mkdir");
1226 let file_md = nested.join("file.md");
1227 std::fs::write(&file_md, "# nested deep").expect("write file_md");
1228
1229 // Cwd is irrelevant; an unrelated tempdir would do. Pass `None` so we
1230 // are unambiguously testing the workspace-pass path.
1231 let content = user_request_with_file_mentions("see @nested/deep/file.md", tmp.path(), None);
1232
1233 assert!(content.contains("# nested deep"), "got: {content}");
1234 assert!(!content.contains("<missing-file"), "got: {content}");
1235 // Path-separator-portable check: the resolved path's filename is the
1236 // most reliable cross-platform anchor (Windows mixes `/` and `\` when
1237 // join() preserves user-typed separators).
1238 let basename = file_md
1239 .file_name()
1240 .and_then(|n| n.to_str())
1241 .expect("file_name utf-8");
1242 assert!(
1243 content.contains(basename),
1244 "basename {basename} not in path; got: {content}",
1245 );
1246 }
1247
1248 /// Snapshot-style check: the rendered `<file>` block for a resolvable
1249 /// mention must include the expected attributes and contents, and must
1250 /// NOT contain `<missing-file>`.
1251 #[test]
1252 fn resolvable_mention_renders_file_block_not_missing_file() {
1253 let tmp = TempDir::new().expect("tempdir");
1254 std::fs::write(tmp.path().join("guide.md"), "# Guide\nUse the fast path.\n")
1255 .expect("write");
1256
1257 let content = user_request_with_file_mentions("read @guide.md", tmp.path(), None);
1258
1259 // Header + tag presence.
1260 assert!(content.contains("Local context from @mentions:"));
1261 assert!(content.contains("<file mention=\"@guide.md\""));
1262 assert!(content.contains("# Guide\nUse the fast path."));
1263 assert!(content.ends_with("</file>"), "got: {content}");
1264 // The bug fingerprint MUST be absent.
1265 assert!(!content.contains("<missing-file"), "got: {content}");
1266 }
1267
1268 /// Negative test: a truly missing path still produces `<missing-file>`
1269 /// so the user gets an explicit signal instead of silent failure.
1270 #[test]
1271 fn truly_missing_mention_still_renders_missing_file() {
1272 let tmp = TempDir::new().expect("tempdir");
1273
1274 let content = user_request_with_file_mentions(
1275 "huh @does/not/exist.txt",
1276 tmp.path(),
1277 Some(tmp.path().to_path_buf()),
1278 );
1279
1280 assert!(
1281 content.contains("<missing-file mention=\"@does/not/exist.txt\""),
1282 "got: {content}",
1283 );
1284 }
1285
1286 #[test]
1287 fn pending_context_preview_is_lexical_and_does_not_probe_paths() {
1288 let previews = pending_context_previews("read @guide.md and @missing.md");
1289
1290 assert_eq!(previews.len(), 2);
1291 assert_eq!(previews[0].kind, "mention");
1292 assert_eq!(previews[0].label, "guide.md");
1293 assert!(!previews[0].included);
1294 assert_eq!(previews[0].detail.as_deref(), Some("resolved on send"));
1295 assert_eq!(previews[1].kind, "mention");
1296 assert_eq!(previews[1].label, "missing.md");
1297 assert!(!previews[1].included);
1298 assert_eq!(previews[1].detail.as_deref(), Some("resolved on send"));
1299 }
1300
1301 #[test]
1302 fn pending_context_preview_distinguishes_attach_media_from_at_media() {
1303 let tmp = TempDir::new().expect("tempdir");
1304 std::fs::write(tmp.path().join("photo.png"), b"png").expect("write");
1305 let attached = tmp.path().join("photo.png").display().to_string();
1306 let input = format!("inspect @photo.png\n[Attached image: {attached}]");
1307
1308 let previews = pending_context_previews(&input);
1309
1310 assert!(
1311 previews
1312 .iter()
1313 .any(|item| item.kind == "media" && !item.included),
1314 "at-mention media should be hint-only: {previews:?}"
1315 );
1316 assert!(
1317 previews
1318 .iter()
1319 .any(|item| item.kind == "image" && item.included),
1320 "/attach media should be included: {previews:?}"
1321 );
1322 }
1323
1324 #[test]
1325 fn manually_typed_basename_does_not_fuzzy_attach_nested_file() {
1326 let tmp = TempDir::new().expect("tempdir");
1327 let nested = tmp.path().join("nested");
1328 std::fs::create_dir_all(&nested).expect("mkdir");
1329 std::fs::write(nested.join("guide.md"), "nested secret").expect("write");
1330
1331 // With no completion index on hand there is no send-time fallback:
1332 // the miss stays an explicit <missing-file> rather than attaching an
1333 // arbitrary same-name file from a nested directory (#4365).
1334 let content = user_request_with_file_mentions("read @guide.md", tmp.path(), None);
1335
1336 assert!(
1337 content.contains("<missing-file mention=\"@guide.md\""),
1338 "a manually typed basename should remain exact: {content}",
1339 );
1340 assert!(
1341 !content.contains("nested secret"),
1342 "exact resolution must not silently attach a fuzzy nested match: {content}",
1343 );
1344 }
1345
1346 // ---------------------------------------------------------------------
1347 // Send-time completion-index fallback
1348 // ---------------------------------------------------------------------
1349 //
1350 // The dogfood failure this guards: `@FINISH-0.9.4.md` typed at the
1351 // workspace root resolved "not found" and injected a <missing-file> block
1352 // carrying the wrong (workspace-root) path, even though the file sat one
1353 // directory down. Misses now fall back to a bounded unique-match search
1354 // of the composer's background completion index; unresolvable misses emit
1355 // an honest block that names only what the user typed.
1356
1357 fn expand_with_index(
1358 input: &str,
1359 workspace: &Path,
1360 cwd: Option<PathBuf>,
1361 index: &[String],
1362 ) -> String {
1363 user_request_with_file_mentions_cached(
1364 input,
1365 workspace,
1366 cwd,
1367 &mut GitMentionCache::default(),
1368 Some(index),
1369 )
1370 }
1371
1372 /// A unique basename hit in the completion index resolves a nested file
1373 /// and injects its real path.
1374 #[test]
1375 fn mention_miss_resolves_via_unique_index_basename() {
1376 let tmp = TempDir::new().expect("tempdir");
1377 let nested = tmp.path().join("ops");
1378 std::fs::create_dir_all(&nested).expect("mkdir");
1379 std::fs::write(nested.join("FINISH-0.9.4.md"), "ship list").expect("write");
1380
1381 let index = vec!["ops/FINISH-0.9.4.md".to_string(), "README.md".to_string()];
1382 let content = expand_with_index("finish @FINISH-0.9.4.md", tmp.path(), None, &index);
1383
1384 assert!(content.contains("ship list"), "got: {content}");
1385 assert!(!content.contains("<missing-file"), "got: {content}");
1386 let real = nested.join("FINISH-0.9.4.md").display().to_string();
1387 assert!(
1388 content.contains(&real),
1389 "expected resolved path {real} in content; got: {content}",
1390 );
1391 }
1392
1393 /// A typed partial path resolves through a unique path-suffix hit.
1394 #[test]
1395 fn mention_miss_resolves_via_unique_index_suffix() {
1396 let tmp = TempDir::new().expect("tempdir");
1397 let nested = tmp.path().join("nested/deep");
1398 std::fs::create_dir_all(&nested).expect("mkdir");
1399 std::fs::write(nested.join("file.md"), "deep body").expect("write");
1400 // A same-basename file elsewhere must not make the suffix hit
1401 // ambiguous: the typed directory context disambiguates.
1402 let other = tmp.path().join("other");
1403 std::fs::create_dir_all(&other).expect("mkdir");
1404 std::fs::write(other.join("file.md"), "other body").expect("write");
1405
1406 let index = vec![
1407 "nested/deep/file.md".to_string(),
1408 "other/file.md".to_string(),
1409 ];
1410 let content = expand_with_index("see @deep/file.md", tmp.path(), None, &index);
1411
1412 assert!(content.contains("deep body"), "got: {content}");
1413 assert!(!content.contains("other body"), "got: {content}");
1414 assert!(!content.contains("<missing-file"), "got: {content}");
1415 }
1416
1417 /// Two same-basename candidates with no typed directory context are
1418 /// ambiguous: nothing is attached and the miss stays explicit.
1419 #[test]
1420 fn ambiguous_index_basename_stays_missing() {
1421 let tmp = TempDir::new().expect("tempdir");
1422 for dir in ["a", "b"] {
1423 std::fs::create_dir_all(tmp.path().join(dir)).expect("mkdir");
1424 std::fs::write(tmp.path().join(dir).join("guide.md"), format!("body {dir}"))
1425 .expect("write");
1426 }
1427
1428 let index = vec!["a/guide.md".to_string(), "b/guide.md".to_string()];
1429 let content = expand_with_index("read @guide.md", tmp.path(), None, &index);
1430
1431 assert!(
1432 content.contains("<missing-file mention=\"@guide.md\""),
1433 "an ambiguous basename must not attach an arbitrary winner: {content}",
1434 );
1435 assert!(!content.contains("body a"), "got: {content}");
1436 assert!(!content.contains("body b"), "got: {content}");
1437 }
1438
1439 /// A stale index entry (file deleted after the scan) must not attach.
1440 #[test]
1441 fn stale_index_entry_stays_missing() {
1442 let tmp = TempDir::new().expect("tempdir");
1443
1444 let index = vec!["ghost.md".to_string()];
1445 let content = expand_with_index("boo @ghost.md", tmp.path(), None, &index);
1446
1447 assert!(
1448 content.contains("<missing-file mention=\"@ghost.md\" />"),
1449 "got: {content}",
1450 );
1451 }
1452
1453 /// Absolute mentions name an exact location; the index must never
1454 /// substitute a same-basename file from inside the workspace.
1455 #[test]
1456 fn absolute_mention_miss_never_uses_index() {
1457 let tmp = TempDir::new().expect("tempdir");
1458 std::fs::write(tmp.path().join("guide.md"), "workspace guide").expect("write");
1459
1460 let index = vec!["guide.md".to_string()];
1461 let content = expand_with_index(
1462 "read @/definitely/absent/guide.md",
1463 tmp.path(),
1464 None,
1465 &index,
1466 );
1467
1468 assert!(
1469 content.contains("<missing-file mention=\"@/definitely/absent/guide.md\" />"),
1470 "got: {content}",
1471 );
1472 assert!(!content.contains("workspace guide"), "got: {content}");
1473 }
1474
1475 /// The honest miss format: the block names only the typed mention and
1476 /// never the non-existent workspace-root join.
1477 #[test]
1478 fn missing_file_block_names_only_the_typed_mention() {
1479 let tmp = TempDir::new().expect("tempdir");
1480
1481 let content = user_request_with_file_mentions(
1482 "huh @does/not/exist.txt",
1483 tmp.path(),
1484 Some(tmp.path().to_path_buf()),
1485 );
1486
1487 assert!(
1488 content.contains("<missing-file mention=\"@does/not/exist.txt\" />"),
1489 "got: {content}",
1490 );
1491 let wrong = tmp.path().join("does/not/exist.txt").display().to_string();
1492 assert!(
1493 !content.contains(&wrong),
1494 "must not inject the wrong workspace-root path {wrong}; got: {content}",
1495 );
1496 }
1497
1498 /// The context inspector mirrors the payload: index-resolved mentions
1499 /// report their real path, unresolved ones report the typed token.
1500 #[test]
1501 fn context_references_reflect_index_resolution() {
1502 let tmp = TempDir::new().expect("tempdir");
1503 let nested = tmp.path().join("ops");
1504 std::fs::create_dir_all(&nested).expect("mkdir");
1505 std::fs::write(nested.join("runbook.md"), "steps").expect("write");
1506
1507 let index = vec!["ops/runbook.md".to_string()];
1508 let references = context_references_from_input_cached(
1509 "read @runbook.md and @absent.md",
1510 tmp.path(),
1511 None,
1512 &mut GitMentionCache::default(),
1513 Some(&index),
1514 );
1515
1516 let resolved = references
1517 .iter()
1518 .find(|r| r.label == "runbook.md")
1519 .expect("runbook reference");
1520 assert_eq!(resolved.kind, ContextReferenceKind::File);
1521 assert!(resolved.included);
1522 let real = nested.join("runbook.md").display().to_string();
1523 assert_eq!(resolved.target, real, "{resolved:?}");
1524
1525 let missing = references
1526 .iter()
1527 .find(|r| r.label == "absent.md")
1528 .expect("absent reference");
1529 assert_eq!(missing.kind, ContextReferenceKind::Missing);
1530 assert!(!missing.included);
1531 assert_eq!(
1532 missing.target, "absent.md",
1533 "a missing mention must not report the workspace-root guess as its target: {missing:?}",
1534 );
1535 }
1536
1537 #[test]
1538 fn media_attachment_references_include_removable_line_ranges() {
1539 let input = "before\n[Attached image: 8x4 PNG at /tmp/pasted.png]\nafter";
1540
1541 let references = media_attachment_references(input);
1542
1543 assert_eq!(references.len(), 1);
1544 let reference = &references[0];
1545 assert_eq!(reference.kind, "image");
1546 assert_eq!(reference.path, "/tmp/pasted.png");
1547 assert_eq!(
1548 &input[reference.start_byte..reference.end_byte],
1549 "[Attached image: 8x4 PNG at /tmp/pasted.png]\n"
1550 );
1551 }
1552
1553 #[test]
1554 fn context_references_preserve_exact_targets_and_roundtrip() {
1555 let tmp = TempDir::new().expect("tempdir");
1556 std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
1557 std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write");
1558 let input = "read @src/main.rs";
1559
1560 let references =
1561 context_references_from_input(input, tmp.path(), Some(tmp.path().to_path_buf()));
1562
1563 assert_eq!(references.len(), 1);
1564 let reference = &references[0];
1565 assert_eq!(reference.kind, ContextReferenceKind::File);
1566 assert_eq!(reference.source, ContextReferenceSource::AtMention);
1567 assert_eq!(reference.label, "src/main.rs");
1568 assert!(reference.target.ends_with("src/main.rs"));
1569 assert!(reference.included);
1570 assert!(reference.expanded);
1571
1572 let encoded = serde_json::to_string(reference).expect("serialize");
1573 let decoded: ContextReference = serde_json::from_str(&encoded).expect("deserialize");
1574 assert_eq!(&decoded, reference);
1575 }
1576
1577 /// Regression test for #1441: truncating at MAX_MENTION_FILE_BYTES must not
1578 /// split a multi-byte UTF-8 sequence, which previously produced U+FFFD
1579 /// replacement characters in the TUI output.
1580 #[test]
1581 fn read_text_prefix_truncation_respects_utf8_char_boundary() {
1582 use std::io::Write;
1583
1584 // Build a file that is MAX_MENTION_FILE_BYTES - 1 ASCII bytes followed
1585 // by a 3-byte CJK character (U+4E2D, '中'). The naive truncate at
1586 // MAX_MENTION_FILE_BYTES cuts after the first byte of '中', producing
1587 // an invalid sequence.
1588 let tmp = TempDir::new().expect("tempdir");
1589 let path = tmp.path().join("cjk.txt");
1590 let mut f = std::fs::File::create(&path).expect("create");
1591 let padding = vec![b'a'; MAX_MENTION_FILE_BYTES as usize - 1];
1592 f.write_all(&padding).expect("write padding");
1593 f.write_all("中".as_bytes()).expect("write CJK");
1594
1595 let (text, truncated) = read_text_prefix(&path).expect("should succeed");
1596 assert!(
1597 truncated,
1598 "file exceeds limit so should be marked truncated"
1599 );
1600 assert!(
1601 !text.contains('\u{FFFD}'),
1602 "truncated text must not contain replacement characters; got: {text:?}",
1603 );
1604 }
1605 // ---------------------------------------------------------------------
1606 // #4067 — @git / @diff composer mentions
1607 // ---------------------------------------------------------------------
1608
1609 fn init_test_repo(dir: &Path) {
1610 for args in [
1611 vec!["init", "--initial-branch=main"],
1612 vec!["config", "user.email", "test@example.com"],
1613 vec!["config", "user.name", "Test"],
1614 ] {
1615 let out = std::process::Command::new("git")
1616 .args(&args)
1617 .current_dir(dir)
1618 .output()
1619 .expect("git available in tests");
1620 assert!(out.status.success(), "git {args:?} failed");
1621 }
1622 }
1623
1624 fn commit_test_repo(dir: &Path) {
1625 for args in [vec!["add", "-A"], vec!["commit", "-m", "initial"]] {
1626 std::process::Command::new("git")
1627 .args(&args)
1628 .current_dir(dir)
1629 .output()
1630 .expect("git available in tests");
1631 }
1632 }
1633
1634 #[test]
1635 fn git_and_diff_mentions_inline_curated_context_not_paths() {
1636 let tmp = TempDir::new().expect("tempdir");
1637 init_test_repo(tmp.path());
1638 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
1639 commit_test_repo(tmp.path());
1640 std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
1641
1642 let expanded = user_request_with_file_mentions(
1643 "look at @git and @diff",
1644 tmp.path(),
1645 Some(tmp.path().to_path_buf()),
1646 );
1647
1648 assert!(expanded.contains("<git-status"), "{expanded}");
1649 assert!(expanded.contains("<git-diff"), "{expanded}");
1650 assert!(expanded.contains("a.txt"), "{expanded}");
1651 // The tokens are not treated as paths, so no missing-file block.
1652 assert!(!expanded.contains("<missing-file"), "{expanded}");
1653 }
1654
1655 #[test]
1656 fn git_mentions_outside_a_repository_say_so_explicitly() {
1657 let tmp = TempDir::new().expect("tempdir");
1658 let expanded = user_request_with_file_mentions(
1659 "status? @git",
1660 tmp.path(),
1661 Some(tmp.path().to_path_buf()),
1662 );
1663 assert!(expanded.contains("<git-unavailable"), "{expanded}");
1664 assert!(expanded.contains("not a git repository"), "{expanded}");
1665 }
1666
1667 #[test]
1668 fn git_mention_is_deduplicated_within_one_message() {
1669 let tmp = TempDir::new().expect("tempdir");
1670 init_test_repo(tmp.path());
1671 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
1672 commit_test_repo(tmp.path());
1673 std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
1674
1675 let expanded = user_request_with_file_mentions(
1676 "@diff and again @diff",
1677 tmp.path(),
1678 Some(tmp.path().to_path_buf()),
1679 );
1680 assert_eq!(expanded.matches("<git-diff").count(), 1, "{expanded}");
1681 }
1682
1683 #[test]
1684 fn paths_that_merely_start_with_git_stay_file_mentions() {
1685 let tmp = TempDir::new().expect("tempdir");
1686 std::fs::write(tmp.path().join("diff.txt"), "plain file").expect("write");
1687
1688 let expanded = user_request_with_file_mentions(
1689 "see @diff.txt",
1690 tmp.path(),
1691 Some(tmp.path().to_path_buf()),
1692 );
1693 assert!(expanded.contains("plain file"), "{expanded}");
1694 assert!(!expanded.contains("<git-diff"), "{expanded}");
1695 }
1696
1697 #[test]
1698 fn large_diff_is_truncated_and_the_inspector_reports_the_budget() {
1699 let tmp = TempDir::new().expect("tempdir");
1700 init_test_repo(tmp.path());
1701 std::fs::write(tmp.path().join("big.txt"), "seed\n").expect("write");
1702 commit_test_repo(tmp.path());
1703 let bulk: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
1704 std::fs::write(tmp.path().join("big.txt"), bulk).expect("write");
1705
1706 let expanded =
1707 user_request_with_file_mentions("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
1708 assert!(
1709 expanded.contains("truncated=\"true\""),
1710 "expected truncation marker"
1711 );
1712
1713 let references =
1714 context_references_from_input("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
1715 let git_ref = references
1716 .iter()
1717 .find(|r| r.kind == ContextReferenceKind::GitContext)
1718 .expect("git reference present in the inspector");
1719 assert_eq!(git_ref.label, "diff");
1720 assert!(git_ref.included);
1721 let detail = git_ref.detail.clone().unwrap_or_default();
1722 assert!(detail.contains("truncated at"), "{detail}");
1723 assert!(
1724 detail.contains(&crate::tui::git_mention::MAX_GIT_DIFF_MENTION_BYTES.to_string()),
1725 "{detail}"
1726 );
1727 }
1728
1729 #[test]
1730 fn empty_repository_reference_is_visible_but_not_included() {
1731 let tmp = TempDir::new().expect("tempdir");
1732 init_test_repo(tmp.path());
1733 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
1734 commit_test_repo(tmp.path());
1735
1736 let references =
1737 context_references_from_input("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
1738 let git_ref = references
1739 .iter()
1740 .find(|r| r.kind == ContextReferenceKind::GitContext)
1741 .expect("git reference present even when there is nothing to show");
1742 assert!(!git_ref.included);
1743 assert!(
1744 git_ref
1745 .detail
1746 .as_deref()
1747 .is_some_and(|d| d.contains("no working-tree changes")),
1748 "{:?}",
1749 git_ref.detail
1750 );
1751 }
1752
1753 #[test]
1754 fn composer_preview_lists_git_mentions_without_running_git() {
1755 let previews = pending_context_previews("@git @diff");
1756 let kinds: Vec<&str> = previews.iter().map(|p| p.kind.as_str()).collect();
1757 assert_eq!(kinds, vec!["git", "git"]);
1758 assert!(previews.iter().all(|p| !p.included));
1759 }
1760
1761 /// #4067 review follow-up: `mention_menu_limit = 0` is a documented way to
1762 /// disable the popup. The git tokens are menu entries like any other and
1763 /// must respect the same cap — otherwise setting 0 still pops a one-entry
1764 /// menu the moment the user types `@g`.
1765 #[test]
1766 fn git_mention_entries_respect_a_zero_menu_limit() {
1767 let paths = vec!["src/main.rs".to_string()];
1768 assert!(with_git_mention_entries(paths.clone(), "g", 0).is_empty());
1769 assert!(with_git_mention_entries(paths.clone(), "d", 0).is_empty());
1770 assert!(with_git_mention_entries(paths.clone(), "", 0).is_empty());
1771 assert!(with_git_mention_entries(Vec::new(), "gi", 0).is_empty());
1772 }
1773
1774 /// A small non-zero limit must cap the token list this function builds.
1775 ///
1776 /// The empty-partial branch is pass-through by design — those entries were
1777 /// already capped upstream by `rank_completion_candidates`, and shrinking
1778 /// them here would silently drop paths the caller asked for.
1779 #[test]
1780 fn git_mention_entries_never_exceed_the_menu_limit() {
1781 let paths = vec!["a.rs".to_string(), "b.rs".to_string()];
1782 for limit in 1..=4 {
1783 let matched = with_git_mention_entries(paths.clone(), "d", limit);
1784 assert!(matched.len() <= limit, "limit {limit}: {matched:?}");
1785 // Both tokens match a bare prefix that hits `git` and `diff`
1786 // through separate entries; the cap still holds.
1787 let both = with_git_mention_entries(Vec::new(), "", limit);
1788 assert!(both.len() <= limit, "limit {limit}: {both:?}");
1789 }
1790 // Pass-through: the caller's already-capped paths survive untouched.
1791 assert_eq!(with_git_mention_entries(paths.clone(), "", 1), paths);
1792 }
1793
1794 /// #4067 review follow-up: one submit resolves a git mention once, not
1795 /// once per surface. `@diff` makes git compute the whole working-tree diff
1796 /// before the byte budget applies, so a repeat is real wasted work.
1797 #[test]
1798 fn a_submit_resolves_each_git_mention_only_once() {
1799 let tmp = TempDir::new().expect("tempdir");
1800 init_test_repo(tmp.path());
1801 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
1802 commit_test_repo(tmp.path());
1803 std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
1804
1805 let mut cache = crate::tui::git_mention::GitMentionCache::default();
1806 let references = context_references_from_input_cached(
1807 "@diff",
1808 tmp.path(),
1809 Some(tmp.path().to_path_buf()),
1810 &mut cache,
1811 None,
1812 );
1813 let expanded = user_request_with_file_mentions_cached(
1814 "@diff",
1815 tmp.path(),
1816 Some(tmp.path().to_path_buf()),
1817 &mut cache,
1818 None,
1819 );
1820
1821 // Both surfaces describe the same resolution.
1822 let git_ref = references
1823 .iter()
1824 .find(|r| r.kind == ContextReferenceKind::GitContext)
1825 .expect("git reference");
1826 assert!(git_ref.included);
1827 assert!(expanded.contains("<git-diff"), "{expanded}");
1828
1829 // And the shared cache holds exactly one entry for it.
1830 assert_eq!(cache.len(), 1, "the diff must be resolved once per submit");
1831 }
1832
1833 #[test]
1834 fn completion_offers_git_and_diff_alongside_paths() {
1835 let paths = vec!["src/main.rs".to_string(), "docs/guide.md".to_string()];
1836 // A bare `@` stays the file picker.
1837 assert_eq!(with_git_mention_entries(paths.clone(), "", 8), paths);
1838
1839 let narrowed = with_git_mention_entries(paths.clone(), "di", 8);
1840 assert_eq!(narrowed.first().map(String::as_str), Some("diff"));
1841 assert!(narrowed.contains(&"src/main.rs".to_string()));
1842
1843 let git_only = with_git_mention_entries(paths.clone(), "g", 8);
1844 assert_eq!(git_only.first().map(String::as_str), Some("git"));
1845
1846 // A partial that matches no token leaves path completion untouched.
1847 assert_eq!(with_git_mention_entries(paths.clone(), "src", 8), paths);
1848 }
1849 }
1850
1850 lines RUST