package acp import ( "mime" "path/filepath" "strings" ) // Mention is a file named in a prompt with "@", which the agent is given as // context beside the words. // // Name is what was typed after the @ — the path relative to the project, which // is what the picker offers and what the text keeps. Path is where the file // actually is, which is what the agent is told. // // acp.Mention{Name: "internal/golang/scanner.go", Path: "/src/p/internal/golang/scanner.go"} type Mention struct { Name string Path string } // URI returns the file: URI the protocol wants for the mention. func (m Mention) URI() string { return "file://" + filepath.ToSlash(m.Path) } // blocksFor turns one prompt into content blocks: a text block for each run of // words, and one resource block for each mention, in the order they were // typed. A prompt with no mentions is one text block, exactly as before. // // The mention's "@name" is taken **out** of the text and replaced by the // block, which is how Zed sends the same thing: the agent sees the file where // the name was, not a name it has to match against an attachment. // // embed says the agent accepts embedded context; read is how the file's text // is obtained when it does. A file that cannot be read is sent as a link // rather than dropped, so the agent is at least told which file was meant. func blocksFor(text string, mentions []Mention, embed bool, read func(string) (string, error)) []ContentBlock { var blocks []ContentBlock var words strings.Builder flush := func() { if words.Len() > 0 { blocks = append(blocks, ContentBlock{Type: ContentText, Text: words.String()}) words.Reset() } } runes := []rune(text) for at := 0; at < len(runes); { mention, width, ok := mentionAt(runes, at, mentions) if !ok { words.WriteRune(runes[at]) at++ continue } flush() blocks = append(blocks, resourceBlock(mention, embed, read)) at += width } flush() return blocks } // mentionAt reports whether a mention starts at position at, and how many // runes it takes. The longest name wins, so "@a/b.go" is not read as "@a" // followed by "/b.go"; and the name has to end the text or be followed by a // space, so "@main.gopher" does not name main.go. func mentionAt(runes []rune, at int, mentions []Mention) (Mention, int, bool) { if runes[at] != mentionRune { return Mention{}, 0, false } var best Mention width := 0 for _, mention := range mentions { name := []rune(string(mentionRune) + mention.Name) if len(name) <= width || !hasRunes(runes[at:], name) { continue } if end := at + len(name); end < len(runes) && !isSpace(runes[end]) { continue } best, width = mention, len(name) } return best, width, width > 0 } // hasRunes reports whether text begins with prefix. func hasRunes(text, prefix []rune) bool { if len(prefix) > len(text) { return false } for i, r := range prefix { if text[i] != r { return false } } return true } // isSpace reports whether a rune ends a word. func isSpace(r rune) bool { return r == ' ' || r == '\t' || r == '\n' } // resourceBlock is the block a mention becomes: the file's text when the agent // takes it and the file can be read, a link to it otherwise. func resourceBlock(mention Mention, embed bool, read func(string) (string, error)) ContentBlock { kind := mimeOf(mention.Path) if embed && read != nil { if text, err := read(mention.Path); err == nil { return ContentBlock{ Type: ContentResource, Resource: &EmbeddedResource{URI: mention.URI(), MimeType: kind, Text: text}, } } } return ContentBlock{Type: ContentResourceLink, URI: mention.URI(), Name: mention.Name, MimeType: kind} } // knownTypes are the media types of the files this family of editors is for. // // They are listed rather than looked up because the system's table is not the // same on two machines — Linux and macOS disagree about ".go" — and an agent // that keys its behaviour on the type deserves the same answer from every copy // of the editor. var knownTypes = map[string]string{ ".go": "text/x-go", ".rs": "text/x-rust", ".py": "text/x-python", ".mbt": "text/x-moonbit", ".golo": "text/x-golo", ".js": "text/javascript", ".mjs": "text/javascript", ".ts": "text/typescript", ".md": "text/markdown", ".toml": "application/toml", ".yaml": "application/yaml", ".yml": "application/yaml", ".json": "application/json", ".sh": "text/x-shellscript", ".txt": "text/plain", } // mimeOf returns the media type to send for a file, and text/plain when nothing // better is known: every mention is a file somebody opened in a text editor. func mimeOf(path string) string { extension := strings.ToLower(filepath.Ext(path)) if kind, ok := knownTypes[extension]; ok { return kind } if kind := mime.TypeByExtension(extension); kind != "" { if semicolon := strings.IndexByte(kind, ';'); semicolon >= 0 { kind = strings.TrimSpace(kind[:semicolon]) } return kind } return "text/plain" }