summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDominik Engelhardt <[email protected]>2025-08-09 03:06:38 +0200
committerGitHub <[email protected]>2025-08-08 20:06:38 -0500
commit81a3e02474445bc4d22c7857d4c3b1f591c1cb56 (patch)
tree3c7dd9de4174f36b1a101c786c2b90ff11bae982
parent7bbc643600a8a669f4dd9136a29f220a5b0e81ab (diff)
downloadopencode-81a3e02474445bc4d22c7857d4c3b1f591c1cb56.tar.gz
opencode-81a3e02474445bc4d22c7857d4c3b1f591c1cb56.zip
feat: improve file attachment pasting (#1704)
-rw-r--r--packages/tui/internal/components/chat/editor.go204
-rw-r--r--packages/tui/internal/components/chat/editor_test.go277
-rw-r--r--packages/tui/internal/tui/tui.go3
3 files changed, 435 insertions, 49 deletions
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
index 509de6244..1cfbbc064 100644
--- a/packages/tui/internal/components/chat/editor.go
+++ b/packages/tui/internal/components/chat/editor.go
@@ -27,6 +27,50 @@ import (
"github.com/sst/opencode/internal/util"
)
+type AttachmentInsertedMsg struct{}
+
+// unescapeClipboardText trims surrounding quotes from clipboard text and returns the inner content.
+// It avoids interpreting backslash escape sequences unless the text is explicitly quoted.
+func (m *editorComponent) unescapeClipboardText(s string) string {
+ t := strings.TrimSpace(s)
+ if len(t) >= 2 {
+ first := t[0]
+ last := t[len(t)-1]
+ if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
+ if u, err := strconv.Unquote(t); err == nil {
+ return u
+ }
+ return t[1 : len(t)-1]
+ }
+ }
+ return t
+}
+
+// pathExists checks if the given path exists. Relative paths are resolved against the app CWD.
+// Supports expanding '~' to the user's home directory.
+func (m *editorComponent) pathExists(p string) bool {
+ if p == "" {
+ return false
+ }
+ if strings.HasPrefix(p, "~") {
+ if home, err := os.UserHomeDir(); err == nil {
+ if p == "~" {
+ p = home
+ } else if strings.HasPrefix(p, "~/") {
+ p = filepath.Join(home, p[2:])
+ }
+ }
+ }
+ check := p
+ if !filepath.IsAbs(check) {
+ check = filepath.Join(m.app.Info.Path.Cwd, check)
+ }
+ if _, err := os.Stat(check); err == nil {
+ return true
+ }
+ return false
+}
+
type EditorComponent interface {
tea.Model
tea.ViewModel
@@ -153,60 +197,123 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
case tea.PasteMsg:
- text := string(msg)
+ // Normalize clipboard text first
+ textRaw := string(msg)
+ text := m.unescapeClipboardText(textRaw)
+
+ // Case 1: pasted content contains one or more inline @paths -> insert attachments inline
+ // We scan the raw pasted text to preserve original content around attachments.
+ if strings.Contains(textRaw, "@") {
+ last := 0
+ idx := 0
+ inserted := 0
+ for idx < len(textRaw) {
+ r, size := utf8.DecodeRuneInString(textRaw[idx:])
+ if r != '@' {
+ idx += size
+ continue
+ }
- if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" {
- statPath := filePath
- if !filepath.IsAbs(filePath) {
- statPath = filepath.Join(m.app.Info.Path.Cwd, filePath)
- }
- if _, err := os.Stat(statPath); err == nil {
- attachment := m.createAttachmentFromPath(filePath)
- if attachment != nil {
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
- return m, nil
+ // Insert preceding chunk before attempting to consume a path
+ if idx > last {
+ m.textarea.InsertRunesFromUserInput([]rune(textRaw[last:idx]))
+ }
+
+ // Extract candidate path after '@' up to whitespace
+ start := idx + size
+ end := start
+ for end < len(textRaw) {
+ nr, ns := utf8.DecodeRuneInString(textRaw[end:])
+ if nr == ' ' || nr == '\t' || nr == '\n' || nr == '\r' {
+ break
+ }
+ end += ns
+ }
+
+ if end > start {
+ raw := textRaw[start:end]
+ // Trim common trailing punctuation that may follow paths in prose
+ trimmed := strings.TrimRight(raw, ",.;:)]}\\\"'?!")
+ suffix := raw[len(trimmed):]
+ p := filepath.Clean(trimmed)
+ if m.pathExists(p) {
+ att := m.createAttachmentFromPath(p)
+ if att != nil {
+ m.textarea.InsertAttachment(att)
+ if suffix != "" {
+ m.textarea.InsertRunesFromUserInput([]rune(suffix))
+ }
+ // Insert a trailing space only if the next rune isn't already whitespace
+ insertSpace := true
+ if end < len(textRaw) {
+ nr, _ := utf8.DecodeRuneInString(textRaw[end:])
+ if nr == ' ' || nr == '\t' || nr == '\n' || nr == '\r' {
+ insertSpace = false
+ }
+ }
+ if insertSpace {
+ m.textarea.InsertString(" ")
+ }
+ inserted++
+ last = end
+ idx = end
+ continue
+ }
+ }
}
+
+ // No valid path -> keep the '@' literally
+ m.textarea.InsertRune('@')
+ last = start
+ idx = start
+ }
+ // Insert any trailing content after the last processed segment
+ if last < len(textRaw) {
+ m.textarea.InsertRunesFromUserInput([]rune(textRaw[last:]))
+ }
+ if inserted > 0 {
+ return m, util.CmdHandler(AttachmentInsertedMsg{})
}
}
- text = strings.ReplaceAll(text, "\\", "")
- text, err := strconv.Unquote(`"` + text + `"`)
- if err != nil {
- slog.Error("Failed to unquote text", "error", err)
- text := string(msg)
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(msg))
+ // Case 2: user typed '@' and then pasted a valid path -> replace '@' with attachment
+ at := m.textarea.LastRuneIndex('@')
+ if at != -1 && at == m.textarea.CursorColumn()-1 {
+ p := filepath.Clean(text)
+ if m.pathExists(p) {
+ cur := m.textarea.CursorColumn()
+ m.textarea.ReplaceRange(at, cur, "")
+ att := m.createAttachmentFromPath(p)
+ if att != nil {
+ m.textarea.InsertAttachment(att)
+ m.textarea.InsertString(" ")
+ return m, util.CmdHandler(AttachmentInsertedMsg{})
+ }
}
- return m, nil
}
- if _, err := os.Stat(text); err != nil {
- slog.Error("Failed to paste file", "error", err)
- text := string(msg)
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(msg))
+
+ // Case 3: plain path pasted (e.g., drag-and-drop) -> attach if image or PDF
+ {
+ p := filepath.Clean(text)
+ if m.pathExists(p) {
+ mime := getMediaTypeFromExtension(strings.ToLower(filepath.Ext(p)))
+ if strings.HasPrefix(mime, "image/") || mime == "application/pdf" {
+ if att := m.createAttachmentFromFile(p); att != nil {
+ m.textarea.InsertAttachment(att)
+ m.textarea.InsertString(" ")
+ return m, util.CmdHandler(AttachmentInsertedMsg{})
+ }
+ }
}
- return m, nil
}
- filePath := text
-
- attachment := m.createAttachmentFromFile(filePath)
- if attachment == nil {
- if m.shouldSummarizePastedText(text) {
- m.handleLongPaste(text)
- } else {
- m.textarea.InsertRunesFromUserInput([]rune(msg))
- }
+ // Default: do not auto-convert. Insert raw text or summarize long pastes.
+ if m.shouldSummarizePastedText(textRaw) {
+ m.handleLongPaste(textRaw)
return m, nil
}
-
- m.textarea.InsertAttachment(attachment)
- m.textarea.InsertString(" ")
+ m.textarea.InsertRunesFromUserInput([]rune(textRaw))
+ return m, nil
case tea.ClipboardMsg:
text := string(msg)
// Check if the pasted text is long and should be summarized
@@ -233,7 +340,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.Item.Value + " ")
- return m, nil
+ return m, util.CmdHandler(AttachmentInsertedMsg{})
}
// The range to replace is from the '@' up to the current cursor position.
@@ -247,13 +354,13 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
attachment := m.createAttachmentFromPath(filePath)
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
- return m, nil
+ return m, util.CmdHandler(AttachmentInsertedMsg{})
case "symbols":
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.Item.Value + " ")
- return m, nil
+ return m, util.CmdHandler(AttachmentInsertedMsg{})
}
cursorCol := m.textarea.CursorColumn()
@@ -287,13 +394,13 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
- return m, nil
+ return m, util.CmdHandler(AttachmentInsertedMsg{})
case "agents":
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.Item.Value + " ")
- return m, nil
+ return m, util.CmdHandler(AttachmentInsertedMsg{})
}
cursorCol := m.textarea.CursorColumn()
@@ -311,8 +418,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
- return m, nil
-
+ return m, util.CmdHandler(AttachmentInsertedMsg{})
default:
slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
return m, nil
diff --git a/packages/tui/internal/components/chat/editor_test.go b/packages/tui/internal/components/chat/editor_test.go
new file mode 100644
index 000000000..f43a4078c
--- /dev/null
+++ b/packages/tui/internal/components/chat/editor_test.go
@@ -0,0 +1,277 @@
+package chat
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/bubbles/v2/spinner"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/sst/opencode/internal/app"
+ "github.com/sst/opencode/internal/completions"
+ "github.com/sst/opencode/internal/components/dialog"
+ "github.com/sst/opencode/internal/components/textarea"
+ "github.com/sst/opencode/internal/styles"
+)
+
+func newTestEditor() *editorComponent {
+ m := &editorComponent{
+ app: &app.App{},
+ textarea: textarea.New(),
+ spinner: spinner.New(),
+ }
+ return m
+}
+
+func TestPasteAtPathWithTrailingComma_PreservesPunctuation_NoDoubleSpace(t *testing.T) {
+ m := newTestEditor()
+ p := createTempTextFile(t, "", "pc.txt", "x")
+
+ paste := "See @" + p + ", next"
+ _, cmd := m.Update(tea.PasteMsg(paste))
+ if cmd == nil {
+ t.Fatalf("expected command to be returned for comma punctuation paste")
+ }
+ if _, ok := cmd().(AttachmentInsertedMsg); !ok {
+ t.Fatalf("expected AttachmentInsertedMsg for comma punctuation paste")
+ }
+ if len(m.textarea.GetAttachments()) != 1 {
+ t.Fatalf("expected 1 attachment, got %d", len(m.textarea.GetAttachments()))
+ }
+ v := m.Value()
+ if !strings.Contains(v, ", next") {
+ t.Fatalf("expected comma and following text to be preserved, got: %q", v)
+ }
+ if strings.Contains(v, ", next") {
+ t.Fatalf("did not expect double space after comma, got: %q", v)
+ }
+}
+
+func TestPasteAtPathWithTrailingQuestion_PreservesPunctuation_NoDoubleSpace(t *testing.T) {
+ m := newTestEditor()
+ p := createTempTextFile(t, "", "pq.txt", "x")
+
+ paste := "Check @" + p + "? Done"
+ _, cmd := m.Update(tea.PasteMsg(paste))
+ if cmd == nil {
+ t.Fatalf("expected command to be returned for question punctuation paste")
+ }
+ if _, ok := cmd().(AttachmentInsertedMsg); !ok {
+ t.Fatalf("expected AttachmentInsertedMsg for question punctuation paste")
+ }
+ if len(m.textarea.GetAttachments()) != 1 {
+ t.Fatalf("expected 1 attachment, got %d", len(m.textarea.GetAttachments()))
+ }
+ v := m.Value()
+ if !strings.Contains(v, "? Done") {
+ t.Fatalf("expected question mark and following text to be preserved, got: %q", v)
+ }
+ if strings.Contains(v, "? Done") {
+ t.Fatalf("did not expect double space after question mark, got: %q", v)
+ }
+}
+
+func TestPasteMultipleInlineAtPaths_AttachesEach(t *testing.T) {
+ m := newTestEditor()
+ dir := t.TempDir()
+ p1 := createTempTextFile(t, dir, "m1.txt", "one")
+ p2 := createTempTextFile(t, dir, "m2.txt", "two")
+
+ // Build a paste with text around, two @paths, and punctuation after the first
+ paste := "Please check @" + p1 + ", and also @" + p2 + " thanks"
+
+ _, cmd := m.Update(tea.PasteMsg(paste))
+ if cmd == nil {
+ t.Fatalf("expected command to be returned for multi inline paste")
+ }
+ if _, ok := cmd().(AttachmentInsertedMsg); !ok {
+ t.Fatalf("expected AttachmentInsertedMsg for multi inline paste")
+ }
+
+ atts := m.textarea.GetAttachments()
+ if len(atts) != 2 {
+ t.Fatalf("expected 2 attachments, got %d", len(atts))
+ }
+ v := m.Value()
+ if !strings.Contains(v, "Please check") || !strings.Contains(v, "and also") || !strings.Contains(v, "thanks") {
+ t.Fatalf("expected surrounding text to be preserved, got: %q", v)
+ }
+}
+
+func createTempTextFile(t *testing.T, dir, name, content string) string {
+ t.Helper()
+ if dir == "" {
+ td, err := os.MkdirTemp("", "editor-test-*")
+ if err != nil {
+ t.Fatalf("failed to make temp dir: %v", err)
+ }
+ dir = td
+ }
+ p := filepath.Join(dir, name)
+ if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
+ t.Fatalf("failed to write temp file: %v", err)
+ }
+ abs, err := filepath.Abs(p)
+ if err != nil {
+ t.Fatalf("failed to get abs path: %v", err)
+ }
+ return abs
+}
+
+func createTempBinFile(t *testing.T, dir, name string, data []byte) string {
+ t.Helper()
+ if dir == "" {
+ td, err := os.MkdirTemp("", "editor-test-*")
+ if err != nil {
+ t.Fatalf("failed to make temp dir: %v", err)
+ }
+ dir = td
+ }
+ p := filepath.Join(dir, name)
+ if err := os.WriteFile(p, data, 0o600); err != nil {
+ t.Fatalf("failed to write temp bin file: %v", err)
+ }
+ abs, err := filepath.Abs(p)
+ if err != nil {
+ t.Fatalf("failed to get abs path: %v", err)
+ }
+ return abs
+}
+
+func TestPasteStartsWithAt_AttachesAndEmitsMsg(t *testing.T) {
+ m := newTestEditor()
+ p := createTempTextFile(t, "", "a.txt", "hello")
+
+ _, cmd := m.Update(tea.PasteMsg("@" + p))
+ if cmd == nil {
+ t.Fatalf("expected command to be returned")
+ }
+ msg := cmd()
+ if _, ok := msg.(AttachmentInsertedMsg); !ok {
+ t.Fatalf("expected AttachmentInsertedMsg, got %T", msg)
+ }
+
+ atts := m.textarea.GetAttachments()
+ if len(atts) != 1 {
+ t.Fatalf("expected 1 attachment, got %d", len(atts))
+ }
+ if v := m.Value(); !strings.HasSuffix(v, " ") {
+ t.Fatalf("expected trailing space after attachment, got value: %q", v)
+ }
+}
+
+func TestPasteAfterAt_ReplacesAtWithAttachment(t *testing.T) {
+ m := newTestEditor()
+ p := createTempTextFile(t, "", "b.txt", "hello")
+
+ m.textarea.SetValue("@")
+ // Cursor should be at the end after SetValue; paste absolute path
+ _, cmd := m.Update(tea.PasteMsg(p))
+ if cmd == nil {
+ t.Fatalf("expected command to be returned")
+ }
+ if _, ok := cmd().(AttachmentInsertedMsg); !ok {
+ t.Fatalf("expected AttachmentInsertedMsg from paste after '@'")
+ }
+
+ // Ensure the raw '@' rune was removed (attachment inserted in its place)
+ if m.textarea.LastRuneIndex('@') != -1 {
+ t.Fatalf("'@' rune should have been removed from the text slice")
+ }
+ if len(m.textarea.GetAttachments()) != 1 {
+ t.Fatalf("expected 1 attachment inserted")
+ }
+ if v := m.Value(); !strings.HasSuffix(v, " ") {
+ t.Fatalf("expected trailing space after attachment, got value: %q", v)
+ }
+}
+
+func TestPlainTextPaste_NoAttachment_NoMsg(t *testing.T) {
+ m := newTestEditor()
+ _, cmd := m.Update(tea.PasteMsg("hello"))
+ if cmd != nil {
+ t.Fatalf("expected no command for plain text paste")
+ }
+ if got := m.Value(); got != "hello" {
+ t.Fatalf("expected value 'hello', got %q", got)
+ }
+ if len(m.textarea.GetAttachments()) != 0 {
+ t.Fatalf("expected no attachments for plain text paste")
+ }
+}
+
+func TestPlainPathPng_AttachesImage(t *testing.T) {
+ m := newTestEditor()
+ // Minimal bytes; content isn't validated, extension determines mime
+ p := createTempBinFile(t, "", "img.png", []byte{0x89, 'P', 'N', 'G'})
+
+ _, cmd := m.Update(tea.PasteMsg(p))
+ if cmd == nil {
+ t.Fatalf("expected command to be returned for image path paste")
+ }
+ if _, ok := cmd().(AttachmentInsertedMsg); !ok {
+ t.Fatalf("expected AttachmentInsertedMsg for image path paste")
+ }
+ atts := m.textarea.GetAttachments()
+ if len(atts) != 1 {
+ t.Fatalf("expected 1 attachment, got %d", len(atts))
+ }
+ if atts[0].MediaType != "image/png" {
+ t.Fatalf("expected image/png mime, got %q", atts[0].MediaType)
+ }
+ if v := m.Value(); !strings.HasSuffix(v, " ") {
+ t.Fatalf("expected trailing space after attachment, got value: %q", v)
+ }
+}
+
+func TestPlainPathPdf_AttachesPDF(t *testing.T) {
+ m := newTestEditor()
+ p := createTempBinFile(t, "", "doc.pdf", []byte("%PDF-1.4"))
+
+ _, cmd := m.Update(tea.PasteMsg(p))
+ if cmd == nil {
+ t.Fatalf("expected command to be returned for pdf path paste")
+ }
+ if _, ok := cmd().(AttachmentInsertedMsg); !ok {
+ t.Fatalf("expected AttachmentInsertedMsg for pdf path paste")
+ }
+ atts := m.textarea.GetAttachments()
+ if len(atts) != 1 {
+ t.Fatalf("expected 1 attachment, got %d", len(atts))
+ }
+ if atts[0].MediaType != "application/pdf" {
+ t.Fatalf("expected application/pdf mime, got %q", atts[0].MediaType)
+ }
+ if v := m.Value(); !strings.HasSuffix(v, " ") {
+ t.Fatalf("expected trailing space after attachment, got value: %q", v)
+ }
+}
+
+func TestCompletionFiles_InsertsAttachment_EmitsMsg(t *testing.T) {
+ m := newTestEditor()
+ p := createTempTextFile(t, "", "c.txt", "hello")
+ m.textarea.SetValue("@")
+
+ item := completions.CompletionSuggestion{
+ ProviderID: "files",
+ Value: p,
+ Display: func(_ styles.Style) string { return p },
+ }
+ // Build the completion selected message as if the user selected from the dialog
+ msg := dialog.CompletionSelectedMsg{Item: item, SearchString: "@"}
+
+ _, cmd := m.Update(msg)
+ if cmd == nil {
+ t.Fatalf("expected command to be returned")
+ }
+ if _, ok := cmd().(AttachmentInsertedMsg); !ok {
+ t.Fatalf("expected AttachmentInsertedMsg from files completion selection")
+ }
+ if len(m.textarea.GetAttachments()) != 1 {
+ t.Fatalf("expected 1 attachment inserted from completion selection")
+ }
+ if v := m.Value(); !strings.HasSuffix(v, " ") {
+ t.Fatalf("expected trailing space after attachment, got value: %q", v)
+ }
+}
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index 22e8afdb3..5f178e15a 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -382,6 +382,9 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Messages = []app.Message{}
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
+ case chat.AttachmentInsertedMsg:
+ // Close completion dialog when the editor inserts an attachment
+ a.showCompletionDialog = false
case opencode.EventListResponseEventInstallationUpdated:
return a, toast.NewSuccessToast(
"opencode updated to "+msg.Properties.Version+", restart to apply.",