turbo-editors/turbo-corepublic Fork 0
main
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

🛟 Updated. 28d5985 · on main · k33g · 4h ago
mention.go · 156 lines · 4.9 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
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"
}