返回 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 crate::tui::app::{App, MentionCompletionCache};
29 use crate::tui::git_mention::{self, GitMentionCache, GitMentionKind};
30 use crate::tui::mention_completion::{MentionDiscoveryBehavior, MentionDiscoveryKey};
31 use crate::working_set::Workspace;
32 use codewhale_core::{
33 ContextReference, ContextReferenceKind, ContextReferenceSource, MediaAttachmentReference,
34 media_attachment_references,
35 };
36
37 /// Maximum number of `@`-mentions whose contents are inlined into one user
38 /// message. Beyond this we stop appending blocks but the raw `@token` text
39 /// remains in the message.
40 pub const MAX_FILE_MENTIONS_PER_MESSAGE: usize = 8;
41 /// Per-file byte ceiling when inlining mention contents.
42 pub const MAX_MENTION_FILE_BYTES: u64 = 128 * 1024;
43 /// Per-directory entry ceiling when inlining a directory listing.
44 pub const MAX_DIRECTORY_MENTION_ENTRIES: usize = 80;
45
46 /// Maximum file-mention completion candidates to consider per keypress. Caps
47 /// the cost of walking large workspaces; subsequent keystrokes narrow further.
48 const FILE_MENTION_COMPLETION_LIMIT: usize = 64;
49
50 /// Compact composer preview row for local context. `included=false` also
51 /// covers lexical `@` mentions whose exact inclusion is resolved on send.
52 #[derive(Debug, Clone, PartialEq, Eq)]
53 pub struct FileMentionPreview {
54 pub kind: String,
55 pub label: String,
56 pub detail: Option<String>,
57 pub included: bool,
58 pub removable: bool,
59 }
60
61 // ---------------------------------------------------------------------------
62 // Tab-completion
63 // ---------------------------------------------------------------------------
64
65 /// If the cursor sits inside a `@<partial>` token in the input, return the
66 /// byte offset where the `@` starts (so we can splice in a completion) and
67 /// the partial path the user has typed so far. The token stops at whitespace
68 /// or the end of input. Returns `None` when the cursor is outside any mention
69 /// or the token is empty (`@` with nothing after it).
70 pub fn partial_file_mention_at_cursor(input: &str, cursor_chars: usize) -> Option<(usize, String)> {
71 let chars: Vec<char> = input.chars().collect();
72 if cursor_chars > chars.len() {
73 return None;
74 }
75 // Walk left from the cursor until we find an `@` or a whitespace; if
76 // whitespace comes first the cursor isn't inside a mention.
77 let mut start_chars = cursor_chars;
78 while start_chars > 0 {
79 let prev = chars[start_chars - 1];
80 if prev == '@' {
81 start_chars -= 1;
82 break;
83 }
84 if prev.is_whitespace() {
85 return None;
86 }
87 start_chars -= 1;
88 }
89 if start_chars == cursor_chars || chars.get(start_chars) != Some(&'@') {
90 return None;
91 }
92 // Confirm the `@` itself is at a valid mention boundary.
93 if !is_file_mention_start(&chars, start_chars) {
94 return None;
95 }
96 // Consume from the `@` to the next whitespace (the end of the token).
97 let mut end_chars = start_chars + 1;
98 while end_chars < chars.len() && !chars[end_chars].is_whitespace() {
99 end_chars += 1;
100 }
101 let partial: String = chars[start_chars + 1..end_chars].iter().collect();
102 let byte_start: usize = chars[..start_chars].iter().map(|c| c.len_utf8()).sum();
103 Some((byte_start, partial))
104 }
105
106 /// Cwd-aware completion entry point. Shares its walker with the future
107 /// Ctrl+P fuzzy picker (#97); see [`Workspace::completions`] for the
108 /// ranking + display rules.
109 #[cfg(test)]
110 pub fn find_file_mention_completions(
111 workspace: &Workspace,
112 partial: &str,
113 limit: usize,
114 ) -> Vec<String> {
115 let entries = workspace.completions(partial, limit);
116 // #441: re-rank by frecency so files the user mentions a lot float up.
117 // Never-mentioned candidates fall back to the workspace ranker's order.
118 let entries = super::file_frecency::rerank_by_frecency(entries);
119 tracing::debug!(
120 target: "codewhale_tui::file_mention",
121 partial = %partial,
122 workspace = %workspace.root.display(),
123 cwd = ?std::env::current_dir().ok(),
124 match_count = entries.len(),
125 "file mention completion walk",
126 );
127 entries
128 }
129
130 /// Resolve the `@`-mention completion popup contents for the current
131 /// composer state. Returns an empty `Vec` when:
132 ///
133 /// - The popup is suppressed (`app.mention_menu_hidden`).
134 /// - The cursor is not inside an `@<partial>` token.
135 /// - The workspace walk produced no candidates.
136 ///
137 /// Mirrors `visible_slash_menu_entries` so the composer widget can treat
138 /// both menus identically (one `Vec<String>` of entries, one selected index).
139 ///
140 /// Once the composer widget is extended to render this as a popup, it will
141 /// pair with `apply_mention_menu_selection` for the Up/Down/Enter flow.
142 #[must_use]
143 pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec<String> {
144 if app.mention_menu_hidden {
145 app.composer.mention_discovery.cancel();
146 return Vec::new();
147 }
148 let Some((_byte_start, partial)) =
149 partial_file_mention_at_cursor(&app.input, app.cursor_position)
150 else {
151 app.composer.mention_discovery.cancel();
152 return Vec::new();
153 };
154 if limit == 0 {
155 app.composer.mention_discovery.cancel();
156 return Vec::new();
157 }
158
159 mention_menu_entries(app, &partial, limit).0
160 }
161
162 /// Drain a completed discovery result without waiting. The event loop calls
163 /// this once per tick so a finished scan repaints the popup even when the user
164 /// has stopped typing.
165 pub(crate) fn poll_background_mention_discovery(app: &mut App) -> bool {
166 if app.composer.mention_discovery.poll() {
167 app.composer.mention_completion_cache = None;
168 return true;
169 }
170 false
171 }
172
173 /// Return `(entries, ready)`. `ready = false` means discovery is still in the
174 /// background; callers must not misreport that temporary empty result as "no
175 /// matches".
176 fn mention_menu_entries(app: &mut App, partial: &str, limit: usize) -> (Vec<String>, bool) {
177 if poll_background_mention_discovery(app) {
178 app.needs_redraw = true;
179 }
180
181 let workspace = app.workspace.clone();
182 let cwd = app.composer.mention_cwd.clone();
183 let walk_depth = app.mention_walk_depth;
184 let behavior = app.mention_menu_behavior.clone();
185 let follow_links = app.workspace_follow_symlinks;
186 let discovery_key = if behavior == "browser" {
187 MentionDiscoveryKey::browser(
188 workspace.clone(),
189 cwd.clone(),
190 walk_depth,
191 follow_links,
192 partial.to_string(),
193 )
194 } else {
195 MentionDiscoveryKey::fuzzy(workspace.clone(), cwd.clone(), walk_depth, follow_links)
196 };
197 app.composer
198 .mention_discovery
199 .ensure_requested(discovery_key.clone());
200
201 if let Some(ref cache) = app.composer.mention_completion_cache
202 && cache.workspace == workspace
203 && cache.cwd == cwd
204 && cache.partial == partial
205 && cache.limit == limit
206 && cache.walk_depth == walk_depth
207 && cache.behavior == behavior
208 && cache.follow_links == follow_links
209 {
210 return (cache.entries.clone(), true);
211 }
212
213 let Some(candidates) = app
214 .composer
215 .mention_discovery
216 .cached_entries(&discovery_key)
217 else {
218 return (Vec::new(), false);
219 };
220 let entries = match &discovery_key.behavior {
221 MentionDiscoveryBehavior::Fuzzy => {
222 let ranked = crate::working_set::rank_completion_candidates(candidates, partial, limit);
223 super::file_frecency::rerank_by_frecency(ranked)
224 }
225 MentionDiscoveryBehavior::Browser { .. } => {
226 candidates.iter().take(limit).cloned().collect()
227 }
228 };
229
230 let entries = with_git_mention_entries(entries, partial, limit);
231
232 app.composer.mention_completion_cache = Some(MentionCompletionCache {
233 workspace,
234 cwd,
235 partial: partial.to_string(),
236 limit,
237 walk_depth,
238 behavior,
239 follow_links,
240 entries: entries.clone(),
241 });
242
243 (entries, true)
244 }
245
246 /// Prepend the `@git` / `@diff` tokens that prefix-match `partial` to the path
247 /// completions, so curated git context is discoverable from the same menu as
248 /// files (#4067). They lead because a two-entry prefix match is what the user
249 /// meant when they typed `gi` or `di`, and paths still fill the rest.
250 ///
251 /// A bare `@` is deliberately left alone: that menu is the file picker, and
252 /// pushing two fixed tokens above every path would cost a slot on every
253 /// mention the user makes. The tokens appear as soon as a matching character
254 /// is typed.
255 fn with_git_mention_entries(entries: Vec<String>, partial: &str, limit: usize) -> Vec<String> {
256 // `mention_menu_limit = 0` is a documented way to disable the popup
257 // entirely. The git tokens are menu entries like any other and must
258 // respect the same cap, or setting 0 would still pop a one-entry menu.
259 if limit == 0 {
260 return Vec::new();
261 }
262 let needle = partial.trim().to_lowercase();
263 if needle.is_empty() {
264 return entries;
265 }
266 let matching: Vec<String> = GitMentionKind::iter_all()
267 .filter(|kind| kind.token().starts_with(&needle))
268 .map(|kind| kind.token().to_string())
269 .collect();
270 if matching.is_empty() {
271 return entries;
272 }
273 let mut combined = matching;
274 combined.truncate(limit);
275 for entry in entries {
276 if combined.len() >= limit {
277 break;
278 }
279 if !combined.contains(&entry) {
280 combined.push(entry);
281 }
282 }
283 combined
284 }
285
286 /// Apply the currently selected `@`-mention popup entry to the composer
287 /// input, splicing it in place of the `@<partial>` token at the cursor.
288 /// Returns `true` if a substitution occurred.
289 ///
290 /// Designed to be invoked by the same keybinding that drives
291 /// `apply_slash_menu_selection` (Enter / Tab); the caller is responsible
292 /// for choosing which menu is "active" based on cursor context.
293 pub fn apply_mention_menu_selection(app: &mut App, entries: &[String]) -> bool {
294 if entries.is_empty() {
295 return false;
296 }
297 let Some((byte_start, partial)) =
298 partial_file_mention_at_cursor(&app.input, app.cursor_position)
299 else {
300 return false;
301 };
302 let selected_idx = app
303 .mention_menu_selected
304 .min(entries.len().saturating_sub(1));
305 let replacement = &entries[selected_idx];
306 // #441: bump this path's frecency before we splice it in. The store
307 // persists asynchronously, so this never blocks input handling.
308 super::file_frecency::record_mention(replacement);
309 replace_file_mention(app, byte_start, &partial, replacement);
310 app.mention_menu_hidden = false;
311 app.status_message = Some(format!("Attached @{replacement}"));
312 true
313 }
314
315 /// Tab-completion handler for `@file` mentions. Mirrors the slash-command
316 /// flow: a single match is applied directly; multiple matches with a longer
317 /// shared prefix extend the partial; otherwise the first few candidates are
318 /// surfaced via the status line. Returns true when the input was modified or
319 /// a suggestion was offered, so the caller can short-circuit other handlers.
320 pub fn try_autocomplete_file_mention(app: &mut App) -> bool {
321 let Some((byte_start, partial)) =
322 partial_file_mention_at_cursor(&app.input, app.cursor_position)
323 else {
324 return false;
325 };
326 let (candidates, ready) = mention_menu_entries(app, &partial, FILE_MENTION_COMPLETION_LIMIT);
327 if !ready {
328 return true;
329 }
330 if candidates.is_empty() {
331 app.status_message = Some(no_file_mention_matches_status(
332 &partial,
333 app.mention_walk_depth,
334 ));
335 return true;
336 }
337 if candidates.len() == 1 {
338 // #441: a unique-match completion is also a "mention" for ranking.
339 super::file_frecency::record_mention(&candidates[0]);
340 replace_file_mention(app, byte_start, &partial, &candidates[0]);
341 app.status_message = Some(format!("Attached @{}", candidates[0]));
342 return true;
343 }
344 let candidate_refs: Vec<&str> = candidates.iter().map(String::as_str).collect();
345 let shared = longest_common_prefix(&candidate_refs);
346 if shared.len() > partial.len() {
347 replace_file_mention(app, byte_start, &partial, shared);
348 app.status_message = Some(format!("@{shared}…"));
349 return true;
350 }
351 let preview = candidates
352 .iter()
353 .take(5)
354 .map(|c| format!("@{c}"))
355 .collect::<Vec<_>>()
356 .join(", ");
357 app.status_message = Some(format!("Matches: {preview}"));
358 true
359 }
360
361 fn no_file_mention_matches_status(partial: &str, walk_depth: usize) -> String {
362 if path_partial_reaches_walk_depth(partial, walk_depth) {
363 format!(
364 "No files match @{partial} (mention_walk_depth={walk_depth}; use /config set mention_walk_depth 0 to search deeper)"
365 )
366 } else {
367 format!("No files match @{partial}")
368 }
369 }
370
371 fn path_partial_reaches_walk_depth(partial: &str, walk_depth: usize) -> bool {
372 if walk_depth == 0 {
373 return false;
374 }
375 let component_count = partial
376 .split(['/', '\\'])
377 .filter(|component| !component.is_empty())
378 .count();
379 component_count >= walk_depth
380 }
381
382 /// Splice a completion into the input, replacing the `@<partial>` token at
383 /// `byte_start` with `@<replacement>`. Cursor moves to the end of the new
384 /// token so further keystrokes extend (or escape via space) naturally.
385 fn replace_file_mention(app: &mut App, byte_start: usize, partial: &str, replacement: &str) {
386 let original_token_len = '@'.len_utf8() + partial.len();
387 let original_token_end = byte_start + original_token_len;
388 let mut new_input =
389 String::with_capacity(app.input.len() - original_token_len + 1 + replacement.len());
390 new_input.push_str(&app.input[..byte_start]);
391 new_input.push('@');
392 new_input.push_str(replacement);
393 if original_token_end < app.input.len() {
394 new_input.push_str(&app.input[original_token_end..]);
395 }
396 let new_cursor_chars =
397 app.input[..byte_start].chars().count() + 1 + replacement.chars().count();
398 app.input = new_input;
399 app.cursor_position = new_cursor_chars;
400 }
401
402 pub fn longest_common_prefix<'a>(values: &[&'a str]) -> &'a str {
403 let Some(first) = values.first().copied() else {
404 return "";
405 };
406 let mut end = first.len();
407
408 for value in values.iter().skip(1) {
409 while end > 0 && !value.starts_with(&first[..end]) {
410 end -= 1;
411 // Ensure we land on a valid UTF-8 char boundary.
412 while end > 0 && !first.is_char_boundary(end) {
413 end -= 1;
414 }
415 }
416 if end == 0 {
417 return "";
418 }
419 }
420
421 &first[..end]
422 }
423
424 // ---------------------------------------------------------------------------
425 // Expansion at send-time
426 // ---------------------------------------------------------------------------
427
428 /// Append a "Local context from @mentions" block to the user's message when
429 /// any `@path` references are present. Returns the input unchanged when
430 /// there are none.
431 ///
432 /// `cwd` carries the user's launch directory and drives the second
433 /// resolution pass (issue #101): relative `@<path>` mentions resolve under
434 /// `cwd` when `workspace.join(path)` doesn't exist, so the user's mental
435 /// anchor (their shell's pwd) wins when it diverges from `--workspace`.
436 /// Pass `None` to disable the cwd pass entirely (workspace-only).
437 ///
438 /// Resolution here never walks the tree on submit (#4365). A miss on the
439 /// exact two-pass lookup falls back to a bounded, unique-match-only search of
440 /// the composer's already-built background completion index
441 /// (`completion_index`, when the caller has one cached); the winning
442 /// candidate must still exist on disk. Ambiguous, stale, or absent matches
443 /// stay an honest `<missing-file>` that names only what the user typed —
444 /// never a fabricated workspace-root path.
445 ///
446 /// Convenience wrapper that allocates a throwaway cache. Test-only: the real
447 /// send paths share one cache across the references and payload passes.
448 #[cfg(test)]
449 pub fn user_request_with_file_mentions(
450 input: &str,
451 workspace: &Path,
452 cwd: Option<PathBuf>,
453 ) -> String {
454 user_request_with_file_mentions_cached(
455 input,
456 workspace,
457 cwd,
458 &mut GitMentionCache::default(),
459 None,
460 )
461 }
462
463 pub fn user_request_with_file_mentions_cached(
464 input: &str,
465 workspace: &Path,
466 cwd: Option<PathBuf>,
467 git_cache: &mut GitMentionCache,
468 completion_index: Option<&[String]>,
469 ) -> String {
470 let Some(context) =
471 local_context_from_file_mentions(input, workspace, cwd, git_cache, completion_index)
472 else {
473 return input.to_string();
474 };
475 format!("{input}\n\n---\n\nLocal context from @mentions:\n{context}")
476 }
477
478 #[must_use]
479 pub fn pending_context_previews(input: &str) -> Vec<FileMentionPreview> {
480 let mut previews = Vec::new();
481 let mut seen = std::collections::HashSet::new();
482 for mention in extract_file_mentions(input)
483 .into_iter()
484 .take(MAX_FILE_MENTIONS_PER_MESSAGE)
485 {
486 if !seen.insert(mention.clone()) {
487 continue;
488 }
489 // Composer previews stay lexical (no git subprocess from the render
490 // loop, same rule as #4365 for path stats); the payload is resolved
491 // once at submit time.
492 if let Some(kind) = git_mention::git_mention_kind(&mention) {
493 previews.push(FileMentionPreview {
494 kind: "git".to_string(),
495 label: kind.label().to_string(),
496 detail: Some("resolved on send".to_string()),
497 included: false,
498 removable: false,
499 });
500 continue;
501 }
502 let media = is_media_path(Path::new(&mention));
503 previews.push(FileMentionPreview {
504 kind: if media { "media" } else { "mention" }.to_string(),
505 label: mention,
506 detail: Some(if media {
507 "use /attach for media bytes".to_string()
508 } else {
509 "resolved on send".to_string()
510 }),
511 // Lexical preview deliberately does not stat the path while the
512 // user types. Exact inclusion/missing metadata is resolved once,
513 // at submit time, rather than from the render loop (#4365).
514 included: false,
515 removable: false,
516 });
517 }
518
519 for attachment in extract_media_attachment_references(input) {
520 previews.push(FileMentionPreview {
521 kind: attachment.kind,
522 label: attachment.path,
523 detail: Some("attached media".to_string()),
524 included: true,
525 removable: true,
526 });
527 }
528 previews
529 }
530
531 /// Convenience wrapper that allocates a throwaway cache. Test-only, as above.
532 #[cfg(test)]
533 #[must_use]
534 pub fn context_references_from_input(
535 input: &str,
536 workspace: &Path,
537 cwd: Option<PathBuf>,
538 ) -> Vec<ContextReference> {
539 context_references_from_input_cached(
540 input,
541 workspace,
542 cwd,
543 &mut GitMentionCache::default(),
544 None,
545 )
546 }
547
548 #[must_use]
549 pub fn context_references_from_input_cached(
550 input: &str,
551 workspace: &Path,
552 cwd: Option<PathBuf>,
553 git_cache: &mut GitMentionCache,
554 completion_index: Option<&[String]>,
555 ) -> Vec<ContextReference> {
556 let mut references = Vec::new();
557 let mut seen = std::collections::HashSet::new();
558 let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd);
559
560 for mention in extract_file_mentions(input)
561 .into_iter()
562 .take(MAX_FILE_MENTIONS_PER_MESSAGE)
563 {
564 // Git mentions resolve against the working tree, not the path index,
565 // so the inspector reports their real size and budget (#4067).
566 if let Some(kind) = git_mention::git_mention_kind(&mention) {
567 let payload = git_cache.resolve(kind, workspace).clone();
568 let detail = match payload.unavailable_reason.as_deref() {
569 Some(reason) => format!("{}, {reason}", kind.label()),
570 None if payload.truncated => format!(
571 "{}, {} bytes truncated at {} budget",
572 kind.label(),
573 payload.bytes,
574 kind.byte_budget()
575 ),
576 None => format!("{}, {} bytes", kind.label(), payload.bytes),
577 };
578 let reference = ContextReference {
579 kind: ContextReferenceKind::GitContext,
580 source: ContextReferenceSource::AtMention,
581 badge: "git".to_string(),
582 label: kind.token().to_string(),
583 target: workspace.display().to_string(),
584 included: payload.unavailable_reason.is_none(),
585 expanded: false,
586 detail: Some(detail),
587 };
588 if seen.insert(format!("git-mention:{}", kind.token())) {
589 references.push(reference);
590 }
591 continue;
592 }
593
594 let (path, display_path, exists) =
595 resolve_mention_for_send(&ws, &mention, completion_index);
596 let reference = context_reference_for_mention(&mention, &path, &display_path, exists);
597 if !seen.insert(format!(
598 "{:?}:{:?}:{}:{}",
599 reference.source, reference.kind, reference.target, reference.label
600 )) {
601 continue;
602 }
603 references.push(reference);
604 }
605
606 for reference in extract_media_attachment_references(input) {
607 let context_reference = ContextReference {
608 kind: ContextReferenceKind::MediaAttachment,
609 source: ContextReferenceSource::Attachment,
610 badge: reference.kind,
611 label: reference.path.clone(),
612 target: reference.path,
613 included: true,
614 expanded: false,
615 detail: Some("attached media".to_string()),
616 };
617 if !seen.insert(format!(
618 "{:?}:{:?}:{}:{}",
619 context_reference.source,
620 context_reference.kind,
621 context_reference.target,
622 context_reference.label
623 )) {
624 continue;
625 }
626 references.push(context_reference);
627 }
628
629 references
630 }
631
632 fn context_reference_for_mention(
633 raw: &str,
634 path: &Path,
635 display_path: &str,
636 exists: bool,
637 ) -> ContextReference {
638 if !exists {
639 return ContextReference {
640 kind: ContextReferenceKind::Missing,
641 source: ContextReferenceSource::AtMention,
642 badge: "missing".to_string(),
643 label: raw.to_string(),
644 // No resolved target exists; naming the workspace-root guess here
645 // would present a path we already know is wrong.
646 target: raw.to_string(),
647 included: false,
648 expanded: false,
649 detail: Some("not found".to_string()),
650 };
651 }
652 if path.is_dir() {
653 return ContextReference {
654 kind: ContextReferenceKind::Directory,
655 source: ContextReferenceSource::AtMention,
656 badge: "dir".to_string(),
657 label: raw.to_string(),
658 target: display_path.to_string(),
659 included: true,
660 expanded: true,
661 detail: Some("directory listing".to_string()),
662 };
663 }
664 if !path.is_file() {
665 return ContextReference {
666 kind: ContextReferenceKind::Unsupported,
667 source: ContextReferenceSource::AtMention,
668 badge: "skipped".to_string(),
669 label: raw.to_string(),
670 target: display_path.to_string(),
671 included: false,
672 expanded: false,
673 detail: Some("unsupported path".to_string()),
674 };
675 }
676 if is_media_path(path) {
677 return ContextReference {
678 kind: ContextReferenceKind::MediaMention,
679 source: ContextReferenceSource::AtMention,
680 badge: "media".to_string(),
681 label: raw.to_string(),
682 target: display_path.to_string(),
683 included: false,
684 expanded: false,
685 detail: Some("use /attach for media bytes".to_string()),
686 };
687 }
688
689 let detail = match std::fs::metadata(path) {
690 Ok(metadata) if metadata.len() > MAX_MENTION_FILE_BYTES => {
691 Some("included truncated".to_string())
692 }
693 Ok(_) => Some("included".to_string()),
694 Err(err) => Some(format!("metadata: {err}")),
695 };
696
697 ContextReference {
698 kind: ContextReferenceKind::File,
699 source: ContextReferenceSource::AtMention,
700 badge: "file".to_string(),
701 label: raw.to_string(),
702 target: display_path.to_string(),
703 included: true,
704 expanded: true,
705 detail: detail.or_else(|| Some(display_path.to_string())),
706 }
707 }
708
709 fn extract_media_attachment_references(input: &str) -> Vec<MediaAttachmentReference> {
710 media_attachment_references(input)
711 }
712
713 // ---------------------------------------------------------------------------
714 // macOS screencapture-temp stabilization
715 // ---------------------------------------------------------------------------
716 //
717 // macOS parks dragged-out screenshots under a per-capture temp directory like
718 // `/var/folders/…/T/Temporary Items/NSIRD_screencaptureui_XXXX/` and deletes
719 // it minutes later. Inbound references to such files are copied to a stable
720 // directory the moment the message is received, so the agent later reads a
721 // path that still exists.
722
723 /// Marker fragments of the macOS screencapture temp directory layout.
724 const SCREENCAPTURE_TEMP_DIR_MARKERS: [&str; 2] = ["Temporary Items", "screencaptureui"];
725
726 /// Stable per-session directory for stabilized screencapture files. Follows
727 /// the same home-first convention as `clipboard.rs`'s clipboard-images dir.
728 pub(crate) fn screenshot_stabilization_dir(workspace: &Path) -> PathBuf {
729 match crate::config::effective_home_dir() {
730 Some(home) => home.join(".codewhale").join("attachments"),
731 None => workspace.join("attachments"),
732 }
733 }
734
735 /// Whether a path lives under a macOS screencapture "Temporary Items" dir:
736 /// it must have a `Temporary Items` component and a `screencaptureui`-named
737 /// component (e.g. `NSIRD_screencaptureui_XXXX`), so ordinary user paths are
738 /// never touched even when their names contain either fragment.
739 fn is_screencapture_temp_path(path: &Path) -> bool {
740 let components: Vec<String> = path
741 .components()
742 .map(|c| c.as_os_str().to_string_lossy().into_owned())
743 .collect();
744 components
745 .iter()
746 .any(|c| c == SCREENCAPTURE_TEMP_DIR_MARKERS[0])
747 && components
748 .iter()
749 .any(|c| c.contains(SCREENCAPTURE_TEMP_DIR_MARKERS[1]))
750 }
751
752 /// The `[Attached …]` parser splits at " at ", so a stable copy must not
753 /// reintroduce that separator in its name.
754 fn stable_attachment_name(file_name: &std::ffi::OsStr) -> String {
755 file_name.to_string_lossy().replace(" at ", "-")
756 }
757
758 /// Copy a screencapture temp file to `artifact_dir` and return the stable
759 /// destination. Returns `None` when the path is not a screencapture temp
760 /// file, not a regular file, or the copy fails — callers keep the original
761 /// reference then. Idempotent: an existing destination is reused without a
762 /// second copy.
763 fn stabilize_screencapture_file(path: &Path, artifact_dir: &Path) -> Option<PathBuf> {
764 if !is_screencapture_temp_path(path) || !path.is_file() {
765 return None;
766 }
767 let dest = artifact_dir.join(stable_attachment_name(path.file_name()?));
768 if !dest.exists()
769 && (std::fs::create_dir_all(artifact_dir).is_err() || std::fs::copy(path, &dest).is_err())
770 {
771 return None;
772 }
773 Some(dest)
774 }
775
776 /// A path reference found in inbound text: its byte span, the path text, and
777 /// how it was carried (`@`-mention prefix and/or surrounding quotes).
778 struct ScreencaptureCandidate {
779 byte_start: usize,
780 byte_end: usize,
781 path: String,
782 quote: Option<char>,
783 mention: bool,
784 }
785
786 /// Reconstruct a path that an unquoted paste split across whitespace tokens
787 /// (the "Temporary Items" component contains a space). Returns the char span
788 /// and joined path of the window that names an existing screencapture temp
789 /// file, bounded to a handful of tokens either side of `seed`.
790 fn screencapture_window(
791 chars: &[char],
792 tokens: &[(usize, usize)],
793 seed: usize,
794 ) -> Option<(usize, usize, String)> {
795 let max_span = tokens.len().min(12);
796 for left in 0..=seed.min(max_span) {
797 for right in 0..=max_span {
798 let end = (seed + right).min(tokens.len() - 1);
799 let (mut ws, mut we) = (tokens[seed - left].0, tokens[end].1);
800 let raw: String = chars[ws..we].iter().collect();
801 // Trim leading delimiters and trailing punctuation against the
802 // raw span (advancing both ends), then collapse interior
803 // whitespace runs so the probe path matches the file.
804 let lead = raw
805 .chars()
806 .take_while(|&ch| matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '@'))
807 .count();
808 let trimmed = trim_unquoted_mention(&raw);
809 ws += lead;
810 we -= raw.chars().count() - trimmed.chars().count();
811 let joined = trimmed
812 .chars()
813 .skip(lead)
814 .collect::<String>()
815 .split_whitespace()
816 .collect::<Vec<_>>()
817 .join(" ");
818 let path = Path::new(&joined);
819 if is_screencapture_temp_path(path) && path.is_file() {
820 return Some((ws, we, joined));
821 }
822 }
823 }
824 None
825 }
826
827 /// Rewrite every inbound reference to a macOS screencapture temp file to a
828 /// stable copy under `artifact_dir`. Non-matching references — not a
829 /// screencapture path, missing, or an unresolvable copy — are left untouched;
830 /// the function never fails and never changes the message otherwise.
831 pub(crate) fn stabilize_screenshot_references(input: &str, artifact_dir: &Path) -> String {
832 let chars: Vec<char> = input.chars().collect();
833 let offsets: Vec<usize> = input.char_indices().map(|(i, _)| i).collect();
834 let mut candidates: Vec<ScreencaptureCandidate> = Vec::new();
835 let mut i = 0;
836 while i < chars.len() {
837 match chars[i] {
838 // `@"quoted path"` / `@bare/path` mentions (mirror `extract_file_mentions`).
839 '@' if is_file_mention_start(&chars, i) => {
840 let byte_start = offsets[i];
841 if let Some(quote @ ('"' | '\'')) = chars.get(i + 1).copied()
842 && let Some(rel) = chars[i + 2..].iter().position(|&ch| ch == quote)
843 {
844 let end = i + 2 + rel;
845 let path: String = chars[i + 2..end].iter().collect();
846 if !path.trim().is_empty() {
847 candidates.push(ScreencaptureCandidate {
848 byte_start,
849 byte_end: offsets.get(end + 1).copied().unwrap_or(input.len()),
850 path: path.trim().to_string(),
851 quote: Some(quote),
852 mention: true,
853 });
854 }
855 i = end + 1;
856 } else {
857 let mut end = i + 1;
858 while end < chars.len() && !chars[end].is_whitespace() {
859 end += 1;
860 }
861 let raw: String = chars[i + 1..end].iter().collect();
862 let trimmed = trim_unquoted_mention(&raw);
863 if !trimmed.is_empty() {
864 candidates.push(ScreencaptureCandidate {
865 byte_start,
866 byte_end: offsets.get(end).copied().unwrap_or(input.len()),
867 path: trimmed.to_string(),
868 quote: None,
869 mention: true,
870 });
871 }
872 i = end;
873 }
874 }
875 // Quoted strings: terminals quote drag-dropped paths with spaces.
876 // A closing quote is only sought on the same line, so a lone
877 // apostrophe in prose never swallows the rest of the message.
878 quote @ ('"' | '\'') => {
879 if let Some(rel) = chars[i + 1..]
880 .iter()
881 .take_while(|&&ch| ch != '\n')
882 .position(|&ch| ch == quote)
883 {
884 let end = i + 1 + rel;
885 let path: String = chars[i + 1..end].iter().collect();
886 if !path.trim().is_empty() {
887 candidates.push(ScreencaptureCandidate {
888 byte_start: offsets[i],
889 byte_end: offsets.get(end + 1).copied().unwrap_or(input.len()),
890 path: path.trim().to_string(),
891 quote: Some(quote),
892 mention: false,
893 });
894 }
895 i = end + 1;
896 } else {
897 i += 1;
898 }
899 }
900 _ => i += 1,
901 }
902 }
903
904 // Bare unquoted pastes: rebuild the path across whitespace tokens.
905 let tokens: Vec<(usize, usize)> = {
906 let mut tokens = Vec::new();
907 let mut start = None;
908 for (idx, ch) in chars.iter().enumerate() {
909 match (ch.is_whitespace(), start) {
910 (true, Some(s)) => {
911 tokens.push((s, idx));
912 start = None;
913 }
914 (false, None) => start = Some(idx),
915 _ => {}
916 }
917 }
918 if let Some(s) = start {
919 tokens.push((s, chars.len()));
920 }
921 tokens
922 };
923 for (seed, &(token_start, token_end)) in tokens.iter().enumerate() {
924 let token: String = chars[token_start..token_end].iter().collect();
925 if !(token.contains("screencaptureui")
926 || token.contains("Temporary")
927 || token.contains("Items"))
928 {
929 continue;
930 }
931 let Some((ws, we, path)) = screencapture_window(&chars, &tokens, seed) else {
932 continue;
933 };
934 let byte_end = offsets.get(we).copied().unwrap_or(input.len());
935 // Only suppress the window when it overlaps a candidate that is
936 // itself a confirmed screencapture temp file (that candidate will
937 // rewrite it); prose quoted with `'` never blocks a real reference.
938 let confirmed = |c: &ScreencaptureCandidate| {
939 is_screencapture_temp_path(Path::new(&c.path)) && Path::new(&c.path).is_file()
940 };
941 let blocked = candidates
942 .iter()
943 .any(|c| c.byte_start < byte_end && offsets[ws] < c.byte_end && confirmed(c));
944 if !blocked {
945 candidates.push(ScreencaptureCandidate {
946 byte_start: offsets[ws],
947 byte_end,
948 path,
949 quote: None,
950 mention: false,
951 });
952 }
953 }
954
955 // Apply last-to-first so byte spans stay valid.
956 candidates.sort_by_key(|c| c.byte_start);
957 let mut output = input.to_string();
958 for candidate in candidates.iter().rev() {
959 let Some(dest) = stabilize_screencapture_file(Path::new(&candidate.path), artifact_dir)
960 else {
961 continue;
962 };
963 let stable = dest.to_string_lossy();
964 let mut replacement = String::new();
965 if candidate.mention {
966 replacement.push('@');
967 }
968 if let Some(quote) = candidate.quote {
969 replacement.push(quote);
970 }
971 replacement.push_str(stable.as_ref());
972 if let Some(quote) = candidate.quote {
973 replacement.push(quote);
974 }
975 output.replace_range(candidate.byte_start..candidate.byte_end, &replacement);
976 }
977 output
978 }
979
980 fn local_context_from_file_mentions(
981 input: &str,
982 workspace: &Path,
983 cwd: Option<PathBuf>,
984 git_cache: &mut GitMentionCache,
985 completion_index: Option<&[String]>,
986 ) -> Option<String> {
987 let mentions = extract_file_mentions(input);
988 if mentions.is_empty() {
989 return None;
990 }
991
992 let mut blocks = Vec::new();
993 let mut seen = std::collections::HashSet::new();
994 let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd);
995
996 for mention in mentions.into_iter().take(MAX_FILE_MENTIONS_PER_MESSAGE) {
997 // `@git` / `@diff` resolve to curated git context, not to a path, so
998 // they short-circuit before any workspace path resolution (#4067).
999 if let Some(kind) = git_mention::git_mention_kind(&mention) {
1000 if !seen.insert(format!("git-mention:{}", kind.token())) {
1001 continue;
1002 }
1003 blocks.push(git_cache.resolve(kind, workspace).block.clone());
1004 continue;
1005 }
1006
1007 // `@path:START-END` attaches a line range of a file (issue #5550).
1008 // Exact resolution wins first: a file that literally contains a colon
1009 // is treated as its full self. Only a genuine miss re-reads the token
1010 // as `path:START-END`.
1011 let mut range = None;
1012 let mut mention_path = mention.as_str();
1013 // `Workspace::resolve_exact` already returns absolute paths when the root
1014 // is absolute (TUI always runs from an absolute workspace), so we
1015 // skip `canonicalize()` here — it's per-mention I/O on the
1016 // message-send hot path. Accept the rare symlink-aliasing dedup
1017 // miss as the cost of avoiding a syscall (Gemini code-review).
1018 let (mut path, mut display_path, mut exists) =
1019 resolve_mention_for_send(&ws, mention_path, completion_index);
1020 if !exists && let Some((path_part, parsed)) = split_mention_range(&mention) {
1021 let (ranged_path, ranged_display, ranged_exists) =
1022 resolve_mention_for_send(&ws, path_part, completion_index);
1023 if ranged_exists {
1024 range = Some(parsed);
1025 mention_path = path_part;
1026 (path, display_path, exists) = (ranged_path, ranged_display, ranged_exists);
1027 }
1028 }
1029 tracing::debug!(
1030 target: "codewhale_tui::file_mention",
1031 raw_typed = %mention,
1032 workspace = %workspace.display(),
1033 cwd = ?std::env::current_dir().ok(),
1034 resolved = %display_path,
1035 exists,
1036 "file mention resolution",
1037 );
1038
1039 // Gate every block — including <missing-file> — through the dedup
1040 // set so a user typing the same non-existent file twice doesn't
1041 // waste tokens on duplicate missing-file blocks (Devin code-review).
1042 // Missing mentions dedup on the typed token: there is no resolved
1043 // path to key on, and the workspace-root guess must not become one.
1044 let dedup_key = if exists {
1045 display_path.clone()
1046 } else {
1047 format!("missing:{mention}")
1048 };
1049 if !seen.insert(dedup_key) {
1050 continue;
1051 }
1052
1053 if exists {
1054 blocks.push(render_file_mention_context(
1055 mention_path,
1056 &path,
1057 &display_path,
1058 range,
1059 ));
1060 } else {
1061 // Honest miss: name only what the user typed. Emitting the
1062 // workspace-root join as `path=` presented a non-existent file
1063 // as if it were the resolved target.
1064 blocks.push(format!("<missing-file mention=\"@{mention}\" />"));
1065 }
1066 }
1067
1068 if blocks.is_empty() {
1069 None
1070 } else {
1071 Some(blocks.join("\n\n"))
1072 }
1073 }
1074
1075 /// Endpoints of a `@path:START-END` mention (1-based, inclusive).
1076 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1077 pub(crate) struct FileRange {
1078 pub start: u32,
1079 pub end: u32,
1080 }
1081
1082 /// Split a trailing `:START-END` range off a mention token.
1083 ///
1084 /// Only an exact `digits-digits` suffix after a non-empty path part is
1085 /// treated as a range, so `notes.txt`, `x:1`, `x:a-b`, `:1-2`, and Windows
1086 /// `C:\\dir\\file` (digits requirement) all stay whole. The caller must have
1087 /// already failed an exact path resolution for the full token before calling.
1088 fn split_mention_range(raw: &str) -> Option<(&str, FileRange)> {
1089 let (path_part, range_part) = raw.rsplit_once(':')?;
1090 if path_part.is_empty() {
1091 return None;
1092 }
1093 let (start, end) = range_part.split_once('-')?;
1094 if start.is_empty()
1095 || end.is_empty()
1096 || !start.bytes().all(|b| b.is_ascii_digit())
1097 || !end.bytes().all(|b| b.is_ascii_digit())
1098 || start.len() > 5
1099 || end.len() > 5
1100 {
1101 return None;
1102 }
1103 let start: u32 = start.parse().ok()?;
1104 let end: u32 = end.parse().ok()?;
1105 if start == 0 || end < start {
1106 return None;
1107 }
1108 Some((path_part, FileRange { start, end }))
1109 }
1110
1111 /// Send-time mention resolution: exact two-pass lookup first (workspace root,
1112 /// then launch cwd), then a bounded fallback against the composer's cached
1113 /// background completion index. Returns the path, its display form, and
1114 /// whether it exists. On a miss the returned path is the workspace-root guess
1115 /// — callers must not present it as resolved when `exists` is false.
1116 fn resolve_mention_for_send(
1117 ws: &Workspace,
1118 mention: &str,
1119 completion_index: Option<&[String]>,
1120 ) -> (PathBuf, String, bool) {
1121 let guess = match ws.resolve_exact(mention) {
1122 Ok(path) => {
1123 let display = path.display().to_string();
1124 return (path, display, true);
1125 }
1126 Err(guess) => guess,
1127 };
1128 if let Some(resolved) = completion_index
1129 .and_then(|candidates| resolve_mention_in_completion_index(mention, candidates, ws))
1130 {
1131 let display = resolved.display().to_string();
1132 return (resolved, display, true);
1133 }
1134 let display = guess.display().to_string();
1135 (guess, display, false)
1136 }
1137
1138 /// Bounded send-time fallback for `@`-mention misses.
1139 ///
1140 /// #4365 keeps filesystem walks off the submit path, so instead of walking we
1141 /// match the typed token against the composer's already-built background
1142 /// completion index (workspace- or cwd-relative display strings). A candidate
1143 /// wins only when it is the *unique* path-suffix or basename match and the
1144 /// winning path still resolves on disk — the index may be a few seconds
1145 /// stale. Anything else (no hit, ambiguous hit, stale hit) returns `None` so
1146 /// the caller emits an honest `<missing-file>` instead of attaching an
1147 /// arbitrary same-name file from a nested directory.
1148 fn resolve_mention_in_completion_index(
1149 mention: &str,
1150 candidates: &[String],
1151 ws: &Workspace,
1152 ) -> Option<PathBuf> {
1153 // Absolute and home-anchored mentions name an exact location; a basename
1154 // lookalike elsewhere in the tree would be a different file, not a fix-up.
1155 // A leading separator is rooted on every platform, but `Path::is_absolute`
1156 // is false for `/foo` on Windows (no drive prefix), which would otherwise
1157 // let a rooted miss fall through to the index and attach an unrelated
1158 // same-name file. Test the root marker directly so the guard holds there.
1159 // `\` is a root marker only on Windows; on Unix it is an ordinary
1160 // (if unusual) leading filename character, so leave that case alone.
1161 let rooted = mention.starts_with('/') || (cfg!(windows) && mention.starts_with('\\'));
1162 if mention.starts_with('~') || rooted || Path::new(mention).is_absolute() {
1163 return None;
1164 }
1165 let needle = mention.replace('\\', "/");
1166 let needle = needle.trim_matches('/');
1167 if needle.is_empty() {
1168 return None;
1169 }
1170 let needle_lower = needle.to_lowercase();
1171 let basename_lower = needle_lower.rsplit('/').next()?;
1172
1173 let normalized = |candidate: &str| candidate.replace('\\', "/");
1174 let suffix_match = |candidate: &str| {
1175 let cand = normalized(candidate);
1176 let cand = cand.trim_end_matches('/').to_lowercase();
1177 !cand.is_empty() && (cand == needle_lower || cand.ends_with(&format!("/{needle_lower}")))
1178 };
1179 let basename_match = |candidate: &str| {
1180 let cand = normalized(candidate);
1181 let cand = cand.trim_end_matches('/').to_lowercase();
1182 !cand.is_empty() && cand.rsplit('/').next() == Some(basename_lower)
1183 };
1184
1185 // Prefer path-suffix hits: they carry the user's typed directory context.
1186 // Fall back to basename hits only when no suffix hit exists. Either way,
1187 // more than one distinct winner is ambiguous and resolves nothing.
1188 let predicates: [&dyn Fn(&str) -> bool; 2] = [&suffix_match, &basename_match];
1189 for predicate in predicates {
1190 let mut winner: Option<&str> = None;
1191 for candidate in candidates {
1192 if !predicate(candidate) {
1193 continue;
1194 }
1195 match winner {
1196 None => winner = Some(candidate.as_str()),
1197 Some(existing) if existing == candidate.as_str() => {}
1198 Some(_) => return None,
1199 }
1200 }
1201 if let Some(winner) = winner {
1202 // The index can be stale; only a path that still resolves exists.
1203 // Display strings are workspace- or cwd-relative, which
1204 // `resolve_exact` re-anchors exactly like a typed path.
1205 //
1206 // Rejoin the components with this platform's separator first.
1207 // Index strings are `/`-separated, and `root.join("ops/f.md")`
1208 // keeps that slash verbatim on Windows, yielding a mixed
1209 // `C:\ws\ops/f.md` that we then hand to the model and print in
1210 // the context inspector. Same path, inconsistent rendering.
1211 let native: PathBuf = winner.split('/').filter(|part| !part.is_empty()).collect();
1212 return ws.resolve_exact(&native.to_string_lossy()).ok();
1213 }
1214 }
1215 None
1216 }
1217
1218 fn extract_file_mentions(input: &str) -> Vec<String> {
1219 let chars: Vec<char> = input.chars().collect();
1220 let mut mentions = Vec::new();
1221 let mut idx = 0;
1222
1223 while idx < chars.len() {
1224 if chars[idx] != '@' || !is_file_mention_start(&chars, idx) {
1225 idx += 1;
1226 continue;
1227 }
1228
1229 let Some(next) = chars.get(idx + 1).copied() else {
1230 break;
1231 };
1232 if next.is_whitespace() {
1233 idx += 1;
1234 continue;
1235 }
1236
1237 if matches!(next, '"' | '\'') {
1238 let quote = next;
1239 let mut end = idx + 2;
1240 let mut raw = String::new();
1241 while end < chars.len() && chars[end] != quote {
1242 raw.push(chars[end]);
1243 end += 1;
1244 }
1245 if !raw.trim().is_empty() {
1246 mentions.push(raw.trim().to_string());
1247 }
1248 idx = end.saturating_add(1);
1249 continue;
1250 }
1251
1252 let mut end = idx + 1;
1253 let mut raw = String::new();
1254 while end < chars.len() && !chars[end].is_whitespace() {
1255 raw.push(chars[end]);
1256 end += 1;
1257 }
1258 let trimmed = trim_unquoted_mention(&raw);
1259 if !trimmed.is_empty() {
1260 mentions.push(trimmed.to_string());
1261 }
1262 idx = end;
1263 }
1264
1265 mentions
1266 }
1267
1268 fn is_file_mention_start(chars: &[char], idx: usize) -> bool {
1269 if idx == 0 {
1270 return true;
1271 }
1272 chars
1273 .get(idx.saturating_sub(1))
1274 .is_some_and(|ch| ch.is_whitespace() || matches!(ch, '(' | '[' | '{' | '<' | '"' | '\''))
1275 }
1276
1277 fn trim_unquoted_mention(raw: &str) -> &str {
1278 let mut trimmed = raw.trim();
1279 while trimmed.chars().count() > 1
1280 && trimmed
1281 .chars()
1282 .last()
1283 .is_some_and(|ch| matches!(ch, ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'))
1284 {
1285 trimmed = &trimmed[..trimmed.len() - trimmed.chars().last().unwrap().len_utf8()];
1286 }
1287 trimmed
1288 }
1289
1290 fn render_file_mention_context(
1291 raw: &str,
1292 path: &Path,
1293 display_path: &str,
1294 range: Option<FileRange>,
1295 ) -> String {
1296 if !path.exists() {
1297 return format!("<missing-file mention=\"@{raw}\" path=\"{display_path}\" />");
1298 }
1299 if path.is_dir() {
1300 return render_directory_mention_context(raw, path, display_path);
1301 }
1302 if !path.is_file() {
1303 return format!("<unsupported-path mention=\"@{raw}\" path=\"{display_path}\" />");
1304 }
1305 if is_media_path(path) {
1306 return format!(
1307 "<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>"
1308 );
1309 }
1310
1311 let range_attr = match range {
1312 Some(FileRange { start, end }) => format!(r#" lines="{start}-{end}""#),
1313 None => String::new(),
1314 };
1315 match read_file_content(path, range) {
1316 Ok((text, truncated, beyond_eof)) => {
1317 let truncated_attr = if truncated { " truncated=\"true\"" } else { "" };
1318 let beyond_attr = if beyond_eof {
1319 " beyond-eof=\"true\""
1320 } else {
1321 ""
1322 };
1323 format!(
1324 "<file mention=\"@{raw}\" path=\"{display_path}\"{range_attr}{truncated_attr}{beyond_attr}>\n{text}\n</file>"
1325 )
1326 }
1327 Err(err) => {
1328 format!(
1329 "<unreadable-file mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-file>"
1330 )
1331 }
1332 }
1333 }
1334
1335 fn render_directory_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
1336 let entries = match std::fs::read_dir(path) {
1337 Ok(entries) => entries,
1338 Err(err) => {
1339 return format!(
1340 "<unreadable-directory mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-directory>"
1341 );
1342 }
1343 };
1344
1345 let mut names = entries
1346 .filter_map(|entry| entry.ok())
1347 .map(|entry| {
1348 let marker = entry
1349 .file_type()
1350 .ok()
1351 .filter(|ty| ty.is_dir())
1352 .map_or("", |_| "/");
1353 format!("{}{}", entry.file_name().to_string_lossy(), marker)
1354 })
1355 .collect::<Vec<_>>();
1356 names.sort();
1357 let total = names.len();
1358 names.truncate(MAX_DIRECTORY_MENTION_ENTRIES);
1359 let mut body = names.join("\n");
1360 if total > MAX_DIRECTORY_MENTION_ENTRIES {
1361 let omitted = total - MAX_DIRECTORY_MENTION_ENTRIES;
1362 let _ = write!(body, "\n... {omitted} more entries");
1363 }
1364 format!("<directory mention=\"@{raw}\" path=\"{display_path}\">\n{body}\n</directory>")
1365 }
1366
1367 /// Bounded read of a mention's file content, optionally sliced to a line
1368 /// range. Returns `(text, truncated, beyond_eof)`: `truncated` mirrors the
1369 /// full-file byte bound; `beyond_eof` is set only when the requested range
1370 /// starts past the end of the file.
1371 fn read_file_content(
1372 path: &Path,
1373 range: Option<FileRange>,
1374 ) -> std::io::Result<(String, bool, bool)> {
1375 let (text, truncated) = read_text_prefix(path)?;
1376 let Some(FileRange { start, end }) = range else {
1377 return Ok((text, truncated, false));
1378 };
1379 let mut lines: Vec<&str> = text.split('\n').collect();
1380 if lines.last().copied() == Some("") {
1381 lines.pop();
1382 }
1383 let start_idx = usize::try_from(start.saturating_sub(1)).unwrap_or(usize::MAX);
1384 if start_idx >= lines.len() {
1385 return Ok((String::new(), truncated, true));
1386 }
1387 let end_idx = usize::try_from(end).unwrap_or(usize::MAX).min(lines.len());
1388 Ok((lines[start_idx..end_idx].join("\n"), truncated, false))
1389 }
1390
1391 fn read_text_prefix(path: &Path) -> std::io::Result<(String, bool)> {
1392 let mut file = std::fs::File::open(path)?;
1393 let mut buffer = Vec::new();
1394 file.by_ref()
1395 .take(MAX_MENTION_FILE_BYTES + 1)
1396 .read_to_end(&mut buffer)?;
1397 let truncated = buffer.len() as u64 > MAX_MENTION_FILE_BYTES;
1398 if truncated {
1399 buffer.truncate(MAX_MENTION_FILE_BYTES as usize);
1400 // Round down to the nearest valid UTF-8 character boundary so a
1401 // multi-byte sequence (CJK, emoji, etc.) is never split at the cut point.
1402 // Only adjust when error_len() is None — that means truncation landed
1403 // mid-sequence (incomplete tail). A Some(_) error_len means the file
1404 // genuinely contains invalid UTF-8 bytes; leave the buffer intact so
1405 // the from_utf8 call below returns the correct "file is not UTF-8" error.
1406 if let Err(e) = std::str::from_utf8(&buffer)
1407 && e.error_len().is_none()
1408 {
1409 buffer.truncate(e.valid_up_to());
1410 }
1411 }
1412 if buffer.contains(&0) {
1413 return Err(std::io::Error::new(
1414 std::io::ErrorKind::InvalidData,
1415 "file appears to be binary",
1416 ));
1417 }
1418 let text = std::str::from_utf8(&buffer)
1419 .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "file is not UTF-8"))?
1420 .to_string();
1421 Ok((text, truncated))
1422 }
1423
1424 fn is_media_path(path: &Path) -> bool {
1425 let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
1426 return false;
1427 };
1428 matches!(
1429 ext.to_ascii_lowercase().as_str(),
1430 "png"
1431 | "jpg"
1432 | "jpeg"
1433 | "gif"
1434 | "webp"
1435 | "bmp"
1436 | "tif"
1437 | "tiff"
1438 | "ppm"
1439 | "mp4"
1440 | "mov"
1441 | "m4v"
1442 | "webm"
1443 | "avi"
1444 | "mkv"
1445 )
1446 }
1447
1448 // ---------------------------------------------------------------------------
1449 // #101 regression repros
1450 // ---------------------------------------------------------------------------
1451 //
1452 // The bug being guarded: typing `@<some/file>` resolved under `--workspace`,
1453 // not the user's launch CWD. When the two diverged (the canonical case is
1454 // `--workspace=/repo` with `pwd=/repo/sub`), every relative `@` token routed
1455 // to the wrong root and the prompt got `<missing-file>` blocks.
1456 #[cfg(test)]
1457 mod tests {
1458 use super::*;
1459 use tempfile::TempDir;
1460
1461 /// #101 regression — workspace-vs-cwd divergence: `@bar.txt` typed from
1462 /// the cwd `<root>/sub` MUST resolve to `<root>/sub/bar.txt`, never to
1463 /// `<root>/bar.txt` (which doesn't exist).
1464 #[test]
1465 fn cwd_pass_resolves_when_workspace_pass_misses() {
1466 let tmp = TempDir::new().expect("tempdir");
1467 let sub = tmp.path().join("sub");
1468 std::fs::create_dir_all(&sub).expect("mkdir");
1469 let bar = sub.join("bar.txt");
1470 std::fs::write(&bar, "hello bar").expect("write bar");
1471
1472 let content =
1473 user_request_with_file_mentions("look at @bar.txt", tmp.path(), Some(sub.clone()));
1474
1475 // The block must reference the cwd-rooted path with the file's body —
1476 // and crucially it must NOT collapse to <missing-file>.
1477 assert!(
1478 content.contains("hello bar"),
1479 "expected file body to be inlined; got: {content}",
1480 );
1481 assert!(
1482 !content.contains("<missing-file"),
1483 "must not surface <missing-file> for a path that exists under cwd; got: {content}",
1484 );
1485 let bar_disp = bar.display().to_string();
1486 assert!(
1487 content.contains(&bar_disp),
1488 "expected resolved path {bar_disp} in content; got: {content}",
1489 );
1490 // Belt-and-suspenders: the workspace-rooted path doesn't exist and
1491 // must not appear in the rendered <file path="..."> attribute.
1492 let wrong = tmp.path().join("bar.txt").display().to_string();
1493 assert!(
1494 !content.contains(&format!("path=\"{wrong}\"")),
1495 "should NOT have routed to {wrong}; got: {content}",
1496 );
1497 }
1498
1499 /// #101 regression — nested workspace path: `@nested/deep/file.md` with
1500 /// the file at workspace root resolves through the workspace pass.
1501 #[test]
1502 fn workspace_pass_resolves_nested_path() {
1503 let tmp = TempDir::new().expect("tempdir");
1504 let nested = tmp.path().join("nested/deep");
1505 std::fs::create_dir_all(&nested).expect("mkdir");
1506 let file_md = nested.join("file.md");
1507 std::fs::write(&file_md, "# nested deep").expect("write file_md");
1508
1509 // Cwd is irrelevant; an unrelated tempdir would do. Pass `None` so we
1510 // are unambiguously testing the workspace-pass path.
1511 let content = user_request_with_file_mentions("see @nested/deep/file.md", tmp.path(), None);
1512
1513 assert!(content.contains("# nested deep"), "got: {content}");
1514 assert!(!content.contains("<missing-file"), "got: {content}");
1515 // Path-separator-portable check: the resolved path's filename is the
1516 // most reliable cross-platform anchor (Windows mixes `/` and `\` when
1517 // join() preserves user-typed separators).
1518 let basename = file_md
1519 .file_name()
1520 .and_then(|n| n.to_str())
1521 .expect("file_name utf-8");
1522 assert!(
1523 content.contains(basename),
1524 "basename {basename} not in path; got: {content}",
1525 );
1526 }
1527
1528 /// Snapshot-style check: the rendered `<file>` block for a resolvable
1529 /// mention must include the expected attributes and contents, and must
1530 /// NOT contain `<missing-file>`.
1531 #[test]
1532 fn resolvable_mention_renders_file_block_not_missing_file() {
1533 let tmp = TempDir::new().expect("tempdir");
1534 std::fs::write(tmp.path().join("guide.md"), "# Guide\nUse the fast path.\n")
1535 .expect("write");
1536
1537 let content = user_request_with_file_mentions("read @guide.md", tmp.path(), None);
1538
1539 // Header + tag presence.
1540 assert!(content.contains("Local context from @mentions:"));
1541 assert!(content.contains("<file mention=\"@guide.md\""));
1542 assert!(content.contains("# Guide\nUse the fast path."));
1543 assert!(content.ends_with("</file>"), "got: {content}");
1544 // The bug fingerprint MUST be absent.
1545 assert!(!content.contains("<missing-file"), "got: {content}");
1546 }
1547
1548 /// Negative test: a truly missing path still produces `<missing-file>`
1549 /// so the user gets an explicit signal instead of silent failure.
1550 #[test]
1551 fn truly_missing_mention_still_renders_missing_file() {
1552 let tmp = TempDir::new().expect("tempdir");
1553
1554 let content = user_request_with_file_mentions(
1555 "huh @does/not/exist.txt",
1556 tmp.path(),
1557 Some(tmp.path().to_path_buf()),
1558 );
1559
1560 assert!(
1561 content.contains("<missing-file mention=\"@does/not/exist.txt\""),
1562 "got: {content}",
1563 );
1564 }
1565
1566 #[test]
1567 fn pending_context_preview_is_lexical_and_does_not_probe_paths() {
1568 let previews = pending_context_previews("read @guide.md and @missing.md");
1569
1570 assert_eq!(previews.len(), 2);
1571 assert_eq!(previews[0].kind, "mention");
1572 assert_eq!(previews[0].label, "guide.md");
1573 assert!(!previews[0].included);
1574 assert_eq!(previews[0].detail.as_deref(), Some("resolved on send"));
1575 assert_eq!(previews[1].kind, "mention");
1576 assert_eq!(previews[1].label, "missing.md");
1577 assert!(!previews[1].included);
1578 assert_eq!(previews[1].detail.as_deref(), Some("resolved on send"));
1579 }
1580
1581 #[test]
1582 fn pending_context_preview_distinguishes_attach_media_from_at_media() {
1583 let tmp = TempDir::new().expect("tempdir");
1584 std::fs::write(tmp.path().join("photo.png"), b"png").expect("write");
1585 let attached = tmp.path().join("photo.png").display().to_string();
1586 let input = format!("inspect @photo.png\n[Attached image: {attached}]");
1587
1588 let previews = pending_context_previews(&input);
1589
1590 assert!(
1591 previews
1592 .iter()
1593 .any(|item| item.kind == "media" && !item.included),
1594 "at-mention media should be hint-only: {previews:?}"
1595 );
1596 assert!(
1597 previews
1598 .iter()
1599 .any(|item| item.kind == "image" && item.included),
1600 "/attach media should be included: {previews:?}"
1601 );
1602 }
1603
1604 #[test]
1605 fn manually_typed_basename_does_not_fuzzy_attach_nested_file() {
1606 let tmp = TempDir::new().expect("tempdir");
1607 let nested = tmp.path().join("nested");
1608 std::fs::create_dir_all(&nested).expect("mkdir");
1609 std::fs::write(nested.join("guide.md"), "nested secret").expect("write");
1610
1611 // With no completion index on hand there is no send-time fallback:
1612 // the miss stays an explicit <missing-file> rather than attaching an
1613 // arbitrary same-name file from a nested directory (#4365).
1614 let content = user_request_with_file_mentions("read @guide.md", tmp.path(), None);
1615
1616 assert!(
1617 content.contains("<missing-file mention=\"@guide.md\""),
1618 "a manually typed basename should remain exact: {content}",
1619 );
1620 assert!(
1621 !content.contains("nested secret"),
1622 "exact resolution must not silently attach a fuzzy nested match: {content}",
1623 );
1624 }
1625
1626 // ---------------------------------------------------------------------
1627 // Send-time completion-index fallback
1628 // ---------------------------------------------------------------------
1629 //
1630 // The dogfood failure this guards: `@FINISH-0.9.4.md` typed at the
1631 // workspace root resolved "not found" and injected a <missing-file> block
1632 // carrying the wrong (workspace-root) path, even though the file sat one
1633 // directory down. Misses now fall back to a bounded unique-match search
1634 // of the composer's background completion index; unresolvable misses emit
1635 // an honest block that names only what the user typed.
1636
1637 fn expand_with_index(
1638 input: &str,
1639 workspace: &Path,
1640 cwd: Option<PathBuf>,
1641 index: &[String],
1642 ) -> String {
1643 user_request_with_file_mentions_cached(
1644 input,
1645 workspace,
1646 cwd,
1647 &mut GitMentionCache::default(),
1648 Some(index),
1649 )
1650 }
1651
1652 /// A unique basename hit in the completion index resolves a nested file
1653 /// and injects its real path.
1654 #[test]
1655 fn mention_miss_resolves_via_unique_index_basename() {
1656 let tmp = TempDir::new().expect("tempdir");
1657 let nested = tmp.path().join("ops");
1658 std::fs::create_dir_all(&nested).expect("mkdir");
1659 std::fs::write(nested.join("FINISH-0.9.4.md"), "ship list").expect("write");
1660
1661 let index = vec!["ops/FINISH-0.9.4.md".to_string(), "README.md".to_string()];
1662 let content = expand_with_index("finish @FINISH-0.9.4.md", tmp.path(), None, &index);
1663
1664 assert!(content.contains("ship list"), "got: {content}");
1665 assert!(!content.contains("<missing-file"), "got: {content}");
1666 let real = nested.join("FINISH-0.9.4.md").display().to_string();
1667 assert!(
1668 content.contains(&real),
1669 "expected resolved path {real} in content; got: {content}",
1670 );
1671 }
1672
1673 /// A typed partial path resolves through a unique path-suffix hit.
1674 #[test]
1675 fn mention_miss_resolves_via_unique_index_suffix() {
1676 let tmp = TempDir::new().expect("tempdir");
1677 let nested = tmp.path().join("nested/deep");
1678 std::fs::create_dir_all(&nested).expect("mkdir");
1679 std::fs::write(nested.join("file.md"), "deep body").expect("write");
1680 // A same-basename file elsewhere must not make the suffix hit
1681 // ambiguous: the typed directory context disambiguates.
1682 let other = tmp.path().join("other");
1683 std::fs::create_dir_all(&other).expect("mkdir");
1684 std::fs::write(other.join("file.md"), "other body").expect("write");
1685
1686 let index = vec![
1687 "nested/deep/file.md".to_string(),
1688 "other/file.md".to_string(),
1689 ];
1690 let content = expand_with_index("see @deep/file.md", tmp.path(), None, &index);
1691
1692 assert!(content.contains("deep body"), "got: {content}");
1693 assert!(!content.contains("other body"), "got: {content}");
1694 assert!(!content.contains("<missing-file"), "got: {content}");
1695 }
1696
1697 /// Two same-basename candidates with no typed directory context are
1698 /// ambiguous: nothing is attached and the miss stays explicit.
1699 #[test]
1700 fn ambiguous_index_basename_stays_missing() {
1701 let tmp = TempDir::new().expect("tempdir");
1702 for dir in ["a", "b"] {
1703 std::fs::create_dir_all(tmp.path().join(dir)).expect("mkdir");
1704 std::fs::write(tmp.path().join(dir).join("guide.md"), format!("body {dir}"))
1705 .expect("write");
1706 }
1707
1708 let index = vec!["a/guide.md".to_string(), "b/guide.md".to_string()];
1709 let content = expand_with_index("read @guide.md", tmp.path(), None, &index);
1710
1711 assert!(
1712 content.contains("<missing-file mention=\"@guide.md\""),
1713 "an ambiguous basename must not attach an arbitrary winner: {content}",
1714 );
1715 assert!(!content.contains("body a"), "got: {content}");
1716 assert!(!content.contains("body b"), "got: {content}");
1717 }
1718
1719 /// A stale index entry (file deleted after the scan) must not attach.
1720 #[test]
1721 fn stale_index_entry_stays_missing() {
1722 let tmp = TempDir::new().expect("tempdir");
1723
1724 let index = vec!["ghost.md".to_string()];
1725 let content = expand_with_index("boo @ghost.md", tmp.path(), None, &index);
1726
1727 assert!(
1728 content.contains("<missing-file mention=\"@ghost.md\" />"),
1729 "got: {content}",
1730 );
1731 }
1732
1733 /// Absolute mentions name an exact location; the index must never
1734 /// substitute a same-basename file from inside the workspace.
1735 #[test]
1736 fn absolute_mention_miss_never_uses_index() {
1737 let tmp = TempDir::new().expect("tempdir");
1738 std::fs::write(tmp.path().join("guide.md"), "workspace guide").expect("write");
1739
1740 let index = vec!["guide.md".to_string()];
1741 let content = expand_with_index(
1742 "read @/definitely/absent/guide.md",
1743 tmp.path(),
1744 None,
1745 &index,
1746 );
1747
1748 assert!(
1749 content.contains("<missing-file mention=\"@/definitely/absent/guide.md\" />"),
1750 "got: {content}",
1751 );
1752 assert!(!content.contains("workspace guide"), "got: {content}");
1753 }
1754
1755 /// The honest miss format: the block names only the typed mention and
1756 /// never the non-existent workspace-root join.
1757 #[test]
1758 fn missing_file_block_names_only_the_typed_mention() {
1759 let tmp = TempDir::new().expect("tempdir");
1760
1761 let content = user_request_with_file_mentions(
1762 "huh @does/not/exist.txt",
1763 tmp.path(),
1764 Some(tmp.path().to_path_buf()),
1765 );
1766
1767 assert!(
1768 content.contains("<missing-file mention=\"@does/not/exist.txt\" />"),
1769 "got: {content}",
1770 );
1771 let wrong = tmp.path().join("does/not/exist.txt").display().to_string();
1772 assert!(
1773 !content.contains(&wrong),
1774 "must not inject the wrong workspace-root path {wrong}; got: {content}",
1775 );
1776 }
1777
1778 /// The context inspector mirrors the payload: index-resolved mentions
1779 /// report their real path, unresolved ones report the typed token.
1780 #[test]
1781 fn context_references_reflect_index_resolution() {
1782 let tmp = TempDir::new().expect("tempdir");
1783 let nested = tmp.path().join("ops");
1784 std::fs::create_dir_all(&nested).expect("mkdir");
1785 std::fs::write(nested.join("runbook.md"), "steps").expect("write");
1786
1787 let index = vec!["ops/runbook.md".to_string()];
1788 let references = context_references_from_input_cached(
1789 "read @runbook.md and @absent.md",
1790 tmp.path(),
1791 None,
1792 &mut GitMentionCache::default(),
1793 Some(&index),
1794 );
1795
1796 let resolved = references
1797 .iter()
1798 .find(|r| r.label == "runbook.md")
1799 .expect("runbook reference");
1800 assert_eq!(resolved.kind, ContextReferenceKind::File);
1801 assert!(resolved.included);
1802 let real = nested.join("runbook.md").display().to_string();
1803 assert_eq!(resolved.target, real, "{resolved:?}");
1804
1805 let missing = references
1806 .iter()
1807 .find(|r| r.label == "absent.md")
1808 .expect("absent reference");
1809 assert_eq!(missing.kind, ContextReferenceKind::Missing);
1810 assert!(!missing.included);
1811 assert_eq!(
1812 missing.target, "absent.md",
1813 "a missing mention must not report the workspace-root guess as its target: {missing:?}",
1814 );
1815 }
1816
1817 #[test]
1818 fn media_attachment_references_include_removable_line_ranges() {
1819 let input = "before\n[Attached image: 8x4 PNG at /tmp/pasted.png]\nafter";
1820
1821 let references = media_attachment_references(input);
1822
1823 assert_eq!(references.len(), 1);
1824 let reference = &references[0];
1825 assert_eq!(reference.kind, "image");
1826 assert_eq!(reference.path, "/tmp/pasted.png");
1827 assert_eq!(
1828 &input[reference.start_byte..reference.end_byte],
1829 "[Attached image: 8x4 PNG at /tmp/pasted.png]\n"
1830 );
1831 }
1832
1833 #[test]
1834 fn context_references_preserve_exact_targets_and_roundtrip() {
1835 let tmp = TempDir::new().expect("tempdir");
1836 std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
1837 std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write");
1838 let input = "read @src/main.rs";
1839
1840 let references =
1841 context_references_from_input(input, tmp.path(), Some(tmp.path().to_path_buf()));
1842
1843 assert_eq!(references.len(), 1);
1844 let reference = &references[0];
1845 assert_eq!(reference.kind, ContextReferenceKind::File);
1846 assert_eq!(reference.source, ContextReferenceSource::AtMention);
1847 assert_eq!(reference.label, "src/main.rs");
1848 assert!(reference.target.ends_with("src/main.rs"));
1849 assert!(reference.included);
1850 assert!(reference.expanded);
1851
1852 let encoded = serde_json::to_string(reference).expect("serialize");
1853 let decoded: ContextReference = serde_json::from_str(&encoded).expect("deserialize");
1854 assert_eq!(&decoded, reference);
1855 }
1856
1857 /// Regression test for #1441: truncating at MAX_MENTION_FILE_BYTES must not
1858 /// split a multi-byte UTF-8 sequence, which previously produced U+FFFD
1859 /// replacement characters in the TUI output.
1860 #[test]
1861 fn read_text_prefix_truncation_respects_utf8_char_boundary() {
1862 use std::io::Write;
1863
1864 // Build a file that is MAX_MENTION_FILE_BYTES - 1 ASCII bytes followed
1865 // by a 3-byte CJK character (U+4E2D, '中'). The naive truncate at
1866 // MAX_MENTION_FILE_BYTES cuts after the first byte of '中', producing
1867 // an invalid sequence.
1868 let tmp = TempDir::new().expect("tempdir");
1869 let path = tmp.path().join("cjk.txt");
1870 let mut f = std::fs::File::create(&path).expect("create");
1871 let padding = vec![b'a'; MAX_MENTION_FILE_BYTES as usize - 1];
1872 f.write_all(&padding).expect("write padding");
1873 f.write_all("中".as_bytes()).expect("write CJK");
1874
1875 let (text, truncated, beyond_eof) = read_file_content(&path, None).expect("should succeed");
1876 assert!(!beyond_eof);
1877 assert!(
1878 truncated,
1879 "file exceeds limit so should be marked truncated"
1880 );
1881 assert!(
1882 !text.contains('\u{FFFD}'),
1883 "truncated text must not contain replacement characters; got: {text:?}",
1884 );
1885 }
1886
1887 #[test]
1888 fn mention_range_splitting_accepts_only_exact_digit_pairs() {
1889 assert_eq!(
1890 split_mention_range("src/lib.rs:120-160"),
1891 Some((
1892 "src/lib.rs",
1893 FileRange {
1894 start: 120,
1895 end: 160
1896 }
1897 )),
1898 );
1899 assert_eq!(
1900 split_mention_range("x:1-2"),
1901 Some(("x", FileRange { start: 1, end: 2 })),
1902 );
1903 for whole in [
1904 "notes.txt",
1905 "x:1",
1906 "x:a-b",
1907 ":1-2",
1908 "x:1-a",
1909 "x:0-2",
1910 "x:2-1",
1911 ] {
1912 assert_eq!(split_mention_range(whole), None, "{whole} must stay whole");
1913 }
1914 }
1915
1916 #[test]
1917 fn ranged_file_mention_slices_lines_and_reports_beyond_eof() {
1918 let tmp = TempDir::new().expect("tempdir");
1919 let path = tmp.path().join("lines.rs");
1920 std::fs::write(&path, "one\ntwo\nthree\nfour\nfive\n").expect("write");
1921
1922 let (text, truncated, beyond_eof) =
1923 read_file_content(&path, Some(FileRange { start: 2, end: 4 })).expect("range read");
1924 assert!(!truncated);
1925 assert!(!beyond_eof);
1926 assert_eq!(text, "two\nthree\nfour");
1927
1928 // An end past the file clamps to what exists.
1929 let (text, _, beyond_eof) =
1930 read_file_content(&path, Some(FileRange { start: 4, end: 99 })).expect("clamped range");
1931 assert!(!beyond_eof);
1932 assert_eq!(text, "four\nfive");
1933
1934 // A start past the end is flagged honestly.
1935 let (text, _, beyond_eof) =
1936 read_file_content(&path, Some(FileRange { start: 9, end: 12 })).expect("beyond range");
1937 assert!(beyond_eof);
1938 assert!(text.is_empty());
1939 }
1940
1941 #[test]
1942 fn ranged_mention_render_annotates_lines_and_honours_the_byte_bound() {
1943 let tmp = TempDir::new().expect("tempdir");
1944 let path = tmp.path().join("notes.rs");
1945 std::fs::write(&path, "a\nb\nc\nd\n").expect("write");
1946 let rendered = render_file_mention_context(
1947 "notes.rs:2-3",
1948 &path,
1949 "notes.rs",
1950 Some(FileRange { start: 2, end: 3 }),
1951 );
1952 assert!(rendered.contains(r#"lines="2-3""#), "{rendered}");
1953 assert!(rendered.contains("\nb\nc\n"));
1954
1955 let beyond = render_file_mention_context(
1956 "notes.rs:80-90",
1957 &path,
1958 "notes.rs",
1959 Some(FileRange { start: 80, end: 90 }),
1960 );
1961 assert!(beyond.contains(r#"beyond-eof="true""#), "{beyond}");
1962 }
1963 // ---------------------------------------------------------------------
1964 // #4067 — @git / @diff composer mentions
1965 // ---------------------------------------------------------------------
1966
1967 fn init_test_repo(dir: &Path) {
1968 for args in [
1969 vec!["init", "--initial-branch=main"],
1970 vec!["config", "user.email", "test@example.com"],
1971 vec!["config", "user.name", "Test"],
1972 ] {
1973 let out = std::process::Command::new("git")
1974 .args(&args)
1975 .current_dir(dir)
1976 .output()
1977 .expect("git available in tests");
1978 assert!(out.status.success(), "git {args:?} failed");
1979 }
1980 }
1981
1982 fn commit_test_repo(dir: &Path) {
1983 for args in [vec!["add", "-A"], vec!["commit", "-m", "initial"]] {
1984 std::process::Command::new("git")
1985 .args(&args)
1986 .current_dir(dir)
1987 .output()
1988 .expect("git available in tests");
1989 }
1990 }
1991
1992 #[test]
1993 fn git_and_diff_mentions_inline_curated_context_not_paths() {
1994 let tmp = TempDir::new().expect("tempdir");
1995 init_test_repo(tmp.path());
1996 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
1997 commit_test_repo(tmp.path());
1998 std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
1999
2000 let expanded = user_request_with_file_mentions(
2001 "look at @git and @diff",
2002 tmp.path(),
2003 Some(tmp.path().to_path_buf()),
2004 );
2005
2006 assert!(expanded.contains("<git-status"), "{expanded}");
2007 assert!(expanded.contains("<git-diff"), "{expanded}");
2008 assert!(expanded.contains("a.txt"), "{expanded}");
2009 // The tokens are not treated as paths, so no missing-file block.
2010 assert!(!expanded.contains("<missing-file"), "{expanded}");
2011 }
2012
2013 #[test]
2014 fn git_mentions_outside_a_repository_say_so_explicitly() {
2015 let tmp = TempDir::new().expect("tempdir");
2016 let expanded = user_request_with_file_mentions(
2017 "status? @git",
2018 tmp.path(),
2019 Some(tmp.path().to_path_buf()),
2020 );
2021 assert!(expanded.contains("<git-unavailable"), "{expanded}");
2022 assert!(expanded.contains("not a git repository"), "{expanded}");
2023 }
2024
2025 #[test]
2026 fn git_mention_is_deduplicated_within_one_message() {
2027 let tmp = TempDir::new().expect("tempdir");
2028 init_test_repo(tmp.path());
2029 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
2030 commit_test_repo(tmp.path());
2031 std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
2032
2033 let expanded = user_request_with_file_mentions(
2034 "@diff and again @diff",
2035 tmp.path(),
2036 Some(tmp.path().to_path_buf()),
2037 );
2038 assert_eq!(expanded.matches("<git-diff").count(), 1, "{expanded}");
2039 }
2040
2041 #[test]
2042 fn paths_that_merely_start_with_git_stay_file_mentions() {
2043 let tmp = TempDir::new().expect("tempdir");
2044 std::fs::write(tmp.path().join("diff.txt"), "plain file").expect("write");
2045
2046 let expanded = user_request_with_file_mentions(
2047 "see @diff.txt",
2048 tmp.path(),
2049 Some(tmp.path().to_path_buf()),
2050 );
2051 assert!(expanded.contains("plain file"), "{expanded}");
2052 assert!(!expanded.contains("<git-diff"), "{expanded}");
2053 }
2054
2055 #[test]
2056 fn large_diff_is_truncated_and_the_inspector_reports_the_budget() {
2057 let tmp = TempDir::new().expect("tempdir");
2058 init_test_repo(tmp.path());
2059 std::fs::write(tmp.path().join("big.txt"), "seed\n").expect("write");
2060 commit_test_repo(tmp.path());
2061 let bulk: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
2062 std::fs::write(tmp.path().join("big.txt"), bulk).expect("write");
2063
2064 let expanded =
2065 user_request_with_file_mentions("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
2066 assert!(
2067 expanded.contains("truncated=\"true\""),
2068 "expected truncation marker"
2069 );
2070
2071 let references =
2072 context_references_from_input("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
2073 let git_ref = references
2074 .iter()
2075 .find(|r| r.kind == ContextReferenceKind::GitContext)
2076 .expect("git reference present in the inspector");
2077 assert_eq!(git_ref.label, "diff");
2078 assert!(git_ref.included);
2079 let detail = git_ref.detail.clone().unwrap_or_default();
2080 assert!(detail.contains("truncated at"), "{detail}");
2081 assert!(
2082 detail.contains(&crate::tui::git_mention::MAX_GIT_DIFF_MENTION_BYTES.to_string()),
2083 "{detail}"
2084 );
2085 }
2086
2087 #[test]
2088 fn empty_repository_reference_is_visible_but_not_included() {
2089 let tmp = TempDir::new().expect("tempdir");
2090 init_test_repo(tmp.path());
2091 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
2092 commit_test_repo(tmp.path());
2093
2094 let references =
2095 context_references_from_input("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
2096 let git_ref = references
2097 .iter()
2098 .find(|r| r.kind == ContextReferenceKind::GitContext)
2099 .expect("git reference present even when there is nothing to show");
2100 assert!(!git_ref.included);
2101 assert!(
2102 git_ref
2103 .detail
2104 .as_deref()
2105 .is_some_and(|d| d.contains("no working-tree changes")),
2106 "{:?}",
2107 git_ref.detail
2108 );
2109 }
2110
2111 #[test]
2112 fn composer_preview_lists_git_mentions_without_running_git() {
2113 let previews = pending_context_previews("@git @diff");
2114 let kinds: Vec<&str> = previews.iter().map(|p| p.kind.as_str()).collect();
2115 assert_eq!(kinds, vec!["git", "git"]);
2116 assert!(previews.iter().all(|p| !p.included));
2117 }
2118
2119 /// #4067 review follow-up: `mention_menu_limit = 0` is a documented way to
2120 /// disable the popup. The git tokens are menu entries like any other and
2121 /// must respect the same cap — otherwise setting 0 still pops a one-entry
2122 /// menu the moment the user types `@g`.
2123 #[test]
2124 fn git_mention_entries_respect_a_zero_menu_limit() {
2125 let paths = vec!["src/main.rs".to_string()];
2126 assert!(with_git_mention_entries(paths.clone(), "g", 0).is_empty());
2127 assert!(with_git_mention_entries(paths.clone(), "d", 0).is_empty());
2128 assert!(with_git_mention_entries(paths.clone(), "", 0).is_empty());
2129 assert!(with_git_mention_entries(Vec::new(), "gi", 0).is_empty());
2130 }
2131
2132 /// A small non-zero limit must cap the token list this function builds.
2133 ///
2134 /// The empty-partial branch is pass-through by design — those entries were
2135 /// already capped upstream by `rank_completion_candidates`, and shrinking
2136 /// them here would silently drop paths the caller asked for.
2137 #[test]
2138 fn git_mention_entries_never_exceed_the_menu_limit() {
2139 let paths = vec!["a.rs".to_string(), "b.rs".to_string()];
2140 for limit in 1..=4 {
2141 let matched = with_git_mention_entries(paths.clone(), "d", limit);
2142 assert!(matched.len() <= limit, "limit {limit}: {matched:?}");
2143 // Both tokens match a bare prefix that hits `git` and `diff`
2144 // through separate entries; the cap still holds.
2145 let both = with_git_mention_entries(Vec::new(), "", limit);
2146 assert!(both.len() <= limit, "limit {limit}: {both:?}");
2147 }
2148 // Pass-through: the caller's already-capped paths survive untouched.
2149 assert_eq!(with_git_mention_entries(paths.clone(), "", 1), paths);
2150 }
2151
2152 /// #4067 review follow-up: one submit resolves a git mention once, not
2153 /// once per surface. `@diff` makes git compute the whole working-tree diff
2154 /// before the byte budget applies, so a repeat is real wasted work.
2155 #[test]
2156 fn a_submit_resolves_each_git_mention_only_once() {
2157 let tmp = TempDir::new().expect("tempdir");
2158 init_test_repo(tmp.path());
2159 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
2160 commit_test_repo(tmp.path());
2161 std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
2162
2163 let mut cache = crate::tui::git_mention::GitMentionCache::default();
2164 let references = context_references_from_input_cached(
2165 "@diff",
2166 tmp.path(),
2167 Some(tmp.path().to_path_buf()),
2168 &mut cache,
2169 None,
2170 );
2171 let expanded = user_request_with_file_mentions_cached(
2172 "@diff",
2173 tmp.path(),
2174 Some(tmp.path().to_path_buf()),
2175 &mut cache,
2176 None,
2177 );
2178
2179 // Both surfaces describe the same resolution.
2180 let git_ref = references
2181 .iter()
2182 .find(|r| r.kind == ContextReferenceKind::GitContext)
2183 .expect("git reference");
2184 assert!(git_ref.included);
2185 assert!(expanded.contains("<git-diff"), "{expanded}");
2186
2187 // And the shared cache holds exactly one entry for it.
2188 assert_eq!(cache.len(), 1, "the diff must be resolved once per submit");
2189 }
2190
2191 #[test]
2192 fn completion_offers_git_and_diff_alongside_paths() {
2193 let paths = vec!["src/main.rs".to_string(), "docs/guide.md".to_string()];
2194 // A bare `@` stays the file picker.
2195 assert_eq!(with_git_mention_entries(paths.clone(), "", 8), paths);
2196
2197 let narrowed = with_git_mention_entries(paths.clone(), "di", 8);
2198 assert_eq!(narrowed.first().map(String::as_str), Some("diff"));
2199 assert!(narrowed.contains(&"src/main.rs".to_string()));
2200
2201 let git_only = with_git_mention_entries(paths.clone(), "g", 8);
2202 assert_eq!(git_only.first().map(String::as_str), Some("git"));
2203
2204 // A partial that matches no token leaves path completion untouched.
2205 assert_eq!(with_git_mention_entries(paths.clone(), "src", 8), paths);
2206 }
2207
2208 // ------------------------------------------------------------------
2209 // macOS screencapture-temp stabilization
2210 // ------------------------------------------------------------------
2211
2212 /// A fake macOS screencapture temp tree. Returns the tempdir (alive for
2213 /// the test's duration), the screenshot source path, and the artifact dir
2214 /// stabilization should copy into.
2215 fn screencapture_fixture() -> (TempDir, PathBuf, PathBuf) {
2216 let tmp = TempDir::new().expect("tempdir");
2217 let source_dir = tmp
2218 .path()
2219 .join("Temporary Items")
2220 .join("NSIRD_screencaptureui_7F3A");
2221 std::fs::create_dir_all(&source_dir).expect("mkdir");
2222 let source = source_dir.join("Screenshot 2026-08-10 at 01.09.39 截图.png");
2223 std::fs::write(&source, b"fake screenshot bytes").expect("write");
2224 let artifact_dir = tmp.path().join("attachments");
2225 (tmp, source, artifact_dir)
2226 }
2227
2228 #[test]
2229 fn detects_screencapture_temp_paths() {
2230 assert!(is_screencapture_temp_path(Path::new(
2231 "/var/folders/x/T/Temporary Items/NSIRD_screencaptureui_ABC/Shot.png"
2232 )));
2233 let (tmp, source, _) = screencapture_fixture();
2234 assert!(is_screencapture_temp_path(&source));
2235 // Only one marker is not a screencapture temp location.
2236 assert!(!is_screencapture_temp_path(Path::new(
2237 "/tmp/Temporary Items/Shot.png"
2238 )));
2239 assert!(!is_screencapture_temp_path(Path::new("/tmp/Shot.png")));
2240 assert!(!is_screencapture_temp_path(tmp.path()));
2241 }
2242
2243 #[test]
2244 fn stabilizes_a_quoted_paste_with_spaces_and_unicode() {
2245 let (tmp, source, artifact_dir) = screencapture_fixture();
2246 let input = format!("take a look at \"{}\" please", source.display());
2247 let out = stabilize_screenshot_references(&input, &artifact_dir);
2248
2249 let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
2250 assert_eq!(
2251 std::fs::read(&stable).expect("stable copy"),
2252 b"fake screenshot bytes"
2253 );
2254 assert!(out.contains(&stable.display().to_string()), "got: {out}");
2255 assert!(!out.contains(&source.display().to_string()), "got: {out}");
2256 assert!(source.is_file(), "source must be left in place");
2257 // Idempotent: the stable path is not a screencapture reference, and a
2258 // second pass over the rewritten text changes nothing.
2259 assert_eq!(stabilize_screenshot_references(&out, &artifact_dir), out);
2260 let _ = tmp;
2261 }
2262
2263 #[test]
2264 fn stabilizes_mention_attached_and_unquoted_references() {
2265 let (tmp, source, artifact_dir) = screencapture_fixture();
2266 let disp = source.display().to_string();
2267 let input = format!("see @\"{disp}\", also [Attached image: {disp}] and bare {disp} here");
2268 let out = stabilize_screenshot_references(&input, &artifact_dir);
2269
2270 let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
2271 let stable_disp = stable.display().to_string();
2272 // Three different carriers, one stable destination each time, and
2273 // exactly one physical copy despite the duplicates.
2274 assert_eq!(out.matches(&stable_disp).count(), 3, "got: {out}");
2275 assert!(!out.contains(&disp), "got: {out}");
2276 assert_eq!(
2277 std::fs::read_dir(&artifact_dir).expect("artifacts").count(),
2278 1
2279 );
2280 let _ = tmp;
2281 }
2282
2283 #[test]
2284 fn leaves_missing_and_non_screencapture_paths_alone() {
2285 let (tmp, source, artifact_dir) = screencapture_fixture();
2286 let ghost = source.with_file_name("Screenshot 1999-01-01 at 00.00.00.png");
2287 let input = format!(
2288 "missing {} regular /tmp/notes.txt mention @README.md quote \"no file\"",
2289 ghost.display()
2290 );
2291 let out = stabilize_screenshot_references(&input, &artifact_dir);
2292 assert_eq!(out, input);
2293 assert!(std::fs::read_dir(&artifact_dir).is_err());
2294 let _ = tmp;
2295 }
2296
2297 #[test]
2298 fn single_quoted_prose_does_not_block_a_real_reference() {
2299 let (tmp, source, artifact_dir) = screencapture_fixture();
2300 let disp = source.display().to_string();
2301 // The `'` pair encloses the path but is prose, not a quoted path.
2302 let input = format!("'check {disp} is here' please");
2303 let out = stabilize_screenshot_references(&input, &artifact_dir);
2304 let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
2305 assert!(out.contains(&stable.display().to_string()), "got: {out}");
2306 assert!(!out.contains(&disp), "got: {out}");
2307 let _ = tmp;
2308 }
2309
2310 #[test]
2311 fn handles_leading_delimiters_on_unquoted_pastes() {
2312 let (tmp, source, artifact_dir) = screencapture_fixture();
2313 let disp = source.display().to_string();
2314 // Paren-wrapped paste: the `(` rides on the first path token, and an
2315 // unquoted `@`-prefixed paste keeps its `@` in the rewritten text.
2316 let input = format!("see ({disp}) and also @{disp} thanks");
2317 let out = stabilize_screenshot_references(&input, &artifact_dir);
2318 let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
2319 let stable_disp = stable.display().to_string();
2320 assert_eq!(out.matches(&stable_disp).count(), 2, "got: {out}");
2321 assert!(out.contains(&format!("({stable_disp})")), "got: {out}");
2322 assert!(out.contains(&format!("@{stable_disp}")), "got: {out}");
2323 assert!(!out.contains(&disp), "got: {out}");
2324 let _ = tmp;
2325 }
2326
2327 #[test]
2328 fn handles_a_multibyte_final_filename_char() {
2329 let tmp = TempDir::new().expect("tempdir");
2330 let source_dir = tmp
2331 .path()
2332 .join("Temporary Items")
2333 .join("NSIRD_screencaptureui_9B2C");
2334 std::fs::create_dir_all(&source_dir).expect("mkdir");
2335 // No ASCII extension: the reference span ends on a CJK code point.
2336 let source = source_dir.join("截图");
2337 std::fs::write(&source, b"screenshot").expect("write");
2338 let artifact_dir = tmp.path().join("attachments");
2339 let input = format!("here {} it is", source.display());
2340 let out = stabilize_screenshot_references(&input, &artifact_dir);
2341 let stable = artifact_dir.join("截图");
2342 assert!(out.contains(&stable.display().to_string()), "got: {out}");
2343 assert!(!out.contains(&source.display().to_string()), "got: {out}");
2344 let _ = tmp;
2345 }
2346
2347 #[test]
2348 fn keeps_the_original_reference_when_the_copy_fails() {
2349 let (tmp, source, _) = screencapture_fixture();
2350 let blocker = tmp.path().join("blocker");
2351 std::fs::write(&blocker, b"x").expect("write");
2352 // create_dir_all under a regular file must fail.
2353 let bad_artifact_dir = blocker.join("attachments");
2354 let input = format!("see \"{}\"", source.display());
2355 let out = stabilize_screenshot_references(&input, &bad_artifact_dir);
2356 assert_eq!(out, input);
2357 assert!(source.is_file());
2358 let _ = tmp;
2359 }
2360 }
2361
2361 lines RUST