@@ -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+
3074type 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