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
17 changes: 17 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<attachments>`
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:
Expand Down
11 changes: 10 additions & 1 deletion pkg/tui/components/completion/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pkg/tui/components/editor/completions/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
114 changes: 110 additions & 4 deletions pkg/tui/components/editor/editor.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package editor

import (
"fmt"
"log/slog"
"os"
"regexp"
"strings"

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}))
Expand Down Expand Up @@ -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>" + attachments.String() + "</attachments>"
}
Loading