| 💾 Saved. d722711 k33g 4h ago | 1 | // Package mention understands the @path notation of a prompt: "explain |
| 2 | // @internal/acp/acp.go" names a file the model should look at. There is no |
| 3 | // picker — the user types the path — and the file's content does not travel |
| 4 | // in the prompt: as for an editor's @-mention over ACP, the mention becomes an |
| 5 | // `[attached file: <absolute path>]` line and the model reads the file with |
| 6 | // its tools. Both front ends use it, so a typed @path means the same thing in |
| 7 | // the terminal and in an editor whose picker was bypassed. |
| 8 | package mention |
| 9 | |
| 10 | import ( |
| 11 | "fmt" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | ) |
| 16 | |
| 17 | // Attachment is one @path mention that named an existing file or directory. |
| 18 | type Attachment struct { |
| 19 | Mention string // as typed, without the @ and the trailing punctuation |
| 20 | Path string // absolute, cleaned |
| 21 | Dir bool // a directory rather than a file |
| 22 | } |
| 23 | |
| 24 | // trailing is the sentence punctuation a mention may be glued to — "look at |
| 25 | // @main.go, then…" mentions main.go, not "main.go,". |
| 26 | const trailing = ",.;:!?)]}'\"`" |
| 27 | |
| 28 | // Expand finds the @path mentions of input, resolves them against cwd (an |
| 29 | // absolute path starts with / or ~/, anything else is relative to cwd) and |
| 30 | // returns the input followed by one attachment line per existing path, in |
| 31 | // order of first appearance, each path once: |
| 32 | // |
| 33 | // [attached file: /work/project/main.go] |
| 34 | // [attached directory: /work/project/internal] |
| 35 | // |
| 36 | // A mention that names nothing on disk is left alone: "@bob" in a sentence is |
| 37 | // not a file. A @ glued to a word (an e-mail address) is not a mention either. |
| 38 | func Expand(input, cwd string) (string, []Attachment) { |
| 39 | var out []Attachment |
| 40 | seen := map[string]bool{} |
| 41 | for _, m := range mentions(input) { |
| 42 | path := resolve(m, cwd) |
| 43 | if seen[path] { |
| 44 | continue |
| 45 | } |
| 46 | info, err := os.Stat(path) |
| 47 | if err != nil { |
| 48 | continue |
| 49 | } |
| 50 | seen[path] = true |
| 51 | out = append(out, Attachment{Mention: m, Path: path, Dir: info.IsDir()}) |
| 52 | } |
| 53 | var b strings.Builder |
| 54 | b.WriteString(input) |
| 55 | for _, a := range out { |
| 56 | fmt.Fprintf(&b, "\n%s", a.Line()) |
| 57 | } |
| 58 | return b.String(), out |
| 59 | } |
| 60 | |
| 61 | // Line is the attachment as the model sees it — the same shape an ACP |
| 62 | // resource_link takes, so the model meets one convention, not two. |
| 63 | func (a Attachment) Line() string { |
| 64 | if a.Dir { |
| 65 | return fmt.Sprintf("[attached directory: %s]", a.Path) |
| 66 | } |
| 67 | return fmt.Sprintf("[attached file: %s]", a.Path) |
| 68 | } |
| 69 | |
| 70 | // mentions returns the candidate paths: every "@something" that starts the |
| 71 | // input or follows a space, an opening bracket or a quote, stripped of the |
| 72 | // punctuation it may end with. Existence is the caller's business. |
| 73 | func mentions(input string) []string { |
| 74 | var out []string |
| 75 | for i := 0; i < len(input); i++ { |
| 76 | if input[i] != '@' || !startsMention(input, i) { |
| 77 | continue |
| 78 | } |
| 79 | end := i + 1 |
| 80 | for end < len(input) && !isSpace(input[end]) { |
| 81 | end++ |
| 82 | } |
| 83 | m := strings.TrimRight(input[i+1:end], trailing) |
| 84 | if m != "" { |
| 85 | out = append(out, m) |
| 86 | } |
| 87 | i = end |
| 88 | } |
| 89 | return out |
| 90 | } |
| 91 | |
| 92 | func startsMention(input string, i int) bool { |
| 93 | if i == 0 { |
| 94 | return true |
| 95 | } |
| 96 | return isSpace(input[i-1]) || strings.IndexByte("([{'\"`", input[i-1]) >= 0 |
| 97 | } |
| 98 | |
| 99 | func isSpace(c byte) bool { |
| 100 | return c == ' ' || c == '\t' || c == '\n' || c == '\r' |
| 101 | } |
| 102 | |
| 103 | // resolve turns a mention into an absolute, cleaned path. |
| 104 | func resolve(m, cwd string) string { |
| 105 | switch { |
| 106 | case filepath.IsAbs(m): |
| 107 | return filepath.Clean(m) |
| 108 | case m == "~" || strings.HasPrefix(m, "~/"): |
| 109 | if home, err := os.UserHomeDir(); err == nil { |
| 110 | return filepath.Clean(filepath.Join(home, m[1:])) |
| 111 | } |
| 112 | } |
| 113 | return filepath.Clean(filepath.Join(cwd, m)) |
| 114 | } |