Skip to content

Commit 8cecb92

Browse files
authored
Merge pull request #1004 from krissetto/fix-file-attachments
Better file attachment support with `@somefile`
2 parents db3dbac + f33f2e1 commit 8cecb92

File tree

6 files changed

+386
-17
lines changed

6 files changed

+386
-17
lines changed

docs/USAGE.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,23 @@ cagent run # Runs the pirate.yaml agent
105105

106106
### Interface-Specific Features
107107

108+
#### File Attachments
109+
110+
In the TUI, you can attach file contents to your message using the `@` trigger:
111+
112+
1. Type `@` to open the file completion menu
113+
2. Start typing to filter files (respects `.gitignore`)
114+
3. Select a file to insert the reference (e.g., `@src/main.go`)
115+
4. When you send your message, the file contents are automatically expanded and attached at the end of your message, while `@somefile.txt` references stay in your message so the LLM can reference the file contents in the context of your question
116+
117+
**Example:**
118+
```
119+
Explain what the code in @pkg/agent/agent.go does
120+
```
121+
122+
The agent gets the full file contents and places them in a structured `<attachments>`
123+
block at the end of the message, while the UI doesn't display full file contents.
124+
108125
#### CLI Interactive Commands
109126

110127
During CLI sessions, you can use special commands:

pkg/tui/components/completion/completion.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,20 @@ func (c *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
123123
case QueryMsg:
124124
c.query = msg.Query
125125
c.filterItems(c.query)
126+
if len(c.filteredItems) == 0 {
127+
c.visible = false
128+
}
126129
return c, nil
127130

128131
case OpenMsg:
129-
c.visible = true
130132
c.items = msg.Items
131133
c.selected = 0
132134
c.scrollOffset = 0
133135
c.filterItems(c.query)
136+
c.visible = len(c.filteredItems) > 0
137+
if !c.visible {
138+
return c, nil
139+
}
134140
return c, core.CmdHandler(OpenedMsg{})
135141

136142
case CloseMsg:
@@ -157,6 +163,9 @@ func (c *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
157163

158164
case key.Matches(msg, c.keyMap.Enter):
159165
c.visible = false
166+
if len(c.filteredItems) == 0 || c.selected >= len(c.filteredItems) {
167+
return c, core.CmdHandler(ClosedMsg{})
168+
}
160169
return c, core.CmdHandler(SelectedMsg{Value: c.filteredItems[c.selected].Value, Execute: c.filteredItems[c.selected].Execute})
161170
case key.Matches(msg, c.keyMap.Escape):
162171
c.visible = false

pkg/tui/components/editor/completions/file.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func (c *fileCompletion) Items() []completion.Item {
4444
for i, f := range files {
4545
items[i] = completion.Item{
4646
Label: f,
47-
Value: f,
47+
Value: "@" + f, // Include @ prefix since completion handler removes trigger
4848
}
4949
}
5050

pkg/tui/components/editor/editor.go

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package editor
22

33
import (
4+
"fmt"
5+
"log/slog"
6+
"os"
47
"regexp"
58
"strings"
69

@@ -24,7 +27,8 @@ var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
2427

2528
// SendMsg represents a message to send
2629
type SendMsg struct {
27-
Content string
30+
Content string // Full content sent to the agent (with file contents expanded)
31+
DisplayContent string // Compact version for UI display (with @filename placeholders)
2832
}
2933

3034
// Editor represents an input editor component
@@ -57,6 +61,11 @@ type editor struct {
5761
userTyped bool
5862
// keyboardEnhancementsSupported tracks whether the terminal supports keyboard enhancements
5963
keyboardEnhancementsSupported bool
64+
// fileRefs tracks @filename placeholders inserted via completion (handles spaces in filenames).
65+
fileRefs []string
66+
// pendingFileRef tracks the current @word being typed (for manual file ref detection).
67+
// Only set when cursor is in a word starting with @, cleared when cursor leaves.
68+
pendingFileRef string
6069
}
6170

6271
// New creates a new editor component
@@ -284,6 +293,13 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
284293
e.textarea.SetValue(newValue)
285294
e.textarea.MoveToEnd()
286295
}
296+
// Track file references when using @ completion, so we can distinguish from
297+
// normal user input that may contain @smth as literal text to send (not a file reference)
298+
if e.currentCompletion != nil && e.currentCompletion.Trigger() == "@" {
299+
e.fileRefs = append(e.fileRefs, msg.Value)
300+
}
301+
// Clear history suggestion after selecting a completion
302+
e.clearSuggestion()
287303
return e, nil
288304
}
289305
return e, nil
@@ -316,22 +332,30 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
316332
// If plain enter and textarea inserted a newline, submit the previous value
317333
if value != prev && msg.String() == "enter" {
318334
if prev != "" && !e.working {
335+
displayContent := prev
336+
e.tryAddFileRef(e.pendingFileRef) // Add any pending @filepath before send
337+
e.pendingFileRef = ""
338+
sendContent := e.appendFileAttachments(prev)
319339
e.textarea.SetValue(prev)
320340
e.textarea.MoveToEnd()
321341
e.textarea.Reset()
322342
e.userTyped = false
323343
e.refreshSuggestion()
324-
return e, core.CmdHandler(SendMsg{Content: prev})
344+
return e, core.CmdHandler(SendMsg{Content: sendContent, DisplayContent: displayContent})
325345
}
326346
return e, nil
327347
}
328348

329349
// Normal enter submit: send current value
330350
if value != "" && !e.working {
351+
displayContent := value
352+
e.tryAddFileRef(e.pendingFileRef) // Add any pending @filepath before send
353+
e.pendingFileRef = ""
354+
sendContent := e.appendFileAttachments(value)
331355
e.textarea.Reset()
332356
e.userTyped = false
333357
e.refreshSuggestion()
334-
return e, core.CmdHandler(SendMsg{Content: value})
358+
return e, core.CmdHandler(SendMsg{Content: sendContent, DisplayContent: displayContent})
335359
}
336360

337361
return e, nil
@@ -383,13 +407,29 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
383407
if e.textarea.Value() == "" {
384408
e.userTyped = false
385409
}
410+
411+
currentWord := e.textarea.Word()
412+
413+
// Track manual @filepath refs - only runs when we're in/leaving an @ word
414+
if e.pendingFileRef != "" && currentWord != e.pendingFileRef {
415+
// Left the @ word - try to add it as file ref
416+
e.tryAddFileRef(e.pendingFileRef)
417+
e.pendingFileRef = ""
418+
}
419+
if e.pendingFileRef == "" && strings.HasPrefix(currentWord, "@") && len(currentWord) > 1 {
420+
// Entered an @ word - start tracking
421+
e.pendingFileRef = currentWord
422+
} else if e.pendingFileRef != "" && strings.HasPrefix(currentWord, "@") {
423+
// Still in @ word but it changed (user typing more) - update tracking
424+
e.pendingFileRef = currentWord
425+
}
426+
386427
if keyMsg.String() == "space" {
387428
e.completionWord = ""
388429
e.currentCompletion = nil
389430
cmds = append(cmds, core.CmdHandler(completion.CloseMsg{}))
390431
}
391432

392-
currentWord := e.textarea.Word()
393433
if e.currentCompletion != nil && strings.HasPrefix(currentWord, e.currentCompletion.Trigger()) {
394434
e.completionWord = currentWord[1:]
395435
cmds = append(cmds, core.CmdHandler(completion.QueryMsg{Query: e.completionWord}))
@@ -457,3 +497,69 @@ func (e *editor) SetWorking(working bool) tea.Cmd {
457497
e.working = working
458498
return nil
459499
}
500+
501+
// tryAddFileRef checks if word is a valid @filepath and adds it to fileRefs.
502+
// Called when cursor leaves a word to detect manually-typed file references.
503+
func (e *editor) tryAddFileRef(word string) {
504+
// Must start with @ and look like a path (contains / or .)
505+
if !strings.HasPrefix(word, "@") || len(word) < 2 {
506+
return
507+
}
508+
509+
path := word[1:] // strip @
510+
if !strings.ContainsAny(path, "/.") {
511+
return // not a path-like reference (e.g., @username)
512+
}
513+
514+
// Check if it's an existing file (not directory)
515+
info, err := os.Stat(path)
516+
if err != nil || info.IsDir() {
517+
return
518+
}
519+
520+
// Avoid duplicates
521+
for _, existing := range e.fileRefs {
522+
if existing == word {
523+
return
524+
}
525+
}
526+
527+
e.fileRefs = append(e.fileRefs, word)
528+
}
529+
530+
// appendFileAttachments appends file contents as a structured attachments section.
531+
// Returns the original content unchanged if no valid file references exist.
532+
func (e *editor) appendFileAttachments(content string) string {
533+
if len(e.fileRefs) == 0 {
534+
return content
535+
}
536+
537+
var attachments strings.Builder
538+
for _, ref := range e.fileRefs {
539+
if !strings.Contains(content, ref) {
540+
continue
541+
}
542+
543+
filename := strings.TrimPrefix(ref, "@")
544+
info, err := os.Stat(filename)
545+
if err != nil || info.IsDir() {
546+
continue
547+
}
548+
549+
data, err := os.ReadFile(filename)
550+
if err != nil {
551+
slog.Warn("failed to read file attachment", "path", filename, "error", err)
552+
continue
553+
}
554+
555+
attachments.WriteString(fmt.Sprintf("\n%s:\n```\n%s\n```\n", ref, string(data)))
556+
}
557+
558+
e.fileRefs = nil
559+
560+
if attachments.Len() == 0 {
561+
return content
562+
}
563+
564+
return content + "\n\n<attachments>" + attachments.String() + "</attachments>"
565+
}

0 commit comments

Comments
 (0)