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
176 changes: 95 additions & 81 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync/atomic"
Expand Down Expand Up @@ -239,7 +238,7 @@ func (a *App) EmitStartupInfo(ctx context.Context, events chan runtime.Event) {
}

// Run one agent loop
func (a *App) Run(ctx context.Context, cancel context.CancelFunc, message string, attachments map[string]string) {
func (a *App) Run(ctx context.Context, cancel context.CancelFunc, message string, attachments []messages.Attachment) {
a.cancel = cancel

// If this is the first message and no title exists, start local title generation
Expand All @@ -256,87 +255,19 @@ func (a *App) Run(ctx context.Context, cancel context.CancelFunc, message string
var textBuilder strings.Builder
textBuilder.WriteString(message)

// multiContent holds the combined text part plus any binary file parts.
// binaryParts holds non-text file parts (images, PDFs, etc.)
var binaryParts []chat.MessagePart

// Attachments are keyed by @filepath placeholder.
for placeholder := range attachments {
filePath := strings.TrimPrefix(placeholder, "@")
if filePath == "" {
slog.Debug("skipping attachment with empty file path", "placeholder", placeholder)
continue
}

absPath, err := filepath.Abs(filePath)
if err != nil {
slog.Warn("skipping attachment: invalid path", "path", filePath, "error", err)
a.events <- runtime.Warning(fmt.Sprintf("Skipped attachment %s: invalid path", filePath), "")
continue
}

fi, err := os.Stat(absPath)
if err != nil {
var reason string
switch {
case os.IsNotExist(err):
reason = "file does not exist"
case os.IsPermission(err):
reason = "permission denied"
default:
reason = fmt.Sprintf("cannot access file: %v", err)
}
slog.Warn("skipping attachment", "path", absPath, "reason", reason)
a.events <- runtime.Warning(fmt.Sprintf("Skipped attachment %s: %s", filePath, reason), "")
continue
}

if !fi.Mode().IsRegular() {
slog.Warn("skipping attachment: not a regular file", "path", absPath, "mode", fi.Mode().String())
a.events <- runtime.Warning(fmt.Sprintf("Skipped attachment %s: not a regular file", filePath), "")
continue
}

const maxAttachmentSize = 100 * 1024 * 1024 // 100MB
if fi.Size() > maxAttachmentSize {
slog.Warn("skipping attachment: file too large", "path", absPath, "size", fi.Size(), "max", maxAttachmentSize)
a.events <- runtime.Warning(fmt.Sprintf("Skipped attachment %s: file too large (max 100MB)", filePath), "")
continue
}

mimeType := chat.DetectMimeType(absPath)

for _, att := range attachments {
switch {
case chat.IsTextFile(absPath):
// Text files are appended to the message text so the model
// sees them in a single content block alongside the user's question.
if fi.Size() > chat.MaxInlineFileSize {
slog.Warn("skipping attachment: text file too large to inline", "path", absPath, "size", fi.Size(), "max", chat.MaxInlineFileSize)
a.events <- runtime.Warning(fmt.Sprintf("Skipped attachment %s: text file too large to inline (max 1MB)", filePath), "")
continue
}
content, err := chat.ReadFileForInline(absPath)
if err != nil {
slog.Warn("skipping attachment: failed to read file", "path", absPath, "error", err)
a.events <- runtime.Warning(fmt.Sprintf("Skipped attachment %s: failed to read file", filePath), "")
continue
}
textBuilder.WriteString("\n\n")
textBuilder.WriteString(content)

case chat.IsSupportedMimeType(mimeType):
// Binary files (images, PDFs) are kept as separate file parts.
binaryParts = append(binaryParts, chat.MessagePart{
Type: chat.MessagePartTypeFile,
File: &chat.MessageFile{
Path: absPath,
MimeType: mimeType,
},
})

case att.FilePath != "":
// File-reference attachment: read and classify from disk.
a.processFileAttachment(ctx, att, &textBuilder, &binaryParts)
case att.Content != "":
// Inline content attachment (e.g. pasted text).
a.processInlineAttachment(att, &textBuilder)
default:
slog.Warn("skipping attachment: unsupported file type", "path", absPath, "mime_type", mimeType)
a.events <- runtime.Warning(fmt.Sprintf("Skipped attachment %s: unsupported file type", filePath), "")
continue
slog.Debug("skipping attachment with no file path or content", "name", att.Name)
}
}

Expand All @@ -361,11 +292,94 @@ func (a *App) Run(ctx context.Context, cancel context.CancelFunc, message string
a.titleGenerating.Store(false)
}

a.events <- event
a.sendEvent(ctx, event)
}
}()
}

// processFileAttachment reads a file from disk, classifies it, and either
// appends its text content to textBuilder or adds a binary part to binaryParts.
func (a *App) processFileAttachment(ctx context.Context, att messages.Attachment, textBuilder *strings.Builder, binaryParts *[]chat.MessagePart) {
absPath := att.FilePath

fi, err := os.Stat(absPath)
if err != nil {
var reason string
switch {
case os.IsNotExist(err):
reason = "file does not exist"
case os.IsPermission(err):
reason = "permission denied"
default:
reason = fmt.Sprintf("cannot access file: %v", err)
}
slog.Warn("skipping attachment", "path", absPath, "reason", reason)
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: %s", att.Name, reason), ""))
return
}

if !fi.Mode().IsRegular() {
slog.Warn("skipping attachment: not a regular file", "path", absPath, "mode", fi.Mode().String())
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: not a regular file", att.Name), ""))
return
}

const maxAttachmentSize = 100 * 1024 * 1024 // 100MB
if fi.Size() > maxAttachmentSize {
slog.Warn("skipping attachment: file too large", "path", absPath, "size", fi.Size(), "max", maxAttachmentSize)
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: file too large (max 100MB)", att.Name), ""))
return
}

mimeType := chat.DetectMimeType(absPath)

switch {
case chat.IsTextFile(absPath):
if fi.Size() > chat.MaxInlineFileSize {
slog.Warn("skipping attachment: text file too large to inline", "path", absPath, "size", fi.Size(), "max", chat.MaxInlineFileSize)
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: text file too large to inline (max 5MB)", att.Name), ""))
return
}
content, err := chat.ReadFileForInline(absPath)
if err != nil {
slog.Warn("skipping attachment: failed to read file", "path", absPath, "error", err)
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: failed to read file", att.Name), ""))
return
}
textBuilder.WriteString("\n\n")
textBuilder.WriteString(content)

case chat.IsSupportedMimeType(mimeType):
*binaryParts = append(*binaryParts, chat.MessagePart{
Type: chat.MessagePartTypeFile,
File: &chat.MessageFile{
Path: absPath,
MimeType: mimeType,
},
})

default:
slog.Warn("skipping attachment: unsupported file type", "path", absPath, "mime_type", mimeType)
a.sendEvent(ctx, runtime.Warning(fmt.Sprintf("Skipped attachment %s: unsupported file type", att.Name), ""))
}
}

// sendEvent sends an event to the TUI, respecting context cancellation to
// avoid blocking on the channel when the consumer has stopped reading.
func (a *App) sendEvent(ctx context.Context, event tea.Msg) {
select {
case a.events <- event:
case <-ctx.Done():
}
}

// processInlineAttachment handles content that is already in memory (e.g. pasted
// text). The content is appended to textBuilder wrapped in an XML tag for context.
func (a *App) processInlineAttachment(att messages.Attachment, textBuilder *strings.Builder) {
textBuilder.WriteString("\n\n")
fmt.Fprintf(textBuilder, "<attached_file path=%q>\n%s\n</attached_file>", att.Name, att.Content)
}

// RunWithMessage runs the agent loop with a pre-constructed message.
// This is used for special cases like image attachments.
func (a *App) RunWithMessage(ctx context.Context, cancel context.CancelFunc, msg *session.Message) {
Expand Down Expand Up @@ -401,7 +415,7 @@ func (a *App) RunWithMessage(ctx context.Context, cancel context.CancelFunc, msg
a.titleGenerating.Store(false)
}

a.events <- event
a.sendEvent(ctx, event)
}
}()
}
Expand Down
56 changes: 37 additions & 19 deletions pkg/tui/components/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1319,11 +1319,21 @@ func (e *editor) tryAddFileRef(word string) {
}

// addFileAttachment adds a file reference as an attachment if valid.
// The path is resolved to an absolute path so downstream consumers
// (e.g. processFileAttachment) always receive a fully qualified path.
func (e *editor) addFileAttachment(placeholder string) {
path := strings.TrimPrefix(placeholder, "@")

// Resolve to absolute path so the attachment carries a fully qualified
// path regardless of the working directory at send time.
absPath, err := filepath.Abs(path)
if err != nil {
slog.Warn("skipping attachment: cannot resolve path", "path", path, "error", err)
return
}

// Check if it's an existing file (not directory)
info, err := os.Stat(path)
info, err := os.Stat(absPath)
if err != nil || info.IsDir() {
return
}
Expand All @@ -1336,22 +1346,25 @@ func (e *editor) addFileAttachment(placeholder string) {
}

e.attachments = append(e.attachments, attachment{
path: path,
path: absPath,
placeholder: placeholder,
label: fmt.Sprintf("%s (%s)", filepath.Base(path), units.HumanSize(float64(info.Size()))),
label: fmt.Sprintf("%s (%s)", filepath.Base(absPath), units.HumanSize(float64(info.Size()))),
sizeBytes: int(info.Size()),
isTemp: false,
})
}

// collectAttachments returns a map of placeholder to file content for all attachments
// referenced in content. Unreferenced attachments are cleaned up.
func (e *editor) collectAttachments(content string) map[string]string {
// collectAttachments returns structured attachments for all items referenced in
// content. For paste attachments the content is read into memory (the backing
// temp file is removed). For file-reference attachments the path is preserved
// so the consumer can read and classify the file (e.g. detect MIME type).
// Unreferenced attachments are cleaned up.
func (e *editor) collectAttachments(content string) []messages.Attachment {
if len(e.attachments) == 0 {
return nil
}

attachments := make(map[string]string)
var result []messages.Attachment
for _, att := range e.attachments {
if !strings.Contains(content, att.placeholder) {
if att.isTemp {
Expand All @@ -1360,24 +1373,29 @@ func (e *editor) collectAttachments(content string) map[string]string {
continue
}

data, err := os.ReadFile(att.path)
if err != nil {
slog.Warn("failed to read attachment", "path", att.path, "error", err)
if att.isTemp {
_ = os.Remove(att.path)
}
continue
}

attachments[att.placeholder] = string(data)

if att.isTemp {
// Paste attachment: read into memory and remove the temp file.
data, err := os.ReadFile(att.path)
_ = os.Remove(att.path)
if err != nil {
slog.Warn("failed to read paste attachment", "path", att.path, "error", err)
continue
}
result = append(result, messages.Attachment{
Name: strings.TrimPrefix(att.placeholder, "@"),
Content: string(data),
})
} else {
// File-reference attachment: keep the path for later processing.
result = append(result, messages.Attachment{
Name: filepath.Base(att.path),
FilePath: att.path,
})
}
}
e.attachments = nil

return attachments
return result
}

// Cleanup removes any temporary paste files that haven't been sent yet.
Expand Down
Loading
Loading