From f33f2e12534a7d31d1a79d2193bd23e27542d225 Mon Sep 17 00:00:00 2001 From: Christopher Petito Date: Mon, 1 Dec 2025 18:47:08 +0100 Subject: [PATCH] Fix file attachment support via @ completion - Hide completion menu when no items match the query - Include @ prefix in file completion value (since handler removes trigger) - Expand file references with actual file contents when sending message Signed-off-by: Christopher Petito --- docs/USAGE.md | 17 ++ pkg/tui/components/completion/completion.go | 11 +- pkg/tui/components/editor/completions/file.go | 2 +- pkg/tui/components/editor/editor.go | 114 ++++++++- pkg/tui/components/editor/editor_test.go | 234 ++++++++++++++++++ pkg/tui/page/chat/chat.go | 25 +- 6 files changed, 386 insertions(+), 17 deletions(-) create mode 100644 pkg/tui/components/editor/editor_test.go diff --git a/docs/USAGE.md b/docs/USAGE.md index 7d41d9e3..e429a504 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -105,6 +105,23 @@ cagent run # Runs the pirate.yaml agent ### Interface-Specific Features +#### File Attachments + +In the TUI, you can attach file contents to your message using the `@` trigger: + +1. Type `@` to open the file completion menu +2. Start typing to filter files (respects `.gitignore`) +3. Select a file to insert the reference (e.g., `@src/main.go`) +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 + +**Example:** +``` +Explain what the code in @pkg/agent/agent.go does +``` + +The agent gets the full file contents and places them in a structured `` +block at the end of the message, while the UI doesn't display full file contents. + #### CLI Interactive Commands During CLI sessions, you can use special commands: diff --git a/pkg/tui/components/completion/completion.go b/pkg/tui/components/completion/completion.go index 72149808..684f7da8 100644 --- a/pkg/tui/components/completion/completion.go +++ b/pkg/tui/components/completion/completion.go @@ -123,14 +123,20 @@ func (c *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case QueryMsg: c.query = msg.Query c.filterItems(c.query) + if len(c.filteredItems) == 0 { + c.visible = false + } return c, nil case OpenMsg: - c.visible = true c.items = msg.Items c.selected = 0 c.scrollOffset = 0 c.filterItems(c.query) + c.visible = len(c.filteredItems) > 0 + if !c.visible { + return c, nil + } return c, core.CmdHandler(OpenedMsg{}) case CloseMsg: @@ -157,6 +163,9 @@ func (c *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) { case key.Matches(msg, c.keyMap.Enter): c.visible = false + if len(c.filteredItems) == 0 || c.selected >= len(c.filteredItems) { + return c, core.CmdHandler(ClosedMsg{}) + } return c, core.CmdHandler(SelectedMsg{Value: c.filteredItems[c.selected].Value, Execute: c.filteredItems[c.selected].Execute}) case key.Matches(msg, c.keyMap.Escape): c.visible = false diff --git a/pkg/tui/components/editor/completions/file.go b/pkg/tui/components/editor/completions/file.go index 13b343d9..39f30246 100644 --- a/pkg/tui/components/editor/completions/file.go +++ b/pkg/tui/components/editor/completions/file.go @@ -44,7 +44,7 @@ func (c *fileCompletion) Items() []completion.Item { for i, f := range files { items[i] = completion.Item{ Label: f, - Value: f, + Value: "@" + f, // Include @ prefix since completion handler removes trigger } } diff --git a/pkg/tui/components/editor/editor.go b/pkg/tui/components/editor/editor.go index b279b791..c4abcfee 100644 --- a/pkg/tui/components/editor/editor.go +++ b/pkg/tui/components/editor/editor.go @@ -1,6 +1,9 @@ package editor import ( + "fmt" + "log/slog" + "os" "regexp" "strings" @@ -24,7 +27,8 @@ var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) // SendMsg represents a message to send type SendMsg struct { - Content string + Content string // Full content sent to the agent (with file contents expanded) + DisplayContent string // Compact version for UI display (with @filename placeholders) } // Editor represents an input editor component @@ -57,6 +61,11 @@ type editor struct { userTyped bool // keyboardEnhancementsSupported tracks whether the terminal supports keyboard enhancements keyboardEnhancementsSupported bool + // fileRefs tracks @filename placeholders inserted via completion (handles spaces in filenames). + fileRefs []string + // pendingFileRef tracks the current @word being typed (for manual file ref detection). + // Only set when cursor is in a word starting with @, cleared when cursor leaves. + pendingFileRef string } // New creates a new editor component @@ -284,6 +293,13 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) { e.textarea.SetValue(newValue) e.textarea.MoveToEnd() } + // Track file references when using @ completion, so we can distinguish from + // normal user input that may contain @smth as literal text to send (not a file reference) + if e.currentCompletion != nil && e.currentCompletion.Trigger() == "@" { + e.fileRefs = append(e.fileRefs, msg.Value) + } + // Clear history suggestion after selecting a completion + e.clearSuggestion() return e, nil } return e, nil @@ -316,22 +332,30 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) { // If plain enter and textarea inserted a newline, submit the previous value if value != prev && msg.String() == "enter" { if prev != "" && !e.working { + displayContent := prev + e.tryAddFileRef(e.pendingFileRef) // Add any pending @filepath before send + e.pendingFileRef = "" + sendContent := e.appendFileAttachments(prev) e.textarea.SetValue(prev) e.textarea.MoveToEnd() e.textarea.Reset() e.userTyped = false e.refreshSuggestion() - return e, core.CmdHandler(SendMsg{Content: prev}) + return e, core.CmdHandler(SendMsg{Content: sendContent, DisplayContent: displayContent}) } return e, nil } // Normal enter submit: send current value if value != "" && !e.working { + displayContent := value + e.tryAddFileRef(e.pendingFileRef) // Add any pending @filepath before send + e.pendingFileRef = "" + sendContent := e.appendFileAttachments(value) e.textarea.Reset() e.userTyped = false e.refreshSuggestion() - return e, core.CmdHandler(SendMsg{Content: value}) + return e, core.CmdHandler(SendMsg{Content: sendContent, DisplayContent: displayContent}) } return e, nil @@ -383,13 +407,29 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) { if e.textarea.Value() == "" { e.userTyped = false } + + currentWord := e.textarea.Word() + + // Track manual @filepath refs - only runs when we're in/leaving an @ word + if e.pendingFileRef != "" && currentWord != e.pendingFileRef { + // Left the @ word - try to add it as file ref + e.tryAddFileRef(e.pendingFileRef) + e.pendingFileRef = "" + } + if e.pendingFileRef == "" && strings.HasPrefix(currentWord, "@") && len(currentWord) > 1 { + // Entered an @ word - start tracking + e.pendingFileRef = currentWord + } else if e.pendingFileRef != "" && strings.HasPrefix(currentWord, "@") { + // Still in @ word but it changed (user typing more) - update tracking + e.pendingFileRef = currentWord + } + if keyMsg.String() == "space" { e.completionWord = "" e.currentCompletion = nil cmds = append(cmds, core.CmdHandler(completion.CloseMsg{})) } - currentWord := e.textarea.Word() if e.currentCompletion != nil && strings.HasPrefix(currentWord, e.currentCompletion.Trigger()) { e.completionWord = currentWord[1:] cmds = append(cmds, core.CmdHandler(completion.QueryMsg{Query: e.completionWord})) @@ -457,3 +497,69 @@ func (e *editor) SetWorking(working bool) tea.Cmd { e.working = working return nil } + +// tryAddFileRef checks if word is a valid @filepath and adds it to fileRefs. +// Called when cursor leaves a word to detect manually-typed file references. +func (e *editor) tryAddFileRef(word string) { + // Must start with @ and look like a path (contains / or .) + if !strings.HasPrefix(word, "@") || len(word) < 2 { + return + } + + path := word[1:] // strip @ + if !strings.ContainsAny(path, "/.") { + return // not a path-like reference (e.g., @username) + } + + // Check if it's an existing file (not directory) + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return + } + + // Avoid duplicates + for _, existing := range e.fileRefs { + if existing == word { + return + } + } + + e.fileRefs = append(e.fileRefs, word) +} + +// appendFileAttachments appends file contents as a structured attachments section. +// Returns the original content unchanged if no valid file references exist. +func (e *editor) appendFileAttachments(content string) string { + if len(e.fileRefs) == 0 { + return content + } + + var attachments strings.Builder + for _, ref := range e.fileRefs { + if !strings.Contains(content, ref) { + continue + } + + filename := strings.TrimPrefix(ref, "@") + info, err := os.Stat(filename) + if err != nil || info.IsDir() { + continue + } + + data, err := os.ReadFile(filename) + if err != nil { + slog.Warn("failed to read file attachment", "path", filename, "error", err) + continue + } + + attachments.WriteString(fmt.Sprintf("\n%s:\n```\n%s\n```\n", ref, string(data))) + } + + e.fileRefs = nil + + if attachments.Len() == 0 { + return content + } + + return content + "\n\n" + attachments.String() + "" +} diff --git a/pkg/tui/components/editor/editor_test.go b/pkg/tui/components/editor/editor_test.go new file mode 100644 index 00000000..2bd145a8 --- /dev/null +++ b/pkg/tui/components/editor/editor_test.go @@ -0,0 +1,234 @@ +package editor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAppendFileAttachments(t *testing.T) { + t.Parallel() + + t.Run("no file refs returns content unchanged", func(t *testing.T) { + t.Parallel() + e := &editor{fileRefs: nil} + content := "hello world" + + result := e.appendFileAttachments(content) + + assert.Equal(t, content, result) + }) + + t.Run("empty file refs returns content unchanged", func(t *testing.T) { + t.Parallel() + e := &editor{fileRefs: []string{}} + content := "hello world" + + result := e.appendFileAttachments(content) + + assert.Equal(t, content, result) + }) + + t.Run("appends file content as attachment", func(t *testing.T) { + t.Parallel() + + // Create a temp file + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("file content here"), 0o644)) + + ref := "@" + tmpFile + e := &editor{fileRefs: []string{ref}} + content := "analyze " + ref + + result := e.appendFileAttachments(content) + + assert.Contains(t, result, "analyze "+ref) + assert.Contains(t, result, "") + assert.Contains(t, result, "") + assert.Contains(t, result, ref+":") + assert.Contains(t, result, "file content here") + assert.Nil(t, e.fileRefs, "fileRefs should be cleared after expansion") + }) + + t.Run("multiple file attachments", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "first.go") + file2 := filepath.Join(tmpDir, "second.go") + require.NoError(t, os.WriteFile(file1, []byte("package first"), 0o644)) + require.NoError(t, os.WriteFile(file2, []byte("package second"), 0o644)) + + ref1 := "@" + file1 + ref2 := "@" + file2 + e := &editor{fileRefs: []string{ref1, ref2}} + content := "compare " + ref1 + " with " + ref2 + + result := e.appendFileAttachments(content) + + assert.Contains(t, result, "compare "+ref1+" with "+ref2) + assert.Contains(t, result, ref1+":") + assert.Contains(t, result, "package first") + assert.Contains(t, result, ref2+":") + assert.Contains(t, result, "package second") + assert.Equal(t, 1, strings.Count(result, "")) + assert.Equal(t, 1, strings.Count(result, "")) + }) + + t.Run("skips refs not in content", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("content"), 0o644)) + + ref := "@" + tmpFile + e := &editor{fileRefs: []string{ref}} + content := "message without the reference" + + result := e.appendFileAttachments(content) + + assert.Equal(t, content, result, "should return unchanged when ref not in content") + assert.Nil(t, e.fileRefs, "fileRefs should be cleared after expansion") + }) + + t.Run("skips nonexistent files", func(t *testing.T) { + t.Parallel() + + ref := "@/nonexistent/path/file.txt" + e := &editor{fileRefs: []string{ref}} + content := "analyze " + ref + + result := e.appendFileAttachments(content) + + assert.Equal(t, content, result, "should return unchanged when file doesn't exist") + assert.Nil(t, e.fileRefs, "fileRefs should still be cleared") + }) + + t.Run("skips directories", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + ref := "@" + tmpDir + e := &editor{fileRefs: []string{ref}} + content := "analyze " + ref + + result := e.appendFileAttachments(content) + + assert.Equal(t, content, result, "should return unchanged for directories") + assert.Nil(t, e.fileRefs, "fileRefs should be cleared after expansion") + }) + + t.Run("mixed valid and invalid refs", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + validFile := filepath.Join(tmpDir, "valid.txt") + require.NoError(t, os.WriteFile(validFile, []byte("valid content"), 0o644)) + + validRef := "@" + validFile + invalidRef := "@/nonexistent/file.txt" + e := &editor{fileRefs: []string{validRef, invalidRef}} + content := "check " + validRef + " and " + invalidRef + + result := e.appendFileAttachments(content) + + assert.Contains(t, result, "") + assert.Contains(t, result, validRef+":") + assert.Contains(t, result, "valid content") + assert.NotContains(t, result, invalidRef+":") + assert.Nil(t, e.fileRefs, "fileRefs should be cleared after expansion") + }) +} + +func TestTryAddFileRef(t *testing.T) { + t.Parallel() + + t.Run("adds valid file path", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "manual.txt") + require.NoError(t, os.WriteFile(tmpFile, []byte("content"), 0o644)) + + e := &editor{fileRefs: nil} + e.tryAddFileRef("@" + tmpFile) + + require.Len(t, e.fileRefs, 1) + assert.Equal(t, "@"+tmpFile, e.fileRefs[0]) + }) + + t.Run("ignores @mentions without path characters", func(t *testing.T) { + t.Parallel() + + e := &editor{fileRefs: nil} + e.tryAddFileRef("@username") + + assert.Nil(t, e.fileRefs, "@mentions without / or . should be ignored") + }) + + t.Run("ignores nonexistent files", func(t *testing.T) { + t.Parallel() + + e := &editor{fileRefs: nil} + e.tryAddFileRef("@/nonexistent/file.txt") + + assert.Nil(t, e.fileRefs, "nonexistent files should be ignored") + }) + + t.Run("ignores directories", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + e := &editor{fileRefs: nil} + e.tryAddFileRef("@" + tmpDir) + + assert.Nil(t, e.fileRefs, "directories should be ignored") + }) + + t.Run("avoids duplicates", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "file.go") + require.NoError(t, os.WriteFile(tmpFile, []byte("content"), 0o644)) + + ref := "@" + tmpFile + e := &editor{fileRefs: []string{ref}} + e.tryAddFileRef(ref) + + assert.Len(t, e.fileRefs, 1, "should not add duplicate") + }) + + t.Run("combines with completion refs", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + completedFile := filepath.Join(tmpDir, "completed.go") + manualFile := filepath.Join(tmpDir, "manual.go") + require.NoError(t, os.WriteFile(completedFile, []byte("package completed"), 0o644)) + require.NoError(t, os.WriteFile(manualFile, []byte("package manual"), 0o644)) + + // completedFile was selected via completion + e := &editor{fileRefs: []string{"@" + completedFile}} + // User typed manualFile and cursor left the word + e.tryAddFileRef("@" + manualFile) + + require.Len(t, e.fileRefs, 2) + assert.Contains(t, e.fileRefs, "@"+completedFile) + assert.Contains(t, e.fileRefs, "@"+manualFile) + + // Verify both get attached + content := "compare @" + completedFile + " with @" + manualFile + result := e.appendFileAttachments(content) + + assert.Contains(t, result, "package completed") + assert.Contains(t, result, "package manual") + assert.Equal(t, 1, strings.Count(result, "")) + }) +} diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index a47154da..6aa1905a 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -259,8 +259,17 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) { return p, cmd case editor.SendMsg: - cmd := p.processMessage(msg.Content) - return p, cmd + // Add user message to UI immediately using the display content (with @filename placeholders) + displayCmd := p.messages.AddUserMessage(msg.DisplayContent) + // Persist display content to history (not expanded file contents) + if p.history != nil { + if err := p.history.Add(msg.DisplayContent); err != nil { + fmt.Fprintf(os.Stderr, "failed to persist command history: %v\n", err) + } + } + // Process the full content (with expanded files) for the agent + processCmd := p.processMessage(msg.Content) + return p, tea.Batch(displayCmd, processCmd) case messages.StreamCancelledMsg: model, cmd := p.messages.Update(msg) @@ -294,8 +303,9 @@ func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) { p.sidebar = model.(sidebar.Model) return p, cmd case *runtime.UserMessageEvent: - cmd := p.messages.AddUserMessage(msg.Message) - return p, cmd + // User message is already added in the SendMsg handler with display content, + // skip adding again from runtime event to avoid duplicates + return p, nil case *runtime.StreamStartedEvent: p.streamCancelled = false spinnerCmd := p.setWorking(true) @@ -615,13 +625,6 @@ func (p *chatPage) processMessage(content string) tea.Cmd { return p.messages.ScrollToBottom() } - // Persist to history - if p.history != nil { - if err := p.history.Add(content); err != nil { - fmt.Fprintf(os.Stderr, "failed to persist command history: %v\n", err) - } - } - p.app.Run(ctx, p.msgCancel, content) return p.messages.ScrollToBottom()