Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions internal/mentions/builtin_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func (BuiltinFilesProvider) List(_ context.Context, req ListRequest) (ListRespon
}

func (BuiltinFilesProvider) Resolve(_ context.Context, req ResolveRequest) (string, error) {
value := strings.TrimSpace(req.Value)
value := req.Value
switch {
case strings.HasPrefix(value, fileValuePrefix):
rel := strings.TrimPrefix(value, fileValuePrefix)
Expand All @@ -71,7 +71,7 @@ func (BuiltinFilesProvider) Resolve(_ context.Context, req ResolveRequest) (stri
if info.IsDir() {
return "", fmt.Errorf("mention is a directory, not a file: %s", abs)
}
return "@" + abs, nil
return atPath(abs), nil
case strings.HasPrefix(value, dirValuePrefix):
rel := strings.TrimPrefix(value, dirValuePrefix)
abs, err := resolveFilePath(req.WorkingDir, rel)
Expand All @@ -85,7 +85,7 @@ func (BuiltinFilesProvider) Resolve(_ context.Context, req ResolveRequest) (stri
if !info.IsDir() {
return "", fmt.Errorf("mention is not a directory: %s", abs)
}
return "@" + abs, nil
return atPath(abs), nil
default:
return "", fmt.Errorf("unsupported mention value %q", value)
}
Expand Down Expand Up @@ -202,7 +202,7 @@ func resolveFilePath(workingDir, rel string) (string, error) {
if err != nil {
return "", err
}
rel = filepath.FromSlash(strings.TrimSpace(rel))
rel = filepath.FromSlash(rel)
if rel == "" || rel == "." {
return absWorking, nil
}
Expand Down Expand Up @@ -247,5 +247,5 @@ func resolveAbsoluteFileMention(value string) (string, error) {
if info.IsDir() {
return "", fmt.Errorf("mention is a directory, not a file: %s", abs)
}
return "@" + abs, nil
return atPath(abs), nil
}
71 changes: 47 additions & 24 deletions internal/mentions/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@ package mentions

import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"unicode"
)

var mentionTokenPattern = regexp.MustCompile(`@([^\s@]+)`)

// Registry routes mention list and resolve requests to providers.
type Registry struct {
mu sync.RWMutex
Expand Down Expand Up @@ -117,40 +115,65 @@ func (r *Registry) ResolveMessage(ctx context.Context, workingDir, message strin
if strings.TrimSpace(message) == "" {
return message, nil
}
matches := mentionTokenPattern.FindAllStringSubmatchIndex(message, -1)
if len(matches) == 0 {
tokens := findMentionTokens(message)
if len(tokens) == 0 {
return message, nil
}
var b strings.Builder
last := 0
for _, match := range matches {
start, end := match[0], match[1]
valueStart, valueEnd := match[2], match[3]
if start > 0 && !unicode.IsSpace(rune(message[start-1])) {
continue
}
b.WriteString(message[last:start])
value := message[valueStart:valueEnd]
resolved, err := r.Resolve(ctx, ResolveRequest{
WorkingDir: workingDir,
Value: value,
AllowedExtensionRoots: allowed,
})
for _, token := range tokens {
b.WriteString(message[last:token.start])
resolved, err := r.resolveToken(ctx, workingDir, token, allowed)
if err != nil {
fmt.Fprintf(os.Stderr, "[mentions] resolve %q: %v\n", value, err)
b.WriteString(message[start:end])
fmt.Fprintf(os.Stderr, "[mentions] resolve %q: %v\n", token.value, err)
b.WriteString(message[token.start:token.end])
} else {
b.WriteString(resolved)
}
last = end
last = token.end
}
b.WriteString(message[last:])
return b.String(), nil
}

// resolveToken resolves one mention. For an unquoted file or dir mention it
// tries the name as written, then with trailing punctuation
// removed, and uses the first name that exists; any other failure, such as a
// path outside the working directory, ends the search. Punctuation trimmed off
// is written back after the resolved path so the sentence around it is
// unchanged. Quoted and extension mentions are resolved exactly as written.
func (r *Registry) resolveToken(ctx context.Context, workingDir string, token mentionToken, allowed map[string]bool) (string, error) {
req := ResolveRequest{WorkingDir: workingDir, Value: token.value, AllowedExtensionRoots: allowed}
prefix, trims := "", false
switch {
case token.quoted:
case strings.HasPrefix(token.value, fileValuePrefix):
prefix, trims = fileValuePrefix, true
case strings.HasPrefix(token.value, dirValuePrefix):
prefix, trims = dirValuePrefix, true
}
if !trims {
return r.Resolve(ctx, req)
}
var err error
for _, candidate := range pathCandidates(prefix, token.value) {
req.Value = candidate
var resolved string
resolved, err = r.Resolve(ctx, req)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return "", err
}
return resolved + token.value[len(candidate):], nil
}
return "", err
}

func (r *Registry) Resolve(ctx context.Context, req ResolveRequest) (string, error) {
value := strings.TrimSpace(req.Value)
if value == "" {
value := req.Value
if strings.TrimSpace(value) == "" {
return "", fmt.Errorf("empty mention value")
}
switch {
Expand Down
148 changes: 148 additions & 0 deletions internal/mentions/tokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>

package mentions

import (
"strings"
"unicode"
"unicode/utf8"
)

// The quoting and punctuation rules match Chatbook mentions in
// notebook-intelligence (chatbook_mentions.py, plmbr/notebook-intelligence#503),
// which follow Claude Code and Codex; change the two together. Unlike Chatbook,
// a mention here must follow whitespace, as Claude Code requires of the `@path`
// mentions nui hands it (see findMentionTokens).

// trailingPunctuation is the sentence punctuation that, typed after an unquoted
// file or dir mention (`see @file:notes.md,`), is not part of the path.
const trailingPunctuation = ".,;:!?)]}'\"\u2026\u201d\u2019\u00bb" +
"\u3002\u3001\uff0c\uff1b\uff1a\uff01\uff1f\uff09\u300d\u300f"

// maxTrailingPunctuation caps how many of those characters are trimmed.
const maxTrailingPunctuation = 5

// mentionToken is one `@...` mention found in a message.
type mentionToken struct {
start, end int // byte offsets of the whole token, `@` included
value string // mention value with any quotes removed, e.g. "file:a b.md"
quoted bool
}

// endsUnquotedToken reports whether r ends an unquoted mention: whitespace,
// the file, group, record, and unit separators Python also treats as
// whitespace, or `@`.
func endsUnquotedToken(r rune) bool {
return r == '@' || unicode.IsSpace(r) || (r >= 0x1c && r <= 0x1f)
}

// breaksQuotedToken reports whether r cannot appear inside a quoted mention.
func breaksQuotedToken(r rune) bool {
switch r {
case '"', '\n', '\r', '\u0085', '\u2028', '\u2029':
return true
}
return false
}

// isWordRune matches Python's Unicode \w, which Chatbook's tokenizer uses.
func isWordRune(r rune) bool {
return r == '_' || unicode.IsLetter(r) || unicode.IsNumber(r)
}

// findMentionTokens returns the mentions in message, in order. A mention starts
// at an `@` at the start of the message or after whitespace, which is also what
// Claude Code requires of the `@path` mentions nui hands it. It is either
// quoted, `@file:"data/my notes.md"`, which is how a value containing
// whitespace or `@` is written, or unquoted and runs to the next whitespace or
// `@`. A closing quote followed directly by a word character or another quote
// does not end a quoted mention; the text is read as one unquoted token, as it
// was before quoting existed.
func findMentionTokens(message string) []mentionToken {
var tokens []mentionToken
for i := 0; i < len(message); {
offset := strings.IndexByte(message[i:], '@')
if offset < 0 {
break
}
start := i + offset
if start > 0 {
prev, _ := utf8.DecodeLastRuneInString(message[:start])
if !unicode.IsSpace(prev) {
i = start + 1
continue
}
}
if token, ok := quotedMentionAt(message, start); ok {
tokens = append(tokens, token)
i = token.end
continue
}
end := start + 1
for end < len(message) {
r, size := utf8.DecodeRuneInString(message[end:])
if endsUnquotedToken(r) {
break
}
end += size
}
if end > start+1 {
tokens = append(tokens, mentionToken{start: start, end: end, value: message[start+1 : end]})
}
i = end
}
return tokens
}

func quotedMentionAt(message string, start int) (mentionToken, bool) {
rest := message[start+1:]
for _, kind := range []string{"file", "dir", "ext"} {
prefix := kind + `:"`
if !strings.HasPrefix(rest, prefix) {
continue
}
body := rest[len(prefix):]
closing := strings.IndexFunc(body, breaksQuotedToken)
if closing <= 0 || body[closing] != '"' {
return mentionToken{}, false
}
end := start + 1 + len(prefix) + closing + 1
if next, size := utf8.DecodeRuneInString(message[end:]); size > 0 && (next == '"' || isWordRune(next)) {
return mentionToken{}, false
}
return mentionToken{
start: start,
end: end,
value: kind + ":" + body[:closing],
quoted: true,
}, true
}
return mentionToken{}, false
}

// pathCandidates returns value, then value with trailing punctuation removed
// one character at a time, at most maxTrailingPunctuation times and never to
// an empty path. The caller stops at the first candidate that exists, so a
// name that really ends in punctuation still resolves and a shorter, unrelated
// name is never reached past it.
func pathCandidates(prefix, value string) []string {
candidates := []string{value}
for len(candidates) <= maxTrailingPunctuation {
last := candidates[len(candidates)-1]
r, size := utf8.DecodeLastRuneInString(last)
if size == 0 || len(last)-size <= len(prefix) || !strings.ContainsRune(trailingPunctuation, r) {
break
}
candidates = append(candidates, last[:len(last)-size])
}
return candidates
}

// atPath renders a resolved absolute path as a mention for the agent, quoting
// it the way Claude Code expects when it contains whitespace.
func atPath(abs string) string {
if strings.IndexFunc(abs, unicode.IsSpace) >= 0 {
return `@"` + abs + `"`
}
return "@" + abs
}
Loading
Loading