Skip to content

Commit 81a3e02

Browse files
authored
feat: improve file attachment pasting (anomalyco#1704)
1 parent 7bbc643 commit 81a3e02

3 files changed

Lines changed: 435 additions & 49 deletions

File tree

packages/tui/internal/components/chat/editor.go

Lines changed: 155 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,50 @@ import (
2727
"github.com/sst/opencode/internal/util"
2828
)
2929

30+
type AttachmentInsertedMsg struct{}
31+
32+
// unescapeClipboardText trims surrounding quotes from clipboard text and returns the inner content.
33+
// It avoids interpreting backslash escape sequences unless the text is explicitly quoted.
34+
func (m *editorComponent) unescapeClipboardText(s string) string {
35+
t := strings.TrimSpace(s)
36+
if len(t) >= 2 {
37+
first := t[0]
38+
last := t[len(t)-1]
39+
if (first == '"' && last == '"') || (first == '\'' && last == '\'') {
40+
if u, err := strconv.Unquote(t); err == nil {
41+
return u
42+
}
43+
return t[1 : len(t)-1]
44+
}
45+
}
46+
return t
47+
}
48+
49+
// pathExists checks if the given path exists. Relative paths are resolved against the app CWD.
50+
// Supports expanding '~' to the user's home directory.
51+
func (m *editorComponent) pathExists(p string) bool {
52+
if p == "" {
53+
return false
54+
}
55+
if strings.HasPrefix(p, "~") {
56+
if home, err := os.UserHomeDir(); err == nil {
57+
if p == "~" {
58+
p = home
59+
} else if strings.HasPrefix(p, "~/") {
60+
p = filepath.Join(home, p[2:])
61+
}
62+
}
63+
}
64+
check := p
65+
if !filepath.IsAbs(check) {
66+
check = filepath.Join(m.app.Info.Path.Cwd, check)
67+
}
68+
if _, err := os.Stat(check); err == nil {
69+
return true
70+
}
71+
return false
72+
}
73+
3074
type EditorComponent interface {
3175
tea.Model
3276
tea.ViewModel
@@ -153,60 +197,123 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
153197
return m, nil
154198
}
155199
case tea.PasteMsg:
156-
text := string(msg)
200+
// Normalize clipboard text first
201+
textRaw := string(msg)
202+
text := m.unescapeClipboardText(textRaw)
203+
204+
// Case 1: pasted content contains one or more inline @paths -> insert attachments inline
205+
// We scan the raw pasted text to preserve original content around attachments.
206+
if strings.Contains(textRaw, "@") {
207+
last := 0
208+
idx := 0
209+
inserted := 0
210+
for idx < len(textRaw) {
211+
r, size := utf8.DecodeRuneInString(textRaw[idx:])
212+
if r != '@' {
213+
idx += size
214+
continue
215+
}
157216

158-
if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" {
159-
statPath := filePath
160-
if !filepath.IsAbs(filePath) {
161-
statPath = filepath.Join(m.app.Info.Path.Cwd, filePath)
162-
}
163-
if _, err := os.Stat(statPath); err == nil {
164-
attachment := m.createAttachmentFromPath(filePath)
165-
if attachment != nil {
166-
m.textarea.InsertAttachment(attachment)
167-
m.textarea.InsertString(" ")
168-
return m, nil
217+
// Insert preceding chunk before attempting to consume a path
218+
if idx > last {
219+
m.textarea.InsertRunesFromUserInput([]rune(textRaw[last:idx]))
220+
}
221+
222+
// Extract candidate path after '@' up to whitespace
223+
start := idx + size
224+
end := start
225+
for end < len(textRaw) {
226+
nr, ns := utf8.DecodeRuneInString(textRaw[end:])
227+
if nr == ' ' || nr == '\t' || nr == '\n' || nr == '\r' {
228+
break
229+
}
230+
end += ns
231+
}
232+
233+
if end > start {
234+
raw := textRaw[start:end]
235+
// Trim common trailing punctuation that may follow paths in prose
236+
trimmed := strings.TrimRight(raw, ",.;:)]}\\\"'?!")
237+
suffix := raw[len(trimmed):]
238+
p := filepath.Clean(trimmed)
239+
if m.pathExists(p) {
240+
att := m.createAttachmentFromPath(p)
241+
if att != nil {
242+
m.textarea.InsertAttachment(att)
243+
if suffix != "" {
244+
m.textarea.InsertRunesFromUserInput([]rune(suffix))
245+
}
246+
// Insert a trailing space only if the next rune isn't already whitespace
247+
insertSpace := true
248+
if end < len(textRaw) {
249+
nr, _ := utf8.DecodeRuneInString(textRaw[end:])
250+
if nr == ' ' || nr == '\t' || nr == '\n' || nr == '\r' {
251+
insertSpace = false
252+
}
253+
}
254+
if insertSpace {
255+
m.textarea.InsertString(" ")
256+
}
257+
inserted++
258+
last = end
259+
idx = end
260+
continue
261+
}
262+
}
169263
}
264+
265+
// No valid path -> keep the '@' literally
266+
m.textarea.InsertRune('@')
267+
last = start
268+
idx = start
269+
}
270+
// Insert any trailing content after the last processed segment
271+
if last < len(textRaw) {
272+
m.textarea.InsertRunesFromUserInput([]rune(textRaw[last:]))
273+
}
274+
if inserted > 0 {
275+
return m, util.CmdHandler(AttachmentInsertedMsg{})
170276
}
171277
}
172278

173-
text = strings.ReplaceAll(text, "\\", "")
174-
text, err := strconv.Unquote(`"` + text + `"`)
175-
if err != nil {
176-
slog.Error("Failed to unquote text", "error", err)
177-
text := string(msg)
178-
if m.shouldSummarizePastedText(text) {
179-
m.handleLongPaste(text)
180-
} else {
181-
m.textarea.InsertRunesFromUserInput([]rune(msg))
279+
// Case 2: user typed '@' and then pasted a valid path -> replace '@' with attachment
280+
at := m.textarea.LastRuneIndex('@')
281+
if at != -1 && at == m.textarea.CursorColumn()-1 {
282+
p := filepath.Clean(text)
283+
if m.pathExists(p) {
284+
cur := m.textarea.CursorColumn()
285+
m.textarea.ReplaceRange(at, cur, "")
286+
att := m.createAttachmentFromPath(p)
287+
if att != nil {
288+
m.textarea.InsertAttachment(att)
289+
m.textarea.InsertString(" ")
290+
return m, util.CmdHandler(AttachmentInsertedMsg{})
291+
}
182292
}
183-
return m, nil
184293
}
185-
if _, err := os.Stat(text); err != nil {
186-
slog.Error("Failed to paste file", "error", err)
187-
text := string(msg)
188-
if m.shouldSummarizePastedText(text) {
189-
m.handleLongPaste(text)
190-
} else {
191-
m.textarea.InsertRunesFromUserInput([]rune(msg))
294+
295+
// Case 3: plain path pasted (e.g., drag-and-drop) -> attach if image or PDF
296+
{
297+
p := filepath.Clean(text)
298+
if m.pathExists(p) {
299+
mime := getMediaTypeFromExtension(strings.ToLower(filepath.Ext(p)))
300+
if strings.HasPrefix(mime, "image/") || mime == "application/pdf" {
301+
if att := m.createAttachmentFromFile(p); att != nil {
302+
m.textarea.InsertAttachment(att)
303+
m.textarea.InsertString(" ")
304+
return m, util.CmdHandler(AttachmentInsertedMsg{})
305+
}
306+
}
192307
}
193-
return m, nil
194308
}
195309

196-
filePath := text
197-
198-
attachment := m.createAttachmentFromFile(filePath)
199-
if attachment == nil {
200-
if m.shouldSummarizePastedText(text) {
201-
m.handleLongPaste(text)
202-
} else {
203-
m.textarea.InsertRunesFromUserInput([]rune(msg))
204-
}
310+
// Default: do not auto-convert. Insert raw text or summarize long pastes.
311+
if m.shouldSummarizePastedText(textRaw) {
312+
m.handleLongPaste(textRaw)
205313
return m, nil
206314
}
207-
208-
m.textarea.InsertAttachment(attachment)
209-
m.textarea.InsertString(" ")
315+
m.textarea.InsertRunesFromUserInput([]rune(textRaw))
316+
return m, nil
210317
case tea.ClipboardMsg:
211318
text := string(msg)
212319
// 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) {
233340
if atIndex == -1 {
234341
// Should not happen, but as a fallback, just insert.
235342
m.textarea.InsertString(msg.Item.Value + " ")
236-
return m, nil
343+
return m, util.CmdHandler(AttachmentInsertedMsg{})
237344
}
238345

239346
// 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) {
247354
attachment := m.createAttachmentFromPath(filePath)
248355
m.textarea.InsertAttachment(attachment)
249356
m.textarea.InsertString(" ")
250-
return m, nil
357+
return m, util.CmdHandler(AttachmentInsertedMsg{})
251358
case "symbols":
252359
atIndex := m.textarea.LastRuneIndex('@')
253360
if atIndex == -1 {
254361
// Should not happen, but as a fallback, just insert.
255362
m.textarea.InsertString(msg.Item.Value + " ")
256-
return m, nil
363+
return m, util.CmdHandler(AttachmentInsertedMsg{})
257364
}
258365

259366
cursorCol := m.textarea.CursorColumn()
@@ -287,13 +394,13 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
287394
}
288395
m.textarea.InsertAttachment(attachment)
289396
m.textarea.InsertString(" ")
290-
return m, nil
397+
return m, util.CmdHandler(AttachmentInsertedMsg{})
291398
case "agents":
292399
atIndex := m.textarea.LastRuneIndex('@')
293400
if atIndex == -1 {
294401
// Should not happen, but as a fallback, just insert.
295402
m.textarea.InsertString(msg.Item.Value + " ")
296-
return m, nil
403+
return m, util.CmdHandler(AttachmentInsertedMsg{})
297404
}
298405

299406
cursorCol := m.textarea.CursorColumn()
@@ -311,8 +418,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
311418

312419
m.textarea.InsertAttachment(attachment)
313420
m.textarea.InsertString(" ")
314-
return m, nil
315-
421+
return m, util.CmdHandler(AttachmentInsertedMsg{})
316422
default:
317423
slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
318424
return m, nil

0 commit comments

Comments
 (0)