| 🛟 Updated. 28d5985 k33g 11h ago | 1 | package acp |
| 2 | |
| 3 | import ( |
| 4 | "mime" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // Mention is a file named in a prompt with "@", which the agent is given as |
| 10 | // context beside the words. |
| 11 | // |
| 12 | // Name is what was typed after the @ — the path relative to the project, which |
| 13 | // is what the picker offers and what the text keeps. Path is where the file |
| 14 | // actually is, which is what the agent is told. |
| 15 | // |
| 16 | // acp.Mention{Name: "internal/golang/scanner.go", Path: "/src/p/internal/golang/scanner.go"} |
| 17 | type Mention struct { |
| 18 | Name string |
| 19 | Path string |
| 20 | } |
| 21 | |
| 22 | // URI returns the file: URI the protocol wants for the mention. |
| 23 | func (m Mention) URI() string { return "file://" + filepath.ToSlash(m.Path) } |
| 24 | |
| 25 | // blocksFor turns one prompt into content blocks: a text block for each run of |
| 26 | // words, and one resource block for each mention, in the order they were |
| 27 | // typed. A prompt with no mentions is one text block, exactly as before. |
| 28 | // |
| 29 | // The mention's "@name" is taken **out** of the text and replaced by the |
| 30 | // block, which is how Zed sends the same thing: the agent sees the file where |
| 31 | // the name was, not a name it has to match against an attachment. |
| 32 | // |
| 33 | // embed says the agent accepts embedded context; read is how the file's text |
| 34 | // is obtained when it does. A file that cannot be read is sent as a link |
| 35 | // rather than dropped, so the agent is at least told which file was meant. |
| 36 | func blocksFor(text string, mentions []Mention, embed bool, read func(string) (string, error)) []ContentBlock { |
| 37 | var blocks []ContentBlock |
| 38 | var words strings.Builder |
| 39 | |
| 40 | flush := func() { |
| 41 | if words.Len() > 0 { |
| 42 | blocks = append(blocks, ContentBlock{Type: ContentText, Text: words.String()}) |
| 43 | words.Reset() |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | runes := []rune(text) |
| 48 | for at := 0; at < len(runes); { |
| 49 | mention, width, ok := mentionAt(runes, at, mentions) |
| 50 | if !ok { |
| 51 | words.WriteRune(runes[at]) |
| 52 | at++ |
| 53 | continue |
| 54 | } |
| 55 | flush() |
| 56 | blocks = append(blocks, resourceBlock(mention, embed, read)) |
| 57 | at += width |
| 58 | } |
| 59 | flush() |
| 60 | return blocks |
| 61 | } |
| 62 | |
| 63 | // mentionAt reports whether a mention starts at position at, and how many |
| 64 | // runes it takes. The longest name wins, so "@a/b.go" is not read as "@a" |
| 65 | // followed by "/b.go"; and the name has to end the text or be followed by a |
| 66 | // space, so "@main.gopher" does not name main.go. |
| 67 | func mentionAt(runes []rune, at int, mentions []Mention) (Mention, int, bool) { |
| 68 | if runes[at] != mentionRune { |
| 69 | return Mention{}, 0, false |
| 70 | } |
| 71 | |
| 72 | var best Mention |
| 73 | width := 0 |
| 74 | for _, mention := range mentions { |
| 75 | name := []rune(string(mentionRune) + mention.Name) |
| 76 | if len(name) <= width || !hasRunes(runes[at:], name) { |
| 77 | continue |
| 78 | } |
| 79 | if end := at + len(name); end < len(runes) && !isSpace(runes[end]) { |
| 80 | continue |
| 81 | } |
| 82 | best, width = mention, len(name) |
| 83 | } |
| 84 | return best, width, width > 0 |
| 85 | } |
| 86 | |
| 87 | // hasRunes reports whether text begins with prefix. |
| 88 | func hasRunes(text, prefix []rune) bool { |
| 89 | if len(prefix) > len(text) { |
| 90 | return false |
| 91 | } |
| 92 | for i, r := range prefix { |
| 93 | if text[i] != r { |
| 94 | return false |
| 95 | } |
| 96 | } |
| 97 | return true |
| 98 | } |
| 99 | |
| 100 | // isSpace reports whether a rune ends a word. |
| 101 | func isSpace(r rune) bool { return r == ' ' || r == '\t' || r == '\n' } |
| 102 | |
| 103 | // resourceBlock is the block a mention becomes: the file's text when the agent |
| 104 | // takes it and the file can be read, a link to it otherwise. |
| 105 | func resourceBlock(mention Mention, embed bool, read func(string) (string, error)) ContentBlock { |
| 106 | kind := mimeOf(mention.Path) |
| 107 | if embed && read != nil { |
| 108 | if text, err := read(mention.Path); err == nil { |
| 109 | return ContentBlock{ |
| 110 | Type: ContentResource, |
| 111 | Resource: &EmbeddedResource{URI: mention.URI(), MimeType: kind, Text: text}, |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | return ContentBlock{Type: ContentResourceLink, URI: mention.URI(), Name: mention.Name, MimeType: kind} |
| 116 | } |
| 117 | |
| 118 | // knownTypes are the media types of the files this family of editors is for. |
| 119 | // |
| 120 | // They are listed rather than looked up because the system's table is not the |
| 121 | // same on two machines — Linux and macOS disagree about ".go" — and an agent |
| 122 | // that keys its behaviour on the type deserves the same answer from every copy |
| 123 | // of the editor. |
| 124 | var knownTypes = map[string]string{ |
| 125 | ".go": "text/x-go", |
| 126 | ".rs": "text/x-rust", |
| 127 | ".py": "text/x-python", |
| 128 | ".mbt": "text/x-moonbit", |
| 129 | ".golo": "text/x-golo", |
| 130 | ".js": "text/javascript", |
| 131 | ".mjs": "text/javascript", |
| 132 | ".ts": "text/typescript", |
| 133 | ".md": "text/markdown", |
| 134 | ".toml": "application/toml", |
| 135 | ".yaml": "application/yaml", |
| 136 | ".yml": "application/yaml", |
| 137 | ".json": "application/json", |
| 138 | ".sh": "text/x-shellscript", |
| 139 | ".txt": "text/plain", |
| 140 | } |
| 141 | |
| 142 | // mimeOf returns the media type to send for a file, and text/plain when nothing |
| 143 | // better is known: every mention is a file somebody opened in a text editor. |
| 144 | func mimeOf(path string) string { |
| 145 | extension := strings.ToLower(filepath.Ext(path)) |
| 146 | if kind, ok := knownTypes[extension]; ok { |
| 147 | return kind |
| 148 | } |
| 149 | if kind := mime.TypeByExtension(extension); kind != "" { |
| 150 | if semicolon := strings.IndexByte(kind, ';'); semicolon >= 0 { |
| 151 | kind = strings.TrimSpace(kind[:semicolon]) |
| 152 | } |
| 153 | return kind |
| 154 | } |
| 155 | return "text/plain" |
| 156 | } |